diff --git a/index.js b/index.js index c6136b5..8c172bf 100644 --- a/index.js +++ b/index.js @@ -29,6 +29,7 @@ import { getGraphStats, getNode, importGraph, + serializeGraph, } from "./graph.js"; import { HOST_ADAPTER_STATE_SEMANTICS, @@ -83,6 +84,15 @@ const MODULE_NAME = "st_bme"; const GRAPH_METADATA_KEY = "st_bme_graph"; 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_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`; function cloneRuntimeDebugValue(value, fallback = null) { if (value == null) { @@ -107,6 +117,7 @@ function getRuntimeDebugState() { taskPromptBuilds: {}, taskLlmRequests: {}, injections: {}, + graphPersistence: null, updatedAt: "", }; } @@ -133,6 +144,11 @@ function recordInjectionSnapshot(kind, snapshot = {}) { }; } +function recordGraphPersistenceSnapshot(snapshot = null) { + const state = touchRuntimeDebugState(); + state.graphPersistence = cloneRuntimeDebugValue(snapshot, null); +} + function readRuntimeDebugSnapshot() { const state = getRuntimeDebugState(); return cloneRuntimeDebugValue( @@ -141,6 +157,7 @@ function readRuntimeDebugSnapshot() { taskPromptBuilds: state.taskPromptBuilds, taskLlmRequests: state.taskLlmRequests, injections: state.injections, + graphPersistence: state.graphPersistence, updatedAt: state.updatedAt, }, { @@ -148,6 +165,7 @@ function readRuntimeDebugSnapshot() { taskPromptBuilds: {}, taskLlmRequests: {}, injections: {}, + graphPersistence: null, updatedAt: "", }, ); @@ -291,6 +309,7 @@ let runtimeStatus = createUiStatus("待命", "准备就绪", "idle"); let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle"); let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle"); let lastRecallStatus = createUiStatus("待命", "尚未执行召回", "idle"); +let graphPersistenceState = createGraphPersistenceState(); const lastStatusToastAt = {}; let pendingRecallSendIntent = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord(); @@ -325,6 +344,279 @@ function createUiStatus(text = "待命", meta = "", level = "idle") { }; } +function createGraphPersistenceState() { + return { + loadState: "no-chat", + chatId: "", + reason: "当前尚未进入聊天", + attemptIndex: 0, + revision: 0, + lastPersistedRevision: 0, + queuedPersistRevision: 0, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + lastPersistReason: "", + writesBlocked: false, + pendingPersist: false, + updatedAt: new Date().toISOString(), + }; +} + +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, + chatId: graphPersistenceState.chatId, + reason: graphPersistenceState.reason, + attemptIndex: graphPersistenceState.attemptIndex, + graphRevision: graphPersistenceState.revision, + lastPersistedRevision: graphPersistenceState.lastPersistedRevision, + queuedPersistRevision: graphPersistenceState.queuedPersistRevision, + shadowSnapshotUsed: graphPersistenceState.shadowSnapshotUsed, + shadowSnapshotRevision: graphPersistenceState.shadowSnapshotRevision, + shadowSnapshotUpdatedAt: graphPersistenceState.shadowSnapshotUpdatedAt, + shadowSnapshotReason: graphPersistenceState.shadowSnapshotReason, + lastPersistReason: graphPersistenceState.lastPersistReason, + writesBlocked: graphPersistenceState.writesBlocked, + pendingPersist: graphPersistenceState.pendingPersist, + canWriteToMetadata: isGraphMetadataWriteAllowed( + graphPersistenceState.loadState, + ), + updatedAt: graphPersistenceState.updatedAt, + }; + return cloneRuntimeDebugValue(snapshot, snapshot); +} + +function syncGraphPersistenceDebugState() { + recordGraphPersistenceSnapshot(getGraphPersistenceLiveState()); +} + +function updateGraphPersistenceState(patch = {}) { + graphPersistenceState = { + ...graphPersistenceState, + ...(patch || {}), + updatedAt: new Date().toISOString(), + }; + syncGraphPersistenceDebugState(); + return graphPersistenceState; +} + +function bumpGraphRevision(reason = "graph-mutation") { + const nextRevision = + Math.max( + graphPersistenceState.revision || 0, + graphPersistenceState.lastPersistedRevision || 0, + graphPersistenceState.queuedPersistRevision || 0, + ) + 1; + updateGraphPersistenceState({ + revision: nextRevision, + lastPersistReason: String(reason || graphPersistenceState.lastPersistReason || ""), + }); + return nextRevision; +} + +function isGraphMetadataWriteAllowed(loadState = graphPersistenceState.loadState) { + return ( + loadState === GRAPH_LOAD_STATES.LOADED || + loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ); +} + +function isGraphReadable(loadState = graphPersistenceState.loadState) { + return ( + loadState === GRAPH_LOAD_STATES.LOADED || + loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED || + loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED || + (loadState === GRAPH_LOAD_STATES.BLOCKED && + graphPersistenceState.shadowSnapshotUsed) + ); +} + +function createGraphLoadUiStatus() { + const state = graphPersistenceState.loadState; + const chatId = graphPersistenceState.chatId || getCurrentChatId(); + switch (state) { + case GRAPH_LOAD_STATES.NO_CHAT: + return createUiStatus("待命", "当前尚未进入聊天", "idle"); + case GRAPH_LOAD_STATES.LOADING: + return createUiStatus( + "图谱加载中", + chatId + ? `正在读取聊天 ${chatId} 的图谱元数据` + : "正在等待聊天上下文准备完成", + "running", + ); + case GRAPH_LOAD_STATES.SHADOW_RESTORED: + return createUiStatus( + "图谱临时恢复", + "已从本次会话临时恢复,正在等待正式聊天元数据", + "warning", + ); + case GRAPH_LOAD_STATES.EMPTY_CONFIRMED: + return createUiStatus( + "图谱待命", + chatId ? "当前聊天还没有图谱" : "当前尚未进入聊天", + "idle", + ); + case GRAPH_LOAD_STATES.BLOCKED: + return createUiStatus( + "图谱加载受阻", + "聊天元数据未就绪,已暂停图谱写回以保护旧数据", + "warning", + ); + case GRAPH_LOAD_STATES.LOADED: + default: + return createUiStatus("待命", "已加载聊天图谱,等待下一次任务", "idle"); + } +} + +function getPanelRuntimeStatus() { + const graphStatus = createGraphLoadUiStatus(); + if ( + graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING || + graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED || + graphPersistenceState.loadState === GRAPH_LOAD_STATES.BLOCKED || + graphPersistenceState.loadState === GRAPH_LOAD_STATES.NO_CHAT + ) { + return graphStatus; + } + return runtimeStatus; +} + +function getGraphMutationBlockReason(operationLabel = "当前操作") { + switch (graphPersistenceState.loadState) { + case GRAPH_LOAD_STATES.LOADING: + return `${operationLabel}已暂停:正在加载当前聊天图谱。`; + case GRAPH_LOAD_STATES.SHADOW_RESTORED: + return `${operationLabel}已暂停:当前图谱还在临时恢复状态,等待正式聊天元数据。`; + case GRAPH_LOAD_STATES.BLOCKED: + return `${operationLabel}已暂停:聊天元数据未就绪,已启用写保护。`; + case GRAPH_LOAD_STATES.NO_CHAT: + return `${operationLabel}已暂停:当前尚未进入聊天。`; + default: + return `${operationLabel}暂不可用。`; + } +} + +function ensureGraphMutationReady(operationLabel = "当前操作", { notify = true } = {}) { + if (isGraphMetadataWriteAllowed()) return true; + if (notify) { + toastr.info(getGraphMutationBlockReason(operationLabel), "ST-BME"); + } + return false; +} + +function applyGraphLoadState( + loadState, + { + chatId = getCurrentChatId(), + reason = "", + attemptIndex = 0, + shadowSnapshotUsed = false, + shadowSnapshotRevision = 0, + shadowSnapshotUpdatedAt = "", + shadowSnapshotReason = "", + revision = graphPersistenceState.revision, + lastPersistedRevision = graphPersistenceState.lastPersistedRevision, + queuedPersistRevision = graphPersistenceState.queuedPersistRevision, + pendingPersist = graphPersistenceState.pendingPersist, + writesBlocked = !isGraphMetadataWriteAllowed(loadState), + } = {}, +) { + updateGraphPersistenceState({ + loadState, + chatId: String(chatId || ""), + reason: String(reason || ""), + attemptIndex, + revision, + lastPersistedRevision, + queuedPersistRevision, + shadowSnapshotUsed, + shadowSnapshotRevision, + shadowSnapshotUpdatedAt, + shadowSnapshotReason, + pendingPersist, + writesBlocked, + }); +} + function normalizeStageNoticeLevel(level = "info") { if (level === "running" || level === "idle") return "info"; if (level === "success" || level === "warning" || level === "error") { @@ -1014,6 +1306,168 @@ function isGraphEffectivelyEmpty(graph) { return true; } +function buildGraphPersistResult({ + saved = false, + queued = false, + blocked = false, + reason = "", + loadState = graphPersistenceState.loadState, + revision = graphPersistenceState.revision, +} = {}) { + return { + saved, + queued, + blocked, + reason: String(reason || ""), + loadState, + revision: Number.isFinite(revision) ? revision : 0, + }; +} + +function maybeCaptureGraphShadowSnapshot(reason = "runtime-shadow") { + const chatId = graphPersistenceState.chatId || getCurrentChatId(); + if (!chatId || !currentGraph) return false; + const hasMeaningfulGraphData = + !isGraphEffectivelyEmpty(currentGraph) || + graphPersistenceState.shadowSnapshotUsed || + graphPersistenceState.lastPersistedRevision > 0; + if (!hasMeaningfulGraphData) return false; + return writeGraphShadowSnapshot(chatId, currentGraph, { + revision: graphPersistenceState.revision, + reason, + }); +} + +function persistGraphToChatMetadata( + context = getContext(), + { reason = "graph-persist", revision = graphPersistenceState.revision } = {}, +) { + if (!context || !currentGraph) { + return buildGraphPersistResult({ + saved: false, + blocked: true, + reason: "missing-context-or-graph", + revision, + }); + } + + const chatId = getCurrentChatId(context); + if (!chatId) { + return buildGraphPersistResult({ + saved: false, + blocked: true, + reason: "missing-chat-id", + revision, + }); + } + + if (typeof context.updateChatMetadata === "function") { + context.updateChatMetadata({ [GRAPH_METADATA_KEY]: currentGraph }); + } else { + if ( + !context.chatMetadata || + typeof context.chatMetadata !== "object" || + Array.isArray(context.chatMetadata) + ) { + context.chatMetadata = {}; + } + context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph; + } + + if (typeof context.saveMetadataDebounced === "function") { + context.saveMetadataDebounced(); + } else { + saveMetadataDebounced(); + } + + applyGraphLoadState(graphPersistenceState.loadState, { + chatId, + reason: graphPersistenceState.reason, + attemptIndex: graphPersistenceState.attemptIndex, + shadowSnapshotUsed: graphPersistenceState.shadowSnapshotUsed, + shadowSnapshotRevision: graphPersistenceState.shadowSnapshotRevision, + shadowSnapshotUpdatedAt: graphPersistenceState.shadowSnapshotUpdatedAt, + shadowSnapshotReason: graphPersistenceState.shadowSnapshotReason, + revision, + lastPersistedRevision: revision, + queuedPersistRevision: 0, + pendingPersist: false, + writesBlocked: false, + }); + updateGraphPersistenceState({ + lastPersistReason: String(reason || ""), + }); + + return buildGraphPersistResult({ + saved: true, + reason, + loadState: graphPersistenceState.loadState, + revision, + }); +} + +function queueGraphPersist( + reason = "graph-persist-blocked", + revision = graphPersistenceState.revision, +) { + maybeCaptureGraphShadowSnapshot(reason); + updateGraphPersistenceState({ + queuedPersistRevision: Math.max( + graphPersistenceState.queuedPersistRevision || 0, + revision || 0, + ), + pendingPersist: true, + writesBlocked: true, + lastPersistReason: String(reason || ""), + }); + + return buildGraphPersistResult({ + queued: true, + blocked: true, + reason, + loadState: graphPersistenceState.loadState, + revision, + }); +} + +function maybeFlushQueuedGraphPersist(reason = "queued-graph-persist") { + if (!currentGraph || !isGraphMetadataWriteAllowed()) { + return buildGraphPersistResult({ + queued: graphPersistenceState.pendingPersist, + blocked: !isGraphMetadataWriteAllowed(), + reason: isGraphMetadataWriteAllowed() + ? "missing-current-graph" + : "write-protected", + }); + } + + if ( + !graphPersistenceState.pendingPersist && + graphPersistenceState.queuedPersistRevision <= + graphPersistenceState.lastPersistedRevision + ) { + return buildGraphPersistResult({ + saved: false, + reason: "no-queued-persist", + }); + } + + const targetRevision = Math.max( + graphPersistenceState.revision || 0, + graphPersistenceState.queuedPersistRevision || 0, + ); + if (targetRevision > (graphPersistenceState.revision || 0)) { + updateGraphPersistenceState({ + revision: targetRevision, + }); + } + + return persistGraphToChatMetadata(getContext(), { + reason, + revision: targetRevision, + }); +} + function scheduleGraphLoadRetry( chatId, reason = "metadata-pending", @@ -1245,6 +1699,7 @@ function snapshotRuntimeUiState() { lastExtractionStatus: { ...(lastExtractionStatus || {}) }, lastVectorStatus: { ...(lastVectorStatus || {}) }, lastRecallStatus: { ...(lastRecallStatus || {}) }, + graphPersistenceState: getGraphPersistenceLiveState(), }; } @@ -1275,6 +1730,9 @@ function restoreRuntimeUiState(snapshot = {}) { ...createUiStatus("待命", "尚未执行召回", "idle"), ...(snapshot.lastRecallStatus || {}), }; + if (snapshot.graphPersistenceState) { + updateGraphPersistenceState(snapshot.graphPersistenceState); + } refreshPanelLiveState(); } @@ -1311,7 +1769,7 @@ async function recordGraphMutation({ extractionCountBefore, }), ); - saveGraphToChat(); + saveGraphToChat({ reason: "record-graph-mutation" }); return vectorSync; } @@ -1454,6 +1912,7 @@ async function ensureVectorReadyIfNeeded( ) { if (!currentGraph) return; ensureCurrentGraphRuntimeState(); + if (!isGraphMetadataWriteAllowed()) return; if (!currentGraph.vectorIndexState?.dirty) return; @@ -1469,13 +1928,13 @@ async function ensureVectorReadyIfNeeded( if (result?.error) { currentGraph.vectorIndexState.lastWarning = result.error; - saveGraphToChat(); + saveGraphToChat({ reason: "vector-auto-repair-failed" }); console.warn("[ST-BME] 向量状态自动修复失败:", reason, result.error); return result; } currentGraph.vectorIndexState.lastWarning = ""; - saveGraphToChat(); + saveGraphToChat({ reason: "vector-auto-repair-succeeded" }); console.log("[ST-BME] 向量状态已自动修复:", reason, result.stats); return result; } @@ -1492,7 +1951,7 @@ async function resetVectorStateForConfigChange(reason = "向量配置已变更") stale: 0, pending: 0, }; - saveGraphToChat(); + saveGraphToChat({ reason: "vector-config-reset" }); } function getPersistedSettingsSnapshot(settings = getSettings()) { @@ -1665,7 +2124,50 @@ function loadGraphFromChat(options = {}) { normalizedExpectedChatId !== chatId ) { clearPendingGraphLoadRetry(); - return false; + return { + success: false, + loaded: false, + loadState: graphPersistenceState.loadState, + reason: "expected-chat-mismatch", + chatId, + attemptIndex, + }; + } + + if (!chatId) { + clearPendingGraphLoadRetry(); + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), ""); + extractionCount = 0; + lastExtractedItems = []; + lastRecalledItems = []; + lastInjectionContent = ""; + runtimeStatus = createUiStatus("待命", "当前尚未进入聊天", "idle"); + lastExtractionStatus = createUiStatus("待命", "当前尚未进入聊天", "idle"); + lastVectorStatus = createUiStatus("待命", "当前尚未进入聊天", "idle"); + lastRecallStatus = createUiStatus("待命", "当前尚未进入聊天", "idle"); + applyGraphLoadState(GRAPH_LOAD_STATES.NO_CHAT, { + chatId: "", + reason: "no-chat", + attemptIndex, + revision: 0, + lastPersistedRevision: 0, + queuedPersistRevision: 0, + pendingPersist: false, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + writesBlocked: true, + }); + refreshPanelLiveState(); + return { + success: false, + loaded: false, + loadState: GRAPH_LOAD_STATES.NO_CHAT, + reason: "no-chat", + chatId: "", + attemptIndex, + }; } const hasChatMetadata = @@ -1675,12 +2177,11 @@ function loadGraphFromChat(options = {}) { const savedData = hasChatMetadata ? context.chatMetadata[GRAPH_METADATA_KEY] : undefined; - const shouldRetry = - Boolean(chatId) && - (savedData == null || savedData === "") && - attemptIndex < GRAPH_LOAD_RETRY_DELAYS_MS.length; + const hasOfficialGraph = savedData != null && savedData !== ""; + const shadowSnapshot = hasOfficialGraph ? null : readGraphShadowSnapshot(chatId); + const shouldRetry = attemptIndex < GRAPH_LOAD_RETRY_DELAYS_MS.length; - if (savedData != null && savedData !== "") { + if (hasOfficialGraph) { clearPendingGraphLoadRetry(); currentGraph = normalizeGraphRuntimeState( deserializeGraph(savedData), @@ -1713,6 +2214,27 @@ function loadGraphFromChat(options = {}) { "已加载聊天图谱,等待下一次召回", "idle", ); + const officialRevision = Math.max( + 1, + shadowSnapshot?.revision || 0, + graphPersistenceState.lastPersistedRevision || 0, + graphPersistenceState.revision || 0, + ); + applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { + chatId, + reason: source, + attemptIndex, + revision: officialRevision, + lastPersistedRevision: officialRevision, + queuedPersistRevision: 0, + pendingPersist: false, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + writesBlocked: false, + }); + removeGraphShadowSnapshot(chatId); console.log("[ST-BME] 从聊天数据加载图谱:", { chatId, @@ -1721,111 +2243,294 @@ function loadGraphFromChat(options = {}) { ...getGraphStats(currentGraph), }); refreshPanelLiveState(); - return true; + return { + success: true, + loaded: true, + loadState: GRAPH_LOAD_STATES.LOADED, + reason: source, + chatId, + attemptIndex, + shadowSnapshotUsed: false, + }; } - if (shouldRetry) { - currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); - extractionCount = 0; - lastExtractedItems = []; - lastRecalledItems = []; - lastInjectionContent = ""; - runtimeStatus = createUiStatus( - "待命", - "正在等待聊天元数据加载,暂不覆盖现有图谱", - "idle", + if (shadowSnapshot) { + currentGraph = normalizeGraphRuntimeState( + deserializeGraph(shadowSnapshot.serializedGraph), + chatId, ); + extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) + ? currentGraph.historyState.extractionCount + : 0; + lastExtractedItems = []; + updateLastRecalledItems(currentGraph.lastRecallResult || []); + lastInjectionContent = ""; + runtimeStatus = createUiStatus("待命", "已从本次会话临时恢复图谱", "idle"); lastExtractionStatus = createUiStatus( "待命", - "正在等待聊天元数据加载", - "idle", + "图谱处于临时恢复状态,等待正式元数据", + "warning", ); lastVectorStatus = createUiStatus( "待命", - "正在等待聊天元数据加载", - "idle", + "图谱处于临时恢复状态,等待正式元数据", + "warning", ); lastRecallStatus = createUiStatus( "待命", - "正在等待聊天元数据加载", - "idle", + "图谱处于临时恢复状态,等待正式元数据", + "warning", ); - scheduleGraphLoadRetry( + + if (hasChatMetadata && !shouldRetry) { + applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { + chatId, + reason: "shadow-snapshot-promoted", + attemptIndex, + revision: Math.max(shadowSnapshot.revision || 0, 1), + lastPersistedRevision: 0, + queuedPersistRevision: Math.max(shadowSnapshot.revision || 0, 1), + pendingPersist: true, + shadowSnapshotUsed: true, + shadowSnapshotRevision: Math.max(shadowSnapshot.revision || 0, 1), + shadowSnapshotUpdatedAt: shadowSnapshot.updatedAt, + shadowSnapshotReason: shadowSnapshot.reason, + writesBlocked: false, + }); + const persistResult = maybeFlushQueuedGraphPersist( + "shadow-snapshot-promoted", + ); + refreshPanelLiveState(); + return { + success: Boolean(persistResult.saved), + loaded: true, + loadState: GRAPH_LOAD_STATES.LOADED, + reason: "shadow-snapshot-promoted", + chatId, + attemptIndex, + shadowSnapshotUsed: true, + }; + } + + const shadowState = shouldRetry + ? GRAPH_LOAD_STATES.SHADOW_RESTORED + : GRAPH_LOAD_STATES.BLOCKED; + applyGraphLoadState(shadowState, { chatId, - hasChatMetadata ? "graph-metadata-missing" : "chat-metadata-missing", + reason: shouldRetry + ? "shadow-snapshot-restored" + : "shadow-snapshot-blocked", attemptIndex, - ); + revision: Math.max(shadowSnapshot.revision || 0, 1), + lastPersistedRevision: 0, + queuedPersistRevision: Math.max(shadowSnapshot.revision || 0, 1), + pendingPersist: true, + shadowSnapshotUsed: true, + shadowSnapshotRevision: Math.max(shadowSnapshot.revision || 0, 1), + shadowSnapshotUpdatedAt: shadowSnapshot.updatedAt, + shadowSnapshotReason: shadowSnapshot.reason, + writesBlocked: true, + }); + if (shouldRetry) { + scheduleGraphLoadRetry( + chatId, + hasChatMetadata ? "official-graph-missing-shadow" : "chat-metadata-missing-shadow", + attemptIndex, + ); + } else { + clearPendingGraphLoadRetry(); + } refreshPanelLiveState(); - return false; + return { + success: false, + loaded: false, + loadState: shadowState, + reason: graphPersistenceState.reason, + chatId, + attemptIndex, + shadowSnapshotUsed: true, + }; } - clearPendingGraphLoadRetry(); currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); extractionCount = 0; lastExtractedItems = []; lastRecalledItems = []; lastInjectionContent = ""; - const noChatLoaded = !chatId; + if (shouldRetry) { + runtimeStatus = createUiStatus( + "待命", + "正在加载当前聊天图谱,暂不写回图谱元数据", + "idle", + ); + lastExtractionStatus = createUiStatus( + "待命", + "正在等待聊天图谱元数据加载", + "idle", + ); + lastVectorStatus = createUiStatus( + "待命", + "正在等待聊天图谱元数据加载", + "idle", + ); + lastRecallStatus = createUiStatus( + "待命", + "正在等待聊天图谱元数据加载", + "idle", + ); + applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, { + chatId, + reason: hasChatMetadata + ? "graph-metadata-missing" + : "chat-metadata-missing", + attemptIndex, + revision: 0, + lastPersistedRevision: 0, + queuedPersistRevision: 0, + pendingPersist: false, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + writesBlocked: true, + }); + scheduleGraphLoadRetry( + chatId, + hasChatMetadata ? "graph-metadata-missing" : "chat-metadata-missing", + attemptIndex, + ); + refreshPanelLiveState(); + return { + success: false, + loaded: false, + loadState: GRAPH_LOAD_STATES.LOADING, + reason: graphPersistenceState.reason, + chatId, + attemptIndex, + shadowSnapshotUsed: false, + }; + } + + clearPendingGraphLoadRetry(); + const confirmedState = hasChatMetadata + ? GRAPH_LOAD_STATES.EMPTY_CONFIRMED + : GRAPH_LOAD_STATES.BLOCKED; runtimeStatus = createUiStatus( "待命", - noChatLoaded ? "当前尚未进入聊天" : "当前聊天尚未建立记忆图谱", - "idle", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ? "当前聊天还没有图谱" + : "聊天元数据未就绪,已暂停图谱写回", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED ? "idle" : "warning", ); lastExtractionStatus = createUiStatus( "待命", - noChatLoaded ? "当前尚未进入聊天" : "当前聊天尚未执行提取", - "idle", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ? "当前聊天尚未执行提取" + : "聊天元数据未就绪,暂不允许修改图谱", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED ? "idle" : "warning", ); lastVectorStatus = createUiStatus( "待命", - noChatLoaded ? "当前尚未进入聊天" : "当前聊天尚未执行向量任务", - "idle", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ? "当前聊天尚未执行向量任务" + : "聊天元数据未就绪,暂不允许修改图谱", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED ? "idle" : "warning", ); lastRecallStatus = createUiStatus( "待命", - noChatLoaded ? "当前尚未进入聊天" : "当前聊天尚未建立记忆图谱", - "idle", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ? "当前聊天尚未建立记忆图谱" + : "聊天元数据未就绪,图谱处于保护状态", + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED ? "idle" : "warning", ); + applyGraphLoadState(confirmedState, { + chatId, + reason: + confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ? "metadata-confirmed-empty" + : "chat-metadata-timeout", + attemptIndex, + revision: 0, + lastPersistedRevision: 0, + queuedPersistRevision: 0, + pendingPersist: false, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + writesBlocked: confirmedState !== GRAPH_LOAD_STATES.EMPTY_CONFIRMED, + }); + if (confirmedState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED) { + removeGraphShadowSnapshot(chatId); + } refreshPanelLiveState(); - return false; + return { + success: false, + loaded: false, + loadState: confirmedState, + reason: graphPersistenceState.reason, + chatId, + attemptIndex, + shadowSnapshotUsed: false, + }; } -function saveGraphToChat() { +function saveGraphToChat(options = {}) { const context = getContext(); - if (!context || !currentGraph) return false; + if (!context || !currentGraph) { + return buildGraphPersistResult({ + saved: false, + blocked: true, + reason: "missing-context-or-graph", + }); + } const chatId = getCurrentChatId(context); + const { + reason = "graph-save", + markMutation = true, + captureShadow = true, + } = options; ensureCurrentGraphRuntimeState(); currentGraph.historyState.extractionCount = extractionCount; + if (!chatId) { + return buildGraphPersistResult({ + saved: false, + blocked: true, + reason: "missing-chat-id", + }); + } - if (isGraphLoadRetryPending(chatId) && isGraphEffectivelyEmpty(currentGraph)) { + const revision = markMutation + ? bumpGraphRevision(reason) + : graphPersistenceState.revision || 0; + + if (captureShadow) { + maybeCaptureGraphShadowSnapshot(reason); + } + + if (!isGraphMetadataWriteAllowed()) { console.warn( - `[ST-BME] 图谱元数据仍在加载中,已跳过空图写回(chat=${chatId})`, + `[ST-BME] 图谱写回已被安全保护拦截(chat=${chatId},state=${graphPersistenceState.loadState},reason=${reason})`, ); - return false; + return queueGraphPersist(reason, revision); } - if (typeof context.updateChatMetadata === "function") { - context.updateChatMetadata({ [GRAPH_METADATA_KEY]: currentGraph }); - } else { - if ( - !context.chatMetadata || - typeof context.chatMetadata !== "object" || - Array.isArray(context.chatMetadata) - ) { - context.chatMetadata = {}; - } - context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph; - } + return persistGraphToChatMetadata(context, { + reason, + revision, + }); +} - if (typeof context.saveMetadataDebounced === "function") { - context.saveMetadataDebounced(); - } else { - saveMetadataDebounced(); - } +function handleGraphShadowSnapshotPageHide() { + maybeCaptureGraphShadowSnapshot("pagehide"); +} - return true; +function handleGraphShadowSnapshotVisibilityChange() { + if (document.visibilityState === "hidden") { + maybeCaptureGraphShadowSnapshot("visibility-hidden"); + } } // ==================== 核心流程 ==================== @@ -2849,7 +3554,7 @@ function inspectHistoryMutation( metaReason, metaDetection.source, ); - saveGraphToChat(); + saveGraphToChat({ reason: "history-dirty-meta-detection" }); notifyHistoryDirty(metaDetection.floor, metaReason); return { dirty: true, @@ -2868,7 +3573,7 @@ function inspectHistoryMutation( detection.reason || trigger, "hash-recheck", ); - saveGraphToChat(); + saveGraphToChat({ reason: "history-dirty-hash-recheck" }); notifyHistoryDirty(detection.earliestAffectedFloor, detection.reason); return { ...detection, @@ -3052,7 +3757,7 @@ async function executeExtractionBatch({ extractionCountBefore, }), ); - saveGraphToChat(); + saveGraphToChat({ reason: "extraction-batch-complete" }); return { success: finalizedBatchStatus.completed, @@ -3252,7 +3957,7 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) { reason: "manual-reroll", }), ); - saveGraphToChat(); + saveGraphToChat({ reason: "reroll-rollback-complete" }); refreshPanelLiveState(); return { @@ -3386,7 +4091,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { trigger, }), ); - saveGraphToChat(); + saveGraphToChat({ reason: "history-recovery-complete" }); refreshPanelLiveState(); updateStageNotice( "history", @@ -3417,7 +4122,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { persist: false, }, ); - saveGraphToChat(); + saveGraphToChat({ reason: "history-recovery-aborted" }); return false; } console.error("[ST-BME] 历史恢复失败,尝试全量重建:", error); @@ -3446,7 +4151,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { reason: `恢复失败后兜底全量重建: ${error?.message || error}`, }), ); - saveGraphToChat(); + saveGraphToChat({ reason: "history-recovery-fallback-rebuild" }); refreshPanelLiveState(); updateStageNotice( "history", @@ -3475,7 +4180,7 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") { reason: String(fallbackError), }, ); - saveGraphToChat(); + saveGraphToChat({ reason: "history-recovery-failed" }); refreshPanelLiveState(); updateStageNotice( "history", @@ -3504,6 +4209,15 @@ async function runExtraction() { const settings = getSettings(); if (!settings.enabled) return; + if (!ensureGraphMutationReady("自动提取", { notify: false })) { + setLastExtractionStatus( + "等待图谱加载", + getGraphMutationBlockReason("自动提取"), + "warning", + { syncRuntime: true }, + ); + return; + } if (!(await recoverHistoryIfNeeded("auto-extract"))) return; const context = getContext(); @@ -3636,7 +4350,7 @@ function applyRecallInjection(settings, recallInput, recentMessages, result) { currentGraph.lastRecallResult = result.selectedNodeIds; updateLastRecalledItems(result.selectedNodeIds || []); - saveGraphToChat(); + saveGraphToChat({ reason: "recall-result-updated" }); const llmLabel = llmMeta.status === "llm" @@ -3696,7 +4410,18 @@ async function runRecall(options = {}) { const settings = getSettings(); if (!settings.enabled || !settings.recallEnabled) return false; - if (!(await recoverHistoryIfNeeded("pre-recall"))) return false; + if (!isGraphReadable()) { + setLastRecallStatus( + "等待图谱加载", + getGraphMutationBlockReason("召回"), + "warning", + { syncRuntime: true }, + ); + return false; + } + if (isGraphMetadataWriteAllowed()) { + if (!(await recoverHistoryIfNeeded("pre-recall"))) return false; + } const context = getContext(); const chat = context.chat; @@ -3985,7 +4710,14 @@ async function onBeforeCombinePrompts() { function onMessageReceived() { // 新消息到达,图状态可能需要更新 if (currentGraph) { - saveGraphToChat(); + if (isGraphMetadataWriteAllowed()) { + saveGraphToChat({ + reason: "message-received-passive-sync", + markMutation: false, + }); + } else { + maybeCaptureGraphShadowSnapshot("message-received-passive-sync"); + } } if ( @@ -4037,6 +4769,7 @@ async function onViewGraph() { async function onRebuild() { if (!confirm("确定要从当前聊天重建图谱?这将清除现有图谱数据。")) return; + if (!ensureGraphMutationReady("重建图谱")) return; const context = getContext(); const chat = context?.chat; @@ -4075,7 +4808,7 @@ async function onRebuild() { reason: "用户手动触发全量重建", }), ); - saveGraphToChat(); + saveGraphToChat({ reason: "manual-rebuild-complete" }); if (currentGraph.vectorIndexState?.lastWarning) { toastr.warning( @@ -4090,7 +4823,7 @@ async function onRebuild() { getCurrentChatId(), ); restoreRuntimeUiState(previousUiState); - saveGraphToChat(); + saveGraphToChat({ reason: "manual-rebuild-restore-previous" }); throw new Error( `图谱重建失败,已恢复到重建前状态: ${error?.message || error}`, ); @@ -4099,6 +4832,7 @@ async function onRebuild() { async function onManualCompress() { if (!currentGraph) return; + if (!ensureGraphMutationReady("手动压缩")) return; const beforeSnapshot = cloneGraphSnapshot(currentGraph); const result = await compressAll( @@ -4134,31 +4868,83 @@ async function onExportGraph() { } async function onImportGraph() { + if (!ensureGraphMutationReady("导入图谱")) { + return { cancelled: true }; + } const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; - input.onchange = async (e) => { - const file = e.target.files[0]; - if (!file) return; + return await new Promise((resolve, reject) => { + let settled = false; + let focusTimer = null; - try { - const text = await file.text(); - currentGraph = normalizeGraphRuntimeState( - importGraph(text), - getCurrentChatId(), - ); - markVectorStateDirty("导入图谱后需要重建向量索引"); - extractionCount = 0; - lastExtractedItems = []; - updateLastRecalledItems(currentGraph.lastRecallResult || []); - clearInjectionState(); - saveGraphToChat(); - toastr.success("图谱已导入"); - } catch (err) { - toastr.error(`导入失败: ${err.message}`); - } - }; - input.click(); + const cleanup = () => { + if (focusTimer) { + clearTimeout(focusTimer); + focusTimer = null; + } + input.onchange = null; + window.removeEventListener("focus", onWindowFocus, true); + }; + + const finish = (value, isError = false) => { + if (settled) return; + settled = true; + cleanup(); + if (isError) { + reject(value); + } else { + resolve(value); + } + }; + + const onWindowFocus = () => { + focusTimer = setTimeout(() => { + if (!settled) { + finish({ cancelled: true }); + } + }, 180); + }; + + window.addEventListener("focus", onWindowFocus, true); + input.addEventListener( + "cancel", + () => { + finish({ cancelled: true }); + }, + { once: true }, + ); + input.onchange = async (e) => { + const file = e.target.files?.[0]; + if (!file) { + finish({ cancelled: true }); + return; + } + + try { + const text = await file.text(); + currentGraph = normalizeGraphRuntimeState( + importGraph(text), + getCurrentChatId(), + ); + markVectorStateDirty("导入图谱后需要重建向量索引"); + extractionCount = 0; + lastExtractedItems = []; + updateLastRecalledItems(currentGraph.lastRecallResult || []); + clearInjectionState(); + saveGraphToChat({ reason: "graph-import-complete" }); + toastr.success("图谱已导入"); + finish({ imported: true, handledToast: true }); + } catch (err) { + const error = + err instanceof Error ? err : new Error(String(err || "导入失败")); + toastr.error(`导入失败: ${error.message}`); + error._stBmeToastHandled = true; + finish(error, true); + } + }; + input.click(); + }); } async function onViewLastInjection() { @@ -4254,6 +5040,7 @@ async function onManualExtract() { toastr.info("记忆提取正在进行中,请稍候"); return; } + if (!ensureGraphMutationReady("手动提取")) return; if (!(await recoverHistoryIfNeeded("manual-extract"))) return; if (!currentGraph) currentGraph = normalizeGraphRuntimeState( @@ -4393,6 +5180,27 @@ async function onReroll({ fromFloor } = {}) { error: "记忆提取正在进行中", }; } + if ( + typeof ensureGraphMutationReady === "function" && + !ensureGraphMutationReady("重新提取") + ) { + return { + success: false, + rollbackPerformed: false, + extractionTriggered: false, + requestedFloor: Number.isFinite(fromFloor) ? fromFloor : null, + effectiveFromFloor: null, + recoveryPath: + typeof graphPersistenceState === "object" + ? graphPersistenceState.loadState + : "graph-not-ready", + affectedBatchCount: 0, + error: + typeof getGraphMutationBlockReason === "function" + ? getGraphMutationBlockReason("重新提取") + : "重新提取已暂停:图谱尚未就绪。", + }; + } if (!currentGraph) { toastr.info("图谱为空,无需重 Roll"); return { @@ -4491,6 +5299,7 @@ async function onReroll({ fromFloor } = {}) { async function onManualSleep() { if (!currentGraph) return; + if (!ensureGraphMutationReady("执行遗忘")) return; const beforeSnapshot = cloneGraphSnapshot(currentGraph); const result = sleepCycle(currentGraph, getSettings()); await recordGraphMutation({ @@ -4502,6 +5311,7 @@ async function onManualSleep() { async function onManualSynopsis() { if (!currentGraph) return; + if (!ensureGraphMutationReady("更新概要")) return; const beforeSnapshot = cloneGraphSnapshot(currentGraph); await generateSynopsis({ graph: currentGraph, @@ -4519,6 +5329,7 @@ async function onManualSynopsis() { async function onManualEvolve() { if (!currentGraph) return; + if (!ensureGraphMutationReady("强制进化")) return; const candidateIds = lastExtractedItems .map((item) => item.id) @@ -4550,6 +5361,7 @@ async function onManualEvolve() { } async function onRebuildVectorIndex(range = null) { + if (!ensureGraphMutationReady(range ? "范围重建向量" : "重建向量")) return; ensureCurrentGraphRuntimeState(); const config = getEmbeddingConfig(); const validation = validateVectorConfig(config); @@ -4567,7 +5379,7 @@ async function onRebuildVectorIndex(range = null) { signal: vectorController.signal, }); - saveGraphToChat(); + saveGraphToChat({ reason: "vector-rebuild-complete" }); if (result?.aborted) { return; } @@ -4598,8 +5410,14 @@ async function onReembedDirect() { (async function init() { await loadServerSettings(); + syncGraphPersistenceDebugState(); initializeHostCapabilityBridge(); installSendIntentHooks(); + globalThis.addEventListener?.("pagehide", handleGraphShadowSnapshotPageHide); + document.addEventListener( + "visibilitychange", + handleGraphShadowSnapshotVisibilityChange, + ); // 注册事件钩子 eventSource.on(event_types.CHAT_CHANGED, onChatChanged); @@ -4637,7 +5455,7 @@ async function onReembedDirect() { getSettings: () => getSettings(), getLastExtract: () => lastExtractedItems, getLastRecall: () => lastRecalledItems, - getRuntimeStatus: () => runtimeStatus, + getRuntimeStatus: () => getPanelRuntimeStatus(), getLastExtractionStatus: () => lastExtractionStatus, getLastVectorStatus: () => lastVectorStatus, getLastRecallStatus: () => lastRecallStatus, @@ -4646,6 +5464,7 @@ async function onReembedDirect() { getLastInjection: () => lastInjectionContent, getRuntimeDebugSnapshot: (options = {}) => getPanelRuntimeDebugSnapshot(options), + getGraphPersistenceState: () => getGraphPersistenceLiveState(), updateSettings: (patch) => { const settings = updateModuleSettings(patch); if (Object.prototype.hasOwnProperty.call(patch, "panelTheme")) { diff --git a/panel.html b/panel.html index 626176a..76c8b4b 100644 --- a/panel.html +++ b/panel.html @@ -188,6 +188,11 @@
+ REALTIME
+
@@ -395,6 +401,11 @@
+
diff --git a/panel.js b/panel.js index 9d1527e..c80c392 100644 --- a/panel.js +++ b/panel.js @@ -72,6 +72,20 @@ const TASK_PROFILE_BOOLEAN_OPTIONS = [ { value: "false", label: "关闭" }, ]; +const GRAPH_WRITE_ACTION_IDS = [ + "bme-act-extract", + "bme-act-compress", + "bme-act-sleep", + "bme-act-synopsis", + "bme-act-evolve", + "bme-act-import", + "bme-act-rebuild", + "bme-act-vector-rebuild", + "bme-act-vector-range", + "bme-act-vector-reembed", + "bme-act-reroll", +]; + const TASK_PROFILE_GENERATION_GROUPS = [ { title: "基础生成参数", @@ -153,6 +167,7 @@ let _getLastVectorStatus = null; let _getLastRecallStatus = null; let _getLastInjection = null; let _getRuntimeDebugSnapshot = null; +let _getGraphPersistenceState = null; let _updateSettings = null; let _actionHandlers = {}; @@ -348,6 +363,7 @@ export async function initPanel({ getLastRecallStatus, getLastInjection, getRuntimeDebugSnapshot, + getGraphPersistenceState, updateSettings, actions, }) { @@ -361,6 +377,7 @@ export async function initPanel({ _getLastRecallStatus = getLastRecallStatus; _getLastInjection = getLastInjection; _getRuntimeDebugSnapshot = getRuntimeDebugSnapshot; + _getGraphPersistenceState = getGraphPersistenceState; _updateSettings = updateSettings; _actionHandlers = actions || {}; @@ -748,8 +765,32 @@ function _syncConfigSectionState() { function _refreshDashboard() { const graph = _getGraph?.(); + const loadInfo = _getGraphPersistenceSnapshot(); if (!graph) return; + if (!_canRenderGraphData(loadInfo) && loadInfo.loadState !== "empty-confirmed") { + _setText("bme-stat-nodes", "—"); + _setText("bme-stat-edges", "—"); + _setText("bme-stat-archived", "—"); + _setText("bme-stat-frag", "—"); + _setText("bme-status-chat-id", loadInfo.chatId || "—"); + _setText("bme-status-history", _getGraphLoadLabel(loadInfo.loadState)); + _setText("bme-status-vector", "等待聊天图谱元数据加载"); + _setText("bme-status-recovery", "等待聊天图谱元数据加载"); + _setText("bme-status-last-extract", "等待聊天图谱元数据加载"); + _setText("bme-status-last-vector", "等待聊天图谱元数据加载"); + _setText("bme-status-last-recall", "等待聊天图谱元数据加载"); + _renderStatefulListPlaceholder( + document.getElementById("bme-recent-extract"), + _getGraphLoadLabel(loadInfo.loadState), + ); + _renderStatefulListPlaceholder( + document.getElementById("bme-recent-recall"), + _getGraphLoadLabel(loadInfo.loadState), + ); + return; + } + const activeNodes = graph.nodes.filter((node) => !node.archived); const archivedCount = graph.nodes.filter((node) => node.archived).length; const totalNodes = graph.nodes.length; @@ -761,7 +802,7 @@ function _refreshDashboard() { _setText("bme-stat-archived", archivedCount); _setText("bme-stat-frag", `${fragRate}%`); - const chatId = graph?.historyState?.chatId || "—"; + const chatId = loadInfo.chatId || graph?.historyState?.chatId || "—"; const lastProcessed = graph?.historyState?.lastProcessedAssistantFloor ?? -1; const dirtyFrom = graph?.historyState?.historyDirtyFrom; const vectorStats = getVectorIndexStats(graph); @@ -771,13 +812,21 @@ function _refreshDashboard() { const extractionStatus = _getLastExtractionStatus?.() || {}; const vectorStatus = _getLastVectorStatus?.() || {}; const recallStatus = _getLastRecallStatus?.() || {}; + const historyPrefix = + loadInfo.loadState === "shadow-restored" + ? "临时恢复 · " + : loadInfo.loadState === "blocked" && loadInfo.shadowSnapshotUsed + ? "保护模式 · " + : ""; _setText("bme-status-chat-id", chatId); _setText( "bme-status-history", - Number.isFinite(dirtyFrom) - ? `脏区从楼层 ${dirtyFrom} 开始,已处理到 ${lastProcessed}` - : `干净,已处理到楼层 ${lastProcessed}`, + `${historyPrefix}${ + Number.isFinite(dirtyFrom) + ? `脏区从楼层 ${dirtyFrom} 开始,已处理到 ${lastProcessed}` + : `干净,已处理到楼层 ${lastProcessed}` + }`, ); _setText( "bme-status-vector", @@ -857,6 +906,7 @@ function _renderRecentList(elementId, items) { function _refreshMemoryBrowser() { const graph = _getGraph?.(); + const loadInfo = _getGraphPersistenceSnapshot(); if (!graph) return; const searchInput = document.getElementById("bme-memory-search"); @@ -864,6 +914,15 @@ function _refreshMemoryBrowser() { const listEl = document.getElementById("bme-memory-list"); if (!listEl) return; + const canRenderGraph = _canRenderGraphData(loadInfo); + if (searchInput) searchInput.disabled = !canRenderGraph; + if (filterSelect) filterSelect.disabled = !canRenderGraph; + + if (!canRenderGraph && loadInfo.loadState !== "empty-confirmed") { + _renderStatefulListPlaceholder(listEl, _getGraphLoadLabel(loadInfo.loadState)); + return; + } + const query = String(searchInput?.value || "") .trim() .toLowerCase(); @@ -887,6 +946,11 @@ function _refreshMemoryBrowser() { return (b.seqRange?.[1] ?? b.seq ?? 0) - (a.seqRange?.[1] ?? a.seq ?? 0); }); + if (!nodes.length && loadInfo.loadState === "empty-confirmed") { + _renderStatefulListPlaceholder(listEl, "当前聊天还没有图谱"); + return; + } + const fragment = document.createDocumentFragment(); nodes.slice(0, 100).forEach((node) => { const name = getNodeDisplayName(node); @@ -1221,7 +1285,10 @@ function _bindActions() { toastr.info(`${label} 进行中…`, "ST-BME", { timeOut: 2000 }); try { - await handler(); + const result = await handler(); + if (result?.cancelled) { + return; + } _refreshDashboard(); _refreshGraph(); if ( @@ -1238,13 +1305,17 @@ function _bindActions() { ) { await _refreshInjectionPreview(); } - toastr.success(`${label} 完成`, "ST-BME"); + if (!result?.handledToast) { + toastr.success(`${label} 完成`, "ST-BME"); + } } catch (error) { console.error(`[ST-BME] Action ${actionKey} failed:`, error); - toastr.error(`${label} 失败: ${error?.message || error}`, "ST-BME"); + if (!error?._stBmeToastHandled) { + toastr.error(`${label} 失败: ${error?.message || error}`, "ST-BME"); + } } finally { - btn.disabled = false; btn.style.opacity = ""; + _refreshGraphAvailabilityState(); } }); } @@ -1281,9 +1352,9 @@ function _bindActions() { toastr.error(`范围重建 失败: ${error?.message || error}`, "ST-BME"); } finally { if (btn) { - btn.disabled = false; btn.style.opacity = ""; } + _refreshGraphAvailabilityState(); } }); @@ -1327,9 +1398,9 @@ function _bindActions() { toastr.error(`重新提取失败: ${error?.message || error}`, "ST-BME"); } finally { if (btn) { - btn.disabled = false; btn.style.opacity = ""; } + _refreshGraphAvailabilityState(); } }); } @@ -2824,6 +2895,7 @@ function _renderTaskDebugTab(state) { const promptBuild = runtimeDebug?.taskPromptBuilds?.[state.taskType] || null; const llmRequest = runtimeDebug?.taskLlmRequests?.[state.taskType] || null; const recallInjection = runtimeDebug?.injections?.recall || null; + const graphPersistence = runtimeDebug?.graphPersistence || null; return `
@@ -2840,6 +2912,9 @@ function _renderTaskDebugTab(state) {
${_renderTaskDebugHostCard(hostCapabilities)}
+
+ ${_renderTaskDebugGraphPersistenceCard(graphPersistence)} +
${_renderTaskDebugPromptCard(state.taskType, promptBuild)}
@@ -2854,6 +2929,62 @@ function _renderTaskDebugTab(state) { `; } +function _renderTaskDebugGraphPersistenceCard(graphPersistence) { + if (!graphPersistence) { + return ` +
图谱持久化状态
+
当前还没有图谱加载/持久化快照。
+ `; + } + + return ` +
+
+
图谱持久化状态
+
+ 最近一次图谱加载与写回协调结果。 +
+
+ ${_escHtml(graphPersistence.loadState || "unknown")} +
+
+
+ 聊天 + ${_escHtml(graphPersistence.chatId || "—")} +
+
+ 原因 + ${_escHtml(graphPersistence.reason || "—")} +
+
+ 尝试次数 + ${_escHtml(String(graphPersistence.attemptIndex ?? 0))} +
+
+ 当前 revision + ${_escHtml(String(graphPersistence.graphRevision ?? 0))} +
+
+ 最近已持久化 revision + ${_escHtml(String(graphPersistence.lastPersistedRevision ?? 0))} +
+
+ 排队中的 revision + ${_escHtml(String(graphPersistence.queuedPersistRevision ?? 0))} +
+
+ 影子快照 + ${_escHtml(graphPersistence.shadowSnapshotUsed ? "已接管" : "未使用")} +
+
+ 写保护 + ${_escHtml(graphPersistence.writesBlocked ? "已启用" : "未启用")} +
+
+ ${_renderDebugDetails("图谱持久化详情", graphPersistence)} + `; +} + function _renderTaskDebugHostCard(hostCapabilities) { if (!hostCapabilities) { return ` @@ -4054,6 +4185,104 @@ function _setText(id, text) { if (el) el.textContent = String(text); } +function _getGraphPersistenceSnapshot() { + return _getGraphPersistenceState?.() || { + loadState: "no-chat", + reason: "", + writesBlocked: true, + shadowSnapshotUsed: false, + pendingPersist: false, + chatId: "", + }; +} + +function _getGraphLoadLabel(loadState = "") { + switch (loadState) { + case "loading": + return "正在加载当前聊天图谱"; + case "shadow-restored": + return "已从本次会话临时恢复,正在等待正式聊天元数据"; + case "empty-confirmed": + return "当前聊天还没有图谱"; + case "blocked": + return "聊天元数据未就绪,已暂停图谱写回以保护旧数据"; + case "loaded": + return "聊天图谱已加载"; + case "no-chat": + default: + return "当前尚未进入聊天"; + } +} + +function _canRenderGraphData(loadInfo = _getGraphPersistenceSnapshot()) { + return ( + loadInfo.loadState === "loaded" || + loadInfo.loadState === "empty-confirmed" || + loadInfo.shadowSnapshotUsed === true + ); +} + +function _isGraphWriteBlocked(loadInfo = _getGraphPersistenceSnapshot()) { + return Boolean(loadInfo.writesBlocked); +} + +function _renderStatefulListPlaceholder(listEl, text) { + if (!listEl) return; + const li = document.createElement("li"); + li.className = "bme-recent-item"; + const content = document.createElement("div"); + content.className = "bme-recent-text"; + content.style.color = "var(--bme-on-surface-dim)"; + content.textContent = text; + li.appendChild(content); + listEl.replaceChildren(li); +} + +function _refreshGraphAvailabilityState() { + const loadInfo = _getGraphPersistenceSnapshot(); + const banner = document.getElementById("bme-action-guard-banner"); + const graphOverlay = document.getElementById("bme-graph-overlay"); + const graphOverlayText = document.getElementById("bme-graph-overlay-text"); + const mobileOverlay = document.getElementById("bme-mobile-graph-overlay"); + const mobileOverlayText = document.getElementById("bme-mobile-graph-overlay-text"); + const blocked = _isGraphWriteBlocked(loadInfo); + const loadLabel = _getGraphLoadLabel(loadInfo.loadState); + + GRAPH_WRITE_ACTION_IDS.forEach((id) => { + const button = document.getElementById(id); + if (!button) return; + button.disabled = blocked; + button.classList.toggle("is-runtime-disabled", blocked); + button.title = blocked ? loadLabel : ""; + }); + + if (banner) { + const shouldShowBanner = blocked; + banner.hidden = !shouldShowBanner; + banner.textContent = shouldShowBanner ? loadLabel : ""; + } + + const shouldShowOverlay = + loadInfo.loadState === "loading" || + loadInfo.loadState === "shadow-restored" || + loadInfo.loadState === "blocked"; + + if (graphOverlay) { + graphOverlay.hidden = !shouldShowOverlay; + graphOverlay.classList.toggle("active", shouldShowOverlay); + } + if (graphOverlayText) { + graphOverlayText.textContent = shouldShowOverlay ? loadLabel : ""; + } + if (mobileOverlay) { + mobileOverlay.hidden = !shouldShowOverlay; + mobileOverlay.classList.toggle("active", shouldShowOverlay); + } + if (mobileOverlayText) { + mobileOverlayText.textContent = shouldShowOverlay ? loadLabel : ""; + } +} + function _refreshRuntimeStatus() { const runtimeStatus = _getRuntimeStatus?.() || {}; const text = runtimeStatus.text || "待命"; @@ -4061,6 +4290,7 @@ function _refreshRuntimeStatus() { _setText("bme-status-text", text); _setText("bme-status-meta", meta); _setText("bme-panel-status", text); + _refreshGraphAvailabilityState(); } function _patchSettings(patch = {}, options = {}) { diff --git a/style.css b/style.css index a857d79..597c79c 100644 --- a/style.css +++ b/style.css @@ -543,6 +543,38 @@ position: relative; } +.bme-graph-overlay { + position: absolute; + inset: 58px 18px 52px; + display: none; + align-items: center; + justify-content: center; + padding: 18px; + background: rgba(6, 7, 10, 0.72); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + z-index: 3; + pointer-events: none; +} + +.bme-graph-overlay.active { + display: flex; +} + +.bme-graph-overlay__text { + max-width: 320px; + text-align: center; + font-size: 12px; + line-height: 1.6; + color: var(--bme-on-surface); +} + +.bme-mobile-graph-preview .bme-graph-overlay { + inset: 0; + border-radius: 0; +} + .bme-config-workspace { display: none; flex: 1; @@ -885,6 +917,32 @@ font-size: 18px; } +.bme-action-btn:disabled, +.bme-action-btn.is-runtime-disabled { + opacity: 0.45; + cursor: not-allowed; + border-color: rgba(255, 255, 255, 0.06); + color: var(--bme-on-surface-dim); + background: rgba(255, 255, 255, 0.02); +} + +.bme-action-btn:disabled:hover, +.bme-action-btn.is-runtime-disabled:hover { + border-color: rgba(255, 255, 255, 0.06); + color: var(--bme-on-surface-dim); + background: rgba(255, 255, 255, 0.02); +} + +.bme-action-guard-banner { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(255, 197, 79, 0.25); + background: rgba(255, 197, 79, 0.08); + color: #ffd27a; + font-size: 11px; + line-height: 1.5; +} + .bme-action-btn.danger:hover { border-color: #ff5252; color: #ff5252; diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs new file mode 100644 index 0000000..5e4a10e --- /dev/null +++ b/tests/graph-persistence.mjs @@ -0,0 +1,471 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +import { + createEmptyGraph, + deserializeGraph, + getGraphStats, + getNode, + serializeGraph, +} from "../graph.js"; +import { normalizeGraphRuntimeState } from "../runtime-state.js"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const indexPath = path.resolve(moduleDir, "../index.js"); +const indexSource = await fs.readFile(indexPath, "utf8"); + +function extractSnippet(startMarker, endMarker) { + const start = indexSource.indexOf(startMarker); + const end = indexSource.indexOf(endMarker); + if (start < 0 || end < 0 || end <= start) { + throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`); + } + return indexSource.slice(start, end).replace(/^export\s+/gm, ""); +} + +const persistencePrelude = extractSnippet( + 'const MODULE_NAME = "st_bme";', + "function clearInjectionState() {", +); +const persistenceCore = extractSnippet( + "function loadGraphFromChat(options = {}) {", + "function handleGraphShadowSnapshotPageHide() {", +); +const messageSnippet = extractSnippet( + "function onMessageReceived() {", + "// ==================== UI 操作 ====================", +); + +function createSessionStorage(seed = null) { + const store = seed instanceof Map ? seed : new Map(); + return { + __store: store, + 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 createMeaningfulGraph(chatId = "chat-test", suffix = "base") { + const graph = createEmptyGraph(); + graph.historyState.chatId = chatId; + graph.historyState.extractionCount = 3; + graph.historyState.lastProcessedAssistantFloor = 6; + graph.lastProcessedSeq = 6; + graph.lastRecallResult = [{ id: `recall-${suffix}` }]; + graph.nodes.push({ + id: `node-${suffix}`, + type: "event", + fields: { + title: `事件-${suffix}`, + summary: `摘要-${suffix}`, + }, + seq: 6, + seqRange: [6, 6], + archived: false, + embedding: null, + importance: 5, + accessCount: 0, + lastAccessTime: Date.now(), + createdTime: Date.now(), + level: 0, + parentId: null, + childIds: [], + prevId: null, + nextId: null, + clusters: [], + }); + return normalizeGraphRuntimeState(graph, chatId); +} + +async function createGraphPersistenceHarness({ + chatId = "chat-test", + chatMetadata = undefined, + sessionStore = null, +} = {}) { + const timers = new Map(); + let nextTimerId = 1; + const storage = createSessionStorage(sessionStore); + + const runtimeContext = { + console, + Date, + Math, + JSON, + Object, + Array, + String, + Number, + Boolean, + structuredClone, + result: null, + sessionStorage: storage, + setTimeout(fn, delay) { + const id = nextTimerId++; + timers.set(id, { fn, delay }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + queueMicrotask(fn) { + fn(); + }, + toastr: { + info() {}, + warning() {}, + error() {}, + success() {}, + }, + window: { + addEventListener() {}, + removeEventListener() {}, + }, + document: { + visibilityState: "visible", + getElementById() { + return null; + }, + }, + refreshPanelLiveState() { + runtimeContext.__panelRefreshCount += 1; + }, + __panelRefreshCount: 0, + createEmptyGraph, + normalizeGraphRuntimeState, + serializeGraph, + deserializeGraph, + getGraphStats, + getNode, + createDefaultTaskProfiles() { + return { + extract: { activeProfileId: "default", profiles: [] }, + recall: { activeProfileId: "default", profiles: [] }, + compress: { activeProfileId: "default", profiles: [] }, + synopsis: { activeProfileId: "default", profiles: [] }, + reflection: { activeProfileId: "default", profiles: [] }, + }; + }, + getContext() { + return runtimeContext.__chatContext; + }, + saveMetadataDebounced() { + runtimeContext.__globalSaveCalls += 1; + }, + __globalSaveCalls: 0, + isAssistantChatMessage() { + return false; + }, + isFreshRecallInputRecord() { + return true; + }, + notifyExtractionIssue() {}, + async runExtraction() {}, + __chatContext: { + chatId, + chatMetadata, + updateChatMetadata(patch) { + const base = + this.chatMetadata && + typeof this.chatMetadata === "object" && + !Array.isArray(this.chatMetadata) + ? this.chatMetadata + : {}; + this.chatMetadata = { + ...base, + ...(patch || {}), + }; + }, + saveMetadataDebounced() { + runtimeContext.__contextSaveCalls += 1; + }, + }, + __contextSaveCalls: 0, + }; + + runtimeContext.globalThis = runtimeContext; + vm.createContext(runtimeContext); + vm.runInContext( + [ + persistencePrelude, + persistenceCore, + messageSnippet, + ` +result = { + GRAPH_LOAD_STATES, + GRAPH_LOAD_RETRY_DELAYS_MS, + readRuntimeDebugSnapshot, + getGraphPersistenceLiveState, + readGraphShadowSnapshot, + writeGraphShadowSnapshot, + removeGraphShadowSnapshot, + maybeCaptureGraphShadowSnapshot, + loadGraphFromChat, + saveGraphToChat, + onMessageReceived, + applyGraphLoadState, + maybeFlushQueuedGraphPersist, + setCurrentGraph(graph) { + currentGraph = graph; + return currentGraph; + }, + getCurrentGraph() { + return currentGraph; + }, + setGraphPersistenceState(patch = {}) { + graphPersistenceState = { + ...graphPersistenceState, + ...(patch || {}), + updatedAt: new Date().toISOString(), + }; + syncGraphPersistenceDebugState(); + return graphPersistenceState; + }, + getGraphPersistenceState() { + return graphPersistenceState; + }, + setChatContext(nextContext) { + globalThis.__chatContext = nextContext; + return globalThis.__chatContext; + }, + getChatContext() { + return globalThis.__chatContext; + }, +}; + `, + ].join("\n"), + runtimeContext, + { filename: indexPath }, + ); + + return { + api: runtimeContext.result, + runtimeContext, + sessionStore: storage.__store, + }; +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-blocked", + chatMetadata: undefined, + }); + const graph = createMeaningfulGraph("chat-blocked", "blocked"); + harness.api.setCurrentGraph(graph); + harness.api.setGraphPersistenceState({ + loadState: "loading", + chatId: "chat-blocked", + reason: "chat-metadata-missing", + revision: 4, + writesBlocked: true, + }); + + const result = harness.api.saveGraphToChat({ + reason: "blocked-save", + markMutation: false, + }); + assert.equal(result.saved, false); + assert.equal(result.queued, true); + assert.equal(result.blocked, true); + assert.equal(harness.runtimeContext.__chatContext.chatMetadata, undefined); + assert.equal(harness.runtimeContext.__contextSaveCalls, 0); + assert.equal(harness.runtimeContext.__globalSaveCalls, 0); + + const shadow = harness.api.readGraphShadowSnapshot("chat-blocked"); + assert.ok(shadow, "loading 状态下应写入会话影子快照"); + assert.equal(shadow.revision, 4); + assert.equal( + harness.api.readRuntimeDebugSnapshot().graphPersistence?.queuedPersistRevision, + 4, + ); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-empty", + chatMetadata: undefined, + }); + harness.api.setCurrentGraph(normalizeGraphRuntimeState(createEmptyGraph(), "chat-empty")); + harness.api.setGraphPersistenceState({ + loadState: "loading", + chatId: "chat-empty", + reason: "chat-metadata-missing", + revision: 0, + writesBlocked: true, + }); + + const result = harness.api.saveGraphToChat({ + reason: "loading-empty-save", + markMutation: false, + }); + assert.equal(result.blocked, true); + assert.equal( + harness.api.readGraphShadowSnapshot("chat-empty"), + null, + "空图不应污染影子快照", + ); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-message", + chatMetadata: undefined, + }); + harness.api.setCurrentGraph(createMeaningfulGraph("chat-message", "message")); + harness.api.setGraphPersistenceState({ + loadState: "loading", + chatId: "chat-message", + reason: "chat-metadata-missing", + revision: 2, + writesBlocked: true, + }); + + harness.api.onMessageReceived(); + + assert.equal( + harness.runtimeContext.__chatContext.chatMetadata, + undefined, + "onMessageReceived 不应在 loading 期间写回 chat metadata", + ); + assert.equal(harness.runtimeContext.__contextSaveCalls, 0); + assert.ok( + harness.api.readGraphShadowSnapshot("chat-message"), + "onMessageReceived 应只做会话快照兜底", + ); +} + +{ + const sharedSession = new Map(); + const writer = await createGraphPersistenceHarness({ + chatId: "chat-shadow", + chatMetadata: undefined, + sessionStore: sharedSession, + }); + writer.api.writeGraphShadowSnapshot( + "chat-shadow", + createMeaningfulGraph("chat-shadow", "shadow"), + { revision: 7, reason: "manual-shadow" }, + ); + + const reader = await createGraphPersistenceHarness({ + chatId: "chat-shadow", + chatMetadata: undefined, + sessionStore: sharedSession, + }); + const result = reader.api.loadGraphFromChat({ + attemptIndex: 0, + source: "shadow-test", + }); + + assert.equal(result.loadState, "shadow-restored"); + assert.equal(reader.api.getCurrentGraph().nodes.length, 1); + assert.equal( + reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed, + true, + ); + assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, true); +} + +{ + const sharedSession = new Map(); + const writer = await createGraphPersistenceHarness({ + chatId: "chat-official", + chatMetadata: undefined, + sessionStore: sharedSession, + }); + writer.api.writeGraphShadowSnapshot( + "chat-official", + createMeaningfulGraph("chat-official", "shadow-stale"), + { revision: 3, reason: "stale-shadow" }, + ); + + const officialGraph = createMeaningfulGraph("chat-official", "official"); + const reader = await createGraphPersistenceHarness({ + chatId: "chat-official", + chatMetadata: { + st_bme_graph: officialGraph, + }, + sessionStore: sharedSession, + }); + const result = reader.api.loadGraphFromChat({ + attemptIndex: 0, + source: "official-load", + }); + + assert.equal(result.loadState, "loaded"); + assert.equal( + reader.api.getCurrentGraph().nodes[0]?.fields?.title, + "事件-official", + ); + assert.equal( + reader.api.readGraphShadowSnapshot("chat-official"), + null, + "正式元数据到位后应清理影子快照", + ); +} + +{ + const harness = await createGraphPersistenceHarness({ + chatId: "chat-empty-confirmed", + chatMetadata: {}, + }); + const result = harness.api.loadGraphFromChat({ + attemptIndex: harness.api.GRAPH_LOAD_RETRY_DELAYS_MS.length, + source: "timeout-empty", + }); + const live = harness.api.getGraphPersistenceLiveState(); + + assert.equal(result.loadState, "empty-confirmed"); + assert.equal(live.writesBlocked, false); + assert.equal(live.canWriteToMetadata, true); + assert.equal(harness.api.getCurrentGraph().nodes.length, 0); + assert.equal( + harness.api.readRuntimeDebugSnapshot().graphPersistence?.loadState, + "empty-confirmed", + ); +} + +{ + const sharedSession = new Map(); + const writer = await createGraphPersistenceHarness({ + chatId: "chat-promote", + chatMetadata: undefined, + sessionStore: sharedSession, + }); + writer.api.writeGraphShadowSnapshot( + "chat-promote", + createMeaningfulGraph("chat-promote", "promote"), + { revision: 9, reason: "pre-refresh" }, + ); + + const reader = await createGraphPersistenceHarness({ + chatId: "chat-promote", + chatMetadata: {}, + sessionStore: sharedSession, + }); + const result = reader.api.loadGraphFromChat({ + attemptIndex: reader.api.GRAPH_LOAD_RETRY_DELAYS_MS.length, + source: "promote-after-timeout", + }); + const live = reader.api.getGraphPersistenceLiveState(); + + assert.equal(result.loadState, "loaded"); + assert.equal( + reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes?.length, + 1, + ); + assert.equal(reader.runtimeContext.__contextSaveCalls, 1); + assert.equal(live.lastPersistedRevision, 9); + assert.equal(live.pendingPersist, false); +} + +console.log("graph-persistence tests passed");