From 1eeb6f05b5bb9298a7371fd3f9d4bd2a8500fa03 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Sat, 4 Apr 2026 23:58:58 +0800
Subject: [PATCH 1/3] Fix message trace refresh and source fidelity
---
panel.js | 30 +++++++++++++++---------------
1 file changed, 15 insertions(+), 15 deletions(-)
diff --git a/panel.js b/panel.js
index de40131..cdc6f5e 100644
--- a/panel.js
+++ b/panel.js
@@ -792,6 +792,11 @@ function _applyWorkspaceMode() {
function _switchConfigSection(sectionId) {
currentConfigSectionId = sectionId || "api";
_syncConfigSectionState();
+ if (currentConfigSectionId === "prompts") {
+ _refreshTaskProfileWorkspace();
+ } else if (currentConfigSectionId === "trace") {
+ _refreshMessageTraceWorkspace();
+ }
}
function _syncConfigSectionState() {
@@ -2737,17 +2742,23 @@ 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 || ""))
: [];
const triggeredUserMessage =
- _extractTriggeredUserMessageFromRecentMessages(recentMessages) ||
- _getLastDebugMessageContent(recallLlmRequest?.messages, "user");
+ _extractTriggeredUserMessageFromRecentMessages(recentMessages);
const hostPayloadText = _buildMainAiTraceText(
triggeredUserMessage,
injectionSnapshot?.injectionText || "",
);
+ const missingUserMessageNotice =
+ injectionSnapshot && !triggeredUserMessage
+ ? `
+
+ 这次没有可靠捕获到主 AI 那边的用户消息,因此这里只展示真实记录到的记忆注入文本,不再用 recall 模型请求去反推,避免误导排查。
+
+ `
+ : "";
if (!injectionSnapshot) {
return `
@@ -2765,6 +2776,7 @@ function _renderMessageTraceRecallCard(state) {
@@ -3501,6 +3508,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 || {};
diff --git a/prompt-builder.js b/prompt-builder.js
index 601f728..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;
@@ -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,
diff --git a/task-regex.js b/task-regex.js
index 8dbe9db..c776587 100644
--- a/task-regex.js
+++ b/task-regex.js
@@ -13,6 +13,20 @@ import {
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",
@@ -69,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"
@@ -76,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}`),
@@ -86,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,
};
@@ -194,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") {
@@ -233,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?.() || {};
@@ -251,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 = {}) {
@@ -322,11 +623,39 @@ 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 (rule.destinationFlags.prompt === false) {
return false;
}
+ if (!shouldApplyRuleForTaskContext(rule, normalizedStage)) {
+ return false;
+ }
if (!normalizedStage) {
return isTaskRegexStageEnabled(stagesConfig, "input");
@@ -340,6 +669,9 @@ function shouldApplyRuleForStage(rule, stage = "", stagesConfig = {}) {
}
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;
@@ -438,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/task-regex.mjs b/tests/task-regex.mjs
index ecb1ed8..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,127 +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, []);
-
- const legacyStageCompatibilitySettings = {
- taskProfilesVersion: 1,
- taskProfiles: {
- extract: {
- activeProfileId: "legacy-stage-compat",
- profiles: [
- {
- id: "legacy-stage-compat",
- taskType: "extract",
- regex: {
- enabled: true,
- inheritStRegex: false,
- sources: {
- global: false,
- preset: false,
- character: false,
- },
- stages: {
- input: true,
- output: true,
- "input.userMessage": false,
- "input.recentMessages": false,
- "input.candidateText": false,
- "input.finalPrompt": false,
- "output.rawResponse": false,
- "output.beforeParse": false,
- },
- localRules: [
- createRule("legacy-input-user", "/Alpha/g", "A1"),
- createRule("legacy-output-raw", "/Omega/g", "O1", {
- source: {
- user_input: false,
- ai_output: true,
- },
- }),
- ],
- },
- },
- ],
- },
- },
- };
-
- const legacyStageInputDebug = { entries: [] };
- const legacyStageInputResult = applyTaskRegex(
- legacyStageCompatibilitySettings,
- "extract",
- "input.userMessage",
- "Alpha",
- legacyStageInputDebug,
- "user",
- );
- assert.equal(legacyStageInputResult, "A1");
- assert.deepEqual(
- legacyStageInputDebug.entries[0].appliedRules.map((item) => item.id),
- ["legacy-input-user"],
- );
-
- const legacyStageOutputDebug = { entries: [] };
- const legacyStageOutputResult = applyTaskRegex(
- legacyStageCompatibilitySettings,
- "extract",
- "output.rawResponse",
- "Omega",
- legacyStageOutputDebug,
- "assistant",
- );
- assert.equal(legacyStageOutputResult, "O1");
- assert.deepEqual(
- legacyStageOutputDebug.entries[0].appliedRules.map((item) => item.id),
- ["legacy-output-raw"],
- );
-
console.log("task-regex tests passed");
} finally {
if (originalSillyTavern === undefined) {