diff --git a/graph-persistence.js b/graph-persistence.js new file mode 100644 index 0000000..af7e941 --- /dev/null +++ b/graph-persistence.js @@ -0,0 +1,238 @@ +// 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 || ""), + }; + } 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 = "" } = {}, +) { + const storageKey = getGraphShadowSnapshotStorageKey(chatId); + if (!storageKey || !graph) return false; + + try { + const serializedGraph = serializeGraph(graph); + globalThis.sessionStorage?.setItem( + storageKey, + JSON.stringify({ + chatId: String(chatId || ""), + revision: Number.isFinite(revision) ? revision : 0, + serializedGraph, + updatedAt: new Date().toISOString(), + reason: String(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 false; + const shadowRevision = Number(shadowSnapshot.revision || 0); + const officialRevision = getGraphPersistedRevision(officialGraph); + return shadowRevision > 0 && shadowRevision > officialRevision; +} diff --git a/index.js b/index.js index a90aa49..000e173 100644 --- a/index.js +++ b/index.js @@ -102,117 +102,39 @@ import { setBatchStageOutcome, shouldRunRecallForTransaction, } from "./ui-status.js"; +import { + cloneGraphForPersistence, + cloneRuntimeDebugValue, + getGraphPersistenceMeta, + getGraphPersistedRevision, + getGraphShadowSnapshotStorageKey, + GRAPH_LOAD_PENDING_CHAT_ID, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + GRAPH_PERSISTENCE_SESSION_ID, + GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX, + GRAPH_STARTUP_RECONCILE_DELAYS_MS, + MODULE_NAME, + readGraphShadowSnapshot, + removeGraphShadowSnapshot, + shouldPreferShadowSnapshotOverOfficial, + stampGraphPersistenceMeta, + writeChatMetadataPatch, + writeGraphShadowSnapshot, +} from "./graph-persistence.js"; // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; let _themesModule = null; -const MODULE_NAME = "st_bme"; -const GRAPH_METADATA_KEY = "st_bme_graph"; -const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence"; const SERVER_SETTINGS_FILENAME = "st-bme-settings.json"; const SERVER_SETTINGS_URL = `/user/files/${SERVER_SETTINGS_FILENAME}`; -const GRAPH_LOAD_STATES = Object.freeze({ - NO_CHAT: "no-chat", - LOADING: "loading", - LOADED: "loaded", - SHADOW_RESTORED: "shadow-restored", - EMPTY_CONFIRMED: "empty-confirmed", - BLOCKED: "blocked", -}); -const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__"; -const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`; -const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000]; - -function cloneRuntimeDebugValue(value, fallback = null) { - if (value == null) { - return fallback; - } - - try { - return JSON.parse(JSON.stringify(value)); - } catch { - return fallback ?? value; - } -} - -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); - }); -} - -const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug(); - -function getGraphPersistenceMeta(graph = currentGraph) { - 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; -} - -function getGraphPersistedRevision(graph = currentGraph) { - const revision = Number(getGraphPersistenceMeta(graph)?.revision); - return Number.isFinite(revision) && revision > 0 ? revision : 0; -} - -function stampGraphPersistenceMeta( - graph = currentGraph, - { - revision = graphPersistenceState.revision, - reason = "", - chatId = getCurrentChatId(), - 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; -} function getChatMetadataIntegrity(context = getContext()) { return normalizeChatIdCandidate(context?.chatMetadata?.integrity); } -function writeChatMetadataPatch(context = getContext(), 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; -} - function triggerChatMetadataSave( context = getContext(), { immediate = false } = {}, @@ -245,23 +167,6 @@ function triggerChatMetadataSave( return "debounced"; } -function cloneGraphForPersistence( - graph = currentGraph, - chatId = getCurrentChatId(), -) { - return normalizeGraphRuntimeState( - deserializeGraph(serializeGraph(graph)), - chatId, - ); -} - -function shouldPreferShadowSnapshotOverOfficial(officialGraph, shadowSnapshot) { - if (!shadowSnapshot) return false; - const shadowRevision = Number(shadowSnapshot.revision || 0); - const officialRevision = getGraphPersistedRevision(officialGraph); - return shadowRevision > 0 && shadowRevision > officialRevision; -} - function getRuntimeDebugState() { const stateKey = "__stBmeRuntimeDebugState"; if (!globalThis[stateKey] || typeof globalThis[stateKey] !== "object") { @@ -493,80 +398,6 @@ const stageAbortControllers = { history: null, }; -function getGraphShadowSnapshotStorageKey(chatId = "") { - const normalizedChatId = String(chatId || "").trim(); - if (!normalizedChatId) return ""; - return `${GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX}${encodeURIComponent(normalizedChatId)}`; -} - -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 || ""), - }; - } catch { - return null; - } -} - -function writeGraphShadowSnapshot( - chatId = "", - graph = currentGraph, - { revision = graphPersistenceState.revision, reason = "" } = {}, -) { - const storageKey = getGraphShadowSnapshotStorageKey(chatId); - if (!storageKey || !graph) return false; - - try { - const serializedGraph = serializeGraph(graph); - globalThis.sessionStorage?.setItem( - storageKey, - JSON.stringify({ - chatId: String(chatId || ""), - revision: Number.isFinite(revision) ? revision : 0, - serializedGraph, - updatedAt: new Date().toISOString(), - reason: String(reason || ""), - }), - ); - return true; - } catch (error) { - console.warn("[ST-BME] 写入会话图谱临时快照失败:", error); - return false; - } -} - -function removeGraphShadowSnapshot(chatId = "") { - const storageKey = getGraphShadowSnapshotStorageKey(chatId); - if (!storageKey) return false; - - try { - globalThis.sessionStorage?.removeItem(storageKey); - return true; - } catch { - return false; - } -} - function getGraphPersistenceLiveState() { const snapshot = { loadState: graphPersistenceState.loadState, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 6baa3d9..3324e45 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -27,6 +27,27 @@ import { clampFloat, formatRecallContextLine, } from "../ui-status.js"; +import { + cloneGraphForPersistence, + cloneRuntimeDebugValue, + getGraphPersistenceMeta, + getGraphPersistedRevision, + getGraphShadowSnapshotStorageKey, + GRAPH_LOAD_PENDING_CHAT_ID, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + GRAPH_PERSISTENCE_SESSION_ID, + GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX, + GRAPH_STARTUP_RECONCILE_DELAYS_MS, + MODULE_NAME, + readGraphShadowSnapshot, + removeGraphShadowSnapshot, + shouldPreferShadowSnapshotOverOfficial, + stampGraphPersistenceMeta, + writeChatMetadataPatch, + writeGraphShadowSnapshot, +} from "../graph-persistence.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const indexPath = path.resolve(moduleDir, "../index.js"); @@ -42,7 +63,7 @@ function extractSnippet(startMarker, endMarker) { } const persistencePrelude = extractSnippet( - 'const MODULE_NAME = "st_bme";', + 'const SERVER_SETTINGS_FILENAME = "st-bme-settings.json";', "function clearInjectionState(options = {}) {", ); const persistenceCore = extractSnippet( @@ -204,6 +225,64 @@ async function createGraphPersistenceHarness({ clampInt, clampFloat, formatRecallContextLine, + cloneGraphForPersistence, + cloneRuntimeDebugValue, + getGraphPersistenceMeta, + getGraphPersistedRevision, + getGraphShadowSnapshotStorageKey, + GRAPH_LOAD_PENDING_CHAT_ID, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + GRAPH_PERSISTENCE_SESSION_ID, + GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX, + GRAPH_STARTUP_RECONCILE_DELAYS_MS, + MODULE_NAME, + readGraphShadowSnapshot, + removeGraphShadowSnapshot, + shouldPreferShadowSnapshotOverOfficial, + stampGraphPersistenceMeta, + writeChatMetadataPatch, + writeGraphShadowSnapshot, + // Shadow snapshot functions need VM-local sessionStorage overrides + // because imported versions use the outer globalThis (no sessionStorage) + readGraphShadowSnapshot(chatId = "") { + const key = getGraphShadowSnapshotStorageKey(chatId); + if (!key) return null; + try { + const raw = storage.getItem(key); + if (!raw) return null; + const snap = JSON.parse(raw); + if (!snap || String(snap.chatId || "") !== String(chatId || "") || + typeof snap.serializedGraph !== "string" || !snap.serializedGraph) return null; + return { + chatId: String(snap.chatId || ""), + revision: Number.isFinite(snap.revision) ? snap.revision : 0, + serializedGraph: snap.serializedGraph, + updatedAt: String(snap.updatedAt || ""), + reason: String(snap.reason || ""), + }; + } catch { return null; } + }, + writeGraphShadowSnapshot(chatId = "", graph = null, { revision = 0, reason = "" } = {}) { + const key = getGraphShadowSnapshotStorageKey(chatId); + if (!key || !graph) return false; + try { + storage.setItem(key, JSON.stringify({ + chatId: String(chatId || ""), + revision: Number.isFinite(revision) ? revision : 0, + serializedGraph: serializeGraph(graph), + updatedAt: new Date().toISOString(), + reason: String(reason || ""), + })); + return true; + } catch { return false; } + }, + removeGraphShadowSnapshot(chatId = "") { + const key = getGraphShadowSnapshotStorageKey(chatId); + if (!key) return false; + try { storage.removeItem(key); return true; } catch { return false; } + }, createDefaultTaskProfiles() { return { extract: { activeProfileId: "default", profiles: [] }, diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 3bff7ab..101676c 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -30,6 +30,18 @@ import { setBatchStageOutcome, shouldRunRecallForTransaction, } from "../ui-status.js"; +import { + cloneRuntimeDebugValue, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + GRAPH_PERSISTENCE_SESSION_ID, + MODULE_NAME, + readGraphShadowSnapshot, + stampGraphPersistenceMeta, + writeChatMetadataPatch, + writeGraphShadowSnapshot, +} from "../graph-persistence.js"; const extensionsShimSource = [ "export const extension_settings = globalThis.__p0ExtensionSettings || {};", @@ -276,6 +288,10 @@ function createGenerationRecallHarness() { getStageNoticeTitle, getStageNoticeDuration, normalizeStageNoticeLevel, + MODULE_NAME, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, }; vm.createContext(context); vm.runInContext(