feat: integrate host bridge prompt pipeline

This commit is contained in:
Youzini-afk
2026-03-26 22:24:45 +08:00
parent 0fcc50997e
commit 777edf9f9a
22 changed files with 2710 additions and 307 deletions

View File

@@ -265,10 +265,11 @@ async function summarizeBatch(
maxRetries: 1,
signal,
taskType: "compress",
additionalMessages: [
...(compressPromptBuild.customMessages || []),
...(compressPromptBuild.additionalMessages || []),
],
additionalMessages:
compressPromptBuild.privateTaskMessages || [
...(compressPromptBuild.customMessages || []),
...(compressPromptBuild.additionalMessages || []),
],
});
}

View File

@@ -315,10 +315,11 @@ export async function consolidateMemories({
maxRetries: 1,
signal,
taskType: "consolidation",
additionalMessages: [
...(consolidationPromptBuild.customMessages || []),
...(consolidationPromptBuild.additionalMessages || []),
],
additionalMessages:
consolidationPromptBuild.privateTaskMessages || [
...(consolidationPromptBuild.customMessages || []),
...(consolidationPromptBuild.additionalMessages || []),
],
});
} catch (e) {
if (isAbortError(e)) throw e;

View File

@@ -152,10 +152,11 @@ export async function extractMemories({
maxRetries: 2,
signal,
taskType: "extract",
additionalMessages: [
...(promptBuild.customMessages || []),
...(promptBuild.additionalMessages || []),
],
additionalMessages:
promptBuild.privateTaskMessages || [
...(promptBuild.customMessages || []),
...(promptBuild.additionalMessages || []),
],
});
throwIfAborted(signal);
@@ -668,10 +669,11 @@ export async function generateSynopsis({
maxRetries: 1,
signal,
taskType: "synopsis",
additionalMessages: [
...(synopsisPromptBuild.customMessages || []),
...(synopsisPromptBuild.additionalMessages || []),
],
additionalMessages:
synopsisPromptBuild.privateTaskMessages || [
...(synopsisPromptBuild.customMessages || []),
...(synopsisPromptBuild.additionalMessages || []),
],
});
if (!result?.summary) return;
@@ -791,10 +793,11 @@ export async function generateReflection({
maxRetries: 1,
signal,
taskType: "reflection",
additionalMessages: [
...(reflectionPromptBuild.customMessages || []),
...(reflectionPromptBuild.additionalMessages || []),
],
additionalMessages:
reflectionPromptBuild.privateTaskMessages || [
...(reflectionPromptBuild.customMessages || []),
...(reflectionPromptBuild.additionalMessages || []),
],
});
if (!result?.insight) return null;

View File

@@ -0,0 +1,113 @@
const DEFAULT_UNAVAILABLE_REASON = "宿主能力不可用";
export const HOST_ADAPTER_VERSION = "phase1-bridge-skeleton";
export function normalizeVersionHints(versionHints = {}) {
const normalized = {};
for (const [key, rawValue] of Object.entries(versionHints || {})) {
if (rawValue == null || rawValue === "") continue;
if (Array.isArray(rawValue)) {
const values = rawValue
.map((value) => String(value ?? "").trim())
.filter(Boolean);
if (values.length > 0) {
normalized[key] = values;
}
continue;
}
if (typeof rawValue === "object") {
const nested = normalizeVersionHints(rawValue);
if (Object.keys(nested).length > 0) {
normalized[key] = nested;
}
continue;
}
normalized[key] = String(rawValue).trim();
}
return Object.freeze(normalized);
}
export function mergeVersionHints(...sources) {
const merged = {};
for (const source of sources) {
if (!source || typeof source !== "object") continue;
for (const [key, value] of Object.entries(source)) {
if (value == null || value === "") continue;
merged[key] = Array.isArray(value) ? [...value] : value;
}
}
return normalizeVersionHints(merged);
}
export function buildCapabilityStatus({
available = false,
mode = "",
fallbackReason = "",
versionHints = {},
} = {}) {
const normalizedAvailable = Boolean(available);
const normalizedMode =
String(mode || "").trim() ||
(normalizedAvailable ? "available" : "unavailable");
return Object.freeze({
available: normalizedAvailable,
mode: normalizedMode,
fallbackReason: normalizedAvailable
? String(fallbackReason || "").trim()
: String(fallbackReason || DEFAULT_UNAVAILABLE_REASON).trim(),
versionHints: normalizeVersionHints(versionHints),
});
}
export function buildCapabilityCollectionSnapshot(
capabilities = {},
options = {},
) {
const normalizedCapabilities = {};
const capabilityNames = Object.keys(capabilities || {});
let availableCount = 0;
for (const name of capabilityNames) {
const capability = buildCapabilityStatus(capabilities[name]);
normalizedCapabilities[name] = capability;
if (capability.available) {
availableCount += 1;
}
}
const totalCount = capabilityNames.length;
const available = availableCount > 0;
const mode =
totalCount === 0
? "empty"
: availableCount === totalCount
? "full"
: available
? "partial"
: "fallback";
return Object.freeze({
available,
mode,
fallbackReason:
available || totalCount === 0 ? "" : "未检测到可用宿主桥接能力",
versionHints: mergeVersionHints(
{
adapter: HOST_ADAPTER_VERSION,
availableCount: String(availableCount),
totalCount: String(totalCount),
},
options.versionHints,
),
...normalizedCapabilities,
});
}

88
host-adapter/context.js Normal file
View File

@@ -0,0 +1,88 @@
import { getContext as extensionGetContext } from "../../../extensions.js";
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
function resolveContextGetter(providedGetter = null) {
if (typeof providedGetter === "function") {
return providedGetter;
}
if (typeof extensionGetContext === "function") {
return extensionGetContext;
}
const globalGetter = globalThis?.SillyTavern?.getContext;
return typeof globalGetter === "function" ? globalGetter : null;
}
function detectContextMode(getContext) {
if (typeof getContext !== "function") {
return "unavailable";
}
if (getContext === extensionGetContext) {
return "extensions-api";
}
return "global-api";
}
export function createContextHostFacade(options = {}) {
const getContext = resolveContextGetter(options.getContext);
const available = typeof getContext === "function";
const mode = detectContextMode(getContext);
return Object.freeze({
available,
mode,
fallbackReason: available ? "" : "未检测到 getContext 宿主接口",
versionHints: mergeVersionHints(
{
getter: "getContext",
source: mode,
sillyTavernGlobal:
globalThis?.SillyTavern && typeof globalThis.SillyTavern === "object"
? "available"
: "missing",
},
options.versionHints,
),
getContext: (...args) => {
if (!available) {
return null;
}
try {
return getContext(...args);
} catch (error) {
console.debug(
"[ST-BME] host-adapter/context getContext 调用失败",
error,
);
return null;
}
},
readContextSnapshot: (...args) => {
if (!available) {
return null;
}
try {
const context = getContext(...args);
return context && typeof context === "object" ? context : null;
} catch (error) {
console.debug("[ST-BME] host-adapter/context 读取上下文失败", error);
return null;
}
},
});
}
export function inspectContextHostCapability(options = {}) {
const facade = createContextHostFacade(options);
return buildCapabilityStatus(facade);
}
export function readHostContext(options = {}) {
return createContextHostFacade(options).readContextSnapshot();
}

176
host-adapter/index.js Normal file
View File

@@ -0,0 +1,176 @@
import {
HOST_ADAPTER_VERSION,
buildCapabilityCollectionSnapshot,
buildCapabilityStatus,
mergeVersionHints,
} from "./capabilities.js";
import { createContextHostFacade } from "./context.js";
import { createInjectionHostFacade } from "./injection.js";
import { createRegexHostFacade } from "./regex.js";
import { createWorldbookHostFacade } from "./worldbook.js";
export const HOST_ADAPTER_STATE_SEMANTICS =
"manual-refresh-diagnostic-snapshot";
export const HOST_ADAPTER_REFRESH_MODE = "manual-rebuild";
let currentHostAdapter = null;
let currentHostAdapterOptions = {};
let currentHostAdapterRevision = 0;
function createSnapshotMetadata(options = {}) {
const snapshotRevision = Number.isSafeInteger(options.snapshotRevision)
? options.snapshotRevision
: 0;
const snapshotCreatedAt =
typeof options.snapshotCreatedAt === "string" && options.snapshotCreatedAt
? options.snapshotCreatedAt
: new Date().toISOString();
return Object.freeze({
stateSemantics: String(
options.stateSemantics || HOST_ADAPTER_STATE_SEMANTICS,
),
refreshMode: String(options.refreshMode || HOST_ADAPTER_REFRESH_MODE),
snapshotRevision,
snapshotCreatedAt,
});
}
function buildManagedCreateOptions(options = {}) {
currentHostAdapterRevision += 1;
return {
...options,
stateSemantics: HOST_ADAPTER_STATE_SEMANTICS,
refreshMode: HOST_ADAPTER_REFRESH_MODE,
snapshotRevision: currentHostAdapterRevision,
snapshotCreatedAt: new Date().toISOString(),
};
}
function buildHostCapabilitySnapshot(
adapter,
options = {},
snapshotMetadata = createSnapshotMetadata(options),
) {
const snapshot = buildCapabilityCollectionSnapshot(
{
context: buildCapabilityStatus(adapter.context),
worldbook: buildCapabilityStatus(adapter.worldbook),
regex: buildCapabilityStatus(adapter.regex),
injection: buildCapabilityStatus(adapter.injection),
},
{
versionHints: mergeVersionHints(
{
adapter: HOST_ADAPTER_VERSION,
scope: "st-bme-host-adapter",
stateSemantics: snapshotMetadata.stateSemantics,
refreshMode: snapshotMetadata.refreshMode,
snapshotRevision: String(snapshotMetadata.snapshotRevision),
snapshotCreatedAt: snapshotMetadata.snapshotCreatedAt,
},
options.versionHints,
),
},
);
return Object.freeze({
...snapshot,
stateSemantics: snapshotMetadata.stateSemantics,
refreshMode: snapshotMetadata.refreshMode,
snapshotRevision: snapshotMetadata.snapshotRevision,
snapshotCreatedAt: snapshotMetadata.snapshotCreatedAt,
});
}
export function createHostAdapter(options = {}) {
const context = createContextHostFacade(options);
const sharedOptions = {
...options,
contextHost: context,
};
const worldbook = createWorldbookHostFacade(sharedOptions);
const regex = createRegexHostFacade(sharedOptions);
const injection = createInjectionHostFacade(sharedOptions);
const adapter = {
context,
worldbook,
regex,
injection,
};
const snapshotMetadata = createSnapshotMetadata(sharedOptions);
const snapshot = buildHostCapabilitySnapshot(
adapter,
sharedOptions,
snapshotMetadata,
);
return Object.freeze({
...snapshot,
...adapter,
getSnapshot() {
return snapshot;
},
readStateMetadata() {
return snapshotMetadata;
},
refresh(options = {}) {
return refreshHostAdapter(options);
},
});
}
export function initializeHostAdapter(options = {}) {
currentHostAdapterOptions = { ...options };
currentHostAdapter = createHostAdapter(
buildManagedCreateOptions(currentHostAdapterOptions),
);
return currentHostAdapter;
}
export function refreshHostAdapter(options = {}) {
currentHostAdapterOptions = {
...currentHostAdapterOptions,
...options,
};
currentHostAdapter = createHostAdapter(
buildManagedCreateOptions(currentHostAdapterOptions),
);
return currentHostAdapter;
}
export function getHostAdapter() {
if (!currentHostAdapter) {
currentHostAdapter = createHostAdapter(
buildManagedCreateOptions(currentHostAdapterOptions),
);
}
return currentHostAdapter;
}
export function getHostCapabilitySnapshot() {
return getHostAdapter().getSnapshot();
}
export function refreshHostCapabilitySnapshot(options = {}) {
return refreshHostAdapter(options).getSnapshot();
}
export function readHostCapability(name, options = {}) {
const normalizedName = String(name || "").trim();
if (!normalizedName) {
return null;
}
const refreshOptions =
options && typeof options === "object" ? { ...options } : {};
const shouldRefresh = refreshOptions.refresh === true;
delete refreshOptions.refresh;
const snapshot = shouldRefresh
? refreshHostCapabilitySnapshot(refreshOptions)
: getHostCapabilitySnapshot();
return snapshot?.[normalizedName] || null;
}

102
host-adapter/injection.js Normal file
View File

@@ -0,0 +1,102 @@
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
import { createContextHostFacade } from "./context.js";
function resolvePromptSetter(providedSetter = null, contextHost = null) {
if (typeof providedSetter === "function") {
return {
setter: providedSetter,
source: "provided",
};
}
const context = contextHost?.readContextSnapshot?.();
if (typeof context?.setExtensionPrompt === "function") {
return {
setter: context.setExtensionPrompt.bind(context),
source: "context",
};
}
return {
setter: null,
source: contextHost?.available ? "context-missing-setter" : "unavailable",
};
}
function detectInjectionMode(setterRecord, contextHost) {
if (typeof setterRecord?.setter === "function") {
return setterRecord.source === "provided"
? "provided-setter"
: "context-extension-prompt";
}
if (contextHost?.available) {
return "context-without-extension-prompt";
}
return "unavailable";
}
export function createInjectionHostFacade(options = {}) {
const contextHost = options.contextHost || createContextHostFacade(options);
const setterRecord = resolvePromptSetter(
options.setExtensionPrompt,
contextHost,
);
const available = typeof setterRecord.setter === "function";
const mode = detectInjectionMode(setterRecord, contextHost);
return Object.freeze({
available,
mode,
fallbackReason: available
? ""
: contextHost?.available
? "当前上下文未暴露 setExtensionPrompt 接口"
: "未检测到可用注入宿主接口",
versionHints: mergeVersionHints(
{
setter: "setExtensionPrompt",
source: setterRecord.source,
contextMode: contextHost?.mode || "unknown",
},
options.versionHints,
),
setExtensionPrompt: (...args) => {
const liveSetterRecord = resolvePromptSetter(
options.setExtensionPrompt,
contextHost,
);
if (typeof liveSetterRecord.setter !== "function") {
return false;
}
try {
liveSetterRecord.setter(...args);
return true;
} catch (error) {
console.debug(
"[ST-BME] host-adapter/injection setExtensionPrompt 调用失败",
error,
);
return false;
}
},
readInjectionSupport: () => {
const liveSetterRecord = resolvePromptSetter(
options.setExtensionPrompt,
contextHost,
);
return Object.freeze({
available: typeof liveSetterRecord.setter === "function",
mode: detectInjectionMode(liveSetterRecord, contextHost),
source: liveSetterRecord.source,
});
},
});
}
export function inspectInjectionHostCapability(options = {}) {
const facade = createInjectionHostFacade(options);
return buildCapabilityStatus(facade);
}

281
host-adapter/regex.js Normal file
View File

@@ -0,0 +1,281 @@
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
import { createContextHostFacade } from "./context.js";
const REGEX_API_NAMES = ["getTavernRegexes", "isCharacterTavernRegexesEnabled"];
function isObjectLike(value) {
return (
value != null && (typeof value === "object" || typeof value === "function")
);
}
function bindHostFunction(container, name) {
const fn = container?.[name];
return typeof fn === "function" ? fn.bind(container) : null;
}
function buildApiMap(container = null) {
return REGEX_API_NAMES.reduce((result, name) => {
result[name] = bindHostFunction(container, name);
return result;
}, {});
}
function countResolvedApis(apiMap = {}) {
return Object.values(apiMap).filter((api) => typeof api === "function")
.length;
}
function resolveProviderCandidate(candidate, options = {}) {
if (!candidate) {
return null;
}
if (typeof candidate === "function") {
try {
const resolved = candidate(options);
return isObjectLike(resolved) ? resolved : null;
} catch (error) {
console.debug("[ST-BME] host-adapter/regex provider 解析失败", error);
return null;
}
}
return isObjectLike(candidate) ? candidate : null;
}
function buildSourceRecord({
label = "unknown",
sourceKind = "unknown",
container = null,
fallback = false,
} = {}) {
const apiMap = buildApiMap(container);
return Object.freeze({
label,
sourceKind,
fallback,
apiMap,
apiCount: countResolvedApis(apiMap),
});
}
function collectExplicitRegexSourceRecords(options = {}) {
const records = [];
const providerCandidates = [
["regexProvider", options.regexProvider],
["providers.regex", options.providers?.regex],
["provider.regex", options.provider?.regex],
["host.regex", options.host?.regex],
["host.providers.regex", options.host?.providers?.regex],
];
for (const [label, candidate] of providerCandidates) {
const container = resolveProviderCandidate(candidate, options);
if (!container) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "provider",
container,
}),
);
}
const apiCandidates = [
["regexApis", options.regexApis],
["apis", options.apis],
["host.apis", options.host?.apis],
["host", options.host],
];
for (const [label, candidate] of apiCandidates) {
if (!isObjectLike(candidate)) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "api-map",
container: candidate,
}),
);
}
return records;
}
function collectContextRegexSourceRecords(contextHost, options = {}) {
const context = contextHost?.readContextSnapshot?.();
if (!isObjectLike(context)) {
return [];
}
const records = [];
const contextCandidates = [
["context.regex", context.regex],
["context.tavernRegex", context.tavernRegex],
["context.host.regex", context.host?.regex],
["context.hostAdapter.regex", context.hostAdapter?.regex],
["context.providers.regex", context.providers?.regex],
["context.extensions.regex", context.extensions?.regex],
["context.TavernHelper", context.TavernHelper],
["context.sillyTavern.TavernHelper", context.sillyTavern?.TavernHelper],
["context", context],
];
for (const [label, candidate] of contextCandidates) {
const container = resolveProviderCandidate(candidate, {
...options,
context,
contextHost,
});
if (!container) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "context",
container,
}),
);
}
return records;
}
function collectGlobalFallbackRecords() {
const records = [];
const fallbackCandidates = [
["globalThis.TavernHelper", globalThis?.TavernHelper],
[
"globalThis.SillyTavern.TavernHelper",
globalThis?.SillyTavern?.TavernHelper,
],
["globalThis", globalThis],
];
for (const [label, candidate] of fallbackCandidates) {
if (!isObjectLike(candidate)) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "global-fallback",
container: candidate,
fallback: true,
}),
);
}
return records;
}
function resolveRegexSource(options = {}, contextHost = null) {
const records = [
...collectExplicitRegexSourceRecords(options),
...collectContextRegexSourceRecords(contextHost, options),
...collectGlobalFallbackRecords(),
];
return (
records.find(
(record) => typeof record.apiMap.getTavernRegexes === "function",
) ||
buildSourceRecord({
label: "none",
sourceKind: "unavailable",
container: null,
})
);
}
function detectRegexMode(apiMap = {}) {
if (typeof apiMap.getTavernRegexes !== "function") {
return "unavailable";
}
return typeof apiMap.isCharacterTavernRegexesEnabled === "function"
? "full"
: "partial";
}
function buildFallbackReason(sourceRecord, available, mode) {
if (!available) {
return "未检测到 Tavern Regex 宿主接口";
}
if (sourceRecord?.fallback && mode === "partial") {
return `当前通过 ${sourceRecord.label} fallback 提供部分 Tavern Regex 能力`;
}
if (sourceRecord?.fallback) {
return `当前通过 ${sourceRecord.label} fallback 提供 Tavern Regex 能力`;
}
if (mode === "partial") {
return `Tavern Regex 桥接仅发现部分接口,来源: ${sourceRecord?.label || "unknown"}`;
}
return "";
}
export function createRegexHostFacade(options = {}) {
const contextHost = options.contextHost || createContextHostFacade(options);
const sourceRecord = resolveRegexSource(options, contextHost);
const mode = detectRegexMode(sourceRecord.apiMap);
const available = mode !== "unavailable";
return Object.freeze({
available,
mode,
fallbackReason: buildFallbackReason(sourceRecord, available, mode),
versionHints: mergeVersionHints(
{
apis: REGEX_API_NAMES.filter(
(name) => typeof sourceRecord.apiMap[name] === "function",
),
apiCount: String(sourceRecord.apiCount),
supportsCharacterToggle:
typeof sourceRecord.apiMap.isCharacterTavernRegexesEnabled ===
"function"
? "yes"
: "no",
source: sourceRecord.sourceKind,
sourceLabel: sourceRecord.label,
fallback: sourceRecord.fallback ? "yes" : "no",
contextMode: contextHost?.mode || "unknown",
},
options.versionHints,
),
getTavernRegexes: sourceRecord.apiMap.getTavernRegexes,
isCharacterTavernRegexesEnabled:
sourceRecord.apiMap.isCharacterTavernRegexesEnabled,
getApi(name) {
return sourceRecord.apiMap[String(name || "")] || null;
},
readApiAvailability() {
return Object.freeze(
REGEX_API_NAMES.reduce((result, name) => {
result[name] = typeof sourceRecord.apiMap[name] === "function";
return result;
}, {}),
);
},
readCapabilitySupport() {
return Object.freeze({
available,
mode,
source: sourceRecord.sourceKind,
sourceLabel: sourceRecord.label,
fallback: sourceRecord.fallback,
});
},
});
}
export function inspectRegexHostCapability(options = {}) {
const facade = createRegexHostFacade(options);
return buildCapabilityStatus(facade);
}

276
host-adapter/worldbook.js Normal file
View File

@@ -0,0 +1,276 @@
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
import { createContextHostFacade } from "./context.js";
const WORLDBOOK_API_NAMES = [
"getWorldbook",
"getLorebookEntries",
"getCharWorldbookNames",
];
function isObjectLike(value) {
return (
value != null && (typeof value === "object" || typeof value === "function")
);
}
function bindHostFunction(container, name) {
const fn = container?.[name];
return typeof fn === "function" ? fn.bind(container) : null;
}
function buildApiMap(container = null) {
return WORLDBOOK_API_NAMES.reduce((result, name) => {
result[name] = bindHostFunction(container, name);
return result;
}, {});
}
function countResolvedApis(apiMap = {}) {
return Object.values(apiMap).filter((api) => typeof api === "function")
.length;
}
function resolveProviderCandidate(candidate, options = {}) {
if (!candidate) {
return null;
}
if (typeof candidate === "function") {
try {
const resolved = candidate(options);
return isObjectLike(resolved) ? resolved : null;
} catch (error) {
console.debug("[ST-BME] host-adapter/worldbook provider 解析失败", error);
return null;
}
}
return isObjectLike(candidate) ? candidate : null;
}
function buildSourceRecord({
label = "unknown",
sourceKind = "unknown",
container = null,
fallback = false,
} = {}) {
const apiMap = buildApiMap(container);
return Object.freeze({
label,
sourceKind,
fallback,
apiMap,
apiCount: countResolvedApis(apiMap),
});
}
function collectExplicitWorldbookSourceRecords(options = {}) {
const records = [];
const providerCandidates = [
["worldbookProvider", options.worldbookProvider],
["providers.worldbook", options.providers?.worldbook],
["provider.worldbook", options.provider?.worldbook],
["host.worldbook", options.host?.worldbook],
["host.providers.worldbook", options.host?.providers?.worldbook],
];
for (const [label, candidate] of providerCandidates) {
const container = resolveProviderCandidate(candidate, options);
if (!container) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "provider",
container,
}),
);
}
const apiCandidates = [
["worldbookApis", options.worldbookApis],
["apis", options.apis],
["host.apis", options.host?.apis],
["host", options.host],
];
for (const [label, candidate] of apiCandidates) {
if (!isObjectLike(candidate)) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "api-map",
container: candidate,
}),
);
}
return records;
}
function collectContextWorldbookSourceRecords(contextHost, options = {}) {
const context = contextHost?.readContextSnapshot?.();
if (!isObjectLike(context)) {
return [];
}
const records = [];
const contextCandidates = [
["context.worldbook", context.worldbook],
["context.worldInfo", context.worldInfo],
["context.host.worldbook", context.host?.worldbook],
["context.hostAdapter.worldbook", context.hostAdapter?.worldbook],
["context.providers.worldbook", context.providers?.worldbook],
["context.extensions.worldbook", context.extensions?.worldbook],
["context.TavernHelper", context.TavernHelper],
["context.sillyTavern.TavernHelper", context.sillyTavern?.TavernHelper],
["context", context],
];
for (const [label, candidate] of contextCandidates) {
const container = resolveProviderCandidate(candidate, {
...options,
context,
contextHost,
});
if (!container) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "context",
container,
}),
);
}
return records;
}
function collectGlobalFallbackRecords() {
const records = [];
const fallbackCandidates = [
["globalThis.TavernHelper", globalThis?.TavernHelper],
[
"globalThis.SillyTavern.TavernHelper",
globalThis?.SillyTavern?.TavernHelper,
],
["globalThis", globalThis],
];
for (const [label, candidate] of fallbackCandidates) {
if (!isObjectLike(candidate)) continue;
records.push(
buildSourceRecord({
label,
sourceKind: "global-fallback",
container: candidate,
fallback: true,
}),
);
}
return records;
}
function resolveWorldbookSource(options = {}, contextHost = null) {
const records = [
...collectExplicitWorldbookSourceRecords(options),
...collectContextWorldbookSourceRecords(contextHost, options),
...collectGlobalFallbackRecords(),
];
return (
records.find((record) => record.apiCount > 0) ||
buildSourceRecord({
label: "none",
sourceKind: "unavailable",
container: null,
})
);
}
function detectWorldbookMode(apiMap = {}) {
const availableCount = countResolvedApis(apiMap);
if (availableCount === 0) return "unavailable";
if (availableCount === WORLDBOOK_API_NAMES.length) return "full";
return "partial";
}
function buildFallbackReason(sourceRecord, available, mode) {
if (!available) {
return "未检测到世界书宿主接口";
}
if (sourceRecord?.fallback && mode === "partial") {
return `当前通过 ${sourceRecord.label} fallback 提供部分世界书能力`;
}
if (sourceRecord?.fallback) {
return `当前通过 ${sourceRecord.label} fallback 提供世界书能力`;
}
if (mode === "partial") {
return `世界书桥接仅发现部分接口,来源: ${sourceRecord?.label || "unknown"}`;
}
return "";
}
export function createWorldbookHostFacade(options = {}) {
const contextHost = options.contextHost || createContextHostFacade(options);
const sourceRecord = resolveWorldbookSource(options, contextHost);
const mode = detectWorldbookMode(sourceRecord.apiMap);
const available = mode !== "unavailable";
return Object.freeze({
available,
mode,
fallbackReason: buildFallbackReason(sourceRecord, available, mode),
versionHints: mergeVersionHints(
{
apiCount: String(sourceRecord.apiCount),
apis: WORLDBOOK_API_NAMES.filter(
(name) => typeof sourceRecord.apiMap[name] === "function",
),
source: sourceRecord.sourceKind,
sourceLabel: sourceRecord.label,
fallback: sourceRecord.fallback ? "yes" : "no",
contextMode: contextHost?.mode || "unknown",
},
options.versionHints,
),
getWorldbook: sourceRecord.apiMap.getWorldbook,
getLorebookEntries: sourceRecord.apiMap.getLorebookEntries,
getCharWorldbookNames: sourceRecord.apiMap.getCharWorldbookNames,
getApi(name) {
return sourceRecord.apiMap[String(name || "")] || null;
},
readApiAvailability() {
return Object.freeze(
WORLDBOOK_API_NAMES.reduce((result, name) => {
result[name] = typeof sourceRecord.apiMap[name] === "function";
return result;
}, {}),
);
},
readCapabilitySupport() {
return Object.freeze({
available,
mode,
source: sourceRecord.sourceKind,
sourceLabel: sourceRecord.label,
fallback: sourceRecord.fallback,
});
},
});
}
export function inspectWorldbookHostCapability(options = {}) {
const facade = createWorldbookHostFacade(options);
return buildCapabilityStatus(facade);
}

210
index.js
View File

@@ -5,6 +5,7 @@ import {
eventSource,
event_types,
extension_prompt_types,
extension_prompt_roles,
getRequestHeaders,
saveSettingsDebounced,
} from "../../../../script.js";
@@ -29,6 +30,14 @@ import {
getNode,
importGraph,
} from "./graph.js";
import {
HOST_ADAPTER_STATE_SEMANTICS,
getHostAdapter,
getHostCapabilitySnapshot,
initializeHostAdapter,
readHostCapability,
refreshHostCapabilitySnapshot,
} from "./host-adapter/index.js";
import { estimateTokens, formatInjection } from "./injector.js";
import { fetchMemoryLLMModels, testLLMConnection } from "./llm.js";
import { getNodeDisplayName } from "./node-labels.js";
@@ -648,6 +657,72 @@ function getSettings() {
return mergedSettings;
}
function initializeHostCapabilityBridge(options = {}) {
try {
initializeHostAdapter({
getContext,
...options,
});
} catch (error) {
console.warn("[ST-BME] 宿主桥接初始化失败:", error);
}
return getHostCapabilityStatus();
}
function buildHostCapabilityErrorStatus(error) {
return {
available: false,
mode: "error",
fallbackReason:
error instanceof Error ? error.message : String(error || "未知错误"),
versionHints: {
stateSemantics: HOST_ADAPTER_STATE_SEMANTICS,
refreshMode: "manual-rebuild",
},
stateSemantics: HOST_ADAPTER_STATE_SEMANTICS,
refreshMode: "manual-rebuild",
snapshotRevision: -1,
snapshotCreatedAt: "",
};
}
export function getHostCapabilityStatus(options = {}) {
const normalizedOptions =
options && typeof options === "object" ? { ...options } : {};
const shouldRefresh = normalizedOptions.refresh === true;
delete normalizedOptions.refresh;
try {
return shouldRefresh
? refreshHostCapabilitySnapshot(normalizedOptions)
: getHostCapabilitySnapshot();
} catch (error) {
console.warn("[ST-BME] 读取宿主桥接状态失败:", error);
return buildHostCapabilityErrorStatus(error);
}
}
export function refreshHostCapabilityStatus(options = {}) {
return getHostCapabilityStatus({
...options,
refresh: true,
});
}
export function getHostCapability(name, options = {}) {
const normalizedName = String(name || "").trim();
if (!normalizedName) return null;
try {
return readHostCapability(normalizedName, options) || null;
} catch (error) {
console.warn("[ST-BME] 读取宿主桥接能力失败:", error);
return getHostCapabilityStatus(options)?.[normalizedName] || null;
}
}
function getSchema() {
const settings = getSettings();
const schema = settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA;
@@ -677,6 +752,97 @@ function getCurrentChatId(context = getContext()) {
return String(context?.chatId || context?.getCurrentChatId?.() || "");
}
function resolveInjectionPromptType(settings = {}) {
const normalized = String(settings?.injectPosition || "atDepth")
.trim()
.toLowerCase();
switch (normalized) {
case "none":
return extension_prompt_types.NONE;
case "beforeprompt":
case "before_prompt":
case "before-prompt":
return extension_prompt_types.BEFORE_PROMPT;
case "inprompt":
case "in_prompt":
case "in-prompt":
return extension_prompt_types.IN_PROMPT;
case "atdepth":
case "at_depth":
case "inchat":
case "in_chat":
case "chat":
default:
return extension_prompt_types.IN_CHAT;
}
}
function resolveInjectionPromptRole(settings = {}) {
switch (Number(settings?.injectRole)) {
case 1:
return extension_prompt_roles.USER;
case 2:
return extension_prompt_roles.ASSISTANT;
default:
return extension_prompt_roles.SYSTEM;
}
}
function applyModuleInjectionPrompt(content = "", settings = getSettings()) {
const position = resolveInjectionPromptType(settings);
const depth =
position === extension_prompt_types.IN_CHAT
? clampInt(settings?.injectDepth, 9999, 0, 9999)
: 0;
const role = resolveInjectionPromptRole(settings);
const adapter = getHostAdapter?.();
const injectionHost = adapter?.injection;
if (
typeof injectionHost?.setExtensionPrompt === "function" &&
injectionHost.setExtensionPrompt(
MODULE_NAME,
content,
position,
depth,
false,
role,
)
) {
return {
applied: true,
source: "host-adapter",
mode: injectionHost.readInjectionSupport?.()?.mode || "",
position,
depth,
role,
};
}
const context = getContext();
if (typeof context?.setExtensionPrompt === "function") {
context.setExtensionPrompt(MODULE_NAME, content, position, depth, false, role);
return {
applied: true,
source: "context",
mode: "legacy-context-setter",
position,
depth,
role,
};
}
return {
applied: false,
source: "unavailable",
mode: "unavailable",
position,
depth,
role,
};
}
function ensureCurrentGraphRuntimeState() {
if (!currentGraph) {
currentGraph = createEmptyGraph();
@@ -696,13 +862,7 @@ function clearInjectionState() {
}
try {
const context = getContext();
context.setExtensionPrompt(
MODULE_NAME,
"",
extension_prompt_types.IN_CHAT,
0,
);
applyModuleInjectionPrompt("", getSettings());
} catch (error) {
console.warn("[ST-BME] 清理旧注入失败:", error);
}
@@ -1232,13 +1392,7 @@ function updateModuleSettings(patch = {}) {
abortAllRunningStages();
dismissAllStageNotices();
try {
const context = getContext();
context.setExtensionPrompt(
MODULE_NAME,
"",
extension_prompt_types.IN_CHAT,
0,
);
applyModuleInjectionPrompt("", settings);
lastInjectionContent = "";
lastRecalledItems = [];
runtimeStatus = createUiStatus(
@@ -3008,13 +3162,7 @@ function getRecallHookLabel(hookName = "") {
}
}
function applyRecallInjection(
context,
settings,
recallInput,
recentMessages,
result,
) {
function applyRecallInjection(settings, recallInput, recentMessages, result) {
const injectionText = formatInjection(result, getSchema()).trim();
lastInjectionContent = injectionText;
const retrievalMeta = result?.meta?.retrieval || {};
@@ -3031,12 +3179,7 @@ function applyRecallInjection(
);
}
context.setExtensionPrompt(
MODULE_NAME,
injectionText,
extension_prompt_types.IN_CHAT,
clampInt(settings.injectDepth, 9999, 0, 9999),
);
applyModuleInjectionPrompt(injectionText, settings);
currentGraph.lastRecallResult = result.selectedNodeIds;
updateLastRecalledItems(result.selectedNodeIds || []);
@@ -3195,13 +3338,7 @@ async function runRecall(options = {}) {
},
});
applyRecallInjection(
context,
settings,
recallInput,
recentMessages,
result,
);
applyRecallInjection(settings, recallInput, recentMessages, result);
return true;
} catch (e) {
if (isAbortError(e)) {
@@ -3787,9 +3924,7 @@ async function onReroll({ fromFloor } = {}) {
const recovery = findJournalRecoveryPoint(currentGraph, targetFloor);
if (recovery && recovery.affectedJournals?.length > 0) {
rollbackAffectedJournals(currentGraph, recovery.affectedJournals);
console.log(
`[ST-BME] 已回滚 ${recovery.affectedJournals.length} 个 batch`,
);
console.log(`[ST-BME] 已回滚 ${recovery.affectedJournals.length} 个 batch`);
}
// 2. 重置提取指针
@@ -3927,6 +4062,7 @@ async function onReembedDirect() {
(async function init() {
await loadServerSettings();
initializeHostCapabilityBridge();
installSendIntentHooks();
// 注册事件钩子

49
llm.js
View File

@@ -244,6 +244,30 @@ function buildJsonAttemptMessages(
return messages;
}
function resolvePrivateRequestSource(
taskType = "",
requestSource = "",
{ allowAnonymous = false } = {},
) {
const normalizedRequestSource = String(requestSource || "").trim();
if (normalizedRequestSource) {
return normalizedRequestSource;
}
const normalizedTaskType = String(taskType || "").trim();
if (normalizedTaskType) {
return `task:${normalizedTaskType}`;
}
if (allowAnonymous) {
return "adhoc";
}
throw new Error(
"ST-BME private LLM requests require taskType or requestSource",
);
}
async function fetchWithTimeout(
url,
options = {},
@@ -314,8 +338,13 @@ async function callDedicatedOpenAICompatible(
jsonMode = false,
maxCompletionTokens = null,
taskType = "",
requestSource = "",
} = {},
) {
const privateRequestSource = resolvePrivateRequestSource(
taskType,
requestSource,
);
const config = getMemoryLLMConfig();
const settings = extension_settings[MODULE_NAME] || {};
const hasDedicatedConfig = hasDedicatedLLMConfig(config);
@@ -350,7 +379,7 @@ async function callDedicatedOpenAICompatible(
return normalized;
}
throw new Error(
"SillyTavern current model returned an unexpected response format",
`${privateRequestSource}: SillyTavern current model returned an unexpected response format`,
);
}
@@ -496,8 +525,13 @@ export async function callLLMForJSON({
maxRetries = 2,
signal,
taskType = "",
requestSource = "",
additionalMessages = [],
} = {}) {
const privateRequestSource = resolvePrivateRequestSource(
taskType,
requestSource,
);
let lastFailureReason = "";
for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -513,6 +547,7 @@ export async function callLLMForJSON({
signal,
jsonMode: true,
taskType,
requestSource: privateRequestSource,
maxCompletionTokens:
attempt === 0
? DEFAULT_JSON_COMPLETION_TOKENS
@@ -561,14 +596,19 @@ export async function callLLMForJSON({
* @param {string} userPrompt
* @returns {Promise<string|null>}
*/
export async function callLLM(systemPrompt, userPrompt) {
export async function callLLM(systemPrompt, userPrompt, options = {}) {
const messages = [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
];
try {
const response = await callDedicatedOpenAICompatible(messages);
const response = await callDedicatedOpenAICompatible(messages, {
signal: options.signal,
taskType: options.taskType || "",
requestSource:
options.requestSource || options.source || "diagnostic:call-llm",
});
return response?.content || null;
} catch (e) {
console.error("[ST-BME] LLM 调用失败:", e);
@@ -592,6 +632,9 @@ export async function testLLMConnection() {
const response = await callLLM(
"你是一个连接测试助手。请只回答 OK。",
"请只回复 OK",
{
requestSource: "diagnostic:test-connection",
},
);
if (typeof response === "string" && response.trim().length > 0) {
return { success: true, mode, error: "" };

View File

@@ -63,6 +63,83 @@ function buildEmptyWorldInfoContext() {
};
}
function createHostInjectionEntry(
entry = {},
position = "after",
source = "worldInfo",
) {
return {
source,
position,
role: normalizeRole(entry.role),
content: String(entry.content || "").trim(),
name: String(entry.name || ""),
sourceName: String(entry.sourceName || entry.name || ""),
worldbook: String(entry.worldbook || ""),
depth:
position === "atDepth" && Number.isFinite(Number(entry.depth))
? Number(entry.depth)
: null,
order: Number.isFinite(Number(entry.order)) ? Number(entry.order) : 0,
};
}
function buildWorldInfoResolution(worldInfoContext = {}) {
const beforeEntries = Array.isArray(worldInfoContext.worldInfoBeforeEntries)
? worldInfoContext.worldInfoBeforeEntries
: [];
const afterEntries = Array.isArray(worldInfoContext.worldInfoAfterEntries)
? worldInfoContext.worldInfoAfterEntries
: [];
const atDepthEntries = Array.isArray(worldInfoContext.worldInfoAtDepthEntries)
? worldInfoContext.worldInfoAtDepthEntries
: [];
const additionalMessages = Array.isArray(worldInfoContext.taskAdditionalMessages)
? worldInfoContext.taskAdditionalMessages
: [];
return {
beforeText: String(worldInfoContext.worldInfoBefore || ""),
afterText: String(worldInfoContext.worldInfoAfter || ""),
beforeEntries,
afterEntries,
atDepthEntries,
activatedEntryNames: Array.isArray(worldInfoContext.activatedWorldInfoNames)
? worldInfoContext.activatedWorldInfoNames
: [],
additionalMessages,
injections: {
before: beforeEntries
.map((entry) => createHostInjectionEntry(entry, "before"))
.filter((entry) => entry.content),
after: afterEntries
.map((entry) => createHostInjectionEntry(entry, "after"))
.filter((entry) => entry.content),
atDepth: atDepthEntries
.map((entry) => createHostInjectionEntry(entry, "atDepth"))
.filter((entry) => entry.content),
},
};
}
function resolveBlockDelivery(block = {}) {
if (
block.type === "builtin" &&
String(block.sourceKey || "") === "worldInfoBefore"
) {
return "host.before";
}
if (
block.type === "builtin" &&
String(block.sourceKey || "") === "worldInfoAfter"
) {
return "host.after";
}
return normalizeRole(block.role) === "system"
? "private.system"
: "private.message";
}
function profileRequiresWorldInfo(profile) {
const blocks = Array.isArray(profile?.blocks) ? profile.blocks : [];
for (const block of blocks) {
@@ -138,9 +215,11 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
...emptyWorldInfo,
...resolvedWorldInfo,
};
const worldInfoResolution = buildWorldInfoResolution(resolvedContext);
let systemPrompt = "";
const customMessages = [];
const renderedBlocks = [];
for (const block of blocks) {
if (!block || block.enabled === false) continue;
@@ -165,6 +244,21 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
if (!String(content || "").trim()) continue;
const mode = normalizeInjectionMode(block.injectionMode);
renderedBlocks.push({
id: String(block.id || ""),
name: String(block.name || ""),
type: String(block.type || "custom"),
role,
sourceKey: String(block.sourceKey || ""),
sourceField: String(block.sourceField || ""),
content,
order: Number.isFinite(Number(block.order))
? Number(block.order)
: block._orderIndex,
injectionMode: mode,
delivery: resolveBlockDelivery(block),
});
if (role === "system") {
if (!systemPrompt) {
systemPrompt = content;
@@ -183,18 +277,31 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
}
}
const privateTaskMessages = [
...customMessages,
...worldInfoResolution.additionalMessages,
];
return {
profile,
hostInjections: worldInfoResolution.injections,
privateTaskPrompt: {
systemPrompt,
messages: privateTaskMessages,
},
privateTaskMessages,
renderedBlocks,
worldInfoResolution,
systemPrompt,
customMessages,
additionalMessages: resolvedContext.taskAdditionalMessages || [],
additionalMessages: worldInfoResolution.additionalMessages,
worldInfo: {
beforeText: resolvedContext.worldInfoBefore,
afterText: resolvedContext.worldInfoAfter,
beforeEntries: resolvedContext.worldInfoBeforeEntries,
afterEntries: resolvedContext.worldInfoAfterEntries,
atDepthEntries: resolvedContext.worldInfoAtDepthEntries,
activatedEntryNames: resolvedContext.activatedWorldInfoNames,
beforeText: worldInfoResolution.beforeText,
afterText: worldInfoResolution.afterText,
beforeEntries: worldInfoResolution.beforeEntries,
afterEntries: worldInfoResolution.afterEntries,
atDepthEntries: worldInfoResolution.atDepthEntries,
activatedEntryNames: worldInfoResolution.activatedEntryNames,
},
debug: {
taskType,
@@ -202,11 +309,18 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) {
profileName: profile?.name || "",
usedLegacyPrompt: Boolean(legacyPrompt),
blockCount: blocks.length,
renderedBlockCount: renderedBlocks.length,
worldInfoRequested,
worldInfoBeforeCount: resolvedContext.worldInfoBeforeEntries.length,
worldInfoAfterCount: resolvedContext.worldInfoAfterEntries.length,
worldInfoAtDepthCount: resolvedContext.worldInfoAtDepthEntries.length,
additionalMessageCount: resolvedContext.taskAdditionalMessages.length,
worldInfoBeforeCount: worldInfoResolution.beforeEntries.length,
worldInfoAfterCount: worldInfoResolution.afterEntries.length,
worldInfoAtDepthCount: worldInfoResolution.atDepthEntries.length,
hostInjectionCount:
worldInfoResolution.injections.before.length +
worldInfoResolution.injections.after.length +
worldInfoResolution.injections.atDepth.length,
customMessageCount: customMessages.length,
additionalMessageCount: worldInfoResolution.additionalMessages.length,
privateTaskMessageCount: privateTaskMessages.length,
},
};
}

View File

@@ -461,10 +461,11 @@ async function llmRecall(
maxRetries: 1,
signal,
taskType: "recall",
additionalMessages: [
...(recallPromptBuild.customMessages || []),
...(recallPromptBuild.additionalMessages || []),
],
additionalMessages:
recallPromptBuild.privateTaskMessages || [
...(recallPromptBuild.customMessages || []),
...(recallPromptBuild.additionalMessages || []),
],
});
if (result?.selected_ids && Array.isArray(result.selected_ids)) {

View File

@@ -3,6 +3,165 @@
import { getContext } from "../../../extensions.js";
function safeClone(value, fallback) {
if (value == null) {
return fallback;
}
try {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
} catch {
// ignore and fall back to JSON clone
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return fallback ?? value;
}
}
function resolveCharacter(ctx) {
const charId = ctx?.characterId;
return (
ctx?.character ||
ctx?.characters?.[Number(charId)] ||
ctx?.characters?.[charId] ||
null
);
}
function resolvePersona(ctx) {
return (
ctx?.powerUserSettings?.persona_description ||
ctx?.extensionSettings?.persona_description ||
ctx?.name1_description ||
ctx?.persona ||
""
);
}
function resolveCharacterDescription(char) {
return (
char?.description ||
char?.data?.description ||
char?.data?.personality ||
""
);
}
function resolveLastUserMessage(chat = []) {
return (
chat.findLast?.((message) => message?.is_user)?.mes ||
[...chat].reverse().find((message) => message?.is_user)?.mes ||
""
);
}
function buildStructuredSnapshot(ctx = {}) {
const char = resolveCharacter(ctx);
const chat = Array.isArray(ctx.chat) ? safeClone(ctx.chat, []) : [];
const currentTime = new Date().toLocaleString("zh-CN");
const globalVars = safeClone(
ctx.extensionSettings?.variables?.global || {},
{},
);
const localVars = safeClone(ctx.chatMetadata?.variables || {}, {});
return {
persona: {
text: resolvePersona(ctx),
lorebook:
ctx.extensionSettings?.persona_description_lorebook ||
ctx.powerUserSettings?.persona_description_lorebook ||
ctx.power_user?.persona_description_lorebook ||
"",
},
character: {
id: ctx.characterId ?? null,
name: ctx.name2 || char?.name || "",
description: resolveCharacterDescription(char),
avatar: char?.avatar ? `/characters/${char.avatar}` : "",
worldbook: char?.data?.extensions?.world || char?.extensions?.world || "",
raw: safeClone(char, null),
},
user: {
name: ctx.name1 || "",
avatar: "",
raw: safeClone(ctx.user || null, null),
},
chat: {
id: ctx.chatId || globalThis.getCurrentChatId?.() || "",
messages: chat,
lastUserMessage: resolveLastUserMessage(chat),
},
worldbook: {
character: char?.data?.extensions?.world || char?.extensions?.world || "",
persona:
ctx.extensionSettings?.persona_description_lorebook ||
ctx.powerUserSettings?.persona_description_lorebook ||
ctx.power_user?.persona_description_lorebook ||
"",
chat: ctx.chatMetadata?.world || "",
},
variables: {
global: globalVars,
local: localVars,
merged: {
...globalVars,
...localVars,
},
},
time: {
current: currentTime,
locale: "zh-CN",
},
host: {
meta: {
onlineStatus: ctx.onlineStatus || "",
selectedGroupId: ctx.selectedGroupId ?? null,
},
capabilities: {
hasGetContext: typeof getContext === "function",
hasGlobalGetContext:
typeof globalThis.SillyTavern?.getContext === "function",
hasCurrentChatId: typeof globalThis.getCurrentChatId === "function",
},
},
raw: safeClone(ctx, {}),
};
}
function buildCompatPromptAliases(snapshot) {
return {
userPersona: snapshot.persona.text,
charDescription: snapshot.character.description,
charName: snapshot.character.name,
userName: snapshot.user.name,
currentTime: snapshot.time.current,
};
}
export function getSTContextSnapshot() {
try {
const ctx = getContext?.() || {};
const snapshot = buildStructuredSnapshot(ctx);
return {
snapshot,
prompt: buildCompatPromptAliases(snapshot),
};
} catch (e) {
console.warn("[ST-BME] getSTContextSnapshot 失败:", e);
const snapshot = buildStructuredSnapshot({});
return {
snapshot,
prompt: buildCompatPromptAliases(snapshot),
};
}
}
/**
* 从 SillyTavern 的 getContext() 提取当前上下文数据,
* 返回的字段可直接展开传入 buildTaskPrompt 的 context 参数,
@@ -11,37 +170,5 @@ import { getContext } from "../../../extensions.js";
* @returns {object} 上下文字段映射
*/
export function getSTContextForPrompt() {
try {
const ctx = getContext?.() || {};
const charId = ctx.characterId;
const char =
ctx.characters?.[Number(charId)] ||
ctx.characters?.[charId] ||
null;
return {
userPersona:
ctx.powerUserSettings?.persona_description ||
ctx.extensionSettings?.persona_description ||
ctx.name1_description ||
ctx.persona ||
"",
charDescription:
char?.description ||
char?.data?.description ||
"",
charName: ctx.name2 || "",
userName: ctx.name1 || "",
currentTime: new Date().toLocaleString("zh-CN"),
};
} catch (e) {
console.warn("[ST-BME] getSTContextForPrompt 失败:", e);
return {
userPersona: "",
charDescription: "",
charName: "",
userName: "",
currentTime: new Date().toLocaleString("zh-CN"),
};
}
return getSTContextSnapshot().prompt;
}

View File

@@ -1,8 +1,16 @@
// ST-BME: 任务级 EJS / 世界书渲染引擎
// 仅用于世界书条目渲染,不开放给用户自定义 prompt 块。
import { getSTContextSnapshot } from "./st-context.js";
const DEFAULT_MAX_RECURSION = 10;
let ejsRuntimePromise = null;
let ejsRuntimeStatePromise = null;
const EJS_RUNTIME_STATUS = {
PRIMARY: "primary",
FALLBACK: "fallback",
FAILED: "failed",
};
const FALLBACK_LODASH = {
get: getByPath,
@@ -26,17 +34,40 @@ function getEjsRuntime() {
return globalThis.ejs || null;
}
async function ensureEjsRuntime() {
if (globalThis.ejs) {
return globalThis.ejs;
function buildEjsRuntimeState(runtime, status, error = null) {
return {
runtime: runtime || null,
status,
isAvailable: Boolean(runtime),
isFallback: status === EJS_RUNTIME_STATUS.FALLBACK,
error: error || null,
};
}
function getCurrentEjsRuntimeState() {
const runtime = getEjsRuntime();
if (!runtime) {
return buildEjsRuntimeState(null, EJS_RUNTIME_STATUS.FAILED);
}
if (ejsRuntimePromise) {
return await ejsRuntimePromise;
return buildEjsRuntimeState(runtime, EJS_RUNTIME_STATUS.PRIMARY);
}
async function ensureEjsRuntime() {
const currentState = getCurrentEjsRuntimeState();
if (currentState.isAvailable) {
return currentState;
}
if (ejsRuntimeStatePromise) {
return await ejsRuntimeStatePromise;
}
ejsRuntimePromise = (async () => {
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window");
ejsRuntimeStatePromise = (async () => {
const hadWindow = Object.prototype.hasOwnProperty.call(
globalThis,
"window",
);
const previousWindow = globalThis.window;
let importError = null;
if (!hadWindow) {
globalThis.window = globalThis;
@@ -45,6 +76,7 @@ async function ensureEjsRuntime() {
try {
await import("./vendor/ejs.js");
} catch (error) {
importError = error;
console.warn("[ST-BME] task-ejs 加载 vendor/ejs.js 失败:", error);
} finally {
if (!hadWindow) {
@@ -54,27 +86,71 @@ async function ensureEjsRuntime() {
}
}
return globalThis.ejs || null;
const runtime = getEjsRuntime();
if (runtime) {
return buildEjsRuntimeState(runtime, EJS_RUNTIME_STATUS.FALLBACK);
}
return buildEjsRuntimeState(null, EJS_RUNTIME_STATUS.FAILED, importError);
})();
return await ejsRuntimePromise;
return await ejsRuntimeStatePromise;
}
function getStContext() {
try {
return globalThis.SillyTavern?.getContext?.() || {};
} catch {
return {};
async function resolveTaskEjsBackend(options = {}) {
if (options.ensureRuntime === false) {
return getCurrentEjsRuntimeState();
}
return await ensureEjsRuntime();
}
function getStChat() {
try {
const ctx = getStContext();
return Array.isArray(ctx.chat) ? ctx.chat : [];
} catch {
return [];
function resolveHostSnapshot(injectedSnapshot) {
if (injectedSnapshot?.snapshot) {
return injectedSnapshot;
}
return getSTContextSnapshot();
}
function getStContext(injectedSnapshot) {
return resolveHostSnapshot(injectedSnapshot).snapshot.raw || {};
}
function getStChat(injectedSnapshot) {
return resolveHostSnapshot(injectedSnapshot).snapshot.chat.messages || [];
}
function buildTemplateContext(templateContext = {}, hostSnapshot) {
const resolvedHost = resolveHostSnapshot(hostSnapshot);
const snapshot = resolvedHost.snapshot;
const promptAliases = resolvedHost.prompt || {};
const lastUserMessage =
typeof templateContext.user_input === "string"
? templateContext.user_input
: snapshot.chat.lastUserMessage || "";
return {
user: snapshot.user.name,
char: snapshot.character.name,
userName: promptAliases.userName || snapshot.user.name,
charName: promptAliases.charName || snapshot.character.name,
persona: promptAliases.userPersona || snapshot.persona.text,
userPersona: promptAliases.userPersona || snapshot.persona.text,
charDescription:
promptAliases.charDescription || snapshot.character.description,
currentTime: promptAliases.currentTime || snapshot.time.current,
stSnapshot: snapshot,
hostSnapshot: snapshot,
lastUserMessage,
last_user_message: lastUserMessage,
userInput: lastUserMessage,
user_input: lastUserMessage,
original: "",
input: "",
lastMessage: "",
lastMessageId: "",
newline: "\n",
trim: "",
...templateContext,
};
}
function cloneDeep(value) {
@@ -148,54 +224,28 @@ function normalizeEntryKey(value) {
}
function normalizeIdentifier(value) {
return String(value || "").trim().toLowerCase();
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 = {}) {
export function substituteTaskEjsParams(
text,
templateContext = {},
options = {},
) {
if (!text || !String(text).includes("{{")) {
return String(text || "");
}
const context = buildTemplateContext(templateContext);
const context = buildTemplateContext(
templateContext,
options.hostSnapshot || templateContext.hostSnapshot,
);
return String(text).replace(/\{\{\s*([a-zA-Z0-9_.$]+)\s*\}\}/g, (_, path) => {
const value = getByPath(context, path);
if (value == null) return "";
@@ -210,17 +260,17 @@ export function substituteTaskEjsParams(text, templateContext = {}) {
});
}
function createVariableState() {
const ctx = getStContext();
const chat = getStChat();
function createVariableState(hostSnapshot) {
const snapshot = resolveHostSnapshot(hostSnapshot).snapshot;
const chat = snapshot.chat.messages || [];
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 || {});
const globalVars = cloneDeep(snapshot.variables.global || {});
const localVars = cloneDeep(snapshot.variables.local || {});
return {
globalVars,
@@ -283,9 +333,16 @@ function activationKey(entry) {
return `${entry.worldbook}::${entry.comment || entry.name}`;
}
function findEntry(renderCtx, currentWorldbook, worldbookOrEntry, entryNameOrData) {
function findEntry(
renderCtx,
currentWorldbook,
worldbookOrEntry,
entryNameOrData,
) {
const explicitWorldbook =
typeof entryNameOrData === "string" ? normalizeEntryKey(worldbookOrEntry) : "";
typeof entryNameOrData === "string"
? normalizeEntryKey(worldbookOrEntry)
: "";
const fallbackWorldbook = normalizeEntryKey(currentWorldbook);
const identifier = normalizeEntryKey(
typeof entryNameOrData === "string" ? entryNameOrData : worldbookOrEntry,
@@ -330,7 +387,10 @@ async function activateWorldInfoInContext(
}
: entry;
renderCtx.activatedEntries.set(activationKey(normalizedEntry), normalizedEntry);
renderCtx.activatedEntries.set(
activationKey(normalizedEntry),
normalizedEntry,
);
return {
world: normalizedEntry.worldbook,
comment: normalizedEntry.comment || normalizedEntry.name,
@@ -416,7 +476,11 @@ function getChatMessageCompat(index, role) {
return chat[resolvedIndex] || "";
}
function getChatMessagesCompat(startOrCount = getStChat().length, endOrRole, role) {
function getChatMessagesCompat(
startOrCount = getStChat().length,
endOrRole,
role,
) {
const allMessages = getStChat().map((message, index) => ({
raw: message,
id: index,
@@ -443,7 +507,9 @@ function getChatMessagesCompat(startOrCount = getStChat().length, endOrRole, rol
if (typeof endOrRole === "string") {
const filtered = filterByRole(allMessages, endOrRole);
return (
startOrCount > 0 ? filtered.slice(0, startOrCount) : filtered.slice(startOrCount)
startOrCount > 0
? filtered.slice(0, startOrCount)
: filtered.slice(startOrCount)
).map((item) => item.text);
}
@@ -477,12 +543,15 @@ function rethrow(err, str, filename, lineNumber, esc) {
}
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 hostSnapshot = resolveHostSnapshot(options.hostSnapshot);
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();
@@ -509,50 +578,63 @@ export function createTaskEjsRenderContext(entries = [], options = {}) {
Number(options.maxRecursion) > 0
? Number(options.maxRecursion)
: DEFAULT_MAX_RECURSION,
variableState: createVariableState(),
hostSnapshot,
variableState: createVariableState(hostSnapshot),
activatedEntries: new Map(),
pulledEntries: new Map(),
templateContext: {
...(options.templateContext || {}),
hostSnapshot: hostSnapshot.snapshot,
stSnapshot: hostSnapshot.snapshot,
},
};
}
export async function evalTaskEjsTemplate(
content,
renderCtx,
extraEnv = {},
) {
const runtime = await ensureEjsRuntime();
export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
const backend = await resolveTaskEjsBackend();
const runtime = backend.runtime;
const hostSnapshot = resolveHostSnapshot(renderCtx?.hostSnapshot);
const snapshot = hostSnapshot.snapshot;
if (!runtime) {
console.warn("[ST-BME] task-ejs 未找到全局 ejs 运行时,跳过渲染");
return substituteTaskEjsParams(content, renderCtx?.templateContext);
console.warn(
"[ST-BME] task-ejs 未找到可用 ejs runtime跳过渲染:",
backend,
);
return substituteTaskEjsParams(content, renderCtx?.templateContext, {
hostSnapshot,
});
}
const processed = substituteTaskEjsParams(content, renderCtx?.templateContext);
const processed = substituteTaskEjsParams(
content,
renderCtx?.templateContext,
{
hostSnapshot,
},
);
if (!processed.includes("<%")) {
return processed;
}
const stCtx = getStContext();
const chat = getStChat();
const stCtx = snapshot.raw || {};
const chat = snapshot.chat.messages || [];
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 ||
"";
: snapshot.chat.lastUserMessage || "";
const context = {
_: utilityLib,
console,
userName: stCtx.name1 || "",
charName: stCtx.name2 || "",
assistantName: stCtx.name2 || "",
characterId: stCtx.characterId,
userName: snapshot.user.name,
charName: snapshot.character.name,
assistantName: snapshot.character.name,
characterId: snapshot.character.id,
hostSnapshot: snapshot,
stSnapshot: snapshot,
get chatId() {
return stCtx.chatId || globalThis.getCurrentChatId?.() || "";
return snapshot.chat.id || "";
},
get variables() {
return renderCtx.variableState.cacheVars;
@@ -560,9 +642,7 @@ export async function evalTaskEjsTemplate(
get lastUserMessageId() {
return chat.findLastIndex
? chat.findLastIndex((message) => message?.is_user)
: [...chat]
.reverse()
.findIndex((message) => message?.is_user);
: [...chat].reverse().findIndex((message) => message?.is_user);
},
get lastUserMessage() {
return (
@@ -583,7 +663,9 @@ export async function evalTaskEjsTemplate(
},
get lastCharMessageId() {
return chat.findLastIndex
? chat.findLastIndex((message) => !message?.is_user && !message?.is_system)
? chat.findLastIndex(
(message) => !message?.is_user && !message?.is_system,
)
: [...chat]
.reverse()
.findIndex((message) => !message?.is_user && !message?.is_system);
@@ -602,44 +684,25 @@ export async function evalTaskEjsTemplate(
return chat.length - 1;
},
get charLoreBook() {
try {
const characters = stCtx.characters;
const charId = stCtx.characterId;
return characters?.[charId]?.data?.extensions?.world || "";
} catch {
return "";
}
return snapshot.worldbook.character || "";
},
get userLoreBook() {
return (
stCtx.extensionSettings?.persona_description_lorebook ||
stCtx.powerUserSettings?.persona_description_lorebook ||
stCtx.power_user?.persona_description_lorebook ||
""
);
return snapshot.worldbook.persona || "";
},
get chatLoreBook() {
return stCtx.chatMetadata?.world || "";
return snapshot.worldbook.chat || "";
},
get charAvatar() {
try {
const characters = stCtx.characters;
const charId = stCtx.characterId;
return characters?.[charId]?.avatar
? `/characters/${characters[charId].avatar}`
: "";
} catch {
return "";
}
return snapshot.character.avatar || "";
},
userAvatar: "",
userAvatar: snapshot.user.avatar || "",
groups: stCtx.groups || [],
groupId: stCtx.selectedGroupId ?? null,
groupId: snapshot.host.meta.selectedGroupId,
get model() {
return stCtx.onlineStatus || "";
return snapshot.host.meta.onlineStatus || "";
},
get SillyTavern() {
return getStContext();
return stCtx;
},
getwi: (worldbookOrEntry, entryNameOrData) =>
getwi(
@@ -704,20 +767,7 @@ export async function evalTaskEjsTemplate(
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 "";
}
},
getchr: () => snapshot.character.description || "",
getchar: undefined,
getChara: undefined,
getprp: async () => "",
@@ -750,8 +800,9 @@ export async function evalTaskEjsTemplate(
})),
selectActivatedEntries: () => [],
activateWorldInfoByKeywords: async () => [],
getEnabledLoreBooks: () =>
[...new Set(renderCtx.entries.map((entry) => entry.worldbook))],
getEnabledLoreBooks: () => [
...new Set(renderCtx.entries.map((entry) => entry.worldbook)),
],
activewi: async (world, entryOrForce, maybeForce) =>
activateWorldInfoInContext(
renderCtx,
@@ -781,9 +832,7 @@ export async function evalTaskEjsTemplate(
}
},
print: (...parts) =>
parts
.filter((part) => part !== undefined && part !== null)
.join(""),
parts.filter((part) => part !== undefined && part !== null).join(""),
...extraEnv,
};
@@ -813,17 +862,24 @@ export async function evalTaskEjsTemplate(
}
export async function renderTaskEjsContent(content, templateContext = {}) {
const processed = substituteTaskEjsParams(content, templateContext);
const hostSnapshot = resolveHostSnapshot(templateContext.hostSnapshot);
const processed = substituteTaskEjsParams(content, templateContext, {
hostSnapshot,
});
if (!processed.includes("<%")) {
return processed;
}
const renderCtx = createTaskEjsRenderContext([], { templateContext });
const renderCtx = createTaskEjsRenderContext([], {
templateContext,
hostSnapshot,
});
return await evalTaskEjsTemplate(processed, renderCtx);
}
export function checkTaskEjsSyntax(content) {
const runtime = getEjsRuntime();
export async function checkTaskEjsSyntax(content) {
const backend = await resolveTaskEjsBackend();
const runtime = backend.runtime;
if (!runtime || !String(content || "").includes("<%")) {
return null;
}
@@ -840,3 +896,7 @@ export function checkTaskEjsSyntax(content) {
return error instanceof Error ? error.message : String(error);
}
}
export async function inspectTaskEjsRuntimeBackend(options = {}) {
return await resolveTaskEjsBackend(options);
}

View File

@@ -3,6 +3,7 @@
// 同时叠加任务本地规则,并按任务阶段执行。
import { extension_settings, getContext } from "../../../extensions.js";
import { getHostAdapter } from "./host-adapter/index.js";
import { getActiveTaskProfile } from "./prompt-profiles.js";
const HTML_TAG_PATTERN =
@@ -26,7 +27,9 @@ const OUTPUT_STAGES = new Set([
function isBeautificationReplace(text = "") {
const normalized = String(text || "");
return HTML_TAG_PATTERN.test(normalized) || HTML_ATTR_PATTERN.test(normalized);
return (
HTML_TAG_PATTERN.test(normalized) || HTML_ATTR_PATTERN.test(normalized)
);
}
function parseRegexFromString(regexStr = "") {
@@ -88,7 +91,9 @@ function normalizeRule(raw = {}, fallbackSource = "local", index = 0) {
prompt: destination
? Boolean(destination.prompt)
: raw.promptOnly !== true,
display: destination ? Boolean(destination.display) : Boolean(raw.markdownOnly),
display: destination
? Boolean(destination.display)
: Boolean(raw.markdownOnly),
},
sourceType: fallbackSource,
raw,
@@ -113,21 +118,115 @@ function readArrayPath(root, paths = []) {
return [];
}
function collectViaApi(sourceType) {
const getter = globalThis?.getTavernRegexes;
if (typeof getter !== "function") return [];
function getLegacyRegexApi(name) {
const fn = globalThis?.[name];
return typeof fn === "function" ? fn : null;
}
function getRegexHost() {
const legacyGetTavernRegexes = getLegacyRegexApi("getTavernRegexes");
const legacyIsCharacterTavernRegexesEnabled = getLegacyRegexApi(
"isCharacterTavernRegexesEnabled",
);
try {
if (sourceType === "global") return getter({ type: "global" }) || [];
if (sourceType === "preset") return getter({ type: "preset", name: "in_use" }) || [];
const regexHost = getHostAdapter?.()?.regex || null;
if (typeof regexHost?.getTavernRegexes === "function") {
const capabilitySupport = regexHost.readCapabilitySupport?.() || {};
const supplementedCapabilities = [];
const missingCapabilities = [];
const resolvedCharacterToggle =
typeof regexHost.isCharacterTavernRegexesEnabled === "function"
? regexHost.isCharacterTavernRegexesEnabled
: legacyIsCharacterTavernRegexesEnabled;
if (typeof regexHost.isCharacterTavernRegexesEnabled !== "function") {
if (resolvedCharacterToggle) {
supplementedCapabilities.push("isCharacterTavernRegexesEnabled");
} else {
missingCapabilities.push("isCharacterTavernRegexesEnabled");
}
}
return {
getTavernRegexes: regexHost.getTavernRegexes,
isCharacterTavernRegexesEnabled: resolvedCharacterToggle,
sourceLabel: capabilitySupport.sourceLabel || "host-adapter.regex",
fallback:
Boolean(capabilitySupport.fallback) ||
supplementedCapabilities.length > 0,
capabilityStatus: Object.freeze({
mode: capabilitySupport.mode || "unknown",
supplementedCapabilities: Object.freeze(supplementedCapabilities),
missingCapabilities: Object.freeze(missingCapabilities),
}),
};
}
} catch (error) {
console.debug(
"[ST-BME] task-regex 读取 regex bridge 失败,回退到 legacy 宿主接口",
error,
);
}
const missingCapabilities = [];
if (typeof legacyGetTavernRegexes !== "function") {
missingCapabilities.push("getTavernRegexes");
}
if (typeof legacyIsCharacterTavernRegexesEnabled !== "function") {
missingCapabilities.push("isCharacterTavernRegexesEnabled");
}
return {
getTavernRegexes: legacyGetTavernRegexes,
isCharacterTavernRegexesEnabled: legacyIsCharacterTavernRegexesEnabled,
sourceLabel: "legacy.globalThis",
fallback: true,
capabilityStatus: Object.freeze({
mode: "legacy",
supplementedCapabilities: Object.freeze([]),
missingCapabilities: Object.freeze(missingCapabilities),
}),
};
}
function collectViaApi(sourceType, regexHost = null) {
const getter = regexHost?.getTavernRegexes;
if (typeof getter !== "function") {
return { supported: false, items: [] };
}
const success = (items) => ({
supported: true,
items: Array.isArray(items) ? items : [],
});
const unsupported = () => ({ supported: false, items: [] });
try {
if (sourceType === "global") {
return success(getter({ type: "global" }));
}
if (sourceType === "preset") {
return success(getter({ type: "preset", name: "in_use" }));
}
if (sourceType === "character") {
const checkEnabled = globalThis?.isCharacterTavernRegexesEnabled;
if (typeof checkEnabled === "function" && !checkEnabled()) return [];
return getter({ type: "character", name: "current" }) || [];
const checkEnabled = regexHost?.isCharacterTavernRegexesEnabled;
if (
typeof checkEnabled !== "function" &&
regexHost?.capabilityStatus?.mode === "partial"
) {
return unsupported();
}
if (typeof checkEnabled === "function" && !checkEnabled()) {
return success([]);
}
return success(getter({ type: "character", name: "current" }));
}
} catch {
return [];
return unsupported();
}
return [];
return unsupported();
}
function collectTavernRules(regexConfig = {}) {
@@ -145,6 +244,7 @@ function collectTavernRules(regexConfig = {}) {
const extSettings = context?.extensionSettings || extension_settings || {};
const oaiSettings =
context?.chatCompletionSettings || globalThis?.oai_settings || {};
const regexHost = getRegexHost();
const collected = [];
const seen = new Set();
@@ -160,9 +260,9 @@ function collectTavernRules(regexConfig = {}) {
};
if (enabledSources.global) {
const viaApi = collectViaApi("global");
if (viaApi.length > 0) {
pushRules(viaApi, "global");
const viaApi = collectViaApi("global", regexHost);
if (viaApi.supported) {
pushRules(viaApi.items, "global");
} else {
pushRules(
readArrayPath(extSettings, [["regex"], ["regex", "regex_scripts"]]),
@@ -172,21 +272,24 @@ function collectTavernRules(regexConfig = {}) {
}
if (enabledSources.preset) {
const viaApi = collectViaApi("preset");
if (viaApi.length > 0) {
pushRules(viaApi, "preset");
const viaApi = collectViaApi("preset", regexHost);
if (viaApi.supported) {
pushRules(viaApi.items, "preset");
} else {
pushRules(
readArrayPath(oaiSettings, [["regex_scripts"], ["extensions", "regex_scripts"]]),
readArrayPath(oaiSettings, [
["regex_scripts"],
["extensions", "regex_scripts"],
]),
"preset",
);
}
}
if (enabledSources.character) {
const viaApi = collectViaApi("character");
if (viaApi.length > 0) {
pushRules(viaApi, "character");
const viaApi = collectViaApi("character", regexHost);
if (viaApi.supported) {
pushRules(viaApi.items, "character");
} else {
const charId = context?.characterId;
const characters = context?.characters;
@@ -218,7 +321,9 @@ function collectLocalRules(regexConfig = {}) {
function shouldApplyRuleForStage(rule, stage = "", stagesConfig = {}) {
// 将细粒度的 stage 名映射到 input / output 两大类
if (PROMPT_STAGES.has(stage)) {
return stagesConfig.input !== false && rule.destinationFlags.prompt !== false;
return (
stagesConfig.input !== false && rule.destinationFlags.prompt !== false
);
}
if (OUTPUT_STAGES.has(stage)) {
return stagesConfig.output !== false;

View File

@@ -71,11 +71,98 @@ function getStContext() {
}
}
function getWorldbookApi(name) {
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 worldbookHost = getHostAdapter?.()?.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),
}),
};
}
} 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),
}),
};
}
function normalizeKey(value) {
return String(value ?? "").trim();
}
@@ -141,7 +228,9 @@ function parseDecorators(content = "") {
}
function isSpecialEntryByComment(comment = "") {
return SPECIAL_NAME_MARKERS.some((marker) => String(comment).includes(marker));
return SPECIAL_NAME_MARKERS.some((marker) =>
String(comment).includes(marker),
);
}
function normalizeEntry(raw = {}, worldbookName = "") {
@@ -183,9 +272,7 @@ function normalizeEntry(raw = {}, worldbookName = "") {
positionType === "after_author_note"
) {
position = WI_POSITION.ANBottom;
} else if (
positionType === "at_depth_as_assistant"
) {
} else if (positionType === "at_depth_as_assistant") {
position = WI_POSITION.atDepth;
role = "assistant";
} else if (positionType === "at_depth_as_user") {
@@ -288,7 +375,9 @@ function matchKeys(haystack = "", needle = "", entry) {
const source = entry.caseSensitive ? haystack : haystack.toLowerCase();
const target = entry.caseSensitive
? String(needle || "").trim()
: String(needle || "").trim().toLowerCase();
: String(needle || "")
.trim()
.toLowerCase();
if (!target) return false;
@@ -349,7 +438,11 @@ function sortEntries(a, b) {
);
}
function selectActivatedEntries(entries = [], trigger = "", templateContext = {}) {
function selectActivatedEntries(
entries = [],
trigger = "",
templateContext = {},
) {
const activationSeedBase = simpleHash(String(trigger || ""));
const activated = new Set();
@@ -386,7 +479,11 @@ function selectActivatedEntries(entries = [], trigger = "", templateContext = {}
"@@preprocessing",
"@@iframe",
];
if (entry.decorators.some((decorator) => specialDecorators.includes(decorator))) {
if (
entry.decorators.some((decorator) =>
specialDecorators.includes(decorator),
)
) {
continue;
}
if (isSpecialEntryByComment(entry.comment)) continue;
@@ -407,9 +504,13 @@ function selectActivatedEntries(entries = [], trigger = "", templateContext = {}
let hasAllMatch = true;
for (const secondaryKey of entry.keysSecondary) {
const substituted = substituteTaskEjsParams(secondaryKey, templateContext);
const substituted = substituteTaskEjsParams(
secondaryKey,
templateContext,
);
const hasMatch =
substituted.trim() !== "" && matchKeys(trigger, substituted.trim(), entry);
substituted.trim() !== "" &&
matchKeys(trigger, substituted.trim(), entry);
if (hasMatch) hasAnyMatch = true;
if (!hasMatch) hasAllMatch = false;
@@ -455,7 +556,9 @@ function selectActivatedEntries(entries = [], trigger = "", templateContext = {}
const prioritized = members.filter((entry) => entry.groupOverride);
if (prioritized.length > 0) {
const topOrder = Math.min(...prioritized.map((entry) => entry.order ?? 100));
const topOrder = Math.min(
...prioritized.map((entry) => entry.order ?? 100),
);
matched.push(
prioritized.find((entry) => (entry.order ?? 100) <= topOrder) ||
prioritized[0],
@@ -468,7 +571,10 @@ function selectActivatedEntries(entries = [], trigger = "", templateContext = {}
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);
const winnerIndex = Math.max(
scores.findIndex((score) => score >= topScore),
0,
);
matched.push(members[winnerIndex]);
continue;
}
@@ -495,13 +601,33 @@ function selectActivatedEntries(entries = [], trigger = "", templateContext = {}
}
async function collectAllWorldbookEntries() {
const getWorldbook = getWorldbookApi("getWorldbook");
const {
getWorldbook,
getLorebookEntries,
getCharWorldbookNames,
sourceLabel,
fallback,
capabilityStatus,
} = await getWorldbookHost();
if (!getWorldbook) {
return [];
}
const getLorebookEntries = getWorldbookApi("getLorebookEntries");
const getCharWorldbookNames = getWorldbookApi("getCharWorldbookNames");
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 allEntries = [];
const loadedNames = new Set();
@@ -524,7 +650,7 @@ async function collectAllWorldbookEntries() {
);
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取 lorebook comment 失败: ${normalizedName}`,
`[ST-BME] task-worldinfo 读取 lorebook comment 失败: ${normalizedName} [${sourceTag}]`,
error,
);
}
@@ -543,7 +669,7 @@ async function collectAllWorldbookEntries() {
}
} catch (error) {
console.debug(
`[ST-BME] task-worldinfo 读取世界书失败: ${normalizedName}`,
`[ST-BME] task-worldinfo 读取世界书失败: ${normalizedName} [${sourceTag}]`,
error,
);
}
@@ -559,7 +685,10 @@ async function collectAllWorldbookEntries() {
await loadWorldbookOnce(additional);
}
} catch (error) {
console.debug("[ST-BME] task-worldinfo 读取角色世界书失败", error);
console.debug(
`[ST-BME] task-worldinfo 读取角色世界书失败 [${sourceTag}]`,
error,
);
}
}
@@ -603,7 +732,9 @@ function normalizeResolvedEntry(entry = {}, fallbackIndex = 0) {
: "system";
return {
name: normalizeKey(entry.name),
sourceName: normalizeKey(entry.sourceName || entry.source_name || entry.name),
sourceName: normalizeKey(
entry.sourceName || entry.source_name || entry.name,
),
worldbook: normalizeKey(entry.worldbook),
content: String(entry.content || ""),
role,
@@ -642,7 +773,11 @@ function buildWorldInfoText(entries = []) {
.join("\n\n");
}
function buildActivationSourceTexts({ chatMessages = [], userMessage = "", templateContext = {} } = {}) {
function buildActivationSourceTexts({
chatMessages = [],
userMessage = "",
templateContext = {},
} = {}) {
const texts = [];
if (Array.isArray(chatMessages)) {

View File

@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const extensionsShimSource = [
"export function getContext(...args) {",
" return globalThis.SillyTavern?.getContext?.(...args) || null;",
"}",
].join("\n");
const extensionsShimUrl = `data:text/javascript,${encodeURIComponent(
extensionsShimSource,
)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "../../../extensions.js") {
return {
shortCircuit: true,
url: extensionsShimUrl,
};
}
return nextResolve(specifier, context);
},
});
const originalSillyTavern = globalThis.SillyTavern;
const originalGetCurrentChatId = globalThis.getCurrentChatId;
const originalEjs = globalThis.ejs;
try {
globalThis.getCurrentChatId = () => "chat-from-global";
globalThis.SillyTavern = {
getContext() {
return {
name1: "User",
name2: "Alice",
name1_description: "旧 persona 字段",
powerUserSettings: {
persona_description: "桥接 persona",
persona_description_lorebook: "persona-book",
},
extensionSettings: {
persona_description: "扩展 persona",
variables: {
global: {
score: 7,
},
},
},
characterId: 0,
characters: [
{
avatar: "alice.png",
data: {
description: "角色描述",
extensions: {
world: "char-book",
},
},
},
],
chatMetadata: {
world: "chat-book",
variables: {
location: "library",
},
},
chat: [
{ is_user: true, mes: "第一句" },
{
is_user: false,
mes: "回应",
variables: {
0: {
mood: "calm",
},
},
},
{ is_user: true, mes: "最后一句" },
],
onlineStatus: "gpt-test",
selectedGroupId: 42,
};
},
};
const { getSTContextForPrompt, getSTContextSnapshot } =
await import("../st-context.js");
const {
substituteTaskEjsParams,
createTaskEjsRenderContext,
evalTaskEjsTemplate,
checkTaskEjsSyntax,
inspectTaskEjsRuntimeBackend,
} = await import("../task-ejs.js");
const promptContext = getSTContextForPrompt();
assert.deepEqual(promptContext, {
userPersona: "桥接 persona",
charDescription: "角色描述",
charName: "Alice",
userName: "User",
currentTime: promptContext.currentTime,
});
const hostSnapshot = getSTContextSnapshot();
assert.equal(hostSnapshot.snapshot.persona.text, "桥接 persona");
assert.equal(hostSnapshot.snapshot.character.description, "角色描述");
assert.equal(hostSnapshot.snapshot.character.worldbook, "char-book");
assert.equal(hostSnapshot.snapshot.worldbook.persona, "persona-book");
assert.equal(hostSnapshot.snapshot.worldbook.chat, "chat-book");
assert.equal(hostSnapshot.snapshot.variables.global.score, 7);
assert.equal(hostSnapshot.snapshot.variables.local.location, "library");
assert.equal(hostSnapshot.snapshot.chat.lastUserMessage, "最后一句");
assert.equal(hostSnapshot.snapshot.chat.id, "chat-from-global");
assert.equal(hostSnapshot.prompt.charName, "Alice");
assert.equal(hostSnapshot.prompt.userPersona, "桥接 persona");
const substitution = substituteTaskEjsParams(
"{{charName}}|{{userPersona}}|{{hostSnapshot.worldbook.chat}}|{{stSnapshot.chat.lastUserMessage}}",
{},
{ hostSnapshot },
);
assert.equal(substitution, "Alice|桥接 persona|chat-book|最后一句");
const compileCalls = [];
globalThis.ejs = {
compile(template) {
compileCalls.push(template);
if (template === "<% broken") {
throw new Error("Unexpected end of input");
}
return async function compiled(locals) {
return [
locals.charName,
locals.userName,
locals.userLoreBook,
locals.chatLoreBook,
locals.variables.score,
locals.variables.location,
locals.lastUserMessage,
locals.hostSnapshot.character.worldbook,
locals.stSnapshot.chat.lastUserMessage,
typeof locals.execute,
].join("|");
};
},
};
const renderCtx = createTaskEjsRenderContext([], {
hostSnapshot,
templateContext: {},
});
const primaryBackend = await inspectTaskEjsRuntimeBackend({
ensureRuntime: false,
});
assert.equal(primaryBackend.status, "primary");
assert.equal(primaryBackend.isAvailable, true);
assert.equal(primaryBackend.isFallback, false);
const syntaxOk = await checkTaskEjsSyntax("<%= 1 %>");
assert.equal(syntaxOk, null);
const rendered = await evalTaskEjsTemplate("<%= 1 %>", renderCtx);
assert.equal(
rendered,
"Alice|User|persona-book|chat-book|7|library|最后一句|char-book|最后一句|function",
);
assert.deepEqual(compileCalls, ["<%= 1 %>", "<%= 1 %>"]);
const syntaxError = await checkTaskEjsSyntax("<% broken");
assert.equal(syntaxError, "Unexpected end of input");
delete globalThis.ejs;
const failedBackend = await inspectTaskEjsRuntimeBackend({
ensureRuntime: false,
});
assert.equal(failedBackend.status, "failed");
assert.equal(failedBackend.isAvailable, false);
assert.equal(failedBackend.isFallback, false);
const passthrough = await evalTaskEjsTemplate("{{charName}}", renderCtx);
assert.equal(passthrough, "Alice");
} finally {
globalThis.SillyTavern = originalSillyTavern;
globalThis.getCurrentChatId = originalGetCurrentChatId;
globalThis.ejs = originalEjs;
}

View File

@@ -30,13 +30,22 @@ const extractProfile = getActiveTaskProfile(
assert.equal(extractProfile.taskType, "extract");
assert.equal(extractProfile.id, "default");
assert.ok(Array.isArray(extractProfile.blocks));
assert.equal(extractProfile.blocks.length, 3);
assert.equal(extractProfile.blocks.length, 7);
assert.deepEqual(
extractProfile.blocks.map((block) => block.name),
["角色定义", "输出格式", "行为规则"],
[
"角色定义",
"角色描述",
"用户设定",
"世界书前块",
"世界书后块",
"输出格式",
"行为规则",
],
);
assert.ok(
extractProfile.blocks.every((block) => block.type === "custom"),
assert.deepEqual(
extractProfile.blocks.map((block) => block.type),
["custom", "builtin", "builtin", "builtin", "builtin", "custom", "custom"],
);
assert.equal(
extractProfile.metadata.legacyPromptField,

View File

@@ -51,7 +51,7 @@ const activeProfile = getActiveTaskProfile(
"extract",
);
assert.equal(activeProfile.name, "激进提取");
assert.equal(activeProfile.blocks.length, 5);
assert.equal(activeProfile.blocks.length, 9);
const builtinBlock = activeProfile.blocks.find(
(block) => block.type === "builtin" && block.sourceKey === "userMessage",
);

296
tests/task-regex.mjs Normal file
View File

@@ -0,0 +1,296 @@
import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const extensionsShimSource = [
"export const extension_settings = globalThis.__taskRegexTestExtensionSettings || {};",
"export function getContext(...args) {",
" return globalThis.SillyTavern?.getContext?.(...args) || null;",
"}",
].join("\n");
const extensionsShimUrl = `data:text/javascript,${encodeURIComponent(
extensionsShimSource,
)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "../../../extensions.js") {
return {
shortCircuit: true,
url: extensionsShimUrl,
};
}
return nextResolve(specifier, context);
},
});
const originalSillyTavern = globalThis.SillyTavern;
const originalGetTavernRegexes = globalThis.getTavernRegexes;
const originalIsCharacterTavernRegexesEnabled =
globalThis.isCharacterTavernRegexesEnabled;
const originalExtensionSettings = globalThis.__taskRegexTestExtensionSettings;
function createRule(id, find, replace, overrides = {}) {
return {
id,
script_name: id,
enabled: true,
find_regex: find,
replace_string: replace,
source: {
user_input: true,
ai_output: true,
...(overrides.source || {}),
},
destination: {
prompt: true,
display: false,
...(overrides.destination || {}),
},
...overrides,
};
}
try {
globalThis.__taskRegexTestExtensionSettings = {
regex: {
regex_scripts: [createRule("legacy-global", "/Gamma/g", "G")],
},
};
globalThis.SillyTavern = {
getContext() {
return {
extensionSettings: globalThis.__taskRegexTestExtensionSettings,
chatCompletionSettings: {
regex_scripts: [createRule("legacy-preset", "/Delta/g", "D")],
},
characterId: 0,
characters: [
{
extensions: {
regex_scripts: [
createRule("legacy-character", "/Epsilon/g", "E"),
],
},
},
],
};
},
};
globalThis.getTavernRegexes = () => {
throw new Error(
"legacy global getter should not be used when bridge exists",
);
};
globalThis.isCharacterTavernRegexesEnabled = () => {
throw new Error(
"legacy character toggle should not be used when bridge full capability exists",
);
};
const { initializeHostAdapter } = await import("../host-adapter/index.js");
const { applyTaskRegex } = await import("../task-regex.js");
const settings = {
taskProfiles: {
extract: {
activeProfileId: "bridge-profile",
profiles: [
{
id: "bridge-profile",
name: "Regex Bridge Test",
taskType: "extract",
builtin: false,
blocks: [],
regex: {
enabled: true,
inheritStRegex: true,
sources: {
global: true,
preset: true,
character: true,
},
stages: {
input: true,
output: true,
},
localRules: [createRule("local-tail", "/Beta/g", "B")],
},
},
],
},
},
};
const bridgeCalls = [];
initializeHostAdapter({
regexProvider: {
getTavernRegexes(request) {
bridgeCalls.push(request);
if (request?.type === "global") {
return [createRule("bridge-global", "/Alpha/g", "A")];
}
if (request?.type === "preset") {
return [createRule("bridge-preset", "/A/g", "P")];
}
if (request?.type === "character") {
return [createRule("bridge-character", "/P/g", "C")];
}
return [];
},
isCharacterTavernRegexesEnabled() {
return true;
},
},
});
const fullBridgeDebug = { entries: [] };
const fullBridgeOutput = applyTaskRegex(
settings,
"extract",
"finalPrompt",
"Alpha Beta",
fullBridgeDebug,
"system",
);
assert.equal(fullBridgeOutput, "C B");
assert.deepEqual(bridgeCalls, [
{ type: "global" },
{ type: "preset", name: "in_use" },
{ type: "character", name: "current" },
]);
assert.deepEqual(
fullBridgeDebug.entries[0].appliedRules.map((item) => item.id),
["bridge-global", "bridge-preset", "bridge-character", "local-tail"],
);
assert.deepEqual(fullBridgeDebug.entries[0].sourceCount, {
tavern: 3,
local: 1,
});
const partialBridgeCalls = [];
initializeHostAdapter({
regexProvider: {
getTavernRegexes(request) {
partialBridgeCalls.push(request);
if (request?.type === "global") {
return [createRule("partial-global", "/Gamma/g", "G1")];
}
return [];
},
},
});
const partialBridgeDebug = { entries: [] };
const partialBridgeOutput = applyTaskRegex(
settings,
"extract",
"finalPrompt",
"Gamma Delta Epsilon",
partialBridgeDebug,
"system",
);
assert.equal(partialBridgeOutput, "G1 Delta E");
assert.deepEqual(partialBridgeCalls, [
{ type: "global" },
{ type: "preset", name: "in_use" },
]);
assert.deepEqual(
partialBridgeDebug.entries[0].appliedRules.map((item) => item.id),
["partial-global", "legacy-character"],
);
assert.deepEqual(partialBridgeDebug.entries[0].sourceCount, {
tavern: 2,
local: 1,
});
const emptyBridgeCalls = [];
initializeHostAdapter({
regexProvider: {
getTavernRegexes(request) {
emptyBridgeCalls.push(request);
if (request?.type === "global") {
return [];
}
if (request?.type === "preset") {
return [createRule("bridge-preset-empty-guard", "/Theta/g", "T")];
}
if (request?.type === "character") {
return [createRule("bridge-character-empty-guard", "/T/g", "C2")];
}
return [];
},
isCharacterTavernRegexesEnabled() {
return true;
},
},
});
const emptyBridgeDebug = { entries: [] };
const emptyBridgeOutput = applyTaskRegex(
settings,
"extract",
"finalPrompt",
"Gamma Theta",
emptyBridgeDebug,
"system",
);
assert.equal(emptyBridgeOutput, "Gamma C2");
assert.deepEqual(emptyBridgeCalls, [
{ type: "global" },
{ type: "preset", name: "in_use" },
{ type: "character", name: "current" },
]);
assert.deepEqual(
emptyBridgeDebug.entries[0].appliedRules.map((item) => item.id),
["bridge-preset-empty-guard", "bridge-character-empty-guard"],
);
assert.equal(
emptyBridgeDebug.entries[0].appliedRules.some(
(item) => item.id === "legacy-global",
),
false,
);
assert.deepEqual(emptyBridgeDebug.entries[0].sourceCount, {
tavern: 2,
local: 1,
});
console.log("task-regex tests passed");
} finally {
if (originalSillyTavern === undefined) {
delete globalThis.SillyTavern;
} else {
globalThis.SillyTavern = originalSillyTavern;
}
if (originalGetTavernRegexes === undefined) {
delete globalThis.getTavernRegexes;
} else {
globalThis.getTavernRegexes = originalGetTavernRegexes;
}
if (originalIsCharacterTavernRegexesEnabled === undefined) {
delete globalThis.isCharacterTavernRegexesEnabled;
} else {
globalThis.isCharacterTavernRegexesEnabled =
originalIsCharacterTavernRegexesEnabled;
}
if (originalExtensionSettings === undefined) {
delete globalThis.__taskRegexTestExtensionSettings;
} else {
globalThis.__taskRegexTestExtensionSettings = originalExtensionSettings;
}
try {
const { initializeHostAdapter } = await import("../host-adapter/index.js");
initializeHostAdapter({});
} catch {
// ignore reset failures in test cleanup
}
}

View File

@@ -1,4 +1,26 @@
import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const extensionsShimSource = [
"export function getContext(...args) {",
" return globalThis.SillyTavern?.getContext?.(...args) || null;",
"}",
].join("\n");
const extensionsShimUrl = `data:text/javascript,${encodeURIComponent(
extensionsShimSource,
)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "../../../extensions.js") {
return {
shortCircuit: true,
url: extensionsShimUrl,
};
}
return nextResolve(specifier, context);
},
});
const originalSillyTavern = globalThis.SillyTavern;
const originalGetCharWorldbookNames = globalThis.getCharWorldbookNames;
@@ -89,6 +111,29 @@ const atDepthEntry = {
extra: {},
};
function createConstantWorldbookEntry(uid, name, content, comment = "") {
return {
uid,
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: {},
};
}
try {
globalThis.SillyTavern = {
getContext() {
@@ -129,7 +174,10 @@ try {
["常驻设定", "EW/Controller/Main", "线索条目"],
);
assert.equal(worldInfo.additionalMessages.length, 1);
assert.equal(worldInfo.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.equal(
worldInfo.additionalMessages[0].content,
"这是一条 atDepth 消息。",
);
const settings = {
taskProfiles: {
@@ -176,8 +224,102 @@ try {
assert.match(promptBuild.systemPrompt, /这里是常驻世界设定/);
assert.match(promptBuild.systemPrompt, /隐藏线索Alice 正在调查/);
assert.equal(
promptBuild.privateTaskMessages.length,
2,
"custom user block + atDepth world info should both enter private task messages",
);
assert.deepEqual(
promptBuild.privateTaskMessages.map((message) => message.role),
["user", "system"],
);
assert.deepEqual(
promptBuild.hostInjections.before.map((entry) => entry.name),
["常驻设定", "EW/Controller/Main", "线索条目"],
);
assert.equal(promptBuild.hostInjections.after.length, 0);
assert.equal(promptBuild.hostInjections.atDepth.length, 1);
assert.equal(promptBuild.hostInjections.atDepth[0].depth, 2);
assert.deepEqual(
promptBuild.renderedBlocks.map((block) => block.delivery),
["host.before", "private.message"],
);
assert.equal(promptBuild.additionalMessages.length, 1);
assert.equal(promptBuild.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.equal(
promptBuild.additionalMessages[0].content,
"这是一条 atDepth 消息。",
);
const { initializeHostAdapter } = await import("../host-adapter/index.js");
const partialBridgeCalls = [];
const partialBridgeEntriesByWorldbook = {
"main-book": [createConstantWorldbookEntry(11, "主书原名", "主书内容。")],
"side-book": [createConstantWorldbookEntry(12, "支线原名", "支线内容。")],
"persona-book": [
createConstantWorldbookEntry(13, "人格原名", "人格内容。"),
],
"chat-book": [createConstantWorldbookEntry(14, "聊天原名", "聊天内容。")],
};
globalThis.SillyTavern = {
getContext() {
return {
name1: "User",
name2: "Alice",
chat: [{ is_user: true, mes: "我们继续调查那条线索" }],
chatMetadata: {
world: "chat-book",
},
extensionSettings: {
persona_description_lorebook: "persona-book",
},
};
},
};
globalThis.getCharWorldbookNames = () => ({
primary: "main-book",
additional: ["side-book"],
});
globalThis.getWorldbook = async () => {
throw new Error(
"legacy getWorldbook should not be used when bridge getWorldbook is available",
);
};
globalThis.getLorebookEntries = async (worldbookName) =>
({
"main-book": [{ uid: 11, comment: "主书注释" }],
"side-book": [{ uid: 12, comment: "支线注释" }],
"persona-book": [{ uid: 13, comment: "人格注释" }],
"chat-book": [{ uid: 14, comment: "聊天注释" }],
})[worldbookName] || [];
initializeHostAdapter({
worldbookProvider: {
async getWorldbook(worldbookName) {
partialBridgeCalls.push(worldbookName);
return partialBridgeEntriesByWorldbook[worldbookName] || [];
},
},
});
const partialBridgeWorldInfo = await resolveTaskWorldInfo({
templateContext: {
recentMessages: "我们继续调查那条线索",
charName: "Alice",
},
userMessage: "继续调查",
});
assert.deepEqual(partialBridgeCalls, [
"main-book",
"side-book",
"persona-book",
"chat-book",
]);
assert.deepEqual(
partialBridgeWorldInfo.beforeEntries.map((entry) => entry.name).sort(),
["主书注释", "支线注释", "人格注释", "聊天注释"].sort(),
);
console.log("task-worldinfo tests passed");
} finally {
@@ -204,4 +346,11 @@ try {
} else {
globalThis.getLorebookEntries = originalGetLorebookEntries;
}
try {
const { initializeHostAdapter } = await import("../host-adapter/index.js");
initializeHostAdapter({});
} catch {
// ignore reset failures in test cleanup
}
}