fix: harden auto extraction trigger flow

This commit is contained in:
Youzini-afk
2026-04-02 14:46:09 +08:00
parent 6f8554e11a
commit c711ff17f7
5 changed files with 297 additions and 10 deletions

View File

@@ -162,11 +162,11 @@ export function registerCoreEventHooksController(runtime) {
}
export function onChatChangedController(runtime) {
runtime.clearCoreEventBindingState?.();
runtime.clearPendingHistoryMutationChecks();
runtime.clearTimeout(runtime.getPendingHistoryRecoveryTimer());
runtime.setPendingHistoryRecoveryTimer(null);
runtime.setPendingHistoryRecoveryTrigger("");
runtime.clearPendingAutoExtraction?.();
runtime.clearPendingGraphLoadRetry();
runtime.setSkipBeforeCombineRecallUntil(0);
runtime.setLastPreGenerationRecallKey("");
@@ -452,7 +452,11 @@ export async function onBeforeCombinePromptsController(
});
}
export function onMessageReceivedController(runtime) {
export function onMessageReceivedController(
runtime,
messageId = null,
_type = "",
) {
const persistenceState = runtime.getGraphPersistenceState?.() || {};
const loadState = persistenceState.loadState || "";
const dbReady =
@@ -488,10 +492,17 @@ export function onMessageReceivedController(runtime) {
const context = runtime.getContext();
const chat = context?.chat;
const receivedMessage =
Array.isArray(chat) && Number.isFinite(Number(messageId))
? chat[Number(messageId)]
: null;
const lastMessage =
Array.isArray(chat) && chat.length > 0 ? chat[chat.length - 1] : null;
const targetMessage = runtime.isAssistantChatMessage(receivedMessage)
? receivedMessage
: lastMessage;
if (runtime.isAssistantChatMessage(lastMessage)) {
if (runtime.isAssistantChatMessage(targetMessage)) {
runtime.queueMicrotask(() => {
void runtime.runExtraction().catch((error) => {
runtime.console.error("[ST-BME] 异步自动提取失败:", error);

View File

@@ -125,11 +125,12 @@ export async function executeExtractionBatchController(
}
export async function runExtractionController(runtime) {
if (runtime.getIsExtracting() || !runtime.getCurrentGraph()) return;
if (runtime.getIsExtracting()) return;
const settings = runtime.getSettings();
if (!settings.enabled) return;
if (!runtime.ensureGraphMutationReady("自动提取", { notify: false })) {
runtime.deferAutoExtraction?.("graph-not-ready");
runtime.setLastExtractionStatus(
"等待图谱加载",
runtime.getGraphMutationBlockReason("自动提取"),
@@ -138,7 +139,17 @@ export async function runExtractionController(runtime) {
);
return;
}
if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) return;
if (!runtime.getCurrentGraph()) {
runtime.ensureCurrentGraphRuntimeState?.();
}
if (!(await runtime.recoverHistoryIfNeeded("auto-extract"))) {
if (runtime.getIsRecoveringHistory?.()) {
runtime.deferAutoExtraction?.("history-recovering");
}
return;
}
const context = runtime.getContext();
const chat = context.chat;

180
index.js
View File

@@ -470,6 +470,7 @@ const RECALL_INPUT_RECORD_TTL_MS = 60000;
const HISTORY_RECOVERY_SETTLE_MS = 80;
const HISTORY_MUTATION_RETRY_DELAYS_MS = [80, 220, 500, 900];
const GRAPH_LOAD_RETRY_DELAYS_MS = [120, 450, 1200, 2500];
const AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS = [120, 320, 800, 1600, 2800];
let runtimeStatus = createUiStatus("待命", "准备就绪", "idle");
let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle");
let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle");
@@ -491,6 +492,14 @@ let pendingHistoryRecoveryTrigger = "";
let pendingHistoryMutationCheckTimers = [];
let pendingGraphLoadRetryTimer = null;
let pendingGraphLoadRetryChatId = "";
let pendingAutoExtractionTimer = null;
let pendingAutoExtraction = {
chatId: "",
messageId: null,
reason: "",
requestedAt: 0,
attempts: 0,
};
let skipBeforeCombineRecallUntil = 0;
let lastPreGenerationRecallKey = "";
let lastPreGenerationRecallAt = 0;
@@ -810,6 +819,18 @@ function applyGraphLoadState(
dbReady,
storageMode: "indexeddb",
});
if (dbReady && isGraphLoadStateDbReady(loadState)) {
const enqueueMicrotask =
typeof globalThis.queueMicrotask === "function"
? globalThis.queueMicrotask.bind(globalThis)
: (task) => Promise.resolve().then(task);
enqueueMicrotask(() => {
if (typeof maybeResumePendingAutoExtraction === "function") {
void maybeResumePendingAutoExtraction(`graph-ready:${loadState}`);
}
});
}
}
function createAbortError(message = "操作已终止") {
@@ -3487,6 +3508,148 @@ function isGraphLoadRetryPending(chatId = getCurrentChatId()) {
);
}
function clearPendingAutoExtraction({ resetState = true } = {}) {
if (pendingAutoExtractionTimer) {
clearTimeout(pendingAutoExtractionTimer);
pendingAutoExtractionTimer = null;
}
if (resetState) {
pendingAutoExtraction = {
chatId: "",
messageId: null,
reason: "",
requestedAt: 0,
attempts: 0,
};
}
}
function deferAutoExtraction(
reason = "auto-extraction-deferred",
{ chatId = getCurrentChatId(), messageId = null, delayMs = null } = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId) {
clearPendingAutoExtraction();
return {
scheduled: false,
reason: "missing-chat-id",
chatId: "",
};
}
const sameChat = normalizedChatId === pendingAutoExtraction.chatId;
const previousAttempts = sameChat
? Math.max(0, Math.floor(Number(pendingAutoExtraction.attempts) || 0))
: 0;
const nextAttempts = previousAttempts + 1;
const resolvedDelayMs = Number.isFinite(Number(delayMs))
? Math.max(0, Math.floor(Number(delayMs)))
: AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS[
Math.min(
previousAttempts,
AUTO_EXTRACTION_DEFER_RETRY_DELAYS_MS.length - 1,
)
];
pendingAutoExtraction = {
chatId: normalizedChatId,
messageId: Number.isFinite(Number(messageId))
? Math.floor(Number(messageId))
: sameChat
? pendingAutoExtraction.messageId
: null,
reason: String(reason || "auto-extraction-deferred"),
requestedAt:
sameChat && pendingAutoExtraction.requestedAt > 0
? pendingAutoExtraction.requestedAt
: Date.now(),
attempts: nextAttempts,
};
if (pendingAutoExtractionTimer) {
clearTimeout(pendingAutoExtractionTimer);
}
pendingAutoExtractionTimer = setTimeout(() => {
pendingAutoExtractionTimer = null;
void maybeResumePendingAutoExtraction(
`retry:${pendingAutoExtraction.reason || "auto-extraction-deferred"}`,
);
}, resolvedDelayMs);
return {
scheduled: true,
chatId: normalizedChatId,
messageId: pendingAutoExtraction.messageId,
reason: pendingAutoExtraction.reason,
attempts: nextAttempts,
delayMs: resolvedDelayMs,
};
}
function maybeResumePendingAutoExtraction(source = "auto-extraction-resume") {
const pendingChatId = normalizeChatIdCandidate(pendingAutoExtraction.chatId);
if (!pendingChatId) {
return {
resumed: false,
reason: "no-pending-auto-extraction",
};
}
const currentChatId = normalizeChatIdCandidate(getCurrentChatId());
if (!currentChatId || currentChatId !== pendingChatId) {
clearPendingAutoExtraction();
return {
resumed: false,
reason: "chat-switched",
chatId: pendingChatId,
currentChatId,
};
}
if (isExtracting) {
return deferAutoExtraction("extracting", {
chatId: pendingChatId,
messageId: pendingAutoExtraction.messageId,
});
}
if (isRecoveringHistory) {
return deferAutoExtraction("history-recovering", {
chatId: pendingChatId,
messageId: pendingAutoExtraction.messageId,
});
}
if (!ensureGraphMutationReady("自动提取", { notify: false })) {
return deferAutoExtraction("graph-not-ready", {
chatId: pendingChatId,
messageId: pendingAutoExtraction.messageId,
});
}
const pendingRequest = { ...pendingAutoExtraction };
clearPendingAutoExtraction();
const enqueueMicrotask =
typeof globalThis.queueMicrotask === "function"
? globalThis.queueMicrotask.bind(globalThis)
: (task) => Promise.resolve().then(task);
enqueueMicrotask(() => {
void runExtraction().catch((error) => {
console.error("[ST-BME] 延迟自动提取失败:", error);
notifyExtractionIssue(error?.message || String(error) || "自动提取失败");
});
});
return {
resumed: true,
source,
...pendingRequest,
};
}
function isGraphEffectivelyEmpty(graph) {
if (!graph || typeof graph !== "object") {
return true;
@@ -7296,6 +7459,15 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
} finally {
finishStageAbortController("history", historyController);
isRecoveringHistory = false;
const enqueueMicrotask =
typeof globalThis.queueMicrotask === "function"
? globalThis.queueMicrotask.bind(globalThis)
: (task) => Promise.resolve().then(task);
enqueueMicrotask(() => {
if (typeof maybeResumePendingAutoExtraction === "function") {
void maybeResumePendingAutoExtraction("history-recovery-finished");
}
});
}
}
@@ -7307,6 +7479,8 @@ async function runExtraction() {
beginStageAbortController,
clampInt,
console,
deferAutoExtraction,
ensureCurrentGraphRuntimeState,
ensureGraphMutationReady,
executeExtractionBatch,
finishStageAbortController,
@@ -7315,6 +7489,7 @@ async function runExtraction() {
getCurrentGraph: () => currentGraph,
getGraphMutationBlockReason,
getIsExtracting: () => isExtracting,
getIsRecoveringHistory: () => isRecoveringHistory,
getLastProcessedAssistantFloor,
getSettings,
getSmartTriggerDecision,
@@ -7468,6 +7643,7 @@ function onChatChanged() {
clearCoreEventBindingState,
clearGenerationRecallTransactionsForChat,
clearInjectionState,
clearPendingAutoExtraction,
clearPendingGraphLoadRetry,
clearPendingHistoryMutationChecks,
clearRecallInputTracking,
@@ -7635,7 +7811,7 @@ async function onBeforeCombinePrompts(promptData = null) {
);
}
function onMessageReceived() {
function onMessageReceived(messageId = null, type = "") {
return onMessageReceivedController({
console,
createRecallInputRecord,
@@ -7660,7 +7836,7 @@ function onMessageReceived() {
setPendingRecallSendIntent: (record) => {
pendingRecallSendIntent = record;
},
});
}, messageId, type);
}
// ==================== UI 操作 ====================

View File

@@ -73,7 +73,7 @@ const persistenceCore = extractSnippet(
"function handleGraphShadowSnapshotPageHide() {",
);
const messageSnippet = extractSnippet(
"function onMessageReceived() {",
'function onMessageReceived(messageId = null, type = "") {',
"// ==================== UI 操作 ====================",
);

View File

@@ -7,11 +7,15 @@ import vm from "node:vm";
import { pruneProcessedMessageHashesFromFloor } from "../chat-history.js";
import {
onBeforeCombinePromptsController,
onChatChangedController,
onGenerationAfterCommandsController,
onGenerationStartedController,
registerCoreEventHooksController,
} from "../event-binding.js";
import { onRerollController } from "../extraction-controller.js";
import {
onRerollController,
runExtractionController,
} from "../extraction-controller.js";
import {
GRAPH_LOAD_STATES,
GRAPH_METADATA_KEY,
@@ -249,7 +253,9 @@ function createGenerationRecallHarness(options = {}) {
const { realApplyFinal = false } = options;
return fs.readFile(indexPath, "utf8").then((source) => {
const start = source.indexOf("const RECALL_INPUT_RECORD_TTL_MS = 60000;");
const end = source.indexOf("function onMessageReceived() {");
const end = source.indexOf(
'function onMessageReceived(messageId = null, type = "") {',
);
if (start < 0 || end < 0 || end <= start) {
throw new Error("无法从 index.js 提取生成召回事务定义");
}
@@ -2791,6 +2797,86 @@ async function testRegisterCoreEventHooksIsIdempotent() {
assert.equal(bindingState.registered, true);
}
async function testChatChangedDoesNotClearCoreEventBindings() {
let clearCoreBindingsCalls = 0;
let clearPendingAutoExtractionCalls = 0;
onChatChangedController({
clearCoreEventBindingState() {
clearCoreBindingsCalls += 1;
},
clearPendingHistoryMutationChecks() {},
clearTimeout() {},
getPendingHistoryRecoveryTimer: () => null,
setPendingHistoryRecoveryTimer() {},
setPendingHistoryRecoveryTrigger() {},
clearPendingAutoExtraction() {
clearPendingAutoExtractionCalls += 1;
},
clearPendingGraphLoadRetry() {},
setSkipBeforeCombineRecallUntil() {},
setLastPreGenerationRecallKey() {},
setLastPreGenerationRecallAt() {},
clearGenerationRecallTransactionsForChat() {},
abortAllRunningStages() {},
dismissAllStageNotices() {},
syncGraphLoadFromLiveContext() {},
clearInjectionState() {},
clearRecallInputTracking() {},
installSendIntentHooks() {},
refreshPersistedRecallMessageUi() {},
});
assert.equal(
clearCoreBindingsCalls,
0,
"聊天切换不应清空核心事件监听,否则后续自动链会失联",
);
assert.equal(clearPendingAutoExtractionCalls, 1);
}
async function testAutoExtractionDefersWhenGraphNotReady() {
const deferredReasons = [];
const statuses = [];
await runExtractionController({
getIsExtracting: () => false,
getCurrentGraph: () => null,
getSettings: () => ({ enabled: true }),
ensureGraphMutationReady: () => false,
deferAutoExtraction(reason) {
deferredReasons.push(reason);
},
setLastExtractionStatus(...args) {
statuses.push(args);
},
getGraphMutationBlockReason: () =>
"自动提取已暂停:正在加载 IndexedDB 图谱。",
});
assert.deepEqual(deferredReasons, ["graph-not-ready"]);
assert.equal(statuses[0]?.[0], "等待图谱加载");
}
async function testAutoExtractionDefersWhenHistoryRecoveryBusy() {
const deferredReasons = [];
await runExtractionController({
getIsExtracting: () => false,
getCurrentGraph: () => ({}),
getSettings: () => ({ enabled: true }),
ensureGraphMutationReady: () => true,
ensureCurrentGraphRuntimeState() {},
recoverHistoryIfNeeded: async () => false,
getIsRecoveringHistory: () => true,
deferAutoExtraction(reason) {
deferredReasons.push(reason);
},
});
assert.deepEqual(deferredReasons, ["history-recovering"]);
}
async function testRemoveNodeHandlesCyclicChildGraph() {
const graph = createEmptyGraph();
const nodeA = addNode(
@@ -3888,6 +3974,9 @@ await testGenerationRecallDifferentKeyCanRunAgain();
await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();
await testGenerationRecallSentMessageClearsStaleTransactionForSameKey();
await testRegisterCoreEventHooksIsIdempotent();
await testChatChangedDoesNotClearCoreEventBindings();
await testAutoExtractionDefersWhenGraphNotReady();
await testAutoExtractionDefersWhenHistoryRecoveryBusy();
await testRemoveNodeHandlesCyclicChildGraph();
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload();