mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
fix: harden auto extraction trigger flow
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
180
index.js
@@ -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 操作 ====================
|
||||
|
||||
@@ -73,7 +73,7 @@ const persistenceCore = extractSnippet(
|
||||
"function handleGraphShadowSnapshotPageHide() {",
|
||||
);
|
||||
const messageSnippet = extractSnippet(
|
||||
"function onMessageReceived() {",
|
||||
'function onMessageReceived(messageId = null, type = "") {',
|
||||
"// ==================== UI 操作 ====================",
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user