mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Reorganize modules into layered directories
This commit is contained in:
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,
|
||||
});
|
||||
}
|
||||
89
host/adapter/context.js
Normal file
89
host/adapter/context.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getContext as extensionGetContext } from "../../../../extensions.js";
|
||||
|
||||
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
|
||||
import { debugDebug } from "../../runtime/debug-logging.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) {
|
||||
debugDebug(
|
||||
"[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) {
|
||||
debugDebug("[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;
|
||||
}
|
||||
103
host/adapter/injection.js
Normal file
103
host/adapter/injection.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
|
||||
import { createContextHostFacade } from "./context.js";
|
||||
import { debugDebug } from "../../runtime/debug-logging.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) {
|
||||
debugDebug(
|
||||
"[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);
|
||||
}
|
||||
308
host/adapter/regex.js
Normal file
308
host/adapter/regex.js
Normal file
@@ -0,0 +1,308 @@
|
||||
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
|
||||
import { createContextHostFacade } from "./context.js";
|
||||
import { debugDebug } from "../../runtime/debug-logging.js";
|
||||
|
||||
const REGEX_API_NAMES = [
|
||||
"getTavernRegexes",
|
||||
"isCharacterTavernRegexesEnabled",
|
||||
"formatAsTavernRegexedString",
|
||||
];
|
||||
|
||||
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) {
|
||||
debugDebug("[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" ||
|
||||
typeof record.apiMap.formatAsTavernRegexedString === "function",
|
||||
) ||
|
||||
buildSourceRecord({
|
||||
label: "none",
|
||||
sourceKind: "unavailable",
|
||||
container: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function detectRegexMode(apiMap = {}) {
|
||||
const hasGetter = typeof apiMap.getTavernRegexes === "function";
|
||||
const hasFormatter =
|
||||
typeof apiMap.formatAsTavernRegexedString === "function";
|
||||
|
||||
if (!hasGetter && !hasFormatter) {
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
if (hasGetter && hasFormatter) {
|
||||
return typeof apiMap.isCharacterTavernRegexesEnabled === "function"
|
||||
? "full"
|
||||
: "partial";
|
||||
}
|
||||
|
||||
return hasFormatter ? "formatter-only" : "getter-only";
|
||||
}
|
||||
|
||||
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"}`;
|
||||
}
|
||||
|
||||
if (mode === "formatter-only") {
|
||||
return `Tavern Regex 桥接仅发现 formatter 接口,来源: ${sourceRecord?.label || "unknown"}`;
|
||||
}
|
||||
|
||||
if (mode === "getter-only") {
|
||||
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,
|
||||
formatAsTavernRegexedString:
|
||||
sourceRecord.apiMap.formatAsTavernRegexedString,
|
||||
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,
|
||||
formatterAvailable:
|
||||
typeof sourceRecord.apiMap.formatAsTavernRegexedString === "function",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function inspectRegexHostCapability(options = {}) {
|
||||
const facade = createRegexHostFacade(options);
|
||||
return buildCapabilityStatus(facade);
|
||||
}
|
||||
277
host/adapter/worldbook.js
Normal file
277
host/adapter/worldbook.js
Normal file
@@ -0,0 +1,277 @@
|
||||
import { buildCapabilityStatus, mergeVersionHints } from "./capabilities.js";
|
||||
import { createContextHostFacade } from "./context.js";
|
||||
import { debugDebug } from "../../runtime/debug-logging.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) {
|
||||
debugDebug("[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);
|
||||
}
|
||||
758
host/event-binding.js
Normal file
758
host/event-binding.js
Normal file
@@ -0,0 +1,758 @@
|
||||
function getTimerApi(runtime) {
|
||||
const rawSetTimeout =
|
||||
typeof runtime?.setTimeout === "function"
|
||||
? runtime.setTimeout
|
||||
: globalThis.setTimeout;
|
||||
const rawClearTimeout =
|
||||
typeof runtime?.clearTimeout === "function"
|
||||
? runtime.clearTimeout
|
||||
: globalThis.clearTimeout;
|
||||
|
||||
return {
|
||||
setTimeout(...args) {
|
||||
return Reflect.apply(rawSetTimeout, globalThis, args);
|
||||
},
|
||||
clearTimeout(...args) {
|
||||
return Reflect.apply(rawClearTimeout, globalThis, args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toSafeFloor(value, fallback = null) {
|
||||
if (value == null || value === "") return fallback;
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? Math.floor(numeric) : fallback;
|
||||
}
|
||||
|
||||
export function registerBeforeCombinePromptsController(runtime, listener) {
|
||||
const makeFirst = runtime.getEventMakeFirst();
|
||||
if (typeof makeFirst === "function") {
|
||||
return makeFirst(
|
||||
runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS,
|
||||
listener,
|
||||
);
|
||||
}
|
||||
|
||||
runtime.console.warn("[ST-BME] eventMakeFirst 不可用,回退到普通事件注册");
|
||||
runtime.eventSource.on(
|
||||
runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS,
|
||||
listener,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerGenerationAfterCommandsController(runtime, listener) {
|
||||
const makeFirst = runtime.getEventMakeFirst();
|
||||
const eventName = runtime.eventTypes.GENERATION_AFTER_COMMANDS;
|
||||
if (typeof makeFirst === "function") {
|
||||
const cleanup = makeFirst(eventName, listener);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
runtime.console.warn(
|
||||
"[ST-BME] eventMakeFirst 不可用,GENERATION_AFTER_COMMANDS 回退到普通事件注册",
|
||||
);
|
||||
runtime.eventSource.on(eventName, listener);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scheduleSendIntentHookRetryController(runtime, delayMs = 400) {
|
||||
const timers = getTimerApi(runtime);
|
||||
timers.clearTimeout(runtime.getSendIntentHookRetryTimer());
|
||||
const timer = timers.setTimeout(() => {
|
||||
runtime.setSendIntentHookRetryTimer(null);
|
||||
runtime.installSendIntentHooks();
|
||||
}, delayMs);
|
||||
runtime.setSendIntentHookRetryTimer(timer);
|
||||
}
|
||||
|
||||
export function installSendIntentHooksController(runtime) {
|
||||
for (const cleanup of runtime.consumeSendIntentHookCleanup()) {
|
||||
try {
|
||||
cleanup();
|
||||
} catch (error) {
|
||||
runtime.console.warn("[ST-BME] 清理发送意图钩子失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const sendButton = runtime.document.getElementById("send_but");
|
||||
const sendTextarea = runtime.document.getElementById("send_textarea");
|
||||
|
||||
if (sendButton) {
|
||||
const captureSendIntent = () => {
|
||||
runtime.recordRecallSendIntent(
|
||||
runtime.getSendTextareaValue(),
|
||||
"send-button",
|
||||
);
|
||||
};
|
||||
|
||||
sendButton.addEventListener("click", captureSendIntent, true);
|
||||
sendButton.addEventListener("pointerup", captureSendIntent, true);
|
||||
sendButton.addEventListener("touchend", captureSendIntent, true);
|
||||
runtime.pushSendIntentHookCleanup(() => {
|
||||
sendButton.removeEventListener("click", captureSendIntent, true);
|
||||
sendButton.removeEventListener("pointerup", captureSendIntent, true);
|
||||
sendButton.removeEventListener("touchend", captureSendIntent, true);
|
||||
});
|
||||
}
|
||||
|
||||
if (sendTextarea) {
|
||||
const captureEnterIntent = (event) => {
|
||||
if (
|
||||
(event.key === "Enter" || event.key === "NumpadEnter") &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
runtime.recordRecallSendIntent(
|
||||
runtime.getSendTextareaValue(),
|
||||
"textarea-enter",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
sendTextarea.addEventListener("keydown", captureEnterIntent, true);
|
||||
runtime.pushSendIntentHookCleanup(() => {
|
||||
sendTextarea.removeEventListener("keydown", captureEnterIntent, true);
|
||||
});
|
||||
}
|
||||
|
||||
if (!sendButton || !sendTextarea) {
|
||||
runtime.scheduleSendIntentHookRetry();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCoreEventHooksController(runtime) {
|
||||
const { eventSource, eventTypes, handlers } = runtime;
|
||||
const registrationState = runtime.getCoreEventBindingState?.() || {};
|
||||
|
||||
if (registrationState.registered) {
|
||||
runtime.console?.warn?.("[ST-BME] 核心事件已注册,跳过重复绑定");
|
||||
return registrationState;
|
||||
}
|
||||
|
||||
const cleanups = [];
|
||||
const bind = (eventName, listener) => {
|
||||
if (!eventName || typeof listener !== "function") return;
|
||||
eventSource.on(eventName, listener);
|
||||
if (typeof eventSource.off === "function") {
|
||||
cleanups.push(() => eventSource.off(eventName, listener));
|
||||
} else if (typeof eventSource.removeListener === "function") {
|
||||
cleanups.push(() => eventSource.removeListener(eventName, listener));
|
||||
}
|
||||
};
|
||||
|
||||
bind(eventTypes.CHAT_CHANGED, handlers.onChatChanged);
|
||||
if (eventTypes.CHAT_LOADED) {
|
||||
bind(eventTypes.CHAT_LOADED, handlers.onChatLoaded);
|
||||
}
|
||||
if (eventTypes.MESSAGE_SENT) {
|
||||
bind(eventTypes.MESSAGE_SENT, handlers.onMessageSent);
|
||||
}
|
||||
if (eventTypes.GENERATION_STARTED) {
|
||||
bind(eventTypes.GENERATION_STARTED, handlers.onGenerationStarted);
|
||||
}
|
||||
if (eventTypes.GENERATION_ENDED) {
|
||||
bind(eventTypes.GENERATION_ENDED, handlers.onGenerationEnded);
|
||||
}
|
||||
|
||||
const beforeCombineCleanup = runtime.registerBeforeCombinePrompts(
|
||||
handlers.onBeforeCombinePrompts,
|
||||
);
|
||||
if (typeof beforeCombineCleanup === "function") {
|
||||
cleanups.push(beforeCombineCleanup);
|
||||
}
|
||||
|
||||
const afterCommandsCleanup = runtime.registerGenerationAfterCommands(
|
||||
handlers.onGenerationAfterCommands,
|
||||
);
|
||||
if (typeof afterCommandsCleanup === "function") {
|
||||
cleanups.push(afterCommandsCleanup);
|
||||
}
|
||||
|
||||
bind(eventTypes.MESSAGE_RECEIVED, handlers.onMessageReceived);
|
||||
bind(eventTypes.MESSAGE_DELETED, handlers.onMessageDeleted);
|
||||
bind(eventTypes.MESSAGE_EDITED, handlers.onMessageEdited);
|
||||
bind(eventTypes.MESSAGE_SWIPED, handlers.onMessageSwiped);
|
||||
if (eventTypes.MESSAGE_UPDATED) {
|
||||
bind(eventTypes.MESSAGE_UPDATED, handlers.onMessageEdited);
|
||||
}
|
||||
if (eventTypes.USER_MESSAGE_RENDERED) {
|
||||
bind(eventTypes.USER_MESSAGE_RENDERED, handlers.onUserMessageRendered);
|
||||
}
|
||||
if (eventTypes.CHARACTER_MESSAGE_RENDERED) {
|
||||
bind(
|
||||
eventTypes.CHARACTER_MESSAGE_RENDERED,
|
||||
handlers.onCharacterMessageRendered,
|
||||
);
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
registered: true,
|
||||
cleanups,
|
||||
registeredAt: Date.now(),
|
||||
};
|
||||
runtime.setCoreEventBindingState?.(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function onChatChangedController(runtime) {
|
||||
const timers = getTimerApi(runtime);
|
||||
runtime.clearPendingHistoryMutationChecks();
|
||||
timers.clearTimeout(runtime.getPendingHistoryRecoveryTimer());
|
||||
runtime.setPendingHistoryRecoveryTimer(null);
|
||||
runtime.setPendingHistoryRecoveryTrigger("");
|
||||
runtime.clearPendingAutoExtraction?.();
|
||||
runtime.clearPendingGraphLoadRetry();
|
||||
runtime.setSkipBeforeCombineRecallUntil(0);
|
||||
runtime.setLastPreGenerationRecallKey("");
|
||||
runtime.setLastPreGenerationRecallAt(0);
|
||||
runtime.clearGenerationRecallTransactionsForChat("", { clearAll: true });
|
||||
runtime.abortAllRunningStages();
|
||||
runtime.dismissAllStageNotices();
|
||||
runtime.syncGraphLoadFromLiveContext({
|
||||
source: "chat-changed",
|
||||
force: true,
|
||||
});
|
||||
runtime.clearCurrentGenerationTrivialSkip?.("chat-changed");
|
||||
runtime.clearInjectionState();
|
||||
runtime.clearRecallInputTracking();
|
||||
runtime.installSendIntentHooks();
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
}
|
||||
|
||||
export function onChatLoadedController(runtime) {
|
||||
runtime.syncGraphLoadFromLiveContext({
|
||||
source: "chat-loaded",
|
||||
});
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
}
|
||||
|
||||
export function onMessageSentController(runtime, messageId) {
|
||||
const context = runtime.getContext();
|
||||
const chat = context?.chat;
|
||||
const normalizedMessageId =
|
||||
messageId === null || messageId === undefined || messageId === ""
|
||||
? null
|
||||
: Number(messageId);
|
||||
let resolvedMessageId = Number.isFinite(normalizedMessageId)
|
||||
? normalizedMessageId
|
||||
: null;
|
||||
let message =
|
||||
Array.isArray(chat) && Number.isFinite(resolvedMessageId)
|
||||
? chat[resolvedMessageId]
|
||||
: null;
|
||||
|
||||
if (!message?.is_user && Array.isArray(chat)) {
|
||||
for (let index = chat.length - 1; index >= 0; index--) {
|
||||
if (!chat[index]?.is_user) continue;
|
||||
resolvedMessageId = index;
|
||||
message = chat[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!message?.is_user) return;
|
||||
if (runtime.isTrivialUserInput?.(message.mes || "")?.trivial) {
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
return;
|
||||
}
|
||||
runtime.recordRecallSentUserMessage(
|
||||
resolvedMessageId,
|
||||
message.mes || "",
|
||||
);
|
||||
// GENERATION_AFTER_COMMANDS 在 sendMessageAsUser 之前触发,此时新用户消息
|
||||
// 尚未进入 chat,recall 记录会被写到上一条 user 上。这里用户消息刚入场,
|
||||
// transaction 仍在桥接窗口内,立即把记录重新绑定到正确的楼层。
|
||||
runtime.rebindRecallRecordToNewUserMessage?.(resolvedMessageId);
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
}
|
||||
|
||||
export function onUserMessageRenderedController(runtime, messageId = null) {
|
||||
// MESSAGE_SENT 早于实际 DOM 挂载;这里等宿主确认 user 楼层渲染完成后,
|
||||
// 再补一次 Recall Card 刷新,避免“当前楼层没卡片,下一楼才补出来”。
|
||||
runtime.refreshPersistedRecallMessageUi?.(40);
|
||||
return {
|
||||
messageId: Number.isFinite(Number(messageId)) ? Number(messageId) : null,
|
||||
refreshed: true,
|
||||
source: "user-message-rendered",
|
||||
};
|
||||
}
|
||||
|
||||
export function onCharacterMessageRenderedController(
|
||||
runtime,
|
||||
messageId = null,
|
||||
type = "",
|
||||
) {
|
||||
runtime.refreshPersistedRecallMessageUi?.(80);
|
||||
return {
|
||||
messageId: Number.isFinite(Number(messageId)) ? Number(messageId) : null,
|
||||
type: String(type || ""),
|
||||
refreshed: true,
|
||||
source: "character-message-rendered",
|
||||
};
|
||||
}
|
||||
|
||||
export function onGenerationStartedController(
|
||||
runtime,
|
||||
type,
|
||||
params = {},
|
||||
dryRun = false,
|
||||
) {
|
||||
if (dryRun) {
|
||||
runtime.markDryRunPromptPreview?.();
|
||||
return null;
|
||||
}
|
||||
runtime.clearDryRunPromptPreview?.();
|
||||
if (params?.automatic_trigger || params?.quiet_prompt) return null;
|
||||
|
||||
const generationType = String(type || "normal").trim() || "normal";
|
||||
if (generationType !== "normal") return null;
|
||||
|
||||
const pendingSendIntent = runtime.getPendingRecallSendIntent?.();
|
||||
const pendingIntentText = runtime.isFreshRecallInputRecord?.(
|
||||
pendingSendIntent,
|
||||
)
|
||||
? pendingSendIntent.text
|
||||
: "";
|
||||
const textareaText =
|
||||
typeof runtime.getSendTextareaValue === "function"
|
||||
? runtime.getSendTextareaValue()
|
||||
: "";
|
||||
const snapshotText =
|
||||
runtime.normalizeRecallInputText?.(pendingIntentText || textareaText) || "";
|
||||
const trivialInputResult = runtime.isTrivialUserInput?.(snapshotText);
|
||||
if (trivialInputResult?.trivial) {
|
||||
const context = runtime.getContext?.() || {};
|
||||
runtime.markCurrentGenerationTrivialSkip?.({
|
||||
reason: trivialInputResult.reason,
|
||||
chatId: context?.chatId || "",
|
||||
chatLength: Array.isArray(context?.chat) ? context.chat.length : 0,
|
||||
});
|
||||
runtime.clearPendingRecallSendIntent?.();
|
||||
runtime.clearPendingHostGenerationInputSnapshot?.();
|
||||
console.info?.(
|
||||
`[ST-BME] trivial-input skip: reason=${trivialInputResult.reason} len=${trivialInputResult.normalizedText.length} hook=GENERATION_STARTED`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
runtime.clearCurrentGenerationTrivialSkip?.("new-non-trivial-generation");
|
||||
return runtime.freezeHostGenerationInputSnapshot(
|
||||
snapshotText,
|
||||
pendingIntentText
|
||||
? "generation-started-send-intent"
|
||||
: "generation-started-textarea",
|
||||
);
|
||||
}
|
||||
|
||||
export function onMessageDeletedController(
|
||||
runtime,
|
||||
chatLengthOrMessageId,
|
||||
meta = null,
|
||||
) {
|
||||
runtime.invalidateRecallAfterHistoryMutation("消息已删除");
|
||||
runtime.scheduleHistoryMutationRecheck(
|
||||
"message-deleted",
|
||||
chatLengthOrMessageId,
|
||||
meta,
|
||||
);
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
}
|
||||
|
||||
export function onMessageEditedController(runtime, messageId, meta = null) {
|
||||
runtime.invalidateRecallAfterHistoryMutation("消息已编辑");
|
||||
runtime.scheduleHistoryMutationRecheck("message-edited", messageId, meta);
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
}
|
||||
|
||||
export async function onMessageSwipedController(runtime, messageId, meta = null) {
|
||||
runtime.invalidateRecallAfterHistoryMutation("已切换楼层 swipe");
|
||||
const parsedFloor = Number(messageId);
|
||||
const fromFloor = Number.isFinite(parsedFloor) ? parsedFloor : undefined;
|
||||
let result = {
|
||||
success: false,
|
||||
rollbackPerformed: false,
|
||||
extractionTriggered: false,
|
||||
requestedFloor: fromFloor ?? null,
|
||||
effectiveFromFloor: null,
|
||||
recoveryPath: "reroll-handler-unavailable",
|
||||
affectedBatchCount: 0,
|
||||
error: "swipe reroll handler unavailable",
|
||||
};
|
||||
|
||||
if (typeof runtime.onReroll === "function") {
|
||||
try {
|
||||
result = await runtime.onReroll({ fromFloor, meta });
|
||||
} catch (error) {
|
||||
runtime.console?.error?.("[ST-BME] swipe reroll failed:", error);
|
||||
result = {
|
||||
success: false,
|
||||
rollbackPerformed: false,
|
||||
extractionTriggered: false,
|
||||
requestedFloor: fromFloor ?? null,
|
||||
effectiveFromFloor: null,
|
||||
recoveryPath: "reroll-threw",
|
||||
affectedBatchCount: 0,
|
||||
error: error?.message || String(error) || "swipe reroll failed",
|
||||
};
|
||||
}
|
||||
} else {
|
||||
runtime.console?.warn?.(
|
||||
"[ST-BME] MESSAGE_SWIPED missing onReroll; skip generic history recovery fallback.",
|
||||
{ messageId, meta },
|
||||
);
|
||||
}
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function onGenerationAfterCommandsController(
|
||||
runtime,
|
||||
type,
|
||||
params = {},
|
||||
dryRun = false,
|
||||
) {
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generationType = String(type || "normal").trim() || "normal";
|
||||
const frozenInputSnapshot =
|
||||
generationType === "normal"
|
||||
? runtime.consumeHostGenerationInputSnapshot?.({ preserve: true }) ||
|
||||
runtime.consumeHostGenerationInputSnapshot?.()
|
||||
: null;
|
||||
|
||||
const context = runtime.getContext();
|
||||
const chat = context?.chat;
|
||||
|
||||
const recallOptions = runtime.buildGenerationAfterCommandsRecallInput(
|
||||
type,
|
||||
{
|
||||
...params,
|
||||
frozenInputSnapshot,
|
||||
},
|
||||
chat,
|
||||
);
|
||||
if (!recallOptions) {
|
||||
return;
|
||||
}
|
||||
if (recallOptions?.__trivialSkip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recallContext = runtime.createGenerationRecallContext({
|
||||
hookName: "GENERATION_AFTER_COMMANDS",
|
||||
generationType,
|
||||
recallOptions,
|
||||
});
|
||||
if (!recallContext.shouldRun && !recallContext.transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeRecallOptions =
|
||||
recallContext.recallOptions || recallOptions || {};
|
||||
const deliveryMode =
|
||||
runtime.resolveGenerationRecallDeliveryMode?.(
|
||||
recallContext.hookName,
|
||||
recallContext.generationType,
|
||||
runtimeRecallOptions,
|
||||
) || "immediate";
|
||||
let recallResult = runtime.getGenerationRecallTransactionResult?.(
|
||||
recallContext.transaction,
|
||||
);
|
||||
|
||||
if (recallContext.shouldRun) {
|
||||
runtime.markGenerationRecallTransactionHookState(
|
||||
recallContext.transaction,
|
||||
recallContext.hookName,
|
||||
"running",
|
||||
);
|
||||
if (deliveryMode === "deferred") {
|
||||
runtime.clearLiveRecallInjectionPromptForRewrite?.();
|
||||
}
|
||||
recallResult = await runtime.runRecall({
|
||||
...runtimeRecallOptions,
|
||||
deliveryMode,
|
||||
recallKey: recallContext.recallKey,
|
||||
hookName: recallContext.hookName,
|
||||
signal: params?.signal,
|
||||
});
|
||||
runtime.storeGenerationRecallTransactionResult?.(
|
||||
recallContext.transaction,
|
||||
recallResult,
|
||||
{
|
||||
hookName: recallContext.hookName,
|
||||
deliveryMode,
|
||||
},
|
||||
);
|
||||
|
||||
runtime.markGenerationRecallTransactionHookState(
|
||||
recallContext.transaction,
|
||||
recallContext.hookName,
|
||||
runtime.getGenerationRecallHookStateFromResult(recallResult),
|
||||
);
|
||||
}
|
||||
|
||||
// immediate 模式下,runRecall → applyRecallInjection 内部已通过
|
||||
// setExtensionPrompt 完成了注入,此处直接返回召回结果。
|
||||
// 后续 GENERATE_BEFORE_COMBINE_PROMPTS 阶段会通过
|
||||
// applyFinalRecallInjectionForGeneration 做 deferred rewrite 兜底。
|
||||
if (deliveryMode === "immediate") {
|
||||
runtime.ensurePersistedRecallRecordForGeneration?.({
|
||||
generationType: recallContext.generationType,
|
||||
recallResult,
|
||||
transaction: recallContext.transaction,
|
||||
recallOptions: runtimeRecallOptions,
|
||||
hookName: recallContext.hookName,
|
||||
});
|
||||
// immediate 路径通常会在 runRecall 内完成持久化;如果当时 user 楼层还没稳定,
|
||||
// 上面的兜底补写会把 fresh recall 绑定回最终 user 楼层。
|
||||
// 这里再补一次 UI 刷新,避免需要等到消息编辑/历史恢复后才看到 Recall Card。
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
return recallResult;
|
||||
}
|
||||
|
||||
return runtime.applyFinalRecallInjectionForGeneration({
|
||||
generationType: recallContext.generationType,
|
||||
freshRecallResult: recallResult,
|
||||
transaction: recallContext.transaction,
|
||||
hookName: recallContext.hookName,
|
||||
});
|
||||
}
|
||||
|
||||
export async function onBeforeCombinePromptsController(
|
||||
runtime,
|
||||
promptData = null,
|
||||
) {
|
||||
if (runtime.consumeDryRunPromptPreview?.()) {
|
||||
return {
|
||||
skipped: true,
|
||||
reason: "dry-run-preview",
|
||||
};
|
||||
}
|
||||
|
||||
const frozenInputSnapshot =
|
||||
runtime.consumeHostGenerationInputSnapshot?.() ||
|
||||
runtime.getPendingHostGenerationInputSnapshot?.() ||
|
||||
runtime.createRecallInputRecord?.() ||
|
||||
{};
|
||||
const context = runtime.getContext();
|
||||
const chat = context?.chat;
|
||||
const normalInput = runtime.buildNormalGenerationRecallInput(chat, {
|
||||
frozenInputSnapshot,
|
||||
});
|
||||
if (normalInput?.__trivialSkip) {
|
||||
return {
|
||||
skipped: true,
|
||||
reason: `trivial:${normalInput.trivialReason || ""}`,
|
||||
};
|
||||
}
|
||||
const recallOptions =
|
||||
normalInput ||
|
||||
runtime.buildHistoryGenerationRecallInput(chat) ||
|
||||
{};
|
||||
const recallContext = runtime.createGenerationRecallContext({
|
||||
hookName: "GENERATE_BEFORE_COMBINE_PROMPTS",
|
||||
generationType: "normal",
|
||||
recallOptions,
|
||||
});
|
||||
if (!recallContext.shouldRun && !recallContext.transaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeRecallOptions =
|
||||
recallContext.recallOptions || recallOptions || {};
|
||||
const deliveryMode =
|
||||
runtime.resolveGenerationRecallDeliveryMode?.(
|
||||
recallContext.hookName,
|
||||
recallContext.generationType,
|
||||
runtimeRecallOptions,
|
||||
) || "deferred";
|
||||
let recallResult = runtime.getGenerationRecallTransactionResult?.(
|
||||
recallContext.transaction,
|
||||
);
|
||||
|
||||
if (recallContext.shouldRun) {
|
||||
runtime.markGenerationRecallTransactionHookState(
|
||||
recallContext.transaction,
|
||||
recallContext.hookName,
|
||||
"running",
|
||||
);
|
||||
if (deliveryMode === "deferred") {
|
||||
runtime.clearLiveRecallInjectionPromptForRewrite?.();
|
||||
}
|
||||
recallResult = await runtime.runRecall({
|
||||
...runtimeRecallOptions,
|
||||
deliveryMode,
|
||||
recallKey: recallContext.recallKey,
|
||||
hookName: recallContext.hookName,
|
||||
});
|
||||
runtime.storeGenerationRecallTransactionResult?.(
|
||||
recallContext.transaction,
|
||||
recallResult,
|
||||
{
|
||||
hookName: recallContext.hookName,
|
||||
deliveryMode,
|
||||
},
|
||||
);
|
||||
runtime.markGenerationRecallTransactionHookState(
|
||||
recallContext.transaction,
|
||||
recallContext.hookName,
|
||||
runtime.getGenerationRecallHookStateFromResult(recallResult),
|
||||
);
|
||||
}
|
||||
|
||||
return runtime.applyFinalRecallInjectionForGeneration({
|
||||
generationType: recallContext.generationType,
|
||||
freshRecallResult: recallResult,
|
||||
transaction: recallContext.transaction,
|
||||
promptData,
|
||||
hookName: recallContext.hookName,
|
||||
});
|
||||
}
|
||||
|
||||
export function onMessageReceivedController(
|
||||
runtime,
|
||||
messageId = null,
|
||||
_type = "",
|
||||
) {
|
||||
const enqueueMicrotask =
|
||||
typeof globalThis.queueMicrotask === "function"
|
||||
? globalThis.queueMicrotask.bind(globalThis)
|
||||
: typeof runtime.queueMicrotask === "function"
|
||||
? (task) => Reflect.apply(runtime.queueMicrotask, globalThis, [task])
|
||||
: (task) => Promise.resolve().then(task);
|
||||
const persistenceState = runtime.getGraphPersistenceState?.() || {};
|
||||
const loadState = persistenceState.loadState || "";
|
||||
const dbReady =
|
||||
persistenceState.dbReady ??
|
||||
(loadState === "loaded" || loadState === "empty-confirmed");
|
||||
if (
|
||||
!dbReady ||
|
||||
loadState === "loading" ||
|
||||
loadState === "shadow-restored" ||
|
||||
loadState === "blocked"
|
||||
) {
|
||||
runtime.syncGraphLoadFromLiveContext?.({
|
||||
source: "message-received-reconcile",
|
||||
});
|
||||
}
|
||||
|
||||
if (runtime.getCurrentGraph()) {
|
||||
if (
|
||||
runtime.getGraphPersistenceState()?.pendingPersist &&
|
||||
runtime.isGraphMetadataWriteAllowed()
|
||||
) {
|
||||
runtime.maybeFlushQueuedGraphPersist("message-received-pending-flush");
|
||||
}
|
||||
}
|
||||
|
||||
const pendingRecallSendIntent = runtime.getPendingRecallSendIntent();
|
||||
if (
|
||||
pendingRecallSendIntent?.text &&
|
||||
!runtime.isFreshRecallInputRecord(pendingRecallSendIntent)
|
||||
) {
|
||||
runtime.setPendingRecallSendIntent(runtime.createRecallInputRecord());
|
||||
}
|
||||
|
||||
const context = runtime.getContext();
|
||||
const chat = context?.chat;
|
||||
const settings =
|
||||
typeof runtime.getSettings === "function" ? runtime.getSettings() : {};
|
||||
const lastProcessedAssistantFloor =
|
||||
typeof runtime.getLastProcessedAssistantFloor === "function"
|
||||
? runtime.getLastProcessedAssistantFloor()
|
||||
: -1;
|
||||
const receivedMessage =
|
||||
Array.isArray(chat) && Number.isFinite(Number(messageId))
|
||||
? chat[Number(messageId)]
|
||||
: null;
|
||||
const lastMessage =
|
||||
Array.isArray(chat) && chat.length > 0 ? chat[chat.length - 1] : null;
|
||||
const targetMessage = runtime.isAssistantChatMessage(receivedMessage)
|
||||
? receivedMessage
|
||||
: lastMessage;
|
||||
const targetMessageIndex = runtime.isAssistantChatMessage(receivedMessage)
|
||||
? Number(messageId)
|
||||
: runtime.isAssistantChatMessage(lastMessage)
|
||||
? chat.length - 1
|
||||
: null;
|
||||
|
||||
if (runtime.isAssistantChatMessage(targetMessage)) {
|
||||
if (runtime.consumeCurrentGenerationTrivialSkip?.(targetMessageIndex)) {
|
||||
runtime.console?.info?.(
|
||||
"[ST-BME] trivial-input skip: extraction bypassed",
|
||||
{ messageId: targetMessageIndex },
|
||||
);
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
return;
|
||||
}
|
||||
const autoExtractionPlan =
|
||||
typeof runtime.resolveAutoExtractionPlan === "function"
|
||||
? runtime.resolveAutoExtractionPlan({
|
||||
chat,
|
||||
settings,
|
||||
lastProcessedAssistantFloor,
|
||||
})
|
||||
: null;
|
||||
if (!autoExtractionPlan?.canRun) {
|
||||
runtime.console?.debug?.(
|
||||
"[ST-BME] assistant message received, auto extraction not runnable yet",
|
||||
{
|
||||
messageId: Number.isFinite(Number(targetMessageIndex))
|
||||
? Number(targetMessageIndex)
|
||||
: null,
|
||||
reason: String(autoExtractionPlan?.reason || "not-runnable"),
|
||||
strategy: String(autoExtractionPlan?.strategy || "normal"),
|
||||
},
|
||||
);
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
return;
|
||||
}
|
||||
runtime.console?.debug?.(
|
||||
"[ST-BME] assistant message received, queueing auto extraction",
|
||||
{
|
||||
messageId: Number.isFinite(Number(targetMessageIndex))
|
||||
? Number(targetMessageIndex)
|
||||
: null,
|
||||
chatLength: Array.isArray(chat) ? chat.length : 0,
|
||||
loadState,
|
||||
dbReady,
|
||||
},
|
||||
);
|
||||
if (
|
||||
runtime.getIsHostGenerationRunning?.() === true &&
|
||||
typeof runtime.deferAutoExtraction === "function"
|
||||
) {
|
||||
runtime.console?.debug?.(
|
||||
"[ST-BME] assistant message received during host generation, deferring auto extraction",
|
||||
{
|
||||
messageId: Number.isFinite(Number(targetMessageIndex))
|
||||
? Number(targetMessageIndex)
|
||||
: null,
|
||||
targetEndFloor: toSafeFloor(autoExtractionPlan.plannedBatchEndFloor, null),
|
||||
},
|
||||
);
|
||||
runtime.deferAutoExtraction("generation-running", {
|
||||
messageId: targetMessageIndex,
|
||||
targetEndFloor: autoExtractionPlan.plannedBatchEndFloor,
|
||||
strategy: autoExtractionPlan.strategy,
|
||||
});
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
return;
|
||||
}
|
||||
enqueueMicrotask(() => {
|
||||
void runtime
|
||||
.runExtraction({
|
||||
lockedEndFloor: autoExtractionPlan.plannedBatchEndFloor,
|
||||
triggerSource: "message-received",
|
||||
})
|
||||
.catch((error) => {
|
||||
runtime.console.error("[ST-BME] 异步自动提取失败:", error);
|
||||
runtime.notifyExtractionIssue(
|
||||
error?.message || String(error) || "自动提取失败",
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
runtime.refreshPersistedRecallMessageUi?.();
|
||||
}
|
||||
174
host/st-context.js
Normal file
174
host/st-context.js
Normal file
@@ -0,0 +1,174 @@
|
||||
// ST-BME: SillyTavern 上下文数据读取辅助
|
||||
// 为 prompt 变量扩展(Phase 2)提供统一的 ST 上下文数据接口
|
||||
|
||||
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 参数,
|
||||
* 用户在自定义 prompt 块中可通过 {{key}} 引用。
|
||||
*
|
||||
* @returns {object} 上下文字段映射
|
||||
*/
|
||||
export function getSTContextForPrompt() {
|
||||
return getSTContextSnapshot().prompt;
|
||||
}
|
||||
163
host/st-native-render.js
Normal file
163
host/st-native-render.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import { substituteParamsExtended } from "../../../../script.js";
|
||||
import jsyaml from "../vendor/js-yaml.mjs";
|
||||
|
||||
function getTemplateRuntime() {
|
||||
return globalThis.window?.EjsTemplate || globalThis.EjsTemplate || null;
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function deepGet(target, path) {
|
||||
if (!target || !path) return undefined;
|
||||
const parts = String(path || "")
|
||||
.split(".")
|
||||
.filter(Boolean);
|
||||
let current = target;
|
||||
for (const part of parts) {
|
||||
if (current == null) return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function getLatestMessageVarTable() {
|
||||
try {
|
||||
if (globalThis.window?.Mvu?.getMvuData) {
|
||||
return (
|
||||
globalThis.window.Mvu.getMvuData({
|
||||
type: "message",
|
||||
message_id: "latest",
|
||||
}) || {}
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const getVars =
|
||||
globalThis.window?.TavernHelper?.getVariables ||
|
||||
globalThis.window?.Mvu?.getMvuData ||
|
||||
globalThis.TavernHelper?.getVariables ||
|
||||
globalThis.Mvu?.getMvuData;
|
||||
if (typeof getVars === "function") {
|
||||
return getVars({ type: "message", message_id: "latest" }) || {};
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function prepareStNativeEjsEnv() {
|
||||
try {
|
||||
const runtime = getTemplateRuntime();
|
||||
const prepare =
|
||||
runtime?.prepareContext || runtime?.preparecontext || null;
|
||||
if (typeof prepare !== "function") {
|
||||
return null;
|
||||
}
|
||||
return (await prepare.call(runtime, {})) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function substituteMacrosViaST(text) {
|
||||
try {
|
||||
if (typeof substituteParamsExtended === "function") {
|
||||
return substituteParamsExtended(text);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function resolveGetMessageVariableMacros(text, messageVars) {
|
||||
return String(text || "").replace(
|
||||
/\{\{\s*get_message_variable::([^}]+)\s*}}/g,
|
||||
(_, rawPath) => {
|
||||
const path = String(rawPath || "").trim();
|
||||
if (!path) return "";
|
||||
return safeStringify(deepGet(messageVars, path));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFormatMessageVariableMacros(text, messageVars) {
|
||||
return String(text || "").replace(
|
||||
/\{\{\s*format_message_variable::([^}]+)\s*}}/g,
|
||||
(_, rawPath) => {
|
||||
const path = String(rawPath || "").trim();
|
||||
if (!path) return "";
|
||||
const value = deepGet(messageVars, path);
|
||||
if (value == null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return jsyaml.dump(value, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
});
|
||||
} catch {
|
||||
return safeStringify(value);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function renderTemplateWithStSupport(
|
||||
text,
|
||||
{ env = null, messageVars = null, evaluateEjs = true } = {},
|
||||
) {
|
||||
const originalText = String(text ?? "");
|
||||
const runtime = getTemplateRuntime();
|
||||
const effectiveEnv = env || null;
|
||||
const effectiveMessageVars =
|
||||
messageVars && typeof messageVars === "object"
|
||||
? messageVars
|
||||
: getLatestMessageVarTable();
|
||||
|
||||
let output = originalText;
|
||||
let ejsEvaluated = false;
|
||||
let ejsError = null;
|
||||
|
||||
if (evaluateEjs && originalText.includes("<%")) {
|
||||
try {
|
||||
const evalTemplate =
|
||||
runtime?.evalTemplate || runtime?.evaltemplate || null;
|
||||
if (runtime && effectiveEnv && typeof evalTemplate === "function") {
|
||||
output = await evalTemplate.call(runtime, output, effectiveEnv);
|
||||
ejsEvaluated = true;
|
||||
}
|
||||
} catch (error) {
|
||||
ejsError = error;
|
||||
}
|
||||
}
|
||||
|
||||
const afterMacroSubstitute = substituteMacrosViaST(output);
|
||||
const afterMessageVariableResolve = resolveFormatMessageVariableMacros(
|
||||
resolveGetMessageVariableMacros(afterMacroSubstitute, effectiveMessageVars),
|
||||
effectiveMessageVars,
|
||||
);
|
||||
|
||||
return {
|
||||
text: afterMessageVariableResolve,
|
||||
stNativeRuntimeAvailable: Boolean(runtime),
|
||||
envPrepared: Boolean(effectiveEnv),
|
||||
ejsEvaluated,
|
||||
ejsError,
|
||||
macroApplied: afterMacroSubstitute !== output,
|
||||
messageVariableMacrosApplied:
|
||||
afterMessageVariableResolve !== afterMacroSubstitute,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user