From 8d36b8daa6bcabc1117b229ed28e9048e3a57555 Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 9 Jun 2026 11:32:18 +0000 Subject: [PATCH] fix(host): restore event priority and EJS rendering --- ena-planner/ena-planner.js | 19 +++-- host/event-binding.js | 23 +++--- tests/ena-planner-ejs.mjs | 70 ++++++++++++++++++ tests/event-binding-priority.mjs | 117 +++++++++++++++++++++++++++++++ 4 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 tests/ena-planner-ejs.mjs create mode 100644 tests/event-binding-priority.mjs diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index ce8743a..92ef842 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -1145,7 +1145,11 @@ function getChatVariables() { } function buildEjsContext() { - const vars = getChatVariables(); + return createEnaPlannerEjsContext(getChatVariables()); +} + +export function createEnaPlannerEjsContext(varsInput = {}) { + const vars = varsInput && typeof varsInput === 'object' ? { ...varsInput } : {}; // getvar: read a chat variable (supports dot-path for nested objects) function getvar(name) { @@ -1195,7 +1199,7 @@ function shouldSkipSyncEjsPreRender(template) { return false; } -function renderEjsTemplate(template, ctx, templateLabel = '') { +export function renderEjsTemplate(template, ctx, templateLabel = '') { const labelSuffix = templateLabel ? ` (${templateLabel})` : ''; if (shouldSkipSyncEjsPreRender(template)) { @@ -1205,7 +1209,15 @@ function renderEjsTemplate(template, ctx, templateLabel = '') { // Try window.ejs first (ST loads this library) if (window.ejs?.render) { try { - return window.ejs.render(template, ctx, { async: false }); + const renderCtx = ctx && typeof ctx === 'object' ? { ...ctx } : ctx; + if (renderCtx && typeof renderCtx === 'object') { + delete renderCtx.__append; + delete renderCtx.print; + } + return window.ejs.render(template, renderCtx, { + async: false, + outputFunctionName: 'print', + }); } catch (e) { console.warn(`[EnaPlanner] EJS render failed${labelSuffix}, template returned as-is:`, e?.message); return template; @@ -2017,4 +2029,3 @@ export function cleanupEnaPlanner() { delete window.stBmeEnaPlanner; _bmeRuntime = null; } - diff --git a/host/event-binding.js b/host/event-binding.js index 981fa0c..37a35f2 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -34,25 +34,28 @@ function isTavernHelperPromptViewerSyntheticGeneration(runtime) { } export function registerBeforeCombinePromptsController(runtime, listener) { - const makeFirst = runtime.getEventMakeFirst(); + const eventName = runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS; + const eventSourceMakeFirst = runtime.eventSource?.makeFirst; + const makeFirst = + typeof eventSourceMakeFirst === "function" + ? eventSourceMakeFirst.bind(runtime.eventSource) + : runtime.getEventMakeFirst?.(); if (typeof makeFirst === "function") { - return makeFirst( - runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS, - listener, - ); + return makeFirst(eventName, listener); } runtime.console.warn("[ST-BME] eventMakeFirst 不可用,回退到普通事件注册"); - runtime.eventSource.on( - runtime.eventTypes.GENERATE_BEFORE_COMBINE_PROMPTS, - listener, - ); + runtime.eventSource.on(eventName, listener); return null; } export function registerGenerationAfterCommandsController(runtime, listener) { - const makeFirst = runtime.getEventMakeFirst(); const eventName = runtime.eventTypes.GENERATION_AFTER_COMMANDS; + const eventSourceMakeFirst = runtime.eventSource?.makeFirst; + const makeFirst = + typeof eventSourceMakeFirst === "function" + ? eventSourceMakeFirst.bind(runtime.eventSource) + : runtime.getEventMakeFirst?.(); if (typeof makeFirst === "function") { const cleanup = makeFirst(eventName, listener); return cleanup; diff --git a/tests/ena-planner-ejs.mjs b/tests/ena-planner-ejs.mjs new file mode 100644 index 0000000..b2517fe --- /dev/null +++ b/tests/ena-planner-ejs.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { + installResolveHooks, + toDataModuleUrl, +} from "./helpers/register-hooks-compat.mjs"; + +const extensionsShimSource = [ + "export const extension_settings = {};", +].join("\n"); +const scriptShimSource = [ + "export function getRequestHeaders() { return {}; }", + "export function saveSettingsDebounced() {}", + "export function substituteParamsExtended(text = '') { return String(text ?? ''); }", +].join("\n"); + +installResolveHooks([ + { + specifiers: ["../../../../extensions.js"], + url: toDataModuleUrl(extensionsShimSource), + }, + { + specifiers: ["../../../../../script.js"], + url: toDataModuleUrl(scriptShimSource), + }, +]); + +const require = createRequire(import.meta.url); +const ejs = require("../vendor/ejs.js"); +const originalWindow = globalThis.window; +const originalWarn = console.warn; +const warnings = []; + +try { + globalThis.window = { ...(originalWindow || {}), ejs }; + console.warn = (...args) => warnings.push(args); + + const { createEnaPlannerEjsContext, renderEjsTemplate } = await import( + "../ena-planner/ena-planner.js" + ); + + const ctx = createEnaPlannerEjsContext({ x: "alpha" }); + assert.equal(renderEjsTemplate("<%= getvar('x') %>", ctx), "alpha"); + assert.equal(renderEjsTemplate("<% print(getvar('x')) %>", ctx), "alpha"); + + const pollutedCtx = { + ...createEnaPlannerEjsContext({ x: "safe" }), + __append() { + throw new Error("locals __append should not shadow EJS output"); + }, + print() { + throw new Error("locals print should not shadow EJS output function"); + }, + }; + assert.equal(renderEjsTemplate("<%= getvar('x') %>", pollutedCtx), "safe"); + assert.equal(renderEjsTemplate("<% print(getvar('x')) %>", pollutedCtx), "safe"); + + const invalidTemplate = "before <% if ( %> after"; + assert.equal(renderEjsTemplate(invalidTemplate, ctx, "invalid"), invalidTemplate); + assert.ok(warnings.some((args) => String(args[0]).includes("EJS render failed"))); +} finally { + console.warn = originalWarn; + if (originalWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = originalWindow; + } +} + +console.log("ena-planner-ejs tests passed"); diff --git a/tests/event-binding-priority.mjs b/tests/event-binding-priority.mjs new file mode 100644 index 0000000..8d5cda3 --- /dev/null +++ b/tests/event-binding-priority.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { + registerBeforeCombinePromptsController, + registerGenerationAfterCommandsController, +} from "../host/event-binding.js"; + +function createRuntime(eventSource, overrides = {}) { + return { + console: { warn() {} }, + eventSource, + eventTypes: { + GENERATE_BEFORE_COMBINE_PROMPTS: "before-combine", + GENERATION_AFTER_COMMANDS: "after-commands", + }, + getEventMakeFirst: () => undefined, + ...overrides, + }; +} + +function testEventSourceMakeFirstWinsAndIsBound() { + const calls = []; + const fallbackCalls = []; + const eventSource = { + marker: "event-source", + makeFirst(eventName, listener) { + assert.equal(this, eventSource); + calls.push({ eventName, listener }); + return () => calls.push({ cleanup: eventName }); + }, + on() { + throw new Error("ordinary .on should not be used when makeFirst exists"); + }, + }; + const runtime = createRuntime(eventSource, { + getEventMakeFirst: () => (...args) => fallbackCalls.push(args), + }); + const beforeListener = () => {}; + const afterListener = () => {}; + + const beforeCleanup = registerBeforeCombinePromptsController( + runtime, + beforeListener, + ); + const afterCleanup = registerGenerationAfterCommandsController( + runtime, + afterListener, + ); + + assert.equal(typeof beforeCleanup, "function"); + assert.equal(typeof afterCleanup, "function"); + assert.deepEqual(calls, [ + { eventName: "before-combine", listener: beforeListener }, + { eventName: "after-commands", listener: afterListener }, + ]); + assert.deepEqual(fallbackCalls, []); +} + +function testRuntimeMakeFirstFallback() { + const calls = []; + const eventSource = { + on() { + throw new Error("ordinary .on should not be used when fallback exists"); + }, + }; + const runtime = createRuntime(eventSource, { + getEventMakeFirst: () => (eventName, listener) => { + calls.push({ eventName, listener }); + return `cleanup:${eventName}`; + }, + }); + const beforeListener = () => {}; + const afterListener = () => {}; + + assert.equal( + registerBeforeCombinePromptsController(runtime, beforeListener), + "cleanup:before-combine", + ); + assert.equal( + registerGenerationAfterCommandsController(runtime, afterListener), + "cleanup:after-commands", + ); + assert.deepEqual(calls, [ + { eventName: "before-combine", listener: beforeListener }, + { eventName: "after-commands", listener: afterListener }, + ]); +} + +function testOrdinaryOnFallback() { + const calls = []; + const eventSource = { + on(eventName, listener) { + calls.push({ eventName, listener }); + }, + }; + const runtime = createRuntime(eventSource); + const beforeListener = () => {}; + const afterListener = () => {}; + + assert.equal( + registerBeforeCombinePromptsController(runtime, beforeListener), + null, + ); + assert.equal( + registerGenerationAfterCommandsController(runtime, afterListener), + null, + ); + assert.deepEqual(calls, [ + { eventName: "before-combine", listener: beforeListener }, + { eventName: "after-commands", listener: afterListener }, + ]); +} + +testEventSourceMakeFirstWinsAndIsBound(); +testRuntimeMakeFirstFallback(); +testOrdinaryOnFallback(); + +console.log("event-binding-priority tests passed");