diff --git a/index.js b/index.js index 5834ba7..64de6eb 100644 --- a/index.js +++ b/index.js @@ -1041,6 +1041,7 @@ const bmeIndexedDbSnapshotCacheByChatId = new Map(); const bmeIndexedDbLoadInFlightByChatId = new Map(); const bmeIndexedDbWriteInFlightByChatId = new Map(); const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map(); +const bmeIndexedDbLocalStoreMigrationInFlightByChatId = new Map(); const bmeIndexedDbLatestQueuedRevisionByChatId = new Map(); const bmeChatStateSnapshotCacheByChatId = new Map(); const bmeChatStateLoadInFlightByChatId = new Map(); @@ -3931,6 +3932,768 @@ function getMessageHideSettings(settings = null) { }; } +function getHideRuntimeAdapters() { + return { + $, + clearTimeout, + getContext, + setTimeout, + }; +} + +async function applyMessageHideNow(reason = "manual-apply") { + try { + const result = await applyHideSettings( + getMessageHideSettings(), + getHideRuntimeAdapters(), + ); + debugLog("[ST-BME] 已应用旧楼层隐藏:", reason, result); + return result; + } catch (error) { + console.warn("[ST-BME] 应用旧楼层隐藏失败:", reason, error); + return { + active: false, + error: error instanceof Error ? error.message : String(error || "未知错误"), + }; + } +} + +function scheduleMessageHideApply(reason = "scheduled", delayMs = 120) { + try { + scheduleHideSettingsApply( + getMessageHideSettings(), + getHideRuntimeAdapters(), + delayMs, + ); + } catch (error) { + console.warn("[ST-BME] 调度旧楼层隐藏失败:", reason, error); + } +} + +async function runIncrementalMessageHide(reason = "incremental") { + try { + const result = await runIncrementalHideCheck( + getMessageHideSettings(), + getHideRuntimeAdapters(), + ); + if (result?.active) { + debugLog("[ST-BME] 已增量更新旧楼层隐藏:", reason, result); + } + return result; + } catch (error) { + console.warn("[ST-BME] 增量更新旧楼层隐藏失败:", reason, error); + return { + active: false, + error: error instanceof Error ? error.message : String(error || "未知错误"), + }; + } +} + +function clearMessageHideState(reason = "reset") { + try { + resetHideState(getHideRuntimeAdapters()); + debugLog("[ST-BME] 已重置旧楼层隐藏状态:", reason); + } catch (error) { + console.warn("[ST-BME] 重置旧楼层隐藏状态失败:", reason, error); + } +} + +async function clearAllHiddenMessages(reason = "manual-clear") { + try { + const result = await unhideAll(getHideRuntimeAdapters()); + debugLog("[ST-BME] 已取消全部旧楼层隐藏:", reason, result); + return result; + } catch (error) { + console.warn("[ST-BME] 取消全部旧楼层隐藏失败:", reason, error); + return { + active: false, + error: error instanceof Error ? error.message : String(error || "未知错误"), + }; + } +} + +function initializeHostCapabilityBridge(options = {}) { + try { + initializeHostAdapter({ + getContext, + ...options, + }); + } catch (error) { + console.warn("[ST-BME] 宿主桥接初始化失败:", error); + } + + return getHostCapabilityStatus(); +} + +function buildHostCapabilityErrorStatus(error) { + const snapshot = { + available: false, + mode: "error", + fallbackReason: + error instanceof Error ? error.message : String(error || "未知错误"), + versionHints: { + stateSemantics: HOST_ADAPTER_STATE_SEMANTICS, + refreshMode: "manual-rebuild", + }, + stateSemantics: HOST_ADAPTER_STATE_SEMANTICS, + refreshMode: "manual-rebuild", + snapshotRevision: -1, + snapshotCreatedAt: "", + }; + recordHostCapabilitySnapshot(snapshot); + return snapshot; +} + +export function getHostCapabilityStatus(options = {}) { + const normalizedOptions = + options && typeof options === "object" ? { ...options } : {}; + const shouldRefresh = normalizedOptions.refresh === true; + + delete normalizedOptions.refresh; + + try { + const snapshot = shouldRefresh + ? refreshHostCapabilitySnapshot(normalizedOptions) + : getHostCapabilitySnapshot(); + recordHostCapabilitySnapshot(snapshot); + return snapshot; + } catch (error) { + console.warn("[ST-BME] 读取宿主桥接状态失败:", error); + return buildHostCapabilityErrorStatus(error); + } +} + +export function refreshHostCapabilityStatus(options = {}) { + return getHostCapabilityStatus({ + ...options, + refresh: true, + }); +} + +export function getHostCapability(name, options = {}) { + const normalizedName = String(name || "").trim(); + if (!normalizedName) return null; + + try { + return readHostCapability(normalizedName, options) || null; + } catch (error) { + console.warn("[ST-BME] 读取宿主桥接能力失败:", error); + return getHostCapabilityStatus(options)?.[normalizedName] || null; + } +} + +export function getPanelRuntimeDebugSnapshot(options = {}) { + const shouldRefreshHost = options?.refreshHost === true; + const hostCapabilities = shouldRefreshHost + ? refreshHostCapabilityStatus() + : getHostCapabilityStatus(); + + return { + hostCapabilities, + messageHiding: getHideStateSnapshot(), + runtimeDebug: readRuntimeDebugSnapshot(), + }; +} + +function getSchema() { + const settings = getSettings(); + const schema = settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA; + const validation = validateSchema(schema); + if (!validation.valid) { + console.warn("[ST-BME] Schema 非法,回退到默认 Schema:", validation.errors); + return DEFAULT_NODE_SCHEMA; + } + return schema; +} + +function getConfiguredTimeoutMs(settings = getSettings()) { + return typeof resolveConfiguredTimeoutMs === "function" + ? resolveConfiguredTimeoutMs(settings, LOCAL_VECTOR_TIMEOUT_MS) + : (() => { + const timeoutMs = Number(settings?.timeoutMs); + return Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : LOCAL_VECTOR_TIMEOUT_MS; + })(); +} + +function getPlannerRecallTimeoutMs() { + return getConfiguredTimeoutMs(getSettings()); +} + +function getEmbeddingConfig(mode = null) { + const settings = getSettings(); + return getVectorConfigFromSettings( + mode ? { ...settings, embeddingTransportMode: mode } : settings, + ); +} + +async function doesIndexedDbChatStoreExist(chatId = "") { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return false; + + const DexieCtor = globalThis.Dexie || (await ensureDexieLoaded()); + if (typeof DexieCtor?.exists === "function") { + return await DexieCtor.exists(buildBmeDbName(normalizedChatId)); + } + + if (typeof DexieCtor?.getDatabaseNames === "function") { + const names = await DexieCtor.getDatabaseNames(); + return Array.isArray(names) + ? names.includes(buildBmeDbName(normalizedChatId)) + : false; + } + + return false; +} + +async function exportIndexedDbSnapshotForChat(chatId = "") { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return null; + } + + if (!(await doesIndexedDbChatStoreExist(normalizedChatId))) { + return null; + } + + const DexieCtor = globalThis.Dexie || (await ensureDexieLoaded()); + const db = new BmeDatabase(normalizedChatId, { + dexieClass: DexieCtor, + }); + + try { + await db.open(); + return await db.exportSnapshot(); + } finally { + await db.close(); + } +} + +function buildRecoveredSnapshotForChatIdentity( + graph, + targetChatId, + { + revision = 0, + integrity = "", + source = "identity-recovery", + legacyChatId = "", + } = {}, +) { + const normalizedTargetChatId = normalizeChatIdCandidate(targetChatId); + const normalizedIntegrity = normalizeChatIdCandidate(integrity); + const normalizedLegacyChatId = normalizeChatIdCandidate(legacyChatId); + const normalizedGraph = cloneGraphForPersistence(graph, normalizedTargetChatId); + const effectiveRevision = Math.max( + 1, + normalizeIndexedDbRevision( + revision || graphPersistenceState.revision || getGraphPersistedRevision(graph), + ), + ); + + stampGraphPersistenceMeta(normalizedGraph, { + revision: effectiveRevision, + reason: source, + chatId: normalizedTargetChatId, + integrity: normalizedIntegrity, + }); + + return buildSnapshotFromGraph(normalizedGraph, { + chatId: normalizedTargetChatId, + revision: effectiveRevision, + lastModified: Date.now(), + meta: { + storagePrimary: "indexeddb", + lastMutationReason: String(source || "identity-recovery"), + integrity: normalizedIntegrity, + migratedFromChatId: normalizedLegacyChatId, + identityMigrationSource: String(source || "identity-recovery"), + }, + }); +} + +async function importRecoveredSnapshotToIndexedDb( + targetDb, + targetChatId, + graph, + { revision = 0, integrity = "", source = "identity-recovery", legacyChatId = "" } = {}, +) { + const snapshot = buildRecoveredSnapshotForChatIdentity(graph, targetChatId, { + revision, + integrity, + source, + legacyChatId, + }); + const importResult = await targetDb.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: snapshot.meta.revision, + markSyncDirty: true, + }); + snapshot.meta.revision = normalizeIndexedDbRevision( + importResult?.revision, + snapshot.meta.revision, + ); + return snapshot; +} + +function doesChatIdMatchResolvedGraphIdentity( + candidateChatId, + identity = resolveCurrentChatIdentity(getContext()), +) { + const normalizedCandidate = normalizeChatIdCandidate(candidateChatId); + if (!normalizedCandidate || !identity || typeof identity !== "object") { + return false; + } + + const knownChatIds = new Set(); + const addKnownChatId = (value) => { + const normalized = normalizeChatIdCandidate(value); + if (normalized) { + knownChatIds.add(normalized); + } + }; + + addKnownChatId(identity.chatId); + addKnownChatId(identity.hostChatId); + addKnownChatId(identity.integrity); + + for (const aliasCandidate of getGraphIdentityAliasCandidates({ + integrity: identity.integrity, + hostChatId: identity.hostChatId, + persistenceChatId: identity.chatId, + })) { + addKnownChatId(aliasCandidate); + } + + return knownChatIds.has(normalizedCandidate); +} + +function areChatIdsEquivalentForResolvedIdentity( + candidateChatId, + referenceChatId, + identity = resolveCurrentChatIdentity(getContext()), +) { + const normalizedCandidate = normalizeChatIdCandidate(candidateChatId); + const normalizedReference = normalizeChatIdCandidate(referenceChatId); + if (!normalizedCandidate || !normalizedReference) { + return normalizedCandidate === normalizedReference; + } + if (normalizedCandidate === normalizedReference) { + return true; + } + return ( + doesChatIdMatchResolvedGraphIdentity(normalizedCandidate, identity) && + doesChatIdMatchResolvedGraphIdentity(normalizedReference, identity) + ); +} + +function getIndexedDbSnapshotHistoryState(snapshot = null) { + const snapshotState = + snapshot?.meta?.runtimeHistoryState && + typeof snapshot.meta.runtimeHistoryState === "object" && + !Array.isArray(snapshot.meta.runtimeHistoryState) + ? snapshot.meta.runtimeHistoryState + : null; + + return { + lastProcessedAssistantFloor: Number.isFinite( + Number(snapshot?.state?.lastProcessedFloor), + ) + ? Number(snapshot.state.lastProcessedFloor) + : Number.isFinite(Number(snapshotState?.lastProcessedAssistantFloor)) + ? Number(snapshotState.lastProcessedAssistantFloor) + : -1, + extractionCount: Number.isFinite(Number(snapshot?.state?.extractionCount)) + ? Number(snapshot.state.extractionCount) + : Number.isFinite(Number(snapshotState?.extractionCount)) + ? Number(snapshotState.extractionCount) + : 0, + }; +} + +function detectStaleIndexedDbSnapshotAgainstRuntime( + chatId, + snapshot, + { identity = resolveCurrentChatIdentity(getContext()) } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !isIndexedDbSnapshotMeaningful(snapshot) || !currentGraph) { + return { + stale: false, + reason: "", + }; + } + + const runtimeChatId = normalizeChatIdCandidate( + currentGraph?.historyState?.chatId || + getGraphPersistenceMeta(currentGraph)?.chatId || + graphPersistenceState.chatId, + ); + if ( + !runtimeChatId || + !areChatIdsEquivalentForResolvedIdentity( + normalizedChatId, + runtimeChatId, + identity, + ) + ) { + return { + stale: false, + reason: "", + }; + } + + const runtimeRevision = Math.max( + normalizeIndexedDbRevision(graphPersistenceState.revision), + normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision), + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + getGraphPersistedRevision(currentGraph), + ); + const snapshotRevision = normalizeIndexedDbRevision(snapshot?.meta?.revision); + if (runtimeRevision > snapshotRevision) { + return { + stale: true, + reason: "runtime-revision-newer", + runtimeRevision, + snapshotRevision, + }; + } + + if (runtimeRevision < snapshotRevision) { + return { + stale: false, + reason: "", + runtimeRevision, + snapshotRevision, + }; + } + + const runtimeLastProcessedFloor = Number.isFinite( + Number(currentGraph?.historyState?.lastProcessedAssistantFloor), + ) + ? Number(currentGraph.historyState.lastProcessedAssistantFloor) + : Number.isFinite(Number(currentGraph?.lastProcessedSeq)) + ? Number(currentGraph.lastProcessedSeq) + : -1; + const runtimeExtractionCount = Number.isFinite( + Number(currentGraph?.historyState?.extractionCount), + ) + ? Number(currentGraph.historyState.extractionCount) + : Number.isFinite(Number(extractionCount)) + ? Number(extractionCount) + : 0; + const snapshotHistoryState = getIndexedDbSnapshotHistoryState(snapshot); + + if (runtimeLastProcessedFloor > snapshotHistoryState.lastProcessedAssistantFloor) { + return { + stale: true, + reason: "runtime-last-processed-newer", + runtimeRevision, + snapshotRevision, + runtimeLastProcessedFloor, + snapshotLastProcessedFloor: snapshotHistoryState.lastProcessedAssistantFloor, + runtimeExtractionCount, + snapshotExtractionCount: snapshotHistoryState.extractionCount, + }; + } + + if (runtimeExtractionCount > snapshotHistoryState.extractionCount) { + return { + stale: true, + reason: "runtime-extraction-count-newer", + runtimeRevision, + snapshotRevision, + runtimeLastProcessedFloor, + snapshotLastProcessedFloor: snapshotHistoryState.lastProcessedAssistantFloor, + runtimeExtractionCount, + snapshotExtractionCount: snapshotHistoryState.extractionCount, + }; + } + + return { + stale: false, + reason: "", + runtimeRevision, + snapshotRevision, + runtimeLastProcessedFloor, + snapshotLastProcessedFloor: snapshotHistoryState.lastProcessedAssistantFloor, + runtimeExtractionCount, + snapshotExtractionCount: snapshotHistoryState.extractionCount, + }; +} + +function resolveCompatibleGraphShadowSnapshot( + identity = resolveCurrentChatIdentity(getContext()), +) { + if (!identity || typeof identity !== "object") { + return null; + } + + const directSnapshot = readGraphShadowSnapshot(identity.chatId); + if (directSnapshot) { + return directSnapshot; + } + + const seenChatIds = new Set( + [identity.chatId].map((value) => normalizeChatIdCandidate(value)).filter(Boolean), + ); + const readByChatId = (value) => { + const normalized = normalizeChatIdCandidate(value); + if (!normalized || seenChatIds.has(normalized)) { + return null; + } + seenChatIds.add(normalized); + return readGraphShadowSnapshot(normalized); + }; + + const hostSnapshot = readByChatId(identity.hostChatId); + if (hostSnapshot) { + return hostSnapshot; + } + + for (const aliasCandidate of getGraphIdentityAliasCandidates({ + integrity: identity.integrity, + hostChatId: identity.hostChatId, + persistenceChatId: identity.chatId, + })) { + const aliasSnapshot = readByChatId(aliasCandidate); + if (aliasSnapshot) { + return aliasSnapshot; + } + } + + return findGraphShadowSnapshotByIntegrity(identity.integrity, { + excludeChatIds: Array.from(seenChatIds), + }); +} + +function createShadowComparisonGraph({ + chatId = "", + revision = 0, + integrity = "", +} = {}) { + const graph = createEmptyGraph(); + stampGraphPersistenceMeta(graph, { + revision: Math.max(0, normalizeIndexedDbRevision(revision)), + chatId: String(chatId || ""), + integrity: String(integrity || ""), + reason: "shadow-compare-reference", + }); + return graph; +} + +function applyShadowSnapshotToRuntime( + chatId, + shadowSnapshot, + { + source = "shadow-restore", + attemptIndex = 0, + promoteToIndexedDb = true, + } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate( + chatId || shadowSnapshot?.chatId, + ); + if (!normalizedChatId || !shadowSnapshot?.serializedGraph) { + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "shadow-invalid", + chatId: normalizedChatId || "", + attemptIndex, + }; + } + + let shadowGraph = null; + try { + shadowGraph = cloneGraphForPersistence( + normalizeGraphRuntimeState( + deserializeGraph(shadowSnapshot.serializedGraph), + normalizedChatId, + ), + normalizedChatId, + ); + } catch (error) { + console.warn("[ST-BME] shadow snapshot 恢复失败:", error); + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "shadow-deserialize-failed", + detail: error?.message || String(error), + chatId: normalizedChatId, + attemptIndex, + }; + } + + const shadowRevision = Math.max( + 1, + normalizeIndexedDbRevision(shadowSnapshot.revision), + ); + stampGraphPersistenceMeta(shadowGraph, { + revision: shadowRevision, + reason: `shadow:${String(source || "shadow-restore")}`, + chatId: normalizedChatId, + integrity: + String(shadowSnapshot.integrity || "").trim() || + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + }); + + currentGraph = shadowGraph; + extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) + ? currentGraph.historyState.extractionCount + : 0; + lastExtractedItems = []; + const restoredRecallUi = restoreRecallUiStateFromPersistence( + getContext()?.chat, + ); + runtimeStatus = createUiStatus( + "图谱临时恢复", + "已从本次会话临时快照恢复最近图谱,正在补写 IndexedDB", + "warning", + ); + lastExtractionStatus = createUiStatus( + "待命", + "已从会话快照恢复最近图谱,等待下一次提取", + "idle", + ); + lastVectorStatus = createUiStatus( + "待命", + currentGraph.vectorIndexState?.lastWarning || + "已从会话快照恢复最近图谱,等待下一次向量任务", + "idle", + ); + lastRecallStatus = createUiStatus( + "待命", + restoredRecallUi.restored + ? "已从持久化召回记录恢复显示,并已恢复最近图谱" + : "已从会话快照恢复最近图谱,等待下一次召回", + "idle", + ); + + applyGraphLoadState(GRAPH_LOAD_STATES.SHADOW_RESTORED, { + chatId: normalizedChatId, + reason: `shadow:${String(source || "shadow-restore")}`, + attemptIndex, + revision: shadowRevision, + lastPersistedRevision: Math.max( + normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision), + shadowRevision, + ), + queuedPersistRevision: Math.max( + normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision), + shadowRevision, + ), + queuedPersistChatId: normalizedChatId, + pendingPersist: Boolean(promoteToIndexedDb), + shadowSnapshotUsed: true, + shadowSnapshotRevision: shadowRevision, + shadowSnapshotUpdatedAt: String(shadowSnapshot.updatedAt || ""), + shadowSnapshotReason: String( + shadowSnapshot.debugReason || shadowSnapshot.reason || source || "", + ), + dbReady: true, + writesBlocked: false, + }); + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: true, + indexedDbLastError: "", + metadataIntegrity: + getChatMetadataIntegrity(getContext()) || + graphPersistenceState.metadataIntegrity, + dualWriteLastResult: { + action: "load", + source: `${String(source || "shadow-restore")}:shadow`, + success: true, + provisional: true, + revision: shadowRevision, + resultCode: "graph.load.shadow-restored", + reason: `shadow:${String(source || "shadow-restore")}`, + at: Date.now(), + }, + }); + rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); + + if (promoteToIndexedDb) { + queueGraphPersistToIndexedDb(normalizedChatId, currentGraph, { + revision: shadowRevision, + reason: `shadow-restore-promote:${String(source || "shadow-restore")}`, + }); + } + + refreshPanelLiveState(); + schedulePersistedRecallMessageUiRefresh(30); + return { + success: true, + loaded: true, + loadState: GRAPH_LOAD_STATES.SHADOW_RESTORED, + reason: `shadow:${String(source || "shadow-restore")}`, + chatId: normalizedChatId, + attemptIndex, + revision: shadowRevision, + shadowRestored: true, + }; +} + +async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { + const action = String(syncPayload?.action || "") + .trim() + .toLowerCase(); + if (action !== "download" && action !== "merge") { + return { + refreshed: false, + reason: "action-not-supported", + action, + }; + } + + const syncedChatId = normalizeChatIdCandidate(syncPayload?.chatId); + const activeIdentity = resolveCurrentChatIdentity(getContext()); + const activeChatId = normalizeChatIdCandidate(activeIdentity.chatId); + const targetChatId = + activeChatId && + syncedChatId && + doesChatIdMatchResolvedGraphIdentity(syncedChatId, activeIdentity) + ? activeChatId + : syncedChatId || activeChatId; + + if (!targetChatId) { + return { + refreshed: false, + reason: "missing-chat-id", + action, + }; + } + + if (activeChatId && targetChatId !== activeChatId) { + return { + refreshed: false, + reason: "chat-switched", + action, + chatId: targetChatId, + activeChatId, + }; + } + + const loadResult = await loadGraphFromIndexedDb(targetChatId, { + source: `sync-post-refresh:${action}`, + allowOverride: true, + applyEmptyState: true, + }); + + return { + refreshed: Boolean(loadResult?.loaded || loadResult?.emptyConfirmed), + action, + chatId: targetChatId, + ...loadResult, + }; +} + function buildBmeSyncRuntimeOptions(extra = {}) { const normalizedExtra = extra && typeof extra === "object" && !Array.isArray(extra) ? extra : {}; @@ -3974,6 +4737,137 @@ function buildBmeSyncRuntimeOptions(extra = {}) { }; } +async function syncIndexedDbMetaToPersistenceState( + chatId, + { syncState = "idle", lastSyncError = "" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return null; + } + + try { + const manager = ensureBmeChatManager(); + if (!manager) { + return null; + } + + const db = await manager.getCurrentDb(normalizedChatId); + if (!db) { + return null; + } + + const storePresentation = resolveDbGraphStorePresentation(db); + const [ + revision, + syncDirty, + syncDirtyReason, + lastSyncUploadedAt, + lastSyncDownloadedAt, + lastSyncedRevision, + lastBackupUploadedAt, + lastBackupRestoredAt, + lastBackupRollbackAt, + lastBackupFilename, + ] = await Promise.all([ + typeof db.getRevision === "function" ? db.getRevision() : 0, + typeof db.getMeta === "function" ? db.getMeta("syncDirty", false) : false, + typeof db.getMeta === "function" ? db.getMeta("syncDirtyReason", "") : "", + typeof db.getMeta === "function" + ? db.getMeta("lastSyncUploadedAt", 0) + : 0, + typeof db.getMeta === "function" + ? db.getMeta("lastSyncDownloadedAt", 0) + : 0, + typeof db.getMeta === "function" ? db.getMeta("lastSyncedRevision", 0) : 0, + typeof db.getMeta === "function" + ? db.getMeta("lastBackupUploadedAt", 0) + : 0, + typeof db.getMeta === "function" + ? db.getMeta("lastBackupRestoredAt", 0) + : 0, + typeof db.getMeta === "function" + ? db.getMeta("lastBackupRollbackAt", 0) + : 0, + typeof db.getMeta === "function" ? db.getMeta("lastBackupFilename", "") : "", + ]); + + const patch = { + storagePrimary: storePresentation.storagePrimary, + storageMode: storePresentation.storageMode, + indexedDbRevision: normalizeIndexedDbRevision(revision), + syncState: normalizeGraphSyncState(syncState), + syncDirty: Boolean(syncDirty), + syncDirtyReason: String(syncDirtyReason || ""), + lastSyncUploadedAt: Number(lastSyncUploadedAt) || 0, + lastSyncDownloadedAt: Number(lastSyncDownloadedAt) || 0, + lastSyncedRevision: Number(lastSyncedRevision) || 0, + lastBackupUploadedAt: Number(lastBackupUploadedAt) || 0, + lastBackupRestoredAt: Number(lastBackupRestoredAt) || 0, + lastBackupRollbackAt: Number(lastBackupRollbackAt) || 0, + lastBackupFilename: String(lastBackupFilename || ""), + lastSyncError: String(lastSyncError || ""), + }; + + updateGraphPersistenceState(patch); + return patch; + } catch (error) { + console.warn("[ST-BME] 读取本地图库同步元数据失败:", error); + updateGraphPersistenceState({ + syncState: "error", + lastSyncError: error?.message || String(error), + }); + return null; + } +} + +async function runBmeAutoSyncForChat(source = "unknown", chatId = "") { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + synced: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + updateGraphPersistenceState({ + syncState: "syncing", + lastSyncError: "", + }); + + try { + const syncResult = await autoSyncOnChatChange( + normalizedChatId, + buildBmeSyncRuntimeOptions({ + trigger: String(source || "chat-change"), + reason: String(source || "chat-change"), + }), + ); + + const syncState = + syncResult?.synced || + syncResult?.reason === "manual-cloud-mode" || + syncResult?.reason === "missing-chat-id" + ? "idle" + : syncResult?.error + ? "warning" + : "idle"; + await syncIndexedDbMetaToPersistenceState(normalizedChatId, { + syncState, + lastSyncError: syncResult?.error || "", + }); + + return syncResult; + } catch (error) { + await syncIndexedDbMetaToPersistenceState(normalizedChatId, { + syncState: "error", + lastSyncError: error?.message || String(error), + }); + throw error; + } +} + function ensureBmeChatManager() { if (typeof BmeChatManager !== "function") { if (!bmeChatManagerUnavailableWarned) { @@ -4999,6 +5893,172 @@ async function maybeMigrateLegacyGraphToIndexedDb( return await migrationTask; } +async function maybeImportLegacyIndexedDbSnapshotToLocalStore( + chatId, + targetDb, + { source = "unknown" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + migrated: false, + reason: "migration-local-store-missing-chat-id", + chatId: "", + }; + } + + const inFlightMigration = + bmeIndexedDbLocalStoreMigrationInFlightByChatId.get(normalizedChatId); + if (inFlightMigration) { + return await inFlightMigration; + } + + const migrationTask = (async () => { + try { + if ( + !targetDb || + typeof targetDb.isEmpty !== "function" || + typeof targetDb.importSnapshot !== "function" || + typeof targetDb.exportSnapshot !== "function" + ) { + return { + migrated: false, + reason: "migration-local-store-unavailable", + chatId: normalizedChatId, + }; + } + + const targetStore = resolveDbGraphStorePresentation(targetDb); + if (targetStore.storagePrimary === "indexeddb") { + return { + migrated: false, + reason: "migration-local-store-indexeddb-active", + chatId: normalizedChatId, + }; + } + + const emptyStatus = await targetDb.isEmpty(); + if (!emptyStatus?.empty) { + return { + migrated: false, + reason: "migration-local-store-not-empty", + chatId: normalizedChatId, + emptyStatus, + }; + } + + const legacySnapshot = await exportIndexedDbSnapshotForChat( + normalizedChatId, + ); + if (!isIndexedDbSnapshotMeaningful(legacySnapshot)) { + return { + migrated: false, + reason: "migration-local-store-legacy-indexeddb-missing", + chatId: normalizedChatId, + }; + } + + const nowMs = Date.now(); + const normalizedRevision = Math.max( + normalizeIndexedDbRevision(legacySnapshot?.meta?.revision), + 1, + ); + const legacyMeta = + legacySnapshot?.meta && + typeof legacySnapshot.meta === "object" && + !Array.isArray(legacySnapshot.meta) + ? legacySnapshot.meta + : {}; + const legacyState = + legacySnapshot?.state && + typeof legacySnapshot.state === "object" && + !Array.isArray(legacySnapshot.state) + ? legacySnapshot.state + : {}; + const importSnapshot = { + meta: { + ...legacyMeta, + chatId: normalizedChatId, + migrationCompletedAt: nowMs, + migrationSource: "legacy_indexeddb_snapshot", + migratedFromStoragePrimary: "indexeddb", + migratedFromStorageMode: BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB, + }, + state: { + ...legacyState, + }, + nodes: Array.isArray(legacySnapshot?.nodes) + ? legacySnapshot.nodes.map((node) => + cloneRuntimeDebugValue(node, node), + ) + : [], + edges: Array.isArray(legacySnapshot?.edges) + ? legacySnapshot.edges.map((edge) => + cloneRuntimeDebugValue(edge, edge), + ) + : [], + tombstones: Array.isArray(legacySnapshot?.tombstones) + ? legacySnapshot.tombstones.map((record) => + cloneRuntimeDebugValue(record, record), + ) + : [], + }; + + const migrationResult = await targetDb.importSnapshot(importSnapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizedRevision, + markSyncDirty: Boolean(legacyMeta.syncDirty), + }); + const snapshot = await targetDb.exportSnapshot(); + + debugDebug("[ST-BME] 已将 legacy IndexedDB 快照迁移到当前本地存储", { + source, + chatId: normalizedChatId, + targetStore: cloneRuntimeDebugValue(targetStore, null), + revision: + snapshot?.meta?.revision || migrationResult?.revision || normalizedRevision, + }); + + return { + migrated: true, + reason: "migration-local-store-completed", + source: "legacy_indexeddb_snapshot", + chatId: normalizedChatId, + migrationResult, + snapshot, + targetStore, + }; + } catch (error) { + console.warn("[ST-BME] 迁移 legacy IndexedDB 快照到当前本地存储失败:", { + chatId: normalizedChatId, + error, + }); + return { + migrated: false, + reason: "migration-local-store-failed", + chatId: normalizedChatId, + error: error?.message || String(error), + }; + } + })().finally(() => { + if ( + bmeIndexedDbLocalStoreMigrationInFlightByChatId.get(normalizedChatId) === + migrationTask + ) { + bmeIndexedDbLocalStoreMigrationInFlightByChatId.delete( + normalizedChatId, + ); + } + }); + + bmeIndexedDbLocalStoreMigrationInFlightByChatId.set( + normalizedChatId, + migrationTask, + ); + return await migrationTask; +} + function applyIndexedDbEmptyToRuntime( chatId, { source = "indexeddb-empty", attemptIndex = 0 } = {}, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 3088c7c..b8dd4d8 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -883,6 +883,64 @@ async function createGraphPersistenceHarness({ buildSnapshotFromGraph, evaluatePersistNativeDeltaGate, buildBmeDbName, + BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb", + BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW: "opfs-shadow", + detectOpfsSupport: async () => ({ + available: false, + reason: "opfs-unsupported-in-test", + }), + isGraphLocalStorageModeOpfs: (mode = "") => + /^opfs-/.test(String(mode || "").trim().toLowerCase()), + normalizeGraphLocalStorageMode: (mode = "", fallback = "indexeddb") => { + const normalized = String(mode || "").trim().toLowerCase(); + if ( + normalized === "indexeddb" || + normalized === "opfs-shadow" || + normalized === "opfs-primary" + ) { + return normalized; + } + return String(fallback || "indexeddb").trim().toLowerCase() || "indexeddb"; + }, + OpfsGraphStore: class { + constructor(dbChatId = "") { + this.chatId = String(dbChatId || ""); + this.storeKind = "opfs"; + this.storeMode = "opfs-shadow"; + } + async open() {} + async close() {} + async exportSnapshot() { + return getIndexedDbSnapshotForChat(this.chatId); + } + async commitDelta(delta, options = {}) { + return commitIndexedDbDelta(this.chatId, delta, options); + } + async importSnapshot(snapshot) { + setIndexedDbSnapshotForChat(this.chatId, snapshot); + return { + revision: Number(snapshot?.meta?.revision) || 0, + }; + } + async isEmpty() { + const snapshot = getIndexedDbSnapshotForChat(this.chatId); + return { + empty: + !snapshot || + (!snapshot.nodes?.length && !snapshot.edges?.length && !snapshot.tombstones?.length), + }; + } + async getRevision() { + return Number(getIndexedDbSnapshotForChat(this.chatId)?.meta?.revision || 0); + } + async getMeta(key, fallbackValue = 0) { + const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {}; + if (!snapshot?.meta || !(key in snapshot.meta)) { + return fallbackValue; + } + return snapshot.meta[key]; + } + }, scheduleUpload() { if (runtimeContext.__scheduleUploadShouldThrow) { throw new Error("schedule-upload-failed"); diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 32d2758..42b39c9 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -122,6 +122,7 @@ export function createGenerationRecallHarness(options = {}) { }, isRecalling: false, getCurrentChatId: () => "chat-main", + normalizeChatIdCandidate: (value = "") => String(value ?? "").trim(), normalizeRecallInputText: (text = "") => String(text || "").trim(), isTrivialUserInput, getAssistantTurns: (chat = []) =>