From 777edf9f9a720e4cf82a79799dba2258997a275c Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Thu, 26 Mar 2026 22:24:45 +0800 Subject: [PATCH] feat: integrate host bridge prompt pipeline --- compressor.js | 9 +- consolidator.js | 9 +- extractor.js | 27 +-- host-adapter/capabilities.js | 113 ++++++++++ host-adapter/context.js | 88 ++++++++ host-adapter/index.js | 176 +++++++++++++++ host-adapter/injection.js | 102 +++++++++ host-adapter/regex.js | 281 ++++++++++++++++++++++++ host-adapter/worldbook.js | 276 ++++++++++++++++++++++++ index.js | 210 ++++++++++++++---- llm.js | 49 ++++- prompt-builder.js | 136 +++++++++++- retriever.js | 9 +- st-context.js | 193 ++++++++++++++--- task-ejs.js | 358 ++++++++++++++++++------------- task-regex.js | 151 +++++++++++-- task-worldinfo.js | 175 +++++++++++++-- tests/st-context-task-ejs.mjs | 187 ++++++++++++++++ tests/task-profile-migration.mjs | 17 +- tests/task-profile-storage.mjs | 2 +- tests/task-regex.mjs | 296 +++++++++++++++++++++++++ tests/task-worldinfo.mjs | 153 ++++++++++++- 22 files changed, 2710 insertions(+), 307 deletions(-) create mode 100644 host-adapter/capabilities.js create mode 100644 host-adapter/context.js create mode 100644 host-adapter/index.js create mode 100644 host-adapter/injection.js create mode 100644 host-adapter/regex.js create mode 100644 host-adapter/worldbook.js create mode 100644 tests/st-context-task-ejs.mjs create mode 100644 tests/task-regex.mjs diff --git a/compressor.js b/compressor.js index 077b362..bff24b0 100644 --- a/compressor.js +++ b/compressor.js @@ -265,10 +265,11 @@ async function summarizeBatch( maxRetries: 1, signal, taskType: "compress", - additionalMessages: [ - ...(compressPromptBuild.customMessages || []), - ...(compressPromptBuild.additionalMessages || []), - ], + additionalMessages: + compressPromptBuild.privateTaskMessages || [ + ...(compressPromptBuild.customMessages || []), + ...(compressPromptBuild.additionalMessages || []), + ], }); } diff --git a/consolidator.js b/consolidator.js index c9b632a..f98009e 100644 --- a/consolidator.js +++ b/consolidator.js @@ -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; diff --git a/extractor.js b/extractor.js index 92d6e93..9b9350f 100644 --- a/extractor.js +++ b/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; diff --git a/host-adapter/capabilities.js b/host-adapter/capabilities.js new file mode 100644 index 0000000..cb4156b --- /dev/null +++ b/host-adapter/capabilities.js @@ -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, + }); +} diff --git a/host-adapter/context.js b/host-adapter/context.js new file mode 100644 index 0000000..5dccb48 --- /dev/null +++ b/host-adapter/context.js @@ -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(); +} diff --git a/host-adapter/index.js b/host-adapter/index.js new file mode 100644 index 0000000..0babf24 --- /dev/null +++ b/host-adapter/index.js @@ -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; +} diff --git a/host-adapter/injection.js b/host-adapter/injection.js new file mode 100644 index 0000000..11e7dcc --- /dev/null +++ b/host-adapter/injection.js @@ -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); +} diff --git a/host-adapter/regex.js b/host-adapter/regex.js new file mode 100644 index 0000000..9612ffa --- /dev/null +++ b/host-adapter/regex.js @@ -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); +} diff --git a/host-adapter/worldbook.js b/host-adapter/worldbook.js new file mode 100644 index 0000000..c4ed884 --- /dev/null +++ b/host-adapter/worldbook.js @@ -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); +} diff --git a/index.js b/index.js index 4f4787d..7712c28 100644 --- a/index.js +++ b/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(); // 注册事件钩子 diff --git a/llm.js b/llm.js index 65346d1..be758ce 100644 --- a/llm.js +++ b/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} */ -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: "" }; diff --git a/prompt-builder.js b/prompt-builder.js index 9ce5326..52eafe7 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -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, }, }; } diff --git a/retriever.js b/retriever.js index 243af9f..7c7e4e2 100644 --- a/retriever.js +++ b/retriever.js @@ -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)) { diff --git a/st-context.js b/st-context.js index b947d99..e5d3368 100644 --- a/st-context.js +++ b/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; } diff --git a/task-ejs.js b/task-ejs.js index 12144fa..7fc8397 100644 --- a/task-ejs.js +++ b/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); +} diff --git a/task-regex.js b/task-regex.js index a3ef5b2..64c1fba 100644 --- a/task-regex.js +++ b/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; diff --git a/task-worldinfo.js b/task-worldinfo.js index 2bf9a86..b8e2e83 100644 --- a/task-worldinfo.js +++ b/task-worldinfo.js @@ -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)) { diff --git a/tests/st-context-task-ejs.mjs b/tests/st-context-task-ejs.mjs new file mode 100644 index 0000000..8c26d99 --- /dev/null +++ b/tests/st-context-task-ejs.mjs @@ -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; +} diff --git a/tests/task-profile-migration.mjs b/tests/task-profile-migration.mjs index f461f54..21f98c6 100644 --- a/tests/task-profile-migration.mjs +++ b/tests/task-profile-migration.mjs @@ -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, diff --git a/tests/task-profile-storage.mjs b/tests/task-profile-storage.mjs index 047148a..24955fb 100644 --- a/tests/task-profile-storage.mjs +++ b/tests/task-profile-storage.mjs @@ -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", ); diff --git a/tests/task-regex.mjs b/tests/task-regex.mjs new file mode 100644 index 0000000..b96b41a --- /dev/null +++ b/tests/task-regex.mjs @@ -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 + } +} diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs index 8ba49a3..e846d63 100644 --- a/tests/task-worldinfo.mjs +++ b/tests/task-worldinfo.mjs @@ -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 + } }