diff --git a/.gitignore b/.gitignore index 2307ec2..90f42cf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ yarn-error.log* pnpm-debug.log* .DS_Store Thumbs.db +skip-trivial-user-input-plan.md +CLAUDE.md +AGENTS.md diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index 37da073..c0c28c7 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -1274,6 +1274,10 @@ async function runPlanningOnce(rawUserInput, silent = false, options = {}) { function getSendTextarea() { return document.getElementById('send_textarea'); } function getSendButton() { return document.getElementById('send_but') || document.getElementById('send_button'); } +function isTrivialPlannerInput(text) { + return _bmeRuntime?.isTrivialUserInput?.(text)?.trivial === true; +} + function shouldInterceptNow() { const s = ensureSettings(); if (!s.enabled || state.isPlanning) return false; @@ -1281,6 +1285,7 @@ function shouldInterceptNow() { if (!ta) return false; const txt = String(ta.value ?? '').trim(); if (!txt) return false; + if (isTrivialPlannerInput(txt)) return false; if (state.bypassNextSend) return false; if (s.skipIfPlotPresent && / TRIVIAL_GENERATION_SKIP_TTL_MS + ) { + currentGenerationTrivialSkip = null; + return null; + } + + const normalizedChatId = normalizeChatIdCandidate(chatId); + const activeChatId = normalizeChatIdCandidate( + currentGenerationTrivialSkip.chatId, + ); + if (normalizedChatId && activeChatId && normalizedChatId !== activeChatId) { + return null; + } + + return currentGenerationTrivialSkip; +} + +function markCurrentGenerationTrivialSkip({ + reason = "", + chatId = getCurrentChatId(), + chatLength = 0, +} = {}) { + currentGenerationTrivialSkip = { + chatId: normalizeChatIdCandidate(chatId), + setAtMs: Date.now(), + reason: String(reason || ""), + generationStartMinChatIndex: Math.max( + 0, + Math.floor(Number(chatLength) || 0), + ), + }; + return currentGenerationTrivialSkip; +} + +function clearCurrentGenerationTrivialSkip(_reason = "") { + const previous = currentGenerationTrivialSkip; + currentGenerationTrivialSkip = null; + return previous; +} + +function consumeCurrentGenerationTrivialSkip( + targetMessageIndex, + chatId = getCurrentChatId(), + now = Date.now(), +) { + const activeSkip = getCurrentGenerationTrivialSkip(chatId, now); + if (!activeSkip) return false; + + const normalizedTargetIndex = Number.isFinite(Number(targetMessageIndex)) + ? Math.floor(Number(targetMessageIndex)) + : null; + if (!Number.isFinite(normalizedTargetIndex)) { + return false; + } + + if ( + normalizedTargetIndex < + Math.max(0, Math.floor(Number(activeSkip.generationStartMinChatIndex) || 0)) + ) { + return false; + } + + currentGenerationTrivialSkip = null; + return true; +} + function recordRecallSendIntent(text, source = "dom-intent") { const normalized = normalizeRecallInputText(text); if (!normalized) return createRecallInputRecord(); @@ -6467,9 +6555,17 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { }; } - return buildNormalGenerationRecallInput(chat, { + const normalInput = buildNormalGenerationRecallInput(chat, { frozenInputSnapshot: params?.frozenInputSnapshot, }); + return normalInput; +} + +function createTrivialRecallSkipSentinel(reason = "") { + return { + __trivialSkip: true, + trivialReason: String(reason || ""), + }; } function buildNormalGenerationRecallInput(chat, options = {}) { @@ -6566,9 +6662,32 @@ function buildNormalGenerationRecallInput(chat, options = {}) { } : null, ].filter(Boolean); + const activeTrivialSkip = getCurrentGenerationTrivialSkip(); + if (activeTrivialSkip) { + clearPendingRecallSendIntent(); + clearPendingHostGenerationInputSnapshot(); + return createTrivialRecallSkipSentinel(activeTrivialSkip.reason); + } + const selectedCandidate = sourceCandidates[0] || null; if (!selectedCandidate?.text) return null; + const trivialInputResult = isTrivialUserInput(selectedCandidate.text); + + if (trivialInputResult.trivial) { + clearPendingRecallSendIntent(); + clearPendingHostGenerationInputSnapshot(); + markCurrentGenerationTrivialSkip({ + reason: trivialInputResult.reason, + chatId: getCurrentChatId(), + chatLength: Array.isArray(chat) ? chat.length : 0, + }); + console.info?.( + `[ST-BME] trivial-input skip: reason=${trivialInputResult.reason} len=${trivialInputResult.normalizedText.length} hook=build-normal-input`, + ); + return createTrivialRecallSkipSentinel(trivialInputResult.reason); + } + return { overrideUserMessage: selectedCandidate.text, generationType: "normal", @@ -7119,6 +7238,7 @@ function invalidateRecallAfterHistoryMutation(reason = "聊天记录已变更") clearGenerationRecallTransactionsForChat(); clearRecallInputTracking(); + clearCurrentGenerationTrivialSkip("history-mutation"); clearInjectionState({ preserveRecallStatus: hadActiveRecall, preserveRuntimeStatus: hadActiveRecall, @@ -8843,10 +8963,14 @@ async function runPlannerRecallForEna({ disableLlmRecall = false, } = {}) { const userMessage = normalizeRecallInputText(rawUserInput || ""); - if (!userMessage) { + const trivialInputResult = isTrivialUserInput(userMessage); + if (trivialInputResult.trivial) { + console.info?.( + `[ST-BME] trivial-input skip: reason=${trivialInputResult.reason} len=${trivialInputResult.normalizedText.length} hook=ena-planner`, + ); return { ok: false, - reason: "empty-user-input", + reason: `trivial-user-input:${trivialInputResult.reason}`, memoryBlock: "", recentMessages: [], result: null, @@ -9018,6 +9142,7 @@ function onChatChanged() { clearPendingAutoExtraction, clearPendingGraphLoadRetry, clearPendingHistoryMutationChecks, + clearCurrentGenerationTrivialSkip, clearRecallInputTracking, clearTimeout, dismissAllStageNotices, @@ -9090,6 +9215,7 @@ function onMessageSent(messageId) { const result = onMessageSentController( { getContext, + isTrivialUserInput, recordRecallSentUserMessage, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, }, @@ -9173,11 +9299,17 @@ function onGenerationStarted(type, params = {}, dryRun = false) { return onGenerationStartedController( { clearDryRunPromptPreview, + clearCurrentGenerationTrivialSkip, + clearPendingHostGenerationInputSnapshot, + clearPendingRecallSendIntent, freezeHostGenerationInputSnapshot, + getContext, getPendingRecallSendIntent: () => pendingRecallSendIntent, getSendTextareaValue, isFreshRecallInputRecord, + isTrivialUserInput, markDryRunPromptPreview, + markCurrentGenerationTrivialSkip, normalizeRecallInputText, }, type, @@ -9254,6 +9386,7 @@ async function onBeforeCombinePrompts(promptData = null) { function onMessageReceived(messageId = null, type = "") { const result = onMessageReceivedController({ console, + consumeCurrentGenerationTrivialSkip, createRecallInputRecord, getContext, getCurrentGraph: () => currentGraph, @@ -9699,6 +9832,7 @@ async function onReembedDirect() { await initEnaPlanner({ getContext, getExtensionPath: () => `scripts/extensions/third-party/${MODULE_NAME}`, + isTrivialUserInput, preparePlannerRecallHandoff, runPlannerRecallForEna, }); diff --git a/package.json b/package.json index 92d85e1..01950b4 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,10 @@ "test:indexeddb-persistence": "node tests/indexeddb-persistence.mjs", "test:indexeddb-sync": "node tests/indexeddb-sync.mjs", "test:indexeddb-migration": "node tests/indexeddb-migration.mjs", + "test:trivial-input": "node tests/trivial-user-input.mjs", "test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration", "test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb", - "test:all": "npm run test:persistence-matrix", + "test:all": "npm run test:persistence-matrix && npm run test:trivial-input", "check": "node --check index.js && node --check bme-db.js && node --check hide-engine.js && node --check panel.js && node --check ui-status.js && node --check event-binding.js" }, "dependencies": { diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs new file mode 100644 index 0000000..215781a --- /dev/null +++ b/tests/helpers/generation-recall-harness.mjs @@ -0,0 +1,363 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; +import { + onBeforeCombinePromptsController, + onGenerationAfterCommandsController, + onGenerationStartedController, + onMessageReceivedController, + onMessageSentController, +} from "../../event-binding.js"; +import { + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + MODULE_NAME, +} from "../../graph-persistence.js"; +import { + buildPersistedRecallRecord, + bumpPersistedRecallGenerationCount, + readPersistedRecallFromUserMessage, + resolveFinalRecallInjectionSource, + writePersistedRecallToUserMessage, +} from "../../recall-persistence.js"; +import { + createGraphPersistenceState, + createRecallInputRecord, + createRecallRunResult, + createUiStatus, + getGenerationRecallHookStateFromResult, + getRecallHookLabel, + getStageNoticeDuration, + getStageNoticeTitle, + hashRecallInput, + isFreshRecallInputRecord, + isTerminalGenerationRecallHookState, + isTrivialUserInput, + normalizeRecallInputText, + normalizeStageNoticeLevel, + shouldRunRecallForTransaction, +} from "../../ui-status.js"; + +const moduleDir = path.dirname(fileURLToPath(import.meta.url)); +const indexPath = path.resolve(moduleDir, "../../index.js"); + +export 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(messageId = null, type = "") {', + ); + const endFallback = source.indexOf("async function runExtraction()"); + const resolvedEnd = end >= 0 ? end : endFallback; + if (start < 0 || resolvedEnd < 0 || resolvedEnd <= start) { + throw new Error("无法从 index.js 提取生成召回事务定义"); + } + const snippet = source + .slice(start, resolvedEnd) + .replace(/^export\s+/gm, ""); + const context = { + console, + Date, + Map, + setTimeout, + clearTimeout, + __sendTextareaValue: "", + document: { + getElementById(id) { + if ( + id === "send_textarea" && + typeof context.__sendTextareaValue === "string" && + context.__sendTextareaValue + ) { + return { value: context.__sendTextareaValue }; + } + return null; + }, + }, + result: null, + currentGraph: {}, + _panelModule: null, + defaultSettings: {}, + settings: {}, + graphPersistenceState: createGraphPersistenceState(), + extension_settings: { [MODULE_NAME]: {} }, + extension_prompt_types: { + NONE: 0, + BEFORE_PROMPT: 1, + IN_PROMPT: 2, + IN_CHAT: 3, + }, + extension_prompt_roles: { + SYSTEM: 0, + USER: 1, + ASSISTANT: 2, + }, + clampInt: (value, fallback = 0, min = 0, max = 9999) => { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); + }, + getHostAdapter: () => null, + migrateLegacyTaskProfiles: (settings = {}) => ({ + taskProfilesVersion: settings?.taskProfilesVersion || 0, + taskProfiles: settings?.taskProfiles || {}, + }), + refreshPanelLiveStateController: () => { + context.refreshPanelCalls += 1; + }, + isRecalling: false, + getCurrentChatId: () => "chat-main", + normalizeRecallInputText: (text = "") => String(text || "").trim(), + isTrivialUserInput, + getLatestUserChatMessage: (chat = []) => + [...chat].reverse().find((message) => message?.is_user) || null, + getLastNonSystemChatMessage: (chat = []) => + [...chat].reverse().find((message) => !message?.is_system) || null, + getSendTextareaValue: () => context.__sendTextareaValue, + getRecallUserMessageSourceLabel: (source = "") => source, + getRecallUserMessageSourceLabelController: (source = "") => source, + buildRecallRecentMessages: ( + chat = [], + _limit, + syntheticUserMessage = "", + ) => + syntheticUserMessage + ? [...chat, { is_user: true, mes: syntheticUserMessage }] + : [...chat], + getContext: () => ({ + chatId: "chat-main", + chat: context.chat, + }), + chat: [], + runRecallCalls: [], + runExtractionCalls: [], + extractionIssues: [], + applyFinalCalls: [], + moduleInjectionCalls: [], + recordedInjectionSnapshots: [], + refreshPanelCalls: 0, + hideScheduleCalls: [], + createRecallInputRecord, + createRecallRunResult, + hashRecallInput, + isFreshRecallInputRecord, + isTerminalGenerationRecallHookState, + shouldRunRecallForTransaction, + getGenerationRecallHookStateFromResult, + createUiStatus, + createGraphPersistenceState, + getRecallHookLabel, + getStageNoticeTitle, + getStageNoticeDuration, + normalizeStageNoticeLevel, + MODULE_NAME, + GRAPH_LOAD_STATES, + GRAPH_METADATA_KEY, + GRAPH_PERSISTENCE_META_KEY, + onBeforeCombinePromptsController, + onGenerationAfterCommandsController, + onGenerationStartedController, + readPersistedRecallFromUserMessage, + writePersistedRecallToUserMessage, + buildPersistedRecallRecord, + resolveFinalRecallInjectionSource, + bumpPersistedRecallGenerationCount, + applyModuleInjectionPrompt: (text = "") => { + const normalizedText = String(text || ""); + context.moduleInjectionCalls.push(normalizedText); + return { + applied: Boolean(normalizedText.trim()), + source: normalizedText.trim() ? "module-injection" : "rewrite-clear", + mode: normalizedText.trim() ? "module-injection" : "rewrite-clear", + }; + }, + getSettings: () => context.settings, + $: () => ({}), + triggerChatMetadataSave: () => { + context.metadataSaveCalls += 1; + return "debounced"; + }, + refreshPanelLiveState: () => { + context.refreshPanelCalls += 1; + }, + recordInjectionSnapshot: (_kind, snapshot = {}) => { + context.recordedInjectionSnapshots.push({ ...snapshot }); + }, + schedulePersistedRecallMessageUiRefresh: () => { + context.recallUiRefreshCalls += 1; + }, + getMessageHideSettings: () => ({}), + getHideRuntimeAdapters: () => ({}), + scheduleHideSettingsApply: (...args) => { + context.hideScheduleCalls.push(args); + }, + estimateTokens: (text = "") => + normalizeRecallInputText(text) + .split(/\s+/) + .filter(Boolean).length || (normalizeRecallInputText(text) ? 1 : 0), + resolveGenerationTargetUserMessageIndex: ( + chat = [], + { generationType } = {}, + ) => { + const normalized = String(generationType || "normal"); + if (!Array.isArray(chat) || chat.length === 0) return null; + if (normalized === "normal") + return chat[chat.length - 1]?.is_user ? chat.length - 1 : null; + for (let index = chat.length - 1; index >= 0; index--) + if (chat[index]?.is_user) return index; + return null; + }, + metadataSaveCalls: 0, + recallUiRefreshCalls: 0, + }; + vm.createContext(context); + 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; } };`, + context, + { filename: indexPath }, + ); + Object.defineProperties(context, { + pendingRecallSendIntent: { + get() { + return context.result.getPendingRecallSendIntent(); + }, + set(value) { + if (value?.text) { + context.result.recordRecallSendIntent( + value?.text || "", + value?.source, + ); + return; + } + context.result.clearPendingRecallSendIntent(); + }, + configurable: true, + }, + lastRecallSentUserMessage: { + get() { + return context.result.getLastRecallSentUserMessage(); + }, + set(value) { + context.result.recordRecallSentUserMessage( + value?.messageId, + value?.text || "", + value?.source, + ); + }, + configurable: true, + }, + }); + const originalApplyFinalRecallInjectionForGeneration = + context.result.applyFinalRecallInjectionForGeneration; + context.applyFinalRecallInjectionForGeneration = (payload = {}) => { + context.applyFinalCalls.push({ ...payload }); + if (realApplyFinal) { + return originalApplyFinalRecallInjectionForGeneration(payload); + } + return { + source: "fresh", + targetUserMessageIndex: null, + }; + }; + context.runRecall = async (options = {}) => { + context.runRecallCalls.push({ ...options }); + const overrideUserMessage = String( + options.overrideUserMessage || options.userMessage || "", + ); + return { + status: "completed", + didRecall: true, + ok: true, + injectionText: `注入:${overrideUserMessage}`, + deliveryMode: String(options.deliveryMode || "immediate"), + source: options.overrideSource, + sourceLabel: options.overrideSourceLabel, + reason: options.overrideReason, + sourceCandidates: Array.isArray(options.sourceCandidates) + ? options.sourceCandidates.map((candidate) => ({ ...candidate })) + : [], + selectedNodeIds: ["node-test-1"], + retrievalMeta: { + vectorHits: 1, + vectorMergedHits: 0, + diffusionHits: 0, + candidatePoolAfterDpp: 1, + }, + llmMeta: { + status: "disabled", + reason: "test-disabled", + candidatePool: 0, + }, + stats: { + coreCount: 1, + recallCount: 1, + }, + }; + }; + context.runExtraction = async (...args) => { + context.runExtractionCalls.push(args); + return { + ok: true, + }; + }; + context.invokeOnMessageSent = (messageId = null) => + onMessageSentController( + { + getContext: context.getContext, + isTrivialUserInput, + recordRecallSentUserMessage: context.result.recordRecallSentUserMessage, + refreshPersistedRecallMessageUi: () => { + context.recallUiRefreshCalls += 1; + }, + }, + messageId, + ); + context.invokeOnMessageReceived = (messageId = null, type = "") => + onMessageReceivedController( + { + console, + consumeCurrentGenerationTrivialSkip: + context.result.consumeCurrentGenerationTrivialSkip, + createRecallInputRecord, + getContext: context.getContext, + getCurrentGraph: () => context.currentGraph, + getGraphPersistenceState: () => context.result.getGraphPersistenceState(), + getPendingHostGenerationInputSnapshot: + context.result.getPendingHostGenerationInputSnapshot, + getPendingRecallSendIntent: () => context.result.getPendingRecallSendIntent(), + isAssistantChatMessage: (message) => + Boolean(message) && !message.is_user && !message.is_system, + isFreshRecallInputRecord, + isGraphMetadataWriteAllowed: () => true, + syncGraphLoadFromLiveContext: () => {}, + maybeCaptureGraphShadowSnapshot: () => {}, + maybeFlushQueuedGraphPersist: () => {}, + notifyExtractionIssue: (message) => { + context.extractionIssues.push(String(message || "")); + }, + queueMicrotask: (task) => task(), + runExtraction: context.runExtraction, + refreshPersistedRecallMessageUi: () => { + context.recallUiRefreshCalls += 1; + }, + setPendingHostGenerationInputSnapshot: () => {}, + setPendingRecallSendIntent: (record) => { + if (record?.text) { + context.result.recordRecallSendIntent( + record.text || "", + record.source, + ); + return; + } + context.result.clearPendingRecallSendIntent(); + }, + }, + messageId, + type, + ); + return context; + }); +} diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index b668893..77caf93 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -66,6 +66,7 @@ import { onManualEvolveController, onManualSleepController, } from "../ui-actions-controller.js"; +import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs"; const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); const extensionsShimSource = [ @@ -281,255 +282,6 @@ function createBatchStageHarness() { }); } -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(messageId = null, type = "") {', - ); - const resolvedEnd = end >= 0 ? end : endFallback; - if (start < 0 || resolvedEnd < 0 || resolvedEnd <= start) { - throw new Error("无法从 index.js 提取生成召回事务定义"); - } - const snippet = source - .slice(start, resolvedEnd) - .replace(/^export\s+/gm, ""); - const context = { - console, - Date, - Map, - setTimeout, - clearTimeout, - __sendTextareaValue: "", - document: { - getElementById(id) { - if ( - id === "send_textarea" && - typeof context.__sendTextareaValue === "string" && - context.__sendTextareaValue - ) { - return { value: context.__sendTextareaValue }; - } - return null; - }, - }, - result: null, - currentGraph: {}, - _panelModule: null, - defaultSettings: {}, - extension_settings: { [MODULE_NAME]: {} }, - extension_prompt_types: { - NONE: 0, - BEFORE_PROMPT: 1, - IN_PROMPT: 2, - IN_CHAT: 3, - }, - extension_prompt_roles: { - SYSTEM: 0, - USER: 1, - ASSISTANT: 2, - }, - clampInt: (value, fallback = 0, min = 0, max = 9999) => { - const numeric = Number(value); - if (!Number.isFinite(numeric)) return fallback; - return Math.min(max, Math.max(min, Math.trunc(numeric))); - }, - getHostAdapter: () => null, - migrateLegacyTaskProfiles: (settings = {}) => ({ - taskProfilesVersion: settings?.taskProfilesVersion || 0, - taskProfiles: settings?.taskProfiles || {}, - }), - refreshPanelLiveStateController: () => { - context.refreshPanelCalls += 1; - }, - isRecalling: false, - getCurrentChatId: () => "chat-main", - normalizeRecallInputText: (text = "") => String(text || "").trim(), - getLatestUserChatMessage: (chat = []) => - [...chat].reverse().find((message) => message?.is_user) || null, - getLastNonSystemChatMessage: (chat = []) => - [...chat].reverse().find((message) => !message?.is_system) || null, - getSendTextareaValue: () => context.__sendTextareaValue, - getRecallUserMessageSourceLabel: (source = "") => source, - getRecallUserMessageSourceLabelController: (source = "") => source, - buildRecallRecentMessages: ( - chat = [], - _limit, - syntheticUserMessage = "", - ) => - syntheticUserMessage - ? [...chat, { is_user: true, mes: syntheticUserMessage }] - : [...chat], - getContext: () => ({ - chatId: "chat-main", - chat: context.chat, - }), - chat: [], - runRecallCalls: [], - applyFinalCalls: [], - moduleInjectionCalls: [], - recordedInjectionSnapshots: [], - refreshPanelCalls: 0, - hideScheduleCalls: [], - createRecallInputRecord, - createRecallRunResult, - hashRecallInput, - normalizeRecallInputText, - isFreshRecallInputRecord, - isTerminalGenerationRecallHookState, - shouldRunRecallForTransaction, - getGenerationRecallHookStateFromResult, - createUiStatus, - createGraphPersistenceState, - getRecallHookLabel, - getStageNoticeTitle, - getStageNoticeDuration, - normalizeStageNoticeLevel, - MODULE_NAME, - GRAPH_LOAD_STATES, - GRAPH_METADATA_KEY, - GRAPH_PERSISTENCE_META_KEY, - onBeforeCombinePromptsController, - onGenerationAfterCommandsController, - onGenerationStartedController, - readPersistedRecallFromUserMessage, - writePersistedRecallToUserMessage, - buildPersistedRecallRecord, - resolveFinalRecallInjectionSource, - bumpPersistedRecallGenerationCount, - applyModuleInjectionPrompt: (text = "") => { - const normalizedText = String(text || ""); - context.moduleInjectionCalls.push(normalizedText); - return { - applied: Boolean(normalizedText.trim()), - source: normalizedText.trim() ? "module-injection" : "rewrite-clear", - mode: normalizedText.trim() ? "module-injection" : "rewrite-clear", - }; - }, - getSettings: () => ({}), - $: () => ({}), - triggerChatMetadataSave: () => { - context.metadataSaveCalls += 1; - return "debounced"; - }, - refreshPanelLiveState: () => { - context.refreshPanelCalls += 1; - }, - recordInjectionSnapshot: (_kind, snapshot = {}) => { - context.recordedInjectionSnapshots.push({ ...snapshot }); - }, - schedulePersistedRecallMessageUiRefresh: () => { - context.recallUiRefreshCalls += 1; - }, - getMessageHideSettings: () => ({}), - getHideRuntimeAdapters: () => ({}), - scheduleHideSettingsApply: (...args) => { - context.hideScheduleCalls.push(args); - }, - estimateTokens: (text = "") => - normalizeRecallInputText(text) - .split(/\s+/) - .filter(Boolean).length || (normalizeRecallInputText(text) ? 1 : 0), - resolveGenerationTargetUserMessageIndex: ( - chat = [], - { generationType } = {}, - ) => { - const normalized = String(generationType || "normal"); - if (!Array.isArray(chat) || chat.length === 0) return null; - if (normalized === "normal") - return chat[chat.length - 1]?.is_user ? chat.length - 1 : null; - for (let index = chat.length - 1; index >= 0; index--) - if (chat[index]?.is_user) return index; - return null; - }, - metadataSaveCalls: 0, - recallUiRefreshCalls: 0, - }; - vm.createContext(context); - vm.runInContext( - `${snippet}\nresult = { hashRecallInput, buildPreGenerationRecallKey, buildGenerationAfterCommandsRecallInput, cleanupGenerationRecallTransactions, buildGenerationRecallTransactionId, beginGenerationRecallTransaction, markGenerationRecallTransactionHookState, shouldRunRecallForTransaction, createGenerationRecallContext, onGenerationStarted, onGenerationEnded, onGenerationAfterCommands, onBeforeCombinePrompts, applyFinalRecallInjectionForGeneration, ensurePersistedRecallRecordForGeneration, findRecentGenerationRecallTransactionForChat, getGenerationRecallTransactionResult, generationRecallTransactions, freezeHostGenerationInputSnapshot, consumeHostGenerationInputSnapshot, getPendingHostGenerationInputSnapshot, recordRecallSendIntent, recordRecallSentUserMessage, getPendingRecallSendIntent: () => pendingRecallSendIntent, getLastRecallSentUserMessage: () => lastRecallSentUserMessage };`, - context, - { filename: indexPath }, - ); - Object.defineProperties(context, { - pendingRecallSendIntent: { - get() { - return context.result.getPendingRecallSendIntent(); - }, - set(value) { - context.result.recordRecallSendIntent( - value?.text || "", - value?.source, - ); - }, - configurable: true, - }, - lastRecallSentUserMessage: { - get() { - return context.result.getLastRecallSentUserMessage(); - }, - set(value) { - context.result.recordRecallSentUserMessage( - value?.messageId, - value?.text || "", - value?.source, - ); - }, - configurable: true, - }, - }); - const originalApplyFinalRecallInjectionForGeneration = - context.result.applyFinalRecallInjectionForGeneration; - context.applyFinalRecallInjectionForGeneration = (payload = {}) => { - context.applyFinalCalls.push({ ...payload }); - if (realApplyFinal) { - return originalApplyFinalRecallInjectionForGeneration(payload); - } - return { - source: "fresh", - targetUserMessageIndex: null, - }; - }; - context.runRecall = async (options = {}) => { - context.runRecallCalls.push({ ...options }); - const overrideUserMessage = String( - options.overrideUserMessage || options.userMessage || "", - ); - return { - status: "completed", - didRecall: true, - ok: true, - injectionText: `注入:${overrideUserMessage}`, - deliveryMode: String(options.deliveryMode || "immediate"), - source: options.overrideSource, - sourceLabel: options.overrideSourceLabel, - reason: options.overrideReason, - sourceCandidates: Array.isArray(options.sourceCandidates) - ? options.sourceCandidates.map((candidate) => ({ ...candidate })) - : [], - selectedNodeIds: ["node-test-1"], - retrievalMeta: { - vectorHits: 1, - vectorMergedHits: 0, - diffusionHits: 0, - candidatePoolAfterDpp: 1, - }, - llmMeta: { - status: "disabled", - reason: "test-disabled", - candidatePool: 0, - }, - stats: { - coreCount: 1, - recallCount: 1, - }, - }; - }; - return context; - }); -} - function createHistoryRecoveryHarness() { return fs.readFile(indexPath, "utf8").then((source) => { const start = source.indexOf("async function recoverHistoryIfNeeded("); diff --git a/tests/runtime-history.mjs b/tests/runtime-history.mjs index 79160d7..f0d0981 100644 --- a/tests/runtime-history.mjs +++ b/tests/runtime-history.mjs @@ -22,12 +22,14 @@ const chat = [ const hashes = snapshotProcessedMessageHashes(chat, 3); const cleanDetection = detectHistoryMutation(chat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: hashes, }); assert.equal(cleanDetection.dirty, false); const missingHashesDetection = detectHistoryMutation(chat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: {}, }); assert.equal(missingHashesDetection.dirty, true); @@ -35,6 +37,7 @@ assert.equal(missingHashesDetection.earliestAffectedFloor, 0); const sparseHashesDetection = detectHistoryMutation(chat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: { 0: hashes[0], 2: hashes[2], @@ -48,6 +51,7 @@ const editedChat = structuredClone(chat); editedChat[1].mes = "我改过内容了。"; const editedDetection = detectHistoryMutation(editedChat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: hashes, }); assert.equal(editedDetection.dirty, true); @@ -58,6 +62,7 @@ bmeHiddenChat[1].is_system = true; bmeHiddenChat[1].extra = { __st_bme_hide_managed: true }; const bmeHiddenDetection = detectHistoryMutation(bmeHiddenChat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: hashes, }); assert.equal(bmeHiddenDetection.dirty, false); @@ -66,6 +71,7 @@ const realSystemFlipChat = structuredClone(chat); realSystemFlipChat[1].is_system = true; const realSystemFlipDetection = detectHistoryMutation(realSystemFlipChat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: hashes, }); assert.equal(realSystemFlipDetection.dirty, false); @@ -91,6 +97,7 @@ assert.equal(migratedDetection.dirty, false); const truncatedChat = chat.slice(0, 2); const truncatedDetection = detectHistoryMutation(truncatedChat, { lastProcessedAssistantFloor: 3, + processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION, processedMessageHashes: hashes, }); assert.equal(truncatedDetection.dirty, true); diff --git a/tests/trivial-user-input.mjs b/tests/trivial-user-input.mjs new file mode 100644 index 0000000..ba8402f --- /dev/null +++ b/tests/trivial-user-input.mjs @@ -0,0 +1,299 @@ +// wired into npm run test:all +import assert from "node:assert/strict"; +import { MODULE_NAME } from "../graph-persistence.js"; +import { isTrivialUserInput } from "../ui-status.js"; +import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs"; + +function assertEmptyRecallInputRecord(record) { + assert.deepEqual(record, { + text: "", + hash: "", + messageId: null, + source: "", + at: 0, + }); +} + +function testIsTrivialUserInputTable() { + const cases = [ + ["", true, "empty"], + [" \n\t ", true, "empty"], + ["/echo hello", true, "slash-command"], + ["/", true, "slash-command"], + [" /echo", true, "slash-command"], + ["a", true, "under-min-tokens"], + ["好", true, "under-min-tokens"], + ["ok", true, "under-min-tokens"], + ["ok a", false, ""], + ["好的", false, ""], + ["好的呀", false, ""], + ["hello world", false, ""], + ["你好", false, ""], + ]; + + for (const [input, trivial, reason] of cases) { + const result = isTrivialUserInput(input); + assert.equal(result.trivial, trivial, `trivial mismatch for ${JSON.stringify(input)}`); + assert.equal(result.reason, reason, `reason mismatch for ${JSON.stringify(input)}`); + } +} + +async function testSlashCommandSkipsRecallAndExtraction() { + const harness = await createGenerationRecallHarness(); + harness.chat = []; + harness.__sendTextareaValue = "/echo test"; + + const startResult = harness.result.onGenerationStarted("normal", {}, false); + assert.equal(startResult, null); + assertEmptyRecallInputRecord(harness.result.getPendingHostGenerationInputSnapshot()); + assertEmptyRecallInputRecord(harness.pendingRecallSendIntent); + assert.equal( + harness.result.getCurrentGenerationTrivialSkip()?.generationStartMinChatIndex, + 0, + ); + + await harness.result.onGenerationAfterCommands("normal", {}, false); + assert.equal(harness.runRecallCalls.length, 0); + + const beforeCombine = await harness.result.onBeforeCombinePrompts(); + assert.deepEqual(beforeCombine, { + skipped: true, + reason: "trivial:slash-command", + }); + assert.equal(harness.runRecallCalls.length, 0); + + harness.chat.push({ is_user: false, mes: "assistant reply" }); + harness.invokeOnMessageReceived(0, ""); + assert.equal(harness.runExtractionCalls.length, 0); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); +} + +async function testUnderMinTokensSkipsRecallAndExtraction() { + const harness = await createGenerationRecallHarness(); + harness.chat = []; + harness.__sendTextareaValue = "a"; + + const startResult = harness.result.onGenerationStarted("normal", {}, false); + assert.equal(startResult, null); + assert.equal( + harness.result.getCurrentGenerationTrivialSkip()?.reason, + "under-min-tokens", + ); + + await harness.result.onGenerationAfterCommands("normal", {}, false); + assert.equal(harness.runRecallCalls.length, 0); + + harness.chat.push({ is_user: false, mes: "assistant reply" }); + harness.invokeOnMessageReceived(0, ""); + assert.equal(harness.runExtractionCalls.length, 0); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); +} + +async function testEmptyInputSkipsPriorHistoryFallback() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "older real user message" }]; + harness.__sendTextareaValue = " "; + + const startResult = harness.result.onGenerationStarted("normal", {}, false); + assert.equal(startResult, null); + assert.equal( + harness.result.getCurrentGenerationTrivialSkip()?.reason, + "empty", + ); + + await harness.result.onGenerationAfterCommands("normal", {}, false); + assert.equal(harness.runRecallCalls.length, 0); + + const beforeCombine = await harness.result.onBeforeCombinePrompts(); + assert.deepEqual(beforeCombine, { + skipped: true, + reason: "trivial:empty", + }); +} + +async function testNormalInputStillRecalls() { + const harness = await createGenerationRecallHarness(); + harness.chat = []; + harness.__sendTextareaValue = "好的呀"; + + const snapshot = harness.result.onGenerationStarted("normal", {}, false); + assert.equal(snapshot?.text, "好的呀"); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); + + const beforeCombine = await harness.result.onBeforeCombinePrompts(); + assert.equal(beforeCombine?.source, "fresh"); + assert.equal(harness.runRecallCalls.length, 1); + assert.equal(harness.runRecallCalls[0].overrideUserMessage, "好的呀"); +} + +async function testSentinelBlocksHistoryFallback() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "真实旧消息" }]; + harness.pendingRecallSendIntent = { + text: "/echo hidden", + source: "send-button", + at: Date.now(), + }; + + const beforeCombine = await harness.result.onBeforeCombinePrompts(); + assert.deepEqual(beforeCombine, { + skipped: true, + reason: "trivial:slash-command", + }); + assert.equal(harness.runRecallCalls.length, 0); +} + +async function testAfterCommandsTrivialSentinelMarksExtractionBypass() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "/echo from chat tail" }]; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + assert.equal(harness.runRecallCalls.length, 0); + assert.equal( + harness.result.getCurrentGenerationTrivialSkip()?.generationStartMinChatIndex, + 1, + ); + + harness.chat.push({ is_user: false, mes: "assistant after bypass flag" }); + harness.invokeOnMessageReceived(1, ""); + assert.equal(harness.runExtractionCalls.length, 0); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); +} + +async function testPlannerRecallTrivialAndNonTrivialPaths() { + const harness = await createGenerationRecallHarness(); + + let recall = await harness.result.runPlannerRecallForEna({ + rawUserInput: "", + }); + assert.equal(recall.reason, "trivial-user-input:empty"); + + recall = await harness.result.runPlannerRecallForEna({ + rawUserInput: "/echo", + }); + assert.equal(recall.reason, "trivial-user-input:slash-command"); + + harness.extension_settings[MODULE_NAME] = { + enabled: true, + recallEnabled: true, + }; + harness.result.setGraphPersistenceState({ + loadState: "loaded", + dbReady: true, + }); + harness.currentGraph = { + nodes: [], + edges: [], + historyState: {}, + }; + recall = await harness.result.runPlannerRecallForEna({ + rawUserInput: "好的呀", + }); + assert.equal(recall.reason, "graph-empty"); +} + +async function testOnMessageSentSkipsTrivialText() { + const harness = await createGenerationRecallHarness(); + harness.chat = [{ is_user: true, mes: "/echo" }]; + + harness.invokeOnMessageSent(0); + + assert.equal(harness.lastRecallSentUserMessage.text, ""); +} + +async function testNonTrivialGenerationClearsResidualTrivialSkip() { + const harness = await createGenerationRecallHarness(); + harness.chat = []; + harness.__sendTextareaValue = "/echo"; + harness.result.onGenerationStarted("normal", {}, false); + assert.ok(harness.result.getCurrentGenerationTrivialSkip()); + + harness.__sendTextareaValue = "hello world"; + const snapshot = harness.result.onGenerationStarted("normal", {}, false); + assert.equal(snapshot?.text, "hello world"); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); + + harness.chat.push({ is_user: false, mes: "assistant after non-trivial" }); + harness.invokeOnMessageReceived(0, ""); + await Promise.resolve(); + assert.equal(harness.runExtractionCalls.length, 1); +} + +async function testNonTargetMessageIdDoesNotConsumeFlag() { + const harness = await createGenerationRecallHarness(); + harness.chat = [ + { is_user: true, mes: "u0" }, + { is_user: false, mes: "a1" }, + { is_user: true, mes: "u2" }, + { is_user: false, mes: "old assistant" }, + { is_user: true, mes: "u4" }, + ]; + harness.__sendTextareaValue = "/echo"; + harness.result.onGenerationStarted("normal", {}, false); + assert.equal( + harness.result.getCurrentGenerationTrivialSkip()?.generationStartMinChatIndex, + 5, + ); + + harness.invokeOnMessageReceived(3, ""); + await Promise.resolve(); + assert.equal(harness.runExtractionCalls.length, 1); + assert.ok(harness.result.getCurrentGenerationTrivialSkip()); + + harness.chat.push({ is_user: false, mes: "target assistant" }); + harness.invokeOnMessageReceived(5, ""); + assert.equal(harness.runExtractionCalls.length, 1); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); +} + +async function testNullMessageIdFallsBackToLastAssistantIndex() { + const harness = await createGenerationRecallHarness(); + harness.chat = [ + { is_user: true, mes: "u0" }, + { is_user: false, mes: "a1" }, + { is_user: true, mes: "u2" }, + { is_user: false, mes: "a3" }, + { is_user: true, mes: "u4" }, + ]; + harness.__sendTextareaValue = "/echo"; + harness.result.onGenerationStarted("normal", {}, false); + + harness.chat.push({ is_user: false, mes: "latest assistant" }); + harness.invokeOnMessageReceived(null, ""); + assert.equal(harness.runExtractionCalls.length, 0); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); +} + +async function testSkipFlagTtlExpires() { + const harness = await createGenerationRecallHarness(); + harness.result.markCurrentGenerationTrivialSkip({ + reason: "slash-command", + chatId: "chat-main", + chatLength: 2, + }); + const originalNow = Date.now; + Date.now = () => originalNow() + 60001; + try { + assert.equal(harness.result.consumeCurrentGenerationTrivialSkip(2), false); + assert.equal(harness.result.getCurrentGenerationTrivialSkip(), null); + } finally { + Date.now = originalNow; + } +} + +await Promise.resolve(); +testIsTrivialUserInputTable(); +await testSlashCommandSkipsRecallAndExtraction(); +await testUnderMinTokensSkipsRecallAndExtraction(); +await testEmptyInputSkipsPriorHistoryFallback(); +await testNormalInputStillRecalls(); +await testSentinelBlocksHistoryFallback(); +await testAfterCommandsTrivialSentinelMarksExtractionBypass(); +await testPlannerRecallTrivialAndNonTrivialPaths(); +await testOnMessageSentSkipsTrivialText(); +await testNonTrivialGenerationClearsResidualTrivialSkip(); +await testNonTargetMessageIdDoesNotConsumeFlag(); +await testNullMessageIdFallsBackToLastAssistantIndex(); +await testSkipFlagTtlExpires(); + +console.log("trivial-user-input tests passed"); diff --git a/ui-status.js b/ui-status.js index 4bd9634..0bfe7da 100644 --- a/ui-status.js +++ b/ui-status.js @@ -323,6 +323,56 @@ export function normalizeRecallInputText(value) { .trim(); } +const TRIVIAL_INPUT_MIN_TOKENS = 2; +const TRIVIAL_INPUT_CJK_TOKEN_REGEX = + /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/gu; + +function estimateTrivialInputTokens(text = "") { + const normalized = normalizeRecallInputText(text); + if (!normalized) return 0; + + const cjkMatches = normalized.match(TRIVIAL_INPUT_CJK_TOKEN_REGEX) || []; + const nonCjkText = normalized.replace(TRIVIAL_INPUT_CJK_TOKEN_REGEX, " "); + const wordTokens = nonCjkText + .split(/\s+/) + .filter(Boolean); + + return cjkMatches.length + wordTokens.length; +} + +export function isTrivialUserInput(text) { + const normalizedText = normalizeRecallInputText(text); + if (!normalizedText) { + return { + trivial: true, + reason: "empty", + normalizedText, + }; + } + + if (normalizedText.startsWith("/")) { + return { + trivial: true, + reason: "slash-command", + normalizedText, + }; + } + + if (estimateTrivialInputTokens(normalizedText) < TRIVIAL_INPUT_MIN_TOKENS) { + return { + trivial: true, + reason: "under-min-tokens", + normalizedText, + }; + } + + return { + trivial: false, + reason: "", + normalizedText, + }; +} + export function hashRecallInput(text) { let hash = 0; const normalized = normalizeRecallInputText(text);