refactor: purify task worldinfo and ejs runtime

This commit is contained in:
Youzini-afk
2026-03-27 02:28:34 +08:00
parent c31af1d1a4
commit b915cd07a7
5 changed files with 787 additions and 600 deletions

View File

@@ -65,13 +65,13 @@ const BUILTIN_BLOCK_DEFINITIONS = [
sourceKey: "worldInfoBefore",
name: "世界书前块",
role: "system",
description: "注入 EW 同款世界书引擎解析后的 before 桶内容,支持角色主/附加世界书、用户设定世界书、聊天世界书,以及世界书条目中的 EJS / getwi。",
description: "注入按酒馆世界书规则解析后的 before 桶内容,支持角色主/附加世界书、用户设定世界书、聊天世界书,以及世界书条目中的 EJS / getwi。",
},
{
sourceKey: "worldInfoAfter",
name: "世界书后块",
role: "system",
description: "注入 EW 同款世界书引擎解析后的 after 桶内容。atDepth 条目不会出现在这里,而是自动并入额外消息链路。",
description: "注入按酒馆世界书规则解析后的 after 桶内容。atDepth 条目不会出现在这里,而是自动并入额外消息链路。",
},
{
sourceKey: "outputRules",

View File

@@ -14,10 +14,8 @@ const EJS_RUNTIME_STATUS = {
const FALLBACK_LODASH = {
get: getByPath,
set: setByPath,
unset: unsetByPath,
cloneDeep: cloneDeep,
escapeRegExp: escapeRegExp,
cloneDeep,
escapeRegExp,
sum(values = []) {
return (Array.isArray(values) ? values : []).reduce(
(total, value) => total + (Number(value) || 0),
@@ -63,6 +61,15 @@ function createTaskEjsRuntimeUnavailableError(backend, content = "") {
return error;
}
function createTaskEjsUnsupportedHelperError(helperName, args = []) {
const error = new Error(`task-ejs unsupported helper: ${String(helperName || "unknown")}`);
error.name = "TaskEjsUnsupportedHelperError";
error.code = "st_bme_task_ejs_unsupported_helper";
error.helperName = String(helperName || "unknown");
error.args = Array.isArray(args) ? cloneDeep(args) : [];
return error;
}
async function ensureEjsRuntime() {
const currentState = getCurrentEjsRuntimeState();
if (currentState.isAvailable) {
@@ -73,10 +80,7 @@ async function ensureEjsRuntime() {
}
ejsRuntimeStatePromise = (async () => {
const hadWindow = Object.prototype.hasOwnProperty.call(
globalThis,
"window",
);
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const previousWindow = globalThis.window;
let importError = null;
@@ -121,10 +125,6 @@ function resolveHostSnapshot(injectedSnapshot) {
return getSTContextSnapshot();
}
function getStContext(injectedSnapshot) {
return resolveHostSnapshot(injectedSnapshot).snapshot.raw || {};
}
function getStChat(injectedSnapshot) {
return resolveHostSnapshot(injectedSnapshot).snapshot.chat.messages || [];
}
@@ -189,43 +189,6 @@ function getByPath(target, path, defaultValue = undefined) {
return result === undefined ? defaultValue : result;
}
function setByPath(target, path, value) {
const segments = String(path || "")
.split(".")
.filter(Boolean);
if (segments.length === 0 || target == null || typeof target !== "object") {
return;
}
let cursor = target;
for (let index = 0; index < segments.length - 1; index += 1) {
const key = segments[index];
if (cursor[key] == null || typeof cursor[key] !== "object") {
cursor[key] = {};
}
cursor = cursor[key];
}
cursor[segments[segments.length - 1]] = value;
}
function unsetByPath(target, path) {
const segments = String(path || "")
.split(".")
.filter(Boolean);
if (segments.length === 0 || target == null || typeof target !== "object") {
return;
}
let cursor = target;
for (let index = 0; index < segments.length - 1; index += 1) {
cursor = cursor?.[segments[index]];
if (cursor == null || typeof cursor !== "object") {
return;
}
}
delete cursor[segments[segments.length - 1]];
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -240,6 +203,13 @@ function normalizeIdentifier(value) {
.toLowerCase();
}
function normalizeRole(role) {
const normalized = String(role || "system").trim().toLowerCase();
return ["system", "user", "assistant"].includes(normalized)
? normalized
: "system";
}
function processChatMessage(message) {
return String(message?.mes ?? message?.message ?? message?.content ?? "");
}
@@ -271,7 +241,7 @@ export function substituteTaskEjsParams(
});
}
function createVariableState(hostSnapshot) {
function createReadOnlyVariableState(hostSnapshot) {
const snapshot = resolveHostSnapshot(hostSnapshot).snapshot;
const chat = snapshot.chat.messages || [];
const lastMessage = chat[chat.length - 1] || {};
@@ -283,7 +253,7 @@ function createVariableState(hostSnapshot) {
const globalVars = cloneDeep(snapshot.variables.global || {});
const localVars = cloneDeep(snapshot.variables.local || {});
return {
return Object.freeze({
globalVars,
localVars,
messageVars,
@@ -292,15 +262,7 @@ function createVariableState(hostSnapshot) {
...localVars,
...messageVars,
},
};
}
function rebuildVariableCache(state) {
state.cacheVars = {
...state.globalVars,
...state.localVars,
...state.messageVars,
};
});
}
function getVariable(state, path, options = {}) {
@@ -317,21 +279,23 @@ function getVariable(state, path, options = {}) {
return getByPath(state.cacheVars, path, options.defaults);
}
function setVariable(state, path, value, options = {}) {
const scope = normalizeIdentifier(options.scope) || "message";
const target =
scope === "global"
? state.globalVars
: scope === "local"
? state.localVars
: state.messageVars;
if (value === undefined) {
unsetByPath(target, path);
} else {
setByPath(target, path, cloneDeep(value));
}
rebuildVariableCache(state);
function normalizeRenderEntry(entry = {}) {
return {
uid: Number(entry.uid) || 0,
name: normalizeEntryKey(entry.name),
comment: normalizeEntryKey(entry.comment),
content: String(entry.content || ""),
worldbook: normalizeEntryKey(entry.worldbook),
role: normalizeRole(entry.role),
position: Number(entry.position ?? 0),
depth: Number(entry.depth ?? 0),
order: Number(entry.order ?? 100),
enabled: entry.enabled !== false,
activationDebug:
entry.activationDebug && typeof entry.activationDebug === "object"
? cloneDeep(entry.activationDebug)
: null,
};
}
function registerEntryLookup(lookup, key, entry) {
@@ -340,16 +304,75 @@ function registerEntryLookup(lookup, key, entry) {
lookup.set(normalizedKey, entry);
}
function activationKey(entry) {
return `${entry.worldbook}::${entry.comment || entry.name}`;
function registerEntries(renderCtx, entries = []) {
for (const rawEntry of Array.isArray(entries) ? entries : []) {
const entry = normalizeRenderEntry(rawEntry);
renderCtx.entries.push(entry);
registerEntryLookup(renderCtx.allEntries, entry.name, entry);
registerEntryLookup(renderCtx.allEntries, entry.comment, entry);
if (!renderCtx.entriesByWorldbook.has(entry.worldbook)) {
renderCtx.entriesByWorldbook.set(entry.worldbook, new Map());
}
const worldbookLookup = renderCtx.entriesByWorldbook.get(entry.worldbook);
registerEntryLookup(worldbookLookup, entry.name, entry);
registerEntryLookup(worldbookLookup, entry.comment, entry);
}
}
function findEntry(
renderCtx,
currentWorldbook,
worldbookOrEntry,
entryNameOrData,
) {
function activationKey(entry) {
return [entry.worldbook, entry.uid || entry.comment || entry.name].join("::");
}
function recordRenderWarning(renderCtx, warning) {
const text = String(warning || "").trim();
if (!text) return;
if (!Array.isArray(renderCtx?.warnings)) {
renderCtx.warnings = [];
}
if (!renderCtx.warnings.includes(text)) {
renderCtx.warnings.push(text);
}
}
async function ensureWorldbookEntriesLoaded(renderCtx, worldbookName) {
const normalizedWorldbook = normalizeEntryKey(worldbookName);
if (!normalizedWorldbook) {
return false;
}
if (renderCtx.entriesByWorldbook.has(normalizedWorldbook)) {
return true;
}
if (renderCtx.worldbookLoadAttempts.has(normalizedWorldbook)) {
return renderCtx.entriesByWorldbook.has(normalizedWorldbook);
}
if (typeof renderCtx.loadWorldbookEntries !== "function") {
return false;
}
renderCtx.worldbookLoadAttempts.add(normalizedWorldbook);
try {
const loadedEntries = await renderCtx.loadWorldbookEntries(normalizedWorldbook);
registerEntries(renderCtx, loadedEntries);
if ((Array.isArray(loadedEntries) ? loadedEntries : []).length > 0) {
renderCtx.lazyLoadedWorldbooks.add(normalizedWorldbook);
return true;
}
} catch (error) {
recordRenderWarning(
renderCtx,
`lazy load worldbook failed: ${normalizedWorldbook}`,
);
console.warn(
`[ST-BME] task-ejs 懒加载世界书失败: ${normalizedWorldbook}`,
error,
);
}
return renderCtx.entriesByWorldbook.has(normalizedWorldbook);
}
async function resolveEntry(renderCtx, currentWorldbook, worldbookOrEntry, entryNameOrData) {
const explicitWorldbook =
typeof entryNameOrData === "string"
? normalizeEntryKey(worldbookOrEntry)
@@ -366,11 +389,20 @@ function findEntry(
return renderCtx.entriesByWorldbook.get(worldbook)?.get(identifier);
};
return (
let resolved =
lookupInWorldbook(explicitWorldbook) ||
lookupInWorldbook(fallbackWorldbook) ||
renderCtx.allEntries.get(identifier)
);
renderCtx.allEntries.get(identifier);
if (!resolved && explicitWorldbook) {
await ensureWorldbookEntriesLoaded(renderCtx, explicitWorldbook);
resolved =
lookupInWorldbook(explicitWorldbook) ||
lookupInWorldbook(fallbackWorldbook) ||
renderCtx.allEntries.get(identifier);
}
return resolved;
}
async function activateWorldInfoInContext(
@@ -380,32 +412,35 @@ async function activateWorldInfoInContext(
entryOrForce,
maybeForce,
) {
const force = typeof entryOrForce === "boolean" ? entryOrForce : maybeForce;
const explicitWorldbook = typeof entryOrForce === "string" ? world : null;
const identifier = typeof entryOrForce === "string" ? entryOrForce : world;
const entry = identifier
? findEntry(renderCtx, currentWorldbook, explicitWorldbook, identifier)
: undefined;
const hasExplicitWorldbook = typeof entryOrForce === "string";
const identifier = normalizeEntryKey(hasExplicitWorldbook ? entryOrForce : world);
const explicitWorldbook = hasExplicitWorldbook ? normalizeEntryKey(world) : "";
const entry = await resolveEntry(
renderCtx,
currentWorldbook,
explicitWorldbook || identifier,
hasExplicitWorldbook ? identifier : undefined,
);
if (!entry) {
recordRenderWarning(
renderCtx,
`activewi target not found: ${explicitWorldbook ? `${explicitWorldbook}/` : ""}${identifier}`,
);
return null;
}
const normalizedEntry = force
? {
...entry,
content: String(entry.content || "").replaceAll("@@dont_activate", ""),
}
: entry;
const normalizedEntry = normalizeRenderEntry({
...entry,
content: String(entry.content || "").replaceAll("@@dont_activate", ""),
});
renderCtx.activatedEntries.set(
activationKey(normalizedEntry),
normalizedEntry,
);
renderCtx.forcedActivatedEntries.set(activationKey(normalizedEntry), normalizedEntry);
return {
world: normalizedEntry.worldbook,
comment: normalizedEntry.comment || normalizedEntry.name,
content: normalizedEntry.content,
forced: typeof maybeForce === "boolean" ? maybeForce : typeof entryOrForce === "boolean",
};
}
@@ -415,7 +450,7 @@ async function getwi(
worldbookOrEntry,
entryNameOrData,
) {
const entry = findEntry(
const entry = await resolveEntry(
renderCtx,
currentWorldbook,
worldbookOrEntry,
@@ -427,23 +462,30 @@ async function getwi(
const entryKey = activationKey(entry);
if (renderCtx.renderStack.has(entryKey)) {
recordRenderWarning(
renderCtx,
`recursive getwi blocked: ${entry.comment || entry.name}`,
);
console.warn(
`[ST-BME] task-ejs 检测到循环 getwi: ${entry.comment || entry.name}`,
);
return substituteTaskEjsParams(entry.content, renderCtx.templateContext);
return "";
}
if (renderCtx.renderStack.size >= renderCtx.maxRecursion) {
recordRenderWarning(
renderCtx,
`getwi recursion limit reached: ${entry.comment || entry.name}`,
);
console.warn(
`[ST-BME] task-ejs 超过最大递归深度: ${renderCtx.maxRecursion}`,
);
return substituteTaskEjsParams(entry.content, renderCtx.templateContext);
return "";
}
const processed = substituteTaskEjsParams(
entry.content,
renderCtx.templateContext,
);
const processed = substituteTaskEjsParams(entry.content, renderCtx.templateContext, {
hostSnapshot: renderCtx.hostSnapshot,
});
let finalContent = processed;
if (processed.includes("<%")) {
@@ -461,20 +503,18 @@ async function getwi(
}
}
if (!renderCtx.pulledEntries.has(entryKey)) {
renderCtx.pulledEntries.set(entryKey, {
name: entry.name,
comment: entry.comment,
content: finalContent,
worldbook: entry.worldbook,
});
}
renderCtx.inlinePulledEntries.set(entryKey, {
name: entry.name,
comment: entry.comment,
content: finalContent,
worldbook: entry.worldbook,
});
return finalContent;
return String(finalContent || "");
}
function getChatMessageCompat(index, role) {
const chat = getStChat()
function getChatMessageCompat(renderCtx, index, role) {
const chat = getStChat(renderCtx?.hostSnapshot)
.filter((message) => {
if (!role) return true;
if (role === "user") return Boolean(message?.is_user);
@@ -487,12 +527,9 @@ function getChatMessageCompat(index, role) {
return chat[resolvedIndex] || "";
}
function getChatMessagesCompat(
startOrCount = getStChat().length,
endOrRole,
role,
) {
const allMessages = getStChat().map((message, index) => ({
function getChatMessagesCompat(renderCtx, startOrCount, endOrRole, role) {
const chat = getStChat(renderCtx?.hostSnapshot);
const allMessages = chat.map((message, index) => ({
raw: message,
id: index,
text: processChatMessage(message),
@@ -529,10 +566,11 @@ function getChatMessagesCompat(
.map((item) => item.text);
}
function matchChatMessagesCompat(pattern) {
const regex =
typeof pattern === "string" ? new RegExp(pattern, "i") : pattern;
return getStChat().some((message) => regex.test(processChatMessage(message)));
function matchChatMessagesCompat(renderCtx, pattern) {
const regex = typeof pattern === "string" ? new RegExp(pattern, "i") : pattern;
return getStChat(renderCtx?.hostSnapshot).some((message) =>
regex.test(processChatMessage(message)),
);
}
function rethrow(err, str, filename, lineNumber, esc) {
@@ -553,55 +591,55 @@ function rethrow(err, str, filename, lineNumber, esc) {
throw err;
}
function makeUnsupportedHelper(helperName) {
return (...args) => {
throw createTaskEjsUnsupportedHelperError(helperName, args);
};
}
function getCurrentActivatedEntries(renderCtx) {
return Array.isArray(renderCtx?.currentActivatedEntries)
? renderCtx.currentActivatedEntries
: [];
}
export function createTaskEjsRenderContext(entries = [], options = {}) {
const hostSnapshot = resolveHostSnapshot(options.hostSnapshot);
const normalizedEntries = (Array.isArray(entries) ? entries : []).map(
(entry) => ({
name: normalizeEntryKey(entry?.name),
comment: normalizeEntryKey(entry?.comment),
content: String(entry?.content || ""),
worldbook: normalizeEntryKey(entry?.worldbook),
}),
);
const allEntries = new Map();
const entriesByWorldbook = new Map();
for (const entry of normalizedEntries) {
registerEntryLookup(allEntries, entry.name, entry);
registerEntryLookup(allEntries, entry.comment, entry);
if (!entriesByWorldbook.has(entry.worldbook)) {
entriesByWorldbook.set(entry.worldbook, new Map());
}
const worldbookLookup = entriesByWorldbook.get(entry.worldbook);
registerEntryLookup(worldbookLookup, entry.name, entry);
registerEntryLookup(worldbookLookup, entry.comment, entry);
}
return {
entries: normalizedEntries,
allEntries,
entriesByWorldbook,
const renderCtx = {
entries: [],
allEntries: new Map(),
entriesByWorldbook: new Map(),
renderStack: new Set(),
worldbookLoadAttempts: new Set(),
lazyLoadedWorldbooks: new Set(),
warnings: [],
maxRecursion:
Number.isFinite(Number(options.maxRecursion)) &&
Number(options.maxRecursion) > 0
Number.isFinite(Number(options.maxRecursion)) && Number(options.maxRecursion) > 0
? Number(options.maxRecursion)
: DEFAULT_MAX_RECURSION,
hostSnapshot,
variableState: createVariableState(hostSnapshot),
activatedEntries: new Map(),
pulledEntries: new Map(),
variableState: createReadOnlyVariableState(hostSnapshot),
currentActivatedEntries: Array.isArray(options.currentActivatedEntries)
? options.currentActivatedEntries.map((entry) => normalizeRenderEntry(entry))
: [],
forcedActivatedEntries: new Map(),
inlinePulledEntries: new Map(),
ejsRuntimeStatus: EJS_RUNTIME_STATUS.FAILED,
ejsRuntimeFallback: false,
ejsLastError: null,
loadWorldbookEntries:
typeof options.loadWorldbookEntries === "function"
? options.loadWorldbookEntries
: null,
templateContext: {
...(options.templateContext || {}),
hostSnapshot: hostSnapshot.snapshot,
stSnapshot: hostSnapshot.snapshot,
},
};
registerEntries(renderCtx, entries);
return renderCtx;
}
export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
@@ -616,29 +654,20 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
: String(backend.error)
: null;
}
const hostSnapshot = resolveHostSnapshot(renderCtx?.hostSnapshot);
const snapshot = hostSnapshot.snapshot;
const processed = substituteTaskEjsParams(content, renderCtx?.templateContext, {
hostSnapshot,
});
if (!runtime) {
const substituted = substituteTaskEjsParams(content, renderCtx?.templateContext, {
hostSnapshot,
});
if (substituted.includes("<%")) {
throw createTaskEjsRuntimeUnavailableError(backend, substituted);
if (processed.includes("<%")) {
throw createTaskEjsRuntimeUnavailableError(backend, processed);
}
console.warn(
"[ST-BME] task-ejs 未找到可用 ejs runtime回退为轻量变量替换:",
backend,
);
return substituted;
return processed;
}
const processed = substituteTaskEjsParams(
content,
renderCtx?.templateContext,
{
hostSnapshot,
},
);
if (!processed.includes("<%")) {
return processed;
}
@@ -651,12 +680,47 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
? renderCtx.templateContext.user_input
: snapshot.chat.lastUserMessage || "";
const unsupported = {
setvar: makeUnsupportedHelper("setvar"),
setLocalVar: makeUnsupportedHelper("setLocalVar"),
setGlobalVar: makeUnsupportedHelper("setGlobalVar"),
setMessageVar: makeUnsupportedHelper("setMessageVar"),
incvar: makeUnsupportedHelper("incvar"),
decvar: makeUnsupportedHelper("decvar"),
delvar: makeUnsupportedHelper("delvar"),
insvar: makeUnsupportedHelper("insvar"),
incLocalVar: makeUnsupportedHelper("incLocalVar"),
incGlobalVar: makeUnsupportedHelper("incGlobalVar"),
incMessageVar: makeUnsupportedHelper("incMessageVar"),
decLocalVar: makeUnsupportedHelper("decLocalVar"),
decGlobalVar: makeUnsupportedHelper("decGlobalVar"),
decMessageVar: makeUnsupportedHelper("decMessageVar"),
patchVariables: makeUnsupportedHelper("patchVariables"),
getprp: makeUnsupportedHelper("getprp"),
getpreset: makeUnsupportedHelper("getpreset"),
getPresetPrompt: makeUnsupportedHelper("getPresetPrompt"),
execute: makeUnsupportedHelper("execute"),
define: makeUnsupportedHelper("define"),
getqr: makeUnsupportedHelper("getqr"),
getQuickReply: makeUnsupportedHelper("getQuickReply"),
selectActivatedEntries: makeUnsupportedHelper("selectActivatedEntries"),
activateWorldInfoByKeywords: makeUnsupportedHelper("activateWorldInfoByKeywords"),
activateRegex: makeUnsupportedHelper("activateRegex"),
injectPrompt: makeUnsupportedHelper("injectPrompt"),
getPromptsInjected: makeUnsupportedHelper("getPromptsInjected"),
hasPromptsInjected: makeUnsupportedHelper("hasPromptsInjected"),
jsonPatch: makeUnsupportedHelper("jsonPatch"),
};
const context = {
_: utilityLib,
console,
userName: snapshot.user.name,
charName: snapshot.character.name,
assistantName: snapshot.character.name,
charDescription: snapshot.character.description || "",
userPersona: snapshot.persona.text || "",
currentTime: snapshot.time.current || "",
characterId: snapshot.character.id,
hostSnapshot: snapshot,
stSnapshot: snapshot,
@@ -667,9 +731,11 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
return renderCtx.variableState.cacheVars;
},
get lastUserMessageId() {
return chat.findLastIndex
? chat.findLastIndex((message) => message?.is_user)
: [...chat].reverse().findIndex((message) => message?.is_user);
if (typeof chat.findLastIndex === "function") {
return chat.findLastIndex((message) => message?.is_user);
}
const reversedIndex = [...chat].reverse().findIndex((message) => message?.is_user);
return reversedIndex < 0 ? -1 : chat.length - 1 - reversedIndex;
},
get lastUserMessage() {
return (
@@ -689,18 +755,19 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
return workflowUserInput;
},
get lastCharMessageId() {
return chat.findLastIndex
? chat.findLastIndex(
(message) => !message?.is_user && !message?.is_system,
)
: [...chat]
.reverse()
.findIndex((message) => !message?.is_user && !message?.is_system);
if (typeof chat.findLastIndex === "function") {
return chat.findLastIndex(
(message) => !message?.is_user && !message?.is_system,
);
}
const reversedIndex = [...chat]
.reverse()
.findIndex((message) => !message?.is_user && !message?.is_system);
return reversedIndex < 0 ? -1 : chat.length - 1 - reversedIndex;
},
get lastCharMessage() {
return (
chat.findLast?.((message) => !message?.is_user && !message?.is_system)
?.mes ||
chat.findLast?.((message) => !message?.is_user && !message?.is_system)?.mes ||
[...chat]
.reverse()
.find((message) => !message?.is_user && !message?.is_system)?.mes ||
@@ -745,8 +812,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
worldbookOrEntry,
entryNameOrData,
),
getvar: (path, options) =>
getVariable(renderCtx.variableState, path, options),
getvar: (path, options) => getVariable(renderCtx.variableState, path, options),
getLocalVar: (path, options = {}) =>
getVariable(renderCtx.variableState, path, {
...options,
@@ -762,51 +828,13 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
...options,
scope: "message",
}),
setvar: (path, value, options = {}) =>
setVariable(renderCtx.variableState, path, value, options),
setLocalVar: (path, value, options = {}) =>
setVariable(renderCtx.variableState, path, value, {
...options,
scope: "local",
}),
setGlobalVar: (path, value, options = {}) =>
setVariable(renderCtx.variableState, path, value, {
...options,
scope: "global",
}),
setMessageVar: (path, value, options = {}) =>
setVariable(renderCtx.variableState, path, value, {
...options,
scope: "message",
}),
incvar: () => undefined,
decvar: () => undefined,
delvar: () => undefined,
insvar: () => undefined,
incLocalVar: () => undefined,
incGlobalVar: () => undefined,
incMessageVar: () => undefined,
decLocalVar: () => undefined,
decGlobalVar: () => undefined,
decMessageVar: () => undefined,
patchVariables: () => undefined,
getChatMessage: (id, role) => getChatMessageCompat(id, role),
getChatMessages: (startOrCount, endOrRole, role) =>
getChatMessagesCompat(startOrCount, endOrRole, role),
matchChatMessages: (pattern) => matchChatMessagesCompat(pattern),
getChatMessage: (id, role) => getChatMessageCompat(renderCtx, id, role),
getChatMessages: (startOrCount = getStChat(hostSnapshot).length, endOrRole, role) =>
getChatMessagesCompat(renderCtx, startOrCount, endOrRole, role),
matchChatMessages: (pattern) => matchChatMessagesCompat(renderCtx, pattern),
getchr: () => snapshot.character.description || "",
getchar: undefined,
getChara: undefined,
getprp: async () => "",
getpreset: async () => "",
getPresetPrompt: async () => "",
execute: async () => "",
define: () => undefined,
evalTemplate: async (innerContent, data = {}) =>
evalTaskEjsTemplate(innerContent, renderCtx, data),
getqr: async () => "",
getQuickReply: async () => "",
findVariables: () => ({}),
getWorldInfoData: async () =>
renderCtx.entries.map((entry) => ({
comment: entry.comment || entry.name,
@@ -814,7 +842,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
world: entry.worldbook,
})),
getWorldInfoActivatedData: async () =>
[...renderCtx.activatedEntries.values()].map((entry) => ({
getCurrentActivatedEntries(renderCtx).map((entry) => ({
comment: entry.comment || entry.name,
content: entry.content,
world: entry.worldbook,
@@ -825,11 +853,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
content: entry.content,
world: entry.worldbook,
})),
selectActivatedEntries: () => [],
activateWorldInfoByKeywords: async () => [],
getEnabledLoreBooks: () => [
...new Set(renderCtx.entries.map((entry) => entry.worldbook)),
],
getEnabledLoreBooks: () => [...new Set(renderCtx.entries.map((entry) => entry.worldbook))],
activewi: async (world, entryOrForce, maybeForce) =>
activateWorldInfoInContext(
renderCtx,
@@ -846,11 +870,6 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
entryOrForce,
maybeForce,
),
activateRegex: () => undefined,
injectPrompt: () => undefined,
getPromptsInjected: () => [],
hasPromptsInjected: () => false,
jsonPatch: () => undefined,
parseJSON: (raw) => {
try {
return JSON.parse(raw);
@@ -860,6 +879,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
},
print: (...parts) =>
parts.filter((part) => part !== undefined && part !== null).join(""),
...unsupported,
...extraEnv,
};
@@ -887,8 +907,11 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
renderCtx.ejsLastError =
error instanceof Error ? error.message : String(error);
}
console.warn("[ST-BME] task-ejs 渲染失败,回退原文本:", error);
return processed;
if (error?.code === "st_bme_task_ejs_unsupported_helper") {
throw error;
}
console.warn("[ST-BME] task-ejs 渲染失败:", error);
throw error;
}
}

View File

@@ -1,6 +1,6 @@
// ST-BME: 任务级世界书激活引擎
// 复刻 Evolution_World 的世界书来源、激活与 EJS 渲染主逻辑,
// 但只接入 ST-BME 的任务预设系统,不引入完整工作流调度层
// 对标 SillyTavern 原生世界书扫描逻辑,并在私有 prompt 组装阶段
// 提供最小 EJS 配合能力,用于 getwi / activewi
import {
createTaskEjsRenderContext,
@@ -36,34 +36,9 @@ const DEPTH_MAPPING = {
};
const DEFAULT_DEPTH = 4;
const DEFAULT_CONTROLLER_ENTRY_PREFIX = "EW/Controller/";
const DEFAULT_MAX_RESOLVE_PASSES = 10;
const WORLDINFO_CACHE_TTL_MS = 3000;
const KNOWN_DECORATORS = [
"@@activate",
"@@dont_activate",
"@@message_formatting",
"@@generate",
"@@generate_before",
"@@generate_after",
"@@render",
"@@render_before",
"@@render_after",
"@@dont_preload",
"@@initial_variables",
"@@always_enabled",
"@@only_preload",
"@@iframe",
"@@preprocessing",
"@@if",
"@@private",
];
const SPECIAL_NAME_MARKERS = [
"[GENERATE:",
"[RENDER:",
"@INJECT",
"[InitialVariables]",
];
const KNOWN_DECORATORS = ["@@activate", "@@dont_activate"];
let worldbookEntriesCache = {
key: "",
@@ -219,34 +194,42 @@ function simpleHash(input = "") {
}
function parseDecorators(content = "") {
const decorators = [];
const cleanLines = [];
const rawContent = String(content || "");
if (!rawContent.startsWith("@@")) {
return {
decorators: [],
cleanContent: rawContent,
};
}
for (const line of String(content || "").split("\n")) {
const trimmed = line.trim();
const matched = KNOWN_DECORATORS.find((decorator) =>
trimmed.startsWith(decorator),
);
if (matched) {
const firstSpace = trimmed.indexOf(" ");
decorators.push(firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed);
} else {
cleanLines.push(line);
const lines = rawContent.split("\n");
const decorators = [];
let index = 0;
while (index < lines.length) {
const line = String(lines[index] || "");
if (!line.startsWith("@@")) {
break;
}
if (line.startsWith("@@@")) {
break;
}
const matched = KNOWN_DECORATORS.find((decorator) =>
line.startsWith(decorator),
);
if (!matched) {
break;
}
decorators.push(line);
index += 1;
}
return {
decorators,
cleanContent: cleanLines.join("\n").trim(),
cleanContent: index > 0 ? lines.slice(index).join("\n") : rawContent,
};
}
function isSpecialEntryByComment(comment = "") {
return SPECIAL_NAME_MARKERS.some((marker) =>
String(comment).includes(marker),
);
}
function normalizeEntry(raw = {}, worldbookName = "") {
const { decorators, cleanContent } = parseDecorators(raw.content || "");
@@ -494,27 +477,6 @@ function selectActivatedEntries(
continue;
}
if (entry.decorators.includes("@@dont_activate")) continue;
if (entry.decorators.includes("@@only_preload")) continue;
const specialDecorators = [
"@@generate",
"@@generate_before",
"@@generate_after",
"@@render",
"@@render_before",
"@@render_after",
"@@initial_variables",
"@@preprocessing",
"@@iframe",
];
if (
entry.decorators.some((decorator) =>
specialDecorators.includes(decorator),
)
) {
continue;
}
if (isSpecialEntryByComment(entry.comment)) continue;
if (entry.keys.length === 0) continue;
const matchedPrimary = entry.keys
@@ -649,16 +611,52 @@ function selectActivatedEntries(
return ungrouped.concat(matched).sort(sortEntries);
}
async function collectAllWorldbookEntries() {
async function loadNormalizedWorldbookEntries(worldbookHost, worldbookName) {
const normalizedName = normalizeKey(worldbookName);
if (!normalizedName || typeof worldbookHost?.getWorldbook !== "function") {
return [];
}
const entries = await worldbookHost.getWorldbook(normalizedName);
let commentByUid = new Map();
if (typeof worldbookHost?.getLorebookEntries === "function") {
try {
const loreEntries = await worldbookHost.getLorebookEntries(normalizedName);
commentByUid = new Map(
(Array.isArray(loreEntries) ? loreEntries : []).map((entry) => [
entry.uid,
String(entry.comment ?? ""),
]),
);
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取 lorebook comment 失败: ${normalizedName}`,
error,
);
}
}
return (Array.isArray(entries) ? entries : []).map((entry) =>
normalizeEntry(
{
...entry,
comment: commentByUid.get(entry.uid) ?? entry.comment ?? "",
},
normalizedName,
),
);
}
async function collectAllWorldbookEntries(worldbookHost = null) {
const resolvedWorldbookHost = worldbookHost || (await getWorldbookHost());
const {
getWorldbook,
getLorebookEntries,
getCharWorldbookNames,
sourceLabel,
fallback,
capabilityStatus,
snapshotRevision,
} = await getWorldbookHost();
} = resolvedWorldbookHost;
const ctx = getStContext();
const debug = {
sourceLabel,
@@ -777,36 +775,11 @@ async function collectAllWorldbookEntries() {
loadedNames.add(normalizedName);
try {
const entries = await getWorldbook(normalizedName);
let commentByUid = new Map();
if (getLorebookEntries) {
try {
const loreEntries = await getLorebookEntries(normalizedName);
commentByUid = new Map(
(Array.isArray(loreEntries) ? loreEntries : []).map((entry) => [
entry.uid,
String(entry.comment ?? ""),
]),
);
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取 lorebook comment 失败: ${normalizedName} [${sourceTag}]`,
error,
);
}
}
for (const entry of Array.isArray(entries) ? entries : []) {
allEntries.push(
normalizeEntry(
{
...entry,
comment: commentByUid.get(entry.uid) ?? entry.comment ?? "",
},
normalizedName,
),
);
}
const entries = await loadNormalizedWorldbookEntries(
resolvedWorldbookHost,
normalizedName,
);
allEntries.push(...entries);
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取世界书失败: ${normalizedName} [${sourceTag}]`,
@@ -876,7 +849,6 @@ function normalizeResolvedEntry(entry = {}, fallbackIndex = 0) {
...entry.activationDebug,
}
: null,
controllerSource: String(entry.controllerSource || ""),
};
}
@@ -956,6 +928,48 @@ function buildActivationSourceTexts({
return uniq(texts.map((text) => String(text).trim()).filter(Boolean));
}
function getEntryIdentity(entry = {}) {
return `${entry.worldbook}:${entry.uid}:${entry.name}`;
}
function toActivationMap(entries = []) {
const map = new Map();
for (const entry of Array.isArray(entries) ? entries : []) {
map.set(getEntryIdentity(entry), entry);
}
return map;
}
function warnLegacyEntryNames(entries = [], warnings = []) {
const legacyNames = uniq(
(Array.isArray(entries) ? entries : [])
.map((entry) => String(entry?.name || "").trim())
.filter(
(name) => name.startsWith("EW/Controller/") || name.startsWith("EW/Dyn/"),
),
);
if (legacyNames.length === 0) {
return;
}
const warning =
`检测到旧 EW 命名条目 (${legacyNames.join(", ")});这些条目现在只按普通世界书条目处理,不再有专用魔法行为`;
if (!warnings.includes(warning)) {
warnings.push(warning);
}
console.warn(`[ST-BME] task-worldinfo ${warning}`);
}
function mergeActivationDebug(entry = {}, overrides = {}) {
return {
...(entry.activationDebug && typeof entry.activationDebug === "object"
? entry.activationDebug
: {}),
...overrides,
};
}
export async function resolveTaskWorldInfo({
settings = {},
chatMessages = [],
@@ -983,8 +997,13 @@ export async function resolveTaskWorldInfo({
activatedEntryCount: 0,
constantActivatedCount: 0,
selectiveActivatedCount: 0,
controllerActivatedCount: 0,
controllerPulledCount: 0,
ejsForcedActivationCount: 0,
ejsInlinePullCount: 0,
resolvePassCount: 0,
forcedActivatedEntries: [],
inlinePulledEntries: [],
lazyLoadedWorldbooks: [],
recursionWarnings: [],
cache: {
hit: false,
key: "",
@@ -1001,7 +1020,8 @@ export async function resolveTaskWorldInfo({
};
try {
const collected = await collectAllWorldbookEntries();
const worldbookHost = await getWorldbookHost();
const collected = await collectAllWorldbookEntries(worldbookHost);
const allEntries = Array.isArray(collected?.entries) ? collected.entries : [];
result.allEntries = allEntries;
result.debug = {
@@ -1021,6 +1041,7 @@ export async function resolveTaskWorldInfo({
if (allEntries.length === 0) {
return result;
}
warnLegacyEntryNames(allEntries, result.debug.warnings);
const triggerTexts = buildActivationSourceTexts({
chatMessages,
@@ -1038,91 +1059,195 @@ export async function resolveTaskWorldInfo({
: String(ejsBackend.error)
: "";
const activated = selectActivatedEntries(allEntries, trigger, {
const normalizedTemplateContext = {
...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) {
};
const initialActivated = selectActivatedEntries(
allEntries,
trigger,
normalizedTemplateContext,
);
if (initialActivated.length === 0) {
return result;
}
const allActivated = toActivationMap(initialActivated);
const aggregatedForcedEntries = new Map();
const aggregatedInlineEntries = new Map();
const recursionWarnings = new Set();
const knownWorldbooks = new Set(
allEntries.map((entry) => entry.worldbook).filter(Boolean),
);
const lazyLoadWorldbookEntries = async (worldbookName) => {
const normalizedWorldbook = normalizeKey(worldbookName);
if (!normalizedWorldbook || knownWorldbooks.has(normalizedWorldbook)) {
return [];
}
const lazyEntries = await loadNormalizedWorldbookEntries(
worldbookHost,
normalizedWorldbook,
);
knownWorldbooks.add(normalizedWorldbook);
return lazyEntries;
};
const renderCtx = createTaskEjsRenderContext(
allEntries.map((entry) => ({
uid: entry.uid,
name: entry.name,
comment: entry.comment,
content: entry.cleanContent || entry.content,
worldbook: entry.worldbook,
role: entry.role,
position: entry.position,
depth: entry.depth,
order: entry.order,
activationDebug: entry.activationDebug,
})),
{
templateContext: {
...templateContext,
user_input: userMessage || templateContext?.user_input || "",
},
templateContext: normalizedTemplateContext,
currentActivatedEntries: [...allActivated.values()],
loadWorldbookEntries: lazyLoadWorldbookEntries,
},
);
const controllerPrefix =
settings.worldInfoControllerEntryPrefix ||
settings.controller_entry_prefix ||
DEFAULT_CONTROLLER_ENTRY_PREFIX;
const maxResolvePasses =
Number.isFinite(Number(settings.worldInfoMaxResolvePasses)) &&
Number(settings.worldInfoMaxResolvePasses) > 0
? Number(settings.worldInfoMaxResolvePasses)
: DEFAULT_MAX_RESOLVE_PASSES;
const beforeEntries = [];
const afterEntries = [];
const atDepthEntries = [];
let resolvedIndex = 0;
let finalResolvedEntries = [];
let hitResolveCap = false;
for (const entry of activated) {
renderCtx.pulledEntries.clear();
for (let pass = 0; pass < maxResolvePasses; pass += 1) {
result.debug.resolvePassCount = pass + 1;
renderCtx.currentActivatedEntries = [...allActivated.values()];
renderCtx.forcedActivatedEntries.clear();
renderCtx.inlinePulledEntries.clear();
renderCtx.warnings = [];
finalResolvedEntries = [];
resolvedIndex = 0;
const sourceContent = entry.cleanContent || entry.content;
const isControllerEntry = entry.name.startsWith(String(controllerPrefix || ""));
let renderedContent = sourceContent;
try {
renderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, {
world_info: {
comment: entry.comment || entry.name,
name: entry.name,
world: entry.worldbook,
},
});
} 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);
const activatedEntries = [...allActivated.values()].sort(sortEntries);
for (const entry of activatedEntries) {
const sourceContent = entry.cleanContent || entry.content;
let renderedContent = sourceContent;
try {
renderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, {
world_info: {
comment: entry.comment || entry.name,
name: entry.name,
world: entry.worldbook,
},
});
} catch (error) {
const warning =
error?.code === "st_bme_task_ejs_unsupported_helper"
? `世界书条目 ${entry.name} 调用了不支持的 helper: ${error.helperName}`
: error?.code === "st_bme_task_ejs_runtime_unavailable"
? `世界书条目 ${entry.name} 依赖 EJS runtime当前已跳过`
: `世界书条目 ${entry.name} 渲染失败,已跳过`;
if (!result.debug.warnings.includes(warning)) {
result.debug.warnings.push(warning);
}
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 = "";
}
renderedContent = "";
for (const warning of renderCtx.warnings || []) {
recursionWarnings.add(String(warning || ""));
}
const trimmedContent = String(renderedContent || "").trim();
if (!trimmedContent) {
continue;
}
finalResolvedEntries.push(
normalizeResolvedEntry(
{
name: entry.comment || entry.name,
sourceName: entry.name,
worldbook: entry.worldbook,
content: trimmedContent,
role: entry.role,
position: entry.position,
depth: entry.depth,
order: entry.order,
activationDebug: entry.activationDebug,
},
resolvedIndex++,
),
);
}
if (!isControllerEntry && !String(renderedContent || "").trim()) {
continue;
for (const pulledEntry of renderCtx.inlinePulledEntries.values()) {
const key = `${pulledEntry.worldbook}:${pulledEntry.name}`;
if (!aggregatedInlineEntries.has(key)) {
aggregatedInlineEntries.set(key, {
name: pulledEntry.comment || pulledEntry.name,
sourceName: pulledEntry.name,
worldbook: pulledEntry.worldbook,
});
}
}
let discoveredNewActivation = false;
for (const forcedEntry of renderCtx.forcedActivatedEntries.values()) {
const key = getEntryIdentity(forcedEntry);
if (!aggregatedForcedEntries.has(key)) {
aggregatedForcedEntries.set(key, {
name: forcedEntry.comment || forcedEntry.name,
sourceName: forcedEntry.name,
worldbook: forcedEntry.worldbook,
});
}
if (!allActivated.has(key)) {
allActivated.set(key, {
...forcedEntry,
activationDebug: mergeActivationDebug(forcedEntry, {
mode: "ejs-forced",
}),
});
discoveredNewActivation = true;
}
}
if (!discoveredNewActivation) {
break;
}
if (pass + 1 >= maxResolvePasses) {
hitResolveCap = true;
}
}
if (hitResolveCap) {
const warning = `世界书 EJS 激活达到递归上限 ${maxResolvePasses},已停止继续展开`;
if (!result.debug.warnings.includes(warning)) {
result.debug.warnings.push(warning);
}
recursionWarnings.add(warning);
}
for (const entry of finalResolvedEntries) {
const bucketName = classifyPosition(entry);
const bucket =
bucketName === "before"
@@ -1130,57 +1255,7 @@ export async function resolveTaskWorldInfo({
: bucketName === "after"
? afterEntries
: atDepthEntries;
if (isControllerEntry) {
for (const pulledEntry of renderCtx.pulledEntries.values()) {
if (!String(pulledEntry.content || "").trim()) continue;
if (
pulledEntry.worldbook === entry.worldbook &&
pulledEntry.name === entry.name
) {
continue;
}
bucket.push(
normalizeResolvedEntry(
{
name: pulledEntry.comment || pulledEntry.name,
sourceName: pulledEntry.name,
worldbook: pulledEntry.worldbook,
content: pulledEntry.content,
role: entry.role,
position: entry.position,
depth: entry.depth,
order: entry.order,
activationDebug: {
...(entry.activationDebug || {}),
mode: "controller-pulled",
},
controllerSource: entry.name,
},
resolvedIndex++,
),
);
result.debug.controllerPulledCount += 1;
}
continue;
}
bucket.push(
normalizeResolvedEntry(
{
name: entry.comment || entry.name,
sourceName: entry.name,
worldbook: entry.worldbook,
content: renderedContent,
role: entry.role,
position: entry.position,
depth: entry.depth,
order: entry.order,
activationDebug: entry.activationDebug,
},
resolvedIndex++,
),
);
bucket.push(entry);
}
result.beforeEntries = beforeEntries;
@@ -1189,33 +1264,48 @@ export async function resolveTaskWorldInfo({
result.beforeText = buildWorldInfoText(result.beforeEntries);
result.afterText = buildWorldInfoText(result.afterEntries);
result.additionalMessages = buildAdditionalMessages(result.atDepthEntries);
result.debug.activatedEntryCount = allActivated.size;
result.debug.constantActivatedCount = [...allActivated.values()].filter(
(entry) => entry.activationDebug?.mode === "constant",
).length;
result.debug.selectiveActivatedCount = [...allActivated.values()].filter(
(entry) =>
entry.activationDebug?.mode === "selective" ||
entry.activationDebug?.mode === "forced",
).length;
result.debug.ejsForcedActivationCount = aggregatedForcedEntries.size;
result.debug.ejsInlinePullCount = aggregatedInlineEntries.size;
result.debug.forcedActivatedEntries = [...aggregatedForcedEntries.values()];
result.debug.inlinePulledEntries = [...aggregatedInlineEntries.values()];
result.debug.lazyLoadedWorldbooks = [...renderCtx.lazyLoadedWorldbooks];
result.debug.recursionWarnings = [...recursionWarnings];
result.debug.resolvedEntries = [
...result.beforeEntries.map((entry) => ({
name: entry.name,
bucket: "before",
sourceName: entry.sourceName,
worldbook: entry.worldbook,
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,
worldbook: entry.worldbook,
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,
worldbook: entry.worldbook,
activationMode: entry.activationDebug?.mode || "",
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
controllerSource: entry.controllerSource || "",
})),
];
result.activatedEntryNames = uniq(
@@ -1223,8 +1313,8 @@ export async function resolveTaskWorldInfo({
...result.beforeEntries.map((entry) => entry.name),
...result.afterEntries.map((entry) => entry.name),
...result.atDepthEntries.map((entry) => entry.name),
...[...renderCtx.activatedEntries.values()].map(
(entry) => entry.comment || entry.name,
...[...aggregatedForcedEntries.values()].map(
(entry) => entry.name || entry.sourceName,
),
].filter(Boolean),
);

View File

@@ -129,6 +129,12 @@ try {
if (template === "<% broken") {
throw new Error("Unexpected end of input");
}
if (template === "<% await execute() %>") {
return async function compiled(locals) {
await locals.execute();
return "";
};
}
return async function compiled(locals) {
return [
locals.charName,
@@ -167,6 +173,13 @@ try {
);
assert.deepEqual(compileCalls, ["<%= 1 %>", "<%= 1 %>"]);
await assert.rejects(
() => evalTaskEjsTemplate("<% await execute() %>", renderCtx),
(error) =>
error?.code === "st_bme_task_ejs_unsupported_helper" &&
error?.helperName === "execute",
);
const syntaxError = await checkTaskEjsSyntax("<% broken");
assert.equal(syntaxError, "Unexpected end of input");

View File

@@ -30,113 +30,145 @@ const originalGetCharWorldbookNames = globalThis.getCharWorldbookNames;
const originalGetWorldbook = globalThis.getWorldbook;
const originalGetLorebookEntries = globalThis.getLorebookEntries;
const constantEntry = {
uid: 1,
name: "常驻设定",
comment: "常驻设定",
content: "这里是常驻世界设定。",
enabled: true,
position: {
type: "before_character_definition",
role: "system",
depth: 0,
order: 10,
},
strategy: {
type: "constant",
keys: [],
keys_secondary: { logic: "and_any", keys: [] },
},
probability: 100,
extra: {},
};
const dynEntry = {
uid: 2,
name: "Dyn/线索",
comment: "线索条目",
content: "隐藏线索:<%= charName %> 正在调查。",
enabled: false,
position: {
type: "before_character_definition",
role: "system",
depth: 0,
order: 20,
},
strategy: {
type: "selective",
keys: ["调查"],
keys_secondary: { logic: "and_any", keys: [] },
},
probability: 100,
extra: {},
};
const controllerEntry = {
uid: 3,
name: "EW/Controller/Main",
comment: "控制器",
content: '<%= await getwi("Dyn/线索") %>',
enabled: true,
position: {
type: "before_character_definition",
role: "system",
depth: 0,
order: 30,
},
strategy: {
type: "constant",
keys: [],
keys_secondary: { logic: "and_any", keys: [] },
},
probability: 100,
extra: {},
};
const atDepthEntry = {
uid: 4,
name: "深度注入",
comment: "深度注入",
content: "这是一条 atDepth 消息。",
enabled: true,
position: {
type: "at_depth_as_system",
role: "system",
depth: 2,
order: 5,
},
strategy: {
type: "constant",
keys: [],
keys_secondary: { logic: "and_any", keys: [] },
},
probability: 100,
extra: {},
};
function createConstantWorldbookEntry(uid, name, content, comment = "") {
function createWorldbookEntry({
uid,
name,
comment = name,
content,
enabled = true,
positionType = "before_character_definition",
role = "system",
depth = 0,
order = 10,
strategyType = "constant",
keys = [],
keysSecondary = [],
}) {
return {
uid,
name,
comment,
content,
enabled: true,
enabled,
position: {
type: "before_character_definition",
role: "system",
depth: 0,
order: 10,
type: positionType,
role,
depth,
order,
},
strategy: {
type: "constant",
keys: [],
keys_secondary: { logic: "and_any", keys: [] },
type: strategyType,
keys,
keys_secondary: { logic: "and_any", keys: keysSecondary },
},
probability: 100,
extra: {},
};
}
function createConstantWorldbookEntry(uid, name, content, comment = name) {
return createWorldbookEntry({
uid,
name,
comment,
content,
});
}
const constantEntry = createWorldbookEntry({
uid: 1,
name: "常驻设定",
comment: "常驻设定",
content: "这里是常驻世界设定。",
order: 10,
});
const dynEntry = createWorldbookEntry({
uid: 2,
name: "EW/Dyn/线索",
comment: "线索条目",
content: "隐藏线索:<%= charName %> 正在调查。",
enabled: false,
strategyType: "selective",
keys: ["调查"],
order: 15,
});
const inlineSummaryEntry = createWorldbookEntry({
uid: 3,
name: "普通 EJS 汇总",
comment: "EJS 汇总",
content: '控制摘要:<%= await getwi("EW/Dyn/线索") %>',
order: 20,
});
const extensionLiteralEntry = createWorldbookEntry({
uid: 4,
name: "扩展语义正文",
comment: "扩展语义正文",
content: "@@generate\n[GENERATE:Test]\n扩展语义只是普通文本。",
order: 25,
});
const externalInlineEntry = createWorldbookEntry({
uid: 5,
name: "外部书汇总",
comment: "外部书汇总",
content: '外部补充:<%= await getwi("bonus-book", "Bonus 条目") %>',
order: 26,
});
const forceControlEntry = createWorldbookEntry({
uid: 6,
name: "普通 EJS 控制",
comment: "EJS 控制",
content: '<% await activewi("强制 after") %>',
order: 30,
});
const forcedAfterEntry = createWorldbookEntry({
uid: 7,
name: "强制 after",
comment: "强制后置",
content: "这是被 EJS 强制激活的后置条目。",
positionType: "after_character_definition",
strategyType: "selective",
keys: ["永远不会命中"],
order: 40,
});
const atDepthEntry = createWorldbookEntry({
uid: 8,
name: "深度注入",
comment: "深度注入",
content: "这是一条 atDepth 消息。",
positionType: "at_depth_as_system",
depth: 2,
order: 5,
});
const bonusEntry = createWorldbookEntry({
uid: 101,
name: "Bonus 条目",
comment: "Bonus 条目",
content: "来自 bonus-book 的补充内容。",
order: 10,
});
const worldbooksByName = {
"main-book": [
constantEntry,
dynEntry,
inlineSummaryEntry,
extensionLiteralEntry,
externalInlineEntry,
forceControlEntry,
forcedAfterEntry,
atDepthEntry,
],
"bonus-book": [bonusEntry],
};
try {
globalThis.SillyTavern = {
getContext() {
@@ -153,13 +185,13 @@ try {
primary: "main-book",
additional: [],
});
globalThis.getWorldbook = async () => [
constantEntry,
dynEntry,
controllerEntry,
atDepthEntry,
];
globalThis.getLorebookEntries = async () => [];
globalThis.getWorldbook = async (worldbookName) =>
worldbooksByName[worldbookName] || [];
globalThis.getLorebookEntries = async (worldbookName) =>
(worldbooksByName[worldbookName] || []).map((entry) => ({
uid: entry.uid,
comment: entry.comment,
}));
const { resolveTaskWorldInfo } = await import("../task-worldinfo.js");
const { buildTaskPrompt } = await import("../prompt-builder.js");
@@ -185,14 +217,30 @@ try {
assert.deepEqual(
worldInfo.beforeEntries.map((entry) => entry.name),
["常驻设定", "线索条目"],
["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总"],
);
assert.doesNotMatch(worldInfo.beforeText, /getwi|<%=?/);
assert.equal(worldInfo.debug.controllerPulledCount, 1);
assert.deepEqual(worldInfo.afterEntries.map((entry) => entry.name), ["强制后置"]);
assert.equal(worldInfo.additionalMessages.length, 1);
assert.equal(worldInfo.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.match(worldInfo.beforeText, /控制摘要隐藏线索Alice 正在调查。/);
assert.match(worldInfo.beforeText, /外部补充:来自 bonus-book 的补充内容。/);
assert.match(worldInfo.beforeText, /@@generate/);
assert.match(worldInfo.beforeText, /\[GENERATE:Test\]/);
assert.doesNotMatch(worldInfo.beforeText, /getwi|<%=?/);
assert.equal(worldInfo.debug.ejsInlinePullCount, 2);
assert.equal(worldInfo.debug.ejsForcedActivationCount, 1);
assert.equal(worldInfo.debug.resolvePassCount >= 2, true);
assert.deepEqual(worldInfo.debug.forcedActivatedEntries.map((entry) => entry.name), [
"强制后置",
]);
assert.deepEqual(
worldInfo.debug.inlinePulledEntries.map((entry) => entry.name).sort(),
["Bonus 条目", "线索条目"].sort(),
);
assert.deepEqual(worldInfo.debug.lazyLoadedWorldbooks, ["bonus-book"]);
assert.equal(
worldInfo.additionalMessages[0].content,
"这是一条 atDepth 消息。",
worldInfo.debug.warnings.some((warning) => warning.includes("旧 EW 命名条目")),
true,
);
const settings = {
@@ -217,11 +265,20 @@ try {
},
{
id: "b2",
type: "builtin",
sourceKey: "worldInfoAfter",
role: "system",
enabled: true,
order: 1,
injectionMode: "append",
},
{
id: "b3",
type: "custom",
content: "角色: {{charName}}",
role: "user",
enabled: true,
order: 1,
order: 2,
injectionMode: "append",
},
],
@@ -239,7 +296,10 @@ try {
});
assert.match(promptBuild.systemPrompt, /这里是常驻世界设定/);
assert.match(promptBuild.systemPrompt, /隐藏线索Alice 正在调查/);
assert.match(promptBuild.systemPrompt, /控制摘要:隐藏线索Alice 正在调查/);
assert.match(promptBuild.systemPrompt, /扩展语义只是普通文本/);
assert.match(promptBuild.systemPrompt, /来自 bonus-book 的补充内容/);
assert.doesNotMatch(promptBuild.systemPrompt, /getwi|<%=?/);
assert.equal(
promptBuild.privateTaskMessages.length,
2,
@@ -251,41 +311,44 @@ try {
);
assert.deepEqual(
promptBuild.hostInjections.before.map((entry) => entry.name),
["常驻设定", "线索条目"],
["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总"],
);
assert.deepEqual(
promptBuild.hostInjections.after.map((entry) => entry.name),
["强制后置"],
);
assert.equal(promptBuild.hostInjections.atDepth.length, 1);
assert.equal(promptBuild.hostInjections.atDepth[0].depth, 2);
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, [
"常驻设定",
"线索条目",
"EJS 汇总",
"扩展语义正文",
"外部书汇总",
]);
assert.equal(promptBuild.hostInjections.after.length, 0);
assert.equal(promptBuild.hostInjections.atDepth.length, 1);
assert.equal(promptBuild.hostInjections.atDepth[0].depth, 2);
assert.equal(promptBuild.hostInjectionPlan.after.length, 1);
assert.equal(promptBuild.hostInjectionPlan.after[0].blockId, "b2");
assert.equal(promptBuild.hostInjectionPlan.after[0].sourceKey, "worldInfoAfter");
assert.deepEqual(promptBuild.hostInjectionPlan.after[0].entryNames, ["强制后置"]);
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"],
["host.before", "host.after", "private.message"],
);
assert.equal(promptBuild.additionalMessages.length, 1);
assert.equal(
promptBuild.additionalMessages[0].content,
"这是一条 atDepth 消息。",
);
assert.equal(promptBuild.additionalMessages[0].content, "这是一条 atDepth 消息。");
const { initializeHostAdapter } = await import("../host-adapter/index.js");
const partialBridgeCalls = [];
const partialBridgeEntriesByWorldbook = {
"main-book": [createConstantWorldbookEntry(11, "主书原名", "主书内容。")],
"side-book": [createConstantWorldbookEntry(12, "支线原名", "支线内容。")],
"persona-book": [
createConstantWorldbookEntry(13, "人格原名", "人格内容。"),
],
"chat-book": [createConstantWorldbookEntry(14, "聊天原名", "聊天内容。")],
"main-book": [createConstantWorldbookEntry(11, "主书原名", "主书内容。", "主书注释")],
"side-book": [createConstantWorldbookEntry(12, "支线原名", "支线内容。", "支线注释")],
"persona-book": [createConstantWorldbookEntry(13, "人格原名", "人格内容。", "人格注释")],
"chat-book": [createConstantWorldbookEntry(14, "聊天原名", "聊天内容。", "聊天注释")],
};
globalThis.SillyTavern = {
@@ -313,12 +376,10 @@ try {
);
};
globalThis.getLorebookEntries = async (worldbookName) =>
({
"main-book": [{ uid: 11, comment: "主书注释" }],
"side-book": [{ uid: 12, comment: "支线注释" }],
"persona-book": [{ uid: 13, comment: "人格注释" }],
"chat-book": [{ uid: 14, comment: "聊天注释" }],
})[worldbookName] || [];
(partialBridgeEntriesByWorldbook[worldbookName] || []).map((entry) => ({
uid: entry.uid,
comment: entry.comment,
}));
initializeHostAdapter({
worldbookProvider: {