mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
- 新增 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: 世界书引擎测试
871 lines
23 KiB
JavaScript
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;
|
|
}
|