diff --git a/ena-planner/ena-planner-runtime-utils.js b/ena-planner/ena-planner-runtime-utils.js new file mode 100644 index 0000000..fb7b854 --- /dev/null +++ b/ena-planner/ena-planner-runtime-utils.js @@ -0,0 +1,64 @@ +export function extractLastNPlots(chat, n) { + if (!Array.isArray(chat) || chat.length === 0) return []; + const want = Math.max(0, Number(n) || 0); + if (!want) return []; + + const plots = []; + const plotRe = /]*>[\s\S]*?<\/plot>/gi; + + for (let i = chat.length - 1; i >= 0; i--) { + const text = chat[i]?.mes ?? ''; + if (!text) continue; + const matches = [...text.matchAll(plotRe)]; + for (let j = matches.length - 1; j >= 0; j--) { + plots.push(matches[j][0]); + if (plots.length >= want) return plots; + } + } + return plots; +} + +export function formatPlotsBlock(plotList) { + if (!Array.isArray(plotList) || plotList.length === 0) return ''; + const chrono = [...plotList].reverse(); + const lines = []; + chrono.forEach((p, idx) => { + lines.push(`【plot -${chrono.length - idx}】\n${p}`); + }); + return `\n${lines.join('\n\n')}\n`; +} + +export function applyPlannerResultAndSend({ + textarea, + button, + rawUserInput = '', + filtered = '', + plannerRecall = null, + runtime = null, + plannerState = null, +} = {}) { + if (!textarea || !button) return { applied: false, reason: 'missing-target' }; + + const raw = String(rawUserInput ?? '').trim(); + const merged = `${raw}\n\n${String(filtered ?? '')}`.trim(); + textarea.value = merged; + if (plannerState && typeof plannerState === 'object') { + plannerState.lastInjectedText = merged; + } + + let handoffPrepared = false; + if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) { + runtime.preparePlannerRecallHandoff({ + rawUserInput: raw, + plannerAugmentedMessage: merged, + plannerRecall, + }); + handoffPrepared = true; + } + + if (plannerState && typeof plannerState === 'object') { + plannerState.bypassNextSend = true; + } + button.click(); + return { applied: true, merged, handoffPrepared }; +} diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index 92ef842..dbff20f 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -1,6 +1,11 @@ import { extension_settings } from '../../../../extensions.js'; import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../script.js'; import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js'; +import { + applyPlannerResultAndSend, + extractLastNPlots, + formatPlotsBlock, +} from './ena-planner-runtime-utils.js'; import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js'; import { createBuiltinPromptBlock, @@ -794,38 +799,6 @@ function collectRecentChatSnippet(chat, maxMessages) { * Plot extraction * -------------------------- */ -function extractLastNPlots(chat, n) { - if (!Array.isArray(chat) || chat.length === 0) return []; - const want = Math.max(0, Number(n) || 0); - if (!want) return []; - - const plots = []; - const plotRe = /]*>[\s\S]*?<\/plot>/gi; - - for (let i = chat.length - 1; i >= 0; i--) { - const text = chat[i]?.mes ?? ''; - if (!text) continue; - const matches = [...text.matchAll(plotRe)]; - for (let j = matches.length - 1; j >= 0; j--) { - plots.push(matches[j][0]); - if (plots.length >= want) return plots; - } - } - return plots; -} - -function formatPlotsBlock(plotList) { - if (!Array.isArray(plotList) || plotList.length === 0) return ''; - // plotList is [newest, ..., oldest] from extractLastNPlots - // Reverse to chronological: oldest first, newest last - const chrono = [...plotList].reverse(); - const lines = []; - chrono.forEach((p, idx) => { - lines.push(`【plot -${chrono.length - idx}】\n${p}`); - }); - return `\n${lines.join('\n\n')}\n`; -} - /** * ------------------------- * Worldbook — read via ST API (like idle-watcher) @@ -1941,22 +1914,18 @@ async function doInterceptAndPlanThenSend() { ta.value = `${raw}\n\n${preview}`.trim(); } }); - const merged = `${raw}\n\n${filtered}`.trim(); - ta.value = merged; - state.lastInjectedText = merged; - - // Ordering requirement: register the one-shot planner recall handoff - // synchronously before btn.click(), with no await/timer hop in between. - if (_bmeRuntime?.preparePlannerRecallHandoff && plannerRecall?.result) { - _bmeRuntime.preparePlannerRecallHandoff({ - rawUserInput: raw, - plannerAugmentedMessage: merged, - plannerRecall, - }); - } - - state.bypassNextSend = true; - btn.click(); + // Ordering requirement: write the merged textarea, register the + // one-shot planner recall handoff synchronously, then click send with + // no await/timer hop in between. + applyPlannerResultAndSend({ + textarea: ta, + button: btn, + rawUserInput: raw, + filtered, + plannerRecall, + runtime: _bmeRuntime, + plannerState: state, + }); } catch (err) { ta.value = raw; state.lastInjectedText = ''; diff --git a/tests/ena-planner-plots.mjs b/tests/ena-planner-plots.mjs new file mode 100644 index 0000000..67abe4c --- /dev/null +++ b/tests/ena-planner-plots.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; + +import { + applyPlannerResultAndSend, + extractLastNPlots, + formatPlotsBlock, +} from '../ena-planner/ena-planner-runtime-utils.js'; + +{ + const chat = [ + { mes: 'no plot here' }, + { mes: 'old one\nold two' }, + { mes: 'assistant says new one' }, + ]; + assert.deepEqual(extractLastNPlots(chat, 2), [ + 'new one', + 'old two', + ]); + assert.deepEqual(extractLastNPlots(chat, 0), []); + assert.deepEqual(extractLastNPlots(null, 3), []); +} + +{ + const block = formatPlotsBlock([ + 'newest', + 'older', + ]); + assert.equal( + block, + '\n【plot -2】\nolder\n\n【plot -1】\nnewest\n', + ); + assert.equal(formatPlotsBlock([]), ''); +} + +{ + const order = []; + const textarea = { value: 'raw' }; + const button = { click: () => order.push('click') }; + const plannerState = { bypassNextSend: false, lastInjectedText: '' }; + const plannerRecall = { result: { selected: ['memory-a'] } }; + const runtime = { + preparePlannerRecallHandoff(payload) { + order.push('handoff'); + assert.equal(payload.rawUserInput, 'raw input'); + assert.equal(payload.plannerAugmentedMessage, 'raw input\n\nnext'); + assert.equal(payload.plannerRecall, plannerRecall); + }, + }; + + const result = applyPlannerResultAndSend({ + textarea, + button, + rawUserInput: 'raw input', + filtered: 'next', + plannerRecall, + runtime, + plannerState, + }); + + assert.deepEqual(order, ['handoff', 'click']); + assert.equal(result.applied, true); + assert.equal(result.handoffPrepared, true); + assert.equal(textarea.value, 'raw input\n\nnext'); + assert.equal(plannerState.lastInjectedText, textarea.value); + assert.equal(plannerState.bypassNextSend, true); +} + +{ + const order = []; + const result = applyPlannerResultAndSend({ + textarea: null, + button: { click: () => order.push('click') }, + }); + assert.deepEqual(result, { applied: false, reason: 'missing-target' }); + assert.deepEqual(order, []); +} + +console.log('ena-planner-plots tests passed');