mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Merge branch 'Youzini-afk:main' into main
This commit is contained in:
@@ -21,6 +21,7 @@ export const GRAPH_LOAD_STATES = Object.freeze({
|
||||
});
|
||||
export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__";
|
||||
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];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -51,6 +52,247 @@ export function 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;
|
||||
|
||||
try {
|
||||
const raw = globalThis.sessionStorage?.getItem(storageKey);
|
||||
const raw = getSessionStorageSafe()?.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const snapshot = JSON.parse(raw);
|
||||
if (
|
||||
!snapshot ||
|
||||
typeof snapshot !== "object" ||
|
||||
String(snapshot.chatId || "") !== String(chatId || "") ||
|
||||
typeof snapshot.serializedGraph !== "string" ||
|
||||
!snapshot.serializedGraph
|
||||
) {
|
||||
const snapshot = normalizeShadowSnapshotPayload(JSON.parse(raw));
|
||||
if (!snapshot || snapshot.chatId !== String(chatId || "")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
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 || ""),
|
||||
};
|
||||
return snapshot;
|
||||
} catch {
|
||||
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 {object} graph
|
||||
@@ -191,7 +472,7 @@ export function writeGraphShadowSnapshot(
|
||||
try {
|
||||
const serializedGraph = serializeGraph(graph);
|
||||
const persistedMeta = getGraphPersistenceMeta(graph) || {};
|
||||
globalThis.sessionStorage?.setItem(
|
||||
getSessionStorageSafe()?.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
chatId: String(chatId || ""),
|
||||
@@ -216,7 +497,7 @@ export function removeGraphShadowSnapshot(chatId = "") {
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
globalThis.sessionStorage?.removeItem(storageKey);
|
||||
getSessionStorageSafe()?.removeItem(storageKey);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
620
index.js
620
index.js
@@ -18,6 +18,8 @@ import {
|
||||
|
||||
import { BmeChatManager } from "./bme-chat-manager.js";
|
||||
import {
|
||||
BmeDatabase,
|
||||
buildBmeDbName,
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
ensureDexieLoaded,
|
||||
@@ -82,6 +84,7 @@ import {
|
||||
generateSynopsis,
|
||||
} from "./extractor.js";
|
||||
import {
|
||||
findGraphShadowSnapshotByIntegrity,
|
||||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||||
GRAPH_LOAD_STATES,
|
||||
GRAPH_METADATA_KEY,
|
||||
@@ -90,7 +93,12 @@ import {
|
||||
cloneGraphForPersistence,
|
||||
cloneRuntimeDebugValue,
|
||||
getGraphPersistedRevision,
|
||||
getGraphPersistenceMeta,
|
||||
getGraphIdentityAliasCandidates,
|
||||
readGraphShadowSnapshot,
|
||||
removeGraphShadowSnapshot,
|
||||
rememberGraphIdentityAlias,
|
||||
resolveGraphIdentityAliasByHostChatId,
|
||||
stampGraphPersistenceMeta,
|
||||
writeChatMetadataPatch,
|
||||
writeGraphShadowSnapshot,
|
||||
@@ -930,10 +938,19 @@ function throwIfAborted(signal, message = "操作已终止") {
|
||||
|
||||
function assertRecoveryChatStillActive(expectedChatId, label = "") {
|
||||
if (!expectedChatId) return;
|
||||
const currentId = getCurrentChatId();
|
||||
if (currentId && currentId !== expectedChatId) {
|
||||
const currentIdentity = resolveCurrentChatIdentity(getContext());
|
||||
const currentId = normalizeChatIdCandidate(currentIdentity.chatId);
|
||||
const normalizedExpectedChatId = normalizeChatIdCandidate(expectedChatId);
|
||||
if (
|
||||
currentId &&
|
||||
normalizedExpectedChatId &&
|
||||
!doesChatIdMatchResolvedGraphIdentity(
|
||||
normalizedExpectedChatId,
|
||||
currentIdentity,
|
||||
)
|
||||
) {
|
||||
throw createAbortError(
|
||||
`历史恢复已终止:聊天已从 ${expectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`,
|
||||
`历史恢复已终止:聊天已从 ${normalizedExpectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3309,7 +3326,7 @@ function isHostChatMetadataReady(context = getContext()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveCurrentChatIdentity(context = getContext()) {
|
||||
function resolveCurrentHostChatId(context = getContext()) {
|
||||
const candidates = [
|
||||
context?.chatId,
|
||||
context?.getCurrentChatId?.(),
|
||||
@@ -3320,13 +3337,43 @@ function resolveCurrentChatIdentity(context = getContext()) {
|
||||
context?.chatMetadata?.sessionId,
|
||||
];
|
||||
|
||||
const chatId =
|
||||
return (
|
||||
candidates
|
||||
.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 {
|
||||
chatId,
|
||||
hostChatId,
|
||||
integrity,
|
||||
identitySource: integrity
|
||||
? "integrity"
|
||||
: aliasedChatId
|
||||
? "alias"
|
||||
: hostChatId
|
||||
? "host-chat-id"
|
||||
: "",
|
||||
hasLikelySelectedChat: hasLikelySelectedChatContext(context),
|
||||
};
|
||||
}
|
||||
@@ -3335,6 +3382,250 @@ function getCurrentChatId(context = getContext()) {
|
||||
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 = {}) {
|
||||
const action = String(syncPayload?.action || "")
|
||||
.trim()
|
||||
@@ -3348,8 +3639,14 @@ async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) {
|
||||
}
|
||||
|
||||
const syncedChatId = normalizeChatIdCandidate(syncPayload?.chatId);
|
||||
const activeChatId = normalizeChatIdCandidate(getCurrentChatId());
|
||||
const targetChatId = syncedChatId || activeChatId;
|
||||
const activeIdentity = resolveCurrentChatIdentity(getContext());
|
||||
const activeChatId = normalizeChatIdCandidate(activeIdentity.chatId);
|
||||
const targetChatId =
|
||||
activeChatId &&
|
||||
syncedChatId &&
|
||||
doesChatIdMatchResolvedGraphIdentity(syncedChatId, activeIdentity)
|
||||
? activeChatId
|
||||
: syncedChatId || activeChatId;
|
||||
|
||||
if (!targetChatId) {
|
||||
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(
|
||||
chatId,
|
||||
context = getContext(),
|
||||
@@ -3980,6 +4494,14 @@ function applyIndexedDbSnapshotToRuntime(
|
||||
normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
|
||||
normalizedChatId,
|
||||
);
|
||||
stampGraphPersistenceMeta(currentGraph, {
|
||||
revision,
|
||||
reason: `indexeddb:${String(source || "indexeddb")}`,
|
||||
chatId: normalizedChatId,
|
||||
integrity:
|
||||
normalizeChatIdCandidate(snapshot?.meta?.integrity) ||
|
||||
getChatMetadataIntegrity(getContext()),
|
||||
});
|
||||
currentGraph.vectorIndexState.lastIntegrityIssue = null;
|
||||
|
||||
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
|
||||
@@ -4045,6 +4567,7 @@ function applyIndexedDbSnapshotToRuntime(
|
||||
at: Date.now(),
|
||||
},
|
||||
});
|
||||
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
|
||||
|
||||
removeGraphShadowSnapshot(normalizedChatId);
|
||||
refreshPanelLiveState();
|
||||
@@ -4101,14 +4624,59 @@ async function loadGraphFromIndexedDb(
|
||||
}
|
||||
const db = await manager.getCurrentDb(normalizedChatId);
|
||||
|
||||
const migrationResult = await maybeMigrateLegacyGraphToIndexedDb(
|
||||
normalizedChatId,
|
||||
getContext(),
|
||||
{
|
||||
source,
|
||||
db,
|
||||
},
|
||||
);
|
||||
const identityRecoveryResult =
|
||||
await maybeRecoverIndexedDbGraphFromStableIdentity(
|
||||
normalizedChatId,
|
||||
getContext(),
|
||||
{
|
||||
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) {
|
||||
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);
|
||||
|
||||
if (!isIndexedDbSnapshotMeaningful(snapshot)) {
|
||||
@@ -4713,6 +5284,7 @@ function persistGraphToChatMetadata(
|
||||
queuedPersistRotateIntegrity: false,
|
||||
queuedPersistReason: "",
|
||||
});
|
||||
rememberResolvedGraphIdentityAlias(context, chatId);
|
||||
|
||||
return buildGraphPersistResult({
|
||||
saved: true,
|
||||
@@ -5949,7 +6521,7 @@ function loadGraphFromChat(options = {}) {
|
||||
normalizeGraphRuntimeState(deserializeGraph(savedData), chatId),
|
||||
chatId,
|
||||
);
|
||||
const shadowSnapshot = readGraphShadowSnapshot(chatId);
|
||||
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity);
|
||||
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
|
||||
officialGraph,
|
||||
shadowSnapshot,
|
||||
@@ -5976,6 +6548,12 @@ function loadGraphFromChat(options = {}) {
|
||||
|
||||
clearPendingGraphLoadRetry();
|
||||
currentGraph = officialGraph;
|
||||
stampGraphPersistenceMeta(currentGraph, {
|
||||
revision: officialRevision,
|
||||
reason: `${source}:metadata-compat-provisional`,
|
||||
chatId,
|
||||
integrity: getChatMetadataIntegrity(context),
|
||||
});
|
||||
extractionCount = Number.isFinite(
|
||||
currentGraph?.historyState?.extractionCount,
|
||||
)
|
||||
@@ -6043,6 +6621,7 @@ function loadGraphFromChat(options = {}) {
|
||||
at: Date.now(),
|
||||
},
|
||||
});
|
||||
rememberResolvedGraphIdentityAlias(context, chatId);
|
||||
|
||||
scheduleIndexedDbGraphProbe(chatId, {
|
||||
source: `${source}:indexeddb-probe`,
|
||||
@@ -6126,6 +6705,7 @@ async function saveGraphToIndexedDb(
|
||||
};
|
||||
}
|
||||
const db = await manager.getCurrentDb(normalizedChatId);
|
||||
const currentIdentity = resolveCurrentChatIdentity(getContext());
|
||||
const baseSnapshot =
|
||||
readCachedIndexedDbSnapshot(normalizedChatId) ||
|
||||
(await db.exportSnapshot());
|
||||
@@ -6137,6 +6717,9 @@ async function saveGraphToIndexedDb(
|
||||
meta: {
|
||||
storagePrimary: "indexeddb",
|
||||
lastMutationReason: String(reason || "graph-save"),
|
||||
integrity:
|
||||
currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
|
||||
hostChatId: currentIdentity.hostChatId || "",
|
||||
},
|
||||
});
|
||||
const importResult = await db.importSnapshot(snapshot, {
|
||||
@@ -6179,6 +6762,7 @@ async function saveGraphToIndexedDb(
|
||||
at: Date.now(),
|
||||
},
|
||||
});
|
||||
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
|
||||
|
||||
return {
|
||||
saved: true,
|
||||
|
||||
@@ -4,15 +4,22 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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 {
|
||||
cloneGraphForPersistence,
|
||||
cloneRuntimeDebugValue,
|
||||
findGraphShadowSnapshotByIntegrity,
|
||||
getGraphPersistedRevision,
|
||||
getGraphIdentityAliasCandidates,
|
||||
getGraphPersistenceMeta,
|
||||
getGraphShadowSnapshotStorageKey,
|
||||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||||
GRAPH_LOAD_STATES,
|
||||
GRAPH_METADATA_KEY,
|
||||
GRAPH_PERSISTENCE_META_KEY,
|
||||
@@ -21,7 +28,9 @@ import {
|
||||
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
|
||||
MODULE_NAME,
|
||||
readGraphShadowSnapshot,
|
||||
rememberGraphIdentityAlias,
|
||||
removeGraphShadowSnapshot,
|
||||
resolveGraphIdentityAliasByHostChatId,
|
||||
shouldPreferShadowSnapshotOverOfficial,
|
||||
stampGraphPersistenceMeta,
|
||||
writeChatMetadataPatch,
|
||||
@@ -83,6 +92,28 @@ const messageSnippet = extractSnippet(
|
||||
);
|
||||
|
||||
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();
|
||||
return {
|
||||
__store: store,
|
||||
@@ -154,15 +185,75 @@ async function createGraphPersistenceHarness({
|
||||
chatId = "chat-test",
|
||||
chatMetadata = undefined,
|
||||
sessionStore = null,
|
||||
localStore = null,
|
||||
globalChatId = "",
|
||||
characterId = "",
|
||||
groupId = null,
|
||||
indexedDbSnapshot = null,
|
||||
indexedDbSnapshots = null,
|
||||
chat = [],
|
||||
} = {}) {
|
||||
const timers = new Map();
|
||||
let nextTimerId = 1;
|
||||
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 = {
|
||||
console,
|
||||
@@ -176,8 +267,12 @@ async function createGraphPersistenceHarness({
|
||||
Boolean,
|
||||
structuredClone,
|
||||
result: null,
|
||||
__indexedDbSnapshot: indexedDbSnapshot,
|
||||
__indexedDbSnapshot: getIndexedDbSnapshotForChat(
|
||||
String(chatId || globalChatId || ""),
|
||||
),
|
||||
__indexedDbSnapshots: indexedDbSnapshotMap,
|
||||
sessionStorage: storage,
|
||||
localStorage,
|
||||
setTimeout(fn, delay) {
|
||||
const id = nextTimerId++;
|
||||
timers.set(id, { fn, delay });
|
||||
@@ -211,6 +306,21 @@ async function createGraphPersistenceHarness({
|
||||
},
|
||||
},
|
||||
__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() {
|
||||
runtimeContext.__panelRefreshCount += 1;
|
||||
},
|
||||
@@ -241,7 +351,9 @@ async function createGraphPersistenceHarness({
|
||||
onMessageReceivedController,
|
||||
getGraphPersistenceMeta,
|
||||
getGraphPersistedRevision,
|
||||
getGraphIdentityAliasCandidates,
|
||||
getGraphShadowSnapshotStorageKey,
|
||||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||||
GRAPH_LOAD_STATES,
|
||||
GRAPH_METADATA_KEY,
|
||||
@@ -250,14 +362,141 @@ async function createGraphPersistenceHarness({
|
||||
GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX,
|
||||
GRAPH_STARTUP_RECONCILE_DELAYS_MS,
|
||||
MODULE_NAME,
|
||||
findGraphShadowSnapshotByIntegrity,
|
||||
readGraphShadowSnapshot,
|
||||
rememberGraphIdentityAlias,
|
||||
removeGraphShadowSnapshot,
|
||||
resolveGraphIdentityAliasByHostChatId,
|
||||
shouldPreferShadowSnapshotOverOfficial,
|
||||
stampGraphPersistenceMeta,
|
||||
writeChatMetadataPatch,
|
||||
writeGraphShadowSnapshot,
|
||||
// Shadow snapshot functions need VM-local sessionStorage overrides
|
||||
// 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 = "") {
|
||||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!key) return null;
|
||||
@@ -286,6 +525,56 @@ async function createGraphPersistenceHarness({
|
||||
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(
|
||||
chatId = "",
|
||||
graph = null,
|
||||
@@ -350,6 +639,8 @@ async function createGraphPersistenceHarness({
|
||||
return true;
|
||||
},
|
||||
notifyExtractionIssue() {},
|
||||
debugDebug() {},
|
||||
debugLog() {},
|
||||
async runExtraction() {},
|
||||
getRequestHeaders() {
|
||||
return {};
|
||||
@@ -394,24 +685,37 @@ async function createGraphPersistenceHarness({
|
||||
__contextImmediateSaveCalls: 0,
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
buildBmeDbName,
|
||||
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 {
|
||||
constructor() {
|
||||
this._db = {
|
||||
this._currentChatId = "";
|
||||
}
|
||||
_createDb(dbChatId = "") {
|
||||
return {
|
||||
async exportSnapshot() {
|
||||
if (runtimeContext.__indexedDbSnapshot) {
|
||||
return structuredClone(runtimeContext.__indexedDbSnapshot);
|
||||
}
|
||||
return {
|
||||
meta: { revision: 0, chatId: "" },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||||
};
|
||||
return getIndexedDbSnapshotForChat(dbChatId);
|
||||
},
|
||||
async importSnapshot(snapshot) {
|
||||
runtimeContext.__indexedDbSnapshot = structuredClone(snapshot);
|
||||
setIndexedDbSnapshotForChat(dbChatId, snapshot);
|
||||
runtimeContext.__indexedDbSnapshot =
|
||||
getIndexedDbSnapshotForChat(dbChatId);
|
||||
return {
|
||||
revision:
|
||||
Number(snapshot?.meta?.revision) ||
|
||||
@@ -420,19 +724,18 @@ async function createGraphPersistenceHarness({
|
||||
};
|
||||
},
|
||||
async getMeta(key, fallbackValue = 0) {
|
||||
const snapshot = runtimeContext.__indexedDbSnapshot || {};
|
||||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
return snapshot.meta[key];
|
||||
},
|
||||
async getRevision() {
|
||||
return (
|
||||
Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) || 0
|
||||
);
|
||||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||||
return Number(snapshot?.meta?.revision) || 0;
|
||||
},
|
||||
async isEmpty() {
|
||||
const snapshot = runtimeContext.__indexedDbSnapshot || {};
|
||||
const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {};
|
||||
const nodes = Array.isArray(snapshot?.nodes)
|
||||
? snapshot.nodes.length
|
||||
: 0;
|
||||
@@ -451,33 +754,44 @@ async function createGraphPersistenceHarness({
|
||||
},
|
||||
async importLegacyGraph(graph, options = {}) {
|
||||
const revision = Number(options?.revision) || 1;
|
||||
runtimeContext.__indexedDbSnapshot = buildSnapshotFromGraph(graph, {
|
||||
chatId: runtimeContext.__chatContext?.chatId || "",
|
||||
const migratedSnapshot = buildSnapshotFromGraph(graph, {
|
||||
chatId: dbChatId || runtimeContext.__chatContext?.chatId || "",
|
||||
revision,
|
||||
meta: {
|
||||
migrationCompletedAt: Date.now(),
|
||||
migrationSource: "chat_metadata",
|
||||
},
|
||||
});
|
||||
setIndexedDbSnapshotForChat(dbChatId, migratedSnapshot);
|
||||
runtimeContext.__indexedDbSnapshot =
|
||||
getIndexedDbSnapshotForChat(dbChatId);
|
||||
return {
|
||||
migrated: true,
|
||||
revision,
|
||||
imported: {
|
||||
nodes: runtimeContext.__indexedDbSnapshot.nodes.length,
|
||||
edges: runtimeContext.__indexedDbSnapshot.edges.length,
|
||||
nodes: runtimeContext.__indexedDbSnapshot?.nodes?.length || 0,
|
||||
edges: runtimeContext.__indexedDbSnapshot?.edges?.length || 0,
|
||||
tombstones:
|
||||
runtimeContext.__indexedDbSnapshot.tombstones.length,
|
||||
runtimeContext.__indexedDbSnapshot?.tombstones?.length || 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
async markSyncDirty() {},
|
||||
};
|
||||
}
|
||||
async getCurrentDb() {
|
||||
return this._db;
|
||||
async getCurrentDb(dbChatId = this._currentChatId) {
|
||||
this._currentChatId = String(dbChatId || this._currentChatId || "");
|
||||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||||
this._currentChatId,
|
||||
);
|
||||
return this._createDb(this._currentChatId);
|
||||
}
|
||||
async switchChat() {
|
||||
return this._db;
|
||||
async switchChat(dbChatId = "") {
|
||||
this._currentChatId = String(dbChatId || "");
|
||||
runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat(
|
||||
this._currentChatId,
|
||||
);
|
||||
return this._createDb(this._currentChatId);
|
||||
}
|
||||
async closeCurrent() {}
|
||||
},
|
||||
@@ -544,11 +858,33 @@ result = {
|
||||
return globalThis.__chatContext;
|
||||
},
|
||||
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() {
|
||||
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"),
|
||||
@@ -708,7 +1044,10 @@ result = {
|
||||
|
||||
assert.equal(result.synced, true);
|
||||
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().storagePrimary,
|
||||
|
||||
Reference in New Issue
Block a user