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) {