mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
370 lines
12 KiB
JavaScript
370 lines
12 KiB
JavaScript
// 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,
|
||
};
|
||
}
|