feat(rag): harden post-LightRAG runtime

Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists.

Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
This commit is contained in:
lix-2026
2026-06-07 10:35:21 +08:00
parent 22a92edcda
commit 9551d4c1dc
59 changed files with 4053 additions and 5249 deletions
@@ -125,6 +125,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
return `${String(session?.rootUri || '').trim()}#${scope}`;
};
const localFolderEventBusChannelKey = (session) => `${String(session?.rootUri || '').trim()}#event-bus`;
const localMarkdownRelativePathFromDocumentId = (documentId) => {
const value = String(documentId || '').trim();
if (!value.startsWith('local-md:')) return '';
@@ -136,6 +138,25 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
);
const localFolderEventItems = (payload) => {
if (!payload || typeof payload !== 'object') return [];
const changedPaths = Array.isArray(payload.changedPaths)
? payload.changedPaths
: (Array.isArray(payload.changed_paths) ? payload.changed_paths : []);
if (changedPaths.length > 0) {
return changedPaths.map((item) => {
if (typeof item === 'string') return { relativePath: item };
if (!item || typeof item !== 'object') return null;
return {
relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(),
documentId: String(item.documentId || item.document_id || '').trim(),
eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(),
};
}).filter(Boolean);
}
return [payload];
};
const sessionBufferStateUrl = (session) => {
if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;
const url = new URL('/api/documents/buffer-state', window.location.origin);
@@ -206,10 +227,14 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (!channel) return;
channel.sessions.delete(session.key);
if (channel.sessions.size === 0) {
try {
channel.eventSource.close();
} catch (_) {
// noop
if (typeof channel.unsubscribe === 'function') {
channel.unsubscribe();
} else if (channel.eventSource && typeof channel.eventSource.close === 'function') {
try {
channel.eventSource.close();
} catch (_) {
// noop
}
}
localFolderEventRegistry.delete(channel.key);
}
@@ -385,7 +410,53 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
}, source || 'mnote-web-document-session-meta');
};
const pageBodySourceMeta = (sourceKind, pageBody) => {
const pageBodySource = pageBodyTiptapDocumentSource(pageBody || {}, '');
const projectionSource = String(pageBody?.projectionSource || pageBody?.projection_source || '').trim();
const blockProjectionVersion = String(pageBody?.blockProjectionVersion || pageBody?.block_projection_version || '').trim();
const localCompatFallback = sourceKind === 'local_folder' && pageBodySource === 'compat.legacy_content';
return {
pageBodySource,
projectionSource,
blockProjectionVersion,
localCompatFallback,
hardGuard: localCompatFallback
? 'local_compat_fallback'
: (sourceKind === 'local_folder' ? 'local_ok' : 'compat_allowed'),
};
};
const applyPageBodySourceMetaToSession = (session, pageBody) => {
const meta = pageBodySourceMeta(session.sourceKind, pageBody || {});
session.pageBodySource = meta.pageBodySource;
session.projectionSource = meta.projectionSource;
session.blockProjectionVersion = meta.blockProjectionVersion;
session.pageBodyLocalCompatFallback = meta.localCompatFallback;
session.pageBodyHardGuard = meta.hardGuard;
};
const syncPageBodySourceDiagnosticsToViews = (session) => {
sessionViews(session).forEach((view) => {
const root = view?.runtimeDescriptor?.root;
if (!(root instanceof HTMLElement)) return;
root.setAttribute('data-mnote-page-body-source', session.pageBodySource || '');
root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false');
root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard || '');
if (session.projectionSource) {
root.setAttribute('data-mnote-projection-source', session.projectionSource);
} else {
root.removeAttribute('data-mnote-projection-source');
}
if (session.blockProjectionVersion) {
root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
} else {
root.removeAttribute('data-mnote-block-projection-version');
}
});
};
const syncSessionMetaToViews = (session) => {
syncPageBodySourceDiagnosticsToViews(session);
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionMetaToView(session, view);
});
@@ -543,6 +614,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
applyPageBodySourceMetaToSession(session, nextBody);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1168,15 +1240,22 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextPageBodyMeta = pageBodySourceMeta(session.sourceKind, nextBody);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
if (!contentChanged) {
applyPageBodySourceMetaToSession(session, nextBody);
syncPageBodySourceDiagnosticsToViews(session);
return;
}
}
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
applyPageBodySourceMetaToSession(session, nextBody);
syncPageBodySourceDiagnosticsToViews(session);
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
@@ -1187,6 +1266,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.pageBodySource = nextPageBodyMeta.pageBodySource;
session.projectionSource = nextPageBodyMeta.projectionSource;
session.blockProjectionVersion = nextPageBodyMeta.blockProjectionVersion;
session.pageBodyLocalCompatFallback = nextPageBodyMeta.localCompatFallback;
session.pageBodyHardGuard = nextPageBodyMeta.hardGuard;
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1284,10 +1368,94 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
};
const handleLocalFolderEventPayloadForSessions = (channel, payload) => {
if (!payload) return;
localFolderEventItems(payload).forEach((item) => {
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof item.documentId === 'string' ? item.documentId.trim() : '';
const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
const eventKind = String(item.eventKind || '');
const relativeTarget = sessionRelativePath(targetSession);
const targetsCurrentDocument = Boolean(
(documentId && documentId === targetSession.documentId)
|| (!documentId && relativePath && relativeTarget && relativePath === relativeTarget)
);
if (!targetsCurrentDocument) return;
if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
if (session.sourceKind !== 'local_folder' || !session.rootUri) {
return;
}
const eventBus = window.__mnoteLocalFolderEventBus;
if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') {
const channelKey = localFolderEventBusChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
const rootUri = String(session.rootUri || '').trim();
const workspaceId = String(session.workspaceId || '').trim();
const handler = (event) => {
const detail = event?.detail || {};
const eventRootUri = String(detail.rootUri || detail.payload?.rootUri || '').trim();
if (eventRootUri && eventRootUri !== rootUri) return;
handleLocalFolderEventPayloadForSessions(channel, detail.payload || detail);
};
const handle = eventBus.startLocalFolderWatcher({
rootUri,
workspaceId,
bootstrap: {
schema: 'mnote.document_session.local_folder_event_bus.v1',
transport: 'local-folder-events',
workspaceId,
},
});
channel = {
key: channelKey,
rootUri,
documentId: '',
resourcePath: '',
eventSource: handle,
sessions: new Map(),
unsubscribe: () => window.removeEventListener('mnote:local-folder:document-changed', handler),
};
window.addEventListener('mnote:local-folder:document-changed', handler);
localFolderEventRegistry.set(channelKey, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
return;
}
if (typeof window.EventSource !== 'function') return;
const channelKey = localFolderEventChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
@@ -1308,42 +1476,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
sessions: new Map(),
};
eventSource.addEventListener('change', (event) => {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
if (!documentId) return;
const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument) return;
if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetsCurrentDocument && targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
handleLocalFolderEventPayloadForSessions(channel, parseLocalFolderEventPayload(event));
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
@@ -1609,7 +1742,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
const pageBodyMeta = pageBodySourceMeta(sourceKind, pageBody);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
@@ -1620,9 +1753,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
pageBodySource,
projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(),
blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(),
pageBodySource: pageBodyMeta.pageBodySource,
projectionSource: pageBodyMeta.projectionSource,
blockProjectionVersion: pageBodyMeta.blockProjectionVersion,
pageBodyLocalCompatFallback: pageBodyMeta.localCompatFallback,
pageBodyHardGuard: pageBodyMeta.hardGuard,
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),