Add Luker chat state persistence compatibility

This commit is contained in:
Youzini-afk
2026-04-10 17:00:37 +08:00
parent 8836c75e3d
commit 5a77506ab1
3 changed files with 922 additions and 38 deletions

View File

@@ -11,6 +11,9 @@ import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
export const MODULE_NAME = "st_bme";
export const GRAPH_METADATA_KEY = "st_bme_graph";
export const GRAPH_COMMIT_MARKER_KEY = "st_bme_commit_marker";
export const GRAPH_CHAT_STATE_NAMESPACE = `${MODULE_NAME}_graph_state`;
export const GRAPH_CHAT_STATE_VERSION = 1;
export const GRAPH_CHAT_STATE_MAX_OPERATIONS = 4000;
export const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence";
export const GRAPH_LOAD_STATES = Object.freeze({
NO_CHAT: "no-chat",
@@ -374,6 +377,184 @@ export function writeChatMetadataPatch(context, patch = {}) {
return true;
}
export function canUseGraphChatState(context = null) {
return (
!!context &&
typeof context.getChatState === "function" &&
typeof context.updateChatState === "function"
);
}
export function normalizeGraphChatStateSnapshot(snapshot = null) {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
return null;
}
const version = Number(snapshot.version);
const revision = Number(snapshot.revision);
const serializedGraph = String(snapshot.serializedGraph || "");
const storageTier = String(snapshot.storageTier || "chat-state");
const chatId = normalizeIdentityValue(snapshot.chatId);
const integrity = normalizeIdentityValue(snapshot.integrity);
const commitMarker = normalizeGraphCommitMarker(snapshot.commitMarker);
if (!serializedGraph) {
return null;
}
return {
version: Number.isFinite(version) && version > 0 ? version : GRAPH_CHAT_STATE_VERSION,
revision: Number.isFinite(revision) && revision > 0 ? revision : 0,
serializedGraph,
persistedAt: String(snapshot.persistedAt || ""),
updatedAt: String(snapshot.updatedAt || snapshot.persistedAt || ""),
reason: String(snapshot.reason || ""),
storageTier,
chatId,
integrity,
commitMarker,
};
}
export function buildGraphChatStateSnapshot(
graph,
{
revision = 0,
storageTier = "chat-state",
accepted = true,
reason = "",
persistedAt = "",
updatedAt = "",
chatId = "",
integrity = "",
lastProcessedAssistantFloor = null,
extractionCount = null,
} = {},
) {
if (!graph) {
return null;
}
const commitMarker = buildGraphCommitMarker(graph, {
revision,
storageTier,
accepted,
reason,
persistedAt,
chatId,
integrity,
lastProcessedAssistantFloor,
extractionCount,
});
return normalizeGraphChatStateSnapshot({
version: GRAPH_CHAT_STATE_VERSION,
revision,
serializedGraph: serializeGraph(graph),
persistedAt: String(persistedAt || new Date().toISOString()),
updatedAt: String(updatedAt || persistedAt || new Date().toISOString()),
reason: String(reason || ""),
storageTier: String(storageTier || "chat-state"),
chatId,
integrity,
commitMarker,
});
}
export async function readGraphChatStateSnapshot(
context = null,
{ namespace = GRAPH_CHAT_STATE_NAMESPACE } = {},
) {
if (!canUseGraphChatState(context)) {
return null;
}
try {
const payload = await context.getChatState(namespace);
return normalizeGraphChatStateSnapshot(payload);
} catch (error) {
console.warn("[ST-BME] 读取聊天侧车图谱失败:", error);
return null;
}
}
export async function writeGraphChatStateSnapshot(
context = null,
graph = null,
{
namespace = GRAPH_CHAT_STATE_NAMESPACE,
revision = 0,
storageTier = "chat-state",
accepted = true,
reason = "",
chatId = "",
integrity = "",
lastProcessedAssistantFloor = null,
extractionCount = null,
maxOperations = GRAPH_CHAT_STATE_MAX_OPERATIONS,
} = {},
) {
if (!canUseGraphChatState(context) || !graph) {
return {
ok: false,
updated: false,
snapshot: null,
reason: "chat-state-unavailable",
};
}
const snapshot = buildGraphChatStateSnapshot(graph, {
revision,
storageTier,
accepted,
reason,
chatId,
integrity,
lastProcessedAssistantFloor,
extractionCount,
});
if (!snapshot) {
return {
ok: false,
updated: false,
snapshot: null,
reason: "chat-state-build-failed",
};
}
try {
const result = await context.updateChatState(
namespace,
() => snapshot,
{
maxOperations,
asyncDiff: false,
maxRetries: 1,
},
);
return {
ok: result?.ok === true,
updated: result?.updated !== false,
snapshot,
reason:
result?.ok === true
? result?.updated === false
? "chat-state-noop"
: "chat-state-saved"
: "chat-state-save-failed",
};
} catch (error) {
console.warn("[ST-BME] 写入聊天侧车图谱失败:", error);
return {
ok: false,
updated: false,
snapshot,
reason: "chat-state-save-failed",
error,
};
}
}
export function normalizeGraphCommitMarker(marker = null) {
if (!marker || typeof marker !== "object" || Array.isArray(marker)) {
return null;

631
index.js
View File

@@ -97,9 +97,11 @@ import {
} from "./maintenance/hierarchical-summary.js";
import {
buildGraphCommitMarker,
canUseGraphChatState,
detectIndexedDbSnapshotCommitMarkerMismatch,
findGraphShadowSnapshotByIntegrity,
getAcceptedCommitMarkerRevision,
GRAPH_CHAT_STATE_NAMESPACE,
GRAPH_LOAD_PENDING_CHAT_ID,
GRAPH_LOAD_STATES,
GRAPH_COMMIT_MARKER_KEY,
@@ -115,9 +117,11 @@ import {
removeGraphShadowSnapshot,
rememberGraphIdentityAlias,
readGraphCommitMarker,
readGraphChatStateSnapshot,
resolveGraphIdentityAliasByHostChatId,
stampGraphPersistenceMeta,
writeChatMetadataPatch,
writeGraphChatStateSnapshot,
writeGraphShadowSnapshot,
} from "./graph/graph-persistence.js";
import {
@@ -688,6 +692,8 @@ const bmeIndexedDbLoadInFlightByChatId = new Map();
const bmeIndexedDbWriteInFlightByChatId = new Map();
const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map();
const bmeIndexedDbLatestQueuedRevisionByChatId = new Map();
const bmeChatStateSnapshotCacheByChatId = new Map();
const bmeChatStateLoadInFlightByChatId = new Map();
const PENDING_GRAPH_PERSIST_RETRY_DELAYS_MS = [500, 1500, 5000];
const PENDING_GRAPH_PERSIST_MAX_RETRY_ATTEMPTS = 5;
const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([
@@ -4329,6 +4335,382 @@ function readCachedIndexedDbSnapshot(chatId) {
return cacheEntry.snapshot;
}
function cacheChatStateSnapshot(chatId, snapshot = null) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return;
bmeChatStateSnapshotCacheByChatId.set(normalizedChatId, {
chatId: normalizedChatId,
revision: Number(snapshot?.revision || 0),
snapshot,
updatedAt: Date.now(),
});
}
function readCachedChatStateSnapshot(chatId) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) return null;
const cacheEntry = bmeChatStateSnapshotCacheByChatId.get(normalizedChatId);
if (!cacheEntry?.snapshot) return null;
return cacheEntry.snapshot;
}
function canUseHostGraphChatStatePersistence(context = getContext()) {
return canUseGraphChatState(context);
}
function selectPreferredCommitMarker(...candidates) {
let bestMarker = null;
let bestRevision = 0;
for (const candidate of candidates) {
const revision = getAcceptedCommitMarkerRevision(candidate);
if (revision > bestRevision) {
bestRevision = revision;
bestMarker = candidate;
}
}
return bestMarker || null;
}
async function persistGraphToHostChatState(
context = getContext(),
{
graph = currentGraph,
revision = graphPersistenceState.revision,
reason = "graph-chat-state",
storageTier = "chat-state",
accepted = true,
lastProcessedAssistantFloor = null,
extractionCount: nextExtractionCount = null,
mode = "primary",
} = {},
) {
if (!context || !graph || !canUseHostGraphChatStatePersistence(context)) {
return {
saved: false,
accepted: false,
reason: "chat-state-unavailable",
revision,
storageTier,
};
}
const chatId = getCurrentChatId(context);
if (!chatId) {
return {
saved: false,
accepted: false,
reason: "missing-chat-id",
revision,
storageTier,
};
}
const resolvedIdentity = resolveCurrentChatIdentity(context);
const nextIntegrity =
getChatMetadataIntegrity(context) ||
normalizeChatIdCandidate(resolvedIdentity?.integrity) ||
graphPersistenceState.metadataIntegrity;
const persistedGraph = cloneGraphForPersistence(graph, chatId);
stampGraphPersistenceMeta(persistedGraph, {
revision,
reason: `chat-state:${String(reason || "graph-chat-state")}`,
chatId,
integrity: nextIntegrity,
});
const writeResult = await writeGraphChatStateSnapshot(
context,
persistedGraph,
{
namespace: GRAPH_CHAT_STATE_NAMESPACE,
revision,
storageTier,
accepted,
reason,
chatId,
integrity: nextIntegrity,
lastProcessedAssistantFloor,
extractionCount: nextExtractionCount,
},
);
if (!writeResult?.ok || !writeResult?.snapshot) {
updateGraphPersistenceState({
dualWriteLastResult: {
action: "save",
target: "chat-state",
success: false,
chatId,
revision: Number(revision || 0),
reason: String(reason || "graph-chat-state"),
mode: String(mode || "primary"),
error: writeResult?.error?.message || writeResult?.reason || "chat-state-save-failed",
at: Date.now(),
},
});
return {
saved: false,
accepted: false,
reason: writeResult?.reason || "chat-state-save-failed",
revision,
storageTier,
error: writeResult?.error || null,
};
}
cacheChatStateSnapshot(chatId, writeResult.snapshot);
rememberResolvedGraphIdentityAlias(context, chatId);
updateGraphPersistenceState({
metadataIntegrity: String(nextIntegrity || graphPersistenceState.metadataIntegrity || ""),
lastPersistReason: String(reason || ""),
lastPersistMode:
mode === "mirror" ? "chat-state-mirror" : "chat-state",
lastAcceptedRevision:
accepted === true
? Math.max(
Number(graphPersistenceState.lastAcceptedRevision || 0),
Number(writeResult.snapshot.revision || revision || 0),
)
: Number(graphPersistenceState.lastAcceptedRevision || 0),
dualWriteLastResult: {
action: "save",
target: "chat-state",
success: true,
chatId,
revision: Number(writeResult.snapshot.revision || revision || 0),
reason: String(reason || "graph-chat-state"),
mode: String(mode || "primary"),
at: Date.now(),
},
});
if (mode !== "mirror") {
clearPendingGraphPersistRetry();
}
return {
saved: true,
accepted,
chatId,
revision: Number(writeResult.snapshot.revision || revision || 0),
reason: String(reason || "graph-chat-state"),
saveMode: mode === "mirror" ? "chat-state-mirror" : "chat-state",
storageTier,
snapshot: writeResult.snapshot,
};
}
async function loadGraphFromChatState(
chatId,
{
source = "chat-state-probe",
attemptIndex = 0,
allowOverride = false,
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
const context = getContext();
if (!normalizedChatId) {
return {
success: false,
loaded: false,
reason: "chat-state-missing-chat-id",
chatId: "",
attemptIndex,
};
}
if (!canUseHostGraphChatStatePersistence(context)) {
return {
success: false,
loaded: false,
reason: "chat-state-unavailable",
chatId: normalizedChatId,
attemptIndex,
};
}
const payload =
(await readGraphChatStateSnapshot(context, {
namespace: GRAPH_CHAT_STATE_NAMESPACE,
})) || readCachedChatStateSnapshot(normalizedChatId);
if (!payload?.serializedGraph) {
return {
success: false,
loaded: false,
reason: "chat-state-empty",
chatId: normalizedChatId,
attemptIndex,
};
}
cacheChatStateSnapshot(normalizedChatId, payload);
let chatStateGraph = null;
try {
chatStateGraph = cloneGraphForPersistence(
normalizeGraphRuntimeState(
deserializeGraph(payload.serializedGraph),
normalizedChatId,
),
normalizedChatId,
);
} catch (error) {
console.warn("[ST-BME] 聊天侧车图谱反序列化失败:", error);
return {
success: false,
loaded: false,
reason: "chat-state-deserialize-failed",
chatId: normalizedChatId,
attemptIndex,
error,
};
}
if (isGraphEffectivelyEmpty(chatStateGraph)) {
return {
success: false,
loaded: false,
reason: "chat-state-empty",
chatId: normalizedChatId,
attemptIndex,
};
}
const revision = Math.max(
1,
Number(payload.revision || getGraphPersistedRevision(chatStateGraph) || 1),
);
const integrity =
normalizeChatIdCandidate(payload.integrity) ||
getChatMetadataIntegrity(context) ||
graphPersistenceState.metadataIntegrity;
stampGraphPersistenceMeta(chatStateGraph, {
revision,
reason: `chat-state:${String(source || "chat-state-probe")}`,
chatId: normalizedChatId,
integrity,
});
const snapshot = buildSnapshotFromGraph(chatStateGraph, {
chatId: normalizedChatId,
revision,
meta: {
storagePrimary: "chat-state",
lastMutationReason: String(payload.reason || source || "chat-state"),
integrity,
},
});
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(
resolveCurrentChatIdentity(context),
);
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
chatStateGraph,
shadowSnapshot,
);
if (shadowSnapshot && shadowDecision?.prefer) {
return applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, {
source: `${source}:shadow-over-chat-state`,
attemptIndex,
});
}
const effectiveCommitMarker = selectPreferredCommitMarker(
payload.commitMarker,
getChatCommitMarker(context),
);
const commitMarkerMismatch = detectIndexedDbSnapshotCommitMarkerMismatch(
snapshot,
effectiveCommitMarker,
);
if (commitMarkerMismatch.mismatched) {
if (
shadowSnapshot &&
Number(shadowSnapshot.revision || 0) >=
Number(commitMarkerMismatch.markerRevision || 0)
) {
return applyShadowSnapshotToRuntime(normalizedChatId, shadowSnapshot, {
source: `${source}:shadow-beats-chat-state-marker`,
attemptIndex,
});
}
return applyPersistMismatchBlockedState(
normalizedChatId,
{
...commitMarkerMismatch,
marker: commitMarkerMismatch.marker || effectiveCommitMarker,
},
{
source: `${source}:chat-state-marker`,
attemptIndex,
},
);
}
const shouldAllowOverride =
allowOverride ||
BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has(graphPersistenceState.loadState) ||
graphPersistenceState.storagePrimary === "chat-state" ||
revision >= normalizeIndexedDbRevision(graphPersistenceState.revision);
if (!shouldAllowOverride) {
return {
success: false,
loaded: false,
reason: "chat-state-stale",
chatId: normalizedChatId,
attemptIndex,
revision,
};
}
if (getCurrentChatId() !== normalizedChatId) {
return {
success: false,
loaded: false,
reason: "chat-state-chat-switched",
chatId: normalizedChatId,
attemptIndex,
revision,
};
}
return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, {
source,
attemptIndex,
storagePrimary: "chat-state",
storageMode: "chat-state",
statusLabel: "聊天侧车",
reasonPrefix: "chat-state",
});
}
function scheduleGraphChatStateProbe(chatId, options = {}) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (
!normalizedChatId ||
!canUseHostGraphChatStatePersistence(getContext()) ||
bmeChatStateLoadInFlightByChatId.has(normalizedChatId)
) {
return;
}
scheduleBmeIndexedDbTask(() => {
const loadPromise = loadGraphFromChatState(normalizedChatId, options)
.catch((error) => {
console.warn("[ST-BME] 聊天侧车后台加载失败:", error);
})
.finally(() => {
if (
bmeChatStateLoadInFlightByChatId.get(normalizedChatId) === loadPromise
) {
bmeChatStateLoadInFlightByChatId.delete(normalizedChatId);
}
});
bmeChatStateLoadInFlightByChatId.set(normalizedChatId, loadPromise);
return loadPromise;
});
}
function readLegacyGraphFromChatMetadata(chatId, context = getContext()) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) return null;
@@ -4813,7 +5195,14 @@ function applyIndexedDbEmptyToRuntime(
function applyIndexedDbSnapshotToRuntime(
chatId,
snapshot,
{ source = "indexeddb", attemptIndex = 0 } = {},
{
source = "indexeddb",
attemptIndex = 0,
storagePrimary = "indexeddb",
storageMode = storagePrimary,
statusLabel = "IndexedDB",
reasonPrefix = "indexeddb",
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
syncCommitMarkerToPersistenceState(getContext());
@@ -4821,7 +5210,7 @@ function applyIndexedDbSnapshotToRuntime(
return {
success: false,
loaded: false,
reason: "indexeddb-empty",
reason: `${reasonPrefix}-empty`,
chatId: normalizedChatId,
attemptIndex,
};
@@ -4836,30 +5225,34 @@ function applyIndexedDbSnapshotToRuntime(
snapshot,
);
if (staleDecision.stale) {
updateGraphPersistenceState({
storagePrimary:
graphPersistenceState.storagePrimary || "indexeddb",
storageMode: graphPersistenceState.storageMode || "indexeddb",
indexedDbRevision: Math.max(
graphPersistenceState.indexedDbRevision || 0,
revision,
),
const persistencePatch = {
storagePrimary: graphPersistenceState.storagePrimary || storagePrimary,
storageMode: graphPersistenceState.storageMode || storageMode,
metadataIntegrity:
getChatMetadataIntegrity(getContext()) ||
graphPersistenceState.metadataIntegrity,
indexedDbLastError: "",
dualWriteLastResult: {
action: "load",
source: String(source || "indexeddb"),
source: String(source || reasonPrefix),
success: false,
rejected: true,
reason: "indexeddb-stale-runtime",
reason: `${reasonPrefix}-stale-runtime`,
revision,
staleDetail: cloneRuntimeDebugValue(staleDecision, null),
at: Date.now(),
},
};
if (storagePrimary === "indexeddb") {
persistencePatch.indexedDbRevision = Math.max(
graphPersistenceState.indexedDbRevision || 0,
revision,
);
}
updateGraphPersistenceState({
...persistencePatch,
});
debugDebug("[ST-BME] 已拒绝用较旧 IndexedDB 快照覆盖当前运行时图谱", {
debugDebug(`[ST-BME] 已拒绝用较旧 ${statusLabel} 快照覆盖当前运行时图谱`, {
chatId: normalizedChatId,
source,
revision,
@@ -4868,7 +5261,7 @@ function applyIndexedDbSnapshotToRuntime(
return {
success: false,
loaded: false,
reason: "indexeddb-stale-runtime",
reason: `${reasonPrefix}-stale-runtime`,
chatId: normalizedChatId,
attemptIndex,
revision,
@@ -4883,25 +5276,30 @@ function applyIndexedDbSnapshotToRuntime(
} catch (error) {
const failureReason =
error?.code === "BME_SNAPSHOT_INTEGRITY_ERROR"
? "indexeddb-snapshot-integrity-rejected"
: "indexeddb-snapshot-load-failed";
updateGraphPersistenceState({
storagePrimary: "indexeddb",
storageMode: "indexeddb",
? `${reasonPrefix}-snapshot-integrity-rejected`
: `${reasonPrefix}-snapshot-load-failed`;
const persistencePatch = {
storagePrimary,
storageMode,
dbReady: true,
indexedDbRevision: revision,
indexedDbLastError: error?.message || String(error),
dualWriteLastResult: {
action: "load",
source: String(source || "indexeddb"),
source: String(source || reasonPrefix),
success: false,
rejected: true,
reason: failureReason,
revision,
at: Date.now(),
},
};
if (storagePrimary === "indexeddb") {
persistencePatch.indexedDbRevision = revision;
}
updateGraphPersistenceState({
...persistencePatch,
});
console.warn("[ST-BME] IndexedDB 图谱快照已拒绝加载", {
console.warn(`[ST-BME] ${statusLabel} 图谱快照已拒绝加载`, {
chatId: normalizedChatId,
source,
revision,
@@ -4925,7 +5323,7 @@ function applyIndexedDbSnapshotToRuntime(
);
stampGraphPersistenceMeta(currentGraph, {
revision,
reason: `indexeddb:${String(source || "indexeddb")}`,
reason: `${reasonPrefix}:${String(source || reasonPrefix)}`,
chatId: normalizedChatId,
integrity:
normalizeChatIdCandidate(snapshot?.meta?.integrity) ||
@@ -4940,29 +5338,29 @@ function applyIndexedDbSnapshotToRuntime(
const restoredRecallUi = restoreRecallUiStateFromPersistence(
getContext()?.chat,
);
runtimeStatus = createUiStatus("待命", "已从 IndexedDB 加载聊天图谱", "idle");
runtimeStatus = createUiStatus("待命", `已从${statusLabel}加载聊天图谱`, "idle");
lastExtractionStatus = createUiStatus(
"待命",
"已从 IndexedDB 加载聊天图谱,等待下一次提取",
`已从${statusLabel}加载聊天图谱,等待下一次提取`,
"idle",
);
lastVectorStatus = createUiStatus(
"待命",
currentGraph.vectorIndexState?.lastWarning ||
"已从 IndexedDB 加载聊天图谱,等待下一次向量任务",
`已从${statusLabel}加载聊天图谱,等待下一次向量任务`,
"idle",
);
lastRecallStatus = createUiStatus(
"待命",
restoredRecallUi.restored
? "已从持久化召回记录恢复显示,等待下一次召回"
: "已从 IndexedDB 加载聊天图谱,等待下一次召回",
: `已从${statusLabel}加载聊天图谱,等待下一次召回`,
"idle",
);
applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, {
chatId: normalizedChatId,
reason: `indexeddb:${source}`,
reason: `${reasonPrefix}:${source}`,
attemptIndex,
revision,
lastPersistedRevision: Math.max(
@@ -4977,16 +5375,15 @@ function applyIndexedDbSnapshotToRuntime(
shadowSnapshotReason: "",
writesBlocked: false,
});
updateGraphPersistenceState({
storagePrimary: "indexeddb",
storageMode: "indexeddb",
const persistencePatch = {
storagePrimary,
storageMode,
dbReady: true,
persistMismatchReason: "",
indexedDbRevision: revision,
metadataIntegrity:
getChatMetadataIntegrity(getContext()) ||
graphPersistenceState.metadataIntegrity,
indexedDbLastError: "",
indexedDbLastError: storagePrimary === "indexeddb" ? "" : graphPersistenceState.indexedDbLastError,
lastAcceptedRevision: Math.max(
Number(graphPersistenceState.lastAcceptedRevision || 0),
revision,
@@ -4994,19 +5391,23 @@ function applyIndexedDbSnapshotToRuntime(
lastSyncError: "",
dualWriteLastResult: {
action: "load",
source: String(source || "indexeddb"),
source: String(source || reasonPrefix),
success: true,
reason: "indexeddb-loaded",
reason: `${reasonPrefix}-loaded`,
revision,
at: Date.now(),
},
});
};
if (storagePrimary === "indexeddb") {
persistencePatch.indexedDbRevision = revision;
}
updateGraphPersistenceState(persistencePatch);
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
removeGraphShadowSnapshot(normalizedChatId);
refreshPanelLiveState();
schedulePersistedRecallMessageUiRefresh(30);
debugDebug("[ST-BME] 已从 IndexedDB 加载图谱", {
debugDebug(`[ST-BME] 已从${statusLabel}加载图谱`, {
chatId: normalizedChatId,
source,
revision,
@@ -5017,7 +5418,7 @@ function applyIndexedDbSnapshotToRuntime(
success: true,
loaded: true,
loadState: GRAPH_LOAD_STATES.LOADED,
reason: `indexeddb:${source}`,
reason: `${reasonPrefix}:${source}`,
chatId: normalizedChatId,
attemptIndex,
shadowSnapshotUsed: false,
@@ -6490,6 +6891,18 @@ async function retryPendingGraphPersist({
reason,
});
if (indexedDbResult?.saved) {
const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context)
? await persistGraphToHostChatState(context, {
graph: pendingPersistGraph,
revision: targetRevision,
reason: `${reason}:chat-state-mirror`,
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor,
extractionCount,
mode: "mirror",
})
: null;
clearPendingGraphPersistRetry();
persistGraphCommitMarker(context, {
reason,
@@ -6531,6 +6944,66 @@ async function retryPendingGraphPersist({
return persistResult;
}
if (canUseHostGraphChatStatePersistence(context)) {
const chatStateResult = await persistGraphToHostChatState(context, {
graph: pendingPersistGraph,
revision: targetRevision,
reason: `${reason}:chat-state-fallback`,
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor,
extractionCount,
mode: "primary",
});
if (chatStateResult?.saved) {
clearPendingGraphPersistRetry();
persistGraphCommitMarker(context, {
reason: `${reason}:chat-state-fallback`,
revision: targetRevision,
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor,
extractionCount,
immediate: true,
});
updateGraphPersistenceState({
pendingPersist: false,
persistMismatchReason: "",
lastAcceptedRevision: Math.max(
Number(graphPersistenceState.lastAcceptedRevision || 0),
targetRevision,
),
lastPersistReason: `${reason}:chat-state-fallback`,
lastPersistMode: "chat-state",
queuedPersistRevision: 0,
queuedPersistChatId: "",
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
storagePrimary: "chat-state",
storageMode: "chat-state",
});
const persistResult = buildGraphPersistResult({
saved: true,
accepted: true,
reason: `${reason}:chat-state-fallback`,
revision: targetRevision,
saveMode: "chat-state",
storageTier: "chat-state",
});
applyAcceptedPendingPersistState(persistResult, {
lastProcessedAssistantFloor,
persistedGraph: pendingPersistGraph,
});
queueGraphPersistToIndexedDb(activeChatId, pendingPersistGraph, {
revision: targetRevision,
reason: `${reason}:chat-state-fallback:promote-indexeddb`,
});
void maybeResumePendingAutoExtraction("pending-persist-resolved:chat-state");
return persistResult;
}
}
if (canPersistGraphToMetadataFallback(context, pendingPersistGraph)) {
const metadataReason = `${reason}:metadata-full-fallback`;
const metadataResult = persistGraphToChatMetadata(context, {
@@ -6635,6 +7108,18 @@ async function persistExtractionBatchResult({
reason,
});
if (indexedDbResult?.saved) {
const chatStateMirrorResult = canUseHostGraphChatStatePersistence(context)
? await persistGraphToHostChatState(context, {
graph: persistGraph,
revision,
reason: `${reason}:chat-state-mirror`,
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor,
extractionCount,
mode: "mirror",
})
: null;
persistGraphCommitMarker(context, {
reason,
revision,
@@ -6670,6 +7155,60 @@ async function persistExtractionBatchResult({
});
}
if (canUseHostGraphChatStatePersistence(context)) {
const chatStateResult = await persistGraphToHostChatState(context, {
graph: persistGraph,
revision,
reason: `${reason}:chat-state-fallback`,
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor,
extractionCount,
mode: "primary",
});
if (chatStateResult?.saved) {
persistGraphCommitMarker(context, {
reason: `${reason}:chat-state-fallback`,
revision,
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor,
extractionCount,
immediate: true,
});
updateGraphPersistenceState({
pendingPersist: false,
persistMismatchReason: "",
lastAcceptedRevision: Math.max(
Number(graphPersistenceState.lastAcceptedRevision || 0),
revision,
),
lastPersistReason: `${reason}:chat-state-fallback`,
lastPersistMode: "chat-state",
queuedPersistRevision: 0,
queuedPersistChatId: "",
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
storagePrimary: "chat-state",
storageMode: "chat-state",
});
clearPendingGraphPersistRetry();
queueGraphPersistToIndexedDb(chatId, persistGraph, {
revision,
reason: `${reason}:chat-state-fallback:promote-indexeddb`,
});
return buildGraphPersistResult({
saved: true,
accepted: true,
reason: `${reason}:chat-state-fallback`,
revision,
saveMode: "chat-state",
storageTier: "chat-state",
});
}
}
const shadowReason = `${reason}:shadow-fallback`;
const shadowCaptured = maybeCaptureGraphShadowSnapshot(shadowReason, {
graph: persistGraph,
@@ -6897,6 +7436,14 @@ function syncGraphLoadFromLiveContext(options = {}) {
};
}
if (canUseHostGraphChatStatePersistence(context)) {
scheduleGraphChatStateProbe(chatId, {
source: `${source}:chat-state-probe`,
attemptIndex: 0,
allowOverride: true,
});
}
const cachedSnapshot = readCachedIndexedDbSnapshot(chatId);
if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) {
const result = applyIndexedDbSnapshotToRuntime(chatId, cachedSnapshot, {
@@ -7913,6 +8460,14 @@ function loadGraphFromChat(options = {}) {
};
}
if (canUseHostGraphChatStatePersistence(context)) {
scheduleGraphChatStateProbe(chatId, {
source: `${source}:chat-state-probe`,
attemptIndex,
allowOverride: true,
});
}
const cachedSnapshot = readCachedIndexedDbSnapshot(chatId);
if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) {
const cachedResult = applyIndexedDbSnapshotToRuntime(

View File

@@ -12,10 +12,13 @@ import {
import { onMessageReceivedController } from "../host/event-binding.js";
import {
buildGraphCommitMarker,
buildGraphChatStateSnapshot,
canUseGraphChatState,
detectIndexedDbSnapshotCommitMarkerMismatch,
cloneGraphForPersistence,
cloneRuntimeDebugValue,
findGraphShadowSnapshotByIntegrity,
GRAPH_CHAT_STATE_NAMESPACE,
getAcceptedCommitMarkerRevision,
getGraphPersistedRevision,
getGraphIdentityAliasCandidates,
@@ -33,6 +36,7 @@ import {
MODULE_NAME,
normalizeGraphCommitMarker,
readGraphCommitMarker,
readGraphChatStateSnapshot,
readGraphShadowSnapshot,
rememberGraphIdentityAlias,
removeGraphShadowSnapshot,
@@ -40,6 +44,7 @@ import {
shouldPreferShadowSnapshotOverOfficial,
stampGraphPersistenceMeta,
writeChatMetadataPatch,
writeGraphChatStateSnapshot,
writeGraphShadowSnapshot,
} from "../graph/graph-persistence.js";
import {
@@ -391,9 +396,12 @@ async function createGraphPersistenceHarness({
readPersistedRecallFromUserMessage,
cloneGraphForPersistence,
buildGraphCommitMarker,
buildGraphChatStateSnapshot,
canUseGraphChatState,
cloneRuntimeDebugValue,
detectIndexedDbSnapshotCommitMarkerMismatch,
onMessageReceivedController,
GRAPH_CHAT_STATE_NAMESPACE,
getAcceptedCommitMarkerRevision,
getGraphPersistenceMeta,
getGraphPersistedRevision,
@@ -412,6 +420,7 @@ async function createGraphPersistenceHarness({
findGraphShadowSnapshotByIntegrity,
normalizeGraphCommitMarker,
readGraphCommitMarker,
readGraphChatStateSnapshot,
readGraphShadowSnapshot,
rememberGraphIdentityAlias,
removeGraphShadowSnapshot,
@@ -419,6 +428,7 @@ async function createGraphPersistenceHarness({
shouldPreferShadowSnapshotOverOfficial,
stampGraphPersistenceMeta,
writeChatMetadataPatch,
writeGraphChatStateSnapshot,
writeGraphShadowSnapshot,
// Shadow snapshot functions need VM-local sessionStorage overrides
// because imported versions use the outer globalThis (no sessionStorage)
@@ -711,6 +721,7 @@ async function createGraphPersistenceHarness({
characterId,
groupId,
chat,
__chatStateStore: new Map(),
updateChatMetadata(patch) {
const base =
this.chatMetadata &&
@@ -729,6 +740,36 @@ async function createGraphPersistenceHarness({
async saveMetadata() {
runtimeContext.__contextImmediateSaveCalls += 1;
},
async getChatState(namespace) {
const key = String(namespace || "").trim().toLowerCase();
const value = this.__chatStateStore.get(key);
return value == null ? null : structuredClone(value);
},
async updateChatState(namespace, updater) {
const key = String(namespace || "").trim().toLowerCase();
if (!key || typeof updater !== "function") {
return { ok: false, state: null, updated: false };
}
const current = this.__chatStateStore.has(key)
? structuredClone(this.__chatStateStore.get(key))
: {};
const next = await updater(structuredClone(current), {
attempt: 0,
target: null,
namespace: key,
});
if (next == null) {
return { ok: true, state: current, updated: false };
}
const currentJson = JSON.stringify(current);
const nextJson = JSON.stringify(next);
this.__chatStateStore.set(key, structuredClone(next));
return {
ok: true,
state: structuredClone(next),
updated: currentJson !== nextJson,
};
},
},
__contextSaveCalls: 0,
__contextImmediateSaveCalls: 0,
@@ -2675,4 +2716,111 @@ result = {
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-state-save",
globalChatId: "chat-state-save",
chatMetadata: {
integrity: "meta-chat-state-save",
},
indexedDbSnapshot: {
meta: {
chatId: "chat-state-save",
revision: 0,
},
nodes: [],
edges: [],
tombstones: [],
state: {
lastProcessedFloor: -1,
extractionCount: 0,
},
},
});
const graph = stampPersistedGraph(
createMeaningfulGraph("chat-state-save", "sidecar"),
{
revision: 7,
integrity: "meta-chat-state-save",
chatId: "chat-state-save",
reason: "chat-state-seed",
},
);
const result = await harness.runtimeContext.persistGraphToHostChatState(
harness.runtimeContext.__chatContext,
{
graph,
revision: 7,
reason: "chat-state-direct-save",
storageTier: "chat-state",
accepted: true,
lastProcessedAssistantFloor: 6,
extractionCount: 3,
mode: "primary",
},
);
assert.equal(result.saved, true);
const stored = await harness.runtimeContext.__chatContext.getChatState(
GRAPH_CHAT_STATE_NAMESPACE,
);
assert.equal(stored?.revision, 7);
assert.equal(stored?.commitMarker?.storageTier, "chat-state");
assert.equal(
harness.api.getGraphPersistenceState().dualWriteLastResult?.target,
"chat-state",
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-state-read",
globalChatId: "chat-state-read",
chatMetadata: {
integrity: "meta-chat-state-read",
},
});
const sidecarGraph = stampPersistedGraph(
createMeaningfulGraph("chat-state-read", "sidecar-read"),
{
revision: 9,
integrity: "meta-chat-state-read",
chatId: "chat-state-read",
reason: "chat-state-read-seed",
},
);
harness.runtimeContext.__chatContext.__chatStateStore.set(
GRAPH_CHAT_STATE_NAMESPACE,
buildGraphChatStateSnapshot(sidecarGraph, {
revision: 9,
storageTier: "chat-state",
accepted: true,
reason: "chat-state-read-seed",
chatId: "chat-state-read",
integrity: "meta-chat-state-read",
lastProcessedAssistantFloor: 6,
extractionCount: 3,
}),
);
const result = await harness.runtimeContext.readGraphChatStateSnapshot(
harness.runtimeContext.__chatContext,
{
namespace: GRAPH_CHAT_STATE_NAMESPACE,
},
);
assert.equal(
harness.runtimeContext.canUseGraphChatState(
harness.runtimeContext.__chatContext,
),
true,
);
assert.equal(result?.revision, 9);
assert.equal(result?.commitMarker?.storageTier, "chat-state");
}
console.log("graph-persistence tests passed");