diff --git a/host/adapter/regex.js b/host/adapter/regex.js index f730791..d11417c 100644 --- a/host/adapter/regex.js +++ b/host/adapter/regex.js @@ -1,3 +1,7 @@ +import { + getRegexedString as coreGetRegexedString, + regex_placement as coreRegexPlacement, +} from "../../../../regex/engine.js"; import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js"; import { createContextHostFacade } from "./context.js"; import { debugDebug } from "../../runtime/debug-logging.js"; @@ -7,6 +11,28 @@ const REGEX_API_NAMES = [ "isCharacterTavernRegexesEnabled", "formatAsTavernRegexedString", ]; +const CORE_REGEX_SOURCE_TO_PLACEMENT_KEY = Object.freeze({ + user_input: "USER_INPUT", + ai_output: "AI_OUTPUT", + slash_command: "SLASH_COMMAND", + world_info: "WORLD_INFO", + reasoning: "REASONING", +}); +const REGEX_SOURCE_KIND_PRIORITY = Object.freeze({ + unknown: 0, + unavailable: 0, + "global-fallback": 1, + context: 2, + "core-bridge": 3, + "api-map": 4, + provider: 5, +}); +const REGEX_BRIDGE_TIER_PRIORITY = Object.freeze({ + unavailable: 0, + "helper-getter-only": 1, + "helper-bridge": 2, + "core-real": 3, +}); function isObjectLike(value) { return ( @@ -19,11 +45,90 @@ function bindHostFunction(container, name) { return typeof fn === "function" ? fn.bind(container) : null; } +function resolveCorePlacement(regexPlacement, source) { + const normalizedSource = String(source || "").trim().toLowerCase(); + const placementKey = CORE_REGEX_SOURCE_TO_PLACEMENT_KEY[normalizedSource]; + if (!placementKey || !isObjectLike(regexPlacement)) { + return null; + } + const placement = regexPlacement?.[placementKey]; + return Number.isFinite(Number(placement)) ? Number(placement) : null; +} + +function hasCoreRegexApi(container) { + return ( + typeof container?.getRegexedString === "function" && + resolveCorePlacement(container?.regex_placement, "user_input") != null + ); +} + +function normalizeCoreFormatterOptions(destination, options = {}) { + const normalizedDestination = + typeof destination === "string" ? String(destination || "").trim() : ""; + const normalizedOptions = + destination && + typeof destination === "object" && + !Array.isArray(destination) + ? { ...destination } + : options && typeof options === "object" && !Array.isArray(options) + ? { ...options } + : {}; + + if (normalizedDestination === "display" && normalizedOptions.isMarkdown == null) { + normalizedOptions.isMarkdown = true; + } + if (normalizedDestination === "prompt" && normalizedOptions.isPrompt == null) { + normalizedOptions.isPrompt = true; + } + if ( + normalizedOptions.character_name != null && + normalizedOptions.characterOverride == null + ) { + normalizedOptions.characterOverride = normalizedOptions.character_name; + } + delete normalizedOptions.character_name; + return normalizedOptions; +} + +function createCoreFormatterBridge(container) { + if (!hasCoreRegexApi(container)) { + return null; + } + const getRegexedString = bindHostFunction(container, "getRegexedString"); + const regexPlacement = container?.regex_placement; + if (typeof getRegexedString !== "function") { + return null; + } + + return function formatAsTavernRegexedString( + text, + source, + destination, + options = {} + ) { + const placement = resolveCorePlacement(regexPlacement, source); + if (placement == null) { + return String(text ?? ""); + } + return getRegexedString( + String(text ?? ""), + placement, + normalizeCoreFormatterOptions(destination, options) + ); + }; +} + function buildApiMap(container = null) { - return REGEX_API_NAMES.reduce((result, name) => { + const apiMap = REGEX_API_NAMES.reduce((result, name) => { result[name] = bindHostFunction(container, name); return result; }, {}); + + if (typeof apiMap.formatAsTavernRegexedString !== "function") { + apiMap.formatAsTavernRegexedString = createCoreFormatterBridge(container); + } + + return apiMap; } function countResolvedApis(apiMap = {}) { @@ -31,6 +136,23 @@ function countResolvedApis(apiMap = {}) { .length; } +function detectBridgeTier({ hasCoreApi = false, apiMap = {} } = {}) { + const hasGetter = typeof apiMap.getTavernRegexes === "function"; + const hasFormatter = + typeof apiMap.formatAsTavernRegexedString === "function"; + + if (hasCoreApi && hasFormatter) { + return "core-real"; + } + if (hasFormatter) { + return "helper-bridge"; + } + if (hasGetter) { + return "helper-getter-only"; + } + return "unavailable"; +} + function resolveProviderCandidate(candidate, options = {}) { if (!candidate) { return null; @@ -56,6 +178,8 @@ function buildSourceRecord({ fallback = false, } = {}) { const apiMap = buildApiMap(container); + const hasCoreApi = hasCoreRegexApi(container); + const bridgeTier = detectBridgeTier({ hasCoreApi, apiMap }); return Object.freeze({ label, @@ -63,6 +187,8 @@ function buildSourceRecord({ fallback, apiMap, apiCount: countResolvedApis(apiMap), + hasCoreApi, + bridgeTier, }); } @@ -111,6 +237,27 @@ function collectExplicitRegexSourceRecords(options = {}) { return records; } +function collectCoreBridgeSourceRecords(options = {}) { + if (options?.disableCoreRegexBridge === true) { + return []; + } + const coreBridge = { + getRegexedString: coreGetRegexedString, + regex_placement: coreRegexPlacement, + }; + if (!hasCoreRegexApi(coreBridge)) { + return []; + } + + return [ + buildSourceRecord({ + label: "sillytavern.core.regex", + sourceKind: "core-bridge", + container: coreBridge, + }), + ]; +} + function collectContextRegexSourceRecords(contextHost, options = {}) { const context = contextHost?.readContextSnapshot?.(); if (!isObjectLike(context)) { @@ -177,19 +324,31 @@ function collectGlobalFallbackRecords() { return records; } -function resolveRegexSource(options = {}, contextHost = null) { - const records = [ - ...collectExplicitRegexSourceRecords(options), - ...collectContextRegexSourceRecords(contextHost, options), - ...collectGlobalFallbackRecords(), - ]; +function scoreSourceRecord(record = {}) { + const sourceScore = + REGEX_SOURCE_KIND_PRIORITY[String(record?.sourceKind || "unknown")] || 0; + const tierScore = + REGEX_BRIDGE_TIER_PRIORITY[String(record?.bridgeTier || "unavailable")] || 0; + if (tierScore <= 0) { + return 0; + } + return sourceScore * 100 + tierScore * 10 + Number(record?.apiCount || 0); +} + +function selectBestRegexSource(records = []) { + let bestRecord = null; + let bestScore = -1; + + for (const record of Array.isArray(records) ? records : []) { + const score = scoreSourceRecord(record); + if (!bestRecord || score > bestScore) { + bestRecord = record; + bestScore = score; + } + } return ( - records.find( - (record) => - typeof record.apiMap.getTavernRegexes === "function" || - typeof record.apiMap.formatAsTavernRegexedString === "function", - ) || + bestRecord || buildSourceRecord({ label: "none", sourceKind: "unavailable", @@ -198,22 +357,19 @@ function resolveRegexSource(options = {}, contextHost = null) { ); } -function detectRegexMode(apiMap = {}) { - const hasGetter = typeof apiMap.getTavernRegexes === "function"; - const hasFormatter = - typeof apiMap.formatAsTavernRegexedString === "function"; +function resolveRegexSource(options = {}, contextHost = null) { + const records = [ + ...collectExplicitRegexSourceRecords(options), + ...collectCoreBridgeSourceRecords(options), + ...collectContextRegexSourceRecords(contextHost, options), + ...collectGlobalFallbackRecords(), + ]; - if (!hasGetter && !hasFormatter) { - return "unavailable"; - } + return selectBestRegexSource(records); +} - if (hasGetter && hasFormatter) { - return typeof apiMap.isCharacterTavernRegexesEnabled === "function" - ? "full" - : "partial"; - } - - return hasFormatter ? "formatter-only" : "getter-only"; +function detectRegexMode(sourceRecord = {}) { + return String(sourceRecord?.bridgeTier || "").trim() || "unavailable"; } function buildFallbackReason(sourceRecord, available, mode) { @@ -221,23 +377,15 @@ function buildFallbackReason(sourceRecord, available, mode) { return "未检测到 Tavern Regex 宿主接口"; } - if (sourceRecord?.fallback && mode === "partial") { - return `当前通过 ${sourceRecord.label} fallback 提供部分 Tavern Regex 能力`; + if (mode === "core-real") { + return ""; } - if (sourceRecord?.fallback) { - return `当前通过 ${sourceRecord.label} fallback 提供 Tavern Regex 能力`; + if (mode === "helper-bridge") { + return `当前通过 ${sourceRecord?.label || "unknown"} helper bridge 提供 Tavern Regex formatter`; } - if (mode === "partial") { - return `Tavern Regex 桥接仅发现部分接口,来源: ${sourceRecord?.label || "unknown"}`; - } - - if (mode === "formatter-only") { - return `Tavern Regex 桥接仅发现 formatter 接口,来源: ${sourceRecord?.label || "unknown"}`; - } - - if (mode === "getter-only") { + if (mode === "helper-getter-only") { return `Tavern Regex 桥接仅发现规则读取接口,来源: ${sourceRecord?.label || "unknown"}`; } @@ -247,31 +395,45 @@ function buildFallbackReason(sourceRecord, available, mode) { export function createRegexHostFacade(options = {}) { const contextHost = options.contextHost || createContextHostFacade(options); const sourceRecord = resolveRegexSource(options, contextHost); - const mode = detectRegexMode(sourceRecord.apiMap); + const mode = detectRegexMode(sourceRecord); const available = mode !== "unavailable"; + const formatterAvailable = + typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function"; + const rulesAvailable = + typeof sourceRecord.apiMap.getTavernRegexes === "function"; + const fallbackReason = buildFallbackReason(sourceRecord, available, mode); + const versionHints = mergeVersionHints( + { + apis: REGEX_API_NAMES.filter( + (name) => typeof sourceRecord.apiMap[name] === "function", + ), + apiCount: String(sourceRecord.apiCount), + supportsCharacterToggle: + typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled === "function" + ? "yes" + : "no", + source: sourceRecord.sourceKind, + sourceLabel: sourceRecord.label, + fallback: sourceRecord.fallback ? "yes" : "no", + contextMode: contextHost?.mode || "unknown", + bridgeTier: sourceRecord.bridgeTier, + hasCoreApi: sourceRecord.hasCoreApi ? "yes" : "no", + }, + options.versionHints, + ); + const capabilityStatus = buildCapabilityStatus({ + available, + mode, + fallbackReason, + versionHints, + }); return Object.freeze({ available, mode, - fallbackReason: buildFallbackReason(sourceRecord, available, mode), - versionHints: mergeVersionHints( - { - apis: REGEX_API_NAMES.filter( - (name) => typeof sourceRecord.apiMap[name] === "function", - ), - apiCount: String(sourceRecord.apiCount), - supportsCharacterToggle: - typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled === - "function" - ? "yes" - : "no", - source: sourceRecord.sourceKind, - sourceLabel: sourceRecord.label, - fallback: sourceRecord.fallback ? "yes" : "no", - contextMode: contextHost?.mode || "unknown", - }, - options.versionHints, - ), + fallbackReason, + versionHints, + capabilityStatus, getTavernRegexes: sourceRecord.apiMap.getTavernRegexes, isCharacterTavernRegexesEnabled: sourceRecord.apiMap.isCharacterTavernRegexesEnabled, @@ -295,8 +457,10 @@ export function createRegexHostFacade(options = {}) { source: sourceRecord.sourceKind, sourceLabel: sourceRecord.label, fallback: sourceRecord.fallback, - formatterAvailable: - typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function", + formatterAvailable, + rulesAvailable, + bridgeTier: sourceRecord.bridgeTier, + hasCoreApi: sourceRecord.hasCoreApi, }); }, }); diff --git a/host/event-binding.js b/host/event-binding.js index 6bb3a0c..4e64039 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -450,6 +450,23 @@ export async function onGenerationAfterCommandsController( const runtimeRecallOptions = recallContext.recallOptions || recallOptions || {}; + if ( + params && + typeof params === "object" && + runtimeRecallOptions?.authoritativeInputUsed === true + ) { + const authoritativePrompt = String( + runtimeRecallOptions?.overrideUserMessage || + runtimeRecallOptions?.userMessage || + "", + ).trim(); + if (authoritativePrompt) { + params.prompt = authoritativePrompt; + if (Object.prototype.hasOwnProperty.call(params, "user_input")) { + params.user_input = authoritativePrompt; + } + } + } const deliveryMode = runtime.resolveGenerationRecallDeliveryMode?.( recallContext.hookName, diff --git a/index.js b/index.js index 62daf7b..966bc4c 100644 --- a/index.js +++ b/index.js @@ -2143,6 +2143,17 @@ function ensurePersistedRecallRecordForGeneration({ ), tokenEstimate: estimateTokens(injectionText), manuallyEdited: false, + authoritativeInputUsed: Boolean( + recallResult?.authoritativeInputUsed ?? + frozenRecallOptions?.authoritativeInputUsed ?? + recallOptions?.authoritativeInputUsed, + ), + boundUserFloorText: String( + recallResult?.boundUserFloorText || + frozenRecallOptions?.boundUserFloorText || + recallOptions?.boundUserFloorText || + "", + ), }, existingRecord, ); @@ -2314,6 +2325,108 @@ function rewriteRecallPayloadWithInjection( }; } +function rewriteRecallPayloadWithAuthoritativeUserInput( + promptData = null, + authoritativeText = "", + boundUserFloorText = "", +) { + const normalizedAuthoritativeText = normalizeRecallInputText(authoritativeText); + const normalizedBoundUserFloorText = normalizeRecallInputText(boundUserFloorText); + if (!normalizedAuthoritativeText) { + return { + applied: false, + changed: false, + path: "", + field: "", + reason: "empty-authoritative-text", + }; + } + + const finalMesSend = Array.isArray(promptData?.finalMesSend) + ? promptData.finalMesSend + : null; + if (!Array.isArray(finalMesSend) || finalMesSend.length <= 0) { + return { + applied: false, + changed: false, + path: "", + field: "", + reason: "finalMesSend-unavailable", + }; + } + + let fallbackIndex = -1; + let matchedIndex = -1; + for (let index = finalMesSend.length - 1; index >= 0; index--) { + const entry = finalMesSend[index]; + if (!entry || typeof entry !== "object") continue; + if (entry.injected === true) continue; + + const messageText = normalizeRecallInputText( + entry.message || entry.mes || entry.content || "", + ); + if (!messageText) continue; + + if (fallbackIndex < 0) { + fallbackIndex = index; + } + + if ( + messageText === normalizedAuthoritativeText || + (normalizedBoundUserFloorText && + messageText === normalizedBoundUserFloorText) + ) { + matchedIndex = index; + break; + } + } + + const targetIndex = + matchedIndex >= 0 + ? matchedIndex + : normalizedBoundUserFloorText + ? -1 + : fallbackIndex; + if (targetIndex < 0) { + return { + applied: false, + changed: false, + path: "finalMesSend", + field: "", + reason: normalizedBoundUserFloorText + ? "bound-user-floor-text-not-found" + : "no-rewritable-finalMesSend-entry", + }; + } + + const entry = finalMesSend[targetIndex]; + const fieldName = Object.prototype.hasOwnProperty.call(entry, "message") + ? "message" + : Object.prototype.hasOwnProperty.call(entry, "mes") + ? "mes" + : Object.prototype.hasOwnProperty.call(entry, "content") + ? "content" + : "message"; + const previousText = normalizeRecallInputText( + entry?.[fieldName] || entry?.message || entry?.mes || entry?.content || "", + ); + const changed = previousText !== normalizedAuthoritativeText; + if (changed) { + entry[fieldName] = normalizedAuthoritativeText; + } + + return { + applied: true, + changed, + path: "finalMesSend", + field: `finalMesSend[${targetIndex}].${fieldName}`, + reason: changed + ? "finalMesSend-authoritative-user-rewritten" + : "authoritative-user-already-matched", + targetIndex, + }; +} + function readGenerationRecallTransactionFinalResolution(transaction) { return transaction?.finalResolution || null; } @@ -2339,6 +2452,98 @@ function applyFinalRecallInjectionForGeneration({ const existingFinalResolution = readGenerationRecallTransactionFinalResolution(transaction); if (existingFinalResolution) { + if ( + promptData && + transaction?.frozenRecallOptions?.authoritativeInputUsed === true + ) { + const recallResult = + freshRecallResult || + getGenerationRecallTransactionResult(transaction) || + null; + const inputRewrite = rewriteRecallPayloadWithAuthoritativeUserInput( + promptData, + transaction?.frozenRecallOptions?.overrideUserMessage || "", + transaction?.frozenRecallOptions?.boundUserFloorText || "", + ); + const rewrite = rewriteRecallPayloadWithInjection( + promptData, + existingFinalResolution.usedText || recallResult?.injectionText || "", + ); + const nextFinalResolution = { + ...existingFinalResolution, + deliveryMode: "deferred", + applicationMode: + rewrite.applied || inputRewrite.applied + ? "rewrite" + : existingFinalResolution.applicationMode, + rewrite, + inputRewrite, + }; + recordInjectionSnapshot("recall", { + taskType: "recall", + source: + String( + recallResult?.source || + transaction?.frozenRecallOptions?.lockedSource || + transaction?.frozenRecallOptions?.overrideSource || + "", + ).trim() || "unknown", + sourceLabel: + String( + recallResult?.sourceLabel || + transaction?.frozenRecallOptions?.lockedSourceLabel || + transaction?.frozenRecallOptions?.overrideSourceLabel || + "", + ).trim() || "未知", + reason: + String( + recallResult?.reason || + transaction?.frozenRecallOptions?.lockedReason || + transaction?.frozenRecallOptions?.overrideReason || + "", + ).trim() || "final-application-reused", + sourceCandidates: Array.isArray(recallResult?.sourceCandidates) + ? recallResult.sourceCandidates.map((candidate) => ({ ...candidate })) + : Array.isArray(transaction?.frozenRecallOptions?.sourceCandidates) + ? transaction.frozenRecallOptions.sourceCandidates.map((candidate) => ({ + ...candidate, + })) + : [], + hookName: String(hookName || recallResult?.hookName || "").trim(), + selectedNodeIds: recallResult?.selectedNodeIds || [], + retrievalMeta: recallResult?.retrievalMeta || {}, + llmMeta: recallResult?.llmMeta || {}, + stats: recallResult?.stats || {}, + injectionText: nextFinalResolution.usedText || "", + deliveryMode: nextFinalResolution.deliveryMode || "", + applicationMode: nextFinalResolution.applicationMode || "none", + transport: nextFinalResolution.transport || { + applied: false, + source: "none", + mode: "none", + }, + rewrite: nextFinalResolution.rewrite || { + applied: false, + path: "", + field: "", + reason: "final-resolution-reused", + }, + inputRewrite, + targetUserMessageIndex: nextFinalResolution.targetUserMessageIndex, + sourceKind: nextFinalResolution.source || "none", + authoritativeInputUsed: true, + boundUserFloorText: String( + transaction?.frozenRecallOptions?.boundUserFloorText || "", + ), + }); + storeGenerationRecallTransactionFinalResolution( + transaction, + nextFinalResolution, + ); + refreshPanelLiveState(); + schedulePersistedRecallMessageUiRefresh(); + return nextFinalResolution; + } return existingFinalResolution; } @@ -2346,15 +2551,21 @@ function applyFinalRecallInjectionForGeneration({ freshRecallResult || getGenerationRecallTransactionResult(transaction) || null; + const hookResolvedDeliveryMode = + String( + resolveGenerationRecallDeliveryMode( + hookName, + generationType, + transaction?.frozenRecallOptions || {}, + ), + ).trim() || "immediate"; const deliveryMode = String( - recallResult?.deliveryMode || - transaction?.lastDeliveryMode || - resolveGenerationRecallDeliveryMode( - hookName, - generationType, - transaction?.frozenRecallOptions || {}, - ), + promptData && hookName === "GENERATE_BEFORE_COMBINE_PROMPTS" + ? hookResolvedDeliveryMode + : recallResult?.deliveryMode || + transaction?.lastDeliveryMode || + hookResolvedDeliveryMode, ).trim() || "immediate"; const chat = getContext()?.chat; @@ -2369,6 +2580,24 @@ function applyFinalRecallInjectionForGeneration({ injectionText: "", record: null, }; + const authoritativeInputRewrite = + deliveryMode === "deferred" && + transaction?.frozenRecallOptions?.authoritativeInputUsed === true + ? rewriteRecallPayloadWithAuthoritativeUserInput( + promptData, + transaction?.frozenRecallOptions?.overrideUserMessage || "", + transaction?.frozenRecallOptions?.boundUserFloorText || "", + ) + : { + applied: false, + changed: false, + path: "", + field: "", + reason: + deliveryMode === "deferred" + ? "authoritative-input-unused" + : "non-deferred-delivery", + }; const rewrite = { applied: false, path: "", @@ -2539,8 +2768,18 @@ function applyFinalRecallInjectionForGeneration({ applicationMode, transport, rewrite, + inputRewrite: authoritativeInputRewrite, targetUserMessageIndex, sourceKind: resolved.source, + authoritativeInputUsed: Boolean( + recallResult?.authoritativeInputUsed ?? + transaction?.frozenRecallOptions?.authoritativeInputUsed, + ), + boundUserFloorText: String( + recallResult?.boundUserFloorText || + transaction?.frozenRecallOptions?.boundUserFloorText || + "", + ), }); refreshPanelLiveState(); @@ -2557,6 +2796,16 @@ function applyFinalRecallInjectionForGeneration({ applicationMode, rewrite, transport, + inputRewrite: authoritativeInputRewrite, + authoritativeInputUsed: Boolean( + recallResult?.authoritativeInputUsed ?? + transaction?.frozenRecallOptions?.authoritativeInputUsed, + ), + boundUserFloorText: String( + recallResult?.boundUserFloorText || + transaction?.frozenRecallOptions?.boundUserFloorText || + "", + ), }; storeGenerationRecallTransactionFinalResolution(transaction, finalResolution); return finalResolution; diff --git a/llm/llm.js b/llm/llm.js index 0383eeb..3ea73ba 100644 --- a/llm/llm.js +++ b/llm/llm.js @@ -129,6 +129,7 @@ function summarizeTaskTimelineEntry(taskType, snapshot = {}) { responseCleaning: cloneRuntimeDebugValue(snapshot?.responseCleaning, null), jsonFailure: cloneRuntimeDebugValue(snapshot?.jsonFailure, null), messages: cloneRuntimeDebugValue(snapshot?.messages, []), + transportMessages: cloneRuntimeDebugValue(snapshot?.transportMessages, []), requestBody: cloneRuntimeDebugValue(snapshot?.requestBody, null), }; } @@ -930,6 +931,76 @@ function looksLikeTruncatedJson(text) { return false; } +function cloneLlmDebugMessageMetadata(message = {}) { + const metadata = {}; + + for (const key of [ + "source", + "sourceKey", + "blockId", + "blockName", + "blockType", + "injectionMode", + "contentOrigin", + "regexSourceType", + "speaker", + "name", + ]) { + const value = String(message?.[key] || "").trim(); + if (value) { + metadata[key] = value; + } + } + + if (message?.derivedFromWorldInfo === true) { + metadata.derivedFromWorldInfo = true; + } + if (message?.sanitizationEligible === true) { + metadata.sanitizationEligible = true; + } + if (Number.isFinite(Number(message?.depth))) { + metadata.depth = Number(message.depth); + } + if (Number.isFinite(Number(message?.order))) { + metadata.order = Number(message.order); + } + + return metadata; +} + +function normalizeLlmDebugMessage(message = {}) { + if (!message || typeof message !== "object") return null; + const role = String(message.role || "").trim().toLowerCase(); + const content = String(message.content || "").trim(); + if (!content || !["system", "user", "assistant"].includes(role)) { + return null; + } + return { + role, + content, + ...cloneLlmDebugMessageMetadata(message), + }; +} + +function buildTransportMessages(messages = []) { + return (Array.isArray(messages) ? messages : []) + .map((message) => { + if (!message || typeof message !== "object") { + return null; + } + const role = String(message.role || "").trim().toLowerCase(); + const content = String(message.content || "").trim(); + if (!content || !["system", "user", "assistant"].includes(role)) { + return null; + } + return { + role, + content, + }; + }) + .filter(Boolean); +} + function buildJsonAttemptMessages( systemPrompt, userPrompt, @@ -961,15 +1032,7 @@ function buildJsonAttemptMessages( const normalizedPromptMessages = Array.isArray(promptMessages) ? promptMessages - .map((message) => { - if (!message || typeof message !== "object") return null; - const role = String(message.role || "").trim().toLowerCase(); - const content = String(message.content || "").trim(); - if (!["system", "user", "assistant"].includes(role) || !content) { - return null; - } - return { role, content }; - }) + .map((message) => normalizeLlmDebugMessage(message)) .filter(Boolean) : []; @@ -1037,12 +1100,9 @@ function buildJsonAttemptMessages( } for (const message of additionalMessages || []) { - if (!message || typeof message !== "object") continue; - const role = String(message.role || "").trim().toLowerCase(); - const content = String(message.content || "").trim(); - if (!content) continue; - if (!["system", "user", "assistant"].includes(role)) continue; - messages.push({ role, content }); + const normalizedMessage = normalizeLlmDebugMessage(message); + if (!normalizedMessage) continue; + messages.push(normalizedMessage); } messages.push({ role: "user", content: userParts.join("\n\n") }); @@ -1054,16 +1114,16 @@ function resolvePrivateRequestSource( requestSource = "", { allowAnonymous = false } = {}, ) { - const normalizedRequestSource = String(requestSource || "").trim(); - if (normalizedRequestSource) { - return normalizedRequestSource; - } - const normalizedTaskType = String(taskType || "").trim(); if (normalizedTaskType) { return `task:${normalizedTaskType}`; } + const normalizedRequestSource = String(requestSource || "").trim(); + if (normalizedRequestSource) { + return normalizedRequestSource; + } + if (allowAnonymous) { return "adhoc"; } @@ -1399,6 +1459,7 @@ async function callDedicatedOpenAICompatible( taskType, requestSource, ); + const transportMessages = buildTransportMessages(messages); const config = getMemoryLLMConfig(taskType); const settings = extension_settings[MODULE_NAME] || {}; const hasDedicatedConfig = hasDedicatedLLMConfig(config); @@ -1448,6 +1509,7 @@ async function callDedicatedOpenAICompatible( requestedLlmPresetName: config.requestedLlmPresetName || "", llmPresetFallbackReason: config.llmPresetFallbackReason || "", messages, + transportMessages, generation: generationResolved.generation || {}, filteredGeneration: generationResolved.filtered || {}, removedGeneration: generationResolved.removed || [], @@ -1463,7 +1525,7 @@ async function callDedicatedOpenAICompatible( if (!hasDedicatedConfig) { const payload = await sendOpenAIRequest( "quiet", - messages, + transportMessages, signal, jsonMode ? { jsonSchema: createGenericJsonSchema() } : {}, ); @@ -1500,7 +1562,7 @@ async function callDedicatedOpenAICompatible( }) : "", model: config.model, - messages, + messages: transportMessages, temperature: filteredGeneration.temperature ?? 1, max_tokens: resolvedCompletionTokens, stream: filteredGeneration.stream ?? false, @@ -1556,6 +1618,7 @@ async function callDedicatedOpenAICompatible( requestedLlmPresetName: config.requestedLlmPresetName || "", llmPresetFallbackReason: config.llmPresetFallbackReason || "", messages, + transportMessages, generation: generationResolved.generation || {}, filteredGeneration, removedGeneration: generationResolved.removed || [], diff --git a/prompting/prompt-builder.js b/prompting/prompt-builder.js index 0dc5fb1..7b84777 100644 --- a/prompting/prompt-builder.js +++ b/prompting/prompt-builder.js @@ -1865,6 +1865,86 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { return result; } +function clonePayloadMessage(message = {}) { + return createExecutionMessage(message.role, message.content, { + source: String(message.source || ""), + blockId: String(message.blockId || ""), + blockName: String(message.blockName || ""), + blockType: String(message.blockType || ""), + sourceKey: String(message.sourceKey || ""), + injectionMode: String(message.injectionMode || ""), + derivedFromWorldInfo: message.derivedFromWorldInfo === true, + contentOrigin: String(message.contentOrigin || ""), + sanitizationEligible: message.sanitizationEligible === true, + regexSourceType: String(message.regexSourceType || ""), + }); +} + +function collectPayloadUserMessageTexts(messages = []) { + return (Array.isArray(messages) ? messages : []) + .filter((message) => String(message?.role || "").trim().toLowerCase() === "user") + .map((message) => String(message?.content || "").trim()) + .filter(Boolean); +} + +function buildSafeFallbackUserPrompt( + settings = {}, + taskType, + { + fallbackUserPrompt = "", + blockedContents = [], + rawExecutionMessages = [], + rawPrivateTaskMessages = [], + } = {}, +) { + const structuredUserPrompt = [ + ...collectPayloadUserMessageTexts(rawExecutionMessages), + ...collectPayloadUserMessageTexts(rawPrivateTaskMessages), + ] + .join("\n\n") + .trim(); + const candidates = [ + { + source: "structured-user-blocks", + text: structuredUserPrompt, + }, + { + source: "fallback-user-prompt", + text: String(fallbackUserPrompt || "").trim(), + }, + ].filter((candidate) => candidate.text); + + for (const candidate of candidates) { + const sanitized = sanitizeInjectionText(settings, taskType, candidate.text, { + mode: "final-injection-safe", + blockedContents, + contentOrigin: PROMPT_CONTENT_ORIGIN.HOST_INJECTED, + sanitizationEligible: true, + role: "user", + applySanitizer: true, + applyHostRegex: false, + path: "payload.fallbackUserPrompt", + stage: "payload-fallback-user-prompt", + }); + const text = String(sanitized.text || "").trim(); + if (text) { + return { + text, + source: candidate.source, + changed: Boolean(sanitized.changed), + dropped: Boolean(sanitized.dropped), + }; + } + } + + return { + text: "", + source: candidates[0]?.source || "", + changed: false, + dropped: candidates.length > 0, + }; +} + export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") { const runtimeMvu = promptBuild?.__mvuRuntime || {}; const taskType = String(promptBuild?.debug?.taskType || ""); @@ -1880,20 +1960,12 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") : []; const rawExecutionMessages = Array.isArray(promptBuild?.executionMessages) ? promptBuild.executionMessages - .map((message) => - createExecutionMessage(message.role, message.content, { - source: String(message.source || ""), - blockId: String(message.blockId || ""), - blockName: String(message.blockName || ""), - blockType: String(message.blockType || ""), - sourceKey: String(message.sourceKey || ""), - injectionMode: String(message.injectionMode || ""), - derivedFromWorldInfo: message.derivedFromWorldInfo === true, - contentOrigin: String(message.contentOrigin || ""), - sanitizationEligible: message.sanitizationEligible === true, - regexSourceType: String(message.regexSourceType || ""), - }), - ) + .map((message) => clonePayloadMessage(message)) + .filter(Boolean) + : []; + const rawPrivateTaskMessages = Array.isArray(promptBuild?.privateTaskMessages) + ? promptBuild.privateTaskMessages + .map((message) => clonePayloadMessage(message)) .filter(Boolean) : []; const executionMessages = sanitizePromptMessages( @@ -1949,22 +2021,39 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") : sanitizePromptMessages( settings, taskType, - Array.isArray(promptBuild?.privateTaskMessages) - ? promptBuild.privateTaskMessages - : [], + rawPrivateTaskMessages, { blockedContents, applySanitizer: (message) => !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); + const hasAdditionalUserMessage = additionalMessages.some( + (message) => message.role === "user", + ); + const fallbackUserPromptResult = + hasUserMessage || hasAdditionalUserMessage + ? { + text: "", + source: hasUserMessage ? "execution-messages" : "additional-messages", + changed: false, + dropped: false, + } + : buildSafeFallbackUserPrompt(settings, taskType, { + fallbackUserPrompt, + blockedContents, + rawExecutionMessages, + rawPrivateTaskMessages, + }); return { systemPrompt: executionMessages.length > 0 ? "" : String(promptBuild?.systemPrompt || ""), - userPrompt: hasUserMessage ? "" : String(fallbackUserPrompt || ""), + userPrompt: fallbackUserPromptResult.text, promptMessages: executionMessages, additionalMessages, + fallbackUserPromptSource: fallbackUserPromptResult.source, + fallbackUserPromptApplied: Boolean(fallbackUserPromptResult.text), }; } diff --git a/prompting/task-regex.js b/prompting/task-regex.js index 16749a7..0805c0d 100644 --- a/prompting/task-regex.js +++ b/prompting/task-regex.js @@ -256,6 +256,10 @@ function getRegexHost() { const capabilitySupport = regexHost.readCapabilitySupport?.() || {}; const supplementedCapabilities = []; const missingCapabilities = []; + const resolvedGetter = + typeof regexHost.getTavernRegexes === "function" + ? regexHost.getTavernRegexes + : legacyGetTavernRegexes; const resolvedCharacterToggle = typeof regexHost.isCharacterTavernRegexesEnabled === "function" ? regexHost.isCharacterTavernRegexesEnabled @@ -265,6 +269,14 @@ function getRegexHost() { ? regexHost.formatAsTavernRegexedString : legacyFormatAsTavernRegexedString; + if (typeof regexHost.getTavernRegexes !== "function") { + if (resolvedGetter) { + supplementedCapabilities.push("getTavernRegexes"); + } else { + missingCapabilities.push("getTavernRegexes"); + } + } + if (typeof regexHost.isCharacterTavernRegexesEnabled !== "function") { if (resolvedCharacterToggle) { supplementedCapabilities.push("isCharacterTavernRegexesEnabled"); @@ -282,16 +294,24 @@ function getRegexHost() { } return { - getTavernRegexes: regexHost.getTavernRegexes, + getTavernRegexes: resolvedGetter, isCharacterTavernRegexesEnabled: resolvedCharacterToggle, formatAsTavernRegexedString: resolvedFormatter, - sourceLabel: capabilitySupport.sourceLabel || "host-adapter.regex", + sourceLabel: + capabilitySupport.sourceLabel || regexHost?.sourceLabel || "host-adapter.regex", fallback: Boolean(capabilitySupport.fallback) || + typeof regexHost.getTavernRegexes !== "function" || + typeof regexHost.isCharacterTavernRegexesEnabled !== "function" || + typeof regexHost.formatAsTavernRegexedString !== "function" || supplementedCapabilities.length > 0, - fallbackReason: String(capabilitySupport.fallbackReason || "").trim(), + fallbackReason: String( + regexHost?.fallbackReason || capabilitySupport.fallbackReason || "", + ).trim(), capabilityStatus: Object.freeze({ - mode: capabilitySupport.mode || "unknown", + mode: capabilitySupport.mode || regexHost?.mode || "unknown", + bridgeTier: + capabilitySupport.bridgeTier || capabilitySupport.mode || regexHost?.mode || "unknown", supplementedCapabilities: Object.freeze(supplementedCapabilities), missingCapabilities: Object.freeze(missingCapabilities), }), @@ -500,10 +520,15 @@ function summarizeRuleForPromptPreview(rule, stageConfig = {}, reason = "") { promptStageMode = promptSemanticApplies ? "replace" : "skip"; } else if (rule?.destinationFlags?.prompt === false || summary.markdownOnly) { promptStageMode = "display-only"; - } else if (summary.beautificationReplace && executionState.mode !== "host-real") { + } else if ( + summary.beautificationReplace && + !["host-real", "host-helper"].includes(executionState.mode) + ) { promptStageMode = "fallback-skip-beautify"; } else if (executionState.mode === "host-real") { promptStageMode = "host-real"; + } else if (executionState.mode === "host-helper") { + promptStageMode = "host-helper"; } else if (executionState.mode === "host-fallback") { promptStageMode = "host-fallback"; } @@ -748,6 +773,10 @@ function collectTavernRulesDetailed(regexConfig = {}) { formatterAvailable: typeof regexHost.formatAsTavernRegexedString === "function", executionMode: buildHostRegexExecutionState(regexHost).mode, + bridgeTier: + regexHost?.capabilityStatus?.bridgeTier || + regexHost?.capabilityStatus?.mode || + "unknown", capabilityStatus: regexHost.capabilityStatus || null, }, sources, @@ -822,21 +851,40 @@ function ruleMatchesFormatterDepth(rule, formatterOptions = null) { } function buildHostRegexExecutionState(regexHost = null) { + const bridgeTier = + String( + regexHost?.capabilityStatus?.bridgeTier || + regexHost?.capabilityStatus?.mode || + "", + ).trim() || "unknown"; const formatterAvailable = typeof regexHost?.formatAsTavernRegexedString === "function"; const rulesAvailable = typeof regexHost?.getTavernRegexes === "function"; - if (formatterAvailable) { + if (formatterAvailable && bridgeTier === "core-real") { return { mode: "host-real", + bridgeTier, formatterAvailable: true, fallbackReason: "", }; } + if (formatterAvailable) { + return { + mode: "host-helper", + bridgeTier, + formatterAvailable: true, + fallbackReason: + String(regexHost?.fallbackReason || "").trim() || + "当前通过 helper bridge 提供 Tavern Regex formatter", + }; + } + if (rulesAvailable) { return { mode: "host-fallback", + bridgeTier, formatterAvailable: false, fallbackReason: String(regexHost?.fallbackReason || "").trim() || @@ -846,6 +894,7 @@ function buildHostRegexExecutionState(regexHost = null) { return { mode: "host-unavailable", + bridgeTier, formatterAvailable: false, fallbackReason: String(regexHost?.fallbackReason || "").trim() || @@ -864,7 +913,7 @@ function shouldReuseTavernRuleForPrompt(rule, executionMode = "host-fallback") { return false; } if ( - executionMode !== "host-real" && + !["host-real", "host-helper"].includes(executionMode) && Boolean(rule?.beautificationReplace) ) { return false; @@ -1106,7 +1155,10 @@ export function applyHostRegexReuse( if ( !normalizedSourceType || - (tavernRules.length === 0 && executionState.mode !== "host-real") + ( + tavernRules.length === 0 && + !["host-real", "host-helper"].includes(executionState.mode) + ) ) { pushDebug(debugCollector, { kind: "host-reuse", @@ -1133,7 +1185,7 @@ export function applyHostRegexReuse( } if ( - executionState.mode === "host-real" && + ["host-real", "host-helper"].includes(executionState.mode) && typeof regexHost?.formatAsTavernRegexedString === "function" ) { try { @@ -1150,23 +1202,29 @@ export function applyHostRegexReuse( taskType: normalizedTaskType, stage: `host:${normalizedSourceType}`, enabled: true, - executionMode: "host-real", + executionMode: executionState.mode, formatterAvailable: true, appliedRules: output !== input - ? [{ id: "__host_formatter__", source: "host-real" }] + ? [{ id: "__host_formatter__", source: executionState.mode }] : [], sourceCount: { tavern: tavernRules.length, local: 0 }, - fallbackReason: "", + fallbackReason: + executionState.mode === "host-real" + ? "" + : executionState.fallbackReason, hostFormatterSource: String(regexHost?.sourceLabel || ""), skippedDisplayOnlyRuleCount, }); return { text: output, changed: output !== input, - executionMode: "host-real", + executionMode: executionState.mode, formatterAvailable: true, formatterSource: String(regexHost?.sourceLabel || ""), - fallbackReason: "", + fallbackReason: + executionState.mode === "host-real" + ? "" + : executionState.fallbackReason, skippedDisplayOnlyRuleCount, }; } catch (error) { diff --git a/retrieval/recall-persistence.js b/retrieval/recall-persistence.js index b418474..69b5cfe 100644 --- a/retrieval/recall-persistence.js +++ b/retrieval/recall-persistence.js @@ -47,6 +47,8 @@ export function readPersistedRecallFromUserMessage(chat, userMessageIndex) { updatedAt: toIsoString(record.updatedAt), generationCount: Math.max(0, Number.parseInt(record.generationCount, 10) || 0), manuallyEdited: Boolean(record.manuallyEdited), + authoritativeInputUsed: Boolean(record.authoritativeInputUsed), + boundUserFloorText: String(record.boundUserFloorText || ""), }; } @@ -69,6 +71,8 @@ export function buildPersistedRecallRecord(payload = {}, existingRecord = null) updatedAt: nowIso, generationCount: 0, manuallyEdited: Boolean(payload.manuallyEdited), + authoritativeInputUsed: Boolean(payload.authoritativeInputUsed), + boundUserFloorText: String(payload.boundUserFloorText || ""), }; } diff --git a/tests/helpers/register-hooks-compat.mjs b/tests/helpers/register-hooks-compat.mjs index da1b471..1d4d3a2 100644 --- a/tests/helpers/register-hooks-compat.mjs +++ b/tests/helpers/register-hooks-compat.mjs @@ -1,11 +1,27 @@ import { register, registerHooks } from "node:module"; +const DEFAULT_REGEX_ENGINE_HOOK_ENTRIES = Object.freeze([ + { + specifiers: ["../../../../regex/engine.js"], + url: toDataModuleUrl([ + "export const regex_placement = { USER_INPUT: 1, AI_OUTPUT: 2, SLASH_COMMAND: 3, WORLD_INFO: 5, REASONING: 6 };", + "export function getRegexedString(...args) {", + " const fn = globalThis.__taskRegexTestCoreGetRegexedString;", + " return typeof fn === 'function' ? fn(...args) : String(args?.[0] ?? '');", + "}", + ].join("\n")), + }, +]); + export function toDataModuleUrl(source = "") { return `data:text/javascript,${encodeURIComponent(String(source || ""))}`; } export function installResolveHooks(entries = []) { - const normalizedEntries = (Array.isArray(entries) ? entries : []) + const normalizedEntries = [ + ...(Array.isArray(entries) ? entries : []), + ...DEFAULT_REGEX_ENGINE_HOOK_ENTRIES, + ] .map((entry) => ({ specifiers: Array.isArray(entry?.specifiers) ? entry.specifiers.map((value) => String(value || "")).filter(Boolean) diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index b222863..36dbd90 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -4244,6 +4244,65 @@ async function testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload() { ); } +async function testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput() { + const harness = await createGenerationRecallHarness({ realApplyFinal: true }); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "楼层稳定输入" }]; + harness.pendingRecallSendIntent = { + text: "发送前真实输入", + hash: "hash-deferred-authoritative-rewrite", + at: Date.now(), + source: "dom-intent", + }; + harness.result.pendingRecallSendIntent = harness.pendingRecallSendIntent; + + await harness.result.onGenerationAfterCommands("normal", {}, false); + + const promptData = { + finalMesSend: [ + { + injected: false, + message: "楼层稳定输入", + extensionPrompts: [], + }, + ], + }; + + const resolution = await harness.result.onBeforeCombinePrompts(promptData); + + assert.equal(harness.runRecallCalls.length, 1); + assert.equal( + harness.runRecallCalls[0].hookName, + "GENERATION_AFTER_COMMANDS", + ); + const transaction = [...harness.result.generationRecallTransactions.values()][0]; + assert.ok(transaction); + assert.equal(transaction.frozenRecallOptions.authoritativeInputUsed, true); + assert.equal(transaction.frozenRecallOptions.boundUserFloorText, "楼层稳定输入"); + assert.equal( + harness.runRecallCalls[0].authoritativeInputUsed, + true, + ); + assert.equal(harness.runRecallCalls[0].boundUserFloorText, "楼层稳定输入"); + assert.equal(promptData.finalMesSend[0].message, "发送前真实输入"); + assert.equal(resolution.applicationMode, "rewrite"); + assert.equal(resolution.authoritativeInputUsed, true); + assert.equal(resolution.boundUserFloorText, "楼层稳定输入"); + assert.equal(resolution.inputRewrite.applied, true); + assert.equal(resolution.inputRewrite.changed, true); + assert.equal(resolution.inputRewrite.field, "finalMesSend[0].message"); + assert.match( + promptData.finalMesSend[0].extensionPrompts.join("\n"), + /注入:发送前真实输入/, + ); + assert.equal( + harness.recordedInjectionSnapshots.at(-1)?.inputRewrite?.applied, + true, + ); +} + async function testGenerationRecallSendIntentBeatsChatTailAndStaysObservable() { const harness = await createGenerationRecallHarness(); harness.chat = [{ is_user: true, mes: "旧的 chat tail" }]; @@ -4480,8 +4539,11 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { hookName: "GENERATION_AFTER_COMMANDS", tokenEstimate: 24, manuallyEdited: false, + authoritativeInputUsed: true, + boundUserFloorText: "稳定楼层输入", nowIso: "2026-01-01T00:00:00.000Z", }); + assert.equal(writePersistedRecallToUserMessage(chat, 2, record), true); const loaded = readPersistedRecallFromUserMessage(chat, 2); @@ -4489,6 +4551,8 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { assert.equal(loaded.injectionText, "fresh-memory"); assert.equal(loaded.generationCount, 0); assert.equal(loaded.manuallyEdited, false); + assert.equal(loaded.authoritativeInputUsed, true); + assert.equal(loaded.boundUserFloorText, "稳定楼层输入"); chat[2].mes = "u2 edited"; assert.equal( @@ -4517,14 +4581,19 @@ async function testPersistentRecallDataLayerLifecycleAndCompatibility() { hookName: "MESSAGE_RECALL_BADGE_RERUN", tokenEstimate: 30, manuallyEdited: false, + authoritativeInputUsed: false, + boundUserFloorText: "", nowIso: "2026-01-01T00:00:02.000Z", }, readPersistedRecallFromUserMessage(chat, 2), ); + assert.equal(writePersistedRecallToUserMessage(chat, 2, overwrite), true); const overwritten = readPersistedRecallFromUserMessage(chat, 2); assert.equal(overwritten?.manuallyEdited, false); assert.equal(overwritten?.injectionText, "system-rerecall"); + assert.equal(overwritten?.authoritativeInputUsed, false); + assert.equal(overwritten?.boundUserFloorText, ""); assert.equal(removePersistedRecallFromUserMessage(chat, 2), true); assert.equal(readPersistedRecallFromUserMessage(chat, 2), null); @@ -4601,17 +4670,39 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( status: "completed", didRecall: true, injectionText: "fresh-memory", + authoritativeInputUsed: true, + boundUserFloorText: "稳定楼层输入", }, transaction: { frozenRecallOptions: { generationType: "normal", targetUserMessageIndex: null, overrideUserMessage: "当前输入", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", }, }, }); + assert.equal(resolution.source, "fresh"); assert.equal(resolution.targetUserMessageIndex, 0); + assert.equal(resolution.authoritativeInputUsed, true); + assert.equal(resolution.boundUserFloorText, "稳定楼层输入"); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.injectionText, + "fresh-memory", + ); + + assert.equal( + JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []), + JSON.stringify([]), + ); + assert.equal(harness.chat[0]?.extra?.bme_recall?.authoritativeInputUsed, true); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.boundUserFloorText, + "稳定楼层输入", + ); + assert.equal(harness.metadataSaveCalls > 0, true); } { @@ -4640,6 +4731,8 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( generationType: "normal", targetUserMessageIndex: null, overrideUserMessage: "尾部 user 仍可匹配", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", }, }, }); @@ -4674,6 +4767,8 @@ async function testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor( generationType: "normal", targetUserMessageIndex: null, overrideUserMessage: "发送前捕获的原始文本", + lockedSource: "send-intent", + hookName: "GENERATION_AFTER_COMMANDS", }, }, }); @@ -4703,6 +4798,8 @@ async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() { didRecall: true, injectionText: "fresh-memory", selectedNodeIds: ["node-a", "node-b"], + authoritativeInputUsed: true, + boundUserFloorText: "稳定楼层输入", }, transaction: { frozenRecallOptions: { @@ -4721,9 +4818,15 @@ async function testGenerationRecallFinalInjectionBackfillsPersistedRecord() { harness.chat[0]?.extra?.bme_recall?.injectionText, "fresh-memory", ); - assert.deepEqual( - harness.chat[0]?.extra?.bme_recall?.selectedNodeIds, - ["node-a", "node-b"], + + assert.equal( + JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []), + JSON.stringify(["node-a", "node-b"]), + ); + assert.equal(harness.chat[0]?.extra?.bme_recall?.authoritativeInputUsed, true); + assert.equal( + harness.chat[0]?.extra?.bme_recall?.boundUserFloorText, + "稳定楼层输入", ); assert.equal(harness.metadataSaveCalls > 0, true); } @@ -4744,9 +4847,9 @@ async function testGenerationRecallImmediateAfterCommandsBackfillsPersistedRecor harness.chat[0]?.extra?.bme_recall?.injectionText, "注入:即时模式补写目标", ); - assert.deepEqual( - harness.chat[0]?.extra?.bme_recall?.selectedNodeIds, - ["node-test-1"], + assert.equal( + JSON.stringify(harness.chat[0]?.extra?.bme_recall?.selectedNodeIds || []), + JSON.stringify(["node-test-1"]), ); assert.equal(harness.metadataSaveCalls > 0, true); } @@ -6079,6 +6182,7 @@ await testAutoExtractionDefersWhenHistoryRecoveryBusy(); await testRemoveNodeHandlesCyclicChildGraph(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testGenerationRecallDeferredRewriteMutatesFinalMesSendPayload(); +await testGenerationRecallDeferredRewriteMutatesFinalMesSendAuthoritativeUserInput(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); await testGenerationRecallFinalInjectionRebindsLatestMatchingUserFloor(); diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs index 4bfc577..bd808bd 100644 --- a/tests/prompt-builder-mvu.mjs +++ b/tests/prompt-builder-mvu.mjs @@ -327,9 +327,42 @@ try { systemOnlyPromptBuild, "fallback hidden text", ); + assert.equal(systemOnlyPayload.userPrompt, "fallback text"); + assert.equal(systemOnlyPayload.fallbackUserPromptSource, "fallback-user-prompt"); + + const additionalUserOnlyPayload = buildTaskLlmPayload( + { + debug: { + taskType: "recall", + }, + systemPrompt: "", + executionMessages: [], + privateTaskMessages: [ + { + role: "user", + content: "来自 additionalMessages 的结构化用户块", + source: "profile-block", + }, + ], + }, + "unused fallback user prompt", + ); + assert.equal(additionalUserOnlyPayload.userPrompt, ""); assert.equal( - systemOnlyPayload.userPrompt, - "fallback hidden text", + additionalUserOnlyPayload.fallbackUserPromptSource, + "additional-messages", + ); + assert.deepEqual( + additionalUserOnlyPayload.additionalMessages.map((message) => ({ + role: message.role, + content: message.content, + })), + [ + { + role: "user", + content: "来自 additionalMessages 的结构化用户块", + }, + ], ); const rawWorldInfoEntries = [ @@ -465,6 +498,10 @@ try { assert.equal(payload.systemPrompt, ""); assert.match(JSON.stringify(payload.promptMessages), /FINAL_BAD/); assert.doesNotMatch(JSON.stringify(payload.promptMessages), /FINAL_GOOD/); + assert.equal( + payload.promptMessages.some((message) => String(message?.regexSourceType || "").trim()), + true, + ); const result = await llm.callLLMForJSON({ systemPrompt: payload.systemPrompt, userPrompt: payload.userPrompt, @@ -492,6 +529,22 @@ try { assert.ok(runtimePromptBuild); assert.ok(runtimeLlmRequest); assert.match(JSON.stringify(runtimeLlmRequest.messages), /FINAL_GOOD/); + assert.equal( + runtimeLlmRequest.messages.some((message) => + String(message?.regexSourceType || "").trim(), + ), + true, + ); + assert.equal( + runtimeLlmRequest.transportMessages.some((message) => + Object.prototype.hasOwnProperty.call(message || {}, "regexSourceType"), + ), + false, + ); + assert.doesNotMatch( + JSON.stringify(capturedBodies[0].messages), + /regexSourceType|sourceKey|blockId|contentOrigin|speaker/i, + ); assert.equal(runtimeLlmRequest.requestCleaning?.applied, true); assert.equal( runtimeLlmRequest.requestCleaning?.stages?.length > 0, @@ -516,7 +569,7 @@ try { /status_current_variable|updatevariable|StatusPlaceHolderImpl|stat_data|display_data|delta_data|get_message_variable/i, ); assert.deepEqual( - runtimeLlmRequest.messages, + runtimeLlmRequest.transportMessages, runtimeLlmRequest.requestBody.messages, ); assert.equal( diff --git a/tests/recall-authoritative-generation-input.mjs b/tests/recall-authoritative-generation-input.mjs index 47d2610..c992f3d 100644 --- a/tests/recall-authoritative-generation-input.mjs +++ b/tests/recall-authoritative-generation-input.mjs @@ -199,6 +199,29 @@ async function testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled() { assert.equal(transaction.frozenRecallOptions.includeSyntheticUserMessage, true); } +async function testGenerationAfterCommandsWritesBackAuthoritativePromptWhenPreserved() { + const harness = await createGenerationRecallHarness(); + harness.extension_settings[MODULE_NAME] = { + recallUseAuthoritativeGenerationInput: true, + }; + harness.chat = [{ is_user: true, mes: "旧的 chat tail" }]; + harness.pendingRecallSendIntent = { + text: "发送前权威输入", + hash: "hash-phase4-writeback", + at: Date.now(), + source: "dom-intent", + }; + const params = { + prompt: "旧 prompt", + user_input: "旧 user_input", + }; + + await harness.result.onGenerationAfterCommands("normal", params, false); + + assert.equal(params.prompt, "发送前权威输入"); + assert.equal(params.user_input, "发送前权威输入"); +} + function testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage() { const runtime = { normalizeRecallInputText(value = "") { @@ -240,6 +263,7 @@ await testSendIntentCanRemainAuthoritativeQueryWhenFlagEnabled(); await testPlannerHandoffCanRemainAuthoritativeQueryWhenFlagEnabled(); await testAuthoritativeSendIntentStaysFrozenAcrossHooksWhenFlagEnabled(); await testHostSnapshotCanRemainAuthoritativeQueryWhenFlagEnabled(); +await testGenerationAfterCommandsWritesBackAuthoritativePromptWhenPreserved(); testResolveRecallInputControllerAppendsSyntheticAuthoritativeUserMessage(); console.log("recall-authoritative-generation-input tests passed"); diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs index cc00b83..fb662b9 100644 --- a/tests/task-regex.mjs +++ b/tests/task-regex.mjs @@ -12,6 +12,16 @@ const extensionsShimSource = [ const extensionsShimUrl = `data:text/javascript,${encodeURIComponent( extensionsShimSource, )}`; +const regexEngineShimSource = [ + "export const regex_placement = { USER_INPUT: 1, AI_OUTPUT: 2, SLASH_COMMAND: 3, WORLD_INFO: 5, REASONING: 6 };", + "export function getRegexedString(...args) {", + " const fn = globalThis.__taskRegexTestCoreGetRegexedString;", + " return typeof fn === 'function' ? fn(...args) : String(args?.[0] ?? '');", + "}", +].join("\n"); +const regexEngineShimUrl = `data:text/javascript,${encodeURIComponent( + regexEngineShimSource, +)}`; installResolveHooks([ { @@ -22,6 +32,10 @@ installResolveHooks([ ], url: extensionsShimUrl, }, + { + specifiers: ["../../../../regex/engine.js"], + url: regexEngineShimUrl, + }, ]); const originalSillyTavern = globalThis.SillyTavern; @@ -29,6 +43,7 @@ const originalGetTavernRegexes = globalThis.getTavernRegexes; const originalIsCharacterTavernRegexesEnabled = globalThis.isCharacterTavernRegexesEnabled; const originalExtensionSettings = globalThis.__taskRegexTestExtensionSettings; +const originalCoreGetRegexedString = globalThis.__taskRegexTestCoreGetRegexedString; const PLACEMENT = Object.freeze({ USER_INPUT: 1, @@ -146,6 +161,14 @@ function setTestContext({ }; } +function setCoreRegexedStringHandler(handler = null) { + if (typeof handler === "function") { + globalThis.__taskRegexTestCoreGetRegexedString = handler; + return; + } + delete globalThis.__taskRegexTestCoreGetRegexedString; +} + try { const { initializeHostAdapter } = await import("../host/adapter/index.js"); const { applyHostRegexReuse, applyTaskRegex, inspectTaskRegexReuse } = await import( @@ -157,6 +180,8 @@ try { normalizeTaskProfile, normalizeTaskRegexStages, } = await import("../prompting/prompt-profiles.js"); + const initializeFallbackHostAdapter = () => + initializeHostAdapter({ disableCoreRegexBridge: true }); const normalizedLegacyStages = normalizeTaskRegexStages({ finalPrompt: true, @@ -245,6 +270,48 @@ try { true, ); + setTestContext({ + extensionSettings: { + regex: [], + preset_allowed_regex: {}, + character_allowed_regex: [], + }, + }); + const coreFormatterCalls = []; + setCoreRegexedStringHandler((text, placement, options) => { + coreFormatterCalls.push({ text, placement, options }); + return String(text || "").replace(/Alpha/g, "CORE"); + }); + initializeHostAdapter({}); + const coreBridgeDebug = { entries: [] }; + const coreBridgeOutput = applyHostRegexReuse( + buildSettings(), + "extract", + "Alpha Beta", + { + sourceType: "user_input", + role: "user", + debugCollector: coreBridgeDebug, + }, + ); + assert.equal(coreBridgeOutput.text, "CORE Beta"); + assert.deepEqual(coreFormatterCalls, [ + { + text: "Alpha Beta", + placement: 1, + options: { + isPrompt: true, + isMarkdown: false, + }, + }, + ]); + assert.equal(coreBridgeDebug.entries[0].executionMode, "host-real"); + assert.equal( + inspectTaskRegexReuse(buildSettings(), "extract").host.bridgeTier, + "core-real", + ); + setCoreRegexedStringHandler(null); + globalThis.getTavernRegexes = () => { throw new Error("legacy global getter should not be used in regex tests"); }; @@ -333,11 +400,15 @@ try { }, }, ]); - assert.equal(fullBridgeDebug.entries[0].executionMode, "host-real"); + assert.equal(fullBridgeDebug.entries[0].executionMode, "host-helper"); assert.deepEqual( fullBridgeDebug.entries[0].appliedRules.map((item) => item.id), ["__host_formatter__"], ); + assert.equal( + inspectTaskRegexReuse(fullBridgeSettings, "extract").host.bridgeTier, + "helper-bridge", + ); assert.equal( applyTaskRegex( fullBridgeSettings, @@ -383,7 +454,7 @@ try { }, ], }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const fallbackDebug = { entries: [] }; const fallbackOutput = applyHostRegexReuse( @@ -412,7 +483,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const depthMissResult = applyHostRegexReuse( buildSettings({ sources: { @@ -476,7 +547,7 @@ try { }, ], }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const fallbackInspect = inspectTaskRegexReuse(buildSettings(), "extract"); assert.equal(fallbackInspect.activeRuleCount, 3); assert.deepEqual( @@ -525,7 +596,7 @@ try { }, ], }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const disallowedOutput = applyHostRegexReuse( buildSettings(), @@ -587,7 +658,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const userReuseResult = applyHostRegexReuse( tavernSemanticsSettings, @@ -641,7 +712,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const markdownFinalDebug = { entries: [] }; const markdownFallbackResult = applyHostRegexReuse( markdownOnlyFinalPromptSettings, @@ -675,7 +746,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const beautifyFinalInspect = inspectTaskRegexReuse( beautifyFinalPromptSettings, "extract", @@ -759,7 +830,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const destinationDebug = { entries: [] }; const destinationReuseResult = applyHostRegexReuse( destinationBeautifySettings, @@ -817,7 +888,7 @@ try { character_allowed_regex: [], }, }); - initializeHostAdapter({}); + initializeFallbackHostAdapter(); const mixedReuseResult = applyHostRegexReuse( tavernSemanticsSettings, "extract", @@ -889,6 +960,12 @@ try { globalThis.__taskRegexTestExtensionSettings = originalExtensionSettings; } + if (originalCoreGetRegexedString === undefined) { + delete globalThis.__taskRegexTestCoreGetRegexedString; + } else { + globalThis.__taskRegexTestCoreGetRegexedString = originalCoreGetRegexedString; + } + try { const { initializeHostAdapter } = await import("../host/adapter/index.js"); initializeHostAdapter({}); diff --git a/ui/panel.js b/ui/panel.js index b2db996..9ab57e4 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -7349,6 +7349,11 @@ function _renderRegexReuseBadges(rule = {}) { className: "is-transform", text: "宿主真实执行", }); + } else if (rule.promptStageMode === "host-helper") { + badges.push({ + className: "is-prompt", + text: "Helper 兼容执行", + }); } else if (rule.promptStageMode === "host-fallback") { badges.push({ className: "is-prompt", @@ -7514,7 +7519,7 @@ function _buildRegexReusePopupContent(snapshot = {}) {
桥接模式 - ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.executionMode || snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.formatterAvailable ? " · formatter" : ""}${snapshot.host?.fallback ? " · fallback" : ""} + ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.executionMode || snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.bridgeTier ? ` · ${_escHtml(snapshot.host.bridgeTier)}` : ""}${snapshot.host?.formatterAvailable ? " · formatter" : ""}${snapshot.host?.fallback ? " · fallback" : ""}
diff --git a/ui/recall-message-ui.js b/ui/recall-message-ui.js index 35f0e3f..4c101e1 100644 --- a/ui/recall-message-ui.js +++ b/ui/recall-message-ui.js @@ -82,6 +82,7 @@ function formatTokenHint(tokenEstimate) { function formatMetaLine(record) { const parts = []; if (record.recallSource) parts.push(`来源: ${record.recallSource}`); + if (record.authoritativeInputUsed) parts.push("权威输入"); if (record.tokenEstimate > 0) parts.push(`~${record.tokenEstimate} tokens`); if (Number.isFinite(record.generationCount) && record.generationCount > 0) { parts.push(`回退 ${record.generationCount} 次`); @@ -180,6 +181,8 @@ function buildExpandedRenderSignature({ return stableSerialize({ updatedAt: String(record?.updatedAt || ""), manuallyEdited: Boolean(record?.manuallyEdited), + authoritativeInputUsed: Boolean(record?.authoritativeInputUsed), + boundUserFloorText: String(record?.boundUserFloorText || ""), generationCount: Number.isFinite(record?.generationCount) ? record.generationCount : 0,