Add manual cloud backup controls and manager modal

This commit is contained in:
Hao19911125
2026-04-10 17:26:57 +08:00
parent a6b3137511
commit 09b6e1e566
11 changed files with 3016 additions and 925 deletions

280
index.js
View File

@@ -27,7 +27,14 @@ import {
import {
autoSyncOnChatChange,
autoSyncOnVisibility,
backupToServer,
buildRestoreSafetyChatId,
deleteRemoteSyncFile,
deleteServerBackup,
getRestoreSafetySnapshotStatus,
listServerBackups,
rollbackFromRestoreSafetySnapshot,
restoreFromServer,
scheduleUpload,
syncNow,
} from "./sync/bme-sync.js";
@@ -116,6 +123,7 @@ import {
rememberGraphIdentityAlias,
readGraphCommitMarker,
resolveGraphIdentityAliasByHostChatId,
shouldPreferShadowSnapshotOverOfficial,
stampGraphPersistenceMeta,
writeChatMetadataPatch,
writeGraphShadowSnapshot,
@@ -757,10 +765,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,
@@ -4012,7 +4029,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",
@@ -4074,6 +4095,7 @@ function buildBmeSyncRuntimeOptions(extra = {}) {
return await manager.getCurrentDb(chatId);
},
getCurrentChatId: () => getCurrentChatId(),
getCloudStorageMode: () => getSettings().cloudStorageMode || "automatic",
getRequestHeaders,
onSyncApplied: async (payload = {}) => {
await refreshRuntimeGraphAfterSyncApplied(payload);
@@ -4110,14 +4132,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 = {
@@ -4125,9 +4159,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 || ""),
};
@@ -4162,7 +4202,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 || "",
});
@@ -7602,6 +7647,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(
@@ -7668,6 +7716,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;
}
@@ -12191,6 +12271,8 @@ const _cleanupRuntime = () => ({
},
setLastExtractedItems: () => { lastExtractedItems = []; },
buildBmeDbName,
buildRestoreSafetyDbName: (chatId) =>
buildBmeDbName(buildRestoreSafetyChatId(chatId)),
closeBmeDb: null,
deleteRemoteSyncFile: (chatId) => deleteRemoteSyncFile(chatId, {
fetch: globalThis.fetch?.bind(globalThis),
@@ -12227,8 +12309,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();
@@ -12279,6 +12547,12 @@ async function onDeleteServerSyncFile() {
deleteCurrentIdb: onDeleteCurrentIdb,
deleteAllIdb: onDeleteAllIdb,
deleteServerSyncFile: onDeleteServerSyncFile,
backupToCloud: onBackupCurrentChatToCloud,
restoreFromCloud: onRestoreCurrentChatFromCloud,
rollbackLastRestore: onRollbackLastRestore,
manageServerBackups: onManageServerBackups,
deleteServerBackupEntry: onDeleteServerBackupEntry,
getRestoreSafetyStatus: onGetRestoreSafetySnapshotStatus,
},
console,
document,
@@ -12381,3 +12655,5 @@ async function onDeleteServerSyncFile() {
}
debugLog("[ST-BME] 初始化完成");
})();

View File

@@ -144,6 +144,7 @@ export const defaultSettings = {
// UI 面板
noticeDisplayMode: "normal",
panelTheme: "crimson",
cloudStorageMode: "automatic",
};
const DEFAULT_SETTING_KEYS = Object.freeze(Object.keys(defaultSettings));

View File

@@ -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(

View File

@@ -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,

View File

@@ -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({

View File

@@ -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() {

View File

@@ -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();

View File

@@ -1072,6 +1072,74 @@
</label>
</div>
<div class="bme-config-card" style="margin-top: 20px;">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">云端存储模式</div>
<div class="bme-config-card-subtitle">
自动模式沿用当前镜像同步;手动模式停止自动云端写入,改为按当前聊天手动备份与恢复。
</div>
</div>
</div>
<div class="bme-config-row">
<label for="bme-setting-cloud-storage-mode">存储模式</label>
<select
id="bme-setting-cloud-storage-mode"
class="bme-config-input"
>
<option value="automatic">自动储存</option>
<option value="manual">手动储存</option>
</select>
</div>
<div class="bme-config-help" id="bme-cloud-storage-mode-help">
自动储存会继续按当前镜像逻辑同步;手动储存只保留本地写入,需要你主动备份和恢复。
</div>
<div
class="bme-config-help"
id="bme-cloud-storage-mode-status"
style="display: none; margin-top: 8px;"
></div>
<div
id="bme-cloud-backup-manual-actions"
class="bme-config-actions"
style="display: none; margin-top: 12px; flex-wrap: wrap;"
>
<button
class="bme-config-secondary-btn"
id="bme-act-backup-to-cloud"
type="button"
>
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>备份到云端</span>
</button>
<button
class="bme-config-secondary-btn"
id="bme-act-restore-from-cloud"
type="button"
>
<i class="fa-solid fa-cloud-arrow-down"></i>
<span>从云端获取备份</span>
</button>
<button
class="bme-config-secondary-btn"
id="bme-act-manage-server-backups"
type="button"
>
<i class="fa-solid fa-box-archive"></i>
<span>管理服务器备份</span>
</button>
<button
class="bme-config-secondary-btn"
id="bme-act-rollback-last-restore"
type="button"
disabled
>
<i class="fa-solid fa-rotate-left"></i>
<span>回滚上次恢复</span>
</button>
</div>
</div>
<div class="bme-config-grid bme-config-grid-2" style="margin-top: 20px;">
<div class="bme-config-card">
<div class="bme-config-card-head">

File diff suppressed because it is too large Load Diff

View File

@@ -1123,6 +1123,7 @@ export async function onDeleteCurrentIdbController(runtime) {
}
const dbName = runtime.buildBmeDbName(chatId);
const restoreSafetyDbName = runtime.buildRestoreSafetyDbName?.(chatId) || "";
if (
!runtime.confirm(
`确定要删除当前聊天的本地缓存数据库?\n\n目标: ${dbName}\n操作不可撤销。`,
@@ -1139,6 +1140,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}`);

View File

@@ -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(),