Fix Tavern regex reuse inspection and matching

This commit is contained in:
Youzini-afk
2026-04-05 02:00:37 +08:00
parent 5cc33fabda
commit e9758feed3
5 changed files with 914 additions and 355 deletions

View File

@@ -126,6 +126,7 @@ import {
createDefaultTaskProfiles,
migrateLegacyTaskProfiles,
} from "./prompt-profiles.js";
import { inspectTaskRegexReuse } from "./task-regex.js";
import {
applyRecallInjectionController,
buildRecallRecentMessagesController,
@@ -9497,6 +9498,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(),

165
panel.js
View File

@@ -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";
@@ -2988,6 +2989,9 @@ async function _handleTaskProfileWorkspaceClick(event) {
}
_refreshTaskProfileWorkspace();
return;
case "inspect-tavern-regex":
await _openRegexReuseInspector(state.taskType);
return;
case "select-block":
currentTaskProfileBlockId = actionEl.dataset.blockId || "";
_refreshTaskProfileWorkspace();
@@ -3394,6 +3398,9 @@ function _renderTaskRegexTab(state) {
任务预设可复用酒馆正则,并叠加当前任务自己的附加规则。
</div>
</div>
<button class="bme-config-secondary-btn" data-task-action="inspect-tavern-regex" type="button">
查看当前复用规则
</button>
</div>
<div class="bme-task-toggle-list">
@@ -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 `<div class="bme-task-empty">${_escHtml(emptyText)}</div>`;
}
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 `
<div class="bme-debug-row">
<span class="bme-debug-key">${_escHtml(rule.name || rule.id || "未命名规则")}</span>
<span class="bme-debug-value">${_escHtml(placementText)}</span>
</div>
<div class="bme-task-note">
<code>${_escHtml(rule.findRegex || "(空 findRegex)")}</code>
${rule.replaceString ? ` -> <code>${_escHtml(rule.replaceString)}</code>` : ""}
${flags.length ? `<br>${_escHtml(flags.join(" · "))}` : ""}
</div>
`;
})
.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 = `
<div class="bme-task-tab-body">
<div class="bme-config-card">
<div class="bme-config-card-title">酒馆正则复用快照</div>
<div class="bme-config-card-subtitle">
这里展示的是当前任务预设下ST-BME 实际会尝试复用的 Tavern 正则来源和规则,不是静态说明文案。
</div>
<div class="bme-debug-list">
<div class="bme-debug-row">
<span class="bme-debug-key">任务</span>
<span class="bme-debug-value">${_escHtml(snapshot.taskType || "—")}</span>
</div>
<div class="bme-debug-row">
<span class="bme-debug-key">预设</span>
<span class="bme-debug-value">${_escHtml(snapshot.profileName || snapshot.profileId || "—")}</span>
</div>
<div class="bme-debug-row">
<span class="bme-debug-key">任务正则</span>
<span class="bme-debug-value">${snapshot.regexEnabled ? "已启用" : "已关闭"}</span>
</div>
<div class="bme-debug-row">
<span class="bme-debug-key">复用酒馆正则</span>
<span class="bme-debug-value">${snapshot.inheritStRegex ? "已启用" : "已关闭"}</span>
</div>
<div class="bme-debug-row">
<span class="bme-debug-key">本地规则数</span>
<span class="bme-debug-value">${Number(snapshot.localRuleCount || 0)}</span>
</div>
<div class="bme-debug-row">
<span class="bme-debug-key">桥接模式</span>
<span class="bme-debug-value">${_escHtml(snapshot.host?.sourceLabel || "unknown")} · ${_escHtml(snapshot.host?.capabilityStatus?.mode || snapshot.host?.mode || "unknown")}${snapshot.host?.fallback ? " · fallback" : ""}</span>
</div>
</div>
</div>
<div class="bme-config-card">
<div class="bme-config-card-title">当前启用开关</div>
<div class="bme-task-note">
来源global=${sourceConfig.global === false ? "关" : "开"} / preset=${sourceConfig.preset === false ? "关" : "开"} / character=${sourceConfig.character === false ? "关" : "开"}
</div>
<div class="bme-task-note">
阶段:${_escHtml(Object.entries(stageConfig).map(([key, value]) => `${key}=${value ? "on" : "off"}`).join(" | ") || "无")}
</div>
</div>
<div class="bme-config-card">
<div class="bme-config-card-title">来源明细</div>
${
sources.length
? sources.map((source) => `
<details class="bme-debug-details" open>
<summary>
${_escHtml(source.label || source.type || "未知来源")}
<span class="bme-debug-value"> · ${_escHtml(_formatRegexReuseSourceState(source))}</span>
</summary>
<div class="bme-task-note">
raw=${Number(source.rawRuleCount || 0)} / active=${Number(source.activeRuleCount || 0)}
${source.reason ? `<br>${_escHtml(source.reason)}` : ""}
</div>
<div class="bme-task-section-label">本来源当前生效规则</div>
${_renderRegexReuseRuleList(source.rules, "该来源当前没有进入任务链的复用规则")}
<div class="bme-task-section-label">被跳过的规则</div>
${_renderRegexReuseRuleList(source.ignoredRules, "没有被额外跳过的规则")}
</details>
`).join("")
: `<div class="bme-task-empty">当前没有可展示的酒馆正则来源。</div>`
}
</div>
<div class="bme-config-card">
<div class="bme-config-card-title">汇总后的复用规则</div>
<div class="bme-config-card-subtitle">
这是经过来源开关、allowlist 和去重后,准备进入当前任务链的 Tavern 规则集合。
</div>
${_renderRegexReuseRuleList(activeRules, "当前没有复用到任何酒馆正则")}
</div>
</div>
`;
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 || {};

View File

@@ -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,

View File

@@ -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)),
};
}

View File

@@ -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", "<b>美化</b>", {
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", "<b>美化</b>", {
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) {