Stabilize recall UI persistence recovery

This commit is contained in:
Youzini-afk
2026-04-03 23:04:06 +08:00
parent 9a07c20a11
commit f2c35b725a
3 changed files with 192 additions and 8 deletions

101
index.js
View File

@@ -1101,7 +1101,7 @@ function updateLastRecalledItems(nodeIds = []) {
return;
}
lastRecalledItems = nodeIds
lastRecalledItems = normalizeRecallNodeIdList(nodeIds)
.map((id) => getNode(currentGraph, id))
.filter(Boolean)
.slice(0, 8)
@@ -1113,6 +1113,61 @@ function updateLastRecalledItems(nodeIds = []) {
);
}
function normalizeRecallNodeIdList(nodeIds = []) {
if (!Array.isArray(nodeIds)) return [];
return nodeIds
.map((entry) => {
if (typeof entry === "string" || typeof entry === "number") {
return String(entry).trim();
}
if (entry && typeof entry === "object") {
return String(entry.id || entry.nodeId || "").trim();
}
return "";
})
.filter(Boolean);
}
function getLatestPersistedRecallDisplayRecord(chat = getContext()?.chat) {
if (!Array.isArray(chat) || chat.length === 0) return null;
for (let index = chat.length - 1; index >= 0; index--) {
if (!chat[index]?.is_user) continue;
const record = readPersistedRecallFromUserMessage(chat, index);
if (record?.injectionText) {
return {
messageIndex: index,
record,
};
}
}
return null;
}
function restoreRecallUiStateFromPersistence(chat = getContext()?.chat) {
const latestPersisted = getLatestPersistedRecallDisplayRecord(chat);
const graphRecallNodeIds = normalizeRecallNodeIdList(
currentGraph?.lastRecallResult,
);
const persistedNodeIds = normalizeRecallNodeIdList(
latestPersisted?.record?.selectedNodeIds,
);
const effectiveNodeIds = graphRecallNodeIds.length
? graphRecallNodeIds
: persistedNodeIds;
updateLastRecalledItems(effectiveNodeIds);
lastInjectionContent = String(latestPersisted?.record?.injectionText || "").trim();
return {
restored: Boolean(lastInjectionContent || effectiveNodeIds.length),
latestPersistedMessageIndex: Number.isFinite(latestPersisted?.messageIndex)
? latestPersisted.messageIndex
: null,
selectedNodeIds: effectiveNodeIds,
injectionTextLength: lastInjectionContent.length,
};
}
function clearRecallInputTracking() {
pendingRecallSendIntent = createRecallInputRecord();
lastRecallSentUserMessage = createRecallInputRecord();
@@ -1309,6 +1364,8 @@ function resolveRecallPersistenceTargetUserMessageIndex(
} = {},
) {
if (!Array.isArray(chat) || chat.length === 0) return null;
const normalizedGenerationType =
String(generationType || "normal").trim() || "normal";
const explicitIndex = Number.isFinite(explicitTargetUserMessageIndex)
? Math.floor(Number(explicitTargetUserMessageIndex))
@@ -1363,8 +1420,28 @@ function resolveRecallPersistenceTargetUserMessageIndex(
}
}
// 正常生成阶段里ST 可能会在真正发送前改写用户文本
// (命令展开、包装显示、助手 UI 处理等),导致 hash 已无法精确匹配。
// 这时仍应优先回绑到“当前最新 user 楼层”,否则召回记录虽然生成了,
// 但 Recall Card 会因为找不到目标楼层而消失。
if (
String(generationType || "normal").trim() !== "normal" &&
normalizedGenerationType === "normal" &&
Number.isFinite(latestUserIndex) &&
chat[latestUserIndex]?.is_user
) {
return latestUserIndex;
}
if (
normalizedGenerationType === "normal" &&
Number.isFinite(preferredMessageId) &&
chat[preferredMessageId]?.is_user
) {
return preferredMessageId;
}
if (
normalizedGenerationType !== "normal" &&
Number.isFinite(latestUserIndex) &&
chat[latestUserIndex]?.is_user
) {
@@ -3397,8 +3474,9 @@ function applyIndexedDbSnapshotToRuntime(
? currentGraph.historyState.extractionCount
: 0;
lastExtractedItems = [];
updateLastRecalledItems(currentGraph.lastRecallResult || []);
lastInjectionContent = "";
const restoredRecallUi = restoreRecallUiStateFromPersistence(
getContext()?.chat,
);
runtimeStatus = createUiStatus("待命", "已从 IndexedDB 加载聊天图谱", "idle");
lastExtractionStatus = createUiStatus(
"待命",
@@ -3413,7 +3491,9 @@ function applyIndexedDbSnapshotToRuntime(
);
lastRecallStatus = createUiStatus(
"待命",
"已从 IndexedDB 加载聊天图谱,等待下一次召回",
restoredRecallUi.restored
? "已从持久化召回记录恢复显示,等待下一次召回"
: "已从 IndexedDB 加载聊天图谱,等待下一次召回",
"idle",
);
@@ -3456,6 +3536,7 @@ function applyIndexedDbSnapshotToRuntime(
removeGraphShadowSnapshot(normalizedChatId);
refreshPanelLiveState();
schedulePersistedRecallMessageUiRefresh(30);
console.debug("[ST-BME] 已从 IndexedDB 加载图谱", {
chatId: normalizedChatId,
source,
@@ -5310,8 +5391,9 @@ function loadGraphFromChat(options = {}) {
? currentGraph.historyState.extractionCount
: 0;
lastExtractedItems = [];
updateLastRecalledItems(currentGraph.lastRecallResult || []);
lastInjectionContent = "";
const restoredRecallUi = restoreRecallUiStateFromPersistence(
context?.chat,
);
runtimeStatus = createUiStatus(
"图谱加载中",
"已从兼容 metadata 暂载图谱,等待 IndexedDB 权威确认",
@@ -5330,7 +5412,9 @@ function loadGraphFromChat(options = {}) {
);
lastRecallStatus = createUiStatus(
"待命",
"兼容图谱暂载中,等待 IndexedDB 确认后再执行召回",
restoredRecallUi.restored
? "已从持久化召回记录恢复显示,等待 IndexedDB 权威确认"
: "兼容图谱暂载中,等待 IndexedDB 确认后再执行召回",
"idle",
);
applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, {
@@ -5377,6 +5461,7 @@ function loadGraphFromChat(options = {}) {
});
refreshPanelLiveState();
schedulePersistedRecallMessageUiRefresh(30);
return {
success: true,
loaded: true,

View File

@@ -34,6 +34,11 @@ import {
getNode,
serializeGraph,
} from "../graph.js";
import {
buildPersistedRecallRecord,
readPersistedRecallFromUserMessage,
} from "../recall-persistence.js";
import { getNodeDisplayName } from "../node-labels.js";
import { normalizeGraphRuntimeState } from "../runtime-state.js";
import {
clampFloat,
@@ -216,6 +221,7 @@ async function createGraphPersistenceHarness({
deserializeGraph,
getGraphStats,
getNode,
getNodeDisplayName,
createUiStatus,
createGraphPersistenceState,
createRecallInputRecord,
@@ -229,6 +235,7 @@ async function createGraphPersistenceHarness({
clampInt,
clampFloat,
formatRecallContextLine,
readPersistedRecallFromUserMessage,
cloneGraphForPersistence,
cloneRuntimeDebugValue,
onMessageReceivedController,
@@ -511,6 +518,12 @@ result = {
getCurrentGraph() {
return currentGraph;
},
getLastInjectionContent() {
return lastInjectionContent;
},
getLastRecalledItems() {
return lastRecalledItems;
},
setGraphPersistenceState(patch = {}) {
graphPersistenceState = {
...graphPersistenceState,
@@ -605,6 +618,54 @@ result = {
);
}
{
const graph = createMeaningfulGraph("chat-recall-ui", "recall-ui");
graph.nodes[0].id = "restore-node";
graph.lastRecallResult = [{ id: "restore-node" }];
stampPersistedGraph(graph, {
revision: 7,
chatId: "chat-recall-ui",
reason: "recall-ui-restore",
});
const harness = await createGraphPersistenceHarness({
chatId: "chat-recall-ui",
globalChatId: "chat-recall-ui",
indexedDbSnapshot: buildSnapshotFromGraph(graph, {
chatId: "chat-recall-ui",
revision: 7,
}),
chat: [
{
is_user: true,
mes: "用户楼层",
extra: {
bme_recall: buildPersistedRecallRecord({
injectionText: "已持久化的召回注入",
selectedNodeIds: [],
nowIso: "2026-01-01T00:00:00.000Z",
}),
},
},
{
is_user: false,
mes: "assistant",
},
],
});
const result = harness.api.syncGraphLoadFromLiveContext({
source: "indexeddb-recall-ui-restore",
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(result.synced, true);
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
assert.equal(harness.api.getLastInjectionContent(), "已持久化的召回注入");
assert.equal(harness.api.getLastRecalledItems().length, 1);
assert.equal(harness.api.getLastRecalledItems()[0]?.id, "restore-node");
}
{
const harness = await createGraphPersistenceHarness({
chatId: "",

View File

@@ -3594,6 +3594,44 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(
assert.equal(resolution.targetUserMessageIndex, 0);
}
{
const harness = await createGenerationRecallHarness({ realApplyFinal: true });
harness.chat = [
{ is_user: true, mes: "酒馆最终写入的用户楼层文本" },
{ is_user: false, mes: "assistant-tail" },
];
harness.result.recordRecallSentUserMessage(0, "发送前捕获的原始文本", "message-sent");
const resolution =
harness.result.applyFinalRecallInjectionForGeneration({
generationType: "normal",
hookName: "GENERATION_AFTER_COMMANDS",
freshRecallResult: {
status: "completed",
didRecall: true,
injectionText: "fresh-memory",
sourceCandidates: [
{
text: "发送前捕获的原始文本",
},
],
},
transaction: {
frozenRecallOptions: {
generationType: "normal",
targetUserMessageIndex: null,
overrideUserMessage: "发送前捕获的原始文本",
},
},
});
assert.equal(
resolution.targetUserMessageIndex,
0,
"normal 生成时即便用户文本被宿主改写,也应回绑到最新 user 楼层",
);
}
}
async function testRecallSubGraphAndDataLayerEntryPoints() {