diff --git a/prompt-profiles.js b/prompt-profiles.js index 2afd69c..f0c2832 100644 --- a/prompt-profiles.js +++ b/prompt-profiles.js @@ -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", diff --git a/task-ejs.js b/task-ejs.js index 5935794..d57195d 100644 --- a/task-ejs.js +++ b/task-ejs.js @@ -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; } } diff --git a/task-worldinfo.js b/task-worldinfo.js index c69a9e7..3f4ab84 100644 --- a/task-worldinfo.js +++ b/task-worldinfo.js @@ -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), ); diff --git a/tests/st-context-task-ejs.mjs b/tests/st-context-task-ejs.mjs index 8c26d99..4074a39 100644 --- a/tests/st-context-task-ejs.mjs +++ b/tests/st-context-task-ejs.mjs @@ -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"); diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs index d1caf25..1ebde76 100644 --- a/tests/task-worldinfo.mjs +++ b/tests/task-worldinfo.mjs @@ -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: {