Harden graph persistence and panel refresh flow

This commit is contained in:
Youzini-afk
2026-03-28 13:33:16 +08:00
parent 3641a342f4
commit b5d8056ae4
5 changed files with 1700 additions and 111 deletions

1021
index.js

File diff suppressed because it is too large Load Diff

View File

@@ -188,6 +188,11 @@
<div class="bme-mobile-graph-preview" id="bme-mobile-graph-area">
<canvas id="bme-mobile-graph-canvas"></canvas>
<div class="bme-graph-overlay" id="bme-mobile-graph-overlay" hidden>
<div class="bme-graph-overlay__text" id="bme-mobile-graph-overlay-text">
正在加载当前聊天图谱
</div>
</div>
<span class="bme-mobile-graph-label">REALTIME</span>
</div>
<div
@@ -236,6 +241,7 @@
<div class="bme-tab-pane" id="bme-pane-actions">
<div class="bme-action-groups">
<div class="bme-action-guard-banner" id="bme-action-guard-banner" hidden></div>
<!-- 记忆操作 -->
<div class="bme-action-group">
<div class="bme-action-group-header">
@@ -395,6 +401,11 @@
</div>
<canvas id="bme-graph-canvas"></canvas>
<div class="bme-graph-overlay" id="bme-graph-overlay" hidden>
<div class="bme-graph-overlay__text" id="bme-graph-overlay-text">
正在加载当前聊天图谱
</div>
</div>
<div class="bme-graph-legend" id="bme-graph-legend"></div>

250
panel.js
View File

@@ -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 `
<div class="bme-task-tab-body">
@@ -2840,6 +2912,9 @@ function _renderTaskDebugTab(state) {
<div class="bme-config-card">
${_renderTaskDebugHostCard(hostCapabilities)}
</div>
<div class="bme-config-card">
${_renderTaskDebugGraphPersistenceCard(graphPersistence)}
</div>
<div class="bme-config-card">
${_renderTaskDebugPromptCard(state.taskType, promptBuild)}
</div>
@@ -2854,6 +2929,62 @@ function _renderTaskDebugTab(state) {
`;
}
function _renderTaskDebugGraphPersistenceCard(graphPersistence) {
if (!graphPersistence) {
return `
<div class="bme-config-card-title">图谱持久化状态</div>
<div class="bme-config-help">当前还没有图谱加载/持久化快照。</div>
`;
}
return `
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">图谱持久化状态</div>
<div class="bme-config-card-subtitle">
最近一次图谱加载与写回协调结果。
</div>
</div>
<span class="bme-task-pill">${_escHtml(graphPersistence.loadState || "unknown")}</span>
</div>
<div class="bme-debug-kv-list">
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">聊天</span>
<span class="bme-debug-kv-value">${_escHtml(graphPersistence.chatId || "—")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">原因</span>
<span class="bme-debug-kv-value">${_escHtml(graphPersistence.reason || "—")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">尝试次数</span>
<span class="bme-debug-kv-value">${_escHtml(String(graphPersistence.attemptIndex ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">当前 revision</span>
<span class="bme-debug-kv-value">${_escHtml(String(graphPersistence.graphRevision ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">最近已持久化 revision</span>
<span class="bme-debug-kv-value">${_escHtml(String(graphPersistence.lastPersistedRevision ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">排队中的 revision</span>
<span class="bme-debug-kv-value">${_escHtml(String(graphPersistence.queuedPersistRevision ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">影子快照</span>
<span class="bme-debug-kv-value">${_escHtml(graphPersistence.shadowSnapshotUsed ? "已接管" : "未使用")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">写保护</span>
<span class="bme-debug-kv-value">${_escHtml(graphPersistence.writesBlocked ? "已启用" : "未启用")}</span>
</div>
</div>
${_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 = {}) {

View File

@@ -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;

471
tests/graph-persistence.mjs Normal file
View File

@@ -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");