diff --git a/index.js b/index.js index 41ff267..a0b696d 100644 --- a/index.js +++ b/index.js @@ -3,331 +3,145 @@ import { eventSource, event_types, substituteParams, chat, generateRaw, saveSett import { selected_group, getGroupMembers } from '../../../group-chats.js'; import { power_user } from '../../../power-user.js'; -const extensionName = 'third-party/rpg-companion-sillytavern'; +// Core modules +import { extensionName, extensionFolderPath } from './src/core/config.js'; +import { + extensionSettings, + lastGeneratedData, + committedTrackerData, + lastActionWasSwipe, + isGenerating, + isPlotProgression, + pendingDiceRoll, + FALLBACK_AVATAR_DATA_URI, + $panelContainer, + $userStatsContainer, + $infoBoxContainer, + $thoughtsContainer, + $inventoryContainer, + setExtensionSettings, + updateExtensionSettings, + setLastGeneratedData, + updateLastGeneratedData, + setCommittedTrackerData, + updateCommittedTrackerData, + setLastActionWasSwipe, + setIsGenerating, + setIsPlotProgression, + setPendingDiceRoll, + setPanelContainer, + setUserStatsContainer, + setInfoBoxContainer, + setThoughtsContainer, + setInventoryContainer +} from './src/core/state.js'; +import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js'; +import { registerAllEvents } from './src/core/events.js'; -// Dynamically determine extension path based on current location -// This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations -const currentScriptPath = import.meta.url; -const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\data\\'); -const extensionFolderPath = isUserExtension - ? `data/default-user/extensions/${extensionName}` - : `scripts/extensions/${extensionName}`; +// Generation & Parsing modules +import { + generateTrackerExample, + generateTrackerInstructions, + generateContextualSummary, + generateRPGPromptText, + generateSeparateUpdatePrompt +} from './src/systems/generation/promptBuilder.js'; +import { parseResponse, parseUserStats } from './src/systems/generation/parser.js'; +import { updateRPGData } from './src/systems/generation/apiClient.js'; +import { onGenerationStarted } from './src/systems/generation/injector.js'; -let extensionSettings = { - enabled: true, - autoUpdate: true, - updateDepth: 4, // How many messages to include in the context - generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately - showUserStats: true, - showInfoBox: true, - showCharacterThoughts: true, - showThoughtsInChat: true, // Show thoughts overlay in chat - enableHtmlPrompt: false, // Enable immersive HTML prompt injection - enablePlotButtons: true, // Show plot progression buttons above chat input - panelPosition: 'right', // 'left', 'right', or 'top' - theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom - customColors: { - bg: '#1a1a2e', - accent: '#16213e', - text: '#eaeaea', - highlight: '#e94560' - }, - statBarColorLow: '#cc3333', // Color for low stat values (red) - statBarColorHigh: '#33cc66', // Color for high stat values (green) - enableAnimations: true, // Enable smooth animations for stats and content updates - mobileFabPosition: { - top: 'calc(var(--topBarBlockSize) + 60px)', - right: '12px' - }, // Saved position for mobile FAB button - userStats: { - health: 100, - satiety: 100, - energy: 100, - hygiene: 100, - arousal: 0, - mood: '😐', - conditions: 'None', - inventory: 'None' - }, - classicStats: { - str: 10, - dex: 10, - con: 10, - int: 10, - wis: 10, - cha: 10 - }, - lastDiceRoll: null // Store last dice roll result -}; +// Rendering modules +import { getSafeThumbnailUrl } from './src/utils/avatars.js'; +import { renderUserStats } from './src/systems/rendering/userStats.js'; +import { renderInfoBox, updateInfoBoxField } from './src/systems/rendering/infoBox.js'; +import { + renderThoughts, + updateCharacterField, + updateChatThoughts, + createThoughtPanel +} from './src/systems/rendering/thoughts.js'; +import { renderInventory } from './src/systems/rendering/inventory.js'; -let lastGeneratedData = { - userStats: null, - infoBox: null, - characterThoughts: null, - html: null -}; +// Interaction modules +import { initInventoryEventListeners } from './src/systems/interaction/inventoryActions.js'; -// Tracks the "committed" tracker data that should be used as source for next generation -// This gets updated when user sends a new message or first time generation -let committedTrackerData = { - userStats: null, - infoBox: null, - characterThoughts: null -}; +// UI Systems modules +import { + applyTheme, + applyCustomTheme, + toggleCustomColors, + toggleAnimations, + updateSettingsPopupTheme, + applyCustomThemeToSettingsPopup +} from './src/systems/ui/theme.js'; +import { + DiceModal, + SettingsModal, + setupDiceRoller, + setupSettingsPopup, + updateDiceDisplay, + addDiceQuickReply, + getSettingsModal +} from './src/systems/ui/modals.js'; +import { + togglePlotButtons, + updateCollapseToggleIcon, + setupCollapseToggle, + updatePanelVisibility, + updateSectionVisibility, + applyPanelPosition, + updateGenerationModeUI +} from './src/systems/ui/layout.js'; +import { + setupMobileToggle, + constrainFabToViewport, + setupMobileTabs, + removeMobileTabs, + setupMobileKeyboardHandling, + setupContentEditableScrolling +} from './src/systems/ui/mobile.js'; +import { + setupDesktopTabs, + removeDesktopTabs +} from './src/systems/ui/desktop.js'; -// Tracks whether the last action was a swipe (for separate mode) -// Used to determine whether to commit lastGeneratedData to committedTrackerData -let lastActionWasSwipe = false; +// Feature modules +import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; +import { setupClassicStatsButtons } from './src/systems/features/classicStats.js'; +import { ensureHtmlCleaningRegex } from './src/systems/features/htmlCleaning.js'; -let isGenerating = false; +// Integration modules +import { + commitTrackerData, + onMessageSent, + onMessageReceived, + onCharacterChanged, + onMessageSwiped, + updatePersonaAvatar, + clearExtensionPrompts +} from './src/systems/integration/sillytavern.js'; -// Tracks if we're currently doing a plot progression -let isPlotProgression = false; +// Old state variable declarations removed - now imported from core modules +// (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js) -// Temporary storage for pending dice roll (not saved until user clicks "Save Roll") -let pendingDiceRoll = null; +// Utility functions removed - now imported from src/utils/avatars.js +// (getSafeThumbnailUrl) -// Fallback avatar image (base64-encoded SVG with "?" icon) -// Using base64 to avoid quote-encoding issues in HTML attributes -const FALLBACK_AVATAR_DATA_URI = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2NjY2NjYyIgb3BhY2l0eT0iMC4zIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjNjY2IiBmb250LXNpemU9IjQwIj4/PC90ZXh0Pjwvc3ZnPg=='; +// Persistence functions removed - now imported from src/core/persistence.js +// (loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData) -// UI Elements -let $panelContainer = null; -let $userStatsContainer = null; -let $infoBoxContainer = null; -let $thoughtsContainer = null; +// Theme functions removed - now imported from src/systems/ui/theme.js +// (applyTheme, applyCustomTheme, toggleCustomColors, toggleAnimations, +// updateSettingsPopupTheme, applyCustomThemeToSettingsPopup) -/** - * Safely attempts to get a thumbnail URL with proper error handling. - * Returns null if the URL cannot be generated to avoid 400 Bad Request errors. - * - * @param {string} type - The type of thumbnail ('persona' or 'avatar') - * @param {string} filename - The filename to get thumbnail for - * @returns {string|null} - The thumbnail URL or null if it fails - */ -function getSafeThumbnailUrl(type, filename) { - // Return null if no filename provided - if (!filename || filename === 'none') { - console.log(`[RPG Companion] No valid filename provided for ${type} thumbnail`); - return null; - } +// Layout functions removed - now imported from src/systems/ui/layout.js +// (togglePlotButtons, updateCollapseToggleIcon, setupCollapseToggle, +// updatePanelVisibility, updateSectionVisibility, applyPanelPosition) +// Note: closeMobilePanelWithAnimation is only used internally by mobile.js - try { - // Attempt to get thumbnail URL from SillyTavern API - const url = getThumbnailUrl(type, filename); - - // Validate that we got a string back - if (typeof url !== 'string' || url.trim() === '') { - console.warn(`[RPG Companion] getThumbnailUrl returned invalid result for ${type}:`, filename); - return null; - } - - console.log(`[RPG Companion] Successfully generated ${type} thumbnail URL for: ${filename}`); - return url; - } catch (error) { - // Log detailed error information for debugging - console.error(`[RPG Companion] Failed to get ${type} thumbnail for "${filename}":`, error); - console.error('[RPG Companion] Error details:', { - type, - filename, - errorMessage: error.message, - errorStack: error.stack - }); - return null; - } -} - -/** - * Loads the extension settings from the global settings object. - */ -function loadSettings() { - if (power_user.extensions && power_user.extensions[extensionName]) { - Object.assign(extensionSettings, power_user.extensions[extensionName]); - // console.log('[RPG Companion] Settings loaded:', extensionSettings); - } else { - // console.log('[RPG Companion] No saved settings found, using defaults'); - } -} - -/** - * Saves the extension settings to the global settings object. - */ -function saveSettings() { - if (!power_user.extensions) { - power_user.extensions = {}; - } - power_user.extensions[extensionName] = extensionSettings; - saveSettingsDebounced(); -} - -/** - * Saves RPG data to the current chat's metadata. - */ -function saveChatData() { - if (!chat_metadata) { - return; - } - - chat_metadata.rpg_companion = { - userStats: extensionSettings.userStats, - classicStats: extensionSettings.classicStats, - lastGeneratedData: lastGeneratedData, - timestamp: Date.now() - }; - - saveChatDebounced(); -} - -/** - * 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. - */ -function updateMessageSwipeData() { - 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) { - // Found last assistant message - update its swipe data - if (!message.extra) { - message.extra = {}; - } - if (!message.extra.rpg_companion_swipes) { - message.extra.rpg_companion_swipes = {}; - } - - const swipeId = message.swipe_id || 0; - message.extra.rpg_companion_swipes[swipeId] = { - userStats: lastGeneratedData.userStats, - infoBox: lastGeneratedData.infoBox, - characterThoughts: lastGeneratedData.characterThoughts - }; - - // console.log('[RPG Companion] Updated message swipe data after user edit'); - break; - } - } -} - -/** - * Loads RPG data from the current chat's metadata. - */ -function loadChatData() { - if (!chat_metadata || !chat_metadata.rpg_companion) { - // Reset to defaults if no data exists - extensionSettings.userStats = { - health: 100, - satiety: 100, - energy: 100, - hygiene: 100, - arousal: 0, - mood: '😐', - conditions: 'None', - inventory: 'None' - }; - lastGeneratedData = { - userStats: null, - infoBox: null, - characterThoughts: null, - html: null - }; - return; - } - - const savedData = chat_metadata.rpg_companion; - - // Restore stats - if (savedData.userStats) { - extensionSettings.userStats = { ...savedData.userStats }; - } - - // Restore classic stats - if (savedData.classicStats) { - extensionSettings.classicStats = { ...savedData.classicStats }; - } - - // Restore last generated data - if (savedData.lastGeneratedData) { - lastGeneratedData = { ...savedData.lastGeneratedData }; - } - - // console.log('[RPG Companion] Loaded chat data:', savedData); -} - -/** - * Applies the selected theme to the panel. - */ -function applyTheme() { - if (!$panelContainer) return; - - const theme = extensionSettings.theme; - - // Remove all theme attributes first - $panelContainer.removeAttr('data-theme'); - - // Clear any inline CSS variable overrides - $panelContainer.css({ - '--rpg-bg': '', - '--rpg-accent': '', - '--rpg-text': '', - '--rpg-highlight': '', - '--rpg-border': '', - '--rpg-shadow': '' - }); - - // Apply the selected theme - if (theme === 'custom') { - applyCustomTheme(); - } else if (theme !== 'default') { - // For non-default themes, set the data-theme attribute - // which will trigger the CSS theme rules - $panelContainer.attr('data-theme', theme); - } - // For 'default', we do nothing - it will use the CSS variables from .rpg-panel class - // which fall back to SillyTavern's theme variables -} - -/** - * Applies custom colors when custom theme is selected. - */ -function applyCustomTheme() { - if (!$panelContainer) return; - - const colors = extensionSettings.customColors; - - // Apply custom CSS variables as inline styles - $panelContainer.css({ - '--rpg-bg': colors.bg, - '--rpg-accent': colors.accent, - '--rpg-text': colors.text, - '--rpg-highlight': colors.highlight, - '--rpg-border': colors.highlight, - '--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow - }); -} - -/** - * Toggles visibility of custom color pickers. - */ -function toggleCustomColors() { - const isCustom = extensionSettings.theme === 'custom'; - $('#rpg-custom-colors').toggle(isCustom); -} - -/** - * Toggles animations on/off by adding/removing a class to the panel. - */ -function toggleAnimations() { - if (extensionSettings.enableAnimations) { - $panelContainer.addClass('rpg-animations-enabled'); - } else { - $panelContainer.removeClass('rpg-animations-enabled'); - } -} +// Mobile UI functions removed - now imported from src/systems/ui/mobile.js +// (setupMobileToggle, constrainFabToViewport, setupMobileTabs, removeMobileTabs, +// setupMobileKeyboardHandling, setupContentEditableScrolling) /** * Adds the extension settings to the Extensions tab. @@ -371,11 +185,12 @@ async function initUI() { `; $('body').append(mobileToggleHtml); - // Cache UI elements - $panelContainer = $('#rpg-companion-panel'); - $userStatsContainer = $('#rpg-user-stats'); - $infoBoxContainer = $('#rpg-info-box'); - $thoughtsContainer = $('#rpg-thoughts'); + // Cache UI elements using state setters + setPanelContainer($('#rpg-companion-panel')); + setUserStatsContainer($('#rpg-user-stats')); + setInfoBoxContainer($('#rpg-info-box')); + setThoughtsContainer($('#rpg-thoughts')); + setInventoryContainer($('#rpg-inventory')); // Set up event listeners (enable/disable is handled in Extensions tab) $('#rpg-toggle-auto-update').on('change', function() { @@ -421,6 +236,12 @@ async function initUI() { updateSectionVisibility(); }); + $('#rpg-toggle-inventory').on('change', function() { + extensionSettings.showInventory = $(this).prop('checked'); + saveSettings(); + updateSectionVisibility(); + }); + $('#rpg-toggle-thoughts-in-chat').on('change', function() { extensionSettings.showThoughtsInChat = $(this).prop('checked'); // console.log('[RPG Companion] Toggle showThoughtsInChat changed to:', extensionSettings.showThoughtsInChat); @@ -452,7 +273,7 @@ async function initUI() { // console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.'); return; } - await updateRPGData(); + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); }); $('#rpg-stat-bar-color-low').on('change', function() { @@ -473,7 +294,7 @@ async function initUI() { saveSettings(); applyTheme(); toggleCustomColors(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Recreate thought bubbles with new theme }); @@ -483,7 +304,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -493,7 +314,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -503,7 +324,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -513,7 +334,7 @@ async function initUI() { saveSettings(); if (extensionSettings.theme === 'custom') { applyCustomTheme(); - updateSettingsPopupTheme(); // Update popup theme instantly + updateSettingsPopupTheme(getSettingsModal()); // Update popup theme instantly updateChatThoughts(); // Update thought bubbles } }); @@ -526,6 +347,7 @@ async function initUI() { $('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats); $('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox); $('#rpg-toggle-thoughts').prop('checked', extensionSettings.showCharacterThoughts); + $('#rpg-toggle-inventory').prop('checked', extensionSettings.showInventory); $('#rpg-toggle-thoughts-in-chat').prop('checked', extensionSettings.showThoughtsInChat); $('#rpg-toggle-html-prompt').prop('checked', extensionSettings.enableHtmlPrompt); $('#rpg-toggle-plot-buttons').prop('checked', extensionSettings.enablePlotButtons); @@ -550,6 +372,11 @@ async function initUI() { // Setup mobile toggle button setupMobileToggle(); + // Setup desktop tabs (only on desktop viewport) + if (window.innerWidth > 1000) { + setupDesktopTabs(); + } + // Setup collapse/expand toggle button setupCollapseToggle(); @@ -557,4130 +384,35 @@ async function initUI() { renderUserStats(); renderInfoBox(); renderThoughts(); + renderInventory(); updateDiceDisplay(); setupDiceRoller(); setupClassicStatsButtons(); setupSettingsPopup(); addDiceQuickReply(); - setupPlotButtons(); + setupPlotButtons(sendPlotProgression); setupMobileKeyboardHandling(); setupContentEditableScrolling(); + initInventoryEventListeners(); } -/** - * Sets up the plot progression buttons inside the send form area. - */ -function setupPlotButtons() { - // Remove existing buttons if any - $('#rpg-plot-buttons').remove(); - // Create wrapper if it doesn't exist (shared with other extensions like Spotify) - if ($('#extension-buttons-wrapper').length === 0) { - $('#send_form').prepend('
'); - } - // Create the button container - const buttonHtml = ` - - `; - // Insert into the wrapper - $('#extension-buttons-wrapper').append(buttonHtml); - // Add event handlers for buttons - $('#rpg-plot-random').on('click', () => sendPlotProgression('random')); - $('#rpg-plot-natural').on('click', () => sendPlotProgression('natural')); +// Rendering functions removed - now imported from src/systems/rendering/* +// (renderUserStats, renderInfoBox, renderThoughts, updateInfoBoxField, +// updateCharacterField, updateChatThoughts, createThoughtPanel) - // Show/hide based on setting - togglePlotButtons(); -}/** - * Toggles the visibility of plot buttons based on settings. - */ -function togglePlotButtons() { - if (extensionSettings.enablePlotButtons && extensionSettings.enabled) { - $('#rpg-plot-buttons').show(); - } else { - $('#rpg-plot-buttons').hide(); - } -} +// Event handlers removed - now imported from src/systems/integration/sillytavern.js +// (commitTrackerData, onMessageSent, onMessageReceived, onCharacterChanged, +// onMessageSwiped, updatePersonaAvatar, clearExtensionPrompts) -/** - * Sends a plot progression request and appends the result to the last message. - * @param {string} type - 'random' or 'natural' - */ -async function sendPlotProgression(type) { - if (!extensionSettings.enabled) { - // console.log('[RPG Companion] Extension is disabled'); - return; - } - // Disable buttons to prevent multiple clicks - $('#rpg-plot-random, #rpg-plot-natural').prop('disabled', true).css('opacity', '0.5'); - // Store original enabled state and temporarily disable extension - // This prevents RPG tracker instructions from being injected during plot progression - const wasEnabled = extensionSettings.enabled; - extensionSettings.enabled = false; - try { - // console.log(`[RPG Companion] Sending ${type} plot progression request...`); - // Build the prompt based on type - let prompt = ''; - if (type === 'random') { - prompt = 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.'; - } else { - prompt = 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.'; - } - // Add HTML prompt if enabled - if (extensionSettings.enableHtmlPrompt) { - prompt += '\n\n' + `If appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: -- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. -- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. -- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. -- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. -- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; - } - - // Set flag to indicate we're doing plot progression - // This will be used by onMessageReceived to clear the prompt after generation completes - isPlotProgression = true; - - // console.log('[RPG Companion] Calling Generate with continuation and plot prompt'); - // console.log('[RPG Companion] Full prompt:', prompt); - - // Pass the prompt via options with the correct property name - // Based on /continue slash command implementation, it uses quiet_prompt (underscore, not camelCase) - const options = { - quiet_prompt: prompt, // Use underscore notation, not camelCase - quietToLoud: true - }; - - // Call Generate with 'continue' type and our custom prompt - await Generate('continue', options); - - // console.log('[RPG Companion] Plot progression generation triggered'); - } catch (error) { - console.error('[RPG Companion] Error sending plot progression:', error); - isPlotProgression = false; - } finally { - // Restore original enabled state and re-enable buttons after a delay - setTimeout(() => { - extensionSettings.enabled = wasEnabled; - $('#rpg-plot-random, #rpg-plot-natural').prop('disabled', false).css('opacity', '1'); - }, 1000); - } -} - -/** - * Modern DiceModal ES6 Class - * Manages dice roller modal with proper state management and CSS classes - */ -class DiceModal { - constructor() { - this.modal = document.getElementById('rpg-dice-popup'); - this.animation = document.getElementById('rpg-dice-animation'); - this.result = document.getElementById('rpg-dice-result'); - this.resultValue = document.getElementById('rpg-dice-result-value'); - this.resultDetails = document.getElementById('rpg-dice-result-details'); - this.rollBtn = document.getElementById('rpg-dice-roll-btn'); - - this.state = 'IDLE'; // IDLE, ROLLING, SHOWING_RESULT - this.isAnimating = false; - } - - /** - * Opens the modal with proper animation - */ - open() { - if (this.isAnimating) return; - - // Apply theme - const theme = extensionSettings.theme; - this.modal.setAttribute('data-theme', theme); - - // Apply custom theme if needed - if (theme === 'custom') { - this._applyCustomTheme(); - } - - // Reset to initial state - this._setState('IDLE'); - - // Open modal with CSS class - this.modal.classList.add('is-open'); - this.modal.classList.remove('is-closing'); - - // Focus management - this.modal.querySelector('#rpg-dice-popup-close')?.focus(); - } - - /** - * Closes the modal with animation - */ - close() { - if (this.isAnimating) return; - - this.isAnimating = true; - this.modal.classList.add('is-closing'); - this.modal.classList.remove('is-open'); - - // Wait for animation to complete - setTimeout(() => { - this.modal.classList.remove('is-closing'); - this.isAnimating = false; - - // Clear pending roll - pendingDiceRoll = null; - }, 200); - } - - /** - * Starts the rolling animation - */ - startRolling() { - this._setState('ROLLING'); - } - - /** - * Shows the result - * @param {number} total - The total roll value - * @param {Array} rolls - Individual roll values - */ - showResult(total, rolls) { - this._setState('SHOWING_RESULT'); - - // Update result values - this.resultValue.textContent = total; - this.resultValue.classList.add('is-animating'); - - // Remove animation class after it completes - setTimeout(() => { - this.resultValue.classList.remove('is-animating'); - }, 500); - - // Show details if multiple rolls - if (rolls && rolls.length > 1) { - this.resultDetails.textContent = `Rolls: ${rolls.join(', ')}`; - } else { - this.resultDetails.textContent = ''; - } - } - - /** - * Manages modal state changes - * @private - */ - _setState(newState) { - this.state = newState; - - switch (newState) { - case 'IDLE': - this.rollBtn.hidden = false; - this.animation.hidden = true; - this.result.hidden = true; - break; - - case 'ROLLING': - this.rollBtn.hidden = true; - this.animation.hidden = false; - this.result.hidden = true; - this.animation.setAttribute('aria-busy', 'true'); - break; - - case 'SHOWING_RESULT': - this.rollBtn.hidden = true; - this.animation.hidden = true; - this.result.hidden = false; - this.animation.setAttribute('aria-busy', 'false'); - break; - } - } - - /** - * Applies custom theme colors - * @private - */ - _applyCustomTheme() { - const content = this.modal.querySelector('.rpg-dice-popup-content'); - if (content && extensionSettings.customColors) { - content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); - content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); - content.style.setProperty('--rpg-text', extensionSettings.customColors.text); - content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); - } - } -} - -// Global instance -let diceModal = null; - -/** - * Sets up the dice roller functionality. - */ -function setupDiceRoller() { - // Initialize DiceModal instance - diceModal = new DiceModal(); - // Click dice display to open popup - $('#rpg-dice-display').on('click', function() { - openDicePopup(); - }); - - // Close popup - handle both close button and backdrop clicks - $('#rpg-dice-popup-close').on('click', function() { - closeDicePopup(); - }); - - // Close on backdrop click (clicking outside content) - $('#rpg-dice-popup').on('click', function(e) { - if (e.target === this) { - closeDicePopup(); - } - }); - - // Roll dice button - $('#rpg-dice-roll-btn').on('click', async function() { - await rollDice(); - }); - - // Save roll button (closes popup and saves the roll) - $('#rpg-dice-save-btn').on('click', function() { - // Save the pending roll - if (pendingDiceRoll) { - extensionSettings.lastDiceRoll = pendingDiceRoll; - saveSettings(); - updateDiceDisplay(); - pendingDiceRoll = null; - } - closeDicePopup(); - }); - - // Reset on Enter key - $('#rpg-dice-count, #rpg-dice-sides').on('keypress', function(e) { - if (e.which === 13) { - rollDice(); - } - }); - - // Clear dice roll button - $('#rpg-clear-dice').on('click', function(e) { - e.stopPropagation(); // Prevent opening the dice popup - clearDiceRoll(); - }); -} - -/** - * Clears the last dice roll. - */ -function clearDiceRoll() { - extensionSettings.lastDiceRoll = null; - saveSettings(); - updateDiceDisplay(); -} - -/** - * Opens the dice rolling popup. - * Backwards compatible wrapper for DiceModal class. - */ -function openDicePopup() { - if (diceModal) { - diceModal.open(); - } -} - -/** - * Closes the dice rolling popup. - * Backwards compatible wrapper for DiceModal class. - */ -function closeDicePopup() { - if (diceModal) { - diceModal.close(); - } -} - -/** - * @deprecated Legacy function - use diceModal._applyCustomTheme() instead - */ -function applyCustomThemeToPopup() { - if (diceModal) { - diceModal._applyCustomTheme(); - } -} - -/** - * Rolls the dice and displays result. - * Refactored to use DiceModal class. - */ -async function rollDice() { - if (!diceModal) return; - - const count = parseInt(String($('#rpg-dice-count').val())) || 1; - const sides = parseInt(String($('#rpg-dice-sides').val())) || 20; - - // Start rolling animation - diceModal.startRolling(); - - // Wait for animation (simulate rolling) - await new Promise(resolve => setTimeout(resolve, 1200)); - - // Execute /roll command - const rollCommand = `/roll ${count}d${sides}`; - const rollResult = await executeRollCommand(rollCommand); - - // Parse result - const total = rollResult.total || 0; - const rolls = rollResult.rolls || []; - - // Store result temporarily (not saved until "Save Roll" is clicked) - pendingDiceRoll = { - formula: `${count}d${sides}`, - total: total, - rolls: rolls, - timestamp: Date.now() - }; - - // Show result - diceModal.showResult(total, rolls); - - // Don't update sidebar display yet - only update when user clicks "Save Roll" -} - -/** - * Executes a /roll command and returns the result. - */ -async function executeRollCommand(command) { - try { - // Parse the dice notation (e.g., "2d20") - const match = command.match(/(\d+)d(\d+)/); - if (!match) { - return { total: 0, rolls: [] }; - } - - const count = parseInt(match[1]); - const sides = parseInt(match[2]); - const rolls = []; - let total = 0; - - for (let i = 0; i < count; i++) { - const roll = Math.floor(Math.random() * sides) + 1; - rolls.push(roll); - total += roll; - } - - return { total, rolls }; - } catch (error) { - console.error('[RPG Companion] Error rolling dice:', error); - return { total: 0, rolls: [] }; - } -} - -/** - * Updates the dice display in the sidebar. - */ -function updateDiceDisplay() { - const lastRoll = extensionSettings.lastDiceRoll; - if (lastRoll) { - $('#rpg-last-roll-text').text(`Last Roll (${lastRoll.formula}): ${lastRoll.total}`); - } else { - $('#rpg-last-roll-text').text('Last Roll: None'); - } -} - -/** - * Adds the Roll Dice quick reply button. - */ -function addDiceQuickReply() { - // Create quick reply button if Quick Replies exist - if (window.quickReplyApi) { - // Quick Reply API integration would go here - // For now, the dice display in the sidebar serves as the button - } -} - -/** - * Sets up event listeners for classic stat +/- buttons using delegation. - * Uses delegated events to persist across re-renders of the stats section. - */ -function setupClassicStatsButtons() { - if (!$userStatsContainer) return; - - // Delegated event listener for increase buttons - $userStatsContainer.on('click', '.rpg-stat-increase', function() { - const stat = $(this).data('stat'); - if (extensionSettings.classicStats[stat] < 100) { - extensionSettings.classicStats[stat]++; - saveSettings(); - saveChatData(); - // Update only the specific stat value, not the entire stats panel - $(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]); - } - }); - - // Delegated event listener for decrease buttons - $userStatsContainer.on('click', '.rpg-stat-decrease', function() { - const stat = $(this).data('stat'); - if (extensionSettings.classicStats[stat] > 1) { - extensionSettings.classicStats[stat]--; - saveSettings(); - saveChatData(); - // Update only the specific stat value, not the entire stats panel - $(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]); - } - }); -} - -/** - * SettingsModal - Manages the settings popup modal - * Handles opening, closing, theming, and animations - */ -class SettingsModal { - constructor() { - this.modal = document.getElementById('rpg-settings-popup'); - this.content = this.modal?.querySelector('.rpg-settings-popup-content'); - this.isAnimating = false; - } - - /** - * Opens the modal with proper animation - */ - open() { - if (this.isAnimating || !this.modal) return; - - // Apply theme - const theme = extensionSettings.theme || 'default'; - this.modal.setAttribute('data-theme', theme); - - // Apply custom theme if needed - if (theme === 'custom') { - this._applyCustomTheme(); - } - - // Open modal with CSS class - this.modal.classList.add('is-open'); - this.modal.classList.remove('is-closing'); - - // Focus management - this.modal.querySelector('#rpg-close-settings')?.focus(); - } - - /** - * Closes the modal with animation - */ - close() { - if (this.isAnimating || !this.modal) return; - - this.isAnimating = true; - this.modal.classList.add('is-closing'); - this.modal.classList.remove('is-open'); - - // Wait for animation to complete - setTimeout(() => { - this.modal.classList.remove('is-closing'); - this.isAnimating = false; - }, 200); - } - - /** - * Updates the theme in real-time (used when theme selector changes) - */ - updateTheme() { - if (!this.modal) return; - - const theme = extensionSettings.theme || 'default'; - this.modal.setAttribute('data-theme', theme); - - if (theme === 'custom') { - this._applyCustomTheme(); - } else { - // Clear custom CSS variables to let theme CSS take over - this._clearCustomTheme(); - } - } - - /** - * Applies custom theme colors - * @private - */ - _applyCustomTheme() { - if (!this.content || !extensionSettings.customColors) return; - - this.content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); - this.content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); - this.content.style.setProperty('--rpg-text', extensionSettings.customColors.text); - this.content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); - } - - /** - * Clears custom theme colors - * @private - */ - _clearCustomTheme() { - if (!this.content) return; - - this.content.style.setProperty('--rpg-bg', ''); - this.content.style.setProperty('--rpg-accent', ''); - this.content.style.setProperty('--rpg-text', ''); - this.content.style.setProperty('--rpg-highlight', ''); - } -} - -// Global instance -let settingsModal = null; - -/** - * Opens the settings popup. - * Backwards compatible wrapper for SettingsModal class. - */ -function openSettingsPopup() { - if (settingsModal) { - settingsModal.open(); - } -} - -/** - * Closes the settings popup. - * Backwards compatible wrapper for SettingsModal class. - */ -function closeSettingsPopup() { - if (settingsModal) { - settingsModal.close(); - } -} - -/** - * Applies custom theme colors to the settings popup. - * Backwards compatible wrapper for SettingsModal class. - * @deprecated Use settingsModal.updateTheme() instead - */ -function applyCustomThemeToSettingsPopup() { - if (settingsModal) { - settingsModal._applyCustomTheme(); - } -} - -/** - * Updates the settings popup theme in real-time. - * Backwards compatible wrapper for SettingsModal class. - */ -function updateSettingsPopupTheme() { - if (settingsModal) { - settingsModal.updateTheme(); - } -} - -/** - * Sets up the settings popup functionality. - */ -function setupSettingsPopup() { - // Initialize SettingsModal instance - settingsModal = new SettingsModal(); - - // Open settings popup - $('#rpg-open-settings').on('click', function() { - openSettingsPopup(); - }); - - // Close settings popup - close button - $('#rpg-close-settings').on('click', function() { - closeSettingsPopup(); - }); - - // Close on backdrop click (clicking outside content) - $('#rpg-settings-popup').on('click', function(e) { - if (e.target === this) { - closeSettingsPopup(); - } - }); - - // Clear cache button - $('#rpg-clear-cache').on('click', function() { - // Clear the data - lastGeneratedData.userStats = null; - lastGeneratedData.infoBox = null; - lastGeneratedData.characterThoughts = null; - - // Clear committed tracker data (used for generation context) - committedTrackerData.userStats = null; - committedTrackerData.infoBox = null; - committedTrackerData.characterThoughts = null; - - // Clear all message swipe data - const chat = getContext().chat; - if (chat && chat.length > 0) { - for (let i = 0; i < chat.length; i++) { - const message = chat[i]; - if (message.extra && message.extra.rpg_companion_swipes) { - delete message.extra.rpg_companion_swipes; - // console.log('[RPG Companion] Cleared swipe data from message at index', i); - } - } - } - - // Clear the UI - if ($infoBoxContainer) { - $infoBoxContainer.empty(); - } - if ($thoughtsContainer) { - $thoughtsContainer.empty(); - } - - // Reset stats to defaults and re-render - extensionSettings.userStats = { - health: 100, - satiety: 100, - energy: 100, - hygiene: 100, - arousal: 0, - mood: '😐', - conditions: 'None', - inventory: 'None' - }; - - // Reset classic stats (attributes) to defaults - extensionSettings.classicStats = { - str: 10, - dex: 10, - con: 10, - int: 10, - wis: 10, - cha: 10 - }; - - // Clear dice roll - extensionSettings.lastDiceRoll = null; - - // Save everything - saveChatData(); - saveSettings(); - - // Re-render user stats and dice display - renderUserStats(); - updateDiceDisplay(); - updateChatThoughts(); // Clear the thought bubble in chat - - // console.log('[RPG Companion] Chat cache cleared'); - }); -} - -/** - * Helper function to close the mobile panel with animation. - */ -function closeMobilePanelWithAnimation() { - const $panel = $('#rpg-companion-panel'); - const $mobileToggle = $('#rpg-mobile-toggle'); - - // Add closing class to trigger slide-out animation - $panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing'); - $mobileToggle.removeClass('active'); - - // Wait for animation to complete before hiding - $panel.one('animationend', function() { - $panel.removeClass('rpg-mobile-closing'); - $('.rpg-mobile-overlay').remove(); - }); -} - -/** - * Sets up the mobile toggle button (FAB). - */ -function setupMobileToggle() { - const $mobileToggle = $('#rpg-mobile-toggle'); - const $panel = $('#rpg-companion-panel'); - const $overlay = $('
'); - - // DIAGNOSTIC: Check if elements exist and log setup state - console.log('[RPG Mobile] ========================================'); - console.log('[RPG Mobile] setupMobileToggle called'); - console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle); - console.log('[RPG Mobile] Panel exists:', $panel.length > 0); - console.log('[RPG Mobile] Window width:', window.innerWidth); - console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000); - console.log('[RPG Mobile] ========================================'); - - if ($mobileToggle.length === 0) { - console.error('[RPG Mobile] ERROR: Mobile toggle button not found in DOM!'); - console.error('[RPG Mobile] Cannot attach event handlers - button does not exist'); - return; // Exit early if button doesn't exist - } - - // Load and apply saved FAB position - if (extensionSettings.mobileFabPosition) { - const pos = extensionSettings.mobileFabPosition; - console.log('[RPG Mobile] Loading saved FAB position:', pos); - - // Apply saved position - if (pos.top) $mobileToggle.css('top', pos.top); - if (pos.right) $mobileToggle.css('right', pos.right); - if (pos.bottom) $mobileToggle.css('bottom', pos.bottom); - if (pos.left) $mobileToggle.css('left', pos.left); - - // Constrain to viewport after position is applied - requestAnimationFrame(() => constrainFabToViewport()); - } - - // Touch/drag state - let isDragging = false; - let touchStartTime = 0; - let touchStartX = 0; - let touchStartY = 0; - let buttonStartX = 0; - let buttonStartY = 0; - const LONG_PRESS_DURATION = 200; // ms to hold before enabling drag - const MOVE_THRESHOLD = 10; // px to move before enabling drag - let rafId = null; // RequestAnimationFrame ID for smooth updates - let pendingX = null; - let pendingY = null; - - // Update position using requestAnimationFrame for smooth rendering - function updateFabPosition() { - if (pendingX !== null && pendingY !== null) { - $mobileToggle.css({ - left: pendingX + 'px', - top: pendingY + 'px', - right: 'auto', - bottom: 'auto' - }); - pendingX = null; - pendingY = null; - } - rafId = null; - } - - // Touch start - begin tracking - $mobileToggle.on('touchstart', function(e) { - const touch = e.originalEvent.touches[0]; - - touchStartTime = Date.now(); - touchStartX = touch.clientX; - touchStartY = touch.clientY; - - const offset = $mobileToggle.offset(); - buttonStartX = offset.left; - buttonStartY = offset.top; - - isDragging = false; - }); - - // Touch move - check if should start dragging - $mobileToggle.on('touchmove', function(e) { - const touch = e.originalEvent.touches[0]; - const deltaX = touch.clientX - touchStartX; - const deltaY = touch.clientY - touchStartY; - const timeSinceStart = Date.now() - touchStartTime; - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Start dragging if held long enough OR moved far enough - if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { - isDragging = true; - $mobileToggle.addClass('dragging'); // Disable transitions while dragging - } - - if (isDragging) { - e.preventDefault(); // Prevent scrolling while dragging - - // Calculate new position - let newX = buttonStartX + deltaX; - let newY = buttonStartY + deltaY; - - // Get button dimensions - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); - - // Constrain to viewport with 10px padding - const minX = 10; - const maxX = window.innerWidth - buttonWidth - 10; - const minY = 10; - const maxY = window.innerHeight - buttonHeight - 10; - - newX = Math.max(minX, Math.min(maxX, newX)); - newY = Math.max(minY, Math.min(maxY, newY)); - - // Store pending position and request animation frame for smooth update - pendingX = newX; - pendingY = newY; - if (!rafId) { - rafId = requestAnimationFrame(updateFabPosition); - } - } - }); - - // Mouse drag support for desktop - let mouseDown = false; - - $mobileToggle.on('mousedown', function(e) { - // Prevent default to avoid text selection - e.preventDefault(); - - touchStartTime = Date.now(); - touchStartX = e.clientX; - touchStartY = e.clientY; - - const offset = $mobileToggle.offset(); - buttonStartX = offset.left; - buttonStartY = offset.top; - - isDragging = false; - mouseDown = true; - }); - - // Mouse move - only track if mouse is down - $(document).on('mousemove', function(e) { - if (!mouseDown) return; - - const deltaX = e.clientX - touchStartX; - const deltaY = e.clientY - touchStartY; - const timeSinceStart = Date.now() - touchStartTime; - const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - - // Start dragging if held long enough OR moved far enough - if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { - isDragging = true; - $mobileToggle.addClass('dragging'); // Disable transitions while dragging - } - - if (isDragging) { - e.preventDefault(); - - // Calculate new position - let newX = buttonStartX + deltaX; - let newY = buttonStartY + deltaY; - - // Get button dimensions - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); - - // Constrain to viewport with 10px padding - const minX = 10; - const maxX = window.innerWidth - buttonWidth - 10; - const minY = 10; - const maxY = window.innerHeight - buttonHeight - 10; - - newX = Math.max(minX, Math.min(maxX, newX)); - newY = Math.max(minY, Math.min(maxY, newY)); - - // Store pending position and request animation frame for smooth update - pendingX = newX; - pendingY = newY; - if (!rafId) { - rafId = requestAnimationFrame(updateFabPosition); - } - } - }); - - // Mouse up - save position or let click handler toggle - $(document).on('mouseup', function(e) { - if (!mouseDown) return; - - mouseDown = false; - - if (isDragging) { - // Was dragging - save new position - const offset = $mobileToggle.offset(); - const newPosition = { - left: offset.left + 'px', - top: offset.top + 'px' - }; - - extensionSettings.mobileFabPosition = newPosition; - saveSettings(); - - console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition); - - // Constrain to viewport bounds (now that position is saved) - setTimeout(() => constrainFabToViewport(), 10); - - // Re-enable transitions with smooth animation - setTimeout(() => { - $mobileToggle.removeClass('dragging'); - }, 50); - - isDragging = false; - - // Prevent click from firing after drag - e.preventDefault(); - e.stopPropagation(); - - // Add flag to prevent click handler from firing - $mobileToggle.data('just-dragged', true); - setTimeout(() => { - $mobileToggle.data('just-dragged', false); - }, 100); - } - // If not dragging, let the click handler toggle the panel - }); - - // Touch end - save position or toggle panel - $mobileToggle.on('touchend', function(e) { - // TEMPORARILY COMMENTED FOR DIAGNOSIS - might be blocking click fallback - // e.preventDefault(); - - if (isDragging) { - // Was dragging - save new position - const offset = $mobileToggle.offset(); - const newPosition = { - left: offset.left + 'px', - top: offset.top + 'px' - }; - - extensionSettings.mobileFabPosition = newPosition; - saveSettings(); - - console.log('[RPG Mobile] Saved new FAB position:', newPosition); - - // Constrain to viewport bounds (now that position is saved) - setTimeout(() => constrainFabToViewport(), 10); - - // Re-enable transitions with smooth animation - setTimeout(() => { - $mobileToggle.removeClass('dragging'); - }, 50); - - isDragging = false; - } else { - // Was a tap - toggle panel - console.log('[RPG Mobile] Quick tap detected - toggling panel'); - - if ($panel.hasClass('rpg-mobile-open')) { - // Close panel with animation - closeMobilePanelWithAnimation(); - } else { - // Open panel - $panel.addClass('rpg-mobile-open'); - $('body').append($overlay); - $mobileToggle.addClass('active'); - - // Close when clicking overlay - $overlay.on('click', function() { - closeMobilePanelWithAnimation(); - }); - } - } - }); - - // Click handler - works on both mobile and desktop - $mobileToggle.on('click', function(e) { - // Skip if we just finished dragging - if ($mobileToggle.data('just-dragged')) { - console.log('[RPG Mobile] Click blocked - just finished dragging'); - return; - } - - console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', { - windowWidth: window.innerWidth, - isMobileViewport: window.innerWidth <= 1000, - panelOpen: $panel.hasClass('rpg-mobile-open') - }); - - // Work on both mobile and desktop (removed viewport check) - if ($panel.hasClass('rpg-mobile-open')) { - console.log('[RPG Mobile] Click: Closing panel'); - closeMobilePanelWithAnimation(); - } else { - console.log('[RPG Mobile] Click: Opening panel'); - $panel.addClass('rpg-mobile-open'); - $('body').append($overlay); - $mobileToggle.addClass('active'); - - $overlay.on('click', function() { - console.log('[RPG Mobile] Overlay clicked - closing panel'); - closeMobilePanelWithAnimation(); - }); - } - }); - - // Handle viewport resize to manage desktop/mobile transitions - let wasMobile = window.innerWidth <= 1000; - let resizeTimer; - - $(window).on('resize', function() { - clearTimeout(resizeTimer); - - const isMobile = window.innerWidth <= 1000; - const $panel = $('#rpg-companion-panel'); - const $mobileToggle = $('#rpg-mobile-toggle'); - - // Transitioning from desktop to mobile - handle immediately for smooth transition - if (!wasMobile && isMobile) { - console.log('[RPG Mobile] Transitioning desktop -> mobile'); - - // Remove desktop positioning classes - $panel.removeClass('rpg-position-right rpg-position-left rpg-position-top'); - - // Clear collapsed state - mobile doesn't use collapse - $panel.removeClass('rpg-collapsed'); - - // Close panel on mobile with animation - closeMobilePanelWithAnimation(); - - // Clear any inline styles that might be overriding CSS - $panel.attr('style', ''); - - console.log('[RPG Mobile] After cleanup:', { - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('bottom'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - } - }); - - // Set up mobile tabs IMMEDIATELY (no debounce delay) - setupMobileTabs(); - - // Update icon for mobile state - updateCollapseToggleIcon(); - - wasMobile = isMobile; - return; - } - - // For mobile to desktop transition, use debounce - resizeTimer = setTimeout(function() { - const isMobile = window.innerWidth <= 1000; - - // Transitioning from mobile to desktop - if (wasMobile && !isMobile) { - // Disable transitions to prevent left→right slide animation - $panel.css('transition', 'none'); - - $panel.removeClass('rpg-mobile-open rpg-mobile-closing'); - $mobileToggle.removeClass('active'); - $('.rpg-mobile-overlay').remove(); - - // Restore desktop positioning class - const position = extensionSettings.panelPosition || 'right'; - $panel.addClass('rpg-position-' + position); - - // Remove mobile tabs structure - removeMobileTabs(); - - // Force reflow to apply position instantly - $panel[0].offsetHeight; - - // Re-enable transitions after positioned - setTimeout(function() { - $panel.css('transition', ''); - }, 50); - } - - wasMobile = isMobile; - - // Constrain FAB to viewport after resize (only if user has positioned it) - constrainFabToViewport(); - }, 150); // Debounce only for mobile→desktop - }); - - // Initialize mobile tabs if starting on mobile - const isMobile = window.innerWidth <= 1000; - if (isMobile) { - const $panel = $('#rpg-companion-panel'); - // Clear any inline styles - $panel.attr('style', ''); - - console.log('[RPG Mobile] Initial load on mobile viewport:', { - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('top'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - } - }); - setupMobileTabs(); - // Set initial icon for mobile - updateCollapseToggleIcon(); - } -} - -/** - * Constrains the mobile FAB button to viewport bounds with top-bar awareness. - * Only runs when button is in user-controlled state (mobileFabPosition exists). - * Ensures button never goes behind the top bar or outside viewport edges. - */ -function constrainFabToViewport() { - // Only constrain if user has set a custom position - if (!extensionSettings.mobileFabPosition) { - console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults'); - return; - } - - const $mobileToggle = $('#rpg-mobile-toggle'); - if ($mobileToggle.length === 0) return; - - // Skip if button is not visible - if (!$mobileToggle.is(':visible')) { - console.log('[RPG Mobile] Skipping viewport constraint - button not visible'); - return; - } - - // Get current position - const offset = $mobileToggle.offset(); - if (!offset) return; - - let currentX = offset.left; - let currentY = offset.top; - - const buttonWidth = $mobileToggle.outerWidth(); - const buttonHeight = $mobileToggle.outerHeight(); - - // Get top bar height from CSS variable (fallback to 50px if not set) - const topBarHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--topBarBlockSize')) || 50; - - // Calculate viewport bounds with padding - // Use top bar height + extra padding for top bound - const minX = 10; - const maxX = window.innerWidth - buttonWidth - 10; - const minY = topBarHeight + 60; // Top bar + extra space for visibility - const maxY = window.innerHeight - buttonHeight - 10; - - // Constrain to bounds - let newX = Math.max(minX, Math.min(maxX, currentX)); - let newY = Math.max(minY, Math.min(maxY, currentY)); - - // Only update if position changed - if (newX !== currentX || newY !== currentY) { - console.log('[RPG Mobile] Constraining FAB to viewport:', { - old: { x: currentX, y: currentY }, - new: { x: newX, y: newY }, - viewport: { width: window.innerWidth, height: window.innerHeight }, - topBarHeight - }); - - // Apply new position - $mobileToggle.css({ - left: newX + 'px', - top: newY + 'px', - right: 'auto', - bottom: 'auto' - }); - - // Save corrected position - extensionSettings.mobileFabPosition = { - left: newX + 'px', - top: newY + 'px' - }; - saveSettings(); - } -} - -/** - * Sets up mobile tab navigation for organizing content. - * Only runs on mobile viewports (<=1000px). - */ -function setupMobileTabs() { - const isMobile = window.innerWidth <= 1000; - if (!isMobile) return; - - // Check if tabs already exist - if ($('.rpg-mobile-tabs').length > 0) return; - - const $panel = $('#rpg-companion-panel'); - const $contentBox = $panel.find('.rpg-content-box'); - - // Get existing sections - const $userStats = $('#rpg-user-stats'); - const $infoBox = $('#rpg-info-box'); - const $thoughts = $('#rpg-thoughts'); - - // If no sections exist, nothing to organize - if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0) { - return; - } - - // Create tab navigation (only show tabs for sections that exist) - const tabs = []; - const hasInfoOrCharacters = $infoBox.length > 0 || $thoughts.length > 0; - - if ($userStats.length > 0) { - tabs.push(''); - } - // Combine Info and Characters into one tab - if (hasInfoOrCharacters) { - tabs.push(''); - } - - const $tabNav = $('
' + tabs.join('') + '
'); - - // Determine which tab should be active - let firstTab = ''; - if ($userStats.length > 0) firstTab = 'stats'; - else if (hasInfoOrCharacters) firstTab = 'info-characters'; - - // Create tab content wrappers - const $statsTab = $('
'); - const $infoCharactersTab = $('
'); - - // Create combined content wrapper for Info and Characters - const $combinedWrapper = $('
'); - - // Move sections into their respective tabs (detach to preserve event handlers) - if ($userStats.length > 0) { - $statsTab.append($userStats.detach()); - $userStats.show(); - } - if ($infoBox.length > 0) { - $combinedWrapper.append($infoBox.detach()); - $infoBox.show(); - } - if ($thoughts.length > 0) { - $combinedWrapper.append($thoughts.detach()); - $thoughts.show(); - } - - // Add combined wrapper to the info-characters tab - if (hasInfoOrCharacters) { - $infoCharactersTab.append($combinedWrapper); - } - - // Hide dividers on mobile - $('.rpg-divider').hide(); - - // Build mobile tab structure - const $mobileContainer = $('
'); - $mobileContainer.append($tabNav); - - // Only append tab content wrappers that have content - if ($userStats.length > 0) $mobileContainer.append($statsTab); - if (hasInfoOrCharacters) $mobileContainer.append($infoCharactersTab); - - // Insert mobile tab structure at the beginning of content box - $contentBox.prepend($mobileContainer); - - // Handle tab switching - $tabNav.find('.rpg-mobile-tab').on('click', function() { - const tabName = $(this).data('tab'); - - // Update active tab button - $tabNav.find('.rpg-mobile-tab').removeClass('active'); - $(this).addClass('active'); - - // Update active tab content - $mobileContainer.find('.rpg-mobile-tab-content').removeClass('active'); - $mobileContainer.find('[data-tab-content="' + tabName + '"]').addClass('active'); - }); -} - -/** - * Removes mobile tab navigation and restores desktop layout. - */ -function removeMobileTabs() { - // Get sections from tabs before removing - const $userStats = $('#rpg-user-stats').detach(); - const $infoBox = $('#rpg-info-box').detach(); - const $thoughts = $('#rpg-thoughts').detach(); - - // Remove mobile tab container - $('.rpg-mobile-container').remove(); - - // Get dividers - const $dividerStats = $('#rpg-divider-stats'); - const $dividerInfo = $('#rpg-divider-info'); - - // Restore original sections to content box in correct order - const $contentBox = $('.rpg-content-box'); - - // Re-insert sections in original order - if ($dividerStats.length) { - $dividerStats.before($userStats); - $dividerInfo.before($infoBox); - $contentBox.append($thoughts); - } else { - // Fallback if dividers don't exist - $contentBox.prepend($thoughts); - $contentBox.prepend($infoBox); - $contentBox.prepend($userStats); - } - - // Show sections and dividers - $userStats.show(); - $infoBox.show(); - $thoughts.show(); - $('.rpg-divider').show(); -} - -/** - * Sets up mobile keyboard handling using Visual Viewport API. - * Prevents layout squashing when keyboard appears by detecting - * viewport changes and adding CSS classes for adjustment. - */ -function setupMobileKeyboardHandling() { - if (!window.visualViewport) { - // console.log('[RPG Mobile] Visual Viewport API not supported'); - return; - } - - const $panel = $('#rpg-companion-panel'); - let keyboardVisible = false; - - // Listen for viewport resize (keyboard show/hide) - window.visualViewport.addEventListener('resize', () => { - // Only handle if panel is open on mobile - if (!$panel.hasClass('rpg-mobile-open')) return; - - const viewportHeight = window.visualViewport.height; - const windowHeight = window.innerHeight; - - // Keyboard visible if viewport significantly smaller than window - // Using 75% threshold to account for browser UI variations - const isKeyboardShowing = viewportHeight < windowHeight * 0.75; - - if (isKeyboardShowing && !keyboardVisible) { - // Keyboard just appeared - keyboardVisible = true; - $panel.addClass('rpg-keyboard-visible'); - // console.log('[RPG Mobile] Keyboard opened'); - } else if (!isKeyboardShowing && keyboardVisible) { - // Keyboard just disappeared - keyboardVisible = false; - $panel.removeClass('rpg-keyboard-visible'); - // console.log('[RPG Mobile] Keyboard closed'); - } - }); -} - -/** - * Handles focus on contenteditable fields to ensure they're visible when keyboard appears. - * Uses smooth scrolling to bring focused field into view with proper padding. - */ -function setupContentEditableScrolling() { - const $panel = $('#rpg-companion-panel'); - - // Use event delegation for all contenteditable fields - $panel.on('focusin', '[contenteditable="true"]', function(e) { - const $field = $(this); - - // Small delay to let keyboard animate in - setTimeout(() => { - // Scroll field into view with padding - // Using 'center' to ensure field is in middle of viewport - $field[0].scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'nearest' - }); - }, 300); - }); -} - -/** - * Sets up the collapse/expand toggle button for side panels. - */ -function setupCollapseToggle() { - const $collapseToggle = $('#rpg-collapse-toggle'); - const $panel = $('#rpg-companion-panel'); - const $icon = $collapseToggle.find('i'); - - $collapseToggle.on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - - const isMobile = window.innerWidth <= 1000; - - // On mobile: button toggles panel open/closed (same as desktop behavior) - if (isMobile) { - const isOpen = $panel.hasClass('rpg-mobile-open'); - console.log('[RPG Mobile] Collapse toggle clicked. Current state:', { - isOpen, - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('bottom'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - } - }); - - if (isOpen) { - // Close panel with animation - console.log('[RPG Mobile] Closing panel'); - closeMobilePanelWithAnimation(); - } else { - // Open panel - console.log('[RPG Mobile] Opening panel'); - $panel.addClass('rpg-mobile-open'); - const $overlay = $('
'); - $('body').append($overlay); - - // Debug: Check state after animation should complete - setTimeout(() => { - console.log('[RPG Mobile] 500ms after opening:', { - panelClasses: $panel.attr('class'), - hasOpenClass: $panel.hasClass('rpg-mobile-open'), - visibility: $panel.css('visibility'), - transform: $panel.css('transform'), - display: $panel.css('display'), - opacity: $panel.css('opacity') - }); - }, 500); - - // Close when clicking overlay - $overlay.on('click', function() { - console.log('[RPG Mobile] Overlay clicked - closing panel'); - closeMobilePanelWithAnimation(); - updateCollapseToggleIcon(); - }); - } - - // Update icon to reflect new state - updateCollapseToggleIcon(); - - console.log('[RPG Mobile] After toggle:', { - panelClasses: $panel.attr('class'), - inlineStyles: $panel.attr('style'), - panelPosition: { - top: $panel.css('top'), - bottom: $panel.css('bottom'), - transform: $panel.css('transform'), - visibility: $panel.css('visibility') - }, - gameContainer: { - opacity: $('.rpg-game-container').css('opacity'), - visibility: $('.rpg-game-container').css('visibility') - } - }); - return; - } - - // Desktop behavior: collapse/expand side panel - const isCollapsed = $panel.hasClass('rpg-collapsed'); - - if (isCollapsed) { - // Expand panel - $panel.removeClass('rpg-collapsed'); - - // Update icon based on position - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } - } else { - // Collapse panel - $panel.addClass('rpg-collapsed'); - - // Update icon based on position - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } - } - }); - - // Set initial icon direction based on panel position - updateCollapseToggleIcon(); -} - -/** - * Updates the collapse toggle icon direction based on panel position. - */ -function updateCollapseToggleIcon() { - const $collapseToggle = $('#rpg-collapse-toggle'); - const $panel = $('#rpg-companion-panel'); - const $icon = $collapseToggle.find('i'); - const isMobile = window.innerWidth <= 1000; - - if (isMobile) { - // Mobile: slides from right, use same icon logic as desktop right panel - const isOpen = $panel.hasClass('rpg-mobile-open'); - console.log('[RPG Mobile] updateCollapseToggleIcon:', { - isMobile: true, - isOpen, - settingIcon: isOpen ? 'chevron-left' : 'chevron-right' - }); - if (isOpen) { - // Panel open - chevron points left (to close/slide back right) - $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left'); - } else { - // Panel closed - chevron points right (to open/slide in from right) - $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right'); - } - } else { - // Desktop: icon direction based on panel position and collapsed state - const isCollapsed = $panel.hasClass('rpg-collapsed'); - - if (isCollapsed) { - // When collapsed, arrow points inward (to expand) - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } - } else { - // When expanded, arrow points outward (to collapse) - if ($panel.hasClass('rpg-position-right')) { - $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); - } else if ($panel.hasClass('rpg-position-left')) { - $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); - } - } - } -} - -/** - * Updates the visibility of the entire panel. - */ -function updatePanelVisibility() { - if (extensionSettings.enabled) { - $panelContainer.show(); - togglePlotButtons(); // Update plot button visibility - } else { - $panelContainer.hide(); - $('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled - } -} - -/** - * Clears all extension prompts. - */ -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-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'); -} - -/** - * Updates the visibility of individual sections. - */ -function updateSectionVisibility() { - // Show/hide sections based on settings - $userStatsContainer.toggle(extensionSettings.showUserStats); - $infoBoxContainer.toggle(extensionSettings.showInfoBox); - $thoughtsContainer.toggle(extensionSettings.showCharacterThoughts); - - // Show/hide dividers intelligently - // Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible - const showDividerAfterStats = extensionSettings.showUserStats && - (extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts); - $('#rpg-divider-stats').toggle(showDividerAfterStats); - - // Divider after Info Box: shown if Info Box is visible AND Mind Reading is visible - const showDividerAfterInfo = extensionSettings.showInfoBox && - extensionSettings.showCharacterThoughts; - $('#rpg-divider-info').toggle(showDividerAfterInfo); -} - -/** - * Applies the selected panel position. - */ -function applyPanelPosition() { - if (!$panelContainer) return; - - const isMobile = window.innerWidth <= 1000; - - // Remove all position classes - $panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top'); - - // On mobile, don't apply desktop position classes - if (isMobile) { - return; - } - - // Desktop: Add the appropriate position class - $panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`); - - // Update collapse toggle icon direction for new position - updateCollapseToggleIcon(); -} - -/** - * Updates the model selector visibility. - */ -/** - * Updates the UI based on generation mode selection. - */ -function updateGenerationModeUI() { - if (extensionSettings.generationMode === 'together') { - // In "together" mode, manual update button is hidden - $('#rpg-manual-update').hide(); - } else { - // In "separate" mode, manual update button is visible - $('#rpg-manual-update').show(); - } -} - -/** - * Generates just the example portion - previous tracker data without tags or explanations. - * This will be appended to the last assistant message to show the format. - * Each section is wrapped in markdown code blocks. - */ -function generateTrackerExample() { - let example = ''; - - // Use COMMITTED data for generation context, not displayed data - // Wrap each tracker section in markdown code blocks - if (extensionSettings.showUserStats && committedTrackerData.userStats) { - example += '```\n' + committedTrackerData.userStats + '\n```\n\n'; - } - - if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { - example += '```\n' + committedTrackerData.infoBox + '\n```\n\n'; - } - - if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { - example += '```\n' + committedTrackerData.characterThoughts + '\n```'; - } - - return example.trim(); -} - -/** - * Generates the instruction portion - format specifications and guidelines. - * @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt (true for main generation, false for separate tracker generation) - * @param {boolean} includeContinuation - Whether to include "After updating the trackers, continue..." instruction - */ -function generateTrackerInstructions(includeHtmlPrompt = true, includeContinuation = true) { - const userName = getContext().name1; - const classicStats = extensionSettings.classicStats; - let instructions = ''; - - // Check if any trackers are enabled - const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts; - - // Only add tracker instructions if at least one tracker is enabled - if (hasAnyTrackers) { - // Universal instruction header - instructions += `\nYou must start your response with an appropriate update to the trackers in EXACTLY the same format as below, enclosed in separate Markdown code fences. Replace X with proper numbers and placeholders in [brackets] (while removing the brackets themselves) with in-world details ${userName} perceives about the current scene and the present characters. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user's actions, the passage of time, and logical consequences:\n`; - - // Add format specifications for each enabled tracker - if (extensionSettings.showUserStats) { - instructions += '```\n'; - instructions += `${userName}'s Stats\n`; - instructions += '---\n'; - instructions += '- Health: X%\n'; - instructions += '- Satiety: X%\n'; - instructions += '- Energy: X%\n'; - instructions += '- Hygiene: X%\n'; - instructions += '- Arousal: X%\n'; - instructions += '[Mood Emoji]: [Conditions (up to three traits)]\n'; - instructions += 'Inventory: [Clothing/Armor, Inventory Items (list of important items/none)]\n'; - instructions += '```\n\n'; - } - - if (extensionSettings.showInfoBox) { - instructions += '```\n'; - instructions += 'Info Box\n'; - instructions += '---\n'; - instructions += '🗓️: [Weekday, Month, Year]\n'; - instructions += '[Weather Emoji]: [Forecast]\n'; - instructions += '🌡️: [Temperature in °C]\n'; - instructions += '🕒: [Time Start → Time End]\n'; - instructions += '🗺️: [Location]\n'; - instructions += '```\n\n'; - } - - if (extensionSettings.showCharacterThoughts) { - instructions += '```\n'; - instructions += 'Present Characters\n'; - instructions += '---\n'; - instructions += `[Present Character's Emoji (do not include ${userName}; state "Unavailable" if no major characters are present in the scene)]: [Name, Visible Physical State (up to three traits), Observable Demeanor Cue (one trait)] | [Enemy/Neutral/Friend/Lover] | [Internal Monologue (in first person POV, up to three sentences long)]\n`; - instructions += '```\n\n'; - } - - // Only add continuation instruction if includeContinuation is true - if (includeContinuation) { - instructions += `After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, a character's emotional state coloring their responses, and so on.\n\n`; - } - - // Include attributes and dice roll only if there was a dice roll - if (extensionSettings.lastDiceRoll) { - const roll = extensionSettings.lastDiceRoll; - instructions += `${userName}'s attributes: STR ${classicStats.str}, DEX ${classicStats.dex}, CON ${classicStats.con}, INT ${classicStats.int}, WIS ${classicStats.wis}, CHA ${classicStats.cha}\n`; - instructions += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeeded or failed the action they attempted.\n\n`; - } - } - - // Append HTML prompt if enabled AND includeHtmlPrompt is true - if (extensionSettings.enableHtmlPrompt && includeHtmlPrompt) { - // Add newlines only if we had tracker instructions - if (hasAnyTrackers) { - instructions += ``; - } else { - instructions += `\n`; - } - - instructions += `If appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: -- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. -- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. -- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. -- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. -- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; - } - - return instructions; -} - -/** - * Generates a formatted contextual summary for SEPARATE mode injection. - * This creates a hybrid summary with clean formatting for main roleplay generation. - */ -function generateContextualSummary() { - // Use COMMITTED data for generation context, not displayed data - const userName = getContext().name1; - let summary = ''; - - // console.log('[RPG Companion] generateContextualSummary called'); - // console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats); - // console.log('[RPG Companion] extensionSettings.userStats:', JSON.stringify(extensionSettings.userStats)); - - // Parse the data into readable format - if (extensionSettings.showUserStats && committedTrackerData.userStats) { - const stats = extensionSettings.userStats; - // console.log('[RPG Companion] Building stats summary with:', stats); - summary += `${userName}'s Stats:\n`; - summary += `Condition: Health ${stats.health}%, Satiety ${stats.satiety}%, Energy ${stats.energy}%, Hygiene ${stats.hygiene}%, Arousal ${stats.arousal}% | ${stats.mood} ${stats.conditions}\n`; - if (stats.inventory && stats.inventory !== 'None') { - summary += `Inventory: ${stats.inventory}\n`; - } - // Include classic stats (attributes) and dice roll only if there was a dice roll - if (extensionSettings.lastDiceRoll) { - const classicStats = extensionSettings.classicStats; - const roll = extensionSettings.lastDiceRoll; - summary += `Attributes: STR ${classicStats.str}, DEX ${classicStats.dex}, CON ${classicStats.con}, INT ${classicStats.int}, WIS ${classicStats.wis}, CHA ${classicStats.cha}\n`; - summary += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeed or fail the action they attempt.\n`; - } - summary += `\n`; - } - - if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { - // Parse info box data - const lines = committedTrackerData.infoBox.split('\n'); - let date = '', weather = '', temp = '', time = '', location = ''; - - // console.log('[RPG Companion] 🔍 Parsing Info Box lines:', lines); - - for (const line of lines) { - // console.log('[RPG Companion] 🔍 Processing line:', line); - // Use separate if statements (not else if) so each line is checked against all conditions - if (line.includes('🗓️:')) { - date = line.replace('🗓️:', '').trim(); - // console.log('[RPG Companion] 📅 Found date:', date); - } - if (line.includes('🌡️:')) { - temp = line.replace('🌡️:', '').trim(); - // console.log('[RPG Companion] 🌡️ Found temp:', temp); - } - if (line.includes('🕒:')) { - time = line.replace('🕒:', '').trim(); - // console.log('[RPG Companion] 🕒 Found time:', time); - } - if (line.includes('🗺️:')) { - location = line.replace('🗺️:', '').trim(); - // console.log('[RPG Companion] 🗺️ Found location:', location); - } - // Check for weather emojis - use a simpler approach - const weatherEmojis = ['🌤️', '☀️', '⛅', '🌦️', '🌧️', '⛈️', '🌩️', '🌨️', '❄️', '🌫️']; - const startsWithWeatherEmoji = weatherEmojis.some(emoji => line.startsWith(emoji + ':')); - if (startsWithWeatherEmoji && !line.includes('🌡️') && !line.includes('🗺️')) { - // Extract weather description (remove emoji and colon) - weather = line.substring(line.indexOf(':') + 1).trim(); - // console.log('[RPG Companion] 🌧️ Found weather:', weather); - } - } - - // console.log('[RPG Companion] 🔍 Parsed values - date:', date, 'weather:', weather, 'temp:', temp, 'time:', time, 'location:', location); - - if (date || weather || temp || time || location) { - summary += `Information:\n`; - summary += `Scene: `; - if (date) summary += `${date}`; - if (location) summary += ` | ${location}`; - if (time) summary += ` | ${time}`; - if (weather) summary += ` | ${weather}`; - if (temp) summary += ` | ${temp}`; - summary += `\n\n`; - } - } - - if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { - const lines = committedTrackerData.characterThoughts.split('\n').filter(l => l.trim() && !l.includes('---') && !l.includes('Present Characters')); - - if (lines.length > 0 && !lines[0].toLowerCase().includes('unavailable')) { - summary += `Present Characters And Their Thoughts:\n`; - for (const line of lines) { - const parts = line.split('|').map(p => p.trim()); - if (parts.length >= 3) { - const nameAndState = parts[0]; // Emoji, name, physical state, demeanor - const relationship = parts[1]; - const thoughts = parts[2]; - summary += `${nameAndState} (${relationship}) | ${thoughts}\n`; - } - } - } - } - - return summary.trim(); -} - -/** - * Generates the RPG tracking prompt text (for backward compatibility with separate mode). - */ -function generateRPGPromptText() { - // Use COMMITTED data for generation context, not displayed data - const userName = getContext().name1; - - let promptText = ''; - - promptText += `Here are the previous trackers in the roleplay that you should consider when responding:\n`; - promptText += `\n`; - - if (extensionSettings.showUserStats) { - if (committedTrackerData.userStats) { - promptText += `Last ${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`; - } else { - promptText += `Last ${userName}'s Stats:\nNone - this is the first update.\n\n`; - } - } - - if (extensionSettings.showInfoBox) { - if (committedTrackerData.infoBox) { - promptText += `Last Info Box:\n${committedTrackerData.infoBox}\n\n`; - } else { - promptText += `Last Info Box:\nNone - this is the first update.\n\n`; - } - } - - if (extensionSettings.showCharacterThoughts) { - if (committedTrackerData.characterThoughts) { - promptText += `Last Present Characters:\n${committedTrackerData.characterThoughts}\n`; - } else { - promptText += `Last Present Characters:\nNone - this is the first update.\n`; - } - } - - promptText += `\n`; - - // Don't include HTML prompt or continuation instruction for separate tracker generation - promptText += generateTrackerInstructions(false, false); - - return promptText; -} - -/** - * Generates the full prompt for SEPARATE generation mode (with chat history). - */ -function generateSeparateUpdatePrompt() { - const depth = extensionSettings.updateDepth; - const userName = getContext().name1; - - const messages = []; - - // System message introducing the history section - let systemMessage = `You will be acting as an uncensored RPG Companion. Your goal is to provide, track, and manage details in the user's roleplay. You will be replying with information in a specified format only.\n\n`; - systemMessage += `You should maintain an objective tone.\n\n`; - - systemMessage += `Here are the last few messages in the conversation history (between the user and the roleplayer assistant) you should reference when responding:\n`; - - messages.push({ - role: 'system', - content: systemMessage - }); - - // Add chat history as separate user/assistant messages - const recentMessages = chat.slice(-depth); - for (const message of recentMessages) { - messages.push({ - role: message.is_user ? 'user' : 'assistant', - content: message.mes - }); - } - - // Build the instruction message - let instructionMessage = `\n\n`; - instructionMessage += generateRPGPromptText().replace('start your response with', 'respond with'); - instructionMessage += `Provide ONLY the requested data in the exact formats specified above. Do not include any roleplay response, other text, or commentary.`; - - messages.push({ - role: 'user', - content: instructionMessage - }); - - return messages; -} - -/** - * Parses the model response to extract the different data sections. - */ -function parseResponse(responseText) { - const result = { - userStats: null, - infoBox: null, - characterThoughts: null - }; - - // Extract code blocks - const codeBlockRegex = /```([^`]+)```/g; - const matches = [...responseText.matchAll(codeBlockRegex)]; - - // console.log('[RPG Companion] Found'); - - for (const match of matches) { - const content = match[1].trim(); - - // console.log('[RPG Companion] Checking code block (first 200 chars):', content.substring(0, 200)); - - // Match Stats section - if (content.match(/Stats\s*\n\s*---/i)) { - result.userStats = content; - // console.log('[RPG Companion] ✓ Found Stats section'); - } - // Match Info Box section - else if (content.match(/Info Box\s*\n\s*---/i)) { - result.infoBox = content; - // console.log('[RPG Companion] ✓ Found Info Box section'); - } - // Match Present Characters section - flexible matching - else if (content.match(/Present Characters\s*\n\s*---/i) || content.includes(" | ")) { - result.characterThoughts = content; - // console.log('[RPG Companion] ✓ Found Present Characters section:', content); - } else { - // console.log('[RPG Companion] ✗ Code block did not match any section'); - } - } - - // console.log('[RPG Companion] Parse results:', { - // hasStats: !!result.userStats, - // hasInfoBox: !!result.infoBox, - // hasThoughts: !!result.characterThoughts - // }); - - return result; -} - -/** - * Main function to update RPG data by calling the AI model (SEPARATE MODE ONLY). - */ -async function updateRPGData() { - if (isGenerating) { - // console.log('[RPG Companion] Already generating, skipping...'); - return; - } - - if (!extensionSettings.enabled) { - return; - } - - if (extensionSettings.generationMode !== 'separate') { - // console.log('[RPG Companion] Not in separate mode, skipping manual update'); - return; - } - - try { - isGenerating = true; - - // Update button to show "Updating..." state - const $updateBtn = $('#rpg-manual-update'); - const originalHtml = $updateBtn.html(); - $updateBtn.html(' Updating...').prop('disabled', true); - - const prompt = generateSeparateUpdatePrompt(); - - // Generate using raw prompt (uses current preset, no chat history) - const response = await generateRaw({ - prompt: prompt, - quietToLoud: false - }); - - if (response) { - // console.log('[RPG Companion] Raw AI response:', response); - const parsedData = parseResponse(response); - // console.log('[RPG Companion] Parsed data:', parsedData); - // console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null'); - - // DON'T update lastGeneratedData here - it should only reflect the data - // from the assistant message the user replied to, not auto-generated updates - // This ensures swipes/regenerations use consistent source data - - // Store RPG data for the last assistant message (separate mode) - const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; - // console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message'); - if (lastMessage && !lastMessage.is_user) { - if (!lastMessage.extra) { - lastMessage.extra = {}; - } - if (!lastMessage.extra.rpg_companion_swipes) { - lastMessage.extra.rpg_companion_swipes = {}; - } - - const currentSwipeId = lastMessage.swipe_id || 0; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { - userStats: parsedData.userStats, - infoBox: parsedData.infoBox, - characterThoughts: parsedData.characterThoughts - }; - - // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); - - // Update lastGeneratedData for display AND future commit - if (parsedData.userStats) { - lastGeneratedData.userStats = parsedData.userStats; - parseUserStats(parsedData.userStats); - } - if (parsedData.infoBox) { - lastGeneratedData.infoBox = parsedData.infoBox; - } - if (parsedData.characterThoughts) { - lastGeneratedData.characterThoughts = parsedData.characterThoughts; - } - // console.log('[RPG Companion] 💾 SEPARATE MODE: Updated lastGeneratedData:', { - // userStats: lastGeneratedData.userStats ? 'exists' : 'null', - // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', - // characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null' - // }); - - // If there's no committed data yet (first time) or only has placeholder data, commit immediately - const hasNoRealData = !committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts; - const hasOnlyPlaceholderData = ( - (!committedTrackerData.userStats || committedTrackerData.userStats === '') && - (!committedTrackerData.infoBox || committedTrackerData.infoBox === 'Info Box\n---\n' || committedTrackerData.infoBox === '') && - (!committedTrackerData.characterThoughts || committedTrackerData.characterThoughts === 'Present Characters\n---\n' || committedTrackerData.characterThoughts === '') - ); - - if (hasNoRealData || hasOnlyPlaceholderData) { - committedTrackerData.userStats = parsedData.userStats; - committedTrackerData.infoBox = parsedData.infoBox; - committedTrackerData.characterThoughts = parsedData.characterThoughts; - // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); - } - - // Render the updated data - renderUserStats(); - renderInfoBox(); - renderThoughts(); - } else { - // No assistant message to attach to - just update display - if (parsedData.userStats) { - parseUserStats(parsedData.userStats); - } - renderUserStats(); - renderInfoBox(); - renderThoughts(); - } - - // Save to chat metadata - saveChatData(); - } - - } catch (error) { - console.error('[RPG Companion] Error updating RPG data:', error); - } finally { - isGenerating = false; - - // Restore button to original state - const $updateBtn = $('#rpg-manual-update'); - $updateBtn.html(' Refresh RPG Info').prop('disabled', false); - - // Reset the flag after tracker generation completes - // This ensures the flag persists through both main generation AND tracker generation - // console.log('[RPG Companion] 🔄 Tracker generation complete - resetting lastActionWasSwipe to false'); - lastActionWasSwipe = false; - } -} - -/** - * Parses user stats from the text and updates the settings. - */ -function parseUserStats(statsText) { - try { - // Extract percentages and mood/conditions - const healthMatch = statsText.match(/Health:\s*(\d+)%/); - const satietyMatch = statsText.match(/Satiety:\s*(\d+)%/); - const energyMatch = statsText.match(/Energy:\s*(\d+)%/); - const hygieneMatch = statsText.match(/Hygiene:\s*(\d+)%/); - const arousalMatch = statsText.match(/Arousal:\s*(\d+)%/); - - // Match new format: [Emoji]: [Conditions] - // Look for a line after Arousal that has format [something]: [text] - // Split by lines and find the line after percentages - const lines = statsText.split('\n'); - let moodMatch = null; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - // Skip lines with percentages or "Inventory:" - if (line.includes('%') || line.toLowerCase().startsWith('inventory:')) continue; - // Match emoji followed by colon and conditions - const match = line.match(/^(.+?):\s*(.+)$/); - if (match) { - moodMatch = match; - break; - } - } - - // Extract inventory - const inventoryMatch = statsText.match(/Inventory:\s*(.+)/i); - - if (healthMatch) extensionSettings.userStats.health = parseInt(healthMatch[1]); - if (satietyMatch) extensionSettings.userStats.satiety = parseInt(satietyMatch[1]); - if (energyMatch) extensionSettings.userStats.energy = parseInt(energyMatch[1]); - if (hygieneMatch) extensionSettings.userStats.hygiene = parseInt(hygieneMatch[1]); - if (arousalMatch) extensionSettings.userStats.arousal = parseInt(arousalMatch[1]); - if (moodMatch) { - extensionSettings.userStats.mood = moodMatch[1].trim(); // Emoji - extensionSettings.userStats.conditions = moodMatch[2].trim(); // Conditions - } - if (inventoryMatch) { - extensionSettings.userStats.inventory = inventoryMatch[1].trim(); - } - - saveSettings(); - } catch (error) { - console.error('[RPG Companion] Error parsing user stats:', error); - } -} - -/** - * Renders the user stats with fancy progress bars. - */ -/** - * Renders the user stats with fancy progress bars. - */ -function renderUserStats() { - if (!extensionSettings.showUserStats || !$userStatsContainer) { - return; - } - - const stats = extensionSettings.userStats; - const userName = getContext().name1; - - // Initialize lastGeneratedData.userStats if it doesn't exist - if (!lastGeneratedData.userStats) { - lastGeneratedData.userStats = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\nInventory: ${stats.inventory}`; - } - - // Get user portrait - handle both default-user and custom persona folders - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let userPortrait = FALLBACK_AVATAR_DATA_URI; - - if (user_avatar) { - // Try to get the thumbnail using our safe helper - const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar); - if (thumbnailUrl) { - userPortrait = thumbnailUrl; - } - } - - // Create gradient from low to high color - const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`; - - const html = ` -
-
-
- ${userName} -
-
- ${stats.inventory || 'None'} -
-
-
-
-
- Health: -
-
-
- ${stats.health}% -
- -
- Satiety: -
-
-
- ${stats.satiety}% -
- -
- Energy: -
-
-
- ${stats.energy}% -
- -
- Hygiene: -
-
-
- ${stats.hygiene}% -
- -
- Arousal: -
-
-
- ${stats.arousal}% -
-
- -
-
${stats.mood}
-
${stats.conditions}
-
-
- -
-
-
-
- STR -
- - ${extensionSettings.classicStats.str} - -
-
-
- DEX -
- - ${extensionSettings.classicStats.dex} - -
-
-
- CON -
- - ${extensionSettings.classicStats.con} - -
-
-
- INT -
- - ${extensionSettings.classicStats.int} - -
-
-
- WIS -
- - ${extensionSettings.classicStats.wis} - -
-
-
- CHA -
- - ${extensionSettings.classicStats.cha} - -
-
-
-
-
-
- `; - - $userStatsContainer.html(html); - - // Add event listeners for editable stat values - $('.rpg-editable-stat').on('blur', function() { - const field = $(this).data('field'); - const textValue = $(this).text().replace('%', '').trim(); - let value = parseInt(textValue); - - // Validate and clamp value between 0 and 100 - if (isNaN(value)) { - value = 0; - } - value = Math.max(0, Math.min(100, value)); - - // Update the setting - extensionSettings.userStats[field] = value; - - // Also update lastGeneratedData to keep it in sync - if (!lastGeneratedData.userStats) { - lastGeneratedData.userStats = ''; - } - // Regenerate the userStats text with updated value - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - - // Re-render to update the bar - renderUserStats(); - }); - - // Add event listener for inventory editing - $('.rpg-inventory-items.rpg-editable').on('blur', function() { - const value = $(this).text().trim(); - extensionSettings.userStats.inventory = value || 'None'; - - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }); - - // Add event listeners for mood/conditions editing - $('.rpg-mood-emoji.rpg-editable').on('blur', function() { - const value = $(this).text().trim(); - extensionSettings.userStats.mood = value || '😐'; - - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }); - - $('.rpg-mood-conditions.rpg-editable').on('blur', function() { - const value = $(this).text().trim(); - extensionSettings.userStats.conditions = value || 'None'; - - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }); -} - -/** - * Renders the info box as a visual dashboard. - */ -function renderInfoBox() { - if (!extensionSettings.showInfoBox || !$infoBoxContainer) { - return; - } - - // Add updating class for animation - if (extensionSettings.enableAnimations) { - $infoBoxContainer.addClass('rpg-content-updating'); - } - - // If no data yet, show placeholder - if (!lastGeneratedData.infoBox) { - const placeholderHtml = ` -
-
-
No data yet
-
Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button
-
-
- `; - $infoBoxContainer.html(placeholderHtml); - if (extensionSettings.enableAnimations) { - setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); - } - return; - } - - // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox); - - // Parse the info box data - const lines = lastGeneratedData.infoBox.split('\n'); - // console.log('[RPG Companion] Info Box split into lines:', lines); - const data = { - date: '', - weekday: '', - month: '', - year: '', - weatherEmoji: '', - weatherForecast: '', - temperature: '', - tempValue: 0, - timeStart: '', - timeEnd: '', - location: '', - characters: [] - }; - - for (const line of lines) { - // console.log('[RPG Companion] Processing line:', line); - - if (line.includes('🗓️:')) { - // console.log('[RPG Companion] → Matched DATE'); - const dateStr = line.replace('🗓️:', '').trim(); - // Parse format: "Weekday, Month Day, Year" or "Weekday, Month, Year" - const dateParts = dateStr.split(',').map(p => p.trim()); - data.weekday = dateParts[0] || ''; - data.month = dateParts[1] || ''; - data.year = dateParts[2] || ''; - data.date = dateStr; - } else if (line.includes('🌡️:')) { - // console.log('[RPG Companion] → Matched TEMPERATURE'); - const tempStr = line.replace('🌡️:', '').trim(); - data.temperature = tempStr; - // Extract numeric value - const tempMatch = tempStr.match(/(-?\d+)/); - if (tempMatch) { - data.tempValue = parseInt(tempMatch[1]); - } - } else if (line.includes('🕒:')) { - // console.log('[RPG Companion] → Matched TIME'); - const timeStr = line.replace('🕒:', '').trim(); - data.time = timeStr; - // Parse "HH:MM → HH:MM" format - const timeParts = timeStr.split('→').map(t => t.trim()); - data.timeStart = timeParts[0] || ''; - data.timeEnd = timeParts[1] || ''; - } else if (line.includes('🗺️:')) { - // console.log('[RPG Companion] → Matched LOCATION'); - data.location = line.replace('🗺️:', '').trim(); - } else { - // Check if it's a weather line - // Since \p{Emoji} doesn't work reliably, use a simpler approach - const hasColon = line.includes(':'); - const notInfoBox = !line.includes('Info Box'); - const notDivider = !line.includes('---'); - const notCodeFence = !line.trim().startsWith('```'); - - // console.log('[RPG Companion] → Checking weather conditions:', { - // line: line, - // hasColon: hasColon, - // notInfoBox: notInfoBox, - // notDivider: notDivider - // }); - - if (hasColon && notInfoBox && notDivider && notCodeFence && line.trim().length > 0) { - // Match format: [Weather Emoji]: [Forecast] - // Capture everything before colon as emoji, everything after as forecast - // console.log('[RPG Companion] → Testing WEATHER match for:', line); - const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/); - if (weatherMatch) { - const potentialEmoji = weatherMatch[1].trim(); - const forecast = weatherMatch[2].trim(); - - // If the first part is short (likely emoji), treat as weather - if (potentialEmoji.length <= 5) { - data.weatherEmoji = potentialEmoji; - data.weatherForecast = forecast; - // console.log('[RPG Companion] ✓ Weather parsed:', data.weatherEmoji, data.weatherForecast); - } else { - // console.log('[RPG Companion] ✗ First part too long for emoji:', potentialEmoji); - } - } else { - // console.log('[RPG Companion] ✗ Weather regex did not match'); - } - } else { - // console.log('[RPG Companion] → No match for this line'); - } - } - } - - // console.log('[RPG Companion] Parsed Info Box data:', { - // date: data.date, - // weatherEmoji: data.weatherEmoji, - // weatherForecast: data.weatherForecast, - // temperature: data.temperature, - // timeStart: data.timeStart, - // location: data.location - // }); - - // Build visual dashboard HTML - // Row 1: Date, Weather, Temperature, Time widgets - let html = '
'; - - // Calendar widget - always show (editable even if empty) - const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON'; - const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY'; - const yearDisplay = data.year || 'YEAR'; - html += ` -
-
${monthShort}
-
${weekdayShort}
-
${yearDisplay}
-
- `; - - // Weather widget - always show (editable even if empty) - const weatherEmoji = data.weatherEmoji || '🌤️'; - const weatherForecast = data.weatherForecast || 'Weather'; - html += ` -
-
${weatherEmoji}
-
${weatherForecast}
-
- `; - - // Temperature widget - always show (editable even if empty) - const tempDisplay = data.temperature || '20°C'; - const tempValue = data.tempValue || 20; - const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100)); - const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560'; - html += ` -
-
-
-
-
-
-
-
${tempDisplay}
-
- `; - - // Time widget - always show (editable even if empty) - const timeDisplay = data.timeStart || '12:00'; - // Parse time for clock hands - const timeMatch = timeDisplay.match(/(\d+):(\d+)/); - let hourAngle = 0; - let minuteAngle = 0; - if (timeMatch) { - const hours = parseInt(timeMatch[1]); - const minutes = parseInt(timeMatch[2]); - hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute - minuteAngle = minutes * 6; // 6° per minute - } - html += ` -
-
-
-
-
-
-
-
-
${timeDisplay}
-
- `; - - html += '
'; - - // Row 2: Location widget (full width) - always show (editable even if empty) - const locationDisplay = data.location || 'Location'; - html += ` -
-
-
-
📍
-
-
${locationDisplay}
-
-
- `; - - $infoBoxContainer.html(html); - - // Add event handlers for editable Info Box fields - $infoBoxContainer.find('.rpg-editable').on('blur', function() { - const field = $(this).data('field'); - const value = $(this).text().trim(); - updateInfoBoxField(field, value); - }); - - // Remove updating class after animation - if (extensionSettings.enableAnimations) { - setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); - } -} - -/** - * Renders character thoughts (Present Characters). - */ -function renderThoughts() { - if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { - return; - } - - // Add updating class for animation - if (extensionSettings.enableAnimations) { - $thoughtsContainer.addClass('rpg-content-updating'); - } - - // Initialize if no data yet - if (!lastGeneratedData.characterThoughts) { - lastGeneratedData.characterThoughts = ''; - } - - const lines = lastGeneratedData.characterThoughts.split('\n'); - const presentCharacters = []; - - // console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts); - // console.log('[RPG Companion] Split into lines:', lines); - - // Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts] - for (const line of lines) { - // Skip empty lines, headers, dividers, and code fences - if (line.trim() && - !line.includes('Present Characters') && - !line.includes('---') && - !line.trim().startsWith('```')) { - - // Match the new format with pipes - const parts = line.split('|').map(p => p.trim()); - - if (parts.length >= 2) { - // First part: [Emoji]: [Name, Status, Demeanor] - const firstPart = parts[0].trim(); - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - const emoji = emojiMatch[1].trim(); - const info = emojiMatch[2].trim(); - const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover - const thoughts = parts[2] ? parts[2].trim() : ''; - - // Parse name from info (first part before comma) - const infoParts = info.split(',').map(p => p.trim()); - const name = infoParts[0] || ''; - const traits = infoParts.slice(1).join(', '); - - if (name && name.toLowerCase() !== 'unavailable') { - presentCharacters.push({ emoji, name, traits, relationship, thoughts }); - // console.log('[RPG Companion] Parsed character:', { name, relationship }); - } - } - } - } - } - - // Relationship status to emoji mapping - const relationshipEmojis = { - 'Enemy': '⚔️', - 'Neutral': '⚖️', - 'Friend': '⭐', - 'Lover': '❤️' - }; - - // Build HTML - let html = ''; - - // console.log('[RPG Companion] Total characters parsed:', presentCharacters.length); - // console.log('[RPG Companion] Characters array:', presentCharacters); - - // If no characters parsed, show a placeholder editable card - if (presentCharacters.length === 0) { - // Get default character portrait (try to use the current character if in 1-on-1 chat) - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let defaultPortrait = FALLBACK_AVATAR_DATA_URI; - let defaultName = 'Character'; - - if (this_chid !== undefined && characters[this_chid]) { - if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); - if (thumbnailUrl) { - defaultPortrait = thumbnailUrl; - } - } - defaultName = characters[this_chid].name || 'Character'; - } - - html += '
'; - html += ` -
-
- ${defaultName} -
⚖️
-
-
-
- 😊 - ${defaultName} -
-
Traits
-
-
- `; - html += '
'; - } else { - html += '
'; - for (const char of presentCharacters) { - // Find character portrait - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let characterPortrait = FALLBACK_AVATAR_DATA_URI; - - // console.log('[RPG Companion] Looking for avatar for:', char.name); - - // For group chats, search through group members first - if (selected_group) { - const groupMembers = getGroupMembers(selected_group); - const matchingMember = groupMembers.find(member => - member && member.name && member.name.toLowerCase() === char.name.toLowerCase() - ); - - if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - } - } - } - - // For regular chats or if not found in group, search all characters - if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { - const matchingCharacter = characters.find(c => - c && c.name && c.name.toLowerCase() === char.name.toLowerCase() - ); - - if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - } - } - } - - // If this is the current character in a 1-on-1 chat, use their portrait - if (this_chid !== undefined && characters[this_chid] && - characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) { - const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - } - } - - // Get relationship emoji - const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️'; - - html += ` -
-
- ${char.name} -
${relationshipEmoji}
-
-
-
- ${char.emoji} - ${char.name} -
-
${char.traits}
-
-
- `; - } - html += '
'; - } - - $thoughtsContainer.html(html); - - // Add event handlers for editable character fields - $thoughtsContainer.find('.rpg-editable').on('blur', function() { - const character = $(this).data('character'); - const field = $(this).data('field'); - const value = $(this).text().trim(); - updateCharacterField(character, field, value); - }); - - // Remove updating class after animation - if (extensionSettings.enableAnimations) { - setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); - } - - // Update chat overlay if enabled - if (extensionSettings.showThoughtsInChat) { - updateChatThoughts(); - } -} - -/** - * Updates a specific field in the Info Box data and re-renders. - */ -function updateInfoBoxField(field, value) { - if (!lastGeneratedData.infoBox) { - // Initialize with empty info box if it doesn't exist - lastGeneratedData.infoBox = 'Info Box\n---\n'; - } - - // Reconstruct the Info Box text with updated field - const lines = lastGeneratedData.infoBox.split('\n'); - let dateLineFound = false; - let dateLineIndex = -1; - - // Find the date line - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('🗓️:')) { - dateLineFound = true; - dateLineIndex = i; - break; - } - } - - const updatedLines = lines.map((line, index) => { - if (field === 'month' && line.includes('🗓️:')) { - const parts = line.split(','); - if (parts.length >= 2) { - // parts[0] = "🗓️: Weekday", parts[1] = " Month", parts[2] = " Year" - parts[1] = ' ' + value; - return parts.join(','); - } else if (parts.length === 1) { - // No existing month/year, add them - return `${parts[0]}, ${value}, YEAR`; - } - } else if (field === 'weekday' && line.includes('🗓️:')) { - const parts = line.split(','); - // Keep the emoji, just update the weekday - const month = parts[1] ? parts[1].trim() : 'Month'; - const year = parts[2] ? parts[2].trim() : 'YEAR'; - return `🗓️: ${value}, ${month}, ${year}`; - } else if (field === 'year' && line.includes('🗓️:')) { - const parts = line.split(','); - if (parts.length >= 3) { - parts[2] = ' ' + value; - return parts.join(','); - } else if (parts.length === 2) { - // No existing year, add it - return `${parts[0]}, ${parts[1]}, ${value}`; - } else if (parts.length === 1) { - // No existing month/year, add them - return `${parts[0]}, Month, ${value}`; - } - } else if (field === 'weatherEmoji' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { - // This is the weather line - const parts = line.split(':'); - if (parts.length >= 2) { - return `${value}: ${parts.slice(1).join(':').trim()}`; - } - } else if (field === 'weatherForecast' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { - // This is the weather line - const parts = line.split(':'); - if (parts.length >= 2) { - return `${parts[0].trim()}: ${value}`; - } - } else if (field === 'temperature' && line.includes('🌡️:')) { - return `🌡️: ${value}`; - } else if (field === 'timeStart' && line.includes('🕒:')) { - // Update time format: "HH:MM → HH:MM" - // When user edits, set both start and end time to the new value - return `🕒: ${value} → ${value}`; - } else if (field === 'location' && line.includes('🗺️:')) { - return `🗺️: ${value}`; - } - return line; - }); - - // If editing a date field but no date line exists, create one after the divider - if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) { - // Find the divider line - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - // Create initial date line with the edited field - let newDateLine = ''; - if (field === 'weekday') { - newDateLine = `🗓️: ${value}, Month, YEAR`; - } else if (field === 'month') { - newDateLine = `🗓️: Weekday, ${value}, YEAR`; - } else if (field === 'year') { - newDateLine = `🗓️: Weekday, Month, ${value}`; - } - // Insert after the divider - updatedLines.splice(dividerIndex + 1, 0, newDateLine); - } - } - - // If editing weather but no weather line exists, create one - if ((field === 'weatherEmoji' || field === 'weatherForecast')) { - let weatherLineFound = false; - for (const line of updatedLines) { - // Check if this is a weather line (has emoji and forecast, not one of the special fields) - if (line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { - weatherLineFound = true; - break; - } - } - - if (!weatherLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - let newWeatherLine = ''; - if (field === 'weatherEmoji') { - newWeatherLine = `${value}: Weather`; - } else if (field === 'weatherForecast') { - newWeatherLine = `🌤️: ${value}`; - } - // Insert after date line if it exists, otherwise after divider - const dateIndex = updatedLines.findIndex(line => line.includes('🗓️:')); - const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1; - updatedLines.splice(insertIndex, 0, newWeatherLine); - } - } - } - - // If editing temperature but no temperature line exists, create one - if (field === 'temperature') { - const tempLineFound = updatedLines.some(line => line.includes('🌡️:')); - if (!tempLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - const newTempLine = `🌡️: ${value}`; - // Find last non-empty line before creating position - let insertIndex = dividerIndex + 1; - for (let i = 0; i < updatedLines.length; i++) { - if (updatedLines[i].includes('🗓️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { - insertIndex = i + 1; - } - } - updatedLines.splice(insertIndex, 0, newTempLine); - } - } - } - - // If editing time but no time line exists, create one - if (field === 'timeStart') { - const timeLineFound = updatedLines.some(line => line.includes('🕒:')); - if (!timeLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - const newTimeLine = `🕒: ${value} → ${value}`; - // Find last non-empty line before creating position - let insertIndex = dividerIndex + 1; - for (let i = 0; i < updatedLines.length; i++) { - if (updatedLines[i].includes('🗓️:') || updatedLines[i].includes('🌡️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { - insertIndex = i + 1; - } - } - updatedLines.splice(insertIndex, 0, newTimeLine); - } - } - } - - // If editing location but no location line exists, create one - if (field === 'location') { - const locationLineFound = updatedLines.some(line => line.includes('🗺️:')); - if (!locationLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - const newLocationLine = `🗺️: ${value}`; - // Insert at the end (before any empty lines) - let insertIndex = updatedLines.length; - for (let i = updatedLines.length - 1; i >= 0; i--) { - if (updatedLines[i].trim() !== '') { - insertIndex = i + 1; - break; - } - } - updatedLines.splice(insertIndex, 0, newLocationLine); - } - } - } - - lastGeneratedData.infoBox = updatedLines.join('\n'); - - // Update the message's swipe data - const chat = getContext().chat; - if (chat && chat.length > 0) { - for (let i = chat.length - 1; i >= 0; i--) { - const message = chat[i]; - if (!message.is_user) { - if (message.extra && message.extra.rpg_companion_swipes) { - const swipeId = message.swipe_id || 0; - if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n'); - // console.log('[RPG Companion] Updated infoBox in message swipe data'); - } - } - break; - } - } - } - - saveChatData(); - renderInfoBox(); -} - -/** - * Updates a specific character field in Present Characters data and re-renders. - */ -function updateCharacterField(characterName, field, value) { - // console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value); - // console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - - // Initialize if it doesn't exist - if (!lastGeneratedData.characterThoughts) { - lastGeneratedData.characterThoughts = 'Present Characters\n---\n'; - } - - const lines = lastGeneratedData.characterThoughts.split('\n'); - let characterFound = false; - - const updatedLines = lines.map(line => { - // Case-insensitive character name matching - if (line.toLowerCase().includes(characterName.toLowerCase())) { - characterFound = true; - const parts = line.split('|').map(p => p.trim()); - if (parts.length >= 2) { - const firstPart = parts[0]; - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - let emoji = emojiMatch[1].trim(); - let info = emojiMatch[2].trim(); - let relationship = parts[1]; - let thoughts = parts[2] || ''; - - const infoParts = info.split(',').map(p => p.trim()); - let name = infoParts[0]; - let traits = infoParts.slice(1).join(', '); - - if (field === 'emoji') { - emoji = value; - } else if (field === 'name') { - name = value; - } else if (field === 'traits') { - traits = value; - } else if (field === 'thoughts') { - thoughts = value; - } else if (field === 'relationship') { - const emojiToRelationship = { - '⚔️': 'Enemy', - '⚖️': 'Neutral', - '⭐': 'Friend', - '❤️': 'Lover' - }; - relationship = emojiToRelationship[value] || value; - } - - const newInfo = traits ? `${name}, ${traits}` : name; - return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`; - } - } - } - return line; - }); - - // If character wasn't found, create a new character line - if (!characterFound) { - // Find the divider line - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - // Create initial character line with the edited field - let emoji = '😊'; - let name = characterName; - let traits = 'Traits'; - let relationship = 'Neutral'; - let thoughts = ''; - - // Apply the edited field - if (field === 'emoji') { - emoji = value; - } else if (field === 'name') { - name = value; - } else if (field === 'traits') { - traits = value; - } else if (field === 'thoughts') { - thoughts = value; - } else if (field === 'relationship') { - const emojiToRelationship = { - '⚔️': 'Enemy', - '⚖️': 'Neutral', - '⭐': 'Friend', - '❤️': 'Lover' - }; - relationship = emojiToRelationship[value] || value; - } - - const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`; - // Insert after the divider - updatedLines.splice(dividerIndex + 1, 0, newCharacterLine); - } - } - - lastGeneratedData.characterThoughts = updatedLines.join('\n'); - // console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - - // Also update the last assistant message's swipe data - const chat = getContext().chat; - if (chat && chat.length > 0) { - // Find the last assistant message - for (let i = chat.length - 1; i >= 0; i--) { - const message = chat[i]; - if (!message.is_user) { - // Found last assistant message - update its swipe data - if (message.extra && message.extra.rpg_companion_swipes) { - const swipeId = message.swipe_id || 0; - if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].characterThoughts = updatedLines.join('\n'); - // console.log('[RPG Companion] Updated thoughts in message swipe data'); - } - } - break; - } - } - } - - saveChatData(); - - // Always update the sidebar panel - renderThoughts(); - - // For thoughts edited from the bubble, delay recreation to allow blur event to complete - // This ensures the edit is saved first, then the bubble is recreated with correct layout - if (field === 'thoughts') { - setTimeout(() => { - updateChatThoughts(); - }, 100); - } else { - // For other fields, recreate immediately - updateChatThoughts(); - } -} - -/** - * Updates or removes thought overlays in the chat. - */ -function updateChatThoughts() { - // console.log('[RPG Companion] ======== updateChatThoughts called ========'); - // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled); - // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat); - // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked')); - // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - - // Remove existing thought panel and icon - $('#rpg-thought-panel').remove(); - $('#rpg-thought-icon').remove(); - $('#chat').off('scroll.thoughtPanel'); - $(window).off('resize.thoughtPanel'); - $(document).off('click.thoughtPanel'); - - // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return - if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { - // console.log('[RPG Companion] Thoughts in chat disabled or no data'); - return; - } - - // Parse the Present Characters data to get thoughts - const lines = lastGeneratedData.characterThoughts.split('\n'); - const thoughtsArray = []; // Array of {name, emoji, thought} - - // console.log('[RPG Companion] Parsing thoughts from lines:', lines); - - for (const line of lines) { - if (line.trim() && - !line.includes('Present Characters') && - !line.includes('---') && - !line.trim().startsWith('```')) { - - const parts = line.split('|').map(p => p.trim()); - // console.log('[RPG Companion] Line parts:', parts); - - if (parts.length >= 3) { - const firstPart = parts[0].trim(); - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - const emoji = emojiMatch[1].trim(); - const info = emojiMatch[2].trim(); - const thoughts = parts[2] ? parts[2].trim() : ''; - - const infoParts = info.split(',').map(p => p.trim()); - const name = infoParts[0] || ''; - - // console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts); - - if (name && thoughts && name.toLowerCase() !== 'unavailable') { - thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts }); - // console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase()); - } - } - } - } - } - - // If no thoughts parsed, return - if (thoughtsArray.length === 0) { - // console.log('[RPG Companion] No thoughts parsed, returning'); - return; - } - - // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); - // console.log('[RPG Companion] Thoughts array:', thoughtsArray); - - // Find the last message to position near - const $messages = $('#chat .mes'); - let $targetMessage = null; - - // Find the most recent non-user message - for (let i = $messages.length - 1; i >= 0; i--) { - const $message = $messages.eq(i); - if ($message.attr('is_user') !== 'true') { - $targetMessage = $message; - break; - } - } - - if (!$targetMessage) { - // console.log('[RPG Companion] No target message found'); - return; - } - - // Create the thought panel with all thoughts - createThoughtPanel($targetMessage, thoughtsArray); -} - -/** - * Creates or updates the floating thought panel positioned next to the character's avatar - */ -function createThoughtPanel($message, thoughtsArray) { - // Remove existing thought panel - $('#rpg-thought-panel').remove(); - $('#rpg-thought-icon').remove(); - - // Get the avatar position from the message - const $avatar = $message.find('.avatar img'); - if (!$avatar.length) { - // console.log('[RPG Companion] No avatar found in message'); - return; - } - - const avatarRect = $avatar[0].getBoundingClientRect(); - const panelPosition = extensionSettings.panelPosition; - const theme = extensionSettings.theme; - - // Build thought bubbles HTML - let thoughtsHtml = ''; - thoughtsArray.forEach((thought, index) => { - thoughtsHtml += ` -
-
- ${thought.emoji} -
-
- ${thought.thought} -
-
- `; - // Add divider between thoughts (except for last one) - if (index < thoughtsArray.length - 1) { - thoughtsHtml += '
'; - } - }); - - // Create the floating thought panel with theme - const $thoughtPanel = $(` -
- -
-
-
-
-
-
- ${thoughtsHtml} -
-
- `); - - // Create the collapsed thought icon - const $thoughtIcon = $(` -
- 💭 -
- `); - - // Apply custom theme colors if custom theme - if (theme === 'custom') { - const customStyles = { - '--rpg-bg': extensionSettings.customColors.bg, - '--rpg-accent': extensionSettings.customColors.accent, - '--rpg-text': extensionSettings.customColors.text, - '--rpg-highlight': extensionSettings.customColors.highlight - }; - $thoughtPanel.css(customStyles); - $thoughtIcon.css(customStyles); - } - - // Force a consistent width for the bubble to ensure proper positioning - $thoughtPanel.css('width', '350px'); - - // Append to body so it's not clipped by chat container - $('body').append($thoughtPanel); - $('body').append($thoughtIcon); - - // Position the panel next to the avatar - const panelWidth = 350; - const panelMargin = 20; - - let top = avatarRect.top + (avatarRect.height / 2); - let left; - let right; - let useRightPosition = false; - let iconTop = avatarRect.top; - let iconLeft; - - // Detect mobile viewport (matches CSS breakpoint) - const isMobile = window.innerWidth <= 1000; - - if (isMobile) { - // On mobile: position icon horizontally centered on avatar - // The CSS transform will shift it upward by 60px - iconTop = avatarRect.top; // Start at avatar top (CSS will move it up) - iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width) - - // Center the thought panel horizontally on mobile - left = window.innerWidth / 2 - panelWidth / 2; - top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing - - // No side-specific classes on mobile - $thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right'); - $thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right'); - - console.log('[RPG Companion] Mobile thought icon positioning:', { - isMobile, - windowWidth: window.innerWidth, - avatarLeft: avatarRect.left, - avatarWidth: avatarRect.width, - iconLeft, - iconTop - }); - } else if (panelPosition === 'left') { - // Main panel is on left, so thought bubble goes to RIGHT side - // Mirror the left side positioning: bubble should be same distance from avatar - // but on the opposite side, extending to the right - const chatContainer = $('#chat')[0]; - const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; - - // Position bubble starting from chat edge, extending right - left = chatRect.right + panelMargin; // Start at chat's right edge + margin - useRightPosition = false; // Use left positioning so it extends right - iconLeft = chatRect.right + 10; // Icon just at the chat edge - $thoughtPanel.addClass('rpg-thought-panel-right'); - $thoughtIcon.addClass('rpg-thought-icon-right'); - - // Position circles to flow from left (toward chat/avatar) to right (toward panel) - $thoughtPanel.find('.rpg-thought-circles').css({ - top: 'calc(50% - 50px)', - left: '-25px', - bottom: 'auto', - right: 'auto' - }); - // Mirror the circle flow for right side (left-to-right) - $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start'); - $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' }); - $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' }); - $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' }); - } else { - // Main panel is on right, so thought bubble goes on left (near avatar) - left = avatarRect.left - panelWidth - panelMargin; - iconLeft = avatarRect.left - 40; - $thoughtPanel.addClass('rpg-thought-panel-left'); - $thoughtIcon.addClass('rpg-thought-icon-left'); - - // Position circles to flow from avatar (left) to bubble (more left) - // Circles should flow right-to-left when bubble is on left - $thoughtPanel.find('.rpg-thought-circles').css({ - top: 'calc(50% - 50px)', - right: '-25px', - bottom: 'auto', - left: 'auto' - }); - // Keep the circle flow for left side (right-to-left) - default from CSS - $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end'); - $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' }); - $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' }); - $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' }); - } - - if (useRightPosition) { - $thoughtPanel.css({ - top: `${top}px`, - right: `${right}px`, - left: 'auto' // Clear left positioning - }); - } else { - $thoughtPanel.css({ - top: `${top}px`, - left: `${left}px`, - right: 'auto' // Clear right positioning - }); - } - - $thoughtIcon.css({ - top: `${iconTop}px`, - left: `${iconLeft}px`, - right: 'auto' // Clear any right positioning - }); - - // Initially hide the panel and show the icon - $thoughtPanel.hide(); - $thoughtIcon.show(); - - // console.log('[RPG Companion] Thought panel created at:', { top, left }); - - // Close button functionality - $thoughtPanel.find('.rpg-thought-close').on('click', function(e) { - e.stopPropagation(); - $thoughtPanel.fadeOut(200); - $thoughtIcon.fadeIn(200); - }); - - // Icon click to show panel - $thoughtIcon.on('click', function(e) { - e.stopPropagation(); - $thoughtIcon.fadeOut(200); - $thoughtPanel.fadeIn(200); - }); - - // Add event handlers for editable thoughts in the bubble - $thoughtPanel.find('.rpg-editable').on('blur', function() { - const character = $(this).data('character'); - const field = $(this).data('field'); - const value = $(this).text().trim(); - // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value); - updateCharacterField(character, field, value); - }); - - // RAF throttling for smooth position updates - let positionUpdateRaf = null; - - // Update position on scroll with RAF throttling - const updatePanelPosition = () => { - if (!$message.is(':visible')) { - $thoughtPanel.hide(); - $thoughtIcon.hide(); - return; - } - - // Cancel any pending RAF - if (positionUpdateRaf) { - cancelAnimationFrame(positionUpdateRaf); - } - - // Schedule update on next frame - positionUpdateRaf = requestAnimationFrame(() => { - const newAvatarRect = $avatar[0].getBoundingClientRect(); - const newTop = newAvatarRect.top + (newAvatarRect.height / 2); - const newIconTop = newAvatarRect.top; - let newLeft, newIconLeft; - - if (panelPosition === 'left') { - // Position at chat's right edge, extending right - const chatContainer = $('#chat')[0]; - const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; - newLeft = chatRect.right + panelMargin; - newIconLeft = chatRect.right + 10; - - $thoughtPanel.css({ - top: `${newTop}px`, - left: `${newLeft}px`, - right: 'auto' - }); - } else { - // Left position relative to avatar - newLeft = newAvatarRect.left - panelWidth - panelMargin; - newIconLeft = newAvatarRect.left - 40; - - $thoughtPanel.css({ - top: `${newTop}px`, - left: `${newLeft}px`, - right: 'auto' - }); - } - - $thoughtIcon.css({ - top: `${newIconTop}px`, - left: `${newIconLeft}px`, - right: 'auto' - }); - - if ($thoughtPanel.is(':visible')) { - $thoughtPanel.show(); - } - if ($thoughtIcon.is(':visible')) { - $thoughtIcon.show(); - } - - positionUpdateRaf = null; - }); - }; - - // Update position on scroll and resize - $('#chat').on('scroll.thoughtPanel', updatePanelPosition); - $(window).on('resize.thoughtPanel', updatePanelPosition); - - // Remove panel when clicking outside (but not when clicking icon or panel) - $(document).on('click.thoughtPanel', function(e) { - if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) { - // Hide the panel and show the icon instead of removing - $thoughtPanel.fadeOut(200); - $thoughtIcon.fadeIn(200); - } - }); -} - -/** - * Event handler for when generation is about to start (TOGETHER MODE). - * Injects RPG tracking prompt into the generation. - * @param {string} type - Generation type - * @param {object} data - Generation data including quietImage flag - */ -function onGenerationStarted(type, data) { - // console.log('[RPG Companion] onGenerationStarted called'); - // console.log('[RPG Companion] enabled:', extensionSettings.enabled); - // console.log('[RPG Companion] generationMode:', extensionSettings.generationMode); - // console.log('[RPG Companion] ⚡ EVENT: onGenerationStarted - lastActionWasSwipe =', lastActionWasSwipe, '| isGenerating =', isGenerating); - // console.log('[RPG Companion] Committed Prompt:', committedTrackerData); - - // Skip tracker injection for image generation requests - if (data?.quietImage) { - // console.log('[RPG Companion] Detected image generation (quietImage=true), skipping tracker injection'); - return; - } - - if (!extensionSettings.enabled) { - return; - } - - const chat = getContext().chat; - const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; - - // For SEPARATE mode only: 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' && !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) - } - } - - // For TOGETHER mode: Check if we need to commit extension data - // Same logic as separate mode - commit on new messages, keep existing data on swipes - if (extensionSettings.generationMode === 'together') { - if (!lastActionWasSwipe) { - // User sent a new message - commit lastGeneratedData before generation - // console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: New message - committing lastGeneratedData'); - committedTrackerData.userStats = lastGeneratedData.userStats; - committedTrackerData.infoBox = lastGeneratedData.infoBox; - committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; - } else { - //console.log('[RPG Companion] 🔄 TOGETHER MODE SWIPE: Using existing committedTrackerData (no commit)'); - } - } - - // Use the committed tracker data as source for generation - // console.log('[RPG Companion] Using committedTrackerData for generation'); - // console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats); - - // Parse stats from committed data to update the extensionSettings for prompt generation - if (committedTrackerData.userStats) { - // console.log('[RPG Companion] Parsing committed userStats into extensionSettings'); - parseUserStats(committedTrackerData.userStats); - // console.log('[RPG Companion] After parsing, extensionSettings.userStats:', JSON.stringify(extensionSettings.userStats)); - } - - if (extensionSettings.generationMode === 'together') { - // console.log('[RPG Companion] In together mode, generating prompts...'); - const example = generateTrackerExample(); - // Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes - const instructions = generateTrackerInstructions(false, true); - - // console.log('[RPG Companion] Example:', example ? 'exists' : 'empty'); - // console.log('[RPG Companion] Chat length:', chat ? chat.length : 'chat is null'); - - // Find the last assistant message in the chat history - let lastAssistantDepth = -1; // -1 means not found - if (chat && chat.length > 0) { - // console.log('[RPG Companion] Searching for last assistant message...'); - // Start from depth 1 (skip depth 0 which is usually user's message or prefill) - for (let depth = 1; depth < chat.length; depth++) { - const index = chat.length - 1 - depth; // Convert depth to index - const message = chat[index]; - // console.log('[RPG Companion] Checking depth', depth, 'index', index, 'message properties:', Object.keys(message)); - // Check for assistant message: not user and not system - if (!message.is_user && !message.is_system) { - // Found assistant message at this depth - // Inject at the SAME depth to prepend to this assistant message - lastAssistantDepth = depth; - // console.log('[RPG Companion] Found last assistant message at depth', depth, '-> injecting at same depth:', lastAssistantDepth); - break; - } - } - } - - // If we have previous tracker data and found an assistant message, inject it as an assistant message - if (example && lastAssistantDepth > 0) { - setExtensionPrompt('rpg-companion-example', example, extension_prompt_types.IN_CHAT, lastAssistantDepth, false, extension_prompt_roles.ASSISTANT); - // console.log('[RPG Companion] Injected tracker example as assistant message at depth:', lastAssistantDepth); - } else { - // console.log('[RPG Companion] NOT injecting example. example:', !!example, 'lastAssistantDepth:', lastAssistantDepth); - } - - // Inject the instructions as a user message at depth 0 (right before generation) - setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER); - // console.log('[RPG Companion] Injected RPG tracking instructions at depth 0 (right before generation)'); - - // Inject HTML prompt separately at depth 0 if enabled (prevents duplication on swipes) - if (extensionSettings.enableHtmlPrompt) { - const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: -- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. -- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. -- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. -- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. -- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; - - setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false); - // console.log('[RPG Companion] Injected HTML prompt at depth 0 for together mode'); - } else { - // Clear HTML prompt if disabled - setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); - } - } else if (extensionSettings.generationMode === 'separate') { - // In SEPARATE mode, inject the contextual summary for main roleplay generation - const contextSummary = generateContextualSummary(); - - if (contextSummary) { - const wrappedContext = `Here is context information about the current scene, and what follows is the last message in the chat history: - -${contextSummary} - -Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses. - - -`; - - // Inject context at depth 1 (before last user message) as SYSTEM - setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false); - // console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary); - } else { - // Clear if no data yet - setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); - } - - // Inject HTML prompt separately at depth 0 if enabled (same as together mode pattern) - if (extensionSettings.enableHtmlPrompt) { - const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: -- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. -- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. -- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. -- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. -- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; - - setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false); - // console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate mode'); - } else { - // Clear HTML prompt if disabled - setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); - } - - // Clear together mode injections - setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); - setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); - } else { - // Clear all injections - 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-context', '', extension_prompt_types.IN_CHAT, 1, false); - } -} - -/** - * 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. - */ -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) { - // 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]; - - 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 { - // console.log('[RPG Companion] No swipe data found for swipe', swipeId); - } - } else { - // console.log('[RPG Companion] No RPG data found in last assistant message'); - } - break; - } - } -} - -/** - * Event handler for when the user sends a message. - * Sets the flag to indicate this is NOT a swipe. - */ -function onMessageSent() { - if (!extensionSettings.enabled) return; - - // User sent a new message - NOT a swipe - lastActionWasSwipe = false; - // console.log('[RPG Companion] 🟢 EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe); -} - -/** - * Event handler for when a message is generated. - */ -async function onMessageReceived(data) { - if (!extensionSettings.enabled) { - return; - } - - if (extensionSettings.generationMode === 'together') { - // In together mode, parse the response to extract RPG data - // The message should be in chat[chat.length - 1] - const lastMessage = chat[chat.length - 1]; - if (lastMessage && !lastMessage.is_user) { - const responseText = lastMessage.mes; - // console.log('[RPG Companion] Parsing together mode response:', responseText); - - const parsedData = parseResponse(responseText); - - // Update stored data - 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; - lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { - userStats: parsedData.userStats, - infoBox: parsedData.infoBox, - characterThoughts: parsedData.characterThoughts - }; - - // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); - - // If there's no committed data yet (first time generating), automatically commit - if (!committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts) { - committedTrackerData.userStats = parsedData.userStats; - committedTrackerData.infoBox = parsedData.infoBox; - committedTrackerData.characterThoughts = parsedData.characterThoughts; - // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); - } else { - // console.log('[RPG Companion] Data will be committed when user replies'); - } - - // Remove the tracker code blocks from the visible message - let cleanedMessage = responseText; - // Remove all code blocks that contain tracker data - 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'); - - // 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(); - } - - // console.log('[RPG Companion] Cleaned message, removed tracker code blocks'); - - // Render the updated data - renderUserStats(); - renderInfoBox(); - renderThoughts(); - - // Save to chat metadata - saveChatData(); - } - } else if (extensionSettings.generationMode === 'separate' && extensionSettings.autoUpdate) { - // In separate mode with auto-update, trigger update after message - setTimeout(async () => { - await updateRPGData(); - }, 500); - } - - // 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'); - lastActionWasSwipe = 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) { - isPlotProgression = false; - // console.log('[RPG Companion] Plot progression generation completed'); - } -} - -/** - * Event handler for character change. - */ -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'); - - // Load chat-specific data when switching chats - loadChatData(); - - // Commit tracker data from the last assistant message to initialize for this chat - commitTrackerData(); - - // Re-render with the loaded data - renderUserStats(); - renderInfoBox(); - renderThoughts(); - - // Update chat thought overlays - updateChatThoughts(); -} - -/** - * Event handler for when a message is swiped. - * Loads the RPG data for the swipe the user navigated to. - */ -function onMessageSwiped(messageIndex) { - if (!extensionSettings.enabled) { - return; - } - - // console.log('[RPG Companion] Message swiped at index:', messageIndex); - - // Get the message that was swiped - const message = chat[messageIndex]; - if (!message || message.is_user) { - 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 - lastActionWasSwipe = true; - // console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (NEW generation) - lastActionWasSwipe =', lastActionWasSwipe); - } else { - // This is navigating to an EXISTING swipe - don't change the flag - // console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (existing swipe navigation) - lastActionWasSwipe unchanged =', lastActionWasSwipe); - } - - // console.log('[RPG Companion] Loading data for swipe', currentSwipeId); - - // Load RPG data for this swipe into lastGeneratedData (for display only) - // This updates what the user sees, but does NOT commit it - // Committed data will be updated when/if the user replies to this swipe - if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) { - const swipeData = message.extra.rpg_companion_swipes[currentSwipeId]; - - // Update display data - lastGeneratedData.userStats = swipeData.userStats || null; - lastGeneratedData.infoBox = swipeData.infoBox || null; - lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; - - // Parse user stats if available - if (swipeData.userStats) { - parseUserStats(swipeData.userStats); - } - - // console.log('[RPG Companion] Loaded RPG data for swipe', currentSwipeId, '(display only, NOT committed)'); - // console.log('[RPG Companion] committedTrackerData unchanged - will be updated if user replies to this swipe'); - } else { - // No data for this swipe - keep existing lastGeneratedData (don't clear it) - // This ensures the display remains consistent and data is available for next commit - // console.log('[RPG Companion] No RPG data for swipe', currentSwipeId, '- keeping existing lastGeneratedData'); - } - - // Re-render the panels (display only - committedTrackerData unchanged) - renderUserStats(); - renderInfoBox(); - renderThoughts(); - - // Update chat thought overlays - updateChatThoughts(); -} - -/** - * Automatically imports the HTML cleaning regex script if it doesn't already exist. - * This regex removes HTML tags from outgoing prompts to prevent formatting issues. - */ -async function ensureHtmlCleaningRegex() { - try { - // Check if the HTML cleaning regex already exists - const scriptName = 'Clean HTML (From Outgoing Prompt)'; - const existingScripts = st_extension_settings?.regex || []; - const alreadyExists = existingScripts.some(script => script.scriptName === scriptName); - - if (alreadyExists) { - console.log('[RPG Companion] HTML cleaning regex already exists, skipping import'); - return; - } - - // Generate a UUID for the script - const uuidv4 = () => { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - }; - - // Create the regex script object based on the attached file - const regexScript = { - id: uuidv4(), - scriptName: scriptName, - findRegex: '/\\s?<(?!\\!--)(?:\"[^\"]*\"|\'[^\']*\'|[^\'\">])*>/g', - replaceString: '', - trimStrings: [], - placement: [2], // 2 = Input (affects outgoing prompt) - disabled: false, - markdownOnly: false, - promptOnly: true, - runOnEdit: true, - substituteRegex: 0, - minDepth: null, - maxDepth: null - }; - - // Add to global regex scripts - if (!Array.isArray(st_extension_settings.regex)) { - st_extension_settings.regex = []; - } - - st_extension_settings.regex.push(regexScript); - - // Save the changes using the already-imported function - saveSettingsDebounced(); - - console.log('[RPG Companion] ✅ HTML cleaning regex imported successfully'); - } catch (error) { - console.error('[RPG Companion] Failed to import HTML cleaning regex:', error); - // Don't throw - this is a nice-to-have feature - } -} - -/** - * Update the persona avatar image when user switches personas - */ -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'); - } -} /** * Main initialization function. @@ -4695,19 +427,18 @@ jQuery(async () => { loadChatData(); // Import the HTML cleaning regex if needed - await ensureHtmlCleaningRegex(); + await ensureHtmlCleaningRegex(st_extension_settings, saveSettingsDebounced); - // Register event listeners - eventSource.on(event_types.MESSAGE_SENT, onMessageSent); - eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted); - eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived); - // Removed CHARACTER_MESSAGE_RENDERED to prevent race condition with cleaned messages - eventSource.on(event_types.CHAT_CHANGED, onCharacterChanged); - eventSource.on(event_types.MESSAGE_SWIPED, onMessageSwiped); - // Update persona avatar when user switches personas or chat changes - eventSource.on(event_types.CHAT_CHANGED, updatePersonaAvatar); - eventSource.on(event_types.USER_MESSAGE_RENDERED, updatePersonaAvatar); - eventSource.on(event_types.SETTINGS_UPDATED, updatePersonaAvatar); + // Register all event listeners + registerAllEvents({ + [event_types.MESSAGE_SENT]: onMessageSent, + [event_types.GENERATION_STARTED]: onGenerationStarted, + [event_types.MESSAGE_RECEIVED]: onMessageReceived, + [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar], + [event_types.MESSAGE_SWIPED]: onMessageSwiped, + [event_types.USER_MESSAGE_RENDERED]: updatePersonaAvatar, + [event_types.SETTINGS_UPDATED]: updatePersonaAvatar + }); // console.log('[RPG Companion] Extension loaded successfully'); } catch (error) { diff --git a/src/core/config.js b/src/core/config.js new file mode 100644 index 0000000..2ddd8b0 --- /dev/null +++ b/src/core/config.js @@ -0,0 +1,77 @@ +/** + * Core Configuration Module + * Extension metadata and configuration constants + */ + +// Type imports +/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */ + +export const extensionName = 'third-party/rpg-companion-sillytavern'; + +/** + * Dynamically determine extension path based on current location + * This supports both global (public/extensions) and user-specific (data/default-user/extensions) installations + */ +const currentScriptPath = import.meta.url; +const isUserExtension = currentScriptPath.includes('/data/') || currentScriptPath.includes('\\data\\'); +export const extensionFolderPath = isUserExtension + ? `data/default-user/extensions/${extensionName}` + : `scripts/extensions/${extensionName}`; + +/** + * Default extension settings + */ +export const defaultSettings = { + enabled: true, + autoUpdate: true, + updateDepth: 4, // How many messages to include in the context + generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately + showUserStats: true, + showInfoBox: true, + showCharacterThoughts: true, + showInventory: true, // Show inventory section (v2 system) + showThoughtsInChat: true, // Show thoughts overlay in chat + enableHtmlPrompt: false, // Enable immersive HTML prompt injection + enablePlotButtons: true, // Show plot progression buttons above chat input + panelPosition: 'right', // 'left', 'right', or 'top' + theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom + customColors: { + bg: '#1a1a2e', + accent: '#16213e', + text: '#eaeaea', + highlight: '#e94560' + }, + statBarColorLow: '#cc3333', // Color for low stat values (red) + statBarColorHigh: '#33cc66', // Color for high stat values (green) + enableAnimations: true, // Enable smooth animations for stats and content updates + mobileFabPosition: { + top: 'calc(var(--topBarBlockSize) + 60px)', + right: '12px' + }, // Saved position for mobile FAB button + userStats: { + health: 100, + satiety: 100, + energy: 100, + hygiene: 100, + arousal: 0, + mood: '😐', + conditions: 'None', + /** @type {InventoryV2} */ + inventory: { + version: 2, + onPerson: "None", + stored: {}, + assets: "None" + } + }, + classicStats: { + str: 10, + dex: 10, + con: 10, + int: 10, + wis: 10, + cha: 10 + }, + lastDiceRoll: null, // Store last dice roll result + collapsedInventoryLocations: [] // Array of collapsed storage location names +}; diff --git a/src/core/events.js b/src/core/events.js new file mode 100644 index 0000000..4b72fd3 --- /dev/null +++ b/src/core/events.js @@ -0,0 +1,88 @@ +/** + * Core Events Module + * Wrapper for SillyTavern event system + */ + +import { eventSource, event_types } from '../../../../../../script.js'; + +/** + * Register an event handler + * @param {string} eventType - Event type from event_types + * @param {Function} handler - Event handler function + */ +export function on(eventType, handler) { + eventSource.on(eventType, handler); +} + +/** + * Register a one-time event handler + * @param {string} eventType - Event type from event_types + * @param {Function} handler - Event handler function + */ +export function once(eventType, handler) { + eventSource.once(eventType, handler); +} + +/** + * Remove an event handler + * @param {string} eventType - Event type from event_types + * @param {Function} handler - Event handler function to remove + */ +export function off(eventType, handler) { + eventSource.off(eventType, handler); +} + +/** + * Emit an event + * @param {string} eventType - Event type to emit + * @param {...*} args - Arguments to pass to handlers + */ +export function emit(eventType, ...args) { + eventSource.emit(eventType, ...args); +} + +/** + * Re-export event types for convenience + */ +export { event_types }; + +// Store registered handlers for cleanup +const registeredHandlers = new Map(); + +/** + * Registers all extension event handlers + * @param {Object} handlers - Map of event types to handler functions or arrays of handler functions + * @example + * registerAllEvents({ + * [event_types.MESSAGE_SENT]: onMessageSent, + * [event_types.CHAT_CHANGED]: [onCharacterChanged, updatePersonaAvatar] + * }); + */ +export function registerAllEvents(handlers) { + for (const [eventType, handler] of Object.entries(handlers)) { + // Handler can be a single function or an array of functions + const handlerArray = Array.isArray(handler) ? handler : [handler]; + + for (const handlerFn of handlerArray) { + eventSource.on(eventType, handlerFn); + + // Store for later cleanup + if (!registeredHandlers.has(eventType)) { + registeredHandlers.set(eventType, []); + } + registeredHandlers.get(eventType).push(handlerFn); + } + } +} + +/** + * Unregisters all extension event handlers (for cleanup/reload) + */ +export function unregisterAllEvents() { + for (const [eventType, handlers] of registeredHandlers.entries()) { + for (const handler of handlers) { + eventSource.off(eventType, handler); + } + } + registeredHandlers.clear(); +} diff --git a/src/core/persistence.js b/src/core/persistence.js new file mode 100644 index 0000000..e143bdd --- /dev/null +++ b/src/core/persistence.js @@ -0,0 +1,170 @@ +/** + * Core Persistence Module + * Handles saving/loading extension settings and chat data + */ + +import { saveSettingsDebounced, chat_metadata, saveChatDebounced } from '../../../../../../script.js'; +import { power_user } from '../../../../../power-user.js'; +import { getContext } from '../../../../../extensions.js'; +import { + extensionSettings, + lastGeneratedData, + setExtensionSettings, + updateExtensionSettings, + setLastGeneratedData, + FEATURE_FLAGS +} from './state.js'; +import { migrateInventory } from '../utils/migration.js'; + +const extensionName = 'third-party/rpg-companion-sillytavern'; + +/** + * Loads the extension settings from the global settings object. + * Automatically migrates v1 inventory to v2 format if needed. + */ +export function loadSettings() { + if (power_user.extensions && power_user.extensions[extensionName]) { + updateExtensionSettings(power_user.extensions[extensionName]); + // console.log('[RPG Companion] Settings loaded:', extensionSettings); + } else { + // console.log('[RPG Companion] No saved settings found, using defaults'); + } + + // Migrate inventory if feature flag enabled + if (FEATURE_FLAGS.useNewInventory) { + const migrationResult = migrateInventory(extensionSettings.userStats.inventory); + if (migrationResult.migrated) { + console.log(`[RPG Companion] Inventory migrated from ${migrationResult.source} to v2 format`); + extensionSettings.userStats.inventory = migrationResult.inventory; + saveSettings(); // Persist migrated inventory + } + } +} + +/** + * Saves the extension settings to the global settings object. + */ +export function saveSettings() { + if (!power_user.extensions) { + power_user.extensions = {}; + } + power_user.extensions[extensionName] = extensionSettings; + saveSettingsDebounced(); +} + +/** + * Saves RPG data to the current chat's metadata. + */ +export function saveChatData() { + if (!chat_metadata) { + return; + } + + chat_metadata.rpg_companion = { + userStats: extensionSettings.userStats, + classicStats: extensionSettings.classicStats, + lastGeneratedData: lastGeneratedData, + timestamp: Date.now() + }; + + saveChatDebounced(); +} + +/** + * 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. + */ +export function updateMessageSwipeData() { + 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) { + // Found last assistant message - update its swipe data + if (!message.extra) { + message.extra = {}; + } + if (!message.extra.rpg_companion_swipes) { + message.extra.rpg_companion_swipes = {}; + } + + const swipeId = message.swipe_id || 0; + message.extra.rpg_companion_swipes[swipeId] = { + userStats: lastGeneratedData.userStats, + infoBox: lastGeneratedData.infoBox, + characterThoughts: lastGeneratedData.characterThoughts + }; + + // console.log('[RPG Companion] Updated message swipe data after user edit'); + break; + } + } +} + +/** + * Loads RPG data from the current chat's metadata. + * Automatically migrates v1 inventory to v2 format if needed. + */ +export function loadChatData() { + if (!chat_metadata || !chat_metadata.rpg_companion) { + // Reset to defaults if no data exists + updateExtensionSettings({ + userStats: { + health: 100, + satiety: 100, + energy: 100, + hygiene: 100, + arousal: 0, + mood: '😐', + conditions: 'None', + // Use v2 inventory format for defaults + inventory: { + version: 2, + onPerson: "None", + stored: {}, + assets: "None" + } + } + }); + setLastGeneratedData({ + userStats: null, + infoBox: null, + characterThoughts: null, + html: null + }); + return; + } + + const savedData = chat_metadata.rpg_companion; + + // Restore stats + if (savedData.userStats) { + extensionSettings.userStats = { ...savedData.userStats }; + } + + // Restore classic stats + if (savedData.classicStats) { + extensionSettings.classicStats = { ...savedData.classicStats }; + } + + // Restore last generated data + if (savedData.lastGeneratedData) { + setLastGeneratedData({ ...savedData.lastGeneratedData }); + } + + // Migrate inventory in chat data if feature flag enabled + if (FEATURE_FLAGS.useNewInventory && extensionSettings.userStats.inventory) { + const migrationResult = migrateInventory(extensionSettings.userStats.inventory); + if (migrationResult.migrated) { + console.log(`[RPG Companion] Chat inventory migrated from ${migrationResult.source} to v2 format`); + extensionSettings.userStats.inventory = migrationResult.inventory; + saveChatData(); // Persist migrated inventory to chat metadata + } + } + + // console.log('[RPG Companion] Loaded chat data:', savedData); +} diff --git a/src/core/state.js b/src/core/state.js new file mode 100644 index 0000000..295f611 --- /dev/null +++ b/src/core/state.js @@ -0,0 +1,200 @@ +/** + * Core State Management Module + * Centralizes all extension state variables + */ + +// Type imports +/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */ + +/** + * Extension settings - persisted to SillyTavern settings + */ +export let extensionSettings = { + enabled: true, + autoUpdate: true, + updateDepth: 4, // How many messages to include in the context + generationMode: 'together', // 'separate' or 'together' - whether to generate with main response or separately + showUserStats: true, + showInfoBox: true, + showCharacterThoughts: true, + showInventory: true, // Show inventory section (v2 system) + showThoughtsInChat: true, // Show thoughts overlay in chat + enableHtmlPrompt: false, // Enable immersive HTML prompt injection + enablePlotButtons: true, // Show plot progression buttons above chat input + panelPosition: 'right', // 'left', 'right', or 'top' + theme: 'default', // Theme: default, sci-fi, fantasy, cyberpunk, custom + customColors: { + bg: '#1a1a2e', + accent: '#16213e', + text: '#eaeaea', + highlight: '#e94560' + }, + statBarColorLow: '#cc3333', // Color for low stat values (red) + statBarColorHigh: '#33cc66', // Color for high stat values (green) + enableAnimations: true, // Enable smooth animations for stats and content updates + mobileFabPosition: { + top: 'calc(var(--topBarBlockSize) + 60px)', + right: '12px' + }, // Saved position for mobile FAB button + userStats: { + health: 100, + satiety: 100, + energy: 100, + hygiene: 100, + arousal: 0, + mood: '😐', + conditions: 'None', + /** @type {InventoryV2} */ + inventory: { + version: 2, + onPerson: "None", + stored: {}, + assets: "None" + } + }, + classicStats: { + str: 10, + dex: 10, + con: 10, + int: 10, + wis: 10, + cha: 10 + }, + lastDiceRoll: null, // Store last dice roll result + collapsedInventoryLocations: [], // Array of collapsed storage location names + inventoryViewModes: { + onPerson: 'list', // 'list' or 'grid' view mode for On Person section + stored: 'list', // 'list' or 'grid' view mode for Stored section + assets: 'list' // 'list' or 'grid' view mode for Assets section + } +}; + +/** + * Last generated data from AI response + */ +export let lastGeneratedData = { + userStats: null, + infoBox: null, + characterThoughts: null, + html: null +}; + +/** + * Tracks the "committed" tracker data that should be used as source for next generation + * This gets updated when user sends a new message or first time generation + */ +export let committedTrackerData = { + userStats: null, + infoBox: null, + characterThoughts: null +}; + +/** + * Tracks whether the last action was a swipe (for separate mode) + * Used to determine whether to commit lastGeneratedData to committedTrackerData + */ +export let lastActionWasSwipe = false; + +/** + * Flag indicating if generation is in progress + */ +export let isGenerating = false; + +/** + * Tracks if we're currently doing a plot progression + */ +export let isPlotProgression = false; + +/** + * Temporary storage for pending dice roll (not saved until user clicks "Save Roll") + */ +export let pendingDiceRoll = null; + +/** + * Feature flags for gradual rollout of new features + */ +export const FEATURE_FLAGS = { + useNewInventory: true // Enable v2 inventory system with categorized storage +}; + +/** + * Fallback avatar image (base64-encoded SVG with "?" icon) + * Using base64 to avoid quote-encoding issues in HTML attributes + */ +export const FALLBACK_AVATAR_DATA_URI = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2NjY2NjYyIgb3BhY2l0eT0iMC4zIi8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIiBmaWxsPSIjNjY2IiBmb250LXNpemU9IjQwIj4/PC90ZXh0Pjwvc3ZnPg=='; + +/** + * UI Element References (jQuery objects) + */ +export let $panelContainer = null; +export let $userStatsContainer = null; +export let $infoBoxContainer = null; +export let $thoughtsContainer = null; +export let $inventoryContainer = null; + +/** + * State setters - provide controlled mutation of state variables + */ +export function setExtensionSettings(newSettings) { + extensionSettings = newSettings; +} + +export function updateExtensionSettings(updates) { + Object.assign(extensionSettings, updates); +} + +export function setLastGeneratedData(data) { + lastGeneratedData = data; +} + +export function updateLastGeneratedData(updates) { + Object.assign(lastGeneratedData, updates); +} + +export function setCommittedTrackerData(data) { + committedTrackerData = data; +} + +export function updateCommittedTrackerData(updates) { + Object.assign(committedTrackerData, updates); +} + +export function setLastActionWasSwipe(value) { + lastActionWasSwipe = value; +} + +export function setIsGenerating(value) { + isGenerating = value; +} + +export function setIsPlotProgression(value) { + isPlotProgression = value; +} + +export function setPendingDiceRoll(roll) { + pendingDiceRoll = roll; +} + +export function getPendingDiceRoll() { + return pendingDiceRoll; +} + +export function setPanelContainer($element) { + $panelContainer = $element; +} + +export function setUserStatsContainer($element) { + $userStatsContainer = $element; +} + +export function setInfoBoxContainer($element) { + $infoBoxContainer = $element; +} + +export function setThoughtsContainer($element) { + $thoughtsContainer = $element; +} + +export function setInventoryContainer($element) { + $inventoryContainer = $element; +} diff --git a/src/systems/features/classicStats.js b/src/systems/features/classicStats.js new file mode 100644 index 0000000..281f1c4 --- /dev/null +++ b/src/systems/features/classicStats.js @@ -0,0 +1,42 @@ +/** + * Classic Stats Module + * Handles classic RPG stat buttons (STR, DEX, CON, INT, WIS, CHA) +/- controls + */ + +import { + extensionSettings, + $userStatsContainer +} from '../../core/state.js'; +import { saveSettings, saveChatData } from '../../core/persistence.js'; + +/** + * Sets up event listeners for classic stat +/- buttons using delegation. + * Uses delegated events to persist across re-renders of the stats section. + */ +export function setupClassicStatsButtons() { + if (!$userStatsContainer) return; + + // Delegated event listener for increase buttons + $userStatsContainer.on('click', '.rpg-stat-increase', function() { + const stat = $(this).data('stat'); + if (extensionSettings.classicStats[stat] < 100) { + extensionSettings.classicStats[stat]++; + saveSettings(); + saveChatData(); + // Update only the specific stat value, not the entire stats panel + $(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]); + } + }); + + // Delegated event listener for decrease buttons + $userStatsContainer.on('click', '.rpg-stat-decrease', function() { + const stat = $(this).data('stat'); + if (extensionSettings.classicStats[stat] > 1) { + extensionSettings.classicStats[stat]--; + saveSettings(); + saveChatData(); + // Update only the specific stat value, not the entire stats panel + $(this).closest('.rpg-classic-stat').find('.rpg-classic-stat-value').text(extensionSettings.classicStats[stat]); + } + }); +} diff --git a/src/systems/features/dice.js b/src/systems/features/dice.js new file mode 100644 index 0000000..92e691b --- /dev/null +++ b/src/systems/features/dice.js @@ -0,0 +1,113 @@ +/** + * Dice System Module + * Handles dice rolling logic, display updates, and quick reply integration + */ + +import { + extensionSettings, + pendingDiceRoll, + setPendingDiceRoll +} from '../../core/state.js'; +import { saveSettings } from '../../core/persistence.js'; + +/** + * Rolls the dice and displays result. + * Works with the DiceModal class for UI updates. + * @param {DiceModal} diceModal - The DiceModal instance + */ +export async function rollDice(diceModal) { + if (!diceModal) return; + + const count = parseInt(String($('#rpg-dice-count').val())) || 1; + const sides = parseInt(String($('#rpg-dice-sides').val())) || 20; + + // Start rolling animation + diceModal.startRolling(); + + // Wait for animation (simulate rolling) + await new Promise(resolve => setTimeout(resolve, 1200)); + + // Execute /roll command + const rollCommand = `/roll ${count}d${sides}`; + const rollResult = await executeRollCommand(rollCommand); + + // Parse result + const total = rollResult.total || 0; + const rolls = rollResult.rolls || []; + + // Store result temporarily (not saved until "Save Roll" is clicked) + setPendingDiceRoll({ + formula: `${count}d${sides}`, + total: total, + rolls: rolls, + timestamp: Date.now() + }); + + // Show result + diceModal.showResult(total, rolls); + + // Don't update sidebar display yet - only update when user clicks "Save Roll" +} + +/** + * Executes a /roll command and returns the result. + * @param {string} command - The roll command (e.g., "/roll 2d20") + * @returns {Promise<{total: number, rolls: Array}>} The roll result + */ +export async function executeRollCommand(command) { + try { + // Parse the dice notation (e.g., "2d20") + const match = command.match(/(\d+)d(\d+)/); + if (!match) { + return { total: 0, rolls: [] }; + } + + const count = parseInt(match[1]); + const sides = parseInt(match[2]); + const rolls = []; + let total = 0; + + for (let i = 0; i < count; i++) { + const roll = Math.floor(Math.random() * sides) + 1; + rolls.push(roll); + total += roll; + } + + return { total, rolls }; + } catch (error) { + console.error('[RPG Companion] Error rolling dice:', error); + return { total: 0, rolls: [] }; + } +} + +/** + * Updates the dice display in the sidebar. + */ +export function updateDiceDisplay() { + const lastRoll = extensionSettings.lastDiceRoll; + if (lastRoll) { + $('#rpg-last-roll-text').text(`Last Roll (${lastRoll.formula}): ${lastRoll.total}`); + } else { + $('#rpg-last-roll-text').text('Last Roll: None'); + } +} + +/** + * Clears the last dice roll. + */ +export function clearDiceRoll() { + extensionSettings.lastDiceRoll = null; + saveSettings(); + updateDiceDisplay(); +} + +/** + * Adds the Roll Dice quick reply button. + */ +export function addDiceQuickReply() { + // Create quick reply button if Quick Replies exist + if (window.quickReplyApi) { + // Quick Reply API integration would go here + // For now, the dice display in the sidebar serves as the button + } +} diff --git a/src/systems/features/htmlCleaning.js b/src/systems/features/htmlCleaning.js new file mode 100644 index 0000000..3c1e4b0 --- /dev/null +++ b/src/systems/features/htmlCleaning.js @@ -0,0 +1,65 @@ +/** + * HTML Cleaning Module + * Automatically imports HTML cleaning regex to strip HTML tags from outgoing prompts + */ + +/** + * Automatically imports the HTML cleaning regex script if it doesn't already exist. + * This regex removes HTML tags from outgoing prompts to prevent formatting issues. + * @param {Object} st_extension_settings - SillyTavern extension settings object + * @param {Function} saveSettingsDebounced - Function to save settings + */ +export async function ensureHtmlCleaningRegex(st_extension_settings, saveSettingsDebounced) { + try { + // Check if the HTML cleaning regex already exists + const scriptName = 'Clean HTML (From Outgoing Prompt)'; + const existingScripts = st_extension_settings?.regex || []; + const alreadyExists = existingScripts.some(script => script.scriptName === scriptName); + + if (alreadyExists) { + console.log('[RPG Companion] HTML cleaning regex already exists, skipping import'); + return; + } + + // Generate a UUID for the script + const uuidv4 = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + }; + + // Create the regex script object based on the attached file + const regexScript = { + id: uuidv4(), + scriptName: scriptName, + findRegex: '/\\s?<(?!\\!--)(?:\"[^\"]*\"|\'[^\']*\'|[^\'\">])*>/g', + replaceString: '', + trimStrings: [], + placement: [2], // 2 = Input (affects outgoing prompt) + disabled: false, + markdownOnly: false, + promptOnly: true, + runOnEdit: true, + substituteRegex: 0, + minDepth: null, + maxDepth: null + }; + + // Add to global regex scripts + if (!Array.isArray(st_extension_settings.regex)) { + st_extension_settings.regex = []; + } + + st_extension_settings.regex.push(regexScript); + + // Save the changes using the already-imported function + saveSettingsDebounced(); + + console.log('[RPG Companion] ✅ HTML cleaning regex imported successfully'); + } catch (error) { + console.error('[RPG Companion] Failed to import HTML cleaning regex:', error); + // Don't throw - this is a nice-to-have feature + } +} diff --git a/src/systems/features/plotProgression.js b/src/systems/features/plotProgression.js new file mode 100644 index 0000000..9af898b --- /dev/null +++ b/src/systems/features/plotProgression.js @@ -0,0 +1,133 @@ +/** + * Plot Progression Module + * Handles plot buttons (Random/Natural) UI setup and plot progression logic + */ + +import { togglePlotButtons } from '../ui/layout.js'; +import { extensionSettings, setIsPlotProgression } from '../../core/state.js'; +import { Generate } from '../../../../../../../script.js'; + +/** + * Sets up the plot progression buttons inside the send form area. + * @param {Function} handlePlotClick - Callback function to handle plot button clicks + */ +export function setupPlotButtons(handlePlotClick) { + // Remove existing buttons if any + $('#rpg-plot-buttons').remove(); + + // Create wrapper if it doesn't exist (shared with other extensions like Spotify) + if ($('#extension-buttons-wrapper').length === 0) { + $('#send_form').prepend('
'); + } + + // Create the button container + const buttonHtml = ` + + `; + + // Insert into the wrapper + $('#extension-buttons-wrapper').append(buttonHtml); + + // Add event handlers for buttons + $('#rpg-plot-random').on('click', () => handlePlotClick('random')); + $('#rpg-plot-natural').on('click', () => handlePlotClick('natural')); + + // Show/hide based on setting + togglePlotButtons(); +} + +/** + * Sends a plot progression request and appends the result to the last message. + * @param {string} type - 'random' or 'natural' + */ +export async function sendPlotProgression(type) { + if (!extensionSettings.enabled) { + // console.log('[RPG Companion] Extension is disabled'); + return; + } + + // Disable buttons to prevent multiple clicks + $('#rpg-plot-random, #rpg-plot-natural').prop('disabled', true).css('opacity', '0.5'); + + // Store original enabled state and temporarily disable extension + // This prevents RPG tracker instructions from being injected during plot progression + const wasEnabled = extensionSettings.enabled; + extensionSettings.enabled = false; + + try { + // console.log(`[RPG Companion] Sending ${type} plot progression request...`); + + // Build the prompt based on type + let prompt = ''; + if (type === 'random') { + prompt = 'Actually, the scene is getting stale. Introduce {{random::stakes::a plot twist::a new character::a cataclysm::a fourth-wall-breaking joke::a sudden atmospheric phenomenon::a plot hook::a running gag::an ecchi scenario::Death from Discworld::a new stake::a drama::a conflict::an angered entity::a god::a vision::a prophetic dream::Il Dottore from Genshin Impact::a new development::a civilian in need::an emotional bit::a threat::a villain::an important memory recollection::a marriage proposal::a date idea::an angry horde of villagers with pitchforks::a talking animal::an enemy::a cliffhanger::a short omniscient POV shift to a completely different character::a quest::an unexpected revelation::a scandal::an evil clone::death of an important character::harm to an important character::a romantic setup::a gossip::a messenger::a plot point from the past::a plot hole::a tragedy::a ghost::an otherworldly occurrence::a plot device::a curse::a magic device::a rival::an unexpected pregnancy::a brothel::a prostitute::a new location::a past lover::a completely random thing::a what-if scenario::a significant choice::war::love::a monster::lewd undertones::Professor Mari::a travelling troupe::a secret::a fortune-teller::something completely different::a killer::a murder mystery::a mystery::a skill check::a deus ex machina::three raccoons in a trench coat::a pet::a slave::an orphan::a psycho::tentacles::"there is only one bed" trope::accidental marriage::a fun twist::a boss battle::sexy corn::an eldritch horror::a character getting hungry, thirsty, or exhausted::horniness::a need for a bathroom break need::someone fainting::an assassination attempt::a meta narration of this all being an out of hand DND session::a dungeon::a friend in need::an old friend::a small time skip::a scene shift::Aurora Borealis, at this time of year, at this time of day, at this part of the country::a grand ball::a surprise party::zombies::foreshadowing::a Spanish Inquisition (nobody expects it)::a natural plot progression}} to make things more interesting! Be creative, but stay grounded in the setting.'; + } else { + prompt = 'Actually, the scene is getting stale. Progress it, to make things more interesting! Reintroduce an unresolved plot point from the past, or push the story further towards the current main goal. Be creative, but stay grounded in the setting.'; + } + + // Add HTML prompt if enabled + if (extensionSettings.enableHtmlPrompt) { + prompt += '\n\n' + `If appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: +- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. +- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. +- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. +- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. +- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; + } + + // Set flag to indicate we're doing plot progression + // This will be used by onMessageReceived to clear the prompt after generation completes + setIsPlotProgression(true); + + // console.log('[RPG Companion] Calling Generate with continuation and plot prompt'); + // console.log('[RPG Companion] Full prompt:', prompt); + + // Pass the prompt via options with the correct property name + // Based on /continue slash command implementation, it uses quiet_prompt (underscore, not camelCase) + const options = { + quiet_prompt: prompt, // Use underscore notation, not camelCase + quietToLoud: true + }; + + // Call Generate with 'continue' type and our custom prompt + await Generate('continue', options); + + // console.log('[RPG Companion] Plot progression generation triggered'); + } catch (error) { + console.error('[RPG Companion] Error sending plot progression:', error); + setIsPlotProgression(false); + } finally { + // Restore original enabled state and re-enable buttons after a delay + setTimeout(() => { + extensionSettings.enabled = wasEnabled; + $('#rpg-plot-random, #rpg-plot-natural').prop('disabled', false).css('opacity', '1'); + }, 1000); + } +} diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js new file mode 100644 index 0000000..a613be2 --- /dev/null +++ b/src/systems/generation/apiClient.js @@ -0,0 +1,157 @@ +/** + * API Client Module + * Handles API calls for RPG tracker generation + */ + +import { generateRaw, chat } from '../../../../../../../script.js'; +import { + extensionSettings, + lastGeneratedData, + committedTrackerData, + isGenerating, + lastActionWasSwipe, + setIsGenerating, + setLastActionWasSwipe +} from '../../core/state.js'; +import { saveChatData } from '../../core/persistence.js'; +import { generateSeparateUpdatePrompt } from './promptBuilder.js'; +import { parseResponse, parseUserStats } from './parser.js'; + +/** + * Updates RPG tracker data using separate API call (separate mode only). + * Makes a dedicated API call to generate tracker data, then stores it + * in the last assistant message's swipe data. + * + * @param {Function} renderUserStats - UI function to render user stats + * @param {Function} renderInfoBox - UI function to render info box + * @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) { + if (isGenerating) { + // console.log('[RPG Companion] Already generating, skipping...'); + return; + } + + if (!extensionSettings.enabled) { + return; + } + + if (extensionSettings.generationMode !== 'separate') { + // console.log('[RPG Companion] Not in separate mode, skipping manual update'); + return; + } + + try { + setIsGenerating(true); + + // Update button to show "Updating..." state + const $updateBtn = $('#rpg-manual-update'); + const originalHtml = $updateBtn.html(); + $updateBtn.html(' Updating...').prop('disabled', true); + + const prompt = generateSeparateUpdatePrompt(); + + // Generate using raw prompt (uses current preset, no chat history) + const response = await generateRaw({ + prompt: prompt, + quietToLoud: false + }); + + if (response) { + // console.log('[RPG Companion] Raw AI response:', response); + const parsedData = parseResponse(response); + // console.log('[RPG Companion] Parsed data:', parsedData); + // console.log('[RPG Companion] parsedData.userStats:', parsedData.userStats ? parsedData.userStats.substring(0, 100) + '...' : 'null'); + + // DON'T update lastGeneratedData here - it should only reflect the data + // from the assistant message the user replied to, not auto-generated updates + // This ensures swipes/regenerations use consistent source data + + // Store RPG data for the last assistant message (separate mode) + const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; + // console.log('[RPG Companion] Last message is_user:', lastMessage ? lastMessage.is_user : 'no message'); + if (lastMessage && !lastMessage.is_user) { + if (!lastMessage.extra) { + lastMessage.extra = {}; + } + if (!lastMessage.extra.rpg_companion_swipes) { + lastMessage.extra.rpg_companion_swipes = {}; + } + + const currentSwipeId = lastMessage.swipe_id || 0; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + userStats: parsedData.userStats, + infoBox: parsedData.infoBox, + characterThoughts: parsedData.characterThoughts + }; + + // console.log('[RPG Companion] Stored separate mode RPG data for message swipe', currentSwipeId); + + // Update lastGeneratedData for display AND future commit + if (parsedData.userStats) { + lastGeneratedData.userStats = parsedData.userStats; + parseUserStats(parsedData.userStats); + } + if (parsedData.infoBox) { + lastGeneratedData.infoBox = parsedData.infoBox; + } + if (parsedData.characterThoughts) { + lastGeneratedData.characterThoughts = parsedData.characterThoughts; + } + // console.log('[RPG Companion] 💾 SEPARATE MODE: Updated lastGeneratedData:', { + // userStats: lastGeneratedData.userStats ? 'exists' : 'null', + // infoBox: lastGeneratedData.infoBox ? 'exists' : 'null', + // characterThoughts: lastGeneratedData.characterThoughts ? 'exists' : 'null' + // }); + + // If there's no committed data yet (first time) or only has placeholder data, commit immediately + const hasNoRealData = !committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts; + const hasOnlyPlaceholderData = ( + (!committedTrackerData.userStats || committedTrackerData.userStats === '') && + (!committedTrackerData.infoBox || committedTrackerData.infoBox === 'Info Box\n---\n' || committedTrackerData.infoBox === '') && + (!committedTrackerData.characterThoughts || committedTrackerData.characterThoughts === 'Present Characters\n---\n' || committedTrackerData.characterThoughts === '') + ); + + if (hasNoRealData || hasOnlyPlaceholderData) { + committedTrackerData.userStats = parsedData.userStats; + committedTrackerData.infoBox = parsedData.infoBox; + committedTrackerData.characterThoughts = parsedData.characterThoughts; + // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); + } + + // Render the updated data + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + } else { + // No assistant message to attach to - just update display + if (parsedData.userStats) { + parseUserStats(parsedData.userStats); + } + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + } + + // Save to chat metadata + saveChatData(); + } + + } catch (error) { + console.error('[RPG Companion] Error updating RPG data:', error); + } finally { + setIsGenerating(false); + + // Restore button to original state + const $updateBtn = $('#rpg-manual-update'); + $updateBtn.html(' Refresh RPG Info').prop('disabled', false); + + // Reset the flag after tracker generation completes + // This ensures the flag persists through both main generation AND tracker generation + // console.log('[RPG Companion] 🔄 Tracker generation complete - resetting lastActionWasSwipe to false'); + setLastActionWasSwipe(false); + } +} diff --git a/src/systems/generation/injector.js b/src/systems/generation/injector.js new file mode 100644 index 0000000..c98d447 --- /dev/null +++ b/src/systems/generation/injector.js @@ -0,0 +1,218 @@ +/** + * Prompt Injector Module + * Handles injection of RPG tracker prompts into the generation context + */ + +import { getContext } from '../../../../../../extensions.js'; +import { setExtensionPrompt, extension_prompt_types, extension_prompt_roles } from '../../../../../../../script.js'; +import { + extensionSettings, + committedTrackerData, + lastGeneratedData, + isGenerating, + lastActionWasSwipe, + setLastActionWasSwipe +} from '../../core/state.js'; +import { parseUserStats } from './parser.js'; +import { + generateTrackerExample, + generateTrackerInstructions, + generateContextualSummary +} from './promptBuilder.js'; + +/** + * Event handler for generation start. + * Manages tracker data commitment and prompt injection based on generation mode. + * + * @param {string} type - Event type + * @param {Object} data - Event data + */ +export function onGenerationStarted(type, data) { + // console.log('[RPG Companion] onGenerationStarted called'); + // console.log('[RPG Companion] enabled:', extensionSettings.enabled); + // console.log('[RPG Companion] generationMode:', extensionSettings.generationMode); + // console.log('[RPG Companion] ⚡ EVENT: onGenerationStarted - lastActionWasSwipe =', lastActionWasSwipe, '| isGenerating =', isGenerating); + // console.log('[RPG Companion] Committed Prompt:', committedTrackerData); + + // Skip tracker injection for image generation requests + if (data?.quietImage) { + // console.log('[RPG Companion] Detected image generation (quietImage=true), skipping tracker injection'); + return; + } + + if (!extensionSettings.enabled) { + return; + } + + const chat = getContext().chat; + const lastMessage = chat && chat.length > 0 ? chat[chat.length - 1] : null; + + // For SEPARATE mode only: 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' && !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) + } + } + + // For TOGETHER mode: Check if we need to commit extension data + // Same logic as separate mode - commit on new messages, keep existing data on swipes + if (extensionSettings.generationMode === 'together') { + if (!lastActionWasSwipe) { + // User sent a new message - commit lastGeneratedData before generation + // console.log('[RPG Companion] 📝 TOGETHER MODE COMMIT: New message - committing lastGeneratedData'); + committedTrackerData.userStats = lastGeneratedData.userStats; + committedTrackerData.infoBox = lastGeneratedData.infoBox; + committedTrackerData.characterThoughts = lastGeneratedData.characterThoughts; + } else { + // console.log('[RPG Companion] 🔄 TOGETHER MODE SWIPE: Using existing committedTrackerData (no commit)'); + } + } + + // Use the committed tracker data as source for generation + // console.log('[RPG Companion] Using committedTrackerData for generation'); + // console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats); + + // Parse stats from committed data to update the extensionSettings for prompt generation + if (committedTrackerData.userStats) { + // console.log('[RPG Companion] Parsing committed userStats into extensionSettings'); + parseUserStats(committedTrackerData.userStats); + // console.log('[RPG Companion] After parsing, extensionSettings.userStats:', JSON.stringify(extensionSettings.userStats)); + } + + if (extensionSettings.generationMode === 'together') { + // console.log('[RPG Companion] In together mode, generating prompts...'); + const example = generateTrackerExample(); + // Don't include HTML prompt in instructions - inject it separately to avoid duplication on swipes + const instructions = generateTrackerInstructions(false, true); + + // console.log('[RPG Companion] Example:', example ? 'exists' : 'empty'); + // console.log('[RPG Companion] Chat length:', chat ? chat.length : 'chat is null'); + + // Find the last assistant message in the chat history + let lastAssistantDepth = -1; // -1 means not found + if (chat && chat.length > 0) { + // console.log('[RPG Companion] Searching for last assistant message...'); + // Start from depth 1 (skip depth 0 which is usually user's message or prefill) + for (let depth = 1; depth < chat.length; depth++) { + const index = chat.length - 1 - depth; // Convert depth to index + const message = chat[index]; + // console.log('[RPG Companion] Checking depth', depth, 'index', index, 'message properties:', Object.keys(message)); + // Check for assistant message: not user and not system + if (!message.is_user && !message.is_system) { + // Found assistant message at this depth + // Inject at the SAME depth to prepend to this assistant message + lastAssistantDepth = depth; + // console.log('[RPG Companion] Found last assistant message at depth', depth, '-> injecting at same depth:', lastAssistantDepth); + break; + } + } + } + + // If we have previous tracker data and found an assistant message, inject it as an assistant message + if (example && lastAssistantDepth > 0) { + setExtensionPrompt('rpg-companion-example', example, extension_prompt_types.IN_CHAT, lastAssistantDepth, false, extension_prompt_roles.ASSISTANT); + // console.log('[RPG Companion] Injected tracker example as assistant message at depth:', lastAssistantDepth); + } else { + // console.log('[RPG Companion] NOT injecting example. example:', !!example, 'lastAssistantDepth:', lastAssistantDepth); + } + + // Inject the instructions as a user message at depth 0 (right before generation) + setExtensionPrompt('rpg-companion-inject', instructions, extension_prompt_types.IN_CHAT, 0, false, extension_prompt_roles.USER); + // console.log('[RPG Companion] Injected RPG tracking instructions at depth 0 (right before generation)'); + + // Inject HTML prompt separately at depth 0 if enabled (prevents duplication on swipes) + if (extensionSettings.enableHtmlPrompt) { + const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: +- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. +- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. +- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. +- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. +- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; + + setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false); + // console.log('[RPG Companion] Injected HTML prompt at depth 0 for together mode'); + } else { + // Clear HTML prompt if disabled + setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); + } + } else if (extensionSettings.generationMode === 'separate') { + // In SEPARATE mode, inject the contextual summary for main roleplay generation + const contextSummary = generateContextualSummary(); + + if (contextSummary) { + const wrappedContext = `Here is context information about the current scene, and what follows is the last message in the chat history: + +${contextSummary} + +Ensure these details naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, or a character's emotional state coloring their responses. + + +`; + + // Inject context at depth 1 (before last user message) as SYSTEM + setExtensionPrompt('rpg-companion-context', wrappedContext, extension_prompt_types.IN_CHAT, 1, false); + // console.log('[RPG Companion] Injected contextual summary for separate mode:', contextSummary); + } else { + // Clear if no data yet + setExtensionPrompt('rpg-companion-context', '', extension_prompt_types.IN_CHAT, 1, false); + } + + // Inject HTML prompt separately at depth 0 if enabled (same as together mode pattern) + if (extensionSettings.enableHtmlPrompt) { + const htmlPrompt = `\nIf appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: +- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. +- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. +- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. +- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. +- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; + + setExtensionPrompt('rpg-companion-html', htmlPrompt, extension_prompt_types.IN_CHAT, 0, false); + // console.log('[RPG Companion] Injected HTML prompt at depth 0 for separate mode'); + } else { + // Clear HTML prompt if disabled + setExtensionPrompt('rpg-companion-html', '', extension_prompt_types.IN_CHAT, 0, false); + } + + // Clear together mode injections + setExtensionPrompt('rpg-companion-inject', '', extension_prompt_types.IN_CHAT, 0, false); + setExtensionPrompt('rpg-companion-example', '', extension_prompt_types.IN_CHAT, 0, false); + } else { + // Clear all injections + 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-context', '', extension_prompt_types.IN_CHAT, 1, false); + } +} diff --git a/src/systems/generation/inventoryParser.js b/src/systems/generation/inventoryParser.js new file mode 100644 index 0000000..f56e28d --- /dev/null +++ b/src/systems/generation/inventoryParser.js @@ -0,0 +1,132 @@ +/** + * Inventory Parser Module + * Extracts v2 inventory data from AI-generated text + */ + +// Type imports +/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ + +/** + * Extracts inventory data from AI-generated stats text in v2 multi-line format. + * + * Expected format from AI: + * ``` + * On Person: Sword (equipped), 3x Health Potions, Leather Armor + * Stored - Home: Spare clothes, Tools, 50 gold coins + * Stored - Bank: Family heirloom, Important documents + * Assets: Motorcycle (garage), Downtown apartment (owned) + * ``` + * + * @param {string} statsText - Raw stats text from AI response + * @returns {InventoryV2|null} Parsed inventory v2 object or null if not found + */ +export function extractInventoryData(statsText) { + if (!statsText || typeof statsText !== 'string') { + return null; + } + + const result = { + version: 2, + onPerson: "None", + stored: {}, + assets: "None" + }; + + let foundAnyInventoryData = false; + + // Split into lines for parsing + const lines = statsText.split('\n'); + + for (const line of lines) { + const trimmed = line.trim(); + + // Parse "On Person: ..." line + const onPersonMatch = trimmed.match(/^On Person:\s*(.+)$/i); + if (onPersonMatch) { + result.onPerson = onPersonMatch[1].trim() || "None"; + foundAnyInventoryData = true; + continue; + } + + // Parse "Stored - [Location]: ..." lines + const storedMatch = trimmed.match(/^Stored\s*-\s*([^:]+):\s*(.+)$/i); + if (storedMatch) { + const locationName = storedMatch[1].trim(); + const items = storedMatch[2].trim(); + if (locationName && items) { + result.stored[locationName] = items; + foundAnyInventoryData = true; + } + continue; + } + + // Parse "Assets: ..." line + const assetsMatch = trimmed.match(/^Assets:\s*(.+)$/i); + if (assetsMatch) { + result.assets = assetsMatch[1].trim() || "None"; + foundAnyInventoryData = true; + continue; + } + } + + // Return null if we didn't find any inventory data + return foundAnyInventoryData ? result : null; +} + +/** + * Attempts to parse legacy v1 inventory format (single line). + * Fallback for old AI responses that haven't been updated to v2 format. + * + * Expected format: "Inventory: Sword, Shield, 3x Potions, Gold coins" + * + * @param {string} text - Text that may contain legacy inventory + * @returns {string|null} Legacy inventory string or null + */ +export function extractLegacyInventory(text) { + if (!text || typeof text !== 'string') { + return null; + } + + // Match old single-line format: "Inventory: ..." + const match = text.match(/Inventory:\s*(.+?)(?:\n|$)/i); + if (match && match[1]) { + const inventoryText = match[1].trim(); + // Return null for empty values like "None" or "" + if (!inventoryText || inventoryText.toLowerCase() === 'none') { + return null; + } + return inventoryText; + } + + return null; +} + +/** + * Main inventory extraction function that tries v2 format first, then falls back to v1. + * Converts v1 format to v2 automatically if found. + * + * @param {string} statsText - Raw stats text from AI response + * @returns {InventoryV2|null} Parsed inventory in v2 format or null + */ +export function extractInventory(statsText) { + // Try v2 format first + const v2Data = extractInventoryData(statsText); + if (v2Data) { + return v2Data; + } + + // Fallback to v1 format and convert to v2 + const v1Data = extractLegacyInventory(statsText); + if (v1Data) { + // Convert v1 string to v2 format (place in onPerson) + return { + version: 2, + onPerson: v1Data, + stored: {}, + assets: "None" + }; + } + + // No inventory data found + return null; +} diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js new file mode 100644 index 0000000..60c6b70 --- /dev/null +++ b/src/systems/generation/parser.js @@ -0,0 +1,161 @@ +/** + * Parser Module + * Handles parsing of AI responses to extract tracker data + */ + +import { extensionSettings, FEATURE_FLAGS } from '../../core/state.js'; +import { saveSettings } from '../../core/persistence.js'; +import { extractInventory } from './inventoryParser.js'; + +/** + * Parses the model response to extract the different data sections. + * Extracts tracker data from markdown code blocks in the AI response. + * + * @param {string} responseText - The raw AI response text + * @returns {{userStats: string|null, infoBox: string|null, characterThoughts: string|null}} Parsed tracker data + */ +export function parseResponse(responseText) { + const result = { + userStats: null, + infoBox: null, + characterThoughts: null + }; + + // Extract code blocks + const codeBlockRegex = /```([^`]+)```/g; + const matches = [...responseText.matchAll(codeBlockRegex)]; + + // console.log('[RPG Companion] Found', matches.length, 'code blocks'); + + for (const match of matches) { + const content = match[1].trim(); + + // console.log('[RPG Companion] Checking code block (first 200 chars):', content.substring(0, 200)); + + // Match Stats section + if (content.match(/Stats\s*\n\s*---/i)) { + result.userStats = content; + // console.log('[RPG Companion] ✓ Found Stats section'); + } + // Match Info Box section + else if (content.match(/Info Box\s*\n\s*---/i)) { + result.infoBox = content; + // console.log('[RPG Companion] ✓ Found Info Box section'); + } + // Match Present Characters section - flexible matching + else if (content.match(/Present Characters\s*\n\s*---/i) || content.includes(" | ")) { + result.characterThoughts = content; + // console.log('[RPG Companion] ✓ Found Present Characters section:', content); + } else { + // console.log('[RPG Companion] ✗ Code block did not match any section'); + } + } + + // console.log('[RPG Companion] Parse results:', { + // hasStats: !!result.userStats, + // hasInfoBox: !!result.infoBox, + // hasThoughts: !!result.characterThoughts + // }); + + return result; +} + +/** + * Parses user stats from the text and updates the extensionSettings. + * Extracts percentages, mood, conditions, and inventory from the stats text. + * + * @param {string} statsText - The raw stats text from AI response + */ +export function parseUserStats(statsText) { + try { + // Extract percentages and mood/conditions + const healthMatch = statsText.match(/Health:\s*(\d+)%/); + const satietyMatch = statsText.match(/Satiety:\s*(\d+)%/); + const energyMatch = statsText.match(/Energy:\s*(\d+)%/); + const hygieneMatch = statsText.match(/Hygiene:\s*(\d+)%/); + const arousalMatch = statsText.match(/Arousal:\s*(\d+)%/); + + // Match new format: [Emoji]: [Conditions] + // Look for a line after Arousal that has format [something]: [text] + // Split by lines and find the line after percentages + const lines = statsText.split('\n'); + let moodMatch = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // Skip lines with percentages or "Inventory:" + if (line.includes('%') || line.toLowerCase().startsWith('inventory:')) continue; + // Match emoji followed by colon and conditions + const match = line.match(/^(.+?):\s*(.+)$/); + if (match) { + moodMatch = match; + break; + } + } + + // Extract inventory - use v2 parser if feature flag enabled, otherwise fallback to v1 + if (FEATURE_FLAGS.useNewInventory) { + const inventoryData = extractInventory(statsText); + if (inventoryData) { + extensionSettings.userStats.inventory = inventoryData; + } + } else { + // Legacy v1 parsing for backward compatibility + const inventoryMatch = statsText.match(/Inventory:\s*(.+)/i); + if (inventoryMatch) { + extensionSettings.userStats.inventory = inventoryMatch[1].trim(); + } + } + + if (healthMatch) extensionSettings.userStats.health = parseInt(healthMatch[1]); + if (satietyMatch) extensionSettings.userStats.satiety = parseInt(satietyMatch[1]); + if (energyMatch) extensionSettings.userStats.energy = parseInt(energyMatch[1]); + if (hygieneMatch) extensionSettings.userStats.hygiene = parseInt(hygieneMatch[1]); + if (arousalMatch) extensionSettings.userStats.arousal = parseInt(arousalMatch[1]); + if (moodMatch) { + extensionSettings.userStats.mood = moodMatch[1].trim(); // Emoji + extensionSettings.userStats.conditions = moodMatch[2].trim(); // Conditions + } + + saveSettings(); + } catch (error) { + console.error('[RPG Companion] Error parsing user stats:', error); + } +} + +/** + * Helper: Extract code blocks from text + * @param {string} text - Text containing markdown code blocks + * @returns {Array} Array of code block contents + */ +export function extractCodeBlocks(text) { + const codeBlockRegex = /```([^`]+)```/g; + const matches = [...text.matchAll(codeBlockRegex)]; + return matches.map(match => match[1].trim()); +} + +/** + * Helper: Parse stats section from code block content + * @param {string} content - Code block content + * @returns {boolean} True if this is a stats section + */ +export function isStatsSection(content) { + return content.match(/Stats\s*\n\s*---/i) !== null; +} + +/** + * Helper: Parse info box section from code block content + * @param {string} content - Code block content + * @returns {boolean} True if this is an info box section + */ +export function isInfoBoxSection(content) { + return content.match(/Info Box\s*\n\s*---/i) !== null; +} + +/** + * Helper: Parse character thoughts section from code block content + * @param {string} content - Code block content + * @returns {boolean} True if this is a character thoughts section + */ +export function isCharacterThoughtsSection(content) { + return content.match(/Present Characters\s*\n\s*---/i) !== null || content.includes(" | "); +} diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js new file mode 100644 index 0000000..a24135b --- /dev/null +++ b/src/systems/generation/promptBuilder.js @@ -0,0 +1,384 @@ +/** + * Prompt Builder Module + * Handles all AI prompt generation for RPG tracker data + */ + +import { getContext } from '../../../../../../extensions.js'; +import { chat } from '../../../../../../../script.js'; +import { extensionSettings, committedTrackerData, FEATURE_FLAGS } from '../../core/state.js'; + +// Type imports +/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ + +/** + * Builds a formatted inventory summary for AI context injection. + * Converts v2 inventory structure to multi-line plaintext format. + * + * @param {InventoryV2|string} inventory - Current inventory (v2 or legacy string) + * @returns {string} Formatted inventory summary for prompt injection + * @example + * // v2 input: { onPerson: "Sword", stored: { Home: "Gold" }, assets: "Horse", version: 2 } + * // Returns: "On Person: Sword\nStored - Home: Gold\nAssets: Horse" + */ +export function buildInventorySummary(inventory) { + // Handle legacy v1 string format + if (typeof inventory === 'string') { + return inventory; + } + + // Handle v2 object format + if (inventory && typeof inventory === 'object' && inventory.version === 2) { + let summary = ''; + + // Add On Person section + if (inventory.onPerson && inventory.onPerson !== 'None') { + summary += `On Person: ${inventory.onPerson}\n`; + } + + // Add Stored sections for each location + if (inventory.stored && Object.keys(inventory.stored).length > 0) { + for (const [location, items] of Object.entries(inventory.stored)) { + if (items && items !== 'None') { + summary += `Stored - ${location}: ${items}\n`; + } + } + } + + // Add Assets section + if (inventory.assets && inventory.assets !== 'None') { + summary += `Assets: ${inventory.assets}`; + } + + return summary.trim(); + } + + // Fallback for unknown format + return 'None'; +} + +/** + * Generates an example block showing current tracker states in markdown code blocks. + * Uses COMMITTED data (not displayed data) for generation context. + * + * @returns {string} Formatted example text with tracker data in code blocks + */ +export function generateTrackerExample() { + let example = ''; + + // Use COMMITTED data for generation context, not displayed data + // Wrap each tracker section in markdown code blocks + if (extensionSettings.showUserStats && committedTrackerData.userStats) { + example += '```\n' + committedTrackerData.userStats + '\n```\n\n'; + } + + if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { + example += '```\n' + committedTrackerData.infoBox + '\n```\n\n'; + } + + if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { + example += '```\n' + committedTrackerData.characterThoughts + '\n```'; + } + + return example.trim(); +} + +/** + * Generates the instruction portion - format specifications and guidelines. + * + * @param {boolean} includeHtmlPrompt - Whether to include the HTML prompt (true for main generation, false for separate tracker generation) + * @param {boolean} includeContinuation - Whether to include "After updating the trackers, continue..." instruction + * @returns {string} Formatted instruction text for the AI + */ +export function generateTrackerInstructions(includeHtmlPrompt = true, includeContinuation = true) { + const userName = getContext().name1; + const classicStats = extensionSettings.classicStats; + let instructions = ''; + + // Check if any trackers are enabled + const hasAnyTrackers = extensionSettings.showUserStats || extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts; + + // Only add tracker instructions if at least one tracker is enabled + if (hasAnyTrackers) { + // Universal instruction header + instructions += `\nYou must start your response with an appropriate update to the trackers in EXACTLY the same format as below, enclosed in separate Markdown code fences. Replace X with proper numbers and placeholders in [brackets] (while removing the brackets themselves) with in-world details ${userName} perceives about the current scene and the present characters. Consider the last trackers in the conversation (if they exist). Manage them accordingly and realistically; raise, lower, change, or keep the values unchanged based on the user's actions, the passage of time, and logical consequences:\n`; + + // Add format specifications for each enabled tracker + if (extensionSettings.showUserStats) { + instructions += '```\n'; + instructions += `${userName}'s Stats\n`; + instructions += '---\n'; + instructions += '- Health: X%\n'; + instructions += '- Satiety: X%\n'; + instructions += '- Energy: X%\n'; + instructions += '- Hygiene: X%\n'; + instructions += '- Arousal: X%\n'; + instructions += '[Mood Emoji]: [Conditions (up to three traits)]\n'; + + // Add inventory format based on feature flag + if (FEATURE_FLAGS.useNewInventory) { + instructions += 'On Person: [Items currently carried/worn, or "None"]\n'; + instructions += 'Stored - [Location Name]: [Items stored at this location]\n'; + instructions += '(Add multiple "Stored - [Location]:" lines as needed for different storage locations)\n'; + instructions += 'Assets: [Vehicles, property, major possessions, or "None"]\n'; + } else { + // Legacy v1 format + instructions += 'Inventory: [Clothing/Armor, Inventory Items (list of important items/none)]\n'; + } + + instructions += '```\n\n'; + } + + if (extensionSettings.showInfoBox) { + instructions += '```\n'; + instructions += 'Info Box\n'; + instructions += '---\n'; + instructions += '🗓️: [Weekday, Month, Year]\n'; + instructions += '[Weather Emoji]: [Forecast]\n'; + instructions += '🌡️: [Temperature in °C]\n'; + instructions += '🕒: [Time Start → Time End]\n'; + instructions += '🗺️: [Location]\n'; + instructions += '```\n\n'; + } + + if (extensionSettings.showCharacterThoughts) { + instructions += '```\n'; + instructions += 'Present Characters\n'; + instructions += '---\n'; + instructions += `[Present Character's Emoji (do not include ${userName}; state "Unavailable" if no major characters are present in the scene)]: [Name, Visible Physical State (up to three traits), Observable Demeanor Cue (one trait)] | [Enemy/Neutral/Friend/Lover] | [Internal Monologue (in first person POV, up to three sentences long)]\n`; + instructions += '```\n\n'; + } + + // Only add continuation instruction if includeContinuation is true + if (includeContinuation) { + instructions += `After updating the trackers, continue directly from where the last message in the chat history left off. Ensure the trackers you provide naturally reflect and influence the narrative. Character behavior, dialogue, and story events should acknowledge these conditions when relevant, such as fatigue affecting performance, low hygiene influencing social interactions, environmental factors shaping the scene, a character's emotional state coloring their responses, and so on.\n\n`; + } + + // Include attributes and dice roll only if there was a dice roll + if (extensionSettings.lastDiceRoll) { + const roll = extensionSettings.lastDiceRoll; + instructions += `${userName}'s attributes: STR ${classicStats.str}, DEX ${classicStats.dex}, CON ${classicStats.con}, INT ${classicStats.int}, WIS ${classicStats.wis}, CHA ${classicStats.cha}\n`; + instructions += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeeded or failed the action they attempted.\n\n`; + } + } + + // Append HTML prompt if enabled AND includeHtmlPrompt is true + if (extensionSettings.enableHtmlPrompt && includeHtmlPrompt) { + // Add newlines only if we had tracker instructions + if (hasAnyTrackers) { + instructions += ``; + } else { + instructions += `\n`; + } + + instructions += `If appropriate, include inline HTML, CSS, and JS elements for creative, visual storytelling throughout your response: +- Use them liberally to depict any in-world content that can be visualized (screens, posters, books, signs, letters, logos, crests, seals, medallions, labels, etc.), with creative license for animations, 3D effects, pop-ups, dropdowns, websites, and so on. +- Style them thematically to match the theme (e.g., sleek for sci-fi, rustic for fantasy), ensuring text is visible. +- Embed all resources directly (e.g., inline SVGs) so nothing relies on external fonts or libraries. +- Place elements naturally in the narrative where characters would see or use them, with no limits on format or application. +- These HTML/CSS/JS elements must be rendered directly without enclosing them in code fences.`; + } + + return instructions; +} + +/** + * Generates a formatted contextual summary for SEPARATE mode injection. + * This creates a hybrid summary with clean formatting for main roleplay generation. + * Uses COMMITTED data (not displayed data) for generation context. + * + * @returns {string} Formatted contextual summary + */ +export function generateContextualSummary() { + // Use COMMITTED data for generation context, not displayed data + const userName = getContext().name1; + let summary = ''; + + // console.log('[RPG Companion] generateContextualSummary called'); + // console.log('[RPG Companion] committedTrackerData.userStats:', committedTrackerData.userStats); + // console.log('[RPG Companion] extensionSettings.userStats:', JSON.stringify(extensionSettings.userStats)); + + // Parse the data into readable format + if (extensionSettings.showUserStats && committedTrackerData.userStats) { + const stats = extensionSettings.userStats; + // console.log('[RPG Companion] Building stats summary with:', stats); + summary += `${userName}'s Stats:\n`; + summary += `Condition: Health ${stats.health}%, Satiety ${stats.satiety}%, Energy ${stats.energy}%, Hygiene ${stats.hygiene}%, Arousal ${stats.arousal}% | ${stats.mood} ${stats.conditions}\n`; + + // Add inventory summary using v2-aware builder + if (stats.inventory) { + const inventorySummary = buildInventorySummary(stats.inventory); + if (inventorySummary && inventorySummary !== 'None') { + summary += `Inventory:\n${inventorySummary}\n`; + } + } + // Include classic stats (attributes) and dice roll only if there was a dice roll + if (extensionSettings.lastDiceRoll) { + const classicStats = extensionSettings.classicStats; + const roll = extensionSettings.lastDiceRoll; + summary += `Attributes: STR ${classicStats.str}, DEX ${classicStats.dex}, CON ${classicStats.con}, INT ${classicStats.int}, WIS ${classicStats.wis}, CHA ${classicStats.cha}\n`; + summary += `${userName} rolled ${roll.total} on the last ${roll.formula} roll. Based on their attributes, decide whether they succeed or fail the action they attempt.\n`; + } + summary += `\n`; + } + + if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { + // Parse info box data + const lines = committedTrackerData.infoBox.split('\n'); + let date = '', weather = '', temp = '', time = '', location = ''; + + // console.log('[RPG Companion] 🔍 Parsing Info Box lines:', lines); + + for (const line of lines) { + // console.log('[RPG Companion] 🔍 Processing line:', line); + // Use separate if statements (not else if) so each line is checked against all conditions + if (line.includes('🗓️:')) { + date = line.replace('🗓️:', '').trim(); + // console.log('[RPG Companion] 📅 Found date:', date); + } + if (line.includes('🌡️:')) { + temp = line.replace('🌡️:', '').trim(); + // console.log('[RPG Companion] 🌡️ Found temp:', temp); + } + if (line.includes('🕒:')) { + time = line.replace('🕒:', '').trim(); + // console.log('[RPG Companion] 🕒 Found time:', time); + } + if (line.includes('🗺️:')) { + location = line.replace('🗺️:', '').trim(); + // console.log('[RPG Companion] 🗺️ Found location:', location); + } + // Check for weather emojis - use a simpler approach + const weatherEmojis = ['🌤️', '☀️', '⛅', '🌦️', '🌧️', '⛈️', '🌩️', '🌨️', '❄️', '🌫️']; + const startsWithWeatherEmoji = weatherEmojis.some(emoji => line.startsWith(emoji + ':')); + if (startsWithWeatherEmoji && !line.includes('🌡️') && !line.includes('🗺️')) { + // Extract weather description (remove emoji and colon) + weather = line.substring(line.indexOf(':') + 1).trim(); + // console.log('[RPG Companion] 🌧️ Found weather:', weather); + } + } + + // console.log('[RPG Companion] 🔍 Parsed values - date:', date, 'weather:', weather, 'temp:', temp, 'time:', time, 'location:', location); + + if (date || weather || temp || time || location) { + summary += `Information:\n`; + summary += `Scene: `; + if (date) summary += `${date}`; + if (location) summary += ` | ${location}`; + if (time) summary += ` | ${time}`; + if (weather) summary += ` | ${weather}`; + if (temp) summary += ` | ${temp}`; + summary += `\n\n`; + } + } + + if (extensionSettings.showCharacterThoughts && committedTrackerData.characterThoughts) { + const lines = committedTrackerData.characterThoughts.split('\n').filter(l => l.trim() && !l.includes('---') && !l.includes('Present Characters')); + + if (lines.length > 0 && !lines[0].toLowerCase().includes('unavailable')) { + summary += `Present Characters And Their Thoughts:\n`; + for (const line of lines) { + const parts = line.split('|').map(p => p.trim()); + if (parts.length >= 3) { + const nameAndState = parts[0]; // Emoji, name, physical state, demeanor + const relationship = parts[1]; + const thoughts = parts[2]; + summary += `${nameAndState} (${relationship}) | ${thoughts}\n`; + } + } + } + } + + return summary.trim(); +} + +/** + * Generates the RPG tracking prompt text (for backward compatibility with separate mode). + * Uses COMMITTED data (not displayed data) for generation context. + * + * @returns {string} Full prompt text for separate tracker generation + */ +export function generateRPGPromptText() { + // Use COMMITTED data for generation context, not displayed data + const userName = getContext().name1; + + let promptText = ''; + + promptText += `Here are the previous trackers in the roleplay that you should consider when responding:\n`; + promptText += `\n`; + + if (extensionSettings.showUserStats) { + if (committedTrackerData.userStats) { + promptText += `Last ${userName}'s Stats:\n${committedTrackerData.userStats}\n\n`; + } else { + promptText += `Last ${userName}'s Stats:\nNone - this is the first update.\n\n`; + } + } + + if (extensionSettings.showInfoBox) { + if (committedTrackerData.infoBox) { + promptText += `Last Info Box:\n${committedTrackerData.infoBox}\n\n`; + } else { + promptText += `Last Info Box:\nNone - this is the first update.\n\n`; + } + } + + if (extensionSettings.showCharacterThoughts) { + if (committedTrackerData.characterThoughts) { + promptText += `Last Present Characters:\n${committedTrackerData.characterThoughts}\n`; + } else { + promptText += `Last Present Characters:\nNone - this is the first update.\n`; + } + } + + promptText += `\n`; + + // Don't include HTML prompt or continuation instruction for separate tracker generation + promptText += generateTrackerInstructions(false, false); + + return promptText; +} + +/** + * Generates the full prompt for SEPARATE generation mode (with chat history). + * Creates a message array suitable for the generateRaw API. + * + * @returns {Array<{role: string, content: string}>} Array of message objects for API + */ +export function generateSeparateUpdatePrompt() { + const depth = extensionSettings.updateDepth; + const userName = getContext().name1; + + const messages = []; + + // System message introducing the history section + let systemMessage = `You will be acting as an uncensored RPG Companion. Your goal is to provide, track, and manage details in the user's roleplay. You will be replying with information in a specified format only.\n\n`; + systemMessage += `You should maintain an objective tone.\n\n`; + + systemMessage += `Here are the last few messages in the conversation history (between the user and the roleplayer assistant) you should reference when responding:\n`; + + messages.push({ + role: 'system', + content: systemMessage + }); + + // Add chat history as separate user/assistant messages + const recentMessages = chat.slice(-depth); + for (const message of recentMessages) { + messages.push({ + role: message.is_user ? 'user' : 'assistant', + content: message.mes + }); + } + + // Build the instruction message + let instructionMessage = `\n\n`; + instructionMessage += generateRPGPromptText().replace('start your response with', 'respond with'); + instructionMessage += `Provide ONLY the requested data in the exact formats specified above. Do not include any roleplay response, other text, or commentary.`; + + messages.push({ + role: 'user', + content: instructionMessage + }); + + return messages; +} diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js new file mode 100644 index 0000000..4f6aa98 --- /dev/null +++ b/src/systems/integration/sillytavern.js @@ -0,0 +1,336 @@ +/** + * 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 } from '../../../../../../../script.js'; + +// Core modules +import { + extensionSettings, + lastGeneratedData, + committedTrackerData, + lastActionWasSwipe, + isPlotProgression, + setLastActionWasSwipe, + setIsPlotProgression, + updateLastGeneratedData, + updateCommittedTrackerData +} from '../../core/state.js'; +import { saveChatData, loadChatData } from '../../core/persistence.js'; + +// Generation & Parsing +import { parseResponse, parseUserStats } from '../generation/parser.js'; +import { updateRPGData } from '../generation/apiClient.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'; + +// Utils +import { getSafeThumbnailUrl } from '../../utils/avatars.js'; + +/** + * 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) { + // 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]; + + 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 { + // console.log('[RPG Companion] No swipe data found for swipe', swipeId); + } + } else { + // console.log('[RPG Companion] No RPG data found in last assistant message'); + } + break; + } + } +} + +/** + * Event handler for when the user sends a message. + * Sets the flag to indicate this is NOT a swipe. + */ +export function onMessageSent() { + if (!extensionSettings.enabled) return; + + // User sent a new message - NOT a swipe + setLastActionWasSwipe(false); + // console.log('[RPG Companion] 🟢 EVENT: onMessageSent - lastActionWasSwipe =', lastActionWasSwipe); +} + +/** + * Event handler for when a message is generated. + */ +export async function onMessageReceived(data) { + if (!extensionSettings.enabled) { + return; + } + + if (extensionSettings.generationMode === 'together') { + // In together mode, parse the response to extract RPG data + // The message should be in chat[chat.length - 1] + const lastMessage = chat[chat.length - 1]; + if (lastMessage && !lastMessage.is_user) { + const responseText = lastMessage.mes; + // console.log('[RPG Companion] Parsing together mode response:', responseText); + + const parsedData = parseResponse(responseText); + + // Update stored data + 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; + lastMessage.extra.rpg_companion_swipes[currentSwipeId] = { + userStats: parsedData.userStats, + infoBox: parsedData.infoBox, + characterThoughts: parsedData.characterThoughts + }; + + // console.log('[RPG Companion] Stored RPG data for swipe', currentSwipeId); + + // If there's no committed data yet (first time generating), automatically commit + if (!committedTrackerData.userStats && !committedTrackerData.infoBox && !committedTrackerData.characterThoughts) { + committedTrackerData.userStats = parsedData.userStats; + committedTrackerData.infoBox = parsedData.infoBox; + committedTrackerData.characterThoughts = parsedData.characterThoughts; + // console.log('[RPG Companion] 🔆 FIRST TIME: Auto-committed tracker data'); + } else { + // console.log('[RPG Companion] Data will be committed when user replies'); + } + + // Remove the tracker code blocks from the visible message + let cleanedMessage = responseText; + // Remove all code blocks that contain tracker data + 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'); + + // 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(); + } + + // console.log('[RPG Companion] Cleaned message, removed tracker code blocks'); + + // Render the updated data + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + + // Save to chat metadata + saveChatData(); + } + } else if (extensionSettings.generationMode === 'separate' && extensionSettings.autoUpdate) { + // In separate mode with auto-update, trigger update after message + setTimeout(async () => { + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); + }, 500); + } + + // 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'); + } +} + +/** + * 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'); + + // Load chat-specific data when switching chats + loadChatData(); + + // Commit tracker data from the last assistant message to initialize for this chat + commitTrackerData(); + + // Re-render with the loaded data + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + + // Update chat thought overlays + updateChatThoughts(); +} + +/** + * 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] Message swiped at index:', messageIndex); + + // Get the message that was swiped + const message = chat[messageIndex]; + if (!message || message.is_user) { + 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); + // console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (NEW generation) - lastActionWasSwipe =', lastActionWasSwipe); + } else { + // This is navigating to an EXISTING swipe - don't change the flag + // console.log('[RPG Companion] 🔵 EVENT: onMessageSwiped (existing swipe navigation) - lastActionWasSwipe unchanged =', lastActionWasSwipe); + } + + // console.log('[RPG Companion] Loading data for swipe', currentSwipeId); + + // Load RPG data for this swipe into lastGeneratedData (for display only) + // This updates what the user sees, but does NOT commit it + // Committed data will be updated when/if the user replies to this swipe + if (message.extra && message.extra.rpg_companion_swipes && message.extra.rpg_companion_swipes[currentSwipeId]) { + const swipeData = message.extra.rpg_companion_swipes[currentSwipeId]; + + // Update display data + lastGeneratedData.userStats = swipeData.userStats || null; + lastGeneratedData.infoBox = swipeData.infoBox || null; + lastGeneratedData.characterThoughts = swipeData.characterThoughts || null; + + // Parse user stats if available + if (swipeData.userStats) { + parseUserStats(swipeData.userStats); + } + + // console.log('[RPG Companion] Loaded RPG data for swipe', currentSwipeId, '(display only, NOT committed)'); + // console.log('[RPG Companion] committedTrackerData unchanged - will be updated if user replies to this swipe'); + } else { + // No data for this swipe - keep existing lastGeneratedData (don't clear it) + // This ensures the display remains consistent and data is available for next commit + // console.log('[RPG Companion] No RPG data for swipe', currentSwipeId, '- keeping existing lastGeneratedData'); + } + + // Re-render the panels (display only - committedTrackerData unchanged) + renderUserStats(); + renderInfoBox(); + renderThoughts(); + renderInventory(); + + // Update chat thought overlays + updateChatThoughts(); +} + +/** + * 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-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'); +} diff --git a/src/systems/interaction/inventoryActions.js b/src/systems/interaction/inventoryActions.js new file mode 100644 index 0000000..80934c0 --- /dev/null +++ b/src/systems/interaction/inventoryActions.js @@ -0,0 +1,484 @@ +/** + * Inventory Actions Module + * Handles all user interactions with the inventory v2 system + */ + +import { extensionSettings, lastGeneratedData } from '../../core/state.js'; +import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; +import { buildInventorySummary } from '../generation/promptBuilder.js'; +import { renderInventory } from '../rendering/inventory.js'; +import { parseItems, serializeItems } from '../../utils/itemParser.js'; + +// Type imports +/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ + +/** + * Current active sub-tab for inventory UI + * @type {string} + */ +let currentActiveSubTab = 'onPerson'; + +/** + * Array of collapsed storage location names + * @type {string[]} + */ +let collapsedLocations = []; + +/** + * Updates lastGeneratedData.userStats to include current inventory in text format. + * This ensures the AI context stays synced with manual edits. + */ +function updateLastGeneratedDataInventory() { + const stats = extensionSettings.userStats; + const inventorySummary = buildInventorySummary(stats.inventory); + + // Rebuild the lastGeneratedData.userStats text format + lastGeneratedData.userStats = + `Health: ${stats.health}%\n` + + `Satiety: ${stats.satiety}%\n` + + `Energy: ${stats.energy}%\n` + + `Hygiene: ${stats.hygiene}%\n` + + `Arousal: ${stats.arousal}%\n` + + `${stats.mood}: ${stats.conditions}\n` + + `${inventorySummary}`; +} + +/** + * Shows the inline form for adding a new item. + * @param {string} field - Field name ('onPerson', 'stored', 'assets') + * @param {string} [location] - Location name (required for 'stored' field) + */ +export function showAddItemForm(field, location) { + let formId; + let inputId; + + if (field === 'stored') { + const locationId = location.replace(/\s+/g, '-'); + formId = `rpg-add-item-form-stored-${locationId}`; + inputId = `.rpg-location-item-input[data-location="${location}"]`; + } else { + formId = `rpg-add-item-form-${field}`; + inputId = `#rpg-new-item-${field}`; + } + + const form = $(`#${formId}`); + const input = $(inputId); + + form.show(); + input.val('').focus(); +} + +/** + * Hides the inline form for adding a new item. + * @param {string} field - Field name ('onPerson', 'stored', 'assets') + * @param {string} [location] - Location name (required for 'stored' field) + */ +export function hideAddItemForm(field, location) { + let formId; + let inputId; + + if (field === 'stored') { + const locationId = location.replace(/\s+/g, '-'); + formId = `rpg-add-item-form-stored-${locationId}`; + inputId = `.rpg-location-item-input[data-location="${location}"]`; + } else { + formId = `rpg-add-item-form-${field}`; + inputId = `#rpg-new-item-${field}`; + } + + const form = $(`#${formId}`); + const input = $(inputId); + + form.hide(); + input.val(''); +} + +/** + * Adds a new item to the inventory. + * @param {string} field - Field name ('onPerson', 'stored', 'assets') + * @param {string} [location] - Location name (required for 'stored' field) + */ +export function saveAddItem(field, location) { + const inventory = extensionSettings.userStats.inventory; + let inputId; + + if (field === 'stored') { + inputId = `.rpg-location-item-input[data-location="${location}"]`; + } else { + inputId = `#rpg-new-item-${field}`; + } + + const input = $(inputId); + const itemName = input.val().trim(); + + if (!itemName) { + hideAddItemForm(field, location); + return; + } + + // Get current items, add new one, serialize back + let currentString; + if (field === 'stored') { + currentString = inventory.stored[location] || 'None'; + } else { + currentString = inventory[field] || 'None'; + } + + const items = parseItems(currentString); + items.push(itemName); + const newString = serializeItems(items); + + // Save back to inventory + if (field === 'stored') { + inventory.stored[location] = newString; + } else { + inventory[field] = newString; + } + + updateLastGeneratedDataInventory(); + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Hide form and re-render + hideAddItemForm(field, location); + renderInventory(); +} + +/** + * Removes an item from the inventory. + * @param {string} field - Field name ('onPerson', 'stored', 'assets') + * @param {number} itemIndex - Index of item to remove + * @param {string} [location] - Location name (required for 'stored' field) + */ +export function removeItem(field, itemIndex, location) { + const inventory = extensionSettings.userStats.inventory; + + // Get current items, remove the one at index, serialize back + let currentString; + if (field === 'stored') { + currentString = inventory.stored[location] || 'None'; + } else { + currentString = inventory[field] || 'None'; + } + + const items = parseItems(currentString); + items.splice(itemIndex, 1); // Remove item at index + const newString = serializeItems(items); + + // Save back to inventory + if (field === 'stored') { + inventory.stored[location] = newString; + } else { + inventory[field] = newString; + } + + updateLastGeneratedDataInventory(); + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Re-render + renderInventory(); +} + +/** + * Shows the inline form for adding a new storage location. + */ +export function showAddLocationForm() { + const form = $('#rpg-add-location-form'); + const input = $('#rpg-new-location-name'); + + form.show(); + input.val('').focus(); +} + +/** + * Hides the inline form for adding a new storage location. + */ +export function hideAddLocationForm() { + const form = $('#rpg-add-location-form'); + const input = $('#rpg-new-location-name'); + + form.hide(); + input.val(''); +} + +/** + * Saves a new storage location from the inline form. + */ +export function saveAddLocation() { + const inventory = extensionSettings.userStats.inventory; + const input = $('#rpg-new-location-name'); + const locationName = input.val().trim(); + + if (!locationName) { + hideAddLocationForm(); + return; + } + + // Check for duplicate + if (inventory.stored[locationName]) { + alert(`Storage location "${locationName}" already exists.`); + return; + } + + // Create new location with default "None" + inventory.stored[locationName] = 'None'; + + updateLastGeneratedDataInventory(); + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Hide form and re-render + hideAddLocationForm(); + renderInventory(); +} + +/** + * Shows the inline confirmation UI for removing a storage location. + * @param {string} locationName - Name of location to remove + */ +export function showRemoveConfirmation(locationName) { + const confirmId = `rpg-remove-confirm-${locationName.replace(/\s+/g, '-')}`; + const confirmUI = $(`#${confirmId}`); + + if (confirmUI.length > 0) { + confirmUI.show(); + } +} + +/** + * Hides the inline confirmation UI for removing a storage location. + * @param {string} locationName - Name of location + */ +export function hideRemoveConfirmation(locationName) { + const confirmId = `rpg-remove-confirm-${locationName.replace(/\s+/g, '-')}`; + const confirmUI = $(`#${confirmId}`); + + if (confirmUI.length > 0) { + confirmUI.hide(); + } +} + +/** + * Confirms and removes a storage location from the inventory. + * @param {string} locationName - Name of location to remove + */ +export function confirmRemoveLocation(locationName) { + const inventory = extensionSettings.userStats.inventory; + delete inventory.stored[locationName]; + + // Remove from collapsed list if present + const index = collapsedLocations.indexOf(locationName); + if (index > -1) { + collapsedLocations.splice(index, 1); + } + + updateLastGeneratedDataInventory(); + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Re-render inventory UI + renderInventory(); +} + +/** + * Toggles the collapsed state of a storage location section. + * @param {string} locationName - Name of location to toggle + */ +export function toggleLocationCollapse(locationName) { + const index = collapsedLocations.indexOf(locationName); + + if (index > -1) { + // Currently collapsed, expand it + collapsedLocations.splice(index, 1); + } else { + // Currently expanded, collapse it + collapsedLocations.push(locationName); + } + + // Save collapsed state to settings + extensionSettings.collapsedInventoryLocations = collapsedLocations; + saveSettings(); + + // Re-render inventory UI + renderInventory(); +} + +/** + * Switches the active inventory sub-tab. + * @param {string} tabName - Name of the tab ('onPerson', 'stored', 'assets') + */ +export function switchInventoryTab(tabName) { + currentActiveSubTab = tabName; + + // Re-render inventory UI + renderInventory(); +} + +/** + * Switches the view mode for an inventory section. + * @param {string} field - Field name ('onPerson', 'stored', 'assets') + * @param {string} mode - View mode ('list' or 'grid') + */ +export function switchViewMode(field, mode) { + // Ensure inventoryViewModes exists + if (!extensionSettings.inventoryViewModes) { + extensionSettings.inventoryViewModes = { + onPerson: 'list', + stored: 'list', + assets: 'list' + }; + } + + // Update view mode + extensionSettings.inventoryViewModes[field] = mode; + + // Save settings + saveSettings(); + + // Re-render inventory UI + renderInventory(); +} + +/** + * Initializes all event listeners for inventory interactions. + * Uses event delegation to handle dynamically created elements. + */ +export function initInventoryEventListeners() { + // Load collapsed state from settings + if (extensionSettings.collapsedInventoryLocations) { + collapsedLocations = extensionSettings.collapsedInventoryLocations; + } + + // Add item button - shows inline form + $(document).on('click', '.rpg-inventory-add-btn[data-action="add-item"]', function(e) { + e.preventDefault(); + const field = $(this).data('field'); + const location = $(this).data('location'); + showAddItemForm(field, location); + }); + + // Add item inline form - save button + $(document).on('click', '.rpg-inline-btn[data-action="save-add-item"]', function(e) { + e.preventDefault(); + const field = $(this).data('field'); + const location = $(this).data('location'); + saveAddItem(field, location); + }); + + // Add item inline form - cancel button + $(document).on('click', '.rpg-inline-btn[data-action="cancel-add-item"]', function(e) { + e.preventDefault(); + const field = $(this).data('field'); + const location = $(this).data('location'); + hideAddItemForm(field, location); + }); + + // Add item inline form - enter key to save + $(document).on('keypress', '.rpg-inline-input', function(e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + const $btn = $(this).closest('.rpg-inline-form').find('[data-action="save-add-item"]'); + if ($btn.length > 0) { + const field = $btn.data('field'); + const location = $btn.data('location'); + saveAddItem(field, location); + } + } + }); + + // Remove item button + $(document).on('click', '.rpg-item-remove[data-action="remove-item"]', function(e) { + e.preventDefault(); + const field = $(this).data('field'); + const itemIndex = parseInt($(this).data('index')); + const location = $(this).data('location'); + removeItem(field, itemIndex, location); + }); + + // Add location button - shows inline form + $(document).on('click', '.rpg-inventory-add-btn[data-action="add-location"]', function(e) { + e.preventDefault(); + showAddLocationForm(); + }); + + // Add location inline form - save button + $(document).on('click', '.rpg-inline-btn[data-action="save-add-location"]', function(e) { + e.preventDefault(); + saveAddLocation(); + }); + + // Add location inline form - cancel button + $(document).on('click', '.rpg-inline-btn[data-action="cancel-add-location"]', function(e) { + e.preventDefault(); + hideAddLocationForm(); + }); + + // Add location inline form - enter key to save + $(document).on('keypress', '#rpg-new-location-name', function(e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + saveAddLocation(); + } + }); + + // Remove location button - shows inline confirmation + $(document).on('click', '.rpg-inventory-remove-btn[data-action="remove-location"]', function(e) { + e.preventDefault(); + const location = $(this).data('location'); + showRemoveConfirmation(location); + }); + + // Remove location inline confirmation - confirm button + $(document).on('click', '.rpg-inline-btn[data-action="confirm-remove-location"]', function(e) { + e.preventDefault(); + const location = $(this).data('location'); + confirmRemoveLocation(location); + }); + + // Remove location inline confirmation - cancel button + $(document).on('click', '.rpg-inline-btn[data-action="cancel-remove-location"]', function(e) { + e.preventDefault(); + const location = $(this).data('location'); + hideRemoveConfirmation(location); + }); + + // Collapse toggle buttons + $(document).on('click', '.rpg-storage-toggle', function(e) { + e.preventDefault(); + const location = $(this).data('location'); + toggleLocationCollapse(location); + }); + + // Sub-tab switching + $(document).on('click', '.rpg-inventory-subtab', function(e) { + e.preventDefault(); + const tab = $(this).data('tab'); + switchInventoryTab(tab); + }); + + // View mode switching + $(document).on('click', '.rpg-view-btn[data-action="switch-view"]', function(e) { + e.preventDefault(); + const field = $(this).data('field'); + const view = $(this).data('view'); + switchViewMode(field, view); + }); + + console.log('[RPG Companion] Inventory event listeners initialized'); +} + +/** + * Gets the current inventory rendering options. + * @returns {Object} Options object with activeSubTab and collapsedLocations + */ +export function getInventoryRenderOptions() { + return { + activeSubTab: currentActiveSubTab, + collapsedLocations + }; +} diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js new file mode 100644 index 0000000..281f388 --- /dev/null +++ b/src/systems/rendering/infoBox.js @@ -0,0 +1,452 @@ +/** + * Info Box Rendering Module + * Handles rendering of the info box dashboard with weather, date, time, and location widgets + */ + +import { getContext } from '../../../../../../extensions.js'; +import { + extensionSettings, + lastGeneratedData, + $infoBoxContainer +} from '../../core/state.js'; +import { saveChatData } from '../../core/persistence.js'; + +/** + * Renders the info box as a visual dashboard with calendar, weather, temperature, clock, and map widgets. + * Includes event listeners for editable fields. + */ +export function renderInfoBox() { + if (!extensionSettings.showInfoBox || !$infoBoxContainer) { + return; + } + + // Add updating class for animation + if (extensionSettings.enableAnimations) { + $infoBoxContainer.addClass('rpg-content-updating'); + } + + // If no data yet, show placeholder + if (!lastGeneratedData.infoBox) { + const placeholderHtml = ` +
+
+
No data yet
+
Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button
+
+
+ `; + $infoBoxContainer.html(placeholderHtml); + if (extensionSettings.enableAnimations) { + setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); + } + return; + } + + // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox); + + // Parse the info box data + const lines = lastGeneratedData.infoBox.split('\n'); + // console.log('[RPG Companion] Info Box split into lines:', lines); + const data = { + date: '', + weekday: '', + month: '', + year: '', + weatherEmoji: '', + weatherForecast: '', + temperature: '', + tempValue: 0, + timeStart: '', + timeEnd: '', + location: '', + characters: [] + }; + + for (const line of lines) { + // console.log('[RPG Companion] Processing line:', line); + + if (line.includes('🗓️:')) { + // console.log('[RPG Companion] → Matched DATE'); + const dateStr = line.replace('🗓️:', '').trim(); + // Parse format: "Weekday, Month Day, Year" or "Weekday, Month, Year" + const dateParts = dateStr.split(',').map(p => p.trim()); + data.weekday = dateParts[0] || ''; + data.month = dateParts[1] || ''; + data.year = dateParts[2] || ''; + data.date = dateStr; + } else if (line.includes('🌡️:')) { + // console.log('[RPG Companion] → Matched TEMPERATURE'); + const tempStr = line.replace('🌡️:', '').trim(); + data.temperature = tempStr; + // Extract numeric value + const tempMatch = tempStr.match(/(-?\d+)/); + if (tempMatch) { + data.tempValue = parseInt(tempMatch[1]); + } + } else if (line.includes('🕒:')) { + // console.log('[RPG Companion] → Matched TIME'); + const timeStr = line.replace('🕒:', '').trim(); + data.time = timeStr; + // Parse "HH:MM → HH:MM" format + const timeParts = timeStr.split('→').map(t => t.trim()); + data.timeStart = timeParts[0] || ''; + data.timeEnd = timeParts[1] || ''; + } else if (line.includes('🗺️:')) { + // console.log('[RPG Companion] → Matched LOCATION'); + data.location = line.replace('🗺️:', '').trim(); + } else { + // Check if it's a weather line + // Since \p{Emoji} doesn't work reliably, use a simpler approach + const hasColon = line.includes(':'); + const notInfoBox = !line.includes('Info Box'); + const notDivider = !line.includes('---'); + const notCodeFence = !line.trim().startsWith('```'); + + // console.log('[RPG Companion] → Checking weather conditions:', { + // line: line, + // hasColon: hasColon, + // notInfoBox: notInfoBox, + // notDivider: notDivider + // }); + + if (hasColon && notInfoBox && notDivider && notCodeFence && line.trim().length > 0) { + // Match format: [Weather Emoji]: [Forecast] + // Capture everything before colon as emoji, everything after as forecast + // console.log('[RPG Companion] → Testing WEATHER match for:', line); + const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/); + if (weatherMatch) { + const potentialEmoji = weatherMatch[1].trim(); + const forecast = weatherMatch[2].trim(); + + // If the first part is short (likely emoji), treat as weather + if (potentialEmoji.length <= 5) { + data.weatherEmoji = potentialEmoji; + data.weatherForecast = forecast; + // console.log('[RPG Companion] ✓ Weather parsed:', data.weatherEmoji, data.weatherForecast); + } else { + // console.log('[RPG Companion] ✗ First part too long for emoji:', potentialEmoji); + } + } else { + // console.log('[RPG Companion] ✗ Weather regex did not match'); + } + } else { + // console.log('[RPG Companion] → No match for this line'); + } + } + } + + // console.log('[RPG Companion] Parsed Info Box data:', { + // date: data.date, + // weatherEmoji: data.weatherEmoji, + // weatherForecast: data.weatherForecast, + // temperature: data.temperature, + // timeStart: data.timeStart, + // location: data.location + // }); + + // Build visual dashboard HTML + // Row 1: Date, Weather, Temperature, Time widgets + let html = '
'; + + // Calendar widget - always show (editable even if empty) + const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON'; + const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY'; + const yearDisplay = data.year || 'YEAR'; + html += ` +
+
${monthShort}
+
${weekdayShort}
+
${yearDisplay}
+
+ `; + + // Weather widget - always show (editable even if empty) + const weatherEmoji = data.weatherEmoji || '🌤️'; + const weatherForecast = data.weatherForecast || 'Weather'; + html += ` +
+
${weatherEmoji}
+
${weatherForecast}
+
+ `; + + // Temperature widget - always show (editable even if empty) + const tempDisplay = data.temperature || '20°C'; + const tempValue = data.tempValue || 20; + const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100)); + const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560'; + html += ` +
+
+
+
+
+
+
+
${tempDisplay}
+
+ `; + + // Time widget - always show (editable even if empty) + const timeDisplay = data.timeStart || '12:00'; + // Parse time for clock hands + const timeMatch = timeDisplay.match(/(\d+):(\d+)/); + let hourAngle = 0; + let minuteAngle = 0; + if (timeMatch) { + const hours = parseInt(timeMatch[1]); + const minutes = parseInt(timeMatch[2]); + hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute + minuteAngle = minutes * 6; // 6° per minute + } + html += ` +
+
+
+
+
+
+
+
+
${timeDisplay}
+
+ `; + + html += '
'; + + // Row 2: Location widget (full width) - always show (editable even if empty) + const locationDisplay = data.location || 'Location'; + html += ` +
+
+
+
📍
+
+
${locationDisplay}
+
+
+ `; + + $infoBoxContainer.html(html); + + // Add event handlers for editable Info Box fields + $infoBoxContainer.find('.rpg-editable').on('blur', function() { + const field = $(this).data('field'); + const value = $(this).text().trim(); + updateInfoBoxField(field, value); + }); + + // Remove updating class after animation + if (extensionSettings.enableAnimations) { + setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); + } +} + +/** + * Updates a specific field in the Info Box data and re-renders. + * Handles complex field reconstruction logic for date parts, weather, temperature, time, and location. + * + * @param {string} field - Field name to update + * @param {string} value - New value for the field + */ +export function updateInfoBoxField(field, value) { + if (!lastGeneratedData.infoBox) { + // Initialize with empty info box if it doesn't exist + lastGeneratedData.infoBox = 'Info Box\n---\n'; + } + + // Reconstruct the Info Box text with updated field + const lines = lastGeneratedData.infoBox.split('\n'); + let dateLineFound = false; + let dateLineIndex = -1; + + // Find the date line + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('🗓️:')) { + dateLineFound = true; + dateLineIndex = i; + break; + } + } + + const updatedLines = lines.map((line, index) => { + if (field === 'month' && line.includes('🗓️:')) { + const parts = line.split(','); + if (parts.length >= 2) { + // parts[0] = "🗓️: Weekday", parts[1] = " Month", parts[2] = " Year" + parts[1] = ' ' + value; + return parts.join(','); + } else if (parts.length === 1) { + // No existing month/year, add them + return `${parts[0]}, ${value}, YEAR`; + } + } else if (field === 'weekday' && line.includes('🗓️:')) { + const parts = line.split(','); + // Keep the emoji, just update the weekday + const month = parts[1] ? parts[1].trim() : 'Month'; + const year = parts[2] ? parts[2].trim() : 'YEAR'; + return `🗓️: ${value}, ${month}, ${year}`; + } else if (field === 'year' && line.includes('🗓️:')) { + const parts = line.split(','); + if (parts.length >= 3) { + parts[2] = ' ' + value; + return parts.join(','); + } else if (parts.length === 2) { + // No existing year, add it + return `${parts[0]}, ${parts[1]}, ${value}`; + } else if (parts.length === 1) { + // No existing month/year, add them + return `${parts[0]}, Month, ${value}`; + } + } else if (field === 'weatherEmoji' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { + // This is the weather line + const parts = line.split(':'); + if (parts.length >= 2) { + return `${value}: ${parts.slice(1).join(':').trim()}`; + } + } else if (field === 'weatherForecast' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { + // This is the weather line + const parts = line.split(':'); + if (parts.length >= 2) { + return `${parts[0].trim()}: ${value}`; + } + } else if (field === 'temperature' && line.includes('🌡️:')) { + return `🌡️: ${value}`; + } else if (field === 'timeStart' && line.includes('🕒:')) { + // Update time format: "HH:MM → HH:MM" + // When user edits, set both start and end time to the new value + return `🕒: ${value} → ${value}`; + } else if (field === 'location' && line.includes('🗺️:')) { + return `🗺️: ${value}`; + } + return line; + }); + + // If editing a date field but no date line exists, create one after the divider + if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) { + // Find the divider line + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + // Create initial date line with the edited field + let newDateLine = ''; + if (field === 'weekday') { + newDateLine = `🗓️: ${value}, Month, YEAR`; + } else if (field === 'month') { + newDateLine = `🗓️: Weekday, ${value}, YEAR`; + } else if (field === 'year') { + newDateLine = `🗓️: Weekday, Month, ${value}`; + } + // Insert after the divider + updatedLines.splice(dividerIndex + 1, 0, newDateLine); + } + } + + // If editing weather but no weather line exists, create one + if ((field === 'weatherEmoji' || field === 'weatherForecast')) { + let weatherLineFound = false; + for (const line of updatedLines) { + // Check if this is a weather line (has emoji and forecast, not one of the special fields) + if (line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { + weatherLineFound = true; + break; + } + } + + if (!weatherLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + let newWeatherLine = ''; + if (field === 'weatherEmoji') { + newWeatherLine = `${value}: Weather`; + } else if (field === 'weatherForecast') { + newWeatherLine = `🌤️: ${value}`; + } + // Insert after date line if it exists, otherwise after divider + const dateIndex = updatedLines.findIndex(line => line.includes('🗓️:')); + const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1; + updatedLines.splice(insertIndex, 0, newWeatherLine); + } + } + } + + // If editing temperature but no temperature line exists, create one + if (field === 'temperature') { + const tempLineFound = updatedLines.some(line => line.includes('🌡️:')); + if (!tempLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + const newTempLine = `🌡️: ${value}`; + // Find last non-empty line before creating position + let insertIndex = dividerIndex + 1; + for (let i = 0; i < updatedLines.length; i++) { + if (updatedLines[i].includes('🗓️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { + insertIndex = i + 1; + } + } + updatedLines.splice(insertIndex, 0, newTempLine); + } + } + } + + // If editing time but no time line exists, create one + if (field === 'timeStart') { + const timeLineFound = updatedLines.some(line => line.includes('🕒:')); + if (!timeLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + const newTimeLine = `🕒: ${value} → ${value}`; + // Find last non-empty line before creating position + let insertIndex = dividerIndex + 1; + for (let i = 0; i < updatedLines.length; i++) { + if (updatedLines[i].includes('🗓️:') || updatedLines[i].includes('🌡️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { + insertIndex = i + 1; + } + } + updatedLines.splice(insertIndex, 0, newTimeLine); + } + } + } + + // If editing location but no location line exists, create one + if (field === 'location') { + const locationLineFound = updatedLines.some(line => line.includes('🗺️:')); + if (!locationLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + const newLocationLine = `🗺️: ${value}`; + // Insert at the end (before any empty lines) + let insertIndex = updatedLines.length; + for (let i = updatedLines.length - 1; i >= 0; i--) { + if (updatedLines[i].trim() !== '') { + insertIndex = i + 1; + break; + } + } + updatedLines.splice(insertIndex, 0, newLocationLine); + } + } + } + + lastGeneratedData.infoBox = updatedLines.join('\n'); + + // Update the message's swipe data + const chat = getContext().chat; + if (chat && chat.length > 0) { + for (let i = chat.length - 1; i >= 0; i--) { + const message = chat[i]; + if (!message.is_user) { + if (message.extra && message.extra.rpg_companion_swipes) { + const swipeId = message.swipe_id || 0; + if (message.extra.rpg_companion_swipes[swipeId]) { + message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n'); + // console.log('[RPG Companion] Updated infoBox in message swipe data'); + } + } + break; + } + } + } + + saveChatData(); + renderInfoBox(); +} diff --git a/src/systems/rendering/inventory.js b/src/systems/rendering/inventory.js new file mode 100644 index 0000000..5fec662 --- /dev/null +++ b/src/systems/rendering/inventory.js @@ -0,0 +1,462 @@ +/** + * Inventory Rendering Module + * Handles UI rendering for inventory v2 system + */ + +import { extensionSettings, $inventoryContainer } from '../../core/state.js'; +import { getInventoryRenderOptions } from '../interaction/inventoryActions.js'; +import { parseItems } from '../../utils/itemParser.js'; + +// Type imports +/** @typedef {import('../../types/inventory.js').InventoryV2} InventoryV2 */ + +/** + * Renders the inventory sub-tab navigation (On Person, Stored, Assets) + * @param {string} activeTab - Currently active sub-tab ('onPerson', 'stored', 'assets') + * @returns {string} HTML for sub-tab navigation + */ +export function renderInventorySubTabs(activeTab = 'onPerson') { + return ` +
+ + + +
+ `; +} + +/** + * Renders the "On Person" inventory view with list or grid display + * @param {string} onPersonItems - Current on-person items (comma-separated string) + * @param {string} viewMode - View mode ('list' or 'grid') + * @returns {string} HTML for on-person view with items and add button + */ +export function renderOnPersonView(onPersonItems, viewMode = 'list') { + const items = parseItems(onPersonItems); + + let itemsHtml = ''; + if (items.length === 0) { + itemsHtml = '
No items carried
'; + } else { + if (viewMode === 'grid') { + // Grid view: card-style items + itemsHtml = items.map((item, index) => ` +
+ + ${escapeHtml(item)} +
+ `).join(''); + } else { + // List view: full-width rows + itemsHtml = items.map((item, index) => ` +
+ ${escapeHtml(item)} + +
+ `).join(''); + } + } + + const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view'; + + return ` +
+
+

Items Currently Carried

+
+
+ + +
+ +
+
+
+ +
+ ${itemsHtml} +
+
+
+ `; +} + +/** + * Renders the "Stored" inventory view with collapsible locations and list/grid views + * @param {Object.} stored - Stored items by location + * @param {string[]} collapsedLocations - Array of collapsed location names + * @param {string} viewMode - View mode ('list' or 'grid') + * @returns {string} HTML for stored inventory with all locations + */ +export function renderStoredView(stored, collapsedLocations = [], viewMode = 'list') { + const locations = Object.keys(stored || {}); + + let html = ` +
+
+

Storage Locations

+
+
+ + +
+ +
+
+
+ + `; + + if (locations.length === 0) { + html += ` +
+ No storage locations yet. Click "Add Location" to create one. +
+ `; + } else { + for (const location of locations) { + const itemString = stored[location]; + const items = parseItems(itemString); + const isCollapsed = collapsedLocations.includes(location); + const locationId = escapeHtml(location).replace(/\s+/g, '-'); + + let itemsHtml = ''; + if (items.length === 0) { + itemsHtml = '
No items stored here
'; + } else { + if (viewMode === 'grid') { + // Grid view: card-style items + itemsHtml = items.map((item, index) => ` +
+ + ${escapeHtml(item)} +
+ `).join(''); + } else { + // List view: full-width rows + itemsHtml = items.map((item, index) => ` +
+ ${escapeHtml(item)} + +
+ `).join(''); + } + } + + const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view'; + + html += ` +
+
+ +
${escapeHtml(location)}
+
+ + +
+
+
+ +
+ ${itemsHtml} +
+
+ +
+ `; + } + } + + html += ` +
+
+ `; + + return html; +} + +/** + * Renders the "Assets" inventory view with list or grid display + * @param {string} assets - Current assets (vehicles, property, equipment) + * @param {string} viewMode - View mode ('list' or 'grid') + * @returns {string} HTML for assets view with items and add button + */ +export function renderAssetsView(assets, viewMode = 'list') { + const items = parseItems(assets); + + let itemsHtml = ''; + if (items.length === 0) { + itemsHtml = '
No assets owned
'; + } else { + if (viewMode === 'grid') { + // Grid view: card-style items + itemsHtml = items.map((item, index) => ` +
+ + ${escapeHtml(item)} +
+ `).join(''); + } else { + // List view: full-width rows + itemsHtml = items.map((item, index) => ` +
+ ${escapeHtml(item)} + +
+ `).join(''); + } + } + + const listViewClass = viewMode === 'list' ? 'rpg-item-list-view' : 'rpg-item-grid-view'; + + return ` +
+
+

Vehicles, Property & Major Possessions

+
+
+ + +
+ +
+
+
+ +
+ ${itemsHtml} +
+
+ + Assets include vehicles (cars, motorcycles), property (homes, apartments), + and major equipment (workshop tools, special items). +
+
+
+ `; +} + +/** + * Generates inventory HTML (internal helper) + * @param {InventoryV2} inventory - Inventory data to render + * @param {Object} options - Rendering options + * @param {string} options.activeSubTab - Currently active sub-tab ('onPerson', 'stored', 'assets') + * @param {string[]} options.collapsedLocations - Collapsed storage locations + * @returns {string} Complete HTML for inventory tab content + */ +function generateInventoryHTML(inventory, options = {}) { + const { + activeSubTab = 'onPerson', + collapsedLocations = [] + } = options; + + // Handle legacy v1 format - convert to v2 for display + let v2Inventory = inventory; + if (typeof inventory === 'string') { + v2Inventory = { + version: 2, + onPerson: inventory, + stored: {}, + assets: 'None' + }; + } + + // Ensure v2 structure has all required fields + if (!v2Inventory || typeof v2Inventory !== 'object') { + v2Inventory = { + version: 2, + onPerson: 'None', + stored: {}, + assets: 'None' + }; + } + + // Additional safety check: ensure required properties exist and are correct type + if (!v2Inventory.onPerson || typeof v2Inventory.onPerson !== 'string') { + v2Inventory.onPerson = 'None'; + } + if (!v2Inventory.stored || typeof v2Inventory.stored !== 'object' || Array.isArray(v2Inventory.stored)) { + v2Inventory.stored = {}; + } + if (!v2Inventory.assets || typeof v2Inventory.assets !== 'string') { + v2Inventory.assets = 'None'; + } + + let html = ` +
+ ${renderInventorySubTabs(activeSubTab)} +
+ `; + + // Get view modes from settings (default to 'list') + const viewModes = extensionSettings.inventoryViewModes || { + onPerson: 'list', + stored: 'list', + assets: 'list' + }; + + // Render the active view + switch (activeSubTab) { + case 'onPerson': + html += renderOnPersonView(v2Inventory.onPerson, viewModes.onPerson); + break; + case 'stored': + html += renderStoredView(v2Inventory.stored, collapsedLocations, viewModes.stored); + break; + case 'assets': + html += renderAssetsView(v2Inventory.assets, viewModes.assets); + break; + default: + html += renderOnPersonView(v2Inventory.onPerson, viewModes.onPerson); + } + + html += ` +
+
+ `; + + return html; +} + +/** + * Updates the inventory display in the DOM (used by inventoryActions) + * @param {string} containerId - ID of container element to update + * @param {Object} options - Rendering options (passed to generateInventoryHTML) + */ +export function updateInventoryDisplay(containerId, options = {}) { + const container = document.getElementById(containerId); + if (!container) { + console.warn(`[RPG Companion] Inventory container not found: ${containerId}`); + return; + } + + const inventory = extensionSettings.userStats.inventory; + const html = generateInventoryHTML(inventory, options); + container.innerHTML = html; +} + +/** + * Main inventory rendering function (matches pattern of other render functions) + * Gets data from state/settings and updates DOM directly. + * Call this after AI generation, character changes, or swipes. + */ +export function renderInventory() { + // Early return if container doesn't exist or section is hidden + if (!$inventoryContainer || !extensionSettings.showInventory) { + return; + } + + // Get inventory data from settings + const inventory = extensionSettings.userStats.inventory; + + // Get current render options (active tab, collapsed locations) + const options = getInventoryRenderOptions(); + + // Generate HTML and update DOM + const html = generateInventoryHTML(inventory, options); + $inventoryContainer.html(html); +} + +/** + * Escapes HTML special characters to prevent XSS + * @param {string} text - Text to escape + * @returns {string} Escaped text + */ +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js new file mode 100644 index 0000000..03d9eb4 --- /dev/null +++ b/src/systems/rendering/thoughts.js @@ -0,0 +1,745 @@ +/** + * Character Thoughts Rendering Module + * Handles rendering of character thoughts panel and floating thought bubbles in chat + */ + +import { getContext } from '../../../../../../extensions.js'; +import { this_chid, characters } from '../../../../../../../script.js'; +import { selected_group, getGroupMembers } from '../../../../../../group-chats.js'; +import { + extensionSettings, + lastGeneratedData, + $thoughtsContainer, + FALLBACK_AVATAR_DATA_URI +} from '../../core/state.js'; +import { saveChatData } from '../../core/persistence.js'; +import { getSafeThumbnailUrl } from '../../utils/avatars.js'; + +/** + * Renders character thoughts (Present Characters) panel. + * Displays character cards with avatars, relationship badges, and traits. + * Includes event listeners for editable character fields. + */ +export function renderThoughts() { + if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { + return; + } + + // Add updating class for animation + if (extensionSettings.enableAnimations) { + $thoughtsContainer.addClass('rpg-content-updating'); + } + + // Initialize if no data yet + if (!lastGeneratedData.characterThoughts) { + lastGeneratedData.characterThoughts = ''; + } + + const lines = lastGeneratedData.characterThoughts.split('\n'); + const presentCharacters = []; + + // console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts); + // console.log('[RPG Companion] Split into lines:', lines); + + // Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts] + for (const line of lines) { + // Skip empty lines, headers, dividers, and code fences + if (line.trim() && + !line.includes('Present Characters') && + !line.includes('---') && + !line.trim().startsWith('```')) { + + // Match the new format with pipes + const parts = line.split('|').map(p => p.trim()); + + if (parts.length >= 2) { + // First part: [Emoji]: [Name, Status, Demeanor] + const firstPart = parts[0].trim(); + const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); + + if (emojiMatch) { + const emoji = emojiMatch[1].trim(); + const info = emojiMatch[2].trim(); + const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover + const thoughts = parts[2] ? parts[2].trim() : ''; + + // Parse name from info (first part before comma) + const infoParts = info.split(',').map(p => p.trim()); + const name = infoParts[0] || ''; + const traits = infoParts.slice(1).join(', '); + + if (name && name.toLowerCase() !== 'unavailable') { + presentCharacters.push({ emoji, name, traits, relationship, thoughts }); + // console.log('[RPG Companion] Parsed character:', { name, relationship }); + } + } + } + } + } + + // Relationship status to emoji mapping + const relationshipEmojis = { + 'Enemy': '⚔️', + 'Neutral': '⚖️', + 'Friend': '⭐', + 'Lover': '❤️' + }; + + // Build HTML + let html = ''; + + // console.log('[RPG Companion] Total characters parsed:', presentCharacters.length); + // console.log('[RPG Companion] Characters array:', presentCharacters); + + // If no characters parsed, show a placeholder editable card + if (presentCharacters.length === 0) { + // Get default character portrait (try to use the current character if in 1-on-1 chat) + // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors + let defaultPortrait = FALLBACK_AVATAR_DATA_URI; + let defaultName = 'Character'; + + if (this_chid !== undefined && characters[this_chid]) { + if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); + if (thumbnailUrl) { + defaultPortrait = thumbnailUrl; + } + } + defaultName = characters[this_chid].name || 'Character'; + } + + html += '
'; + html += ` +
+
+ ${defaultName} +
⚖️
+
+
+
+ 😊 + ${defaultName} +
+
Traits
+
+
+ `; + html += '
'; + } else { + html += '
'; + for (const char of presentCharacters) { + // Find character portrait + // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors + let characterPortrait = FALLBACK_AVATAR_DATA_URI; + + // console.log('[RPG Companion] Looking for avatar for:', char.name); + + // For group chats, search through group members first + if (selected_group) { + const groupMembers = getGroupMembers(selected_group); + const matchingMember = groupMembers.find(member => + member && member.name && member.name.toLowerCase() === char.name.toLowerCase() + ); + + if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); + if (thumbnailUrl) { + characterPortrait = thumbnailUrl; + } + } + } + + // For regular chats or if not found in group, search all characters + if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { + const matchingCharacter = characters.find(c => + c && c.name && c.name.toLowerCase() === char.name.toLowerCase() + ); + + if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); + if (thumbnailUrl) { + characterPortrait = thumbnailUrl; + } + } + } + + // If this is the current character in a 1-on-1 chat, use their portrait + if (this_chid !== undefined && characters[this_chid] && + characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) { + const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); + if (thumbnailUrl) { + characterPortrait = thumbnailUrl; + } + } + + // Get relationship emoji + const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️'; + + html += ` +
+
+ ${char.name} +
${relationshipEmoji}
+
+
+
+ ${char.emoji} + ${char.name} +
+
${char.traits}
+
+
+ `; + } + html += '
'; + } + + $thoughtsContainer.html(html); + + // Add event handlers for editable character fields + $thoughtsContainer.find('.rpg-editable').on('blur', function() { + const character = $(this).data('character'); + const field = $(this).data('field'); + const value = $(this).text().trim(); + updateCharacterField(character, field, value); + }); + + // Remove updating class after animation + if (extensionSettings.enableAnimations) { + setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); + } + + // Update chat overlay if enabled + if (extensionSettings.showThoughtsInChat) { + updateChatThoughts(); + } +} + +/** + * Updates a specific character field in Present Characters data and re-renders. + * Handles character creation if character doesn't exist yet. + * + * @param {string} characterName - Name of the character to update + * @param {string} field - Field to update (emoji, name, traits, thoughts, relationship) + * @param {string} value - New value for the field + */ +export function updateCharacterField(characterName, field, value) { + // console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value); + // console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + + // Initialize if it doesn't exist + if (!lastGeneratedData.characterThoughts) { + lastGeneratedData.characterThoughts = 'Present Characters\n---\n'; + } + + const lines = lastGeneratedData.characterThoughts.split('\n'); + let characterFound = false; + + const updatedLines = lines.map(line => { + // Case-insensitive character name matching + if (line.toLowerCase().includes(characterName.toLowerCase())) { + characterFound = true; + const parts = line.split('|').map(p => p.trim()); + if (parts.length >= 2) { + const firstPart = parts[0]; + const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); + + if (emojiMatch) { + let emoji = emojiMatch[1].trim(); + let info = emojiMatch[2].trim(); + let relationship = parts[1]; + let thoughts = parts[2] || ''; + + const infoParts = info.split(',').map(p => p.trim()); + let name = infoParts[0]; + let traits = infoParts.slice(1).join(', '); + + if (field === 'emoji') { + emoji = value; + } else if (field === 'name') { + name = value; + } else if (field === 'traits') { + traits = value; + } else if (field === 'thoughts') { + thoughts = value; + } else if (field === 'relationship') { + const emojiToRelationship = { + '⚔️': 'Enemy', + '⚖️': 'Neutral', + '⭐': 'Friend', + '❤️': 'Lover' + }; + relationship = emojiToRelationship[value] || value; + } + + const newInfo = traits ? `${name}, ${traits}` : name; + return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`; + } + } + } + return line; + }); + + // If character wasn't found, create a new character line + if (!characterFound) { + // Find the divider line + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + // Create initial character line with the edited field + let emoji = '😊'; + let name = characterName; + let traits = 'Traits'; + let relationship = 'Neutral'; + let thoughts = ''; + + // Apply the edited field + if (field === 'emoji') { + emoji = value; + } else if (field === 'name') { + name = value; + } else if (field === 'traits') { + traits = value; + } else if (field === 'thoughts') { + thoughts = value; + } else if (field === 'relationship') { + const emojiToRelationship = { + '⚔️': 'Enemy', + '⚖️': 'Neutral', + '⭐': 'Friend', + '❤️': 'Lover' + }; + relationship = emojiToRelationship[value] || value; + } + + const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`; + // Insert after the divider + updatedLines.splice(dividerIndex + 1, 0, newCharacterLine); + } + } + + lastGeneratedData.characterThoughts = updatedLines.join('\n'); + // console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + + // Also update the last assistant message's swipe data + const chat = getContext().chat; + if (chat && chat.length > 0) { + // Find the last assistant message + for (let i = chat.length - 1; i >= 0; i--) { + const message = chat[i]; + if (!message.is_user) { + // Found last assistant message - update its swipe data + if (message.extra && message.extra.rpg_companion_swipes) { + const swipeId = message.swipe_id || 0; + if (message.extra.rpg_companion_swipes[swipeId]) { + message.extra.rpg_companion_swipes[swipeId].characterThoughts = updatedLines.join('\n'); + // console.log('[RPG Companion] Updated thoughts in message swipe data'); + } + } + break; + } + } + } + + saveChatData(); + + // Always update the sidebar panel + renderThoughts(); + + // For thoughts edited from the bubble, delay recreation to allow blur event to complete + // This ensures the edit is saved first, then the bubble is recreated with correct layout + if (field === 'thoughts') { + setTimeout(() => { + updateChatThoughts(); + }, 100); + } else { + // For other fields, recreate immediately + updateChatThoughts(); + } +} + +/** + * Updates or removes thought overlays in the chat. + * Creates floating thought bubbles positioned near character avatars. + */ +export function updateChatThoughts() { + // console.log('[RPG Companion] ======== updateChatThoughts called ========'); + // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled); + // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat); + // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked')); + // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + + // Remove existing thought panel and icon + $('#rpg-thought-panel').remove(); + $('#rpg-thought-icon').remove(); + $('#chat').off('scroll.thoughtPanel'); + $(window).off('resize.thoughtPanel'); + $(document).off('click.thoughtPanel'); + + // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return + if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { + // console.log('[RPG Companion] Thoughts in chat disabled or no data'); + return; + } + + // Parse the Present Characters data to get thoughts + const lines = lastGeneratedData.characterThoughts.split('\n'); + const thoughtsArray = []; // Array of {name, emoji, thought} + + // console.log('[RPG Companion] Parsing thoughts from lines:', lines); + + for (const line of lines) { + if (line.trim() && + !line.includes('Present Characters') && + !line.includes('---') && + !line.trim().startsWith('```')) { + + const parts = line.split('|').map(p => p.trim()); + // console.log('[RPG Companion] Line parts:', parts); + + if (parts.length >= 3) { + const firstPart = parts[0].trim(); + const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); + + if (emojiMatch) { + const emoji = emojiMatch[1].trim(); + const info = emojiMatch[2].trim(); + const thoughts = parts[2] ? parts[2].trim() : ''; + + const infoParts = info.split(',').map(p => p.trim()); + const name = infoParts[0] || ''; + + // console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts); + + if (name && thoughts && name.toLowerCase() !== 'unavailable') { + thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts }); + // console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase()); + } + } + } + } + } + + // If no thoughts parsed, return + if (thoughtsArray.length === 0) { + // console.log('[RPG Companion] No thoughts parsed, returning'); + return; + } + + // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); + // console.log('[RPG Companion] Thoughts array:', thoughtsArray); + + // Find the last message to position near + const $messages = $('#chat .mes'); + let $targetMessage = null; + + // Find the most recent non-user message + for (let i = $messages.length - 1; i >= 0; i--) { + const $message = $messages.eq(i); + if ($message.attr('is_user') !== 'true') { + $targetMessage = $message; + break; + } + } + + if (!$targetMessage) { + // console.log('[RPG Companion] No target message found'); + return; + } + + // Create the thought panel with all thoughts + createThoughtPanel($targetMessage, thoughtsArray); +} + +/** + * Creates or updates the floating thought panel positioned next to the character's avatar. + * Handles responsive positioning for left/right panel modes and mobile viewports. + * + * @param {jQuery} $message - Message element to position the panel relative to + * @param {Array} thoughtsArray - Array of thought objects {name, emoji, thought} + */ +export function createThoughtPanel($message, thoughtsArray) { + // Remove existing thought panel + $('#rpg-thought-panel').remove(); + $('#rpg-thought-icon').remove(); + + // Get the avatar position from the message + const $avatar = $message.find('.avatar img'); + if (!$avatar.length) { + // console.log('[RPG Companion] No avatar found in message'); + return; + } + + const avatarRect = $avatar[0].getBoundingClientRect(); + const panelPosition = extensionSettings.panelPosition; + const theme = extensionSettings.theme; + + // Build thought bubbles HTML + let thoughtsHtml = ''; + thoughtsArray.forEach((thought, index) => { + thoughtsHtml += ` +
+
+ ${thought.emoji} +
+
+ ${thought.thought} +
+
+ `; + // Add divider between thoughts (except for last one) + if (index < thoughtsArray.length - 1) { + thoughtsHtml += '
'; + } + }); + + // Create the floating thought panel with theme + const $thoughtPanel = $(` +
+ +
+
+
+
+
+
+ ${thoughtsHtml} +
+
+ `); + + // Create the collapsed thought icon + const $thoughtIcon = $(` +
+ 💭 +
+ `); + + // Apply custom theme colors if custom theme + if (theme === 'custom') { + const customStyles = { + '--rpg-bg': extensionSettings.customColors.bg, + '--rpg-accent': extensionSettings.customColors.accent, + '--rpg-text': extensionSettings.customColors.text, + '--rpg-highlight': extensionSettings.customColors.highlight + }; + $thoughtPanel.css(customStyles); + $thoughtIcon.css(customStyles); + } + + // Force a consistent width for the bubble to ensure proper positioning + $thoughtPanel.css('width', '350px'); + + // Append to body so it's not clipped by chat container + $('body').append($thoughtPanel); + $('body').append($thoughtIcon); + + // Position the panel next to the avatar + const panelWidth = 350; + const panelMargin = 20; + + let top = avatarRect.top + (avatarRect.height / 2); + let left; + let right; + let useRightPosition = false; + let iconTop = avatarRect.top; + let iconLeft; + + // Detect mobile viewport (matches CSS breakpoint) + const isMobile = window.innerWidth <= 1000; + + if (isMobile) { + // On mobile: position icon horizontally centered on avatar + // The CSS transform will shift it upward by 60px + iconTop = avatarRect.top; // Start at avatar top (CSS will move it up) + iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width) + + // Center the thought panel horizontally on mobile + left = window.innerWidth / 2 - panelWidth / 2; + top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing + + // No side-specific classes on mobile + $thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right'); + $thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right'); + + console.log('[RPG Companion] Mobile thought icon positioning:', { + isMobile, + windowWidth: window.innerWidth, + avatarLeft: avatarRect.left, + avatarWidth: avatarRect.width, + iconLeft, + iconTop + }); + } else if (panelPosition === 'left') { + // Main panel is on left, so thought bubble goes to RIGHT side + // Mirror the left side positioning: bubble should be same distance from avatar + // but on the opposite side, extending to the right + const chatContainer = $('#chat')[0]; + const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; + + // Position bubble starting from chat edge, extending right + left = chatRect.right + panelMargin; // Start at chat's right edge + margin + useRightPosition = false; // Use left positioning so it extends right + iconLeft = chatRect.right + 10; // Icon just at the chat edge + $thoughtPanel.addClass('rpg-thought-panel-right'); + $thoughtIcon.addClass('rpg-thought-icon-right'); + + // Position circles to flow from left (toward chat/avatar) to right (toward panel) + $thoughtPanel.find('.rpg-thought-circles').css({ + top: 'calc(50% - 50px)', + left: '-25px', + bottom: 'auto', + right: 'auto' + }); + // Mirror the circle flow for right side (left-to-right) + $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start'); + $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' }); + $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' }); + $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' }); + } else { + // Main panel is on right, so thought bubble goes on left (near avatar) + left = avatarRect.left - panelWidth - panelMargin; + iconLeft = avatarRect.left - 40; + $thoughtPanel.addClass('rpg-thought-panel-left'); + $thoughtIcon.addClass('rpg-thought-icon-left'); + + // Position circles to flow from avatar (left) to bubble (more left) + // Circles should flow right-to-left when bubble is on left + $thoughtPanel.find('.rpg-thought-circles').css({ + top: 'calc(50% - 50px)', + right: '-25px', + bottom: 'auto', + left: 'auto' + }); + // Keep the circle flow for left side (right-to-left) - default from CSS + $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end'); + $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' }); + $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' }); + $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' }); + } + + if (useRightPosition) { + $thoughtPanel.css({ + top: `${top}px`, + right: `${right}px`, + left: 'auto' // Clear left positioning + }); + } else { + $thoughtPanel.css({ + top: `${top}px`, + left: `${left}px`, + right: 'auto' // Clear right positioning + }); + } + + $thoughtIcon.css({ + top: `${iconTop}px`, + left: `${iconLeft}px`, + right: 'auto' // Clear any right positioning + }); + + // Initially hide the panel and show the icon + $thoughtPanel.hide(); + $thoughtIcon.show(); + + // console.log('[RPG Companion] Thought panel created at:', { top, left }); + + // Close button functionality + $thoughtPanel.find('.rpg-thought-close').on('click', function(e) { + e.stopPropagation(); + $thoughtPanel.fadeOut(200); + $thoughtIcon.fadeIn(200); + }); + + // Icon click to show panel + $thoughtIcon.on('click', function(e) { + e.stopPropagation(); + $thoughtIcon.fadeOut(200); + $thoughtPanel.fadeIn(200); + }); + + // Add event handlers for editable thoughts in the bubble + $thoughtPanel.find('.rpg-editable').on('blur', function() { + const character = $(this).data('character'); + const field = $(this).data('field'); + const value = $(this).text().trim(); + // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value); + updateCharacterField(character, field, value); + }); + + // RAF throttling for smooth position updates + let positionUpdateRaf = null; + + // Update position on scroll with RAF throttling + const updatePanelPosition = () => { + if (!$message.is(':visible')) { + $thoughtPanel.hide(); + $thoughtIcon.hide(); + return; + } + + // Cancel any pending RAF + if (positionUpdateRaf) { + cancelAnimationFrame(positionUpdateRaf); + } + + // Schedule update on next frame + positionUpdateRaf = requestAnimationFrame(() => { + const newAvatarRect = $avatar[0].getBoundingClientRect(); + const newTop = newAvatarRect.top + (newAvatarRect.height / 2); + const newIconTop = newAvatarRect.top; + let newLeft, newIconLeft; + + if (panelPosition === 'left') { + // Position at chat's right edge, extending right + const chatContainer = $('#chat')[0]; + const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; + newLeft = chatRect.right + panelMargin; + newIconLeft = chatRect.right + 10; + + $thoughtPanel.css({ + top: `${newTop}px`, + left: `${newLeft}px`, + right: 'auto' + }); + } else { + // Left position relative to avatar + newLeft = newAvatarRect.left - panelWidth - panelMargin; + newIconLeft = newAvatarRect.left - 40; + + $thoughtPanel.css({ + top: `${newTop}px`, + left: `${newLeft}px`, + right: 'auto' + }); + } + + $thoughtIcon.css({ + top: `${newIconTop}px`, + left: `${newIconLeft}px`, + right: 'auto' + }); + + if ($thoughtPanel.is(':visible')) { + $thoughtPanel.show(); + } + if ($thoughtIcon.is(':visible')) { + $thoughtIcon.show(); + } + + positionUpdateRaf = null; + }); + }; + + // Update position on scroll and resize + $('#chat').on('scroll.thoughtPanel', updatePanelPosition); + $(window).on('resize.thoughtPanel', updatePanelPosition); + + // Remove panel when clicking outside (but not when clicking icon or panel) + $(document).on('click.thoughtPanel', function(e) { + if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) { + // Hide the panel and show the icon instead of removing + $thoughtPanel.fadeOut(200); + $thoughtIcon.fadeIn(200); + } + }); +} diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js new file mode 100644 index 0000000..6b907fb --- /dev/null +++ b/src/systems/rendering/userStats.js @@ -0,0 +1,223 @@ +/** + * User Stats Rendering Module + * Handles rendering of the user stats panel with progress bars and classic RPG stats + */ + +import { getContext } from '../../../../../../extensions.js'; +import { user_avatar } from '../../../../../../../script.js'; +import { + extensionSettings, + lastGeneratedData, + $userStatsContainer, + FALLBACK_AVATAR_DATA_URI +} from '../../core/state.js'; +import { + saveSettings, + saveChatData, + updateMessageSwipeData +} from '../../core/persistence.js'; +import { getSafeThumbnailUrl } from '../../utils/avatars.js'; + +/** + * Renders the user stats panel with health bars, mood, inventory, and classic stats. + * Includes event listeners for editable fields. + */ +export function renderUserStats() { + if (!extensionSettings.showUserStats || !$userStatsContainer) { + return; + } + + const stats = extensionSettings.userStats; + const userName = getContext().name1; + + // Initialize lastGeneratedData.userStats if it doesn't exist + if (!lastGeneratedData.userStats) { + lastGeneratedData.userStats = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\nInventory: ${stats.inventory}`; + } + + // Get user portrait - handle both default-user and custom persona folders + // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors + let userPortrait = FALLBACK_AVATAR_DATA_URI; + + if (user_avatar) { + // Try to get the thumbnail using our safe helper + const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar); + if (thumbnailUrl) { + userPortrait = thumbnailUrl; + } + } + + // Create gradient from low to high color + const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`; + + const html = ` +
+
+
+ ${userName} +
+
+
+ Health: +
+
+
+ ${stats.health}% +
+ +
+ Satiety: +
+
+
+ ${stats.satiety}% +
+ +
+ Energy: +
+
+
+ ${stats.energy}% +
+ +
+ Hygiene: +
+
+
+ ${stats.hygiene}% +
+ +
+ Arousal: +
+
+
+ ${stats.arousal}% +
+
+ +
+
${stats.mood}
+
${stats.conditions}
+
+
+ +
+
+
+
+ STR +
+ + ${extensionSettings.classicStats.str} + +
+
+
+ DEX +
+ + ${extensionSettings.classicStats.dex} + +
+
+
+ CON +
+ + ${extensionSettings.classicStats.con} + +
+
+
+ INT +
+ + ${extensionSettings.classicStats.int} + +
+
+
+ WIS +
+ + ${extensionSettings.classicStats.wis} + +
+
+
+ CHA +
+ + ${extensionSettings.classicStats.cha} + +
+
+
+
+
+
+ `; + + $userStatsContainer.html(html); + + // Add event listeners for editable stat values + $('.rpg-editable-stat').on('blur', function() { + const field = $(this).data('field'); + const textValue = $(this).text().replace('%', '').trim(); + let value = parseInt(textValue); + + // Validate and clamp value between 0 and 100 + if (isNaN(value)) { + value = 0; + } + value = Math.max(0, Math.min(100, value)); + + // Update the setting + extensionSettings.userStats[field] = value; + + // Also update lastGeneratedData to keep it in sync + if (!lastGeneratedData.userStats) { + lastGeneratedData.userStats = ''; + } + // Regenerate the userStats text with updated value + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Re-render to update the bar + renderUserStats(); + }); + + // Add event listeners for mood/conditions editing + $('.rpg-mood-emoji.rpg-editable').on('blur', function() { + const value = $(this).text().trim(); + extensionSettings.userStats.mood = value || '😐'; + + // Update lastGeneratedData + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + }); + + $('.rpg-mood-conditions.rpg-editable').on('blur', function() { + const value = $(this).text().trim(); + extensionSettings.userStats.conditions = value || 'None'; + + // Update lastGeneratedData + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + }); +} diff --git a/src/systems/ui/desktop.js b/src/systems/ui/desktop.js new file mode 100644 index 0000000..2b8e2a4 --- /dev/null +++ b/src/systems/ui/desktop.js @@ -0,0 +1,139 @@ +/** + * Desktop UI Module + * Handles desktop-specific UI functionality: tab navigation + */ + +/** + * Sets up desktop tab navigation for organizing content. + * Only runs on desktop viewports (>1000px). + * Creates two tabs: Status (Stats/Info/Thoughts) and Inventory. + */ +export function setupDesktopTabs() { + const isDesktop = window.innerWidth > 1000; + if (!isDesktop) return; + + // Check if tabs already exist + if ($('.rpg-tabs-nav').length > 0) return; + + const $contentBox = $('.rpg-content-box'); + + // Get existing sections + const $userStats = $('#rpg-user-stats'); + const $infoBox = $('#rpg-info-box'); + const $thoughts = $('#rpg-thoughts'); + const $inventory = $('#rpg-inventory'); + + // If no sections exist, nothing to organize + if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0) { + return; + } + + // Create tab navigation + const $tabNav = $(` +
+ + +
+ `); + + // Create tab content containers + const $statusTab = $('
'); + const $inventoryTab = $('
'); + + // Move sections into their respective tabs (detach to preserve event handlers) + if ($userStats.length > 0) { + $statusTab.append($userStats.detach()); + $userStats.show(); + } + if ($infoBox.length > 0) { + $statusTab.append($infoBox.detach()); + $infoBox.show(); + } + if ($thoughts.length > 0) { + $statusTab.append($thoughts.detach()); + $thoughts.show(); + } + if ($inventory.length > 0) { + $inventoryTab.append($inventory.detach()); + $inventory.show(); + } + + // Hide dividers on desktop tabs (tabs separate content naturally) + $('.rpg-divider').hide(); + + // Build desktop tab structure + const $tabsContainer = $('
'); + $tabsContainer.append($tabNav); + $tabsContainer.append($statusTab); + $tabsContainer.append($inventoryTab); + + // Replace content box with tabs container + $contentBox.html('').append($tabsContainer); + + // Handle tab switching + $tabNav.find('.rpg-tab-btn').on('click', function() { + const tabName = $(this).data('tab'); + + // Update active tab button + $tabNav.find('.rpg-tab-btn').removeClass('active'); + $(this).addClass('active'); + + // Update active tab content + $('.rpg-tab-content').removeClass('active'); + $(`.rpg-tab-content[data-tab-content="${tabName}"]`).addClass('active'); + }); + + console.log('[RPG Desktop] Desktop tabs initialized'); +} + +/** + * Removes desktop tab navigation and restores original layout. + * Used when transitioning from desktop to mobile. + */ +export function removeDesktopTabs() { + // Get sections from tabs before removing + const $userStats = $('#rpg-user-stats').detach(); + const $infoBox = $('#rpg-info-box').detach(); + const $thoughts = $('#rpg-thoughts').detach(); + const $inventory = $('#rpg-inventory').detach(); + + // Remove tabs container + $('.rpg-tabs-container').remove(); + + // Get dividers + const $dividerStats = $('#rpg-divider-stats'); + const $dividerInfo = $('#rpg-divider-info'); + const $dividerThoughts = $('#rpg-divider-thoughts'); + + // Restore original sections to content box in correct order + const $contentBox = $('.rpg-content-box'); + + // Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory + if ($dividerStats.length) { + $dividerStats.before($userStats); + $dividerInfo.before($infoBox); + $dividerThoughts.before($thoughts); + $contentBox.append($inventory); + } else { + // Fallback if dividers don't exist + $contentBox.append($userStats); + $contentBox.append($infoBox); + $contentBox.append($thoughts); + $contentBox.append($inventory); + } + + // Show sections and dividers + $userStats.show(); + $infoBox.show(); + $thoughts.show(); + $inventory.show(); + $('.rpg-divider').show(); + + console.log('[RPG Desktop] Desktop tabs removed'); +} diff --git a/src/systems/ui/layout.js b/src/systems/ui/layout.js new file mode 100644 index 0000000..9993154 --- /dev/null +++ b/src/systems/ui/layout.js @@ -0,0 +1,276 @@ +/** + * Layout Management Module + * Handles panel visibility, section visibility, collapse/expand toggle, and panel positioning + */ + +import { + extensionSettings, + $panelContainer, + $userStatsContainer, + $infoBoxContainer, + $thoughtsContainer, + $inventoryContainer +} from '../../core/state.js'; + +/** + * Toggles the visibility of plot buttons based on settings. + */ +export function togglePlotButtons() { + if (extensionSettings.enablePlotButtons && extensionSettings.enabled) { + $('#rpg-plot-buttons').show(); + } else { + $('#rpg-plot-buttons').hide(); + } +} + +/** + * Helper function to close the mobile panel with animation. + */ +export function closeMobilePanelWithAnimation() { + const $panel = $('#rpg-companion-panel'); + const $mobileToggle = $('#rpg-mobile-toggle'); + + // Add closing class to trigger slide-out animation + $panel.removeClass('rpg-mobile-open').addClass('rpg-mobile-closing'); + $mobileToggle.removeClass('active'); + + // Wait for animation to complete before hiding + $panel.one('animationend', function() { + $panel.removeClass('rpg-mobile-closing'); + $('.rpg-mobile-overlay').remove(); + }); +} + +/** + * Updates the collapse toggle icon direction based on panel position. + */ +export function updateCollapseToggleIcon() { + const $collapseToggle = $('#rpg-collapse-toggle'); + const $panel = $('#rpg-companion-panel'); + const $icon = $collapseToggle.find('i'); + const isMobile = window.innerWidth <= 1000; + + if (isMobile) { + // Mobile: slides from right, use same icon logic as desktop right panel + const isOpen = $panel.hasClass('rpg-mobile-open'); + console.log('[RPG Mobile] updateCollapseToggleIcon:', { + isMobile: true, + isOpen, + settingIcon: isOpen ? 'chevron-left' : 'chevron-right' + }); + if (isOpen) { + // Panel open - chevron points left (to close/slide back right) + $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-right').addClass('fa-chevron-left'); + } else { + // Panel closed - chevron points right (to open/slide in from right) + $icon.removeClass('fa-chevron-down fa-chevron-up fa-chevron-left').addClass('fa-chevron-right'); + } + } else { + // Desktop: icon direction based on panel position and collapsed state + const isCollapsed = $panel.hasClass('rpg-collapsed'); + + if (isCollapsed) { + // When collapsed, arrow points inward (to expand) + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } + } else { + // When expanded, arrow points outward (to collapse) + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } + } + } +} + +/** + * Sets up the collapse/expand toggle button for side panels. + */ +export function setupCollapseToggle() { + const $collapseToggle = $('#rpg-collapse-toggle'); + const $panel = $('#rpg-companion-panel'); + const $icon = $collapseToggle.find('i'); + + $collapseToggle.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + const isMobile = window.innerWidth <= 1000; + + // On mobile: button toggles panel open/closed (same as desktop behavior) + if (isMobile) { + const isOpen = $panel.hasClass('rpg-mobile-open'); + console.log('[RPG Mobile] Collapse toggle clicked. Current state:', { + isOpen, + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('bottom'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + } + }); + + if (isOpen) { + // Close panel with animation + console.log('[RPG Mobile] Closing panel'); + closeMobilePanelWithAnimation(); + } else { + // Open panel + console.log('[RPG Mobile] Opening panel'); + $panel.addClass('rpg-mobile-open'); + const $overlay = $('
'); + $('body').append($overlay); + + // Debug: Check state after animation should complete + setTimeout(() => { + console.log('[RPG Mobile] 500ms after opening:', { + panelClasses: $panel.attr('class'), + hasOpenClass: $panel.hasClass('rpg-mobile-open'), + visibility: $panel.css('visibility'), + transform: $panel.css('transform'), + display: $panel.css('display'), + opacity: $panel.css('opacity') + }); + }, 500); + + // Close when clicking overlay + $overlay.on('click', function() { + console.log('[RPG Mobile] Overlay clicked - closing panel'); + closeMobilePanelWithAnimation(); + updateCollapseToggleIcon(); + }); + } + + // Update icon to reflect new state + updateCollapseToggleIcon(); + + console.log('[RPG Mobile] After toggle:', { + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('bottom'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + }, + gameContainer: { + opacity: $('.rpg-game-container').css('opacity'), + visibility: $('.rpg-game-container').css('visibility') + } + }); + return; + } + + // Desktop behavior: collapse/expand side panel + const isCollapsed = $panel.hasClass('rpg-collapsed'); + + if (isCollapsed) { + // Expand panel + $panel.removeClass('rpg-collapsed'); + + // Update icon based on position + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } + } else { + // Collapse panel + $panel.addClass('rpg-collapsed'); + + // Update icon based on position + if ($panel.hasClass('rpg-position-right')) { + $icon.removeClass('fa-chevron-right').addClass('fa-chevron-left'); + } else if ($panel.hasClass('rpg-position-left')) { + $icon.removeClass('fa-chevron-left').addClass('fa-chevron-right'); + } + } + }); + + // Set initial icon direction based on panel position + updateCollapseToggleIcon(); +} + +/** + * Updates the visibility of the entire panel. + */ +export function updatePanelVisibility() { + if (extensionSettings.enabled) { + $panelContainer.show(); + togglePlotButtons(); // Update plot button visibility + } else { + $panelContainer.hide(); + $('#rpg-plot-buttons').hide(); // Hide plot buttons when disabled + } +} + +/** + * Updates the visibility of individual sections. + */ +export function updateSectionVisibility() { + // Show/hide sections based on settings + $userStatsContainer.toggle(extensionSettings.showUserStats); + $infoBoxContainer.toggle(extensionSettings.showInfoBox); + $thoughtsContainer.toggle(extensionSettings.showCharacterThoughts); + if ($inventoryContainer) { + $inventoryContainer.toggle(extensionSettings.showInventory); + } + + // Show/hide dividers intelligently + // Divider after User Stats: shown if User Stats is visible AND at least one section after it is visible + const showDividerAfterStats = extensionSettings.showUserStats && + (extensionSettings.showInfoBox || extensionSettings.showCharacterThoughts || extensionSettings.showInventory); + $('#rpg-divider-stats').toggle(showDividerAfterStats); + + // Divider after Info Box: shown if Info Box is visible AND at least one section after it is visible + const showDividerAfterInfo = extensionSettings.showInfoBox && + (extensionSettings.showCharacterThoughts || extensionSettings.showInventory); + $('#rpg-divider-info').toggle(showDividerAfterInfo); + + // Divider after Thoughts: shown if Thoughts is visible AND Inventory is visible + const showDividerAfterThoughts = extensionSettings.showCharacterThoughts && + extensionSettings.showInventory; + $('#rpg-divider-thoughts').toggle(showDividerAfterThoughts); +} + +/** + * Applies the selected panel position. + */ +export function applyPanelPosition() { + if (!$panelContainer) return; + + const isMobile = window.innerWidth <= 1000; + + // Remove all position classes + $panelContainer.removeClass('rpg-position-left rpg-position-right rpg-position-top'); + + // On mobile, don't apply desktop position classes + if (isMobile) { + return; + } + + // Desktop: Add the appropriate position class + $panelContainer.addClass(`rpg-position-${extensionSettings.panelPosition}`); + + // Update collapse toggle icon direction for new position + updateCollapseToggleIcon(); +} + +/** + * Updates the UI based on generation mode selection. + */ +export function updateGenerationModeUI() { + if (extensionSettings.generationMode === 'together') { + // In "together" mode, manual update button is hidden + $('#rpg-manual-update').hide(); + } else { + // In "separate" mode, manual update button is visible + $('#rpg-manual-update').show(); + } +} diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js new file mode 100644 index 0000000..f2648b3 --- /dev/null +++ b/src/systems/ui/mobile.js @@ -0,0 +1,718 @@ +/** + * Mobile UI Module + * Handles mobile-specific UI functionality: FAB dragging, tabs, keyboard handling + */ + +import { extensionSettings } from '../../core/state.js'; +import { saveSettings } from '../../core/persistence.js'; +import { closeMobilePanelWithAnimation, updateCollapseToggleIcon } from './layout.js'; +import { setupDesktopTabs, removeDesktopTabs } from './desktop.js'; + +/** + * Sets up the mobile toggle button (FAB) with drag functionality. + * Handles touch/mouse events for positioning and panel toggling. + */ +export function setupMobileToggle() { + const $mobileToggle = $('#rpg-mobile-toggle'); + const $panel = $('#rpg-companion-panel'); + const $overlay = $('
'); + + // DIAGNOSTIC: Check if elements exist and log setup state + console.log('[RPG Mobile] ========================================'); + console.log('[RPG Mobile] setupMobileToggle called'); + console.log('[RPG Mobile] Button exists:', $mobileToggle.length > 0, 'jQuery object:', $mobileToggle); + console.log('[RPG Mobile] Panel exists:', $panel.length > 0); + console.log('[RPG Mobile] Window width:', window.innerWidth); + console.log('[RPG Mobile] Is mobile viewport (<=1000):', window.innerWidth <= 1000); + console.log('[RPG Mobile] ========================================'); + + if ($mobileToggle.length === 0) { + console.error('[RPG Mobile] ERROR: Mobile toggle button not found in DOM!'); + console.error('[RPG Mobile] Cannot attach event handlers - button does not exist'); + return; // Exit early if button doesn't exist + } + + // Load and apply saved FAB position + if (extensionSettings.mobileFabPosition) { + const pos = extensionSettings.mobileFabPosition; + console.log('[RPG Mobile] Loading saved FAB position:', pos); + + // Apply saved position + if (pos.top) $mobileToggle.css('top', pos.top); + if (pos.right) $mobileToggle.css('right', pos.right); + if (pos.bottom) $mobileToggle.css('bottom', pos.bottom); + if (pos.left) $mobileToggle.css('left', pos.left); + + // Constrain to viewport after position is applied + requestAnimationFrame(() => constrainFabToViewport()); + } + + // Touch/drag state + let isDragging = false; + let touchStartTime = 0; + let touchStartX = 0; + let touchStartY = 0; + let buttonStartX = 0; + let buttonStartY = 0; + const LONG_PRESS_DURATION = 200; // ms to hold before enabling drag + const MOVE_THRESHOLD = 10; // px to move before enabling drag + let rafId = null; // RequestAnimationFrame ID for smooth updates + let pendingX = null; + let pendingY = null; + + // Update position using requestAnimationFrame for smooth rendering + function updateFabPosition() { + if (pendingX !== null && pendingY !== null) { + $mobileToggle.css({ + left: pendingX + 'px', + top: pendingY + 'px', + right: 'auto', + bottom: 'auto' + }); + pendingX = null; + pendingY = null; + } + rafId = null; + } + + // Touch start - begin tracking + $mobileToggle.on('touchstart', function(e) { + const touch = e.originalEvent.touches[0]; + + touchStartTime = Date.now(); + touchStartX = touch.clientX; + touchStartY = touch.clientY; + + const offset = $mobileToggle.offset(); + buttonStartX = offset.left; + buttonStartY = offset.top; + + isDragging = false; + }); + + // Touch move - check if should start dragging + $mobileToggle.on('touchmove', function(e) { + const touch = e.originalEvent.touches[0]; + const deltaX = touch.clientX - touchStartX; + const deltaY = touch.clientY - touchStartY; + const timeSinceStart = Date.now() - touchStartTime; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Start dragging if held long enough OR moved far enough + if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { + isDragging = true; + $mobileToggle.addClass('dragging'); // Disable transitions while dragging + } + + if (isDragging) { + e.preventDefault(); // Prevent scrolling while dragging + + // Calculate new position + let newX = buttonStartX + deltaX; + let newY = buttonStartY + deltaY; + + // Get button dimensions + const buttonWidth = $mobileToggle.outerWidth(); + const buttonHeight = $mobileToggle.outerHeight(); + + // Constrain to viewport with 10px padding + const minX = 10; + const maxX = window.innerWidth - buttonWidth - 10; + const minY = 10; + const maxY = window.innerHeight - buttonHeight - 10; + + newX = Math.max(minX, Math.min(maxX, newX)); + newY = Math.max(minY, Math.min(maxY, newY)); + + // Store pending position and request animation frame for smooth update + pendingX = newX; + pendingY = newY; + if (!rafId) { + rafId = requestAnimationFrame(updateFabPosition); + } + } + }); + + // Mouse drag support for desktop + let mouseDown = false; + + $mobileToggle.on('mousedown', function(e) { + // Prevent default to avoid text selection + e.preventDefault(); + + touchStartTime = Date.now(); + touchStartX = e.clientX; + touchStartY = e.clientY; + + const offset = $mobileToggle.offset(); + buttonStartX = offset.left; + buttonStartY = offset.top; + + isDragging = false; + mouseDown = true; + }); + + // Mouse move - only track if mouse is down + $(document).on('mousemove', function(e) { + if (!mouseDown) return; + + const deltaX = e.clientX - touchStartX; + const deltaY = e.clientY - touchStartY; + const timeSinceStart = Date.now() - touchStartTime; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Start dragging if held long enough OR moved far enough + if (!isDragging && (timeSinceStart > LONG_PRESS_DURATION || distance > MOVE_THRESHOLD)) { + isDragging = true; + $mobileToggle.addClass('dragging'); // Disable transitions while dragging + } + + if (isDragging) { + e.preventDefault(); + + // Calculate new position + let newX = buttonStartX + deltaX; + let newY = buttonStartY + deltaY; + + // Get button dimensions + const buttonWidth = $mobileToggle.outerWidth(); + const buttonHeight = $mobileToggle.outerHeight(); + + // Constrain to viewport with 10px padding + const minX = 10; + const maxX = window.innerWidth - buttonWidth - 10; + const minY = 10; + const maxY = window.innerHeight - buttonHeight - 10; + + newX = Math.max(minX, Math.min(maxX, newX)); + newY = Math.max(minY, Math.min(maxY, newY)); + + // Store pending position and request animation frame for smooth update + pendingX = newX; + pendingY = newY; + if (!rafId) { + rafId = requestAnimationFrame(updateFabPosition); + } + } + }); + + // Mouse up - save position or let click handler toggle + $(document).on('mouseup', function(e) { + if (!mouseDown) return; + + mouseDown = false; + + if (isDragging) { + // Was dragging - save new position + const offset = $mobileToggle.offset(); + const newPosition = { + left: offset.left + 'px', + top: offset.top + 'px' + }; + + extensionSettings.mobileFabPosition = newPosition; + saveSettings(); + + console.log('[RPG Mobile] Saved new FAB position (mouse):', newPosition); + + // Constrain to viewport bounds (now that position is saved) + setTimeout(() => constrainFabToViewport(), 10); + + // Re-enable transitions with smooth animation + setTimeout(() => { + $mobileToggle.removeClass('dragging'); + }, 50); + + isDragging = false; + + // Prevent click from firing after drag + e.preventDefault(); + e.stopPropagation(); + + // Add flag to prevent click handler from firing + $mobileToggle.data('just-dragged', true); + setTimeout(() => { + $mobileToggle.data('just-dragged', false); + }, 100); + } + // If not dragging, let the click handler toggle the panel + }); + + // Touch end - save position or toggle panel + $mobileToggle.on('touchend', function(e) { + // TEMPORARILY COMMENTED FOR DIAGNOSIS - might be blocking click fallback + // e.preventDefault(); + + if (isDragging) { + // Was dragging - save new position + const offset = $mobileToggle.offset(); + const newPosition = { + left: offset.left + 'px', + top: offset.top + 'px' + }; + + extensionSettings.mobileFabPosition = newPosition; + saveSettings(); + + console.log('[RPG Mobile] Saved new FAB position:', newPosition); + + // Constrain to viewport bounds (now that position is saved) + setTimeout(() => constrainFabToViewport(), 10); + + // Re-enable transitions with smooth animation + setTimeout(() => { + $mobileToggle.removeClass('dragging'); + }, 50); + + isDragging = false; + } else { + // Was a tap - toggle panel + console.log('[RPG Mobile] Quick tap detected - toggling panel'); + + if ($panel.hasClass('rpg-mobile-open')) { + // Close panel with animation + closeMobilePanelWithAnimation(); + } else { + // Open panel + $panel.addClass('rpg-mobile-open'); + $('body').append($overlay); + $mobileToggle.addClass('active'); + + // Close when clicking overlay + $overlay.on('click', function() { + closeMobilePanelWithAnimation(); + }); + } + } + }); + + // Click handler - works on both mobile and desktop + $mobileToggle.on('click', function(e) { + // Skip if we just finished dragging + if ($mobileToggle.data('just-dragged')) { + console.log('[RPG Mobile] Click blocked - just finished dragging'); + return; + } + + console.log('[RPG Mobile] >>> CLICK EVENT FIRED <<<', { + windowWidth: window.innerWidth, + isMobileViewport: window.innerWidth <= 1000, + panelOpen: $panel.hasClass('rpg-mobile-open') + }); + + // Work on both mobile and desktop (removed viewport check) + if ($panel.hasClass('rpg-mobile-open')) { + console.log('[RPG Mobile] Click: Closing panel'); + closeMobilePanelWithAnimation(); + } else { + console.log('[RPG Mobile] Click: Opening panel'); + $panel.addClass('rpg-mobile-open'); + $('body').append($overlay); + $mobileToggle.addClass('active'); + + $overlay.on('click', function() { + console.log('[RPG Mobile] Overlay clicked - closing panel'); + closeMobilePanelWithAnimation(); + }); + } + }); + + // Handle viewport resize to manage desktop/mobile transitions + let wasMobile = window.innerWidth <= 1000; + let resizeTimer; + + $(window).on('resize', function() { + clearTimeout(resizeTimer); + + const isMobile = window.innerWidth <= 1000; + const $panel = $('#rpg-companion-panel'); + const $mobileToggle = $('#rpg-mobile-toggle'); + + // Transitioning from desktop to mobile - handle immediately for smooth transition + if (!wasMobile && isMobile) { + console.log('[RPG Mobile] Transitioning desktop -> mobile'); + + // Remove desktop tabs first + removeDesktopTabs(); + + // Remove desktop positioning classes + $panel.removeClass('rpg-position-right rpg-position-left rpg-position-top'); + + // Clear collapsed state - mobile doesn't use collapse + $panel.removeClass('rpg-collapsed'); + + // Close panel on mobile with animation + closeMobilePanelWithAnimation(); + + // Clear any inline styles that might be overriding CSS + $panel.attr('style', ''); + + console.log('[RPG Mobile] After cleanup:', { + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('bottom'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + } + }); + + // Set up mobile tabs IMMEDIATELY (no debounce delay) + setupMobileTabs(); + + // Update icon for mobile state + updateCollapseToggleIcon(); + + wasMobile = isMobile; + return; + } + + // For mobile to desktop transition, use debounce + resizeTimer = setTimeout(function() { + const isMobile = window.innerWidth <= 1000; + + // Transitioning from mobile to desktop + if (wasMobile && !isMobile) { + // Disable transitions to prevent left→right slide animation + $panel.css('transition', 'none'); + + $panel.removeClass('rpg-mobile-open rpg-mobile-closing'); + $mobileToggle.removeClass('active'); + $('.rpg-mobile-overlay').remove(); + + // Restore desktop positioning class + const position = extensionSettings.panelPosition || 'right'; + $panel.addClass('rpg-position-' + position); + + // Remove mobile tabs structure + removeMobileTabs(); + + // Setup desktop tabs + setupDesktopTabs(); + + // Force reflow to apply position instantly + $panel[0].offsetHeight; + + // Re-enable transitions after positioned + setTimeout(function() { + $panel.css('transition', ''); + }, 50); + } + + wasMobile = isMobile; + + // Constrain FAB to viewport after resize (only if user has positioned it) + constrainFabToViewport(); + }, 150); // Debounce only for mobile→desktop + }); + + // Initialize mobile tabs if starting on mobile + const isMobile = window.innerWidth <= 1000; + if (isMobile) { + const $panel = $('#rpg-companion-panel'); + // Clear any inline styles + $panel.attr('style', ''); + + console.log('[RPG Mobile] Initial load on mobile viewport:', { + panelClasses: $panel.attr('class'), + inlineStyles: $panel.attr('style'), + panelPosition: { + top: $panel.css('top'), + bottom: $panel.css('top'), + transform: $panel.css('transform'), + visibility: $panel.css('visibility') + } + }); + setupMobileTabs(); + // Set initial icon for mobile + updateCollapseToggleIcon(); + } +} + +/** + * Constrains the mobile FAB button to viewport bounds with top-bar awareness. + * Only runs when button is in user-controlled state (mobileFabPosition exists). + * Ensures button never goes behind the top bar or outside viewport edges. + */ +export function constrainFabToViewport() { + // Only constrain if user has set a custom position + if (!extensionSettings.mobileFabPosition) { + console.log('[RPG Mobile] Skipping viewport constraint - using CSS defaults'); + return; + } + + const $mobileToggle = $('#rpg-mobile-toggle'); + if ($mobileToggle.length === 0) return; + + // Skip if button is not visible + if (!$mobileToggle.is(':visible')) { + console.log('[RPG Mobile] Skipping viewport constraint - button not visible'); + return; + } + + // Get current position + const offset = $mobileToggle.offset(); + if (!offset) return; + + let currentX = offset.left; + let currentY = offset.top; + + const buttonWidth = $mobileToggle.outerWidth(); + const buttonHeight = $mobileToggle.outerHeight(); + + // Get top bar height from CSS variable (fallback to 50px if not set) + const topBarHeight = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--topBarBlockSize')) || 50; + + // Calculate viewport bounds with padding + // Use top bar height + extra padding for top bound + const minX = 10; + const maxX = window.innerWidth - buttonWidth - 10; + const minY = topBarHeight + 60; // Top bar + extra space for visibility + const maxY = window.innerHeight - buttonHeight - 10; + + // Constrain to bounds + let newX = Math.max(minX, Math.min(maxX, currentX)); + let newY = Math.max(minY, Math.min(maxY, currentY)); + + // Only update if position changed + if (newX !== currentX || newY !== currentY) { + console.log('[RPG Mobile] Constraining FAB to viewport:', { + old: { x: currentX, y: currentY }, + new: { x: newX, y: newY }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + topBarHeight + }); + + // Apply new position + $mobileToggle.css({ + left: newX + 'px', + top: newY + 'px', + right: 'auto', + bottom: 'auto' + }); + + // Save corrected position + extensionSettings.mobileFabPosition = { + left: newX + 'px', + top: newY + 'px' + }; + saveSettings(); + } +} + +/** + * Sets up mobile tab navigation for organizing content. + * Only runs on mobile viewports (<=1000px). + */ +export function setupMobileTabs() { + const isMobile = window.innerWidth <= 1000; + if (!isMobile) return; + + // Check if tabs already exist + if ($('.rpg-mobile-tabs').length > 0) return; + + const $panel = $('#rpg-companion-panel'); + const $contentBox = $panel.find('.rpg-content-box'); + + // Get existing sections + const $userStats = $('#rpg-user-stats'); + const $infoBox = $('#rpg-info-box'); + const $thoughts = $('#rpg-thoughts'); + const $inventory = $('#rpg-inventory'); + + // If no sections exist, nothing to organize + if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0) { + return; + } + + // Create tab navigation (3 tabs for mobile) + const tabs = []; + const hasStats = $userStats.length > 0; + const hasInfo = $infoBox.length > 0 || $thoughts.length > 0; + const hasInventory = $inventory.length > 0; + + // Tab 1: Stats (User Stats only) + if (hasStats) { + tabs.push(''); + } + // Tab 2: Info (Info Box + Character Thoughts) + if (hasInfo) { + tabs.push(''); + } + // Tab 3: Inventory + if (hasInventory) { + tabs.push(''); + } + + const $tabNav = $('
' + tabs.join('') + '
'); + + // Determine which tab should be active + let firstTab = ''; + if (hasStats) firstTab = 'stats'; + else if (hasInfo) firstTab = 'info'; + else if (hasInventory) firstTab = 'inventory'; + + // Create tab content wrappers + const $statsTab = $('
'); + const $infoTab = $('
'); + const $inventoryTab = $('
'); + + // Move sections into their respective tabs (detach to preserve event handlers) + // Stats tab: User Stats only + if ($userStats.length > 0) { + $statsTab.append($userStats.detach()); + $userStats.show(); + } + + // Info tab: Info Box + Character Thoughts + if ($infoBox.length > 0) { + $infoTab.append($infoBox.detach()); + $infoBox.show(); + } + if ($thoughts.length > 0) { + $infoTab.append($thoughts.detach()); + $thoughts.show(); + } + + // Inventory tab: Inventory only + if ($inventory.length > 0) { + $inventoryTab.append($inventory.detach()); + $inventory.show(); + } + + // Hide dividers on mobile + $('.rpg-divider').hide(); + + // Build mobile tab structure + const $mobileContainer = $('
'); + $mobileContainer.append($tabNav); + + // Only append tab content wrappers that have content + if (hasStats) $mobileContainer.append($statsTab); + if (hasInfo) $mobileContainer.append($infoTab); + if (hasInventory) $mobileContainer.append($inventoryTab); + + // Insert mobile tab structure at the beginning of content box + $contentBox.prepend($mobileContainer); + + // Handle tab switching + $tabNav.find('.rpg-mobile-tab').on('click', function() { + const tabName = $(this).data('tab'); + + // Update active tab button + $tabNav.find('.rpg-mobile-tab').removeClass('active'); + $(this).addClass('active'); + + // Update active tab content + $mobileContainer.find('.rpg-mobile-tab-content').removeClass('active'); + $mobileContainer.find('[data-tab-content="' + tabName + '"]').addClass('active'); + }); +} + +/** + * Removes mobile tab navigation and restores desktop layout. + */ +export function removeMobileTabs() { + // Get sections from tabs before removing + const $userStats = $('#rpg-user-stats').detach(); + const $infoBox = $('#rpg-info-box').detach(); + const $thoughts = $('#rpg-thoughts').detach(); + const $inventory = $('#rpg-inventory').detach(); + + // Remove mobile tab container + $('.rpg-mobile-container').remove(); + + // Get dividers + const $dividerStats = $('#rpg-divider-stats'); + const $dividerInfo = $('#rpg-divider-info'); + const $dividerThoughts = $('#rpg-divider-thoughts'); + + // Restore original sections to content box in correct order + const $contentBox = $('.rpg-content-box'); + + // Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory + if ($dividerStats.length) { + $dividerStats.before($userStats); + $dividerInfo.before($infoBox); + $dividerThoughts.before($thoughts); + $contentBox.append($inventory); + } else { + // Fallback if dividers don't exist + $contentBox.prepend($inventory); + $contentBox.prepend($thoughts); + $contentBox.prepend($infoBox); + $contentBox.prepend($userStats); + } + + // Show sections and dividers + $userStats.show(); + $infoBox.show(); + $thoughts.show(); + $inventory.show(); + $('.rpg-divider').show(); +} + +/** + * Sets up mobile keyboard handling using Visual Viewport API. + * Prevents layout squashing when keyboard appears by detecting + * viewport changes and adding CSS classes for adjustment. + */ +export function setupMobileKeyboardHandling() { + if (!window.visualViewport) { + // console.log('[RPG Mobile] Visual Viewport API not supported'); + return; + } + + const $panel = $('#rpg-companion-panel'); + let keyboardVisible = false; + + // Listen for viewport resize (keyboard show/hide) + window.visualViewport.addEventListener('resize', () => { + // Only handle if panel is open on mobile + if (!$panel.hasClass('rpg-mobile-open')) return; + + const viewportHeight = window.visualViewport.height; + const windowHeight = window.innerHeight; + + // Keyboard visible if viewport significantly smaller than window + // Using 75% threshold to account for browser UI variations + const isKeyboardShowing = viewportHeight < windowHeight * 0.75; + + if (isKeyboardShowing && !keyboardVisible) { + // Keyboard just appeared + keyboardVisible = true; + $panel.addClass('rpg-keyboard-visible'); + // console.log('[RPG Mobile] Keyboard opened'); + } else if (!isKeyboardShowing && keyboardVisible) { + // Keyboard just disappeared + keyboardVisible = false; + $panel.removeClass('rpg-keyboard-visible'); + // console.log('[RPG Mobile] Keyboard closed'); + } + }); +} + +/** + * Handles focus on contenteditable fields to ensure they're visible when keyboard appears. + * Uses smooth scrolling to bring focused field into view with proper padding. + */ +export function setupContentEditableScrolling() { + const $panel = $('#rpg-companion-panel'); + + // Use event delegation for all contenteditable fields + $panel.on('focusin', '[contenteditable="true"]', function(e) { + const $field = $(this); + + // Small delay to let keyboard animate in + setTimeout(() => { + // Scroll field into view with padding + // Using 'center' to ensure field is in middle of viewport + $field[0].scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest' + }); + }, 300); + }); +} diff --git a/src/systems/ui/modals.js b/src/systems/ui/modals.js new file mode 100644 index 0000000..51b1827 --- /dev/null +++ b/src/systems/ui/modals.js @@ -0,0 +1,500 @@ +/** + * Modal Management Module + * Handles DiceModal and SettingsModal ES6 classes with state management + */ + +import { getContext } from '../../../../../../extensions.js'; +import { + extensionSettings, + lastGeneratedData, + committedTrackerData, + $infoBoxContainer, + $thoughtsContainer, + setPendingDiceRoll, + getPendingDiceRoll +} from '../../core/state.js'; +import { saveSettings, saveChatData } from '../../core/persistence.js'; +import { renderUserStats } from '../rendering/userStats.js'; +import { updateChatThoughts } from '../rendering/thoughts.js'; +import { + rollDice as rollDiceCore, + clearDiceRoll as clearDiceRollCore, + updateDiceDisplay as updateDiceDisplayCore, + addDiceQuickReply as addDiceQuickReplyCore +} from '../features/dice.js'; + +/** + * Modern DiceModal ES6 Class + * Manages dice roller modal with proper state management and CSS classes + */ +export class DiceModal { + constructor() { + this.modal = document.getElementById('rpg-dice-popup'); + this.animation = document.getElementById('rpg-dice-animation'); + this.result = document.getElementById('rpg-dice-result'); + this.resultValue = document.getElementById('rpg-dice-result-value'); + this.resultDetails = document.getElementById('rpg-dice-result-details'); + this.rollBtn = document.getElementById('rpg-dice-roll-btn'); + + this.state = 'IDLE'; // IDLE, ROLLING, SHOWING_RESULT + this.isAnimating = false; + } + + /** + * Opens the modal with proper animation + */ + open() { + if (this.isAnimating) return; + + // Apply theme + const theme = extensionSettings.theme; + this.modal.setAttribute('data-theme', theme); + + // Apply custom theme if needed + if (theme === 'custom') { + this._applyCustomTheme(); + } + + // Reset to initial state + this._setState('IDLE'); + + // Open modal with CSS class + this.modal.classList.add('is-open'); + this.modal.classList.remove('is-closing'); + + // Focus management + this.modal.querySelector('#rpg-dice-popup-close')?.focus(); + } + + /** + * Closes the modal with animation + */ + close() { + if (this.isAnimating) return; + + this.isAnimating = true; + this.modal.classList.add('is-closing'); + this.modal.classList.remove('is-open'); + + // Wait for animation to complete + setTimeout(() => { + this.modal.classList.remove('is-closing'); + this.isAnimating = false; + + // Clear pending roll + setPendingDiceRoll(null); + }, 200); + } + + /** + * Starts the rolling animation + */ + startRolling() { + this._setState('ROLLING'); + } + + /** + * Shows the result + * @param {number} total - The total roll value + * @param {Array} rolls - Individual roll values + */ + showResult(total, rolls) { + this._setState('SHOWING_RESULT'); + + // Update result values + this.resultValue.textContent = total; + this.resultValue.classList.add('is-animating'); + + // Remove animation class after it completes + setTimeout(() => { + this.resultValue.classList.remove('is-animating'); + }, 500); + + // Show details if multiple rolls + if (rolls && rolls.length > 1) { + this.resultDetails.textContent = `Rolls: ${rolls.join(', ')}`; + } else { + this.resultDetails.textContent = ''; + } + } + + /** + * Manages modal state changes + * @private + */ + _setState(newState) { + this.state = newState; + + switch (newState) { + case 'IDLE': + this.rollBtn.hidden = false; + this.animation.hidden = true; + this.result.hidden = true; + break; + + case 'ROLLING': + this.rollBtn.hidden = true; + this.animation.hidden = false; + this.result.hidden = true; + this.animation.setAttribute('aria-busy', 'true'); + break; + + case 'SHOWING_RESULT': + this.rollBtn.hidden = true; + this.animation.hidden = true; + this.result.hidden = false; + this.animation.setAttribute('aria-busy', 'false'); + break; + } + } + + /** + * Applies custom theme colors + * @private + */ + _applyCustomTheme() { + const content = this.modal.querySelector('.rpg-dice-popup-content'); + if (content && extensionSettings.customColors) { + content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); + content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); + content.style.setProperty('--rpg-text', extensionSettings.customColors.text); + content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); + } + } +} + +/** + * SettingsModal - Manages the settings popup modal + * Handles opening, closing, theming, and animations + */ +export class SettingsModal { + constructor() { + this.modal = document.getElementById('rpg-settings-popup'); + this.content = this.modal?.querySelector('.rpg-settings-popup-content'); + this.isAnimating = false; + } + + /** + * Opens the modal with proper animation + */ + open() { + if (this.isAnimating || !this.modal) return; + + // Apply theme + const theme = extensionSettings.theme || 'default'; + this.modal.setAttribute('data-theme', theme); + + // Apply custom theme if needed + if (theme === 'custom') { + this._applyCustomTheme(); + } + + // Open modal with CSS class + this.modal.classList.add('is-open'); + this.modal.classList.remove('is-closing'); + + // Focus management + this.modal.querySelector('#rpg-close-settings')?.focus(); + } + + /** + * Closes the modal with animation + */ + close() { + if (this.isAnimating || !this.modal) return; + + this.isAnimating = true; + this.modal.classList.add('is-closing'); + this.modal.classList.remove('is-open'); + + // Wait for animation to complete + setTimeout(() => { + this.modal.classList.remove('is-closing'); + this.isAnimating = false; + }, 200); + } + + /** + * Updates the theme in real-time (used when theme selector changes) + */ + updateTheme() { + if (!this.modal) return; + + const theme = extensionSettings.theme || 'default'; + this.modal.setAttribute('data-theme', theme); + + if (theme === 'custom') { + this._applyCustomTheme(); + } else { + // Clear custom CSS variables to let theme CSS take over + this._clearCustomTheme(); + } + } + + /** + * Applies custom theme colors + * @private + */ + _applyCustomTheme() { + if (!this.content || !extensionSettings.customColors) return; + + this.content.style.setProperty('--rpg-bg', extensionSettings.customColors.bg); + this.content.style.setProperty('--rpg-accent', extensionSettings.customColors.accent); + this.content.style.setProperty('--rpg-text', extensionSettings.customColors.text); + this.content.style.setProperty('--rpg-highlight', extensionSettings.customColors.highlight); + } + + /** + * Clears custom theme colors + * @private + */ + _clearCustomTheme() { + if (!this.content) return; + + this.content.style.setProperty('--rpg-bg', ''); + this.content.style.setProperty('--rpg-accent', ''); + this.content.style.setProperty('--rpg-text', ''); + this.content.style.setProperty('--rpg-highlight', ''); + } +} + +// Global instances +let diceModal = null; +let settingsModal = null; + +/** + * Sets up the dice roller functionality. + * @returns {DiceModal} The initialized DiceModal instance + */ +export function setupDiceRoller() { + // Initialize DiceModal instance + diceModal = new DiceModal(); + + // Click dice display to open popup + $('#rpg-dice-display').on('click', function() { + openDicePopup(); + }); + + // Close popup - handle both close button and backdrop clicks + $('#rpg-dice-popup-close').on('click', function() { + closeDicePopup(); + }); + + // Close on backdrop click (clicking outside content) + $('#rpg-dice-popup').on('click', function(e) { + if (e.target === this) { + closeDicePopup(); + } + }); + + // Roll dice button + $('#rpg-dice-roll-btn').on('click', async function() { + await rollDiceCore(diceModal); + }); + + // Save roll button (closes popup and saves the roll) + $('#rpg-dice-save-btn').on('click', function() { + // Save the pending roll + const roll = getPendingDiceRoll(); + if (roll) { + extensionSettings.lastDiceRoll = roll; + saveSettings(); + updateDiceDisplayCore(); + setPendingDiceRoll(null); + } + closeDicePopup(); + }); + + // Reset on Enter key + $('#rpg-dice-count, #rpg-dice-sides').on('keypress', function(e) { + if (e.which === 13) { + rollDiceCore(diceModal); + } + }); + + // Clear dice roll button + $('#rpg-clear-dice').on('click', function(e) { + e.stopPropagation(); // Prevent opening the dice popup + clearDiceRollCore(); + }); + + return diceModal; +} + +/** + * Sets up the settings popup functionality. + * @returns {SettingsModal} The initialized SettingsModal instance + */ +export function setupSettingsPopup() { + // Initialize SettingsModal instance + settingsModal = new SettingsModal(); + + // Open settings popup + $('#rpg-open-settings').on('click', function() { + openSettingsPopup(); + }); + + // Close settings popup - close button + $('#rpg-close-settings').on('click', function() { + closeSettingsPopup(); + }); + + // Close on backdrop click (clicking outside content) + $('#rpg-settings-popup').on('click', function(e) { + if (e.target === this) { + closeSettingsPopup(); + } + }); + + // Clear cache button + $('#rpg-clear-cache').on('click', function() { + // Clear the data + lastGeneratedData.userStats = null; + lastGeneratedData.infoBox = null; + lastGeneratedData.characterThoughts = null; + + // Clear committed tracker data (used for generation context) + committedTrackerData.userStats = null; + committedTrackerData.infoBox = null; + committedTrackerData.characterThoughts = null; + + // Clear all message swipe data + const chat = getContext().chat; + if (chat && chat.length > 0) { + for (let i = 0; i < chat.length; i++) { + const message = chat[i]; + if (message.extra && message.extra.rpg_companion_swipes) { + delete message.extra.rpg_companion_swipes; + // console.log('[RPG Companion] Cleared swipe data from message at index', i); + } + } + } + + // Clear the UI + if ($infoBoxContainer) { + $infoBoxContainer.empty(); + } + if ($thoughtsContainer) { + $thoughtsContainer.empty(); + } + + // Reset stats to defaults and re-render + extensionSettings.userStats = { + health: 100, + satiety: 100, + energy: 100, + hygiene: 100, + arousal: 0, + mood: '😐', + conditions: 'None', + inventory: 'None' + }; + + // Reset classic stats (attributes) to defaults + extensionSettings.classicStats = { + str: 10, + dex: 10, + con: 10, + int: 10, + wis: 10, + cha: 10 + }; + + // Clear dice roll + extensionSettings.lastDiceRoll = null; + + // Save everything + saveChatData(); + saveSettings(); + + // Re-render user stats and dice display + renderUserStats(); + updateDiceDisplayCore(); + updateChatThoughts(); // Clear the thought bubble in chat + + // console.log('[RPG Companion] Chat cache cleared'); + }); + + return settingsModal; +} + +/** + * Opens the dice rolling popup. + * Backwards compatible wrapper for DiceModal class. + */ +export function openDicePopup() { + if (diceModal) { + diceModal.open(); + } +} + +/** + * Closes the dice rolling popup. + * Backwards compatible wrapper for DiceModal class. + */ +export function closeDicePopup() { + if (diceModal) { + diceModal.close(); + } +} + +/** + * Opens the settings popup. + * Backwards compatible wrapper for SettingsModal class. + */ +export function openSettingsPopup() { + if (settingsModal) { + settingsModal.open(); + } +} + +/** + * Closes the settings popup. + * Backwards compatible wrapper for SettingsModal class. + */ +export function closeSettingsPopup() { + if (settingsModal) { + settingsModal.close(); + } +} + +/** + * @deprecated Legacy function - use diceModal._applyCustomTheme() instead + */ +export function applyCustomThemeToPopup() { + if (diceModal) { + diceModal._applyCustomTheme(); + } +} + +/** + * Clears the last dice roll. + * Backwards compatible wrapper for dice module. + */ +export function clearDiceRoll() { + clearDiceRollCore(); +} + +/** + * Updates the dice display in the sidebar. + * Backwards compatible wrapper for dice module. + */ +export function updateDiceDisplay() { + updateDiceDisplayCore(); +} + +/** + * Adds the Roll Dice quick reply button. + * Backwards compatible wrapper for dice module. + */ +export function addDiceQuickReply() { + addDiceQuickReplyCore(); +} + +/** + * Returns the SettingsModal instance for external use + * @returns {SettingsModal} The global SettingsModal instance + */ +export function getSettingsModal() { + return settingsModal; +} diff --git a/src/systems/ui/theme.js b/src/systems/ui/theme.js new file mode 100644 index 0000000..2f2b061 --- /dev/null +++ b/src/systems/ui/theme.js @@ -0,0 +1,100 @@ +/** + * Theme Management Module + * Handles theme application, custom colors, and animations + */ + +import { extensionSettings, $panelContainer } from '../../core/state.js'; + +/** + * Applies the selected theme to the panel. + */ +export function applyTheme() { + if (!$panelContainer) return; + + const theme = extensionSettings.theme; + + // Remove all theme attributes first + $panelContainer.removeAttr('data-theme'); + + // Clear any inline CSS variable overrides + $panelContainer.css({ + '--rpg-bg': '', + '--rpg-accent': '', + '--rpg-text': '', + '--rpg-highlight': '', + '--rpg-border': '', + '--rpg-shadow': '' + }); + + // Apply the selected theme + if (theme === 'custom') { + applyCustomTheme(); + } else if (theme !== 'default') { + // For non-default themes, set the data-theme attribute + // which will trigger the CSS theme rules + $panelContainer.attr('data-theme', theme); + } + // For 'default', we do nothing - it will use the CSS variables from .rpg-panel class + // which fall back to SillyTavern's theme variables +} + +/** + * Applies custom colors when custom theme is selected. + */ +export function applyCustomTheme() { + if (!$panelContainer) return; + + const colors = extensionSettings.customColors; + + // Apply custom CSS variables as inline styles + $panelContainer.css({ + '--rpg-bg': colors.bg, + '--rpg-accent': colors.accent, + '--rpg-text': colors.text, + '--rpg-highlight': colors.highlight, + '--rpg-border': colors.highlight, + '--rpg-shadow': `${colors.highlight}80` // Add alpha for shadow + }); +} + +/** + * Toggles visibility of custom color pickers. + */ +export function toggleCustomColors() { + const isCustom = extensionSettings.theme === 'custom'; + $('#rpg-custom-colors').toggle(isCustom); +} + +/** + * Toggles animations on/off by adding/removing a class to the panel. + */ +export function toggleAnimations() { + if (extensionSettings.enableAnimations) { + $panelContainer.addClass('rpg-animations-enabled'); + } else { + $panelContainer.removeClass('rpg-animations-enabled'); + } +} + +/** + * Updates the settings popup theme in real-time. + * Backwards compatible wrapper for SettingsModal class. + * @param {Object} settingsModal - The SettingsModal instance (passed as parameter to avoid circular dependency) + */ +export function updateSettingsPopupTheme(settingsModal) { + if (settingsModal) { + settingsModal.updateTheme(); + } +} + +/** + * Applies custom theme colors to the settings popup. + * Backwards compatible wrapper for SettingsModal class. + * @deprecated Use settingsModal.updateTheme() instead + * @param {Object} settingsModal - The SettingsModal instance (passed as parameter to avoid circular dependency) + */ +export function applyCustomThemeToSettingsPopup(settingsModal) { + if (settingsModal) { + settingsModal._applyCustomTheme(); + } +} diff --git a/src/types/inventory.js b/src/types/inventory.js new file mode 100644 index 0000000..3cfebcf --- /dev/null +++ b/src/types/inventory.js @@ -0,0 +1,30 @@ +/** + * Inventory Type Definitions + * JSDoc types for RPG Companion inventory system v2 + */ + +/** + * Version 2 inventory structure with categorized storage + * @typedef {Object} InventoryV2 + * @property {number} version - Schema version (always 2) + * @property {string} onPerson - Items currently carried/worn by the character (plaintext list) + * @property {Object.} stored - Items stored at named locations (location name → plaintext list) + * @property {string} assets - Character's vehicles, property, and major possessions (plaintext list) + */ + +/** + * Version 1 inventory structure (legacy string format) + * Simple plaintext string like "Sword, Shield, 3x Potions" + * @typedef {string} InventoryV1 + */ + +/** + * Result of inventory migration operation + * @typedef {Object} MigrationResult + * @property {InventoryV2} inventory - The migrated inventory data in v2 format + * @property {boolean} migrated - Whether migration was performed (true if v1→v2, false if already v2) + * @property {string} source - Source version ('v1', 'v2', 'null', 'default') + */ + +// Export types for JSDoc consumption (this file has no runtime exports) +export {}; diff --git a/src/utils/avatars.js b/src/utils/avatars.js new file mode 100644 index 0000000..382870c --- /dev/null +++ b/src/utils/avatars.js @@ -0,0 +1,46 @@ +/** + * Avatar Utilities Module + * Handles safe avatar/thumbnail URL generation with error handling + */ + +import { getThumbnailUrl } from '../../../../../../script.js'; + +/** + * Safely retrieves a thumbnail URL from SillyTavern's API with error handling. + * Returns null instead of throwing errors to prevent extension crashes. + * + * @param {string} type - Type of thumbnail ('avatar' or 'persona') + * @param {string} filename - Filename of the avatar/persona + * @returns {string|null} Thumbnail URL or null if unavailable/error + */ +export function getSafeThumbnailUrl(type, filename) { + // Return null if no filename provided + if (!filename || filename === 'none') { + console.log(`[RPG Companion] No valid filename provided for ${type} thumbnail`); + return null; + } + + try { + // Attempt to get thumbnail URL from SillyTavern API + const url = getThumbnailUrl(type, filename); + + // Validate that we got a string back + if (typeof url !== 'string' || url.trim() === '') { + console.warn(`[RPG Companion] getThumbnailUrl returned invalid result for ${type}:`, filename); + return null; + } + + console.log(`[RPG Companion] Successfully generated ${type} thumbnail URL for: ${filename}`); + return url; + } catch (error) { + // Log detailed error information for debugging + console.error(`[RPG Companion] Failed to get ${type} thumbnail for "${filename}":`, error); + console.error('[RPG Companion] Error details:', { + type, + filename, + errorMessage: error.message, + errorStack: error.stack + }); + return null; + } +} diff --git a/src/utils/itemParser.js b/src/utils/itemParser.js new file mode 100644 index 0000000..edf82a1 --- /dev/null +++ b/src/utils/itemParser.js @@ -0,0 +1,97 @@ +/** + * Item Parser Module + * Utilities for parsing item strings into arrays and vice versa + */ + +/** + * Parses a comma-separated item string into an array of trimmed item names. + * Filters out empty strings and handles "None" gracefully. + * Smart handling: collapses newlines inside parentheses, preserves them outside. + * + * @param {string} itemString - Comma-separated items (e.g., "Sword, Shield, 3x Potions") + * @returns {string[]} Array of item names, or empty array if none + * + * @example + * parseItems("Sword, Shield, 3x Potions") // ["Sword", "Shield", "3x Potions"] + * parseItems("Books (magical\ntomes), Sword") // ["Books (magical tomes)", "Sword"] + * parseItems("None") // [] + * parseItems("") // [] + * parseItems(null) // [] + */ +export function parseItems(itemString) { + // Handle null/undefined/non-string + if (!itemString || typeof itemString !== 'string') { + return []; + } + + // Trim and check for "None" (case-insensitive) + const trimmed = itemString.trim(); + if (trimmed === '' || trimmed.toLowerCase() === 'none') { + return []; + } + + // Collapse newlines inside parentheses + let processed = ''; + let parenDepth = 0; + + for (let i = 0; i < trimmed.length; i++) { + const char = trimmed[i]; + + if (char === '(') { + parenDepth++; + processed += char; + } else if (char === ')') { + parenDepth--; + processed += char; + } else if ((char === '\n' || char === '\r') && parenDepth > 0) { + // Inside parentheses: replace newline with space + // Skip if previous char was already a space + if (processed[processed.length - 1] !== ' ') { + processed += ' '; + } + } else { + processed += char; + } + } + + // Clean up multiple consecutive spaces + processed = processed.replace(/\s+/g, ' '); + + // Split by comma, trim each item, filter empties + return processed + .split(',') + .map(item => item.trim()) + .filter(item => item !== '' && item.toLowerCase() !== 'none'); +} + +/** + * Serializes an array of items back into a comma-separated string. + * Returns "None" for empty arrays. + * + * @param {string[]} itemArray - Array of item names + * @returns {string} Comma-separated string, or "None" if empty + * + * @example + * serializeItems(["Sword", "Shield", "3x Potions"]) // "Sword, Shield, 3x Potions" + * serializeItems([]) // "None" + * serializeItems(["Sword"]) // "Sword" + */ +export function serializeItems(itemArray) { + // Handle null/undefined/non-array + if (!itemArray || !Array.isArray(itemArray)) { + return 'None'; + } + + // Filter out empty strings and trim + const cleaned = itemArray + .filter(item => item && typeof item === 'string' && item.trim() !== '') + .map(item => item.trim()); + + // Return "None" if array is empty after cleaning + if (cleaned.length === 0) { + return 'None'; + } + + // Join with comma and space + return cleaned.join(', '); +} diff --git a/src/utils/migration.js b/src/utils/migration.js new file mode 100644 index 0000000..b5b0f23 --- /dev/null +++ b/src/utils/migration.js @@ -0,0 +1,84 @@ +/** + * Inventory Migration Module + * Handles conversion from v1 (string) to v2 (structured) inventory format + */ + +// Type imports +/** @typedef {import('../types/inventory.js').InventoryV1} InventoryV1 */ +/** @typedef {import('../types/inventory.js').InventoryV2} InventoryV2 */ +/** @typedef {import('../types/inventory.js').MigrationResult} MigrationResult */ + +/** + * Default v2 inventory structure for new/empty inventories + * @type {InventoryV2} + */ +const DEFAULT_INVENTORY_V2 = { + version: 2, + onPerson: "None", + stored: {}, + assets: "None" +}; + +/** + * Migrates inventory data from v1 (string) to v2 (structured) format. + * Handles all edge cases: null, undefined, "None", already-migrated data. + * + * @param {InventoryV1 | InventoryV2 | null | undefined} inventory - Inventory data to migrate + * @returns {MigrationResult} Migration result with v2 inventory and metadata + */ +export function migrateInventory(inventory) { + // Case 1: Already v2 format (has version property and is an object) + if (inventory && typeof inventory === 'object' && inventory.version === 2) { + // console.log('[RPG Companion Migration] Inventory already v2, no migration needed'); + return { + inventory: inventory, + migrated: false, + source: 'v2' + }; + } + + // Case 2: null or undefined → use defaults + if (inventory === null || inventory === undefined) { + // console.log('[RPG Companion Migration] Inventory is null/undefined, using defaults'); + return { + inventory: { ...DEFAULT_INVENTORY_V2 }, + migrated: true, + source: 'null' + }; + } + + // Case 3: v1 string format → migrate to v2 + if (typeof inventory === 'string') { + // Check if it's an empty/default string + const trimmed = inventory.trim(); + if (trimmed === '' || trimmed.toLowerCase() === 'none') { + // console.log('[RPG Companion Migration] Inventory is empty/None, using defaults'); + return { + inventory: { ...DEFAULT_INVENTORY_V2 }, + migrated: true, + source: 'v1' + }; + } + + // Non-empty v1 string → migrate to v2.onPerson + // console.log('[RPG Companion Migration] Migrating v1 string to v2.onPerson:', inventory); + return { + inventory: { + version: 2, + onPerson: inventory, + stored: {}, + assets: "None" + }, + migrated: true, + source: 'v1' + }; + } + + // Case 4: Unknown format (malformed object, number, etc.) → use defaults + console.warn('[RPG Companion Migration] Unknown inventory format, using defaults:', inventory); + return { + inventory: { ...DEFAULT_INVENTORY_V2 }, + migrated: true, + source: 'default' + }; +} diff --git a/style.css b/style.css index 2cbe466..ed880ba 100644 --- a/style.css +++ b/style.css @@ -978,7 +978,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { border-radius: 0.75em; padding: 0.375em; margin-bottom: 0; - flex: 1; + flex: 0 0 auto; min-height: 0; display: flex; flex-direction: column; @@ -2395,6 +2395,9 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Result Section */ .rpg-dice-result { + display: flex; + flex-direction: column; + align-items: center; text-align: center; padding: var(--modal-padding); background: rgba(0, 0, 0, 0.3); @@ -2847,7 +2850,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { position: fixed; z-index: 1000; /* Lower z-index to stay below dropdown menus */ pointer-events: auto; - max-width: 21.875rem; + max-width: 15.3rem; /* 30% smaller than 21.875rem (350px → 245px) */ transform: translateY(-50%); animation: thoughtPanelFadeIn 0.4s ease-out; } @@ -3129,7 +3132,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-thought-content { - font-size: clamp(9px, 1.2vw, 11px); + font-size: clamp(12px, 3.5vw, 16px); } } @@ -3363,7 +3366,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Mobile icon styling - use chevrons for drawer UX */ .rpg-collapse-toggle i { transform: none !important; - font-size: 20px; + font-size: clamp(20px, 5.1vw, 24px); } /* ======================================== @@ -3400,7 +3403,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { align-items: center; justify-content: center; gap: 6px; - font-size: 14px; + font-size: clamp(14px, 3.6vw, 18px); font-weight: 500; color: var(--SmartThemeBodyColor); background: transparent; @@ -3418,13 +3421,13 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-mobile-tab.active { - color: var(--SmartThemeQuoteColor); - border-bottom-color: var(--SmartThemeQuoteColor); - background: rgba(255, 255, 255, 0.05); + color: var(--rpg-highlight); + border-bottom-color: var(--rpg-highlight); + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); } .rpg-mobile-tab i { - font-size: 16px; + font-size: clamp(16px, 4.1vw, 20px); } /* Tab content sections */ @@ -3446,18 +3449,18 @@ body:has(.rpg-panel.rpg-position-left) #sheld { to { opacity: 1; transform: translateY(0); } } - /* Combined Info & Characters wrapper */ - .rpg-mobile-combined-content { - display: flex; + /* Info tab contains Info Box and Characters with 50/50 split */ + .rpg-mobile-tab-content[data-tab-content="info"].active { flex-direction: column; gap: 0; height: 100%; min-height: 0; } - /* Info Box takes fixed 50% of vertical space */ - .rpg-mobile-combined-content > #rpg-info-box { - flex: 0 0 50%; + /* Info Box takes 50% of vertical space */ + .rpg-mobile-tab-content[data-tab-content="info"] > #rpg-info-box, + .rpg-mobile-tab-content[data-tab-content="info"] > .rpg-info-section { + flex: 1 1 50% !important; min-height: 0; overflow-y: auto; padding-bottom: 16px; @@ -3466,16 +3469,17 @@ body:has(.rpg-panel.rpg-position-left) #sheld { gap: 0.5em; } - /* Characters section takes remaining 50% */ - .rpg-mobile-combined-content > #rpg-thoughts { - flex: 1; + /* Characters section takes 50% of vertical space */ + .rpg-mobile-tab-content[data-tab-content="info"] > #rpg-thoughts, + .rpg-mobile-tab-content[data-tab-content="info"] > .rpg-thoughts-section { + flex: 1 1 50% !important; min-height: 0; overflow-y: auto; padding-bottom: 16px; } /* Add divider between Info and Characters */ - .rpg-mobile-combined-content > .rpg-section:not(:last-child) { + .rpg-mobile-tab-content[data-tab-content="info"] > .rpg-section:not(:last-child) { border-bottom: 1px solid var(--SmartThemeBorderColor); margin-bottom: 16px; } @@ -3492,6 +3496,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Rows scale proportionally to fill Info Box */ .rpg-dashboard-row-1 { flex: 1.2 !important; /* Slightly more space for 4 widgets */ + height: auto !important; /* Remove desktop height constraint */ display: flex !important; gap: 0.25em; } @@ -3511,6 +3516,39 @@ body:has(.rpg-panel.rpg-position-left) #sheld { justify-content: center; } + /* Info screen text readability - convert vw to readable pixel sizes */ + .rpg-calendar-top { + font-size: clamp(8px, 2.1vw, 10px) !important; + } + + .rpg-calendar-day { + font-size: clamp(11px, 2.9vw, 14px) !important; + } + + .rpg-calendar-year { + font-size: clamp(7px, 1.8vw, 10px) !important; + } + + .rpg-weather-forecast { + font-size: clamp(9px, 2.2vw, 11px) !important; + } + + .rpg-temp-value { + font-size: clamp(10px, 2.5vw, 13px) !important; + } + + .rpg-time-value { + font-size: clamp(10px, 2.5vw, 13px) !important; + } + + .rpg-location-text { + font-size: clamp(11px, 2.8vw, 14px) !important; + } + + .rpg-map-marker { + font-size: clamp(16px, 4.1vw, 20px) !important; + } + /* ======================================== MOBILE STATS TAB LAYOUT IMPROVEMENTS ======================================== */ @@ -3519,7 +3557,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-stats-section { display: grid !important; grid-template-columns: 40% 60%; /* Left for inventory/mood, right for attributes */ - grid-template-rows: auto auto auto auto; /* Portrait, stat bars, inventory, mood */ + grid-template-rows: auto auto auto auto auto; /* Portrait, bars, inventory, mood, attributes */ gap: 12px; padding: 16px 12px; } @@ -3557,41 +3595,80 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: contents !important; } + /* Center the avatar wrapper on mobile */ + .rpg-stats-left > div:first-child { + grid-column: 1 / 3; + justify-content: center !important; + } + .rpg-stats-grid { grid-column: 1 / 3; grid-row: 2; + min-height: 180px; /* Stretch vertically to fill more space */ + gap: clamp(8px, 2vw, 12px); /* Increase gap between stat rows */ } - /* Inventory - bottom left */ + /* Increase stat bar text sizes for mobile readability */ + .rpg-stat-label { + font-size: clamp(11px, 2.8vw, 14px) !important; + } + + .rpg-stat-value { + font-size: clamp(12px, 3.1vw, 16px) !important; + } + + /* Make the avatar+inventory flex container grow to full width */ + .rpg-stats-left > div[style*="display: flex"] { + width: 100% !important; /* Force full width in grid cell */ + flex: 1 !important; /* Allow it to grow */ + grid-column: 1 / 3 !important; /* Take full grid width */ + grid-row: 1 !important; /* Position in row 1 */ + } + + /* Inventory - expand to fill horizontal space in flex container */ .rpg-inventory-box { - grid-column: 1; - grid-row: 3; - margin: 0; - min-height: auto; - max-height: none; - max-width: 100%; /* Override 12.5rem restriction */ + flex: 1 !important; /* Grow to fill all available space */ + min-width: 0 !important; /* Allow flexbox to work properly */ + max-width: none !important; /* Remove desktop width restrictions */ + width: auto !important; /* Don't force a specific width */ + min-height: 60px; /* Give it decent height */ + max-height: none; /* Remove height restriction */ } - /* Mood - below inventory on left */ + /* Increase inventory text size for readability on mobile */ + .rpg-inventory-items { + font-size: clamp(11px, 2.8vw, 14px) !important; + line-height: 1.4; + width: 100% !important; /* Fill the inventory box */ + white-space: normal !important; /* Allow text wrapping */ + } + + /* Mood - row 4, aligned with attributes top */ .rpg-mood { grid-column: 1; - grid-row: 4; + grid-row: 4 / 6; /* Span 2 rows to match attributes height */ display: flex; flex-direction: column; gap: 6px; min-width: 0; } - /* Attributes - right side, spanning rows 3-4 */ + /* Make mood text readable on mobile */ + .rpg-mood-conditions { + font-size: clamp(11px, 2.8vw, 14px); + line-height: 1.3; + } + + /* Attributes - right side, rows 4-6 aligned with mood */ .rpg-stats-right { grid-column: 2; - grid-row: 3 / 5; + grid-row: 4 / 6; display: contents !important; } .rpg-classic-stats { grid-column: 2; - grid-row: 3 / 5; + grid-row: 4 / 6; /* Start at row 4, aligned with mood top */ } /* Attributes as ultra-compact 2x3 grid for mobile */ @@ -3627,7 +3704,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-classic-stat-label { grid-column: 1; grid-row: 1; - font-size: 9px; + font-size: clamp(9px, 2.3vw, 12px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; @@ -3639,7 +3716,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-classic-stat-value { grid-column: 2; grid-row: 1; - font-size: 14px; + font-size: clamp(14px, 3.6vw, 18px); font-weight: 700; color: var(--SmartThemeQuoteColor); text-align: center; @@ -3666,7 +3743,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { min-height: 24px !important; width: 24px; height: 24px; - font-size: 14px; + font-size: clamp(14px, 3.6vw, 18px); font-weight: 700; border-radius: 4px; display: flex !important; @@ -3697,6 +3774,10 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Position thought icon above avatar on mobile to prevent off-screen clipping */ /* JavaScript will calculate position, but add transform to move it above and right */ #rpg-thought-icon { + /* 20% larger than desktop for better visibility on mobile */ + width: 2.7rem !important; + height: 2.7rem !important; + font-size: 2.2vw !important; /* Use transform to shift icon above and to the right of avatar */ transform: translate(50px, -45px) !important; /* Smooth animation for position changes during scroll */ @@ -3712,11 +3793,103 @@ body:has(.rpg-panel.rpg-position-left) #sheld { .rpg-relationship-badge { width: 18px !important; height: 18px !important; - font-size: 10px !important; + font-size: clamp(10px, 2.6vw, 13px) !important; padding: 0 !important; min-height: unset !important; line-height: 18px !important; } + + /* ======================================== + MOBILE CHARACTER INFO TEXT SIZES + ======================================== */ + + /* Larger character emoji for mobile readability */ + .rpg-character-emoji { + font-size: clamp(16px, 4.1vw, 20px) !important; + } + + /* Readable character names on mobile */ + .rpg-character-name { + font-size: clamp(12px, 3.1vw, 16px) !important; + } + + /* Readable character traits on mobile */ + .rpg-character-traits { + font-size: clamp(11px, 2.8vw, 14px) !important; + } + + /* ======================================== + MOBILE DICE DISPLAY + ======================================== */ + + /* Make dice display larger and more visible on mobile */ + .rpg-dice-display { + font-size: clamp(12px, 3.1vw, 16px) !important; + padding: clamp(8px, 2vw, 12px) !important; + min-height: 44px; /* Touch-friendly height */ + gap: clamp(6px, 1.5vw, 10px) !important; + } + + /* Larger dice icon for mobile */ + .rpg-dice-display i { + font-size: clamp(18px, 4.6vw, 24px) !important; + } + + /* Larger clear button for mobile touch */ + .rpg-clear-dice-btn { + font-size: clamp(18px, 4.6vw, 24px) !important; + width: clamp(28px, 7.2vw, 36px) !important; + height: clamp(28px, 7.2vw, 36px) !important; + } + + /* ======================================== + MOBILE UI BUTTONS + ======================================== */ + + /* Readable settings button on mobile */ + .rpg-btn-settings { + font-size: clamp(14px, 3.6vw, 18px) !important; + } + + /* Larger mobile toggle dice icon (button size is fine) */ + .rpg-mobile-toggle { + font-size: clamp(20px, 5.1vw, 26px) !important; + } + + /* ======================================== + MOBILE SETTINGS POPUP + ======================================== */ + + /* Readable settings popup title */ + .rpg-settings-popup-header h3 { + font-size: clamp(16px, 4.1vw, 20px) !important; + } + + /* Readable settings group headers */ + .rpg-settings-group h4 { + font-size: clamp(14px, 3.6vw, 18px) !important; + } + + /* Readable setting labels */ + .rpg-setting-row label { + font-size: clamp(12px, 3.1vw, 16px) !important; + } + + /* Readable dropdowns and inputs */ + .rpg-select, + .rpg-input { + font-size: clamp(13px, 3.3vw, 17px) !important; + } + + /* Readable helper text */ + .rpg-setting-row small { + font-size: clamp(10px, 2.6vw, 13px) !important; + } + + /* Readable clear cache button */ + .rpg-btn-clear-cache { + font-size: clamp(13px, 3.3vw, 17px) !important; + } } /* Extra small screens - adjust FAB position */ @@ -3745,3 +3918,607 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: 2.2vw; } } + +/* ======================================== + Inventory System v2 Styles + ======================================== */ + +/* Inventory Container */ +.rpg-inventory-container { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.5rem; + font-size: 0.9rem; +} + +/* Sub-tabs Navigation */ +.rpg-inventory-subtabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--SmartThemeBorderColor); + padding-bottom: 0.5rem; +} + +.rpg-inventory-subtab { + flex: 1; + padding: 0.5rem 1rem; + background: transparent; + border: 2px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; +} + +.rpg-inventory-subtab:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-inventory-subtab.active { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); + font-weight: 600; +} + +/* Inventory Sections */ +.rpg-inventory-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rpg-inventory-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--SmartThemeBorderColor); +} + +.rpg-inventory-header h4 { + margin: 0; + font-size: 1.1rem; + color: var(--SmartThemeBodyColor); +} + +.rpg-inventory-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rpg-inventory-text { + padding: 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; +} + +.rpg-inventory-empty { + padding: 2rem; + text-align: center; + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; +} + +.rpg-inventory-hint { + padding: 0.5rem; + background: transparent; + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; + font-size: 0.85rem; + color: var(--SmartThemeFastUISliderColColor); + display: flex; + gap: 0.5rem; + align-items: flex-start; +} + +.rpg-inventory-hint i { + margin-top: 0.1rem; +} + +/* Storage Locations */ +.rpg-storage-location { + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + overflow: hidden; +} + +.rpg-storage-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--SmartThemeQuoteColor); + cursor: pointer; +} + +.rpg-storage-toggle { + background: none; + border: none; + color: var(--SmartThemeBodyColor); + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; +} + +.rpg-storage-toggle:hover { + color: var(--ac-style-color-matchedText); +} + +.rpg-storage-name { + flex: 1; + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #000000; +} + +.rpg-storage-actions { + display: flex; + gap: 0.5rem; +} + +.rpg-storage-content { + padding: 0.75rem; + background: var(--SmartThemeBlurTintColor); +} + +.rpg-storage-location.collapsed .rpg-storage-content { + display: none; +} + +/* Buttons */ +.rpg-inventory-edit-btn, +.rpg-inventory-add-btn, +.rpg-inventory-remove-btn { + padding: 0.4rem 0.75rem; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + background: var(--SmartThemeBlurTintColor); + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.rpg-inventory-edit-btn:hover { + background: var(--ac-style-color-matchedText); + border-color: var(--ac-style-color-matchedText); + color: white; +} + +.rpg-inventory-add-btn { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-inventory-add-btn:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); +} + +.rpg-inventory-remove-btn { + background: transparent; + color: var(--SmartThemeFastUISliderColColor); +} + +.rpg-inventory-remove-btn:hover { + background: #dc3545; + border-color: #dc3545; + color: white; +} + +/* Inline Editing Styles */ +.rpg-inventory-text.rpg-editable { + cursor: text; + transition: all 0.2s ease; + min-height: 2rem; +} + +.rpg-inventory-text.rpg-editable:hover { + background: var(--SmartThemeQuoteColor); + border-color: var(--ac-style-color-matchedText); +} + +.rpg-inventory-text.rpg-editable:focus { + outline: none; + border-color: var(--ac-style-color-matchedText); + background: var(--SmartThemeEmColor); + box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2); +} + +.rpg-inventory-text.rpg-editable:empty::before { + content: 'Click to edit...'; + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; +} + +/* Inline Forms */ +.rpg-inline-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--SmartThemeQuoteColor); + border: 1px solid var(--ac-style-color-matchedText); + border-radius: 0.25rem; + margin-bottom: 0.75rem; +} + +.rpg-inline-input { + padding: 0.5rem 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.9rem; + font-family: inherit; +} + +.rpg-inline-input:focus { + outline: none; + border-color: var(--ac-style-color-matchedText); + box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2); +} + +.rpg-inline-buttons { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.rpg-inline-btn { + padding: 0.4rem 0.75rem; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + background: var(--SmartThemeBlurTintColor); + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.rpg-inline-btn:hover { + opacity: 0.85; +} + +.rpg-inline-cancel { + background: var(--SmartThemeBlurTintColor); + color: var(--SmartThemeFastUISliderColColor); +} + +.rpg-inline-cancel:hover { + background: #6c757d; + border-color: #6c757d; + color: white; +} + +.rpg-inline-save, +.rpg-inline-confirm { + background: var(--ac-style-color-matchedText); + border-color: var(--ac-style-color-matchedText); + color: white; +} + +.rpg-inline-save:hover, +.rpg-inline-confirm:hover { + opacity: 0.85; +} + +/* Inline Confirmation */ +.rpg-inline-confirmation { + padding: 0.75rem; + background: var(--SmartThemeQuoteColor); + border: 1px solid #dc3545; + border-radius: 0.25rem; + margin-top: 0.5rem; +} + +.rpg-inline-confirmation p { + margin: 0 0 0.75rem 0; + color: var(--SmartThemeBodyColor); + font-size: 0.9rem; +} + +/* ============================================ + ITEM LIST AND GRID VIEW STYLES + ============================================ */ + +/* Item list container base styles */ +.rpg-item-list { + min-height: 2rem; + padding: 0.5rem; +} + +/* LIST VIEW - Full-width rows */ +.rpg-item-list-view { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rpg-item-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: transparent; + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.rpg-item-row:hover { + border-color: var(--rpg-highlight); + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); +} + +.rpg-item-row .rpg-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpg-item-row .rpg-item-remove { + flex-shrink: 0; + padding: 0.3rem 0.6rem; + background: transparent; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeFastUISliderColColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; +} + +.rpg-item-row .rpg-item-remove:hover { + background: #dc3545; + border-color: #dc3545; + color: white; +} + +/* GRID VIEW - Responsive card grid */ +.rpg-item-grid-view { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.rpg-item-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem 0.75rem; + background: transparent; + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.9rem; + transition: all 0.2s ease; + min-height: 80px; +} + +.rpg-item-card:hover { + border-color: var(--rpg-highlight); + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); +} + +.rpg-item-card .rpg-item-name { + text-align: center; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + max-width: 100%; +} + +.rpg-item-card .rpg-item-remove { + position: absolute; + top: 0.25rem; + right: 0.25rem; + padding: 0; + width: 1.5rem; + height: 1.5rem; + background: transparent; + border: none; + border-radius: 0.25rem; + color: var(--SmartThemeFastUISliderColColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: flex; + align-items: center; + justify-content: center; +} + +.rpg-item-card .rpg-item-remove:hover { + background: #dc3545; + color: white; +} + +/* Empty state message */ +.rpg-inventory-empty { + padding: 1rem; + text-align: center; + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; + font-size: 0.9rem; +} + +/* View Toggle Buttons */ +.rpg-inventory-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.rpg-view-toggle { + display: flex; + gap: 0.25rem; +} + +.rpg-view-btn { + padding: 0.35rem 0.6rem; + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeFastUISliderColColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; +} + +.rpg-view-btn:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-view-btn.active { + background: var(--SmartThemeBlurTintColor); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +/* ============================================ + DESKTOP TABS SYSTEM + ============================================ */ + +/* Desktop tabs container */ +.rpg-tabs-container { + display: flex; + flex-direction: column; + gap: 0; + height: 100%; + width: 100%; +} + +/* Desktop tab navigation */ +.rpg-tabs-nav { + display: flex; + gap: 0; + background: var(--SmartThemeBlurTintColor); + border-bottom: 2px solid var(--SmartThemeBorderColor); + margin-bottom: 1rem; +} + +/* Desktop tab button */ +.rpg-tab-btn { + flex: 1; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 500; + color: var(--SmartThemeBodyColor); + background: transparent; + border: none; + border-bottom: 3px solid transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.rpg-tab-btn:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + color: var(--rpg-highlight); +} + +.rpg-tab-btn.active { + background: transparent; + border-bottom-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-tab-btn i { + font-size: 1.1rem; +} + +/* Desktop tab content */ +.rpg-tab-content { + display: none; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + overflow-x: hidden; + padding: 0 0.5rem; + flex: 1; +} + +.rpg-tab-content.active { + display: flex; +} + +/* Hide dividers when tabs are active (tabs separate content) */ +.rpg-tabs-container .rpg-divider { + display: none; +} + +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .rpg-inventory-subtabs { + flex-direction: column; + gap: 0.35rem; + } + + .rpg-inventory-subtab { + font-size: 1rem; + padding: 0.75rem; + } + + .rpg-inventory-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .rpg-inventory-header h4 { + font-size: 1rem; + } + + .rpg-inventory-edit-btn, + .rpg-inventory-add-btn, + .rpg-inventory-remove-btn { + padding: 0.6rem 1rem; + font-size: 0.95rem; + min-height: 2.5rem; + } + + .rpg-storage-header { + padding: 0.75rem; + } + + .rpg-storage-toggle { + min-width: 2rem; + min-height: 2rem; + } +} diff --git a/template.html b/template.html index 6d30c04..a720db6 100644 --- a/template.html +++ b/template.html @@ -44,6 +44,14 @@
+ + +
+ + +
+ +
@@ -159,6 +167,11 @@ Show Present Characters + +