/** * SillyTavern Integration Module * Handles all event listeners and integration with SillyTavern's event system */ import { getContext } from '../../../../../../extensions.js'; import { chat, chat_metadata, user_avatar, setExtensionPrompt, extension_prompt_types } from '../../../../../../../script.js'; // Core modules import { extensionSettings, lastGeneratedData, committedTrackerData, lastActionWasSwipe, isPlotProgression, isAwaitingNewMessage, setLastActionWasSwipe, setIsPlotProgression, setIsGenerating, setIsAwaitingNewMessage, updateLastGeneratedData, updateCommittedTrackerData, $musicPlayerContainer, incrementSeparateGenerationId } from '../../core/state.js'; import { saveChatData, loadChatData, autoSwitchPresetForEntity, getMessageSwipeTrackerData, getCurrentMessageSwipeTrackerData, restoreLatestTrackerStateFromChat, setMessageSwipeTrackerData, getSwipeData, commitTrackerDataFromPriorMessage, inheritSwipeDataFromPriorMessage } from '../../core/persistence.js'; import { i18n } from '../../core/i18n.js'; // Generation & Parsing import { parseResponse, parseUserStats } from '../generation/parser.js'; import { parseAndStoreSpotifyUrl, convertToEmbedUrl } from '../features/musicPlayer.js'; import { updateRPGData } from '../generation/apiClient.js'; import { removeLocks } from '../generation/lockManager.js'; import { onGenerationStarted, initHistoryInjectionListeners } from '../generation/injector.js'; // Rendering import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; import { renderQuests } from '../rendering/quests.js'; import { renderMusicPlayer } from '../rendering/musicPlayer.js'; // Utils import { getSafeThumbnailUrl } from '../../utils/avatars.js'; // UI import { setFabLoadingState, updateFabWidgets } from '../ui/mobile.js'; import { updateStripWidgets } from '../ui/desktop.js'; // Chapter checkpoint import { updateAllCheckpointIndicators } from '../ui/checkpointUI.js'; import { restoreCheckpointOnLoad } from '../features/chapterCheckpoint.js'; let chatStateRehydrateRunId = 0; /** * 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 * response use the same committed context. */ export function commitTrackerData() { const chat = getContext().chat; if (!chat || chat.length === 0) { return; } // Find the last assistant message for (let i = chat.length - 1; i >= 0; i--) { const message = chat[i]; if (!message.is_user && !message.is_system) { // Found last assistant message - commit its tracker data 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; const rawCharacterThoughts = swipeData.characterThoughts; if (rawCharacterThoughts == null) { committedTrackerData.characterThoughts = null; } else if (typeof rawCharacterThoughts === 'object') { committedTrackerData.characterThoughts = JSON.stringify(rawCharacterThoughts); } else { committedTrackerData.characterThoughts = String(rawCharacterThoughts); } } else { // No saved swipe data — treat as empty (e.g. first message, no prior generation) committedTrackerData.userStats = null; committedTrackerData.infoBox = null; committedTrackerData.characterThoughts = null; } break; } } } function getSwipeTrackerData(message) { return getMessageSwipeTrackerData(message); } function getCurrentSwipeTrackerData(message) { return getCurrentMessageSwipeTrackerData(message); } function hasAssistantMessageBody() { const $messages = $('#chat .mes'); for (let i = $messages.length - 1; i >= 0; i--) { const $message = $messages.eq(i); if ($message.attr('is_user') === 'true') continue; if ($message.find('.mes_text').length > 0) { return true; } } return false; } function hasAnyTrackerStateInChat() { const chatMessages = getContext()?.chat || []; for (let i = chatMessages.length - 1; i >= 0; i--) { const swipeData = getSwipeTrackerData(chatMessages[i]); if (swipeData?.userStats || swipeData?.infoBox || swipeData?.characterThoughts) { return true; } } return false; } function hasAssistantMessagesInChat() { const chatMessages = getContext()?.chat || []; return chatMessages.some(message => message && !message.is_user && !message.is_system); } function hasPotentialTrackerSourceInChat() { const chatMessages = getContext()?.chat || []; for (const message of chatMessages) { if (!message || message.is_user || message.is_system) { continue; } if (message.extra?.rpg_companion_swipes) { return true; } if (Array.isArray(message.swipe_info) && message.swipe_info.some(info => info?.extra?.rpg_companion_swipes)) { return true; } if (Array.isArray(message.swipes) && message.swipes.length > 1) { return true; } } return false; } function maybeRehydrateUserStatsFromDisplayData() { const hasSavedUserStats = !!chat_metadata?.rpg_companion?.userStats; if (!hasSavedUserStats && lastGeneratedData.userStats) { try { parseUserStats(lastGeneratedData.userStats); } catch (error) { console.warn('[RPG Companion] Failed to rebuild user stats from display data:', error); } } } function getCurrentSwipeText(message) { const swipeId = Number(message?.swipe_id ?? 0); if (Array.isArray(message?.swipes) && typeof message.swipes[swipeId] === 'string' && message.swipes[swipeId].trim()) { return message.swipes[swipeId]; } 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]; if (!message || message.is_user || message.is_system) { continue; } const swipeId = Number(message.swipe_id ?? 0); if (getCurrentSwipeTrackerData(message)) { continue; } const currentSwipeText = getCurrentSwipeText(message); if (!currentSwipeText) { continue; } const parsedData = parseResponse(currentSwipeText); if (parsedData.userStats) { parsedData.userStats = removeLocks(parsedData.userStats); } if (parsedData.infoBox) { parsedData.infoBox = removeLocks(parsedData.infoBox); } if (parsedData.characterThoughts) { parsedData.characterThoughts = removeLocks(parsedData.characterThoughts); } if (!parsedData.userStats && !parsedData.infoBox && !parsedData.characterThoughts) { continue; } setMessageSwipeTrackerData(message, swipeId, { userStats: parsedData.userStats || null, infoBox: parsedData.infoBox || null, characterThoughts: parsedData.characterThoughts || null }); return true; } return false; } function restoreOrRepairLatestTrackerState() { const chatMessages = getContext()?.chat || []; let restored = restoreLatestTrackerStateFromChat(chatMessages); if (!restored) { const repaired = repairLatestTrackerStateFromCurrentSwipeContent(chatMessages); if (repaired) { restored = restoreLatestTrackerStateFromChat(chatMessages); } } return restored; } function rerenderRpgState() { renderUserStats(); renderInfoBox(); renderThoughts(); renderInventory(); renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); updateFabWidgets(); updateStripWidgets(); } export function scheduleChatStateRehydration() { chatStateRehydrateRunId++; const runId = chatStateRehydrateRunId; let attempts = 0; const maxAttempts = 15; const eagerRetryAttempts = 4; const tryRestoreState = () => { if (runId !== chatStateRehydrateRunId) { return; } attempts++; loadChatData(); restoreOrRepairLatestTrackerState(); maybeRehydrateUserStatsFromDisplayData(); rerenderRpgState(); const hasRestoredTrackerState = !!( lastGeneratedData.userStats || lastGeneratedData.infoBox || lastGeneratedData.characterThoughts || committedTrackerData.userStats || committedTrackerData.infoBox || committedTrackerData.characterThoughts ); const hasStoredTrackerState = !!chat_metadata?.rpg_companion || hasAnyTrackerStateInChat(); const hasAssistantMessages = hasAssistantMessagesInChat(); const hasPotentialTrackerSource = hasPotentialTrackerSourceInChat(); const chatBodyReady = hasAssistantMessageBody(); if (chatBodyReady) { updateChatThoughts(); } const shouldRetryForRestore = !hasRestoredTrackerState && ( hasStoredTrackerState || (hasAssistantMessages && attempts < eagerRetryAttempts) || (hasPotentialTrackerSource && attempts < maxAttempts) ); const shouldRetryForDom = !chatBodyReady && hasAssistantMessages; if ((shouldRetryForRestore || shouldRetryForDom) && attempts < maxAttempts) { setTimeout(tryRestoreState, 200); } }; setTimeout(tryRestoreState, 200); } export function onChatLoaded() { loadChatData(); restoreOrRepairLatestTrackerState(); maybeRehydrateUserStatsFromDisplayData(); rerenderRpgState(); scheduleChatStateRehydration(); updateAllCheckpointIndicators(); } function syncDisplayedTrackerStateFromChat() { const restored = restoreOrRepairLatestTrackerState(); if (!restored) { lastGeneratedData.userStats = null; lastGeneratedData.infoBox = null; lastGeneratedData.characterThoughts = null; committedTrackerData.userStats = null; committedTrackerData.infoBox = null; committedTrackerData.characterThoughts = null; } rerenderRpgState(); updateChatThoughts(); } /** * Event handler for when the user sends a message. * Sets the flag to indicate this is NOT a swipe. * In together mode, commits displayed data (only for real messages, not streaming placeholders). */ export function onMessageSent() { if (!extensionSettings.enabled) return; // console.log('[RPG Companion] đŸŸĸ EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe); // Check if this is a streaming placeholder message (content = "...") // When streaming is on, ST sends a "..." placeholder before generation starts const context = getContext(); 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; } // console.log('[RPG Companion] đŸŸĸ EVENT: onMessageSent (after placeholder check)'); // console.log('[RPG Companion] đŸŸĸ NOTE: lastActionWasSwipe will be reset in onMessageReceived after generation completes'); // Set flag to indicate we're expecting a new message from generation // 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 } /** * Event handler for when a message is generated. */ export async function onMessageReceived(data) { // console.log('[RPG Companion] onMessageReceived called, lastActionWasSwipe:', lastActionWasSwipe); if (!extensionSettings.enabled) { return; } // Reset swipe flag after generation completes // This ensures next user message (whether from original or swipe) triggers commit setLastActionWasSwipe(false); // console.log('[RPG Companion] đŸŸĸ Reset lastActionWasSwipe = false (generation completed)'); if (extensionSettings.generationMode === 'together') { // In together mode, parse the response to extract RPG 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); // Note: Don't show parsing error here - this event fires when loading chat history too // Error notification is handled in apiClient.js for fresh generations only // Remove locks from parsed data (JSON format only, text format is unaffected) if (parsedData.userStats) { parsedData.userStats = removeLocks(parsedData.userStats); } if (parsedData.infoBox) { parsedData.infoBox = removeLocks(parsedData.infoBox); } if (parsedData.characterThoughts) { parsedData.characterThoughts = removeLocks(parsedData.characterThoughts); } // Parse and store Spotify URL if feature is enabled parseAndStoreSpotifyUrl(responseText); // Update display data with newly parsed response // console.log('[RPG Companion] 📝 TOGETHER MODE: Updating lastGeneratedData with parsed response'); if (parsedData.userStats) { lastGeneratedData.userStats = parsedData.userStats; parseUserStats(parsedData.userStats); } if (parsedData.infoBox) { lastGeneratedData.infoBox = parsedData.infoBox; } if (parsedData.characterThoughts) { lastGeneratedData.characterThoughts = parsedData.characterThoughts; } // Store RPG data for this specific swipe in the message's extra field if (!lastMessage.extra) { lastMessage.extra = {}; } if (!lastMessage.extra.rpg_companion_swipes) { lastMessage.extra.rpg_companion_swipes = {}; } const currentSwipeId = resolveActiveSwipeId(lastMessage); setMessageSwipeTrackerData(lastMessage, currentSwipeId, { userStats: parsedData.userStats, infoBox: parsedData.infoBox, characterThoughts: parsedData.characterThoughts }); // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); // Remove the tracker code blocks from the visible message let cleanedMessage = responseText; // Note: JSON code blocks are hidden from display by regex script (but preserved in message data) // Remove old text format code blocks (legacy support) cleanedMessage = cleanedMessage.replace(/```[^`]*?Stats\s*\n\s*---[^`]*?```\s*/gi, ''); cleanedMessage = cleanedMessage.replace(/```[^`]*?Info Box\s*\n\s*---[^`]*?```\s*/gi, ''); cleanedMessage = cleanedMessage.replace(/```[^`]*?Present Characters\s*\n\s*---[^`]*?```\s*/gi, ''); // Remove any stray "---" dividers that might appear after the code blocks cleanedMessage = cleanedMessage.replace(/^\s*---\s*$/gm, ''); // Clean up multiple consecutive newlines cleanedMessage = cleanedMessage.replace(/\n{3,}/g, '\n\n'); // Note: XML tags are automatically hidden by SillyTavern // Note: tags are also automatically hidden by SillyTavern // Update the message in chat history lastMessage.mes = cleanedMessage.trim(); // Update the swipe text as well if (lastMessage.swipes && lastMessage.swipes[currentSwipeId] !== undefined) { lastMessage.swipes[currentSwipeId] = cleanedMessage.trim(); } // Render the updated data FIRST (before cleaning DOM) renderUserStats(); renderInfoBox(); renderThoughts(); renderInventory(); renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); // Update FAB widgets and strip widgets with newly parsed data updateFabWidgets(); updateStripWidgets(); // Then update the DOM to reflect the cleaned message // Using updateMessageBlock to perform macro substitutions + regex formatting const messageId = chat.length - 1; updateMessageBlock(messageId, lastMessage, { rerenderMessage: true }); // console.log('[RPG Companion] Cleaned message, removed tracker code blocks from DOM'); // Re-insert chat thoughts after SillyTavern finishes rerendering the cleaned message DOM. if (parsedData.characterThoughts) { setTimeout(() => updateChatThoughts(), 100); } // Save to chat metadata saveChatData(); } } else if (extensionSettings.generationMode === 'separate' || extensionSettings.generationMode === 'external') { // In separate/external mode, also parse Spotify URLs from the main roleplay response const lastMessage = chat[chat.length - 1]; if (lastMessage && !lastMessage.is_user) { const responseText = lastMessage.mes; // Parse and store Spotify URL const foundSpotifyUrl = parseAndStoreSpotifyUrl(responseText); // No need to clean message - SillyTavern auto-hides tags if (foundSpotifyUrl && extensionSettings.enableSpotifyMusic) { // 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, genId); // Update FAB widgets and strip widgets after separate/external mode update completes setFabLoadingState(false); updateFabWidgets(); updateStripWidgets(); }, 500); } } // Reset the awaiting flag after processing the message setIsAwaitingNewMessage(false); // Reset the swipe flag after generation completes // This ensures that if the user swiped → auto-reply generated → flag is now cleared // so the next user message will be treated as a new message (not a swipe) if (lastActionWasSwipe) { // console.log('[RPG Companion] 🔄 Generation complete after swipe - resetting lastActionWasSwipe to false'); setLastActionWasSwipe(false); } // Clear plot progression flag if this was a plot progression generation // Note: No need to clear extension prompt since we used quiet_prompt option if (isPlotProgression) { setIsPlotProgression(false); // console.log('[RPG Companion] Plot progression generation completed'); } // Stop FAB loading state and update widgets setFabLoadingState(false); updateFabWidgets(); updateStripWidgets(); // Re-apply checkpoint in case SillyTavern unhid messages during generation await restoreCheckpointOnLoad(); } /** * Event handler for character change. */ export function onCharacterChanged() { // Remove thought panel and icon when changing characters $('#rpg-thought-panel').remove(); $('#rpg-thought-icon').remove(); $('.rpg-inline-thoughts, .rpg-inline-thought').remove(); $('#chat').off('scroll.thoughtPanel'); $(window).off('resize.thoughtPanel'); $(document).off('click.thoughtPanel'); // Auto-switch to the preset associated with this character/group (if any) const presetSwitched = autoSwitchPresetForEntity(); // if (presetSwitched) { // console.log('[RPG Companion] Auto-switched preset for character'); // } // 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. // commitTrackerData() will be called naturally when new messages arrive. // Re-render with the loaded data and retry once SillyTavern finishes restoring chat state. rerenderRpgState(); scheduleChatStateRehydration(); // Update checkpoint indicators for the loaded chat updateAllCheckpointIndicators(); } /** * Event handler for when a message is swiped. * Loads the RPG data for the swipe the user navigated to. */ export function onMessageSwiped(messageIndex) { if (!extensionSettings.enabled) { return; } // console.log('[RPG Companion] đŸ”ĩ EVENT: onMessageSwiped at index:', messageIndex); // Get the message that was swiped const message = chat[messageIndex]; if (!message || message.is_user) { // console.log('[RPG Companion] đŸ”ĩ Ignoring swipe - message is user or undefined'); return; } 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 // Check if the swipe already exists (has content in the swipes array) const isExistingSwipe = message.swipes && message.swipes[currentSwipeId] !== undefined && message.swipes[currentSwipeId] !== null && message.swipes[currentSwipeId].length > 0; const swipeData = getSwipeData(message, currentSwipeId); const isPendingNewSwipe = currentSwipeId >= swipeCount; if (!isExistingSwipe) { // 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 // console.log('[RPG Companion] đŸ”ĩ EXISTING swipe navigation - lastActionWasSwipe unchanged =', lastActionWasSwipe); } if (isPendingNewSwipe) { lastGeneratedData.characterThoughts = null; } // console.log('[RPG Companion] Loading data for swipe', currentSwipeId); // Load saved swipe data for the active swipe only. // Using the current-swipe helper here avoids falling back to another // stored swipe payload and showing stale tracker state. if (swipeData) { // Load swipe data into lastGeneratedData for display (both modes) lastGeneratedData.userStats = swipeData.userStats || null; lastGeneratedData.infoBox = swipeData.infoBox || null; // Normalize characterThoughts to string format (for backward compatibility 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; } // Sync extensionSettings.userStats so stat bars reflect this swipe if (swipeData.userStats) { parseUserStats(swipeData.userStats); } // console.log('[RPG Companion] 🔄 Loaded swipe data for swipe:', currentSwipeId); } else { // console.log('[RPG Companion] â„šī¸ No stored data for swipe:', currentSwipeId); } // Re-render the panels renderUserStats(); renderInfoBox(); renderThoughts(); renderInventory(); renderQuests(); renderMusicPlayer($musicPlayerContainer[0]); // Update widget strips with the newly loaded swipe data updateFabWidgets(); updateStripWidgets(); // Update chat thought overlays updateChatThoughts(); } export function onMessageDeleted() { if (!extensionSettings.enabled) { return; } // 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 || []; let lastAssistantIndex = -1; for (let i = currentChat.length - 1; i >= 0; i--) { if (!currentChat[i].is_user && !currentChat[i].is_system) { lastAssistantIndex = i; break; } } syncDisplayedTrackerStateFromChat(); // After the display state has been rebuilt, restore generation context from // the assistant message immediately before the new tail message so the next // generation uses the correct N-1 tracker state. if (lastAssistantIndex !== -1) { commitTrackerDataFromPriorMessage(lastAssistantIndex); } saveChatData(); } /** * Update the persona avatar image when user switches personas */ export function updatePersonaAvatar() { const portraitImg = document.querySelector('.rpg-user-portrait'); if (!portraitImg) { // console.log('[RPG Companion] Portrait image element not found in DOM'); return; } // Get current user_avatar from context instead of using imported value const context = getContext(); const currentUserAvatar = context.user_avatar || user_avatar; // console.log('[RPG Companion] Attempting to update persona avatar:', currentUserAvatar); // Try to get a valid thumbnail URL using our safe helper if (currentUserAvatar) { const thumbnailUrl = getSafeThumbnailUrl('persona', currentUserAvatar); if (thumbnailUrl) { // Only update the src if we got a valid URL portraitImg.src = thumbnailUrl; // console.log('[RPG Companion] Persona avatar updated successfully'); } else { // Don't update the src if we couldn't get a valid URL // This prevents 400 errors and keeps the existing image // console.warn('[RPG Companion] Could not get valid thumbnail URL for persona avatar, keeping existing image'); } } else { // console.log('[RPG Companion] No user avatar configured, keeping existing image'); } } /** * Clears all extension prompts. */ export function clearExtensionPrompts() { setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-dialogue-coloring', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-spotify', '', extension_prompt_types.IN_CHAT, 0, false); setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); // Note: rpg-companion-plot is not cleared here since it's passed via quiet_prompt option // console.log('[RPG Companion] Cleared all extension prompts'); } /** * Event handler for when generation stops or ends * Re-applies checkpoint if SillyTavern unhid messages */ export async function onGenerationEnded() { // console.log('[RPG Companion] 🏁 onGenerationEnded called'); // Note: isGenerating flag is cleared in onMessageReceived after parsing (together mode) // or in apiClient.js after separate generation completes (separate mode) // SillyTavern may auto-unhide messages when generation stops // Re-apply checkpoint if one exists await restoreCheckpointOnLoad(); } /** * Initialize history injection event listeners. * Should be called once during extension initialization. */ export function initHistoryInjection() { initHistoryInjectionListeners(); }