Harden graph recovery and shadow persistence

This commit is contained in:
Youzini-afk
2026-04-07 17:18:18 +08:00
parent aa1d194c28
commit 854e3a7a79
11 changed files with 713 additions and 20 deletions

View File

@@ -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,
}); });
} }

View File

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

View File

@@ -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
View File

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

View File

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

View File

@@ -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" },

View File

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

View File

@@ -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(),

View File

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

View File

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

View File

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