feat: Phase 3 世界书引擎移植 + EJS 支持

- 新增 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: 世界书引擎测试
This commit is contained in:
Youzini-afk
2026-03-26 13:57:07 +08:00
parent 2f9524d993
commit d31c0325d3
12 changed files with 3963 additions and 93 deletions

View File

@@ -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 || []),
],
});
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,87 +1,18 @@
// ST-BME: Prompt BuilderPhase 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));
});
}

View File

@@ -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: "输出规则",

View File

@@ -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)) {

View File

@@ -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 ||

842
task-ejs.js Normal file
View File

@@ -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);
}
}

870
task-worldinfo.js Normal file
View File

@@ -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;
}

View File

@@ -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;

207
tests/task-worldinfo.mjs Normal file
View File

@@ -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;
}
}

1793
vendor/ejs.js vendored Normal file

File diff suppressed because it is too large Load Diff