diff --git a/index.js b/index.js index 864d7da..c5ce5fe 100644 --- a/index.js +++ b/index.js @@ -27,7 +27,14 @@ import { import { autoSyncOnChatChange, autoSyncOnVisibility, + backupToServer, + buildRestoreSafetyChatId, deleteRemoteSyncFile, + deleteServerBackup, + getRestoreSafetySnapshotStatus, + listServerBackups, + rollbackFromRestoreSafetySnapshot, + restoreFromServer, scheduleUpload, syncNow, } from "./sync/bme-sync.js"; @@ -120,6 +127,7 @@ import { readGraphCommitMarker, readGraphChatStateSnapshot, resolveGraphIdentityAliasByHostChatId, + shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, writeGraphChatStateSnapshot, @@ -765,10 +773,19 @@ function getGraphPersistenceLiveState() { indexedDbRevision: graphPersistenceState.indexedDbRevision || 0, indexedDbLastError: graphPersistenceState.indexedDbLastError || "", syncState: normalizeGraphSyncState(graphPersistenceState.syncState), + syncDirty: Boolean(graphPersistenceState.syncDirty), + syncDirtyReason: String(graphPersistenceState.syncDirtyReason || ""), lastSyncUploadedAt: Number(graphPersistenceState.lastSyncUploadedAt) || 0, lastSyncDownloadedAt: Number(graphPersistenceState.lastSyncDownloadedAt) || 0, lastSyncedRevision: Number(graphPersistenceState.lastSyncedRevision) || 0, + lastBackupUploadedAt: + Number(graphPersistenceState.lastBackupUploadedAt) || 0, + lastBackupRestoredAt: + Number(graphPersistenceState.lastBackupRestoredAt) || 0, + lastBackupRollbackAt: + Number(graphPersistenceState.lastBackupRollbackAt) || 0, + lastBackupFilename: String(graphPersistenceState.lastBackupFilename || ""), lastSyncError: String(graphPersistenceState.lastSyncError || ""), dualWriteLastResult: cloneRuntimeDebugValue( graphPersistenceState.dualWriteLastResult, @@ -4020,7 +4037,11 @@ async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { const action = String(syncPayload?.action || "") .trim() .toLowerCase(); - if (action !== "download" && action !== "merge") { + if ( + action !== "download" + && action !== "merge" + && action !== "restore-backup" + ) { return { refreshed: false, reason: "action-not-supported", @@ -4082,6 +4103,7 @@ function buildBmeSyncRuntimeOptions(extra = {}) { return await manager.getCurrentDb(chatId); }, getCurrentChatId: () => getCurrentChatId(), + getCloudStorageMode: () => getSettings().cloudStorageMode || "automatic", getRequestHeaders, onSyncApplied: async (payload = {}) => { await refreshRuntimeGraphAfterSyncApplied(payload); @@ -4118,14 +4140,26 @@ async function syncIndexedDbMetaToPersistenceState( const db = await manager.getCurrentDb(normalizedChatId); const [ revision, + syncDirty, + syncDirtyReason, lastSyncUploadedAt, lastSyncDownloadedAt, lastSyncedRevision, + lastBackupUploadedAt, + lastBackupRestoredAt, + lastBackupRollbackAt, + lastBackupFilename, ] = await Promise.all([ db.getRevision(), + db.getMeta("syncDirty", false), + db.getMeta("syncDirtyReason", ""), db.getMeta("lastSyncUploadedAt", 0), db.getMeta("lastSyncDownloadedAt", 0), db.getMeta("lastSyncedRevision", 0), + db.getMeta("lastBackupUploadedAt", 0), + db.getMeta("lastBackupRestoredAt", 0), + db.getMeta("lastBackupRollbackAt", 0), + db.getMeta("lastBackupFilename", ""), ]); const patch = { @@ -4133,9 +4167,15 @@ async function syncIndexedDbMetaToPersistenceState( storageMode: "indexeddb", 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 || ""), }; @@ -4170,7 +4210,12 @@ async function runBmeAutoSyncForChat(source = "unknown", chatId = "") { ); await syncIndexedDbMetaToPersistenceState(normalizedChatId, { - syncState: syncResult?.synced ? "idle" : "warning", + syncState: + syncResult?.action === "manual-probe" + ? "idle" + : syncResult?.synced + ? "idle" + : "warning", lastSyncError: syncResult?.error || "", }); @@ -8265,6 +8310,9 @@ function updateModuleSettings(patch = {}) { const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]); const noticeUiKeys = new Set(["noticeDisplayMode"]); const settings = getSettings(); + const previousCloudStorageMode = String( + settings.cloudStorageMode || "automatic", + ); Object.assign(settings, patch); extension_settings[MODULE_NAME] = settings; globalThis.__stBmeDebugLoggingEnabled = Boolean( @@ -8331,6 +8379,38 @@ function updateModuleSettings(patch = {}) { refreshVisibleStageNotices(); } + const currentCloudStorageMode = String( + settings.cloudStorageMode || "automatic", + ); + if ( + previousCloudStorageMode !== "automatic" + && currentCloudStorageMode === "automatic" + ) { + const chatId = getCurrentChatId(); + if (chatId) { + scheduleBmeIndexedDbTask(async () => { + try { + await syncNow( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "mode-switch-bootstrap", + trigger: "settings:cloud-storage-mode-bootstrap", + }), + ); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + } catch (error) { + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "error", + lastSyncError: error?.message || String(error), + }); + } + }); + } + } + scheduleServerSettingsSave(); return settings; } @@ -12907,6 +12987,8 @@ const _cleanupRuntime = () => ({ }, setLastExtractedItems: () => { lastExtractedItems = []; }, buildBmeDbName, + buildRestoreSafetyDbName: (chatId) => + buildBmeDbName(buildRestoreSafetyChatId(chatId)), closeBmeDb: null, deleteRemoteSyncFile: (chatId) => deleteRemoteSyncFile(chatId, { fetch: globalThis.fetch?.bind(globalThis), @@ -12943,8 +13025,194 @@ async function onDeleteServerSyncFile() { return await onDeleteServerSyncFileController(_cleanupRuntime()); } +async function onBackupCurrentChatToCloud() { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下文"); + return { handledToast: true }; + } + + const result = await backupToServer( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-backup", + trigger: "panel:manual-backup", + }), + ); + + if (!result?.backedUp) { + const backupFailureMessage = + result?.reason === "backup-manifest-error" + ? result?.backupUploaded + ? "备份文件已上传,但服务器备份清单更新失败,请稍后重试" + : "服务器备份清单更新失败,请稍后重试" + : `备份失败: ${result?.error?.message || result?.reason || "未知原因"}`; + toastr.error(backupFailureMessage); + return { handledToast: true, result }; + } + + toastr.success("当前聊天已备份到云端"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; +} + +async function onRestoreCurrentChatFromCloud() { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下文"); + return { handledToast: true }; + } + + const confirmed = globalThis.confirm?.( + "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗?", + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await restoreFromServer( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore", + trigger: "panel:manual-restore", + }), + ); + + if (!result?.restored) { + const reasonMap = { + "not-found": "服务器上没有找到当前聊天的备份", + "backup-missing": "服务器上没有找到当前聊天的备份", + "backup-version-mismatch": "备份版本与当前运行时不兼容", + "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", + "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", + }; + toastr.error( + reasonMap[result?.reason] || + `恢复失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已从云端恢复当前聊天备份"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; +} + +async function onManageServerBackups() { + const chatId = getCurrentChatId(); + const { entries } = await listServerBackups( + buildBmeSyncRuntimeOptions({ + reason: "manage-backups", + trigger: "panel:manage-backups", + }), + ); + return { + entries: Array.isArray(entries) ? entries : [], + currentChatId: chatId, + handledToast: true, + skipDashboardRefresh: true, + }; +} + +async function onDeleteServerBackupEntry(payload = {}) { + const chatId = String(payload?.chatId || "").trim(); + const filename = String(payload?.filename || "").trim(); + if (!chatId) { + return { + deleted: false, + reason: "missing-chat-id", + filename, + handledToast: true, + skipDashboardRefresh: true, + }; + } + + const deleteResult = await deleteServerBackup( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "delete-backup", + trigger: "panel:delete-backup", + }), + ); + + return { + ...deleteResult, + filename, + handledToast: true, + skipDashboardRefresh: true, + }; +} + // ==================== 初始化 ==================== +async function onGetRestoreSafetySnapshotStatus() { + const chatId = getCurrentChatId(); + if (!chatId) { + return { + exists: false, + chatId: "", + createdAt: 0, + reason: "missing-chat-id", + }; + } + + return await getRestoreSafetySnapshotStatus( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore-safety-status", + trigger: "panel:restore-safety-status", + }), + ); +} + +async function onRollbackLastRestore() { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下文"); + return { handledToast: true }; + } + + const safetyStatus = await onGetRestoreSafetySnapshotStatus(); + if (!safetyStatus?.exists) { + toastr.info("当前聊天还没有可用的上次恢复回滚点"); + return { handledToast: true, result: safetyStatus }; + } + + const confirmed = globalThis.confirm?.( + "这会回滚到上次从云端恢复之前的本地状态。确定继续吗?", + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await rollbackFromRestoreSafetySnapshot( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore-safety-rollback", + trigger: "panel:rollback-last-restore", + }), + ); + + if (!result?.restored) { + toastr.error( + `回滚失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已回滚到上次恢复前的本地状态"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; +} (async function init() { await loadServerSettings(); syncGraphPersistenceDebugState(); @@ -12996,6 +13264,12 @@ async function onDeleteServerSyncFile() { deleteCurrentIdb: onDeleteCurrentIdb, deleteAllIdb: onDeleteAllIdb, deleteServerSyncFile: onDeleteServerSyncFile, + backupToCloud: onBackupCurrentChatToCloud, + restoreFromCloud: onRestoreCurrentChatFromCloud, + rollbackLastRestore: onRollbackLastRestore, + manageServerBackups: onManageServerBackups, + deleteServerBackupEntry: onDeleteServerBackupEntry, + getRestoreSafetyStatus: onGetRestoreSafetySnapshotStatus, }, console, document, @@ -13098,3 +13372,5 @@ async function onDeleteServerSyncFile() { } debugLog("[ST-BME] 初始化完成"); })(); + + diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index 6d9f346..5e5b497 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -321,12 +321,31 @@ function buildCommittedBatchPersistSnapshot( }; } +function isPersistenceRevisionAccepted(runtime, persistence = null) { + if (!persistence || persistence.accepted === true) return true; + const graphPersistenceState = runtime?.getGraphPersistenceState?.() || {}; + if (graphPersistenceState.pendingPersist === true) { + return false; + } + const persistenceRevision = Number(persistence?.revision || 0); + if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { + return false; + } + const lastAcceptedRevision = Math.max( + Number(graphPersistenceState?.lastAcceptedRevision || 0), + Number(graphPersistenceState?.commitMarker?.accepted === true + ? graphPersistenceState?.commitMarker?.revision + : 0), + ); + return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; +} + function getPendingPersistenceGateInfo(runtime) { const graph = runtime?.getCurrentGraph?.(); const batchStatus = graph?.historyState?.lastBatchStatus || null; const persistence = batchStatus?.persistence || null; const pendingPersist = runtime?.getGraphPersistenceState?.()?.pendingPersist === true; - const accepted = persistence?.accepted === true; + const accepted = isPersistenceRevisionAccepted(runtime, persistence); if (!pendingPersist && (!persistence || accepted)) { return null; } diff --git a/manifest.json b/manifest.json index 9e5ee4f..e939cc1 100644 --- a/manifest.json +++ b/manifest.json @@ -6,6 +6,6 @@ "js": "index.js", "css": "style.css", "author": "Youzini", - "version": "4.3.9", + "version": "4.4.0", "homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology" } diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index fe8f267..e5d2c62 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -144,6 +144,7 @@ export const defaultSettings = { // UI 面板 noticeDisplayMode: "normal", panelTheme: "crimson", + cloudStorageMode: "automatic", }; const DEFAULT_SETTING_KEYS = Object.freeze(Object.keys(defaultSettings)); diff --git a/sync/bme-db.js b/sync/bme-db.js index 6541c1a..37d7576 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -20,6 +20,10 @@ export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; export const BME_RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; export const BME_RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult"; export const BME_RUNTIME_SUMMARY_STATE_META_KEY = "runtimeSummaryState"; +export const BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY = "maintenanceJournal"; +export const BME_RUNTIME_KNOWLEDGE_STATE_META_KEY = "knowledgeState"; +export const BME_RUNTIME_REGION_STATE_META_KEY = "regionState"; +export const BME_RUNTIME_TIMELINE_STATE_META_KEY = "timelineState"; export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; @@ -45,6 +49,11 @@ function createDefaultMetaValues(chatId = "", nowMs = Date.now()) { lastSyncUploadedAt: 0, lastSyncDownloadedAt: 0, lastSyncedRevision: 0, + lastBackupUploadedAt: 0, + lastBackupRestoredAt: 0, + lastBackupRollbackAt: 0, + lastBackupFilename: "", + syncDirtyReason: "", deviceId: "", nodeCount: 0, edgeCount: 0, @@ -250,6 +259,15 @@ export function buildSnapshotFromGraph(graph, options = {}) { if (chatId) { graphInput.historyState.chatId = chatId; } + const legacyActiveOwnerKey = String( + graphInput?.knowledgeState?.activeOwnerKey || "", + ).trim(); + const legacyActiveRegion = String( + graphInput?.regionState?.activeRegion || "", + ).trim(); + const legacyActiveSegmentId = String( + graphInput?.timelineState?.activeSegmentId || "", + ).trim(); graphInput.vectorIndexState.collectionId = buildVectorCollectionId( chatId || graphInput.historyState.chatId || "", ); @@ -352,6 +370,45 @@ export function buildSnapshotFromGraph(graph, options = {}) { runtimeGraph?.summaryState || {}, {}, ), + [BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: toPlainData( + runtimeGraph?.maintenanceJournal || [], + [], + ), + [BME_RUNTIME_KNOWLEDGE_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.knowledgeState || {}), + activeOwnerKey: String( + legacyActiveOwnerKey || + runtimeGraph?.historyState?.activeRecallOwnerKey || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_REGION_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.regionState || {}), + activeRegion: String( + legacyActiveRegion || + runtimeGraph?.historyState?.activeRegion || + runtimeGraph?.regionState?.manualActiveRegion || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_TIMELINE_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.timelineState || {}), + activeSegmentId: String( + legacyActiveSegmentId || + runtimeGraph?.historyState?.activeStorySegmentId || + runtimeGraph?.timelineState?.manualActiveSegmentId || + "", + ).trim(), + }, + {}, + ), [BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: Number.isFinite( Number(runtimeGraph?.lastProcessedSeq), ) @@ -399,10 +456,43 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { normalizedSnapshot.meta?.[BME_RUNTIME_LAST_RECALL_META_KEY], null, ); + runtimeGraph.maintenanceJournal = toArray( + normalizedSnapshot.meta?.[BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY], + ); + runtimeGraph.knowledgeState = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_KNOWLEDGE_STATE_META_KEY], + runtimeGraph.knowledgeState || {}, + ); + runtimeGraph.regionState = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_REGION_STATE_META_KEY], + runtimeGraph.regionState || {}, + ); + runtimeGraph.timelineState = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_TIMELINE_STATE_META_KEY], + runtimeGraph.timelineState || {}, + ); runtimeGraph.summaryState = toPlainData( normalizedSnapshot.meta?.[BME_RUNTIME_SUMMARY_STATE_META_KEY], runtimeGraph.summaryState || {}, ); + const rawKnowledgeState = + runtimeGraph.knowledgeState && + typeof runtimeGraph.knowledgeState === "object" && + !Array.isArray(runtimeGraph.knowledgeState) + ? runtimeGraph.knowledgeState + : {}; + const rawRegionState = + runtimeGraph.regionState && + typeof runtimeGraph.regionState === "object" && + !Array.isArray(runtimeGraph.regionState) + ? runtimeGraph.regionState + : {}; + const rawTimelineState = + runtimeGraph.timelineState && + typeof runtimeGraph.timelineState === "object" && + !Array.isArray(runtimeGraph.timelineState) + ? runtimeGraph.timelineState + : {}; runtimeGraph.historyState = { ...(runtimeGraph.historyState || {}), @@ -424,6 +514,59 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { ?.extractionCount ?? META_DEFAULT_EXTRACTION_COUNT, ), }; + if ( + typeof runtimeGraph.historyState.activeRecallOwnerKey !== "string" || + !runtimeGraph.historyState.activeRecallOwnerKey + ) { + const legacyActiveOwnerKey = String(rawKnowledgeState.activeOwnerKey || "").trim(); + if (legacyActiveOwnerKey) { + runtimeGraph.historyState.activeRecallOwnerKey = legacyActiveOwnerKey; + } + } + if ( + typeof runtimeGraph.historyState.activeRegion !== "string" || + !runtimeGraph.historyState.activeRegion + ) { + const legacyActiveRegion = String(rawRegionState.activeRegion || "").trim(); + if (legacyActiveRegion) { + runtimeGraph.historyState.activeRegion = legacyActiveRegion; + if ( + typeof runtimeGraph.historyState.activeRegionSource !== "string" || + !runtimeGraph.historyState.activeRegionSource + ) { + runtimeGraph.historyState.activeRegionSource = "snapshot"; + } + } + } + if ( + typeof runtimeGraph.historyState.activeStorySegmentId !== "string" || + !runtimeGraph.historyState.activeStorySegmentId + ) { + const legacyActiveSegmentId = String(rawTimelineState.activeSegmentId || "").trim(); + if (legacyActiveSegmentId) { + runtimeGraph.historyState.activeStorySegmentId = legacyActiveSegmentId; + const activeSegment = Array.isArray(rawTimelineState.segments) + ? rawTimelineState.segments.find( + (segment) => String(segment?.id || "").trim() === legacyActiveSegmentId, + ) + : null; + if ( + (typeof runtimeGraph.historyState.activeStoryTimeLabel !== "string" || + !runtimeGraph.historyState.activeStoryTimeLabel) && + activeSegment + ) { + runtimeGraph.historyState.activeStoryTimeLabel = String( + activeSegment.label || "", + ).trim(); + } + if ( + typeof runtimeGraph.historyState.activeStoryTimeSource !== "string" || + !runtimeGraph.historyState.activeStoryTimeSource + ) { + runtimeGraph.historyState.activeStoryTimeSource = "snapshot"; + } + } + } runtimeGraph.vectorIndexState = { ...(runtimeGraph.vectorIndexState || {}), ...(normalizedSnapshot.meta?.[BME_RUNTIME_VECTOR_META_KEY] || {}), @@ -442,6 +585,41 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { : Number(runtimeGraph.historyState.lastProcessedAssistantFloor); const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId); + if ( + normalizedGraph.knowledgeState && + typeof normalizedGraph.knowledgeState === "object" && + !Array.isArray(normalizedGraph.knowledgeState) + ) { + normalizedGraph.knowledgeState.activeOwnerKey = String( + normalizedGraph.historyState?.activeRecallOwnerKey || + rawKnowledgeState.activeOwnerKey || + "", + ).trim(); + } + if ( + normalizedGraph.regionState && + typeof normalizedGraph.regionState === "object" && + !Array.isArray(normalizedGraph.regionState) + ) { + normalizedGraph.regionState.activeRegion = String( + normalizedGraph.historyState?.activeRegion || + normalizedGraph.regionState.manualActiveRegion || + rawRegionState.activeRegion || + "", + ).trim(); + } + if ( + normalizedGraph.timelineState && + typeof normalizedGraph.timelineState === "object" && + !Array.isArray(normalizedGraph.timelineState) + ) { + normalizedGraph.timelineState.activeSegmentId = String( + normalizedGraph.historyState?.activeStorySegmentId || + normalizedGraph.timelineState.manualActiveSegmentId || + rawTimelineState.activeSegmentId || + "", + ).trim(); + } const historyState = normalizedGraph.historyState || {}; const vectorState = normalizedGraph.vectorIndexState || {}; const resolvedLastProcessedFloor = Number.isFinite( diff --git a/sync/bme-sync.js b/sync/bme-sync.js index 75ff3b7..b0f31b3 100644 --- a/sync/bme-sync.js +++ b/sync/bme-sync.js @@ -1,8 +1,12 @@ +import { BmeDatabase } from "./bme-db.js"; import { PROCESSED_MESSAGE_HASH_VERSION } from "../runtime/runtime-state.js"; const BME_SYNC_FILE_PREFIX = "ST-BME_sync_"; const BME_SYNC_FILE_SUFFIX = ".json"; const BME_SYNC_FILENAME_MAX_LENGTH = 180; +const BME_BACKUP_FILE_PREFIX = "ST-BME_backup_"; +const BME_BACKUP_MANIFEST_FILENAME = "ST-BME_BackupManifest.json"; +const BME_BACKUP_SCHEMA_VERSION = 1; export const BME_SYNC_DEVICE_ID_KEY = "st_bme_sync_device_id_v1"; export const BME_SYNC_UPLOAD_DEBOUNCE_MS = 2500; @@ -18,6 +22,11 @@ const RUNTIME_HISTORY_META_KEY = "runtimeHistoryState"; const RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; const RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; const RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult"; +const RUNTIME_SUMMARY_STATE_META_KEY = "runtimeSummaryState"; +const RUNTIME_MAINTENANCE_JOURNAL_META_KEY = "maintenanceJournal"; +const RUNTIME_KNOWLEDGE_STATE_META_KEY = "knowledgeState"; +const RUNTIME_REGION_STATE_META_KEY = "regionState"; +const RUNTIME_TIMELINE_STATE_META_KEY = "timelineState"; const RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; const RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; const RUNTIME_BATCH_JOURNAL_LIMIT = 96; @@ -26,6 +35,24 @@ function normalizeChatId(chatId) { return String(chatId ?? "").trim(); } +export function buildRestoreSafetyChatId(chatId) { + return `__restore_safety__${normalizeChatId(chatId)}`; +} + +function resolveCloudStorageMode(options = {}) { + const mode = + typeof options.getCloudStorageMode === "function" + ? options.getCloudStorageMode() + : options.cloudStorageMode; + return String(mode || "automatic").trim().toLowerCase() === "manual" + ? "manual" + : "automatic"; +} + +function isAutomaticCloudMode(options = {}) { + return resolveCloudStorageMode(options) === "automatic"; +} + function createStableFilenameHash(input = "") { let hash = 2166136261; const normalized = String(input ?? ""); @@ -49,6 +76,26 @@ function normalizeRemoteFilenameCandidate(fileName, fallbackValue = "ST-BME_sync return sanitized || fallbackValue; } +function buildBackupFilename(chatId) { + const normalizedChatId = normalizeChatId(chatId); + const hash = createStableFilenameHash(normalizedChatId || "unknown"); + const rawSlug = normalizeRemoteFilenameCandidate(normalizedChatId, ""); + const suffixPart = `-${hash}${BME_SYNC_FILE_SUFFIX}`; + const maxSlugLength = Math.max( + 0, + BME_SYNC_FILENAME_MAX_LENGTH - + BME_BACKUP_FILE_PREFIX.length - + suffixPart.length, + ); + const safeSlug = rawSlug + .slice(0, maxSlugLength) + .replace(/^[_.-]+|[_.-]+$/g, ""); + const core = safeSlug + ? `${BME_BACKUP_FILE_PREFIX}${safeSlug}-${hash}` + : `${BME_BACKUP_FILE_PREFIX}${hash}`; + return `${core}${BME_SYNC_FILE_SUFFIX}`; +} + function normalizeLegacyRemoteFilenameCandidate( fileName, fallbackValue = "ST-BME_sync_unknown.json", @@ -141,6 +188,48 @@ function toSerializableData(value, fallback = null) { } } +function normalizeBackupManifestEntry(rawEntry = {}) { + if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) { + return null; + } + + const filename = String(rawEntry.filename || "").trim(); + if ( + !filename + || !filename.startsWith(BME_BACKUP_FILE_PREFIX) + || !filename.endsWith(BME_SYNC_FILE_SUFFIX) + ) { + return null; + } + + return { + filename, + serverPath: String(rawEntry.serverPath || "").trim(), + chatId: normalizeChatId(rawEntry.chatId), + revision: normalizeRevision(rawEntry.revision), + lastModified: normalizeTimestamp(rawEntry.lastModified, 0), + backupTime: normalizeTimestamp(rawEntry.backupTime, 0), + size: normalizeNonNegativeInteger(rawEntry.size, 0), + schemaVersion: normalizeNonNegativeInteger(rawEntry.schemaVersion, 0), + }; +} + +function normalizeBackupEnvelope(payload = {}, chatId = "") { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + + const normalizedSnapshot = normalizeSyncSnapshot(payload.snapshot, chatId); + return { + kind: String(payload.kind || "st-bme-backup").trim().toLowerCase(), + version: normalizeNonNegativeInteger(payload.version, 0), + chatId: normalizeChatId(payload.chatId || normalizedSnapshot.meta?.chatId), + createdAt: normalizeTimestamp(payload.createdAt, 0), + sourceDeviceId: String(payload.sourceDeviceId || "").trim(), + snapshot: normalizedSnapshot, + }; +} + function getStorage() { const storage = globalThis.localStorage; if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") { @@ -215,6 +304,283 @@ function getFetch(options = {}) { return fetchImpl; } +async function getSafetyDb(chatId, options = {}) { + if (typeof options.getSafetyDb === "function") { + return await options.getSafetyDb(chatId); + } + + const db = new BmeDatabase(buildRestoreSafetyChatId(chatId)); + await db.open(); + return db; +} + +async function fetchBackupManifest(options = {}) { + const fetchImpl = getFetch(options); + const response = await fetchImpl( + `/user/files/${BME_BACKUP_MANIFEST_FILENAME}?t=${Date.now()}`, + { + method: "GET", + cache: "no-store", + }, + ); + if (response.status === 404) { + return []; + } + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `manifest read failed: HTTP ${response.status}`); + } + const rawPayload = await response.json(); + if (!Array.isArray(rawPayload)) { + throw new Error("backup manifest payload is not an array"); + } + return rawPayload.map(normalizeBackupManifestEntry).filter(Boolean); +} + +async function writeBackupManifest(entries = [], options = {}) { + const fetchImpl = getFetch(options); + const payload = JSON.stringify(entries, null, 2); + const response = await fetchImpl("/api/files/upload", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: BME_BACKUP_MANIFEST_FILENAME, + data: encodeBase64Utf8(payload), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } +} + +async function upsertBackupManifestEntry(entry, options = {}) { + const existingEntries = await fetchBackupManifest(options); + const filteredEntries = existingEntries.filter( + (candidate) => candidate.filename !== entry.filename, + ); + filteredEntries.push(normalizeBackupManifestEntry(entry)); + filteredEntries.sort((left, right) => right.backupTime - left.backupTime); + await writeBackupManifest(filteredEntries, options); +} + +async function readBackupEnvelope(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + const backupFilename = buildBackupFilename(normalizedChatId); + const fetchImpl = getFetch(options); + + try { + const response = await fetchImpl( + `/user/files/${encodeURIComponent(backupFilename)}?t=${Date.now()}`, + { + method: "GET", + cache: "no-store", + }, + ); + if (response.status === 404) { + return { + exists: false, + filename: backupFilename, + envelope: null, + reason: "not-found", + }; + } + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + const payload = await response.json(); + const envelope = normalizeBackupEnvelope(payload, normalizedChatId); + if (!envelope) { + return { + exists: false, + filename: backupFilename, + envelope: null, + reason: "invalid-backup", + }; + } + return { + exists: true, + filename: backupFilename, + envelope, + reason: "ok", + }; + } catch (error) { + return { + exists: false, + filename: backupFilename, + envelope: null, + reason: "backup-read-error", + error, + }; + } +} + +async function writeBackupEnvelope(envelope, chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + const filename = buildBackupFilename(normalizedChatId); + const fetchImpl = getFetch(options); + const payload = JSON.stringify(envelope, null, 2); + const response = await fetchImpl("/api/files/upload", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: filename, + data: encodeBase64Utf8(payload), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + const uploadResult = await response.json().catch(() => ({})); + return { + filename, + path: String(uploadResult?.path || `/user/files/${filename}`), + }; +} + +async function createRestoreSafetySnapshot(chatId, snapshot, options = {}) { + const safetyDb = await getSafetyDb(chatId, options); + const revision = normalizeRevision(snapshot?.meta?.revision); + try { + await safetyDb.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision, + markSyncDirty: false, + }); + await patchDbMeta(safetyDb, { + restoreSafetySnapshotExists: true, + restoreSafetySnapshotCreatedAt: Date.now(), + restoreSafetySnapshotChatId: normalizeChatId(chatId), + }); + } finally { + if (typeof options.getSafetyDb !== "function") { + await safetyDb.close().catch(() => {}); + } + } +} + +export async function getRestoreSafetySnapshotStatus(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + exists: false, + chatId: "", + createdAt: 0, + reason: "missing-chat-id", + }; + } + + try { + const safetyDb = await getSafetyDb(normalizedChatId, options); + try { + const exists = Boolean( + await readDbMeta(safetyDb, "restoreSafetySnapshotExists", false), + ); + const createdAt = normalizeTimestamp( + await readDbMeta(safetyDb, "restoreSafetySnapshotCreatedAt", 0), + 0, + ); + return { + exists, + chatId: normalizedChatId, + createdAt: exists ? createdAt : 0, + reason: exists ? "ok" : "not-found", + }; + } finally { + if (typeof options.getSafetyDb !== "function") { + await safetyDb.close().catch(() => {}); + } + } + } catch (error) { + return { + exists: false, + chatId: normalizedChatId, + createdAt: 0, + reason: "safety-status-error", + error, + }; + } +} + +export async function rollbackFromRestoreSafetySnapshot(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + restored: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const status = await getRestoreSafetySnapshotStatus(normalizedChatId, options); + if (!status.exists) { + return { + restored: false, + chatId: normalizedChatId, + reason: status.reason || "safety-not-found", + }; + } + + const safetyDb = await getSafetyDb(normalizedChatId, options); + try { + const snapshot = normalizeSyncSnapshot( + await safetyDb.exportSnapshot(), + normalizedChatId, + ); + const db = await getDb(normalizedChatId, options); + await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizeRevision(snapshot.meta?.revision), + markSyncDirty: false, + }); + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + syncDirty: true, + syncDirtyReason: "restore-safety-rollback", + lastBackupRollbackAt: Date.now(), + }); + await invokeSyncAppliedHook(options, { + chatId: normalizedChatId, + action: "restore-backup", + revision: normalizeRevision(snapshot.meta?.revision), + }); + return { + restored: true, + chatId: normalizedChatId, + revision: normalizeRevision(snapshot.meta?.revision), + createdAt: normalizeTimestamp(status.createdAt, 0), + }; + } finally { + if (typeof options.getSafetyDb !== "function") { + await safetyDb.close().catch(() => {}); + } + } + } catch (error) { + console.warn("[ST-BME] 回滚本地安全快照失败:", error); + return { + restored: false, + chatId: normalizedChatId, + reason: "restore-safety-rollback-error", + error, + }; + } +} + function getRequestHeadersSafe(options = {}) { if (typeof options.getRequestHeaders === "function") { try { @@ -919,6 +1285,22 @@ async function getDb(chatId, options = {}) { return db; } +async function readDbMeta(db, key, fallbackValue = null) { + if (!db || typeof key !== "string" || !key) return fallbackValue; + if (typeof db.getMeta === "function") { + return db.getMeta(key, fallbackValue); + } + if (db.meta instanceof Map) { + return db.meta.has(key) ? db.meta.get(key) : fallbackValue; + } + if (db.meta && typeof db.meta === "object" && !Array.isArray(db.meta)) { + return Object.prototype.hasOwnProperty.call(db.meta, key) + ? db.meta[key] + : fallbackValue; + } + return fallbackValue; +} + async function patchDbMeta(db, patch = {}) { if (!db || !patch || typeof patch !== "object") return; if (typeof db.patchMeta === "function") { @@ -1289,6 +1671,280 @@ export async function getRemoteStatus(chatId, options = {}) { }; } +export async function listServerBackups(options = {}) { + const entries = await fetchBackupManifest(options); + return { + entries, + filename: BME_BACKUP_MANIFEST_FILENAME, + }; +} + +export async function backupToServer(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + backedUp: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const db = await getDb(normalizedChatId, options); + const snapshot = normalizeSyncSnapshot( + await db.exportSnapshot(), + normalizedChatId, + ); + const nowMs = Date.now(); + const deviceId = getOrCreateDeviceId(); + + snapshot.meta.chatId = normalizedChatId; + snapshot.meta.deviceId = snapshot.meta.deviceId || deviceId; + snapshot.meta.lastModified = normalizeTimestamp( + snapshot.meta.lastModified, + nowMs, + ); + + const envelope = { + kind: "st-bme-backup", + version: BME_BACKUP_SCHEMA_VERSION, + chatId: normalizedChatId, + createdAt: nowMs, + sourceDeviceId: deviceId, + snapshot: { + meta: toSerializableData(snapshot.meta, {}), + nodes: toSerializableData(snapshot.nodes, []), + edges: toSerializableData(snapshot.edges, []), + tombstones: toSerializableData(snapshot.tombstones, []), + state: toSerializableData(snapshot.state, {}), + }, + }; + + const uploadResult = await writeBackupEnvelope( + envelope, + normalizedChatId, + options, + ); + const serializedEnvelope = JSON.stringify(envelope); + + try { + await upsertBackupManifestEntry( + { + filename: uploadResult.filename, + serverPath: String(uploadResult.path || "").replace(/^\/+/, ""), + chatId: normalizedChatId, + revision: normalizeRevision(snapshot.meta.revision), + lastModified: normalizeTimestamp(snapshot.meta.lastModified, nowMs), + backupTime: nowMs, + size: serializedEnvelope.length, + schemaVersion: BME_BACKUP_SCHEMA_VERSION, + }, + options, + ); + } catch (manifestError) { + return { + backedUp: false, + chatId: normalizedChatId, + filename: uploadResult.filename, + remotePath: uploadResult.path, + reason: "backup-manifest-error", + backupUploaded: true, + error: manifestError, + }; + } + + await patchDbMeta(db, { + deviceId, + syncDirty: false, + syncDirtyReason: "", + lastBackupUploadedAt: nowMs, + lastBackupFilename: uploadResult.filename, + }); + + return { + backedUp: true, + chatId: normalizedChatId, + filename: uploadResult.filename, + remotePath: uploadResult.path, + revision: normalizeRevision(snapshot.meta.revision), + backupTime: nowMs, + }; + } catch (error) { + console.warn("[ST-BME] 手动备份到云端失败:", error); + return { + backedUp: false, + chatId: normalizedChatId, + reason: "backup-error", + error, + }; + } +} + +export async function restoreFromServer(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + restored: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const db = await getDb(normalizedChatId, options); + const remoteResult = await readBackupEnvelope(normalizedChatId, options); + if (!remoteResult.exists || !remoteResult.envelope) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename || "", + reason: remoteResult.reason || "backup-missing", + }; + } + + const envelope = remoteResult.envelope; + if (envelope.version !== BME_BACKUP_SCHEMA_VERSION) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename, + reason: "backup-version-mismatch", + }; + } + + if (envelope.chatId !== normalizedChatId) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename, + reason: "backup-chat-id-mismatch", + }; + } + + const snapshot = normalizeSyncSnapshot(envelope.snapshot, normalizedChatId); + if (normalizeChatId(snapshot.meta?.chatId) !== normalizedChatId) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename, + reason: "snapshot-chat-id-mismatch", + }; + } + + const localSnapshot = normalizeSyncSnapshot( + await db.exportSnapshot(), + normalizedChatId, + ); + await createRestoreSafetySnapshot( + normalizedChatId, + localSnapshot, + options, + ); + + await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizeRevision(snapshot.meta.revision), + markSyncDirty: false, + }); + + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + syncDirty: false, + syncDirtyReason: "", + lastBackupRestoredAt: Date.now(), + lastBackupFilename: + remoteResult.filename || buildBackupFilename(normalizedChatId), + }); + + await invokeSyncAppliedHook(options, { + chatId: normalizedChatId, + action: "restore-backup", + revision: normalizeRevision(snapshot.meta.revision), + }); + + return { + restored: true, + chatId: normalizedChatId, + filename: remoteResult.filename, + revision: normalizeRevision(snapshot.meta.revision), + backupTime: normalizeTimestamp(envelope.createdAt, 0), + }; + } catch (error) { + console.warn("[ST-BME] 从云端恢复备份失败:", error); + return { + restored: false, + chatId: normalizedChatId, + reason: "restore-error", + error, + }; + } +} + +export async function deleteServerBackup(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + deleted: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + const filename = buildBackupFilename(normalizedChatId); + const fetchImpl = getFetch(options); + + try { + const response = await fetchImpl("/api/files/delete", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + path: `/user/files/${filename}`, + }), + }); + + if (!response.ok && response.status !== 404) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + try { + const existingEntries = await fetchBackupManifest(options); + const filteredEntries = existingEntries.filter( + (entry) => entry.filename !== filename, + ); + await writeBackupManifest(filteredEntries, options); + } catch (manifestError) { + return { + deleted: false, + chatId: normalizedChatId, + filename, + reason: "delete-backup-manifest-error", + backupDeleted: true, + error: manifestError, + }; + } + + return { + deleted: true, + chatId: normalizedChatId, + filename, + }; + } catch (error) { + console.warn("[ST-BME] 删除服务端备份失败:", error); + return { + deleted: false, + chatId: normalizedChatId, + filename, + reason: "delete-backup-error", + error, + }; + } +} + export async function upload(chatId, options = {}) { const normalizedChatId = normalizeChatId(chatId); if (!normalizedChatId) { @@ -1503,6 +2159,61 @@ export function mergeSnapshots(localSnapshot, remoteSnapshot, options = {}) { ); const mergedLastRecallResult = mergeRuntimeLastRecallResult(local, remote); + const mergedSummaryState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_SUMMARY_STATE_META_KEY], + remote.meta?.[RUNTIME_SUMMARY_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_SUMMARY_STATE_META_KEY] ?? + local.meta?.[RUNTIME_SUMMARY_STATE_META_KEY] ?? + {}, + {}, + ); + const mergedMaintenanceJournal = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY], + remote.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] ?? + local.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] ?? + [], + [], + ); + const mergedKnowledgeState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY], + remote.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY] ?? + local.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY] ?? + {}, + {}, + ); + const mergedRegionState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_REGION_STATE_META_KEY], + remote.meta?.[RUNTIME_REGION_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_REGION_STATE_META_KEY] ?? + local.meta?.[RUNTIME_REGION_STATE_META_KEY] ?? + {}, + {}, + ); + const mergedTimelineState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_TIMELINE_STATE_META_KEY], + remote.meta?.[RUNTIME_TIMELINE_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_TIMELINE_STATE_META_KEY] ?? + local.meta?.[RUNTIME_TIMELINE_STATE_META_KEY] ?? + {}, + {}, + ); const mergedLastProcessedSeq = Math.max( normalizeNonNegativeInteger(local.meta?.[RUNTIME_LAST_PROCESSED_SEQ_META_KEY], 0), @@ -1523,6 +2234,11 @@ export function mergeSnapshots(localSnapshot, remoteSnapshot, options = {}) { [RUNTIME_VECTOR_META_KEY]: mergedVectorState, [RUNTIME_BATCH_JOURNAL_META_KEY]: mergedBatchJournal, [RUNTIME_LAST_RECALL_META_KEY]: mergedLastRecallResult, + [RUNTIME_SUMMARY_STATE_META_KEY]: mergedSummaryState, + [RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: mergedMaintenanceJournal, + [RUNTIME_KNOWLEDGE_STATE_META_KEY]: mergedKnowledgeState, + [RUNTIME_REGION_STATE_META_KEY]: mergedRegionState, + [RUNTIME_TIMELINE_STATE_META_KEY]: mergedTimelineState, [RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: mergedLastProcessedSeq, [RUNTIME_GRAPH_VERSION_META_KEY]: mergedRuntimeGraphVersion, schemaVersion: Math.max( @@ -1565,6 +2281,16 @@ export async function syncNow(chatId, options = {}) { }; } + if (!isAutomaticCloudMode(options)) { + return { + synced: false, + chatId: normalizedChatId, + action: "manual-probe", + reason: "manual-cloud-mode", + remoteStatus: null, + }; + } + return await withChatSyncLock(normalizedChatId, async () => { const db = await getDb(normalizedChatId, options); const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId); @@ -1680,6 +2406,14 @@ export function scheduleUpload(chatId, options = {}) { }; } + if (!isAutomaticCloudMode(options)) { + return { + scheduled: false, + chatId: normalizedChatId, + reason: "manual-cloud-mode", + }; + } + const debounceMs = Number.isFinite(Number(options.debounceMs)) ? Math.max(0, Math.floor(Number(options.debounceMs))) : BME_SYNC_UPLOAD_DEBOUNCE_MS; @@ -1713,6 +2447,16 @@ export function autoSyncOnChatChange(chatId, options = {}) { }); } + if (!isAutomaticCloudMode(options)) { + return Promise.resolve({ + synced: false, + chatId: normalizedChatId, + action: "manual-probe", + reason: "manual-cloud-mode", + remoteStatus: null, + }); + } + return syncNow(normalizedChatId, { ...options, trigger: options.trigger || "chat-change", @@ -1745,6 +2489,7 @@ export function autoSyncOnVisibility(options = {}) { const chatId = normalizeChatId(chatIdResolver()); if (!chatId) return; + if (!isAutomaticCloudMode(options)) return; autoSyncOnChatChange(chatId, { ...options, diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index e78c88b..efc451f 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -60,6 +60,7 @@ assert.equal(defaultSettings.enableReflection, true); assert.equal(defaultSettings.consolidationAutoMinNewNodes, 2); assert.equal(defaultSettings.enableAutoCompression, true); assert.equal(defaultSettings.compressionEveryN, 10); +assert.equal(defaultSettings.cloudStorageMode, "automatic"); assert.equal(defaultSettings.worldInfoFilterMode, "default"); assert.equal(defaultSettings.worldInfoFilterCustomKeywords, ""); assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false); @@ -77,6 +78,7 @@ assert.equal(migratedSettings.consolidationAutoMinNewNodes, 7); assert.equal(migratedSettings.extractAutoDelayLatestAssistant, true); assert.equal(migratedSettings.enableAutoCompression, true); assert.equal(migratedSettings.compressionEveryN, 10); +assert.equal(migratedSettings.cloudStorageMode, "automatic"); assert.equal("maintenanceAutoMinNewNodes" in migratedSettings, false); const migratedLegacyCompressionDisabled = mergePersistedSettings({ diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs index 2346a60..b25b9c0 100644 --- a/tests/indexeddb-persistence.mjs +++ b/tests/indexeddb-persistence.mjs @@ -436,6 +436,49 @@ async function testGraphSnapshotConverters() { processedRange: [8, 9], }, ]; + graph.maintenanceJournal = [ + { + id: "maintenance-1", + action: "compress", + updatedAt: 123, + }, + ]; + graph.knowledgeState = { + activeOwnerKey: "owner:hero", + owners: { + "owner:hero": { + ownerKey: "owner:hero", + displayName: "Hero", + }, + }, + }; + graph.regionState = { + activeRegion: "camp", + knownRegions: { + camp: { + regionId: "camp", + displayName: "Camp", + }, + }, + }; + graph.timelineState = { + activeSegmentId: "segment-1", + segments: [ + { + id: "segment-1", + label: "Night 1", + }, + ], + }; + graph.summaryState = { + updatedAt: 456, + entries: [ + { + id: "summary-1", + text: "Summary text", + }, + ], + }; graph.nodes.push({ id: "node-converter", type: "event", @@ -461,6 +504,11 @@ async function testGraphSnapshotConverters() { assert.equal(rebuilt.nodes.length, 1); assert.equal(rebuilt.nodes[0].id, "node-converter"); assert.equal(rebuilt.vectorIndexState.hashToNodeId["vec-hash"], "node-converter"); + assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1"); + assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero"); + assert.equal(rebuilt.regionState.activeRegion, "camp"); + assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1"); + assert.equal(rebuilt.summaryState.entries[0].id, "summary-1"); } async function main() { diff --git a/tests/indexeddb-sync.mjs b/tests/indexeddb-sync.mjs index b03a6d5..5fcc96f 100644 --- a/tests/indexeddb-sync.mjs +++ b/tests/indexeddb-sync.mjs @@ -6,11 +6,18 @@ import { __testOnlyDecodeBase64Utf8, autoSyncOnChatChange, autoSyncOnVisibility, + backupToServer, + buildRestoreSafetyChatId, deleteRemoteSyncFile, + deleteServerBackup, + getRestoreSafetySnapshotStatus, getOrCreateDeviceId, getRemoteStatus, download, + listServerBackups, mergeSnapshots, + rollbackFromRestoreSafetySnapshot, + restoreFromServer, scheduleUpload, syncNow, upload, @@ -470,6 +477,11 @@ async function testMergeRuntimeMetaPolicies() { { id: "journal-drop-local", processedRange: [4, 5], createdAt: 110 }, ], runtimeLastRecallResult: { nodes: ["local-only"] }, + runtimeSummaryState: { updatedAt: 500, frontier: ["local-summary"] }, + maintenanceJournal: [{ id: "maintenance-local", updatedAt: 600 }], + knowledgeState: { updatedAt: 700, activeOwnerKey: "local-owner" }, + regionState: { updatedAt: 800, activeRegion: "local-region" }, + timelineState: { updatedAt: 900, activeSegmentId: "local-segment" }, runtimeLastProcessedSeq: 2, runtimeGraphVersion: 10, }, @@ -519,6 +531,11 @@ async function testMergeRuntimeMetaPolicies() { { id: "journal-drop-remote", processedRange: [3, 4], createdAt: 220 }, ], runtimeLastRecallResult: { nodes: ["remote-only"] }, + runtimeSummaryState: { updatedAt: 1500, frontier: ["remote-summary"] }, + maintenanceJournal: [{ id: "maintenance-remote", updatedAt: 1600 }], + knowledgeState: { updatedAt: 1700, activeOwnerKey: "remote-owner" }, + regionState: { updatedAt: 1800, activeRegion: "remote-region" }, + timelineState: { updatedAt: 1900, activeSegmentId: "remote-segment" }, runtimeLastProcessedSeq: 9, runtimeGraphVersion: 7, }, @@ -553,10 +570,242 @@ async function testMergeRuntimeMetaPolicies() { assert.equal(merged.meta.runtimeBatchJournal[0].id, "journal-shared"); assert.deepEqual(merged.meta.runtimeBatchJournal[0].processedRange, [0, 3]); assert.equal(merged.meta.runtimeLastRecallResult, null); + assert.equal(merged.meta.runtimeSummaryState.frontier[0], "remote-summary"); + assert.equal(merged.meta.maintenanceJournal[0].id, "maintenance-remote"); + assert.equal(merged.meta.knowledgeState.activeOwnerKey, "remote-owner"); + assert.equal(merged.meta.regionState.activeRegion, "remote-region"); + assert.equal(merged.meta.timelineState.activeSegmentId, "remote-segment"); assert.equal(merged.meta.runtimeLastProcessedSeq, 9); assert.equal(merged.meta.runtimeGraphVersion, 11); } +async function testManualCloudModeGuards() { + const { fetch, logs } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + dbByChatId.set("chat-manual", new FakeDb("chat-manual")); + + const runtime = { + ...buildRuntimeOptions({ dbByChatId, fetch }), + cloudStorageMode: "manual", + }; + + const scheduleResult = scheduleUpload("chat-manual", runtime); + assert.equal(scheduleResult.scheduled, false); + assert.equal(scheduleResult.reason, "manual-cloud-mode"); + + const syncResult = await syncNow("chat-manual", runtime); + assert.equal(syncResult.action, "manual-probe"); + assert.equal(logs.uploadCalls, 0); + + const chatChangeResult = await autoSyncOnChatChange("chat-manual", runtime); + assert.equal(chatChangeResult.action, "manual-probe"); + assert.equal(chatChangeResult.remoteStatus, null); + assert.equal(logs.getCalls, 0); + assert.equal(logs.uploadCalls, 0); +} + +async function testManualBackupAndRestoreFlow() { + const { fetch, remoteFiles } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const db = new FakeDb("chat-backup-flow", { + meta: { + schemaVersion: 1, + chatId: "chat-backup-flow", + revision: 8, + lastModified: 80, + deviceId: "", + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "local-node", updatedAt: 80 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 4, + extractionCount: 2, + }, + }); + db.meta.set("syncDirty", true); + dbByChatId.set("chat-backup-flow", db); + + const safetyDb = new FakeDb("__restore_safety__chat-backup-flow"); + const hookCalls = []; + const runtime = { + ...buildRuntimeOptions({ dbByChatId, fetch }), + getSafetyDb: async () => safetyDb, + onSyncApplied: async (payload) => hookCalls.push({ ...payload }), + }; + + const backupResult = await backupToServer("chat-backup-flow", runtime); + assert.equal(backupResult.backedUp, true); + assert.equal(db.meta.get("syncDirty"), false); + assert.ok(Number(db.meta.get("lastBackupUploadedAt")) > 0); + assert.ok(String(db.meta.get("lastBackupFilename") || "").startsWith("ST-BME_backup_")); + + const manifestResult = await listServerBackups(runtime); + assert.equal(manifestResult.entries.length, 1); + assert.equal(manifestResult.entries[0].chatId, "chat-backup-flow"); + + db.snapshot = { + meta: { + schemaVersion: 1, + chatId: "chat-backup-flow", + revision: 1, + lastModified: 10, + deviceId: "", + nodeCount: 0, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: -1, + extractionCount: 0, + }, + }; + + const restoreResult = await restoreFromServer("chat-backup-flow", runtime); + assert.equal(restoreResult.restored, true); + assert.equal(db.snapshot.nodes[0].id, "local-node"); + assert.ok(Number(db.meta.get("lastBackupRestoredAt")) > 0); + const safetyStatus = await getRestoreSafetySnapshotStatus( + "chat-backup-flow", + runtime, + ); + assert.equal(safetyStatus.exists, true); + assert.equal(safetyDb.lastImportPayload.meta.revision, 1); + assert.deepEqual( + hookCalls.map((item) => item.action), + ["restore-backup"], + ); + + db.snapshot = { + meta: { + schemaVersion: 1, + chatId: "chat-backup-flow", + revision: 99, + lastModified: 999, + deviceId: "", + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "broken-node", updatedAt: 999 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 88, + extractionCount: 9, + }, + }; + + const rollbackResult = await rollbackFromRestoreSafetySnapshot( + "chat-backup-flow", + runtime, + ); + assert.equal(rollbackResult.restored, true); + assert.equal(db.snapshot.meta.revision, 1); + assert.equal(db.snapshot.nodes.length, 0); + assert.equal(db.meta.get("syncDirty"), true); + assert.ok(Number(db.meta.get("lastBackupRollbackAt")) > 0); + + const deleteResult = await deleteServerBackup("chat-backup-flow", runtime); + assert.equal(deleteResult.deleted, true); + const manifestAfterDelete = await listServerBackups(runtime); + assert.equal(manifestAfterDelete.entries.length, 0); + assert.equal( + Array.from(remoteFiles.keys()).some((key) => key.startsWith("ST-BME_backup_")), + false, + ); +} + +async function testBackupManifestReadFailureDoesNotOverwriteManifest() { + const { fetch, remoteFiles } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const db = new FakeDb("chat-manifest-guard", { + meta: { + schemaVersion: 1, + chatId: "chat-manifest-guard", + revision: 3, + lastModified: 30, + deviceId: "", + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "node-manifest", updatedAt: 30 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 2, + extractionCount: 1, + }, + }); + dbByChatId.set("chat-manifest-guard", db); + + remoteFiles.set("ST-BME_BackupManifest.json", [ + { + filename: "ST-BME_backup_existing-a.json", + serverPath: "user/files/ST-BME_backup_existing-a.json", + chatId: "chat-a", + revision: 1, + lastModified: 10, + backupTime: 10, + size: 100, + schemaVersion: 1, + }, + ]); + + let failManifestRead = true; + const guardedFetch = async (url, options = {}) => { + if ( + failManifestRead + && String(options?.method || "GET").toUpperCase() === "GET" + && String(url).startsWith("/user/files/ST-BME_BackupManifest.json") + ) { + return createJsonResponse(500, "manifest read failed"); + } + return await fetch(url, options); + }; + + const runtime = buildRuntimeOptions({ dbByChatId, fetch: guardedFetch }); + const backupResult = await backupToServer("chat-manifest-guard", runtime); + assert.equal(backupResult.backedUp, false); + assert.equal(backupResult.reason, "backup-manifest-error"); + assert.equal(backupResult.backupUploaded, true); + + failManifestRead = false; + const manifestResult = await listServerBackups(runtime); + assert.equal(manifestResult.entries.length, 1); + assert.equal(manifestResult.entries[0].chatId, "chat-a"); +} + +async function testRestoreValidationDoesNotCreateSafetySnapshot() { + const { fetch } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const db = new FakeDb("chat-no-backup"); + const safetyDb = new FakeDb(buildRestoreSafetyChatId("chat-no-backup")); + dbByChatId.set("chat-no-backup", db); + + const runtime = { + ...buildRuntimeOptions({ dbByChatId, fetch }), + getSafetyDb: async () => safetyDb, + }; + + const restoreResult = await restoreFromServer("chat-no-backup", runtime); + assert.equal(restoreResult.restored, false); + assert.equal(restoreResult.reason, "not-found"); + + const safetyStatus = await getRestoreSafetySnapshotStatus( + "chat-no-backup", + runtime, + ); + assert.equal(safetyStatus.exists, false); +} + async function testSyncNowLockAndAutoSync() { const { fetch, remoteFiles, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); @@ -826,6 +1075,10 @@ async function main() { await testLegacyRemoteFilenameFallbackAndReuse(); await testMergeRules(); await testMergeRuntimeMetaPolicies(); + await testManualCloudModeGuards(); + await testManualBackupAndRestoreFlow(); + await testBackupManifestReadFailureDoesNotOverwriteManifest(); + await testRestoreValidationDoesNotCreateSafetySnapshot(); await testSyncNowLockAndAutoSync(); await testDeleteRemoteSyncFile(); await testDeleteRemoteSyncFileFallsBackToLegacyFilename(); diff --git a/tests/mobile-status-regressions.mjs b/tests/mobile-status-regressions.mjs index ff0399d..cf809d7 100644 --- a/tests/mobile-status-regressions.mjs +++ b/tests/mobile-status-regressions.mjs @@ -206,6 +206,129 @@ async function testManualExtractNoBatchesDoesNotStayRunning() { assert.notEqual(context.runtimeStatus.level, "running"); } +async function testManualExtractIgnoresSupersededPendingPersistence() { + let executeExtractionBatchCalls = 0; + let assistantTurnCallCount = 0; + const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }]; + const context = { + ...createBaseStatusContext(), + isExtracting: false, + graphPersistenceState: { + pendingPersist: false, + lastAcceptedRevision: 7, + }, + currentGraph: { + historyState: { + lastBatchStatus: { + processedRange: [1, 1], + persistence: { + outcome: "queued", + accepted: false, + revision: 7, + reason: "extraction-batch-complete:pending", + storageTier: "none", + }, + }, + }, + }, + getCurrentChatId() { + return "chat-mobile"; + }, + getCurrentGraph() { + return context.currentGraph; + }, + getIsExtracting() { + return context.isExtracting; + }, + getGraphPersistenceState() { + return { + pendingPersist: false, + lastAcceptedRevision: 7, + }; + }, + ensureGraphMutationReady() { + return true; + }, + async recoverHistoryIfNeeded() { + return true; + }, + normalizeGraphRuntimeState(graph) { + return graph; + }, + setCurrentGraph(graph) { + context.currentGraph = graph; + }, + createEmptyGraph() { + return {}; + }, + getContext() { + return { chat }; + }, + getAssistantTurns() { + assistantTurnCallCount += 1; + return assistantTurnCallCount <= 2 ? [1] : []; + }, + getLastProcessedAssistantFloor() { + return 0; + }, + clampInt(value, fallback) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; + }, + getSettings() { + return { extractEvery: 1 }; + }, + beginStageAbortController() { + return { signal: {} }; + }, + async executeExtractionBatch() { + executeExtractionBatchCalls += 1; + return { + success: true, + result: { + newNodes: 0, + updatedNodes: 0, + newEdges: 0, + }, + effects: {}, + batchStatus: { + persistence: { + accepted: true, + }, + }, + historyAdvanceAllowed: true, + }; + }, + async retryPendingGraphPersist() { + return { + accepted: false, + reason: "no-pending-persist", + }; + }, + isAbortError() { + return false; + }, + onManualExtractController, + finishStageAbortController() {}, + setIsExtracting(value) { + context.isExtracting = value; + }, + setLastExtractionStatus(text, meta, level) { + context.lastExtractionStatus = { text, meta, level }; + context.runtimeStatus = { text, meta, level }; + }, + toastr: { + info() {}, + success() {}, + warning() {}, + error() {}, + }, + result: null, + }; + await onManualExtractController(context, { drainAll: false }); + assert.equal(executeExtractionBatchCalls, 1); + assert.notEqual(context.lastExtractionStatus.text, "等待持久化确认"); +} + async function testManualRebuildSetsTerminalRuntimeStatus() { const chat = [{ is_user: true, mes: "u" }, { is_user: false, mes: "a" }]; const context = { @@ -281,6 +404,7 @@ async function testManualRebuildSetsTerminalRuntimeStatus() { testIndexDefinesLastProcessedAssistantFloorHelper(); await testVectorSyncTerminalStateUpdatesRuntime(); await testManualExtractNoBatchesDoesNotStayRunning(); +await testManualExtractIgnoresSupersededPendingPersistence(); await testManualRebuildSetsTerminalRuntimeStatus(); console.log("mobile-status-regressions tests passed"); diff --git a/ui/panel.html b/ui/panel.html index f03b19f..07cf088 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -1090,6 +1090,74 @@ +
+
+
+
云端存储模式
+
+ 自动模式沿用当前镜像同步;手动模式停止自动云端写入,改为按当前聊天手动备份与恢复。 +
+
+
+
+ + +
+
+ 自动储存会继续按当前镜像逻辑同步;手动储存只保留本地写入,需要你主动备份和恢复。 +
+ + +
+
diff --git a/ui/panel.js b/ui/panel.js index d7a3a85..18e34ac 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -332,6 +332,143 @@ async function getPopupRuntime() { return await popupRuntimePromise; } +function _ensureCloudBackupManagerStyles() { + if (document.getElementById("bme-cloud-backup-manager-styles")) return; + const style = document.createElement("style"); + style.id = "bme-cloud-backup-manager-styles"; + style.textContent = ` + .bme-cloud-backup-modal { + width: min(920px, 88vw); + max-width: 100%; + color: var(--SmartThemeBodyColor, #f2efe8); + } + .bme-cloud-backup-modal__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; + } + .bme-cloud-backup-modal__title { + font-size: 22px; + font-weight: 700; + margin: 0; + } + .bme-cloud-backup-modal__subtitle { + opacity: 0.78; + line-height: 1.5; + margin-top: 6px; + } + .bme-cloud-backup-modal__tools { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + .bme-cloud-backup-modal__btn { + border: 1px solid var(--SmartThemeBorderColor, rgba(255,255,255,0.18)); + background: var(--SmartThemeBlurTintColor, rgba(255,255,255,0.06)); + color: inherit; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + } + .bme-cloud-backup-modal__btn:hover:not(:disabled) { + border-color: rgba(255, 181, 71, 0.65); + } + .bme-cloud-backup-modal__btn:disabled { + opacity: 0.55; + cursor: wait; + } + .bme-cloud-backup-modal__list { + display: grid; + gap: 12px; + max-height: 62vh; + overflow: auto; + padding-right: 4px; + } + .bme-cloud-backup-modal__empty, + .bme-cloud-backup-modal__loading { + border: 1px dashed var(--SmartThemeBorderColor, rgba(255,255,255,0.18)); + border-radius: 14px; + padding: 18px; + opacity: 0.85; + text-align: center; + } + .bme-cloud-backup-card { + border: 1px solid var(--SmartThemeBorderColor, rgba(255,255,255,0.18)); + border-radius: 14px; + padding: 14px; + background: rgba(255,255,255,0.03); + } + .bme-cloud-backup-card.is-current-chat { + border-color: rgba(255, 181, 71, 0.78); + box-shadow: 0 0 0 1px rgba(255, 181, 71, 0.22) inset; + } + .bme-cloud-backup-card__top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 8px; + } + .bme-cloud-backup-card__title { + font-size: 16px; + font-weight: 700; + word-break: break-all; + } + .bme-cloud-backup-card__badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255, 181, 71, 0.18); + color: #ffcd73; + flex-shrink: 0; + } + .bme-cloud-backup-card__meta { + display: grid; + gap: 4px; + font-size: 13px; + opacity: 0.86; + margin-bottom: 10px; + } + .bme-cloud-backup-card__filename { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + opacity: 0.75; + word-break: break-all; + margin-bottom: 12px; + } + .bme-cloud-backup-card__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + .bme-cloud-backup-card__danger { + border-color: rgba(255, 99, 99, 0.45); + } + .bme-cloud-backup-card__danger:hover:not(:disabled) { + border-color: rgba(255, 99, 99, 0.72); + } + @media (max-width: 720px) { + .bme-cloud-backup-modal__header { + flex-direction: column; + } + .bme-cloud-backup-modal__tools { + justify-content: flex-start; + } + .bme-cloud-backup-card__top { + flex-direction: column; + } + } + `; + document.head?.appendChild(style); +} + function mountPanelHtml(html) { const markup = String(html || "").trim(); if (!markup) { @@ -3617,6 +3754,10 @@ function _bindActions() { "bme-act-delete-current-idb": "deleteCurrentIdb", "bme-act-delete-all-idb": "deleteAllIdb", "bme-act-delete-server-sync": "deleteServerSyncFile", + "bme-act-backup-to-cloud": "backupToCloud", + "bme-act-restore-from-cloud": "restoreFromCloud", + "bme-act-manage-server-backups": "manageServerBackups", + "bme-act-rollback-last-restore": "rollbackLastRestore", }; const actionLabels = { @@ -3638,6 +3779,10 @@ function _bindActions() { deleteCurrentIdb: "清空当前 IDB", deleteAllIdb: "清空全部 IDB", deleteServerSyncFile: "清空服务端同步文件", + backupToCloud: "\u5907\u4efd\u5230\u4e91\u7aef", + restoreFromCloud: "\u4ece\u4e91\u7aef\u83b7\u53d6\u5907\u4efd", + manageServerBackups: "\u7ba1\u7406\u670d\u52a1\u5668\u5907\u4efd", + rollbackLastRestore: "\u56de\u6eda\u4e0a\u6b21\u6062\u590d", }; for (const [elementId, actionKey] of Object.entries(bindings)) { @@ -3645,7 +3790,10 @@ function _bindActions() { if (!btn) continue; btn.addEventListener("click", async () => { - const handler = _actionHandlers[actionKey]; + const handler = + actionKey === "manageServerBackups" + ? _openServerBackupManagerModal + : _actionHandlers[actionKey]; if (!handler) return; const label = actionLabels[actionKey] || actionKey; @@ -3663,34 +3811,39 @@ function _bindActions() { if (result?.cancelled) { return; } - _refreshDashboard(); - _refreshGraph(); - if ( - document - .getElementById("bme-pane-memory") - ?.classList.contains("active") - ) { - _refreshMemoryBrowser(); - } - if ( - document - .getElementById("bme-pane-injection") - ?.classList.contains("active") - ) { - await _refreshInjectionPreview(); + if (!result?.skipDashboardRefresh) { + _refreshDashboard(); + _refreshGraph(); + if ( + document + .getElementById("bme-pane-memory") + ?.classList.contains("active") + ) { + _refreshMemoryBrowser(); + } + if ( + document + .getElementById("bme-pane-injection") + ?.classList.contains("active") + ) { + await _refreshInjectionPreview(); + } } if (!result?.handledToast) { toastr.success(`${label} 完成`, "ST-BME"); } + void _refreshCloudBackupManualUi(); } catch (error) { console.error(`[ST-BME] Action ${actionKey} failed:`, error); if (!error?._stBmeToastHandled) { toastr.error(`${label} 失败: ${error?.message || error}`, "ST-BME"); } } finally { + btn.disabled = false; btn.style.opacity = ""; _refreshRuntimeStatus(); _refreshGraphAvailabilityState(); + void _refreshCloudBackupManualUi(); } }); } @@ -4165,6 +4318,11 @@ function _refreshConfigTab() { "bme-setting-notice-display-mode", settings.noticeDisplayMode ?? "normal", ); + _setInputValue( + "bme-setting-cloud-storage-mode", + settings.cloudStorageMode || "automatic", + ); + _refreshCloudStorageModeUi(settings); _setInputValue( "bme-setting-wi-filter-mode", settings.worldInfoFilterMode || "default", @@ -4568,6 +4726,18 @@ function _bindConfigControls() { }); noticeDisplayModeEl.dataset.bmeBound = "true"; } + const cloudStorageModeEl = document.getElementById( + "bme-setting-cloud-storage-mode", + ); + if (cloudStorageModeEl && cloudStorageModeEl.dataset.bmeBound !== "true") { + cloudStorageModeEl.addEventListener("change", () => { + const settings = _patchSettings({ + cloudStorageMode: cloudStorageModeEl.value || "automatic", + }); + _refreshCloudStorageModeUi(settings); + }); + cloudStorageModeEl.dataset.bmeBound = "true"; + } const wiFilterModeEl = document.getElementById("bme-setting-wi-filter-mode"); if (wiFilterModeEl && wiFilterModeEl.dataset.bmeBound !== "true") { wiFilterModeEl.addEventListener("change", () => { @@ -9292,9 +9462,15 @@ function _getGraphPersistenceSnapshot() { storageMode: "indexeddb", dbReady: false, syncState: "idle", + syncDirty: false, + syncDirtyReason: "", lastSyncUploadedAt: 0, lastSyncDownloadedAt: 0, lastSyncedRevision: 0, + lastBackupUploadedAt: 0, + lastBackupRestoredAt: 0, + lastBackupRollbackAt: 0, + lastBackupFilename: "", lastSyncError: "", }; } @@ -9320,11 +9496,30 @@ function _formatPersistenceOutcomeLabel(outcome = "") { } } +function _isPersistenceRevisionAccepted(persistence = null, loadInfo = {}) { + if (!persistence || persistence.accepted === true) return true; + if (loadInfo?.pendingPersist === true) return false; + const persistenceRevision = Number(persistence?.revision || 0); + if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) { + return false; + } + const commitMarkerRevision = + loadInfo?.commitMarker?.accepted === true + ? Number(loadInfo.commitMarker.revision || 0) + : 0; + const lastAcceptedRevision = Math.max( + Number(loadInfo?.lastAcceptedRevision || 0), + commitMarkerRevision, + ); + return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision; +} + function _formatDashboardPersistMeta(loadInfo = {}, batchStatus = null) { const persistence = batchStatus?.persistence || null; if (persistence) { + const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const parts = [ - _formatPersistenceOutcomeLabel(persistence.outcome), + accepted ? "已确认" : _formatPersistenceOutcomeLabel(persistence.outcome), persistence.storageTier ? `tier ${persistence.storageTier}` : "", Number.isFinite(Number(persistence.revision)) && Number(persistence.revision) > 0 ? `rev ${Number(persistence.revision)}` @@ -9355,6 +9550,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = const lastConfirmedFloor = graph?.historyState?.lastProcessedAssistantFloor ?? -1; const persistence = batchStatus?.persistence || null; + const accepted = _isPersistenceRevisionAccepted(persistence, loadInfo); const processedRange = Array.isArray(batchStatus?.processedRange) ? batchStatus.processedRange : []; @@ -9363,7 +9559,7 @@ function _formatDashboardHistoryMeta(graph = null, loadInfo = {}, batchStatus = ? Number(processedRange[1]) : null; - if (persistence && persistence.accepted !== true && pendingFloor != null) { + if (persistence && !accepted && pendingFloor != null) { return `持久化待确认:本地已抽取到楼层 ${pendingFloor},已确认楼层 ${lastConfirmedFloor}`; } @@ -9472,6 +9668,141 @@ function _refreshGraphAvailabilityState() { } } +function _formatCloudTimeLabel(timestamp) { + const normalized = Number(timestamp); + if (!Number.isFinite(normalized) || normalized <= 0) return ""; + try { + return new Date(normalized).toLocaleString(); + } catch { + return ""; + } +} + +function _renderCloudStorageModeStatus( + settings = _getSettings?.() || {}, + loadInfo = _getGraphPersistenceSnapshot(), +) { + const statusEl = document.getElementById("bme-cloud-storage-mode-status"); + if (!statusEl) return; + + const mode = String(settings?.cloudStorageMode || "automatic"); + if (mode !== "manual") { + statusEl.style.display = "none"; + statusEl.textContent = ""; + return; + } + + const lines = []; + const syncDirty = Boolean(loadInfo?.syncDirty); + const dirtyReason = String(loadInfo?.syncDirtyReason || "").trim(); + const backupUploadedAt = Number(loadInfo?.lastBackupUploadedAt) || 0; + const backupRestoredAt = Number(loadInfo?.lastBackupRestoredAt) || 0; + const backupRollbackAt = Number(loadInfo?.lastBackupRollbackAt) || 0; + const backupFilename = String(loadInfo?.lastBackupFilename || "").trim(); + const dualWrite = loadInfo?.dualWriteLastResult || null; + const dualWriteAt = Number(dualWrite?.at) || 0; + const needsPostRecoveryBackup = + Boolean(dualWrite?.success) && + ["migration", "identity-recovery"].includes(String(dualWrite?.action || "")) && + dualWriteAt > backupUploadedAt; + + if (syncDirty) { + lines.push( + dirtyReason + ? `\u672c\u5730\u6709\u672a\u5907\u4efd\u7684\u6539\u52a8\uff0c\u7b49\u5f85\u4f60\u624b\u52a8\u4e0a\u4f20\u3002\u539f\u56e0\uff1a${dirtyReason}` + : "\u672c\u5730\u6709\u672a\u5907\u4efd\u7684\u6539\u52a8\uff0c\u7b49\u5f85\u4f60\u624b\u52a8\u4e0a\u4f20\u3002", + ); + } else if (backupUploadedAt > 0) { + const uploadedAtText = _formatCloudTimeLabel(backupUploadedAt); + lines.push( + uploadedAtText + ? `\u4e0a\u6b21\u5907\u4efd\u4e8e ${uploadedAtText}${backupFilename ? `\uff0c\u6587\u4ef6\uff1a${backupFilename}` : ""}` + : "\u5f53\u524d\u804a\u5929\u5df2\u6709\u4e91\u7aef\u5907\u4efd\u8bb0\u5f55\u3002", + ); + } else { + lines.push("\u8fd8\u6ca1\u6709\u4e3a\u5f53\u524d\u804a\u5929\u4e0a\u4f20\u8fc7\u624b\u52a8\u5907\u4efd\u3002"); + } + + if (backupRestoredAt > 0) { + const restoredAtText = _formatCloudTimeLabel(backupRestoredAt); + if (restoredAtText) { + lines.push(`\u4e0a\u6b21\u4ece\u4e91\u7aef\u6062\u590d\u4e8e ${restoredAtText}${backupFilename ? `\uff0c\u6587\u4ef6\uff1a${backupFilename}` : ""}`); + } + } + + if (backupRollbackAt > 0) { + const rollbackAtText = _formatCloudTimeLabel(backupRollbackAt); + if (rollbackAtText) { + lines.push(`\u6700\u8fd1\u4e00\u6b21\u5df2\u56de\u6eda\u5230\u6062\u590d\u524d\u7684\u672c\u5730\u5feb\u7167\uff0c\u65f6\u95f4\uff1a${rollbackAtText}`); + } + } + + if (needsPostRecoveryBackup) { + const actionLabel = + String(dualWrite?.action || "") === "identity-recovery" + ? "\u8eab\u4efd\u6062\u590d" + : "\u8fc1\u79fb"; + lines.push(`\u5df2\u5b8c\u6210${actionLabel}\uff0c\u4f46\u4e91\u7aef\u5907\u4efd\u8fd8\u6ca1\u8ddf\u4e0a\u8fd9\u6b21\u53d8\u66f4\u3002\u5982\u679c\u4f60\u8981\u5728 A/B \u8bbe\u5907\u95f4\u63a5\u529b\uff0c\u8bf7\u518d\u70b9\u4e00\u6b21\u201c\u5907\u4efd\u5230\u4e91\u7aef\u201d\u3002`); + } + + statusEl.style.display = lines.length ? "" : "none"; + statusEl.innerHTML = lines.map((line) => `
${_escHtml(line)}
`).join(""); +} + +async function _refreshCloudBackupManualUi(settings = _getSettings?.() || {}) { + const mode = String(settings?.cloudStorageMode || "automatic"); + const rollbackButton = document.getElementById("bme-act-rollback-last-restore"); + if (!rollbackButton) return; + + if (mode !== "manual") { + rollbackButton.disabled = true; + rollbackButton.title = ""; + return; + } + + if (typeof _actionHandlers.getRestoreSafetyStatus !== "function") { + rollbackButton.disabled = true; + rollbackButton.title = ""; + return; + } + + rollbackButton.disabled = true; + rollbackButton.title = "\u6b63\u5728\u68c0\u67e5\u662f\u5426\u5b58\u5728\u53ef\u7528\u7684\u56de\u6eda\u5feb\u7167..."; + try { + const status = await _actionHandlers.getRestoreSafetyStatus(); + const hasSafety = Boolean(status?.exists); + rollbackButton.disabled = !hasSafety; + rollbackButton.title = hasSafety + ? status?.createdAt + ? `\u5df2\u68c0\u6d4b\u5230\u4e0a\u6b21\u6062\u590d\u524d\u7684\u672c\u5730\u5b89\u5168\u5feb\u7167\uff0c\u521b\u5efa\u65f6\u95f4\uff1a${new Date(status.createdAt).toLocaleString()}` + : "\u5df2\u68c0\u6d4b\u5230\u4e0a\u6b21\u6062\u590d\u524d\u7684\u672c\u5730\u5b89\u5168\u5feb\u7167\uff0c\u53ef\u4ee5\u56de\u6eda\u3002" + : "\u5f53\u524d\u804a\u5929\u8fd8\u6ca1\u6709\u53ef\u7528\u7684\u56de\u6eda\u5feb\u7167\u3002"; + } catch (error) { + console.error("[ST-BME] failed to read restore safety snapshot status:", error); + rollbackButton.disabled = true; + rollbackButton.title = "\u8bfb\u53d6\u56de\u6eda\u5feb\u7167\u72b6\u6001\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5\u3002"; + } +} + +function _refreshCloudStorageModeUi(settings = _getSettings?.() || {}) { + const mode = String(settings?.cloudStorageMode || "automatic"); + const manualActions = document.getElementById( + "bme-cloud-backup-manual-actions", + ); + const helpText = document.getElementById("bme-cloud-storage-mode-help"); + if (manualActions) { + manualActions.style.display = mode === "manual" ? "" : "none"; + } + if (helpText) { + helpText.textContent = + mode === "manual" + ? "\u624b\u52a8\u50a8\u5b58\u53ea\u4fdd\u7559\u672c\u5730 IndexedDB \u5199\u5165\uff0c\u4e0d\u4f1a\u81ea\u52a8\u4e0a\u4f20\u6216\u8986\u76d6\u4e91\u7aef\u3002\u9700\u8981\u63a5\u529b\u65f6\uff0c\u8bf7\u624b\u52a8\u70b9\u51fb\u4e0b\u65b9\u6309\u94ae\u3002" + : "\u81ea\u52a8\u50a8\u5b58\u4f1a\u7ee7\u7eed\u6cbf\u7528\u5f53\u524d\u955c\u50cf\u540c\u6b65\u903b\u8f91\u4e0e\u95f4\u9694\uff1b\u624b\u52a8\u50a8\u5b58\u53ea\u4fdd\u7559\u672c\u5730\u5199\u5165\uff0c\u9700\u8981\u4f60\u4e3b\u52a8\u5907\u4efd\u548c\u6062\u590d\u3002"; + } + _renderCloudStorageModeStatus(settings, _getGraphPersistenceSnapshot()); + void _refreshCloudBackupManualUi(settings); +} + function _refreshRuntimeStatus() { const runtimeStatus = _getRuntimeStatus?.() || {}; const text = runtimeStatus.text || "待命"; @@ -9479,6 +9810,7 @@ function _refreshRuntimeStatus() { _setText("bme-status-text", text); _setText("bme-status-meta", meta); _setText("bme-panel-status", text); + _renderCloudStorageModeStatus(_getSettings?.() || {}, _getGraphPersistenceSnapshot()); _refreshGraphAvailabilityState(); } @@ -9496,9 +9828,213 @@ function _patchSettings(patch = {}, options = {}) { if (options.refreshTaskWorkspace) _refreshTaskProfileWorkspace(settings); if (options.refreshTheme) _highlightThemeChoice(settings.panelTheme || "crimson"); + _refreshCloudStorageModeUi(settings); return settings; } +function _formatBackupManagerTime(timestamp) { + const value = Number(timestamp); + if (!Number.isFinite(value) || value <= 0) { + return "\u672a\u8bb0\u5f55"; + } + try { + return new Date(value).toLocaleString(); + } catch { + return "\u672a\u8bb0\u5f55"; + } +} + +function _buildCloudBackupManagerHtml(state = {}) { + const entries = Array.isArray(state.entries) ? state.entries : []; + const currentChatId = String(state.currentChatId || "").trim(); + if (state.loading) { + return ` +
+ \u6b63\u5728\u8bfb\u53d6\u670d\u52a1\u5668\u5907\u4efd\u5217\u8868... +
+ `; + } + + if (!entries.length) { + return ` +
+ \u670d\u52a1\u5668\u4e0a\u8fd8\u6ca1\u6709 ST-BME \u5907\u4efd\u3002
+ \u5148\u5728\u5f53\u524d\u804a\u5929\u70b9\u4e00\u6b21\u201c\u5907\u4efd\u5230\u4e91\u7aef\u201d\u5c31\u4f1a\u51fa\u73b0\u5728\u8fd9\u91cc\u3002 +
+ `; + } + + return entries + .map((entry) => { + const chatId = String(entry?.chatId || "").trim(); + const filename = String(entry?.filename || "").trim(); + const isCurrentChat = currentChatId && chatId === currentChatId; + const backupTime = _formatBackupManagerTime(entry?.backupTime); + const lastModified = _formatBackupManagerTime(entry?.lastModified); + const sizeLabel = + Number.isFinite(Number(entry?.size)) && Number(entry.size) > 0 + ? `${Number(entry.size)} B` + : "\u672a\u77e5\u5927\u5c0f"; + return ` +
+
+
${_escHtml(chatId || "(unknown chat)")}
+ ${isCurrentChat ? '
\u5f53\u524d\u804a\u5929
' : ""} +
+
+
Revision: ${_escHtml(String(entry?.revision ?? 0))}
+
\u5907\u4efd\u65f6\u95f4: ${_escHtml(backupTime)}
+
\u6700\u540e\u4fee\u6539: ${_escHtml(lastModified)}
+
\u6587\u4ef6\u5927\u5c0f: ${_escHtml(sizeLabel)}
+
+
${_escHtml(filename)}
+
+ +
+
+ `; + }) + .join(""); +} + +async function _openServerBackupManagerModal() { + if (typeof _actionHandlers.manageServerBackups !== "function") { + toastr.info("\u5f53\u524d\u8fd0\u884c\u65f6\u6ca1\u6709\u63a5\u5165\u670d\u52a1\u5668\u5907\u4efd\u7ba1\u7406\u5165\u53e3", "ST-BME"); + return { handledToast: true, skipDashboardRefresh: true }; + } + + _ensureCloudBackupManagerStyles(); + const { callGenericPopup, POPUP_TYPE } = await getPopupRuntime(); + const state = { + loading: true, + busy: false, + entries: [], + currentChatId: "", + }; + + const container = document.createElement("div"); + container.className = "bme-cloud-backup-modal"; + container.innerHTML = ` +
+
+
\u7ba1\u7406\u670d\u52a1\u5668\u5907\u4efd
+
+ \u8fd9\u91cc\u5c55\u793a\u7684\u662f\u624b\u52a8\u5907\u4efd\u6587\u4ef6\uff0c\u4e0d\u4f1a\u628a\u81ea\u52a8\u540c\u6b65\u955c\u50cf\u6df7\u8fdb\u6765\u3002
+ \u5220\u9664\u64cd\u4f5c\u53ea\u5f71\u54cd\u4e91\u7aef\u5907\u4efd\uff0c\u4e0d\u4f1a\u6539\u52a8\u5f53\u524d\u8bbe\u5907\u7684\u672c\u5730 IndexedDB\u3002 +
+
+
+ +
+
+
+ `; + + const listEl = container.querySelector(".bme-cloud-backup-modal__list"); + const render = () => { + if (!listEl) return; + listEl.innerHTML = _buildCloudBackupManagerHtml(state); + const refreshBtn = container.querySelector('[data-bme-backup-action="refresh"]'); + if (refreshBtn) refreshBtn.disabled = Boolean(state.busy || state.loading); + }; + + const refreshEntries = async ({ showToast = false } = {}) => { + state.loading = true; + render(); + try { + const result = await _actionHandlers.manageServerBackups(); + state.entries = Array.isArray(result?.entries) ? result.entries : []; + state.currentChatId = String(result?.currentChatId || "").trim(); + if (showToast) { + toastr.success("\u670d\u52a1\u5668\u5907\u4efd\u5217\u8868\u5df2\u5237\u65b0", "ST-BME"); + } + } catch (error) { + console.error("[ST-BME] failed to load server backups:", error); + toastr.error(`\u8bfb\u53d6\u670d\u52a1\u5668\u5907\u4efd\u5931\u8d25: ${error?.message || error}`, "ST-BME"); + } finally { + state.loading = false; + render(); + } + }; + + const deleteEntry = async (chatId, filename) => { + if (typeof _actionHandlers.deleteServerBackupEntry !== "function") { + toastr.error("\u5f53\u524d\u8fd0\u884c\u65f6\u6ca1\u6709\u63a5\u5165\u5220\u9664\u670d\u52a1\u5668\u5907\u4efd\u5165\u53e3", "ST-BME"); + return; + } + + if (!globalThis.confirm?.(`\u786e\u5b9a\u8981\u5220\u9664\u670d\u52a1\u5668\u5907\u4efd ${filename} \u5417\uff1f\u6b64\u64cd\u4f5c\u4e0d\u53ef\u64a4\u9500\u3002`)) { + return; + } + + state.busy = true; + render(); + try { + const result = await _actionHandlers.deleteServerBackupEntry({ + chatId, + filename, + }); + if (!result?.deleted) { + const message = + result?.reason === "delete-backup-manifest-error" + ? result?.backupDeleted + ? "\u5907\u4efd\u6587\u4ef6\u5df2\u5220\u9664\uff0c\u4f46\u670d\u52a1\u5668\u5907\u4efd\u6e05\u5355\u66f4\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" + : "\u670d\u52a1\u5668\u5907\u4efd\u6e05\u5355\u66f4\u65b0\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5" + : `\u5220\u9664\u5931\u8d25: ${result?.error?.message || result?.reason || "\u672a\u77e5\u539f\u56e0"}`; + toastr.error(message, "ST-BME"); + return; + } + toastr.success(`\u5df2\u5220\u9664\u670d\u52a1\u5668\u5907\u4efd\uff1a${filename}`, "ST-BME"); + await refreshEntries(); + } catch (error) { + console.error("[ST-BME] failed to delete server backup:", error); + toastr.error(`\u5220\u9664\u5931\u8d25: ${error?.message || error}`, "ST-BME"); + } finally { + state.busy = false; + render(); + void _refreshCloudBackupManualUi(); + } + }; + + container.addEventListener("click", async (event) => { + const button = event.target.closest?.("[data-bme-backup-action]"); + if (!button || button.disabled) return; + const action = String(button.dataset.bmeBackupAction || ""); + if (action === "refresh") { + await refreshEntries({ showToast: true }); + return; + } + if (action === "delete") { + await deleteEntry( + String(button.dataset.chatId || "").trim(), + String(button.dataset.filename || "").trim(), + ); + } + }); + + await refreshEntries(); + await callGenericPopup(container, POPUP_TYPE.TEXT, "", { + okButton: "\u5173\u95ed", + wide: true, + large: true, + allowVerticalScrolling: true, + }); + return { handledToast: true, skipDashboardRefresh: true }; +} + function _normalizeLlmPresetSettings(settings = _getSettings?.() || {}) { const normalized = sanitizeLlmPresetSettings(settings); diff --git a/ui/ui-actions-controller.js b/ui/ui-actions-controller.js index 7a6006f..c1e29e7 100644 --- a/ui/ui-actions-controller.js +++ b/ui/ui-actions-controller.js @@ -1151,6 +1151,7 @@ export async function onDeleteCurrentIdbController(runtime) { } const dbName = runtime.buildBmeDbName(chatId); + const restoreSafetyDbName = runtime.buildRestoreSafetyDbName?.(chatId) || ""; if ( !runtime.confirm( `确定要删除当前聊天的本地缓存数据库?\n\n目标: ${dbName}\n操作不可撤销。`, @@ -1167,6 +1168,14 @@ export async function onDeleteCurrentIdbController(runtime) { req.onerror = () => reject(req.error); req.onblocked = () => resolve(); }); + if (restoreSafetyDbName) { + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(restoreSafetyDbName); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }); + } runtime.toastr.success(`已删除数据库 ${dbName}`); } catch (error) { runtime.toastr.error(`删除失败: ${error?.message || error}`); diff --git a/ui/ui-status.js b/ui/ui-status.js index 7a42ed1..b80110a 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -61,6 +61,12 @@ export function createGraphPersistenceState() { lastSyncUploadedAt: 0, lastSyncDownloadedAt: 0, lastSyncedRevision: 0, + lastBackupUploadedAt: 0, + lastBackupRestoredAt: 0, + lastBackupRollbackAt: 0, + lastBackupFilename: "", + syncDirty: false, + syncDirtyReason: "", lastSyncError: "", dualWriteLastResult: null, updatedAt: new Date().toISOString(),