mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Harden graph recovery and shadow persistence
This commit is contained in:
@@ -51,9 +51,9 @@ export function getAssistantTurns(chat) {
|
|||||||
const assistantTurns = [];
|
const assistantTurns = [];
|
||||||
// 从 index 1 开始:index 0 是角色卡首条消息(greeting),不参与提取
|
// 从 index 1 开始:index 0 是角色卡首条消息(greeting),不参与提取
|
||||||
for (let index = 1; index < chat.length; index++) {
|
for (let index = 1; index < chat.length; index++) {
|
||||||
if (isAssistantChatMessage(chat[index], { index, chat })) {
|
if (!isAssistantChatMessage(chat[index], { index, chat })) continue;
|
||||||
assistantTurns.push(index);
|
if (!String(chat[index]?.mes ?? "").trim()) continue;
|
||||||
}
|
assistantTurns.push(index);
|
||||||
}
|
}
|
||||||
return assistantTurns;
|
return assistantTurns;
|
||||||
}
|
}
|
||||||
@@ -75,10 +75,12 @@ export function buildExtractionMessages(chat, startIdx, endIdx, settings) {
|
|||||||
) {
|
) {
|
||||||
const msg = chat[index];
|
const msg = chat[index];
|
||||||
if (isSystemMessageForExtraction(msg, { index, chat })) continue;
|
if (isSystemMessageForExtraction(msg, { index, chat })) continue;
|
||||||
|
const content = sanitizePlannerMessageText(msg);
|
||||||
|
if (!String(content || "").trim()) continue;
|
||||||
messages.push({
|
messages.push({
|
||||||
seq: index,
|
seq: index,
|
||||||
role: msg.is_user ? "user" : "assistant",
|
role: msg.is_user ? "user" : "assistant",
|
||||||
content: sanitizePlannerMessageText(msg),
|
content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -685,6 +685,24 @@ export function onMessageReceivedController(
|
|||||||
dbReady,
|
dbReady,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (
|
||||||
|
runtime.getIsHostGenerationRunning?.() === true &&
|
||||||
|
typeof runtime.deferAutoExtraction === "function"
|
||||||
|
) {
|
||||||
|
runtime.console?.debug?.(
|
||||||
|
"[ST-BME] assistant message received during host generation, deferring auto extraction",
|
||||||
|
{
|
||||||
|
messageId: Number.isFinite(Number(targetMessageIndex))
|
||||||
|
? Number(targetMessageIndex)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
runtime.deferAutoExtraction("generation-running", {
|
||||||
|
messageId: targetMessageIndex,
|
||||||
|
});
|
||||||
|
runtime.refreshPersistedRecallMessageUi?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
enqueueMicrotask(() => {
|
enqueueMicrotask(() => {
|
||||||
void runtime.runExtraction().catch((error) => {
|
void runtime.runExtraction().catch((error) => {
|
||||||
runtime.console.error("[ST-BME] 异步自动提取失败:", error);
|
runtime.console.error("[ST-BME] 异步自动提取失败:", error);
|
||||||
|
|||||||
4
graph.js
4
graph.js
@@ -6,6 +6,7 @@ import {
|
|||||||
createDefaultHistoryState,
|
createDefaultHistoryState,
|
||||||
createDefaultVectorIndexState,
|
createDefaultVectorIndexState,
|
||||||
normalizeGraphRuntimeState,
|
normalizeGraphRuntimeState,
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
} from "./runtime-state.js";
|
} from "./runtime-state.js";
|
||||||
import {
|
import {
|
||||||
hasSameScopeIdentity,
|
hasSameScopeIdentity,
|
||||||
@@ -717,7 +718,10 @@ export function importGraph(json) {
|
|||||||
node.embedding = null;
|
node.embedding = null;
|
||||||
}
|
}
|
||||||
graph.batchJournal = createDefaultBatchJournal();
|
graph.batchJournal = createDefaultBatchJournal();
|
||||||
|
graph.historyState.processedMessageHashVersion =
|
||||||
|
PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
graph.historyState.processedMessageHashes = {};
|
graph.historyState.processedMessageHashes = {};
|
||||||
|
graph.historyState.processedMessageHashesNeedRefresh = true;
|
||||||
graph.historyState.historyDirtyFrom = null;
|
graph.historyState.historyDirtyFrom = null;
|
||||||
graph.vectorIndexState.hashToNodeId = {};
|
graph.vectorIndexState.hashToNodeId = {};
|
||||||
graph.vectorIndexState.nodeToHash = {};
|
graph.vectorIndexState.nodeToHash = {};
|
||||||
|
|||||||
365
index.js
365
index.js
@@ -181,6 +181,7 @@ import {
|
|||||||
markHistoryDirty,
|
markHistoryDirty,
|
||||||
normalizeGraphRuntimeState,
|
normalizeGraphRuntimeState,
|
||||||
PROCESSED_MESSAGE_HASH_VERSION,
|
PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
|
rebindProcessedHistoryStateToChat,
|
||||||
snapshotProcessedMessageHashes,
|
snapshotProcessedMessageHashes,
|
||||||
undoLatestMaintenance,
|
undoLatestMaintenance,
|
||||||
} from "./runtime-state.js";
|
} from "./runtime-state.js";
|
||||||
@@ -546,6 +547,7 @@ const HISTORY_RECOVERY_SETTLE_MS = 80;
|
|||||||
const HISTORY_MUTATION_RETRY_DELAYS_MS = [80, 220, 500, 900];
|
const HISTORY_MUTATION_RETRY_DELAYS_MS = [80, 220, 500, 900];
|
||||||
const GRAPH_LOAD_RETRY_DELAYS_MS = [120, 450, 1200, 2500];
|
const GRAPH_LOAD_RETRY_DELAYS_MS = [120, 450, 1200, 2500];
|
||||||
const AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS = [120, 320, 800, 1600, 2800];
|
const AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS = [120, 320, 800, 1600, 2800];
|
||||||
|
const AUTO_EXTRACTION_HOST_SETTLE_MS = 120;
|
||||||
let runtimeStatus = createUiStatus("待命", "准备就绪", "idle");
|
let runtimeStatus = createUiStatus("待命", "准备就绪", "idle");
|
||||||
let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle");
|
let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle");
|
||||||
let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle");
|
let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle");
|
||||||
@@ -576,6 +578,8 @@ let pendingAutoExtraction = {
|
|||||||
requestedAt: 0,
|
requestedAt: 0,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
};
|
};
|
||||||
|
let isHostGenerationRunning = false;
|
||||||
|
let lastHostGenerationEndedAt = 0;
|
||||||
let skipBeforeCombineRecallUntil = 0;
|
let skipBeforeCombineRecallUntil = 0;
|
||||||
let lastPreGenerationRecallKey = "";
|
let lastPreGenerationRecallKey = "";
|
||||||
let lastPreGenerationRecallAt = 0;
|
let lastPreGenerationRecallAt = 0;
|
||||||
@@ -3788,6 +3792,178 @@ function resolveCompatibleGraphShadowSnapshot(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createShadowComparisonGraph({
|
||||||
|
chatId = "",
|
||||||
|
revision = 0,
|
||||||
|
integrity = "",
|
||||||
|
} = {}) {
|
||||||
|
const graph = createEmptyGraph();
|
||||||
|
stampGraphPersistenceMeta(graph, {
|
||||||
|
revision: Math.max(0, normalizeIndexedDbRevision(revision)),
|
||||||
|
chatId: String(chatId || ""),
|
||||||
|
integrity: String(integrity || ""),
|
||||||
|
reason: "shadow-compare-reference",
|
||||||
|
});
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyShadowSnapshotToRuntime(
|
||||||
|
chatId,
|
||||||
|
shadowSnapshot,
|
||||||
|
{
|
||||||
|
source = "shadow-restore",
|
||||||
|
attemptIndex = 0,
|
||||||
|
promoteToIndexedDb = true,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const normalizedChatId = normalizeChatIdCandidate(
|
||||||
|
chatId || shadowSnapshot?.chatId,
|
||||||
|
);
|
||||||
|
if (!normalizedChatId || !shadowSnapshot?.serializedGraph) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
loaded: false,
|
||||||
|
loadState: graphPersistenceState.loadState,
|
||||||
|
reason: "shadow-invalid",
|
||||||
|
chatId: normalizedChatId || "",
|
||||||
|
attemptIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let shadowGraph = null;
|
||||||
|
try {
|
||||||
|
shadowGraph = cloneGraphForPersistence(
|
||||||
|
normalizeGraphRuntimeState(
|
||||||
|
deserializeGraph(shadowSnapshot.serializedGraph),
|
||||||
|
normalizedChatId,
|
||||||
|
),
|
||||||
|
normalizedChatId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ST-BME] shadow snapshot 恢复失败:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
loaded: false,
|
||||||
|
loadState: graphPersistenceState.loadState,
|
||||||
|
reason: "shadow-deserialize-failed",
|
||||||
|
detail: error?.message || String(error),
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
attemptIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shadowRevision = Math.max(
|
||||||
|
1,
|
||||||
|
normalizeIndexedDbRevision(shadowSnapshot.revision),
|
||||||
|
);
|
||||||
|
stampGraphPersistenceMeta(shadowGraph, {
|
||||||
|
revision: shadowRevision,
|
||||||
|
reason: `shadow:${String(source || "shadow-restore")}`,
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
integrity:
|
||||||
|
String(shadowSnapshot.integrity || "").trim() ||
|
||||||
|
getChatMetadataIntegrity(getContext()) ||
|
||||||
|
graphPersistenceState.metadataIntegrity,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentGraph = shadowGraph;
|
||||||
|
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
|
||||||
|
? currentGraph.historyState.extractionCount
|
||||||
|
: 0;
|
||||||
|
lastExtractedItems = [];
|
||||||
|
const restoredRecallUi = restoreRecallUiStateFromPersistence(
|
||||||
|
getContext()?.chat,
|
||||||
|
);
|
||||||
|
runtimeStatus = createUiStatus(
|
||||||
|
"图谱临时恢复",
|
||||||
|
"已从本次会话临时快照恢复最近图谱,正在补写 IndexedDB",
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
lastExtractionStatus = createUiStatus(
|
||||||
|
"待命",
|
||||||
|
"已从会话快照恢复最近图谱,等待下一次提取",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
lastVectorStatus = createUiStatus(
|
||||||
|
"待命",
|
||||||
|
currentGraph.vectorIndexState?.lastWarning ||
|
||||||
|
"已从会话快照恢复最近图谱,等待下一次向量任务",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
lastRecallStatus = createUiStatus(
|
||||||
|
"待命",
|
||||||
|
restoredRecallUi.restored
|
||||||
|
? "已从持久化召回记录恢复显示,并已恢复最近图谱"
|
||||||
|
: "已从会话快照恢复最近图谱,等待下一次召回",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
|
||||||
|
applyGraphLoadState(GRAPH_LOAD_STATES.SHADOW_RESTORED, {
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
reason: `shadow:${String(source || "shadow-restore")}`,
|
||||||
|
attemptIndex,
|
||||||
|
revision: shadowRevision,
|
||||||
|
lastPersistedRevision: Math.max(
|
||||||
|
normalizeIndexedDbRevision(graphPersistenceState.lastPersistedRevision),
|
||||||
|
shadowRevision,
|
||||||
|
),
|
||||||
|
queuedPersistRevision: Math.max(
|
||||||
|
normalizeIndexedDbRevision(graphPersistenceState.queuedPersistRevision),
|
||||||
|
shadowRevision,
|
||||||
|
),
|
||||||
|
queuedPersistChatId: normalizedChatId,
|
||||||
|
pendingPersist: Boolean(promoteToIndexedDb),
|
||||||
|
shadowSnapshotUsed: true,
|
||||||
|
shadowSnapshotRevision: shadowRevision,
|
||||||
|
shadowSnapshotUpdatedAt: String(shadowSnapshot.updatedAt || ""),
|
||||||
|
shadowSnapshotReason: String(
|
||||||
|
shadowSnapshot.debugReason || shadowSnapshot.reason || source || "",
|
||||||
|
),
|
||||||
|
dbReady: true,
|
||||||
|
writesBlocked: false,
|
||||||
|
});
|
||||||
|
updateGraphPersistenceState({
|
||||||
|
storagePrimary: "indexeddb",
|
||||||
|
storageMode: "indexeddb",
|
||||||
|
dbReady: true,
|
||||||
|
indexedDbLastError: "",
|
||||||
|
metadataIntegrity:
|
||||||
|
getChatMetadataIntegrity(getContext()) ||
|
||||||
|
graphPersistenceState.metadataIntegrity,
|
||||||
|
dualWriteLastResult: {
|
||||||
|
action: "load",
|
||||||
|
source: `${String(source || "shadow-restore")}:shadow`,
|
||||||
|
success: true,
|
||||||
|
provisional: true,
|
||||||
|
revision: shadowRevision,
|
||||||
|
resultCode: "graph.load.shadow-restored",
|
||||||
|
reason: `shadow:${String(source || "shadow-restore")}`,
|
||||||
|
at: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
|
||||||
|
|
||||||
|
if (promoteToIndexedDb) {
|
||||||
|
queueGraphPersistToIndexedDb(normalizedChatId, currentGraph, {
|
||||||
|
revision: shadowRevision,
|
||||||
|
reason: `shadow-restore-promote:${String(source || "shadow-restore")}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPanelLiveState();
|
||||||
|
schedulePersistedRecallMessageUiRefresh(30);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
loaded: true,
|
||||||
|
loadState: GRAPH_LOAD_STATES.SHADOW_RESTORED,
|
||||||
|
reason: `shadow:${String(source || "shadow-restore")}`,
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
attemptIndex,
|
||||||
|
revision: shadowRevision,
|
||||||
|
shadowRestored: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) {
|
async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) {
|
||||||
const action = String(syncPayload?.action || "")
|
const action = String(syncPayload?.action || "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -4924,10 +5100,26 @@ async function loadGraphFromIndexedDb(
|
|||||||
identityRecoveryResult?.snapshot ||
|
identityRecoveryResult?.snapshot ||
|
||||||
migrationResult?.snapshot ||
|
migrationResult?.snapshot ||
|
||||||
(await db.exportSnapshot());
|
(await db.exportSnapshot());
|
||||||
|
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(
|
||||||
|
resolveCurrentChatIdentity(getContext()),
|
||||||
|
);
|
||||||
|
|
||||||
cacheIndexedDbSnapshot(normalizedChatId, snapshot);
|
cacheIndexedDbSnapshot(normalizedChatId, snapshot);
|
||||||
|
|
||||||
if (!isIndexedDbSnapshotMeaningful(snapshot)) {
|
if (!isIndexedDbSnapshotMeaningful(snapshot)) {
|
||||||
|
if (shadowSnapshot) {
|
||||||
|
const shadowRestoreResult = applyShadowSnapshotToRuntime(
|
||||||
|
normalizedChatId,
|
||||||
|
shadowSnapshot,
|
||||||
|
{
|
||||||
|
source: `${source}:shadow-indexeddb-empty`,
|
||||||
|
attemptIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (shadowRestoreResult?.loaded) {
|
||||||
|
return shadowRestoreResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (applyEmptyState && getCurrentChatId() === normalizedChatId) {
|
if (applyEmptyState && getCurrentChatId() === normalizedChatId) {
|
||||||
return applyIndexedDbEmptyToRuntime(normalizedChatId, {
|
return applyIndexedDbEmptyToRuntime(normalizedChatId, {
|
||||||
source,
|
source,
|
||||||
@@ -4946,6 +5138,39 @@ async function loadGraphFromIndexedDb(
|
|||||||
const snapshotRevision = normalizeIndexedDbRevision(
|
const snapshotRevision = normalizeIndexedDbRevision(
|
||||||
snapshot?.meta?.revision,
|
snapshot?.meta?.revision,
|
||||||
);
|
);
|
||||||
|
const snapshotIntegrity = String(snapshot?.meta?.integrity || "").trim();
|
||||||
|
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
|
||||||
|
createShadowComparisonGraph({
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
revision: snapshotRevision,
|
||||||
|
integrity: snapshotIntegrity,
|
||||||
|
}),
|
||||||
|
shadowSnapshot,
|
||||||
|
);
|
||||||
|
if (shadowSnapshot && shadowDecision?.reason) {
|
||||||
|
updateGraphPersistenceState({
|
||||||
|
dualWriteLastResult: {
|
||||||
|
action: "shadow-compare",
|
||||||
|
source: `${source}:indexeddb-shadow-compare`,
|
||||||
|
success: Boolean(shadowDecision.prefer),
|
||||||
|
reason: shadowDecision.reason,
|
||||||
|
resultCode: String(shadowDecision.resultCode || ""),
|
||||||
|
shadowRevision: Number(shadowSnapshot.revision || 0),
|
||||||
|
officialRevision: snapshotRevision,
|
||||||
|
at: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (shadowSnapshot && shadowDecision?.prefer) {
|
||||||
|
return applyShadowSnapshotToRuntime(
|
||||||
|
normalizedChatId,
|
||||||
|
shadowSnapshot,
|
||||||
|
{
|
||||||
|
source: `${source}:shadow-newer-than-indexeddb`,
|
||||||
|
attemptIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
const shouldAllowOverride =
|
const shouldAllowOverride =
|
||||||
allowOverride ||
|
allowOverride ||
|
||||||
BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has(
|
BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has(
|
||||||
@@ -5193,7 +5418,10 @@ function deferAutoExtraction(
|
|||||||
? Math.max(0, Math.floor(Number(pendingAutoExtraction.attempts) || 0))
|
? Math.max(0, Math.floor(Number(pendingAutoExtraction.attempts) || 0))
|
||||||
: 0;
|
: 0;
|
||||||
const nextAttempts = previousAttempts + 1;
|
const nextAttempts = previousAttempts + 1;
|
||||||
const resolvedDelayMs = Number.isFinite(Number(delayMs))
|
const resolvedDelayMs =
|
||||||
|
delayMs !== null &&
|
||||||
|
delayMs !== undefined &&
|
||||||
|
Number.isFinite(Number(delayMs))
|
||||||
? Math.max(0, Math.floor(Number(delayMs)))
|
? Math.max(0, Math.floor(Number(delayMs)))
|
||||||
: AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS[
|
: AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS[
|
||||||
Math.min(
|
Math.min(
|
||||||
@@ -5272,6 +5500,26 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHostGenerationRunning) {
|
||||||
|
return deferAutoExtraction("generation-running", {
|
||||||
|
chatId: pendingChatId,
|
||||||
|
messageId: pendingAutoExtraction.messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostGenerationSettleRemainingMs =
|
||||||
|
lastHostGenerationEndedAt > 0
|
||||||
|
? AUTO_EXTRACTION_HOST_SETTLE_MS -
|
||||||
|
(Date.now() - lastHostGenerationEndedAt)
|
||||||
|
: 0;
|
||||||
|
if (hostGenerationSettleRemainingMs > 0) {
|
||||||
|
return deferAutoExtraction("generation-settling", {
|
||||||
|
chatId: pendingChatId,
|
||||||
|
messageId: pendingAutoExtraction.messageId,
|
||||||
|
delayMs: hostGenerationSettleRemainingMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecoveringHistory) {
|
if (isRecoveringHistory) {
|
||||||
return deferAutoExtraction("history-recovering", {
|
return deferAutoExtraction("history-recovering", {
|
||||||
chatId: pendingChatId,
|
chatId: pendingChatId,
|
||||||
@@ -5295,6 +5543,31 @@ function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumeContext = getContext();
|
||||||
|
const resumeChat = resumeContext?.chat;
|
||||||
|
if (
|
||||||
|
Array.isArray(resumeChat) &&
|
||||||
|
Number.isFinite(Number(pendingAutoExtraction.messageId))
|
||||||
|
) {
|
||||||
|
const pendingMessageIndex = Math.floor(
|
||||||
|
Number(pendingAutoExtraction.messageId),
|
||||||
|
);
|
||||||
|
const pendingMessage = resumeChat[pendingMessageIndex];
|
||||||
|
if (
|
||||||
|
isAssistantChatMessage(pendingMessage, {
|
||||||
|
index: pendingMessageIndex,
|
||||||
|
chat: resumeChat,
|
||||||
|
}) &&
|
||||||
|
!String(pendingMessage?.mes ?? "").trim()
|
||||||
|
) {
|
||||||
|
return deferAutoExtraction("assistant-message-empty", {
|
||||||
|
chatId: pendingChatId,
|
||||||
|
messageId: pendingMessageIndex,
|
||||||
|
delayMs: AUTO_EXTRACTION_HOST_SETTLE_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pendingRequest = { ...pendingAutoExtraction };
|
const pendingRequest = { ...pendingAutoExtraction };
|
||||||
clearPendingAutoExtraction();
|
clearPendingAutoExtraction();
|
||||||
console.debug?.("[ST-BME] resuming pending auto extraction", {
|
console.debug?.("[ST-BME] resuming pending auto extraction", {
|
||||||
@@ -6607,6 +6880,7 @@ function loadGraphFromChat(options = {}) {
|
|||||||
const context = getContext();
|
const context = getContext();
|
||||||
const chatIdentity = resolveCurrentChatIdentity(context);
|
const chatIdentity = resolveCurrentChatIdentity(context);
|
||||||
const chatId = chatIdentity.chatId;
|
const chatId = chatIdentity.chatId;
|
||||||
|
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity);
|
||||||
const normalizedExpectedChatId = String(expectedChatId || "");
|
const normalizedExpectedChatId = String(expectedChatId || "");
|
||||||
if (attemptIndex === 0) {
|
if (attemptIndex === 0) {
|
||||||
clearPendingGraphLoadRetry();
|
clearPendingGraphLoadRetry();
|
||||||
@@ -6757,7 +7031,6 @@ function loadGraphFromChat(options = {}) {
|
|||||||
normalizeGraphRuntimeState(deserializeGraph(savedData), chatId),
|
normalizeGraphRuntimeState(deserializeGraph(savedData), chatId),
|
||||||
chatId,
|
chatId,
|
||||||
);
|
);
|
||||||
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity);
|
|
||||||
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
|
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
|
||||||
officialGraph,
|
officialGraph,
|
||||||
shadowSnapshot,
|
shadowSnapshot,
|
||||||
@@ -6827,6 +7100,14 @@ function loadGraphFromChat(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shadowSnapshot && shadowDecision?.prefer) {
|
||||||
|
clearPendingGraphLoadRetry();
|
||||||
|
return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, {
|
||||||
|
source: `${source}:metadata-shadow`,
|
||||||
|
attemptIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
clearPendingGraphLoadRetry();
|
clearPendingGraphLoadRetry();
|
||||||
currentGraph = officialGraph;
|
currentGraph = officialGraph;
|
||||||
stampGraphPersistenceMeta(currentGraph, {
|
stampGraphPersistenceMeta(currentGraph, {
|
||||||
@@ -6929,6 +7210,14 @@ function loadGraphFromChat(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shadowSnapshot) {
|
||||||
|
clearPendingGraphLoadRetry();
|
||||||
|
return applyShadowSnapshotToRuntime(chatId, shadowSnapshot, {
|
||||||
|
source: `${source}:shadow-no-official`,
|
||||||
|
attemptIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, {
|
applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, {
|
||||||
chatId,
|
chatId,
|
||||||
reason: `indexeddb-probe-pending:${String(source || "direct-load")}`,
|
reason: `indexeddb-probe-pending:${String(source || "direct-load")}`,
|
||||||
@@ -7043,6 +7332,37 @@ async function saveGraphToIndexedDb(
|
|||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED &&
|
||||||
|
areChatIdsEquivalentForResolvedIdentity(
|
||||||
|
normalizedChatId,
|
||||||
|
graphPersistenceState.chatId || getCurrentChatId(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, {
|
||||||
|
chatId: normalizedChatId,
|
||||||
|
reason: `shadow-promoted:${String(reason || "graph-save")}`,
|
||||||
|
revision: snapshot.meta.revision,
|
||||||
|
lastPersistedRevision: snapshot.meta.revision,
|
||||||
|
queuedPersistRevision: 0,
|
||||||
|
queuedPersistChatId: "",
|
||||||
|
pendingPersist: false,
|
||||||
|
shadowSnapshotUsed: true,
|
||||||
|
shadowSnapshotRevision: Math.max(
|
||||||
|
Number(graphPersistenceState.shadowSnapshotRevision || 0),
|
||||||
|
snapshot.meta.revision,
|
||||||
|
),
|
||||||
|
shadowSnapshotUpdatedAt: String(
|
||||||
|
graphPersistenceState.shadowSnapshotUpdatedAt || "",
|
||||||
|
),
|
||||||
|
shadowSnapshotReason: String(
|
||||||
|
graphPersistenceState.shadowSnapshotReason ||
|
||||||
|
"shadow-restore-promoted",
|
||||||
|
),
|
||||||
|
dbReady: true,
|
||||||
|
writesBlocked: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
|
rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -7268,11 +7588,23 @@ function saveGraphToChat(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleGraphShadowSnapshotPageHide() {
|
function handleGraphShadowSnapshotPageHide() {
|
||||||
|
saveGraphToChat({
|
||||||
|
reason: "pagehide-passive-persist",
|
||||||
|
markMutation: false,
|
||||||
|
captureShadow: true,
|
||||||
|
immediate: false,
|
||||||
|
});
|
||||||
maybeCaptureGraphShadowSnapshot("pagehide");
|
maybeCaptureGraphShadowSnapshot("pagehide");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGraphShadowSnapshotVisibilityChange() {
|
function handleGraphShadowSnapshotVisibilityChange() {
|
||||||
if (document.visibilityState === "hidden") {
|
if (document.visibilityState === "hidden") {
|
||||||
|
saveGraphToChat({
|
||||||
|
reason: "visibility-hidden-passive-persist",
|
||||||
|
markMutation: false,
|
||||||
|
captureShadow: true,
|
||||||
|
immediate: false,
|
||||||
|
});
|
||||||
maybeCaptureGraphShadowSnapshot("visibility-hidden");
|
maybeCaptureGraphShadowSnapshot("visibility-hidden");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9013,9 +9345,10 @@ function inspectHistoryMutation(
|
|||||||
Array.isArray(chat) &&
|
Array.isArray(chat) &&
|
||||||
currentGraph.historyState?.processedMessageHashesNeedRefresh === true
|
currentGraph.historyState?.processedMessageHashesNeedRefresh === true
|
||||||
) {
|
) {
|
||||||
updateProcessedHistorySnapshot(
|
rebindProcessedHistoryStateToChat(
|
||||||
|
currentGraph,
|
||||||
chat,
|
chat,
|
||||||
currentGraph.historyState.lastProcessedAssistantFloor ?? -1,
|
getAssistantTurns(chat),
|
||||||
);
|
);
|
||||||
console.debug?.(
|
console.debug?.(
|
||||||
"[ST-BME] refreshed processed message hashes after hash-version migration",
|
"[ST-BME] refreshed processed message hashes after hash-version migration",
|
||||||
@@ -10104,6 +10437,8 @@ async function runRecall(options = {}) {
|
|||||||
// ==================== 事件钩子 ====================
|
// ==================== 事件钩子 ====================
|
||||||
|
|
||||||
function onChatChanged() {
|
function onChatChanged() {
|
||||||
|
isHostGenerationRunning = false;
|
||||||
|
lastHostGenerationEndedAt = 0;
|
||||||
if (typeof clearMessageHideState === "function") {
|
if (typeof clearMessageHideState === "function") {
|
||||||
clearMessageHideState("chat-changed");
|
clearMessageHideState("chat-changed");
|
||||||
}
|
}
|
||||||
@@ -10211,13 +10546,15 @@ function onUserMessageRendered(messageId = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onCharacterMessageRendered(messageId = null, type = "") {
|
function onCharacterMessageRendered(messageId = null, type = "") {
|
||||||
return onCharacterMessageRenderedController(
|
const result = onCharacterMessageRenderedController(
|
||||||
{
|
{
|
||||||
refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh,
|
refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh,
|
||||||
},
|
},
|
||||||
messageId,
|
messageId,
|
||||||
type,
|
type,
|
||||||
);
|
);
|
||||||
|
void maybeResumePendingAutoExtraction("character-message-rendered");
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMessageDeleted(chatLengthOrMessageId, meta = null) {
|
function onMessageDeleted(chatLengthOrMessageId, meta = null) {
|
||||||
@@ -10270,6 +10607,16 @@ async function onMessageSwiped(messageId, meta = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onGenerationStarted(type, params = {}, dryRun = false) {
|
function onGenerationStarted(type, params = {}, dryRun = false) {
|
||||||
|
const generationType = String(type || "normal").trim() || "normal";
|
||||||
|
if (
|
||||||
|
!dryRun &&
|
||||||
|
!params?.automatic_trigger &&
|
||||||
|
!params?.quiet_prompt &&
|
||||||
|
generationType === "normal"
|
||||||
|
) {
|
||||||
|
isHostGenerationRunning = true;
|
||||||
|
lastHostGenerationEndedAt = 0;
|
||||||
|
}
|
||||||
return onGenerationStartedController(
|
return onGenerationStartedController(
|
||||||
{
|
{
|
||||||
clearDryRunPromptPreview,
|
clearDryRunPromptPreview,
|
||||||
@@ -10293,6 +10640,8 @@ function onGenerationStarted(type, params = {}, dryRun = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onGenerationEnded(_chatLength = null) {
|
function onGenerationEnded(_chatLength = null) {
|
||||||
|
isHostGenerationRunning = false;
|
||||||
|
lastHostGenerationEndedAt = Date.now();
|
||||||
const recentTransaction = findRecentGenerationRecallTransactionForChat();
|
const recentTransaction = findRecentGenerationRecallTransactionForChat();
|
||||||
const recentRecallResult =
|
const recentRecallResult =
|
||||||
getGenerationRecallTransactionResult(recentTransaction);
|
getGenerationRecallTransactionResult(recentTransaction);
|
||||||
@@ -10307,6 +10656,7 @@ function onGenerationEnded(_chatLength = null) {
|
|||||||
"",
|
"",
|
||||||
});
|
});
|
||||||
schedulePersistedRecallMessageUiRefresh(320);
|
schedulePersistedRecallMessageUiRefresh(320);
|
||||||
|
void maybeResumePendingAutoExtraction("generation-ended");
|
||||||
if (typeof scheduleMessageHideApply === "function") {
|
if (typeof scheduleMessageHideApply === "function") {
|
||||||
scheduleMessageHideApply("generation-ended", 180);
|
scheduleMessageHideApply("generation-ended", 180);
|
||||||
}
|
}
|
||||||
@@ -10362,9 +10712,11 @@ function onMessageReceived(messageId = null, type = "") {
|
|||||||
console,
|
console,
|
||||||
consumeCurrentGenerationTrivialSkip,
|
consumeCurrentGenerationTrivialSkip,
|
||||||
createRecallInputRecord,
|
createRecallInputRecord,
|
||||||
|
deferAutoExtraction,
|
||||||
getContext,
|
getContext,
|
||||||
getCurrentGraph: () => currentGraph,
|
getCurrentGraph: () => currentGraph,
|
||||||
getGraphPersistenceState: () => graphPersistenceState,
|
getGraphPersistenceState: () => graphPersistenceState,
|
||||||
|
getIsHostGenerationRunning: () => isHostGenerationRunning,
|
||||||
getPendingHostGenerationInputSnapshot,
|
getPendingHostGenerationInputSnapshot,
|
||||||
getPendingRecallSendIntent: () => pendingRecallSendIntent,
|
getPendingRecallSendIntent: () => pendingRecallSendIntent,
|
||||||
isAssistantChatMessage,
|
isAssistantChatMessage,
|
||||||
@@ -10519,10 +10871,13 @@ async function onImportGraph() {
|
|||||||
clearTimeout,
|
clearTimeout,
|
||||||
document,
|
document,
|
||||||
ensureGraphMutationReady,
|
ensureGraphMutationReady,
|
||||||
|
getAssistantTurns,
|
||||||
|
getContext,
|
||||||
getCurrentChatId,
|
getCurrentChatId,
|
||||||
importGraph,
|
importGraph,
|
||||||
markVectorStateDirty,
|
markVectorStateDirty,
|
||||||
normalizeGraphRuntimeState,
|
normalizeGraphRuntimeState,
|
||||||
|
rebindProcessedHistoryStateToChat,
|
||||||
saveGraphToChat,
|
saveGraphToChat,
|
||||||
setCurrentGraph: (graph) => {
|
setCurrentGraph: (graph) => {
|
||||||
currentGraph = graph;
|
currentGraph = graph;
|
||||||
|
|||||||
@@ -278,6 +278,72 @@ export function snapshotProcessedMessageHashes(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rebindProcessedHistoryStateToChat(
|
||||||
|
graph,
|
||||||
|
chat,
|
||||||
|
assistantTurns = [],
|
||||||
|
) {
|
||||||
|
if (!graph || typeof graph !== "object") {
|
||||||
|
return {
|
||||||
|
rebound: false,
|
||||||
|
reason: "missing-graph",
|
||||||
|
lastProcessedAssistantFloor: -1,
|
||||||
|
maxAssistantFloor: -1,
|
||||||
|
clamped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyState =
|
||||||
|
graph.historyState && typeof graph.historyState === "object"
|
||||||
|
? graph.historyState
|
||||||
|
: createDefaultHistoryState();
|
||||||
|
graph.historyState = historyState;
|
||||||
|
|
||||||
|
const normalizedAssistantTurns = Array.isArray(assistantTurns)
|
||||||
|
? assistantTurns
|
||||||
|
.map((value) => Number.parseInt(value, 10))
|
||||||
|
.filter(Number.isFinite)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
: [];
|
||||||
|
const maxAssistantFloor =
|
||||||
|
normalizedAssistantTurns.length > 0
|
||||||
|
? normalizedAssistantTurns[normalizedAssistantTurns.length - 1]
|
||||||
|
: -1;
|
||||||
|
const rawLastProcessedAssistantFloor = Number.isFinite(
|
||||||
|
historyState.lastProcessedAssistantFloor,
|
||||||
|
)
|
||||||
|
? Math.floor(historyState.lastProcessedAssistantFloor)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
let safeLastProcessedAssistantFloor = rawLastProcessedAssistantFloor;
|
||||||
|
if (!Array.isArray(chat) || chat.length === 0 || maxAssistantFloor < 0) {
|
||||||
|
safeLastProcessedAssistantFloor = -1;
|
||||||
|
} else if (safeLastProcessedAssistantFloor > maxAssistantFloor) {
|
||||||
|
safeLastProcessedAssistantFloor = maxAssistantFloor;
|
||||||
|
}
|
||||||
|
|
||||||
|
historyState.lastProcessedAssistantFloor = safeLastProcessedAssistantFloor;
|
||||||
|
historyState.processedMessageHashVersion = PROCESSED_MESSAGE_HASH_VERSION;
|
||||||
|
historyState.processedMessageHashes =
|
||||||
|
safeLastProcessedAssistantFloor >= 0
|
||||||
|
? snapshotProcessedMessageHashes(chat, safeLastProcessedAssistantFloor)
|
||||||
|
: {};
|
||||||
|
historyState.processedMessageHashesNeedRefresh = false;
|
||||||
|
graph.lastProcessedSeq = safeLastProcessedAssistantFloor;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rebound: true,
|
||||||
|
reason:
|
||||||
|
safeLastProcessedAssistantFloor < 0
|
||||||
|
? "no-processed-assistant-floor"
|
||||||
|
: "ok",
|
||||||
|
lastProcessedAssistantFloor: safeLastProcessedAssistantFloor,
|
||||||
|
maxAssistantFloor,
|
||||||
|
clamped:
|
||||||
|
safeLastProcessedAssistantFloor !== rawLastProcessedAssistantFloor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function detectHistoryMutation(chat, historyState) {
|
export function detectHistoryMutation(chat, historyState) {
|
||||||
const lastProcessedAssistantFloor =
|
const lastProcessedAssistantFloor =
|
||||||
historyState?.lastProcessedAssistantFloor ?? -1;
|
historyState?.lastProcessedAssistantFloor ?? -1;
|
||||||
|
|||||||
@@ -87,6 +87,34 @@ assert.deepEqual(
|
|||||||
"extraction should keep BME-managed hidden context but still skip real system messages",
|
"extraction should keep BME-managed hidden context but still skip real system messages",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const blankAssistantChat = [
|
||||||
|
{ is_user: false, is_system: true, mes: "greeting/system" },
|
||||||
|
{ is_user: true, is_system: false, mes: "user-1" },
|
||||||
|
{ is_user: false, is_system: false, mes: " " },
|
||||||
|
{ is_user: true, is_system: false, mes: "<plot>secret</plot>" },
|
||||||
|
{ is_user: false, is_system: false, mes: "assistant-2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
getAssistantTurns(blankAssistantChat),
|
||||||
|
[4],
|
||||||
|
"blank assistant floors should not be treated as extractable turns",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
buildExtractionMessages(blankAssistantChat, 4, 4, {
|
||||||
|
extractContextTurns: 3,
|
||||||
|
}).map((message) => ({
|
||||||
|
seq: message.seq,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{ seq: 1, role: "user", content: "user-1" },
|
||||||
|
{ seq: 4, role: "assistant", content: "assistant-2" },
|
||||||
|
],
|
||||||
|
"blank assistant text and planner-tag-only user text should be skipped",
|
||||||
|
);
|
||||||
|
|
||||||
resetHideState();
|
resetHideState();
|
||||||
const autoHiddenChat = [
|
const autoHiddenChat = [
|
||||||
{ is_user: false, is_system: true, mes: "greeting/system" },
|
{ is_user: false, is_system: true, mes: "greeting/system" },
|
||||||
|
|||||||
@@ -1641,13 +1641,16 @@ result = {
|
|||||||
source: "shadow-test",
|
source: "shadow-test",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.loadState, "loading");
|
assert.equal(result.loadState, "shadow-restored");
|
||||||
assert.equal(reader.api.getCurrentGraph(), null);
|
assert.equal(
|
||||||
|
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||||
|
"事件-shadow",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed,
|
reader.api.getGraphPersistenceLiveState().shadowSnapshotUsed,
|
||||||
false,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, true);
|
assert.equal(reader.api.getGraphPersistenceLiveState().writesBlocked, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1949,7 +1952,7 @@ result = {
|
|||||||
});
|
});
|
||||||
const live = reader.api.getGraphPersistenceLiveState();
|
const live = reader.api.getGraphPersistenceLiveState();
|
||||||
|
|
||||||
assert.equal(result.loadState, "loading");
|
assert.equal(result.loadState, "shadow-restored");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes
|
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes
|
||||||
?.length,
|
?.length,
|
||||||
@@ -1961,8 +1964,8 @@ result = {
|
|||||||
);
|
);
|
||||||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||||||
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
|
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
|
||||||
assert.equal(live.lastPersistedRevision, 0);
|
assert.equal(live.lastPersistedRevision, 9);
|
||||||
assert.equal(live.pendingPersist, false);
|
assert.equal(live.pendingPersist, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -2102,7 +2105,7 @@ result = {
|
|||||||
source: "load-shadow-decoupled",
|
source: "load-shadow-decoupled",
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.loadState, "loading");
|
assert.equal(result.loadState, "shadow-restored");
|
||||||
const runtimeGraph = reader.api.getCurrentGraph();
|
const runtimeGraph = reader.api.getCurrentGraph();
|
||||||
const persistedGraph =
|
const persistedGraph =
|
||||||
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||||||
@@ -2113,6 +2116,10 @@ result = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
runtimeGraph.nodes[0].fields.title = "runtime-shadow-mutated";
|
runtimeGraph.nodes[0].fields.title = "runtime-shadow-mutated";
|
||||||
|
assert.equal(
|
||||||
|
runtimeGraph.nodes[0].fields.title,
|
||||||
|
"runtime-shadow-mutated",
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
persistedGraph.nodes[0].fields.title,
|
persistedGraph.nodes[0].fields.title,
|
||||||
"事件-official-older",
|
"事件-official-older",
|
||||||
@@ -2355,6 +2362,65 @@ result = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const sharedSession = new Map();
|
||||||
|
const writer = await createGraphPersistenceHarness({
|
||||||
|
chatId: "chat-indexeddb-shadow-restore",
|
||||||
|
globalChatId: "chat-indexeddb-shadow-restore",
|
||||||
|
sessionStore: sharedSession,
|
||||||
|
});
|
||||||
|
writer.api.writeGraphShadowSnapshot(
|
||||||
|
"chat-indexeddb-shadow-restore",
|
||||||
|
createMeaningfulGraph("chat-indexeddb-shadow-restore", "shadow-newer"),
|
||||||
|
{
|
||||||
|
revision: 9,
|
||||||
|
reason: "pagehide-refresh",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const indexedDbGraph = stampPersistedGraph(
|
||||||
|
createMeaningfulGraph("chat-indexeddb-shadow-restore", "indexeddb-older"),
|
||||||
|
{
|
||||||
|
revision: 4,
|
||||||
|
integrity: "meta-indexeddb-shadow-restore",
|
||||||
|
chatId: "chat-indexeddb-shadow-restore",
|
||||||
|
reason: "indexeddb-older",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
|
||||||
|
chatId: "chat-indexeddb-shadow-restore",
|
||||||
|
revision: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const harness = await createGraphPersistenceHarness({
|
||||||
|
chatId: "chat-indexeddb-shadow-restore",
|
||||||
|
globalChatId: "chat-indexeddb-shadow-restore",
|
||||||
|
indexedDbSnapshot,
|
||||||
|
sessionStore: sharedSession,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.api.loadGraphFromIndexedDb(
|
||||||
|
"chat-indexeddb-shadow-restore",
|
||||||
|
{
|
||||||
|
source: "indexeddb-shadow-restore",
|
||||||
|
allowOverride: true,
|
||||||
|
applyEmptyState: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.loadState, "shadow-restored");
|
||||||
|
assert.equal(
|
||||||
|
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||||
|
"事件-shadow-newer",
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(
|
||||||
|
harness.api.getIndexedDbSnapshot().meta.revision,
|
||||||
|
9,
|
||||||
|
"shadow 恢复后应回补 IndexedDB 修正旧快照",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const legacyGraph = stampPersistedGraph(
|
const legacyGraph = stampPersistedGraph(
|
||||||
createMeaningfulGraph("chat-legacy-migration", "legacy"),
|
createMeaningfulGraph("chat-legacy-migration", "legacy"),
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ export function createGenerationRecallHarness(options = {}) {
|
|||||||
recordedInjectionSnapshots: [],
|
recordedInjectionSnapshots: [],
|
||||||
refreshPanelCalls: 0,
|
refreshPanelCalls: 0,
|
||||||
hideScheduleCalls: [],
|
hideScheduleCalls: [],
|
||||||
|
isExtracting: false,
|
||||||
|
isRecoveringHistory: false,
|
||||||
|
isAssistantChatMessage: (message) =>
|
||||||
|
Boolean(message) && !message.is_user && !message.is_system,
|
||||||
createRecallInputRecord,
|
createRecallInputRecord,
|
||||||
createRecallRunResult,
|
createRecallRunResult,
|
||||||
hashRecallInput,
|
hashRecallInput,
|
||||||
@@ -215,7 +219,7 @@ export function createGenerationRecallHarness(options = {}) {
|
|||||||
};
|
};
|
||||||
vm.createContext(context);
|
vm.createContext(context);
|
||||||
vm.runInContext(
|
vm.runInContext(
|
||||||
`${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
|
`${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, buildNormalGenerationRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, clearPendingHostGenerationInputSnapshot, recordRecallSendIntent, clearPendingRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage, getCurrentGenerationTrivialSkip, markCurrentGenerationTrivialSkip, clearCurrentGenerationTrivialSkip, consumeCurrentGenerationTrivialSkip, deferAutoExtraction, maybeResumePendingAutoExtraction, clearPendingAutoExtraction, getPendingAutoExtraction: () => ({ ...pendingAutoExtraction }), getIsHostGenerationRunning: () => isHostGenerationRunning, runPlannerRecallForEna, getGraphPersistenceState: () => graphPersistenceState, setGraphPersistenceState: (value = {}) => { graphPersistenceState = { ...graphPersistenceState, ...(value || {}) }; return graphPersistenceState; } };`,
|
||||||
context,
|
context,
|
||||||
{ filename: indexPath },
|
{ filename: indexPath },
|
||||||
);
|
);
|
||||||
@@ -322,9 +326,12 @@ export function createGenerationRecallHarness(options = {}) {
|
|||||||
consumeCurrentGenerationTrivialSkip:
|
consumeCurrentGenerationTrivialSkip:
|
||||||
context.result.consumeCurrentGenerationTrivialSkip,
|
context.result.consumeCurrentGenerationTrivialSkip,
|
||||||
createRecallInputRecord,
|
createRecallInputRecord,
|
||||||
|
deferAutoExtraction: context.result.deferAutoExtraction,
|
||||||
getContext: context.getContext,
|
getContext: context.getContext,
|
||||||
getCurrentGraph: () => context.currentGraph,
|
getCurrentGraph: () => context.currentGraph,
|
||||||
getGraphPersistenceState: () => context.result.getGraphPersistenceState(),
|
getGraphPersistenceState: () => context.result.getGraphPersistenceState(),
|
||||||
|
getIsHostGenerationRunning: () =>
|
||||||
|
context.result.getIsHostGenerationRunning(),
|
||||||
getPendingHostGenerationInputSnapshot:
|
getPendingHostGenerationInputSnapshot:
|
||||||
context.result.getPendingHostGenerationInputSnapshot,
|
context.result.getPendingHostGenerationInputSnapshot,
|
||||||
getPendingRecallSendIntent: () => context.result.getPendingRecallSendIntent(),
|
getPendingRecallSendIntent: () => context.result.getPendingRecallSendIntent(),
|
||||||
|
|||||||
@@ -3716,6 +3716,89 @@ async function testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask()
|
|||||||
assert.equal(refreshCalls, 1);
|
assert.equal(refreshCalls, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testMessageReceivedDefersExtractionDuringHostGeneration() {
|
||||||
|
let runExtractionCalls = 0;
|
||||||
|
const deferred = [];
|
||||||
|
|
||||||
|
onMessageReceivedController(
|
||||||
|
{
|
||||||
|
getGraphPersistenceState: () => ({ loadState: "loaded", dbReady: true }),
|
||||||
|
getCurrentGraph: () => null,
|
||||||
|
getPendingRecallSendIntent: () => ({ text: "", at: 0 }),
|
||||||
|
getIsHostGenerationRunning: () => true,
|
||||||
|
isFreshRecallInputRecord: () => true,
|
||||||
|
createRecallInputRecord: () => ({ text: "", at: 0 }),
|
||||||
|
deferAutoExtraction(reason, meta = {}) {
|
||||||
|
deferred.push({
|
||||||
|
reason,
|
||||||
|
messageId: Number.isFinite(Number(meta?.messageId))
|
||||||
|
? Number(meta.messageId)
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setPendingRecallSendIntent() {},
|
||||||
|
getContext: () => ({
|
||||||
|
chat: [
|
||||||
|
{ is_user: true, mes: "u1" },
|
||||||
|
{ is_user: false, mes: "a1" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
isAssistantChatMessage(message) {
|
||||||
|
return Boolean(message) && !message.is_user && !message.is_system;
|
||||||
|
},
|
||||||
|
runExtraction: async () => {
|
||||||
|
runExtractionCalls += 1;
|
||||||
|
},
|
||||||
|
console: {
|
||||||
|
error() {},
|
||||||
|
},
|
||||||
|
notifyExtractionIssue() {},
|
||||||
|
refreshPersistedRecallMessageUi() {},
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
"assistant",
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForTick();
|
||||||
|
|
||||||
|
assert.equal(runExtractionCalls, 0);
|
||||||
|
assert.deepEqual(deferred, [
|
||||||
|
{
|
||||||
|
reason: "generation-running",
|
||||||
|
messageId: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testGenerationEndedResumesPendingAutoExtractionAfterSettle() {
|
||||||
|
const harness = await createGenerationRecallHarness();
|
||||||
|
harness.chat = [
|
||||||
|
{ is_user: true, mes: "u1" },
|
||||||
|
{ is_user: false, mes: "streaming response" },
|
||||||
|
];
|
||||||
|
harness.result.setGraphPersistenceState({
|
||||||
|
loadState: "loaded",
|
||||||
|
dbReady: true,
|
||||||
|
chatId: "chat-main",
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.result.onGenerationStarted("normal", {}, false);
|
||||||
|
harness.invokeOnMessageReceived(1, "assistant");
|
||||||
|
await waitForTick();
|
||||||
|
|
||||||
|
assert.equal(harness.runExtractionCalls.length, 0);
|
||||||
|
assert.equal(
|
||||||
|
harness.result.getPendingAutoExtraction().reason,
|
||||||
|
"generation-running",
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.result.onGenerationEnded();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 180));
|
||||||
|
|
||||||
|
assert.equal(harness.runExtractionCalls.length, 1);
|
||||||
|
harness.result.clearPendingAutoExtraction();
|
||||||
|
}
|
||||||
|
|
||||||
async function testAutoExtractionDefersWhenGraphNotReady() {
|
async function testAutoExtractionDefersWhenGraphNotReady() {
|
||||||
const deferredReasons = [];
|
const deferredReasons = [];
|
||||||
const statuses = [];
|
const statuses = [];
|
||||||
@@ -5671,6 +5754,8 @@ await testMessageSentFallsBackToLatestUserWhenHostMessageIdInvalid();
|
|||||||
await testUserMessageRenderedRefreshesRecallUiAfterRealDomRender();
|
await testUserMessageRenderedRefreshesRecallUiAfterRealDomRender();
|
||||||
await testCharacterMessageRenderedRefreshesRecallUiAfterAssistantRender();
|
await testCharacterMessageRenderedRefreshesRecallUiAfterAssistantRender();
|
||||||
await testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask();
|
await testMessageReceivedQueuesExtractionWithoutRuntimeQueueMicrotask();
|
||||||
|
await testMessageReceivedDefersExtractionDuringHostGeneration();
|
||||||
|
await testGenerationEndedResumesPendingAutoExtractionAfterSettle();
|
||||||
await testAutoExtractionDefersWhenGraphNotReady();
|
await testAutoExtractionDefersWhenGraphNotReady();
|
||||||
await testAutoExtractionDefersWhenAlreadyExtracting();
|
await testAutoExtractionDefersWhenAlreadyExtracting();
|
||||||
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
|
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
findJournalRecoveryPoint,
|
findJournalRecoveryPoint,
|
||||||
normalizeGraphRuntimeState,
|
normalizeGraphRuntimeState,
|
||||||
PROCESSED_MESSAGE_HASH_VERSION,
|
PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
|
rebindProcessedHistoryStateToChat,
|
||||||
rollbackBatch,
|
rollbackBatch,
|
||||||
snapshotProcessedMessageHashes,
|
snapshotProcessedMessageHashes,
|
||||||
} from "../runtime-state.js";
|
} from "../runtime-state.js";
|
||||||
@@ -94,6 +95,28 @@ assert.equal(migratedGraph.historyState.processedMessageHashesNeedRefresh, true)
|
|||||||
const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState);
|
const migratedDetection = detectHistoryMutation(chat, migratedGraph.historyState);
|
||||||
assert.equal(migratedDetection.dirty, false);
|
assert.equal(migratedDetection.dirty, false);
|
||||||
|
|
||||||
|
const importedGraph = normalizeGraphRuntimeState({
|
||||||
|
historyState: {
|
||||||
|
chatId: "chat-history-test",
|
||||||
|
lastProcessedAssistantFloor: 99,
|
||||||
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
||||||
|
processedMessageHashes: {},
|
||||||
|
processedMessageHashesNeedRefresh: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const reboundResult = rebindProcessedHistoryStateToChat(importedGraph, chat, [
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
]);
|
||||||
|
assert.equal(reboundResult.rebound, true);
|
||||||
|
assert.equal(reboundResult.lastProcessedAssistantFloor, 3);
|
||||||
|
assert.equal(reboundResult.clamped, true);
|
||||||
|
assert.equal(importedGraph.historyState.processedMessageHashesNeedRefresh, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
importedGraph.historyState.processedMessageHashes,
|
||||||
|
snapshotProcessedMessageHashes(chat, 3),
|
||||||
|
);
|
||||||
|
|
||||||
const truncatedChat = chat.slice(0, 2);
|
const truncatedChat = chat.slice(0, 2);
|
||||||
const truncatedDetection = detectHistoryMutation(truncatedChat, {
|
const truncatedDetection = detectHistoryMutation(truncatedChat, {
|
||||||
lastProcessedAssistantFloor: 3,
|
lastProcessedAssistantFloor: 3,
|
||||||
|
|||||||
@@ -126,6 +126,35 @@ function updateManualActionUiState(runtime, text, meta = "", level = "idle") {
|
|||||||
runtime?.refreshPanelLiveState?.();
|
runtime?.refreshPanelLiveState?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rebindImportedGraphToCurrentChat(runtime, importedGraph) {
|
||||||
|
if (!importedGraph || typeof importedGraph !== "object") {
|
||||||
|
return {
|
||||||
|
rebound: false,
|
||||||
|
reason: "missing-graph",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = runtime.getContext?.()?.chat;
|
||||||
|
const assistantTurns =
|
||||||
|
typeof runtime.getAssistantTurns === "function" && Array.isArray(chat)
|
||||||
|
? runtime.getAssistantTurns(chat)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (typeof runtime.rebindProcessedHistoryStateToChat === "function") {
|
||||||
|
return runtime.rebindProcessedHistoryStateToChat(
|
||||||
|
importedGraph,
|
||||||
|
chat,
|
||||||
|
assistantTurns,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
importedGraph.historyState.processedMessageHashesNeedRefresh = true;
|
||||||
|
return {
|
||||||
|
rebound: false,
|
||||||
|
reason: "missing-history-rebind-helper",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function onViewGraphController(runtime) {
|
export async function onViewGraphController(runtime) {
|
||||||
const graph = runtime.getCurrentGraph();
|
const graph = runtime.getCurrentGraph();
|
||||||
if (!graph) {
|
if (!graph) {
|
||||||
@@ -497,14 +526,24 @@ export async function onImportGraphController(runtime) {
|
|||||||
runtime.importGraph(text),
|
runtime.importGraph(text),
|
||||||
runtime.getCurrentChatId(),
|
runtime.getCurrentChatId(),
|
||||||
);
|
);
|
||||||
|
const historyRebind = rebindImportedGraphToCurrentChat(
|
||||||
|
runtime,
|
||||||
|
importedGraph,
|
||||||
|
);
|
||||||
runtime.setCurrentGraph(importedGraph);
|
runtime.setCurrentGraph(importedGraph);
|
||||||
runtime.markVectorStateDirty("导入图谱后需要重建向量索引");
|
runtime.markVectorStateDirty("导入图谱后需要重建向量索引");
|
||||||
runtime.setExtractionCount(0);
|
runtime.setExtractionCount(
|
||||||
|
Math.max(0, Number(importedGraph?.historyState?.extractionCount) || 0),
|
||||||
|
);
|
||||||
runtime.setLastExtractedItems([]);
|
runtime.setLastExtractedItems([]);
|
||||||
runtime.updateLastRecalledItems(importedGraph.lastRecallResult || []);
|
runtime.updateLastRecalledItems(importedGraph.lastRecallResult || []);
|
||||||
runtime.clearInjectionState();
|
runtime.clearInjectionState();
|
||||||
runtime.saveGraphToChat({ reason: "graph-import-complete" });
|
runtime.saveGraphToChat({ reason: "graph-import-complete" });
|
||||||
runtime.toastr.success("图谱已导入");
|
runtime.toastr.success(
|
||||||
|
historyRebind?.rebound === true
|
||||||
|
? "图谱已导入,并已重新绑定当前聊天历史"
|
||||||
|
: "图谱已导入",
|
||||||
|
);
|
||||||
finish({ imported: true, handledToast: true });
|
finish({ imported: true, handledToast: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error =
|
const error =
|
||||||
|
|||||||
Reference in New Issue
Block a user