// ST-BME: 任务级 EJS / 世界书渲染引擎 // 仅用于世界书条目渲染,不开放给用户自定义 prompt 块。 import { getSTContextSnapshot } from "./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); }