diff --git a/ena-planner/ena-planner-runtime-utils.js b/ena-planner/ena-planner-runtime-utils.js index fb7b854..69690a7 100644 --- a/ena-planner/ena-planner-runtime-utils.js +++ b/ena-planner/ena-planner-runtime-utils.js @@ -34,6 +34,7 @@ export function applyPlannerResultAndSend({ rawUserInput = '', filtered = '', plannerRecall = null, + plannerPlotRecord = null, runtime = null, plannerState = null, } = {}) { @@ -48,10 +49,18 @@ export function applyPlannerResultAndSend({ let handoffPrepared = false; if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) { + const plotRecordPayload = plannerPlotRecord && typeof plannerPlotRecord === 'object' + ? { + ...plannerPlotRecord, + rawUserInput: raw, + plannerAugmentedMessage: merged, + } + : null; runtime.preparePlannerRecallHandoff({ rawUserInput: raw, plannerAugmentedMessage: merged, plannerRecall, + plannerPlotRecord: plotRecordPayload, }); handoffPrepared = true; } diff --git a/ena-planner/ena-planner.js b/ena-planner/ena-planner.js index dbff20f..4eb2a37 100644 --- a/ena-planner/ena-planner.js +++ b/ena-planner/ena-planner.js @@ -3,9 +3,8 @@ import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } fr import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js'; import { applyPlannerResultAndSend, - extractLastNPlots, - formatPlotsBlock, } from './ena-planner-runtime-utils.js'; +import { readPlannerPlotHistory } from './planner-plot-history.js'; import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js'; import { createBuiltinPromptBlock, @@ -1743,7 +1742,7 @@ async function buildPlannerMessages(rawUserInput) { // a little continuity even when memory recall returns empty. const recentChatRaw = collectRecentChatSnippet(chat, 2); - const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount)); + const plotsRaw = readPlannerPlotHistory(chat, { count: s.plotCount }).block; // Build scanText for worldbook keyword activation const scanText = [charBlockRaw, recentChatRaw, plotsRaw, rawUserInput].join('\n\n'); @@ -1923,6 +1922,10 @@ async function doInterceptAndPlanThenSend() { rawUserInput: raw, filtered, plannerRecall, + plannerPlotRecord: { + rawUserInput: raw, + plotText: filtered, + }, runtime: _bmeRuntime, plannerState: state, }); diff --git a/ena-planner/planner-plot-history.js b/ena-planner/planner-plot-history.js new file mode 100644 index 0000000..358abcc --- /dev/null +++ b/ena-planner/planner-plot-history.js @@ -0,0 +1,132 @@ +import { + extractLastNPlots, + formatPlotsBlock, +} from './ena-planner-runtime-utils.js'; + +export const ST_BME_PLOT_HISTORY_KEY = 'st_bme_plot'; +export const ST_BME_PLOT_HISTORY_VERSION = 1; + +export function hashPlannerPlotInput(text = '') { + let hash = 2166136261; + for (const char of String(text || '')) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return String(Math.abs(hash >>> 0)); +} + +export function createStructuredPlotRecord({ + rawUserInput = '', + plannerAugmentedMessage = '', + plotText = '', + plotBlocks = null, + promptProfileId = '', + recallHandoffId = '', + taskResults = [], + createdAt = Date.now(), + inputHash = '', +} = {}) { + const normalizedRaw = String(rawUserInput || '').trim(); + const normalizedPlot = String(plotText || '').trim(); + const blocks = Array.isArray(plotBlocks) + ? plotBlocks.map((item) => String(item || '').trim()).filter(Boolean) + : extractLastNPlots([{ mes: normalizedPlot }], 99); + return { + version: ST_BME_PLOT_HISTORY_VERSION, + inputHash: String(inputHash || hashPlannerPlotInput(normalizedRaw)), + rawUserInput: normalizedRaw, + plannerAugmentedMessage: String(plannerAugmentedMessage || '').trim(), + plotText: normalizedPlot, + plotBlocks: blocks, + promptProfileId: String(promptProfileId || ''), + recallHandoffId: String(recallHandoffId || ''), + taskResults: Array.isArray(taskResults) ? taskResults : [], + createdAt: Number.isFinite(Number(createdAt)) ? Number(createdAt) : Date.now(), + }; +} + +export function normalizeStructuredPlotRecord(value) { + if (!value || typeof value !== 'object') return null; + if (Number(value.version) !== ST_BME_PLOT_HISTORY_VERSION) return null; + const plotText = String(value.plotText || '').trim(); + const plotBlocks = Array.isArray(value.plotBlocks) + ? value.plotBlocks.map((item) => String(item || '').trim()).filter(Boolean) + : []; + if (!plotText && plotBlocks.length === 0) return null; + return createStructuredPlotRecord({ + ...value, + plotText, + plotBlocks, + createdAt: value.createdAt, + }); +} + +export function readStructuredPlotRecordFromMessage(message) { + return normalizeStructuredPlotRecord(message?.extra?.[ST_BME_PLOT_HISTORY_KEY]); +} + +export function collectStructuredPlotRecords(chat, count = 2) { + if (!Array.isArray(chat) || chat.length === 0) return []; + const want = Math.max(0, Number(count) || 0); + if (!want) return []; + const records = []; + for (let index = chat.length - 1; index >= 0; index--) { + const record = readStructuredPlotRecordFromMessage(chat[index]); + if (!record) continue; + records.push(record); + if (records.length >= want) break; + } + return records; +} + +export function readPlannerPlotHistory(chat, { count = 2 } = {}) { + const structuredRecords = collectStructuredPlotRecords(chat, count); + if (structuredRecords.length > 0) { + const plots = structuredRecords.map((record) => record.plotText || record.plotBlocks.join('\n')).filter(Boolean); + return { + source: 'structured', + records: structuredRecords, + plots, + block: formatPlotsBlock(plots), + }; + } + + const plots = extractLastNPlots(chat, count); + return { + source: plots.length > 0 ? 'legacy' : 'empty', + records: [], + plots, + block: formatPlotsBlock(plots), + }; +} + +export function writeStructuredPlotRecordToMessage(message, recordInput) { + if (!message || typeof message !== 'object' || !message.is_user) return false; + const record = normalizeStructuredPlotRecord( + recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput), + ); + if (!record) return false; + message.extra = message.extra && typeof message.extra === 'object' + ? message.extra + : {}; + message.extra[ST_BME_PLOT_HISTORY_KEY] = record; + return true; +} + +export function writeStructuredPlotRecordToMatchingUserMessage(chat, recordInput) { + if (!Array.isArray(chat)) return null; + const record = normalizeStructuredPlotRecord( + recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput), + ); + if (!record) return null; + const inputHash = String(record.inputHash || hashPlannerPlotInput(record.rawUserInput)); + for (let index = chat.length - 1; index >= 0; index--) { + const message = chat[index]; + if (!message?.is_user) continue; + if (hashPlannerPlotInput(message.mes || '') !== inputHash) continue; + if (writeStructuredPlotRecordToMessage(message, record)) { + return { index, record }; + } + } + return null; +} diff --git a/host/event-binding.js b/host/event-binding.js index 37a35f2..df0d3d9 100644 --- a/host/event-binding.js +++ b/host/event-binding.js @@ -327,6 +327,7 @@ export function onMessageSentController(runtime, messageId) { resolvedMessageId, message.mes || "", ); + runtime.persistPlannerPlotRecordToUserMessage?.(resolvedMessageId); // GENERATION_AFTER_COMMANDS 在 sendMessageAsUser 之前触发,此时新用户消息 // 尚未进入 chat,recall 记录会被写到上一条 user 上。这里用户消息刚入场, // transaction 仍在桥接窗口内,立即把记录重新绑定到正确的楼层。 diff --git a/index.js b/index.js index 92f7d75..5b8151d 100644 --- a/index.js +++ b/index.js @@ -111,6 +111,7 @@ import { registerGenerationAfterCommandsController, scheduleSendIntentHookRetryController, } from "./host/event-binding.js"; +import { writeStructuredPlotRecordToMessage } from "./ena-planner/planner-plot-history.js"; import { BME_HOST_PROFILE_LUKER, getBmeHostAdapter, @@ -14734,16 +14735,43 @@ function preparePlannerRecallHandoff({ rawUserInput = "", plannerAugmentedMessage = "", plannerRecall = null, + plannerPlotRecord = null, chatId = getCurrentChatId(), } = {}) { return rerollRecallInput.preparePlannerRecallHandoff({ rawUserInput, plannerAugmentedMessage, plannerRecall, + plannerPlotRecord, chatId, }); } +function persistPlannerPlotRecordToUserMessage(newUserMessageIndex) { + const context = getContext(); + const chat = context?.chat; + if ( + !Array.isArray(chat) || + !Number.isFinite(newUserMessageIndex) || + !chat[newUserMessageIndex]?.is_user + ) { + return false; + } + const handoff = peekPlannerRecallHandoff(context?.chatId || getCurrentChatId()); + const plannerPlotRecord = handoff?.plannerPlotRecord; + if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") { + return false; + } + const wrote = writeStructuredPlotRecordToMessage(chat[newUserMessageIndex], { + ...plannerPlotRecord, + recallHandoffId: handoff.id || plannerPlotRecord.recallHandoffId || "", + }); + if (wrote) { + triggerChatMetadataSave(context, { immediate: false }); + } + return wrote; +} + function buildPreGenerationRecallKey(type, options = {}) { return generationRecallTransactionRuntime.buildPreGenerationRecallKey( type, @@ -16051,6 +16079,7 @@ function onMessageSent(messageId) { getContext, isTrivialUserInput, markCurrentGenerationTrivialSkip, + persistPlannerPlotRecordToUserMessage, recordRecallSentUserMessage, rebindRecallRecordToNewUserMessage, refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, diff --git a/runtime/reroll-recall-input.js b/runtime/reroll-recall-input.js index df7cde7..3f04986 100644 --- a/runtime/reroll-recall-input.js +++ b/runtime/reroll-recall-input.js @@ -309,6 +309,7 @@ export function createRerollRecallInput(deps = {}) { rawUserInput = "", plannerAugmentedMessage = "", plannerRecall = null, + plannerPlotRecord = null, chatId = getCurrentChatId(), } = {}) { const normalizedChatId = normalizeChatIdCandidate(chatId); @@ -340,6 +341,10 @@ export function createRerollRecallInput(deps = {}) { ? plannerRecall.recentMessages.map((item) => String(item || "")) : [], injectionText, + plannerPlotRecord: + plannerPlotRecord && typeof plannerPlotRecord === "object" + ? { ...plannerPlotRecord } + : null, source: "planner-handoff", sourceLabel: "Planner handoff", createdAt, diff --git a/tests/ena-planner-plots.mjs b/tests/ena-planner-plots.mjs index 67abe4c..befdb82 100644 --- a/tests/ena-planner-plots.mjs +++ b/tests/ena-planner-plots.mjs @@ -5,6 +5,12 @@ import { extractLastNPlots, formatPlotsBlock, } from '../ena-planner/ena-planner-runtime-utils.js'; +import { + createStructuredPlotRecord, + readPlannerPlotHistory, + writeStructuredPlotRecordToMatchingUserMessage, + writeStructuredPlotRecordToMessage, +} from '../ena-planner/planner-plot-history.js'; { const chat = [ @@ -44,6 +50,11 @@ import { assert.equal(payload.rawUserInput, 'raw input'); assert.equal(payload.plannerAugmentedMessage, 'raw input\n\nnext'); assert.equal(payload.plannerRecall, plannerRecall); + assert.deepEqual(payload.plannerPlotRecord, { + rawUserInput: 'raw input', + plannerAugmentedMessage: 'raw input\n\nnext', + plotText: 'next', + }); }, }; @@ -53,6 +64,7 @@ import { rawUserInput: 'raw input', filtered: 'next', plannerRecall, + plannerPlotRecord: { plotText: 'next' }, runtime, plannerState, }); @@ -65,6 +77,51 @@ import { assert.equal(plannerState.bypassNextSend, true); } +{ + const chat = [ + { is_user: true, mes: 'raw old', extra: {} }, + { is_user: false, mes: 'legacy stale' }, + { is_user: true, mes: 'raw latest', extra: {} }, + ]; + writeStructuredPlotRecordToMessage(chat[2], createStructuredPlotRecord({ + rawUserInput: 'raw latest', + plannerAugmentedMessage: 'raw latest\n\nstructured', + plotText: 'structured', + })); + const history = readPlannerPlotHistory(chat, { count: 2 }); + assert.equal(history.source, 'structured'); + assert.deepEqual(history.plots, ['structured']); + assert.ok(history.block.includes('structured')); + assert.ok(!history.block.includes('legacy stale')); +} + +{ + const chat = [ + { is_user: true, mes: 'raw old', extra: {} }, + { is_user: false, mes: 'legacy old' }, + ]; + chat[0].extra.st_bme_plot = { version: 999, plotText: 'bad' }; + const history = readPlannerPlotHistory(chat, { count: 1 }); + assert.equal(history.source, 'legacy'); + assert.deepEqual(history.plots, ['legacy old']); +} + +{ + const chat = [ + { is_user: true, mes: 'first input', extra: {} }, + { is_user: false, mes: 'assistant' }, + { is_user: true, mes: 'second input', extra: {} }, + ]; + const result = writeStructuredPlotRecordToMatchingUserMessage(chat, { + rawUserInput: 'first input', + plannerAugmentedMessage: 'first input\n\nfirst plan', + plotText: 'first plan', + }); + assert.equal(result.index, 0); + assert.equal(chat[0].extra.st_bme_plot.plotText, 'first plan'); + assert.equal(chat[2].extra.st_bme_plot, undefined); +} + { const order = []; const result = applyPlannerResultAndSend({