mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat: integrate host bridge prompt pipeline
This commit is contained in:
@@ -265,10 +265,11 @@ async function summarizeBatch(
|
||||
maxRetries: 1,
|
||||
signal,
|
||||
taskType: "compress",
|
||||
additionalMessages: [
|
||||
...(compressPromptBuild.customMessages || []),
|
||||
...(compressPromptBuild.additionalMessages || []),
|
||||
],
|
||||
additionalMessages:
|
||||
compressPromptBuild.privateTaskMessages || [
|
||||
...(compressPromptBuild.customMessages || []),
|
||||
...(compressPromptBuild.additionalMessages || []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
27
extractor.js
27
extractor.js
@@ -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;
|
||||
|
||||
113
host-adapter/capabilities.js
Normal file
113
host-adapter/capabilities.js
Normal 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
88
host-adapter/context.js
Normal 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
176
host-adapter/index.js
Normal 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
102
host-adapter/injection.js
Normal 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
281
host-adapter/regex.js
Normal 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
276
host-adapter/worldbook.js
Normal 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
210
index.js
@@ -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
49
llm.js
@@ -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: "" };
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
193
st-context.js
193
st-context.js
@@ -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;
|
||||
}
|
||||
|
||||
358
task-ejs.js
358
task-ejs.js
@@ -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);
|
||||
}
|
||||
|
||||
151
task-regex.js
151
task-regex.js
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
187
tests/st-context-task-ejs.mjs
Normal file
187
tests/st-context-task-ejs.mjs
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
296
tests/task-regex.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user