diff --git a/index.js b/index.js index 4237e5c..7131b9e 100644 --- a/index.js +++ b/index.js @@ -42,7 +42,7 @@ import { setMusicPlayerContainer, clearSessionAvatarPrompts } from './src/core/state.js'; -import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js'; +import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData, commitTrackerDataFromPriorMessage } from './src/core/persistence.js'; import { registerAllEvents } from './src/core/events.js'; // Generation & Parsing modules @@ -151,6 +151,7 @@ import { onMessageReceived, onCharacterChanged, onMessageSwiped, + onMessageDeleted, updatePersonaAvatar, clearExtensionPrompts, onGenerationEnded, @@ -799,6 +800,17 @@ async function initUI() { // console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.'); return; } + const currentChat = getContext().chat; + let lastAssistantIndex = -1; + for (let i = currentChat.length - 1; i >= 0; i--) { + if (!currentChat[i].is_user && !currentChat[i].is_system) { + lastAssistantIndex = i; + break; + } + } + if (lastAssistantIndex !== -1) { + commitTrackerDataFromPriorMessage(lastAssistantIndex); + } await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); }); @@ -807,6 +819,17 @@ async function initUI() { if (!extensionSettings.enabled) { return; } + const currentChat = getContext().chat; + let lastAssistantIndex = -1; + for (let i = currentChat.length - 1; i >= 0; i--) { + if (!currentChat[i].is_user && !currentChat[i].is_system) { + lastAssistantIndex = i; + break; + } + } + if (lastAssistantIndex !== -1) { + commitTrackerDataFromPriorMessage(lastAssistantIndex); + } await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); }); @@ -1354,6 +1377,7 @@ jQuery(async () => { [event_types.GENERATION_ENDED]: onGenerationEnded, [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar, restoreCheckpointOnLoad, clearSessionAvatarPrompts], [event_types.MESSAGE_SWIPED]: onMessageSwiped, + [event_types.MESSAGE_DELETED]: onMessageDeleted, [event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar, [event_types.SETTINGS_UPDATED]: updatePersonaAvatar }); diff --git a/src/core/persistence.js b/src/core/persistence.js index e6343c8..5cf7c96 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -224,6 +224,26 @@ export function saveChatData() { saveChatDebounced(); } +/** + * Mirrors a tracker data entry into message.swipe_info so it survives page reloads. + * ST only serializes swipe_info to disk; message.extra is in-memory only. + * Guard: skips silently if swipe_info[swipeId] doesn't exist yet + * + * @param {Object} message - The chat message object + * @param {number} swipeId - The swipe index to mirror into + * @param {Object} swipeEntry - { userStats, infoBox, characterThoughts } + */ +export function mirrorToSwipeInfo(message, swipeId, swipeEntry) { + if (!message.swipe_info || !message.swipe_info[swipeId]) return; + if (!message.swipe_info[swipeId].extra) { + message.swipe_info[swipeId].extra = {}; + } + if (!message.swipe_info[swipeId].extra.rpg_companion_swipes) { + message.swipe_info[swipeId].extra.rpg_companion_swipes = {}; + } + message.swipe_info[swipeId].extra.rpg_companion_swipes[swipeId] = swipeEntry; +} + /** * Updates the last assistant message's swipe data with current tracker data. * This ensures user edits are preserved across swipes and included in generation context. @@ -247,11 +267,15 @@ export function updateMessageSwipeData() { } const swipeId = message.swipe_id || 0; - message.extra.rpg_companion_swipes[swipeId] = { + const swipeEntry = { userStats: lastGeneratedData.userStats, infoBox: lastGeneratedData.infoBox, characterThoughts: lastGeneratedData.characterThoughts }; + message.extra.rpg_companion_swipes[swipeId] = swipeEntry; + + // Mirror to swipe_info so data survives page reloads regardless of active swipe + mirrorToSwipeInfo(message, swipeId, swipeEntry); // console.log('[RPG Companion] Updated message swipe data after user edit'); break; @@ -259,6 +283,123 @@ export function updateMessageSwipeData() { } } +/** + * Reads RPG tracker data for a specific swipe from a message. + * Checks message.extra first (in-memory, current session), then message.swipe_info + * (serialized by SillyTavern on save, available after page reload). + * + * @param {Object} message - The chat message object + * @param {number} swipeId - The swipe index to read + * @returns {{userStats, infoBox, characterThoughts}|null} The swipe data or null + */ +export function getSwipeData(message, swipeId) { + // Primary: in-memory extra (current session or after a recent write) + const fromExtra = message.extra?.rpg_companion_swipes?.[swipeId]; + if (fromExtra) return fromExtra; + + // Fallback: swipe_info (populated by ST when loading from disk) + const fromSwipeInfo = message.swipe_info?.[swipeId]?.extra?.rpg_companion_swipes?.[swipeId]; + if (fromSwipeInfo) return fromSwipeInfo; + + return null; +} + +/** + * Commits tracker data from the assistant message immediately before currentMessageIndex. + * Walks backward through the chat skipping the current message, user messages, and system + * messages until it finds the prior assistant message, then loads its active swipe data. + * If no prior assistant message exists or exists without a tracker state, nulls out all fields so + * the AI generates from an empty context rather than a ghost state. + * + * @param {number} currentMessageIndex - Index of the message to start searching before + */ +export function commitTrackerDataFromPriorMessage(currentMessageIndex) { + const chat = getContext().chat; + if (!chat || chat.length === 0) { + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + return; + } + + // console.log('[RPG Companion] commitTrackerDataFromPriorMessage called with index', currentMessageIndex, '| chat.length =', chat.length); + + for (let i = currentMessageIndex - 1; i >= 0; i--) { + const message = chat[i]; + if (message.is_user || message.is_system) continue; + + // Found the prior assistant message — commit its active swipe data + const swipeId = message.swipe_id || 0; + const swipeData = getSwipeData(message, swipeId); + // console.log('[RPG Companion] Committing from chat[' + i + '] swipe', swipeId, '| has swipe data:', !!swipeData); + committedTrackerData.userStats = swipeData?.userStats || null; + committedTrackerData.infoBox = swipeData?.infoBox || null; + const rawCharacterThoughts = swipeData?.characterThoughts; + committedTrackerData.characterThoughts = + rawCharacterThoughts == null + ? null + : (typeof rawCharacterThoughts === 'string' + ? rawCharacterThoughts + : JSON.stringify(rawCharacterThoughts)); + return; + } + + // No prior assistant message found — use empty context + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; +} + +/** + * Populates a message's current swipe slot with tracker data inherited from the + * nearest prior assistant message, when no tracker data has been generated for + * this swipe yet (e.g. auto-update is disabled). + * + * This ensures that commitTrackerDataFromPriorMessage can always find a tracker + * state to commit when the user sends the next message, rather than nulling + * everything out and resetting the tracker display to empty. + * + * Does nothing if the current swipe already has its own tracker data. + * + * @param {Object} message - The assistant message object to inherit into + * @param {number} messageIndex - Index of that message in chat + * @returns {boolean} True if inheritance was written, false otherwise + */ +export function inheritSwipeDataFromPriorMessage(message, messageIndex) { + const chat = getContext().chat; + if (!chat) return false; + + const currentSwipeId = message.swipe_id || 0; + + // Don't overwrite if this swipe already has its own tracker data. + if (getSwipeData(message, currentSwipeId)) return false; + + // Walk backward to find the nearest prior assistant message with swipe data. + for (let i = messageIndex - 1; i >= 0; i--) { + const msg = chat[i]; + if (msg.is_user || msg.is_system) continue; + + const swipeId = msg.swipe_id || 0; + const swipeData = getSwipeData(msg, swipeId); + if (!swipeData) continue; // No data on this assistant message; keep searching further back + + // Write inherited data into this swipe slot. + if (!message.extra) message.extra = {}; + if (!message.extra.rpg_companion_swipes) message.extra.rpg_companion_swipes = {}; + + const inherited = { + userStats: swipeData.userStats, + infoBox: swipeData.infoBox, + characterThoughts: swipeData.characterThoughts + }; + message.extra.rpg_companion_swipes[currentSwipeId] = inherited; + mirrorToSwipeInfo(message, currentSwipeId, inherited); + // console.log('[RPG Companion] Inherited tracker data from chat[' + i + '] into current swipe slot', currentSwipeId); + return true; + } + return false; +} + /** * Loads RPG data from the current chat's metadata. * Automatically migrates v1 inventory to v2 format if needed. diff --git a/src/core/state.js b/src/core/state.js index 693072a..e90367e 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -381,6 +381,32 @@ export let isPlotProgression = false; */ export let isAwaitingNewMessage = false; +/** + * Monotonically-increasing counter used to detect stale separate-mode tracker + * generation results. Incremented each time a new automated generation is + * triggered or a message deletion occurs so any in-flight (or pending) call + * from a previous generation can recognise that its result is no longer valid. + */ +let separateGenerationId = 0; + +/** + * Returns the current separate generation ID. + * @returns {number} + */ +export function getSeparateGenerationId() { + return separateGenerationId; +} + +/** + * Increments and returns the new separate generation ID. + * Call this when starting a new generation or when a deletion + * invalidates any pending/in-flight generation. + * @returns {number} The new ID + */ +export function incrementSeparateGenerationId() { + return ++separateGenerationId; +} + /** * Temporary storage for pending dice roll (not saved until user clicks "Save Roll") */ diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 4c38f68..d22b762 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -18,9 +18,10 @@ import { lastActionWasSwipe, setIsGenerating, setLastActionWasSwipe, - $musicPlayerContainer + $musicPlayerContainer, + getSeparateGenerationId } from '../../core/state.js'; -import { saveChatData } from '../../core/persistence.js'; +import { saveChatData, mirrorToSwipeInfo } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; @@ -218,7 +219,7 @@ export async function switchToPreset(presetName) { * @param {Function} renderThoughts - UI function to render character thoughts * @param {Function} renderInventory - UI function to render inventory */ -export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory) { +export async function updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, generationId = null) { if (isGenerating) { // console.log('[RPG Companion] Already generating, skipping...'); return; @@ -262,6 +263,14 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough }); } + // If a generationId was provided and the counter has since been incremented + // (by a deletion or a newer generation), discard this result entirely. + // The finally block still runs to restore button state. + if (generationId !== null && getSeparateGenerationId() !== generationId) { + // console.log('[RPG Companion] ⚠️ Separate generation result discarded — superseded (genId', generationId, '!= current', getSeparateGenerationId(), ')'); + return; + } + if (response) { // console.log('[RPG Companion] Raw AI response:', response); const parsedData = parseResponse(response); @@ -317,11 +326,15 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough } const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; + + // Mirror to swipe_info so this swipe survives page reload even if never manually edited + mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); } diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js index c920db9..0e7cfcf 100644 --- a/src/systems/generation/injector.js +++ b/src/systems/generation/injector.js @@ -30,6 +30,7 @@ import { SPOTIFY_FORMAT_INSTRUCTION } from './promptBuilder.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; +import { commitTrackerDataFromPriorMessage } from '../../core/persistence.js'; // Track suppression state for event handler let currentSuppressionState = false; @@ -622,25 +623,15 @@ export async function onGenerationStarted(type, data, dryRun) { const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength; if (shouldCommit) { - // console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing data from BEFORE user message'); + // console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: User sent message - committing from N-1 assistant message'); // console.log('[RPG Companion] Chat length:', currentChatLength, 'Last committed:', lastCommittedChatLength); - // console.log('[RPG Companion] BEFORE: committedTrackerData =', { - // userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null' - // // }); - // console.log('[RPG Companion] BEFORE: lastGeneratedData =', { - // userStats: lastGeneratedData.userStats ? `${lastGeneratedData.userStats.substring(0, 50)}...` : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? `${lastGeneratedData.characterThoughts.substring(0, 100)}...` : 'null' - // }); - // Commit displayed data (from before user sent message) - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; + // Commit from the prior assistant message's swipe store (N-1 rule). + // currentChatLength - 1 is the new AI placeholder; the function walks backward + // past it and the user message to find the previous AI message's tracker state. + commitTrackerDataFromPriorMessage(currentChatLength - 1); - // Track chat length to prevent duplicate commits + // Track chat length to prevent duplicate commits from streaming lastCommittedChatLength = currentChatLength; // console.log('[RPG Companion] AFTER: committedTrackerData =', { @@ -668,38 +659,14 @@ export async function onGenerationStarted(type, data, dryRun) { // console.log('[RPG Companion DEBUG] Before generating:', lastGeneratedData.characterThoughts, ' , committed - ', committedTrackerData.characterThoughts); if ((extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') && !isGenerating) { if (!lastActionWasSwipe) { - // User sent a new message - commit lastGeneratedData before generation - // console.log('[RPG Companion] 📝 COMMIT: New message - committing lastGeneratedData'); - // console.log('[RPG Companion] BEFORE commit - committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // // }); - // console.log('[RPG Companion] BEFORE commit - lastGeneratedData:', { - // userStats: lastGeneratedData.userStats ? 'exists' : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null' - // }); - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - // console.log('[RPG Companion] AFTER commit - committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // }); - - // Reset flag after committing (ready for next cycle) - - } else { - // console.log('[RPG Companion] 🔄 SWIPE: Using existing committedTrackerData (no commit)'); - // console.log('[RPG Companion] committedTrackerData:', { - // userStats: committedTrackerData.userStats ? 'exists' : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? 'exists' : 'null' - // }); - // Reset flag after using it (swipe generation complete, ready for next action) + // User sent a new message - commit from the prior assistant message's swipe store + // (N-1 rule) rather than lastGeneratedData, which may reflect a sibling swipe's + // outcome and would poison the context for the new generation. + // currentChatLength - 1 is the new AI placeholder; search starts before it. + commitTrackerDataFromPriorMessage(currentChatLength - 1); } + // If lastActionWasSwipe, context was already committed by commitTrackerDataFromPriorMessage + // in onMessageSwiped before generation started. } // Use the committed tracker data as source for generation diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 3af43a8..d55d8f8 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -20,9 +20,10 @@ import { setIsAwaitingNewMessage, updateLastGeneratedData, updateCommittedTrackerData, - $musicPlayerContainer + $musicPlayerContainer, + incrementSeparateGenerationId } from '../../core/state.js'; -import { saveChatData, loadChatData, autoSwitchPresetForEntity } from '../../core/persistence.js'; +import { saveChatData, loadChatData, autoSwitchPresetForEntity, getSwipeData, commitTrackerDataFromPriorMessage, inheritSwipeDataFromPriorMessage, mirrorToSwipeInfo } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing @@ -51,6 +52,45 @@ import { updateStripWidgets } from '../ui/desktop.js'; import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; +/** + * Reads the swipe store of the last assistant message in `currentChat` and + * writes its data into `lastGeneratedData`, including syncing stat bars via + * `parseUserStats`. If no assistant message exists, or none has stored swipe + * data, `lastGeneratedData` is left unchanged. + * + * Use this wherever the displayed tracker state must be re-derived from the + * authoritative swipe store rather than from chat_metadata (e.g. after a + * CHAT_CHANGED caused by branching, or after a message deletion). + * + * @param {Array} currentChat - Live chat array from getContext().chat + * @returns {boolean} True if swipe data was found and applied + */ +function syncLastGeneratedDataFromSwipeStore(currentChat) { + for (let i = currentChat.length - 1; i >= 0; i--) { + const msg = currentChat[i]; + if (!msg.is_user && !msg.is_system) { + const swipeId = msg.swipe_id || 0; + const swipeData = getSwipeData(msg, swipeId); + if (swipeData) { + lastGeneratedData.userStats = swipeData.userStats || null; + lastGeneratedData.infoBox = swipeData.infoBox || null; + // Normalize characterThoughts to string (backward compat with old object format). + if (swipeData.characterThoughts && typeof swipeData.characterThoughts === 'object') { + lastGeneratedData.characterThoughts = JSON.stringify(swipeData.characterThoughts, null, 2); + } else { + lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; + } + if (swipeData.userStats) { + parseUserStats(swipeData.userStats); + } + return true; + } + return false; // Last assistant message exists but has no swipe data yet + } + } + return false; // No assistant messages in chat +} + /** * Commits the tracker data from the last assistant message to be used as source for next generation. * This should be called when the user has replied to a message, ensuring all swipes of the next @@ -65,22 +105,28 @@ export function commitTrackerData() { // Find the last assistant message for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; - if (!message.is_user) { + if (!message.is_user && !message.is_system) { // Found last assistant message - commit its tracker data - if (message.extra && message.extra.rpg_companion_swipes) { - const swipeId = message.swipe_id || 0; - const swipeData = message.extra.rpg_companion_swipes[swipeId]; + const swipeId = message.swipe_id || 0; + const swipeData = getSwipeData(message, swipeId); - if (swipeData) { - // console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId); - committedTrackerData.userStats = swipeData.userStats || null; - committedTrackerData.infoBox = swipeData.infoBox || null; - committedTrackerData.characterThoughts = swipeData.characterThoughts || null; + if (swipeData) { + // console.log('[RPG Companion] Committing tracker data from assistant message at index', i, 'swipe', swipeId); + committedTrackerData.userStats = swipeData.userStats || null; + committedTrackerData.infoBox = swipeData.infoBox || null; + const rawCharacterThoughts = swipeData.characterThoughts; + if (rawCharacterThoughts == null) { + committedTrackerData.characterThoughts = null; + } else if (typeof rawCharacterThoughts === 'object') { + committedTrackerData.characterThoughts = JSON.stringify(rawCharacterThoughts); } else { - // console.log('[RPG Companion] No swipe data found for swipe', swipeId); + committedTrackerData.characterThoughts = String(rawCharacterThoughts); } } else { - // console.log('[RPG Companion] No RPG data found in last assistant message'); + // No saved swipe data — treat as empty (e.g. first message, no prior generation) + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; } break; } @@ -118,17 +164,6 @@ export function onMessageSent() { // Note: FAB spinning is NOT shown for together mode since no extra API request is made // The RPG data comes embedded in the main response // FAB spinning is handled by apiClient.js for separate/external modes when updateRPGData() is called - - // For separate mode with auto-update disabled, commit displayed tracker - if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) { - if (lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts) { - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - - // console.log('[RPG Companion] 💾 SEPARATE MODE: Committed displayed tracker (auto-update disabled)'); - } - } } /** @@ -193,11 +228,15 @@ export async function onMessageReceived(data) { } const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + const swipeEntry = { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = swipeEntry; + + // Mirror to swipe_info so this swipe survives page reload even if never manually edited + mirrorToSwipeInfo(lastMessage, currentSwipeId, swipeEntry); // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); @@ -261,13 +300,26 @@ export async function onMessageReceived(data) { // Just render the music player renderMusicPlayer($musicPlayerContainer[0]); } + + // When auto-update is disabled, no tracker API call will run for this message. + // Inherit the prior assistant message's tracker data into this swipe slot so that + // commitTrackerDataFromPriorMessage can find a valid state next turn instead of nulling everything. + // Inheritance does not overwrite existing data, so it's safe to call even if the condition misses an edge case. + if (!extensionSettings.autoUpdate || !isAwaitingNewMessage) { + inheritSwipeDataFromPriorMessage(lastMessage, chat.length - 1); + } } // Trigger auto-update if enabled (for both separate and external modes) // Only trigger if this is a newly generated message, not loading chat history if (extensionSettings.autoUpdate && isAwaitingNewMessage) { + // Capture the current generation ID before the async gap so that any + // message deletion (or a newer generation) that increments the counter + // while the 500ms timer or the API call is in-flight will cause + // updateRPGData to discard its result rather than stomping the UI. + const genId = incrementSeparateGenerationId(); setTimeout(async () => { - await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory, genId); // Update FAB widgets and strip widgets after separate/external mode update completes setFabLoadingState(false); updateFabWidgets(); @@ -323,6 +375,22 @@ export function onCharacterChanged() { // Load chat-specific data when switching chats loadChatData(); + // chat_metadata may not reflect the actual chat tail for branches, so + // loadChatData() may have just restored stale data from the parent chat. + // Override lastGeneratedData from the swipe store of the last assistant message. + // The message objects in the branch already carry their full swipe stores, making this authoritative. + // If no swipe data exists (e.g. branching at message 0, or a chat with no generations yet), + // null out lastGeneratedData and committedTrackerData so we don't display stale values from the parent chat. + const hadSwipeData = syncLastGeneratedDataFromSwipeStore(getContext().chat); + if (!hadSwipeData) { + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + } + // Don't call commitTrackerData() here - it would overwrite the loaded committedTrackerData // with data from the last message, which may be null/empty. The loaded committedTrackerData // already contains the committed state from when we last left this chat. @@ -378,6 +446,9 @@ export function onMessageSwiped(messageIndex) { // This is a NEW swipe that will trigger generation setLastActionWasSwipe(true); setIsAwaitingNewMessage(true); + // Immediately commit context from the prior assistant message (N-1) so generation + // uses the world state before this message, not the last-viewed sibling swipe. + commitTrackerDataFromPriorMessage(messageIndex); // console.log('[RPG Companion] 🔵 NEW swipe detected - Set lastActionWasSwipe = true'); } else { // This is navigating to an EXISTING swipe - don't change the flag @@ -386,12 +457,12 @@ export function onMessageSwiped(messageIndex) { // console.log('[RPG Companion] Loading data for swipe', currentSwipeId); - // IMPORTANT: onMessageSwiped is for DISPLAY only! - // lastGeneratedData is for DISPLAY, committedTrackerData is for GENERATION - // It's safe to load swipe data into lastGeneratedData - it won't be committed due to !lastActionWasSwipe check - if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) { - const swipeData = message.extra.rpg_companion_swipes[currentSwipeId]; - + // Load saved swipe data into both display (lastGeneratedData) and extensionSettings. + // Safe to call parseUserStats() unconditionally because updateMessageSwipeData() is called + // on every manual edit, so the swipe store always reflects the latest user changes before + // any navigation can overwrite them. + const swipeData = getSwipeData(message, currentSwipeId); + if (swipeData) { // Load swipe data into lastGeneratedData for display (both modes) lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.infoBox = swipeData.infoBox || null; @@ -403,13 +474,12 @@ export function onMessageSwiped(messageIndex) { lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; } - // DON'T parse user stats when loading swipe data - // This would overwrite manually edited fields (like Conditions) with old swipe data - // The lastGeneratedData is loaded for display purposes only - // parseUserStats() updates extensionSettings.userStats which should only be modified - // by new generations or manual edits, not by swipe navigation + // Sync extensionSettings.userStats so stat bars reflect this swipe + if (swipeData.userStats) { + parseUserStats(swipeData.userStats); + } - // console.log('[RPG Companion] 🔄 Loaded swipe data into lastGeneratedData for display:', currentSwipeId); + // console.log('[RPG Companion] 🔄 Loaded swipe data for swipe:', currentSwipeId); } else { // console.log('[RPG Companion] ℹ️ No stored data for swipe:', currentSwipeId); } @@ -422,10 +492,85 @@ export function onMessageSwiped(messageIndex) { renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); + // Update widget strips with the newly loaded swipe data + updateFabWidgets(); + updateStripWidgets(); + // Update chat thought overlays updateChatThoughts(); } +/** + * Event handler for when a message is deleted. + * Re-syncs lastGeneratedData, committedTrackerData, and all UI panels to the + * new last assistant message's active swipe — or clears everything if no + * assistant messages remain. + */ +export function onMessageDeleted() { + if (!extensionSettings.enabled) return; + + // console.log('[RPG Companion] 🗑️ EVENT: onMessageDeleted'); + + // Invalidate any pending or in-flight separate-mode generation so + // its result is not applied to the (now-changed) chat tail. + incrementSeparateGenerationId(); + + const currentChat = getContext().chat; + + // Walk backward to find the new last assistant message. + let lastAssistantIndex = -1; + for (let i = currentChat.length - 1; i >= 0; i--) { + if (!currentChat[i].is_user && !currentChat[i].is_system) { + lastAssistantIndex = i; + break; + } + } + + if (lastAssistantIndex === -1) { + // No assistant messages remain — clear all state. + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + // console.log('[RPG Companion] 🗑️ No assistant messages remain — cleared all tracker state.'); + } else { + // Restore display state from the new tail message's active swipe. + // If the message has no swipe data yet, null the fields so we + // don't show stale data from the deleted message. + const hadSwipeData = syncLastGeneratedDataFromSwipeStore(currentChat); + if (!hadSwipeData) { + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + // console.log('[RPG Companion] 🗑️ No swipe data for last assistant message — cleared display state.'); + } + + // Commit context from the message *before* the new tail assistant message, + // so any subsequent generation uses the correct N-1 world state. + commitTrackerDataFromPriorMessage(lastAssistantIndex); + } + + // Re-render all panels. + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + renderQuests(); + renderMusicPlayer($musicPlayerContainer[0]); + + // Update widget strips. + updateFabWidgets(); + updateStripWidgets(); + + // Persist updated state. + saveChatData(); +} + /** * Update the persona avatar image when user switches personas */