Merge main into pr-6-review with safe message trace resolution

This commit is contained in:
Youzini-afk
2026-04-05 03:21:30 +08:00
8 changed files with 1274 additions and 348 deletions

View File

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

166
llm.js
View File

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

263
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";
@@ -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
? `
<div class="bme-config-help">
这次没有可靠捕获到主 AI 那边的用户消息,因此这里只展示真实记录到的记忆注入文本,不再用 recall 模型请求去反推,避免误导排查。
</div>
`
: "";
if (!injectionSnapshot) {
return `
@@ -2770,6 +2822,7 @@ function _renderMessageTraceRecallCard(state) {
</div>
<span class="bme-task-pill">${_escHtml(_formatTaskProfileTime(injectionSnapshot.updatedAt))}</span>
</div>
${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 `
<div class="bme-task-tab-body">
<div class="bme-task-regex-top">
@@ -3358,6 +3403,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">
@@ -3420,14 +3468,14 @@ function _renderTaskRegexTab(state) {
<span class="bme-toggle-title">${_escHtml(stage.label)}</span>
<span class="bme-toggle-desc">${_escHtml(stage.desc)}</span>
</span>
<input
type="checkbox"
data-regex-stage="${_escAttr(stage.key)}"
${(regex.stages?.[stage.key] ?? true) ? "checked" : ""}
/>
</label>
`,
).join("")}
<input
type="checkbox"
data-regex-stage="${_escAttr(stage.key)}"
${isTaskRegexStageEnabled(normalizedStages, stage.key) ? "checked" : ""}
/>
</label>
`,
).join("")}
</div>
</div>
@@ -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 `<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 || {};
@@ -3759,8 +3965,13 @@ function _renderTaskDebugLlmCard(taskType, llmRequest) {
<span class="bme-debug-kv-key">输出清洗</span>
<span class="bme-debug-kv-value">${_escHtml(llmRequest.responseCleaning?.applied ? "已生效" : "未生效")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">发送前输入清洗</span>
<span class="bme-debug-kv-value">${_escHtml(llmRequest.requestCleaning?.applied ? "已生效" : "未生效")}</span>
</div>
</div>
${_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) => ({

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

View File

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

View File

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

View File

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

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