mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Harden runtime debug and task pipeline
This commit is contained in:
@@ -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 || []),
|
||||
|
||||
@@ -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 || []),
|
||||
|
||||
17
embedding.js
17
embedding.js
@@ -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) {
|
||||
|
||||
26
extractor.js
26
extractor.js
@@ -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
228
index.js
@@ -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
221
llm.js
@@ -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
290
panel.js
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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: "角色",
|
||||
|
||||
@@ -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
9
request-timeout.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export function resolveConfiguredTimeoutMs(
|
||||
settings = {},
|
||||
fallbackMs = 300000,
|
||||
) {
|
||||
const timeoutMs = Number(settings?.timeoutMs);
|
||||
return Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: fallbackMs;
|
||||
}
|
||||
15
retriever.js
15
retriever.js
@@ -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 || []),
|
||||
|
||||
41
task-ejs.js
41
task-ejs.js
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user