Files
ST-Bionic-Memory-Ecology/graph-persistence.js

370 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ST-BME: 图谱持久化常量与纯工具函数
// 不依赖 index.js 模块级可变状态currentGraph / graphPersistenceState 等)
import { deserializeGraph, serializeGraph } from "./graph.js";
import { normalizeGraphRuntimeState } from "./runtime-state.js";
// ═══════════════════════════════════════════════════════════
// 常量
// ═══════════════════════════════════════════════════════════
export const MODULE_NAME = "st_bme";
export const GRAPH_METADATA_KEY = "st_bme_graph";
export const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence";
export const GRAPH_LOAD_STATES = Object.freeze({
NO_CHAT: "no-chat",
LOADING: "loading",
LOADED: "loaded",
SHADOW_RESTORED: "shadow-restored",
EMPTY_CONFIRMED: "empty-confirmed",
BLOCKED: "blocked",
});
export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__";
export const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`;
export const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000];
// ═══════════════════════════════════════════════════════════
// 纯工具
// ═══════════════════════════════════════════════════════════
export function cloneRuntimeDebugValue(value, fallback = null) {
if (value == null) {
return fallback;
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return fallback ?? value;
}
}
export function createLocalIntegritySlug() {
const nativeUuid = globalThis.crypto?.randomUUID?.();
if (nativeUuid) return nativeUuid;
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
const random = Math.floor(Math.random() * 16);
const value = char === "x" ? random : (random & 0x3) | 0x8;
return value.toString(16);
});
}
export const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug();
// ═══════════════════════════════════════════════════════════
// 图谱持久化元数据
// ═══════════════════════════════════════════════════════════
/**
* @param {object} graph
* @returns {object|null}
*/
export function getGraphPersistenceMeta(graph) {
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
return null;
}
const meta = graph[GRAPH_PERSISTENCE_META_KEY];
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
return null;
}
return meta;
}
/**
* @param {object} graph
* @returns {number}
*/
export function getGraphPersistedRevision(graph) {
const revision = Number(getGraphPersistenceMeta(graph)?.revision);
return Number.isFinite(revision) && revision > 0 ? revision : 0;
}
/**
* @param {object} graph
* @param {object} opts
* @param {number} [opts.revision]
* @param {string} [opts.reason]
* @param {string} [opts.chatId]
* @param {string} [opts.integrity]
*/
export function stampGraphPersistenceMeta(
graph,
{ revision = 0, reason = "", chatId = "", integrity = "" } = {},
) {
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
return null;
}
const existingMeta = getGraphPersistenceMeta(graph) || {};
const nextMeta = {
...existingMeta,
revision: Number.isFinite(revision) && revision > 0 ? revision : 0,
updatedAt: new Date().toISOString(),
sessionId: GRAPH_PERSISTENCE_SESSION_ID,
reason: String(reason || ""),
chatId: String(chatId || existingMeta.chatId || ""),
integrity: String(integrity || existingMeta.integrity || ""),
};
graph[GRAPH_PERSISTENCE_META_KEY] = nextMeta;
return nextMeta;
}
// ═══════════════════════════════════════════════════════════
// 聊天元数据
// ═══════════════════════════════════════════════════════════
export function writeChatMetadataPatch(context, patch = {}) {
if (!context) return false;
if (typeof context.updateChatMetadata === "function") {
context.updateChatMetadata(patch);
return true;
}
if (
!context.chatMetadata ||
typeof context.chatMetadata !== "object" ||
Array.isArray(context.chatMetadata)
) {
context.chatMetadata = {};
}
Object.assign(context.chatMetadata, patch || {});
return true;
}
// ═══════════════════════════════════════════════════════════
// Shadow Snapshot会话存储
// ═══════════════════════════════════════════════════════════
export function getGraphShadowSnapshotStorageKey(chatId = "") {
const normalizedChatId = String(chatId || "").trim();
if (!normalizedChatId) return "";
return `${GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX}${encodeURIComponent(normalizedChatId)}`;
}
export function readGraphShadowSnapshot(chatId = "") {
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
if (!storageKey) return null;
try {
const raw = globalThis.sessionStorage?.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
) {
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 || ""),
};
} catch {
return null;
}
}
/**
* @param {string} chatId
* @param {object} graph
* @param {object} [opts]
* @param {number} [opts.revision]
* @param {string} [opts.reason]
*/
export function writeGraphShadowSnapshot(
chatId,
graph,
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
) {
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
if (!storageKey || !graph) return false;
try {
const serializedGraph = serializeGraph(graph);
const persistedMeta = getGraphPersistenceMeta(graph) || {};
globalThis.sessionStorage?.setItem(
storageKey,
JSON.stringify({
chatId: String(chatId || ""),
revision: Number.isFinite(revision) ? revision : 0,
serializedGraph,
updatedAt: new Date().toISOString(),
reason: String(reason || ""),
integrity: String(integrity || persistedMeta.integrity || ""),
persistedChatId: String(persistedMeta.chatId || ""),
debugReason: String(debugReason || reason || ""),
}),
);
return true;
} catch (error) {
console.warn("[ST-BME] 写入会话图谱临时快照失败:", error);
return false;
}
}
export function removeGraphShadowSnapshot(chatId = "") {
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
if (!storageKey) return false;
try {
globalThis.sessionStorage?.removeItem(storageKey);
return true;
} catch {
return false;
}
}
// ═══════════════════════════════════════════════════════════
// 图谱克隆 / 比较
// ═══════════════════════════════════════════════════════════
export function cloneGraphForPersistence(graph, chatId = "") {
return normalizeGraphRuntimeState(
deserializeGraph(serializeGraph(graph)),
chatId,
);
}
export function shouldPreferShadowSnapshotOverOfficial(
officialGraph,
shadowSnapshot,
) {
if (!shadowSnapshot) {
return {
prefer: false,
reason: "shadow-missing",
resultCode: "shadow.missing",
};
}
const shadowRevision = Number(shadowSnapshot.revision || 0);
const officialRevision = getGraphPersistedRevision(officialGraph);
const officialMeta = getGraphPersistenceMeta(officialGraph) || {};
const normalizedOfficialChatId = String(officialMeta.chatId || "").trim();
const normalizedShadowChatId = String(shadowSnapshot.chatId || "").trim();
const normalizedShadowPersistedChatId = String(
shadowSnapshot.persistedChatId || "",
).trim();
const officialIntegrity = String(officialMeta.integrity || "").trim();
const shadowIntegrity = String(shadowSnapshot.integrity || "").trim();
if (shadowRevision <= 0) {
return {
prefer: false,
reason: "shadow-revision-invalid",
resultCode: "shadow.reject.revision-invalid",
shadowRevision,
officialRevision,
};
}
if (
normalizedOfficialChatId &&
normalizedShadowPersistedChatId &&
normalizedOfficialChatId !== normalizedShadowPersistedChatId
) {
return {
prefer: false,
reason: "shadow-persisted-chat-mismatch",
resultCode: "shadow.reject.persisted-chat-mismatch",
shadowRevision,
officialRevision,
officialChatId: normalizedOfficialChatId,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (
normalizedOfficialChatId &&
normalizedShadowChatId &&
normalizedOfficialChatId !== normalizedShadowChatId
) {
return {
prefer: false,
reason: "shadow-chat-mismatch",
resultCode: "shadow.reject.chat-mismatch",
shadowRevision,
officialRevision,
officialChatId: normalizedOfficialChatId,
shadowChatId: normalizedShadowChatId,
};
}
if (
officialIntegrity &&
shadowIntegrity &&
officialIntegrity !== shadowIntegrity
) {
return {
prefer: false,
reason: "shadow-integrity-mismatch",
resultCode: "shadow.reject.integrity-mismatch",
shadowRevision,
officialRevision,
officialIntegrity,
shadowIntegrity,
};
}
if (
normalizedShadowPersistedChatId &&
normalizedShadowChatId &&
normalizedShadowPersistedChatId !== normalizedShadowChatId
) {
return {
prefer: false,
reason: "shadow-self-chat-mismatch",
resultCode: "shadow.reject.self-chat-mismatch",
shadowRevision,
officialRevision,
shadowChatId: normalizedShadowChatId,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (normalizedShadowPersistedChatId && !normalizedOfficialChatId) {
return {
prefer: false,
reason: "shadow-persisted-chat-without-official-chat",
resultCode: "shadow.reject.persisted-chat-without-official-chat",
shadowRevision,
officialRevision,
shadowPersistedChatId: normalizedShadowPersistedChatId,
};
}
if (shadowIntegrity && !officialIntegrity) {
return {
prefer: false,
reason: "shadow-integrity-without-official-integrity",
resultCode: "shadow.reject.integrity-without-official-integrity",
shadowRevision,
officialRevision,
shadowIntegrity,
};
}
return {
prefer: shadowRevision > 0 && shadowRevision > officialRevision,
reason:
shadowRevision > officialRevision
? "shadow-newer-than-official"
: "shadow-not-newer-than-official",
resultCode:
shadowRevision > officialRevision
? "shadow.accept.newer-than-official"
: "shadow.keep.official-not-older",
shadowRevision,
officialRevision,
};
}