Files
rpg-companion-sillytavern/src/systems/integration/sillytavern.js
T
2026-02-25 19:32:54 -04:00

644 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SillyTavern Integration Module
* Handles all event listeners and integration with SillyTavern's event system
*/
import { getContext } from '../../../../../../extensions.js';
import { chat, user_avatar, setExtensionPrompt, extension_prompt_types, saveChatDebounced } 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, getSwipeData, commitTrackerDataFromPriorMessage, inheritSwipeDataFromPriorMessage, mirrorToSwipeInfo } 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';
/**
* 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;
// Normalise 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;
committedTrackerData.characterThoughts = swipeData.characterThoughts || null;
} 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;
}
}
}
/**
* 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
// For separate mode with auto-update disabled, commit from the prior assistant message's
// swipe store rather than lastGeneratedData to avoid ghost context from sibling swipes.
// At this point chat[chat.length - 1] is the user message, so search from before it.
if (extensionSettings.generationMode === 'separate' && !extensionSettings.autoUpdate) {
commitTrackerDataFromPriorMessage(chat.length - 1);
// console.log('[RPG Companion] 💾 SEPARATE MODE: Committed from prior assistant message (auto-update disabled)');
}
}
/**
* 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 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 = lastMessage.swipe_id || 0;
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);
// 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: <trackers> XML tags are automatically hidden by SillyTavern
// Note: <Song - Artist/> 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');
// 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 <Song - Artist/> 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
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();
$('#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
renderUserStats();
renderInfoBox();
renderThoughts();
renderInventory();
renderQuests();
renderMusicPlayer($musicPlayerContainer[0]);
// Update FAB widgets and strip widgets with loaded data
updateFabWidgets();
updateStripWidgets();
// Update chat thought overlays
updateChatThoughts();
// 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 = message.swipe_id || 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;
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);
}
// console.log('[RPG Companion] Loading data for swipe', 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;
// 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();
}
/**
* 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
*/
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();
}