Files
ST-Bionic-Memory-Ecology/task-worldinfo.js
Youzini-afk d31c0325d3 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: 世界书引擎测试
2026-03-26 13:57:07 +08:00

871 lines
23 KiB
JavaScript

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