diff --git a/index.js b/index.js index d8546ab..1ef0bf5 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ import { $infoBoxContainer, $thoughtsContainer, $inventoryContainer, + $questsContainer, setExtensionSettings, updateExtensionSettings, setLastGeneratedData, @@ -33,7 +34,8 @@ import { setUserStatsContainer, setInfoBoxContainer, setThoughtsContainer, - setInventoryContainer + setInventoryContainer, + setQuestsContainer } from './src/core/state.js'; import { loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData } from './src/core/persistence.js'; import { registerAllEvents } from './src/core/events.js'; @@ -61,6 +63,7 @@ import { createThoughtPanel } from './src/systems/rendering/thoughts.js'; import { renderInventory } from './src/systems/rendering/inventory.js'; +import { renderQuests } from './src/systems/rendering/quests.js'; // Interaction modules import { initInventoryEventListeners } from './src/systems/interaction/inventoryActions.js'; @@ -98,22 +101,19 @@ import { setupMobileTabs, removeMobileTabs, setupMobileKeyboardHandling, - setupContentEditableScrolling, - setupRefreshButtonDrag, - setupDebugButtonDrag + setupContentEditableScrolling } from './src/systems/ui/mobile.js'; import { setupDesktopTabs, removeDesktopTabs } from './src/systems/ui/desktop.js'; -import { - updateDebugUIVisibility -} from './src/systems/ui/debug.js'; // Feature modules import { setupPlotButtons, sendPlotProgression } from './src/systems/features/plotProgression.js'; import { setupClassicStatsButtons } from './src/systems/features/classicStats.js'; import { ensureHtmlCleaningRegex, detectConflictingRegexScripts } from './src/systems/features/htmlCleaning.js'; +import { setupMemoryRecollectionButton, updateMemoryRecollectionButton } from './src/systems/features/memoryRecollection.js'; +import { initLorebookLimiter } from './src/systems/features/lorebookLimiter.js'; // Integration modules import { @@ -201,6 +201,9 @@ function addExtensionSettings() { // Re-create thought bubbles when re-enabled updateChatThoughts(); // This will re-create the thought bubble if data exists } + + // Update Memory Recollection button visibility + updateMemoryRecollectionButton(); }); } @@ -222,28 +225,13 @@ async function initUI() { `; $('body').append(mobileToggleHtml); - // Add mobile refresh button (same pattern as toggle button) - const mobileRefreshHtml = ` - - `; - $('body').append(mobileRefreshHtml); - - // Add debug toggle FAB button (same pattern as other mobile FABs) - const debugToggleHtml = ` - - `; - $('body').append(debugToggleHtml); - // Cache UI elements using state setters setPanelContainer($('#rpg-companion-panel')); setUserStatsContainer($('#rpg-user-stats')); setInfoBoxContainer($('#rpg-info-box')); setThoughtsContainer($('#rpg-thoughts')); setInventoryContainer($('#rpg-inventory')); + setQuestsContainer($('#rpg-quests')); // Set up event listeners (enable/disable is handled in Extensions tab) $('#rpg-toggle-auto-update').on('change', function() { @@ -265,6 +253,12 @@ async function initUI() { saveSettings(); }); + $('#rpg-memory-messages').on('change', function() { + const value = $(this).val(); + extensionSettings.memoryMessagesToProcess = parseInt(String(value)); + saveSettings(); + }); + $('#rpg-generation-mode').on('change', function() { extensionSettings.generationMode = String($(this).val()); saveSettings(); @@ -292,10 +286,6 @@ async function initUI() { extensionSettings.showCharacterThoughts = $(this).prop('checked'); saveSettings(); updateSectionVisibility(); - // Refresh the content when toggling on/off - if (extensionSettings.showCharacterThoughts) { - renderThoughts(); - } }); $('#rpg-toggle-inventory').on('change', function() { @@ -324,77 +314,18 @@ async function initUI() { togglePlotButtons(); }); - $('#rpg-toggle-debug-mode').on('change', function() { - extensionSettings.debugMode = $(this).prop('checked'); - saveSettings(); - updateDebugUIVisibility(); - }); - $('#rpg-toggle-animations').on('change', function() { extensionSettings.enableAnimations = $(this).prop('checked'); saveSettings(); toggleAnimations(); }); - // Bind to both desktop and mobile refresh buttons - $('#rpg-manual-update, #rpg-manual-update-mobile').on('click', async function() { - // Get mobile button reference - const $mobileBtn = $('#rpg-manual-update-mobile'); - - // Skip if we just finished dragging the mobile button - if ($mobileBtn.data('just-dragged')) { - console.log('[RPG Companion] Click blocked - just finished dragging refresh button'); - return; - } - + $('#rpg-manual-update').on('click', async function() { if (!extensionSettings.enabled) { // console.log('[RPG Companion] Extension is disabled. Please enable it in the Extensions tab.'); return; } - - // Remove focus to prevent sticky black state on mobile - $(this).blur(); - - // Add spinning animation to mobile button - $mobileBtn.addClass('spinning'); - - try { - await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); - } finally { - // Remove spinning animation when done - $mobileBtn.removeClass('spinning'); - } - }); - - // Reset FAB positions button - $('#rpg-reset-fab-positions').on('click', function() { - console.log('[RPG Companion] Resetting FAB positions to defaults'); - - // Reset to defaults (top-left stacked) - extensionSettings.mobileFabPosition = { - top: 'calc(var(--topBarBlockSize) + 20px)', - left: '12px' - }; - extensionSettings.mobileRefreshPosition = { - top: 'calc(var(--topBarBlockSize) + 80px)', - left: '12px' - }; - extensionSettings.debugFabPosition = { - top: 'calc(var(--topBarBlockSize) + 140px)', - left: '12px' - }; - - // Save settings - saveSettings(); - - // Apply positions immediately to visible buttons - $('#rpg-mobile-toggle').css(extensionSettings.mobileFabPosition); - $('#rpg-manual-update-mobile').css(extensionSettings.mobileRefreshPosition); - $('#rpg-debug-toggle').css(extensionSettings.debugFabPosition); - - // Show success feedback - toastr.success('Button positions reset to defaults', 'RPG Companion'); - console.log('[RPG Companion] FAB positions reset successfully'); + await updateRPGData(renderUserStats, renderInfoBox, renderThoughts, renderInventory); }); $('#rpg-stat-bar-color-low').on('change', function() { @@ -464,6 +395,7 @@ async function initUI() { $('#rpg-toggle-auto-update').prop('checked', extensionSettings.autoUpdate); $('#rpg-position-select').val(extensionSettings.panelPosition); $('#rpg-update-depth').val(extensionSettings.updateDepth); + $('#rpg-memory-messages').val(extensionSettings.memoryMessagesToProcess || 16); $('#rpg-use-separate-preset').prop('checked', extensionSettings.useSeparatePreset); $('#rpg-toggle-user-stats').prop('checked', extensionSettings.showUserStats); $('#rpg-toggle-info-box').prop('checked', extensionSettings.showInfoBox); @@ -472,7 +404,6 @@ async function initUI() { $('#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); - $('#rpg-toggle-debug-mode').prop('checked', extensionSettings.debugMode); $('#rpg-toggle-animations').prop('checked', extensionSettings.enableAnimations); $('#rpg-stat-bar-color-low').val(extensionSettings.statBarColorLow); $('#rpg-stat-bar-color-high').val(extensionSettings.statBarColorHigh); @@ -574,6 +505,7 @@ async function initUI() { renderInfoBox(); renderThoughts(); renderInventory(); + renderQuests(); } // Setup remaining UI components @@ -585,12 +517,13 @@ async function initUI() { setupPlotButtons(sendPlotProgression); setupMobileKeyboardHandling(); setupContentEditableScrolling(); - setupRefreshButtonDrag(); - setupDebugButtonDrag(); initInventoryEventListeners(); - // Initialize debug UI if debug mode is enabled - updateDebugUIVisibility(); + // Setup Memory Recollection button in World Info + setupMemoryRecollectionButton(); + + // Initialize Lorebook Limiter + initLorebookLimiter(); } diff --git a/src/core/config.js b/src/core/config.js index 8e5b1ba..fc633b6 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -46,17 +46,9 @@ export const defaultSettings = { statBarColorHigh: '#33cc66', // Color for high stat values (green) enableAnimations: true, // Enable smooth animations for stats and content updates mobileFabPosition: { - top: 'calc(var(--topBarBlockSize) + 20px)', - left: '12px' - }, // Saved position for mobile FAB button (top-left, stacked vertically) - mobileRefreshPosition: { - top: 'calc(var(--topBarBlockSize) + 80px)', - left: '12px' - }, // Saved position for mobile refresh button (below toggle button) - debugFabPosition: { - top: 'calc(var(--topBarBlockSize) + 140px)', - left: '12px' - }, // Saved position for debug FAB button (below refresh button) + top: 'calc(var(--topBarBlockSize) + 60px)', + right: '12px' + }, // Saved position for mobile FAB button userStats: { health: 100, satiety: 100, @@ -83,5 +75,6 @@ export const defaultSettings = { }, lastDiceRoll: null, // Store last dice roll result collapsedInventoryLocations: [], // Array of collapsed storage location names - debugMode: false // Enable debug logging visible in UI (for mobile debugging) + debugMode: false, // Enable debug logging visible in UI (for mobile debugging) + memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection }; diff --git a/src/core/persistence.js b/src/core/persistence.js index df4dd13..77d308b 100644 --- a/src/core/persistence.js +++ b/src/core/persistence.js @@ -143,6 +143,7 @@ export function saveChatData() { chat_metadata.rpg_companion = { userStats: extensionSettings.userStats, classicStats: extensionSettings.classicStats, + quests: extensionSettings.quests, lastGeneratedData: lastGeneratedData, committedTrackerData: committedTrackerData, timestamp: Date.now() @@ -237,6 +238,17 @@ export function loadChatData() { extensionSettings.classicStats = { ...savedData.classicStats }; } + // Restore quests + if (savedData.quests) { + extensionSettings.quests = { ...savedData.quests }; + } else { + // Initialize with defaults if not present + extensionSettings.quests = { + main: "None", + optional: [] + }; + } + // Restore last generated data if (savedData.lastGeneratedData) { setLastGeneratedData({ ...savedData.lastGeneratedData }); diff --git a/src/core/state.js b/src/core/state.js index ac4801a..8b18610 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -34,17 +34,9 @@ export let extensionSettings = { statBarColorHigh: '#33cc66', // Color for high stat values (green) enableAnimations: true, // Enable smooth animations for stats and content updates mobileFabPosition: { - top: 'calc(var(--topBarBlockSize) + 20px)', - left: '12px' - }, // Saved position for mobile FAB button (top-left, stacked vertically) - mobileRefreshPosition: { - top: 'calc(var(--topBarBlockSize) + 80px)', - left: '12px' - }, // Saved position for mobile refresh button (below toggle button) - debugFabPosition: { - top: 'calc(var(--topBarBlockSize) + 140px)', - left: '12px' - }, // Saved position for debug FAB button (below refresh button) + top: 'calc(var(--topBarBlockSize) + 60px)', + right: '12px' + }, // Saved position for mobile FAB button userStats: { health: 100, satiety: 100, @@ -61,6 +53,17 @@ export let extensionSettings = { assets: "None" } }, + statNames: { + health: 'Health', + satiety: 'Satiety', + energy: 'Energy', + hygiene: 'Hygiene', + arousal: 'Arousal' + }, + quests: { + main: "None", // Current main quest title + optional: [] // Array of optional quest titles + }, level: 1, // User's character level classicStats: { str: 10, @@ -78,6 +81,7 @@ export let extensionSettings = { assets: 'list' // 'list' or 'grid' view mode for Assets section }, debugMode: false, // Enable debug logging visible in UI (for mobile debugging) + memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection // Dashboard v2.0 Configuration dashboard: { @@ -157,6 +161,25 @@ export let isPlotProgression = false; */ export let pendingDiceRoll = null; +/** + * Debug logs array for troubleshooting + */ +export let debugLogs = []; + +/** + * Add a debug log entry + * @param {string} message - The log message + * @param {any} data - Optional data to log + */ +export function addDebugLog(message, data = null) { + const timestamp = new Date().toISOString(); + debugLogs.push({ timestamp, message, data }); + // Keep only last 100 logs + if (debugLogs.length > 100) { + debugLogs.shift(); + } +} + /** * Feature flags for gradual rollout of new features */ @@ -164,12 +187,6 @@ export const FEATURE_FLAGS = { useNewInventory: true // Enable v2 inventory system with categorized storage }; -/** - * Debug logs storage for mobile-friendly debugging - * Stores parser logs that can be viewed in UI - */ -export let debugLogs = []; - /** * Fallback avatar image (base64-encoded SVG with "?" icon) * Using base64 to avoid quote-encoding issues in HTML attributes @@ -184,6 +201,7 @@ export let $userStatsContainer = null; export let $infoBoxContainer = null; export let $thoughtsContainer = null; export let $inventoryContainer = null; +export let $questsContainer = null; /** * State setters - provide controlled mutation of state variables @@ -252,25 +270,6 @@ export function setInventoryContainer($element) { $inventoryContainer = $element; } -export function addDebugLog(message, data = null) { - const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; // HH:MM:SS - const logEntry = { - timestamp, - message, - data: data ? JSON.stringify(data, null, 2) : null - }; - debugLogs.push(logEntry); - - // Keep only last 100 entries to avoid memory issues - if (debugLogs.length > 100) { - debugLogs.shift(); - } -} - -export function clearDebugLogs() { - debugLogs = []; -} - -export function getDebugLogs() { - return debugLogs; +export function setQuestsContainer($element) { + $questsContainer = $element; } diff --git a/src/systems/features/lorebookLimiter.js b/src/systems/features/lorebookLimiter.js new file mode 100644 index 0000000..9d2e6a5 --- /dev/null +++ b/src/systems/features/lorebookLimiter.js @@ -0,0 +1,267 @@ +/** + * Lorebook Limiter Module + * Adds maximum activation limit to SillyTavern's World Info system + */ + +import { eventSource, event_types } from '../../../../../../../script.js'; + +let maxActivations = 0; // 0 = unlimited +let settingsInitialized = false; +let activatedEntriesThisGeneration = []; + +/** + * Initialize the lorebook limiter + */ +export function initLorebookLimiter() { + console.log('[Lorebook Limiter] Initializing...'); + + // Load saved setting + const saved = localStorage.getItem('rpg_max_lorebook_activations'); + if (saved !== null) { + maxActivations = parseInt(saved, 10); + } + + // Wait for World Info settings to be ready + eventSource.on('worldInfoSettings', () => { + setTimeout(() => { + if (!settingsInitialized) { + injectMaxActivationsUI(); + settingsInitialized = true; + } + }, 100); + }); + + // Try when the WI drawer is opened + const tryInjectOnClick = () => { + const wiButton = document.querySelector('#WIDrawerIcon'); + if (wiButton) { + wiButton.addEventListener('click', () => { + setTimeout(() => { + if (!settingsInitialized) { + injectMaxActivationsUI(); + settingsInitialized = true; + } + }, 300); + }); + console.log('[Lorebook Limiter] Attached to WI drawer button'); + } + }; + + // Also try on app ready + eventSource.on('app_ready', () => { + setTimeout(() => { + tryInjectOnClick(); + if (!settingsInitialized) { + injectMaxActivationsUI(); + settingsInitialized = true; + } + }, 1000); + }); + + // Patch the world info activation system + patchWorldInfoActivation(); +} + +/** + * Inject the Maximum Activations UI into World Info settings + */ +function injectMaxActivationsUI() { + console.log('[Lorebook Limiter] Injecting UI...'); + + // Check if already injected + if (document.querySelector('#rpg-max-lorebook-activations-container')) { + console.log('[Lorebook Limiter] UI already injected'); + return; + } + + // Find the Memory Recollection button - we'll add our UI right after it + const memoryButton = document.querySelector('.rpg-memory-recollection-btn'); + + if (!memoryButton) { + console.log('[Lorebook Limiter] Memory Recollection button not found yet'); + return; + } + + const container = memoryButton.parentElement; + if (!container) { + console.log('[Lorebook Limiter] Could not find button container'); + return; + } + + console.log('[Lorebook Limiter] Found Memory Recollection button, injecting slider after it'); + + // Create the UI - styled to match the extension's theme + const settingHTML = ` +
+ + Limit entries per generation (0 = unlimited) +
+ `; + + // Insert after the Memory Recollection button + memoryButton.insertAdjacentHTML('afterend', settingHTML); + + // Add event listener + const input = document.querySelector('#rpg-max-activations-input'); + + if (input) { + input.addEventListener('input', (e) => { + let value = parseInt(e.target.value, 10); + if (isNaN(value) || value < 0) value = 0; + if (value > 9999) value = 9999; + + maxActivations = value; + e.target.value = value; + localStorage.setItem('rpg_max_lorebook_activations', value.toString()); + console.log(`[Lorebook Limiter] Max activations set to: ${value}`); + }); + + console.log('[Lorebook Limiter] ✅ UI injected successfully'); + } +} + +/** + * Patch the world info activation system to enforce the limit + */ +function patchWorldInfoActivation() { + console.log('[Lorebook Limiter] Setting up activation limiter...'); + + // We need to intercept at the module level + // Use a Proxy on the module loader + const originalDefine = window.define; + const originalRequire = window.require; + + // Try multiple approaches to hook into the WI system + const attemptPatch = () => { + // Approach 1: Direct window access + if (window.getWorldInfoPrompt) { + const original = window.getWorldInfoPrompt; + window.getWorldInfoPrompt = async function(...args) { + const result = await original.apply(this, args); + + if (maxActivations > 0 && result) { + // Count entries in the worldInfoString + const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim()); + if (lines.length > maxActivations) { + console.log(`[Lorebook Limiter] Limiting ${lines.length} WI lines to ${maxActivations}`); + + // Trim the strings + const limitedLines = lines.slice(0, maxActivations); + result.worldInfoBefore = limitedLines.join('\n'); + result.worldInfoAfter = ''; + result.worldInfoString = result.worldInfoBefore; + + console.log(`[Lorebook Limiter] ✅ Limited from ${lines.length} to ${limitedLines.length} entries`); + } + } + + return result; + }; + + console.log('[Lorebook Limiter] ✅ Patched window.getWorldInfoPrompt'); + return true; + } + + // Approach 2: Through SillyTavern context + if (window.SillyTavern?.getContext) { + const ctx = window.SillyTavern.getContext(); + if (ctx.getWorldInfoPrompt) { + const original = ctx.getWorldInfoPrompt; + ctx.getWorldInfoPrompt = async function(...args) { + const result = await original.apply(this, args); + + if (maxActivations > 0 && result) { + const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim()); + if (lines.length > maxActivations) { + console.log(`[Lorebook Limiter] Limiting ${lines.length} WI entries to ${maxActivations}`); + const limitedLines = lines.slice(0, maxActivations); + result.worldInfoBefore = limitedLines.join('\n'); + result.worldInfoAfter = ''; + result.worldInfoString = result.worldInfoBefore; + } + } + + return result; + }; + + console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().getWorldInfoPrompt'); + return true; + } + + // Try checkWorldInfo instead + if (ctx.checkWorldInfo) { + const original = ctx.checkWorldInfo; + ctx.checkWorldInfo = async function(...args) { + const result = await original.apply(this, args); + + if (maxActivations > 0 && result?.allActivatedEntries?.size > maxActivations) { + console.log(`[Lorebook Limiter] Limiting ${result.allActivatedEntries.size} entries to ${maxActivations}`); + + // Keep only first N entries + const entries = Array.from(result.allActivatedEntries.entries()); + result.allActivatedEntries = new Map(entries.slice(0, maxActivations)); + + // Also limit the string output + const lines = (result.worldInfoBefore + result.worldInfoAfter).split('\n').filter(l => l.trim()); + if (lines.length > maxActivations) { + const limitedLines = lines.slice(0, maxActivations); + result.worldInfoBefore = limitedLines.join('\n'); + result.worldInfoAfter = ''; + } + + console.log(`[Lorebook Limiter] ✅ Limited to ${result.allActivatedEntries.size} entries`); + } + + return result; + }; + + console.log('[Lorebook Limiter] ✅ Patched SillyTavern.getContext().checkWorldInfo'); + return true; + } + } + + return false; + }; + + // Try immediately + if (!attemptPatch()) { + // Retry after delays + setTimeout(() => attemptPatch() || setTimeout(() => attemptPatch(), 2000), 1000); + } +} + +/** + * Update the maximum activations limit + */ +export function setMaxActivations(value) { + maxActivations = parseInt(value, 10); + localStorage.setItem('rpg_max_lorebook_activations', value.toString()); + + // Update UI if it exists + const valueDisplay = document.querySelector('#rpg-max-activations-value'); + const slider = document.querySelector('#rpg-max-activations-slider'); + + if (valueDisplay) { + valueDisplay.textContent = value; + } + if (slider) { + slider.value = value; + } +} + +/** + * Get current maximum activations limit + */ +export function getMaxActivations() { + return maxActivations; +} diff --git a/src/systems/features/memoryRecollection.js b/src/systems/features/memoryRecollection.js new file mode 100644 index 0000000..95c3396 --- /dev/null +++ b/src/systems/features/memoryRecollection.js @@ -0,0 +1,843 @@ +/** + * Memory Recollection Module + * Handles generation of lorebook entries from chat history + */ + +import { chat, characters, this_chid, generateRaw, substituteParams, eventSource, event_types } from '../../../../../../../script.js'; +import { selected_group } from '../../../../../../group-chats.js'; +import { extensionSettings, addDebugLog } from '../../core/state.js'; +import { saveSettings } from '../../core/persistence.js'; +import { checkWorldInfo, createNewWorldInfo, openWorldInfoEditor, saveWorldInfo, setWorldInfoSettings } from '../../../../../../world-info.js'; + +/** + * Helper to log to both console and debug logs array + */ +function debugLog(message, data = null) { + if (data !== null && data !== undefined) { + console.log(message, data); + } else { + console.log(message); + } + if (extensionSettings.debugMode) { + addDebugLog(message, data); + } +} + +/** + * Get or create the Memory Recollection lorebook + * @returns {Promise} The UID of the Memory Recollection lorebook + */ +async function getOrCreateMemoryLorebook() { + const lorebookName = 'Memory Recollection'; + + try { + debugLog('[Memory Recollection] Checking for existing lorebook...'); + + // Use checkWorldInfo to see if it exists + const exists = await checkWorldInfo(lorebookName); + + if (exists) { + debugLog('[Memory Recollection] Found existing lorebook:', lorebookName); + return lorebookName; + } + + // Create new lorebook using SillyTavern's imported function + debugLog('[Memory Recollection] Creating new Memory Recollection lorebook'); + + // Call the imported createNewWorldInfo function + await createNewWorldInfo(lorebookName, true); + + debugLog('[Memory Recollection] Created lorebook:', lorebookName); + + // Wait for the file system to settle + await new Promise(resolve => setTimeout(resolve, 500)); + + return lorebookName; + } catch (error) { + console.error('[Memory Recollection] Error in getOrCreateMemoryLorebook:', error); + throw error; + } +} +/** + * Create the constant "Relevant Memories:" header entry + * @param {string} lorebookUid - The UID of the lorebook + * @returns {Object} The header entry object + */ +function createConstantHeaderEntry() { + const entry = { + uid: 1, // Fixed UID so it's always first + key: [], + keysecondary: [], + comment: 'Relevant Memories Header', + content: 'Relevant Memories:', + constant: true, // Always inserted + vectorized: false, + selective: false, + selectiveLogic: 0, + addMemo: false, + order: 99, // First in order + position: 4, // at Depth + disable: false, + ignoreBudget: false, + excludeRecursion: false, + preventRecursion: false, + matchPersonaDescription: false, + matchCharacterDescription: false, + matchCharacterPersonality: false, + matchCharacterDepthPrompt: false, + matchScenario: false, + matchCreatorNotes: false, + delayUntilRecursion: false, + probability: 100, + useProbability: true, + depth: 1, // Insertion depth + outletName: '', + group: '', + groupOverride: false, + groupWeight: 100, + scanDepth: null, + caseSensitive: null, + matchWholeWords: null, + useGroupScoring: null, + automationId: '', + role: 0, // System role + sticky: 0, + cooldown: 0, + delay: 0, + triggers: [], + displayIndex: 0, + characterFilter: { + isExclude: false, + names: [], + tags: [] + } + }; + + debugLog('[Memory Recollection] Created constant header entry'); + return entry; +} + +/** + * Save a world info entry to a lorebook + * @param {string} lorebookUid - The filename/UID of the lorebook + * @param {Object} entry - The entry data + */ +async function saveWorldInfoEntry(lorebookUid, entry) { + try { + debugLog('[Memory Recollection] Saving entry to lorebook:', lorebookUid); + + // Open the world info editor for this lorebook to load its data + await openWorldInfoEditor(lorebookUid); + + // Wait for it to load + await new Promise(resolve => setTimeout(resolve, 500)); + + // Now access the loaded world info data + const worldInfo = window.world_info; + + debugLog('[Memory Recollection] World info after opening:', { + type: typeof worldInfo, + isArray: Array.isArray(worldInfo), + hasEntries: worldInfo?.entries !== undefined, + keys: worldInfo ? Object.keys(worldInfo).slice(0, 10) : null + }); + + // Try different structures - it might be an array or might have different properties + let entries; + if (worldInfo && typeof worldInfo === 'object') { + if (worldInfo.entries) { + entries = worldInfo.entries; + } else if (Array.isArray(worldInfo)) { + // If it's an array, convert to entries object + entries = {}; + worldInfo.forEach((e, i) => { + if (e && e.uid) { + entries[e.uid] = e; + } + }); + } + } + + if (!entries) { + entries = {}; + } + + // Add the entry + entries[entry.uid] = entry; + + debugLog('[Memory Recollection] Entry added, saving world info...'); + + // Save using the imported saveWorldInfo function + // Pass the entries as the data structure + await saveWorldInfo(lorebookUid, { entries }); + + debugLog('[Memory Recollection] Entry saved successfully'); + return { success: true }; + } catch (error) { + console.error('[Memory Recollection] Error saving entry:', error); + throw error; + } +} + +/** + * Save multiple world info entries to a lorebook at once + * @param {string} lorebookUid - The filename/UID of the lorebook + * @param {Array} newEntries - Array of entry objects to add + */ +async function saveWorldInfoEntries(lorebookUid, newEntries) { + try { + debugLog(`[Memory Recollection] Saving ${newEntries.length} entries to lorebook:`, lorebookUid); + + // Open the world info editor for this lorebook to load its data + await openWorldInfoEditor(lorebookUid); + + // Wait for it to load + await new Promise(resolve => setTimeout(resolve, 500)); + + // Now access the loaded world info data + const worldInfo = window.world_info; + + // Try different structures - it might be an array or might have different properties + let entries = {}; + if (worldInfo && typeof worldInfo === 'object') { + if (worldInfo.entries) { + entries = { ...worldInfo.entries }; // Clone existing entries + } else if (Array.isArray(worldInfo)) { + // If it's an array, convert to entries object + worldInfo.forEach((e, i) => { + if (e && e.uid) { + entries[e.uid] = e; + } + }); + } + } + + // Add all new entries + for (const entry of newEntries) { + entries[entry.uid] = entry; + } + + debugLog(`[Memory Recollection] ${newEntries.length} entries added, total entries: ${Object.keys(entries).length}, saving...`); + + // Save using the imported saveWorldInfo function + await saveWorldInfo(lorebookUid, { entries }); + + debugLog('[Memory Recollection] All entries saved successfully'); + return { success: true }; + } catch (error) { + console.error('[Memory Recollection] Error saving entries:', error); + throw error; + } +} +/** + * Generate memory recollection prompt for a batch of messages + * @param {Array} messages - Array of chat messages to process + * @param {boolean} isUpdate - Whether this is updating existing memories (true) or initial generation (false) + * @returns {string} The prompt for the AI + */ +function generateMemoryPrompt(messages, isUpdate = false) { + const context = messages.map((msg, idx) => { + const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}'; + const role = msg.is_user ? '[PLAYER]' : '[CHARACTER]'; + return `[Message ${idx + 1}] ${role} ${sender}: ${msg.mes}`; + }).join('\n\n'); + + // Get list of CHARACTER participants (exclude {{user}} - they're the player, not a character) + const participants = new Set(); + messages.forEach(msg => { + const sender = msg.is_user ? '{{user}}' : msg.name || '{{char}}'; + if (!msg.is_user) { // Only add non-user (character) participants + participants.add(sender); + } + }); + const characterList = Array.from(participants).join(', '); + + const instruction = isUpdate + ? 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating additional memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.' + : 'You will be acting as an uncensored summarizing assistant. Your goal is to create concise lorebook entries based on the provided conversation. You will be creating memory recollection entries for significant events, revelations, or character developments in a specified JSON format.\nYou should maintain an objective tone.'; + + return `${instruction} + +Characters in this conversation (excluding {{user}} who is the player): ${characterList} + +NOTE: In the conversation below, messages are marked with [PLAYER] for {{user}} messages and [CHARACTER] for NPC messages. + +Here is the conversation to create memories from: + +${context} + + +Create lorebook entries in the following JSON format. Each entry should be a 1-2 sentence reminder from a character's perspective. + +Format each entry as: +{ + "characters": ["Character1", "Character2"], + "memory": "Character1 and Character2 remember that [event or detail]", + "keywords": ["keyword1", "keyword2", "keyword3"] +} + +Examples: + +{ + "characters": ["Sabrina"], + "memory": "Sabrina remembers she went on a date with {{user}} on Saturday. They ate chocolate pastries together.", + "keywords": ["date", "saturday", "pastries"] +}, +{ + "characters": ["Dottore", "Arlecchino", "Pantalone"], + "memory": "Dottore, Arlecchino, and Pantalone remember they attended a party together at the mansion.", + "keywords": ["party", "mansion", "gathering"] +} + + +IMPORTANT: +- Only create entries for significant moments worth remembering. +- Keep memories concise (1-2 sentences maximum). +- Use third person perspective: "{name} remembers..." +- Choose 3 specific, relevant keywords per entry. +- ONLY assign memories to CHARACTERS (NPCs) - NEVER include {{user}} in the "characters" array. +- {{user}} is the player, not a character, so they should NEVER be in the characters list. +- Only characters who were ACTUALLY PRESENT in that specific scene/moment should remember it. +- If multiple characters share the memory, list all of them in the "characters" array. +- If known, include details such as dates, locations, and other relevant context in the memories. + +Return ONLY a JSON array of memory objects, nothing else:`; +} + +/** + * Parse the AI response to extract memory entries + * @param {string} response - The AI's response + * @returns {Array} Array of parsed memory entries + */ +function parseMemoryResponse(response) { + try { + // Try to extract JSON from code blocks + const jsonMatch = response.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/); + const jsonString = jsonMatch ? jsonMatch[1] : response; + + // Parse JSON + const memories = JSON.parse(jsonString.trim()); + + if (!Array.isArray(memories)) { + throw new Error('Response is not an array'); + } + + debugLog('[Memory Recollection] Parsed memories:', memories); + return memories; + + } catch (error) { + debugLog('[Memory Recollection] Failed to parse response:', error); + console.error('[Memory Recollection] Parse error:', error); + console.error('[Memory Recollection] Raw response:', response); + return []; + } +} + +/** + * Create a world info entry from a memory object + * @param {string} lorebookUid - The UID of the lorebook + * @param {Object} memory - The memory object + * @param {number} index - The index for ordering + */ +async function createMemoryEntry(lorebookUid, memory, index) { + const { characters: characterList, memory: content, keywords } = memory; + + // Handle character filter - just use the character names directly + let characterNames = []; + + if (Array.isArray(characterList) && characterList.length > 0) { + // New format: array of character names + characterNames = characterList.map(name => name.trim()); + debugLog(`[Memory Recollection] Character names for filter:`, characterNames); + } else if (typeof characterList === 'string' && characterList.trim() !== '') { + // Legacy string format or comma-separated - parse it + characterNames = characterList.split(',').map(n => n.trim()).filter(n => n !== ''); + debugLog(`[Memory Recollection] Character names for filter:`, characterNames); + } + + const entry = { + uid: Date.now() + index, // Simple UID generation + key: keywords || [], + keysecondary: [], + comment: `Memory: ${characterNames.join(', ')}`, + content: content, + constant: false, + vectorized: false, + selective: true, + selectiveLogic: 0, + addMemo: false, + order: 100, + position: 4, // at Depth + disable: false, + ignoreBudget: false, + excludeRecursion: false, + preventRecursion: false, + matchPersonaDescription: false, + matchCharacterDescription: false, + matchCharacterPersonality: false, + matchCharacterDepthPrompt: false, + matchScenario: false, + matchCreatorNotes: false, + delayUntilRecursion: false, + probability: 100, + useProbability: true, + depth: 1, // Insertion depth + outletName: '', + group: '', + groupOverride: false, + groupWeight: 100, + scanDepth: null, + caseSensitive: null, + matchWholeWords: null, + useGroupScoring: null, + automationId: '', + role: 0, // 0 = System role (matching the example) + sticky: 0, + cooldown: 0, + delay: 0, + triggers: [], + displayIndex: index + 1, + characterFilter: { + isExclude: false, + names: characterNames, // Array of character names + tags: [] + }, + extensions: { + position: 4, // at Depth + depth: 1, + role: 1 + } + }; + + debugLog(`[Memory Recollection] Created entry for ${characterNames.join(', ')} with character filter:`, characterNames); + return entry; // Return instead of saving +} + +/** + * Process a batch of messages and generate memory entries + * @param {Array} messages - Array of messages to process + * @param {string} lorebookUid - The UID of the lorebook + * @param {boolean} isUpdate - Whether this is an update (true) or initial generation (false) + * @param {number} startIndex - Starting index for entry ordering + * @returns {Promise} Array of created entries + */ +async function processBatch(messages, lorebookUid, isUpdate, startIndex) { + debugLog(`[Memory Recollection] Processing batch of ${messages.length} messages (isUpdate: ${isUpdate})`); + + const prompt = generateMemoryPrompt(messages, isUpdate); + + // Generate using SillyTavern's generateRaw + const response = await generateRaw(prompt, '', false, false); + + if (!response) { + throw new Error('No response from AI'); + } + + // Parse the response + const memories = parseMemoryResponse(response); + + if (memories.length === 0) { + debugLog('[Memory Recollection] No memories extracted from this batch'); + // Return -1 to signal parse failure (vs 0 for valid but empty response) + throw new Error('Failed to parse memories from AI response. The response may be invalid or the service may be unavailable.'); + } + + // Create entries for each memory (but don't save yet) + const entries = []; + for (let i = 0; i < memories.length; i++) { + const entry = await createMemoryEntry(lorebookUid, memories[i], startIndex + i); + entries.push(entry); + } + + debugLog(`[Memory Recollection] Created ${entries.length} entries from batch`); + return entries; +} + +/** + * Main function to start memory recollection process + * @param {Function} onProgress - Callback for progress updates (current, total) + * @param {Function} onComplete - Callback when complete + * @param {Function} onError - Callback for errors + */ +export async function startMemoryRecollection(onProgress, onComplete, onError) { + try { + debugLog('[Memory Recollection] Starting memory recollection process'); + + // Get or create the lorebook + const lorebookUid = await getOrCreateMemoryLorebook(); + + // Get messages to process count from settings + const messagesToProcess = extensionSettings.memoryMessagesToProcess || 16; + + // Check if this is an update (lorebook already exists with entries) + const world_info = window.world_info; + const lorebook = world_info.globalSelect?.find(book => book.uid === lorebookUid); + const existingEntryCount = lorebook?.entries ? Object.keys(lorebook.entries).length : 0; + const isUpdate = existingEntryCount > 1; // More than just the header + + let messagesToProcessArray; + if (isUpdate) { + // Process only the last batch + const totalMessages = chat.length; + const startIdx = Math.max(0, totalMessages - messagesToProcess); + messagesToProcessArray = chat.slice(startIdx); + debugLog(`[Memory Recollection] Update mode: Processing last ${messagesToProcess} messages`); + } else { + // Process entire chat in batches + messagesToProcessArray = chat; + debugLog(`[Memory Recollection] Initial mode: Processing all ${chat.length} messages`); + } + + const totalBatches = Math.ceil(messagesToProcessArray.length / messagesToProcess); + let entryIndex = existingEntryCount; + const allEntries = []; // Accumulate all entries here + + for (let i = 0; i < totalBatches; i++) { + const batchStart = i * messagesToProcess; + const batchEnd = Math.min(batchStart + messagesToProcess, messagesToProcessArray.length); + const batch = messagesToProcessArray.slice(batchStart, batchEnd); + + onProgress(i + 1, totalBatches); + + try { + const batchEntries = await processBatch(batch, lorebookUid, isUpdate && i === 0, entryIndex); + allEntries.push(...batchEntries); // Add to accumulator + entryIndex += batchEntries.length; + } catch (error) { + // Batch failed - ask user if they want to retry + debugLog('[Memory Recollection] Batch failed:', error.message); + + const retry = await new Promise(resolve => { + const retryModal = document.createElement('div'); + retryModal.className = 'rpg-memory-modal-overlay'; + retryModal.innerHTML = ` +
+
+

⚠️ Generation Failed

+
+
+

Error: ${error.message}

+

Batch ${i + 1} of ${totalBatches} failed to process.

+

Would you like to retry this batch?

+
+ +
+ `; + + document.body.appendChild(retryModal); + + retryModal.querySelector('.rpg-memory-cancel').addEventListener('click', () => { + document.body.removeChild(retryModal); + resolve(false); + }); + + retryModal.querySelector('.rpg-memory-proceed').addEventListener('click', () => { + document.body.removeChild(retryModal); + resolve(true); + }); + }); + + if (retry) { + // Retry the same batch + i--; + continue; + } + // Otherwise skip this batch and continue + } + + // Small delay between batches to avoid rate limiting + if (i < totalBatches - 1) { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + // Add the constant header entry at the end + const headerEntry = createConstantHeaderEntry(); + allEntries.push(headerEntry); // Add to end of array + + // Save all entries at once + if (allEntries.length > 0) { + debugLog(`[Memory Recollection] Saving ${allEntries.length} total entries (including header) to lorebook...`); + await saveWorldInfoEntries(lorebookUid, allEntries); + + // Trigger world info refresh by simulating the WI button click to reload the list + // This ensures the newly created lorebook appears in the dropdown + const wiButton = document.querySelector('#WIDrawerIcon'); + if (wiButton) { + // Close and reopen to force refresh + wiButton.click(); + await new Promise(resolve => setTimeout(resolve, 100)); + wiButton.click(); + debugLog('[Memory Recollection] Triggered WI panel refresh'); + } + + // Also emit the update event + eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED); + } + + debugLog('[Memory Recollection] Process complete'); + + // Open the World Info editor with the Memory Recollection lorebook + try { + await openWorldInfoEditor(lorebookUid); + debugLog('[Memory Recollection] Opened World Info editor with Memory Recollection lorebook'); + } catch (err) { + debugLog('[Memory Recollection] Could not open World Info editor:', err); + } + + onComplete(allEntries.length); + + } catch (error) { + debugLog('[Memory Recollection] Error:', error); + onError(error); + } +} + +/** + * Show memory recollection confirmation modal + */ +export function showMemoryRecollectionModal() { + const modal = document.createElement('div'); + modal.className = 'rpg-memory-modal-overlay'; + modal.innerHTML = ` +
+
+

⚠️ Memory Recollection

+
+
+

Warning! This process will trigger multiple generation requests and will take time.

+

Ensure your currently selected model is the one you want to use for this task.

+

+ Messages per batch: ${extensionSettings.memoryMessagesToProcess || 16} +
+ (You can change this in the extension settings) +

+
+ +
+ `; + + document.body.appendChild(modal); + + // Event listeners + modal.querySelector('.rpg-memory-cancel').addEventListener('click', () => { + document.body.removeChild(modal); + }); + + modal.querySelector('.rpg-memory-proceed').addEventListener('click', () => { + document.body.removeChild(modal); + showMemoryProgressModal(); + }); + + // Click outside to close + modal.addEventListener('click', (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }); +} + +/** + * Show progress modal during memory recollection + */ +function showMemoryProgressModal() { + const modal = document.createElement('div'); + modal.className = 'rpg-memory-modal-overlay'; + modal.innerHTML = ` +
+
+

🧠 Processing Memories...

+
+
+

Processing batch 0 of 0

+
+
+
+

Initializing...

+
+
+ `; + + document.body.appendChild(modal); + + const currentSpan = modal.querySelector('.rpg-memory-current'); + const totalSpan = modal.querySelector('.rpg-memory-total'); + const progressFill = modal.querySelector('.rpg-memory-progress-fill'); + const statusText = modal.querySelector('.rpg-memory-status'); + + // Start the process + startMemoryRecollection( + (current, total) => { + currentSpan.textContent = current; + totalSpan.textContent = total; + const percentage = (current / total) * 100; + progressFill.style.width = `${percentage}%`; + statusText.textContent = `Processing memories from batch ${current}...`; + }, + (entriesCreated) => { + statusText.innerHTML = ` + ✅ Complete! Created ${entriesCreated} memory entries.
+ The "Memory Recollection" lorebook has been created.
+ ⚠️ Please refresh SillyTavern to see the lorebook in the World Info dropdown. + `; + progressFill.style.width = '100%'; + + // Add close button + const closeBtn = document.createElement('button'); + closeBtn.className = 'rpg-memory-modal-btn rpg-memory-close'; + closeBtn.textContent = 'Close'; + closeBtn.style.marginTop = '15px'; + closeBtn.addEventListener('click', () => { + document.body.removeChild(modal); + }); + modal.querySelector('.rpg-memory-modal-body').appendChild(closeBtn); + }, + (error) => { + statusText.textContent = `Error: ${error.message}`; + statusText.style.color = '#e94560'; + + // Close after 5 seconds + setTimeout(() => { + document.body.removeChild(modal); + }, 5000); + } + ); +} + +/** + * Setup the memory recollection button in World Info section + */ +export function setupMemoryRecollectionButton() { + console.log('[Memory Recollection] Setting up button via event listener'); + + // Use SillyTavern's built-in event to know when WI is ready + // This fires after the worldInfoSettings are loaded + eventSource.on('worldInfoSettings', () => { + console.log('[Memory Recollection] worldInfoSettings event fired'); + setTimeout(updateButton, 100); + }); + + // Also try on app ready + eventSource.on('app_ready', () => { + console.log('[Memory Recollection] app_ready event fired'); + setTimeout(updateButton, 500); + }); + + // Try immediately as well + setTimeout(updateButton, 2000); + + function updateButton() { + const existingButton = document.querySelector('.rpg-memory-recollection-btn'); + + // If extension is disabled, remove button if it exists + if (!extensionSettings.enabled) { + if (existingButton) { + console.log('[Memory Recollection] Extension disabled, removing button'); + existingButton.remove(); + } + return; + } + + // Extension is enabled, add button if it doesn't exist + addButton(); + } + + function addButton() { + // Check if button already exists + if (document.querySelector('.rpg-memory-recollection-btn')) { + console.log('[Memory Recollection] Button already exists'); + return; + } + + console.log('[Memory Recollection] Attempting to add button...'); + + // World Info button bar is inside the world editor + // Look for the specific button container + const selectors = [ + '#world_editor_buttons', + '#world_popup .world_button_bar', + '#WorldInfo .world_button_bar', + '.world_button_bar', + '#world_popup .justifyLeft', + '#WorldInfo .justifyLeft', + '#world_popup', + '#WorldInfo' + ]; + + let container = null; + for (const selector of selectors) { + container = document.querySelector(selector); + if (container) { + console.log(`[Memory Recollection] Found container with selector: ${selector}`, container); + break; + } + } + + if (!container) { + console.log('[Memory Recollection] No suitable container found yet'); + return; + } + + // Create the button + const button = document.createElement('button'); + button.id = 'rpg-memory-recollection-button'; + button.className = 'rpg-memory-recollection-btn menu_button'; + button.innerHTML = ' Memory Recollection'; + button.title = 'Generate memory recollection entries from chat history'; + + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + showMemoryRecollectionModal(); + }); + + // Insert the button - prepend to put it first + if (container.classList.contains('world_button_bar') || container.classList.contains('justifyLeft')) { + container.insertBefore(button, container.firstChild); + } else { + // Find or create a button container + let buttonContainer = container.querySelector('.world_button_bar') || + container.querySelector('.justifyLeft'); + + if (!buttonContainer) { + buttonContainer = document.createElement('div'); + buttonContainer.className = 'world_button_bar justifyLeft'; + container.insertBefore(buttonContainer, container.firstChild); + } + + buttonContainer.insertBefore(button, buttonContainer.firstChild); + } + + console.log('[Memory Recollection] ✅ Button added successfully!'); + } +} + +/** + * Update button visibility based on extension enabled state + * Call this when the extension is toggled on/off + */ +export function updateMemoryRecollectionButton() { + const existingButton = document.querySelector('.rpg-memory-recollection-btn'); + + if (!extensionSettings.enabled) { + // Extension disabled - remove button if it exists + if (existingButton) { + console.log('[Memory Recollection] Extension disabled, removing button'); + existingButton.remove(); + } + } else { + // Extension enabled - ensure button exists + if (!existingButton) { + console.log('[Memory Recollection] Extension enabled, adding button'); + setTimeout(() => { + setupMemoryRecollectionButton(); + }, 100); + } + } +} diff --git a/src/systems/generation/apiClient.js b/src/systems/generation/apiClient.js index 5c145e5..7112f54 100644 --- a/src/systems/generation/apiClient.js +++ b/src/systems/generation/apiClient.js @@ -18,6 +18,11 @@ import { saveChatData } from '../../core/persistence.js'; import { generateSeparateUpdatePrompt } from './promptBuilder.js'; import { parseResponse, parseUserStats } from './parser.js'; import { refreshDashboard } from '../dashboard/dashboardIntegration.js'; +import { renderUserStats } from '../rendering/userStats.js'; +import { renderInfoBox } from '../rendering/infoBox.js'; +import { renderThoughts } from '../rendering/thoughts.js'; +import { renderInventory } from '../rendering/inventory.js'; +import { renderQuests } from '../rendering/quests.js'; // Store the original preset name to restore after tracker generation let originalPresetName = null; @@ -175,15 +180,16 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough // 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 === '') + // Only auto-commit on TRULY first generation (no committed data exists at all) + // This prevents auto-commit after refresh when we have saved committed data + const hasAnyCommittedContent = ( + (committedTrackerData.userStats && committedTrackerData.userStats.trim() !== '') || + (committedTrackerData.infoBox && committedTrackerData.infoBox.trim() !== '' && committedTrackerData.infoBox !== 'Info Box\n---\n') || + (committedTrackerData.characterThoughts && committedTrackerData.characterThoughts.trim() !== '' && committedTrackerData.characterThoughts !== 'Present Characters\n---\n') ); - if (hasNoRealData || hasOnlyPlaceholderData) { + // Only commit if we have NO committed content at all (truly first time ever) + if (!hasAnyCommittedContent) { committedTrackerData.userStats = parsedData.userStats; committedTrackerData.infoBox = parsedData.infoBox; committedTrackerData.characterThoughts = parsedData.characterThoughts; @@ -195,6 +201,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough renderInfoBox(); renderThoughts(); renderInventory(); + renderQuests(); // Refresh dashboard widgets (v2 dashboard) refreshDashboard(); @@ -207,6 +214,7 @@ export async function updateRPGData(renderUserStats, renderInfoBox, renderThough renderInfoBox(); renderThoughts(); renderInventory(); + renderQuests(); // Refresh dashboard widgets (v2 dashboard) refreshDashboard(); diff --git a/src/systems/generation/parser.js b/src/systems/generation/parser.js index d3ce1c4..2472ffc 100644 --- a/src/systems/generation/parser.js +++ b/src/systems/generation/parser.js @@ -7,6 +7,70 @@ import { extensionSettings, FEATURE_FLAGS, addDebugLog } from '../../core/state. import { saveSettings } from '../../core/persistence.js'; import { extractInventory } from './inventoryParser.js'; +/** + * Helper to separate emoji from text in a string + * Handles cases where there's no comma or space after emoji + * @param {string} str - String potentially containing emoji followed by text + * @returns {{emoji: string, text: string}} Separated emoji and text + */ +function separateEmojiFromText(str) { + if (!str) return { emoji: '', text: '' }; + + str = str.trim(); + + // Regex to match emoji at the start (handles most emoji including compound ones) + // This matches emoji sequences including skin tones, gender modifiers, etc. + const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u; + const emojiMatch = str.match(emojiRegex); + + if (emojiMatch) { + const emoji = emojiMatch[0]; + let text = str.substring(emoji.length).trim(); + + // Remove leading comma or space if present + text = text.replace(/^[,\s]+/, ''); + + return { emoji, text }; + } + + // No emoji found - check if there's a comma separator anyway + const commaParts = str.split(','); + if (commaParts.length >= 2) { + return { + emoji: commaParts[0].trim(), + text: commaParts.slice(1).join(',').trim() + }; + } + + // No clear separation - return original as text + return { emoji: '', text: str }; +} + +/** + * Helper to strip enclosing brackets from text + * Removes [], {}, and () from the entire text if it's wrapped + * @param {string} text - Text that may be wrapped in brackets + * @returns {string} Text with brackets removed + */ +function stripBrackets(text) { + if (!text) return text; + + // Remove leading and trailing whitespace first + text = text.trim(); + + // Check if the entire text is wrapped in brackets and remove them + // This handles cases where models wrap entire sections in brackets + while ( + (text.startsWith('[') && text.endsWith(']')) || + (text.startsWith('{') && text.endsWith('}')) || + (text.startsWith('(') && text.endsWith(')')) + ) { + text = text.substring(1, text.length - 1).trim(); + } + + return text; +} + /** * Helper to log to both console and debug logs array */ @@ -37,9 +101,15 @@ export function parseResponse(responseText) { debugLog('[RPG Parser] Response length:', responseText.length + ' chars'); debugLog('[RPG Parser] First 500 chars:', responseText.substring(0, 500)); + // Remove content inside thinking tags first (model's internal reasoning) + // This prevents parsing code blocks from the model's thinking process + let cleanedResponse = responseText.replace(/[\s\S]*?<\/think>/gi, ''); + cleanedResponse = cleanedResponse.replace(/[\s\S]*?<\/thinking>/gi, ''); + debugLog('[RPG Parser] Removed thinking tags, new length:', cleanedResponse.length + ' chars'); + // Extract code blocks const codeBlockRegex = /```([^`]+)```/g; - const matches = [...responseText.matchAll(codeBlockRegex)]; + const matches = [...cleanedResponse.matchAll(codeBlockRegex)]; debugLog('[RPG Parser] Found', matches.length + ' code blocks'); @@ -63,21 +133,21 @@ export function parseResponse(responseText) { // Extract User Stats section const statsMatch = content.match(/(User )?Stats\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*(Info Box|Present Characters)|$)/i); if (statsMatch && !result.userStats) { - result.userStats = statsMatch[0].trim(); + result.userStats = stripBrackets(statsMatch[0].trim()); debugLog('[RPG Parser] ✓ Extracted Stats from combined block'); } // Extract Info Box section const infoBoxMatch = content.match(/Info Box\s*\n\s*---[\s\S]*?(?=\n\s*\n\s*Present Characters|$)/i); if (infoBoxMatch && !result.infoBox) { - result.infoBox = infoBoxMatch[0].trim(); + result.infoBox = stripBrackets(infoBoxMatch[0].trim()); debugLog('[RPG Parser] ✓ Extracted Info Box from combined block'); } // Extract Present Characters section const charactersMatch = content.match(/Present Characters\s*\n\s*---[\s\S]*$/i); if (charactersMatch && !result.characterThoughts) { - result.characterThoughts = charactersMatch[0].trim(); + result.characterThoughts = stripBrackets(charactersMatch[0].trim()); debugLog('[RPG Parser] ✓ Extracted Present Characters from combined block'); } } else { @@ -107,13 +177,13 @@ export function parseResponse(responseText) { (content.includes(" | ") && (content.includes("Thoughts") || content.includes("💭"))); if (isStats && !result.userStats) { - result.userStats = content; + result.userStats = stripBrackets(content); debugLog('[RPG Parser] ✓ Matched: Stats section'); } else if (isInfoBox && !result.infoBox) { - result.infoBox = content; + result.infoBox = stripBrackets(content); debugLog('[RPG Parser] ✓ Matched: Info Box section'); } else if (isCharacters && !result.characterThoughts) { - result.characterThoughts = content; + result.characterThoughts = stripBrackets(content); debugLog('[RPG Parser] ✓ Matched: Present Characters section'); debugLog('[RPG Parser] Full content:', content); } else { @@ -169,18 +239,36 @@ export function parseUserStats(statsText) { // Format 2: Status: [Emoji], [Conditions] // Format 3: [Emoji]: [Conditions] (legacy) // Format 4: Mood: [Emoji] - [Conditions] + // Format 5: Status: [Emoji Conditions] (no separator - FIXED) let moodMatch = null; - // Try new format: Status: emoji, conditions - const statusMatch = statsText.match(/Status:\s*(.+?),\s*(.+)/i); + // Try new format: Status: emoji, conditions OR Status: emojiConditions + const statusMatch = statsText.match(/Status:\s*(.+)/i); if (statusMatch) { - moodMatch = [null, statusMatch[1].trim(), statusMatch[2].trim()]; + const statusContent = statusMatch[1].trim(); + const { emoji, text } = separateEmojiFromText(statusContent); + if (emoji && text) { + moodMatch = [null, emoji, text]; + } else if (statusContent.includes(',')) { + // Fallback to comma split if emoji detection failed + const parts = statusContent.split(',').map(p => p.trim()); + moodMatch = [null, parts[0], parts.slice(1).join(', ')]; + } } - // Try alternative: Mood: emoji, conditions - else { - const moodAltMatch = statsText.match(/Mood:\s*(.+?)[,\-]\s*(.+)/i); + + // Try alternative: Mood: emoji, conditions OR Mood: emojiConditions + if (!moodMatch) { + const moodAltMatch = statsText.match(/Mood:\s*(.+)/i); if (moodAltMatch) { - moodMatch = [null, moodAltMatch[1].trim(), moodAltMatch[2].trim()]; + const moodContent = moodAltMatch[1].trim(); + const { emoji, text } = separateEmojiFromText(moodContent); + if (emoji && text) { + moodMatch = [null, emoji, text]; + } else if (moodContent.includes(',') || moodContent.includes('-')) { + // Fallback to comma/dash split if emoji detection failed + const parts = moodContent.split(/[,\-]/).map(p => p.trim()); + moodMatch = [null, parts[0], parts.slice(1).join(', ')]; + } } } @@ -245,6 +333,28 @@ export function parseUserStats(statsText) { extensionSettings.userStats.conditions = moodMatch[2].trim(); // Conditions } + // Extract quests + const mainQuestMatch = statsText.match(/Main Quests?:\s*(.+)/i); + if (mainQuestMatch) { + extensionSettings.quests.main = mainQuestMatch[1].trim(); + debugLog('[RPG Parser] Main quests extracted:', mainQuestMatch[1].trim()); + } + + const optionalQuestsMatch = statsText.match(/Optional Quests:\s*(.+)/i); + if (optionalQuestsMatch) { + const questsText = optionalQuestsMatch[1].trim(); + if (questsText && questsText !== 'None') { + // Split by comma and clean up + extensionSettings.quests.optional = questsText + .split(',') + .map(q => q.trim()) + .filter(q => q && q !== 'None'); + } else { + extensionSettings.quests.optional = []; + } + debugLog('[RPG Parser] Optional quests extracted:', extensionSettings.quests.optional); + } + debugLog('[RPG Parser] Final userStats after parsing:', { health: extensionSettings.userStats.health, satiety: extensionSettings.userStats.satiety, diff --git a/src/systems/generation/promptBuilder.js b/src/systems/generation/promptBuilder.js index 40ed4ce..2392776 100644 --- a/src/systems/generation/promptBuilder.js +++ b/src/systems/generation/promptBuilder.js @@ -104,14 +104,23 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon // Add format specifications for each enabled tracker if (extensionSettings.showUserStats) { + // Get custom stat names with fallback defaults + const statNames = extensionSettings.statNames || { + health: 'Health', + satiety: 'Satiety', + energy: 'Energy', + hygiene: 'Hygiene', + arousal: 'Arousal' + }; + 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 += `- ${statNames.health}: X%\n`; + instructions += `- ${statNames.satiety}: X%\n`; + instructions += `- ${statNames.energy}: X%\n`; + instructions += `- ${statNames.hygiene}: X%\n`; + instructions += `- ${statNames.arousal}: X%\n`; instructions += 'Status: [Mood Emoji, Conditions (up to three traits)]\n'; // Add inventory format based on feature flag @@ -122,9 +131,13 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon 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 += 'Inventory: [Clothing/Armor, Inventory Items (list of important items, or "None")]\\n'; } + // Add quests section + instructions += 'Main Quests: [Short title of the currently active main quest (for example, "Save the world"), or "None"]\n'; + instructions += 'Optional Quests: [Short titles of the currently active optional quests (for example, "Find Zandik\'s book"), or "None"]\n'; + instructions += '```\n\n'; } @@ -137,6 +150,7 @@ export function generateTrackerInstructions(includeHtmlPrompt = true, includeCon instructions += 'Temperature: [Temperature in °C]\n'; instructions += 'Time: [Time Start → Time End]\n'; instructions += 'Location: [Location]\n'; + instructions += 'Recent Events: [Up to three past events leading to the ongoing scene (short descriptors with no details, for example, "last-night date with Mary")]\n'; instructions += '```\n\n'; } @@ -208,9 +222,23 @@ export function generateContextualSummary() { if (stats.inventory) { const inventorySummary = buildInventorySummary(stats.inventory); if (inventorySummary && inventorySummary !== 'None') { - summary += `Inventory:\n${inventorySummary}\n`; + summary += `${inventorySummary}\n`; } } + + // Add quests summary + if (extensionSettings.quests) { + if (extensionSettings.quests.main && extensionSettings.quests.main !== 'None') { + summary += `Main Quests: ${extensionSettings.quests.main}\n`; + } + if (extensionSettings.quests.optional && extensionSettings.quests.optional.length > 0) { + const optionalQuests = extensionSettings.quests.optional.filter(q => q && q !== 'None').join(', '); + if (optionalQuests) { + summary += `Optional Quests: ${optionalQuests}\n`; + } + } + } + // Include classic stats (attributes) and dice roll only if there was a dice roll if (extensionSettings.lastDiceRoll) { const classicStats = extensionSettings.classicStats; @@ -224,7 +252,7 @@ export function generateContextualSummary() { if (extensionSettings.showInfoBox && committedTrackerData.infoBox) { // Parse info box data - support both new and legacy formats const lines = committedTrackerData.infoBox.split('\n'); - let date = '', weather = '', temp = '', time = '', location = ''; + let date = '', weather = '', temp = '', time = '', location = '', recentEvents = ''; // console.log('[RPG Companion] 🔍 Parsing Info Box lines:', lines); @@ -242,6 +270,8 @@ export function generateContextualSummary() { time = line.replace('Time:', '').trim(); } else if (line.startsWith('Location:')) { location = line.replace('Location:', '').trim(); + } else if (line.startsWith('Recent Events:')) { + recentEvents = line.replace('Recent Events:', '').trim(); } // Legacy format with emojis (for backward compatibility) else if (line.includes('🗓️:')) { @@ -264,7 +294,7 @@ export function generateContextualSummary() { // console.log('[RPG Companion] 🔍 Parsed values - date:', date, 'weather:', weather, 'temp:', temp, 'time:', time, 'location:', location); - if (date || weather || temp || time || location) { + if (date || weather || temp || time || location || recentEvents) { summary += `Information:\n`; summary += `Scene: `; if (date) summary += `${date}`; @@ -272,7 +302,9 @@ export function generateContextualSummary() { if (time) summary += ` | ${time}`; if (weather) summary += ` | ${weather}`; if (temp) summary += ` | ${temp}`; - summary += `\n\n`; + summary += `\n`; + if (recentEvents) summary += `Recent Events: ${recentEvents}\n`; + summary += `\n`; } } @@ -317,6 +349,22 @@ export function generateRPGPromptText() { } else { promptText += `Last ${userName}'s Stats:\nNone - this is the first update.\n\n`; } + + // Add current quests to the previous data context + if (extensionSettings.quests) { + if (extensionSettings.quests.main && extensionSettings.quests.main !== 'None') { + promptText += `Main Quests: ${extensionSettings.quests.main}\n`; + } else { + promptText += `Main Quests: None\n`; + } + if (extensionSettings.quests.optional && extensionSettings.quests.optional.length > 0) { + const optionalQuests = extensionSettings.quests.optional.filter(q => q && q !== 'None').join(', '); + promptText += `Optional Quests: ${optionalQuests || 'None'}\n`; + } else { + promptText += `Optional Quests: None\n`; + } + promptText += `\n`; + } } if (extensionSettings.showInfoBox) { diff --git a/src/systems/integration/sillytavern.js b/src/systems/integration/sillytavern.js index 3b7b0a8..53ac984 100644 --- a/src/systems/integration/sillytavern.js +++ b/src/systems/integration/sillytavern.js @@ -30,6 +30,7 @@ import { renderUserStats } from '../rendering/userStats.js'; import { renderInfoBox } from '../rendering/infoBox.js'; import { renderThoughts, updateChatThoughts } from '../rendering/thoughts.js'; import { renderInventory } from '../rendering/inventory.js'; +import { renderQuests } from '../rendering/quests.js'; // Dashboard import { refreshDashboard } from '../dashboard/dashboardIntegration.js'; @@ -177,6 +178,7 @@ export async function onMessageReceived(data) { renderInfoBox(); renderThoughts(); renderInventory(); + renderQuests(); // Refresh dashboard widgets (v2 dashboard) refreshDashboard(); @@ -237,6 +239,7 @@ export function onCharacterChanged() { renderInfoBox(); renderThoughts(); renderInventory(); + renderQuests(); // Refresh dashboard widgets (v2 dashboard) refreshDashboard(); @@ -311,6 +314,7 @@ export function onMessageSwiped(messageIndex) { renderInfoBox(); renderThoughts(); renderInventory(); + renderQuests(); // Update chat thought overlays updateChatThoughts(); diff --git a/src/systems/interaction/inventoryActions.js b/src/systems/interaction/inventoryActions.js index 4742070..6c50ee3 100644 --- a/src/systems/interaction/inventoryActions.js +++ b/src/systems/interaction/inventoryActions.js @@ -6,6 +6,7 @@ import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js'; import { saveSettings, saveChatData, updateMessageSwipeData } from '../../core/persistence.js'; import { buildInventorySummary } from '../generation/promptBuilder.js'; +import { buildUserStatsText } from '../rendering/userStats.js'; import { renderInventory, getLocationId } from '../rendering/inventory.js'; import { parseItems, serializeItems } from '../../utils/itemParser.js'; import { sanitizeLocationName, sanitizeItemName } from '../../utils/security.js'; @@ -42,18 +43,8 @@ let openForms = { * This ensures manual edits are immediately visible to AI in next generation. */ function updateLastGeneratedDataInventory() { - const stats = extensionSettings.userStats; - const inventorySummary = buildInventorySummary(stats.inventory); - - // Rebuild the userStats text format - const statsText = - `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}`; + // Rebuild the userStats text format using custom stat names + const statsText = buildUserStatsText(); // Update BOTH lastGeneratedData AND committedTrackerData // This makes manual edits immediately visible to AI diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js index 1243840..c42d4c9 100644 --- a/src/systems/rendering/infoBox.js +++ b/src/systems/rendering/infoBox.js @@ -12,6 +12,44 @@ import { } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; +/** + * Helper to separate emoji from text in a string + * Handles cases where there's no comma or space after emoji + * @param {string} str - String potentially containing emoji followed by text + * @returns {{emoji: string, text: string}} Separated emoji and text + */ +function separateEmojiFromText(str) { + if (!str) return { emoji: '', text: '' }; + + str = str.trim(); + + // Regex to match emoji at the start (handles most emoji including compound ones) + const emojiRegex = /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F000}-\u{1F02F}\u{1F0A0}-\u{1F0FF}\u{1F100}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F910}-\u{1F96B}\u{1F980}-\u{1F9E0}\u{FE00}-\u{FE0F}\u{200D}\u{20E3}]+/u; + const emojiMatch = str.match(emojiRegex); + + if (emojiMatch) { + const emoji = emojiMatch[0]; + let text = str.substring(emoji.length).trim(); + + // Remove leading comma or space if present + text = text.replace(/^[,\s]+/, ''); + + return { emoji, text }; + } + + // No emoji found - check if there's a comma separator anyway + const commaParts = str.split(','); + if (commaParts.length >= 2) { + return { + emoji: commaParts[0].trim(), + text: commaParts.slice(1).join(',').trim() + }; + } + + // No clear separation - return original as text + return { emoji: '', text: str }; +} + /** * Renders the info box as a visual dashboard with calendar, weather, temperature, clock, and map widgets. * Includes event listeners for editable fields. @@ -26,8 +64,11 @@ export function renderInfoBox() { $infoBoxContainer.addClass('rpg-content-updating'); } + // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) + const infoBoxData = lastGeneratedData.infoBox || committedTrackerData.infoBox; + // If no data yet, show placeholder - if (!lastGeneratedData.infoBox) { + if (!infoBoxData) { const placeholderHtml = `
@@ -43,10 +84,10 @@ export function renderInfoBox() { return; } - // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox); + // console.log('[RPG Companion] renderInfoBox called with data:', infoBoxData); // Parse the info box data - const lines = lastGeneratedData.infoBox.split('\n'); + const lines = infoBoxData.split('\n'); // console.log('[RPG Companion] Info Box split into lines:', lines); const data = { date: '', @@ -155,11 +196,24 @@ export function renderInfoBox() { } } else if (line.startsWith('Weather:')) { if (!parsedFields.weather) { - // New text format: Weather: [Emoji], [Forecast] + // New text format: Weather: [Emoji], [Forecast] OR Weather: [Emoji][Forecast] (no separator - FIXED) const weatherStr = line.replace('Weather:', '').trim(); - const weatherParts = weatherStr.split(',').map(p => p.trim()); - data.weatherEmoji = weatherParts[0] || ''; - data.weatherForecast = weatherParts[1] || ''; + const { emoji, text } = separateEmojiFromText(weatherStr); + + if (emoji && text) { + data.weatherEmoji = emoji; + data.weatherForecast = text; + } else if (weatherStr.includes(',')) { + // Fallback to comma split if emoji detection failed + const weatherParts = weatherStr.split(',').map(p => p.trim()); + data.weatherEmoji = weatherParts[0] || ''; + data.weatherForecast = weatherParts[1] || ''; + } else { + // No clear separation - assume it's all forecast text + data.weatherEmoji = '🌤️'; // Default emoji + data.weatherForecast = weatherStr; + } + parsedFields.weather = true; } } else { @@ -217,8 +271,11 @@ export function renderInfoBox() { // }); // Build visual dashboard HTML + // Wrap all content in a scrollable container + let html = '
'; + // Row 1: Date, Weather, Temperature, Time widgets - let html = '
'; + html += '
'; // Calendar widget - always show (editable even if empty) // Display abbreviated version but allow editing full value @@ -301,6 +358,67 @@ export function renderInfoBox() {
`; + // Row 3: Recent Events widget (notebook style) - dynamically show 1-3 events + // Parse Recent Events from infoBox string + let recentEvents = []; + if (committedTrackerData.infoBox) { + const recentEventsLine = committedTrackerData.infoBox.split('\n').find(line => line.startsWith('Recent Events:')); + if (recentEventsLine) { + const eventsString = recentEventsLine.replace('Recent Events:', '').trim(); + if (eventsString) { + recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e); + } + } + } + + const validEvents = recentEvents.filter(e => e && e.trim() && e !== 'Event 1' && e !== 'Event 2' && e !== 'Event 3'); + + // If no valid events, show at least one placeholder + if (validEvents.length === 0) { + validEvents.push('Click to add event'); + } + + html += ` +
+
+
+
+
+
+
+
Recent Events
+
+ `; + + // Dynamically generate event lines (max 3) + for (let i = 0; i < Math.min(validEvents.length, 3); i++) { + html += ` +
+ + ${validEvents[i]} +
+ `; + } + + // If we have less than 3 events, add empty placeholders with + icon + for (let i = validEvents.length; i < 3; i++) { + html += ` +
+ + + Add event... +
+ `; + } + + html += ` +
+
+
+ `; + + // Close the scrollable content wrapper + html += '
'; + $infoBoxContainer.html(html); // Add event handlers for editable Info Box fields @@ -320,7 +438,12 @@ export function renderInfoBox() { } } - updateInfoBoxField(field, value); + // Handle recent events separately + if (field === 'event1' || field === 'event2' || field === 'event3') { + updateRecentEvent(field, value); + } else { + updateInfoBoxField(field, value); + } }); // For date fields, show full value on focus @@ -610,3 +733,84 @@ export function updateInfoBoxField(field, value) { renderInfoBox(); } } + +/** + * Update a recent event in the committed tracker data + * @param {string} field - event1, event2, or event3 + * @param {string} value - New event text + */ +function updateRecentEvent(field, value) { + // Map field to index + const eventIndex = { + 'event1': 0, + 'event2': 1, + 'event3': 2 + }[field]; + + if (eventIndex !== undefined) { + // Parse current infoBox to get existing events + const lines = (committedTrackerData.infoBox || '').split('\n'); + let recentEvents = []; + + // Find existing Recent Events line + const recentEventsLine = lines.find(line => line.startsWith('Recent Events:')); + if (recentEventsLine) { + const eventsString = recentEventsLine.replace('Recent Events:', '').trim(); + if (eventsString) { + recentEvents = eventsString.split(',').map(e => e.trim()).filter(e => e); + } + } + + // Ensure array has enough slots + while (recentEvents.length <= eventIndex) { + recentEvents.push(''); + } + + // Update the specific event + recentEvents[eventIndex] = value; + + // Filter out empty events and rebuild the line + const validEvents = recentEvents.filter(e => e && e.trim()); + const newRecentEventsLine = validEvents.length > 0 + ? `Recent Events: ${validEvents.join(', ')}` + : ''; + + // Update infoBox with new Recent Events line + const updatedLines = lines.filter(line => !line.startsWith('Recent Events:')); + if (newRecentEventsLine) { + // Add Recent Events line 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, newRecentEventsLine); + } + + committedTrackerData.infoBox = updatedLines.join('\n'); + 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'); + } + } + break; + } + } + } + + saveChatData(); + renderInfoBox(); + console.log(`[RPG Companion] Updated recent event ${field}:`, value); + } +} diff --git a/src/systems/rendering/quests.js b/src/systems/rendering/quests.js new file mode 100644 index 0000000..efaa8da --- /dev/null +++ b/src/systems/rendering/quests.js @@ -0,0 +1,306 @@ +/** + * Quests Rendering Module + * Handles UI rendering for quests system (main and optional quests) + */ + +import { extensionSettings, $questsContainer } from '../../core/state.js'; +import { saveSettings } from '../../core/persistence.js'; + +/** + * HTML escape helper + * @param {string} text - Text to escape + * @returns {string} Escaped HTML + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Renders the quests sub-tab navigation (Main, Optional) + * @param {string} activeTab - Currently active sub-tab ('main', 'optional') + * @returns {string} HTML for sub-tab navigation + */ +export function renderQuestsSubTabs(activeTab = 'main') { + return ` +
+ + +
+ `; +} + +/** + * Renders the main quest view + * @param {string} mainQuest - Current main quest title + * @returns {string} HTML for main quest view + */ +export function renderMainQuestView(mainQuest) { + const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : ''; + const hasQuest = questDisplay.length > 0; + + return ` +
+
+

Main Quests

+ ${!hasQuest ? `` : ''} +
+
+ ${hasQuest ? ` + +
+
${escapeHtml(questDisplay)}
+
+ + +
+
+ ` : ` + +
No active main quests
+ `} +
+
+ + The main quests represent your primary objective in the story. +
+
+ `; +} + +/** + * Renders the optional quests view + * @param {string[]} optionalQuests - Array of optional quest titles + * @returns {string} HTML for optional quests view + */ +export function renderOptionalQuestsView(optionalQuests) { + const quests = optionalQuests.filter(q => q && q !== 'None'); + + let questsHtml = ''; + if (quests.length === 0) { + questsHtml = '
No active optional quests
'; + } else { + questsHtml = quests.map((quest, index) => ` +
+
${escapeHtml(quest)}
+
+ +
+
+ `).join(''); + } + + return ` +
+
+

Optional Quests

+ +
+
+ +
+ ${questsHtml} +
+
+ + Optional quests are side objectives that complement your main story. +
+
+
+ `; +} + +/** + * Main render function for quests + */ +export function renderQuests() { + if (!extensionSettings.showInventory || !$questsContainer) { + return; + } + + // Get current sub-tab from container or default to 'main' + const activeSubTab = $questsContainer.data('active-subtab') || 'main'; + + // Get quests data + const mainQuest = extensionSettings.quests.main || 'None'; + const optionalQuests = extensionSettings.quests.optional || []; + + // Build HTML + let html = '
'; + html += renderQuestsSubTabs(activeSubTab); + + // Render active sub-tab + html += '
'; + if (activeSubTab === 'main') { + html += renderMainQuestView(mainQuest); + } else { + html += renderOptionalQuestsView(optionalQuests); + } + html += '
'; + + $questsContainer.html(html); + + // Attach event handlers + attachQuestEventHandlers(); +} + +/** + * Attach event handlers for quest interactions + */ +function attachQuestEventHandlers() { + // Sub-tab switching + $questsContainer.find('.rpg-quests-subtab').on('click', function() { + const tab = $(this).data('tab'); + $questsContainer.data('active-subtab', tab); + renderQuests(); + }); + + // Add quest button + $questsContainer.find('[data-action="add-quest"]').on('click', function() { + const field = $(this).data('field'); + $(`#rpg-add-quest-form-${field}`).show(); + $(`#rpg-new-quest-${field}`).focus(); + }); + + // Cancel add quest + $questsContainer.find('[data-action="cancel-add-quest"]').on('click', function() { + const field = $(this).data('field'); + $(`#rpg-add-quest-form-${field}`).hide(); + $(`#rpg-new-quest-${field}`).val(''); + }); + + // Save add quest + $questsContainer.find('[data-action="save-add-quest"]').on('click', function() { + const field = $(this).data('field'); + const input = $(`#rpg-new-quest-${field}`); + const questTitle = input.val().trim(); + + if (questTitle) { + if (field === 'main') { + extensionSettings.quests.main = questTitle; + } else { + if (!extensionSettings.quests.optional) { + extensionSettings.quests.optional = []; + } + extensionSettings.quests.optional.push(questTitle); + } + saveSettings(); + renderQuests(); + } + }); + + // Edit quest (main only) + $questsContainer.find('[data-action="edit-quest"]').on('click', function() { + const field = $(this).data('field'); + $(`#rpg-edit-quest-form-${field}`).show(); + $('.rpg-quest-item[data-field="main"]').hide(); + $(`#rpg-edit-quest-${field}`).focus(); + }); + + // Cancel edit quest + $questsContainer.find('[data-action="cancel-edit-quest"]').on('click', function() { + const field = $(this).data('field'); + $(`#rpg-edit-quest-form-${field}`).hide(); + $('.rpg-quest-item[data-field="main"]').show(); + }); + + // Save edit quest + $questsContainer.find('[data-action="save-edit-quest"]').on('click', function() { + const field = $(this).data('field'); + const input = $(`#rpg-edit-quest-${field}`); + const questTitle = input.val().trim(); + + if (questTitle) { + extensionSettings.quests.main = questTitle; + saveSettings(); + renderQuests(); + } + }); + + // Remove quest + $questsContainer.find('[data-action="remove-quest"]').on('click', function() { + const field = $(this).data('field'); + const index = $(this).data('index'); + + if (field === 'main') { + extensionSettings.quests.main = 'None'; + } else { + extensionSettings.quests.optional.splice(index, 1); + } + saveSettings(); + renderQuests(); + }); + + // Inline editing for optional quests + $questsContainer.find('.rpg-quest-title.rpg-editable').on('blur', function() { + const $this = $(this); + const field = $this.data('field'); + const index = $this.data('index'); + const newTitle = $this.text().trim(); + + if (newTitle && field === 'optional' && index !== undefined) { + extensionSettings.quests.optional[index] = newTitle; + saveSettings(); + } + }); + + // Enter key to save in forms + $questsContainer.find('.rpg-inline-input').on('keypress', function(e) { + if (e.which === 13) { + const field = $(this).attr('id').includes('edit') ? + $(this).attr('id').replace('rpg-edit-quest-', '') : + $(this).attr('id').replace('rpg-new-quest-', ''); + + if ($(this).attr('id').includes('edit')) { + $(`[data-action="save-edit-quest"][data-field="${field}"]`).click(); + } else { + $(`[data-action="save-add-quest"][data-field="${field}"]`).click(); + } + } + }); +} diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js index 02f073c..a4c0d89 100644 --- a/src/systems/rendering/thoughts.js +++ b/src/systems/rendering/thoughts.js @@ -76,15 +76,13 @@ export function renderThoughts() { $thoughtsContainer.addClass('rpg-content-updating'); } - // Initialize if no data yet - if (!lastGeneratedData.characterThoughts) { - lastGeneratedData.characterThoughts = ''; - } + // Use committedTrackerData as fallback if lastGeneratedData is empty (e.g., after page refresh) + const characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || ''; - debugLog('[RPG Thoughts] Raw characterThoughts data:', lastGeneratedData.characterThoughts); - debugLog('[RPG Thoughts] Data length:', lastGeneratedData.characterThoughts.length + ' chars'); + debugLog('[RPG Thoughts] Raw characterThoughts data:', characterThoughtsData); + debugLog('[RPG Thoughts] Data length:', characterThoughtsData.length + ' chars'); - const lines = lastGeneratedData.characterThoughts.split('\n'); + const lines = characterThoughtsData.split('\n'); const presentCharacters = []; debugLog('[RPG Thoughts] Split into lines count:', lines.length); @@ -378,8 +376,14 @@ export function updateCharacterField(characterName, field, value) { if (emojiMatch) { let emoji = emojiMatch[1].trim(); let info = emojiMatch[2].trim(); - let relationship = parts[1]; - let thoughts = parts[2] || ''; + let relationship = parts[1] ? parts[1].trim() : ''; + let thoughts = parts[2] ? parts[2].trim() : ''; + + // Handle 4-part format (with demeanor) + if (parts.length >= 4) { + relationship = parts[2] ? parts[2].trim() : ''; + thoughts = parts[3] ? parts[3].trim() : ''; + } const infoParts = info.split(',').map(p => p.trim()); let name = infoParts[0]; diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js index eed3789..b51299f 100644 --- a/src/systems/rendering/userStats.js +++ b/src/systems/rendering/userStats.js @@ -20,9 +20,28 @@ import { import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { buildInventorySummary } from '../generation/promptBuilder.js'; +/** + * Builds the user stats text string using custom stat names + * @returns {string} Formatted stats text for tracker + */ +export function buildUserStatsText() { + const stats = extensionSettings.userStats; + const statNames = extensionSettings.statNames || { + health: 'Health', + satiety: 'Satiety', + energy: 'Energy', + hygiene: 'Hygiene', + arousal: 'Arousal' + }; + const inventorySummary = buildInventorySummary(stats.inventory); + + return `${statNames.health}: ${stats.health}%\n${statNames.satiety}: ${stats.satiety}%\n${statNames.energy}: ${stats.energy}%\n${statNames.hygiene}: ${stats.hygiene}%\n${statNames.arousal}: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\n${inventorySummary}`; +} + /** * 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) { @@ -30,11 +49,18 @@ export function renderUserStats() { } const stats = extensionSettings.userStats; + const statNames = extensionSettings.statNames || { + health: 'Health', + satiety: 'Satiety', + energy: 'Energy', + hygiene: 'Hygiene', + arousal: 'Arousal' + }; 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}`; + lastGeneratedData.userStats = buildUserStatsText(); } // Get user portrait - handle both default-user and custom persona folders @@ -64,7 +90,7 @@ export function renderUserStats() {
- Health: + ${statNames.health}:
@@ -72,7 +98,7 @@ export function renderUserStats() {
- Satiety: + ${statNames.satiety}:
@@ -80,7 +106,7 @@ export function renderUserStats() {
- Energy: + ${statNames.energy}:
@@ -88,7 +114,7 @@ export function renderUserStats() {
- Hygiene: + ${statNames.hygiene}:
@@ -96,7 +122,7 @@ export function renderUserStats() {
- Arousal: + ${statNames.arousal}:
@@ -184,10 +210,8 @@ export function renderUserStats() { // Update the setting extensionSettings.userStats[field] = value; - // Rebuild userStats text with proper inventory format - const stats = extensionSettings.userStats; - const inventorySummary = buildInventorySummary(stats.inventory); - const statsText = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\n${inventorySummary}`; + // Rebuild userStats text with custom stat names + const statsText = buildUserStatsText(); // Update BOTH lastGeneratedData AND committedTrackerData // This makes manual edits immediately visible to AI @@ -207,10 +231,8 @@ export function renderUserStats() { const value = $(this).text().trim(); extensionSettings.userStats.mood = value || '😐'; - // Rebuild userStats text with proper inventory format - const stats = extensionSettings.userStats; - const inventorySummary = buildInventorySummary(stats.inventory); - const statsText = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\n${inventorySummary}`; + // Rebuild userStats text with custom stat names + const statsText = buildUserStatsText(); // Update BOTH lastGeneratedData AND committedTrackerData // This makes manual edits immediately visible to AI @@ -226,10 +248,8 @@ export function renderUserStats() { const value = $(this).text().trim(); extensionSettings.userStats.conditions = value || 'None'; - // Rebuild userStats text with proper inventory format - const stats = extensionSettings.userStats; - const inventorySummary = buildInventorySummary(stats.inventory); - const statsText = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\n${inventorySummary}`; + // Rebuild userStats text with custom stat names + const statsText = buildUserStatsText(); // Update BOTH lastGeneratedData AND committedTrackerData // This makes manual edits immediately visible to AI @@ -241,6 +261,30 @@ export function renderUserStats() { updateMessageSwipeData(); }); + // Add event listeners for stat name editing + $('.rpg-editable-stat-name').on('blur', function() { + const field = $(this).data('field'); + const value = $(this).text().trim().replace(':', ''); + + if (!extensionSettings.statNames) { + extensionSettings.statNames = { + health: 'Health', + satiety: 'Satiety', + energy: 'Energy', + hygiene: 'Hygiene', + arousal: 'Arousal' + }; + } + + extensionSettings.statNames[field] = value || extensionSettings.statNames[field]; + + saveSettings(); + saveChatData(); + + // Re-render to update the display + renderUserStats(); + }); + // Add event listener for level editing $('.rpg-level-value.rpg-editable').on('blur', function() { let value = parseInt($(this).text().trim()); diff --git a/src/systems/ui/desktop.js b/src/systems/ui/desktop.js index 2b8e2a4..b7e07e2 100644 --- a/src/systems/ui/desktop.js +++ b/src/systems/ui/desktop.js @@ -22,9 +22,10 @@ export function setupDesktopTabs() { const $infoBox = $('#rpg-info-box'); const $thoughts = $('#rpg-thoughts'); const $inventory = $('#rpg-inventory'); + const $quests = $('#rpg-quests'); // If no sections exist, nothing to organize - if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0) { + if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) { return; } @@ -39,12 +40,17 @@ export function setupDesktopTabs() { Inventory +
`); // Create tab content containers const $statusTab = $('
'); const $inventoryTab = $('
'); + const $questsTab = $('
'); // Move sections into their respective tabs (detach to preserve event handlers) if ($userStats.length > 0) { @@ -63,6 +69,10 @@ export function setupDesktopTabs() { $inventoryTab.append($inventory.detach()); $inventory.show(); } + if ($quests.length > 0) { + $questsTab.append($quests.detach()); + $quests.show(); + } // Hide dividers on desktop tabs (tabs separate content naturally) $('.rpg-divider').hide(); @@ -72,6 +82,7 @@ export function setupDesktopTabs() { $tabsContainer.append($tabNav); $tabsContainer.append($statusTab); $tabsContainer.append($inventoryTab); + $tabsContainer.append($questsTab); // Replace content box with tabs container $contentBox.html('').append($tabsContainer); @@ -102,6 +113,7 @@ export function removeDesktopTabs() { const $infoBox = $('#rpg-info-box').detach(); const $thoughts = $('#rpg-thoughts').detach(); const $inventory = $('#rpg-inventory').detach(); + const $quests = $('#rpg-quests').detach(); // Remove tabs container $('.rpg-tabs-container').remove(); @@ -114,18 +126,20 @@ export function removeDesktopTabs() { // 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 + // Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests if ($dividerStats.length) { $dividerStats.before($userStats); $dividerInfo.before($infoBox); $dividerThoughts.before($thoughts); $contentBox.append($inventory); + $contentBox.append($quests); } else { // Fallback if dividers don't exist $contentBox.append($userStats); $contentBox.append($infoBox); $contentBox.append($thoughts); $contentBox.append($inventory); + $contentBox.append($quests); } // Show sections and dividers diff --git a/src/systems/ui/layout.js b/src/systems/ui/layout.js index 597183c..9993154 100644 --- a/src/systems/ui/layout.js +++ b/src/systems/ui/layout.js @@ -266,15 +266,11 @@ export function applyPanelPosition() { * Updates the UI based on generation mode selection. */ export function updateGenerationModeUI() { - const $mobileBtn = $('#rpg-manual-update-mobile'); - if (extensionSettings.generationMode === 'together') { - // In "together" mode, hide both desktop and mobile refresh buttons + // In "together" mode, manual update button is hidden $('#rpg-manual-update').hide(); - $mobileBtn.addClass('rpg-hidden-mode'); } else { - // In "separate" mode, show both desktop and mobile refresh buttons + // In "separate" mode, manual update button is visible $('#rpg-manual-update').show(); - $mobileBtn.removeClass('rpg-hidden-mode'); } } diff --git a/src/systems/ui/mobile.js b/src/systems/ui/mobile.js index 817e62a..263e5e5 100644 --- a/src/systems/ui/mobile.js +++ b/src/systems/ui/mobile.js @@ -527,9 +527,10 @@ export function setupMobileTabs() { const $infoBox = $('#rpg-info-box'); const $thoughts = $('#rpg-thoughts'); const $inventory = $('#rpg-inventory'); + const $quests = $('#rpg-quests'); // If no sections exist, nothing to organize - if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0) { + if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) { return; } @@ -538,6 +539,7 @@ export function setupMobileTabs() { const hasStats = $userStats.length > 0; const hasInfo = $infoBox.length > 0 || $thoughts.length > 0; const hasInventory = $inventory.length > 0; + const hasQuests = $quests.length > 0; // Tab 1: Stats (User Stats only) if (hasStats) { @@ -551,6 +553,10 @@ export function setupMobileTabs() { if (hasInventory) { tabs.push(''); } + // Tab 4: Quests + if (hasQuests) { + tabs.push(''); + } const $tabNav = $('
' + tabs.join('') + '
'); @@ -559,11 +565,13 @@ export function setupMobileTabs() { if (hasStats) firstTab = 'stats'; else if (hasInfo) firstTab = 'info'; else if (hasInventory) firstTab = 'inventory'; + else if (hasQuests) firstTab = 'quests'; // Create tab content wrappers const $statsTab = $('
'); const $infoTab = $('
'); const $inventoryTab = $('
'); + const $questsTab = $('
'); // Move sections into their respective tabs (detach to preserve event handlers) // Stats tab: User Stats only @@ -588,6 +596,12 @@ export function setupMobileTabs() { $inventory.show(); } + // Quests tab: Quests only + if ($quests.length > 0) { + $questsTab.append($quests.detach()); + $quests.show(); + } + // Hide dividers on mobile $('.rpg-divider').hide(); @@ -599,6 +613,8 @@ export function setupMobileTabs() { if (hasStats) $mobileContainer.append($statsTab); if (hasInfo) $mobileContainer.append($infoTab); if (hasInventory) $mobileContainer.append($inventoryTab); + if (hasQuests) $mobileContainer.append($questsTab); + if (hasInventory) $mobileContainer.append($inventoryTab); // Insert mobile tab structure at the beginning of content box $contentBox.prepend($mobileContainer); @@ -626,6 +642,7 @@ export function removeMobileTabs() { const $infoBox = $('#rpg-info-box').detach(); const $thoughts = $('#rpg-thoughts').detach(); const $inventory = $('#rpg-inventory').detach(); + const $quests = $('#rpg-quests').detach(); // Remove mobile tab container $('.rpg-mobile-container').remove(); @@ -638,14 +655,16 @@ export function removeMobileTabs() { // 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 + // Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests if ($dividerStats.length) { $dividerStats.before($userStats); $dividerInfo.before($infoBox); $dividerThoughts.before($thoughts); $contentBox.append($inventory); + $contentBox.append($quests); } else { // Fallback if dividers don't exist + $contentBox.prepend($quests); $contentBox.prepend($inventory); $contentBox.prepend($thoughts); $contentBox.prepend($infoBox); diff --git a/src/systems/ui/modals.js b/src/systems/ui/modals.js index 1e43847..0191ef7 100644 --- a/src/systems/ui/modals.js +++ b/src/systems/ui/modals.js @@ -16,6 +16,7 @@ import { import { saveSettings, saveChatData } from '../../core/persistence.js'; import { renderUserStats } from '../rendering/userStats.js'; import { updateChatThoughts } from '../rendering/thoughts.js'; +import { renderQuests } from '../rendering/quests.js'; import { rollDice as rollDiceCore, clearDiceRoll as clearDiceRollCore, @@ -409,6 +410,12 @@ export function setupSettingsPopup() { // Clear dice roll extensionSettings.lastDiceRoll = null; + // Clear quests + extensionSettings.quests = { + main: "None", + optional: [] + }; + // Save everything saveChatData(); saveSettings(); @@ -417,6 +424,7 @@ export function setupSettingsPopup() { renderUserStats(); updateDiceDisplayCore(); updateChatThoughts(); // Clear the thought bubble in chat + renderQuests(); // Clear and re-render quests UI // console.log('[RPG Companion] Chat cache cleared'); }); diff --git a/style.css b/style.css index edf9d0d..ab0365b 100644 --- a/style.css +++ b/style.css @@ -576,8 +576,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-user-portrait { - width: clamp(1.7vw, 1.8vw, 1.9vw); - height: clamp(1.7vw, 1.8vw, 1.9vw); + width: clamp(24px, 4vh, 32px); + height: clamp(24px, 4vh, 32px); border-radius: 50%; border: 2px solid var(--rpg-highlight); box-shadow: 0 0 8px var(--rpg-highlight); @@ -726,7 +726,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* User name and level - inline with portrait */ .rpg-user-name { font-weight: 600; - font-size: 0.7vw; + font-size: 1em; color: var(--rpg-text-color); white-space: nowrap; overflow: hidden; @@ -734,14 +734,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-level-label { - font-size: 0.7vw; + font-size: 1em; font-weight: 600; color: var(--rpg-text-color); opacity: 0.7; } .rpg-level-value { - font-size: 0.7vw; + font-size: 1em; font-weight: 700; color: var(--rpg-highlight-color); padding: clamp(1px, 0.2vh, 2px) 0.375em; @@ -2088,17 +2088,45 @@ body:has(.rpg-panel.rpg-position-left) #sheld { border-radius: 0.75em; padding: 0.375em; margin-bottom: 0; - flex: 0 0 auto; + flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.25em; align-items: stretch; width: 100%; + overflow: hidden; +} + +/* Scrollable content wrapper inside info section */ +.rpg-info-content { + display: flex; + flex-direction: column; + gap: 0.25em; + flex: 1; + min-height: 0; overflow-y: auto; overflow-x: hidden; } +.rpg-info-content::-webkit-scrollbar { + width: 0.188rem; +} + +.rpg-info-content::-webkit-scrollbar-track { + background: var(--rpg-bg); + border-radius: 2px; +} + +.rpg-info-content::-webkit-scrollbar-thumb { + background: var(--rpg-highlight); + border-radius: 2px; +} + +.rpg-info-content::-webkit-scrollbar-thumb:hover { + background: var(--rpg-text); +} + .rpg-dashboard { display: flex; gap: 0.25em; @@ -2422,6 +2450,161 @@ body:has(.rpg-panel.rpg-position-left) #sheld { flex-shrink: 0; } +/* Row 3: Recent Events */ +.rpg-dashboard-row-3 { + flex: 0 0 auto; + min-height: 0; +} + +.rpg-dashboard-row-3 .rpg-dashboard-widget { + flex: 1; + width: 100%; +} + +/* Recent Events Widget - Notebook Style */ +.rpg-events-widget { + background: linear-gradient(to bottom, + rgba(255, 248, 220, 0.08) 0%, + rgba(255, 248, 220, 0.12) 50%, + rgba(255, 248, 220, 0.08) 100%); + border: 2px solid var(--rpg-border); + border-radius: 0.375em; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + transition: transform 0.2s ease, box-shadow 0.2s ease; + min-height: 0; + overflow: visible; + position: relative; +} + +/* Notebook paper lines effect */ +.rpg-events-widget::before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-image: repeating-linear-gradient( + transparent, + transparent calc(1em + 0.5em), + rgba(var(--SmartThemeBorderColor), 0.08) calc(1em + 0.5em), + rgba(var(--SmartThemeBorderColor), 0.08) calc(1em + 0.6em) + ); + pointer-events: none; + z-index: 0; +} + +.rpg-events-widget:hover { + transform: translateY(-0.125rem); + box-shadow: 0 4px 12px var(--rpg-shadow); +} + +/* Notebook ring binding at top */ +.rpg-notebook-header { + display: flex; + justify-content: center; + gap: 1.5em; + padding: 0.25em 0; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--rpg-border); + position: relative; + z-index: 1; +} + +.rpg-notebook-ring { + width: 0.5em; + height: 0.5em; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, + rgba(80, 80, 80, 0.4), + rgba(40, 40, 40, 0.6)); + border: 1px solid rgba(0, 0, 0, 0.5); + box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.2), + 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.rpg-notebook-title { + font-size: clamp(0.5vw, 0.6vw, 0.7vw); + font-weight: bold; + color: var(--rpg-highlight); + text-align: center; + padding: 0.25em 0.5em 0.25em 0.5em; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.9; + position: relative; + z-index: 1; +} + +.rpg-notebook-lines { + display: flex; + flex-direction: column; + gap: 0.125em; + padding: 0.25em 0.75em 0.5em 0.75em; + position: relative; + z-index: 1; +} + +.rpg-notebook-line { + display: flex; + align-items: flex-start; + gap: 0.375em; + position: relative; +} + +.rpg-notebook-line.rpg-event-add { + opacity: 0.5; +} + +.rpg-notebook-line.rpg-event-add:hover { + opacity: 0.8; +} + +.rpg-bullet { + font-size: clamp(0.5vw, 0.6vw, 0.7vw); + color: var(--rpg-highlight); + flex-shrink: 0; + line-height: 1.4; + opacity: 0.8; +} + +.rpg-event-add .rpg-bullet { + color: var(--rpg-text); + opacity: 0.6; +} + +.rpg-event-text { + font-size: clamp(0.45vw, 0.55vw, 0.65vw); + color: var(--rpg-text); + line-height: 1.4; + flex: 1; + word-wrap: break-word; + cursor: text; + padding: 0.125em 0.25em; + border-radius: 0.2em; + transition: background 0.2s ease; + opacity: 0.85; +} + +.rpg-event-text.rpg-event-placeholder { + opacity: 0.5; + font-style: italic; +} + +.rpg-event-text:hover { + background: rgba(255, 255, 255, 0.05); + opacity: 1; +} + +.rpg-event-text:focus { + background: rgba(255, 255, 255, 0.1); + outline: 1px solid var(--rpg-highlight); + opacity: 1; +} + /* Character Status Cards */ .rpg-character-status { display: flex; @@ -2776,7 +2959,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { /* Editable field styles */ .rpg-editable, -.rpg-editable-stat { +.rpg-editable-stat, +.rpg-editable-stat-name { cursor: text; transition: all 0.2s ease; border-radius: 2px; @@ -2784,13 +2968,15 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } .rpg-editable:hover, -.rpg-editable-stat:hover { +.rpg-editable-stat:hover, +.rpg-editable-stat-name:hover { background: var(--rpg-accent); outline: 1px solid var(--rpg-highlight); } .rpg-editable:focus, -.rpg-editable-stat:focus { +.rpg-editable-stat:focus, +.rpg-editable-stat-name:focus { background: var(--rpg-bg); outline: 2px solid var(--rpg-highlight); box-shadow: 0 0 8px var(--rpg-highlight); @@ -3117,6 +3303,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transform: translateY(0); } +/* Reset FAB Positions Button - Similar to clear cache but different color */ .rpg-btn-reset-fab { width: 100%; padding: 0.625em; @@ -3145,6 +3332,276 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transform: translateY(0); } +/* ============================================ + MEMORY RECOLLECTION STYLES + ============================================ */ + +/* Memory Recollection Button */ +.rpg-memory-recollection-btn { + width: 100%; + padding: 0.75em 1em; + margin-bottom: 10px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: 2px solid rgba(102, 126, 234, 0.5); + border-radius: 0.5em; + color: #ffffff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5em; + transition: all 0.3s ease; +} + +.rpg-memory-recollection-btn:hover { + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + border-color: rgba(102, 126, 234, 0.8); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +.rpg-memory-recollection-btn:active { + transform: translateY(0); +} + +/* Modal Overlay */ +.rpg-memory-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(5px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Modal Container */ +.rpg-memory-modal { + background: var(--SmartThemeBlurTintColor, #1a1a2e); + border: 2px solid var(--SmartThemeBorderColor, #667eea); + border-radius: 12px; + max-width: 500px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + animation: modalSlideIn 0.3s ease; +} + +@keyframes modalSlideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Modal Header */ +.rpg-memory-modal-header { + padding: 1.25em; + border-bottom: 1px solid var(--SmartThemeBorderColor, #667eea); + background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); +} + +.rpg-memory-modal-header h3 { + margin: 0; + color: var(--SmartThemeBodyColor, #eaeaea); + font-size: 1.25em; + font-weight: 600; +} + +/* Modal Body */ +.rpg-memory-modal-body { + padding: 1.5em; + color: var(--SmartThemeBodyColor, #eaeaea); +} + +.rpg-memory-modal-body p { + margin: 0.75em 0; + line-height: 1.6; +} + +.rpg-memory-modal-info { + background: rgba(102, 126, 234, 0.1); + padding: 1em; + border-radius: 8px; + border-left: 4px solid #667eea; + margin-top: 1em; +} + +.rpg-memory-modal-hint { + font-size: 0.85em; + color: #999; +} + +/* Progress Elements */ +.rpg-memory-progress-text { + text-align: center; + font-weight: 600; + margin-bottom: 1em; +} + +.rpg-memory-progress-bar { + width: 100%; + height: 30px; + background: rgba(0, 0, 0, 0.3); + border-radius: 15px; + overflow: hidden; + position: relative; +} + +.rpg-memory-progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 600; + font-size: 0.85em; +} + +.rpg-memory-status { + margin-top: 1em; + padding: 0.75em; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + font-size: 0.9em; + color: #999; + max-height: 100px; + overflow-y: auto; +} + +/* Modal Footer */ +.rpg-memory-modal-footer { + padding: 1em 1.25em; + border-top: 1px solid var(--SmartThemeBorderColor, #667eea); + display: flex; + gap: 0.75em; + justify-content: flex-end; +} + +/* Modal Buttons */ +.rpg-memory-modal-btn { + padding: 0.625em 1.25em; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + font-size: 14px; +} + +.rpg-memory-cancel { + background: rgba(220, 53, 69, 0.2); + color: #ff6b6b; + border: 2px solid rgba(220, 53, 69, 0.5); +} + +.rpg-memory-cancel:hover { + background: rgba(220, 53, 69, 0.3); + border-color: rgba(220, 53, 69, 0.8); +} + +.rpg-memory-proceed { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: 2px solid rgba(102, 126, 234, 0.5); +} + +.rpg-memory-proceed:hover { + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + border-color: rgba(102, 126, 234, 0.8); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} + +/* ============================================ + LOREBOOK LIMITER STYLING + ============================================ */ + +.rpg-lorebook-limiter-container { + width: 100%; + padding: 0.75em 1em; + margin-bottom: 10px; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); + border: 2px solid rgba(102, 126, 234, 0.3); + border-radius: 0.5em; + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.rpg-lorebook-limiter-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1em; + cursor: pointer; +} + +.rpg-lorebook-limiter-title { + color: rgba(102, 126, 234, 1); + font-weight: 600; + font-size: 14px; +} + +.rpg-lorebook-limiter-input { + width: 120px; + padding: 0.5em; + background: rgba(0, 0, 0, 0.3); + border: 2px solid rgba(102, 126, 234, 0.4); + border-radius: 0.3em; + color: #ffffff; + font-size: 14px; + font-weight: 600; + text-align: center; + transition: all 0.3s ease; +} + +.rpg-lorebook-limiter-input:focus { + outline: none; + border-color: rgba(102, 126, 234, 0.8); + background: rgba(0, 0, 0, 0.5); + box-shadow: 0 0 8px rgba(102, 126, 234, 0.3); +} + +.rpg-lorebook-limiter-input::placeholder { + color: rgba(255, 255, 255, 0.4); + font-weight: 400; +} + +.rpg-lorebook-limiter-hint { + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + font-style: italic; +} + +.rpg-memory-close { + background: rgba(52, 152, 219, 0.2); + color: #5dade2; + border: 2px solid rgba(52, 152, 219, 0.5); +} + +.rpg-memory-close:hover { + background: rgba(52, 152, 219, 0.3); + border-color: rgba(52, 152, 219, 0.8); +} + /* ============================================ THEME VARIATIONS ============================================ */ @@ -3309,6 +3766,215 @@ body:has(.rpg-panel.rpg-position-left) #sheld { ); } +/* ============================================ + THEME SUPPORT FOR ALL PANEL ELEMENTS + ============================================ */ + +/* Apply theme colors to tabs navigation */ +.rpg-panel[data-theme="sci-fi"] .rpg-tabs-nav, +.rpg-panel[data-theme="fantasy"] .rpg-tabs-nav, +.rpg-panel[data-theme="cyberpunk"] .rpg-tabs-nav { + border-bottom-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-tab-btn, +.rpg-panel[data-theme="fantasy"] .rpg-tab-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-tab-btn { + background: var(--rpg-accent); + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-tab-btn:hover, +.rpg-panel[data-theme="fantasy"] .rpg-tab-btn:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-tab-btn:hover { + background: var(--rpg-highlight); + border-color: var(--rpg-highlight); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-tab-btn.active, +.rpg-panel[data-theme="fantasy"] .rpg-tab-btn.active, +.rpg-panel[data-theme="cyberpunk"] .rpg-tab-btn.active { + background: var(--rpg-highlight); + border-color: var(--rpg-highlight); + color: var(--rpg-bg); +} + +/* Apply theme colors to inventory subtabs */ +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-subtabs, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-subtabs, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-subtabs { + border-bottom-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-subtab, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-subtab, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-subtab { + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-subtab:hover, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-subtab:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-subtab:hover { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-subtab.active, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-subtab.active, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-subtab.active { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +/* Apply theme colors to quests subtabs */ +.rpg-panel[data-theme="sci-fi"] .rpg-quests-subtabs, +.rpg-panel[data-theme="fantasy"] .rpg-quests-subtabs, +.rpg-panel[data-theme="cyberpunk"] .rpg-quests-subtabs { + border-bottom-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-quests-subtab, +.rpg-panel[data-theme="fantasy"] .rpg-quests-subtab, +.rpg-panel[data-theme="cyberpunk"] .rpg-quests-subtab { + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-quests-subtab:hover, +.rpg-panel[data-theme="fantasy"] .rpg-quests-subtab:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-quests-subtab:hover { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-quests-subtab.active, +.rpg-panel[data-theme="fantasy"] .rpg-quests-subtab.active, +.rpg-panel[data-theme="cyberpunk"] .rpg-quests-subtab.active { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +/* Apply theme colors to storage locations */ +.rpg-panel[data-theme="sci-fi"] .rpg-storage-location, +.rpg-panel[data-theme="fantasy"] .rpg-storage-location, +.rpg-panel[data-theme="cyberpunk"] .rpg-storage-location { + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-storage-header, +.rpg-panel[data-theme="fantasy"] .rpg-storage-header, +.rpg-panel[data-theme="cyberpunk"] .rpg-storage-header { + background: var(--rpg-highlight); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-storage-content, +.rpg-panel[data-theme="fantasy"] .rpg-storage-content, +.rpg-panel[data-theme="cyberpunk"] .rpg-storage-content { + background: var(--rpg-accent); +} + +/* Apply theme colors to buttons */ +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-edit-btn, +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-add-btn, +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-remove-btn, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-edit, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-remove, +.rpg-panel[data-theme="sci-fi"] .rpg-add-quest-btn, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-edit-btn, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-add-btn, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-remove-btn, +.rpg-panel[data-theme="fantasy"] .rpg-quest-edit, +.rpg-panel[data-theme="fantasy"] .rpg-quest-remove, +.rpg-panel[data-theme="fantasy"] .rpg-add-quest-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-edit-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-add-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-remove-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-edit, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-remove, +.rpg-panel[data-theme="cyberpunk"] .rpg-add-quest-btn { + background: var(--rpg-accent); + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-add-btn, +.rpg-panel[data-theme="sci-fi"] .rpg-add-quest-btn, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-add-btn, +.rpg-panel[data-theme="fantasy"] .rpg-add-quest-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-add-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-add-quest-btn { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +/* Apply theme colors to inventory/quest items */ +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-text, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-item, +.rpg-panel[data-theme="sci-fi"] .rpg-main-quest-display, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-text, +.rpg-panel[data-theme="fantasy"] .rpg-quest-item, +.rpg-panel[data-theme="fantasy"] .rpg-main-quest-display, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-text, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-item, +.rpg-panel[data-theme="cyberpunk"] .rpg-main-quest-display { + background: var(--rpg-accent); + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-quest-item:hover, +.rpg-panel[data-theme="fantasy"] .rpg-quest-item:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-item:hover { + border-color: var(--rpg-highlight); +} + +/* Apply theme colors to hints and empty states */ +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-hint, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-hint, +.rpg-panel[data-theme="sci-fi"] .rpg-inventory-empty, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-empty, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-hint, +.rpg-panel[data-theme="fantasy"] .rpg-quest-hint, +.rpg-panel[data-theme="fantasy"] .rpg-inventory-empty, +.rpg-panel[data-theme="fantasy"] .rpg-quest-empty, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-hint, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-hint, +.rpg-panel[data-theme="cyberpunk"] .rpg-inventory-empty, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-empty { + border-color: var(--rpg-highlight); + color: var(--rpg-text); +} + +/* Apply theme colors to input fields */ +.rpg-panel[data-theme="sci-fi"] .rpg-inline-input, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-edit-form input, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-edit-form textarea, +.rpg-panel[data-theme="fantasy"] .rpg-inline-input, +.rpg-panel[data-theme="fantasy"] .rpg-quest-edit-form input, +.rpg-panel[data-theme="fantasy"] .rpg-quest-edit-form textarea, +.rpg-panel[data-theme="cyberpunk"] .rpg-inline-input, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-edit-form input, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-edit-form textarea { + background: var(--rpg-bg); + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-inline-input:focus, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-edit-form input:focus, +.rpg-panel[data-theme="sci-fi"] .rpg-quest-edit-form textarea:focus, +.rpg-panel[data-theme="fantasy"] .rpg-inline-input:focus, +.rpg-panel[data-theme="fantasy"] .rpg-quest-edit-form input:focus, +.rpg-panel[data-theme="fantasy"] .rpg-quest-edit-form textarea:focus, +.rpg-panel[data-theme="cyberpunk"] .rpg-inline-input:focus, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-edit-form input:focus, +.rpg-panel[data-theme="cyberpunk"] .rpg-quest-edit-form textarea:focus { + border-color: var(--rpg-highlight); +} + /* ============================================ RESPONSIVE DESIGN ============================================ */ @@ -4381,66 +5047,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { transform: rotate(180deg); } -/* ============================================ - MOBILE REFRESH FAB BUTTON (Same pattern as mobile toggle) - ============================================ */ -.rpg-mobile-refresh { - display: none; - align-items: center; - justify-content: center; - position: fixed; - /* Position set by JavaScript based on saved settings */ - width: 44px; - height: 44px; - border-radius: 50%; - background: var(--SmartThemeBlurTintColor); - border: 2px solid var(--SmartThemeBorderColor); - color: var(--SmartThemeBodyColor); - font-size: 1.125rem; - cursor: grab; - z-index: 1001; /* Below mobile toggle (10002), above debug (1000) */ - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - transition: opacity 0.3s ease, transform 0.2s ease, top 0.3s ease, left 0.3s ease, right 0.3s ease, bottom 0.3s ease; - user-select: none; - -webkit-user-select: none; - will-change: top, left; - /* Hidden by default - shown when panel open AND separate mode */ - opacity: 0; - pointer-events: none; -} - -.rpg-mobile-refresh.dragging { - transition: none; - cursor: grabbing; -} - -.rpg-mobile-refresh:hover { - transform: scale(1.1); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); -} - -.rpg-mobile-refresh:active { - transform: scale(0.95); -} - -/* Spinning animation when refreshing */ -.rpg-mobile-refresh.spinning i { - animation: rpg-spin 0.8s linear infinite; -} - -.rpg-mobile-refresh i { - pointer-events: none; -} - -@keyframes rpg-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - /* Mobile overlay backdrop */ .rpg-mobile-overlay { display: none; @@ -5331,11 +5937,6 @@ body:has(.rpg-panel.rpg-position-left) #sheld { font-size: clamp(20px, 5.1vw, 26px) !important; } - /* Larger mobile refresh icon */ - .rpg-mobile-refresh { - font-size: clamp(20px, 5.1vw, 26px) !important; - } - /* ======================================== MOBILE SETTINGS POPUP ======================================== */ @@ -5367,10 +5968,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } /* Readable clear cache button */ - .rpg-btn-clear-cache { - font-size: clamp(13px, 3.3vw, 17px) !important; - } - + .rpg-btn-clear-cache, .rpg-btn-reset-fab { font-size: clamp(13px, 3.3vw, 17px) !important; } @@ -5446,6 +6044,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld { ======================================== */ /* Inventory Container */ +#rpg-inventory { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0.5rem; + font-size: 0.9rem; +} + .rpg-inventory-container { display: flex; flex-direction: column; @@ -5477,10 +6083,34 @@ body:has(.rpg-panel.rpg-position-left) #sheld { gap: 0.5rem; border-bottom: 2px solid var(--SmartThemeBorderColor); padding-bottom: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-inventory-subtabs::-webkit-scrollbar { + height: 6px; +} + +.rpg-inventory-subtabs::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-inventory-subtabs::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-inventory-subtabs::-webkit-scrollbar-thumb:hover { + background: var(--rpg-accent); } .rpg-inventory-subtab { flex: 1; + min-width: fit-content; + white-space: nowrap; padding: 0.5rem 1rem; background: transparent; border: 2px solid var(--SmartThemeBorderColor); @@ -5529,12 +6159,37 @@ body:has(.rpg-panel.rpg-position-left) #sheld { align-items: center; padding-bottom: 0.5rem; border-bottom: 1px solid var(--SmartThemeBorderColor); + gap: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-inventory-header::-webkit-scrollbar { + height: 6px; +} + +.rpg-inventory-header::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-inventory-header::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-inventory-header::-webkit-scrollbar-thumb:hover { + background: var(--rpg-accent); } .rpg-inventory-header h4 { margin: 0; font-size: 1.1rem; color: var(--SmartThemeBodyColor); + white-space: nowrap; + min-width: fit-content; } .rpg-inventory-content { @@ -5664,6 +6319,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { background: transparent; border-color: var(--rpg-highlight); color: var(--rpg-highlight); + white-space: nowrap; + min-width: fit-content; } .rpg-inventory-add-btn:hover { @@ -5932,11 +6589,14 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: flex; align-items: center; gap: 0.75rem; + flex-wrap: nowrap; + min-width: fit-content; } .rpg-view-toggle { display: flex; gap: 0.25rem; + flex-wrap: nowrap; } .rpg-view-btn { @@ -5946,6 +6606,8 @@ body:has(.rpg-panel.rpg-position-left) #sheld { border-radius: 0.25rem; color: var(--SmartThemeFastUISliderColColor); cursor: pointer; + white-space: nowrap; + min-width: fit-content; transition: all 0.2s ease; font-size: 0.9rem; display: flex; @@ -5984,11 +6646,35 @@ body:has(.rpg-panel.rpg-position-left) #sheld { gap: 0; background: var(--SmartThemeBlurTintColor); border-bottom: 2px solid var(--SmartThemeBorderColor); + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-tabs-nav::-webkit-scrollbar { + height: 6px; +} + +.rpg-tabs-nav::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-tabs-nav::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-tabs-nav::-webkit-scrollbar-thumb:hover { + background: var(--rpg-highlight); } /* Desktop tab button */ .rpg-tab-btn { flex: 1; + min-width: fit-content; + white-space: nowrap; padding: 0.75rem 1rem; display: flex; align-items: center; @@ -6089,238 +6775,305 @@ body:has(.rpg-panel.rpg-position-left) #sheld { } } -/* =================================================================== - Debug Panel Styles - Mobile-Friendly Debug Log Viewer - =================================================================== */ - /* ============================================ - DEBUG TOGGLE FAB BUTTON (Same pattern as mobile FABs) + QUESTS SYSTEM STYLING ============================================ */ -.rpg-debug-toggle { - display: none; /* Hidden by default, shown when debugMode is enabled */ - align-items: center; - justify-content: center; - position: fixed; - /* Position set by JavaScript based on saved settings */ - width: 44px; - height: 44px; - border-radius: 50%; - background: var(--SmartThemeBlurTintColor); - border: 2px solid var(--SmartThemeBorderColor); - color: var(--rpg-text, #ecf0f1); - font-size: 1.85vw; - cursor: grab; - z-index: 1000; /* Below refresh (1001) and mobile toggle (10002) */ - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); - transition: opacity 0.3s ease, transform 0.2s ease, top 0.3s ease, left 0.3s ease, right 0.3s ease, bottom 0.3s ease; - user-select: none; - -webkit-user-select: none; - will-change: top, left; -} -/* Disable transitions while actively dragging */ -.rpg-debug-toggle.dragging { - transition: none; - cursor: grabbing; -} - -.rpg-debug-toggle:hover { - transform: scale(1.1); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); -} - -.rpg-debug-toggle:active { - transform: scale(0.95); -} - -/* Debug panel */ -.rpg-debug-panel { - position: fixed; - bottom: 0; - left: 0; - right: 0; - height: 60vh; - background: var(--SmartThemeBlurTintColor, #1a1a1a); - border-top: 2px solid var(--SmartThemeBorderColor, #333); - z-index: 10002; +/* Quest Container */ +#rpg-quests { display: flex; flex-direction: column; - transform: translateY(100%); - transition: transform 0.3s ease; - box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); + gap: 1rem; + padding: 0.5rem; + font-size: 0.9rem; } -.rpg-debug-panel.rpg-debug-open { - transform: translateY(0); +/* Quest Sub-tabs Navigation (Main / Optional) */ +.rpg-quests-subtabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--SmartThemeBorderColor); + padding-bottom: 0.5rem; } -.rpg-debug-header { +.rpg-quests-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; + text-align: center; +} + +.rpg-quests-subtab:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-quests-subtab.active { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); + font-weight: 600; +} + +/* Quest Sections */ +.rpg-quest-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rpg-quest-header { display: flex; justify-content: space-between; align-items: center; - padding: 1rem; - border-bottom: 1px solid var(--SmartThemeBorderColor, #333); - background: var(--SmartThemeBodyColor, #0d0d0d); + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--SmartThemeBorderColor); } -.rpg-debug-header h3 { +.rpg-quest-header h4 { margin: 0; - font-size: 1.2rem; - color: var(--rpg-text, #ecf0f1); + font-size: 1.1rem; + color: var(--SmartThemeBodyColor); } -.rpg-debug-actions { +.rpg-quest-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Main Quest Display */ +.rpg-main-quest-display { + padding: 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 1rem; + font-weight: 500; +} + +.rpg-main-quest-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +/* Optional Quests List */ +.rpg-quest-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rpg-quest-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + transition: all 0.2s ease; +} + +.rpg-quest-item:hover { + border-color: var(--rpg-highlight); + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.05); +} + +.rpg-quest-title { + flex: 1; + color: var(--SmartThemeBodyColor); + line-height: 1.5; + word-wrap: break-word; +} + +.rpg-quest-actions { display: flex; gap: 0.5rem; } -.rpg-debug-actions button { - background: var(--SmartThemeBlurTintColor, #2a2a2a); - border: 1px solid var(--SmartThemeBorderColor, #444); - color: var(--rpg-text, #ecf0f1); - width: 36px; - height: 36px; - border-radius: 6px; +/* Quest Buttons */ +.rpg-quest-edit, +.rpg-quest-remove, +.rpg-add-quest-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; - justify-content: center; - transition: all 0.2s ease; + gap: 0.35rem; } -.rpg-debug-actions button:hover { - background: var(--SmartThemeBorderColor, #333); - transform: scale(1.05); +.rpg-quest-edit:hover { + background: var(--ac-style-color-matchedText); + border-color: var(--ac-style-color-matchedText); + color: white; } -.rpg-debug-actions button:active { - transform: scale(0.95); +.rpg-quest-remove:hover { + background: #e74c3c; + border-color: #e74c3c; + color: white; } -.rpg-debug-actions button i { - pointer-events: none; /* Prevent icon from blocking clicks */ +.rpg-add-quest-btn { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); } -.rpg-debug-logs { - flex: 1; - overflow-y: auto; - padding: 1rem; - font-family: 'Courier New', Courier, monospace; - font-size: 0.85rem; - line-height: 1.4; - color: var(--rpg-text, #ecf0f1); +.rpg-add-quest-btn:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); } -.rpg-debug-empty { - text-align: center; +/* Quest Empty State */ +.rpg-quest-empty { padding: 2rem; - color: #888; + text-align: center; + color: var(--SmartThemeFastUISliderColColor); font-style: italic; } -.rpg-debug-entry { - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); +/* Quest Hint */ +.rpg-quest-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-debug-entry:last-child { - border-bottom: none; +.rpg-quest-hint i { + margin-top: 0.1rem; } -.rpg-debug-time { - color: #888; - font-size: 0.75rem; -} - -.rpg-debug-message { - color: #4fc3f7; -} - -.rpg-debug-data { - margin: 0.5rem 0 0 0; +/* Quest Inline Edit Form */ +.rpg-quest-edit-form { + display: flex; + flex-direction: column; + gap: 0.75rem; padding: 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; +} + +.rpg-quest-edit-form input, +.rpg-quest-edit-form textarea { + width: 100%; + padding: 0.5rem; background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - overflow-x: auto; - color: #9ccc65; - font-size: 0.8rem; - white-space: pre-wrap; - word-break: break-word; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-family: inherit; + font-size: 0.9rem; } -/* Mobile view - slide from right like main panel */ -@media (max-width: 1000px) { - .rpg-debug-panel { - /* Reset bottom slide positioning */ - transform: none; - transition: none; - bottom: auto; +.rpg-quest-edit-form input:focus, +.rpg-quest-edit-form textarea:focus { + outline: none; + border-color: var(--rpg-highlight); +} - /* Mobile panel - slide from right */ - position: fixed !important; - top: var(--topBarBlockSize) !important; - right: 0 !important; - left: auto !important; +.rpg-quest-edit-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} - /* Mobile sizing using dynamic viewport units */ - width: 85dvw !important; - max-width: 400px !important; - height: calc(100dvh - var(--topBarBlockSize)) !important; +.rpg-quest-save, +.rpg-quest-cancel { + padding: 0.5rem 1rem; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s ease; +} - /* Hidden by default */ - display: none !important; +.rpg-quest-save { + background: var(--rpg-highlight); + border-color: var(--rpg-highlight); + color: white; +} - /* Mobile scrolling */ - overflow-y: auto !important; - -webkit-overflow-scrolling: touch; +.rpg-quest-save:hover { + background: #ff6b81; + border-color: #ff6b81; +} - /* Styling */ - border-radius: 20px 0 0 0; - border-left: 1px solid var(--SmartThemeBorderColor); - border-top: 1px solid var(--SmartThemeBorderColor); - border-bottom: none; - backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2)); - box-shadow: -5px 0 20px rgba(0, 0, 0, 0.5); +.rpg-quest-cancel { + background: transparent; + color: var(--SmartThemeBodyColor); +} + +.rpg-quest-cancel:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* Mobile Responsive Styles */ +@media (max-width: 768px) { + .rpg-quests-subtabs { + flex-direction: column; + gap: 0.5rem; } - /* Show panel when opened with slide-in animation */ - .rpg-debug-panel.rpg-mobile-open { - display: flex !important; - z-index: 10002; - animation: rpgSlideInFromRight 0.3s ease-in-out; + .rpg-quests-subtab { + padding: 0.75rem; + font-size: 1rem; } - /* Closing animation - slide out to right */ - .rpg-debug-panel.rpg-mobile-closing { - display: flex !important; - z-index: 10002; - animation: rpgSlideOutToRight 0.3s ease-in-out; + .rpg-quest-header h4 { + font-size: 1rem; } - /* Debug logs container needs to stay scrollable */ - .rpg-debug-logs { - overflow-y: auto; - -webkit-overflow-scrolling: touch; + .rpg-quest-edit, + .rpg-quest-remove, + .rpg-add-quest-btn { + padding: 0.6rem 1rem; + font-size: 0.95rem; + min-height: 2.5rem; } - /* Debug toggle button on mobile */ - .rpg-debug-toggle { - font-size: clamp(20px, 5.1vw, 26px) !important; + .rpg-quest-item { + flex-direction: column; + align-items: flex-start; + } + + .rpg-quest-actions { + width: 100%; + justify-content: space-between; + } + + .rpg-main-quest-actions { + flex-direction: column; + } + + .rpg-main-quest-actions button { + width: 100%; } } -/* Desktop view - smaller panel in bottom right */ -@media (min-width: 1001px) { - .rpg-debug-panel { - bottom: 20px; - left: auto; - right: 20px; - width: 600px; - max-width: 90vw; - height: 400px; - border-radius: 12px; - border: 2px solid var(--SmartThemeBorderColor, #333); - } -} diff --git a/template.html b/template.html index 2d3bac8..014e796 100644 --- a/template.html +++ b/template.html @@ -52,6 +52,11 @@
+ + +
+ +
@@ -214,6 +219,12 @@ Number of recent messages to include (Separate mode only)
+
+ + + Number of messages to process per batch in Memory Recollection +
+