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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user