Files
ST-Bionic-Memory-Ecology/prompting/task-ejs.js
2026-04-08 01:17:57 +08:00

1196 lines
35 KiB
JavaScript

// ST-BME: 任务级 EJS / 世界书渲染引擎
// 仅用于世界书条目渲染,不开放给用户自定义 prompt 块。
import { getSTContextSnapshot } from "../host/st-context.js";
const DEFAULT_MAX_RECURSION = 10;
let ejsRuntimeStatePromise = null;
const EJS_RUNTIME_STATUS = {
PRIMARY: "primary",
FALLBACK: "fallback",
FAILED: "failed",
};
const FALLBACK_LODASH = {
get: getByPath,
cloneDeep,
escapeRegExp,
sum(values = []) {
return (Array.isArray(values) ? values : []).reduce(
(total, value) => total + (Number(value) || 0),
0,
);
},
};
function getUtilityLib() {
return globalThis._ || FALLBACK_LODASH;
}
function getEjsRuntime() {
return globalThis.ejs || null;
}
function buildEjsRuntimeState(runtime, status, error = null) {
return {
runtime: runtime || null,
status,
isAvailable: Boolean(runtime),
isFallback: status === EJS_RUNTIME_STATUS.FALLBACK,
error: error || null,
};
}
function getCurrentEjsRuntimeState() {
const runtime = getEjsRuntime();
if (!runtime) {
return buildEjsRuntimeState(null, EJS_RUNTIME_STATUS.FAILED);
}
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;
}
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) {
return currentState;
}
if (ejsRuntimeStatePromise) {
return await ejsRuntimeStatePromise;
}
ejsRuntimeStatePromise = (async () => {
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
const previousWindow = globalThis.window;
let importError = null;
if (!hadWindow) {
globalThis.window = globalThis;
}
try {
await import("../vendor/ejs.js");
} catch (error) {
importError = error;
console.warn("[ST-BME] task-ejs 加载 ../vendor/ejs.js 失败:", error);
} finally {
if (!hadWindow) {
delete globalThis.window;
} else {
globalThis.window = previousWindow;
}
}
const runtime = getEjsRuntime();
if (runtime) {
return buildEjsRuntimeState(runtime, EJS_RUNTIME_STATUS.FALLBACK);
}
return buildEjsRuntimeState(null, EJS_RUNTIME_STATUS.FAILED, importError);
})();
return await ejsRuntimeStatePromise;
}
async function resolveTaskEjsBackend(options = {}) {
if (options.ensureRuntime === false) {
return getCurrentEjsRuntimeState();
}
return await ensureEjsRuntime();
}
function resolveHostSnapshot(injectedSnapshot) {
if (injectedSnapshot?.snapshot) {
return injectedSnapshot;
}
return getSTContextSnapshot();
}
function getStChat(injectedSnapshot) {
return resolveHostSnapshot(injectedSnapshot).snapshot.chat.messages || [];
}
function buildTemplateContext(templateContext = {}, hostSnapshot) {
const resolvedHost = resolveHostSnapshot(hostSnapshot);
const snapshot = resolvedHost.snapshot;
const promptAliases = resolvedHost.prompt || {};
const lastUserMessage =
typeof templateContext.user_input === "string"
? templateContext.user_input
: snapshot.chat.lastUserMessage || "";
return {
userMessage: "",
recentMessages: "",
chatMessages: [],
dialogueText: "",
candidateText: "",
candidateNodes: [],
nodeContent: "",
eventSummary: "",
characterSummary: "",
threadSummary: "",
contradictionSummary: "",
graphStats: "",
schema: "",
currentRange: "",
worldInfoBefore: "",
worldInfoAfter: "",
worldInfoBeforeEntries: [],
worldInfoAfterEntries: [],
worldInfoAtDepthEntries: [],
activatedWorldInfoNames: [],
taskAdditionalMessages: [],
user: snapshot.user.name,
char: snapshot.character.name,
userName: promptAliases.userName || snapshot.user.name,
charName: promptAliases.charName || snapshot.character.name,
assistantName: promptAliases.charName || snapshot.character.name,
persona: promptAliases.userPersona || snapshot.persona.text,
userPersona: promptAliases.userPersona || snapshot.persona.text,
charDescription:
promptAliases.charDescription || snapshot.character.description,
currentTime: promptAliases.currentTime || snapshot.time.current,
stSnapshot: snapshot,
hostSnapshot: snapshot,
lastUserMessage,
last_user_message: lastUserMessage,
userInput: lastUserMessage,
user_input: lastUserMessage,
original: "",
input: "",
lastMessage: "",
lastMessageId: "",
newline: "\n",
trim: "",
...templateContext,
};
}
function cloneDeep(value) {
if (value == null) return value;
try {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
} catch {
// ignore and fall back to JSON
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
function isPlainObject(value) {
if (!value || typeof value !== "object") {
return false;
}
if (Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function getByPath(target, path, defaultValue = undefined) {
const result = String(path || "")
.split(".")
.filter(Boolean)
.reduce((acc, key) => (acc == null ? undefined : acc[key]), target);
return result === undefined ? defaultValue : result;
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeEntryKey(value) {
return String(value ?? "").trim();
}
function isEntryIdentifier(value) {
return (
typeof value === "string" ||
typeof value === "number" ||
value instanceof RegExp
);
}
function cloneRegExp(pattern) {
return new RegExp(pattern.source, pattern.flags);
}
function matchesWorldbookIdentifier(worldbook, identifier) {
if (!isEntryIdentifier(identifier)) {
return false;
}
if (identifier instanceof RegExp) {
return cloneRegExp(identifier).test(String(worldbook || ""));
}
return normalizeEntryKey(worldbook) === normalizeEntryKey(identifier);
}
function matchesEntryIdentifier(entry = {}, identifier) {
if (!isEntryIdentifier(identifier)) {
return false;
}
const entryName = normalizeEntryKey(entry.name);
const entryComment = normalizeEntryKey(entry.comment);
const entryUid = Number(entry.uid) || 0;
if (identifier instanceof RegExp) {
const pattern = cloneRegExp(identifier);
return pattern.test(entryComment) || pattern.test(entryName);
}
if (typeof identifier === "number") {
return entryUid === identifier;
}
const normalizedIdentifier = normalizeEntryKey(identifier);
if (!normalizedIdentifier) {
return false;
}
return (
entryComment === normalizedIdentifier ||
entryName === normalizedIdentifier ||
String(entryUid) === normalizedIdentifier
);
}
function normalizeIdentifier(value) {
return String(value || "")
.trim()
.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 ?? "");
}
export function substituteTaskEjsParams(
text,
templateContext = {},
options = {},
) {
if (!text || !String(text).includes("{{")) {
return String(text || "");
}
const context = buildTemplateContext(
templateContext,
options.hostSnapshot || templateContext.hostSnapshot,
);
return String(text).replace(/\{\{\s*([a-zA-Z0-9_.$]+)\s*\}\}/g, (_, path) => {
const value = getByPath(context, path);
if (value == null) return "";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return "";
}
}
return String(value);
});
}
function createReadOnlyVariableState(hostSnapshot) {
const snapshot = resolveHostSnapshot(hostSnapshot).snapshot;
const chat = snapshot.chat.messages || [];
const lastMessage = chat[chat.length - 1] || {};
const swipeId = Number(lastMessage?.swipe_id ?? 0);
const messageVars =
lastMessage?.variables && typeof lastMessage.variables === "object"
? cloneDeep(lastMessage.variables[swipeId] || {})
: {};
const globalVars = cloneDeep(snapshot.variables.global || {});
const localVars = cloneDeep(snapshot.variables.local || {});
return Object.freeze({
globalVars,
localVars,
messageVars,
cacheVars: {
...globalVars,
...localVars,
...messageVars,
},
});
}
function getVariable(state, path, options = {}) {
const scope = normalizeIdentifier(options.scope);
if (scope === "global") {
return getByPath(state.globalVars, path, options.defaults);
}
if (scope === "local") {
return getByPath(state.localVars, path, options.defaults);
}
if (scope === "message") {
return getByPath(state.messageVars, path, options.defaults);
}
return getByPath(state.cacheVars, path, options.defaults);
}
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) {
const normalizedKey = normalizeEntryKey(key);
if (!normalizedKey || lookup.has(normalizedKey)) return;
lookup.set(normalizedKey, entry);
}
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);
registerEntryLookup(renderCtx.allEntries, entry.uid, 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);
registerEntryLookup(worldbookLookup, entry.uid, entry);
}
}
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);
}
function lookupEntryInMap(lookup, identifier) {
if (!(lookup instanceof Map) || !isEntryIdentifier(identifier)) {
return undefined;
}
if (!(identifier instanceof RegExp)) {
const direct = lookup.get(normalizeEntryKey(identifier));
if (direct) {
return direct;
}
}
for (const entry of lookup.values()) {
if (matchesEntryIdentifier(entry, identifier)) {
return entry;
}
}
return undefined;
}
function buildCandidateLookups(renderCtx, currentWorldbook, explicitWorldbook = null) {
const candidates = [];
const seen = new Set();
const pushLookup = (lookup) => {
if (!(lookup instanceof Map) || seen.has(lookup)) {
return;
}
seen.add(lookup);
candidates.push(lookup);
};
if (typeof explicitWorldbook === "string") {
pushLookup(renderCtx.entriesByWorldbook.get(normalizeEntryKey(explicitWorldbook)));
} else if (explicitWorldbook instanceof RegExp) {
for (const [worldbookName, lookup] of renderCtx.entriesByWorldbook.entries()) {
if (matchesWorldbookIdentifier(worldbookName, explicitWorldbook)) {
pushLookup(lookup);
}
}
}
const fallbackWorldbook = normalizeEntryKey(currentWorldbook);
if (fallbackWorldbook) {
pushLookup(renderCtx.entriesByWorldbook.get(fallbackWorldbook));
}
pushLookup(renderCtx.allEntries);
return candidates;
}
async function resolveEntry(renderCtx, currentWorldbook, worldbookOrEntry, entryNameOrData) {
const hasExplicitWorldbook = isEntryIdentifier(entryNameOrData);
const explicitWorldbook = hasExplicitWorldbook ? worldbookOrEntry : null;
const fallbackWorldbook = normalizeEntryKey(currentWorldbook);
const identifier = hasExplicitWorldbook ? entryNameOrData : worldbookOrEntry;
if (!isEntryIdentifier(identifier)) {
return undefined;
}
const directLookups = buildCandidateLookups(
renderCtx,
fallbackWorldbook,
explicitWorldbook,
);
for (const lookup of directLookups) {
const matched = lookupEntryInMap(lookup, identifier);
if (matched) {
return matched;
}
}
if (typeof explicitWorldbook === "string" && normalizeEntryKey(explicitWorldbook)) {
await ensureWorldbookEntriesLoaded(renderCtx, explicitWorldbook);
const loadedLookups = buildCandidateLookups(
renderCtx,
fallbackWorldbook,
explicitWorldbook,
);
for (const lookup of loadedLookups) {
const matched = lookupEntryInMap(lookup, identifier);
if (matched) {
return matched;
}
}
}
if (!renderCtx.resolveIgnoredEntry || identifier instanceof RegExp) {
return undefined;
}
const normalizedIdentifier = normalizeEntryKey(identifier);
const explicitWorldbookName =
typeof explicitWorldbook === "string" ? normalizeEntryKey(explicitWorldbook) : "";
const ignoredEntry =
renderCtx.resolveIgnoredEntry(
explicitWorldbookName || fallbackWorldbook,
normalizedIdentifier,
) || renderCtx.resolveIgnoredEntry("", normalizedIdentifier);
if (ignoredEntry) {
const descriptor = ignoredEntry.sourceName || ignoredEntry.name || normalizedIdentifier;
recordRenderWarning(
renderCtx,
`mvu filtered world info blocked: ${ignoredEntry.worldbook ? `${ignoredEntry.worldbook}/` : ""}${descriptor}`,
);
}
return undefined;
}
function parseActivateWorldInfoArgs(world, entryOrForce, maybeForce) {
const hasExplicitWorldbook = isEntryIdentifier(entryOrForce);
return {
explicitWorldbook: hasExplicitWorldbook ? world : null,
identifier: hasExplicitWorldbook ? entryOrForce : world,
force:
typeof maybeForce === "boolean"
? maybeForce
: typeof entryOrForce === "boolean",
};
}
function parseGetwiArgs(worldbookOrEntry, entryNameOrData, dataOrUndefined) {
const hasExplicitWorldbook = isEntryIdentifier(entryNameOrData);
return {
explicitWorldbook: hasExplicitWorldbook ? worldbookOrEntry : null,
identifier: hasExplicitWorldbook ? entryNameOrData : worldbookOrEntry,
data: isPlainObject(hasExplicitWorldbook ? dataOrUndefined : entryNameOrData)
? cloneDeep(hasExplicitWorldbook ? dataOrUndefined : entryNameOrData)
: {},
};
}
function mergeEjsExtraEnv(...values) {
const utilityLib = getUtilityLib();
const merge = typeof utilityLib?.merge === "function" ? utilityLib.merge : null;
const plainValues = values.filter((value) => isPlainObject(value));
if (plainValues.length === 0) {
return {};
}
if (merge) {
return merge({}, ...plainValues.map((value) => cloneDeep(value)));
}
return Object.assign({}, ...plainValues.map((value) => ({ ...value })));
}
async function activateWorldInfoInContext(
renderCtx,
currentWorldbook,
world,
entryOrForce,
maybeForce,
) {
const parsed = parseActivateWorldInfoArgs(world, entryOrForce, maybeForce);
const identifierLabel =
parsed.identifier instanceof RegExp
? parsed.identifier.toString()
: normalizeEntryKey(parsed.identifier);
const explicitWorldbookLabel =
typeof parsed.explicitWorldbook === "string"
? normalizeEntryKey(parsed.explicitWorldbook)
: parsed.explicitWorldbook instanceof RegExp
? parsed.explicitWorldbook.toString()
: "";
const entry = await resolveEntry(
renderCtx,
currentWorldbook,
parsed.explicitWorldbook,
parsed.identifier,
);
if (!entry) {
recordRenderWarning(
renderCtx,
`activewi target not found: ${explicitWorldbookLabel ? `${explicitWorldbookLabel}/` : ""}${identifierLabel}`,
);
return null;
}
const normalizedEntry = normalizeRenderEntry({
...entry,
content: String(entry.content || "").replaceAll("@@dont_activate", ""),
});
renderCtx.forcedActivatedEntries.set(activationKey(normalizedEntry), normalizedEntry);
return {
world: normalizedEntry.worldbook,
comment: normalizedEntry.comment || normalizedEntry.name,
content: normalizedEntry.content,
forced: parsed.force,
};
}
async function getwi(
renderCtx,
currentWorldbook,
worldbookOrEntry,
entryNameOrData,
dataOrUndefined,
) {
const parsed = parseGetwiArgs(
worldbookOrEntry,
entryNameOrData,
dataOrUndefined,
);
const entry = await resolveEntry(
renderCtx,
currentWorldbook,
parsed.explicitWorldbook,
parsed.identifier,
);
if (!entry) {
return "";
}
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 "";
}
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 "";
}
const processed = substituteTaskEjsParams(entry.content, renderCtx.templateContext, {
hostSnapshot: renderCtx.hostSnapshot,
});
let finalContent = processed;
if (processed.includes("<%")) {
renderCtx.renderStack.add(entryKey);
try {
finalContent = await evalTaskEjsTemplate(processed, renderCtx, {
...mergeEjsExtraEnv(parsed.data),
world_info: {
comment: entry.comment || entry.name,
name: entry.name,
world: entry.worldbook,
},
});
} finally {
renderCtx.renderStack.delete(entryKey);
}
}
renderCtx.inlinePulledEntries.set(entryKey, {
name: entry.name,
comment: entry.comment,
content: finalContent,
worldbook: entry.worldbook,
});
return String(finalContent || "");
}
function getChatMessageCompat(renderCtx, index, role) {
const chat = getStChat(renderCtx?.hostSnapshot)
.filter((message) => {
if (!role) return true;
if (role === "user") return Boolean(message?.is_user);
if (role === "system") return Boolean(message?.is_system);
return !message?.is_user && !message?.is_system;
})
.map(processChatMessage);
const resolvedIndex = index >= 0 ? index : chat.length + index;
return chat[resolvedIndex] || "";
}
function getChatMessagesCompat(renderCtx, startOrCount, endOrRole, role) {
const chat = getStChat(renderCtx?.hostSnapshot);
const allMessages = chat.map((message, index) => ({
raw: message,
id: index,
text: processChatMessage(message),
}));
const filterByRole = (items, currentRole) => {
if (!currentRole) return items;
return items.filter((item) => {
if (currentRole === "user") return Boolean(item.raw?.is_user);
if (currentRole === "system") return Boolean(item.raw?.is_system);
return !item.raw?.is_user && !item.raw?.is_system;
});
};
if (endOrRole == null) {
return (
startOrCount > 0
? allMessages.slice(0, startOrCount)
: allMessages.slice(startOrCount)
).map((item) => item.text);
}
if (typeof endOrRole === "string") {
const filtered = filterByRole(allMessages, endOrRole);
return (
startOrCount > 0
? filtered.slice(0, startOrCount)
: filtered.slice(startOrCount)
).map((item) => item.text);
}
return filterByRole(allMessages, role)
.slice(startOrCount, endOrRole)
.map((item) => item.text);
}
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) {
const lines = String(str || "").split("\n");
const start = Math.max(lineNumber - 3, 0);
const end = Math.min(lines.length, lineNumber + 3);
const escapedFileName =
typeof esc === "function" ? esc(filename) : filename || "ejs";
const context = lines
.slice(start, end)
.map((line, index) => {
const currentLine = index + start + 1;
return `${currentLine === lineNumber ? " >> " : " "}${currentLine}| ${line}`;
})
.join("\n");
err.message = `${escapedFileName}:${lineNumber}\n${context}\n\n${err.message}`;
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 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(options.maxRecursion)
: DEFAULT_MAX_RECURSION,
hostSnapshot,
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,
resolveIgnoredEntry:
typeof options.resolveIgnoredEntry === "function"
? options.resolveIgnoredEntry
: null,
templateContext: {
...(options.templateContext || {}),
hostSnapshot: hostSnapshot.snapshot,
stSnapshot: hostSnapshot.snapshot,
},
};
registerEntries(renderCtx, entries);
return renderCtx;
}
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;
const templateAliases = buildTemplateContext(renderCtx?.templateContext || {}, hostSnapshot);
const processed = substituteTaskEjsParams(content, renderCtx?.templateContext, {
hostSnapshot,
});
if (!runtime) {
if (processed.includes("<%")) {
throw createTaskEjsRuntimeUnavailableError(backend, processed);
}
return processed;
}
if (!processed.includes("<%")) {
return processed;
}
const stCtx = snapshot.raw || {};
const chat = snapshot.chat.messages || [];
const utilityLib = getUtilityLib();
const templateRuntimeEnv = mergeEjsExtraEnv(templateAliases);
const workflowUserInput =
typeof renderCtx?.templateContext?.user_input === "string"
? 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,
...templateRuntimeEnv,
stat_data: renderCtx.variableState?.cacheVars?.stat_data,
user: templateAliases.user,
char: templateAliases.char,
persona:
templateAliases.persona || templateAliases.userPersona || snapshot.persona.text || "",
userName: templateAliases.userName || snapshot.user.name,
charName: templateAliases.charName || snapshot.character.name,
assistantName:
templateAliases.assistantName ||
templateAliases.charName ||
snapshot.character.name,
charDescription:
templateAliases.charDescription || snapshot.character.description || "",
userPersona: templateAliases.userPersona || snapshot.persona.text || "",
currentTime: templateAliases.currentTime || snapshot.time.current || "",
characterId: snapshot.character.id,
hostSnapshot: snapshot,
stSnapshot: snapshot,
get chatId() {
return snapshot.chat.id || "";
},
get variables() {
return renderCtx.variableState.cacheVars;
},
get stat_data() {
return renderCtx.variableState?.cacheVars?.stat_data;
},
get lastUserMessageId() {
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 (
workflowUserInput ||
chat.findLast?.((message) => message?.is_user)?.mes ||
[...chat].reverse().find((message) => message?.is_user)?.mes ||
""
);
},
get last_user_message() {
return this.lastUserMessage;
},
get userInput() {
return workflowUserInput;
},
get user_input() {
return workflowUserInput;
},
get lastCharMessageId() {
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]
.reverse()
.find((message) => !message?.is_user && !message?.is_system)?.mes ||
""
);
},
get lastMessageId() {
return chat.length - 1;
},
get charLoreBook() {
return snapshot.worldbook.character || "";
},
get userLoreBook() {
return snapshot.worldbook.persona || "";
},
get chatLoreBook() {
return snapshot.worldbook.chat || "";
},
get charAvatar() {
return snapshot.character.avatar || "";
},
userAvatar: snapshot.user.avatar || "",
groups: stCtx.groups || [],
groupId: snapshot.host.meta.selectedGroupId,
get model() {
return snapshot.host.meta.onlineStatus || "";
},
get SillyTavern() {
return stCtx;
},
getwi: (worldbookOrEntry, entryNameOrData, dataOrUndefined) =>
getwi(
renderCtx,
String(context.world_info?.world || ""),
worldbookOrEntry,
entryNameOrData,
dataOrUndefined,
),
getWorldInfo: (worldbookOrEntry, entryNameOrData, dataOrUndefined) =>
getwi(
renderCtx,
String(context.world_info?.world || ""),
worldbookOrEntry,
entryNameOrData,
dataOrUndefined,
),
getvar: (path, options) => getVariable(renderCtx.variableState, path, options),
getLocalVar: (path, options = {}) =>
getVariable(renderCtx.variableState, path, {
...options,
scope: "local",
}),
getGlobalVar: (path, options = {}) =>
getVariable(renderCtx.variableState, path, {
...options,
scope: "global",
}),
getMessageVar: (path, options = {}) =>
getVariable(renderCtx.variableState, path, {
...options,
scope: "message",
}),
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 || "",
evalTemplate: async (innerContent, data = {}) =>
evalTaskEjsTemplate(innerContent, renderCtx, data),
getWorldInfoData: async () =>
renderCtx.entries.map((entry) => ({
comment: entry.comment || entry.name,
content: entry.content,
world: entry.worldbook,
})),
getWorldInfoActivatedData: async () =>
getCurrentActivatedEntries(renderCtx).map((entry) => ({
comment: entry.comment || entry.name,
content: entry.content,
world: entry.worldbook,
})),
getEnabledWorldInfoEntries: async () =>
renderCtx.entries.map((entry) => ({
comment: entry.comment || entry.name,
content: entry.content,
world: entry.worldbook,
})),
getEnabledLoreBooks: () => [...new Set(renderCtx.entries.map((entry) => entry.worldbook))],
activewi: async (world, entryOrForce, maybeForce) =>
activateWorldInfoInContext(
renderCtx,
String(context.world_info?.world || ""),
world,
entryOrForce,
maybeForce,
),
activateWorldInfo: async (world, entryOrForce, maybeForce) =>
activateWorldInfoInContext(
renderCtx,
String(context.world_info?.world || ""),
world,
entryOrForce,
maybeForce,
),
parseJSON: (raw) => {
try {
return JSON.parse(raw);
} catch {
return null;
}
},
print: (...parts) =>
parts.filter((part) => part !== undefined && part !== null).join(""),
...unsupported,
...extraEnv,
};
context.getchar = context.getchr;
context.getChara = context.getchr;
try {
const compiled = runtime.compile(processed, {
async: true,
outputFunctionName: "print",
_with: true,
localsName: "locals",
client: true,
});
const result = await compiled.call(
context,
context,
(value) => value,
() => ({ filename: "", template: "" }),
rethrow,
);
return result ?? "";
} catch (error) {
if (renderCtx && typeof renderCtx === "object") {
renderCtx.ejsLastError =
error instanceof Error ? error.message : String(error);
}
if (error?.code === "st_bme_task_ejs_unsupported_helper") {
throw error;
}
console.warn("[ST-BME] task-ejs 渲染失败:", error);
throw error;
}
}
export async function renderTaskEjsContent(content, templateContext = {}) {
const hostSnapshot = resolveHostSnapshot(templateContext.hostSnapshot);
const processed = substituteTaskEjsParams(content, templateContext, {
hostSnapshot,
});
if (!processed.includes("<%")) {
return processed;
}
const renderCtx = createTaskEjsRenderContext([], {
templateContext,
hostSnapshot,
});
return await evalTaskEjsTemplate(processed, renderCtx);
}
export async function checkTaskEjsSyntax(content) {
const backend = await resolveTaskEjsBackend();
const runtime = backend.runtime;
if (!runtime || !String(content || "").includes("<%")) {
return null;
}
try {
runtime.compile(content, {
async: true,
client: true,
_with: true,
localsName: "locals",
});
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
export async function inspectTaskEjsRuntimeBackend(options = {}) {
return await resolveTaskEjsBackend(options);
}