diff --git a/task-ejs.js b/task-ejs.js index 4272820..cc3cc1d 100644 --- a/task-ejs.js +++ b/task-ejs.js @@ -139,10 +139,32 @@ function buildTemplateContext(templateContext = {}, hostSnapshot) { : 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: @@ -181,6 +203,17 @@ function cloneDeep(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(".") @@ -197,6 +230,60 @@ 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() @@ -310,6 +397,7 @@ function registerEntries(renderCtx, entries = []) { 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()); @@ -317,6 +405,7 @@ function registerEntries(renderCtx, entries = []) { const worldbookLookup = renderCtx.entriesByWorldbook.get(entry.worldbook); registerEntryLookup(worldbookLookup, entry.name, entry); registerEntryLookup(worldbookLookup, entry.comment, entry); + registerEntryLookup(worldbookLookup, entry.uid, entry); } } @@ -372,53 +461,151 @@ async function ensureWorldbookEntriesLoaded(renderCtx, worldbookName) { return renderCtx.entriesByWorldbook.has(normalizedWorldbook); } -async function resolveEntry(renderCtx, currentWorldbook, worldbookOrEntry, entryNameOrData) { - const explicitWorldbook = - typeof entryNameOrData === "string" - ? normalizeEntryKey(worldbookOrEntry) - : ""; - const fallbackWorldbook = normalizeEntryKey(currentWorldbook); - const identifier = normalizeEntryKey( - typeof entryNameOrData === "string" ? entryNameOrData : worldbookOrEntry, - ); - - if (!identifier) return undefined; - - const lookupInWorldbook = (worldbook) => { - if (!worldbook) return undefined; - return renderCtx.entriesByWorldbook.get(worldbook)?.get(identifier); - }; - - let resolved = - lookupInWorldbook(explicitWorldbook) || - lookupInWorldbook(fallbackWorldbook) || - renderCtx.allEntries.get(identifier); - - if (!resolved && explicitWorldbook) { - await ensureWorldbookEntriesLoaded(renderCtx, explicitWorldbook); - resolved = - lookupInWorldbook(explicitWorldbook) || - lookupInWorldbook(fallbackWorldbook) || - renderCtx.allEntries.get(identifier); +function lookupEntryInMap(lookup, identifier) { + if (!(lookup instanceof Map) || !isEntryIdentifier(identifier)) { + return undefined; } - if ( - !resolved && - typeof renderCtx.resolveIgnoredEntry === "function" - ) { - const ignoredEntry = - renderCtx.resolveIgnoredEntry(explicitWorldbook || fallbackWorldbook, identifier) || - renderCtx.resolveIgnoredEntry("", identifier); - if (ignoredEntry) { - const descriptor = ignoredEntry.sourceName || ignoredEntry.name || identifier; - recordRenderWarning( - renderCtx, - `mvu filtered world info blocked: ${ignoredEntry.worldbook ? `${ignoredEntry.worldbook}/` : ""}${descriptor}`, - ); + if (!(identifier instanceof RegExp)) { + const direct = lookup.get(normalizeEntryKey(identifier)); + if (direct) { + return direct; } } - return resolved; + 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( @@ -428,20 +615,28 @@ async function activateWorldInfoInContext( entryOrForce, maybeForce, ) { - const hasExplicitWorldbook = typeof entryOrForce === "string"; - const identifier = normalizeEntryKey(hasExplicitWorldbook ? entryOrForce : world); - const explicitWorldbook = hasExplicitWorldbook ? normalizeEntryKey(world) : ""; + 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, - explicitWorldbook || identifier, - hasExplicitWorldbook ? identifier : undefined, + parsed.explicitWorldbook, + parsed.identifier, ); if (!entry) { recordRenderWarning( renderCtx, - `activewi target not found: ${explicitWorldbook ? `${explicitWorldbook}/` : ""}${identifier}`, + `activewi target not found: ${explicitWorldbookLabel ? `${explicitWorldbookLabel}/` : ""}${identifierLabel}`, ); return null; } @@ -456,7 +651,7 @@ async function activateWorldInfoInContext( world: normalizedEntry.worldbook, comment: normalizedEntry.comment || normalizedEntry.name, content: normalizedEntry.content, - forced: typeof maybeForce === "boolean" ? maybeForce : typeof entryOrForce === "boolean", + forced: parsed.force, }; } @@ -465,12 +660,18 @@ async function getwi( currentWorldbook, worldbookOrEntry, entryNameOrData, + dataOrUndefined, ) { + const parsed = parseGetwiArgs( + worldbookOrEntry, + entryNameOrData, + dataOrUndefined, + ); const entry = await resolveEntry( renderCtx, currentWorldbook, - worldbookOrEntry, - entryNameOrData, + parsed.explicitWorldbook, + parsed.identifier, ); if (!entry) { return ""; @@ -508,6 +709,7 @@ async function getwi( renderCtx.renderStack.add(entryKey); try { finalContent = await evalTaskEjsTemplate(processed, renderCtx, { + ...mergeEjsExtraEnv(parsed.data), world_info: { comment: entry.comment || entry.name, name: entry.name, @@ -677,6 +879,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) { const hostSnapshot = resolveHostSnapshot(renderCtx?.hostSnapshot); const snapshot = hostSnapshot.snapshot; + const templateAliases = buildTemplateContext(renderCtx?.templateContext || {}, hostSnapshot); const processed = substituteTaskEjsParams(content, renderCtx?.templateContext, { hostSnapshot, }); @@ -695,6 +898,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) { 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 @@ -735,12 +939,21 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) { 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 || "", + ...templateRuntimeEnv, + 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, @@ -818,19 +1031,21 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) { get SillyTavern() { return stCtx; }, - getwi: (worldbookOrEntry, entryNameOrData) => + getwi: (worldbookOrEntry, entryNameOrData, dataOrUndefined) => getwi( renderCtx, String(context.world_info?.world || ""), worldbookOrEntry, entryNameOrData, + dataOrUndefined, ), - getWorldInfo: (worldbookOrEntry, entryNameOrData) => + 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 = {}) => diff --git a/tests/st-context-task-ejs.mjs b/tests/st-context-task-ejs.mjs index 4074a39..e861ddc 100644 --- a/tests/st-context-task-ejs.mjs +++ b/tests/st-context-task-ejs.mjs @@ -144,6 +144,8 @@ try { locals.variables.score, locals.variables.location, locals.lastUserMessage, + locals.recentMessages, + locals.persona, locals.hostSnapshot.character.worldbook, locals.stSnapshot.chat.lastUserMessage, typeof locals.execute, @@ -154,7 +156,14 @@ try { const renderCtx = createTaskEjsRenderContext([], { hostSnapshot, - templateContext: {}, + templateContext: { + user: "AliasUser", + char: "AliasAlice", + userName: "AliasUser", + charName: "AliasAlice", + recentMessages: "最近上下文", + persona: "AliasPersona", + }, }); const primaryBackend = await inspectTaskEjsRuntimeBackend({ ensureRuntime: false, @@ -169,7 +178,7 @@ try { const rendered = await evalTaskEjsTemplate("<%= 1 %>", renderCtx); assert.equal( rendered, - "Alice|User|persona-book|chat-book|7|library|最后一句|char-book|最后一句|function", + "AliasAlice|AliasUser|persona-book|chat-book|7|library|最后一句|最近上下文|AliasPersona|char-book|最后一句|function", ); assert.deepEqual(compileCalls, ["<%= 1 %>", "<%= 1 %>"]); @@ -192,7 +201,7 @@ try { assert.equal(failedBackend.isFallback, false); const passthrough = await evalTaskEjsTemplate("{{charName}}", renderCtx); - assert.equal(passthrough, "Alice"); + assert.equal(passthrough, "AliasAlice"); } finally { globalThis.SillyTavern = originalSillyTavern; globalThis.getCurrentChatId = originalGetCurrentChatId; diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs index 03a2c88..922856a 100644 --- a/tests/task-worldinfo.mjs +++ b/tests/task-worldinfo.mjs @@ -103,6 +103,25 @@ const inlineSummaryEntry = createWorldbookEntry({ order: 20, }); +const inlineDataSummaryEntry = createWorldbookEntry({ + uid: 12, + name: "数据 EJS 汇总", + comment: "数据 EJS 汇总", + content: + '数据摘要:<%= await getwi("数据模板", { clue: "蓝钥匙", mood: "紧张" }) %>', + order: 21, +}); + +const inlineDataTemplateEntry = createWorldbookEntry({ + uid: 13, + name: "数据模板", + comment: "数据模板", + content: + "线索=<%= clue %>;情绪=<%= mood %>;角色=<%= char %>;用户=<%= user %>;上下文=<%= recentMessages %>", + enabled: false, + order: 22, +}); + const extensionLiteralEntry = createWorldbookEntry({ uid: 4, name: "扩展语义正文", @@ -193,6 +212,8 @@ const worldbooksByName = { constantEntry, dynEntry, inlineSummaryEntry, + inlineDataSummaryEntry, + inlineDataTemplateEntry, extensionLiteralEntry, externalInlineEntry, mvuLazyProbeEntry, @@ -242,6 +263,16 @@ try { true, "constant world info should still resolve without trigger text", ); + assert.equal( + emptyTriggerWorldInfo.beforeEntries.some((entry) => entry.name === "数据 EJS 汇总"), + true, + "constant EJS entry should still render with empty template context defaults", + ); + assert.match(emptyTriggerWorldInfo.beforeText, /数据摘要:线索=蓝钥匙;情绪=紧张;角色=Alice;用户=User;上下文=/); + assert.equal( + emptyTriggerWorldInfo.debug.warnings.some((warning) => warning.includes("渲染失败")), + false, + ); const worldInfo = await resolveTaskWorldInfo({ templateContext: { @@ -253,19 +284,30 @@ try { assert.deepEqual( worldInfo.beforeEntries.map((entry) => entry.name), - ["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总", "MVU 懒加载探测"], + [ + "常驻设定", + "EJS 汇总", + "数据 EJS 汇总", + "扩展语义正文", + "外部书汇总", + "MVU 懒加载探测", + ], ); 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, + /数据摘要:线索=蓝钥匙;情绪=紧张;角色=Alice;用户=User;上下文=我们继续调查那条线索/, + ); assert.match(worldInfo.beforeText, /外部补充:来自 bonus-book 的补充内容。/); assert.match(worldInfo.beforeText, /MVU lazy:/); assert.match(worldInfo.beforeText, /@@generate/); assert.match(worldInfo.beforeText, /\[GENERATE:Test\]/); assert.doesNotMatch(worldInfo.beforeText, /getwi|<%=?/); assert.doesNotMatch(worldInfo.beforeText, /status_current_variable|变量更新规则|updatevariable/i); - assert.equal(worldInfo.debug.ejsInlinePullCount, 2); + assert.equal(worldInfo.debug.ejsInlinePullCount, 3); assert.equal(worldInfo.debug.ejsForcedActivationCount, 1); assert.equal(worldInfo.debug.resolvePassCount >= 2, true); assert.deepEqual(worldInfo.debug.forcedActivatedEntries.map((entry) => entry.name), [ @@ -273,7 +315,7 @@ try { ]); assert.deepEqual( worldInfo.debug.inlinePulledEntries.map((entry) => entry.name).sort(), - ["Bonus 条目", "线索条目"].sort(), + ["Bonus 条目", "数据模板", "线索条目"].sort(), ); assert.deepEqual(worldInfo.debug.lazyLoadedWorldbooks, ["bonus-book"]); assert.equal(worldInfo.debug.mvu.filteredEntryCount, 2); @@ -348,6 +390,10 @@ try { assert.match(promptBuild.systemPrompt, /这里是常驻世界设定/); assert.match(promptBuild.systemPrompt, /控制摘要:隐藏线索:Alice 正在调查/); + assert.match( + promptBuild.systemPrompt, + /数据摘要:线索=蓝钥匙;情绪=紧张;角色=Alice;用户=User;上下文=我们继续调查那条线索/, + ); assert.match(promptBuild.systemPrompt, /扩展语义只是普通文本/); assert.match(promptBuild.systemPrompt, /来自 bonus-book 的补充内容/); assert.match(promptBuild.systemPrompt, /MVU lazy:/); @@ -364,7 +410,14 @@ try { ); assert.deepEqual( promptBuild.hostInjections.before.map((entry) => entry.name), - ["常驻设定", "EJS 汇总", "扩展语义正文", "外部书汇总", "MVU 懒加载探测"], + [ + "常驻设定", + "EJS 汇总", + "数据 EJS 汇总", + "扩展语义正文", + "外部书汇总", + "MVU 懒加载探测", + ], ); assert.deepEqual( promptBuild.hostInjections.after.map((entry) => entry.name), @@ -378,6 +431,7 @@ try { assert.deepEqual(promptBuild.hostInjectionPlan.before[0].entryNames, [ "常驻设定", "EJS 汇总", + "数据 EJS 汇总", "扩展语义正文", "外部书汇总", "MVU 懒加载探测",