From d31c0325d31961b53a573d686159d675f7883f2a Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Thu, 26 Mar 2026 13:57:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E4=B8=96=E7=95=8C=E4=B9=A6?= =?UTF-8?q?=E5=BC=95=E6=93=8E=E7=A7=BB=E6=A4=8D=20+=20EJS=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 task-worldinfo.js: 从 EW 移植世界书激活/分桶引擎 - 新增 task-ejs.js: 从 EW 移植 EJS 模板渲染引擎 - 新增 vendor/ejs.js: EJS runtime vendor - prompt-builder.js: 改为异步, 接入 worldInfoBefore/After/atDepth - prompt-profiles.js: 新增内置块 charDescription/userPersona/worldInfoBefore/After - 更新 extractor/retriever/compressor/consolidator 接入新 builder - st-context.js: 扩展 ST 上下文字段兜底 - 新增 tests/task-worldinfo.mjs: 世界书引擎测试 --- compressor.js | 7 +- consolidator.js | 7 +- extractor.js | 21 +- prompt-builder.js | 273 ++++-- prompt-profiles.js | 24 + retriever.js | 7 +- st-context.js | 2 + task-ejs.js | 842 +++++++++++++++++ task-worldinfo.js | 870 +++++++++++++++++ tests/retrieval-config.mjs | 3 + tests/task-worldinfo.mjs | 207 +++++ vendor/ejs.js | 1793 ++++++++++++++++++++++++++++++++++++ 12 files changed, 3963 insertions(+), 93 deletions(-) create mode 100644 task-ejs.js create mode 100644 task-worldinfo.js create mode 100644 tests/task-worldinfo.mjs create mode 100644 vendor/ejs.js diff --git a/compressor.js b/compressor.js index ad0f418..077b362 100644 --- a/compressor.js +++ b/compressor.js @@ -229,7 +229,7 @@ async function summarizeBatch( const instruction = typeDef.compression.instruction || "将以下节点压缩总结为一条精炼记录。"; - const compressPromptBuild = buildTaskPrompt(settings, "compress", { + const compressPromptBuild = await buildTaskPrompt(settings, "compress", { taskName: "compress", nodeContent: nodeDescriptions, candidateNodes: nodeDescriptions, @@ -265,7 +265,10 @@ async function summarizeBatch( maxRetries: 1, signal, taskType: "compress", - additionalMessages: compressPromptBuild.customMessages || [], + additionalMessages: [ + ...(compressPromptBuild.customMessages || []), + ...(compressPromptBuild.additionalMessages || []), + ], }); } diff --git a/consolidator.js b/consolidator.js index d5beb3f..c9b632a 100644 --- a/consolidator.js +++ b/consolidator.js @@ -294,7 +294,7 @@ export async function consolidateMemories({ const userPrompt = userPromptSections.join("\n\n"); let decision; - const consolidationPromptBuild = buildTaskPrompt(settings, "consolidation", { + const consolidationPromptBuild = await buildTaskPrompt(settings, "consolidation", { taskName: "consolidation", candidateNodes: userPrompt, candidateText: userPrompt, @@ -315,7 +315,10 @@ export async function consolidateMemories({ maxRetries: 1, signal, taskType: "consolidation", - additionalMessages: consolidationPromptBuild.customMessages || [], + additionalMessages: [ + ...(consolidationPromptBuild.customMessages || []), + ...(consolidationPromptBuild.additionalMessages || []), + ], }); } catch (e) { if (isAbortError(e)) throw e; diff --git a/extractor.js b/extractor.js index 0716d95..92d6e93 100644 --- a/extractor.js +++ b/extractor.js @@ -109,7 +109,7 @@ export async function extractMemories({ ? `${messages[0]?.seq ?? "?"} ~ ${messages[messages.length - 1]?.seq ?? "?"}` : ""; - const promptBuild = buildTaskPrompt(settings, "extract", { + const promptBuild = await buildTaskPrompt(settings, "extract", { taskName: "extract", schema: schemaDescription, schemaDescription, @@ -152,7 +152,10 @@ export async function extractMemories({ maxRetries: 2, signal, taskType: "extract", - additionalMessages: promptBuild.customMessages || [], + additionalMessages: [ + ...(promptBuild.customMessages || []), + ...(promptBuild.additionalMessages || []), + ], }); throwIfAborted(signal); @@ -629,7 +632,7 @@ export async function generateSynopsis({ .map((n) => `${n.fields.title}: ${n.fields.status || "active"}`) .join("; "); - const synopsisPromptBuild = buildTaskPrompt(settings, "synopsis", { + const synopsisPromptBuild = await buildTaskPrompt(settings, "synopsis", { taskName: "synopsis", eventSummary: eventSummaries, characterSummary: charSummary || "(无)", @@ -665,7 +668,10 @@ export async function generateSynopsis({ maxRetries: 1, signal, taskType: "synopsis", - additionalMessages: synopsisPromptBuild.customMessages || [], + additionalMessages: [ + ...(synopsisPromptBuild.customMessages || []), + ...(synopsisPromptBuild.additionalMessages || []), + ], }); if (!result?.summary) return; @@ -742,7 +748,7 @@ export async function generateReflection({ .map((e) => `${e.fromId} -> ${e.toId} (${e.relation})`) .join("\n"); - const reflectionPromptBuild = buildTaskPrompt(settings, "reflection", { + const reflectionPromptBuild = await buildTaskPrompt(settings, "reflection", { taskName: "reflection", eventSummary, characterSummary: characterSummary || "(无)", @@ -785,7 +791,10 @@ export async function generateReflection({ maxRetries: 1, signal, taskType: "reflection", - additionalMessages: reflectionPromptBuild.customMessages || [], + additionalMessages: [ + ...(reflectionPromptBuild.customMessages || []), + ...(reflectionPromptBuild.additionalMessages || []), + ], }); if (!result?.insight) return null; diff --git a/prompt-builder.js b/prompt-builder.js index b8c4650..9ce5326 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -1,87 +1,18 @@ -// ST-BME: Prompt Builder(Phase 1 兼容骨架) +// ST-BME: Prompt Builder +// 统一负责任务预设块排序、变量渲染,以及世界书/EJS 上下文接入。 import { getActiveTaskProfile, getLegacyPromptForTask } from "./prompt-profiles.js"; +import { resolveTaskWorldInfo } from "./task-worldinfo.js"; -export function buildTaskPrompt(settings = {}, taskType, context = {}) { - const profile = getActiveTaskProfile(settings, taskType); - const legacyPrompt = getLegacyPromptForTask(settings, taskType); - const rawBlocks = Array.isArray(profile?.blocks) ? profile.blocks : []; - const blocks = rawBlocks - .map((block, index) => ({ ...block, _orderIndex: index })) - .sort((a, b) => { - const orderA = Number.isFinite(Number(a.order)) - ? Number(a.order) - : a._orderIndex; - const orderB = Number.isFinite(Number(b.order)) - ? Number(b.order) - : b._orderIndex; - return orderA - orderB; - }); - - let systemPrompt = ""; - const customMessages = []; - - for (const block of blocks) { - if (!block || block.enabled === false) continue; - const role = normalizeRole(block.role); - let content = ""; - - if (block.type === "legacyPrompt") { - content = legacyPrompt || block.content || ""; - } else if (block.type === "builtin") { - if (block.content) { - content = interpolateVariables(block.content, context); - } else if (block.sourceKey) { - const value = getByPath(context, block.sourceKey); - if (value != null) { - content = - typeof value === "string" ? value : JSON.stringify(value, null, 2); - } - } - } else if (block.type === "custom") { - content = interpolateVariables(block.content || "", context); - } - - if (!content) continue; - const mode = normalizeInjectionMode(block.injectionMode); - - if (role === "system") { - if (!systemPrompt) { - systemPrompt = content; - } else if (mode === "prepend") { - systemPrompt = `${content}\n\n${systemPrompt}`; - } else { - systemPrompt = `${systemPrompt}\n\n${content}`; - } - } else { - if (mode === "prepend") { - customMessages.unshift({ role, content }); - } else { - customMessages.push({ role, content }); - } - } - } - - return { - profile, - systemPrompt, - customMessages, - debug: { - taskType, - profileId: profile?.id || "", - profileName: profile?.name || "", - usedLegacyPrompt: Boolean(legacyPrompt), - blockCount: blocks.length, - }, - }; -} - -export function interpolateVariables(template, context = {}) { - return String(template || "").replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => { - const value = getByPath(context, key); - return value == null ? "" : String(value); - }); -} +const WORLD_INFO_VARIABLE_KEYS = [ + "worldInfoBefore", + "worldInfoAfter", + "worldInfoBeforeEntries", + "worldInfoAfterEntries", + "worldInfoAtDepthEntries", + "activatedWorldInfoNames", + "taskAdditionalMessages", +]; function getByPath(target, path) { return String(path || "") @@ -105,3 +36,183 @@ function normalizeInjectionMode(mode) { } return "append"; } + +function stringifyInterpolatedValue(value) { + if (value == null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function buildEmptyWorldInfoContext() { + return { + worldInfoBefore: "", + worldInfoAfter: "", + worldInfoBeforeEntries: [], + worldInfoAfterEntries: [], + worldInfoAtDepthEntries: [], + activatedWorldInfoNames: [], + taskAdditionalMessages: [], + }; +} + +function profileRequiresWorldInfo(profile) { + const blocks = Array.isArray(profile?.blocks) ? profile.blocks : []; + for (const block of blocks) { + if (!block || block.enabled === false) continue; + if ( + block.type === "builtin" && + ["worldInfoBefore", "worldInfoAfter"].includes(String(block.sourceKey || "")) + ) { + return true; + } + + const rawContent = String(block.content || ""); + if (!rawContent.includes("{{")) continue; + if ( + WORLD_INFO_VARIABLE_KEYS.some((key) => + rawContent.includes(`{{${key}}}`) || + rawContent.includes(`{{ ${key} }}`), + ) + ) { + return true; + } + } + return false; +} + +function extractWorldInfoChatMessages(context = {}) { + if (Array.isArray(context.chatMessages)) { + return context.chatMessages; + } + return []; +} + +export async function buildTaskPrompt(settings = {}, taskType, context = {}) { + const profile = getActiveTaskProfile(settings, taskType); + const legacyPrompt = getLegacyPromptForTask(settings, taskType); + const rawBlocks = Array.isArray(profile?.blocks) ? profile.blocks : []; + const blocks = rawBlocks + .map((block, index) => ({ ...block, _orderIndex: index })) + .sort((a, b) => { + const orderA = Number.isFinite(Number(a.order)) + ? Number(a.order) + : a._orderIndex; + const orderB = Number.isFinite(Number(b.order)) + ? Number(b.order) + : b._orderIndex; + return orderA - orderB; + }); + + const worldInfoRequested = profileRequiresWorldInfo(profile); + const emptyWorldInfo = buildEmptyWorldInfoContext(); + let resolvedWorldInfo = emptyWorldInfo; + + if (worldInfoRequested) { + const worldInfo = await resolveTaskWorldInfo({ + settings, + chatMessages: extractWorldInfoChatMessages(context), + userMessage: String(context.userMessage || ""), + templateContext: context, + }); + resolvedWorldInfo = { + worldInfoBefore: worldInfo.beforeText || "", + worldInfoAfter: worldInfo.afterText || "", + worldInfoBeforeEntries: worldInfo.beforeEntries || [], + worldInfoAfterEntries: worldInfo.afterEntries || [], + worldInfoAtDepthEntries: worldInfo.atDepthEntries || [], + activatedWorldInfoNames: worldInfo.activatedEntryNames || [], + taskAdditionalMessages: worldInfo.additionalMessages || [], + }; + } + + const resolvedContext = { + ...context, + ...emptyWorldInfo, + ...resolvedWorldInfo, + }; + + let systemPrompt = ""; + const customMessages = []; + + for (const block of blocks) { + if (!block || block.enabled === false) continue; + + const role = normalizeRole(block.role); + let content = ""; + + if (block.type === "legacyPrompt") { + content = legacyPrompt || block.content || ""; + } else if (block.type === "builtin") { + if (block.content) { + content = interpolateVariables(block.content, resolvedContext); + } else if (block.sourceKey) { + content = stringifyInterpolatedValue( + getByPath(resolvedContext, block.sourceKey), + ); + } + } else if (block.type === "custom") { + content = interpolateVariables(block.content || "", resolvedContext); + } + + if (!String(content || "").trim()) continue; + + const mode = normalizeInjectionMode(block.injectionMode); + if (role === "system") { + if (!systemPrompt) { + systemPrompt = content; + } else if (mode === "prepend") { + systemPrompt = `${content}\n\n${systemPrompt}`; + } else { + systemPrompt = `${systemPrompt}\n\n${content}`; + } + continue; + } + + if (mode === "prepend") { + customMessages.unshift({ role, content }); + } else { + customMessages.push({ role, content }); + } + } + + return { + profile, + systemPrompt, + customMessages, + additionalMessages: resolvedContext.taskAdditionalMessages || [], + worldInfo: { + beforeText: resolvedContext.worldInfoBefore, + afterText: resolvedContext.worldInfoAfter, + beforeEntries: resolvedContext.worldInfoBeforeEntries, + afterEntries: resolvedContext.worldInfoAfterEntries, + atDepthEntries: resolvedContext.worldInfoAtDepthEntries, + activatedEntryNames: resolvedContext.activatedWorldInfoNames, + }, + debug: { + taskType, + profileId: profile?.id || "", + profileName: profile?.name || "", + usedLegacyPrompt: Boolean(legacyPrompt), + blockCount: blocks.length, + worldInfoRequested, + worldInfoBeforeCount: resolvedContext.worldInfoBeforeEntries.length, + worldInfoAfterCount: resolvedContext.worldInfoAfterEntries.length, + worldInfoAtDepthCount: resolvedContext.worldInfoAtDepthEntries.length, + additionalMessageCount: resolvedContext.taskAdditionalMessages.length, + }, + }; +} + +export function interpolateVariables(template, context = {}) { + return String(template || "").replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => { + return stringifyInterpolatedValue(getByPath(context, key)); + }); +} diff --git a/prompt-profiles.js b/prompt-profiles.js index 1aa525d..8794429 100644 --- a/prompt-profiles.js +++ b/prompt-profiles.js @@ -49,6 +49,30 @@ const BUILTIN_BLOCK_DEFINITIONS = [ role: "system", description: "注入任务级系统指令。可用于添加通用约束或全局规则。提示:可创建多个自定义块并设置不同角色(system/user/assistant)来实现多轮对话式 prompt 编排,利用 few-shot 引导 LLM 遵守格式。可用变量:{{charName}}、{{userName}}、{{charDescription}}、{{userPersona}}、{{currentTime}}。", }, + { + sourceKey: "charDescription", + name: "角色描述", + role: "system", + description: "注入当前角色卡的描述正文。适合需要把角色设定直接并入任务 prompt 的预设。", + }, + { + sourceKey: "userPersona", + name: "用户设定", + role: "system", + description: "注入当前用户 Persona / 用户设定。适合让任务在生成时参考玩家长期设定。", + }, + { + sourceKey: "worldInfoBefore", + name: "世界书前块", + role: "system", + description: "注入 EW 同款世界书引擎解析后的 before 桶内容,支持角色主/附加世界书、用户设定世界书、聊天世界书,以及世界书条目中的 EJS / getwi。", + }, + { + sourceKey: "worldInfoAfter", + name: "世界书后块", + role: "system", + description: "注入 EW 同款世界书引擎解析后的 after 桶内容。atDepth 条目不会出现在这里,而是自动并入额外消息链路。", + }, { sourceKey: "outputRules", name: "输出规则", diff --git a/retriever.js b/retriever.js index c4c41a1..243af9f 100644 --- a/retriever.js +++ b/retriever.js @@ -419,7 +419,7 @@ async function llmRecall( }) .join("\n"); - const recallPromptBuild = buildTaskPrompt(settings, "recall", { + const recallPromptBuild = await buildTaskPrompt(settings, "recall", { taskName: "recall", recentMessages: contextStr || "(无)", userMessage, @@ -461,7 +461,10 @@ async function llmRecall( maxRetries: 1, signal, taskType: "recall", - additionalMessages: recallPromptBuild.customMessages || [], + additionalMessages: [ + ...(recallPromptBuild.customMessages || []), + ...(recallPromptBuild.additionalMessages || []), + ], }); if (result?.selected_ids && Array.isArray(result.selected_ids)) { diff --git a/st-context.js b/st-context.js index bebd939..b947d99 100644 --- a/st-context.js +++ b/st-context.js @@ -22,7 +22,9 @@ export function getSTContextForPrompt() { return { userPersona: ctx.powerUserSettings?.persona_description || + ctx.extensionSettings?.persona_description || ctx.name1_description || + ctx.persona || "", charDescription: char?.description || diff --git a/task-ejs.js b/task-ejs.js new file mode 100644 index 0000000..12144fa --- /dev/null +++ b/task-ejs.js @@ -0,0 +1,842 @@ +// ST-BME: 任务级 EJS / 世界书渲染引擎 +// 仅用于世界书条目渲染,不开放给用户自定义 prompt 块。 + +const DEFAULT_MAX_RECURSION = 10; +let ejsRuntimePromise = null; + +const FALLBACK_LODASH = { + get: getByPath, + set: setByPath, + unset: unsetByPath, + cloneDeep: cloneDeep, + escapeRegExp: 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; +} + +async function ensureEjsRuntime() { + if (globalThis.ejs) { + return globalThis.ejs; + } + if (ejsRuntimePromise) { + return await ejsRuntimePromise; + } + + ejsRuntimePromise = (async () => { + const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); + const previousWindow = globalThis.window; + + if (!hadWindow) { + globalThis.window = globalThis; + } + + try { + await import("./vendor/ejs.js"); + } catch (error) { + console.warn("[ST-BME] task-ejs 加载 vendor/ejs.js 失败:", error); + } finally { + if (!hadWindow) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + } + + return globalThis.ejs || null; + })(); + + return await ejsRuntimePromise; +} + +function getStContext() { + try { + return globalThis.SillyTavern?.getContext?.() || {}; + } catch { + return {}; + } +} + +function getStChat() { + try { + const ctx = getStContext(); + return Array.isArray(ctx.chat) ? ctx.chat : []; + } catch { + return []; + } +} + +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 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 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, "\\$&"); +} + +function normalizeEntryKey(value) { + return String(value ?? "").trim(); +} + +function normalizeIdentifier(value) { + return String(value || "").trim().toLowerCase(); +} + +function processChatMessage(message) { + return String(message?.mes ?? message?.message ?? message?.content ?? ""); +} + +function buildTemplateContext(templateContext = {}) { + const ctx = getStContext(); + const chat = getStChat(); + const lastUserMessage = + typeof templateContext.user_input === "string" + ? templateContext.user_input + : chat.findLast?.((message) => message?.is_user)?.mes || + [...chat].reverse().find((message) => message?.is_user)?.mes || + ""; + + return { + user: ctx.name1 || "", + char: ctx.name2 || "", + userName: ctx.name1 || "", + charName: ctx.name2 || "", + persona: + ctx.powerUserSettings?.persona_description || + ctx.extensionSettings?.persona_description || + ctx.name1_description || + ctx.persona || + "", + lastUserMessage, + last_user_message: lastUserMessage, + userInput: lastUserMessage, + user_input: lastUserMessage, + original: "", + input: "", + lastMessage: "", + lastMessageId: "", + newline: "\n", + trim: "", + ...templateContext, + }; +} + +export function substituteTaskEjsParams(text, templateContext = {}) { + if (!text || !String(text).includes("{{")) { + return String(text || ""); + } + + const context = buildTemplateContext(templateContext); + 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 createVariableState() { + const ctx = getStContext(); + const chat = getStChat(); + 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(ctx.extensionSettings?.variables?.global || {}); + const localVars = cloneDeep(ctx.chatMetadata?.variables || {}); + + return { + globalVars, + localVars, + messageVars, + cacheVars: { + ...globalVars, + ...localVars, + ...messageVars, + }, + }; +} + +function rebuildVariableCache(state) { + state.cacheVars = { + ...state.globalVars, + ...state.localVars, + ...state.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 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 registerEntryLookup(lookup, key, entry) { + const normalizedKey = normalizeEntryKey(key); + if (!normalizedKey || lookup.has(normalizedKey)) return; + lookup.set(normalizedKey, entry); +} + +function activationKey(entry) { + return `${entry.worldbook}::${entry.comment || entry.name}`; +} + +function findEntry(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); + }; + + return ( + lookupInWorldbook(explicitWorldbook) || + lookupInWorldbook(fallbackWorldbook) || + renderCtx.allEntries.get(identifier) + ); +} + +async function activateWorldInfoInContext( + renderCtx, + currentWorldbook, + world, + 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; + + if (!entry) { + return null; + } + + const normalizedEntry = force + ? { + ...entry, + content: String(entry.content || "").replaceAll("@@dont_activate", ""), + } + : entry; + + renderCtx.activatedEntries.set(activationKey(normalizedEntry), normalizedEntry); + return { + world: normalizedEntry.worldbook, + comment: normalizedEntry.comment || normalizedEntry.name, + content: normalizedEntry.content, + }; +} + +async function getwi( + renderCtx, + currentWorldbook, + worldbookOrEntry, + entryNameOrData, +) { + const entry = findEntry( + renderCtx, + currentWorldbook, + worldbookOrEntry, + entryNameOrData, + ); + if (!entry) { + return ""; + } + + const entryKey = activationKey(entry); + if (renderCtx.renderStack.has(entryKey)) { + console.warn( + `[ST-BME] task-ejs 检测到循环 getwi: ${entry.comment || entry.name}`, + ); + return substituteTaskEjsParams(entry.content, renderCtx.templateContext); + } + + if (renderCtx.renderStack.size >= renderCtx.maxRecursion) { + console.warn( + `[ST-BME] task-ejs 超过最大递归深度: ${renderCtx.maxRecursion}`, + ); + return substituteTaskEjsParams(entry.content, renderCtx.templateContext); + } + + const processed = substituteTaskEjsParams( + entry.content, + renderCtx.templateContext, + ); + let finalContent = processed; + + if (processed.includes("<%")) { + renderCtx.renderStack.add(entryKey); + try { + finalContent = await evalTaskEjsTemplate(processed, renderCtx, { + world_info: { + comment: entry.comment || entry.name, + name: entry.name, + world: entry.worldbook, + }, + }); + } finally { + renderCtx.renderStack.delete(entryKey); + } + } + + if (!renderCtx.pulledEntries.has(entryKey)) { + renderCtx.pulledEntries.set(entryKey, { + name: entry.name, + comment: entry.comment, + content: finalContent, + worldbook: entry.worldbook, + }); + } + + return finalContent; +} + +function getChatMessageCompat(index, role) { + const chat = getStChat() + .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(startOrCount = getStChat().length, endOrRole, role) { + const allMessages = getStChat().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(pattern) { + const regex = + typeof pattern === "string" ? new RegExp(pattern, "i") : pattern; + return getStChat().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; +} + +export function createTaskEjsRenderContext(entries = [], options = {}) { + 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, + renderStack: new Set(), + maxRecursion: + Number.isFinite(Number(options.maxRecursion)) && + Number(options.maxRecursion) > 0 + ? Number(options.maxRecursion) + : DEFAULT_MAX_RECURSION, + variableState: createVariableState(), + activatedEntries: new Map(), + pulledEntries: new Map(), + templateContext: { + ...(options.templateContext || {}), + }, + }; +} + +export async function evalTaskEjsTemplate( + content, + renderCtx, + extraEnv = {}, +) { + const runtime = await ensureEjsRuntime(); + if (!runtime) { + console.warn("[ST-BME] task-ejs 未找到全局 ejs 运行时,跳过渲染"); + return substituteTaskEjsParams(content, renderCtx?.templateContext); + } + + const processed = substituteTaskEjsParams(content, renderCtx?.templateContext); + if (!processed.includes("<%")) { + return processed; + } + + const stCtx = getStContext(); + const chat = getStChat(); + const utilityLib = getUtilityLib(); + const workflowUserInput = + typeof renderCtx?.templateContext?.user_input === "string" + ? renderCtx.templateContext.user_input + : chat.findLast?.((message) => message?.is_user)?.mes || + [...chat].reverse().find((message) => message?.is_user)?.mes || + ""; + + const context = { + _: utilityLib, + console, + userName: stCtx.name1 || "", + charName: stCtx.name2 || "", + assistantName: stCtx.name2 || "", + characterId: stCtx.characterId, + get chatId() { + return stCtx.chatId || globalThis.getCurrentChatId?.() || ""; + }, + get variables() { + return renderCtx.variableState.cacheVars; + }, + get lastUserMessageId() { + return chat.findLastIndex + ? chat.findLastIndex((message) => message?.is_user) + : [...chat] + .reverse() + .findIndex((message) => message?.is_user); + }, + 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() { + return chat.findLastIndex + ? chat.findLastIndex((message) => !message?.is_user && !message?.is_system) + : [...chat] + .reverse() + .findIndex((message) => !message?.is_user && !message?.is_system); + }, + 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() { + try { + const characters = stCtx.characters; + const charId = stCtx.characterId; + return characters?.[charId]?.data?.extensions?.world || ""; + } catch { + return ""; + } + }, + get userLoreBook() { + return ( + stCtx.extensionSettings?.persona_description_lorebook || + stCtx.powerUserSettings?.persona_description_lorebook || + stCtx.power_user?.persona_description_lorebook || + "" + ); + }, + get chatLoreBook() { + return stCtx.chatMetadata?.world || ""; + }, + get charAvatar() { + try { + const characters = stCtx.characters; + const charId = stCtx.characterId; + return characters?.[charId]?.avatar + ? `/characters/${characters[charId].avatar}` + : ""; + } catch { + return ""; + } + }, + userAvatar: "", + groups: stCtx.groups || [], + groupId: stCtx.selectedGroupId ?? null, + get model() { + return stCtx.onlineStatus || ""; + }, + get SillyTavern() { + return getStContext(); + }, + getwi: (worldbookOrEntry, entryNameOrData) => + getwi( + renderCtx, + String(context.world_info?.world || ""), + worldbookOrEntry, + entryNameOrData, + ), + getWorldInfo: (worldbookOrEntry, entryNameOrData) => + getwi( + renderCtx, + String(context.world_info?.world || ""), + worldbookOrEntry, + entryNameOrData, + ), + 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", + }), + 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), + getchr: () => { + try { + const characters = stCtx.characters; + const charId = stCtx.characterId; + const character = characters?.[charId]; + return ( + character?.description || + character?.data?.description || + "" + ); + } catch { + return ""; + } + }, + 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, + content: entry.content, + world: entry.worldbook, + })), + getWorldInfoActivatedData: async () => + [...renderCtx.activatedEntries.values()].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, + })), + selectActivatedEntries: () => [], + activateWorldInfoByKeywords: async () => [], + 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, + ), + activateRegex: () => undefined, + injectPrompt: () => undefined, + getPromptsInjected: () => [], + hasPromptsInjected: () => false, + jsonPatch: () => undefined, + parseJSON: (raw) => { + try { + return JSON.parse(raw); + } catch { + return null; + } + }, + print: (...parts) => + parts + .filter((part) => part !== undefined && part !== null) + .join(""), + ...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) { + console.warn("[ST-BME] task-ejs 渲染失败,回退原文本:", error); + return processed; + } +} + +export async function renderTaskEjsContent(content, templateContext = {}) { + const processed = substituteTaskEjsParams(content, templateContext); + if (!processed.includes("<%")) { + return processed; + } + + const renderCtx = createTaskEjsRenderContext([], { templateContext }); + return await evalTaskEjsTemplate(processed, renderCtx); +} + +export function checkTaskEjsSyntax(content) { + const runtime = getEjsRuntime(); + 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); + } +} diff --git a/task-worldinfo.js b/task-worldinfo.js new file mode 100644 index 0000000..2bf9a86 --- /dev/null +++ b/task-worldinfo.js @@ -0,0 +1,870 @@ +// ST-BME: 任务级世界书激活引擎 +// 复刻 Evolution_World 的世界书来源、激活与 EJS 渲染主逻辑, +// 但只接入 ST-BME 的任务预设系统,不引入完整工作流调度层。 + +import { + createTaskEjsRenderContext, + evalTaskEjsTemplate, + substituteTaskEjsParams, +} from "./task-ejs.js"; + +const WI_POSITION = { + before: 0, + after: 1, + EMTop: 2, + EMBottom: 3, + ANTop: 4, + ANBottom: 5, + atDepth: 6, +}; + +const WI_LOGIC = { + AND_ANY: 0, + NOT_ALL: 1, + NOT_ANY: 2, + AND_ALL: 3, +}; + +const DEPTH_MAPPING = { + [WI_POSITION.before]: 4, + [WI_POSITION.after]: 3, + [WI_POSITION.EMTop]: 2, + [WI_POSITION.EMBottom]: 1, + [WI_POSITION.ANTop]: 1, + [WI_POSITION.ANBottom]: -1, +}; + +const DEFAULT_DEPTH = 4; +const DEFAULT_CONTROLLER_ENTRY_PREFIX = "EW/Controller/"; +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]", +]; + +function getStContext() { + try { + return globalThis.SillyTavern?.getContext?.() || {}; + } catch { + return {}; + } +} + +function getWorldbookApi(name) { + const fn = globalThis[name]; + return typeof fn === "function" ? fn : null; +} + +function normalizeKey(value) { + return String(value ?? "").trim(); +} + +function escapeRegExp(value) { + return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function uniq(values = []) { + return [...new Set((Array.isArray(values) ? values : []).filter(Boolean))]; +} + +function groupBy(items = [], getKey) { + const grouped = {}; + for (const item of items) { + const key = String(getKey(item) ?? ""); + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(item); + } + return grouped; +} + +function sum(values = []) { + return (Array.isArray(values) ? values : []).reduce( + (total, value) => total + (Number(value) || 0), + 0, + ); +} + +function simpleHash(input = "") { + let hash = 2166136261; + const text = String(input || ""); + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `h${(hash >>> 0).toString(16).padStart(8, "0")}`; +} + +function parseDecorators(content = "") { + const decorators = []; + const cleanLines = []; + + 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); + } + } + + return { + decorators, + cleanContent: cleanLines.join("\n").trim(), + }; +} + +function isSpecialEntryByComment(comment = "") { + return SPECIAL_NAME_MARKERS.some((marker) => String(comment).includes(marker)); +} + +function normalizeEntry(raw = {}, worldbookName = "") { + const { decorators, cleanContent } = parseDecorators(raw.content || ""); + + const positionType = raw.position?.type ?? "at_depth"; + let position = WI_POSITION.atDepth; + let role = raw.position?.role ?? "system"; + + if ( + positionType === "before_char" || + positionType === "before" || + positionType === "before_character_definition" + ) { + position = WI_POSITION.before; + } else if ( + positionType === "after_char" || + positionType === "after" || + positionType === "after_character_definition" + ) { + position = WI_POSITION.after; + } else if ( + positionType === "em_top" || + positionType === "before_example_messages" + ) { + position = WI_POSITION.EMTop; + } else if ( + positionType === "em_bottom" || + positionType === "after_example_messages" + ) { + position = WI_POSITION.EMBottom; + } else if ( + positionType === "an_top" || + positionType === "before_author_note" + ) { + position = WI_POSITION.ANTop; + } else if ( + positionType === "an_bottom" || + positionType === "after_author_note" + ) { + position = WI_POSITION.ANBottom; + } else if ( + positionType === "at_depth_as_assistant" + ) { + position = WI_POSITION.atDepth; + role = "assistant"; + } else if (positionType === "at_depth_as_user") { + position = WI_POSITION.atDepth; + role = "user"; + } else if (typeof raw.extensions?.position === "number") { + position = raw.extensions.position; + } + + let enabled; + if (typeof raw.disable === "boolean") { + enabled = !raw.disable; + } else if (typeof raw.enabled === "boolean") { + enabled = raw.enabled; + } else { + enabled = true; + } + + let selectiveLogic = WI_LOGIC.AND_ANY; + const logic = raw.strategy?.keys_secondary?.logic; + if (logic === "not_all") selectiveLogic = WI_LOGIC.NOT_ALL; + if (logic === "not_any") selectiveLogic = WI_LOGIC.NOT_ANY; + if (logic === "and_all") selectiveLogic = WI_LOGIC.AND_ALL; + + return { + uid: Number(raw.uid) || 0, + name: normalizeKey(raw.name), + comment: normalizeKey(raw.comment), + content: String(raw.content || ""), + cleanContent, + decorators, + enabled, + worldbook: normalizeKey(worldbookName), + constant: raw.strategy?.type === "constant", + selective: raw.strategy?.type === "selective", + keys: Array.isArray(raw.strategy?.keys) ? raw.strategy.keys : [], + keysSecondary: Array.isArray(raw.strategy?.keys_secondary?.keys) + ? raw.strategy.keys_secondary.keys + : [], + selectiveLogic, + useProbability: + (raw.extensions?.useProbability === true || + raw.probability !== undefined) && + Number(raw.probability ?? 100) < 100, + probability: Number(raw.probability ?? 100), + caseSensitive: Boolean(raw.extra?.caseSensitive), + matchWholeWords: Boolean(raw.extra?.matchWholeWords), + group: normalizeKey(raw.extra?.group), + groupOverride: Boolean(raw.extra?.groupOverride), + groupWeight: Number(raw.extra?.groupWeight ?? 100), + useGroupScoring: Boolean(raw.extra?.useGroupScoring), + position, + depth: Number(raw.position?.depth ?? 0), + order: Number(raw.position?.order ?? 100), + role, + }; +} + +function parseRegexFromString(input = "") { + const match = /^\/(.*?)\/([gimsuy]*)$/.exec(String(input || "").trim()); + if (!match) return null; + try { + return new RegExp(match[1], match[2]); + } catch { + return null; + } +} + +function deterministicPercent(seed) { + const hashed = simpleHash(seed).replace(/^h/, ""); + const parsed = Number.parseInt(hashed.slice(0, 8), 16); + if (!Number.isFinite(parsed)) return 100; + return (parsed % 100) + 1; +} + +function deterministicWeightedIndex(weights = [], seed = "") { + const normalized = weights.map((weight) => + Math.max(0, Math.trunc(Number(weight) || 0)), + ); + const totalWeight = sum(normalized); + if (totalWeight <= 0) return -1; + + const hashed = simpleHash(seed).replace(/^h/, ""); + let roll = (Number.parseInt(hashed.slice(0, 8), 16) % totalWeight) + 1; + for (let index = 0; index < normalized.length; index += 1) { + roll -= normalized[index]; + if (roll <= 0) { + return index; + } + } + return normalized.length - 1; +} + +function matchKeys(haystack = "", needle = "", entry) { + const regex = parseRegexFromString(String(needle || "").trim()); + if (regex) { + return regex.test(haystack); + } + + const source = entry.caseSensitive ? haystack : haystack.toLowerCase(); + const target = entry.caseSensitive + ? String(needle || "").trim() + : String(needle || "").trim().toLowerCase(); + + if (!target) return false; + + if (entry.matchWholeWords) { + const words = target.split(/\s+/); + if (words.length > 1) { + return source.includes(target); + } + return new RegExp(`(?:^|\\W)(${escapeRegExp(target)})(?:$|\\W)`).test( + source, + ); + } + + return source.includes(target); +} + +function getScore(trigger = "", entry) { + let primaryScore = 0; + let secondaryScore = 0; + + for (const key of entry.keys) { + if (matchKeys(trigger, key, entry)) primaryScore += 1; + } + for (const key of entry.keysSecondary) { + if (matchKeys(trigger, key, entry)) secondaryScore += 1; + } + + if (entry.keys.length === 0) return 0; + + if (entry.keysSecondary.length > 0) { + if (entry.selectiveLogic === WI_LOGIC.AND_ANY) { + return primaryScore + secondaryScore; + } + if (entry.selectiveLogic === WI_LOGIC.AND_ALL) { + return secondaryScore === entry.keysSecondary.length + ? primaryScore + secondaryScore + : primaryScore; + } + } + + return primaryScore; +} + +function calcDepth(entry, maxDepth) { + const offset = DEPTH_MAPPING[entry.position]; + if (offset == null) { + return entry.depth ?? DEFAULT_DEPTH; + } + return offset + maxDepth; +} + +function sortEntries(a, b) { + const maxDepth = Math.max(a.depth ?? 0, b.depth ?? 0, DEFAULT_DEPTH); + return ( + calcDepth(b, maxDepth) - calcDepth(a, maxDepth) || + (a.order ?? 100) - (b.order ?? 100) || + (b.uid ?? 0) - (a.uid ?? 0) + ); +} + +function selectActivatedEntries(entries = [], trigger = "", templateContext = {}) { + const activationSeedBase = simpleHash(String(trigger || "")); + const activated = new Set(); + + for (const entry of entries) { + if (!entry.enabled) continue; + + if (entry.useProbability) { + const probabilityRoll = deterministicPercent( + `${activationSeedBase}:prob:${entry.worldbook}:${entry.uid}:${entry.name}`, + ); + if (entry.probability < probabilityRoll) continue; + } + + if (entry.constant) { + activated.add(entry); + continue; + } + + if (entry.decorators.includes("@@activate")) { + activated.add(entry); + 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 + .map((key) => substituteTaskEjsParams(key, templateContext)) + .find((key) => matchKeys(trigger, key, entry)); + if (!matchedPrimary) continue; + + const hasSecondaryKeys = entry.selective && entry.keysSecondary.length > 0; + if (!hasSecondaryKeys) { + activated.add(entry); + continue; + } + + let hasAnyMatch = false; + let hasAllMatch = true; + + for (const secondaryKey of entry.keysSecondary) { + const substituted = substituteTaskEjsParams(secondaryKey, templateContext); + const hasMatch = + substituted.trim() !== "" && matchKeys(trigger, substituted.trim(), entry); + if (hasMatch) hasAnyMatch = true; + if (!hasMatch) hasAllMatch = false; + + if (entry.selectiveLogic === WI_LOGIC.AND_ANY && hasMatch) { + activated.add(entry); + break; + } + + if (entry.selectiveLogic === WI_LOGIC.NOT_ALL && !hasMatch) { + activated.add(entry); + break; + } + } + + if (entry.selectiveLogic === WI_LOGIC.NOT_ANY && !hasAnyMatch) { + activated.add(entry); + continue; + } + + if (entry.selectiveLogic === WI_LOGIC.AND_ALL && hasAllMatch) { + activated.add(entry); + } + } + + if (activated.size === 0) { + return []; + } + + const grouped = groupBy([...activated], (entry) => entry.group || ""); + const ungrouped = grouped[""] || []; + if (ungrouped.length > 0 && Object.keys(grouped).length <= 1) { + return ungrouped.sort(sortEntries); + } + + const matched = []; + for (const [groupName, members] of Object.entries(grouped)) { + if (groupName === "") continue; + + if (members.length === 1) { + matched.push(members[0]); + continue; + } + + const prioritized = members.filter((entry) => entry.groupOverride); + if (prioritized.length > 0) { + const topOrder = Math.min(...prioritized.map((entry) => entry.order ?? 100)); + matched.push( + prioritized.find((entry) => (entry.order ?? 100) <= topOrder) || + prioritized[0], + ); + continue; + } + + const scored = members.filter((entry) => entry.useGroupScoring); + if (scored.length > 0) { + const scores = members.map((entry) => getScore(trigger, entry)); + const topScore = Math.max(...scores); + if (topScore > 0) { + const winnerIndex = Math.max(scores.findIndex((score) => score >= topScore), 0); + matched.push(members[winnerIndex]); + continue; + } + } + + const weighted = members.filter( + (entry) => !entry.groupOverride && !entry.useGroupScoring, + ); + if (weighted.length > 0) { + const weights = weighted.map((entry) => entry.groupWeight); + const winner = deterministicWeightedIndex( + weights, + `${activationSeedBase}:group:${groupName}:${weighted + .map((entry) => `${entry.worldbook}:${entry.uid}`) + .join("|")}`, + ); + if (winner >= 0) { + matched.push(weighted[winner]); + } + } + } + + return ungrouped.concat(matched).sort(sortEntries); +} + +async function collectAllWorldbookEntries() { + const getWorldbook = getWorldbookApi("getWorldbook"); + if (!getWorldbook) { + return []; + } + + const getLorebookEntries = getWorldbookApi("getLorebookEntries"); + const getCharWorldbookNames = getWorldbookApi("getCharWorldbookNames"); + const allEntries = []; + const loadedNames = new Set(); + + async function loadWorldbookOnce(worldbookName) { + const normalizedName = normalizeKey(worldbookName); + if (!normalizedName || loadedNames.has(normalizedName)) return; + 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}`, + error, + ); + } + } + + for (const entry of Array.isArray(entries) ? entries : []) { + allEntries.push( + normalizeEntry( + { + ...entry, + comment: commentByUid.get(entry.uid) ?? entry.comment ?? "", + }, + normalizedName, + ), + ); + } + } catch (error) { + console.debug( + `[ST-BME] task-worldinfo 读取世界书失败: ${normalizedName}`, + error, + ); + } + } + + if (getCharWorldbookNames) { + try { + const charWorldbooks = getCharWorldbookNames("current") || {}; + if (charWorldbooks.primary) { + await loadWorldbookOnce(charWorldbooks.primary); + } + for (const additional of charWorldbooks.additional || []) { + await loadWorldbookOnce(additional); + } + } catch (error) { + console.debug("[ST-BME] task-worldinfo 读取角色世界书失败", error); + } + } + + const ctx = getStContext(); + const personaLorebook = + ctx.extensionSettings?.persona_description_lorebook || + ctx.powerUserSettings?.persona_description_lorebook || + ctx.power_user?.persona_description_lorebook || + ""; + if (personaLorebook) { + await loadWorldbookOnce(personaLorebook); + } + + const chatLorebook = ctx.chatMetadata?.world || ""; + if (chatLorebook) { + await loadWorldbookOnce(chatLorebook); + } + + return allEntries; +} + +function classifyPosition(entry) { + switch (entry.position) { + case WI_POSITION.before: + case WI_POSITION.EMTop: + case WI_POSITION.ANTop: + return "before"; + case WI_POSITION.atDepth: + return "atDepth"; + case WI_POSITION.after: + case WI_POSITION.EMBottom: + case WI_POSITION.ANBottom: + default: + return "after"; + } +} + +function normalizeResolvedEntry(entry = {}, fallbackIndex = 0) { + const role = ["system", "user", "assistant"].includes(entry.role) + ? entry.role + : "system"; + return { + name: normalizeKey(entry.name), + sourceName: normalizeKey(entry.sourceName || entry.source_name || entry.name), + worldbook: normalizeKey(entry.worldbook), + content: String(entry.content || ""), + role, + position: Number(entry.position ?? WI_POSITION.after), + depth: Number(entry.depth ?? 0), + order: Number(entry.order ?? 100), + index: fallbackIndex, + }; +} + +function sortAtDepthEntries(entries = []) { + return [...entries].sort((a, b) => { + const depthA = Number(a.depth ?? 0); + const depthB = Number(b.depth ?? 0); + return ( + depthB - depthA || + (a.order ?? 100) - (b.order ?? 100) || + a.index - b.index + ); + }); +} + +function buildAdditionalMessages(entries = []) { + return sortAtDepthEntries(entries) + .map((entry) => ({ + role: entry.role, + content: String(entry.content || "").trim(), + })) + .filter((entry) => entry.content); +} + +function buildWorldInfoText(entries = []) { + return entries + .map((entry) => String(entry.content || "").trim()) + .filter(Boolean) + .join("\n\n"); +} + +function buildActivationSourceTexts({ chatMessages = [], userMessage = "", templateContext = {} } = {}) { + const texts = []; + + if (Array.isArray(chatMessages)) { + for (const message of chatMessages) { + const text = + typeof message === "string" + ? message + : typeof message?.content === "string" + ? message.content + : typeof message?.mes === "string" + ? message.mes + : ""; + if (text) texts.push(text); + } + } + + if (typeof userMessage === "string" && userMessage.trim()) { + texts.push(userMessage); + } + + const fallbackContextFields = [ + "recentMessages", + "dialogueText", + "userMessage", + "candidateNodes", + "candidateText", + "nodeContent", + "eventSummary", + "characterSummary", + "threadSummary", + "contradictionSummary", + ]; + + for (const key of fallbackContextFields) { + const value = templateContext?.[key]; + if (typeof value === "string" && value.trim()) { + texts.push(value); + } + } + + return uniq(texts.map((text) => String(text).trim()).filter(Boolean)); +} + +export async function resolveTaskWorldInfo({ + settings = {}, + chatMessages = [], + userMessage = "", + templateContext = {}, +} = {}) { + const result = { + beforeEntries: [], + afterEntries: [], + atDepthEntries: [], + beforeText: "", + afterText: "", + additionalMessages: [], + activatedEntryNames: [], + allEntries: [], + }; + + try { + const allEntries = await collectAllWorldbookEntries(); + result.allEntries = allEntries; + if (allEntries.length === 0) { + return result; + } + + const triggerTexts = buildActivationSourceTexts({ + chatMessages, + userMessage, + templateContext, + }); + const trigger = triggerTexts.join("\n\n"); + if (!trigger.trim()) { + return result; + } + + const activated = selectActivatedEntries(allEntries, trigger, { + ...templateContext, + user_input: userMessage || templateContext?.user_input || "", + }); + if (activated.length === 0) { + return result; + } + + const renderCtx = createTaskEjsRenderContext( + allEntries.map((entry) => ({ + name: entry.name, + comment: entry.comment, + content: entry.cleanContent || entry.content, + worldbook: entry.worldbook, + })), + { + templateContext: { + ...templateContext, + user_input: userMessage || templateContext?.user_input || "", + }, + }, + ); + + const controllerPrefix = + settings.worldInfoControllerEntryPrefix || + settings.controller_entry_prefix || + DEFAULT_CONTROLLER_ENTRY_PREFIX; + + const beforeEntries = []; + const afterEntries = []; + const atDepthEntries = []; + let resolvedIndex = 0; + + for (const entry of activated) { + renderCtx.pulledEntries.clear(); + + 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) { + console.warn( + `[ST-BME] task-worldinfo 渲染世界书条目失败: ${entry.name}`, + error, + ); + } + + if (!String(renderedContent || "").trim()) { + continue; + } + + const bucketName = classifyPosition(entry); + const bucket = + bucketName === "before" + ? beforeEntries + : bucketName === "after" + ? afterEntries + : atDepthEntries; + + if (entry.name.startsWith(String(controllerPrefix || ""))) { + bucket.push( + normalizeResolvedEntry( + { + name: entry.name, + sourceName: entry.name, + worldbook: entry.worldbook, + content: sourceContent, + role: entry.role, + position: entry.position, + depth: entry.depth, + order: entry.order, + }, + resolvedIndex++, + ), + ); + + 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, + }, + resolvedIndex++, + ), + ); + } + 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, + }, + resolvedIndex++, + ), + ); + } + + result.beforeEntries = beforeEntries; + result.afterEntries = afterEntries; + result.atDepthEntries = sortAtDepthEntries(atDepthEntries); + result.beforeText = buildWorldInfoText(result.beforeEntries); + result.afterText = buildWorldInfoText(result.afterEntries); + result.additionalMessages = buildAdditionalMessages(result.atDepthEntries); + result.activatedEntryNames = uniq( + [ + ...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, + ), + ].filter(Boolean), + ); + } catch (error) { + console.error("[ST-BME] task-worldinfo 解析失败:", error); + } + + return result; +} diff --git a/tests/retrieval-config.mjs b/tests/retrieval-config.mjs index 39e45f2..160acf7 100644 --- a/tests/retrieval-config.mjs +++ b/tests/retrieval-config.mjs @@ -124,6 +124,9 @@ const retrieve = await loadRetrieve({ .filter((line) => line.trim().startsWith("[")).length; return { selected_ids: ["rule-2", "rule-1"] }; }, + getSTContextForPrompt() { + return {}; + }, }); state.vectorCalls.length = 0; diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs new file mode 100644 index 0000000..8ba49a3 --- /dev/null +++ b/tests/task-worldinfo.mjs @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; + +const originalSillyTavern = globalThis.SillyTavern; +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: {}, +}; + +try { + globalThis.SillyTavern = { + getContext() { + return { + name1: "User", + name2: "Alice", + chat: [{ is_user: true, mes: "我们继续调查那条线索" }], + chatMetadata: {}, + extensionSettings: {}, + }; + }, + }; + globalThis.getCharWorldbookNames = () => ({ + primary: "main-book", + additional: [], + }); + globalThis.getWorldbook = async () => [ + constantEntry, + dynEntry, + controllerEntry, + atDepthEntry, + ]; + globalThis.getLorebookEntries = async () => []; + + const { resolveTaskWorldInfo } = await import("../task-worldinfo.js"); + const { buildTaskPrompt } = await import("../prompt-builder.js"); + + const worldInfo = await resolveTaskWorldInfo({ + templateContext: { + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + userMessage: "继续调查", + }); + + assert.deepEqual( + worldInfo.beforeEntries.map((entry) => entry.name), + ["常驻设定", "EW/Controller/Main", "线索条目"], + ); + assert.equal(worldInfo.additionalMessages.length, 1); + assert.equal(worldInfo.additionalMessages[0].content, "这是一条 atDepth 消息。"); + + const settings = { + taskProfiles: { + recall: { + activeProfileId: "custom", + profiles: [ + { + id: "custom", + name: "测试预设", + taskType: "recall", + builtin: false, + blocks: [ + { + id: "b1", + type: "builtin", + sourceKey: "worldInfoBefore", + role: "system", + enabled: true, + order: 0, + injectionMode: "append", + }, + { + id: "b2", + type: "custom", + content: "角色: {{charName}}", + role: "user", + enabled: true, + order: 1, + injectionMode: "append", + }, + ], + }, + ], + }, + }, + }; + + const promptBuild = await buildTaskPrompt(settings, "recall", { + taskName: "recall", + userMessage: "继续调查", + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }); + + assert.match(promptBuild.systemPrompt, /这里是常驻世界设定/); + assert.match(promptBuild.systemPrompt, /隐藏线索:Alice 正在调查/); + assert.equal(promptBuild.additionalMessages.length, 1); + assert.equal(promptBuild.additionalMessages[0].content, "这是一条 atDepth 消息。"); + + console.log("task-worldinfo tests passed"); +} finally { + if (originalSillyTavern === undefined) { + delete globalThis.SillyTavern; + } else { + globalThis.SillyTavern = originalSillyTavern; + } + + if (originalGetCharWorldbookNames === undefined) { + delete globalThis.getCharWorldbookNames; + } else { + globalThis.getCharWorldbookNames = originalGetCharWorldbookNames; + } + + if (originalGetWorldbook === undefined) { + delete globalThis.getWorldbook; + } else { + globalThis.getWorldbook = originalGetWorldbook; + } + + if (originalGetLorebookEntries === undefined) { + delete globalThis.getLorebookEntries; + } else { + globalThis.getLorebookEntries = originalGetLorebookEntries; + } +} diff --git a/vendor/ejs.js b/vendor/ejs.js new file mode 100644 index 0000000..e177b14 --- /dev/null +++ b/vendor/ejs.js @@ -0,0 +1,1793 @@ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.ejs = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i + * @author Tiancheng "Timothy" Gu + * @project EJS + * @license {@link http://www.apache.org/licenses/LICENSE-2.0 Apache License, Version 2.0} + */ + + /** + * EJS internal functions. + * + * Technically this "module" lies in the same file as {@link module:ejs}, for + * the sake of organization all the private functions re grouped into this + * module. + * + * @module ejs-internal + * @private + */ + + /** + * Embedded JavaScript templating engine. + * + * @module ejs + * @public + */ + + + var fs = require('fs'); + var path = require('path'); + var utils = require('./utils'); + + var scopeOptionWarned = false; + /** @type {string} */ + var _VERSION_STRING = require('../package.json').version; + var _DEFAULT_OPEN_DELIMITER = '<'; + var _DEFAULT_CLOSE_DELIMITER = '>'; + var _DEFAULT_DELIMITER = '%'; + var _DEFAULT_LOCALS_NAME = 'locals'; + var _NAME = 'ejs'; + var _REGEX_STRING = '(<%%|%%>|<%=|<%-|<%_|<%#|<%|%>|-%>|_%>)'; + var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug', + 'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async']; + // We don't allow 'cache' option to be passed in the data obj for + // the normal `render` call, but this is where Express 2 & 3 put it + // so we make an exception for `renderFile` + var _OPTS_PASSABLE_WITH_DATA_EXPRESS = _OPTS_PASSABLE_WITH_DATA.concat('cache'); + var _BOM = /^\uFEFF/; + var _JS_IDENTIFIER = /^[\p{ID_Start}$_][\p{ID_Continue}$_]*$/u; + + /** + * EJS template function cache. This can be a LRU object from lru-cache NPM + * module. By default, it is {@link module:utils.cache}, a simple in-process + * cache that grows continuously. + * + * @type {Cache} + */ + + exports.cache = utils.cache; + + /** + * Custom file loader. Useful for template preprocessing or restricting access + * to a certain part of the filesystem. + * + * @type {fileLoader} + */ + + exports.fileLoader = fs.readFileSync; + + /** + * Name of the object containing the locals. + * + * This variable is overridden by {@link Options}`.localsName` if it is not + * `undefined`. + * + * @type {String} + * @public + */ + + exports.localsName = _DEFAULT_LOCALS_NAME; + + /** + * Promise implementation -- defaults to the native implementation if available + * This is mostly just for testability + * + * @type {PromiseConstructorLike} + * @public + */ + + exports.promiseImpl = (new Function('return this;'))().Promise; + + /** + * Get the path to the included file from the parent file path and the + * specified path. + * + * @param {String} name specified path + * @param {String} filename parent file path + * @param {Boolean} [isDir=false] whether the parent file path is a directory + * @return {String} + */ + exports.resolveInclude = function(name, filename, isDir) { + var dirname = path.dirname; + var extname = path.extname; + var resolve = path.resolve; + var includePath = resolve(isDir ? filename : dirname(filename), name); + var ext = extname(name); + if (!ext) { + includePath += '.ejs'; + } + return includePath; + }; + + /** + * Try to resolve file path on multiple directories + * + * @param {String} name specified path + * @param {Array} paths list of possible parent directory paths + * @return {String} + */ + function resolvePaths(name, paths) { + var filePath; + if (paths.some(function (v) { + filePath = exports.resolveInclude(name, v, true); + return fs.existsSync(filePath); + })) { + return filePath; + } + } + + /** + * Get the path to the included file by Options + * + * @param {String} path specified path + * @param {Options} options compilation options + * @return {String} + */ + function getIncludePath(path, options) { + var includePath; + var filePath; + var views = options.views; + var match = /^[A-Za-z]+:\\|^\//.exec(path); + + // Abs path + if (match && match.length) { + path = path.replace(/^\/*/, ''); + if (Array.isArray(options.root)) { + includePath = resolvePaths(path, options.root); + } else { + includePath = exports.resolveInclude(path, options.root || '/', true); + } + } + // Relative paths + else { + // Look relative to a passed filename first + if (options.filename) { + filePath = exports.resolveInclude(path, options.filename); + if (fs.existsSync(filePath)) { + includePath = filePath; + } + } + // Then look in any views directories + if (!includePath && Array.isArray(views)) { + includePath = resolvePaths(path, views); + } + if (!includePath && typeof options.includer !== 'function') { + throw new Error('Could not find the include file "' + + options.escapeFunction(path) + '"'); + } + } + return includePath; + } + + /** + * Get the template from a string or a file, either compiled on-the-fly or + * read from cache (if enabled), and cache the template if needed. + * + * If `template` is not set, the file specified in `options.filename` will be + * read. + * + * If `options.cache` is true, this function reads the file from + * `options.filename` so it must be set prior to calling this function. + * + * @memberof module:ejs-internal + * @param {Options} options compilation options + * @param {String} [template] template source + * @return {(TemplateFunction|ClientFunction)} + * Depending on the value of `options.client`, either type might be returned. + * @static + */ + + function handleCache(options, template) { + var func; + var filename = options.filename; + var hasTemplate = arguments.length > 1; + + if (options.cache) { + if (!filename) { + throw new Error('cache option requires a filename'); + } + func = exports.cache.get(filename); + if (func) { + return func; + } + if (!hasTemplate) { + template = fileLoader(filename).toString().replace(_BOM, ''); + } + } + else if (!hasTemplate) { + // istanbul ignore if: should not happen at all + if (!filename) { + throw new Error('Internal EJS error: no file name or template ' + + 'provided'); + } + template = fileLoader(filename).toString().replace(_BOM, ''); + } + func = exports.compile(template, options); + if (options.cache) { + exports.cache.set(filename, func); + } + return func; + } + + /** + * Try calling handleCache with the given options and data and call the + * callback with the result. If an error occurs, call the callback with + * the error. Used by renderFile(). + * + * @memberof module:ejs-internal + * @param {Options} options compilation options + * @param {Object} data template data + * @param {RenderFileCallback} cb callback + * @static + */ + + function tryHandleCache(options, data, cb) { + var result; + if (!cb) { + if (typeof exports.promiseImpl == 'function') { + return new exports.promiseImpl(function (resolve, reject) { + try { + result = handleCache(options)(data); + resolve(result); + } + catch (err) { + reject(err); + } + }); + } + else { + throw new Error('Please provide a callback function'); + } + } + else { + try { + result = handleCache(options)(data); + } + catch (err) { + return cb(err); + } + + cb(null, result); + } + } + + /** + * fileLoader is independent + * + * @param {String} filePath ejs file path. + * @return {String} The contents of the specified file. + * @static + */ + + function fileLoader(filePath){ + return exports.fileLoader(filePath); + } + + /** + * Get the template function. + * + * If `options.cache` is `true`, then the template is cached. + * + * @memberof module:ejs-internal + * @param {String} path path for the specified file + * @param {Options} options compilation options + * @return {(TemplateFunction|ClientFunction)} + * Depending on the value of `options.client`, either type might be returned + * @static + */ + + function includeFile(path, options) { + var opts = utils.shallowCopy(utils.createNullProtoObjWherePossible(), options); + opts.filename = getIncludePath(path, opts); + if (typeof options.includer === 'function') { + var includerResult = options.includer(path, opts.filename); + if (includerResult) { + if (includerResult.filename) { + opts.filename = includerResult.filename; + } + if (includerResult.template) { + return handleCache(opts, includerResult.template); + } + } + } + return handleCache(opts); + } + + /** + * Re-throw the given `err` in context to the `str` of ejs, `filename`, and + * `lineno`. + * + * @implements {RethrowCallback} + * @memberof module:ejs-internal + * @param {Error} err Error object + * @param {String} str EJS source + * @param {String} flnm file name of the EJS file + * @param {Number} lineno line number of the error + * @param {EscapeCallback} esc + * @static + */ + + function rethrow(err, str, flnm, lineno, esc) { + var lines = str.split('\n'); + var start = Math.max(lineno - 3, 0); + var end = Math.min(lines.length, lineno + 3); + var filename = esc(flnm); + // Error context + var context = lines.slice(start, end).map(function (line, i){ + var curr = i + start + 1; + return (curr == lineno ? ' >> ' : ' ') + + curr + + '| ' + + line; + }).join('\n'); + + // Alter exception message + err.path = filename; + err.message = (filename || 'ejs') + ':' + + lineno + '\n' + + context + '\n\n' + + err.message; + + throw err; + } + + function stripSemi(str){ + return str.replace(/;(\s*$)/, '$1'); + } + + /** + * Compile the given `str` of ejs into a template function. + * + * @param {String} template EJS template + * + * @param {Options} [opts] compilation options + * + * @return {(TemplateFunction|ClientFunction)} + * Depending on the value of `opts.client`, either type might be returned. + * Note that the return type of the function also depends on the value of `opts.async`. + * @public + */ + + exports.compile = function compile(template, opts) { + var templ; + + // v1 compat + // 'scope' is 'context' + // FIXME: Remove this in a future version + if (opts && opts.scope) { + if (!scopeOptionWarned){ + console.warn('`scope` option is deprecated and will be removed in EJS 3'); + scopeOptionWarned = true; + } + if (!opts.context) { + opts.context = opts.scope; + } + delete opts.scope; + } + templ = new Template(template, opts); + return templ.compile(); + }; + + /** + * Render the given `template` of ejs. + * + * If you would like to include options but not data, you need to explicitly + * call this function with `data` being an empty object or `null`. + * + * @param {String} template EJS template + * @param {Object} [data={}] template data + * @param {Options} [opts={}] compilation and rendering options + * @return {(String|Promise)} + * Return value type depends on `opts.async`. + * @public + */ + + exports.render = function (template, d, o) { + var data = d || utils.createNullProtoObjWherePossible(); + var opts = o || utils.createNullProtoObjWherePossible(); + + // No options object -- if there are optiony names + // in the data, copy them to options + if (arguments.length == 2) { + utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA); + } + + return handleCache(opts, template)(data); + }; + + /** + * Render an EJS file at the given `path` and callback `cb(err, str)`. + * + * If you would like to include options but not data, you need to explicitly + * call this function with `data` being an empty object or `null`. + * + * @param {String} path path to the EJS file + * @param {Object} [data={}] template data + * @param {Options} [opts={}] compilation and rendering options + * @param {RenderFileCallback} cb callback + * @public + */ + + exports.renderFile = function () { + var args = Array.prototype.slice.call(arguments); + var filename = args.shift(); + var cb; + var opts = {filename: filename}; + var data; + var viewOpts; + + // Do we have a callback? + if (typeof arguments[arguments.length - 1] == 'function') { + cb = args.pop(); + } + // Do we have data/opts? + if (args.length) { + // Should always have data obj + data = args.shift(); + // Normal passed opts (data obj + opts obj) + if (args.length) { + // Use shallowCopy so we don't pollute passed in opts obj with new vals + utils.shallowCopy(opts, args.pop()); + } + // Special casing for Express (settings + opts-in-data) + else { + // Express 3 and 4 + if (data.settings) { + // Pull a few things from known locations + if (data.settings.views) { + opts.views = data.settings.views; + } + if (data.settings['view cache']) { + opts.cache = true; + } + // Undocumented after Express 2, but still usable, esp. for + // items that are unsafe to be passed along with data, like `root` + viewOpts = data.settings['view options']; + if (viewOpts) { + utils.shallowCopy(opts, viewOpts); + } + } + // Express 2 and lower, values set in app.locals, or people who just + // want to pass options in their data. NOTE: These values will override + // anything previously set in settings or settings['view options'] + utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS); + } + opts.filename = filename; + } + else { + data = utils.createNullProtoObjWherePossible(); + } + + return tryHandleCache(opts, data, cb); + }; + + /** + * Clear intermediate JavaScript cache. Calls {@link Cache#reset}. + * @public + */ + + /** + * EJS template class + * @public + */ + exports.Template = Template; + + exports.clearCache = function () { + exports.cache.reset(); + }; + + function Template(text, optsParam) { + var opts = utils.hasOwnOnlyObject(optsParam); + var options = utils.createNullProtoObjWherePossible(); + this.templateText = text; + /** @type {string | null} */ + this.mode = null; + this.truncate = false; + this.currentLine = 1; + this.source = ''; + options.client = opts.client || false; + options.escapeFunction = opts.escape || opts.escapeFunction || utils.escapeXML; + options.compileDebug = opts.compileDebug !== false; + options.debug = !!opts.debug; + options.filename = opts.filename; + options.openDelimiter = opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER; + options.closeDelimiter = opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER; + options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER; + options.strict = opts.strict || false; + options.context = opts.context; + options.cache = opts.cache || false; + options.rmWhitespace = opts.rmWhitespace; + options.root = opts.root; + options.includer = opts.includer; + options.outputFunctionName = opts.outputFunctionName; + options.localsName = opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME; + options.views = opts.views; + options.async = opts.async; + options.destructuredLocals = opts.destructuredLocals; + options.legacyInclude = typeof opts.legacyInclude != 'undefined' ? !!opts.legacyInclude : true; + + if (options.strict) { + options._with = false; + } + else { + options._with = typeof opts._with != 'undefined' ? opts._with : true; + } + + this.opts = options; + + this.regex = this.createRegex(); + } + + Template.modes = { + EVAL: 'eval', + ESCAPED: 'escaped', + RAW: 'raw', + COMMENT: 'comment', + LITERAL: 'literal' + }; + + Template.prototype = { + createRegex: function () { + var str = _REGEX_STRING; + var delim = utils.escapeRegExpChars(this.opts.delimiter); + var open = utils.escapeRegExpChars(this.opts.openDelimiter); + var close = utils.escapeRegExpChars(this.opts.closeDelimiter); + str = str.replace(/%/g, delim) + .replace(//g, close); + return new RegExp(str); + }, + + compile: function () { + /** @type {string} */ + var src; + /** @type {ClientFunction} */ + var fn; + var opts = this.opts; + var prepended = ''; + var appended = ''; + /** @type {EscapeCallback} */ + var escapeFn = opts.escapeFunction; + /** @type {FunctionConstructor} */ + var ctor; + /** @type {string} */ + var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined'; + + if (!this.source) { + this.generateSource(); + prepended += + ' var __output = "";\n' + + ' function __append(...args) { args.filter(x => x !== undefined && x !== null).forEach(s => __output += s) }\n'; + if (opts.outputFunctionName) { + if (!_JS_IDENTIFIER.test(opts.outputFunctionName)) { + throw new Error('outputFunctionName is not a valid JS identifier.'); + } + prepended += ' const ' + opts.outputFunctionName + ' = __append;' + '\n'; + } + if (opts.localsName && !_JS_IDENTIFIER.test(opts.localsName)) { + throw new Error('localsName is not a valid JS identifier.'); + } + if (opts.destructuredLocals && opts.destructuredLocals.length) { + var destructuring = ' {\n const __locals = (' + opts.localsName + ' || {}),\n '; + for (var i = 0; i < opts.destructuredLocals.length; i++) { + var name = opts.destructuredLocals[i]; + if (!_JS_IDENTIFIER.test(name)) { + throw new Error('destructuredLocals[' + i + '] is not a valid JS identifier.'); + } + if (i > 0) { + destructuring += ',\n '; + } + destructuring += name + ' = __locals.' + name; + } + prepended += destructuring + ';\n'; + } + if (opts._with !== false) { + prepended += ' with (' + opts.localsName + ' || {}) '; + } + prepended += ' {' + '\n'; + appended += ' }' + '\n'; + if (opts.destructuredLocals && opts.destructuredLocals.length) + appended += ' }\n'; + + appended += ' return __output;' + '\n'; + this.source = prepended + this.source + appended; + } + + if (opts.compileDebug) { + src = 'var __line = 1' + '\n' + + ' , __lines = ' + JSON.stringify(this.templateText) + '\n' + + ' , __filename = ' + sanitizedFilename + ';' + '\n' + + 'try {' + '\n' + + this.source + + '} catch (e) {' + '\n' + + ' rethrow(e, __lines, __filename, __line, escapeFn);' + '\n' + + '}' + '\n'; + } + else { + src = this.source; + } + + if (opts.client) { + src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src; + if (opts.compileDebug) { + src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src; + } + } + + if (opts.strict) { + src = '"use strict";\n' + src; + } + if (opts.debug) { + console.log(src); + } + if (opts.compileDebug && opts.filename) { + src = src + '\n' + + '//# sourceURL=' + sanitizedFilename + '\n'; + } + + try { + if (opts.async) { + // Have to use generated function for this, since in envs without support, + // it breaks in parsing + try { + ctor = (new Function('return (async function(){}).constructor;'))(); + } + catch(e) { + if (e instanceof SyntaxError) { + throw new Error('This environment does not support async/await'); + } + else { + throw e; + } + } + } + else { + ctor = Function; + } + fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src); + } + catch(e) { + e.src = src; + // istanbul ignore else + if (e instanceof SyntaxError) { + if (opts.filename) { + e.message += ' in ' + opts.filename; + } + e.message += ' while compiling ejs\n\n'; + /* + e.message += 'If the above error is not helpful, you may want to try EJS-Lint:\n'; + e.message += 'https://github.com/RyanZim/EJS-Lint'; + if (!opts.async) { + e.message += '\n'; + e.message += 'Or, if you meant to create an async function, pass `async: true` as an option.'; + } + */ + } + throw e; + } + + // Return a callable function which will execute the function + // created by the source-code, with the passed data as locals + // Adds a local `include` function which allows full recursive include + var returnedFn = opts.client ? fn : function anonymous(data) { + var include = function (path, includeData) { + var d = utils.shallowCopy(utils.createNullProtoObjWherePossible(), data); + if (includeData) { + d = utils.shallowCopy(d, includeData); + } + return includeFile(path, opts)(d); + }; + return fn.apply(opts.context, + [data || utils.createNullProtoObjWherePossible(), escapeFn, include, rethrow]); + }; + if (opts.filename && typeof Object.defineProperty === 'function') { + var filename = opts.filename; + var basename = path.basename(filename, path.extname(filename)); + try { + Object.defineProperty(returnedFn, 'name', { + value: basename, + writable: false, + enumerable: false, + configurable: true + }); + } catch (e) {/* ignore */} + } + return returnedFn; + }, + + generateSource: function() { + var opts = this.opts; + var d = this.opts.delimiter; + var o = this.opts.openDelimiter; + var c = this.opts.closeDelimiter; + + if (opts.rmWhitespace) { + this.templateText = + this.templateText.replace(/[\r\n]+/g, '\n').replace(/^\s+|\s+$/gm, ''); + } + this.templateText = + this.templateText.replace(/[ \t]*<%_/gm, '<%_').replace(/_%>[ \t]*/gm, '_%>'); + + + var allTokens = this.parseTemplateText(); + var processedTokens = []; + var i = 0; + + while (i < allTokens.length) { + var token = allTokens[i]; + + if (token.startsWith(o + d) && token !== o + d + d) { + var nestingLevel = 1; + var contentBuffer = []; + var j = i + 1; + + while (j < allTokens.length) { + var innerToken = allTokens[j]; + + if (innerToken.startsWith(o + d) && innerToken !== o + d + d) { + nestingLevel++; + } else if (innerToken.endsWith(d + c) && innerToken !== d + d + c) { + nestingLevel--; + + if (nestingLevel === 0) { + processedTokens.push(token); + processedTokens.push(contentBuffer.join('')); + processedTokens.push(innerToken); + + i = j; + break; + } + } + + contentBuffer.push(innerToken); + j++; + } + + if (nestingLevel !== 0) { + throw new Error('Could not find matching close tag for "' + token + '".'); + } + + } else { + processedTokens.push(token); + } + + i++; + } + + var self = this; + if (processedTokens.length) { + processedTokens.forEach(function (line) { + self.scanLine(line); + }); + } + }, + + parseTemplateText: function () { + var str = this.templateText; + var pat = this.regex; + var result = pat.exec(str); + var arr = []; + var firstPos; + + while (result) { + firstPos = result.index; + + if (firstPos !== 0) { + arr.push(str.substring(0, firstPos)); + str = str.slice(firstPos); + } + + arr.push(result[0]); + str = str.slice(result[0].length); + result = pat.exec(str); + } + + if (str) { + arr.push(str); + } + + return arr; + }, + + _addOutput: function (line) { + if (this.truncate) { + // Only replace single leading linebreak in the line after + // -%> tag -- this is the single, trailing linebreak + // after the tag that the truncation mode replaces + // Handle Win / Unix / old Mac linebreaks -- do the \r\n + // combo first in the regex-or + line = line.replace(/^(?:\r\n|\r|\n)/, ''); + this.truncate = false; + } + if (!line) { + return line; + } + + // Preserve literal slashes + line = line.replace(/\\/g, '\\\\'); + + // Convert linebreaks + line = line.replace(/\n/g, '\\n'); + line = line.replace(/\r/g, '\\r'); + + // Escape double-quotes + // - this will be the delimiter during execution + line = line.replace(/"/g, '\\"'); + this.source += ' ; __append("' + line + '")' + '\n'; + }, + + scanLine: function (line) { + var self = this; + var d = this.opts.delimiter; + var o = this.opts.openDelimiter; + var c = this.opts.closeDelimiter; + var newLineCount = 0; + + newLineCount = (line.split('\n').length - 1); + + switch (line) { + case o + d: + case o + d + '_': + this.mode = Template.modes.EVAL; + break; + case o + d + '=': + this.mode = Template.modes.ESCAPED; + break; + case o + d + '-': + this.mode = Template.modes.RAW; + break; + case o + d + '#': + this.mode = Template.modes.COMMENT; + break; + case o + d + d: + this.mode = Template.modes.LITERAL; + this.source += ' ; __append("' + line.replace(o + d + d, o + d) + '")' + '\n'; + break; + case d + d + c: + this.mode = Template.modes.LITERAL; + this.source += ' ; __append("' + line.replace(d + d + c, d + c) + '")' + '\n'; + break; + case d + c: + case '-' + d + c: + case '_' + d + c: + if (this.mode == Template.modes.LITERAL) { + this._addOutput(line); + } + + this.mode = null; + this.truncate = line.indexOf('-') === 0 || line.indexOf('_') === 0; + break; + default: + // In script mode, depends on type of tag + if (this.mode) { + // If '//' is found without a line break, add a line break. + switch (this.mode) { + case Template.modes.EVAL: + case Template.modes.ESCAPED: + case Template.modes.RAW: + if (line.lastIndexOf('//') > line.lastIndexOf('\n')) { + line += '\n'; + } + } + switch (this.mode) { + // Just executing code + case Template.modes.EVAL: + this.source += ' ; ' + line + '\n'; + break; + // Exec, esc, and output + case Template.modes.ESCAPED: + this.source += ' ; __append(escapeFn(' + stripSemi(line) + '))' + '\n'; + break; + // Exec and output + case Template.modes.RAW: + this.source += ' ; __append(' + stripSemi(line) + ')' + '\n'; + break; + case Template.modes.COMMENT: + // Do nothing + break; + // Literal <%% mode, append as raw output + case Template.modes.LITERAL: + this._addOutput(line); + break; + } + } + // In string mode, just add the output + else { + this._addOutput(line); + } + } + + if (self.opts.compileDebug && newLineCount) { + this.currentLine += newLineCount; + this.source += ' ; __line = ' + this.currentLine + '\n'; + } + } + }; + + /** + * Escape characters reserved in XML. + * + * This is simply an export of {@link module:utils.escapeXML}. + * + * If `markup` is `undefined` or `null`, the empty string is returned. + * + * @param {String} markup Input string + * @return {String} Escaped string + * @public + * @func + * */ + exports.escapeXML = utils.escapeXML; + + /** + * Express.js support. + * + * This is an alias for {@link module:ejs.renderFile}, in order to support + * Express.js out-of-the-box. + * + * @func + */ + + exports.__express = exports.renderFile; + + /** + * Version of EJS. + * + * @readonly + * @type {String} + * @public + */ + + exports.VERSION = _VERSION_STRING; + + /** + * Name for detection of EJS. + * + * @readonly + * @type {String} + * @public + */ + + exports.name = _NAME; + + /* istanbul ignore if */ + if (typeof window != 'undefined') { + window.ejs = exports; + } + + + },{"../package.json":6,"./utils":2,"fs":3,"path":4}],2:[function(require,module,exports){ + /* + * EJS Embedded JavaScript templates + * Copyright 2112 Matthew Eernisse (mde@fleegix.org) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + + /** + * Private utility functions + * @module utils + * @private + */ + + 'use strict'; + + var regExpChars = /[|\\{}()[\]^$+*?.]/g; + var hasOwnProperty = Object.prototype.hasOwnProperty; + var hasOwn = function (obj, key) { return hasOwnProperty.apply(obj, [key]); }; + + /** + * Escape characters reserved in regular expressions. + * + * If `string` is `undefined` or `null`, the empty string is returned. + * + * @param {String} string Input string + * @return {String} Escaped string + * @static + * @private + */ + exports.escapeRegExpChars = function (string) { + // istanbul ignore if + if (!string) { + return ''; + } + return String(string).replace(regExpChars, '\\$&'); + }; + + var _ENCODE_HTML_RULES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + var _MATCH_HTML = /[&<>'"]/g; + + function encode_char(c) { + return _ENCODE_HTML_RULES[c] || c; + } + + /** + * Stringified version of constants used by {@link module:utils.escapeXML}. + * + * It is used in the process of generating {@link ClientFunction}s. + * + * @readonly + * @type {String} + */ + + var escapeFuncStr = + 'var _ENCODE_HTML_RULES = {\n' + + ' "&": "&"\n' + + ' , "<": "<"\n' + + ' , ">": ">"\n' + + ' , \'"\': """\n' + + ' , "\'": "'"\n' + + ' }\n' + + ' , _MATCH_HTML = /[&<>\'"]/g;\n' + + 'function encode_char(c) {\n' + + ' return _ENCODE_HTML_RULES[c] || c;\n' + + '};\n'; + + /** + * Escape characters reserved in XML. + * + * If `markup` is `undefined` or `null`, the empty string is returned. + * + * @implements {EscapeCallback} + * @param {String} markup Input string + * @return {String} Escaped string + * @static + * @private + */ + + exports.escapeXML = function (markup) { + return markup == undefined + ? '' + : String(markup) + .replace(_MATCH_HTML, encode_char); + }; + + function escapeXMLToString() { + return Function.prototype.toString.call(this) + ';\n' + escapeFuncStr; + } + + try { + if (typeof Object.defineProperty === 'function') { + // If the Function prototype is frozen, the "toString" property is non-writable. This means that any objects which inherit this property + // cannot have the property changed using an assignment. If using strict mode, attempting that will cause an error. If not using strict + // mode, attempting that will be silently ignored. + // However, we can still explicitly shadow the prototype's "toString" property by defining a new "toString" property on this object. + Object.defineProperty(exports.escapeXML, 'toString', { value: escapeXMLToString }); + } else { + // If Object.defineProperty() doesn't exist, attempt to shadow this property using the assignment operator. + exports.escapeXML.toString = escapeXMLToString; + } + } catch (err) { + console.warn('Unable to set escapeXML.toString (is the Function prototype frozen?)'); + } + + /** + * Naive copy of properties from one object to another. + * Does not recurse into non-scalar properties + * Does not check to see if the property has a value before copying + * + * @param {Object} to Destination object + * @param {Object} from Source object + * @return {Object} Destination object + * @static + * @private + */ + exports.shallowCopy = function (to, from) { + from = from || {}; + if ((to !== null) && (to !== undefined)) { + for (var p in from) { + if (!hasOwn(from, p)) { + continue; + } + if (p === '__proto__' || p === 'constructor') { + continue; + } + to[p] = from[p]; + } + } + return to; + }; + + /** + * Naive copy of a list of key names, from one object to another. + * Only copies property if it is actually defined + * Does not recurse into non-scalar properties + * + * @param {Object} to Destination object + * @param {Object} from Source object + * @param {Array} list List of properties to copy + * @return {Object} Destination object + * @static + * @private + */ + exports.shallowCopyFromList = function (to, from, list) { + list = list || []; + from = from || {}; + if ((to !== null) && (to !== undefined)) { + for (var i = 0; i < list.length; i++) { + var p = list[i]; + if (typeof from[p] != 'undefined') { + if (!hasOwn(from, p)) { + continue; + } + if (p === '__proto__' || p === 'constructor') { + continue; + } + to[p] = from[p]; + } + } + } + return to; + }; + + /** + * Simple in-process cache implementation. Does not implement limits of any + * sort. + * + * @implements {Cache} + * @static + * @private + */ + exports.cache = { + _data: new Map(), + _capacity: 64, + set: function (key, val) { + if (this._data.has(key)) + this._data.delete(key); + this._data.set(key, val); + while (this._capacity > 0 && this._data.size > this._capacity) { + const first = this._data.keys().next().value; + this._data.delete(first); + } + }, + get: function (key) { + if (!this._data.has(key)) + return undefined; + const value = this._data.get(key); + this._data.delete(key); + this._data.set(key, value); + return value; + }, + remove: function (key) { + this._data.delete(key); + }, + reset: function () { + this._data.clear(); + } + }; + + /** + * Transforms hyphen case variable into camel case. + * + * @param {String} string Hyphen case string + * @return {String} Camel case string + * @static + * @private + */ + exports.hyphenToCamel = function (str) { + return str.replace(/-[a-z]/g, function (match) { return match[1].toUpperCase(); }); + }; + + /** + * Returns a null-prototype object in runtimes that support it + * + * @return {Object} Object, prototype will be set to null where possible + * @static + * @private + */ + exports.createNullProtoObjWherePossible = (function () { + if (typeof Object.create == 'function') { + return function () { + return Object.create(null); + }; + } + if (!({__proto__: null} instanceof Object)) { + return function () { + return {__proto__: null}; + }; + } + // Not possible, just pass through + return function () { + return {}; + }; + })(); + + exports.hasOwnOnlyObject = function (obj) { + var o = exports.createNullProtoObjWherePossible(); + for (var p in obj) { + if (hasOwn(obj, p)) { + o[p] = obj[p]; + } + } + return o; + }; + + + },{}],3:[function(require,module,exports){ + + },{}],4:[function(require,module,exports){ + (function (process){ + // .dirname, .basename, and .extname methods are extracted from Node.js v8.11.1, + // backported and transplited with Babel, with backwards-compat fixes + + // Copyright Joyent, Inc. and other Node contributors. + // + // Permission is hereby granted, free of charge, to any person obtaining a + // copy of this software and associated documentation files (the + // "Software"), to deal in the Software without restriction, including + // without limitation the rights to use, copy, modify, merge, publish, + // distribute, sublicense, and/or sell copies of the Software, and to permit + // persons to whom the Software is furnished to do so, subject to the + // following conditions: + // + // The above copyright notice and this permission notice shall be included + // in all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN + // NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + // DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE + // USE OR OTHER DEALINGS IN THE SOFTWARE. + + // resolves . and .. elements in a path array with directory names there + // must be no slashes, empty elements, or device names (c:\) in the array + // (so also no leading and trailing slashes - it does not distinguish + // relative and absolute paths) + function normalizeArray(parts, allowAboveRoot) { + // if the path tries to go above the root, `up` ends up > 0 + var up = 0; + for (var i = parts.length - 1; i >= 0; i--) { + var last = parts[i]; + if (last === '.') { + parts.splice(i, 1); + } else if (last === '..') { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + + // if the path is allowed to go above the root, restore leading ..s + if (allowAboveRoot) { + for (; up--; up) { + parts.unshift('..'); + } + } + + return parts; + } + + // path.resolve([from ...], to) + // posix version + exports.resolve = function() { + var resolvedPath = '', + resolvedAbsolute = false; + + for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) { + var path = (i >= 0) ? arguments[i] : process.cwd(); + + // Skip empty and invalid entries + if (typeof path !== 'string') { + throw new TypeError('Arguments to path.resolve must be strings'); + } else if (!path) { + continue; + } + + resolvedPath = path + '/' + resolvedPath; + resolvedAbsolute = path.charAt(0) === '/'; + } + + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + + // Normalize the path + resolvedPath = normalizeArray(filter(resolvedPath.split('/'), function(p) { + return !!p; + }), !resolvedAbsolute).join('/'); + + return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.'; + }; + + // path.normalize(path) + // posix version + exports.normalize = function(path) { + var isAbsolute = exports.isAbsolute(path), + trailingSlash = substr(path, -1) === '/'; + + // Normalize the path + path = normalizeArray(filter(path.split('/'), function(p) { + return !!p; + }), !isAbsolute).join('/'); + + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isAbsolute ? '/' : '') + path; + }; + + // posix version + exports.isAbsolute = function(path) { + return path.charAt(0) === '/'; + }; + + // posix version + exports.join = function() { + var paths = Array.prototype.slice.call(arguments, 0); + return exports.normalize(filter(paths, function(p, index) { + if (typeof p !== 'string') { + throw new TypeError('Arguments to path.join must be strings'); + } + return p; + }).join('/')); + }; + + + // path.relative(from, to) + // posix version + exports.relative = function(from, to) { + from = exports.resolve(from).substr(1); + to = exports.resolve(to).substr(1); + + function trim(arr) { + var start = 0; + for (; start < arr.length; start++) { + if (arr[start] !== '') break; + } + + var end = arr.length - 1; + for (; end >= 0; end--) { + if (arr[end] !== '') break; + } + + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + + var fromParts = trim(from.split('/')); + var toParts = trim(to.split('/')); + + var length = Math.min(fromParts.length, toParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + + var outputParts = []; + for (var i = samePartsLength; i < fromParts.length; i++) { + outputParts.push('..'); + } + + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + + return outputParts.join('/'); + }; + + exports.sep = '/'; + exports.delimiter = ':'; + + exports.dirname = function (path) { + if (typeof path !== 'string') path = path + ''; + if (path.length === 0) return '.'; + var code = path.charCodeAt(0); + var hasRoot = code === 47 /*/*/; + var end = -1; + var matchedSlash = true; + for (var i = path.length - 1; i >= 1; --i) { + code = path.charCodeAt(i); + if (code === 47 /*/*/) { + if (!matchedSlash) { + end = i; + break; + } + } else { + // We saw the first non-path separator + matchedSlash = false; + } + } + + if (end === -1) return hasRoot ? '/' : '.'; + if (hasRoot && end === 1) { + // return '//'; + // Backwards-compat fix: + return '/'; + } + return path.slice(0, end); + }; + + function basename(path) { + if (typeof path !== 'string') path = path + ''; + + var start = 0; + var end = -1; + var matchedSlash = true; + var i; + + for (i = path.length - 1; i >= 0; --i) { + if (path.charCodeAt(i) === 47 /*/*/) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + start = i + 1; + break; + } + } else if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // path component + matchedSlash = false; + end = i + 1; + } + } + + if (end === -1) return ''; + return path.slice(start, end); + } + + // Uses a mixed approach for backwards-compatibility, as ext behavior changed + // in new Node.js versions, so only basename() above is backported here + exports.basename = function (path, ext) { + var f = basename(path); + if (ext && f.substr(-1 * ext.length) === ext) { + f = f.substr(0, f.length - ext.length); + } + return f; + }; + + exports.extname = function (path) { + if (typeof path !== 'string') path = path + ''; + var startDot = -1; + var startPart = 0; + var end = -1; + var matchedSlash = true; + // Track the state of characters (if any) we see before our first dot and + // after any path separator we find + var preDotState = 0; + for (var i = path.length - 1; i >= 0; --i) { + var code = path.charCodeAt(i); + if (code === 47 /*/*/) { + // If we reached a path separator that was not part of a set of path + // separators at the end of the string, stop now + if (!matchedSlash) { + startPart = i + 1; + break; + } + continue; + } + if (end === -1) { + // We saw the first non-path separator, mark this as the end of our + // extension + matchedSlash = false; + end = i + 1; + } + if (code === 46 /*.*/) { + // If this is our first dot, mark it as the start of our extension + if (startDot === -1) + startDot = i; + else if (preDotState !== 1) + preDotState = 1; + } else if (startDot !== -1) { + // We saw a non-dot and non-path separator before our dot, so we should + // have a good chance at having a non-empty extension + preDotState = -1; + } + } + + if (startDot === -1 || end === -1 || + // We saw a non-dot character immediately before the dot + preDotState === 0 || + // The (right-most) trimmed path component is exactly '..' + preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) { + return ''; + } + return path.slice(startDot, end); + }; + + function filter (xs, f) { + if (xs.filter) return xs.filter(f); + var res = []; + for (var i = 0; i < xs.length; i++) { + if (f(xs[i], i, xs)) res.push(xs[i]); + } + return res; + } + + // String.prototype.substr - negative index don't work in IE8 + var substr = 'ab'.substr(-1) === 'b' + ? function (str, start, len) { return str.substr(start, len) } + : function (str, start, len) { + if (start < 0) start = str.length + start; + return str.substr(start, len); + } + ; + + }).call(this,require('_process')) + },{"_process":5}],5:[function(require,module,exports){ + // shim for using process in browser + var process = module.exports = {}; + + // cached from whatever global is present so that test runners that stub it + // don't break things. But we need to wrap it in a try catch in case it is + // wrapped in strict mode code which doesn't define any globals. It's inside a + // function because try/catches deoptimize in certain engines. + + var cachedSetTimeout; + var cachedClearTimeout; + + function defaultSetTimout() { + throw new Error('setTimeout has not been defined'); + } + function defaultClearTimeout () { + throw new Error('clearTimeout has not been defined'); + } + (function () { + try { + if (typeof setTimeout === 'function') { + cachedSetTimeout = setTimeout; + } else { + cachedSetTimeout = defaultSetTimout; + } + } catch (e) { + cachedSetTimeout = defaultSetTimout; + } + try { + if (typeof clearTimeout === 'function') { + cachedClearTimeout = clearTimeout; + } else { + cachedClearTimeout = defaultClearTimeout; + } + } catch (e) { + cachedClearTimeout = defaultClearTimeout; + } + } ()) + function runTimeout(fun) { + if (cachedSetTimeout === setTimeout) { + //normal enviroments in sane situations + return setTimeout(fun, 0); + } + // if setTimeout wasn't available but was latter defined + if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) { + cachedSetTimeout = setTimeout; + return setTimeout(fun, 0); + } + try { + // when when somebody has screwed with setTimeout but no I.E. maddness + return cachedSetTimeout(fun, 0); + } catch(e){ + try { + // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally + return cachedSetTimeout.call(null, fun, 0); + } catch(e){ + // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error + return cachedSetTimeout.call(this, fun, 0); + } + } + + + } + function runClearTimeout(marker) { + if (cachedClearTimeout === clearTimeout) { + //normal enviroments in sane situations + return clearTimeout(marker); + } + // if clearTimeout wasn't available but was latter defined + if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) { + cachedClearTimeout = clearTimeout; + return clearTimeout(marker); + } + try { + // when when somebody has screwed with setTimeout but no I.E. maddness + return cachedClearTimeout(marker); + } catch (e){ + try { + // When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally + return cachedClearTimeout.call(null, marker); + } catch (e){ + // same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error. + // Some versions of I.E. have different rules for clearTimeout vs setTimeout + return cachedClearTimeout.call(this, marker); + } + } + + + + } + var queue = []; + var draining = false; + var currentQueue; + var queueIndex = -1; + + function cleanUpNextTick() { + if (!draining || !currentQueue) { + return; + } + draining = false; + if (currentQueue.length) { + queue = currentQueue.concat(queue); + } else { + queueIndex = -1; + } + if (queue.length) { + drainQueue(); + } + } + + function drainQueue() { + if (draining) { + return; + } + var timeout = runTimeout(cleanUpNextTick); + draining = true; + + var len = queue.length; + while(len) { + currentQueue = queue; + queue = []; + while (++queueIndex < len) { + if (currentQueue) { + currentQueue[queueIndex].run(); + } + } + queueIndex = -1; + len = queue.length; + } + currentQueue = null; + draining = false; + runClearTimeout(timeout); + } + + process.nextTick = function (fun) { + var args = new Array(arguments.length - 1); + if (arguments.length > 1) { + for (var i = 1; i < arguments.length; i++) { + args[i - 1] = arguments[i]; + } + } + queue.push(new Item(fun, args)); + if (queue.length === 1 && !draining) { + runTimeout(drainQueue); + } + }; + + // v8 likes predictible objects + function Item(fun, array) { + this.fun = fun; + this.array = array; + } + Item.prototype.run = function () { + this.fun.apply(null, this.array); + }; + process.title = 'browser'; + process.browser = true; + process.env = {}; + process.argv = []; + process.version = ''; // empty string to avoid regexp issues + process.versions = {}; + + function noop() {} + + process.on = noop; + process.addListener = noop; + process.once = noop; + process.off = noop; + process.removeListener = noop; + process.removeAllListeners = noop; + process.emit = noop; + process.prependListener = noop; + process.prependOnceListener = noop; + + process.listeners = function (name) { return [] } + + process.binding = function (name) { + throw new Error('process.binding is not supported'); + }; + + process.cwd = function () { return '/' }; + process.chdir = function (dir) { + throw new Error('process.chdir is not supported'); + }; + process.umask = function() { return 0; }; + + },{}],6:[function(require,module,exports){ + module.exports={ + "name": "ejs", + "description": "Embedded JavaScript templates", + "keywords": [ + "template", + "engine", + "ejs" + ], + "version": "3.1.9", + "author": "Matthew Eernisse (http://fleegix.org)", + "license": "Apache-2.0", + "bin": { + "ejs": "./bin/cli.js" + }, + "main": "./lib/ejs.js", + "jsdelivr": "ejs.min.js", + "unpkg": "ejs.min.js", + "repository": { + "type": "git", + "url": "git://github.com/mde/ejs.git" + }, + "bugs": "https://github.com/mde/ejs/issues", + "homepage": "https://github.com/mde/ejs", + "dependencies": { + "jake": "^10.8.5" + }, + "devDependencies": { + "browserify": "^16.5.1", + "eslint": "^6.8.0", + "git-directory-deploy": "^1.5.1", + "jsdoc": "^4.0.2", + "lru-cache": "^4.0.1", + "mocha": "^10.2.0", + "uglify-js": "^3.3.16" + }, + "engines": { + "node": ">=0.10.0" + }, + "scripts": { + "test": "npx jake test" + } + } + + },{}]},{},[1])(1) + }); + \ No newline at end of file