mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
1237 lines
35 KiB
JavaScript
1237 lines
35 KiB
JavaScript
// ST-BME: 任务级世界书激活引擎
|
||
// 复刻 Evolution_World 的世界书来源、激活与 EJS 渲染主逻辑,
|
||
// 但只接入 ST-BME 的任务预设系统,不引入完整工作流调度层。
|
||
|
||
import {
|
||
createTaskEjsRenderContext,
|
||
evalTaskEjsTemplate,
|
||
inspectTaskEjsRuntimeBackend,
|
||
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 WORLDINFO_CACHE_TTL_MS = 3000;
|
||
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]",
|
||
];
|
||
|
||
let worldbookEntriesCache = {
|
||
key: "",
|
||
createdAt: 0,
|
||
expiresAt: 0,
|
||
entries: [],
|
||
debug: null,
|
||
};
|
||
|
||
function getStContext() {
|
||
try {
|
||
return globalThis.SillyTavern?.getContext?.() || {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function getLegacyWorldbookApi(name) {
|
||
const fn = globalThis[name];
|
||
return typeof fn === "function" ? fn : null;
|
||
}
|
||
|
||
async function getWorldbookHost() {
|
||
const legacyGetWorldbook = getLegacyWorldbookApi("getWorldbook");
|
||
const legacyGetLorebookEntries = getLegacyWorldbookApi("getLorebookEntries");
|
||
const legacyGetCharWorldbookNames = getLegacyWorldbookApi(
|
||
"getCharWorldbookNames",
|
||
);
|
||
|
||
try {
|
||
const { getHostAdapter } = await import("./host-adapter/index.js");
|
||
const adapter = getHostAdapter?.() || null;
|
||
const adapterSnapshot = adapter?.getSnapshot?.() || null;
|
||
const worldbookHost = adapter?.worldbook || null;
|
||
if (typeof worldbookHost?.getWorldbook === "function") {
|
||
const capabilitySupport = worldbookHost.readCapabilitySupport?.() || {};
|
||
const bridgeGetLorebookEntries =
|
||
typeof worldbookHost.getLorebookEntries === "function"
|
||
? worldbookHost.getLorebookEntries
|
||
: null;
|
||
const bridgeGetCharWorldbookNames =
|
||
typeof worldbookHost.getCharWorldbookNames === "function"
|
||
? worldbookHost.getCharWorldbookNames
|
||
: null;
|
||
const supplementedCapabilities = [];
|
||
const missingCapabilities = [];
|
||
|
||
const resolvedGetLorebookEntries =
|
||
bridgeGetLorebookEntries || legacyGetLorebookEntries;
|
||
if (!bridgeGetLorebookEntries) {
|
||
if (resolvedGetLorebookEntries) {
|
||
supplementedCapabilities.push("getLorebookEntries");
|
||
} else {
|
||
missingCapabilities.push("getLorebookEntries");
|
||
}
|
||
}
|
||
|
||
const resolvedGetCharWorldbookNames =
|
||
bridgeGetCharWorldbookNames || legacyGetCharWorldbookNames;
|
||
if (!bridgeGetCharWorldbookNames) {
|
||
if (resolvedGetCharWorldbookNames) {
|
||
supplementedCapabilities.push("getCharWorldbookNames");
|
||
} else {
|
||
missingCapabilities.push("getCharWorldbookNames");
|
||
}
|
||
}
|
||
|
||
return {
|
||
getWorldbook: worldbookHost.getWorldbook,
|
||
getLorebookEntries: resolvedGetLorebookEntries,
|
||
getCharWorldbookNames: resolvedGetCharWorldbookNames,
|
||
sourceLabel: capabilitySupport.sourceLabel || "host-adapter.worldbook",
|
||
fallback:
|
||
Boolean(capabilitySupport.fallback) ||
|
||
supplementedCapabilities.length > 0,
|
||
capabilityStatus: Object.freeze({
|
||
mode: capabilitySupport.mode || "unknown",
|
||
supplementedCapabilities: Object.freeze(supplementedCapabilities),
|
||
missingCapabilities: Object.freeze(missingCapabilities),
|
||
}),
|
||
snapshotRevision: Number(adapterSnapshot?.snapshotRevision || 0),
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.debug(
|
||
"[ST-BME] task-worldinfo 读取 worldbook bridge 失败,回退到 legacy 宿主接口",
|
||
error,
|
||
);
|
||
}
|
||
|
||
const missingCapabilities = [];
|
||
if (typeof legacyGetLorebookEntries !== "function") {
|
||
missingCapabilities.push("getLorebookEntries");
|
||
}
|
||
if (typeof legacyGetCharWorldbookNames !== "function") {
|
||
missingCapabilities.push("getCharWorldbookNames");
|
||
}
|
||
|
||
return {
|
||
getWorldbook: legacyGetWorldbook,
|
||
getLorebookEntries: legacyGetLorebookEntries,
|
||
getCharWorldbookNames: legacyGetCharWorldbookNames,
|
||
sourceLabel: "legacy.globalThis",
|
||
fallback: true,
|
||
capabilityStatus: Object.freeze({
|
||
mode: "legacy",
|
||
supplementedCapabilities: Object.freeze([]),
|
||
missingCapabilities: Object.freeze(missingCapabilities),
|
||
}),
|
||
snapshotRevision: 0,
|
||
};
|
||
}
|
||
|
||
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 Map();
|
||
|
||
const addActivated = (entry, activationDebug = {}) => {
|
||
const key = `${entry.worldbook}:${entry.uid}:${entry.name}`;
|
||
activated.set(key, {
|
||
...entry,
|
||
activationDebug: {
|
||
mode: activationDebug.mode || "",
|
||
matchedPrimaryKey: activationDebug.matchedPrimaryKey || "",
|
||
matchedSecondaryKeys: Array.isArray(activationDebug.matchedSecondaryKeys)
|
||
? activationDebug.matchedSecondaryKeys
|
||
: [],
|
||
},
|
||
});
|
||
};
|
||
|
||
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) {
|
||
addActivated(entry, { mode: "constant" });
|
||
continue;
|
||
}
|
||
|
||
if (entry.decorators.includes("@@activate")) {
|
||
addActivated(entry, { mode: "forced" });
|
||
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) {
|
||
addActivated(entry, {
|
||
mode: "selective",
|
||
matchedPrimaryKey: matchedPrimary,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
let hasAnyMatch = false;
|
||
let hasAllMatch = true;
|
||
const matchedSecondaryKeys = [];
|
||
|
||
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 (hasMatch) matchedSecondaryKeys.push(substituted.trim());
|
||
|
||
if (entry.selectiveLogic === WI_LOGIC.AND_ANY && hasMatch) {
|
||
addActivated(entry, {
|
||
mode: "selective",
|
||
matchedPrimaryKey: matchedPrimary,
|
||
matchedSecondaryKeys,
|
||
});
|
||
break;
|
||
}
|
||
|
||
if (entry.selectiveLogic === WI_LOGIC.NOT_ALL && !hasMatch) {
|
||
addActivated(entry, {
|
||
mode: "selective",
|
||
matchedPrimaryKey: matchedPrimary,
|
||
matchedSecondaryKeys,
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (entry.selectiveLogic === WI_LOGIC.NOT_ANY && !hasAnyMatch) {
|
||
addActivated(entry, {
|
||
mode: "selective",
|
||
matchedPrimaryKey: matchedPrimary,
|
||
matchedSecondaryKeys,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
if (entry.selectiveLogic === WI_LOGIC.AND_ALL && hasAllMatch) {
|
||
addActivated(entry, {
|
||
mode: "selective",
|
||
matchedPrimaryKey: matchedPrimary,
|
||
matchedSecondaryKeys,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (activated.size === 0) {
|
||
return [];
|
||
}
|
||
|
||
const grouped = groupBy([...activated.values()], (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,
|
||
getLorebookEntries,
|
||
getCharWorldbookNames,
|
||
sourceLabel,
|
||
fallback,
|
||
capabilityStatus,
|
||
snapshotRevision,
|
||
} = await getWorldbookHost();
|
||
const ctx = getStContext();
|
||
const debug = {
|
||
sourceLabel,
|
||
fallback,
|
||
capabilityStatus,
|
||
snapshotRevision: Number(snapshotRevision || 0),
|
||
requestedWorldbooks: [],
|
||
loadedWorldbooks: [],
|
||
worldbookCount: 0,
|
||
cache: {
|
||
hit: false,
|
||
key: "",
|
||
ageMs: 0,
|
||
ttlMs: WORLDINFO_CACHE_TTL_MS,
|
||
},
|
||
loadMs: 0,
|
||
};
|
||
if (!getWorldbook) {
|
||
return {
|
||
entries: [],
|
||
debug,
|
||
};
|
||
}
|
||
|
||
const sourceTag = `${sourceLabel}${fallback ? ", fallback" : ""}`;
|
||
const supplementedCapabilities =
|
||
capabilityStatus?.supplementedCapabilities || [];
|
||
const missingCapabilities = capabilityStatus?.missingCapabilities || [];
|
||
if (supplementedCapabilities.length > 0) {
|
||
console.debug(
|
||
`[ST-BME] task-worldinfo worldbook bridge 已通过 legacy 补齐关键能力: ${supplementedCapabilities.join(", ")} [${sourceTag}]`,
|
||
);
|
||
}
|
||
if (missingCapabilities.length > 0) {
|
||
console.warn(
|
||
`[ST-BME] task-worldinfo worldbook host 缺失关键能力,将显式降级相关旧语义: ${missingCapabilities.join(", ")} [${sourceTag}]`,
|
||
);
|
||
}
|
||
|
||
const charWorldbooks = {
|
||
primary: "",
|
||
additional: [],
|
||
};
|
||
if (getCharWorldbookNames) {
|
||
try {
|
||
const resolved = getCharWorldbookNames("current") || {};
|
||
charWorldbooks.primary = normalizeKey(resolved.primary);
|
||
charWorldbooks.additional = Array.isArray(resolved.additional)
|
||
? resolved.additional.map((name) => normalizeKey(name)).filter(Boolean)
|
||
: [];
|
||
} catch (error) {
|
||
console.debug(
|
||
`[ST-BME] task-worldinfo 读取角色世界书失败 [${sourceTag}]`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
const personaLorebook =
|
||
ctx.extensionSettings?.persona_description_lorebook ||
|
||
ctx.powerUserSettings?.persona_description_lorebook ||
|
||
ctx.power_user?.persona_description_lorebook ||
|
||
"";
|
||
const chatLorebook = ctx.chatMetadata?.world || "";
|
||
|
||
const requestedWorldbooks = uniq(
|
||
[
|
||
charWorldbooks.primary,
|
||
...(charWorldbooks.additional || []),
|
||
personaLorebook,
|
||
chatLorebook,
|
||
]
|
||
.map((name) => normalizeKey(name))
|
||
.filter(Boolean),
|
||
);
|
||
debug.requestedWorldbooks = requestedWorldbooks;
|
||
|
||
const cacheKey = JSON.stringify({
|
||
chatId: ctx.chatId || globalThis.getCurrentChatId?.() || "",
|
||
characterId: ctx.characterId ?? "",
|
||
requestedWorldbooks,
|
||
sourceLabel,
|
||
fallback,
|
||
snapshotRevision: Number(snapshotRevision || 0),
|
||
});
|
||
debug.cache.key = cacheKey;
|
||
|
||
if (
|
||
worldbookEntriesCache.key === cacheKey &&
|
||
worldbookEntriesCache.expiresAt > Date.now()
|
||
) {
|
||
return {
|
||
entries: worldbookEntriesCache.entries,
|
||
debug: {
|
||
...debug,
|
||
loadedWorldbooks:
|
||
worldbookEntriesCache.debug?.loadedWorldbooks || requestedWorldbooks,
|
||
worldbookCount: worldbookEntriesCache.entries.length,
|
||
loadMs: worldbookEntriesCache.debug?.loadMs || 0,
|
||
cache: {
|
||
...debug.cache,
|
||
hit: true,
|
||
ageMs: Math.max(0, Date.now() - worldbookEntriesCache.createdAt),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
const allEntries = [];
|
||
const loadedNames = new Set();
|
||
const startedAt = Date.now();
|
||
|
||
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} [${sourceTag}]`,
|
||
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} [${sourceTag}]`,
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
|
||
for (const worldbookName of requestedWorldbooks) {
|
||
await loadWorldbookOnce(worldbookName);
|
||
}
|
||
|
||
debug.loadedWorldbooks = [...loadedNames];
|
||
debug.worldbookCount = allEntries.length;
|
||
debug.loadMs = Date.now() - startedAt;
|
||
worldbookEntriesCache = {
|
||
key: cacheKey,
|
||
createdAt: Date.now(),
|
||
expiresAt: Date.now() + WORLDINFO_CACHE_TTL_MS,
|
||
entries: allEntries,
|
||
debug: {
|
||
...debug,
|
||
},
|
||
};
|
||
|
||
return {
|
||
entries: allEntries,
|
||
debug,
|
||
};
|
||
}
|
||
|
||
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,
|
||
activationDebug:
|
||
entry.activationDebug && typeof entry.activationDebug === "object"
|
||
? {
|
||
...entry.activationDebug,
|
||
}
|
||
: null,
|
||
controllerSource: String(entry.controllerSource || ""),
|
||
};
|
||
}
|
||
|
||
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: [],
|
||
debug: {
|
||
sourceLabel: "",
|
||
fallback: false,
|
||
capabilityStatus: null,
|
||
snapshotRevision: 0,
|
||
requestedWorldbooks: [],
|
||
loadedWorldbooks: [],
|
||
worldbookCount: 0,
|
||
triggerLength: 0,
|
||
activatedEntryCount: 0,
|
||
constantActivatedCount: 0,
|
||
selectiveActivatedCount: 0,
|
||
controllerActivatedCount: 0,
|
||
controllerPulledCount: 0,
|
||
cache: {
|
||
hit: false,
|
||
key: "",
|
||
ageMs: 0,
|
||
ttlMs: WORLDINFO_CACHE_TTL_MS,
|
||
},
|
||
loadMs: 0,
|
||
ejsRuntimeStatus: "",
|
||
ejsRuntimeFallback: false,
|
||
ejsLastError: "",
|
||
warnings: [],
|
||
resolvedEntries: [],
|
||
},
|
||
};
|
||
|
||
try {
|
||
const collected = await collectAllWorldbookEntries();
|
||
const allEntries = Array.isArray(collected?.entries) ? collected.entries : [];
|
||
result.allEntries = allEntries;
|
||
result.debug = {
|
||
...result.debug,
|
||
...(collected?.debug || {}),
|
||
cache: {
|
||
...result.debug.cache,
|
||
...(collected?.debug?.cache || {}),
|
||
},
|
||
warnings: Array.isArray(result.debug.warnings)
|
||
? result.debug.warnings
|
||
: [],
|
||
resolvedEntries: Array.isArray(result.debug.resolvedEntries)
|
||
? result.debug.resolvedEntries
|
||
: [],
|
||
};
|
||
if (allEntries.length === 0) {
|
||
return result;
|
||
}
|
||
|
||
const triggerTexts = buildActivationSourceTexts({
|
||
chatMessages,
|
||
userMessage,
|
||
templateContext,
|
||
});
|
||
const trigger = triggerTexts.join("\n\n");
|
||
result.debug.triggerLength = trigger.length;
|
||
const ejsBackend = await inspectTaskEjsRuntimeBackend();
|
||
result.debug.ejsRuntimeStatus = ejsBackend.status || "";
|
||
result.debug.ejsRuntimeFallback = Boolean(ejsBackend.isFallback);
|
||
result.debug.ejsLastError = ejsBackend.error
|
||
? ejsBackend.error instanceof Error
|
||
? ejsBackend.error.message
|
||
: String(ejsBackend.error)
|
||
: "";
|
||
|
||
const activated = selectActivatedEntries(allEntries, trigger, {
|
||
...templateContext,
|
||
user_input: userMessage || templateContext?.user_input || "",
|
||
});
|
||
result.debug.activatedEntryCount = activated.length;
|
||
result.debug.constantActivatedCount = activated.filter(
|
||
(entry) => entry.activationDebug?.mode === "constant",
|
||
).length;
|
||
result.debug.selectiveActivatedCount = activated.filter(
|
||
(entry) => entry.activationDebug?.mode === "selective",
|
||
).length;
|
||
result.debug.controllerActivatedCount = activated.filter((entry) =>
|
||
entry.name.startsWith(String(
|
||
settings.worldInfoControllerEntryPrefix ||
|
||
settings.controller_entry_prefix ||
|
||
DEFAULT_CONTROLLER_ENTRY_PREFIX,
|
||
)),
|
||
).length;
|
||
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;
|
||
const isControllerEntry = entry.name.startsWith(String(controllerPrefix || ""));
|
||
let renderedContent = sourceContent;
|
||
try {
|
||
renderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, {
|
||
world_info: {
|
||
comment: entry.comment || entry.name,
|
||
name: entry.name,
|
||
world: entry.worldbook,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
result.debug.warnings.push(
|
||
error?.code === "st_bme_task_ejs_runtime_unavailable"
|
||
? `世界书条目 ${entry.name} 依赖 EJS runtime,当前已跳过`
|
||
: `世界书条目 ${entry.name} 渲染失败,已跳过`,
|
||
);
|
||
console.warn(
|
||
`[ST-BME] task-worldinfo 渲染世界书条目失败: ${entry.name}`,
|
||
error,
|
||
);
|
||
if (
|
||
error?.code === "st_bme_task_ejs_runtime_unavailable" &&
|
||
!result.debug.ejsLastError
|
||
) {
|
||
result.debug.ejsLastError =
|
||
error instanceof Error ? error.message : String(error);
|
||
}
|
||
renderedContent = "";
|
||
}
|
||
|
||
if (!isControllerEntry && !String(renderedContent || "").trim()) {
|
||
continue;
|
||
}
|
||
|
||
const bucketName = classifyPosition(entry);
|
||
const bucket =
|
||
bucketName === "before"
|
||
? beforeEntries
|
||
: bucketName === "after"
|
||
? afterEntries
|
||
: atDepthEntries;
|
||
|
||
if (isControllerEntry) {
|
||
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,
|
||
activationDebug: {
|
||
...(entry.activationDebug || {}),
|
||
mode: "controller-pulled",
|
||
},
|
||
controllerSource: entry.name,
|
||
},
|
||
resolvedIndex++,
|
||
),
|
||
);
|
||
result.debug.controllerPulledCount += 1;
|
||
}
|
||
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,
|
||
activationDebug: entry.activationDebug,
|
||
},
|
||
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.debug.resolvedEntries = [
|
||
...result.beforeEntries.map((entry) => ({
|
||
name: entry.name,
|
||
bucket: "before",
|
||
sourceName: entry.sourceName,
|
||
activationMode: entry.activationDebug?.mode || "",
|
||
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
|
||
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
|
||
controllerSource: entry.controllerSource || "",
|
||
})),
|
||
...result.afterEntries.map((entry) => ({
|
||
name: entry.name,
|
||
bucket: "after",
|
||
sourceName: entry.sourceName,
|
||
activationMode: entry.activationDebug?.mode || "",
|
||
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
|
||
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
|
||
controllerSource: entry.controllerSource || "",
|
||
})),
|
||
...result.atDepthEntries.map((entry) => ({
|
||
name: entry.name,
|
||
bucket: "atDepth",
|
||
sourceName: entry.sourceName,
|
||
activationMode: entry.activationDebug?.mode || "",
|
||
matchedPrimaryKey: entry.activationDebug?.matchedPrimaryKey || "",
|
||
matchedSecondaryKeys: entry.activationDebug?.matchedSecondaryKeys || [],
|
||
controllerSource: entry.controllerSource || "",
|
||
})),
|
||
];
|
||
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;
|
||
}
|