diff --git a/index.js b/index.js index e4d460a..4b9fa14 100644 --- a/index.js +++ b/index.js @@ -126,6 +126,7 @@ import { createDefaultTaskProfiles, migrateLegacyTaskProfiles, } from "./prompt-profiles.js"; +import { inspectTaskRegexReuse } from "./task-regex.js"; import { applyRecallInjectionController, buildRecallRecentMessagesController, @@ -350,6 +351,9 @@ function readRuntimeDebugSnapshot() { taskPromptBuilds: {}, taskLlmRequests: {}, injections: {}, + messageTrace: { + lastSentUserMessage: null, + }, maintenance: { lastAction: null, lastUndoResult: null, @@ -1206,6 +1210,9 @@ function clearRecallInputTracking() { pendingRecallSendIntent = createRecallInputRecord(); lastRecallSentUserMessage = createRecallInputRecord(); pendingHostGenerationInputSnapshot = createRecallInputRecord(); + recordMessageTraceSnapshot({ + lastSentUserMessage: null, + }); clearPlannerRecallHandoffsForChat("", { clearAll: true }); } @@ -9521,6 +9528,8 @@ async function onReembedDirect() { testMemoryLLM: onTestMemoryLLM, fetchMemoryLLMModels: onFetchMemoryLLMModels, fetchEmbeddingModels: onFetchEmbeddingModels, + inspectTaskRegexReuse: (taskType) => + inspectTaskRegexReuse(getSettings(), taskType), applyCurrentHide: () => applyMessageHideNow("panel-manual-apply"), clearCurrentHide: () => clearAllHiddenMessages("panel-manual-clear"), rebuildVectorIndex: () => onRebuildVectorIndex(), diff --git a/llm.js b/llm.js index 2931f8d..0c8f406 100644 --- a/llm.js +++ b/llm.js @@ -215,6 +215,104 @@ function applyTaskOutputRegexStages(taskType, text) { }; } +function applyTaskFinalInputRegex(taskType, messages = []) { + const normalizedMessages = (Array.isArray(messages) ? messages : []) + .map((message) => { + if (!message || typeof message !== "object") { + return null; + } + const role = String(message.role || "").trim().toLowerCase(); + if (!["system", "user", "assistant"].includes(role)) { + return null; + } + return { + ...message, + role, + content: String(message.content || ""), + }; + }) + .filter(Boolean); + const normalizedTaskType = String(taskType || "").trim(); + + if (!normalizedTaskType || normalizedMessages.length === 0) { + const cleanedMessages = normalizedMessages.filter((message) => + String(message.content || "").trim(), + ); + return { + messages: cleanedMessages, + debug: { + stage: "input.finalPrompt", + changed: cleanedMessages.length !== normalizedMessages.length, + applied: false, + rawMessageCount: normalizedMessages.length, + cleanedMessageCount: cleanedMessages.length, + droppedMessageCount: normalizedMessages.length - cleanedMessages.length, + stages: [], + }, + }; + } + + const settings = extension_settings[MODULE_NAME] || {}; + const regexDebug = { entries: [] }; + let changed = false; + let droppedMessageCount = 0; + const cleanedMessages = normalizedMessages + .map((message) => { + const originalContent = String(message.content || ""); + const cleanedContent = applyTaskRegex( + settings, + normalizedTaskType, + "input.finalPrompt", + originalContent, + regexDebug, + message.role, + ); + if (cleanedContent !== originalContent) { + changed = true; + } + if (!String(cleanedContent || "").trim()) { + droppedMessageCount += 1; + return null; + } + return { + ...message, + content: cleanedContent, + }; + }) + .filter(Boolean); + const normalizedEntries = normalizeRegexDebugEntries(regexDebug); + const applied = normalizedEntries.some( + (entry) => entry.appliedRules.length > 0, + ); + + return { + messages: cleanedMessages, + debug: { + stage: "input.finalPrompt", + changed: changed || droppedMessageCount > 0, + applied, + rawMessageCount: normalizedMessages.length, + cleanedMessageCount: cleanedMessages.length, + droppedMessageCount, + stages: normalizedEntries, + }, + }; +} + +function attachRequestCleaningToPromptExecution( + promptExecutionSummary, + requestCleaning, +) { + const base = + promptExecutionSummary && typeof promptExecutionSummary === "object" + ? cloneRuntimeDebugValue(promptExecutionSummary, {}) + : {}; + if (requestCleaning && typeof requestCleaning === "object") { + base.requestCleaning = cloneRuntimeDebugValue(requestCleaning, null); + } + return base; +} + function buildEffectiveLlmRoute( hasDedicatedConfig, privateRequestSource, @@ -1477,7 +1575,7 @@ export async function callLLMForJSON({ for (let attempt = 0; attempt <= maxRetries; attempt++) { try { - const messages = buildJsonAttemptMessages( + const assembledMessages = buildJsonAttemptMessages( systemPrompt, userPrompt, attempt, @@ -1485,7 +1583,25 @@ export async function callLLMForJSON({ additionalMessages, promptMessages, ); - const response = await callDedicatedOpenAICompatible(messages, { + const requestCleaning = applyTaskFinalInputRegex( + taskType, + assembledMessages, + ); + const promptExecutionSnapshot = attachRequestCleaningToPromptExecution( + promptExecutionSummary, + requestCleaning.debug, + ); + recordTaskLlmRequest( + taskType || privateRequestSource, + { + requestCleaning: requestCleaning.debug, + promptExecution: promptExecutionSnapshot, + }, + { + merge: true, + }, + ); + const response = await callDedicatedOpenAICompatible(requestCleaning.messages, { signal, jsonMode: true, taskType, @@ -1500,8 +1616,9 @@ export async function callLLMForJSON({ recordTaskLlmRequest( taskType || privateRequestSource, { + requestCleaning: requestCleaning.debug, responseCleaning: outputCleanup.debug, - promptExecution: promptExecutionSummary, + promptExecution: promptExecutionSnapshot, }, { merge: true, @@ -1592,19 +1709,48 @@ export async function callLLM(systemPrompt, userPrompt, options = {}) { return await override(systemPrompt, userPrompt, options); } - const messages = [ + const taskType = String(options.taskType || "").trim(); + const privateRequestSource = resolvePrivateRequestSource( + taskType, + options.requestSource || options.source || "diagnostic:call-llm", + { allowAnonymous: true }, + ); + const promptExecutionSummary = buildPromptExecutionSummary( + options.debugContext || null, + ); + const assembledMessages = [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, ]; + const requestCleaning = applyTaskFinalInputRegex(taskType, assembledMessages); + const promptExecutionSnapshot = attachRequestCleaningToPromptExecution( + promptExecutionSummary, + requestCleaning.debug, + ); try { - const response = await callDedicatedOpenAICompatible(messages, { - signal: options.signal, - taskType: options.taskType || "", - requestSource: - options.requestSource || options.source || "diagnostic:call-llm", + recordTaskLlmRequest(taskType || privateRequestSource, { + requestCleaning: requestCleaning.debug, + promptExecution: promptExecutionSnapshot, + }, { + merge: true, }); - return response?.content || null; + const response = await callDedicatedOpenAICompatible(requestCleaning.messages, { + signal: options.signal, + taskType, + requestSource: privateRequestSource, + }); + const responseText = + typeof response?.content === "string" ? response.content : ""; + const outputCleanup = applyTaskOutputRegexStages(taskType, responseText); + recordTaskLlmRequest(taskType || privateRequestSource, { + requestCleaning: requestCleaning.debug, + responseCleaning: outputCleanup.debug, + promptExecution: promptExecutionSnapshot, + }, { + merge: true, + }); + return outputCleanup.cleanedText || null; } catch (e) { console.error("[ST-BME] LLM 调用失败:", e); return null; diff --git a/panel.js b/panel.js index 5ea8f37..d31a408 100644 --- a/panel.js +++ b/panel.js @@ -1,5 +1,6 @@ // ST-BME: 操控面板交互逻辑 +import { callGenericPopup, POPUP_TYPE } from "../../../popup.js"; import { renderTemplateAsync } from "../../../templates.js"; import { GraphRenderer } from "./graph-renderer.js"; import { getNodeDisplayName } from "./node-labels.js"; @@ -20,6 +21,8 @@ import { getLegacyPromptFieldForTask, getTaskTypeOptions, importTaskProfile as parseImportedTaskProfile, + isTaskRegexStageEnabled, + normalizeTaskRegexStages, restoreDefaultTaskProfile, setActiveTaskProfileId, upsertTaskProfile, @@ -143,8 +146,46 @@ const TASK_PROFILE_GENERATION_GROUPS = [ ]; const TASK_PROFILE_REGEX_STAGES = [ - { key: "input", label: "输入阶段", desc: "对发送给 LLM 的 prompt 执行正则替换。" }, - { key: "output", label: "输出阶段", desc: "对 LLM 返回的结果执行正则替换。" }, + { + key: "input", + label: "输入总开关", + desc: "控制全部输入阶段;未单独覆写的细分阶段会跟随它。", + }, + { + key: "input.userMessage", + label: "输入: 用户消息", + desc: "处理当前 userMessage。", + }, + { + key: "input.recentMessages", + label: "输入: 最近上下文", + desc: "处理 recentMessages、chatMessages、dialogueText。", + }, + { + key: "input.candidateText", + label: "输入: 候选与摘要", + desc: "处理 candidateText、candidateNodes、nodeContent 和各类摘要。", + }, + { + key: "input.finalPrompt", + label: "输入: 发送前最终消息", + desc: "在最终 messages 全部组装完成、真正发送给 LLM 前统一清洗。", + }, + { + key: "output", + label: "输出总开关", + desc: "控制全部输出阶段;未单独覆写的细分阶段会跟随它。", + }, + { + key: "output.rawResponse", + label: "输出: 原始响应", + desc: "LLM 原始文本到手后先清洗一次。", + }, + { + key: "output.beforeParse", + label: "输出: 解析前", + desc: "在 JSON 提取/解析前再清洗一次。", + }, ]; let panelEl = null; @@ -792,6 +833,11 @@ function _applyWorkspaceMode() { function _switchConfigSection(sectionId) { currentConfigSectionId = sectionId || "api"; _syncConfigSectionState(); + if (currentConfigSectionId === "prompts") { + _refreshTaskProfileWorkspace(); + } else if (currentConfigSectionId === "trace") { + _refreshMessageTraceWorkspace(); + } } function _syncConfigSectionState() { @@ -2738,7 +2784,6 @@ function _renderMessageTraceWorkspace(state) { function _renderMessageTraceRecallCard(state) { const injectionSnapshot = state.recallInjection || null; - const recallLlmRequest = state.recallLlmRequest || null; const recentMessages = Array.isArray(injectionSnapshot?.recentMessages) ? injectionSnapshot.recentMessages.map((item) => String(item || "")) : []; @@ -2747,12 +2792,19 @@ function _renderMessageTraceRecallCard(state) { ).trim(); const triggeredUserMessage = lastSentUserMessage || - _extractTriggeredUserMessageFromRecentMessages(recentMessages) || - _getLastDebugMessageContent(recallLlmRequest?.messages, "user"); + _extractTriggeredUserMessageFromRecentMessages(recentMessages); const hostPayloadText = _buildMainAiTraceText( triggeredUserMessage, injectionSnapshot?.injectionText || "", ); + const missingUserMessageNotice = + injectionSnapshot && !triggeredUserMessage + ? ` +
+ 这次没有可靠捕获到主 AI 那边的用户消息,因此这里只展示真实记录到的记忆注入文本,不再用 recall 模型请求去反推,避免误导排查。 +
+ ` + : ""; if (!injectionSnapshot) { return ` @@ -2770,6 +2822,7 @@ function _renderMessageTraceRecallCard(state) { ${_escHtml(_formatTaskProfileTime(injectionSnapshot.updatedAt))} + ${missingUserMessageNotice} ${_renderMessageTraceTextBlock( "发送给主 AI 的内容", hostPayloadText, @@ -2838,18 +2891,6 @@ function _normalizeDebugMessages(messages = []) { .filter(Boolean); } -function _getLastDebugMessageContent(messages = [], role = "") { - const normalizedRole = String(role || "").trim().toLowerCase(); - const normalizedMessages = _normalizeDebugMessages(messages); - for (let index = normalizedMessages.length - 1; index >= 0; index--) { - const message = normalizedMessages[index]; - if (!normalizedRole || message.role === normalizedRole) { - return message.content; - } - } - return ""; -} - function _stringifyTraceMessages(messages = []) { const normalizedMessages = _normalizeDebugMessages(messages); if (!normalizedMessages.length) return ""; @@ -2953,6 +2994,9 @@ async function _handleTaskProfileWorkspaceClick(event) { } _refreshTaskProfileWorkspace(); return; + case "inspect-tavern-regex": + await _openRegexReuseInspector(state.taskType); + return; case "select-block": currentTaskProfileBlockId = actionEl.dataset.blockId || ""; _refreshTaskProfileWorkspace(); @@ -3347,6 +3391,7 @@ function _renderTaskGenerationTab(state) { function _renderTaskRegexTab(state) { const regex = state.profile.regex || {}; + const normalizedStages = normalizeTaskRegexStages(regex.stages || {}); return `
@@ -3358,6 +3403,9 @@ function _renderTaskRegexTab(state) { 任务预设可复用酒馆正则,并叠加当前任务自己的附加规则。
+
@@ -3420,14 +3468,14 @@ function _renderTaskRegexTab(state) { ${_escHtml(stage.label)} ${_escHtml(stage.desc)} - - - `, - ).join("")} + + + `, + ).join("")}
@@ -3465,6 +3513,164 @@ function _renderTaskRegexTab(state) { `; } +function _formatRegexReuseSourceState(source = {}) { + const states = []; + states.push(source.enabled ? "已启用" : "已关闭"); + states.push(source.allowed === false ? "未获酒馆允许" : "允许参与"); + states.push( + source.resolvedVia === "bridge" + ? "通过桥接读取" + : source.resolvedVia === "fallback" + ? "通过 fallback 读取" + : "来源未知", + ); + return states.join(" · "); +} + +function _renderRegexReuseRuleList(rules = [], emptyText = "无") { + if (!Array.isArray(rules) || rules.length === 0) { + return `
${_escHtml(emptyText)}
`; + } + + return rules + .map((rule) => { + const placementText = Array.isArray(rule.placementLabels) && rule.placementLabels.length + ? rule.placementLabels.join(" / ") + : "未声明 placement"; + const flags = [ + rule.promptOnly ? "promptOnly" : "", + rule.markdownOnly ? "markdownOnly" : "", + rule.reason ? `原因: ${rule.reason}` : "", + ].filter(Boolean); + return ` +
+ ${_escHtml(rule.name || rule.id || "未命名规则")} + ${_escHtml(placementText)} +
+
+ ${_escHtml(rule.findRegex || "(空 findRegex)")} + ${rule.replaceString ? ` -> ${_escHtml(rule.replaceString)}` : ""} + ${flags.length ? `
${_escHtml(flags.join(" · "))}` : ""} +
+ `; + }) + .join(""); +} + +function _buildRegexReusePopupContent(snapshot = {}) { + const container = document.createElement("div"); + const sources = Array.isArray(snapshot.sources) ? snapshot.sources : []; + const activeRules = Array.isArray(snapshot.activeRules) ? snapshot.activeRules : []; + const stageConfig = snapshot.stageConfig && typeof snapshot.stageConfig === "object" + ? snapshot.stageConfig + : {}; + const sourceConfig = snapshot.sourceConfig && typeof snapshot.sourceConfig === "object" + ? snapshot.sourceConfig + : {}; + + container.innerHTML = ` +
+
+
酒馆正则复用快照
+
+ 这里展示的是当前任务预设下,ST-BME 实际会尝试复用的 Tavern 正则来源和规则,不是静态说明文案。 +
+
+
+ 任务 + ${_escHtml(snapshot.taskType || "—")} +
+
+ 预设 + ${_escHtml(snapshot.profileName || snapshot.profileId || "—")} +
+
+ 任务正则 + ${snapshot.regexEnabled ? "已启用" : "已关闭"} +
+
+ 复用酒馆正则 + ${snapshot.inheritStRegex ? "已启用" : "已关闭"} +
+
+ 本地规则数 + ${Number(snapshot.localRuleCount || 0)} +
+
+ 桥接模式 + ${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.fallback ? " · fallback" : ""} +
+
+
+ +
+
当前启用开关
+
+ 来源:global=${sourceConfig.global === false ? "关" : "开"} / preset=${sourceConfig.preset === false ? "关" : "开"} / character=${sourceConfig.character === false ? "关" : "开"} +
+
+ 阶段:${_escHtml(Object.entries(stageConfig).map(([key, value]) => `${key}=${value ? "on" : "off"}`).join(" | ") || "无")} +
+
+ +
+
来源明细
+ ${ + sources.length + ? sources.map((source) => ` +
+ + ${_escHtml(source.label || source.type || "未知来源")} + · ${_escHtml(_formatRegexReuseSourceState(source))} + +
+ raw=${Number(source.rawRuleCount || 0)} / active=${Number(source.activeRuleCount || 0)} + ${source.reason ? `
${_escHtml(source.reason)}` : ""} +
+ + ${_renderRegexReuseRuleList(source.rules, "该来源当前没有进入任务链的复用规则")} + + ${_renderRegexReuseRuleList(source.ignoredRules, "没有被额外跳过的规则")} +
+ `).join("") + : `
当前没有可展示的酒馆正则来源。
` + } +
+ +
+
汇总后的复用规则
+
+ 这是经过来源开关、allowlist 和去重后,准备进入当前任务链的 Tavern 规则集合。 +
+ ${_renderRegexReuseRuleList(activeRules, "当前没有复用到任何酒馆正则")} +
+
+ `; + + return container; +} + +async function _openRegexReuseInspector(taskType) { + if (typeof _actionHandlers.inspectTaskRegexReuse !== "function") { + toastr.info("当前运行时没有接入正则复用诊断入口", "ST-BME"); + return; + } + + try { + const snapshot = await _actionHandlers.inspectTaskRegexReuse(taskType); + const content = _buildRegexReusePopupContent(snapshot || {}); + await callGenericPopup(content, POPUP_TYPE.TEXT, "", { + okButton: "关闭", + wide: true, + large: true, + allowVerticalScrolling: true, + }); + } catch (error) { + console.error("[ST-BME] 打开正则复用检查弹窗失败:", error); + toastr.error("打开正则复用检查弹窗失败", "ST-BME"); + } +} + function _renderTaskDebugTab(state) { const hostCapabilities = state.runtimeDebug?.hostCapabilities || null; const runtimeDebug = state.runtimeDebug?.runtimeDebug || {}; @@ -3759,8 +3965,13 @@ function _renderTaskDebugLlmCard(taskType, llmRequest) { 输出清洗 ${_escHtml(llmRequest.responseCleaning?.applied ? "已生效" : "未生效")} +
+ 发送前输入清洗 + ${_escHtml(llmRequest.requestCleaning?.applied ? "已生效" : "未生效")} +
${_renderDebugDetails("提示词执行摘要", llmRequest.promptExecution || null)} + ${_renderDebugDetails("发送前输入清洗", llmRequest.requestCleaning || null)} ${_renderDebugDetails("实际请求路径", llmRequest.effectiveRoute || null)} ${_renderDebugDetails("输出清洗", llmRequest.responseCleaning || null)} ${_renderDebugDetails("实际保留参数", llmRequest.filteredGeneration || {})} @@ -4628,7 +4839,7 @@ function _normalizeTaskProfileDraft(profile = {}) { stages: { input: true, output: true, - ...(draft.regex?.stages || {}), + ...normalizeTaskRegexStages(draft.regex?.stages || {}), }, localRules: Array.isArray(draft.regex?.localRules) ? draft.regex.localRules.map((rule) => ({ diff --git a/prompt-builder.js b/prompt-builder.js index 0ce9c71..c58c3d5 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -46,6 +46,13 @@ const INPUT_REGEX_STAGE_BY_FIELD = { contradictionSummary: "input.candidateText", }; +const INPUT_REGEX_ROLE_BY_FIELD = { + userMessage: "user", + recentMessages: "mixed", + chatMessages: "mixed", + dialogueText: "mixed", +}; + function cloneRuntimeDebugValue(value, fallback = null) { if (value == null) { return fallback; @@ -526,7 +533,7 @@ function sanitizePromptMessages( messages = [], { blockedContents = [], - regexStage = "input.finalPrompt", + regexStage = "", debugState = null, regexCollector = null, } = {}, @@ -601,6 +608,7 @@ function sanitizePromptContextInputs( } const value = sanitizedContext[fieldName]; const regexStage = INPUT_REGEX_STAGE_BY_FIELD[fieldName] || ""; + const regexRole = INPUT_REGEX_ROLE_BY_FIELD[fieldName] || "system"; const sanitized = sanitizeStructuredPromptValue( settings, taskType, @@ -610,7 +618,7 @@ function sanitizePromptContextInputs( path: fieldName, mode: "aggressive", regexStage, - role: "system", + role: regexRole, debugState, regexCollector, applyMvu, @@ -646,7 +654,7 @@ function sanitizeWorldInfoEntries( { mode: "aggressive", blockedContents, - regexStage: "input.finalPrompt", + regexStage: "", role: entry?.role || "system", regexCollector, }, @@ -728,7 +736,7 @@ function sanitizeWorldInfoContext( { mode: "aggressive", blockedContents: runtimeBlockedContents, - regexStage: "input.finalPrompt", + regexStage: "", role: message?.role || "system", regexCollector, }, @@ -1107,7 +1115,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { { mode: "final-safe", blockedContents: worldInfoRuntimeBlockedContents, - regexStage: "input.finalPrompt", + regexStage: "", role, regexCollector: promptRegexInput, }, diff --git a/prompt-profiles.js b/prompt-profiles.js index ffdac17..9369151 100644 --- a/prompt-profiles.js +++ b/prompt-profiles.js @@ -569,6 +569,88 @@ function normalizeRegexLocalRule(rule = {}, taskType = "task", index = 0) { }; } +const TASK_REGEX_STAGE_ALIAS_MAP = Object.freeze({ + finalPrompt: "input.finalPrompt", + rawResponse: "output.rawResponse", + beforeParse: "output.beforeParse", +}); + +const TASK_REGEX_STAGE_GROUPS = Object.freeze({ + input: Object.freeze([ + "input.userMessage", + "input.recentMessages", + "input.candidateText", + "input.finalPrompt", + ]), + output: Object.freeze([ + "output.rawResponse", + "output.beforeParse", + ]), +}); + +function normalizeRegexStageKey(stageKey = "") { + const normalized = String(stageKey || "").trim(); + return TASK_REGEX_STAGE_ALIAS_MAP[normalized] || normalized; +} + +export function normalizeTaskRegexStages(stages = {}) { + const source = + stages && typeof stages === "object" && !Array.isArray(stages) ? stages : {}; + const normalized = { ...source }; + + for (const [legacyKey, canonicalKey] of Object.entries( + TASK_REGEX_STAGE_ALIAS_MAP, + )) { + if ( + !Object.prototype.hasOwnProperty.call(normalized, canonicalKey) && + Object.prototype.hasOwnProperty.call(normalized, legacyKey) + ) { + normalized[canonicalKey] = Boolean(normalized[legacyKey]); + } + delete normalized[legacyKey]; + } + + for (const [groupKey, stageKeys] of Object.entries(TASK_REGEX_STAGE_GROUPS)) { + if (normalized[groupKey] === false) { + continue; + } + const allSpecificStagesFalse = + stageKeys.length > 0 && + stageKeys.every((stageKey) => normalized[stageKey] === false); + if (!allSpecificStagesFalse) { + continue; + } + for (const stageKey of stageKeys) { + delete normalized[stageKey]; + } + } + + return normalized; +} + +export function isTaskRegexStageEnabled(stages = {}, stageKey = "") { + const normalizedStages = normalizeTaskRegexStages(stages); + const normalizedStageKey = normalizeRegexStageKey(stageKey); + + if (!normalizedStageKey) { + return normalizedStages.input !== false; + } + + if (Object.prototype.hasOwnProperty.call(normalizedStages, normalizedStageKey)) { + return normalizedStages[normalizedStageKey] !== false; + } + + if (normalizedStageKey.startsWith("input.")) { + return normalizedStages.input !== false; + } + + if (normalizedStageKey.startsWith("output.")) { + return normalizedStages.output !== false; + } + + return normalizedStages[normalizedStageKey] !== false; +} + function normalizeTaskProfilesState(taskProfiles = {}) { return ensureTaskProfiles({ taskProfiles }); } @@ -741,7 +823,7 @@ function createFallbackDefaultTaskProfile(taskType) { preset: true, character: true, }, - stages: { + stages: normalizeTaskRegexStages({ finalPrompt: true, "input.userMessage": false, "input.recentMessages": false, @@ -751,7 +833,7 @@ function createFallbackDefaultTaskProfile(taskType) { beforeParse: false, "output.rawResponse": false, "output.beforeParse": false, - }, + }), localRules: [], }, metadata: { @@ -799,10 +881,10 @@ export function createDefaultTaskProfile(taskType) { ...fallback.regex.sources, ...(template?.regex?.sources || {}), }, - stages: { + stages: normalizeTaskRegexStages({ ...fallback.regex.stages, ...(template?.regex?.stages || {}), - }, + }), localRules: Array.isArray(template?.regex?.localRules) ? template.regex.localRules.map((rule, index) => normalizeRegexLocalRule(rule, taskType, index), @@ -978,10 +1060,10 @@ export function normalizeTaskProfile(taskType, profile = {}, settings = {}) { ...base.regex.sources, ...(profile?.regex?.sources || {}), }, - stages: { + stages: normalizeTaskRegexStages({ ...base.regex.stages, ...(profile?.regex?.stages || {}), - }, + }), localRules: Array.isArray(profile?.regex?.localRules) ? profile.regex.localRules.map((rule, index) => normalizeRegexLocalRule(rule, taskType, index), diff --git a/task-regex.js b/task-regex.js index 92de906..c776587 100644 --- a/task-regex.js +++ b/task-regex.js @@ -4,11 +4,29 @@ import { extension_settings, getContext } from "../../../extensions.js"; import { getHostAdapter } from "./host-adapter/index.js"; -import { getActiveTaskProfile } from "./prompt-profiles.js"; +import { + getActiveTaskProfile, + isTaskRegexStageEnabled, + normalizeTaskRegexStages, +} from "./prompt-profiles.js"; const HTML_TAG_PATTERN = /<\/?(?:div|span|p|br|hr|img|details|summary|section|article|aside|header|footer|nav|ul|ol|li|table|tr|td|th|h[1-6]|a|em|strong|blockquote|pre|code|svg|path)\b/i; const HTML_ATTR_PATTERN = /\b(?:style|class|id|href|src|data-)\s*=/i; +const TAVERN_REGEX_PLACEMENT = Object.freeze({ + USER_INPUT: 1, + AI_OUTPUT: 2, + SLASH_COMMAND: 3, + WORLD_INFO: 5, + REASONING: 6, +}); +const TAVERN_REGEX_PLACEMENT_LABELS = Object.freeze({ + [TAVERN_REGEX_PLACEMENT.USER_INPUT]: "用户输入", + [TAVERN_REGEX_PLACEMENT.AI_OUTPUT]: "AI 输出", + [TAVERN_REGEX_PLACEMENT.SLASH_COMMAND]: "斜杠命令", + [TAVERN_REGEX_PLACEMENT.WORLD_INFO]: "世界书", + [TAVERN_REGEX_PLACEMENT.REASONING]: "推理/思维", +}); const PROMPT_STAGES = new Set([ "finalPrompt", @@ -65,6 +83,53 @@ function normalizeTrimStrings(rawTrim) { return []; } +function normalizeRulePlacement(rawPlacement) { + const placement = Array.isArray(rawPlacement) ? rawPlacement : []; + return placement + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)); +} + +function isTavernRuleShape(raw = {}) { + return ( + Array.isArray(raw?.placement) || + Object.prototype.hasOwnProperty.call(raw || {}, "promptOnly") || + Object.prototype.hasOwnProperty.call(raw || {}, "markdownOnly") || + Object.prototype.hasOwnProperty.call(raw || {}, "scriptName") || + Object.prototype.hasOwnProperty.call(raw || {}, "findRegex") || + Object.prototype.hasOwnProperty.call(raw || {}, "replaceString") + ); +} + +function buildRuleSourceFlags(source, placement, isTavernRule) { + if (source && typeof source === "object") { + return { + user: Boolean(source.user_input), + assistant: Boolean(source.ai_output), + system: Boolean(source.ai_output), + }; + } + + if (isTavernRule && placement.length > 0) { + return { + user: placement.includes(TAVERN_REGEX_PLACEMENT.USER_INPUT), + assistant: placement.includes(TAVERN_REGEX_PLACEMENT.AI_OUTPUT), + system: placement.some((item) => + [ + TAVERN_REGEX_PLACEMENT.WORLD_INFO, + TAVERN_REGEX_PLACEMENT.REASONING, + ].includes(item), + ), + }; + } + + return { + user: true, + assistant: true, + system: true, + }; +} + function normalizeRule(raw = {}, fallbackSource = "local", index = 0) { const destination = raw?.destination && typeof raw.destination === "object" @@ -72,6 +137,8 @@ function normalizeRule(raw = {}, fallbackSource = "local", index = 0) { : null; const source = raw?.source && typeof raw.source === "object" ? raw.source : null; + const placement = normalizeRulePlacement(raw?.placement); + const isTavernRule = isTavernRuleShape(raw); return { id: String(raw.id || `${fallbackSource}-${index + 1}`), @@ -82,19 +149,25 @@ function normalizeRule(raw = {}, fallbackSource = "local", index = 0) { raw.replace_string ?? raw.replaceString ?? raw.replace ?? "", ), trimStrings: normalizeTrimStrings(raw.trim_strings ?? raw.trimStrings), - sourceFlags: { - user: source ? Boolean(source.user_input) : true, - assistant: source ? Boolean(source.ai_output) : true, - system: source ? Boolean(source.ai_output) : true, - }, + sourceFlags: buildRuleSourceFlags(source, placement, isTavernRule), destinationFlags: { prompt: destination ? Boolean(destination.prompt) - : raw.promptOnly !== true, + : raw.markdownOnly !== true, display: destination ? Boolean(destination.display) : Boolean(raw.markdownOnly), }, + promptOnly: Boolean(raw.promptOnly), + markdownOnly: Boolean(raw.markdownOnly), + placement, + minDepth: Number.isFinite(Number(raw.min_depth ?? raw.minDepth)) + ? Number(raw.min_depth ?? raw.minDepth) + : null, + maxDepth: Number.isFinite(Number(raw.max_depth ?? raw.maxDepth)) + ? Number(raw.max_depth ?? raw.maxDepth) + : null, + isTavernRule, sourceType: fallbackSource, raw, }; @@ -190,6 +263,136 @@ function getRegexHost() { }; } +function getPresetManagerFromContext(context = {}) { + if (typeof context?.getPresetManager !== "function") { + return null; + } + + try { + const manager = context.getPresetManager(); + return manager && typeof manager === "object" ? manager : null; + } catch { + return null; + } +} + +function getCurrentPresetInfo(context = {}) { + const presetManager = getPresetManagerFromContext(context); + const apiId = String(presetManager?.apiId || "").trim(); + const presetName = + typeof presetManager?.getSelectedPresetName === "function" + ? String(presetManager.getSelectedPresetName() || "").trim() + : ""; + + return { + presetManager, + apiId, + presetName, + }; +} + +function isPresetRegexAllowed(extSettings = {}, apiId = "", presetName = "") { + if (!apiId || !presetName) { + return false; + } + return Boolean(extSettings?.preset_allowed_regex?.[apiId]?.includes?.(presetName)); +} + +function getCurrentCharacterInfo(context = {}) { + const rawCharacterId = context?.characterId; + const characterId = Number(rawCharacterId); + if (!Number.isFinite(characterId) || characterId < 0) { + return { + characterId: null, + character: null, + avatar: "", + }; + } + + const characters = Array.isArray(context?.characters) ? context.characters : []; + const character = characters[characterId] || null; + + return { + characterId, + character, + avatar: String(character?.avatar || ""), + }; +} + +function isCharacterRegexAllowed(extSettings = {}, avatar = "") { + if (!avatar) { + return false; + } + return Boolean(extSettings?.character_allowed_regex?.includes?.(avatar)); +} + +function readGlobalFallbackRules(extSettings = {}) { + return readArrayPath(extSettings, [ + ["regex"], + ["regex_scripts"], + ["regex", "regex_scripts"], + ]); +} + +function readPresetFallbackRules(context = {}, oaiSettings = {}) { + const { presetManager } = getCurrentPresetInfo(context); + if (typeof presetManager?.readPresetExtensionField === "function") { + try { + const scripts = presetManager.readPresetExtensionField({ + path: "regex_scripts", + }); + if (Array.isArray(scripts)) { + return scripts; + } + } catch { + // ignore and continue to legacy paths + } + } + + return readArrayPath(oaiSettings, [ + ["regex_scripts"], + ["extensions", "regex_scripts"], + ]); +} + +function readCharacterFallbackRules(context = {}) { + const { character } = getCurrentCharacterInfo(context); + if (!character) { + return []; + } + + return readArrayPath(character, [ + ["data", "extensions", "regex_scripts"], + ["extensions", "regex_scripts"], + ]); +} + +function getPlacementLabels(placement = []) { + return (Array.isArray(placement) ? placement : []).map( + (item) => TAVERN_REGEX_PLACEMENT_LABELS[item] || `#${item}`, + ); +} + +function summarizeRule(rule, reason = "") { + const normalized = rule && typeof rule === "object" ? rule : {}; + return { + id: String(normalized.id || ""), + name: String(normalized.scriptName || normalized.id || ""), + findRegex: String(normalized.findRegex || ""), + replaceString: String(normalized.replaceString || ""), + sourceType: String(normalized.sourceType || ""), + promptOnly: Boolean(normalized.promptOnly), + markdownOnly: Boolean(normalized.markdownOnly), + placement: Array.isArray(normalized.placement) ? [...normalized.placement] : [], + placementLabels: getPlacementLabels(normalized.placement), + minDepth: + normalized.minDepth == null ? null : Number(normalized.minDepth), + maxDepth: + normalized.maxDepth == null ? null : Number(normalized.maxDepth), + reason: String(reason || ""), + }; +} + function collectViaApi(sourceType, regexHost = null) { const getter = regexHost?.getTavernRegexes; if (typeof getter !== "function") { @@ -229,15 +432,13 @@ function collectViaApi(sourceType, regexHost = null) { return unsupported(); } -function collectTavernRules(regexConfig = {}) { +function collectTavernRulesDetailed(regexConfig = {}) { const shouldReuse = regexConfig.inheritStRegex !== false; - if (!shouldReuse) return []; - const sourceConfig = regexConfig.sources || {}; const enabledSources = { - global: sourceConfig.global !== false, - preset: sourceConfig.preset !== false, - character: sourceConfig.character !== false, + global: shouldReuse && sourceConfig.global !== false, + preset: shouldReuse && sourceConfig.preset !== false, + character: shouldReuse && sourceConfig.character !== false, }; const context = getContext?.() || {}; @@ -247,66 +448,170 @@ function collectTavernRules(regexConfig = {}) { const regexHost = getRegexHost(); const collected = []; const seen = new Set(); + const sources = []; - const pushRules = (items, sourceType) => { - for (let index = 0; index < items.length; index++) { - const normalized = normalizeRule(items[index], sourceType, index); - if (!normalized.enabled || !normalized.findRegex) continue; - const key = `${sourceType}:${normalized.id}:${normalized.findRegex}`; - if (seen.has(key)) continue; - seen.add(key); - collected.push(normalized); + const appendSourceSnapshot = ({ + type, + label, + enabled, + supported, + resolvedVia, + allowed = true, + reason = "", + rawItems = [], + }) => { + const effectiveItems = + enabled && allowed ? (Array.isArray(rawItems) ? rawItems : []) : []; + const activeRules = []; + const ignoredRules = []; + + if (!enabled) { + sources.push({ + type, + label, + enabled, + supported, + resolvedVia, + allowed, + reason: + reason || (shouldReuse ? "当前任务已关闭该来源" : "当前任务未启用复用酒馆正则"), + rawRuleCount: Array.isArray(rawItems) ? rawItems.length : 0, + activeRuleCount: 0, + rules: [], + ignoredRules: [], + }); + return; } - }; - if (enabledSources.global) { - const viaApi = collectViaApi("global", regexHost); - if (viaApi.supported) { - pushRules(viaApi.items, "global"); - } else { - pushRules( - readArrayPath(extSettings, [["regex"], ["regex", "regex_scripts"]]), - "global", - ); - } - } - - if (enabledSources.preset) { - const viaApi = collectViaApi("preset", regexHost); - if (viaApi.supported) { - pushRules(viaApi.items, "preset"); - } else { - pushRules( - readArrayPath(oaiSettings, [ - ["regex_scripts"], - ["extensions", "regex_scripts"], - ]), - "preset", - ); - } - } - - if (enabledSources.character) { - const viaApi = collectViaApi("character", regexHost); - if (viaApi.supported) { - pushRules(viaApi.items, "character"); - } else { - const charId = context?.characterId; - const characters = context?.characters; - if (charId !== undefined && characters) { - const character = characters[Number(charId)]; - pushRules( - readArrayPath(character, [ - ["extensions", "regex_scripts"], - ["data", "extensions", "regex_scripts"], - ]), - "character", + if (!allowed && Array.isArray(rawItems)) { + for (let index = 0; index < rawItems.length; index++) { + ignoredRules.push( + summarizeRule(normalizeRule(rawItems[index], type, index), "not-allowed"), ); } } + + for (let index = 0; index < effectiveItems.length; index++) { + const normalized = normalizeRule(effectiveItems[index], type, index); + if (!normalized.enabled) { + ignoredRules.push(summarizeRule(normalized, "disabled")); + continue; + } + if (!normalized.findRegex) { + ignoredRules.push(summarizeRule(normalized, "missing-find-regex")); + continue; + } + const key = `${type}:${normalized.id}:${normalized.findRegex}`; + if (seen.has(key)) { + ignoredRules.push(summarizeRule(normalized, "duplicate")); + continue; + } + seen.add(key); + collected.push(normalized); + activeRules.push(summarizeRule(normalized)); + } + + sources.push({ + type, + label, + enabled, + supported, + resolvedVia, + allowed, + reason, + rawRuleCount: Array.isArray(rawItems) ? rawItems.length : 0, + activeRuleCount: activeRules.length, + rules: activeRules, + ignoredRules, + }); + }; + + const globalViaApi = collectViaApi("global", regexHost); + appendSourceSnapshot({ + type: "global", + label: "全局", + enabled: enabledSources.global, + supported: true, + resolvedVia: globalViaApi.supported ? "bridge" : "fallback", + rawItems: globalViaApi.supported + ? globalViaApi.items + : readGlobalFallbackRules(extSettings), + }); + + const presetViaApi = collectViaApi("preset", regexHost); + if (presetViaApi.supported) { + appendSourceSnapshot({ + type: "preset", + label: "当前预设", + enabled: enabledSources.preset, + supported: true, + resolvedVia: "bridge", + rawItems: presetViaApi.items, + }); + } else { + const { apiId, presetName } = getCurrentPresetInfo(context); + const rawItems = readPresetFallbackRules(context, oaiSettings); + const allowed = isPresetRegexAllowed(extSettings, apiId, presetName); + appendSourceSnapshot({ + type: "preset", + label: "当前预设", + enabled: enabledSources.preset, + supported: true, + resolvedVia: "fallback", + allowed, + reason: allowed + ? "" + : apiId && presetName + ? `酒馆当前未允许预设 "${presetName}" 的正则参与运行` + : "未识别到酒馆当前生效的预设", + rawItems, + }); } - return collected; + const characterViaApi = collectViaApi("character", regexHost); + if (characterViaApi.supported) { + appendSourceSnapshot({ + type: "character", + label: "角色卡", + enabled: enabledSources.character, + supported: true, + resolvedVia: "bridge", + rawItems: characterViaApi.items, + }); + } else { + const { avatar } = getCurrentCharacterInfo(context); + const rawItems = readCharacterFallbackRules(context); + const allowed = isCharacterRegexAllowed(extSettings, avatar); + appendSourceSnapshot({ + type: "character", + label: "角色卡", + enabled: enabledSources.character, + supported: true, + resolvedVia: "fallback", + allowed, + reason: allowed + ? "" + : avatar + ? "酒馆当前未允许该角色卡的 scoped regex 参与运行" + : "当前没有可用的角色卡上下文", + rawItems, + }); + } + + return { + shouldReuse, + host: { + sourceLabel: regexHost.sourceLabel, + fallback: Boolean(regexHost.fallback), + capabilityStatus: regexHost.capabilityStatus || null, + }, + sources, + rules: collected, + }; +} + +function collectTavernRules(regexConfig = {}) { + return collectTavernRulesDetailed(regexConfig).rules; } function collectLocalRules(regexConfig = {}) { @@ -318,31 +623,55 @@ function collectLocalRules(regexConfig = {}) { .filter((rule) => rule.enabled && rule.findRegex); } +function shouldApplyRuleForTaskContext(rule, stage = "") { + if (!rule?.isTavernRule) { + return true; + } + + if (rule.markdownOnly) { + return false; + } + + const normalizedStage = String(stage || "").trim(); + const isFinalPromptStage = + normalizedStage === "finalPrompt" || normalizedStage === "input.finalPrompt"; + const isOutputStage = OUTPUT_STAGES.has(normalizedStage); + + if (isFinalPromptStage) { + return rule.promptOnly === true; + } + + if (isOutputStage) { + return rule.promptOnly !== true; + } + + return rule.promptOnly !== true; +} + function shouldApplyRuleForStage(rule, stage = "", stagesConfig = {}) { const normalizedStage = String(stage || "").trim(); - if ( - normalizedStage && - Object.prototype.hasOwnProperty.call(stagesConfig, normalizedStage) - ) { - return ( - stagesConfig[normalizedStage] !== false && - rule.destinationFlags.prompt !== false - ); + if (rule.destinationFlags.prompt === false) { + return false; } - if (PROMPT_STAGES.has(normalizedStage)) { - return ( - stagesConfig.input !== false && rule.destinationFlags.prompt !== false - ); + if (!shouldApplyRuleForTaskContext(rule, normalizedStage)) { + return false; } - if (OUTPUT_STAGES.has(normalizedStage)) { - return ( - stagesConfig.output !== false && rule.destinationFlags.prompt !== false - ); + + if (!normalizedStage) { + return isTaskRegexStageEnabled(stagesConfig, "input"); } - return stagesConfig.input !== false && rule.destinationFlags.prompt !== false; + + if (PROMPT_STAGES.has(normalizedStage) || OUTPUT_STAGES.has(normalizedStage)) { + return isTaskRegexStageEnabled(stagesConfig, normalizedStage); + } + + return isTaskRegexStageEnabled(stagesConfig, normalizedStage); } function shouldApplyRuleForRole(rule, role = "system") { + if (role === "mixed") { + return rule.sourceFlags.user !== false || rule.sourceFlags.assistant !== false; + } if (role === "user") return rule.sourceFlags.user !== false; if (role === "assistant") return rule.sourceFlags.assistant !== false; return rule.sourceFlags.system !== false; @@ -398,7 +727,7 @@ export function applyTaskRegex( } // 阶段检查已移到 shouldApplyRuleForStage 中,无需单独 gate - const stagesConfig = regexConfig?.stages || {}; + const stagesConfig = normalizeTaskRegexStages(regexConfig?.stages || {}); const tavernRules = collectTavernRules(regexConfig); const localRules = collectLocalRules(regexConfig); @@ -441,3 +770,30 @@ export function applyTaskRegex( return output; } + +export function inspectTaskRegexReuse(settings = {}, taskType = "") { + const profile = getActiveTaskProfile(settings, taskType); + const regexConfig = profile?.regex || {}; + const detailed = collectTavernRulesDetailed(regexConfig); + + return { + taskType: String(taskType || ""), + profileId: String(profile?.id || ""), + profileName: String(profile?.name || ""), + regexEnabled: regexConfig.enabled !== false, + inheritStRegex: regexConfig.inheritStRegex !== false, + stageConfig: normalizeTaskRegexStages(regexConfig.stages || {}), + sourceConfig: { + global: regexConfig.sources?.global !== false, + preset: regexConfig.sources?.preset !== false, + character: regexConfig.sources?.character !== false, + }, + localRuleCount: Array.isArray(regexConfig.localRules) + ? regexConfig.localRules.length + : 0, + sources: detailed.sources, + host: detailed.host, + activeRuleCount: detailed.rules.length, + activeRules: detailed.rules.map((rule) => summarizeRule(rule)), + }; +} diff --git a/tests/prompt-builder-mvu.mjs b/tests/prompt-builder-mvu.mjs index 5e5eb40..40c4063 100644 --- a/tests/prompt-builder-mvu.mjs +++ b/tests/prompt-builder-mvu.mjs @@ -254,7 +254,8 @@ try { assert.match(promptBuild.systemPrompt, /GOOD_RECENT/); assert.match(JSON.stringify(promptBuild.executionMessages), /GOOD_CANDIDATE/); - assert.match(promptBuild.systemPrompt, /FINAL_GOOD/); + assert.match(promptBuild.systemPrompt, /FINAL_BAD/); + assert.doesNotMatch(promptBuild.systemPrompt, /FINAL_GOOD/); assert.equal( promptBuild.debug.mvu.sanitizedFields.some((entry) => entry.name === "userMessage"), true, @@ -454,6 +455,8 @@ try { const payload = buildTaskLlmPayload(promptBuild, "unused fallback"); assert.equal(payload.systemPrompt, ""); + assert.match(JSON.stringify(payload.promptMessages), /FINAL_BAD/); + assert.doesNotMatch(JSON.stringify(payload.promptMessages), /FINAL_GOOD/); const result = await llm.callLLMForJSON({ systemPrompt: payload.systemPrompt, userPrompt: payload.userPrompt, @@ -466,6 +469,8 @@ try { assert.deepEqual(result, { ok: true }); assert.equal(capturedBodies.length, 1); + assert.match(JSON.stringify(capturedBodies[0].messages), /FINAL_GOOD/); + assert.doesNotMatch(JSON.stringify(capturedBodies[0].messages), /FINAL_BAD/); assert.doesNotMatch( JSON.stringify(capturedBodies[0].messages), /status_current_variable|updatevariable|StatusPlaceHolderImpl|stat_data|display_data|delta_data|get_message_variable/i, @@ -478,6 +483,18 @@ try { assert.ok(runtimePromptBuild); assert.ok(runtimeLlmRequest); + assert.match(JSON.stringify(runtimeLlmRequest.messages), /FINAL_GOOD/); + assert.equal(runtimeLlmRequest.requestCleaning?.applied, true); + assert.equal( + runtimeLlmRequest.requestCleaning?.stages?.length > 0, + true, + ); + assert.equal( + runtimeLlmRequest.requestCleaning?.stages?.every( + (entry) => entry.stage === "input.finalPrompt", + ), + true, + ); assert.doesNotMatch( JSON.stringify(runtimePromptBuild.executionMessages), /status_current_variable|updatevariable|StatusPlaceHolderImpl|stat_data|display_data|delta_data|get_message_variable/i, diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs index b236281..cdbc31c 100644 --- a/tests/task-regex.mjs +++ b/tests/task-regex.mjs @@ -32,7 +32,14 @@ const originalIsCharacterTavernRegexesEnabled = globalThis.isCharacterTavernRegexesEnabled; const originalExtensionSettings = globalThis.__taskRegexTestExtensionSettings; -function createRule(id, find, replace, overrides = {}) { +const PLACEMENT = Object.freeze({ + USER_INPUT: 1, + AI_OUTPUT: 2, + WORLD_INFO: 5, + REASONING: 6, +}); + +function createLocalRule(id, find, replace, overrides = {}) { return { id, script_name: id, @@ -53,56 +60,32 @@ function createRule(id, find, replace, overrides = {}) { }; } -try { - globalThis.__taskRegexTestExtensionSettings = { - regex: { - regex_scripts: [createRule("legacy-global", "/Gamma/g", "G")], - }, +function createTavernRule(id, findRegex, replaceString, overrides = {}) { + return { + id, + scriptName: id, + enabled: true, + findRegex, + replaceString, + trimStrings: [], + placement: [PLACEMENT.WORLD_INFO], + promptOnly: false, + markdownOnly: false, + minDepth: null, + maxDepth: null, + ...overrides, }; +} - globalThis.SillyTavern = { - getContext() { - return { - extensionSettings: globalThis.__taskRegexTestExtensionSettings, - chatCompletionSettings: { - regex_scripts: [createRule("legacy-preset", "/Delta/g", "D")], - }, - characterId: 0, - characters: [ - { - extensions: { - regex_scripts: [ - createRule("legacy-character", "/Epsilon/g", "E"), - ], - }, - }, - ], - }; - }, - }; - - globalThis.getTavernRegexes = () => { - throw new Error( - "legacy global getter should not be used when bridge exists", - ); - }; - globalThis.isCharacterTavernRegexesEnabled = () => { - throw new Error( - "legacy character toggle should not be used when bridge full capability exists", - ); - }; - - const { initializeHostAdapter } = await import("../host-adapter/index.js"); - const { applyTaskRegex } = await import("../task-regex.js"); - - const settings = { +function buildSettings(regex = {}) { + return { taskProfiles: { extract: { - activeProfileId: "bridge-profile", + activeProfileId: "default", profiles: [ { - id: "bridge-profile", - name: "Regex Bridge Test", + id: "default", + name: "Regex Test", taskType: "extract", builtin: false, blocks: [], @@ -117,28 +100,105 @@ try { stages: { input: true, output: true, + "input.userMessage": true, + "input.recentMessages": true, + "input.candidateText": true, + "input.finalPrompt": true, + "output.rawResponse": true, + "output.beforeParse": true, }, - localRules: [createRule("local-tail", "/Beta/g", "B")], + localRules: [], + ...regex, }, }, ], }, }, }; +} +function setTestContext({ + extensionSettings, + presetScripts = [], + presetName = "Live Preset", + apiId = "openai", + characterId = 0, + characters = [], +} = {}) { + globalThis.__taskRegexTestExtensionSettings = extensionSettings; + globalThis.SillyTavern = { + getContext() { + return { + extensionSettings, + characterId, + characters, + getPresetManager() { + return { + apiId, + getSelectedPresetName() { + return presetName; + }, + readPresetExtensionField({ path } = {}) { + return path === "regex_scripts" ? presetScripts : []; + }, + }; + }, + }; + }, + }; +} + +try { + const { initializeHostAdapter } = await import("../host-adapter/index.js"); + const { applyTaskRegex, inspectTaskRegexReuse } = await import( + "../task-regex.js" + ); + + globalThis.getTavernRegexes = () => { + throw new Error("legacy global getter should not be used in regex tests"); + }; + globalThis.isCharacterTavernRegexesEnabled = () => { + throw new Error( + "legacy character toggle should not be used in regex tests", + ); + }; + + setTestContext({ + extensionSettings: { + regex: [], + preset_allowed_regex: {}, + character_allowed_regex: [], + }, + }); + + const fullBridgeSettings = buildSettings({ + localRules: [createLocalRule("local-tail", "/Beta/g", "B")], + }); const bridgeCalls = []; initializeHostAdapter({ regexProvider: { getTavernRegexes(request) { bridgeCalls.push(request); if (request?.type === "global") { - return [createRule("bridge-global", "/Alpha/g", "A")]; + return [ + createTavernRule("bridge-global", "/Alpha/g", "A", { + promptOnly: true, + }), + ]; } if (request?.type === "preset") { - return [createRule("bridge-preset", "/A/g", "P")]; + return [ + createTavernRule("bridge-preset", "/A/g", "P", { + promptOnly: true, + }), + ]; } if (request?.type === "character") { - return [createRule("bridge-character", "/P/g", "C")]; + return [ + createTavernRule("bridge-character", "/P/g", "C", { + promptOnly: true, + }), + ]; } return []; }, @@ -150,7 +210,7 @@ try { const fullBridgeDebug = { entries: [] }; const fullBridgeOutput = applyTaskRegex( - settings, + fullBridgeSettings, "extract", "finalPrompt", "Alpha Beta", @@ -168,140 +228,225 @@ try { fullBridgeDebug.entries[0].appliedRules.map((item) => item.id), ["bridge-global", "bridge-preset", "bridge-character", "local-tail"], ); - assert.deepEqual(fullBridgeDebug.entries[0].sourceCount, { - tavern: 3, - local: 1, - }); - const partialBridgeCalls = []; - initializeHostAdapter({ - regexProvider: { - getTavernRegexes(request) { - partialBridgeCalls.push(request); - if (request?.type === "global") { - return [createRule("partial-global", "/Gamma/g", "G1")]; - } - return []; - }, + const fallbackExtensionSettings = { + regex: [ + createTavernRule("global-fallback", "/Gamma/g", "G1", { + promptOnly: true, + }), + ], + preset_allowed_regex: { + openai: ["Live Preset"], }, + character_allowed_regex: ["hero.png"], + }; + setTestContext({ + extensionSettings: fallbackExtensionSettings, + presetScripts: [ + createTavernRule("preset-fallback", "/G1/g", "P1", { + promptOnly: true, + }), + ], + characters: [ + { + avatar: "hero.png", + data: { + extensions: { + regex_scripts: [ + createTavernRule("character-fallback", "/P1/g", "C1", { + promptOnly: true, + }), + ], + }, + }, + }, + ], }); + initializeHostAdapter({}); - const partialBridgeDebug = { entries: [] }; - const partialBridgeOutput = applyTaskRegex( - settings, + const fallbackDebug = { entries: [] }; + const fallbackOutput = applyTaskRegex( + buildSettings(), "extract", - "finalPrompt", - "Gamma Delta Epsilon", - partialBridgeDebug, + "input.finalPrompt", + "Gamma", + fallbackDebug, "system", ); + assert.equal(fallbackOutput, "C1"); - assert.equal(partialBridgeOutput, "G1 Delta E"); - assert.deepEqual(partialBridgeCalls, [ - { type: "global" }, - { type: "preset", name: "in_use" }, - ]); + const fallbackInspect = inspectTaskRegexReuse(buildSettings(), "extract"); + assert.equal(fallbackInspect.activeRuleCount, 3); assert.deepEqual( - partialBridgeDebug.entries[0].appliedRules.map((item) => item.id), - ["partial-global", "legacy-character"], - ); - assert.deepEqual(partialBridgeDebug.entries[0].sourceCount, { - tavern: 2, - local: 1, - }); - - const emptyBridgeCalls = []; - initializeHostAdapter({ - regexProvider: { - getTavernRegexes(request) { - emptyBridgeCalls.push(request); - if (request?.type === "global") { - return []; - } - if (request?.type === "preset") { - return [createRule("bridge-preset-empty-guard", "/Theta/g", "T")]; - } - if (request?.type === "character") { - return [createRule("bridge-character-empty-guard", "/T/g", "C2")]; - } - return []; - }, - isCharacterTavernRegexesEnabled() { - return true; - }, - }, - }); - - const emptyBridgeDebug = { entries: [] }; - const emptyBridgeOutput = applyTaskRegex( - settings, - "extract", - "finalPrompt", - "Gamma Theta", - emptyBridgeDebug, - "system", - ); - - assert.equal(emptyBridgeOutput, "Gamma C2"); - assert.deepEqual(emptyBridgeCalls, [ - { type: "global" }, - { type: "preset", name: "in_use" }, - { type: "character", name: "current" }, - ]); - assert.deepEqual( - emptyBridgeDebug.entries[0].appliedRules.map((item) => item.id), - ["bridge-preset-empty-guard", "bridge-character-empty-guard"], + fallbackInspect.activeRules.map((rule) => rule.id), + ["global-fallback", "preset-fallback", "character-fallback"], ); assert.equal( - emptyBridgeDebug.entries[0].appliedRules.some( - (item) => item.id === "legacy-global", - ), + fallbackInspect.sources.find((source) => source.type === "preset") + ?.resolvedVia, + "fallback", + ); + assert.equal( + fallbackInspect.sources.find((source) => source.type === "character") + ?.allowed, + true, + ); + + const disallowedExtensionSettings = { + regex: [ + createTavernRule("global-only", "/Gamma/g", "G2", { + promptOnly: true, + }), + ], + preset_allowed_regex: {}, + character_allowed_regex: [], + }; + setTestContext({ + extensionSettings: disallowedExtensionSettings, + presetScripts: [ + createTavernRule("preset-blocked", "/G2/g", "P2", { + promptOnly: true, + }), + ], + characters: [ + { + avatar: "blocked.png", + data: { + extensions: { + regex_scripts: [ + createTavernRule("character-blocked", "/P2/g", "C2", { + promptOnly: true, + }), + ], + }, + }, + }, + ], + }); + initializeHostAdapter({}); + + const disallowedOutput = applyTaskRegex( + buildSettings(), + "extract", + "input.finalPrompt", + "Gamma", + { entries: [] }, + "system", + ); + assert.equal(disallowedOutput, "G2"); + + const disallowedInspect = inspectTaskRegexReuse(buildSettings(), "extract"); + assert.equal(disallowedInspect.activeRuleCount, 1); + assert.equal( + disallowedInspect.sources.find((source) => source.type === "preset") + ?.allowed, + false, + ); + assert.equal( + disallowedInspect.sources.find((source) => source.type === "character") + ?.allowed, false, ); - assert.deepEqual(emptyBridgeDebug.entries[0].sourceCount, { - tavern: 2, - local: 1, - }); - const outputGuardSettings = { - taskProfiles: { - extract: { - activeProfileId: "output-guard", - profiles: [ - { - id: "output-guard", - name: "Output Guard", - taskType: "extract", - builtin: false, - blocks: [], - regex: { - enabled: true, - inheritStRegex: false, - stages: { - input: true, - output: true, - "output.rawResponse": true, - }, - localRules: [ - createRule("display-only-output", "/美化/g", "美化", { - destination: { - prompt: false, - display: true, - }, - }), - createRule("prompt-output", "/JSON/g", "DONE", { - destination: { - prompt: true, - display: false, - }, - }), - ], - }, - }, - ], - }, + const tavernSemanticsSettings = buildSettings({ + sources: { + global: true, + preset: false, + character: false, }, - }; + }); + setTestContext({ + extensionSettings: { + regex: [ + createTavernRule("user-prompt-only", "/Alpha/g", "A", { + placement: [PLACEMENT.USER_INPUT], + promptOnly: true, + }), + createTavernRule("markdown-only", "/Alpha/g", "M", { + placement: [PLACEMENT.USER_INPUT], + markdownOnly: true, + }), + createTavernRule("output-only", "/Answer/g", "AI", { + placement: [PLACEMENT.AI_OUTPUT], + }), + createTavernRule("world-info-only", "/Lore/g", "SYS", { + placement: [PLACEMENT.WORLD_INFO], + }), + createTavernRule("recent-user", "/User/g", "U", { + placement: [PLACEMENT.USER_INPUT], + }), + createTavernRule("recent-ai", "/Reply/g", "R", { + placement: [PLACEMENT.AI_OUTPUT], + }), + ], + preset_allowed_regex: {}, + character_allowed_regex: [], + }, + }); + initializeHostAdapter({}); + + assert.equal( + applyTaskRegex( + tavernSemanticsSettings, + "extract", + "input.userMessage", + "Alpha", + { entries: [] }, + "user", + ), + "Alpha", + ); + assert.equal( + applyTaskRegex( + tavernSemanticsSettings, + "extract", + "input.finalPrompt", + "Alpha", + { entries: [] }, + "user", + ), + "A", + ); + assert.equal( + applyTaskRegex( + tavernSemanticsSettings, + "extract", + "output.rawResponse", + "Answer Lore", + { entries: [] }, + "assistant", + ), + "AI Lore", + ); + assert.equal( + applyTaskRegex( + tavernSemanticsSettings, + "extract", + "input.recentMessages", + "User Reply Lore", + { entries: [] }, + "mixed", + ), + "U R Lore", + ); + + const outputGuardSettings = buildSettings({ + inheritStRegex: false, + localRules: [ + createLocalRule("display-only-output", "/美化/g", "美化", { + destination: { + prompt: false, + display: true, + }, + }), + createLocalRule("prompt-output", "/JSON/g", "DONE", { + destination: { + prompt: true, + display: false, + }, + }), + ], + }); const outputGuardDebug = { entries: [] }; const outputGuardResult = applyTaskRegex( outputGuardSettings, @@ -317,54 +462,6 @@ try { ["prompt-output"], ); - const exactStageSettings = { - taskProfilesVersion: 1, - taskProfiles: { - extract: { - activeProfileId: "default", - profiles: [ - { - id: "default", - taskType: "extract", - regex: { - enabled: true, - inheritStRegex: false, - sources: { - global: false, - preset: false, - character: false, - }, - stages: { - output: true, - "output.rawResponse": false, - "output.beforeParse": true, - }, - localRules: [ - createRule("exact-stage", "/JSON/g", "DONE", { - destination: { - prompt: true, - display: false, - }, - }), - ], - }, - }, - ], - }, - }, - }; - const exactStageDebug = { entries: [] }; - const exactStageResult = applyTaskRegex( - exactStageSettings, - "extract", - "output.rawResponse", - "JSON", - exactStageDebug, - "assistant", - ); - assert.equal(exactStageResult, "JSON"); - assert.deepEqual(exactStageDebug.entries[0].appliedRules, []); - console.log("task-regex tests passed"); } finally { if (originalSillyTavern === undefined) {