Merge branch 'Youzini-afk:main' into main

This commit is contained in:
Hao19911125
2026-04-06 22:40:28 +08:00
committed by GitHub
3 changed files with 1273 additions and 69 deletions

View File

@@ -21,6 +21,7 @@ export const GRAPH_LOAD_STATES = Object.freeze({
}); });
export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__"; export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__";
export const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`; export const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`;
export const GRAPH_IDENTITY_ALIAS_STORAGE_KEY = `${MODULE_NAME}:chat-identity-aliases`;
export const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000]; export const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000];
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -51,6 +52,247 @@ export function createLocalIntegritySlug() {
export const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug(); export const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug();
function normalizeIdentityValue(value) {
return String(value ?? "").trim();
}
function getLocalStorageSafe() {
const storage = globalThis.localStorage;
if (
!storage ||
typeof storage.getItem !== "function" ||
typeof storage.setItem !== "function"
) {
return null;
}
return storage;
}
function getSessionStorageSafe() {
const storage = globalThis.sessionStorage;
if (!storage || typeof storage.getItem !== "function") {
return null;
}
return storage;
}
function listStorageKeys(storage) {
if (!storage) return [];
if (typeof storage.length === "number" && typeof storage.key === "function") {
const keys = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (typeof key === "string" && key) {
keys.push(key);
}
}
return keys;
}
if (storage.__store instanceof Map) {
return Array.from(storage.__store.keys()).map((key) => String(key));
}
return [];
}
function readGraphIdentityAliasRegistryRaw() {
const storage = getLocalStorageSafe();
if (!storage) {
return {
byIntegrity: {},
};
}
try {
const raw = storage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
if (!raw) {
return {
byIntegrity: {},
};
}
const parsed = JSON.parse(raw);
const byIntegrity =
parsed?.byIntegrity &&
typeof parsed.byIntegrity === "object" &&
!Array.isArray(parsed.byIntegrity)
? parsed.byIntegrity
: {};
return {
byIntegrity,
};
} catch {
return {
byIntegrity: {},
};
}
}
function writeGraphIdentityAliasRegistryRaw(registry = null) {
const storage = getLocalStorageSafe();
if (!storage) return false;
try {
storage.setItem(
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
JSON.stringify({
byIntegrity:
registry?.byIntegrity &&
typeof registry.byIntegrity === "object" &&
!Array.isArray(registry.byIntegrity)
? registry.byIntegrity
: {},
}),
);
return true;
} catch {
return false;
}
}
function normalizeGraphIdentityAliasEntry(entry = {}, integrity = "") {
const normalizedIntegrity = normalizeIdentityValue(integrity || entry.integrity);
const normalizedPersistenceChatId = normalizeIdentityValue(
entry.persistenceChatId || normalizedIntegrity,
);
const normalizedHostChatIds = Array.from(
new Set(
(Array.isArray(entry.hostChatIds) ? entry.hostChatIds : [])
.map((value) => normalizeIdentityValue(value))
.filter(Boolean),
),
).slice(-16);
return {
integrity: normalizedIntegrity,
persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity,
hostChatIds: normalizedHostChatIds,
updatedAt: String(entry.updatedAt || ""),
};
}
export function rememberGraphIdentityAlias({
integrity = "",
hostChatId = "",
persistenceChatId = "",
} = {}) {
const normalizedIntegrity = normalizeIdentityValue(integrity);
if (!normalizedIntegrity) return null;
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
const normalizedPersistenceChatId = normalizeIdentityValue(
persistenceChatId || normalizedIntegrity,
);
const registry = readGraphIdentityAliasRegistryRaw();
const existingEntry = normalizeGraphIdentityAliasEntry(
registry.byIntegrity?.[normalizedIntegrity] || {},
normalizedIntegrity,
);
const hostChatIds = Array.from(
new Set(
[normalizedHostChatId, ...existingEntry.hostChatIds].filter(Boolean),
),
).slice(-16);
const nextEntry = {
integrity: normalizedIntegrity,
persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity,
hostChatIds,
updatedAt: new Date().toISOString(),
};
registry.byIntegrity[normalizedIntegrity] = nextEntry;
writeGraphIdentityAliasRegistryRaw(registry);
return nextEntry;
}
export function resolveGraphIdentityAliasByHostChatId(hostChatId = "") {
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
if (!normalizedHostChatId) return "";
const registry = readGraphIdentityAliasRegistryRaw();
let bestEntry = null;
for (const [integrity, value] of Object.entries(registry.byIntegrity || {})) {
const entry = normalizeGraphIdentityAliasEntry(value, integrity);
if (!entry.hostChatIds.includes(normalizedHostChatId)) {
continue;
}
if (!bestEntry) {
bestEntry = entry;
continue;
}
if (String(entry.updatedAt || "") > String(bestEntry.updatedAt || "")) {
bestEntry = entry;
}
}
return normalizeIdentityValue(bestEntry?.persistenceChatId || "");
}
export function getGraphIdentityAliasCandidates({
integrity = "",
hostChatId = "",
persistenceChatId = "",
} = {}) {
const normalizedIntegrity = normalizeIdentityValue(integrity);
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
const normalizedPersistenceChatId = normalizeIdentityValue(persistenceChatId);
const registry = readGraphIdentityAliasRegistryRaw();
const candidates = [];
const seen = new Set();
const pushCandidate = (value) => {
const normalized = normalizeIdentityValue(value);
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
candidates.push(normalized);
};
if (normalizedIntegrity) {
const entry = normalizeGraphIdentityAliasEntry(
registry.byIntegrity?.[normalizedIntegrity] || {},
normalizedIntegrity,
);
pushCandidate(entry.persistenceChatId);
for (const value of entry.hostChatIds) {
pushCandidate(value);
}
} else if (normalizedHostChatId) {
pushCandidate(resolveGraphIdentityAliasByHostChatId(normalizedHostChatId));
}
pushCandidate(normalizedHostChatId);
pushCandidate(normalizedPersistenceChatId);
return candidates;
}
function normalizeShadowSnapshotPayload(snapshot = null) {
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const serializedGraph = String(snapshot.serializedGraph || "");
const chatId = normalizeIdentityValue(snapshot.chatId);
if (!chatId || !serializedGraph) {
return null;
}
return {
chatId,
revision: Number.isFinite(snapshot.revision) ? snapshot.revision : 0,
serializedGraph,
updatedAt: String(snapshot.updatedAt || ""),
reason: String(snapshot.reason || ""),
integrity: normalizeIdentityValue(snapshot.integrity),
persistedChatId: normalizeIdentityValue(snapshot.persistedChatId),
debugReason: String(snapshot.debugReason || snapshot.reason || ""),
};
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 图谱持久化元数据 // 图谱持久化元数据
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -146,33 +388,72 @@ export function readGraphShadowSnapshot(chatId = "") {
if (!storageKey) return null; if (!storageKey) return null;
try { try {
const raw = globalThis.sessionStorage?.getItem(storageKey); const raw = getSessionStorageSafe()?.getItem(storageKey);
if (!raw) return null; if (!raw) return null;
const snapshot = JSON.parse(raw); const snapshot = normalizeShadowSnapshotPayload(JSON.parse(raw));
if ( if (!snapshot || snapshot.chatId !== String(chatId || "")) {
!snapshot ||
typeof snapshot !== "object" ||
String(snapshot.chatId || "") !== String(chatId || "") ||
typeof snapshot.serializedGraph !== "string" ||
!snapshot.serializedGraph
) {
return null; return null;
} }
return { return snapshot;
chatId: String(snapshot.chatId || ""),
revision: Number.isFinite(snapshot.revision) ? snapshot.revision : 0,
serializedGraph: snapshot.serializedGraph,
updatedAt: String(snapshot.updatedAt || ""),
reason: String(snapshot.reason || ""),
integrity: String(snapshot.integrity || ""),
persistedChatId: String(snapshot.persistedChatId || ""),
debugReason: String(snapshot.debugReason || snapshot.reason || ""),
};
} catch { } catch {
return null; return null;
} }
} }
export function findGraphShadowSnapshotByIntegrity(
integrity = "",
{ excludeChatIds = [] } = {},
) {
const normalizedIntegrity = normalizeIdentityValue(integrity);
if (!normalizedIntegrity) return null;
const storage = getSessionStorageSafe();
if (!storage) return null;
const excludedChatIds = new Set(
(Array.isArray(excludeChatIds) ? excludeChatIds : [])
.map((value) => normalizeIdentityValue(value))
.filter(Boolean),
);
let bestSnapshot = null;
for (const key of listStorageKeys(storage)) {
if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) {
continue;
}
try {
const snapshot = normalizeShadowSnapshotPayload(
JSON.parse(storage.getItem(key)),
);
if (!snapshot || snapshot.integrity !== normalizedIntegrity) {
continue;
}
if (excludedChatIds.has(snapshot.chatId)) {
continue;
}
const bestRevision = Number(bestSnapshot?.revision || 0);
const nextRevision = Number(snapshot.revision || 0);
if (!bestSnapshot || nextRevision > bestRevision) {
bestSnapshot = snapshot;
continue;
}
if (
nextRevision === bestRevision &&
String(snapshot.updatedAt || "") > String(bestSnapshot.updatedAt || "")
) {
bestSnapshot = snapshot;
}
} catch {
// ignore broken shadow snapshot payloads
}
}
return bestSnapshot;
}
/** /**
* @param {string} chatId * @param {string} chatId
* @param {object} graph * @param {object} graph
@@ -191,7 +472,7 @@ export function writeGraphShadowSnapshot(
try { try {
const serializedGraph = serializeGraph(graph); const serializedGraph = serializeGraph(graph);
const persistedMeta = getGraphPersistenceMeta(graph) || {}; const persistedMeta = getGraphPersistenceMeta(graph) || {};
globalThis.sessionStorage?.setItem( getSessionStorageSafe()?.setItem(
storageKey, storageKey,
JSON.stringify({ JSON.stringify({
chatId: String(chatId || ""), chatId: String(chatId || ""),
@@ -216,7 +497,7 @@ export function removeGraphShadowSnapshot(chatId = "") {
if (!storageKey) return false; if (!storageKey) return false;
try { try {
globalThis.sessionStorage?.removeItem(storageKey); getSessionStorageSafe()?.removeItem(storageKey);
return true; return true;
} catch { } catch {
return false; return false;

620
index.js
View File

@@ -18,6 +18,8 @@ import {
import { BmeChatManager } from "./bme-chat-manager.js"; import { BmeChatManager } from "./bme-chat-manager.js";
import { import {
BmeDatabase,
buildBmeDbName,
buildGraphFromSnapshot, buildGraphFromSnapshot,
buildSnapshotFromGraph, buildSnapshotFromGraph,
ensureDexieLoaded, ensureDexieLoaded,
@@ -82,6 +84,7 @@ import {
generateSynopsis, generateSynopsis,
} from "./extractor.js"; } from "./extractor.js";
import { import {
findGraphShadowSnapshotByIntegrity,
GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_PENDING_CHAT_ID,
GRAPH_LOAD_STATES, GRAPH_LOAD_STATES,
GRAPH_METADATA_KEY, GRAPH_METADATA_KEY,
@@ -90,7 +93,12 @@ import {
cloneGraphForPersistence, cloneGraphForPersistence,
cloneRuntimeDebugValue, cloneRuntimeDebugValue,
getGraphPersistedRevision, getGraphPersistedRevision,
getGraphPersistenceMeta,
getGraphIdentityAliasCandidates,
readGraphShadowSnapshot,
removeGraphShadowSnapshot, removeGraphShadowSnapshot,
rememberGraphIdentityAlias,
resolveGraphIdentityAliasByHostChatId,
stampGraphPersistenceMeta, stampGraphPersistenceMeta,
writeChatMetadataPatch, writeChatMetadataPatch,
writeGraphShadowSnapshot, writeGraphShadowSnapshot,
@@ -930,10 +938,19 @@ function throwIfAborted(signal, message = "操作已终止") {
function assertRecoveryChatStillActive(expectedChatId, label = "") { function assertRecoveryChatStillActive(expectedChatId, label = "") {
if (!expectedChatId) return; if (!expectedChatId) return;
const currentId = getCurrentChatId(); const currentIdentity = resolveCurrentChatIdentity(getContext());
if (currentId && currentId !== expectedChatId) { const currentId = normalizeChatIdCandidate(currentIdentity.chatId);
const normalizedExpectedChatId = normalizeChatIdCandidate(expectedChatId);
if (
currentId &&
normalizedExpectedChatId &&
!doesChatIdMatchResolvedGraphIdentity(
normalizedExpectedChatId,
currentIdentity,
)
) {
throw createAbortError( throw createAbortError(
`历史恢复已终止:聊天已从 ${expectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`, `历史恢复已终止:聊天已从 ${normalizedExpectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`,
); );
} }
} }
@@ -3309,7 +3326,7 @@ function isHostChatMetadataReady(context = getContext()) {
return false; return false;
} }
function resolveCurrentChatIdentity(context = getContext()) { function resolveCurrentHostChatId(context = getContext()) {
const candidates = [ const candidates = [
context?.chatId, context?.chatId,
context?.getCurrentChatId?.(), context?.getCurrentChatId?.(),
@@ -3320,13 +3337,43 @@ function resolveCurrentChatIdentity(context = getContext()) {
context?.chatMetadata?.sessionId, context?.chatMetadata?.sessionId,
]; ];
const chatId = return (
candidates candidates
.map((candidate) => normalizeChatIdCandidate(candidate)) .map((candidate) => normalizeChatIdCandidate(candidate))
.find(Boolean) || ""; .find(Boolean) || ""
);
}
function resolveCurrentChatIdentity(context = getContext()) {
const hostChatId = resolveCurrentHostChatId(context);
const integrity =
typeof getChatMetadataIntegrity === "function"
? getChatMetadataIntegrity(context)
: normalizeChatIdCandidate(
context?.chatMetadata?.integrity ||
context?.chatMetadata?.chat_id ||
context?.chatMetadata?.chatId ||
"",
);
const aliasedChatId =
!integrity &&
hostChatId &&
typeof resolveGraphIdentityAliasByHostChatId === "function"
? resolveGraphIdentityAliasByHostChatId(hostChatId)
: "";
const chatId = integrity || aliasedChatId || hostChatId;
return { return {
chatId, chatId,
hostChatId,
integrity,
identitySource: integrity
? "integrity"
: aliasedChatId
? "alias"
: hostChatId
? "host-chat-id"
: "",
hasLikelySelectedChat: hasLikelySelectedChatContext(context), hasLikelySelectedChat: hasLikelySelectedChatContext(context),
}; };
} }
@@ -3335,6 +3382,250 @@ function getCurrentChatId(context = getContext()) {
return resolveCurrentChatIdentity(context).chatId; return resolveCurrentChatIdentity(context).chatId;
} }
function rememberResolvedGraphIdentityAlias(
context = getContext(),
persistenceChatId = getCurrentChatId(context),
) {
const identity = resolveCurrentChatIdentity(context);
if (!identity.integrity || !persistenceChatId) {
return null;
}
return rememberGraphIdentityAlias({
integrity: identity.integrity,
hostChatId: identity.hostChatId,
persistenceChatId,
});
}
function buildLegacyGraphIdentityCandidates(
targetChatId,
context = getContext(),
{ shadowSnapshot = null } = {},
) {
const normalizedTargetChatId = normalizeChatIdCandidate(targetChatId);
const identity = resolveCurrentChatIdentity(context);
const candidates = new Set();
const addCandidate = (value) => {
const normalized = normalizeChatIdCandidate(value);
if (!normalized || normalized === normalizedTargetChatId) return;
candidates.add(normalized);
};
addCandidate(identity.hostChatId);
for (const aliasCandidate of getGraphIdentityAliasCandidates({
integrity: identity.integrity,
hostChatId: identity.hostChatId,
persistenceChatId: normalizedTargetChatId,
})) {
addCandidate(aliasCandidate);
}
const currentGraphMeta = getGraphPersistenceMeta(currentGraph) || {};
const runtimeGraphIntegrity = normalizeChatIdCandidate(
currentGraphMeta.integrity || graphPersistenceState.metadataIntegrity,
);
if (
identity.integrity &&
runtimeGraphIntegrity &&
runtimeGraphIntegrity === identity.integrity
) {
addCandidate(graphPersistenceState.chatId);
addCandidate(currentGraph?.historyState?.chatId);
addCandidate(currentGraphMeta.chatId);
}
addCandidate(shadowSnapshot?.chatId);
addCandidate(shadowSnapshot?.persistedChatId);
return Array.from(candidates);
}
async function doesIndexedDbChatStoreExist(chatId = "") {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) return false;
const DexieCtor = globalThis.Dexie || (await ensureDexieLoaded());
if (typeof DexieCtor?.exists === "function") {
return await DexieCtor.exists(buildBmeDbName(normalizedChatId));
}
if (typeof DexieCtor?.getDatabaseNames === "function") {
const names = await DexieCtor.getDatabaseNames();
return Array.isArray(names)
? names.includes(buildBmeDbName(normalizedChatId))
: false;
}
return false;
}
async function exportIndexedDbSnapshotForChat(chatId = "") {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) {
return null;
}
if (!(await doesIndexedDbChatStoreExist(normalizedChatId))) {
return null;
}
const DexieCtor = globalThis.Dexie || (await ensureDexieLoaded());
const db = new BmeDatabase(normalizedChatId, {
dexieClass: DexieCtor,
});
try {
await db.open();
return await db.exportSnapshot();
} finally {
await db.close();
}
}
function buildRecoveredSnapshotForChatIdentity(
graph,
targetChatId,
{
revision = 0,
integrity = "",
source = "identity-recovery",
legacyChatId = "",
} = {},
) {
const normalizedTargetChatId = normalizeChatIdCandidate(targetChatId);
const normalizedIntegrity = normalizeChatIdCandidate(integrity);
const normalizedLegacyChatId = normalizeChatIdCandidate(legacyChatId);
const normalizedGraph = cloneGraphForPersistence(graph, normalizedTargetChatId);
const effectiveRevision = Math.max(
1,
normalizeIndexedDbRevision(
revision || graphPersistenceState.revision || getGraphPersistedRevision(graph),
),
);
stampGraphPersistenceMeta(normalizedGraph, {
revision: effectiveRevision,
reason: source,
chatId: normalizedTargetChatId,
integrity: normalizedIntegrity,
});
return buildSnapshotFromGraph(normalizedGraph, {
chatId: normalizedTargetChatId,
revision: effectiveRevision,
lastModified: Date.now(),
meta: {
storagePrimary: "indexeddb",
lastMutationReason: String(source || "identity-recovery"),
integrity: normalizedIntegrity,
migratedFromChatId: normalizedLegacyChatId,
identityMigrationSource: String(source || "identity-recovery"),
},
});
}
async function importRecoveredSnapshotToIndexedDb(
targetDb,
targetChatId,
graph,
{ revision = 0, integrity = "", source = "identity-recovery", legacyChatId = "" } = {},
) {
const snapshot = buildRecoveredSnapshotForChatIdentity(graph, targetChatId, {
revision,
integrity,
source,
legacyChatId,
});
const importResult = await targetDb.importSnapshot(snapshot, {
mode: "replace",
preserveRevision: true,
revision: snapshot.meta.revision,
markSyncDirty: true,
});
snapshot.meta.revision = normalizeIndexedDbRevision(
importResult?.revision,
snapshot.meta.revision,
);
return snapshot;
}
function doesChatIdMatchResolvedGraphIdentity(
candidateChatId,
identity = resolveCurrentChatIdentity(getContext()),
) {
const normalizedCandidate = normalizeChatIdCandidate(candidateChatId);
if (!normalizedCandidate || !identity || typeof identity !== "object") {
return false;
}
const knownChatIds = new Set();
const addKnownChatId = (value) => {
const normalized = normalizeChatIdCandidate(value);
if (normalized) {
knownChatIds.add(normalized);
}
};
addKnownChatId(identity.chatId);
addKnownChatId(identity.hostChatId);
addKnownChatId(identity.integrity);
for (const aliasCandidate of getGraphIdentityAliasCandidates({
integrity: identity.integrity,
hostChatId: identity.hostChatId,
persistenceChatId: identity.chatId,
})) {
addKnownChatId(aliasCandidate);
}
return knownChatIds.has(normalizedCandidate);
}
function resolveCompatibleGraphShadowSnapshot(
identity = resolveCurrentChatIdentity(getContext()),
) {
if (!identity || typeof identity !== "object") {
return null;
}
const directSnapshot = readGraphShadowSnapshot(identity.chatId);
if (directSnapshot) {
return directSnapshot;
}
const seenChatIds = new Set(
[identity.chatId].map((value) => normalizeChatIdCandidate(value)).filter(Boolean),
);
const readByChatId = (value) => {
const normalized = normalizeChatIdCandidate(value);
if (!normalized || seenChatIds.has(normalized)) {
return null;
}
seenChatIds.add(normalized);
return readGraphShadowSnapshot(normalized);
};
const hostSnapshot = readByChatId(identity.hostChatId);
if (hostSnapshot) {
return hostSnapshot;
}
for (const aliasCandidate of getGraphIdentityAliasCandidates({
integrity: identity.integrity,
hostChatId: identity.hostChatId,
persistenceChatId: identity.chatId,
})) {
const aliasSnapshot = readByChatId(aliasCandidate);
if (aliasSnapshot) {
return aliasSnapshot;
}
}
return findGraphShadowSnapshotByIntegrity(identity.integrity, {
excludeChatIds: Array.from(seenChatIds),
});
}
async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) {
const action = String(syncPayload?.action || "") const action = String(syncPayload?.action || "")
.trim() .trim()
@@ -3348,8 +3639,14 @@ async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) {
} }
const syncedChatId = normalizeChatIdCandidate(syncPayload?.chatId); const syncedChatId = normalizeChatIdCandidate(syncPayload?.chatId);
const activeChatId = normalizeChatIdCandidate(getCurrentChatId()); const activeIdentity = resolveCurrentChatIdentity(getContext());
const targetChatId = syncedChatId || activeChatId; const activeChatId = normalizeChatIdCandidate(activeIdentity.chatId);
const targetChatId =
activeChatId &&
syncedChatId &&
doesChatIdMatchResolvedGraphIdentity(syncedChatId, activeIdentity)
? activeChatId
: syncedChatId || activeChatId;
if (!targetChatId) { if (!targetChatId) {
return { return {
@@ -3671,6 +3968,223 @@ function readLegacyGraphFromChatMetadata(chatId, context = getContext()) {
} }
} }
async function maybeRecoverIndexedDbGraphFromStableIdentity(
chatId,
context = getContext(),
{ source = "unknown", db = null } = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) {
return {
migrated: false,
reason: "identity-recovery-missing-chat-id",
chatId: "",
};
}
const identity = resolveCurrentChatIdentity(context);
if (!identity.integrity) {
return {
migrated: false,
reason: "identity-recovery-integrity-missing",
chatId: normalizedChatId,
};
}
const manager = ensureBmeChatManager();
if (!manager) {
return {
migrated: false,
reason: "identity-recovery-manager-unavailable",
chatId: normalizedChatId,
};
}
const targetDb = db || (await manager.getCurrentDb(normalizedChatId));
if (!targetDb) {
return {
migrated: false,
reason: "identity-recovery-db-unavailable",
chatId: normalizedChatId,
};
}
const emptyStatus = await targetDb.isEmpty();
if (!emptyStatus?.empty) {
return {
migrated: false,
reason: "identity-recovery-target-not-empty",
chatId: normalizedChatId,
emptyStatus,
};
}
const finalizeMigration = async (
graph,
{
revision = 0,
legacyChatId = "",
migrationSource = "identity-recovery",
shadowChatId = "",
} = {},
) => {
const snapshot = await importRecoveredSnapshotToIndexedDb(
targetDb,
normalizedChatId,
graph,
{
revision,
integrity: identity.integrity,
source: migrationSource,
legacyChatId,
},
);
cacheIndexedDbSnapshot(normalizedChatId, snapshot);
rememberResolvedGraphIdentityAlias(context, normalizedChatId);
if (shadowChatId && shadowChatId !== normalizedChatId) {
removeGraphShadowSnapshot(shadowChatId);
}
let syncResult = {
synced: false,
reason: "identity-recovery-sync-skipped",
chatId: normalizedChatId,
};
try {
syncResult = await syncNow(
normalizedChatId,
buildBmeSyncRuntimeOptions({
reason: "identity-recovery",
trigger: `${String(source || "identity-recovery")}:identity-recovery`,
}),
);
} catch (syncError) {
console.warn("[ST-BME] 身份恢复后的同步失败:", syncError);
syncResult = {
synced: false,
reason: "identity-recovery-sync-failed",
chatId: normalizedChatId,
error: syncError?.message || String(syncError),
};
}
return {
migrated: true,
reason: "identity-recovery-completed",
chatId: normalizedChatId,
legacyChatId: normalizeChatIdCandidate(legacyChatId),
source: migrationSource,
snapshot,
syncResult,
};
};
const currentGraphMeta = getGraphPersistenceMeta(currentGraph) || {};
const runtimeGraphIntegrity = normalizeChatIdCandidate(
currentGraphMeta.integrity || graphPersistenceState.metadataIntegrity,
);
const runtimeGraphChatId = normalizeChatIdCandidate(
currentGraph?.historyState?.chatId ||
currentGraphMeta.chatId ||
graphPersistenceState.chatId,
);
if (
currentGraph &&
!isGraphEffectivelyEmpty(currentGraph) &&
runtimeGraphIntegrity &&
runtimeGraphIntegrity === identity.integrity &&
runtimeGraphChatId &&
runtimeGraphChatId !== normalizedChatId
) {
return await finalizeMigration(currentGraph, {
revision: Math.max(
graphPersistenceState.revision || 0,
getGraphPersistedRevision(currentGraph),
1,
),
legacyChatId: runtimeGraphChatId,
migrationSource: "runtime-identity-promotion",
});
}
const aliasShadowSnapshot = findGraphShadowSnapshotByIntegrity(
identity.integrity,
{
excludeChatIds: [normalizedChatId],
},
);
if (aliasShadowSnapshot?.serializedGraph) {
try {
const shadowGraph = normalizeGraphRuntimeState(
deserializeGraph(aliasShadowSnapshot.serializedGraph),
normalizedChatId,
);
if (!isGraphEffectivelyEmpty(shadowGraph)) {
return await finalizeMigration(shadowGraph, {
revision: Math.max(
Number(aliasShadowSnapshot.revision || 0),
getGraphPersistedRevision(shadowGraph),
1,
),
legacyChatId:
aliasShadowSnapshot.persistedChatId || aliasShadowSnapshot.chatId,
migrationSource: "shadow-identity-recovery",
shadowChatId: aliasShadowSnapshot.chatId,
});
}
} catch (error) {
console.warn("[ST-BME] 通过影子快照恢复聊天身份失败:", error);
}
}
const legacyCandidates = buildLegacyGraphIdentityCandidates(
normalizedChatId,
context,
{
shadowSnapshot: aliasShadowSnapshot,
},
);
for (const legacyChatId of legacyCandidates) {
try {
const legacySnapshot = await exportIndexedDbSnapshotForChat(legacyChatId);
if (!isIndexedDbSnapshotMeaningful(legacySnapshot)) {
continue;
}
const legacyGraph = buildGraphFromSnapshot(legacySnapshot, {
chatId: legacyChatId,
});
if (isGraphEffectivelyEmpty(legacyGraph)) {
continue;
}
return await finalizeMigration(legacyGraph, {
revision: Math.max(
normalizeIndexedDbRevision(legacySnapshot?.meta?.revision),
getGraphPersistedRevision(legacyGraph),
1,
),
legacyChatId,
migrationSource: "indexeddb-identity-alias",
});
} catch (error) {
console.warn("[ST-BME] 读取旧身份 IndexedDB 图谱失败:", {
legacyChatId,
error,
});
}
}
return {
migrated: false,
reason: "identity-recovery-no-match",
chatId: normalizedChatId,
};
}
async function maybeMigrateLegacyGraphToIndexedDb( async function maybeMigrateLegacyGraphToIndexedDb(
chatId, chatId,
context = getContext(), context = getContext(),
@@ -3980,6 +4494,14 @@ function applyIndexedDbSnapshotToRuntime(
normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId), normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
normalizedChatId, normalizedChatId,
); );
stampGraphPersistenceMeta(currentGraph, {
revision,
reason: `indexeddb:${String(source || "indexeddb")}`,
chatId: normalizedChatId,
integrity:
normalizeChatIdCandidate(snapshot?.meta?.integrity) ||
getChatMetadataIntegrity(getContext()),
});
currentGraph.vectorIndexState.lastIntegrityIssue = null; currentGraph.vectorIndexState.lastIntegrityIssue = null;
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
@@ -4045,6 +4567,7 @@ function applyIndexedDbSnapshotToRuntime(
at: Date.now(), at: Date.now(),
}, },
}); });
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
removeGraphShadowSnapshot(normalizedChatId); removeGraphShadowSnapshot(normalizedChatId);
refreshPanelLiveState(); refreshPanelLiveState();
@@ -4101,14 +4624,59 @@ async function loadGraphFromIndexedDb(
} }
const db = await manager.getCurrentDb(normalizedChatId); const db = await manager.getCurrentDb(normalizedChatId);
const migrationResult = await maybeMigrateLegacyGraphToIndexedDb( const identityRecoveryResult =
normalizedChatId, await maybeRecoverIndexedDbGraphFromStableIdentity(
getContext(), normalizedChatId,
{ getContext(),
source, {
db, source,
}, db,
); },
);
if (identityRecoveryResult?.migrated) {
const recoveredRevision = normalizeIndexedDbRevision(
identityRecoveryResult?.snapshot?.meta?.revision,
);
updateGraphPersistenceState({
storagePrimary: "indexeddb",
storageMode: "indexeddb",
indexedDbRevision: recoveredRevision,
indexedDbLastError: "",
lastSyncError: "",
dualWriteLastResult: {
action: "identity-recovery",
source: String(identityRecoveryResult?.source || "indexeddb"),
success: true,
chatId: normalizedChatId,
legacyChatId: String(identityRecoveryResult?.legacyChatId || ""),
revision: recoveredRevision,
reason: String(
identityRecoveryResult?.reason || "identity-recovery",
),
at: Date.now(),
syncResult: cloneRuntimeDebugValue(
identityRecoveryResult?.syncResult,
null,
),
},
});
}
const migrationResult = identityRecoveryResult?.migrated
? {
migrated: false,
reason: "identity-recovery-already-applied",
chatId: normalizedChatId,
}
: await maybeMigrateLegacyGraphToIndexedDb(
normalizedChatId,
getContext(),
{
source,
db,
},
);
if (migrationResult?.migrated) { if (migrationResult?.migrated) {
const migratedRevision = normalizeIndexedDbRevision( const migratedRevision = normalizeIndexedDbRevision(
@@ -4146,8 +4714,11 @@ async function loadGraphFromIndexedDb(
}, },
}); });
} }
const snapshot =
identityRecoveryResult?.snapshot ||
migrationResult?.snapshot ||
(await db.exportSnapshot());
const snapshot = migrationResult?.snapshot || (await db.exportSnapshot());
cacheIndexedDbSnapshot(normalizedChatId, snapshot); cacheIndexedDbSnapshot(normalizedChatId, snapshot);
if (!isIndexedDbSnapshotMeaningful(snapshot)) { if (!isIndexedDbSnapshotMeaningful(snapshot)) {
@@ -4713,6 +5284,7 @@ function persistGraphToChatMetadata(
queuedPersistRotateIntegrity: false, queuedPersistRotateIntegrity: false,
queuedPersistReason: "", queuedPersistReason: "",
}); });
rememberResolvedGraphIdentityAlias(context, chatId);
return buildGraphPersistResult({ return buildGraphPersistResult({
saved: true, saved: true,
@@ -5949,7 +6521,7 @@ function loadGraphFromChat(options = {}) {
normalizeGraphRuntimeState(deserializeGraph(savedData), chatId), normalizeGraphRuntimeState(deserializeGraph(savedData), chatId),
chatId, chatId,
); );
const shadowSnapshot = readGraphShadowSnapshot(chatId); const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity);
const shadowDecision = shouldPreferShadowSnapshotOverOfficial( const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
officialGraph, officialGraph,
shadowSnapshot, shadowSnapshot,
@@ -5976,6 +6548,12 @@ function loadGraphFromChat(options = {}) {
clearPendingGraphLoadRetry(); clearPendingGraphLoadRetry();
currentGraph = officialGraph; currentGraph = officialGraph;
stampGraphPersistenceMeta(currentGraph, {
revision: officialRevision,
reason: `${source}:metadata-compat-provisional`,
chatId,
integrity: getChatMetadataIntegrity(context),
});
extractionCount = Number.isFinite( extractionCount = Number.isFinite(
currentGraph?.historyState?.extractionCount, currentGraph?.historyState?.extractionCount,
) )
@@ -6043,6 +6621,7 @@ function loadGraphFromChat(options = {}) {
at: Date.now(), at: Date.now(),
}, },
}); });
rememberResolvedGraphIdentityAlias(context, chatId);
scheduleIndexedDbGraphProbe(chatId, { scheduleIndexedDbGraphProbe(chatId, {
source: `${source}:indexeddb-probe`, source: `${source}:indexeddb-probe`,
@@ -6126,6 +6705,7 @@ async function saveGraphToIndexedDb(
}; };
} }
const db = await manager.getCurrentDb(normalizedChatId); const db = await manager.getCurrentDb(normalizedChatId);
const currentIdentity = resolveCurrentChatIdentity(getContext());
const baseSnapshot = const baseSnapshot =
readCachedIndexedDbSnapshot(normalizedChatId) || readCachedIndexedDbSnapshot(normalizedChatId) ||
(await db.exportSnapshot()); (await db.exportSnapshot());
@@ -6137,6 +6717,9 @@ async function saveGraphToIndexedDb(
meta: { meta: {
storagePrimary: "indexeddb", storagePrimary: "indexeddb",
lastMutationReason: String(reason || "graph-save"), lastMutationReason: String(reason || "graph-save"),
integrity:
currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
hostChatId: currentIdentity.hostChatId || "",
}, },
}); });
const importResult = await db.importSnapshot(snapshot, { const importResult = await db.importSnapshot(snapshot, {
@@ -6179,6 +6762,7 @@ async function saveGraphToIndexedDb(
at: Date.now(), at: Date.now(),
}, },
}); });
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
return { return {
saved: true, saved: true,

View File

@@ -4,15 +4,22 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import vm from "node:vm"; import vm from "node:vm";
import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js"; import {
buildBmeDbName,
buildGraphFromSnapshot,
buildSnapshotFromGraph,
} from "../bme-db.js";
import { onMessageReceivedController } from "../event-binding.js"; import { onMessageReceivedController } from "../event-binding.js";
import { import {
cloneGraphForPersistence, cloneGraphForPersistence,
cloneRuntimeDebugValue, cloneRuntimeDebugValue,
findGraphShadowSnapshotByIntegrity,
getGraphPersistedRevision, getGraphPersistedRevision,
getGraphIdentityAliasCandidates,
getGraphPersistenceMeta, getGraphPersistenceMeta,
getGraphShadowSnapshotStorageKey, getGraphShadowSnapshotStorageKey,
GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_PENDING_CHAT_ID,
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
GRAPH_LOAD_STATES, GRAPH_LOAD_STATES,
GRAPH_METADATA_KEY, GRAPH_METADATA_KEY,
GRAPH_PERSISTENCE_META_KEY, GRAPH_PERSISTENCE_META_KEY,
@@ -21,7 +28,9 @@ import {
GRAPH_STARTUP_RECONCILE_DELAYS_MS, GRAPH_STARTUP_RECONCILE_DELAYS_MS,
MODULE_NAME, MODULE_NAME,
readGraphShadowSnapshot, readGraphShadowSnapshot,
rememberGraphIdentityAlias,
removeGraphShadowSnapshot, removeGraphShadowSnapshot,
resolveGraphIdentityAliasByHostChatId,
shouldPreferShadowSnapshotOverOfficial, shouldPreferShadowSnapshotOverOfficial,
stampGraphPersistenceMeta, stampGraphPersistenceMeta,
writeChatMetadataPatch, writeChatMetadataPatch,
@@ -83,6 +92,28 @@ const messageSnippet = extractSnippet(
); );
function createSessionStorage(seed = null) { function createSessionStorage(seed = null) {
const store = seed instanceof Map ? seed : new Map();
return {
__store: store,
get length() {
return store.size;
},
key(index) {
return Array.from(store.keys())[Number(index)] ?? null;
},
getItem(key) {
return store.has(key) ? store.get(key) : null;
},
setItem(key, value) {
store.set(String(key), String(value));
},
removeItem(key) {
store.delete(String(key));
},
};
}
function createLocalStorage(seed = null) {
const store = seed instanceof Map ? seed : new Map(); const store = seed instanceof Map ? seed : new Map();
return { return {
__store: store, __store: store,
@@ -154,15 +185,75 @@ async function createGraphPersistenceHarness({
chatId = "chat-test", chatId = "chat-test",
chatMetadata = undefined, chatMetadata = undefined,
sessionStore = null, sessionStore = null,
localStore = null,
globalChatId = "", globalChatId = "",
characterId = "", characterId = "",
groupId = null, groupId = null,
indexedDbSnapshot = null, indexedDbSnapshot = null,
indexedDbSnapshots = null,
chat = [], chat = [],
} = {}) { } = {}) {
const timers = new Map(); const timers = new Map();
let nextTimerId = 1; let nextTimerId = 1;
const storage = createSessionStorage(sessionStore); const storage = createSessionStorage(sessionStore);
const localStorage = createLocalStorage(localStore);
const indexedDbSnapshotMap =
indexedDbSnapshots instanceof Map
? new Map(indexedDbSnapshots)
: new Map(
Object.entries(
indexedDbSnapshots &&
typeof indexedDbSnapshots === "object" &&
!Array.isArray(indexedDbSnapshots)
? indexedDbSnapshots
: {},
),
);
if (indexedDbSnapshot) {
const primaryChatId = String(chatId || globalChatId || "");
if (primaryChatId) {
indexedDbSnapshotMap.set(primaryChatId, structuredClone(indexedDbSnapshot));
}
}
function buildEmptyIndexedDbSnapshot(targetChatId = "") {
return {
meta: { revision: 0, chatId: String(targetChatId || "") },
nodes: [],
edges: [],
tombstones: [],
state: { lastProcessedFloor: -1, extractionCount: 0 },
};
}
function getIndexedDbSnapshotForChat(targetChatId = "") {
const normalizedChatId = String(targetChatId || "");
if (normalizedChatId && indexedDbSnapshotMap.has(normalizedChatId)) {
return structuredClone(indexedDbSnapshotMap.get(normalizedChatId));
}
if (
normalizedChatId &&
indexedDbSnapshot &&
!indexedDbSnapshotMap.size &&
normalizedChatId === String(chatId || globalChatId || "")
) {
return structuredClone(indexedDbSnapshot);
}
return buildEmptyIndexedDbSnapshot(normalizedChatId);
}
function setIndexedDbSnapshotForChat(targetChatId = "", snapshot = null) {
const normalizedChatId = String(targetChatId || "");
if (!normalizedChatId) return;
if (!snapshot) {
indexedDbSnapshotMap.delete(normalizedChatId);
return;
}
indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot));
}
const runtimeContext = { const runtimeContext = {
console, console,
@@ -176,8 +267,12 @@ async function createGraphPersistenceHarness({
Boolean, Boolean,
structuredClone, structuredClone,
result: null, result: null,
__indexedDbSnapshot: indexedDbSnapshot, __indexedDbSnapshot: getIndexedDbSnapshotForChat(
String(chatId || globalChatId || ""),
),
__indexedDbSnapshots: indexedDbSnapshotMap,
sessionStorage: storage, sessionStorage: storage,
localStorage,
setTimeout(fn, delay) { setTimeout(fn, delay) {
const id = nextTimerId++; const id = nextTimerId++;
timers.set(id, { fn, delay }); timers.set(id, { fn, delay });
@@ -211,6 +306,21 @@ async function createGraphPersistenceHarness({
}, },
}, },
__globalChatId: String(globalChatId || ""), __globalChatId: String(globalChatId || ""),
Dexie: {
async exists(dbName = "") {
return Array.from(indexedDbSnapshotMap.keys()).some(
(candidateChatId) => buildBmeDbName(candidateChatId) === String(dbName),
);
},
async getDatabaseNames() {
return Array.from(indexedDbSnapshotMap.keys()).map((candidateChatId) =>
buildBmeDbName(candidateChatId),
);
},
},
async ensureDexieLoaded() {
return runtimeContext.Dexie;
},
refreshPanelLiveState() { refreshPanelLiveState() {
runtimeContext.__panelRefreshCount += 1; runtimeContext.__panelRefreshCount += 1;
}, },
@@ -241,7 +351,9 @@ async function createGraphPersistenceHarness({
onMessageReceivedController, onMessageReceivedController,
getGraphPersistenceMeta, getGraphPersistenceMeta,
getGraphPersistedRevision, getGraphPersistedRevision,
getGraphIdentityAliasCandidates,
getGraphShadowSnapshotStorageKey, getGraphShadowSnapshotStorageKey,
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_PENDING_CHAT_ID,
GRAPH_LOAD_STATES, GRAPH_LOAD_STATES,
GRAPH_METADATA_KEY, GRAPH_METADATA_KEY,
@@ -250,14 +362,141 @@ async function createGraphPersistenceHarness({
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX, GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
GRAPH_STARTUP_RECONCILE_DELAYS_MS, GRAPH_STARTUP_RECONCILE_DELAYS_MS,
MODULE_NAME, MODULE_NAME,
findGraphShadowSnapshotByIntegrity,
readGraphShadowSnapshot, readGraphShadowSnapshot,
rememberGraphIdentityAlias,
removeGraphShadowSnapshot, removeGraphShadowSnapshot,
resolveGraphIdentityAliasByHostChatId,
shouldPreferShadowSnapshotOverOfficial, shouldPreferShadowSnapshotOverOfficial,
stampGraphPersistenceMeta, stampGraphPersistenceMeta,
writeChatMetadataPatch, writeChatMetadataPatch,
writeGraphShadowSnapshot, writeGraphShadowSnapshot,
// Shadow snapshot functions need VM-local sessionStorage overrides // Shadow snapshot functions need VM-local sessionStorage overrides
// because imported versions use the outer globalThis (no sessionStorage) // because imported versions use the outer globalThis (no sessionStorage)
rememberGraphIdentityAlias({
integrity = "",
hostChatId = "",
persistenceChatId = "",
} = {}) {
const normalizedIntegrity = String(integrity || "").trim();
if (!normalizedIntegrity) return null;
const normalizedHostChatId = String(hostChatId || "").trim();
const normalizedPersistenceChatId = String(
persistenceChatId || normalizedIntegrity,
).trim();
let registry = { byIntegrity: {} };
try {
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (
parsed?.byIntegrity &&
typeof parsed.byIntegrity === "object" &&
!Array.isArray(parsed.byIntegrity)
) {
registry = { byIntegrity: parsed.byIntegrity };
}
}
} catch {
registry = { byIntegrity: {} };
}
const current = registry.byIntegrity[normalizedIntegrity] || {};
const hostChatIds = Array.from(
new Set(
[
normalizedHostChatId,
...(Array.isArray(current.hostChatIds) ? current.hostChatIds : []),
].filter(Boolean),
),
);
const next = {
integrity: normalizedIntegrity,
persistenceChatId: normalizedPersistenceChatId,
hostChatIds,
updatedAt: new Date().toISOString(),
};
registry.byIntegrity[normalizedIntegrity] = next;
localStorage.setItem(
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
JSON.stringify(registry),
);
return next;
},
resolveGraphIdentityAliasByHostChatId(hostChatId = "") {
const normalizedHostChatId = String(hostChatId || "").trim();
if (!normalizedHostChatId) return "";
try {
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} };
let best = "";
let bestUpdatedAt = "";
for (const value of Object.values(parsed.byIntegrity || {})) {
const hostChatIds = Array.isArray(value?.hostChatIds)
? value.hostChatIds.map((item) => String(item || "").trim())
: [];
if (!hostChatIds.includes(normalizedHostChatId)) continue;
const persistenceChatId = String(
value?.persistenceChatId || value?.integrity || "",
).trim();
if (!persistenceChatId) continue;
const updatedAt = String(value?.updatedAt || "");
if (!best || updatedAt > bestUpdatedAt) {
best = persistenceChatId;
bestUpdatedAt = updatedAt;
}
}
return best;
} catch {
return "";
}
},
getGraphIdentityAliasCandidates({
integrity = "",
hostChatId = "",
persistenceChatId = "",
} = {}) {
const normalizedIntegrity = String(integrity || "").trim();
const normalizedHostChatId = String(hostChatId || "").trim();
const normalizedPersistenceChatId = String(
persistenceChatId || "",
).trim();
const candidates = [];
const seen = new Set();
const addCandidate = (value) => {
const normalized = String(value || "").trim();
if (!normalized || seen.has(normalized)) return;
seen.add(normalized);
candidates.push(normalized);
};
try {
const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} };
if (normalizedIntegrity) {
const value = parsed.byIntegrity?.[normalizedIntegrity] || {};
addCandidate(value?.persistenceChatId || value?.integrity || "");
for (const candidate of Array.isArray(value?.hostChatIds)
? value.hostChatIds
: []) {
addCandidate(candidate);
}
} else if (normalizedHostChatId) {
addCandidate(
runtimeContext.resolveGraphIdentityAliasByHostChatId(
normalizedHostChatId,
),
);
}
} catch {
// ignore
}
addCandidate(normalizedHostChatId);
addCandidate(normalizedPersistenceChatId);
return candidates;
},
readGraphShadowSnapshot(chatId = "") { readGraphShadowSnapshot(chatId = "") {
const key = getGraphShadowSnapshotStorageKey(chatId); const key = getGraphShadowSnapshotStorageKey(chatId);
if (!key) return null; if (!key) return null;
@@ -286,6 +525,56 @@ async function createGraphPersistenceHarness({
return null; return null;
} }
}, },
findGraphShadowSnapshotByIntegrity(integrity = "", { excludeChatIds = [] } = {}) {
const normalizedIntegrity = String(integrity || "").trim();
if (!normalizedIntegrity) return null;
const excluded = new Set(
(Array.isArray(excludeChatIds) ? excludeChatIds : [])
.map((value) => String(value || "").trim())
.filter(Boolean),
);
let best = null;
for (const key of storage.__store.keys()) {
if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) {
continue;
}
try {
const snap = JSON.parse(storage.getItem(key));
if (
!snap ||
String(snap.integrity || "") !== normalizedIntegrity ||
typeof snap.serializedGraph !== "string" ||
!snap.serializedGraph
) {
continue;
}
const normalizedChatId = String(snap.chatId || "").trim();
if (!normalizedChatId || excluded.has(normalizedChatId)) {
continue;
}
if (
!best ||
Number(snap.revision || 0) > Number(best.revision || 0) ||
(Number(snap.revision || 0) === Number(best.revision || 0) &&
String(snap.updatedAt || "") > String(best.updatedAt || ""))
) {
best = {
chatId: normalizedChatId,
revision: Number.isFinite(snap.revision) ? snap.revision : 0,
serializedGraph: snap.serializedGraph,
updatedAt: String(snap.updatedAt || ""),
reason: String(snap.reason || ""),
integrity: String(snap.integrity || ""),
persistedChatId: String(snap.persistedChatId || ""),
debugReason: String(snap.debugReason || snap.reason || ""),
};
}
} catch {
// ignore
}
}
return best;
},
writeGraphShadowSnapshot( writeGraphShadowSnapshot(
chatId = "", chatId = "",
graph = null, graph = null,
@@ -350,6 +639,8 @@ async function createGraphPersistenceHarness({
return true; return true;
}, },
notifyExtractionIssue() {}, notifyExtractionIssue() {},
debugDebug() {},
debugLog() {},
async runExtraction() {}, async runExtraction() {},
getRequestHeaders() { getRequestHeaders() {
return {}; return {};
@@ -394,24 +685,37 @@ async function createGraphPersistenceHarness({
__contextImmediateSaveCalls: 0, __contextImmediateSaveCalls: 0,
buildGraphFromSnapshot, buildGraphFromSnapshot,
buildSnapshotFromGraph, buildSnapshotFromGraph,
buildBmeDbName,
scheduleUpload() {}, scheduleUpload() {},
BmeDatabase: class {
constructor(dbChatId = "") {
this.chatId = String(dbChatId || "");
}
async open() {}
async close() {}
async exportSnapshot() {
return getIndexedDbSnapshotForChat(this.chatId);
}
async importSnapshot(snapshot) {
setIndexedDbSnapshotForChat(this.chatId, snapshot);
return {
revision: Number(snapshot?.meta?.revision) || 0,
};
}
},
BmeChatManager: class { BmeChatManager: class {
constructor() { constructor() {
this._db = { this._currentChatId = "";
}
_createDb(dbChatId = "") {
return {
async exportSnapshot() { async exportSnapshot() {
if (runtimeContext.__indexedDbSnapshot) { return getIndexedDbSnapshotForChat(dbChatId);
return structuredClone(runtimeContext.__indexedDbSnapshot);
}
return {
meta: { revision: 0, chatId: "" },
nodes: [],
edges: [],
tombstones: [],
state: { lastProcessedFloor: -1, extractionCount: 0 },
};
}, },
async importSnapshot(snapshot) { async importSnapshot(snapshot) {
runtimeContext.__indexedDbSnapshot = structuredClone(snapshot); setIndexedDbSnapshotForChat(dbChatId, snapshot);
runtimeContext.__indexedDbSnapshot =
getIndexedDbSnapshotForChat(dbChatId);
return { return {
revision: revision:
Number(snapshot?.meta?.revision) || Number(snapshot?.meta?.revision) ||
@@ -420,19 +724,18 @@ async function createGraphPersistenceHarness({
}; };
}, },
async getMeta(key, fallbackValue = 0) { async getMeta(key, fallbackValue = 0) {
const snapshot = runtimeContext.__indexedDbSnapshot || {}; const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
if (!snapshot?.meta || !(key in snapshot.meta)) { if (!snapshot?.meta || !(key in snapshot.meta)) {
return fallbackValue; return fallbackValue;
} }
return snapshot.meta[key]; return snapshot.meta[key];
}, },
async getRevision() { async getRevision() {
return ( const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) || 0 return Number(snapshot?.meta?.revision) || 0;
);
}, },
async isEmpty() { async isEmpty() {
const snapshot = runtimeContext.__indexedDbSnapshot || {}; const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
const nodes = Array.isArray(snapshot?.nodes) const nodes = Array.isArray(snapshot?.nodes)
? snapshot.nodes.length ? snapshot.nodes.length
: 0; : 0;
@@ -451,33 +754,44 @@ async function createGraphPersistenceHarness({
}, },
async importLegacyGraph(graph, options = {}) { async importLegacyGraph(graph, options = {}) {
const revision = Number(options?.revision) || 1; const revision = Number(options?.revision) || 1;
runtimeContext.__indexedDbSnapshot = buildSnapshotFromGraph(graph, { const migratedSnapshot = buildSnapshotFromGraph(graph, {
chatId: runtimeContext.__chatContext?.chatId || "", chatId: dbChatId || runtimeContext.__chatContext?.chatId || "",
revision, revision,
meta: { meta: {
migrationCompletedAt: Date.now(), migrationCompletedAt: Date.now(),
migrationSource: "chat_metadata", migrationSource: "chat_metadata",
}, },
}); });
setIndexedDbSnapshotForChat(dbChatId, migratedSnapshot);
runtimeContext.__indexedDbSnapshot =
getIndexedDbSnapshotForChat(dbChatId);
return { return {
migrated: true, migrated: true,
revision, revision,
imported: { imported: {
nodes: runtimeContext.__indexedDbSnapshot.nodes.length, nodes: runtimeContext.__indexedDbSnapshot?.nodes?.length || 0,
edges: runtimeContext.__indexedDbSnapshot.edges.length, edges: runtimeContext.__indexedDbSnapshot?.edges?.length || 0,
tombstones: tombstones:
runtimeContext.__indexedDbSnapshot.tombstones.length, runtimeContext.__indexedDbSnapshot?.tombstones?.length || 0,
}, },
}; };
}, },
async markSyncDirty() {}, async markSyncDirty() {},
}; };
} }
async getCurrentDb() { async getCurrentDb(dbChatId = this._currentChatId) {
return this._db; this._currentChatId = String(dbChatId || this._currentChatId || "");
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
this._currentChatId,
);
return this._createDb(this._currentChatId);
} }
async switchChat() { async switchChat(dbChatId = "") {
return this._db; this._currentChatId = String(dbChatId || "");
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
this._currentChatId,
);
return this._createDb(this._currentChatId);
} }
async closeCurrent() {} async closeCurrent() {}
}, },
@@ -544,11 +858,33 @@ result = {
return globalThis.__chatContext; return globalThis.__chatContext;
}, },
setIndexedDbSnapshot(snapshot) { setIndexedDbSnapshot(snapshot) {
globalThis.__indexedDbSnapshot = snapshot; const activeChatId =
String(globalThis.__chatContext?.chatId || globalThis.__globalChatId || "");
if (activeChatId) {
globalThis.__indexedDbSnapshots.set(
activeChatId,
structuredClone(snapshot),
);
}
globalThis.__indexedDbSnapshot = structuredClone(snapshot);
}, },
getIndexedDbSnapshot() { getIndexedDbSnapshot() {
return globalThis.__indexedDbSnapshot; return globalThis.__indexedDbSnapshot;
}, },
setIndexedDbSnapshotForChat(chatId, snapshot) {
const normalizedChatId = String(chatId || "");
if (!normalizedChatId) return;
globalThis.__indexedDbSnapshots.set(
normalizedChatId,
structuredClone(snapshot),
);
},
getIndexedDbSnapshotForChat(chatId) {
const normalizedChatId = String(chatId || "");
if (!normalizedChatId) return null;
const snapshot = globalThis.__indexedDbSnapshots.get(normalizedChatId);
return snapshot ? structuredClone(snapshot) : null;
},
}; };
`, `,
].join("\n"), ].join("\n"),
@@ -708,7 +1044,10 @@ result = {
assert.equal(result.synced, true); assert.equal(result.synced, true);
assert.equal(result.loadState, "loading"); assert.equal(result.loadState, "loading");
assert.equal(harness.api.getCurrentGraph().historyState.chatId, "chat-late"); assert.equal(
harness.api.getCurrentGraph().historyState.chatId,
"chat-late-ready",
);
assert.equal(harness.api.getGraphPersistenceState().dbReady, true); assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
assert.equal( assert.equal(
harness.api.getGraphPersistenceState().storagePrimary, harness.api.getGraphPersistenceState().storagePrimary,