mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
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:
@@ -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 || []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
21
extractor.js
21
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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: "输出规则",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
842
task-ejs.js
Normal 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
870
task-worldinfo.js
Normal 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;
|
||||
}
|
||||
@@ -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
207
tests/task-worldinfo.mjs
Normal 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
1793
vendor/ejs.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user