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_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
620
index.js
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user