Harden runtime debug and task pipeline

This commit is contained in:
Youzini-afk
2026-03-27 01:26:56 +08:00
parent b20e8dbb44
commit c31af1d1a4
17 changed files with 1750 additions and 238 deletions

View File

@@ -11,7 +11,7 @@ import {
getNode,
} from "./graph.js";
import { callLLMForJSON } from "./llm.js";
import { buildTaskPrompt } from "./prompt-builder.js";
import { buildTaskExecutionDebugContext, buildTaskPrompt } from "./prompt-builder.js";
import { getSTContextForPrompt } from "./st-context.js";
import { applyTaskRegex } from "./task-regex.js";
import { isDirectVectorConfig } from "./vector-index.js";
@@ -22,6 +22,12 @@ function createAbortError(message = "操作已终止") {
return error;
}
function createTaskLlmDebugContext(promptBuild, regexInput) {
return typeof buildTaskExecutionDebugContext === "function"
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
: null;
}
function throwIfAborted(signal) {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : createAbortError();
@@ -249,6 +255,7 @@ async function summarizeBatch(
graphStats: `node_count=${nodes.length}, node_type=${typeDef.id}`,
...getSTContextForPrompt(),
});
const compressRegexInput = { entries: [] };
const systemPrompt = applyTaskRegex(
settings,
"compress",
@@ -267,6 +274,8 @@ async function summarizeBatch(
"- 去除重复和低信息密度内容",
"- 压缩后文本应精炼,目标 150 字左右",
].join("\n"),
compressRegexInput,
"system",
);
const userPrompt = `请压缩以下 ${nodes.length} 个 "${typeDef.label}" 节点:\n\n${nodeDescriptions}`;
@@ -277,6 +286,10 @@ async function summarizeBatch(
maxRetries: 1,
signal,
taskType: "compress",
debugContext: createTaskLlmDebugContext(
compressPromptBuild,
compressRegexInput,
),
additionalMessages:
compressPromptBuild.privateTaskMessages || [
...(compressPromptBuild.customMessages || []),

View File

@@ -5,7 +5,7 @@
import { embedBatch, searchSimilar } from "./embedding.js";
import { addEdge, createEdge, getActiveNodes, getNode } from "./graph.js";
import { callLLMForJSON } from "./llm.js";
import { buildTaskPrompt } from "./prompt-builder.js";
import { buildTaskExecutionDebugContext, buildTaskPrompt } from "./prompt-builder.js";
import { getSTContextForPrompt } from "./st-context.js";
import { applyTaskRegex } from "./task-regex.js";
import {
@@ -21,6 +21,12 @@ function createAbortError(message = "操作已终止") {
return error;
}
function createTaskLlmDebugContext(promptBuild, regexInput) {
return typeof buildTaskExecutionDebugContext === "function"
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
: null;
}
function isAbortError(error) {
return error?.name === "AbortError";
}
@@ -301,20 +307,28 @@ export async function consolidateMemories({
graphStats: `new_entries=${newEntries.length}, threshold=${conflictThreshold}`,
...getSTContextForPrompt(),
});
const consolidationRegexInput = { entries: [] };
const consolidationSystemPrompt = applyTaskRegex(
settings,
"consolidation",
"finalPrompt",
consolidationPromptBuild.systemPrompt ||
customPrompt ||
CONSOLIDATION_SYSTEM_PROMPT,
consolidationRegexInput,
"system",
);
try {
decision = await callLLMForJSON({
systemPrompt: applyTaskRegex(
settings,
"consolidation",
"finalPrompt",
consolidationPromptBuild.systemPrompt ||
customPrompt ||
CONSOLIDATION_SYSTEM_PROMPT,
),
systemPrompt: consolidationSystemPrompt,
userPrompt,
maxRetries: 1,
signal,
taskType: "consolidation",
debugContext: createTaskLlmDebugContext(
consolidationPromptBuild,
consolidationRegexInput,
),
additionalMessages:
consolidationPromptBuild.privateTaskMessages || [
...(consolidationPromptBuild.customMessages || []),

View File

@@ -7,6 +7,7 @@
*/
import { extension_settings } from "../../../extensions.js";
import { resolveConfiguredTimeoutMs } from "./request-timeout.js";
const MODULE_NAME = "st_bme";
const EMBEDDING_REQUEST_TIMEOUT_MS = 300000;
@@ -19,10 +20,14 @@ function getEmbeddingTestOverride(name) {
function getConfiguredTimeoutMs(
settings = extension_settings[MODULE_NAME] || {},
) {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: EMBEDDING_REQUEST_TIMEOUT_MS;
return typeof resolveConfiguredTimeoutMs === "function"
? resolveConfiguredTimeoutMs(settings, EMBEDDING_REQUEST_TIMEOUT_MS)
: (() => {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: EMBEDDING_REQUEST_TIMEOUT_MS;
})();
}
function isAbortError(error) {
@@ -131,7 +136,7 @@ export async function embedText(text, config, { signal } = {}) {
input: text,
}),
},
getConfiguredTimeoutMs(),
getConfiguredTimeoutMs(config),
);
if (!response.ok) {
@@ -196,7 +201,7 @@ export async function embedBatch(texts, config, { signal } = {}) {
input: texts,
}),
},
getConfiguredTimeoutMs(),
getConfiguredTimeoutMs(config),
);
if (!response.ok) {

View File

@@ -16,7 +16,7 @@ import {
} from "./graph.js";
import { callLLMForJSON } from "./llm.js";
import { ensureEventTitle, getNodeDisplayName } from "./node-labels.js";
import { buildTaskPrompt } from "./prompt-builder.js";
import { buildTaskExecutionDebugContext, buildTaskPrompt } from "./prompt-builder.js";
import { RELATION_TYPES } from "./schema.js";
import { applyTaskRegex } from "./task-regex.js";
import { getSTContextForPrompt } from "./st-context.js";
@@ -28,6 +28,12 @@ function createAbortError(message = "操作已终止") {
return error;
}
function createTaskLlmDebugContext(promptBuild, regexInput) {
return typeof buildTaskExecutionDebugContext === "function"
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
: null;
}
function isAbortError(error) {
return error?.name === "AbortError";
}
@@ -122,6 +128,7 @@ export async function extractMemories({
});
// 系统提示词
const extractRegexInput = { entries: [] };
const systemPrompt = applyTaskRegex(
settings,
"extract",
@@ -129,6 +136,8 @@ export async function extractMemories({
promptBuild.systemPrompt ||
extractPrompt ||
buildDefaultExtractPrompt(schema),
extractRegexInput,
"system",
);
// 用户提示词
@@ -152,6 +161,7 @@ export async function extractMemories({
maxRetries: 2,
signal,
taskType: "extract",
debugContext: createTaskLlmDebugContext(promptBuild, extractRegexInput),
additionalMessages:
promptBuild.privateTaskMessages || [
...(promptBuild.customMessages || []),
@@ -641,6 +651,7 @@ export async function generateSynopsis({
graphStats: `event=${eventNodes.length}, character=${characterNodes.length}, thread=${threadNodes.length}`,
...getSTContextForPrompt(),
});
const synopsisRegexInput = { entries: [] };
const synopsisSystemPrompt = applyTaskRegex(
settings,
"synopsis",
@@ -652,6 +663,8 @@ export async function generateSynopsis({
'输出 JSON{"summary": "前情提要文本200字以内"}',
"要求:涵盖核心冲突、关键转折、主要角色当前状态。",
].join("\n"),
synopsisRegexInput,
"system",
);
const result = await callLLMForJSON({
@@ -669,6 +682,10 @@ export async function generateSynopsis({
maxRetries: 1,
signal,
taskType: "synopsis",
debugContext: createTaskLlmDebugContext(
synopsisPromptBuild,
synopsisRegexInput,
),
additionalMessages:
synopsisPromptBuild.privateTaskMessages || [
...(synopsisPromptBuild.customMessages || []),
@@ -759,6 +776,7 @@ export async function generateReflection({
graphStats: `event=${recentEvents.length}, character=${recentCharacters.length}, thread=${recentThreads.length}`,
...getSTContextForPrompt(),
});
const reflectionRegexInput = { entries: [] };
const reflectionSystemPrompt = applyTaskRegex(
settings,
"reflection",
@@ -773,6 +791,8 @@ export async function generateReflection({
"suggestion 给出后续检索或叙事上值得关注的提示。",
"不要复述全部事件,要提炼高层结论。",
].join("\n"),
reflectionRegexInput,
"system",
);
const result = await callLLMForJSON({
@@ -793,6 +813,10 @@ export async function generateReflection({
maxRetries: 1,
signal,
taskType: "reflection",
debugContext: createTaskLlmDebugContext(
reflectionPromptBuild,
reflectionRegexInput,
),
additionalMessages:
reflectionPromptBuild.privateTaskMessages || [
...(reflectionPromptBuild.customMessages || []),

228
index.js
View File

@@ -49,6 +49,7 @@ import {
import { retrieve } from "./retriever.js";
import {
appendBatchJournal,
buildReverseJournalRecoveryPlan,
buildRecoveryResult,
clearHistoryDirty,
cloneGraphSnapshot,
@@ -72,6 +73,7 @@ import {
testVectorConnection,
validateVectorConfig,
} from "./vector-index.js";
import { resolveConfiguredTimeoutMs } from "./request-timeout.js";
// 操控面板模块(动态加载,防止加载失败崩溃整个扩展)
let _panelModule = null;
@@ -820,10 +822,14 @@ function getSchema() {
}
function getConfiguredTimeoutMs(settings = getSettings()) {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: LOCAL_VECTOR_TIMEOUT_MS;
return typeof resolveConfiguredTimeoutMs === "function"
? resolveConfiguredTimeoutMs(settings, LOCAL_VECTOR_TIMEOUT_MS)
: (() => {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: LOCAL_VECTOR_TIMEOUT_MS;
})();
}
function getEmbeddingConfig(mode = null) {
@@ -2929,6 +2935,120 @@ function rollbackAffectedJournals(graph, affectedJournals = []) {
: [];
}
function pruneProcessedMessageHashesFromFloor(graph, fromFloor) {
if (!graph?.historyState?.processedMessageHashes) return;
if (!Number.isFinite(fromFloor)) return;
const hashes = graph.historyState.processedMessageHashes;
for (const key of Object.keys(hashes)) {
if (Number(key) >= fromFloor) {
delete hashes[key];
}
}
}
async function rollbackGraphForReroll(targetFloor, context = getContext()) {
ensureCurrentGraphRuntimeState();
const chatId = getCurrentChatId(context);
const recoveryPoint = findJournalRecoveryPoint(currentGraph, targetFloor);
if (!recoveryPoint) {
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: targetFloor,
effectiveFromFloor: null,
recoveryPath: "unavailable",
affectedBatchCount: 0,
error:
"未找到可用的回滚点,无法安全重新提取。请先执行一次历史恢复或重新提取更早的批次。",
};
}
clearInjectionState();
lastExtractedItems = [];
const config = getEmbeddingConfig();
const recoveryPath = recoveryPoint.path || "unknown";
const affectedBatchCount = recoveryPoint.affectedBatchCount || 0;
if (recoveryPath === "reverse-journal") {
const recoveryPlan = buildReverseJournalRecoveryPlan(
recoveryPoint.affectedJournals,
targetFloor,
);
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
extractionCount = currentGraph.historyState.extractionCount || 0;
applyRecoveryPlanToVectorState(recoveryPlan, targetFloor);
if (
isBackendVectorConfig(config) &&
recoveryPlan.backendDeleteHashes.length > 0
) {
await deleteBackendVectorHashesForRecovery(
currentGraph.vectorIndexState.collectionId,
config,
recoveryPlan.backendDeleteHashes,
);
}
await prepareVectorStateForReplay(false, undefined, {
skipBackendPurge: isBackendVectorConfig(config),
});
} else if (recoveryPath === "legacy-snapshot") {
currentGraph = normalizeGraphRuntimeState(recoveryPoint.snapshotBefore, chatId);
extractionCount = currentGraph.historyState.extractionCount || 0;
await prepareVectorStateForReplay(false);
} else {
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: targetFloor,
effectiveFromFloor: null,
recoveryPath,
affectedBatchCount,
error: `不支持的回滚路径: ${recoveryPath}`,
};
}
const effectiveFromFloor = Number.isFinite(
currentGraph.historyState?.lastProcessedAssistantFloor,
)
? currentGraph.historyState.lastProcessedAssistantFloor + 1
: 0;
pruneProcessedMessageHashesFromFloor(currentGraph, effectiveFromFloor);
currentGraph.lastProcessedSeq =
currentGraph.historyState?.lastProcessedAssistantFloor ?? -1;
clearHistoryDirty(
currentGraph,
buildRecoveryResult("reroll-rollback", {
fromFloor: targetFloor,
effectiveFromFloor,
path: recoveryPath,
affectedBatchCount,
detectionSource: "manual-reroll",
reason: "manual-reroll",
}),
);
saveGraphToChat();
refreshPanelLiveState();
return {
success: true,
rollbackPerformed: true,
extractionTriggered: false,
requestedFloor: targetFloor,
effectiveFromFloor,
recoveryPath,
affectedBatchCount,
error: "",
};
}
async function recoverHistoryIfNeeded(trigger = "history-recovery") {
if (!currentGraph || isRecoveringHistory) {
return !isRecoveringHistory;
@@ -4002,18 +4122,45 @@ async function onManualExtract() {
async function onReroll({ fromFloor } = {}) {
if (isExtracting) {
toastr.info("记忆提取正在进行中,请稍候");
return;
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "busy",
affectedBatchCount: 0,
error: "记忆提取正在进行中",
};
}
if (!currentGraph) {
toastr.info("图谱为空,无需重 Roll");
return;
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "empty-graph",
affectedBatchCount: 0,
error: "图谱为空",
};
}
const context = getContext();
const chat = context.chat;
if (!Array.isArray(chat) || chat.length === 0) {
toastr.info("当前聊天为空");
return;
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "empty-chat",
affectedBatchCount: 0,
error: "当前聊天为空",
};
}
// 确定回滚起点
@@ -4023,7 +4170,16 @@ async function onReroll({ fromFloor } = {}) {
const assistantTurns = getAssistantTurns(chat);
if (assistantTurns.length === 0) {
toastr.info("聊天中没有 AI 回复");
return;
return {
success: false,
rollbackPerformed: false,
extractionTriggered: false,
requestedFloor: null,
effectiveFromFloor: null,
recoveryPath: "no-assistant-turn",
affectedBatchCount: 0,
error: "聊天中没有 AI 回复",
};
}
targetFloor = assistantTurns[assistantTurns.length - 1];
}
@@ -4037,42 +4193,40 @@ async function onReroll({ fromFloor } = {}) {
timeOut: 2000,
});
await onManualExtract();
return;
return {
success: true,
rollbackPerformed: false,
extractionTriggered: true,
requestedFloor: targetFloor,
effectiveFromFloor: lastProcessed + 1,
recoveryPath: "direct-extract",
affectedBatchCount: 0,
extractionStatus: lastExtractionStatus?.level || "idle",
error: "",
};
}
console.log(`[ST-BME] 重 Roll 开始,目标楼层: ${targetFloor}`);
// 1. 找到受影响的 journal 并回滚
const recovery = findJournalRecoveryPoint(currentGraph, targetFloor);
if (recovery && recovery.affectedJournals?.length > 0) {
rollbackAffectedJournals(currentGraph, recovery.affectedJournals);
console.log(`[ST-BME] 已回滚 ${recovery.affectedJournals.length} 个 batch`);
const rollbackResult = await rollbackGraphForReroll(targetFloor, context);
if (!rollbackResult.success) {
toastr.error(rollbackResult.error, "ST-BME 重 Roll");
return rollbackResult;
}
// 2. 重置提取指针
const newFloor = targetFloor - 1;
currentGraph.historyState.lastProcessedAssistantFloor = newFloor;
currentGraph.lastProcessedSeq = newFloor;
const rerollDesc =
rollbackResult.effectiveFromFloor !== targetFloor
? `已按批次边界回滚到楼层 ${rollbackResult.effectiveFromFloor} 开始重新提取…`
: `已回滚到楼层 ${targetFloor} 开始重新提取…`;
toastr.info(rerollDesc, "ST-BME 重 Roll", {
timeOut: 2500,
});
// 3. 清理 processedMessageHashes 中 >= targetFloor 的条目
const hashes = currentGraph.historyState.processedMessageHashes || {};
for (const key of Object.keys(hashes)) {
if (Number(key) >= targetFloor) {
delete hashes[key];
}
}
// 4. 保存回滚后的状态
saveGraph();
toastr.info(
`已回滚到楼层 ${targetFloor} 之前,开始重新提取…`,
"ST-BME 重 Roll",
{ timeOut: 2000 },
);
// 5. 触发重新提取(复用手动提取逻辑)
await onManualExtract();
return {
...rollbackResult,
extractionTriggered: true,
extractionStatus: lastExtractionStatus?.level || "idle",
};
}
async function onManualSleep() {

221
llm.js
View File

@@ -5,12 +5,16 @@ import { getRequestHeaders } from "../../../../script.js";
import { extension_settings } from "../../../extensions.js";
import { chat_completion_sources, sendOpenAIRequest } from "../../../openai.js";
import { resolveTaskGenerationOptions } from "./generation-options.js";
import { resolveConfiguredTimeoutMs } from "./request-timeout.js";
import { applyTaskRegex } from "./task-regex.js";
const MODULE_NAME = "st_bme";
const LLM_REQUEST_TIMEOUT_MS = 300000;
const DEFAULT_TEXT_COMPLETION_TOKENS = 64000;
const DEFAULT_JSON_COMPLETION_TOKENS = 64000;
const RETRY_JSON_COMPLETION_TOKENS = 3200;
const SENSITIVE_DEBUG_KEY_PATTERN =
/^(authorization|proxy_password|api[_-]?key|access[_-]?token|refresh[_-]?token|secret|password)$/i;
function cloneRuntimeDebugValue(value, fallback = null) {
if (value == null) {
@@ -24,6 +28,57 @@ function cloneRuntimeDebugValue(value, fallback = null) {
}
}
function redactSensitiveString(value) {
return String(value ?? "")
.replace(/(Bearer\s+)[^\s"'\r\n]+/gi, "$1[REDACTED]")
.replace(
/(Authorization\s*:\s*Bearer\s+)[^\s"'\r\n]+/gi,
"$1[REDACTED]",
)
.replace(/(proxy_password\s*:\s*)[^\r\n]+/gi, "$1[REDACTED]");
}
function redactSensitiveValue(value, currentKey = "") {
if (value == null) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => redactSensitiveValue(item, currentKey));
}
if (typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [
key,
redactSensitiveValue(entryValue, key),
]),
);
}
if (typeof value === "string") {
if (SENSITIVE_DEBUG_KEY_PATTERN.test(String(currentKey || ""))) {
return value ? "[REDACTED]" : "";
}
return redactSensitiveString(value);
}
if (SENSITIVE_DEBUG_KEY_PATTERN.test(String(currentKey || ""))) {
return "[REDACTED]";
}
return value;
}
function sanitizeLlmDebugSnapshot(snapshot = {}) {
const cloned = cloneRuntimeDebugValue(snapshot, {});
const redacted = redactSensitiveValue(cloned);
if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) {
redacted.redacted = true;
}
return redacted;
}
function getRuntimeDebugState() {
const stateKey = "__stBmeRuntimeDebugState";
if (
@@ -41,12 +96,17 @@ function getRuntimeDebugState() {
return globalThis[stateKey];
}
function recordTaskLlmRequest(taskType, snapshot = {}) {
function recordTaskLlmRequest(taskType, snapshot = {}, options = {}) {
const normalizedTaskType = String(taskType || "").trim() || "unknown";
const state = getRuntimeDebugState();
const shouldMerge = options?.merge === true;
const previousSnapshot = shouldMerge
? cloneRuntimeDebugValue(state.taskLlmRequests[normalizedTaskType], {})
: {};
state.taskLlmRequests[normalizedTaskType] = {
...previousSnapshot,
updatedAt: new Date().toISOString(),
...cloneRuntimeDebugValue(snapshot, {}),
...sanitizeLlmDebugSnapshot(snapshot),
};
state.updatedAt = new Date().toISOString();
}
@@ -67,10 +127,131 @@ function getMemoryLLMConfig() {
}
function getConfiguredTimeoutMs(settings = {}) {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: LLM_REQUEST_TIMEOUT_MS;
return typeof resolveConfiguredTimeoutMs === "function"
? resolveConfiguredTimeoutMs(settings, LLM_REQUEST_TIMEOUT_MS)
: (() => {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: LLM_REQUEST_TIMEOUT_MS;
})();
}
function normalizeRegexDebugEntries(debugCollector = null) {
if (!Array.isArray(debugCollector?.entries)) {
return [];
}
return debugCollector.entries.map((entry) => ({
taskType: String(entry?.taskType || ""),
stage: String(entry?.stage || ""),
enabled: entry?.enabled !== false,
appliedRules: Array.isArray(entry?.appliedRules)
? entry.appliedRules.map((rule) => ({
id: String(rule?.id || ""),
source: String(rule?.source || ""),
error: String(rule?.error || ""),
}))
: [],
sourceCount: {
tavern: Number(entry?.sourceCount?.tavern || 0),
local: Number(entry?.sourceCount?.local || 0),
},
}));
}
function applyTaskOutputRegexStages(taskType, text) {
const normalizedTaskType = String(taskType || "").trim();
const rawText = typeof text === "string" ? text : "";
if (!normalizedTaskType || !rawText) {
return {
cleanedText: rawText,
debug: {
changed: false,
applied: false,
stages: [],
rawLength: rawText.length,
cleanedLength: rawText.length,
},
};
}
const settings = extension_settings[MODULE_NAME] || {};
const regexDebug = { entries: [] };
const afterRawStage = applyTaskRegex(
settings,
normalizedTaskType,
"output.rawResponse",
rawText,
regexDebug,
"assistant",
);
const cleanedText = applyTaskRegex(
settings,
normalizedTaskType,
"output.beforeParse",
afterRawStage,
regexDebug,
"assistant",
);
const normalizedEntries = normalizeRegexDebugEntries(regexDebug);
const applied = normalizedEntries.some(
(entry) => entry.appliedRules.length > 0,
);
return {
cleanedText,
debug: {
changed: cleanedText !== rawText,
applied,
rawLength: rawText.length,
cleanedLength: cleanedText.length,
stages: normalizedEntries,
},
};
}
function buildEffectiveLlmRoute(
hasDedicatedConfig,
privateRequestSource,
taskType = "",
) {
const dedicated = Boolean(hasDedicatedConfig);
return {
taskType: String(taskType || "").trim(),
requestSource: String(privateRequestSource || "").trim(),
llm: dedicated ? "dedicated-memory-llm" : "sillytavern-current-model",
transport: dedicated
? "dedicated-openai-compatible"
: "sillytavern-current-model",
};
}
function buildPromptExecutionSummary(debugContext = null) {
if (!debugContext || typeof debugContext !== "object") {
return null;
}
return {
promptAssembly:
debugContext.promptAssembly && typeof debugContext.promptAssembly === "object"
? cloneRuntimeDebugValue(debugContext.promptAssembly, {})
: null,
promptBuild:
debugContext.promptBuild && typeof debugContext.promptBuild === "object"
? cloneRuntimeDebugValue(debugContext.promptBuild, {})
: null,
effectiveDelivery:
debugContext.effectiveDelivery &&
typeof debugContext.effectiveDelivery === "object"
? cloneRuntimeDebugValue(debugContext.effectiveDelivery, {})
: null,
ejsRuntimeStatus: String(debugContext.ejsRuntimeStatus || ""),
worldInfo:
debugContext.worldInfo && typeof debugContext.worldInfo === "object"
? cloneRuntimeDebugValue(debugContext.worldInfo, {})
: null,
regexInput: normalizeRegexDebugEntries(debugContext.regexInput),
};
}
function normalizeOpenAICompatibleBaseUrl(value) {
@@ -423,6 +604,11 @@ async function callDedicatedOpenAICompatible(
filteredGeneration: generationResolved.filtered || {},
removedGeneration: generationResolved.removed || [],
capabilityMode: generationResolved.capabilityMode || "",
effectiveRoute: buildEffectiveLlmRoute(
hasDedicatedConfig,
privateRequestSource,
taskType,
),
maxCompletionTokens,
});
if (!hasDedicatedConfig) {
@@ -521,6 +707,11 @@ async function callDedicatedOpenAICompatible(
removedGeneration: generationResolved.removed || [],
capabilityMode: generationResolved.capabilityMode || "",
resolvedCompletionTokens,
effectiveRoute: buildEffectiveLlmRoute(
true,
privateRequestSource,
taskType,
),
requestBody: body,
});
@@ -605,6 +796,7 @@ export async function callLLMForJSON({
taskType = "",
requestSource = "",
additionalMessages = [],
debugContext = null,
} = {}) {
const override = getLlmTestOverride("callLLMForJSON");
if (override) {
@@ -616,6 +808,7 @@ export async function callLLMForJSON({
taskType,
requestSource,
additionalMessages,
debugContext,
});
}
@@ -624,6 +817,7 @@ export async function callLLMForJSON({
requestSource,
);
let lastFailureReason = "";
const promptExecutionSummary = buildPromptExecutionSummary(debugContext);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
@@ -645,6 +839,17 @@ export async function callLLMForJSON({
: RETRY_JSON_COMPLETION_TOKENS,
});
const responseText = response?.content || "";
const outputCleanup = applyTaskOutputRegexStages(taskType, responseText);
recordTaskLlmRequest(
taskType || privateRequestSource,
{
responseCleaning: outputCleanup.debug,
promptExecution: promptExecutionSummary,
},
{
merge: true,
},
);
if (!responseText || typeof responseText !== "string") {
console.warn(`[ST-BME] LLM 返回空响应 (尝试 ${attempt + 1})`);
@@ -653,14 +858,14 @@ export async function callLLMForJSON({
}
// 尝试解析 JSON
const parsed = extractJSON(responseText);
const parsed = extractJSON(outputCleanup.cleanedText);
if (parsed !== null) {
return parsed;
}
const truncated =
response.finishReason === "length" ||
looksLikeTruncatedJson(responseText);
looksLikeTruncatedJson(outputCleanup.cleanedText);
lastFailureReason = truncated
? "输出因长度限制被截断,请重新输出更紧凑的完整 JSON"
: "输出不是有效 JSON请严格返回紧凑 JSON 对象";

290
panel.js
View File

@@ -444,23 +444,41 @@ function _renderRecentList(elementId, items) {
if (!listEl) return;
if (!items.length) {
listEl.innerHTML =
'<li class="bme-recent-item"><div class="bme-recent-text" style="color:var(--bme-on-surface-dim)">暂无数据</div></li>';
const li = document.createElement("li");
li.className = "bme-recent-item";
const text = document.createElement("div");
text.className = "bme-recent-text";
text.style.color = "var(--bme-on-surface-dim)";
text.textContent = "暂无数据";
li.appendChild(text);
listEl.replaceChildren(li);
return;
}
listEl.innerHTML = items
.map((item) => {
const secondary = item.meta || item.time || "";
return `<li class="bme-recent-item">
<span class="bme-type-badge ${item.type}">${_typeLabel(item.type)}</span>
<div>
<div class="bme-recent-text">${_escHtml(item.name || "—")}</div>
<div class="bme-recent-meta">${_escHtml(secondary)}</div>
</div>
</li>`;
})
.join("");
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const secondary = item.meta || item.time || "";
const li = document.createElement("li");
li.className = "bme-recent-item";
const badge = document.createElement("span");
badge.className = `bme-type-badge ${_safeCssToken(item.type)}`;
badge.textContent = _typeLabel(item.type);
li.appendChild(badge);
const content = document.createElement("div");
const title = document.createElement("div");
title.className = "bme-recent-text";
title.textContent = item.name || "—";
const meta = document.createElement("div");
meta.className = "bme-recent-meta";
meta.textContent = secondary;
content.append(title, meta);
li.appendChild(content);
fragment.appendChild(li);
});
listEl.replaceChildren(fragment);
}
// ==================== 记忆浏览器 ====================
@@ -497,25 +515,43 @@ function _refreshMemoryBrowser() {
return (b.seqRange?.[1] ?? b.seq ?? 0) - (a.seqRange?.[1] ?? a.seq ?? 0);
});
listEl.innerHTML = nodes
.slice(0, 100)
.map((node) => {
const name = getNodeDisplayName(node);
const snippet = _getNodeSnippet(node);
return `<li class="bme-memory-item" data-node-id="${node.id}">
<span class="bme-type-badge ${node.type}">${_typeLabel(node.type)}</span>
<div>
<div class="bme-memory-name">${_escHtml(name)}</div>
<div class="bme-memory-content">${_escHtml(snippet)}</div>
<div class="bme-memory-meta">
<span>imp: ${node.importance || 5}</span>
<span>acc: ${node.accessCount || 0}</span>
<span>seq: ${node.seqRange?.[1] ?? node.seq ?? 0}</span>
</div>
</div>
</li>`;
})
.join("");
const fragment = document.createDocumentFragment();
nodes.slice(0, 100).forEach((node) => {
const name = getNodeDisplayName(node);
const snippet = _getNodeSnippet(node);
const li = document.createElement("li");
li.className = "bme-memory-item";
li.dataset.nodeId = String(node.id || "");
const badge = document.createElement("span");
badge.className = `bme-type-badge ${_safeCssToken(node.type)}`;
badge.textContent = _typeLabel(node.type);
li.appendChild(badge);
const content = document.createElement("div");
const title = document.createElement("div");
title.className = "bme-memory-name";
title.textContent = name;
const body = document.createElement("div");
body.className = "bme-memory-content";
body.textContent = snippet;
const meta = document.createElement("div");
meta.className = "bme-memory-meta";
["imp", "acc", "seq"].forEach((key, index) => {
const span = document.createElement("span");
span.textContent =
index === 0
? `imp: ${node.importance || 5}`
: index === 1
? `acc: ${node.accessCount || 0}`
: `seq: ${node.seqRange?.[1] ?? node.seq ?? 0}`;
meta.appendChild(span);
});
content.append(title, body, meta);
li.appendChild(content);
fragment.appendChild(li);
});
listEl.replaceChildren(fragment);
listEl.querySelectorAll(".bme-memory-item").forEach((el) => {
el.addEventListener("click", () => {
@@ -547,8 +583,11 @@ async function _refreshInjectionPreview() {
const injection = String(_getLastInjection?.() || "").trim();
if (!injection) {
container.innerHTML =
'<div class="bme-injection-preview" style="color:var(--bme-on-surface-dim)">暂无注入内容。先完成一次召回或正常生成后再查看。</div>';
const empty = document.createElement("div");
empty.className = "bme-injection-preview";
empty.style.color = "var(--bme-on-surface-dim)";
empty.textContent = "暂无注入内容。先完成一次召回或正常生成后再查看。";
container.replaceChildren(empty);
if (tokenEl) tokenEl.textContent = "";
return;
}
@@ -556,10 +595,17 @@ async function _refreshInjectionPreview() {
try {
const { estimateTokens } = await import("./injector.js");
const totalTokens = estimateTokens(injection);
container.innerHTML = `<div class="bme-injection-preview">${_escHtml(injection)}</div>`;
const preview = document.createElement("div");
preview.className = "bme-injection-preview";
preview.textContent = injection;
container.replaceChildren(preview);
if (tokenEl) tokenEl.textContent = `${totalTokens} tokens`;
} catch (error) {
container.innerHTML = `<div class="bme-injection-preview" style="color:var(--bme-accent3)">预览生成失败: ${_escHtml(error.message)}</div>`;
const failure = document.createElement("div");
failure.className = "bme-injection-preview";
failure.style.color = "var(--bme-accent3)";
failure.textContent = `预览生成失败: ${error.message}`;
container.replaceChildren(failure);
if (tokenEl) tokenEl.textContent = "";
}
}
@@ -589,14 +635,18 @@ function _buildLegend() {
{ key: "reflection", label: "反思" },
];
legendEl.innerHTML = types
.map(
(type) => `<span class="bme-legend-item">
<span class="bme-legend-dot" style="background:${colors[type.key]}"></span>
${type.label}
</span>`,
)
.join("");
const fragment = document.createDocumentFragment();
types.forEach((type) => {
const item = document.createElement("span");
item.className = "bme-legend-item";
const dot = document.createElement("span");
dot.className = "bme-legend-dot";
dot.style.background = colors[type.key] || "";
item.appendChild(dot);
item.append(document.createTextNode(type.label));
fragment.appendChild(item);
});
legendEl.replaceChildren(fragment);
}
function _bindGraphControls() {
@@ -648,14 +698,19 @@ function _showNodeDetail(node) {
});
}
bodyEl.innerHTML = items
.map(
(item) => `<div class="bme-node-detail-field">
<label>${_escHtml(item.label)}</label>
<div class="value">${_escHtml(String(item.value ?? "—"))}</div>
</div>`,
)
.join("");
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const row = document.createElement("div");
row.className = "bme-node-detail-field";
const label = document.createElement("label");
label.textContent = item.label;
const value = document.createElement("div");
value.className = "value";
value.textContent = String(item.value ?? "—");
row.append(label, value);
fragment.appendChild(row);
});
bodyEl.replaceChildren(fragment);
detailEl.classList.add("open");
}
@@ -1856,7 +1911,7 @@ function _renderTaskProfileWorkspace(state) {
<button
class="bme-task-type-btn ${item.id === state.taskType ? "active" : ""}"
data-task-action="switch-task-type"
data-task-type="${_escHtml(item.id)}"
data-task-type="${_escAttr(item.id)}"
type="button"
>
<span>${_escHtml(item.label)}</span>
@@ -1892,7 +1947,7 @@ function _renderTaskProfileWorkspace(state) {
.map(
(profile) => `
<option
value="${_escHtml(profile.id)}"
value="${_escAttr(profile.id)}"
${profile.id === state.profile.id ? "selected" : ""}
>
${_escHtml(profile.name)}${profile.builtin ? " · 内置" : ""}
@@ -1908,7 +1963,7 @@ function _renderTaskProfileWorkspace(state) {
id="bme-task-profile-name"
class="bme-config-input"
type="text"
value="${_escHtml(state.profile.name || "")}"
value="${_escAttr(state.profile.name || "")}"
placeholder="输入预设名称"
/>
</div>
@@ -1933,7 +1988,7 @@ function _renderTaskProfileWorkspace(state) {
<button
class="bme-task-subtab-btn ${tab.id === state.taskTabId ? "active" : ""}"
data-task-action="switch-task-tab"
data-task-tab="${_escHtml(tab.id)}"
data-task-tab="${_escAttr(tab.id)}"
type="button"
>
${_escHtml(tab.label)}
@@ -1980,7 +2035,7 @@ function _renderTaskPromptTab(state) {
${state.builtinBlockDefinitions
.map(
(item) => `
<option value="${_escHtml(item.sourceKey)}">
<option value="${_escAttr(item.sourceKey)}">
${_escHtml(item.name)}
</option>
`,
@@ -2122,7 +2177,7 @@ function _renderTaskRegexTab(state) {
</span>
<input
type="checkbox"
data-regex-stage="${_escHtml(stage.key)}"
data-regex-stage="${_escAttr(stage.key)}"
${(regex.stages?.[stage.key] ?? true) ? "checked" : ""}
/>
</label>
@@ -2292,19 +2347,33 @@ function _renderTaskDebugPromptCard(taskType, promptBuild) {
<span class="bme-debug-kv-value">${_escHtml(String(promptBuild.debug?.renderedBlockCount ?? promptBuild.renderedBlocks?.length ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">宿主注入</span>
<span class="bme-debug-kv-key">注入计划</span>
<span class="bme-debug-kv-value">${_escHtml(String(promptBuild.debug?.hostInjectionPlanCount ?? promptBuild.debug?.hostInjectionCount ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">私有消息</span>
<span class="bme-debug-kv-value">${_escHtml(String(promptBuild.debug?.privateTaskMessageCount ?? promptBuild.privateTaskMessages?.length ?? 0))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">EJS 状态</span>
<span class="bme-debug-kv-value">${_escHtml(promptBuild.debug?.ejsRuntimeStatus || "unknown")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">世界书</span>
<span class="bme-debug-kv-value">${_escHtml(promptBuild.debug?.effectivePath?.worldInfo || "unknown")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">世界书缓存</span>
<span class="bme-debug-kv-value">${_escHtml(promptBuild.debug?.worldInfoCacheHit ? "命中" : "未命中")}</span>
</div>
</div>
${_renderDebugDetails("实际投递路径", promptBuild.debug?.effectivePath || null)}
${_renderDebugDetails("渲染后的块", promptBuild.renderedBlocks)}
${_renderDebugDetails("宿主注入计划", promptBuild.hostInjectionPlan || null)}
${_renderDebugDetails("宿主注入描述", promptBuild.hostInjections)}
${_renderDebugDetails("注入计划(推导)", promptBuild.hostInjectionPlan || null)}
${_renderDebugDetails("世界书注入内容(当前实际仍走私有 prompt", promptBuild.hostInjections)}
${_renderDebugDetails("私有任务消息", promptBuild.privateTaskMessages)}
${_renderDebugDetails("系统提示词", promptBuild.systemPrompt || "")}
${_renderDebugDetails("世界书调试", promptBuild.worldInfo?.debug || promptBuild.worldInfoResolution?.debug || null)}
`;
}
@@ -2343,7 +2412,22 @@ function _renderTaskDebugLlmCard(taskType, llmRequest) {
<span class="bme-debug-kv-key">能力过滤模式</span>
<span class="bme-debug-kv-value">${_escHtml(llmRequest.capabilityMode || "—")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">调试脱敏</span>
<span class="bme-debug-kv-value">${_escHtml(llmRequest.redacted ? "已脱敏" : "未标记")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">实际路径</span>
<span class="bme-debug-kv-value">${_escHtml(llmRequest.effectiveRoute?.llm || llmRequest.route || "—")}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">输出清洗</span>
<span class="bme-debug-kv-value">${_escHtml(llmRequest.responseCleaning?.applied ? "已生效" : "未生效")}</span>
</div>
</div>
${_renderDebugDetails("提示词执行摘要", llmRequest.promptExecution || null)}
${_renderDebugDetails("实际请求路径", llmRequest.effectiveRoute || null)}
${_renderDebugDetails("输出清洗", llmRequest.responseCleaning || null)}
${_renderDebugDetails("实际保留参数", llmRequest.filteredGeneration || {})}
${_renderDebugDetails("被过滤掉的参数", llmRequest.removedGeneration || [])}
${_renderDebugDetails("最终消息列表", llmRequest.messages || [])}
@@ -2437,7 +2521,7 @@ function _renderTaskBlockListItem(block, index, state) {
<button
class="bme-task-list-item ${isSelected ? "active" : ""}"
data-task-action="select-block"
data-block-id="${_escHtml(block.id)}"
data-block-id="${_escAttr(block.id)}"
type="button"
>
<span class="bme-task-list-index">#${index + 1}</span>
@@ -2454,7 +2538,7 @@ function _renderTaskBlockListItem(block, index, state) {
<button
class="bme-config-secondary-btn bme-task-mini-btn"
data-task-action="move-block-up"
data-block-id="${_escHtml(block.id)}"
data-block-id="${_escAttr(block.id)}"
type="button"
>
上移
@@ -2462,7 +2546,7 @@ function _renderTaskBlockListItem(block, index, state) {
<button
class="bme-config-secondary-btn bme-task-mini-btn"
data-task-action="move-block-down"
data-block-id="${_escHtml(block.id)}"
data-block-id="${_escAttr(block.id)}"
type="button"
>
下移
@@ -2470,7 +2554,7 @@ function _renderTaskBlockListItem(block, index, state) {
<button
class="bme-config-secondary-btn bme-task-mini-btn"
data-task-action="toggle-block-enabled"
data-block-id="${_escHtml(block.id)}"
data-block-id="${_escAttr(block.id)}"
type="button"
>
${block.enabled ? "停用" : "启用"}
@@ -2478,7 +2562,7 @@ function _renderTaskBlockListItem(block, index, state) {
<button
class="bme-config-secondary-btn bme-task-mini-btn"
data-task-action="delete-block"
data-block-id="${_escHtml(block.id)}"
data-block-id="${_escAttr(block.id)}"
type="button"
>
删除
@@ -2501,7 +2585,7 @@ function _renderTaskBlockEditor(state) {
.map(
(item) => `
<option
value="${_escHtml(item.sourceKey)}"
value="${_escAttr(item.sourceKey)}"
${item.sourceKey === block.sourceKey ? "selected" : ""}
>
${_escHtml(item.name)}
@@ -2531,13 +2615,13 @@ function _renderTaskBlockEditor(state) {
<div class="bme-config-row">
<label>块名称</label>
<input
class="bme-config-input"
type="text"
data-block-field="name"
value="${_escHtml(block.name || "")}"
placeholder="用于工作区显示"
/>
<input
class="bme-config-input"
type="text"
data-block-field="name"
value="${_escAttr(block.name || "")}"
placeholder="用于工作区显示"
/>
</div>
<div class="bme-task-field-grid">
@@ -2624,7 +2708,7 @@ function _renderTaskBlockEditor(state) {
<input
class="bme-config-input"
type="text"
value="${_escHtml(legacyField || block.sourceField || "")}"
value="${_escAttr(legacyField || block.sourceField || "")}"
readonly
/>
</div>
@@ -2662,7 +2746,7 @@ function _renderGenerationField(field, value) {
<label>${_escHtml(field.label)}</label>
<select
class="bme-config-input"
data-generation-key="${_escHtml(field.key)}"
data-generation-key="${_escAttr(field.key)}"
data-value-type="tri_bool"
>
${TASK_PROFILE_BOOLEAN_OPTIONS.map(
@@ -2683,13 +2767,13 @@ function _renderGenerationField(field, value) {
<label>${_escHtml(field.label)}</label>
<select
class="bme-config-input"
data-generation-key="${_escHtml(field.key)}"
data-generation-key="${_escAttr(field.key)}"
data-value-type="text"
>
${(field.options || [])
.map(
(item) => `
<option value="${_escHtml(item.value)}" ${item.value === String(effectiveValue ?? "") ? "selected" : ""}>
<option value="${_escAttr(item.value)}" ${item.value === String(effectiveValue ?? "") ? "selected" : ""}>
${_escHtml(item.label)}
</option>
`,
@@ -2714,7 +2798,7 @@ function _renderGenerationField(field, value) {
max="${field.max ?? 1}"
step="${field.step ?? 0.01}"
value="${displayValue}"
data-generation-key="${_escHtml(field.key)}"
data-generation-key="${_escAttr(field.key)}"
data-value-type="number"
/>
<input
@@ -2723,9 +2807,9 @@ function _renderGenerationField(field, value) {
min="${field.min ?? 0}"
max="${field.max ?? 1}"
step="${field.step ?? 0.01}"
value="${_escHtml(numValue)}"
value="${_escAttr(numValue)}"
placeholder="默认"
data-generation-key="${_escHtml(field.key)}"
data-generation-key="${_escAttr(field.key)}"
data-value-type="number"
/>
</div>
@@ -2740,9 +2824,9 @@ function _renderGenerationField(field, value) {
class="bme-config-input"
type="${field.type === "text" ? "text" : "number"}"
${field.step ? `step="${field.step}"` : ""}
value="${_escHtml(effectiveValue ?? "")}"
value="${_escAttr(effectiveValue ?? "")}"
placeholder="留空 = 跟随默认"
data-generation-key="${_escHtml(field.key)}"
data-generation-key="${_escAttr(field.key)}"
data-value-type="${field.type === "text" ? "text" : "number"}"
/>
</div>
@@ -2756,7 +2840,7 @@ function _renderRegexRuleListItem(rule, index, state) {
<button
class="bme-task-list-item ${isSelected ? "active" : ""}"
data-task-action="select-regex-rule"
data-rule-id="${_escHtml(rule.id)}"
data-rule-id="${_escAttr(rule.id)}"
type="button"
>
<span class="bme-task-list-index">#${index + 1}</span>
@@ -2771,7 +2855,7 @@ function _renderRegexRuleListItem(rule, index, state) {
<button
class="bme-config-secondary-btn bme-task-mini-btn"
data-task-action="delete-regex-rule"
data-rule-id="${_escHtml(rule.id)}"
data-rule-id="${_escAttr(rule.id)}"
type="button"
>
删除
@@ -2807,12 +2891,12 @@ function _renderRegexRuleEditor(state) {
<div class="bme-config-row">
<label>规则名称</label>
<input
class="bme-config-input"
type="text"
data-regex-rule-field="script_name"
value="${_escHtml(rule.script_name || "")}"
/>
<input
class="bme-config-input"
type="text"
data-regex-rule-field="script_name"
value="${_escAttr(rule.script_name || "")}"
/>
</div>
<label class="bme-toggle-item bme-task-editor-toggle">
@@ -2861,7 +2945,7 @@ function _renderRegexRuleEditor(state) {
class="bme-config-input"
type="number"
data-regex-rule-field="min_depth"
value="${_escHtml(rule.min_depth ?? 0)}"
value="${_escAttr(rule.min_depth ?? 0)}"
/>
</div>
<div class="bme-config-row">
@@ -2870,7 +2954,7 @@ function _renderRegexRuleEditor(state) {
class="bme-config-input"
type="number"
data-regex-rule-field="max_depth"
value="${_escHtml(rule.max_depth ?? 9999)}"
value="${_escAttr(rule.max_depth ?? 9999)}"
/>
</div>
</div>
@@ -3505,6 +3589,24 @@ function _escHtml(str) {
return div.innerHTML;
}
function _escAttr(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function _safeCssToken(value, fallback = "unknown") {
const token = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return token || fallback;
}
function _typeLabel(type) {
const map = {
character: "角色",

View File

@@ -53,6 +53,60 @@ function recordTaskPromptBuild(taskType, snapshot = {}) {
state.updatedAt = new Date().toISOString();
}
export function buildTaskExecutionDebugContext(
promptBuild = null,
options = {},
) {
const promptDebug = promptBuild?.debug || {};
const worldInfoDebug =
promptBuild?.worldInfo?.debug || promptBuild?.worldInfoResolution?.debug || {};
const worldInfoHit =
Number(promptDebug.worldInfoBeforeCount || 0) +
Number(promptDebug.worldInfoAfterCount || 0) +
Number(promptDebug.worldInfoAtDepthCount || 0) >
0;
return {
promptAssembly: {
mode: "private-task-prompt",
hostInjectionPlanMode:
promptDebug.hostInjectionPlanMode || "diagnostic-plan-only",
privateTaskMessageCount: Number(
promptDebug.privateTaskMessageCount ??
promptBuild?.privateTaskMessages?.length ??
0,
),
},
promptBuild: {
taskType: String(promptDebug.taskType || ""),
profileId: String(promptDebug.profileId || ""),
profileName: String(promptDebug.profileName || ""),
renderedBlockCount: Number(promptDebug.renderedBlockCount || 0),
privateTaskMessageCount: Number(promptDebug.privateTaskMessageCount || 0),
},
effectiveDelivery:
promptDebug.effectiveDelivery && typeof promptDebug.effectiveDelivery === "object"
? cloneRuntimeDebugValue(promptDebug.effectiveDelivery, {})
: null,
ejsRuntimeStatus: String(
promptDebug.ejsRuntimeStatus || worldInfoDebug.ejsRuntimeStatus || "",
),
worldInfo: {
requested: promptDebug.worldInfoRequested !== false,
hit: worldInfoHit,
cacheHit: Boolean(promptDebug.worldInfoCacheHit),
beforeCount: Number(promptDebug.worldInfoBeforeCount || 0),
afterCount: Number(promptDebug.worldInfoAfterCount || 0),
atDepthCount: Number(promptDebug.worldInfoAtDepthCount || 0),
loadMs: Number(worldInfoDebug.loadMs || 0),
},
regexInput:
options.regexInput && typeof options.regexInput === "object"
? cloneRuntimeDebugValue(options.regexInput, {})
: null,
};
}
function getByPath(target, path) {
return String(path || "")
.split(".")
@@ -99,6 +153,7 @@ function buildEmptyWorldInfoContext() {
worldInfoAtDepthEntries: [],
activatedWorldInfoNames: [],
taskAdditionalMessages: [],
worldInfoDebug: null,
};
}
@@ -147,6 +202,11 @@ function buildWorldInfoResolution(worldInfoContext = {}) {
? worldInfoContext.activatedWorldInfoNames
: [],
additionalMessages,
debug:
worldInfoContext.worldInfoDebug &&
typeof worldInfoContext.worldInfoDebug === "object"
? worldInfoContext.worldInfoDebug
: null,
injections: {
before: beforeEntries
.map((entry) => createHostInjectionEntry(entry, "before"))
@@ -338,6 +398,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
worldInfoAtDepthEntries: worldInfo.atDepthEntries || [],
activatedWorldInfoNames: worldInfo.activatedEntryNames || [],
taskAdditionalMessages: worldInfo.additionalMessages || [],
worldInfoDebug: worldInfo.debug || null,
};
}
@@ -388,6 +449,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
: block._orderIndex,
injectionMode: mode,
delivery: resolveBlockDelivery(block),
effectiveDelivery: role === "system" ? "private.system" : "private.message",
});
if (role === "system") {
@@ -438,6 +500,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
afterEntries: worldInfoResolution.afterEntries,
atDepthEntries: worldInfoResolution.atDepthEntries,
activatedEntryNames: worldInfoResolution.activatedEntryNames,
debug: worldInfoResolution.debug,
},
debug: {
taskType,
@@ -458,9 +521,31 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
hostInjectionPlan.before.length +
hostInjectionPlan.after.length +
hostInjectionPlan.atDepth.length,
hostInjectionPlanMode: "diagnostic-plan-only",
customMessageCount: customMessages.length,
additionalMessageCount: worldInfoResolution.additionalMessages.length,
privateTaskMessageCount: privateTaskMessages.length,
effectiveDelivery: {
systemBlocks: "private.system",
customMessages: "private.message",
worldInfoBeforeAfter: "private.system (host injection plan is diagnostic only)",
worldInfoAtDepth: "private.message",
},
worldInfoCacheHit: Boolean(worldInfoResolution.debug?.cache?.hit),
ejsRuntimeStatus: worldInfoResolution.debug?.ejsRuntimeStatus || "",
effectivePath: {
promptAssembly: "private-task-prompt",
hostInjectionPlan: "diagnostic-plan-only",
ejs:
worldInfoResolution.debug?.ejsRuntimeStatus ||
"unknown",
worldInfo:
worldInfoRequested !== false
? worldInfoResolution.activatedEntryNames.length > 0
? "matched"
: "requested-but-missed"
: "disabled",
},
},
};

9
request-timeout.js Normal file
View File

@@ -0,0 +1,9 @@
export function resolveConfiguredTimeoutMs(
settings = {},
fallbackMs = 300000,
) {
const timeoutMs = Number(settings?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: fallbackMs;
}

View File

@@ -11,7 +11,7 @@ import {
getNodeEdges,
} from "./graph.js";
import { callLLMForJSON } from "./llm.js";
import { buildTaskPrompt } from "./prompt-builder.js";
import { buildTaskExecutionDebugContext, buildTaskPrompt } from "./prompt-builder.js";
import { applyTaskRegex } from "./task-regex.js";
import { getSTContextForPrompt } from "./st-context.js";
import { findSimilarNodesByText, validateVectorConfig } from "./vector-index.js";
@@ -22,6 +22,12 @@ function createAbortError(message = "操作已终止") {
return error;
}
function createTaskLlmDebugContext(promptBuild, regexInput) {
return typeof buildTaskExecutionDebugContext === "function"
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
: null;
}
function isAbortError(error) {
return error?.name === "AbortError";
}
@@ -428,6 +434,7 @@ async function llmRecall(
graphStats: `candidate_count=${candidates.length}`,
...getSTContextForPrompt(),
});
const recallRegexInput = { entries: [] };
const systemPrompt = applyTaskRegex(
settings,
"recall",
@@ -440,6 +447,8 @@ async function llmRecall(
"输出严格的 JSON 格式:",
'{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}',
].join("\n"),
recallRegexInput,
"system",
);
const userPrompt = [
@@ -461,6 +470,10 @@ async function llmRecall(
maxRetries: 1,
signal,
taskType: "recall",
debugContext: createTaskLlmDebugContext(
recallPromptBuild,
recallRegexInput,
),
additionalMessages:
recallPromptBuild.privateTaskMessages || [
...(recallPromptBuild.customMessages || []),

View File

@@ -52,6 +52,17 @@ function getCurrentEjsRuntimeState() {
return buildEjsRuntimeState(runtime, EJS_RUNTIME_STATUS.PRIMARY);
}
function createTaskEjsRuntimeUnavailableError(backend, content = "") {
const error = new Error(
`task-ejs runtime unavailable (${backend?.status || EJS_RUNTIME_STATUS.FAILED})`,
);
error.name = "TaskEjsRuntimeUnavailableError";
error.code = "st_bme_task_ejs_runtime_unavailable";
error.backend = backend || null;
error.content = String(content || "");
return error;
}
async function ensureEjsRuntime() {
const currentState = getCurrentEjsRuntimeState();
if (currentState.isAvailable) {
@@ -582,6 +593,9 @@ export function createTaskEjsRenderContext(entries = [], options = {}) {
variableState: createVariableState(hostSnapshot),
activatedEntries: new Map(),
pulledEntries: new Map(),
ejsRuntimeStatus: EJS_RUNTIME_STATUS.FAILED,
ejsRuntimeFallback: false,
ejsLastError: null,
templateContext: {
...(options.templateContext || {}),
hostSnapshot: hostSnapshot.snapshot,
@@ -593,16 +607,29 @@ export function createTaskEjsRenderContext(entries = [], options = {}) {
export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
const backend = await resolveTaskEjsBackend();
const runtime = backend.runtime;
if (renderCtx && typeof renderCtx === "object") {
renderCtx.ejsRuntimeStatus = backend.status;
renderCtx.ejsRuntimeFallback = Boolean(backend.isFallback);
renderCtx.ejsLastError = backend.error
? backend.error instanceof Error
? backend.error.message
: String(backend.error)
: null;
}
const hostSnapshot = resolveHostSnapshot(renderCtx?.hostSnapshot);
const snapshot = hostSnapshot.snapshot;
if (!runtime) {
console.warn(
"[ST-BME] task-ejs 未找到可用 ejs runtime跳过渲染:",
backend,
);
return substituteTaskEjsParams(content, renderCtx?.templateContext, {
const substituted = substituteTaskEjsParams(content, renderCtx?.templateContext, {
hostSnapshot,
});
if (substituted.includes("<%")) {
throw createTaskEjsRuntimeUnavailableError(backend, substituted);
}
console.warn(
"[ST-BME] task-ejs 未找到可用 ejs runtime回退为轻量变量替换:",
backend,
);
return substituted;
}
const processed = substituteTaskEjsParams(
@@ -856,6 +883,10 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
);
return result ?? "";
} catch (error) {
if (renderCtx && typeof renderCtx === "object") {
renderCtx.ejsLastError =
error instanceof Error ? error.message : String(error);
}
console.warn("[ST-BME] task-ejs 渲染失败,回退原文本:", error);
return processed;
}

View File

@@ -319,16 +319,26 @@ function collectLocalRules(regexConfig = {}) {
}
function shouldApplyRuleForStage(rule, stage = "", stagesConfig = {}) {
// 将细粒度的 stage 名映射到 input / output 两大类
if (PROMPT_STAGES.has(stage)) {
const normalizedStage = String(stage || "").trim();
if (
normalizedStage &&
Object.prototype.hasOwnProperty.call(stagesConfig, normalizedStage)
) {
return (
stagesConfig[normalizedStage] !== false &&
rule.destinationFlags.prompt !== false
);
}
if (PROMPT_STAGES.has(normalizedStage)) {
return (
stagesConfig.input !== false && rule.destinationFlags.prompt !== false
);
}
if (OUTPUT_STAGES.has(stage)) {
return stagesConfig.output !== false;
if (OUTPUT_STAGES.has(normalizedStage)) {
return (
stagesConfig.output !== false && rule.destinationFlags.prompt !== false
);
}
// 未知 stage 回退到 input
return stagesConfig.input !== false && rule.destinationFlags.prompt !== false;
}

View File

@@ -5,6 +5,7 @@
import {
createTaskEjsRenderContext,
evalTaskEjsTemplate,
inspectTaskEjsRuntimeBackend,
substituteTaskEjsParams,
} from "./task-ejs.js";
@@ -36,6 +37,7 @@ const DEPTH_MAPPING = {
const DEFAULT_DEPTH = 4;
const DEFAULT_CONTROLLER_ENTRY_PREFIX = "EW/Controller/";
const WORLDINFO_CACHE_TTL_MS = 3000;
const KNOWN_DECORATORS = [
"@@activate",
"@@dont_activate",
@@ -63,6 +65,14 @@ const SPECIAL_NAME_MARKERS = [
"[InitialVariables]",
];
let worldbookEntriesCache = {
key: "",
createdAt: 0,
expiresAt: 0,
entries: [],
debug: null,
};
function getStContext() {
try {
return globalThis.SillyTavern?.getContext?.() || {};
@@ -85,7 +95,9 @@ async function getWorldbookHost() {
try {
const { getHostAdapter } = await import("./host-adapter/index.js");
const worldbookHost = getHostAdapter?.()?.worldbook || null;
const adapter = getHostAdapter?.() || null;
const adapterSnapshot = adapter?.getSnapshot?.() || null;
const worldbookHost = adapter?.worldbook || null;
if (typeof worldbookHost?.getWorldbook === "function") {
const capabilitySupport = worldbookHost.readCapabilitySupport?.() || {};
const bridgeGetLorebookEntries =
@@ -132,6 +144,7 @@ async function getWorldbookHost() {
supplementedCapabilities: Object.freeze(supplementedCapabilities),
missingCapabilities: Object.freeze(missingCapabilities),
}),
snapshotRevision: Number(adapterSnapshot?.snapshotRevision || 0),
};
}
} catch (error) {
@@ -160,6 +173,7 @@ async function getWorldbookHost() {
supplementedCapabilities: Object.freeze([]),
missingCapabilities: Object.freeze(missingCapabilities),
}),
snapshotRevision: 0,
};
}
@@ -444,7 +458,21 @@ function selectActivatedEntries(
templateContext = {},
) {
const activationSeedBase = simpleHash(String(trigger || ""));
const activated = new Set();
const activated = new Map();
const addActivated = (entry, activationDebug = {}) => {
const key = `${entry.worldbook}:${entry.uid}:${entry.name}`;
activated.set(key, {
...entry,
activationDebug: {
mode: activationDebug.mode || "",
matchedPrimaryKey: activationDebug.matchedPrimaryKey || "",
matchedSecondaryKeys: Array.isArray(activationDebug.matchedSecondaryKeys)
? activationDebug.matchedSecondaryKeys
: [],
},
});
};
for (const entry of entries) {
if (!entry.enabled) continue;
@@ -457,12 +485,12 @@ function selectActivatedEntries(
}
if (entry.constant) {
activated.add(entry);
addActivated(entry, { mode: "constant" });
continue;
}
if (entry.decorators.includes("@@activate")) {
activated.add(entry);
addActivated(entry, { mode: "forced" });
continue;
}
if (entry.decorators.includes("@@dont_activate")) continue;
@@ -496,12 +524,16 @@ function selectActivatedEntries(
const hasSecondaryKeys = entry.selective && entry.keysSecondary.length > 0;
if (!hasSecondaryKeys) {
activated.add(entry);
addActivated(entry, {
mode: "selective",
matchedPrimaryKey: matchedPrimary,
});
continue;
}
let hasAnyMatch = false;
let hasAllMatch = true;
const matchedSecondaryKeys = [];
for (const secondaryKey of entry.keysSecondary) {
const substituted = substituteTaskEjsParams(
@@ -513,25 +545,42 @@ function selectActivatedEntries(
matchKeys(trigger, substituted.trim(), entry);
if (hasMatch) hasAnyMatch = true;
if (!hasMatch) hasAllMatch = false;
if (hasMatch) matchedSecondaryKeys.push(substituted.trim());
if (entry.selectiveLogic === WI_LOGIC.AND_ANY && hasMatch) {
activated.add(entry);
addActivated(entry, {
mode: "selective",
matchedPrimaryKey: matchedPrimary,
matchedSecondaryKeys,
});
break;
}
if (entry.selectiveLogic === WI_LOGIC.NOT_ALL && !hasMatch) {
activated.add(entry);
addActivated(entry, {
mode: "selective",
matchedPrimaryKey: matchedPrimary,
matchedSecondaryKeys,
});
break;
}
}
if (entry.selectiveLogic === WI_LOGIC.NOT_ANY && !hasAnyMatch) {
activated.add(entry);
addActivated(entry, {
mode: "selective",
matchedPrimaryKey: matchedPrimary,
matchedSecondaryKeys,
});
continue;
}
if (entry.selectiveLogic === WI_LOGIC.AND_ALL && hasAllMatch) {
activated.add(entry);
addActivated(entry, {
mode: "selective",
matchedPrimaryKey: matchedPrimary,
matchedSecondaryKeys,
});
}
}
@@ -539,7 +588,7 @@ function selectActivatedEntries(
return [];
}
const grouped = groupBy([...activated], (entry) => entry.group || "");
const grouped = groupBy([...activated.values()], (entry) => entry.group || "");
const ungrouped = grouped[""] || [];
if (ungrouped.length > 0 && Object.keys(grouped).length <= 1) {
return ungrouped.sort(sortEntries);
@@ -608,9 +657,30 @@ async function collectAllWorldbookEntries() {
sourceLabel,
fallback,
capabilityStatus,
snapshotRevision,
} = await getWorldbookHost();
const ctx = getStContext();
const debug = {
sourceLabel,
fallback,
capabilityStatus,
snapshotRevision: Number(snapshotRevision || 0),
requestedWorldbooks: [],
loadedWorldbooks: [],
worldbookCount: 0,
cache: {
hit: false,
key: "",
ageMs: 0,
ttlMs: WORLDINFO_CACHE_TTL_MS,
},
loadMs: 0,
};
if (!getWorldbook) {
return [];
return {
entries: [],
debug,
};
}
const sourceTag = `${sourceLabel}${fallback ? ", fallback" : ""}`;
@@ -628,8 +698,78 @@ async function collectAllWorldbookEntries() {
);
}
const charWorldbooks = {
primary: "",
additional: [],
};
if (getCharWorldbookNames) {
try {
const resolved = getCharWorldbookNames("current") || {};
charWorldbooks.primary = normalizeKey(resolved.primary);
charWorldbooks.additional = Array.isArray(resolved.additional)
? resolved.additional.map((name) => normalizeKey(name)).filter(Boolean)
: [];
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取角色世界书失败 [${sourceTag}]`,
error,
);
}
}
const personaLorebook =
ctx.extensionSettings?.persona_description_lorebook ||
ctx.powerUserSettings?.persona_description_lorebook ||
ctx.power_user?.persona_description_lorebook ||
"";
const chatLorebook = ctx.chatMetadata?.world || "";
const requestedWorldbooks = uniq(
[
charWorldbooks.primary,
...(charWorldbooks.additional || []),
personaLorebook,
chatLorebook,
]
.map((name) => normalizeKey(name))
.filter(Boolean),
);
debug.requestedWorldbooks = requestedWorldbooks;
const cacheKey = JSON.stringify({
chatId: ctx.chatId || globalThis.getCurrentChatId?.() || "",
characterId: ctx.characterId ?? "",
requestedWorldbooks,
sourceLabel,
fallback,
snapshotRevision: Number(snapshotRevision || 0),
});
debug.cache.key = cacheKey;
if (
worldbookEntriesCache.key === cacheKey &&
worldbookEntriesCache.expiresAt > Date.now()
) {
return {
entries: worldbookEntriesCache.entries,
debug: {
...debug,
loadedWorldbooks:
worldbookEntriesCache.debug?.loadedWorldbooks || requestedWorldbooks,
worldbookCount: worldbookEntriesCache.entries.length,
loadMs: worldbookEntriesCache.debug?.loadMs || 0,
cache: {
...debug.cache,
hit: true,
ageMs: Math.max(0, Date.now() - worldbookEntriesCache.createdAt),
},
},
};
}
const allEntries = [];
const loadedNames = new Set();
const startedAt = Date.now();
async function loadWorldbookOnce(worldbookName) {
const normalizedName = normalizeKey(worldbookName);
@@ -675,39 +815,27 @@ async function collectAllWorldbookEntries() {
}
}
if (getCharWorldbookNames) {
try {
const charWorldbooks = getCharWorldbookNames("current") || {};
if (charWorldbooks.primary) {
await loadWorldbookOnce(charWorldbooks.primary);
}
for (const additional of charWorldbooks.additional || []) {
await loadWorldbookOnce(additional);
}
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取角色世界书失败 [${sourceTag}]`,
error,
);
}
for (const worldbookName of requestedWorldbooks) {
await loadWorldbookOnce(worldbookName);
}
const ctx = getStContext();
const personaLorebook =
ctx.extensionSettings?.persona_description_lorebook ||
ctx.powerUserSettings?.persona_description_lorebook ||
ctx.power_user?.persona_description_lorebook ||
"";
if (personaLorebook) {
await loadWorldbookOnce(personaLorebook);
}
debug.loadedWorldbooks = [...loadedNames];
debug.worldbookCount = allEntries.length;
debug.loadMs = Date.now() - startedAt;
worldbookEntriesCache = {
key: cacheKey,
createdAt: Date.now(),
expiresAt: Date.now() + WORLDINFO_CACHE_TTL_MS,
entries: allEntries,
debug: {
...debug,
},
};
const chatLorebook = ctx.chatMetadata?.world || "";
if (chatLorebook) {
await loadWorldbookOnce(chatLorebook);
}
return allEntries;
return {
entries: allEntries,
debug,
};
}
function classifyPosition(entry) {
@@ -742,6 +870,13 @@ function normalizeResolvedEntry(entry = {}, fallbackIndex = 0) {
depth: Number(entry.depth ?? 0),
order: Number(entry.order ?? 100),
index: fallbackIndex,
activationDebug:
entry.activationDebug && typeof entry.activationDebug === "object"
? {
...entry.activationDebug,
}
: null,
controllerSource: String(entry.controllerSource || ""),
};
}
@@ -836,11 +971,53 @@ export async function resolveTaskWorldInfo({
additionalMessages: [],
activatedEntryNames: [],
allEntries: [],
debug: {
sourceLabel: "",
fallback: false,
capabilityStatus: null,
snapshotRevision: 0,
requestedWorldbooks: [],
loadedWorldbooks: [],
worldbookCount: 0,
triggerLength: 0,
activatedEntryCount: 0,
constantActivatedCount: 0,
selectiveActivatedCount: 0,
controllerActivatedCount: 0,
controllerPulledCount: 0,
cache: {
hit: false,
key: "",
ageMs: 0,
ttlMs: WORLDINFO_CACHE_TTL_MS,
},
loadMs: 0,
ejsRuntimeStatus: "",
ejsRuntimeFallback: false,
ejsLastError: "",
warnings: [],
resolvedEntries: [],
},
};
try {
const allEntries = await collectAllWorldbookEntries();
const collected = await collectAllWorldbookEntries();
const allEntries = Array.isArray(collected?.entries) ? collected.entries : [];
result.allEntries = allEntries;
result.debug = {
...result.debug,
...(collected?.debug || {}),
cache: {
...result.debug.cache,
...(collected?.debug?.cache || {}),
},
warnings: Array.isArray(result.debug.warnings)
? result.debug.warnings
: [],
resolvedEntries: Array.isArray(result.debug.resolvedEntries)
? result.debug.resolvedEntries
: [],
};
if (allEntries.length === 0) {
return result;
}
@@ -851,14 +1028,34 @@ export async function resolveTaskWorldInfo({
templateContext,
});
const trigger = triggerTexts.join("\n\n");
if (!trigger.trim()) {
return result;
}
result.debug.triggerLength = trigger.length;
const ejsBackend = await inspectTaskEjsRuntimeBackend();
result.debug.ejsRuntimeStatus = ejsBackend.status || "";
result.debug.ejsRuntimeFallback = Boolean(ejsBackend.isFallback);
result.debug.ejsLastError = ejsBackend.error
? ejsBackend.error instanceof Error
? ejsBackend.error.message
: String(ejsBackend.error)
: "";
const activated = selectActivatedEntries(allEntries, trigger, {
...templateContext,
user_input: userMessage || templateContext?.user_input || "",
});
result.debug.activatedEntryCount = activated.length;
result.debug.constantActivatedCount = activated.filter(
(entry) => entry.activationDebug?.mode === "constant",
).length;
result.debug.selectiveActivatedCount = activated.filter(
(entry) => entry.activationDebug?.mode === "selective",
).length;
result.debug.controllerActivatedCount = activated.filter((entry) =>
entry.name.startsWith(String(
settings.worldInfoControllerEntryPrefix ||
settings.controller_entry_prefix ||
DEFAULT_CONTROLLER_ENTRY_PREFIX,
)),
).length;
if (activated.length === 0) {
return result;
}
@@ -892,6 +1089,7 @@ export async function resolveTaskWorldInfo({
renderCtx.pulledEntries.clear();
const sourceContent = entry.cleanContent || entry.content;
const isControllerEntry = entry.name.startsWith(String(controllerPrefix || ""));
let renderedContent = sourceContent;
try {
renderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, {
@@ -902,13 +1100,26 @@ export async function resolveTaskWorldInfo({
},
});
} catch (error) {
result.debug.warnings.push(
error?.code === "st_bme_task_ejs_runtime_unavailable"
? `世界书条目 ${entry.name} 依赖 EJS runtime当前已跳过`
: `世界书条目 ${entry.name} 渲染失败,已跳过`,
);
console.warn(
`[ST-BME] task-worldinfo 渲染世界书条目失败: ${entry.name}`,
error,
);
if (
error?.code === "st_bme_task_ejs_runtime_unavailable" &&
!result.debug.ejsLastError
) {
result.debug.ejsLastError =
error instanceof Error ? error.message : String(error);
}
renderedContent = "";
}
if (!String(renderedContent || "").trim()) {
if (!isControllerEntry && !String(renderedContent || "").trim()) {
continue;
}
@@ -920,23 +1131,7 @@ export async function resolveTaskWorldInfo({
? afterEntries
: atDepthEntries;
if (entry.name.startsWith(String(controllerPrefix || ""))) {
bucket.push(
normalizeResolvedEntry(
{
name: entry.name,
sourceName: entry.name,
worldbook: entry.worldbook,
content: sourceContent,
role: entry.role,
position: entry.position,
depth: entry.depth,
order: entry.order,
},
resolvedIndex++,
),
);
if (isControllerEntry) {
for (const pulledEntry of renderCtx.pulledEntries.values()) {
if (!String(pulledEntry.content || "").trim()) continue;
if (
@@ -956,10 +1151,16 @@ export async function resolveTaskWorldInfo({
position: entry.position,
depth: entry.depth,
order: entry.order,
activationDebug: {
...(entry.activationDebug || {}),
mode: "controller-pulled",
},
controllerSource: entry.name,
},
resolvedIndex++,
),
);
result.debug.controllerPulledCount += 1;
}
continue;
}
@@ -975,6 +1176,7 @@ export async function resolveTaskWorldInfo({
position: entry.position,
depth: entry.depth,
order: entry.order,
activationDebug: entry.activationDebug,
},
resolvedIndex++,
),
@@ -987,6 +1189,35 @@ export async function resolveTaskWorldInfo({
result.beforeText = buildWorldInfoText(result.beforeEntries);
result.afterText = buildWorldInfoText(result.afterEntries);
result.additionalMessages = buildAdditionalMessages(result.atDepthEntries);
result.debug.resolvedEntries = [
...result.beforeEntries.map((entry) => ({
name: entry.name,
bucket: "before",
sourceName: entry.sourceName,
activationMode: entry.activationDebug?.mode || "",
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
controllerSource: entry.controllerSource || "",
})),
...result.afterEntries.map((entry) => ({
name: entry.name,
bucket: "after",
sourceName: entry.sourceName,
activationMode: entry.activationDebug?.mode || "",
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
controllerSource: entry.controllerSource || "",
})),
...result.atDepthEntries.map((entry) => ({
name: entry.name,
bucket: "atDepth",
sourceName: entry.sourceName,
activationMode: entry.activationDebug?.mode || "",
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
controllerSource: entry.controllerSource || "",
})),
];
result.activatedEntryNames = uniq(
[
...result.beforeEntries.map((entry) => entry.name),

View File

@@ -37,7 +37,10 @@ const openAiShimUrl = `data:text/javascript,${encodeURIComponent(
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "../../../extensions.js") {
if (
specifier === "../../../extensions.js" ||
specifier === "../../../../extensions.js"
) {
return {
shortCircuit: true,
url: extensionsShimUrl,
@@ -82,6 +85,8 @@ const {
normalizeGraphRuntimeState,
rollbackBatch,
} = await import("../runtime-state.js");
const { createDefaultTaskProfiles } = await import("../prompt-profiles.js");
const extensionsApi = await import("../../../../extensions.js");
const llm = await import("../llm.js");
const embedding = await import("../embedding.js");
@@ -237,6 +242,159 @@ function createGenerationRecallHarness() {
});
}
function createRerollHarness() {
const indexPath = path.resolve("./index.js");
return fs.readFile(indexPath, "utf8").then((source) => {
const helperStart = source.indexOf(
"function pruneProcessedMessageHashesFromFloor(",
);
const helperEnd = source.indexOf("async function recoverHistoryIfNeeded(");
const rerollStart = source.indexOf("async function onReroll(");
const rerollEnd = source.indexOf("async function onManualSleep()");
if (
helperStart < 0 ||
helperEnd < 0 ||
rerollStart < 0 ||
rerollEnd < 0 ||
helperEnd <= helperStart ||
rerollEnd <= rerollStart
) {
throw new Error("无法从 index.js 提取 reroll 定义");
}
const snippet = [
source.slice(helperStart, helperEnd),
source.slice(rerollStart, rerollEnd),
]
.join("\n")
.replace(/^export\s+/gm, "");
const context = {
console,
Date,
result: null,
currentGraph: null,
isExtracting: false,
extractionCount: 0,
lastExtractedItems: ["stale-node"],
lastExtractionStatus: { level: "idle" },
chat: [],
embeddingConfig: { mode: "backend" },
rollbackAffectedJournalsCalls: [],
deletedHashesCalls: [],
prepareVectorStateCalls: [],
recoveryPlans: [],
saveGraphToChatCalls: 0,
refreshPanelCalls: 0,
clearInjectionCalls: 0,
onManualExtractCalls: 0,
clearedHistoryDirty: null,
postRollbackGraph: null,
manualExtractLevel: "success",
ensureCurrentGraphRuntimeState() {
return context.currentGraph;
},
getContext() {
return {
chat: context.chat,
chatId: "chat-main",
};
},
getCurrentChatId() {
return "chat-main";
},
getAssistantTurns(chat = []) {
return chat.flatMap((message, index) =>
!message?.is_user && !message?.is_system ? [index] : [],
);
},
getLastProcessedAssistantFloor() {
return Number(
context.currentGraph?.historyState?.lastProcessedAssistantFloor ?? -1,
);
},
findJournalRecoveryPoint(graph, floor) {
return context.findJournalRecoveryPointImpl(graph, floor);
},
findJournalRecoveryPointImpl() {
return null;
},
buildReverseJournalRecoveryPlan(...args) {
return context.buildReverseJournalRecoveryPlanImpl(...args);
},
buildReverseJournalRecoveryPlanImpl() {
return {
backendDeleteHashes: [],
replayRequiredNodeIds: [],
pendingRepairFromFloor: null,
legacyGapFallback: false,
dirtyReason: "history-recovery-replay",
};
},
rollbackAffectedJournals(graph, journals) {
context.rollbackAffectedJournalsCalls.push({ graph, journals });
if (context.postRollbackGraph) {
context.currentGraph = context.postRollbackGraph;
}
},
normalizeGraphRuntimeState(graph) {
return graph;
},
getEmbeddingConfig() {
return context.embeddingConfig;
},
applyRecoveryPlanToVectorState(plan, floor) {
context.recoveryPlans.push({ plan, floor });
},
isBackendVectorConfig(config) {
return config?.mode === "backend";
},
async deleteBackendVectorHashesForRecovery(...args) {
context.deletedHashesCalls.push(args);
},
async prepareVectorStateForReplay(...args) {
context.prepareVectorStateCalls.push(args);
},
clearHistoryDirty(graph, result) {
context.clearedHistoryDirty = result;
graph.historyState ||= {};
graph.historyState.historyDirtyFrom = null;
graph.historyState.lastRecoveryResult = result;
},
buildRecoveryResult(status, extra = {}) {
return {
status,
...extra,
};
},
saveGraphToChat() {
context.saveGraphToChatCalls += 1;
return true;
},
refreshPanelLiveState() {
context.refreshPanelCalls += 1;
},
clearInjectionState() {
context.clearInjectionCalls += 1;
},
async onManualExtract() {
context.onManualExtractCalls += 1;
context.lastExtractionStatus = { level: context.manualExtractLevel };
},
toastr: {
info() {},
error() {},
success() {},
},
};
vm.createContext(context);
vm.runInContext(
`${snippet}\nresult = { pruneProcessedMessageHashesFromFloor, rollbackGraphForReroll, onReroll };`,
context,
{ filename: indexPath },
);
return context;
});
}
function pushTestOverrides(patch = {}) {
const previous = globalThis.__stBmeTestOverrides || {};
globalThis.__stBmeTestOverrides = {
@@ -1138,6 +1296,337 @@ async function testGenerationRecallDifferentKeyCanRunAgain() {
);
}
async function testRerollUsesBatchBoundaryRollbackAndPersistsState() {
const harness = await createRerollHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
{ is_user: true, mes: "u2" },
{ is_user: false, mes: "a2" },
{ is_user: true, mes: "u3" },
{ is_user: false, mes: "a3" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 5,
processedMessageHashes: {
1: "hash-1",
3: "hash-3",
5: "hash-5",
},
},
vectorIndexState: {
collectionId: "col-1",
},
batchJournal: [{ id: "journal-1" }],
lastProcessedSeq: 5,
};
harness.postRollbackGraph = {
historyState: {
lastProcessedAssistantFloor: 1,
processedMessageHashes: {
1: "hash-1",
3: "stale-hash",
},
},
vectorIndexState: {
collectionId: "col-1",
},
batchJournal: [],
lastProcessedSeq: 1,
};
harness.findJournalRecoveryPointImpl = () => ({
path: "reverse-journal",
affectedBatchCount: 1,
affectedJournals: [{ id: "journal-1" }],
});
harness.buildReverseJournalRecoveryPlanImpl = () => ({
backendDeleteHashes: ["hash-old"],
replayRequiredNodeIds: ["node-1"],
pendingRepairFromFloor: 2,
legacyGapFallback: false,
dirtyReason: "history-recovery-replay",
});
const result = await harness.result.onReroll({ fromFloor: 3 });
assert.equal(result.success, true);
assert.equal(result.rollbackPerformed, true);
assert.equal(result.recoveryPath, "reverse-journal");
assert.equal(result.effectiveFromFloor, 2);
assert.equal(harness.rollbackAffectedJournalsCalls.length, 1);
assert.equal(harness.deletedHashesCalls.length, 1);
assert.equal(harness.prepareVectorStateCalls.length, 1);
assert.equal(harness.prepareVectorStateCalls[0][2].skipBackendPurge, true);
assert.equal(harness.saveGraphToChatCalls, 1);
assert.equal(harness.refreshPanelCalls, 1);
assert.equal(harness.clearInjectionCalls, 1);
assert.equal(harness.onManualExtractCalls, 1);
assert.equal(harness.currentGraph.historyState.processedMessageHashes[3], undefined);
assert.equal(harness.lastExtractedItems.length, 0);
}
async function testRerollRejectsMissingRecoveryPoint() {
const harness = await createRerollHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
{ is_user: true, mes: "u2" },
{ is_user: false, mes: "a2" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 3,
processedMessageHashes: {
1: "hash-1",
3: "hash-3",
},
},
vectorIndexState: {
collectionId: "col-1",
},
batchJournal: [],
lastProcessedSeq: 3,
};
const result = await harness.result.onReroll({ fromFloor: 3 });
assert.equal(result.success, false);
assert.equal(result.recoveryPath, "unavailable");
assert.equal(harness.onManualExtractCalls, 0);
assert.equal(harness.saveGraphToChatCalls, 0);
}
async function testRerollFallsBackToDirectExtractForUnprocessedFloor() {
const harness = await createRerollHarness();
harness.chat = [
{ is_user: true, mes: "u1" },
{ is_user: false, mes: "a1" },
{ is_user: true, mes: "u2" },
{ is_user: false, mes: "a2" },
];
harness.currentGraph = {
historyState: {
lastProcessedAssistantFloor: 1,
processedMessageHashes: {
1: "hash-1",
},
},
vectorIndexState: {
collectionId: "col-1",
},
batchJournal: [],
lastProcessedSeq: 1,
};
const result = await harness.result.onReroll({ fromFloor: 3 });
assert.equal(result.success, true);
assert.equal(result.rollbackPerformed, false);
assert.equal(result.recoveryPath, "direct-extract");
assert.equal(result.effectiveFromFloor, 2);
assert.equal(harness.onManualExtractCalls, 1);
assert.equal(harness.saveGraphToChatCalls, 0);
}
async function testLlmDebugSnapshotRedactsSecretsBeforeStorage() {
const originalFetch = globalThis.fetch;
const previousSettings = JSON.parse(
JSON.stringify(extensionsApi.extension_settings.st_bme || {}),
);
delete globalThis.__stBmeRuntimeDebugState;
extensionsApi.extension_settings.st_bme = {
...previousSettings,
llmApiUrl: "https://example.com/v1",
llmApiKey: "sk-secret-redaction",
llmModel: "gpt-test",
timeoutMs: 1234,
};
globalThis.fetch = async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content: '{"ok":true}',
},
finish_reason: "stop",
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
try {
const result = await llm.callLLMForJSON({
systemPrompt: "system",
userPrompt: "user",
maxRetries: 0,
requestSource: "test:redaction",
});
assert.deepEqual(result, { ok: true });
const snapshot =
globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.["test:redaction"];
assert.ok(snapshot);
assert.equal(snapshot.redacted, true);
const serialized = JSON.stringify(snapshot);
assert.doesNotMatch(serialized, /sk-secret-redaction/);
assert.match(serialized, /\[REDACTED\]/);
} finally {
globalThis.fetch = originalFetch;
extensionsApi.extension_settings.st_bme = previousSettings;
}
}
async function testEmbeddingUsesConfigTimeoutInsteadOfDefault() {
const originalFetch = globalThis.fetch;
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
let capturedDelay = null;
globalThis.setTimeout = (fn, delay, ...args) => {
capturedDelay = delay;
return originalSetTimeout(fn, 0, ...args);
};
globalThis.clearTimeout = originalClearTimeout;
globalThis.fetch = async (_url, options = {}) =>
await new Promise((resolve, reject) => {
options.signal?.addEventListener(
"abort",
() => reject(options.signal.reason),
{ once: true },
);
});
try {
await assert.rejects(
embedding.embedText("timeout test", {
apiUrl: "https://example.com/v1",
model: "text-embedding-test",
timeoutMs: 7,
}),
/Embedding 请求超时/,
);
assert.equal(capturedDelay, 7);
} finally {
globalThis.fetch = originalFetch;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
}
async function testLlmOutputRegexCleansResponseBeforeJsonParse() {
const originalFetch = globalThis.fetch;
const previousSettings = JSON.parse(
JSON.stringify(extensionsApi.extension_settings.st_bme || {}),
);
delete globalThis.__stBmeRuntimeDebugState;
const taskProfiles = createDefaultTaskProfiles();
taskProfiles.extract.profiles[0].regex = {
...taskProfiles.extract.profiles[0].regex,
enabled: true,
inheritStRegex: false,
stages: {
...taskProfiles.extract.profiles[0].regex.stages,
"output.rawResponse": true,
"output.beforeParse": true,
},
localRules: [
{
id: "strip-prefix",
script_name: "strip-prefix",
enabled: true,
find_regex: "/^NOTE:\\s*/g",
replace_string: "",
trim_strings: [],
source: {
ai_output: true,
},
destination: {
prompt: true,
display: false,
},
},
{
id: "strip-suffix",
script_name: "strip-suffix",
enabled: true,
find_regex: "/\\s*END$/g",
replace_string: "",
trim_strings: [],
source: {
ai_output: true,
},
destination: {
prompt: true,
display: false,
},
},
],
};
extensionsApi.extension_settings.st_bme = {
...previousSettings,
llmApiUrl: "https://example.com/v1",
llmApiKey: "sk-secret-redaction",
llmModel: "gpt-test",
taskProfilesVersion: 1,
taskProfiles,
};
globalThis.fetch = async () =>
new Response(
JSON.stringify({
choices: [
{
message: {
content: 'NOTE: {"ok":true} END',
},
finish_reason: "stop",
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
},
);
try {
const result = await llm.callLLMForJSON({
systemPrompt: "system",
userPrompt: "user",
maxRetries: 0,
taskType: "extract",
requestSource: "test:output-regex",
});
assert.deepEqual(result, { ok: true });
const snapshot =
globalThis.__stBmeRuntimeDebugState?.taskLlmRequests?.extract;
assert.ok(snapshot);
assert.equal(snapshot.responseCleaning?.applied, true);
assert.equal(snapshot.responseCleaning?.changed, true);
assert.deepEqual(
snapshot.responseCleaning?.stages?.map((entry) => entry.stage),
["output.rawResponse", "output.beforeParse"],
);
} finally {
globalThis.fetch = originalFetch;
extensionsApi.extension_settings.st_bme = previousSettings;
}
}
await testCompressorMigratesEdgesToCompressedNode();
await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure();
await testExtractorFailsOnUnknownOperation();
@@ -1155,5 +1644,11 @@ await testProcessedHistoryAdvanceRequiresCompleteStrongSuccess();
await testGenerationRecallTransactionDedupesDoubleHookBySameKey();
await testGenerationRecallBeforeCombineRunsStandalone();
await testGenerationRecallDifferentKeyCanRunAgain();
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
await testRerollRejectsMissingRecoveryPoint();
await testRerollFallsBackToDirectExtractForUnprocessedFloor();
await testLlmDebugSnapshotRedactsSecretsBeforeStorage();
await testEmbeddingUsesConfigTimeoutInsteadOfDefault();
await testLlmOutputRegexCleansResponseBeforeJsonParse();
console.log("p0-regressions tests passed");

View File

@@ -263,6 +263,108 @@ try {
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 outputGuardDebug = { entries: [] };
const outputGuardResult = applyTaskRegex(
outputGuardSettings,
"extract",
"output.rawResponse",
"JSON 美化",
outputGuardDebug,
"assistant",
);
assert.equal(outputGuardResult, "DONE 美化");
assert.deepEqual(
outputGuardDebug.entries[0].appliedRules.map((item) => item.id),
["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) {

View File

@@ -164,6 +164,17 @@ try {
const { resolveTaskWorldInfo } = await import("../task-worldinfo.js");
const { buildTaskPrompt } = await import("../prompt-builder.js");
const emptyTriggerWorldInfo = await resolveTaskWorldInfo({
chatMessages: [],
userMessage: "",
templateContext: {},
});
assert.equal(
emptyTriggerWorldInfo.beforeEntries.some((entry) => entry.name === "常驻设定"),
true,
"constant world info should still resolve without trigger text",
);
const worldInfo = await resolveTaskWorldInfo({
templateContext: {
recentMessages: "我们继续调查那条线索",
@@ -174,8 +185,10 @@ try {
assert.deepEqual(
worldInfo.beforeEntries.map((entry) => entry.name),
["常驻设定", "EW/Controller/Main", "线索条目"],
["常驻设定", "线索条目"],
);
assert.doesNotMatch(worldInfo.beforeText, /getwi|<%=?/);
assert.equal(worldInfo.debug.controllerPulledCount, 1);
assert.equal(worldInfo.additionalMessages.length, 1);
assert.equal(
worldInfo.additionalMessages[0].content,
@@ -238,14 +251,13 @@ try {
);
assert.deepEqual(
promptBuild.hostInjections.before.map((entry) => entry.name),
["常驻设定", "EW/Controller/Main", "线索条目"],
["常驻设定", "线索条目"],
);
assert.equal(promptBuild.hostInjectionPlan.before.length, 1);
assert.equal(promptBuild.hostInjectionPlan.before[0].blockId, "b1");
assert.equal(promptBuild.hostInjectionPlan.before[0].sourceKey, "worldInfoBefore");
assert.deepEqual(promptBuild.hostInjectionPlan.before[0].entryNames, [
"常驻设定",
"EW/Controller/Main",
"线索条目",
]);
assert.equal(promptBuild.hostInjections.after.length, 0);
@@ -253,6 +265,8 @@ try {
assert.equal(promptBuild.hostInjections.atDepth[0].depth, 2);
assert.equal(promptBuild.hostInjectionPlan.atDepth.length, 1);
assert.equal(promptBuild.hostInjectionPlan.atDepth[0].entryName, "深度注入");
assert.equal(typeof promptBuild.debug.worldInfoCacheHit, "boolean");
assert.doesNotMatch(promptBuild.systemPrompt, /getwi|<%=?/);
assert.deepEqual(
promptBuild.renderedBlocks.map((block) => block.delivery),
["host.before", "private.message"],

View File

@@ -3,6 +3,7 @@
import { getRequestHeaders } from "../../../../script.js";
import { embedBatch, embedText, searchSimilar } from "./embedding.js";
import { getActiveNodes } from "./graph.js";
import { resolveConfiguredTimeoutMs } from "./request-timeout.js";
import { buildVectorCollectionId, stableHashString } from "./runtime-state.js";
export const BACKEND_VECTOR_SOURCES = [
@@ -33,10 +34,14 @@ const MODEL_LIST_ENDPOINTS = {
const VECTOR_REQUEST_TIMEOUT_MS = 300000;
function getConfiguredTimeoutMs(config = {}) {
const timeoutMs = Number(config?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: VECTOR_REQUEST_TIMEOUT_MS;
return typeof resolveConfiguredTimeoutMs === "function"
? resolveConfiguredTimeoutMs(config, VECTOR_REQUEST_TIMEOUT_MS)
: (() => {
const timeoutMs = Number(config?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: VECTOR_REQUEST_TIMEOUT_MS;
})();
}
const BACKEND_STATUS_MODEL_SOURCES = {