feat(rag): replace LiteParse flows with LightRAG provider

This commit is contained in:
lix-2026
2026-06-07 01:10:31 +08:00
parent f46c2fb5d0
commit 1f25364374
40 changed files with 7232 additions and 2350 deletions
@@ -258,6 +258,26 @@ import {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
return text.replace(/["\\]/g, '\\$&');
};
const localFileOpenUrl = (rootUri, path) => {
const normalizedRootUri = String(rootUri || '').trim();
const normalizedPath = String(path || '').trim();
if (!normalizedRootUri || !normalizedPath) return '';
const url = new URL('/api/local-folder/files/open', window.location.origin);
url.searchParams.set('rootUri', normalizedRootUri);
url.searchParams.set('path', normalizedPath);
return url.toString();
};
const resourceHrefFromUrlState = (rootUri, path, title) => {
const fileUrl = localFileOpenUrl(rootUri, path);
if (!fileUrl) return '';
if (/\.pdf$/i.test(String(title || path || ''))) {
const url = new URL('/pdf-preview', window.location.origin);
url.searchParams.set('fileUrl', fileUrl);
url.searchParams.set('fileName', title || path);
return url.toString();
}
return fileUrl;
};
const applyDocumentEvidenceLocatorFromUrl = (root) => {
if (!(root instanceof HTMLElement)) return;
const url = currentUrl();
@@ -289,7 +309,7 @@ import {
const url = currentUrl();
const raw = String(url.searchParams.get('resourceTab') || '').trim();
if (!raw) return null;
const objectIdentity = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : raw;
const objectIdentity = raw.startsWith('resource:') ? raw : (raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : raw);
if (!objectIdentity.startsWith('resource:file:')) return null;
const rootUri = currentWebShellRootUri();
if (!rootUri) return null;
@@ -299,6 +319,7 @@ import {
const path = rest.slice(prefix.length).replace(/^\/+/, '');
if (!path) return null;
const title = path.split('/').filter(Boolean).pop() || path;
const href = resourceHrefFromUrlState(rootUri, path, title);
return {
objectIdentity,
documentId: currentWebShellDocumentId(),
@@ -309,6 +330,8 @@ import {
assetId: `local:asset:${path}`,
title,
fileName: title,
href,
officeUrl: href,
openTarget: 'active-tab',
page: url.searchParams.get('page') || undefined,
bbox: url.searchParams.get('bbox') || undefined,
@@ -1021,18 +1044,22 @@ import {
});
}, { once: true });
const restoreResourceTabFromUrl = () => {
const restoreInput = restoreResourceTabInputFromUrl();
if (!restoreInput) return Promise.resolve(false);
return openResourceInActiveTab(restoreInput).catch((error) => {
console.warn('mnote resource tab URL 恢复失败', error);
return false;
});
};
if (paneRuntimes.length) {
Promise.all(paneRuntimes.map((paneRuntime) => mountPane(paneRuntime))).then(() => {
const restoreInput = restoreResourceTabInputFromUrl();
if (restoreInput) {
return openResourceInActiveTab(restoreInput).catch((error) => {
console.warn('mnote resource tab URL 恢复失败', error);
return false;
});
}
return false;
return restoreResourceTabFromUrl();
}).catch((error) => {
console.error('mnote multi-pane editor mount failed', error);
});
} else {
void restoreResourceTabFromUrl();
}
})();
@@ -1373,17 +1373,17 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const statusNode = entry.panel.querySelector('[data-mnote-local-ocr-status-text]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = message || (
normalizedStatus === 'done' ? 'OCR 已完成'
: normalizedStatus === 'running' ? 'OCR 处理中'
: normalizedStatus === 'failed' ? 'OCR 失败'
: normalizedStatus === 'stale' ? 'OCR 需更新'
: 'OCR 未生成'
normalizedStatus === 'done' ? '资料库已索引'
: normalizedStatus === 'running' ? '资料库索引中'
: normalizedStatus === 'failed' ? '资料库索引失败'
: normalizedStatus === 'stale' ? '资料库需更新'
: '资料库未索引'
);
}
const openButton = entry.panel.querySelector('[data-mnote-local-ocr-action="open"]');
if (openButton instanceof HTMLButtonElement) openButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
if (openButton instanceof HTMLButtonElement) openButton.disabled = true;
const insertButton = entry.panel.querySelector('[data-mnote-local-ocr-action="insert"]');
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = !entry.localOcrJob?.ocrRootRelativePath;
if (insertButton instanceof HTMLButtonElement) insertButton.disabled = true;
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: {
status: normalizedStatus,
@@ -1394,15 +1394,50 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}));
};
const knowledgeRagJobFromRegistryEntry = (registryEntry, rootUri) => {
if (!registryEntry || typeof registryEntry !== 'object') return null;
const sourceRootRelativePath = String(registryEntry.sourceRootRelativePath || registryEntry.source_path || '').trim();
if (!sourceRootRelativePath) return null;
const indexed = Boolean(registryEntry.indexedAtMs || registryEntry.indexed_at_ms) && Boolean(registryEntry.lightRagDocId || registryEntry.light_rag_doc_id);
const failed = registryEntry.deletedAtMs != null || registryEntry.deleted_at_ms != null || registryEntry.stale === true;
const status = failed ? 'failed' : indexed ? 'done' : 'running';
return {
taskKind: 'knowledge_rag',
jobId: String(registryEntry.sourceId || registryEntry.source_id || `knowledge-rag:${sourceRootRelativePath}`),
sourceRootRelativePath,
rootUri: String(registryEntry.rootUri || rootUri || '').trim(),
ocrRootRelativePath: '',
provider: 'lightrag',
status,
stageLabel: status === 'done' ? '资料库已索引' : status === 'failed' ? '资料库需重建' : '资料库索引中',
stale: registryEntry.stale === true,
updatedAtMs: Number(registryEntry.updatedAtMs || registryEntry.updated_at_ms || Date.now()),
finishedAtMs: status === 'done' ? Number(registryEntry.indexedAtMs || registryEntry.indexed_at_ms || Date.now()) : null,
error: status === 'failed' ? 'knowledge_rag_source_stale_or_deleted' : '',
lightRagDocId: String(registryEntry.lightRagDocId || registryEntry.light_rag_doc_id || ''),
lightRagFilePath: String(registryEntry.lightRagFilePath || registryEntry.light_rag_file_path || ''),
};
};
const knowledgeRagRegistryEntries = (payload) => {
const registry = payload && payload.registry && typeof payload.registry === 'object' ? payload.registry : null;
return Array.isArray(registry?.entries) ? registry.entries : [];
};
const readLocalOcrStatus = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return null;
const url = new URL('/api/local-folder/ocr/status', window.location.origin);
const url = new URL('/api/knowledge-rag/status', window.location.origin);
url.searchParams.set('rootUri', entry.rootUri);
url.searchParams.set('sourceRootRelativePath', entry.path);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return null;
return payload.job || null;
const targetPath = String(entry.path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => {
return String(candidate?.sourceRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '') === targetPath;
});
return knowledgeRagJobFromRegistryEntry(registryEntry, entry.rootUri);
};
const localOcrTaskState = {
@@ -1475,7 +1510,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
toggle.addEventListener('click', (event) => {
if (toggle.getAttribute('data-mnote-action') === 'open-ocr-settings') {
event.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:open-local-ocr-settings'));
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
return;
}
void runManualLocalOcrForActiveTarget(toggle).catch((error) => {
@@ -1506,7 +1541,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const statusTextForLocalOcrJob = (job) => {
const status = String(job?.status || '').trim();
if (job?.stageLabel) return String(job.stageLabel);
if (job?.taskKind === 'local_index') {
if (job?.taskKind === 'local_index' || job?.taskKind === 'knowledge_rag') {
return status === 'done' ? '索引已完成'
: status === 'failed' ? '索引失败'
: status === 'running' ? '索引中'
@@ -1557,20 +1592,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
document.body.appendChild(dock);
}
let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
if (!(toggle instanceof HTMLButtonElement)) {
const actions = document.querySelector('.wolai-topbar-actions');
toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'wolai-icon-button mnote-local-ocr-task-toggle';
toggle.setAttribute('title', 'OCR 设置');
toggle.setAttribute('aria-label', 'OCR 设置');
toggle.setAttribute('data-testid', 'mnote-local-ocr-task-toggle');
toggle.setAttribute('data-mnote-action', 'open-ocr-settings');
toggle.innerHTML = '<span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span>';
if (actions instanceof HTMLElement) actions.appendChild(toggle);
else document.body.appendChild(toggle);
}
bindLocalOcrTopbarAction();
if (toggle instanceof HTMLButtonElement) bindLocalOcrTopbarAction();
if (dock.getAttribute('data-mnote-local-ocr-bound') === 'true') return dock;
dock.setAttribute('data-mnote-local-ocr-bound', 'true');
dock.addEventListener('click', (event) => {
@@ -1617,23 +1639,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
}
const openButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-open]') : null;
if (openButton instanceof HTMLElement) {
const sourcePath = openButton.getAttribute('data-mnote-local-ocr-task-open') || '';
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
if (job) {
void openResourceInActiveTab({
kind: 'markdown',
title: String(job.ocrRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR',
path: job.ocrRootRelativePath,
objectIdentity: `local-ocr:${job.ocrRootRelativePath}`,
assetId: `local-ocr:${job.ocrRootRelativePath}`,
documentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
ownerDocumentId: job.ownerDocumentId || currentWebShellDocumentId() || '',
workspaceId: currentWebShellWorkspaceId() || '',
sourceKind: 'local_folder',
rootUri: localOcrTaskState.rootUri || '',
resourceKind: 'markdown',
}).catch((error) => console.warn('mnote local OCR 任务打开失败', error));
}
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
}
const retryButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-retry]') : null;
if (retryButton instanceof HTMLElement) {
@@ -1670,7 +1676,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
.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 设置');
const label = runningCount > 0 ? `${runningCount}资料库索引处理中,打开资料库设置` : (jobs.length > 0 ? `${jobs.length}资料库任务,打开资料库设置` : '资料库设置');
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
toggle.setAttribute('title', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
@@ -1681,7 +1687,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
badge.textContent = String(runningCount > 0 ? runningCount : jobs.length);
badge.hidden = jobs.length === 0;
} else {
toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`;
toggle.textContent = runningCount > 0 ? `索引 ${runningCount} 处理中` : `索引 ${jobs.length}`;
}
});
const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]');
@@ -1731,7 +1737,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
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';
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '资料库索引';
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>';
@@ -1766,7 +1772,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (open instanceof HTMLButtonElement) {
open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || ''));
open.disabled = !job.ocrRootRelativePath;
open.hidden = job.taskKind === 'local_index';
open.hidden = job.taskKind === 'local_index' || job.taskKind === 'knowledge_rag';
}
const retry = row.querySelector('[data-mnote-local-ocr-task-retry]');
if (retry instanceof HTMLButtonElement) {
@@ -1780,7 +1786,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const deleteOcr = row.querySelector('[data-mnote-local-ocr-task-delete]');
if (deleteOcr instanceof HTMLButtonElement) {
deleteOcr.setAttribute('data-mnote-local-ocr-task-delete', String(job.sourceRootRelativePath || ''));
deleteOcr.hidden = job.taskKind === 'local_index' || !job.ocrRootRelativePath;
deleteOcr.hidden = job.taskKind === 'local_index' || !String(job.sourceRootRelativePath || '').trim();
}
list.appendChild(row);
});
@@ -1790,13 +1796,15 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const normalizedRoot = String(rootUri || '').trim();
if (!normalizedRoot) return;
localOcrTaskState.rootUri = normalizedRoot;
const url = new URL('/api/local-folder/ocr/jobs', window.location.origin);
const url = new URL('/api/knowledge-rag/status', window.location.origin);
url.searchParams.set('rootUri', normalizedRoot);
const workspaceId = String(currentWebShellWorkspaceId() || '').trim();
if (workspaceId) url.searchParams.set('workspaceId', workspaceId);
const response = await fetch(url.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) return;
(Array.isArray(payload.jobs) ? payload.jobs : []).forEach((job) => {
if (job && typeof job === 'object') job.rootUri = normalizedRoot;
knowledgeRagRegistryEntries(payload).forEach((entry) => {
const job = knowledgeRagJobFromRegistryEntry(entry, normalizedRoot);
updateLocalOcrTaskState(job);
});
renderLocalOcrTaskDock();
@@ -1827,74 +1835,37 @@ export const createResourceTabRuntime = (dependencies = {}) => {
};
const openLocalOcrSidecar = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
if (!ocrPath || typeof openResourceInActiveTab !== 'function') return false;
const title = ocrPath.split('/').filter(Boolean).pop() || 'OCR';
return await openResourceInActiveTab({
kind: 'markdown',
title,
path: ocrPath,
objectIdentity: `local-ocr:${ocrPath}`,
assetId: `local-ocr:${ocrPath}`,
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
ownerDocumentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
workspaceId: String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim(),
sourceKind: 'local_folder',
rootUri: String(entry.rootUri || '').trim(),
resourceKind: 'markdown',
paneRole: normalizePaneRole(entry.paneRole),
});
window.dispatchEvent(new CustomEvent('mnote:open-knowledge-rag-settings'));
return false;
};
const insertLocalOcrLink = async (entry, job) => {
const target = job || entry?.localOcrJob || null;
const ocrPath = String(target?.ocrRootRelativePath || '').trim();
if (!ocrPath) return false;
const response = await fetch('/api/local-folder/ocr/insert', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri: String(entry.rootUri || '').trim(),
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
ocrRootRelativePath: ocrPath,
mode: 'link',
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `local_ocr_insert_failed_${response.status}`);
}
setLocalOcrStatus(entry, 'done', 'OCR 链接已插入正文', target);
return true;
throw new Error('OCR sidecar 已退役,请使用 LightRAG 资料库引用与问答。');
};
const createLocalOcrJob = async (entry) => {
if (!isLocalOcrSourceEntry(entry)) return null;
const provider = localOcrProvider();
const entryRootUri = String(entry.rootUri || '').trim();
if (entryRootUri) localOcrTaskState.rootUri = entryRootUri;
const startedAt = Date.now();
const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', provider, '处理中', startedAt);
setLocalOcrStatus(entry, 'running', 'OCR 处理中', pendingJob);
const pendingJob = localOcrJobSnapshotFromEntry(entry, 'running', 'lightrag', '资料库索引中', startedAt);
pendingJob.taskKind = 'knowledge_rag';
setLocalOcrStatus(entry, 'running', '资料库索引中', pendingJob);
updateLocalOcrTaskState(pendingJob);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
const body = {
workspaceId,
rootUri: String(entry.rootUri || '').trim(),
documentId: String(entry.ownerDocumentId || entry.documentId || currentWebShellDocumentId() || '').trim(),
sourceRootRelativePath: String(entry.path || '').trim(),
provider,
sources: [{ sourcePath: String(entry.path || '').trim() }],
};
if (provider === 'mock') {
body.mockMarkdown = `# OCR Result\n\n${entry.title || entry.path} OCR UI smoke text`;
}
const response = await fetch('/api/local-folder/ocr/jobs', {
const response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
const message = payload?.error?.message || payload?.message || payload?.error?.code || `local_ocr_job_failed_${response.status}`;
const message = payload?.error?.message || payload?.message || payload?.error?.code || `knowledge_rag_ingest_failed_${response.status}`;
const failedJob = {
...pendingJob,
status: 'failed',
@@ -1907,10 +1878,22 @@ export const createResourceTabRuntime = (dependencies = {}) => {
updateLocalOcrTaskState(failedJob);
throw new Error(message);
}
const job = payload.job && typeof payload.job === 'object' ? { ...payload.job, rootUri: entryRootUri } : null;
const registryEntry = knowledgeRagRegistryEntries(payload).find((candidate) => {
return String(candidate?.sourceRootRelativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '') === String(entry.path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
});
const job = knowledgeRagJobFromRegistryEntry(registryEntry, entryRootUri) || {
...pendingJob,
status: payload.retryRequired ? 'running' : 'done',
stageLabel: payload.retryRequired ? 'LightRAG 忙碌,等待重试' : '资料库已提交索引',
updatedAtMs: Date.now(),
finishedAtMs: payload.retryRequired ? null : Date.now(),
};
const displayStatus = localOcrDisplayStatus(job, 'done');
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job);
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForLocalOcrJob(job), job);
if (job) updateLocalOcrTaskState(job);
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri: entryRootUri, workspaceId, sourceRootRelativePath: String(entry.path || '').trim(), result: payload }
}));
return job;
};
@@ -1919,27 +1902,24 @@ export const createResourceTabRuntime = (dependencies = {}) => {
if (!sourcePath) return false;
const job = localOcrTaskState.jobsBySource.get(sourcePath) || null;
const rootUri = String(job?.rootUri || localOcrTaskState.rootUri || currentWebShellRootUri() || '').trim();
const response = await fetch('/api/local-folder/ocr/delete', {
const response = await fetch('/api/knowledge-rag/delete-source', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
rootUri,
sourceRootRelativePath: sourcePath,
workspaceId: String(currentWebShellWorkspaceId() || '').trim(),
sourcePath,
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || `local_ocr_delete_failed_${response.status}`);
}
if (payload.deleted !== true) {
throw new Error('local_ocr_delete_noop');
throw new Error(payload?.error?.message || `knowledge_rag_delete_failed_${response.status}`);
}
localOcrTaskState.jobsBySource.delete(sourcePath);
renderLocalOcrTaskDock();
const ocrPath = String(job?.ocrRootRelativePath || '').trim();
if (ocrPath) {
dispatchLocalOcrFileTreeRefresh({ ...job, status: 'stale', updatedAtMs: Date.now(), ocrRootRelativePath: ocrPath }, localOcrTaskState.rootUri);
}
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri, workspaceId: String(currentWebShellWorkspaceId() || '').trim(), sourceRootRelativePath: sourcePath, result: payload }
}));
return true;
};
@@ -2118,14 +2098,14 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
});
}
setLocalOcrStatus(entry, 'idle', 'OCR 未生成', null);
setLocalOcrStatus(entry, 'idle', '资料库未索引', null);
ensureLocalOcrTaskDock();
ensureLocalOcrTaskEvents(entry.rootUri);
void loadLocalOcrJobs(entry.rootUri).catch(() => undefined);
void readLocalOcrStatus(entry).then((job) => {
if (!job) return;
const displayStatus = localOcrDisplayStatus(job, 'done');
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? 'OCR 需更新' : 'OCR 已完成', job);
setLocalOcrStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForLocalOcrJob(job), job);
updateLocalOcrTaskState(job);
}).catch(() => undefined);
};
@@ -421,70 +421,77 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
}
async function runLocalOcrForDetail(detail, trigger) {
var sourceRootRelativePath = localOcrSourceRelativePath(detail);
var rootUri = localOcrRootUri(detail, trigger);
var documentId = String(detail && detail.documentId || currentDocumentId() || '').trim();
if (!sourceRootRelativePath || !rootUri || !documentId) {
throw new Error('缺少 OCR 来源、rootUri 或 owner documentId');
}
if (!isLocalOcrSourceFileName(String(detail && (detail.title || detail.fileName) || sourceRootRelativePath))) {
throw new Error('OCR 仅支持图片和 PDF');
}
var provider = localOcrProvider();
var body = {
rootUri: rootUri,
documentId: documentId,
sourceRootRelativePath: sourceRootRelativePath,
provider: provider
};
if (provider === 'mock') {
body.mockMarkdown = '# OCR Result\n\n' + (detail.title || sourceRootRelativePath) + ' OCR menu smoke text';
}
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running');
var response = await fetch('/api/local-folder/ocr/jobs', {
var payload = await ingestKnowledgeRagForDetail(detail, trigger).catch(function(error) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
throw error;
});
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
var status = payload && payload.retryRequired ? 'retry' : 'done';
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', status);
document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', sourceRootRelativePath);
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: { status: status, job: payload, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
}));
return payload;
}
async function ingestKnowledgeRagForDetail(detail, trigger) {
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
if (!sourceRootRelativePath || !rootUri) {
throw new Error('缺少资料库来源或 rootUri');
}
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'running');
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-path', sourceRootRelativePath);
var response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify(body)
body: JSON.stringify({
workspaceId: workspaceId,
rootUri: rootUri,
sources: [{ sourcePath: sourceRootRelativePath }]
})
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || payload.ok !== true) {
var message = payload && payload.error && payload.error.message ? payload.error.message : 'OCR 生成失败';
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
if (!response.ok || !payload || payload.ok === false) {
var message = payload && payload.message ? payload.message : '资料库索引失败';
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'failed');
throw new Error(message);
}
var job = payload.job || {};
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', String(job.status || 'done'));
if (job.ocrRootRelativePath) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', String(job.ocrRootRelativePath));
if (typeof openLocalResourceInActiveTab === 'function') {
await openLocalResourceInActiveTab({
path: String(job.ocrRootRelativePath),
title: String(job.ocrRootRelativePath).split('/').filter(Boolean).pop() || 'OCR',
kind: 'markdown',
assetId: 'local-ocr:' + String(job.ocrRootRelativePath),
documentId: documentId,
workspaceId: String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim(),
rootUri: rootUri
});
}
}
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: { status: String(job.status || 'done'), job: job, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', payload.retryRequired ? 'retry' : 'done');
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri: rootUri, workspaceId: workspaceId, sourceRootRelativePath: sourceRootRelativePath, result: payload }
}));
return job;
return payload;
}
function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
if (action === 'local-ocr') {
recordFileTreeAction('local-ocr', detail);
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
void runLocalOcrForDetail(detail, trigger).then(function(job) {
recordFileTreeActionStatus(String(job && job.status || 'done'), detail);
recordFileTreeActionStatus(job && job.retryRequired ? 'retry' : 'done', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : 'OCR 生成失败');
window.alert(error && error.message ? error.message : '资料库索引失败');
});
return;
}
if (action === 'knowledge-rag-index') {
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
void ingestKnowledgeRagForDetail(detail, trigger).then(function() {
recordFileTreeActionStatus('done', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资料库索引失败');
});
return;
}
@@ -1010,10 +1017,10 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
];
if (localOcrSupported) {
var ocrItem = { action: 'local-ocr', icon: 'auto_awesome', label: '生成 OCR', when: '!workspace.readonly' };
var insertAt = isAttachment ? 11 : isAsset ? 3 : -1;
if (insertAt >= 0) items.splice(insertAt, 0, ocrItem);
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index') {
var ragItem = { action: 'knowledge-rag-index', icon: 'travel_explore', label: '加入资料库索引', when: '!workspace.readonly' };
var insertAt = isAttachment ? 11 : isAsset ? 3 : 3;
if (insertAt >= 0) items.splice(insertAt, 0, ragItem);
}
items.forEach(function(item) {
if (item.when !== undefined && !evaluateSidebarFileTreeWhen(ctx, item.when)) {
@@ -443,43 +443,7 @@ export function createSidebarPageAiTargetRuntime(context) {
}
async function fetchPageAiOcrSidecarContext(editorTarget) {
if (currentSourceKind() !== 'local_folder') return null;
var target = editorTarget && typeof editorTarget === 'object' ? editorTarget : {};
var workspacePath = target.workspacePath && typeof target.workspacePath === 'object' ? target.workspacePath : {};
var relativePath = String(workspacePath.relativePath || target.path || '').trim();
var resourceKind = String(target.resourceKind || target.editorKind || workspacePath.resourceKind || '').trim();
if (!relativePath || !pageAiOcrEligiblePath(relativePath) || !pageAiOcrEligibleResourceKind(resourceKind)) return null;
var rootUri = String(workspacePath.rootUri || currentRootUri() || '').trim();
if (!rootUri) return null;
try {
var statusUrl = new URL('/api/local-folder/ocr/status', window.location.origin);
statusUrl.searchParams.set('rootUri', rootUri);
statusUrl.searchParams.set('sourceRootRelativePath', relativePath);
var statusResponse = await fetch(statusUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
var statusPayload = await statusResponse.json().catch(function() { return null; });
var job = statusResponse.ok && statusPayload && statusPayload.ok === true ? statusPayload.job : null;
var ocrPath = String(job && job.ocrRootRelativePath || '').trim();
if (!ocrPath || String(job.status || '') === 'failed') return null;
var readUrl = new URL('/api/local-folder/ocr/read', window.location.origin);
readUrl.searchParams.set('rootUri', rootUri);
readUrl.searchParams.set('ocrRootRelativePath', ocrPath);
var readResponse = await fetch(readUrl.toString(), { cache: 'no-store', headers: { accept: 'application/json' } });
var readPayload = await readResponse.json().catch(function() { return null; });
if (!readResponse.ok || !readPayload || readPayload.ok !== true) return null;
return {
schema: 'mnote.local_ocr_context.v1',
source: 'local_ocr_sidecar',
sourceRootRelativePath: relativePath,
ocrRootRelativePath: ocrPath,
status: String(job.status || ''),
stale: job.stale === true,
provider: String(job.provider || ''),
modelVersion: String(job.modelVersion || ''),
plainTextPreview: pageAiOcrBodyPreview(readPayload.markdown || ''),
};
} catch (_) {
return null;
}
return null;
}
async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
@@ -71,6 +71,15 @@ export function createSidebarPageSettingsRuntime(context) {
return Object.assign({ 'localOcr.autoEnabled': false }, pageUiState.localOcrPreferences || {});
}
function currentKnowledgeRagSummary() {
return pageUiState.knowledgeRagSummary || {};
}
var knowledgeRagBridgeTimer = 0;
var knowledgeRagBridgeGeneration = 0;
var knowledgeRagBridgeAttempt = 0;
var knowledgeRagBridgeDelays = [1200, 2000, 3200, 5000, 8000, 13000, 21000, 34000];
function pageWidthModeLabel(mode) {
if (mode === 'inherit') return '继承默认';
if (mode === 'readable') return '阅读';
@@ -337,6 +346,12 @@ export function createSidebarPageSettingsRuntime(context) {
ocrTrigger.setAttribute('data-state', ocrOpen ? 'open' : 'closed');
ocrTrigger.setAttribute('aria-expanded', ocrOpen ? 'true' : 'false');
}
var ragTrigger = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
var ragOpen = isKnowledgeRagSettingsOpen();
if (ragTrigger instanceof HTMLElement) {
ragTrigger.setAttribute('data-state', ragOpen ? 'open' : 'closed');
ragTrigger.setAttribute('aria-expanded', ragOpen ? 'true' : 'false');
}
}
function createPageOptionRow(key, type) {
@@ -380,8 +395,8 @@ export function createSidebarPageSettingsRuntime(context) {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="localOcrAutoEnabled">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">自动 OCR</span>' +
'<span class="wolai-page-setting-hint">默认关闭;仅作为搜索和 AI 索引补充处理图片与图片型 PDF</span>' +
'<span class="wolai-page-setting-label">资料库自动索引</span>' +
'<span class="wolai-page-setting-hint">LiteParse/OCR sidecar 已退役;图片、PDF、Office 统一由 LightRAG 资料库处理</span>' +
'</span>' +
'<input type="checkbox" class="wolai-page-setting-checkbox" data-local-ocr-option-checkbox="autoEnabled" />' +
'</label>';
@@ -659,27 +674,53 @@ export function createSidebarPageSettingsRuntime(context) {
}
function ensureLocalOcrSettingsPopover() {
var existing = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
if (existing instanceof HTMLElement) return existing;
var popover = document.createElement('div');
popover.className = 'wolai-page-settings-popover mnote-local-ocr-settings-popover';
popover.setAttribute('data-testid', 'mnote-local-ocr-settings-popover');
popover.setAttribute('data-mnote-surface', 'local-ocr-settings');
popover.hidden = true;
popover.innerHTML = '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-local-ocr-settings-panel" role="dialog" aria-modal="false" aria-label="OCR 设置">' +
return ensureKnowledgeRagSettingsPopover();
}
function createKnowledgeRagSettingsPanelHtml() {
return '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel" role="dialog" aria-modal="false" aria-label="资料库问答设置">' +
'<div class="mnote-settings-panel-head">' +
'<strong>OCR 设置</strong>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭 OCR 设置">×</button>' +
'<strong>资料库问答</strong>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭资料库问答设置">×</button>' +
'</div>' +
'<div class="wolai-page-settings-section">' +
createLocalOcrAutoRow() +
'<div class="wolai-page-settings-index-actions">' +
'<button type="button" data-local-ocr-settings-action="run-active">识别当前资源</button>' +
'<button type="button" data-local-ocr-settings-action="tasks">查看 OCR 任务</button>' +
'</div>' +
'<div class="wolai-page-settings-index-panel" data-testid="mnote-knowledge-rag-settings-panel">' +
'<div class="wolai-page-settings-index-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'<section class="wolai-page-settings-index-group mnote-knowledge-rag-control-group">' +
'<div class="wolai-page-settings-index-title">资料操作</div>' +
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') +
'</div>' +
'<div class="mnote-knowledge-rag-source-tools">' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="add-source">新增来源</button>' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="use-filetree-selection">使用选中文件树项</button>' +
'</div>' +
'<div class="wolai-page-settings-index-actions">' +
'<button type="button" data-knowledge-rag-action="refresh">刷新状态</button>' +
'<button type="button" data-knowledge-rag-action="ingest">索引来源</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'<button type="button" data-knowledge-rag-action="open-dashboard">打开 LightRAG</button>' +
'</div>' +
'</section>' +
'<section class="wolai-page-settings-index-group">' +
'<div class="wolai-page-settings-index-title">资料来源</div>' +
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
'<div class="mnote-knowledge-rag-source-list" data-testid="mnote-knowledge-rag-sources"></div>' +
'</section>' +
'</div>' +
'</div>';
}
function ensureKnowledgeRagSettingsPopover() {
var existing = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
if (existing instanceof HTMLElement) return existing;
var popover = document.createElement('div');
popover.className = 'wolai-page-settings-popover mnote-knowledge-rag-settings-popover';
popover.setAttribute('data-testid', 'mnote-knowledge-rag-settings-popover');
popover.setAttribute('data-mnote-surface', 'knowledge-rag-settings');
popover.hidden = true;
popover.innerHTML = createKnowledgeRagSettingsPanelHtml();
document.body.appendChild(popover);
return popover;
}
@@ -1015,6 +1056,656 @@ export function createSidebarPageSettingsRuntime(context) {
}
}
function knowledgeRagScopeKey() {
return [
currentSourceKind(),
currentRootUri(),
resolveWorkspaceId(document.body)
].join('|');
}
function knowledgeRagIsAvailable() {
return currentSourceKind() === 'local_folder' && Boolean(currentRootUri());
}
function knowledgeRagSourceInputRow(value) {
return '' +
'<div class="mnote-knowledge-rag-source-input-row" data-knowledge-rag-source-input-row="true">' +
'<input type="text" class="wolai-page-settings-index-path" data-knowledge-rag-source-input="true" value="' + escapeHtml(value || '') + '" placeholder="文件或目录,例如 docs/book.pdf / docs" spellcheck="false" />' +
'<span class="mnote-knowledge-rag-input-status" data-knowledge-rag-input-status-label="true" hidden></span>' +
'<button type="button" class="wolai-page-settings-index-remove" data-knowledge-rag-action="remove-source" aria-label="删除资料来源">×</button>' +
'</div>';
}
function knowledgeRagSourceInputValues() {
var popover = ensureKnowledgeRagSettingsPopover();
return Array.from(popover.querySelectorAll('[data-knowledge-rag-source-input]')).map(function(input) {
return input instanceof HTMLInputElement ? input.value.trim() : '';
}).filter(Boolean).filter(function(value, index, list) {
return list.indexOf(value) === index;
});
}
function knowledgeRagCurrentFilter() {
return pageUiState.knowledgeRagSourceFilter || 'attention';
}
function setKnowledgeRagSourceFilter(filter) {
pageUiState.knowledgeRagSourceFilter = String(filter || 'attention').trim() || 'attention';
renderKnowledgeRagSettings();
}
function knowledgeRagRegistryEntryCategory(entry) {
var deleted = entry && entry.deletedAtMs;
var stale = entry && entry.stale;
var providerStatus = String(entry && entry.lightRagStatus || '').trim();
var failed = providerStatus === 'failed';
var deleting = providerStatus === 'delete_submitted';
var deletedDone = providerStatus === 'delete_completed';
var docId = String(entry && entry.lightRagDocId || '');
var indexed = entry && entry.indexedAtMs && docId && !deleted && !stale && !failed && !deleting && !deletedDone;
if (deletedDone) return 'removed';
if (failed || stale || deleted) return 'failed';
if (indexed) return 'indexed';
return 'indexing';
}
function knowledgeRagProviderDocuments(status) {
var documents = status && status.documents && typeof status.documents === 'object' ? status.documents : {};
return Array.isArray(documents.documents) ? documents.documents : [];
}
function knowledgeRagStatusHasInFlight(status) {
var registry = status && status.registry && typeof status.registry === 'object' ? status.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
if (entries.some(function(entry) {
var providerStatus = String(entry && entry.lightRagStatus || '').trim();
return !entry?.deletedAtMs && !entry?.stale && !(entry?.indexedAtMs && entry?.lightRagDocId)
&& ['submitted', 'pending', 'processing', 'parsed', ''].includes(providerStatus);
})) return true;
var groups = status && status.documents && status.documents.rawStatusGroups && typeof status.documents.rawStatusGroups === 'object'
? status.documents.rawStatusGroups
: {};
return ['pending', 'parse', 'parsed', 'analyze', 'processing', 'process'].some(function(key) {
return Number(groups[key] || 0) > 0;
});
}
function scheduleKnowledgeRagStatusBridge(reason) {
if (!knowledgeRagIsAvailable()) return;
if (knowledgeRagBridgeTimer) window.clearTimeout(knowledgeRagBridgeTimer);
knowledgeRagBridgeGeneration += 1;
knowledgeRagBridgeAttempt = 0;
runKnowledgeRagStatusBridge(knowledgeRagBridgeGeneration, reason || 'source-updated');
}
function runKnowledgeRagStatusBridge(generation, reason) {
if (generation !== knowledgeRagBridgeGeneration || !knowledgeRagIsAvailable()) return;
var delay = knowledgeRagBridgeDelays[Math.min(knowledgeRagBridgeAttempt, knowledgeRagBridgeDelays.length - 1)];
knowledgeRagBridgeTimer = window.setTimeout(async function() {
if (generation !== knowledgeRagBridgeGeneration || !knowledgeRagIsAvailable()) return;
knowledgeRagBridgeAttempt += 1;
await loadKnowledgeRagStatus(true, { silentLoading: true, notifyUpdated: true, reason: reason || 'bridge' });
var status = currentKnowledgeRagSummary().status || {};
if (knowledgeRagStatusHasInFlight(status) && knowledgeRagBridgeAttempt < knowledgeRagBridgeDelays.length) {
runKnowledgeRagStatusBridge(generation, reason || 'bridge');
}
}, delay);
}
function knowledgeRagUnmappedProviderRows(status, entries) {
var docs = knowledgeRagProviderDocuments(status);
if (!docs.length) return [];
var mappedDocIds = new Set();
var mappedFilePaths = new Set();
(Array.isArray(entries) ? entries : []).forEach(function(entry) {
var docId = String(entry && entry.lightRagDocId || '').trim();
var filePath = String(entry && entry.lightRagFilePath || '').trim();
if (docId) mappedDocIds.add(docId);
if (filePath) mappedFilePaths.add(filePath);
});
return docs.filter(function(doc) {
var docId = String(doc && doc.id || '').trim();
var filePath = String(doc && doc.filePath || '').trim();
return !(docId && mappedDocIds.has(docId)) && !(filePath && mappedFilePaths.has(filePath));
}).map(function(doc) {
return {
kind: 'provider',
category: String(doc.status || doc.statusGroup || '').toLowerCase().includes('fail') ? 'unmapped_failed' : 'unmapped',
doc: doc
};
});
}
function knowledgeRagVisibleForFilter(row, filter) {
if (filter === 'all') return true;
if (filter === 'attention') return row.category !== 'indexed';
if (filter === 'unmapped') return row.category === 'unmapped' || row.category === 'unmapped_failed';
if (filter === 'failed') return row.category === 'failed' || row.category === 'unmapped_failed';
return row.category === filter;
}
function renderKnowledgeRagSourceFilters(popover, rows) {
var filtersNode = popover.querySelector('[data-testid="mnote-knowledge-rag-source-filters"]');
if (!(filtersNode instanceof HTMLElement)) return;
var counts = rows.reduce(function(acc, row) {
acc.all += 1;
if (row.category !== 'indexed') acc.attention += 1;
if (row.category === 'indexing') acc.indexing += 1;
if (row.category === 'failed' || row.category === 'unmapped_failed') acc.failed += 1;
if (row.category === 'indexed') acc.indexed += 1;
if (row.category === 'removed') acc.removed += 1;
if (row.category === 'unmapped' || row.category === 'unmapped_failed') acc.unmapped += 1;
return acc;
}, { all: 0, attention: 0, indexing: 0, failed: 0, indexed: 0, removed: 0, unmapped: 0 });
var current = knowledgeRagCurrentFilter();
var options = [
['attention', '需处理'],
['indexing', '索引中'],
['failed', '失败/过期'],
['indexed', '已索引'],
['removed', '已移除'],
['unmapped', 'LightRAG 未映射'],
['all', '全部']
];
filtersNode.innerHTML = options.map(function(item) {
var key = item[0];
var label = item[1];
return '<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="' + key + '" data-active="' + (current === key ? 'true' : 'false') + '">' + escapeHtml(label) + ' <span>' + String(counts[key] || 0) + '</span></button>';
}).join('');
}
function renderKnowledgeRagSources(popover, entries, status) {
var sourcesNode = popover.querySelector('[data-testid="mnote-knowledge-rag-sources"]');
if (!(sourcesNode instanceof HTMLElement)) return;
var registryRows = (Array.isArray(entries) ? entries : []).map(function(entry) {
return { kind: 'registry', category: knowledgeRagRegistryEntryCategory(entry), entry: entry };
});
var rows = registryRows.concat(knowledgeRagUnmappedProviderRows(status || {}, entries));
renderKnowledgeRagSourceFilters(popover, rows);
var filter = knowledgeRagCurrentFilter();
var items = rows.filter(function(row) {
return knowledgeRagVisibleForFilter(row, filter);
});
if (!rows.length) {
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有登记到 LightRAG 的资料来源</div>';
return;
}
if (!items.length) {
sourcesNode.innerHTML = '<div class="wolai-page-settings-index-empty">当前分类没有资料来源</div>';
return;
}
sourcesNode.innerHTML = items.map(function(row) {
if (row.kind === 'provider') {
var doc = row.doc || {};
var providerPath = String(doc.filePath || doc.id || '');
var docStatus = String(doc.status || doc.statusGroup || '');
var failedDoc = row.category === 'unmapped_failed';
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-source-path="' + escapeHtml(providerPath) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + (failedDoc ? 'fault' : 'indexed') + '" title="' + escapeHtml(docStatus || 'LightRAG 文档') + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(providerPath) + '">' + escapeHtml(providerPath || 'LightRAG 文档') + '</strong>' +
'<span>' + escapeHtml('LightRAG 未映射 · ' + (docStatus || 'unknown') + (doc.id ? ' · ' + doc.id : '')) + '</span>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions"><button type="button" disabled>只读</button></div>' +
'</div>';
}
var entry = row.entry || {};
var deleted = entry && entry.deletedAtMs;
var stale = entry && entry.stale;
var providerStatus = String(entry && entry.lightRagStatus || '').trim();
var failed = providerStatus === 'failed';
var deleting = providerStatus === 'delete_submitted';
var deletedDone = providerStatus === 'delete_completed';
var docId = String(entry && entry.lightRagDocId || '');
var indexed = entry && entry.indexedAtMs && docId && !deleted && !stale && !failed && !deleting && !deletedDone;
var kind = deletedDone ? '' : (deleting ? 'indexing' : (failed || stale || deleted ? 'fault' : (indexed ? 'indexed' : 'indexing')));
var title = String(entry && entry.sourceRootRelativePath || entry && entry.lightRagFilePath || '');
var status = deletedDone ? 'LightRAG 已移除'
: deleting ? '删除已提交'
: deleted ? '已删除'
: stale ? '已过期'
: failed ? '索引失败'
: indexed ? '已索引'
: (docId || providerStatus ? '正在索引' : '待提交');
var meta = docId ? status + ' · ' + docId : status;
var sourcePath = String(entry && entry.sourceRootRelativePath || '');
var canReindex = Boolean(sourcePath && !deleting);
var canDelete = Boolean(sourcePath && !deleted && !deletedDone && !deleting);
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(status) + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名来源') + '</strong>' +
'<span>' + escapeHtml(meta) + '</span>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-actions">' +
'<button type="button" data-knowledge-rag-action="reindex-source" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canReindex ? '' : ' disabled') + '>重索引</button>' +
'<button type="button" data-knowledge-rag-action="delete-source" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '"' + (canDelete ? '' : ' disabled') + '>删除索引</button>' +
'</div>' +
'</div>';
}).join('');
}
function renderKnowledgeRagSettings(popover) {
popover = popover || ensureKnowledgeRagSettingsPopover();
var statusNode = popover.querySelector('[data-testid="mnote-knowledge-rag-status"]');
var metaNode = popover.querySelector('[data-testid="mnote-knowledge-rag-meta"]');
var dashboardButton = popover.querySelector('[data-knowledge-rag-action="open-dashboard"]');
if (!(statusNode instanceof HTMLElement)) return;
if (!knowledgeRagIsAvailable()) {
statusNode.textContent = '资料库问答仅在本地工作区可用';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
var summary = currentKnowledgeRagSummary();
if (summary.loading) {
statusNode.textContent = '正在读取 LightRAG 状态...';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
if (summary.error) {
statusNode.textContent = 'LightRAG 状态读取失败:' + summary.error;
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = true;
return;
}
var status = summary.status || {};
var health = status.health && typeof status.health === 'object' ? status.health : {};
var healthBody = health.health && typeof health.health === 'object' ? health.health : {};
var healthy = health.ok === true && String(healthBody.status || '') === 'healthy';
var registry = status.registry && typeof status.registry === 'object' ? status.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
var providerDocs = knowledgeRagProviderDocuments(status);
statusNode.textContent = healthy
? 'LightRAG healthyMNote 登记 ' + entries.length + ' 个来源,LightRAG 文档 ' + providerDocs.length + ' 个'
: 'LightRAG 未就绪';
if (metaNode instanceof HTMLElement) {
var dashboardUrl = publicKnowledgeRagDashboardUrl(status);
var inputDir = String(status.inputDir || '');
var workingDir = String(healthBody.working_directory || '');
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 || '');
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>Input</span><code>' + escapeHtml(inputDir || '未配置') + '</code></div>' +
'<div><span>Storage</span><code>' + escapeHtml(workingDir || '未上报') + '</code></div>';
}
renderKnowledgeRagSources(popover, entries, status);
if (dashboardButton instanceof HTMLButtonElement) dashboardButton.disabled = !publicKnowledgeRagDashboardUrl(status);
}
function publicKnowledgeRagDashboardUrl(status) {
var url = String(status && (status.dashboardUrl || status.endpoint) || '').trim();
if (!url) return '';
try {
var parsed = new URL(url, window.location.href);
if (['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname) && window.location.hostname && !['127.0.0.1', 'localhost', '::1'].includes(window.location.hostname)) {
parsed.hostname = window.location.hostname;
return parsed.toString();
}
} catch (_) {}
return url;
}
async function loadKnowledgeRagStatus(force, options) {
options = options || {};
if (!knowledgeRagIsAvailable()) {
renderKnowledgeRagSettings();
return;
}
var scopeKey = knowledgeRagScopeKey();
var current = currentKnowledgeRagSummary();
if (!force && current.scopeKey === scopeKey && !current.error && !current.loading) {
renderKnowledgeRagSettings();
return;
}
if (!options.silentLoading) {
pageUiState.knowledgeRagSummary = {
scopeKey: scopeKey,
loading: true,
error: ''
};
renderKnowledgeRagSettings();
}
try {
var params = new URLSearchParams();
params.set('workspaceId', resolveWorkspaceId(document.body));
params.set('rootUri', currentRootUri());
var response = await fetch('/api/knowledge-rag/status?' + params.toString(), {
headers: { accept: 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_status_' + response.status);
}
pageUiState.knowledgeRagSummary = {
scopeKey: scopeKey,
loading: false,
error: '',
status: payload
};
if (options.notifyUpdated) {
notifyKnowledgeRagSourceUpdated({ action: 'status-sync', reason: options.reason || 'bridge', status: payload });
}
} catch (error) {
pageUiState.knowledgeRagSummary = {
scopeKey: scopeKey,
loading: false,
error: error instanceof Error ? error.message : String(error),
status: {}
};
}
renderKnowledgeRagSettings();
}
function addKnowledgeRagSourceInput() {
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (!(node instanceof HTMLElement)) return;
node.insertAdjacentHTML('beforeend', knowledgeRagSourceInputRow(''));
var inputs = Array.from(node.querySelectorAll('[data-knowledge-rag-source-input]'));
var last = inputs[inputs.length - 1];
if (last instanceof HTMLInputElement) last.focus();
}
function knowledgeRagFileTreeSelectionPaths() {
var rows = Array.from(document.querySelectorAll(
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-selected="true"][data-local-relative-path]'
));
if (!rows.length) {
rows = Array.from(document.querySelectorAll(
'#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-active="true"][data-local-relative-path]'
));
}
return rows.map(function(row) {
return String(row.getAttribute('data-local-relative-path') || '').trim();
}).filter(Boolean).filter(function(value, index, list) {
return list.indexOf(value) === index;
});
}
function useKnowledgeRagFileTreeSelection() {
var selectedPaths = knowledgeRagFileTreeSelectionPaths();
if (!selectedPaths.length) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: '请先在文件树中选中要索引的文件或目录'
});
renderKnowledgeRagSettings();
return;
}
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (!(node instanceof HTMLElement)) return;
var existing = knowledgeRagSourceInputValues();
var next = existing.concat(selectedPaths).filter(function(value, index, list) {
return list.indexOf(value) === index;
});
node.innerHTML = next.map(knowledgeRagSourceInputRow).join('');
}
function notifyKnowledgeRagSourceUpdated(detail) {
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: Object.assign({
rootUri: currentRootUri(),
workspaceId: resolveWorkspaceId(document.body)
}, detail || {})
}));
}
function removeKnowledgeRagSourceInput(button) {
var row = button && button.closest ? button.closest('[data-knowledge-rag-source-input-row]') : null;
if (row instanceof HTMLElement) row.remove();
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (node instanceof HTMLElement && !node.querySelector('[data-knowledge-rag-source-input]')) {
node.innerHTML = knowledgeRagSourceInputRow('');
}
}
function resetKnowledgeRagSourceInputs() {
var popover = ensureKnowledgeRagSettingsPopover();
var node = popover.querySelector('[data-testid="mnote-knowledge-rag-source-inputs"]');
if (node instanceof HTMLElement) node.innerHTML = knowledgeRagSourceInputRow('');
}
async function ingestKnowledgeRagSources() {
if (!knowledgeRagIsAvailable()) return;
var sources = knowledgeRagSourceInputValues();
if (!sources.length) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: '请先填写要索引的资料路径'
});
renderKnowledgeRagSettings();
return;
}
setKnowledgeRagInputStatuses(Object.fromEntries(sources.map(function(path) {
return [path, 'running'];
})));
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
sources: sources.map(function(path) { return { sourcePath: path }; })
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_ingest_' + response.status);
}
if (payload.retryRequired) {
setKnowledgeRagInputStatuses(Object.fromEntries(sources.map(function(path) {
return [path, 'retry'];
})));
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'ingest', sources: sources, result: payload, retryRequired: true });
scheduleKnowledgeRagStatusBridge('ingest-retry');
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: 'LightRAG 正在处理其他任务,请稍后刷新或重试'
});
renderKnowledgeRagSettings();
return;
}
var configured = Array.isArray(payload.configuredSources) ? payload.configuredSources : [];
var done = {};
configured.forEach(function(item) {
var requested = String(item && item.requestedSourcePath || item && item.sourceRootRelativePath || '').trim();
if (requested) done[requested] = 'done';
});
setKnowledgeRagInputStatuses(done, 'done');
await loadKnowledgeRagStatus(true);
resetKnowledgeRagSourceInputs();
notifyKnowledgeRagSourceUpdated({ action: 'ingest', sources: sources, result: payload });
scheduleKnowledgeRagStatusBridge('ingest');
} catch (error) {
setKnowledgeRagInputStatuses(Object.fromEntries(sources.map(function(path) {
return [path, 'failed'];
})));
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
async function ingestSingleKnowledgeRagSource(sourcePath) {
sourcePath = String(sourcePath || '').trim();
if (!sourcePath || !knowledgeRagIsAvailable()) return;
setKnowledgeRagInputStatuses({ [sourcePath]: 'running' });
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/ingest', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
sources: [{ sourcePath: sourcePath }],
force: true
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_reindex_' + response.status);
}
if (payload.retryRequired) {
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'reindex', sourceRootRelativePath: sourcePath, result: payload, retryRequired: true });
scheduleKnowledgeRagStatusBridge('reindex-retry');
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: 'LightRAG 正在处理其他任务,请稍后重试'
});
renderKnowledgeRagSettings();
return;
}
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'reindex', sourceRootRelativePath: sourcePath, result: payload });
scheduleKnowledgeRagStatusBridge('reindex');
} catch (error) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
async function deleteKnowledgeRagSource(sourcePath) {
sourcePath = String(sourcePath || '').trim();
if (!sourcePath || !knowledgeRagIsAvailable()) return;
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/delete-source', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri(),
sourcePath: sourcePath
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_delete_' + response.status);
}
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'delete', sourceRootRelativePath: sourcePath, result: payload });
} catch (error) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
async function pruneKnowledgeRagRegistry() {
if (!knowledgeRagIsAvailable()) return;
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: true,
error: ''
});
renderKnowledgeRagSettings();
try {
var response = await fetch('/api/knowledge-rag/prune-registry', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({
workspaceId: resolveWorkspaceId(document.body),
rootUri: currentRootUri()
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.message ? payload.message : 'knowledge_rag_prune_' + response.status);
}
await loadKnowledgeRagStatus(true);
notifyKnowledgeRagSourceUpdated({ action: 'prune', result: payload });
} catch (error) {
pageUiState.knowledgeRagSummary = Object.assign({}, currentKnowledgeRagSummary(), {
scopeKey: knowledgeRagScopeKey(),
loading: false,
error: error instanceof Error ? error.message : String(error)
});
renderKnowledgeRagSettings();
}
}
function openKnowledgeRagDashboard() {
var status = currentKnowledgeRagSummary().status || {};
var url = publicKnowledgeRagDashboardUrl(status);
if (!url) return;
window.open(url, '_blank', 'noopener');
}
function knowledgeRagInputStatusText(status) {
if (status === 'running') return '索引中';
if (status === 'done') return '已提交';
if (status === 'retry') return '需重试';
if (status === 'failed') return '失败';
return '待索引';
}
function setKnowledgeRagInputStatuses(statusByPath, fallbackStatus) {
var popover = ensureKnowledgeRagSettingsPopover();
Array.from(popover.querySelectorAll('[data-knowledge-rag-source-input-row]')).forEach(function(row) {
var input = row.querySelector('[data-knowledge-rag-source-input]');
var label = row.querySelector('[data-knowledge-rag-input-status-label]');
if (!(input instanceof HTMLInputElement) || !(label instanceof HTMLElement)) return;
var value = input.value.trim();
var status = statusByPath && statusByPath[value] || fallbackStatus || 'idle';
label.textContent = knowledgeRagInputStatusText(status);
label.setAttribute('data-status', status);
label.hidden = status === 'idle';
});
}
function renderPageSettingsPopover() {
var popover = ensurePageSettingsPopover();
var options = currentPageOptions();
@@ -1222,14 +1913,20 @@ export function createSidebarPageSettingsRuntime(context) {
return popover instanceof HTMLElement && !popover.hidden;
}
function isKnowledgeRagSettingsOpen() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function isAnySettingsOpen() {
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isLocalOcrSettingsOpen();
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isLocalOcrSettingsOpen() || isKnowledgeRagSettingsOpen();
}
function openPageSettingsPopover(initialTab) {
if (!currentDocumentId()) return;
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensurePageSettingsPopover();
renderPageSettingsPopover();
setActivePageSettingsTab(initialTab || 'page');
@@ -1241,6 +1938,7 @@ export function createSidebarPageSettingsRuntime(context) {
function openPageIndexSettingsPopover() {
closePageSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensureLocalIndexSettingsPopover();
renderPageSettingsLocalIndex(popover);
popover.hidden = false;
@@ -1249,11 +1947,21 @@ export function createSidebarPageSettingsRuntime(context) {
}
function openLocalOcrSettingsPopover() {
openKnowledgeRagSettingsPopover();
}
function openKnowledgeRagSettingsPopover() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
var popover = ensureLocalOcrSettingsPopover();
renderLocalOcrOptions(popover);
closeLocalOcrSettingsPopover();
var popover = ensureKnowledgeRagSettingsPopover();
renderKnowledgeRagSettings(popover);
popover.hidden = false;
void loadKnowledgeRagStatus(false).then(function() {
if (knowledgeRagStatusHasInFlight(currentKnowledgeRagSummary().status || {})) {
scheduleKnowledgeRagStatusBridge('open-panel');
}
});
updateStandaloneSettingsTriggerState();
}
@@ -1276,10 +1984,17 @@ export function createSidebarPageSettingsRuntime(context) {
updateStandaloneSettingsTriggerState();
}
function closeKnowledgeRagSettingsPopover() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
updateStandaloneSettingsTriggerState();
}
function closeAllSettingsPopovers() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
}
function togglePageSettingsPopover() {
@@ -1295,27 +2010,36 @@ export function createSidebarPageSettingsRuntime(context) {
openLocalOcrSettingsPopover();
});
window.addEventListener('mnote:knowledge-rag-source-updated', function() {
if (isKnowledgeRagSettingsOpen()) void loadKnowledgeRagStatus(true);
});
return {
addLocalIndexRange,
applyPageOptionsToShell,
closeAllSettingsPopovers,
closeKnowledgeRagSettingsPopover,
closePageHistoryDrawer,
closeLocalIndexSettingsPopover,
closeLocalOcrSettingsPopover,
closePageSettingsPopover,
closePageShareDialog,
currentLocalOcrPreferences,
currentKnowledgeRagSummary,
currentPageOptions,
ensureHistorySnapshotsSeeded,
ensureKnowledgeRagSettingsPopover,
ensureLocalIndexSettingsPopover,
ensureLocalOcrSettingsPopover,
ensurePageHistoryDrawer,
ensurePageSettingsPopover,
ensurePageShareDialog,
isAnySettingsOpen,
isKnowledgeRagSettingsOpen,
isLocalIndexSettingsOpen,
isLocalOcrSettingsOpen,
isPageSettingsOpen,
openKnowledgeRagSettingsPopover,
openPageHistoryDrawer,
openLocalOcrSettingsPopover,
openPageSettingsPopover,
@@ -1331,6 +2055,16 @@ export function createSidebarPageSettingsRuntime(context) {
renderPageSettingsPopover,
setActivePageSettingsTab,
refreshLocalIndex,
addKnowledgeRagSourceInput,
deleteKnowledgeRagSource,
ingestKnowledgeRagSources,
ingestSingleKnowledgeRagSource,
loadKnowledgeRagStatus,
openKnowledgeRagDashboard,
pruneKnowledgeRagRegistry,
removeKnowledgeRagSourceInput,
setKnowledgeRagSourceFilter,
useKnowledgeRagFileTreeSelection,
togglePageSettingsPopover,
updatePageSettingsTriggerState,
writeGlobalShowHeadingNumbers,
@@ -1163,7 +1163,7 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
var ownerDocumentId = fileOwnerDocumentId(item, documentId, objectIdentity);
var iconKind = iconKindOf(item);
var indexStatus = String((item && item.indexStatus) || (item && item.resourceMeta && item.resourceMeta.indexStatus) || '').trim();
if (indexStatus !== 'indexed' && indexStatus !== 'failed') indexStatus = '';
if (!['indexed', 'indexing', 'failed'].includes(indexStatus)) indexStatus = '';
var cachedChildren = relativePath ? cachedFileTreeRows(relativePath) : [];
var title = isFileTreeProjectionPageRow(rowKind, assetId)
? fileTreePageTitle(rawTitle)
@@ -210,14 +210,25 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args);
const openPageIndexSettingsPopover = (...args) => sidebarPageSettings.openPageIndexSettingsPopover(...args);
const openLocalOcrSettingsPopover = (...args) => sidebarPageSettings.openLocalOcrSettingsPopover(...args);
const openKnowledgeRagSettingsPopover = (...args) => sidebarPageSettings.openKnowledgeRagSettingsPopover(...args);
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
const addLocalIndexRange = (...args) => sidebarPageSettings.addLocalIndexRange(...args);
const addKnowledgeRagSourceInput = (...args) => sidebarPageSettings.addKnowledgeRagSourceInput(...args);
const deleteKnowledgeRagSource = (...args) => sidebarPageSettings.deleteKnowledgeRagSource(...args);
const ingestKnowledgeRagSources = (...args) => sidebarPageSettings.ingestKnowledgeRagSources(...args);
const ingestSingleKnowledgeRagSource = (...args) => sidebarPageSettings.ingestSingleKnowledgeRagSource(...args);
const loadKnowledgeRagStatus = (...args) => sidebarPageSettings.loadKnowledgeRagStatus(...args);
const openKnowledgeRagDashboard = (...args) => sidebarPageSettings.openKnowledgeRagDashboard(...args);
const pruneKnowledgeRagRegistry = (...args) => sidebarPageSettings.pruneKnowledgeRagRegistry(...args);
const setKnowledgeRagSourceFilter = (...args) => sidebarPageSettings.setKnowledgeRagSourceFilter(...args);
const useKnowledgeRagFileTreeSelection = (...args) => sidebarPageSettings.useKnowledgeRagFileTreeSelection(...args);
const persistLocalOcrAutoPreference = (...args) => sidebarPageSettings.persistLocalOcrAutoPreference(...args);
const persistLocalIndexSettings = (...args) => sidebarPageSettings.persistLocalIndexSettings(...args);
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
const refreshLocalIndex = (...args) => sidebarPageSettings.refreshLocalIndex(...args);
const removeKnowledgeRagSourceInput = (...args) => sidebarPageSettings.removeKnowledgeRagSourceInput(...args);
const removeLocalIndexRange = (...args) => sidebarPageSettings.removeLocalIndexRange(...args);
const recordPageHistorySnapshot = (...args) => sidebarPageSettings.recordPageHistorySnapshot(...args);
const renderPageSettingsPopover = (...args) => sidebarPageSettings.renderPageSettingsPopover(...args);
@@ -1919,6 +1930,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
window.addEventListener('wolai:local-assets-changed', function(event) {
applyAssetsChangedToFileTree(event.detail || {});
});
window.addEventListener('mnote:knowledge-rag-source-updated', function() {
if (currentSourceKind() === 'local_folder') void refreshLocalFolderSidebarSnapshot();
});
installMindmapAssetFetchObserver();
async function uploadFilesWithResolvedTarget(files, detail, options) {
@@ -2670,23 +2684,28 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var indexSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-index-settings"]');
if (indexSettingsTrigger) {
e.preventDefault();
openPageIndexSettingsPopover();
openKnowledgeRagSettingsPopover();
return;
}
var knowledgeRagSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-settings"]');
if (knowledgeRagSettingsTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
}
var ocrTaskTrigger = closestAction(e.target, '[data-mnote-action="toggle-ocr-tasks"]');
if (ocrTaskTrigger) {
e.preventDefault();
window.dispatchEvent(new CustomEvent('mnote:local-ocr-settings-action', {
detail: { action: 'tasks' }
}));
openKnowledgeRagSettingsPopover();
return;
}
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"]');
if (ocrSettingsTrigger) {
e.preventDefault();
openLocalOcrSettingsPopover();
openKnowledgeRagSettingsPopover();
return;
}
@@ -2742,6 +2761,52 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var knowledgeRagAction = closestAction(e.target, '[data-knowledge-rag-action]');
if (knowledgeRagAction) {
e.preventDefault();
var knowledgeRagActionName = knowledgeRagAction.getAttribute('data-knowledge-rag-action') || '';
if (knowledgeRagActionName === 'refresh') {
void loadKnowledgeRagStatus(true);
return;
}
if (knowledgeRagActionName === 'add-source') {
addKnowledgeRagSourceInput();
return;
}
if (knowledgeRagActionName === 'remove-source') {
removeKnowledgeRagSourceInput(knowledgeRagAction);
return;
}
if (knowledgeRagActionName === 'use-filetree-selection') {
useKnowledgeRagFileTreeSelection();
return;
}
if (knowledgeRagActionName === 'ingest') {
void ingestKnowledgeRagSources();
return;
}
if (knowledgeRagActionName === 'reindex-source') {
void ingestSingleKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
return;
}
if (knowledgeRagActionName === 'delete-source') {
void deleteKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
return;
}
if (knowledgeRagActionName === 'prune') {
void pruneKnowledgeRagRegistry();
return;
}
if (knowledgeRagActionName === 'filter-sources') {
setKnowledgeRagSourceFilter(knowledgeRagAction.getAttribute('data-knowledge-rag-filter') || 'attention');
return;
}
if (knowledgeRagActionName === 'open-dashboard') {
openKnowledgeRagDashboard();
return;
}
}
var pageSettingsAction = closestAction(e.target, '[data-page-settings-action]');
if (pageSettingsAction) {
e.preventDefault();
+26 -518
View File
@@ -1,17 +1,12 @@
use core_protocol::{
EvidenceBBox, EvidenceRange, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
SourceMapBlockKind, SourceMapPage, SourceMapSection, SourceMapTextItem,
PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, SourceMapBlockKind, SourceMapPage,
SourceMapSection, PARSED_RESOURCE_ARTIFACT_SCHEMA, RESOURCE_SOURCE_MAP_SCHEMA,
};
use serde_json::{json, Value};
use std::env;
use std::fs;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::UNIX_EPOCH;
use std::{env::split_paths, process::Command as StdCommand};
use tokio::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseCapability {
@@ -101,14 +96,8 @@ impl ParseProvider for LiteParseProvider {
}
fn can_parse(&self, input: &ParseInput) -> ParseCapability {
if is_liteparse_path(&input.source_root_relative_path) {
match input.mode {
ParseProviderMode::Ocr => ParseCapability::Unsupported,
ParseProviderMode::Auto | ParseProviderMode::NoOcr => ParseCapability::Preferred,
}
} else {
ParseCapability::Unsupported
}
let _ = input;
ParseCapability::Unsupported
}
fn parse<'a>(
@@ -122,12 +111,8 @@ impl ParseProvider for LiteParseProvider {
pub fn default_parse_provider_id(input: &ParseInput) -> &'static str {
if is_markdown_path(&input.source_root_relative_path) {
"markdown"
} else if is_liteparse_path(&input.source_root_relative_path)
&& !matches!(input.mode, ParseProviderMode::Ocr)
{
"liteparse"
} else {
"mineru"
"lightrag"
}
}
@@ -138,153 +123,16 @@ pub fn select_parse_provider_id(
if is_markdown_path(&input.source_root_relative_path) {
return "markdown";
}
if is_liteparse_path(&input.source_root_relative_path) {
if matches!(input.mode, ParseProviderMode::Ocr) {
return "mineru";
}
if native_text_confidence.is_some_and(|confidence| confidence < 0.2) {
return "mineru";
}
return "liteparse";
}
"mineru"
let _ = native_text_confidence;
"lightrag"
}
async fn parse_liteparse_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
let source_path = input.root_path.join(&input.source_root_relative_path);
let metadata = read_liteparse_source_metadata(&source_path)?;
let bytes = read_liteparse_source_bytes(&source_path)?;
let output = Command::new(liteparse_bin())
.arg("parse")
.arg("--format")
.arg("json")
.arg("--no-ocr")
.arg("-q")
.arg(&source_path)
.output()
.await
.map_err(|error| {
ParseError::new(
"liteparse_command_failed",
format!("LiteParse 命令执行失败: {error}"),
)
})?;
parse_liteparse_command_output(input, metadata, bytes, output)
}
pub(crate) fn parse_liteparse_input_blocking(
input: ParseInput,
) -> Result<ParseProviderOutput, ParseError> {
let source_path = input.root_path.join(&input.source_root_relative_path);
let metadata = read_liteparse_source_metadata(&source_path)?;
let bytes = read_liteparse_source_bytes(&source_path)?;
let output = StdCommand::new(liteparse_bin())
.arg("parse")
.arg("--format")
.arg("json")
.arg("--no-ocr")
.arg("-q")
.arg(&source_path)
.output()
.map_err(|error| {
ParseError::new(
"liteparse_command_failed",
format!("LiteParse 命令执行失败: {error}"),
)
})?;
parse_liteparse_command_output(input, metadata, bytes, output)
}
pub(crate) fn liteparse_runtime_available() -> bool {
if let Ok(value) = env::var("MNOTE_LITEPARSE_BIN") {
let trimmed = value.trim();
if trimmed.is_empty() {
return false;
}
let path = Path::new(trimmed);
return path.components().count() > 1 && path.exists() || command_exists(trimmed);
}
command_exists("lit") || command_exists("liteparse")
}
fn read_liteparse_source_metadata(source_path: &Path) -> Result<fs::Metadata, ParseError> {
fs::metadata(source_path).map_err(|error| {
ParseError::new(
"liteparse_parse_stat_failed",
format!(
"无法读取 LiteParse 证据源状态 {}: {error}",
source_path.display()
),
)
})
}
fn read_liteparse_source_bytes(source_path: &Path) -> Result<Vec<u8>, ParseError> {
fs::read(source_path).map_err(|error| {
ParseError::new(
"liteparse_parse_read_failed",
format!(
"无法读取 LiteParse 证据源 {}: {error}",
source_path.display()
),
)
})
}
fn parse_liteparse_command_output(
input: ParseInput,
metadata: fs::Metadata,
bytes: Vec<u8>,
output: std::process::Output,
) -> Result<ParseProviderOutput, ParseError> {
let updated_at_ms = metadata
.modified()
.ok()
.and_then(|value| value.duration_since(UNIX_EPOCH).ok())
.map(|value| value.as_millis() as u64)
.unwrap_or_default();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ParseError::new(
"liteparse_command_failed",
format!("LiteParse 解析失败: {}", stderr.trim()),
));
}
let stdout = String::from_utf8(output.stdout).map_err(|error| {
ParseError::new(
"liteparse_output_utf8_invalid",
format!("LiteParse 输出不是 UTF-8: {error}"),
)
})?;
let value = serde_json::from_str::<Value>(&stdout).map_err(|error| {
ParseError::new(
"liteparse_output_json_invalid",
format!("LiteParse JSON 输出无效: {error}"),
)
})?;
let source_hash = binary_source_hash(&bytes, metadata.len(), updated_at_ms);
let markdown = liteparse_markdown(&value);
let source_map = liteparse_source_map(&input, &value, &source_hash);
let artifact = ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
provider: "liteparse".into(),
model_version: liteparse_version(&value),
owner_document_id: input.owner_document_id,
owner_document_path: input.owner_document_path,
source_root_relative_path: input.source_root_relative_path.clone(),
source_hash,
artifact_root_relative_path: format!("{}.parse.md", input.source_root_relative_path),
source_map_root_relative_path: format!(
"{}.source-map.json",
input.source_root_relative_path
),
updated_at_ms,
};
Ok(ParseProviderOutput {
artifact,
source_map,
markdown,
})
let _ = input;
Err(ParseError::new(
"liteparse_retired",
"LiteParse 已退役;PDF、Office、图片 OCR 与资料索引统一由 LightRAG provider 处理",
))
}
fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseError> {
@@ -337,270 +185,6 @@ fn parse_markdown_input(input: ParseInput) -> Result<ParseProviderOutput, ParseE
})
}
fn liteparse_bin() -> String {
env::var("MNOTE_LITEPARSE_BIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
if command_exists("lit") {
"lit".into()
} else {
"liteparse".into()
}
})
}
fn command_exists(command: &str) -> bool {
let path = Path::new(command);
if path.components().count() > 1 {
return path.exists();
}
let Some(paths) = env::var_os("PATH") else {
return false;
};
split_paths(&paths).any(|base| base.join(command).exists())
}
fn liteparse_version(value: &Value) -> Option<String> {
value
.get("version")
.or_else(|| value.get("modelVersion"))
.or_else(|| value.get("model_version"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| Some("liteparse-v2".into()))
}
fn liteparse_markdown(value: &Value) -> String {
for key in ["markdown", "text", "content"] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
let text = text.trim();
if !text.is_empty() {
return text.to_string();
}
}
}
let lines = liteparse_pages(value)
.into_iter()
.flat_map(|(_, page)| liteparse_blocks(&page))
.filter_map(|block| liteparse_item_text(&block))
.collect::<Vec<_>>();
lines.join("\n\n")
}
fn liteparse_source_map(input: &ParseInput, value: &Value, source_hash: &str) -> ResourceSourceMap {
let mut pages = Vec::new();
let mut sections = Vec::new();
let mut section_path = Vec::<String>::new();
let mut global_char = 0_u64;
for (page_index, page_value) in liteparse_pages(value) {
let page_number = liteparse_page_number(&page_value).unwrap_or(page_index);
let blocks = liteparse_blocks(&page_value);
let mut source_blocks = Vec::new();
let mut text_items = Vec::new();
for (block_index, block) in blocks.iter().enumerate() {
let Some(text) = liteparse_item_text(block) else {
continue;
};
let block_id = liteparse_item_id(block)
.unwrap_or_else(|| format!("p{page_number}_b{}", block_index + 1));
let text_item_id = format!("p{page_number}_t{}", block_index + 1);
let block_type = liteparse_block_kind(block);
let char_range = EvidenceRange {
start: global_char,
end: global_char + text.chars().count() as u64,
};
global_char = char_range.end + 1;
let bbox = liteparse_bbox(block);
if matches!(block_type, SourceMapBlockKind::Heading) {
let level = liteparse_heading_level(block).unwrap_or(1).max(1);
section_path.truncate(level.saturating_sub(1));
section_path.push(text.clone());
sections.push(SourceMapSection {
id: format!(
"sec_{}",
stable_segment(&format!(
"{}:{}:{}",
input.source_root_relative_path,
page_number,
section_path.join("/")
))
),
title: text.clone(),
path: section_path.clone(),
page_start: Some(page_number),
page_end: Some(page_number),
block_ids: vec![block_id.clone()],
});
} else if let Some(section) = sections.last_mut() {
section.page_end = Some(page_number);
if !section.block_ids.iter().any(|id| id == &block_id) {
section.block_ids.push(block_id.clone());
}
}
text_items.push(SourceMapTextItem {
id: text_item_id,
text: text.clone(),
bbox: bbox.clone(),
char_range: Some(char_range.clone()),
});
source_blocks.push(SourceMapBlock {
id: block_id,
block_type,
text,
bbox,
char_range: Some(char_range),
});
}
pages.push(SourceMapPage {
page: page_number,
width: liteparse_number(&page_value, &["width", "pageWidth"]),
height: liteparse_number(&page_value, &["height", "pageHeight"]),
text_items,
blocks: source_blocks,
});
}
ResourceSourceMap {
schema: RESOURCE_SOURCE_MAP_SCHEMA.into(),
provider: "liteparse".into(),
model_version: liteparse_version(value),
owner_document_path: input.owner_document_path.clone(),
source_root_relative_path: input.source_root_relative_path.clone(),
source_hash: source_hash.to_string(),
page_count: pages.iter().map(|page| page.page).max(),
pages,
sections,
}
}
fn liteparse_pages(value: &Value) -> Vec<(u32, Value)> {
if let Some(pages) = value.get("pages").and_then(Value::as_array) {
return pages
.iter()
.enumerate()
.map(|(index, page)| ((index + 1) as u32, page.clone()))
.collect();
}
vec![(1, json!({ "blocks": liteparse_blocks(value) }))]
}
fn liteparse_blocks(page: &Value) -> Vec<Value> {
for key in ["blocks", "items", "textItems", "text_items", "elements"] {
if let Some(items) = page.get(key).and_then(Value::as_array) {
return items.clone();
}
}
if let Some(text) = liteparse_item_text(page) {
return vec![json!({ "text": text })];
}
Vec::new()
}
fn liteparse_item_text(value: &Value) -> Option<String> {
for key in ["text", "content", "markdown", "value"] {
if let Some(text) = value.get(key).and_then(Value::as_str) {
let trimmed = text.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn liteparse_item_id(value: &Value) -> Option<String> {
value
.get("id")
.or_else(|| value.get("blockId"))
.or_else(|| value.get("block_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn liteparse_page_number(value: &Value) -> Option<u32> {
for key in ["page", "pageNumber", "page_number", "page_no", "pageNo"] {
if let Some(page) = value.get(key).and_then(Value::as_u64) {
return u32::try_from(page.max(1)).ok();
}
}
if let Some(page_idx) = value.get("page_idx").and_then(Value::as_u64) {
return u32::try_from(page_idx + 1).ok();
}
None
}
fn liteparse_block_kind(value: &Value) -> SourceMapBlockKind {
match value
.get("type")
.or_else(|| value.get("blockType"))
.or_else(|| value.get("block_type"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"title" | "heading" | "header" => SourceMapBlockKind::Heading,
"table" => SourceMapBlockKind::Table,
"figure" => SourceMapBlockKind::Figure,
"image" => SourceMapBlockKind::Image,
"list" => SourceMapBlockKind::List,
"text" | "paragraph" => SourceMapBlockKind::Paragraph,
_ => SourceMapBlockKind::Text,
}
}
fn liteparse_heading_level(value: &Value) -> Option<usize> {
value
.get("level")
.or_else(|| value.pointer("/props/level"))
.and_then(Value::as_u64)
.map(|value| value as usize)
}
fn liteparse_bbox(value: &Value) -> Option<EvidenceBBox> {
if let Some(bbox) = value
.get("bbox")
.or_else(|| value.get("boundingBox"))
.or_else(|| value.get("bounding_box"))
.and_then(Value::as_array)
{
if bbox.len() != 4 {
return None;
}
return Some(EvidenceBBox {
x0: bbox[0].as_f64()?,
y0: bbox[1].as_f64()?,
x1: bbox[2].as_f64()?,
y1: bbox[3].as_f64()?,
});
}
let x = value.get("x").and_then(Value::as_f64)?;
let y = value.get("y").and_then(Value::as_f64)?;
let width = value
.get("width")
.or_else(|| value.get("w"))
.and_then(Value::as_f64)?;
let height = value
.get("height")
.or_else(|| value.get("h"))
.and_then(Value::as_f64)?;
Some(EvidenceBBox {
x0: x,
y0: y,
x1: x + width,
y1: y + height,
})
}
fn liteparse_number(value: &Value, keys: &[&str]) -> Option<f64> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_f64))
}
fn markdown_source_map(input: &ParseInput, markdown: &str, source_hash: &str) -> ResourceSourceMap {
let mut section_path: Vec<String> = Vec::new();
let mut sections = Vec::new();
@@ -686,18 +270,6 @@ fn is_markdown_path(path: &str) -> bool {
.is_some_and(|ext| ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown"))
}
fn is_liteparse_path(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|ext| {
matches!(
ext.to_ascii_lowercase().as_str(),
"pdf" | "doc" | "docx" | "odt" | "ppt" | "pptx" | "odp" | "xls" | "xlsx" | "ods"
)
})
}
fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
format!(
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
@@ -705,13 +277,6 @@ fn source_hash(content: &str, size: u64, updated_at_ms: u64) -> String {
)
}
fn binary_source_hash(content: &[u8], size: u64, updated_at_ms: u64) -> String {
format!(
"fnv1a64:{:016x}:size:{size}:mtime:{updated_at_ms}",
fnv1a64(content)
)
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325_u64;
for byte in bytes {
@@ -728,12 +293,6 @@ fn stable_segment(value: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
@@ -781,7 +340,7 @@ mod tests {
}
#[test]
fn default_provider_routes_text_pdf_to_liteparse_no_ocr() {
fn default_provider_routes_pdf_to_lightrag_after_liteparse_retirement() {
let input = ParseInput {
root_path: PathBuf::from("/workspace"),
root_uri: "file:///workspace".into(),
@@ -791,16 +350,13 @@ mod tests {
mode: ParseProviderMode::NoOcr,
};
let liteparse = LiteParseProvider;
assert_eq!(default_parse_provider_id(&input), "liteparse");
assert_eq!(liteparse.can_parse(&input), ParseCapability::Preferred);
assert_eq!(default_parse_provider_id(&input), "lightrag");
assert_eq!(liteparse.can_parse(&input), ParseCapability::Unsupported);
}
#[tokio::test]
#[cfg(unix)]
async fn liteparse_provider_maps_json_output_to_artifact_and_source_map() {
use std::os::unix::fs::PermissionsExt;
let _guard = env_lock().lock().expect("env lock");
async fn liteparse_provider_returns_retired_error() {
let root = temp_root("mnote-liteparse-provider");
fs::create_dir_all(root.join("docs").join("Page.assets")).expect("assets");
fs::write(
@@ -808,32 +364,6 @@ mod tests {
b"%PDF-1.4",
)
.expect("pdf");
let lit = root.join("fake-lit");
fs::write(
&lit,
r#"#!/usr/bin/env bash
cat <<'JSON'
{
"version": "liteparse-test",
"pages": [{
"page": 3,
"width": 612,
"height": 792,
"blocks": [
{"id": "b1", "type": "heading", "level": 1, "text": "解除条件", "bbox": [72, 120, 220, 150]},
{"id": "b2", "type": "text", "text": "提前三十日通知", "x": 72, "y": 160, "width": 228, "height": 28}
]
}]
}
JSON
"#,
)
.expect("fake lit");
let mut permissions = fs::metadata(&lit).expect("fake lit metadata").permissions();
permissions.set_mode(0o755);
fs::set_permissions(&lit, permissions).expect("chmod fake lit");
let old_bin = env::var("MNOTE_LITEPARSE_BIN").ok();
env::set_var("MNOTE_LITEPARSE_BIN", &lit);
let input = ParseInput {
root_path: root.clone(),
@@ -846,40 +376,15 @@ JSON
let output = LiteParseProvider
.parse(input)
.await
.expect("parse liteparse");
.expect_err("liteparse retired");
if let Some(value) = old_bin {
env::set_var("MNOTE_LITEPARSE_BIN", value);
} else {
env::remove_var("MNOTE_LITEPARSE_BIN");
}
assert_eq!(output.artifact.provider, "liteparse");
assert_eq!(
output.artifact.model_version.as_deref(),
Some("liteparse-test")
);
assert!(output.artifact.source_hash.starts_with("fnv1a64:"));
assert_eq!(output.markdown, "解除条件\n\n提前三十日通知");
assert_eq!(output.source_map.provider, "liteparse");
assert_eq!(output.source_map.pages[0].page, 3);
assert_eq!(
output.source_map.pages[0].blocks[1]
.bbox
.as_ref()
.unwrap()
.x0,
72.0
);
assert_eq!(
output.source_map.sections[0].path,
vec!["解除条件".to_string()]
);
assert_eq!(output.code, "liteparse_retired");
let _ = fs::remove_dir_all(&root);
}
#[test]
fn default_provider_routes_ocr_policy_to_mineru() {
fn default_provider_routes_ocr_policy_to_lightrag() {
let input = ParseInput {
root_path: PathBuf::from("/workspace"),
root_uri: "file:///workspace".into(),
@@ -888,11 +393,11 @@ JSON
source_root_relative_path: "Page.assets/spec.pdf".into(),
mode: ParseProviderMode::Ocr,
};
assert_eq!(default_parse_provider_id(&input), "mineru");
assert_eq!(default_parse_provider_id(&input), "lightrag");
}
#[test]
fn provider_selection_routes_scanned_pdf_or_image_to_mineru() {
fn provider_selection_routes_scanned_pdf_or_image_to_lightrag() {
let scanned_pdf = ParseInput {
root_path: PathBuf::from("/workspace"),
root_uri: "file:///workspace".into(),
@@ -901,12 +406,15 @@ JSON
source_root_relative_path: "Page.assets/scan.pdf".into(),
mode: ParseProviderMode::Auto,
};
assert_eq!(select_parse_provider_id(&scanned_pdf, Some(0.05)), "mineru");
assert_eq!(
select_parse_provider_id(&scanned_pdf, Some(0.05)),
"lightrag"
);
let image = ParseInput {
source_root_relative_path: "Page.assets/photo.png".into(),
..scanned_pdf
};
assert_eq!(select_parse_provider_id(&image, None), "mineru");
assert_eq!(select_parse_provider_id(&image, None), "lightrag");
}
}
@@ -0,0 +1,157 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagStatusQuery,
};
use axum::extract::{Extension, Json, Query, State};
use serde_json::{json, Value};
pub async fn status(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let args = input.args.clone().unwrap_or_else(|| json!({}));
let body = KnowledgeRagStatusQuery {
workspace_id: input.effective_workspace_id().or_else(|| {
args.get("workspaceId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
root_uri: input.effective_root_uri().or_else(|| {
args.get("rootUri")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}),
};
let Json(payload) = crate::routes::knowledge_rag::status(
State(state.clone()),
Extension(context.clone()),
Query(body),
)
.await?;
Ok(payload)
}
pub async fn query(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
format!("资料库问答参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::query_rag(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(compact_query_result_for_agent(payload))
}
pub async fn open_reference(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
let body =
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_open_reference_payload_invalid",
format!("资料库引用打开参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::open_reference(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(payload)
}
fn compact_query_result_for_agent(payload: Value) -> Value {
let references = payload
.get("references")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(8)
.map(compact_reference_for_agent)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let citations = references
.iter()
.filter_map(|reference| {
reference
.get("citationMarkdown")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
})
.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.",
"references": references,
"citations": citations,
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"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),
})
}
fn compact_reference_for_agent(reference: &Value) -> Value {
let quote = reference
.get("quote")
.and_then(Value::as_str)
.map(|value| value.chars().take(700).collect::<String>())
.unwrap_or_default();
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")),
"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,
"locator": reference.get("locator").cloned().unwrap_or(Value::Null),
"locatorDegraded": reference.get("locatorDegraded").cloned().unwrap_or(Value::Bool(true)),
"citationMarkdown": reference.get("citationMarkdown").cloned().unwrap_or(Value::Null),
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
})
}
@@ -12,12 +12,9 @@ pub fn manifest() -> Value {
context_resolve_target_tool(),
doc_fetch_tool(),
doc_find_tool(),
evidence_search_tool(),
evidence_read_tool(),
evidence_open_tool(),
index_status_tool(),
index_refresh_tool(),
index_update_settings_tool(),
knowledge_rag_status_tool(),
knowledge_rag_query_tool(),
knowledge_rag_open_reference_tool(),
block_fetch_tool(),
doc_plan_update_tool(),
block_replace_tool(),
@@ -293,6 +290,7 @@ fn doc_find_tool() -> Value {
})
}
#[allow(dead_code)]
fn evidence_search_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -340,6 +338,7 @@ fn evidence_search_tool() -> Value {
})
}
#[allow(dead_code)]
fn evidence_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -371,6 +370,7 @@ fn evidence_read_tool() -> Value {
})
}
#[allow(dead_code)]
fn evidence_open_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -391,6 +391,93 @@ fn evidence_open_tool() -> Value {
})
}
fn knowledge_rag_status_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看 LightRAG 资料库 provider 状态、dashboard 地址、source registry 和同步状态。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn knowledge_rag_query_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("query".into(), json!({ "type": "string" }));
map.insert("question".into(), json!({ "type": "string" }));
map.insert(
"mode".into(),
json!({ "type": "string", "enum": ["local", "global", "hybrid", "naive", "mix", "bypass"], "default": "mix" }),
);
map.insert("topK".into(), json!({ "type": "integer", "default": 40 }));
map.insert(
"chunkTopK".into(),
json!({ "type": "integer", "default": 20 }),
);
map.insert(
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"sourcePaths".into(),
json!({
"type": "array",
"items": { "type": "string" },
"description": "可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。"
}),
);
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射后的引用。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
fn knowledge_rag_open_reference_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("reference".into(), json!({ "type": "object" }));
map.insert("referenceId".into(), json!({ "type": "string" }));
map.insert("filePath".into(), json!({ "type": "string" }));
map.insert("chunkId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.knowledge_rag.open_reference",
"description": "把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "filePath"],
"properties": properties
}
})
}
#[allow(dead_code)]
fn index_status_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -411,6 +498,7 @@ fn index_status_tool() -> Value {
})
}
#[allow(dead_code)]
fn index_refresh_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -431,6 +519,7 @@ fn index_refresh_tool() -> Value {
})
}
#[allow(dead_code)]
fn index_update_settings_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -744,7 +833,7 @@ fn page_get_tool() -> Value {
fn page_save_tool() -> Value {
json!({
"name": "mnote.page.save",
"description": "粗粒度兼容兜底:保存当前页面正文;本地 Markdown 普通编辑优先使用 agent 原生 patch/diff,只有整页覆盖/追加且其它工具无法表达时使用",
"description": "粗粒度 compat / cloud 兜底:保存当前页面正文;local-first 本地 Markdown 普通编辑禁止使用该工具,必须优先让 agent 原生 patch/diff 直接编辑授权文件;仅在用户明确要求整页覆盖/追加且其它工具无法表达时使用",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["page.write"],
"status": "available",
@@ -795,7 +884,7 @@ fn available_tool(
fn doc_markdown_edit_tool() -> Value {
let mut tool = write_tool(
"mnote.doc.markdown_edit",
"兼容 / 远端代理 fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 默认优先让 agent 原生 patch/diff 直接编辑授权文件;仅在需要 MNote 兼容工具、远端代理或结构校验时使用。",
"compat / remote / cloud fallback:通过文本级搜索替换编辑 markdown 内容。local-first 本地 workspace 的普通 Markdown 编辑禁止使用该工具,必须优先让 agent 原生 patch/diff 直接编辑授权文件;仅在远端代理、cloud/compat 或需要 MNote 结构校验时使用。",
["block.write", "page.write"],
json!({
"operations": {
@@ -4,6 +4,7 @@ pub mod context_tools;
pub mod doc;
pub mod evidence;
pub mod index;
pub mod knowledge_rag;
pub mod manifest;
pub mod onlyoffice_live;
pub mod page;
+20 -24
View File
@@ -36,24 +36,22 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-local-index",
title: "本地索引与证据检索",
description: "检索本地文档证据,并管理本地索引范围、刷新和删除。",
id: "mnote-knowledge-rag",
title: "资料库问答",
description:
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: false,
read_only: true,
requires_context_refs: &["folder"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.evidence.search",
"mnote.evidence.read",
"mnote.evidence.open",
"mnote.index.status",
"mnote.index.refresh",
"mnote.index.update_settings",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.open_reference",
],
content: include_str!("../../../../../skills/mnote-local-index/SKILL.md"),
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
},
MnoteCapabilityPack {
id: "mnote-local-file",
@@ -208,7 +206,7 @@ pub fn manifest_capabilities() -> Vec<Value> {
fn canonical_skill_id(skill_id: &str) -> &str {
match skill_id {
"mnote-document-evidence" => "mnote-local-index",
"mnote-document-evidence" | "mnote-local-index" => "mnote-knowledge-rag",
other => other,
}
}
@@ -364,33 +362,31 @@ mod tests {
}
#[test]
fn skill_registry_exposes_local_index_skill_to_agents() {
fn skill_registry_retired_local_index_in_favor_of_lightrag() {
let hermes_skills = skill_summaries_for_agent(Some("hermes"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-index"));
let skill = hermes_skills
.iter()
.find(|skill| skill["id"] == "mnote-local-index")
.expect("hermes should see local index skill");
assert_eq!(skill["readOnly"], false);
.find(|skill| skill["id"] == "mnote-knowledge-rag")
.expect("hermes should see LightRAG skill");
assert_eq!(skill["readOnly"], true);
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.evidence.search"));
assert!(skill["toolNames"]
.as_array()
.expect("tool names")
.iter()
.any(|name| name == "mnote.index.update_settings"));
.any(|name| name == "mnote.knowledge_rag.query"));
assert!(!hermes_skills
.iter()
.any(|skill| skill["id"] == "mnote-document-evidence"));
}
#[test]
fn skill_read_keeps_document_evidence_compat_alias() {
fn skill_read_maps_document_evidence_alias_to_lightrag() {
let skill = find_skill("mnote-document-evidence", Some("reasonix"))
.expect("compat alias should resolve");
assert_eq!(skill.id, "mnote-local-index");
assert_eq!(skill.id, "mnote-knowledge-rag");
}
#[tokio::test]
+15 -6
View File
@@ -17,14 +17,23 @@ use serde_json::{json, Value};
use std::path::{Component, Path};
pub async fn search(
State(state): State<AppState>,
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(body): Json<EvidenceSearchRequest>,
Json(_body): Json<EvidenceSearchRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let response = search_payload(&state, &context, body).await?;
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("application/json"));
Ok((StatusCode::OK, headers, Json(response)))
Ok((
StatusCode::GONE,
headers,
Json(json!({
"ok": false,
"code": "mnote_evidence_search_retired",
"message": "旧 LiteParse/evidence 搜索已退役;PDF、Office、图片 OCR 与资料索引统一走 /api/knowledge-rag/query",
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
))
}
pub(crate) async fn search_payload(
@@ -241,7 +250,7 @@ fn suggested_evidence_queries(query: &str) -> Vec<String> {
suggestions
}
fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
pub(crate) fn citation_markdown_for_locator(locator: &EvidenceLocator) -> String {
let label = citation_label_for_locator(locator);
let url = citation_url_for_locator(locator);
format!(
@@ -280,7 +289,7 @@ fn citation_label_for_locator(locator: &EvidenceLocator) -> String {
parts.join(" · ")
}
fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
pub(crate) fn citation_url_for_locator(locator: &EvidenceLocator) -> String {
let owner_document_id = locator.owner_document_id.trim();
let mut url = if owner_document_id.is_empty() {
let existing = locator.open_action.url.trim();
@@ -3,7 +3,7 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{
artifact, block, context_tools, doc, evidence, index, manifest, onlyoffice_live, page,
artifact, block, context_tools, doc, evidence, knowledge_rag, manifest, onlyoffice_live, page,
resource, skill, ToolCallInput,
};
use axum::extract::{Extension, Query, State};
@@ -362,14 +362,27 @@ pub(crate) async fn execute_mnote_tool_call(
"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" => evidence::evidence_search(&state, &context, &input).await,
"mnote.evidence.read" => evidence::evidence_read(&state, &context, &input).await,
"mnote.evidence.open" => evidence::evidence_open(&state, &context, &input).await,
"mnote.index.status" => index::index_status(&state, &context, &input).await,
"mnote.index.refresh" => index::index_refresh(&state, &context, &input).await,
"mnote.index.update_settings" => {
index::index_update_settings(&state, &context, &input).await
"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",
)
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.open_reference" => {
knowledge_rag::open_reference(&state, &context, &input).await
}
"mnote.index.status" | "mnote.index.refresh" | "mnote.index.update_settings" => Err(
WebError::new(
StatusCode::GONE,
"mnote_index_tools_retired",
"旧本地索引 tools 已退役;资料索引统一由 LightRAG provider 处理",
)
.with_context(&context),
),
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
"mnote.block.fetch" => block::block_fetch(&state, &context, &input).await,
"mnote.block.replace" => block::block_replace(&state, &context, &input).await,
@@ -709,6 +722,9 @@ fn is_read_tool(tool_name: &str) -> bool {
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.open_reference"
| "mnote.index.status"
| "mnote.index.refresh"
| "mnote.block.fetch"
@@ -738,6 +754,9 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
| "mnote.evidence.search"
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.open_reference"
)
}
@@ -1604,23 +1623,29 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
let manifest = &payload["manifest"];
let capabilities = manifest["capabilities"].as_array().expect("capabilities");
assert!(capabilities
assert!(!capabilities
.iter()
.any(|capability| capability["id"] == "mnote-local-index"));
assert!(capabilities
.iter()
.any(|capability| capability["id"] == "mnote-knowledge-rag"));
assert!(capabilities
.iter()
.any(|capability| capability["id"] == "mnote-onlyoffice-live"));
let tools = manifest["tools"].as_array().expect("tools");
let index_status = tools
assert!(!tools
.iter()
.find(|tool| tool["name"] == "mnote.index.status")
.expect("index status tool");
assert!(index_status["capabilityIds"]
.any(|tool| tool["name"] == "mnote.index.status"));
let knowledge_rag_query = tools
.iter()
.find(|tool| tool["name"] == "mnote.knowledge_rag.query")
.expect("knowledge rag query tool");
assert!(knowledge_rag_query["capabilityIds"]
.as_array()
.expect("index capability ids")
.expect("knowledge rag capability ids")
.iter()
.any(|id| id == "mnote-local-index"));
.any(|id| id == "mnote-knowledge-rag"));
let onlyoffice_batch_set = tools
.iter()
@@ -5573,7 +5598,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_tools_local_index_update_and_status_manage_scope() {
async fn hermes_tools_local_index_tools_are_retired() {
let root = std::env::temp_dir().join(format!("mnote-index-tool-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join(".mnote")).expect("metadata");
@@ -5583,11 +5608,7 @@ mod tests {
r#"{"workspaceId":"local-ws-index-tool","ownerId":"user_1","createdAt":"2026-06-04T00:00:00Z","capabilities":["local_files","search"]}"#,
)
.expect("manifest");
fs::write(
root.join("docs").join("indexed.md"),
"# Indexed\n\nindex-tool-token\n",
)
.expect("markdown");
fs::write(root.join("docs").join("indexed.md"), "# Indexed\n").expect("markdown");
let root_uri = format!("file://{}", root.display());
let app = app();
@@ -5625,18 +5646,16 @@ mod tests {
)
.await
.expect("update response");
assert_eq!(update_response.status(), StatusCode::OK);
assert_eq!(update_response.status(), StatusCode::GONE);
let update_body = to_bytes(update_response.into_body(), usize::MAX)
.await
.expect("update body");
let update_payload: Value = serde_json::from_slice(&update_body).expect("update json");
assert_eq!(
update_payload["result"]["settings"]["includePaths"][0],
"docs"
update_payload["code"].as_str(),
Some("mnote_index_tools_retired")
);
assert_eq!(update_payload["result"]["index"]["documentCount"], 1);
assert!(root.join(".mnote/index/search-index.json").exists());
assert!(root.join(".mnote/index/evidence.sqlite").exists());
assert!(!root.join(".mnote/index/search-index.json").exists());
let status_response = app
.oneshot(
@@ -5664,16 +5683,15 @@ mod tests {
)
.await
.expect("status response");
assert_eq!(status_response.status(), StatusCode::OK);
assert_eq!(status_response.status(), StatusCode::GONE);
let status_body = to_bytes(status_response.into_body(), usize::MAX)
.await
.expect("status body");
let status_payload: Value = serde_json::from_slice(&status_body).expect("status json");
assert_eq!(
status_payload["result"]["result"]["settings"]["includePaths"][0],
"docs"
status_payload["code"].as_str(),
Some("mnote_index_tools_retired")
);
assert_eq!(status_payload["audit"]["effect"], "read");
let _ = fs::remove_dir_all(&root);
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,15 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use crate::routes::{
knowledge_rag,
local_folder_source::{
decode_local_id_segment, ensure_local_workspace_read_access_with_state,
load_local_folder_file_tree_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_workspace_id_from_root_uri,
},
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue};
use axum::response::sse::{Event as SseEvent, Sse};
@@ -230,12 +233,19 @@ async fn build_tree_live_stream(
})?;
let stream = stream::unfold(
(Some(initial_payload), subscription, root_uri, workspace_id),
|(payload, mut subscription, root_uri, workspace_id)| async move {
(
Some(initial_payload),
subscription,
root_uri,
workspace_id,
state,
context,
),
|(payload, mut subscription, root_uri, workspace_id, state, context)| async move {
if let Some(payload) = payload {
return Some((
Ok(stream_event("snapshot", &payload)),
(None, subscription, root_uri, workspace_id),
(None, subscription, root_uri, workspace_id, state, context),
));
}
@@ -253,6 +263,13 @@ async fn build_tree_live_stream(
Err(_) => break,
}
}
let _ = knowledge_rag::sync_registry_for_root(
&state,
&context,
&root_uri,
Some(&workspace_id),
)
.await;
if let Some(batch_payload) = build_local_folder_watch_batch_payload(
&root_uri,
&workspace_id,
@@ -260,7 +277,7 @@ async fn build_tree_live_stream(
) {
return Some((
Ok(stream_event("watch_batch", &batch_payload)),
(None, subscription, root_uri, workspace_id),
(None, subscription, root_uri, workspace_id, state, context),
));
}
let error_payload = build_tree_live_error_payload(
@@ -271,7 +288,7 @@ async fn build_tree_live_stream(
);
return Some((
Ok(stream_event("tree_error", &error_payload)),
(None, subscription, root_uri, workspace_id),
(None, subscription, root_uri, workspace_id, state, context),
));
}
Err(RecvError::Lagged(_)) => continue,
@@ -9,7 +9,7 @@ use crate::routes::local_markdown_parser::{
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
};
use crate::routes::snapshot_support::ProjectionSnapshot;
use crate::routes::{local_ocr, local_search_index};
use crate::routes::{knowledge_rag, local_ocr, local_search_index};
use axum::extract::{Extension, Multipart, Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::Json;
@@ -307,14 +307,16 @@ struct LocalFolderScanResult {
#[derive(Debug, Clone, Default)]
struct LocalFileTreeIndexState {
indexed_paths: BTreeSet<String>,
indexing_paths: BTreeSet<String>,
failed_paths: BTreeSet<String>,
}
impl LocalFileTreeIndexState {
fn load(root: &Path) -> Self {
local_search_index::local_evidence_source_statuses(root)
fn load(root: &Path, workspace_id: &str, root_uri: &str) -> Self {
knowledge_rag::knowledge_rag_source_statuses(root, workspace_id, root_uri)
.map(|statuses| Self {
indexed_paths: statuses.indexed_paths,
indexing_paths: statuses.indexing_paths,
failed_paths: statuses.failed_paths,
})
.unwrap_or_default()
@@ -334,6 +336,9 @@ impl LocalFileTreeIndexState {
if self.indexed_paths.contains(path) {
return Some("indexed".to_string());
}
if self.indexing_paths.contains(path) {
return Some("indexing".to_string());
}
None
}
}
@@ -2752,7 +2757,8 @@ fn load_local_folder_file_tree_scope_snapshot(
let workspace_id = local_workspace_id(&canonical_root);
let root_source_uri = file_uri_for_path(&canonical_root);
let metadata = load_local_folder_metadata(&canonical_root)?;
let index_state = LocalFileTreeIndexState::load(&canonical_root);
let index_state =
LocalFileTreeIndexState::load(&canonical_root, &workspace_id, &root_source_uri);
let parent_relative_path = parent_relative_path
.map(str::trim)
.filter(|value| !value.is_empty() && *value != ".")
@@ -13646,84 +13652,118 @@ fn main() {}
}
#[test]
fn local_file_tree_marks_indexed_and_failed_source_files() {
let root = temp_root("mnote-local-filetree-index-status");
fn local_file_tree_marks_lightrag_indexed_indexing_and_failed_source_files() {
let root = temp_root("mnote-lightrag-filetree-index-status");
init_workspace(&root);
std::fs::write(root.join("ok.pdf"), b"%PDF-1.4\nok").expect("ok pdf");
std::fs::write(root.join("pending.pdf"), b"%PDF-1.4\npending").expect("pending pdf");
std::fs::write(root.join("failed.pdf"), b"%PDF-1.4\nfailed").expect("failed pdf");
std::fs::write(root.join("deleting.pdf"), b"%PDF-1.4\ndeleting").expect("deleting pdf");
std::fs::write(root.join("removed.pdf"), b"%PDF-1.4\nremoved").expect("removed pdf");
std::fs::write(root.join("draft.md"), "# Draft\n").expect("draft");
let index_dir = root.join(".mnote").join("index");
std::fs::create_dir_all(&index_dir).expect("index dir");
let evidence_path = index_dir.join("evidence.sqlite");
let connection = rusqlite::Connection::open(&evidence_path).expect("evidence sqlite");
connection
.execute_batch(
r#"
CREATE TABLE evidence_resource(
resource_id TEXT PRIMARY KEY,
owner_document_id TEXT NOT NULL,
owner_document_path TEXT NOT NULL,
source_root_relative_path TEXT NOT NULL,
provider TEXT NOT NULL,
source_hash TEXT NOT NULL,
artifact_root_relative_path TEXT NOT NULL,
source_map_root_relative_path TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
"#,
)
.expect("evidence schema");
connection
.execute(
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
"local-resource:ok.pdf#parse",
"local-resource:ok.pdf",
"ok.pdf",
"ok.pdf",
"liteparse",
"hash",
"ok.ocr/ok.pdf.parse.md",
"ok.ocr/ok.pdf.source-map.json",
1_i64,
],
)
.expect("insert indexed evidence");
connection
.execute(
"INSERT INTO evidence_resource(resource_id, owner_document_id, owner_document_path, source_root_relative_path, provider, source_hash, artifact_root_relative_path, source_map_root_relative_path, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
"local-md:draft.md",
"local-md:draft.md",
"draft.md",
"draft.md",
"markdown",
"hash",
"draft.md",
"draft.md.source-map.json",
1_i64,
],
)
.expect("insert markdown evidence");
let root_uri = format!("file://{}", root.display());
let search_index = json!({
"version": 1,
"builtAt": 1,
let workspace_id = local_workspace_id(&root);
let registry = json!({
"schema": "mnote.knowledge_rag.source_registry.v1",
"workspaceId": workspace_id,
"rootUri": root_uri,
"workspaceId": "local-filetree-index-status",
"indexedPaths": ["."],
"documents": [],
"resources": [
{"resourceId": "local-resource:ok.pdf", "resourceType": "pdf", "title": "ok", "path": "ok.pdf", "updatedAt": 1},
{"resourceId": "local-resource:failed.pdf", "resourceType": "pdf", "title": "failed", "path": "failed.pdf", "updatedAt": 1}
"updatedAtMs": 1,
"entries": [
{
"sourceId": "lightrag-source-ok",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("ok.pdf").display().to_string(),
"sourceRootRelativePath": "ok.pdf",
"sourceHash": "hash-ok",
"lightRagDocId": "doc-ok",
"lightRagStatus": "processed",
"lightRagFilePath": "ok.pdf",
"symlinkPath": "/tmp/ok.pdf",
"parserHint": null,
"indexedAtMs": 2,
"deletedAtMs": null,
"stale": false,
"updatedAtMs": 2
},
{
"sourceId": "lightrag-source-pending",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("pending.pdf").display().to_string(),
"sourceRootRelativePath": "pending.pdf",
"sourceHash": "hash-pending",
"lightRagDocId": null,
"lightRagStatus": "submitted",
"lightRagFilePath": "pending.pdf",
"symlinkPath": "/tmp/pending.pdf",
"parserHint": null,
"indexedAtMs": null,
"deletedAtMs": null,
"stale": false,
"updatedAtMs": 2
},
{
"sourceId": "lightrag-source-failed",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("failed.pdf").display().to_string(),
"sourceRootRelativePath": "failed.pdf",
"sourceHash": "hash-failed",
"lightRagDocId": null,
"lightRagStatus": "failed",
"lightRagFilePath": "failed.pdf",
"symlinkPath": "/tmp/failed.pdf",
"parserHint": null,
"indexedAtMs": null,
"deletedAtMs": 3,
"stale": true,
"updatedAtMs": 3
},
{
"sourceId": "lightrag-source-deleting",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("deleting.pdf").display().to_string(),
"sourceRootRelativePath": "deleting.pdf",
"sourceHash": "hash-deleting",
"lightRagDocId": "doc-deleting",
"lightRagStatus": "delete_submitted",
"lightRagFilePath": "deleting.pdf",
"symlinkPath": "/tmp/deleting.pdf",
"parserHint": null,
"indexedAtMs": 2,
"deletedAtMs": 3,
"stale": true,
"updatedAtMs": 3
},
{
"sourceId": "lightrag-source-removed",
"workspaceId": workspace_id,
"rootUri": root_uri,
"sourcePath": root.join("removed.pdf").display().to_string(),
"sourceRootRelativePath": "removed.pdf",
"sourceHash": "hash-removed",
"lightRagDocId": null,
"lightRagStatus": "delete_completed",
"lightRagFilePath": "removed.pdf",
"symlinkPath": "/tmp/removed.pdf",
"parserHint": null,
"indexedAtMs": null,
"deletedAtMs": 3,
"stale": true,
"updatedAtMs": 3
}
]
});
std::fs::write(
index_dir.join("search-index.json"),
format!("{}\n", serde_json::to_string_pretty(&search_index).unwrap()),
index_dir.join("lightrag-source-registry.json"),
format!("{}\n", serde_json::to_string_pretty(&registry).unwrap()),
)
.expect("search index");
.expect("lightrag registry");
let file_tree = load_local_folder_file_tree_snapshot(&format!("file://{}", root.display()))
.expect("file tree");
@@ -13737,7 +13777,10 @@ CREATE TABLE evidence_resource(
.and_then(|item| item["indexStatus"].as_str())
};
assert_eq!(status_for("ok.pdf"), Some("indexed"));
assert_eq!(status_for("pending.pdf"), Some("indexing"));
assert_eq!(status_for("failed.pdf"), Some("failed"));
assert_eq!(status_for("deleting.pdf"), Some("indexing"));
assert_eq!(status_for("removed.pdf"), None);
assert_eq!(status_for("draft.md"), None);
let _ = std::fs::remove_dir_all(&root);
@@ -2131,6 +2131,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_writes_mock_sidecar_and_reads_status() {
let root = temp_root("mnote-local-ocr-route");
write_workspace_manifest(&root);
@@ -2255,6 +2256,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_reuses_existing_done_sidecar_without_reprocessing() {
let root = temp_root("mnote-local-ocr-dedup-done");
write_workspace_manifest(&root);
@@ -2331,6 +2333,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_recovers_existing_done_sidecar_when_index_is_missing() {
let root = temp_root("mnote-local-ocr-dedup-sidecar-recover");
write_workspace_manifest(&root);
@@ -2405,6 +2408,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_reuses_existing_done_before_mineru_token_check() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
@@ -2484,6 +2488,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_broadcasts_stage_updates() {
let root = temp_root("mnote-local-ocr-events");
write_workspace_manifest(&root);
@@ -2548,6 +2553,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_rejects_missing_mineru_token() {
let old_mnote_token = std::env::var("MNOTE_MINERU_API_TOKEN").ok();
let old_mineru_token = std::env::var("MINERU_API_TOKEN").ok();
@@ -2584,6 +2590,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_runs_mineru_runtime_against_http_mock() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
@@ -2832,6 +2839,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_rejects_root_escape_source() {
let root = temp_root("mnote-local-ocr-escape");
write_workspace_manifest(&root);
@@ -2853,6 +2861,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_rejects_non_image_pdf_source() {
let root = temp_root("mnote-local-ocr-unsupported");
write_workspace_manifest(&root);
@@ -2882,6 +2891,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_jobs_route_writes_failed_mock_entry_with_redacted_error() {
let root = temp_root("mnote-local-ocr-failed");
write_workspace_manifest(&root);
@@ -2938,6 +2948,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "local OCR HTTP routes are retired in favor of LightRAG"]
async fn local_ocr_insert_route_appends_explicit_ocr_link() {
let root = temp_root("mnote-local-ocr-insert");
write_workspace_manifest(&root);
@@ -1,7 +1,4 @@
use crate::error::WebError;
use crate::evidence_parse::{
ParseInput, ParseProviderMode, liteparse_runtime_available, parse_liteparse_input_blocking,
};
use crate::routes::local_folder_source::encode_local_id_segment;
use crate::routes::local_markdown_parser::{
parse_markdown_attachment_link, parse_markdown_page, split_frontmatter,
@@ -9,12 +6,12 @@ use crate::routes::local_markdown_parser::{
use crate::routes::local_ocr;
use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceInput};
use core_protocol::{
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult,
PARSED_RESOURCE_ARTIFACT_SCHEMA, ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock,
EvidenceLocator, EvidenceSearchMatchInfo, EvidenceSearchResult, ParsedResourceArtifact,
ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
};
use rusqlite::{Connection, OptionalExtension, params};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -183,49 +180,7 @@ pub(crate) fn query_local_search_index_with_settings(
}
}
}
if include_ocr && results.len() < limit.max(1) as usize {
for entry in local_ocr::ocr_index_entries(root_path)? {
if !index_relative_path_is_included(
&entry.source_root_relative_path,
&result_settings.include_paths,
) {
continue;
}
if let Some(page_id) = page_id {
if let Some(resource_path) = page_resource_path.as_deref() {
if entry.source_root_relative_path != resource_path {
continue;
}
} else if entry.owner_document_id != page_id {
continue;
}
}
if entry.status != "done" {
continue;
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let markdown = match fs::read_to_string(&ocr_path) {
Ok(markdown) => markdown,
Err(_) => continue,
};
if local_ocr::parse_ocr_frontmatter(&markdown).is_none() {
continue;
}
let body = local_ocr::strip_ocr_frontmatter(&markdown);
if !local_search_ocr_matches(&entry, body, &normalized_query, title_only, exact) {
continue;
}
results.push(local_search_ocr_projection(
&entry,
body,
root_uri,
&normalized_query,
));
if results.len() >= limit.max(1) as usize {
break;
}
}
}
let _ = include_ocr;
Ok(json!({
"enqueueAssetIds": [],
"projectionOwner": "rust-kernel",
@@ -2244,14 +2199,6 @@ fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Re
for resource in &index.resources {
insert_resource_evidence(&tx, root_path, index, resource)?;
}
for entry in local_ocr::ocr_index_entries(root_path)?
.into_iter()
.filter(|entry| {
index_relative_path_is_included(&entry.source_root_relative_path, &index.indexed_paths)
})
{
insert_ocr_evidence(&tx, root_path, index, &entry)?;
}
for document in &index.documents {
insert_document_graph_edges(&tx, document)?;
}
@@ -2373,15 +2320,6 @@ fn refresh_evidence_sqlite_index_for_path(
{
insert_resource_evidence(&tx, root_path, index, resource)?;
}
for entry in local_ocr::ocr_index_entries(root_path)?
.into_iter()
.filter(|entry| {
entry.ocr_root_relative_path == relative_path
|| entry.source_root_relative_path == relative_path
})
{
insert_ocr_evidence(&tx, root_path, index, &entry)?;
}
tx.execute(
"INSERT OR REPLACE INTO evidence_meta(key, value) VALUES('schema_version', ?1)",
params![EVIDENCE_SQLITE_SCHEMA_VERSION.to_string()],
@@ -2664,147 +2602,18 @@ fn insert_resource_evidence(
}
fn parse_resource_evidence_artifact(
root_path: &Path,
index: &LocalSearchIndex,
resource: &LocalSearchResource,
_root_path: &Path,
_index: &LocalSearchIndex,
_resource: &LocalSearchResource,
) -> Result<Option<crate::evidence_parse::ParseProviderOutput>, WebError> {
if !resource_parse_supported(resource) || !liteparse_runtime_available() {
return Ok(None);
}
let owner = evidence_owner_for_resource(index, resource);
let mut output = match parse_liteparse_input_blocking(ParseInput {
root_path: root_path.to_path_buf(),
root_uri: index.root_uri.clone(),
owner_document_id: owner.document_id.clone(),
owner_document_path: owner.path.clone(),
source_root_relative_path: resource.path.clone(),
mode: ParseProviderMode::NoOcr,
}) {
Ok(output) => output,
Err(_) => return Ok(None),
};
let sidecar = resource_parse_sidecar_paths(&owner.path, &resource.path);
write_parsed_resource_sidecars(root_path, &sidecar, &output.markdown, &output.source_map)?;
output.artifact.artifact_root_relative_path = sidecar.parse_root_relative_path;
output.artifact.source_map_root_relative_path = sidecar.source_map_root_relative_path.clone();
output.source_map.owner_document_path = owner.path;
Ok(Some(output))
}
fn resource_parse_supported(resource: &LocalSearchResource) -> bool {
matches!(resource.resource_type.as_str(), "pdf" | "office")
Ok(None)
}
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[derive(Debug, Clone)]
struct EvidenceResourceOwner {
document_id: String,
path: String,
}
fn evidence_owner_for_resource(
index: &LocalSearchIndex,
resource: &LocalSearchResource,
) -> EvidenceResourceOwner {
if let Some(document) = index.documents.iter().find(|document| {
document.resource_refs.iter().any(|reference| {
normalize_local_reference_path(&document.path, reference) == resource.path
})
}) {
return EvidenceResourceOwner {
document_id: document.document_id.clone(),
path: document.path.clone(),
};
}
EvidenceResourceOwner {
document_id: resource.resource_id.clone(),
path: resource.path.clone(),
}
}
#[derive(Debug, Clone)]
struct ResourceParseSidecarPaths {
parse_root_relative_path: String,
source_map_root_relative_path: String,
}
fn resource_parse_sidecar_paths(
owner_document_path: &str,
source_root_relative_path: &str,
) -> ResourceParseSidecarPaths {
let owner_parent = Path::new(owner_document_path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let owner_stem = Path::new(owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Page");
let source_leaf = Path::new(source_root_relative_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("source");
let sidecar_dir = owner_parent.join(format!("{owner_stem}.ocr"));
let parse_root_relative_path = sidecar_dir
.join(format!("{source_leaf}.parse.md"))
.to_string_lossy()
.replace('\\', "/");
let source_map_root_relative_path = sidecar_dir
.join(format!("{source_leaf}.source-map.json"))
.to_string_lossy()
.replace('\\', "/");
ResourceParseSidecarPaths {
parse_root_relative_path,
source_map_root_relative_path,
}
}
fn write_parsed_resource_sidecars(
root_path: &Path,
sidecar: &ResourceParseSidecarPaths,
markdown: &str,
source_map: &ResourceSourceMap,
) -> Result<(), WebError> {
let parse_path = root_path.join(&sidecar.parse_root_relative_path);
let source_map_path = root_path.join(&sidecar.source_map_root_relative_path);
for path in [&parse_path, &source_map_path] {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"evidence_parse_sidecar_create_failed",
format!(
"无法创建证据解析 sidecar 目录 {}: {error}",
parent.display()
),
)
})?;
}
}
fs::write(&parse_path, markdown.trim_end()).map_err(|error| {
WebError::bad_request_code(
"evidence_parse_sidecar_write_failed",
format!(
"无法写入证据解析 Markdown {}: {error}",
parse_path.display()
),
)
})?;
let source_map_json = serde_json::to_string_pretty(source_map)
.map_err(|error| WebError::internal(format!("证据 source-map 序列化失败: {error}")))?;
fs::write(&source_map_path, format!("{source_map_json}\n")).map_err(|error| {
WebError::bad_request_code(
"evidence_parse_sidecar_write_failed",
format!(
"无法写入证据 source-map {}: {error}",
source_map_path.display()
),
)
})
}
#[allow(dead_code)]
fn insert_ocr_evidence(
connection: &Connection,
root_path: &Path,
@@ -2863,6 +2672,7 @@ fn insert_ocr_evidence(
insert_evidence_block(connection, &resource_id, &resource_id, body, locator)
}
#[allow(dead_code)]
fn ocr_parsed_artifact(
entry: &local_ocr::OcrIndexEntry,
source_map_root_relative_path: &str,
@@ -3461,12 +3271,14 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
.map_err(sqlite_error)
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub(crate) struct LocalEvidenceSourceStatuses {
pub(crate) indexed_paths: BTreeSet<String>,
pub(crate) failed_paths: BTreeSet<String>,
}
#[allow(dead_code)]
pub(crate) fn local_evidence_source_statuses(
root_path: &Path,
) -> Result<LocalEvidenceSourceStatuses, WebError> {
@@ -3509,21 +3321,6 @@ pub(crate) fn local_evidence_source_statuses(
}
}
}
for entry in local_ocr::ocr_index_entries(root_path)? {
match entry.status.as_str() {
"done" => {
statuses
.indexed_paths
.insert(entry.source_root_relative_path);
}
"failed" | "interrupted" => {
statuses
.failed_paths
.insert(entry.source_root_relative_path);
}
_ => {}
}
}
Ok(statuses)
}
@@ -3563,6 +3360,7 @@ fn local_resource_path_from_document_id(document_id: &str) -> Option<String> {
.filter(|value| !value.trim().is_empty())
}
#[allow(dead_code)]
fn path_source_map_path(path: &str) -> Option<String> {
if let Some(stripped) = path.strip_suffix(".ocr.md") {
return Some(format!("{stripped}.source-map.json"));
@@ -3681,6 +3479,7 @@ fn local_search_resource_matches(
}
}
#[allow(dead_code)]
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
@@ -3758,6 +3557,7 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
#[allow(dead_code)]
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
@@ -4375,55 +4175,41 @@ mod tests {
.iter()
.find(|item| item["documentId"].as_str() == Some("local-md:README.md"))
.expect("home result");
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx"))
);
assert!(
home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
))
);
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("docs/child.md")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("maps/idea.mindmap.json")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("office/report.xlsx")));
assert!(home["publicPath"]
.as_str()
.is_some_and(|path| path.starts_with(
"/documents/local-md:README.md?sourceKind=local_folder&rootUri=file%3A%2F%2F"
)));
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
let connection = Connection::open(&evidence_db).expect("open evidence sqlite");
let markdown_edge_count: i64 = connection
@@ -4479,12 +4265,11 @@ mod tests {
Some("local-mdid:child-page")
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
);
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
let mindmap_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4497,19 +4282,19 @@ mod tests {
false,
)
.expect("mindmap projection");
assert!(
mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"].as_str().is_some_and(|path| path
assert!(mindmap_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("mindmap")
&& item["path"].as_str() == Some("maps/idea.mindmap.json")
&& item["publicPath"]
.as_str()
.is_some_and(|path| path
.starts_with("/?treeView=filetree&sourceKind=local_folder&rootUri="))
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?")))
);
&& item["publicPath"]
.as_str()
.is_some_and(|path| !path.starts_with("/tree?"))));
let office_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4522,14 +4307,12 @@ mod tests {
false,
)
.expect("office projection");
assert!(
office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx"))
);
assert!(office_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("office")
&& item["path"].as_str() == Some("office/report.xlsx")));
let pdf_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4542,14 +4325,12 @@ mod tests {
false,
)
.expect("pdf projection");
assert!(
pdf_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("pdf")
&& item["path"].as_str() == Some("assets/spec.pdf"))
);
assert!(pdf_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("pdf")
&& item["path"].as_str() == Some("assets/spec.pdf")));
let image_projection = query_local_search_index(
&root,
&format!("file://{}", root.display()),
@@ -4562,14 +4343,12 @@ mod tests {
false,
)
.expect("image projection");
assert!(
image_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("image")
&& item["path"].as_str() == Some("assets/diagram.png"))
);
assert!(image_projection["results"]
.as_array()
.unwrap()
.iter()
.any(|item| item["resourceType"].as_str() == Some("image")
&& item["path"].as_str() == Some("assets/diagram.png")));
let _ = fs::remove_dir_all(&root);
}
@@ -5194,12 +4973,10 @@ mod tests {
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md")
);
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let child = index
.documents
.iter()
@@ -5227,18 +5004,14 @@ mod tests {
let index = read_local_search_index(&root)
.expect("read index")
.expect("index exists");
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/child.md")
);
assert!(
index
.documents
.iter()
.any(|document| document.path == "README.md")
);
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/child.md"));
assert!(index
.documents
.iter()
.any(|document| document.path == "README.md"));
let removed_evidence = query_evidence_sqlite_results(&root, "ChangedToken", None, 10)
.expect("removed evidence")
.expect("sqlite exists");
@@ -5251,7 +5024,7 @@ mod tests {
}
#[test]
fn local_search_ocr_sidecar_requires_include_ocr_and_returns_owner_page() {
fn local_search_ocr_sidecar_is_hidden_after_lightrag_retirement() {
let root = temp_root("mnote-local-search-ocr");
let root_uri = format!("file://{}", root.display());
let workspace_id = "local-ws-ocr";
@@ -5344,38 +5117,18 @@ mod tests {
true,
)
.expect("with ocr");
let result = with_ocr["results"]
.as_array()
.and_then(|items| items.first())
.expect("ocr result");
assert_eq!(
result["documentId"].as_str(),
Some("local-md:docs~2FPage.md")
);
assert_eq!(result["hasOcr"].as_bool(), Some(true));
assert_eq!(
result["ocrEvidence"]["sourceRootRelativePath"].as_str(),
Some("docs/Page.assets/photo.png")
);
assert_eq!(
result["ocrEvidence"]["ocrRootRelativePath"].as_str(),
Some("docs/Page.ocr/photo.png.ocr.md")
);
assert_eq!(with_ocr["results"].as_array().map(Vec::len), Some(0));
let index = read_local_search_index(&root)
.expect("read search index")
.expect("search index");
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md")
);
assert!(
!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
);
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png.ocr.md"));
assert!(!index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
refresh_local_search_index_for_path(
&root,
&root_uri,
@@ -5386,12 +5139,10 @@ mod tests {
let refreshed_index = read_local_search_index(&root)
.expect("read refreshed search index")
.expect("refreshed search index");
assert!(
!refreshed_index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md")
);
assert!(!refreshed_index
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
let _ = fs::remove_dir_all(&root);
}
@@ -5492,21 +5243,15 @@ mod tests {
.expect("sqlite query")
.expect("sqlite exists");
assert!(results.len() >= 2);
assert!(
results
.iter()
.any(|result| result.source.owner_document_path == "README.md")
);
assert!(
results
.iter()
.any(|result| result.source.owner_document_path == "docs/child.md")
);
assert!(
results
.iter()
.all(|result| result.source.schema == "mnote.evidence_locator.v1")
);
assert!(results
.iter()
.any(|result| result.source.owner_document_path == "README.md"));
assert!(results
.iter()
.any(|result| result.source.owner_document_path == "docs/child.md"));
assert!(results
.iter()
.all(|result| result.source.schema == "mnote.evidence_locator.v1"));
let home_result = results
.iter()
.find(|result| result.source.owner_document_path == "README.md")
@@ -5664,16 +5409,12 @@ mod tests {
.expect("sqlite exists");
assert_eq!(context.len(), 2);
assert!(
context
.iter()
.all(|item| item.source.owner_document_path == "README.md")
);
assert!(
context
.iter()
.any(|item| item.quote.contains("ReadContextToken"))
);
assert!(context
.iter()
.all(|item| item.source.owner_document_path == "README.md"));
assert!(context
.iter()
.any(|item| item.quote.contains("ReadContextToken")));
let _ = fs::remove_dir_all(&root);
}
@@ -5771,18 +5512,16 @@ JSON
Some("docs/Page.assets/spec.pdf"),
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
);
assert!(
root.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists()
);
assert!(
root.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
.exists()
);
assert!(root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists());
assert!(root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
.exists());
let _ = fs::remove_dir_all(&root);
}
+34 -7
View File
@@ -11,9 +11,11 @@ mod hermes;
mod hermes_client;
mod hermes_tools;
mod kernel;
pub(crate) mod knowledge_rag;
mod local_folder_events;
mod local_folder_source;
mod local_markdown_parser;
#[allow(dead_code)]
mod local_ocr;
mod local_search_index;
mod media;
@@ -81,6 +83,21 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.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/query", post(knowledge_rag::query_rag))
.route(
"/api/knowledge-rag/open-reference",
post(knowledge_rag::open_reference),
)
.route(
"/api/knowledge-rag/delete-source",
post(knowledge_rag::delete_source),
)
.route(
"/api/knowledge-rag/prune-registry",
post(knowledge_rag::prune_registry),
)
.route(
"/mindmap/{doc_id}/{mindmap_id}",
get(mindmap_shell::mindmap_object_shell),
@@ -558,14 +575,24 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/local-folder/ocr/jobs",
get(local_ocr::list_jobs)
.post(local_ocr::create_job)
.delete(local_ocr::delete_job),
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/status",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/read",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/insert",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route(
"/api/local-folder/ocr/delete",
any(knowledge_rag::retired_local_ocr_endpoint),
)
.route("/api/local-folder/ocr/status", get(local_ocr::status))
.route("/api/local-folder/ocr/read", get(local_ocr::read))
.route("/api/local-folder/ocr/insert", post(local_ocr::insert))
.route("/api/local-folder/ocr/delete", post(local_ocr::delete_job))
.route(
"/api/local-folder/workspaces/default",
post(local_folder_source::create_default_local_workspace),
+3 -1
View File
@@ -669,7 +669,9 @@ pub(crate) fn collect_filetree_render_rows(
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| *value == "indexed" || *value == "failed")
.filter(|value| {
*value == "indexed" || *value == "indexing" || *value == "failed"
})
.map(ToOwned::to_owned),
selected,
})
+39 -47
View File
@@ -188,8 +188,7 @@ pub fn PageLayout(
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button mnote-local-ocr-task-toggle" title="OCR 设置" aria-label="OCR 设置" data-testid="mnote-local-ocr-task-toggle" data-mnote-action="open-ocr-settings"><span class="material-symbols-outlined" data-icon="document_scanner" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
<button type="button" class="wolai-icon-button" title="索引设置" aria-label="索引设置" data-testid="mnote-local-index-settings-toggle" data-mnote-action="open-index-settings"><span class="material-symbols-outlined" data-icon="manage_search" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="资料库问答" aria-label="资料库问答" data-testid="mnote-knowledge-rag-settings-toggle" data-mnote-action="open-knowledge-rag-settings"><span class="material-symbols-outlined" data-icon="travel_explore" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
@@ -201,7 +200,6 @@ pub fn PageLayout(
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<button type="button" data-testid="mnote-floating-task-toggle" class="wolai-floating-button wolai-floating-button--tasks mnote-local-ocr-task-toggle" title="后台任务" aria-label="后台任务" data-mnote-action="toggle-ocr-tasks"><span class="material-symbols-outlined" data-icon="pending_actions" aria-hidden="true"></span><span class="mnote-local-ocr-task-badge" data-mnote-local-ocr-task-count hidden>0</span></button>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
</div>
</div>
@@ -489,61 +487,55 @@ mod tests {
}
#[test]
fn page_layout_exposes_standalone_index_and_ocr_settings() {
fn page_layout_exposes_lightrag_knowledge_rag_settings_only() {
let html = crate::ssr::render_view(leptos::view! {
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
<main>"正文"</main>
</super::PageLayout>
});
assert!(html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-index-settings""#));
assert!(html.contains(r#"data-icon="manage_search""#));
assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#));
assert!(html.contains(r#"data-testid="mnote-floating-task-toggle""#));
assert!(html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
assert!(html.contains(r#"data-icon="pending_actions""#));
assert!(!html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
assert!(!html.contains(r#"data-mnote-action="open-index-settings""#));
assert!(html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(html.contains(r#"data-icon="travel_explore""#));
assert!(crate::ssr::styles::MNOTE_CSS.contains(r#"data-icon="travel_explore""#));
assert!(!html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
assert!(!html.contains(r#"data-mnote-action="open-ocr-settings""#));
assert!(!html.contains(r#"data-testid="mnote-floating-task-toggle""#));
assert!(!html.contains(r#"data-mnote-action="toggle-ocr-tasks""#));
}
#[test]
fn sidebar_settings_runtime_keeps_index_and_ocr_out_of_page_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-index-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-local-ocr-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-range-input"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-index-status=\"' + kind"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (kind === 'indexed')"));
fn sidebar_settings_runtime_routes_index_and_ocr_to_lightrag_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/status?"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/ingest"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/prune-registry"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("use-filetree-selection"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-action=\"prune\""));
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("scheduleKnowledgeRagStatusBridge"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_submitted"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_completed"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("publicKnowledgeRagDashboardUrl"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("label.hidden = status === 'idle'"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
"renderLocalIndexRangeRows(popover, currentLocalIndexRangeValues(popover).concat([''])"
),
"新增索引范围必须保留空白输入行,不能先过滤成默认 ."
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-persisted=\"true\""),
"已保存索引范围必须锁定为不可直接修改"
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("return currentLocalIndexRangeValues(popover).map"),
"删除最后一个索引范围后保存应提交空数组,不能强制回填 ."
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("var savedIncludePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];"),
"索引面板默认不应把空配置回填成工作区根目录"
);
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains(
"var includePaths = savedIncludePaths.length ? savedIncludePaths : indexedPaths;"
),
"无用户配置但已有索引缓存时仍应展示旧索引范围,便于删除"
);
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS
.contains("data-local-ocr-settings-action=\"run-active\""));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"toggle-ocr-tasks\"]"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("detail: { action: 'tasks' }"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("useKnowledgeRagFileTreeSelection"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("reindex-source"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("deleteKnowledgeRagSource"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pruneKnowledgeRagRegistry"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("setKnowledgeRagSourceFilter"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:knowledge-rag-source-updated"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openPageIndexSettingsPopover();"));
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOcrSettingsPopover();"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用"));
assert!(
+168
View File
@@ -184,6 +184,7 @@ a:hover {
.material-symbols-outlined[data-icon="mode_comment"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 5h14v10H9l-4 4V5Z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="person_add"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='9' cy='8' r='3.2' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M3.5 20a5.5 5.5 0 0 1 11 0M18 8v6M15 11h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="slideshow"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 5h16v12H4z' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'/%3E%3Cpath d='m10 9 5 2-5 2zM9 21h6' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="travel_explore"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='11' cy='11' r='7' fill='none' stroke='black' stroke-width='2'/%3E%3Cpath d='M4 11h14M11 4a10 10 0 0 1 0 14M11 4a10 10 0 0 0 0 14M16.5 16.5 21 21' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M7.5 8.5h2.5l1 1.5 2.5-.5 1 2-2 1 .5 2.5h-3l-1.5-2-2 .5' fill='none' stroke='black' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="document_scanner"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M7 3H5a2 2 0 0 0-2 2v2M17 3h2a2 2 0 0 1 2 2v2M7 21H5a2 2 0 0 1-2-2v-2M17 21h2a2 2 0 0 0 2-2v-2M7 8h10M7 12h10M7 16h7' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="article"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 4h12v16H6zM9 8h6M9 12h6M9 16h4' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
.material-symbols-outlined[data-icon="sync"] { --mnote-icon-mask: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20 6v5h-5M4 18v-5h5M7.5 9A6 6 0 0 1 18 6M16.5 15A6 6 0 0 1 6 18' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); }
@@ -1747,6 +1748,7 @@ body {
}
.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after,
.sidebar-tree .tree-row[data-index-status="indexing"] .tree-kind-badge::after,
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
position: absolute;
left: -1px;
@@ -1767,6 +1769,11 @@ body {
background: #38B86E;
}
.sidebar-tree .tree-row[data-index-status="indexing"] .tree-kind-badge::after {
content: "";
background: #D99A2B;
}
.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after {
content: "x";
background: #D94841;
@@ -3867,6 +3874,167 @@ body {
width: min(386px, calc(100vw - 24px));
}
.mnote-knowledge-rag-settings-panel {
width: min(440px, calc(100vw - 24px));
max-height: calc(100vh - 72px);
overflow-y: auto;
}
.mnote-knowledge-rag-meta {
display: flex;
flex-direction: column;
gap: 5px;
}
.mnote-knowledge-rag-meta div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
color: #8B8782;
font-size: 12px;
line-height: 18px;
}
.mnote-knowledge-rag-meta code {
min-width: 0;
overflow: hidden;
color: #1B1C1C;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-knowledge-rag-source-list,
.mnote-knowledge-rag-source-inputs {
display: flex;
flex-direction: column;
gap: 6px;
}
.mnote-knowledge-rag-source-tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mnote-knowledge-rag-control-group .wolai-page-settings-index-actions {
justify-content: flex-start;
}
.mnote-knowledge-rag-source-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mnote-knowledge-rag-source-filters button {
border: 1px solid #E3E1DE;
border-radius: 6px;
background: #FFFFFF;
color: #5F5A54;
font-size: 11px;
line-height: 18px;
padding: 2px 7px;
white-space: nowrap;
}
.mnote-knowledge-rag-source-filters button[data-active="true"] {
border-color: #B8DDBB;
background: #EAF7EA;
color: #166534;
}
.mnote-knowledge-rag-source-filters button span {
color: #8B8782;
font-variant-numeric: tabular-nums;
}
.mnote-knowledge-rag-source-row,
.mnote-knowledge-rag-source-input-row {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.mnote-knowledge-rag-source-input-row {
grid-template-columns: minmax(0, 1fr) 56px 28px;
}
.mnote-knowledge-rag-input-status {
color: #8B8782;
font-size: 11px;
line-height: 16px;
text-align: right;
white-space: nowrap;
}
.mnote-knowledge-rag-input-status[data-status="done"] {
color: #15803D;
}
.mnote-knowledge-rag-input-status[data-status="running"],
.mnote-knowledge-rag-input-status[data-status="retry"] {
color: #B45309;
}
.mnote-knowledge-rag-input-status[data-status="failed"] {
color: #B91C1C;
}
.mnote-knowledge-rag-source-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.mnote-knowledge-rag-source-main strong {
overflow: hidden;
color: #1B1C1C;
font-size: 12px;
font-weight: 600;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-knowledge-rag-source-main span {
overflow: hidden;
color: #8B8782;
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.mnote-knowledge-rag-source-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.mnote-knowledge-rag-source-actions button {
min-width: 0;
border: 1px solid #E3E1DE;
border-radius: 6px;
background: #FFFFFF;
color: #5F5A54;
font-size: 11px;
line-height: 18px;
padding: 2px 6px;
white-space: nowrap;
}
.mnote-knowledge-rag-source-actions button:hover:not(:disabled) {
background: #F4F3F3;
color: #1B1C1C;
}
.mnote-knowledge-rag-source-actions button:disabled {
opacity: 0.45;
}
.wolai-page-settings-tabs {
display: flex;
gap: 6px;