diff --git a/src/core/persistence.js b/src/core/persistence.js index e92fd79..15f3e02 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -556,6 +556,38 @@ export function getSwipeData(message, swipeId) { return null; } +/** + * Resolve active swipe index for a message. + * Falls back to message.swipe_id, but prefers exact match against current + * message text when available to avoid stale swipe_id during event timing races. + * + * @param {Object} message - Assistant message object + * @returns {number} Active swipe index + */ +function resolveActiveSwipeId(message) { + const fallbackSwipeId = Number(message?.swipe_id ?? 0); + const swipes = Array.isArray(message?.swipes) ? message.swipes : null; + + if (!swipes || swipes.length === 0) { + return Math.max(0, fallbackSwipeId); + } + + const currentText = typeof message?.mes === 'string' ? message.mes : ''; + if (currentText) { + for (let i = swipes.length - 1; i >= 0; i--) { + if (typeof swipes[i] === 'string' && swipes[i] === currentText) { + return i; + } + } + } + + if (fallbackSwipeId < 0) { + return 0; + } + + return Math.min(fallbackSwipeId, swipes.length - 1); +} + /** * Commits tracker data from the assistant message immediately before currentMessageIndex. * Walks backward through the chat skipping the current message, user messages, and system @@ -574,25 +606,30 @@ export function commitTrackerDataFromPriorMessage(currentMessageIndex) { 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 swipeId = resolveActiveSwipeId(message); 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; + + if (!swipeData) { + // Keep searching backward for a valid state if this assistant message has no data + continue; + } + + 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; } @@ -631,7 +668,7 @@ export function inheritSwipeDataFromPriorMessage(message, messageIndex) { const msg = chat[i]; if (msg.is_user || msg.is_system) continue; - const swipeId = msg.swipe_id || 0; + const swipeId = resolveActiveSwipeId(msg); const swipeData = getSwipeData(msg, swipeId); if (!swipeData) continue; // No data on this assistant message; keep searching further back diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js index 0e7cfcf..46d007b 100644 --- a/src/systems/generation/injector.js +++ b/src/systems/generation/injector.js @@ -38,8 +38,9 @@ let currentSuppressionState = false; // Type imports /** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ -// Track last chat length we committed at to prevent duplicate commits from streaming -let lastCommittedChatLength = -1; +// Track the latest user message we committed for to prevent duplicate commits +// when GENERATION_STARTED can fire multiple times for the same turn. +let lastCommittedUserMessageSignature = null; // Store context map for prompt injection (used by event handlers) let pendingContextMap = new Map(); @@ -607,66 +608,12 @@ export async function onGenerationStarted(type, data, dryRun) { // Ensure checkpoint is applied before generation await restoreCheckpointOnLoad(); - const currentChatLength = chat ? chat.length : 0; - - // For TOGETHER mode: Commit when user sends message (before first generation) - if (extensionSettings.generationMode === 'together') { - // By the time onGenerationStarted fires, ST has already added the placeholder AI message - // So we check the second-to-last message to see if user just sent a message - const secondToLastMessage = chat && chat.length > 1 ? chat[chat.length - 2] : null; - const isUserMessage = secondToLastMessage && secondToLastMessage.is_user; - - // Commit if: - // 1. Second-to-last message is from USER (user just sent message) - // 2. Not a swipe (lastActionWasSwipe = false) - // 3. Haven't already committed for this chat length (prevent streaming duplicates) - const shouldCommit = isUserMessage && !lastActionWasSwipe && currentChatLength !== lastCommittedChatLength; - - if (shouldCommit) { - // 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); - - // 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 from streaming - lastCommittedChatLength = currentChatLength; - - // console.log('[RPG Companion] AFTER: committedTrackerData =', { - // userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null' - // }); - } else if (lastActionWasSwipe) { - // console.log('[RPG Companion] ⏭️ Skipping commit: swipe (using previous committed data)'); - } else if (!isUserMessage) { - // console.log('[RPG Companion] ⏭️ Skipping commit: second-to-last message is not user message (likely swipe or continuation)'); - } - - // console.log('[RPG Companion] 📦 TOGETHER MODE: Injecting committed tracker data into prompt'); - // console.log('[RPG Companion] committedTrackerData =', { - // userStats: committedTrackerData.userStats ? `${committedTrackerData.userStats.substring(0, 50)}...` : 'null', - // infoBox: committedTrackerData.infoBox ? 'exists' : 'null', - // characterThoughts: committedTrackerData.characterThoughts ? `${committedTrackerData.characterThoughts.substring(0, 100)}...` : 'null' - // }); - } - - // For SEPARATE and EXTERNAL modes: Check if we need to commit extension data - // BUT: Only do this for the MAIN generation, not the tracker update generation - // If isGenerating is true, this is the tracker update generation (second call), so skip flag logic - // 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 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. + // If this is a new generation (not a swipe and not the tracker update pass), + // commit the tracker data from the last assistant message (N-1 rule). + // Passing chat.length ensures we start searching backwards from the end of the chat, + // correctly finding the latest valid assistant state regardless of where the user message is. + if (!lastActionWasSwipe && !isGenerating) { + commitTrackerDataFromPriorMessage(chat ? chat.length : 0); } // Use the committed tracker data as source for generation diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 518a527..f89fe8c 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -231,6 +231,38 @@ function getCurrentSwipeText(message) { return typeof message?.mes === 'string' ? message.mes : ''; } +/** + * Resolves the currently active swipe index for a message. + * Some ST flows can briefly expose a stale message.swipe_id during swipe transitions, + * so we also match against message.mes in the swipes array when possible. + * + * @param {Object} message - Assistant message object + * @returns {number} Active swipe index + */ +function resolveActiveSwipeId(message) { + const fallbackSwipeId = Number(message?.swipe_id ?? 0); + const swipes = Array.isArray(message?.swipes) ? message.swipes : null; + + if (!swipes || swipes.length === 0) { + return Math.max(0, fallbackSwipeId); + } + + const currentText = typeof message?.mes === 'string' ? message.mes : ''; + if (currentText) { + for (let i = swipes.length - 1; i >= 0; i--) { + if (typeof swipes[i] === 'string' && swipes[i] === currentText) { + return i; + } + } + } + + if (fallbackSwipeId < 0) { + return 0; + } + + return Math.min(fallbackSwipeId, swipes.length - 1); +} + function repairLatestTrackerStateFromCurrentSwipeContent(chatMessages = getContext()?.chat || []) { for (let i = chatMessages.length - 1; i >= 0; i--) { const message = chatMessages[i]; @@ -393,6 +425,7 @@ export function onMessageSent() { const chat = context.chat; const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; + if (lastMessage && lastMessage.mes === '...') { // console.log('[RPG Companion] 🟢 Ignoring onMessageSent: streaming placeholder message'); return; @@ -405,6 +438,9 @@ export function onMessageSent() { // This allows auto-update to distinguish between new generations and loading chat history setIsAwaitingNewMessage(true); + + + // 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 @@ -430,6 +466,7 @@ export async function onMessageReceived(data) { // Commit happens in onMessageSent (when user sends message, before generation) const lastMessage = chat[chat.length - 1]; if (lastMessage && !lastMessage.is_user) { + const rawSwipeId = Number(lastMessage.swipe_id ?? 0); const responseText = lastMessage.mes; const parsedData = parseResponse(responseText); @@ -471,7 +508,8 @@ export async function onMessageReceived(data) { lastMessage.extra.rpg_companion_swipes = {}; } - const currentSwipeId = lastMessage.swipe_id || 0; + const currentSwipeId = resolveActiveSwipeId(lastMessage); + setMessageSwipeTrackerData(lastMessage, currentSwipeId, { userStats: parsedData.userStats, infoBox: parsedData.infoBox, @@ -668,7 +706,7 @@ export function onMessageSwiped(messageIndex) { return; } - const currentSwipeId = message.swipe_id || 0; + const currentSwipeId = resolveActiveSwipeId(message); const swipeCount = Array.isArray(message.swipes) ? message.swipes.length : 0; // Only set flag to true if this swipe will trigger a NEW generation @@ -677,7 +715,7 @@ export function onMessageSwiped(messageIndex) { message.swipes[currentSwipeId] !== undefined && message.swipes[currentSwipeId] !== null && message.swipes[currentSwipeId].length > 0; - const swipeData = getCurrentSwipeTrackerData(message); + const swipeData = getSwipeData(message, currentSwipeId); const isPendingNewSwipe = currentSwipeId >= swipeCount; if (!isExistingSwipe) {