diff --git a/index.js b/index.js index 6345a62..3d7a0e2 100644 --- a/index.js +++ b/index.js @@ -48,48 +48,22 @@ import { parseResponse, parseUserStats } from './src/systems/generation/parser.j import { updateRPGData } from './src/systems/generation/apiClient.js'; import { onGenerationStarted } from './src/systems/generation/injector.js'; +// Rendering modules +import { getSafeThumbnailUrl } from './src/utils/avatars.js'; +import { renderUserStats } from './src/systems/rendering/userStats.js'; +import { renderInfoBox, updateInfoBoxField } from './src/systems/rendering/infoBox.js'; +import { + renderThoughts, + updateCharacterField, + updateChatThoughts, + createThoughtPanel +} from './src/systems/rendering/thoughts.js'; + // Old state variable declarations removed - now imported from core modules // (extensionSettings, lastGeneratedData, committedTrackerData, etc. are now in src/core/state.js) -/** - * Safely attempts to get a thumbnail URL with proper error handling. - * Returns null if the URL cannot be generated to avoid 400 Bad Request errors. - * - * @param {string} type - The type of thumbnail ('persona' or 'avatar') - * @param {string} filename - The filename to get thumbnail for - * @returns {string|null} - The thumbnail URL or null if it fails - */ -function getSafeThumbnailUrl(type, filename) { - // Return null if no filename provided - if (!filename || filename === 'none') { - console.log(`[RPG Companion] No valid filename provided for ${type} thumbnail`); - return null; - } - - try { - // Attempt to get thumbnail URL from SillyTavern API - const url = getThumbnailUrl(type, filename); - - // Validate that we got a string back - if (typeof url !== 'string' || url.trim() === '') { - console.warn(`[RPG Companion] getThumbnailUrl returned invalid result for ${type}:`, filename); - return null; - } - - console.log(`[RPG Companion] Successfully generated ${type} thumbnail URL for: ${filename}`); - return url; - } catch (error) { - // Log detailed error information for debugging - console.error(`[RPG Companion] Failed to get ${type} thumbnail for "${filename}":`, error); - console.error('[RPG Companion] Error details:', { - type, - filename, - errorMessage: error.message, - errorStack: error.stack - }); - return null; - } -} +// Utility functions removed - now imported from src/utils/avatars.js +// (getSafeThumbnailUrl) // Persistence functions removed - now imported from src/core/persistence.js // (loadSettings, saveSettings, saveChatData, loadChatData, updateMessageSwipeData) @@ -2070,1383 +2044,10 @@ function updateGenerationModeUI() { } } +// Rendering functions removed - now imported from src/systems/rendering/* +// (renderUserStats, renderInfoBox, renderThoughts, updateInfoBoxField, +// updateCharacterField, updateChatThoughts, createThoughtPanel) -/** - * Renders the user stats with fancy progress bars. - */ -/** - * Renders the user stats with fancy progress bars. - */ -function renderUserStats() { - if (!extensionSettings.showUserStats || !$userStatsContainer) { - return; - } - - const stats = extensionSettings.userStats; - const userName = getContext().name1; - - // Initialize lastGeneratedData.userStats if it doesn't exist - if (!lastGeneratedData.userStats) { - lastGeneratedData.userStats = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\nInventory: ${stats.inventory}`; - } - - // Get user portrait - handle both default-user and custom persona folders - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let userPortrait = FALLBACK_AVATAR_DATA_URI; - - if (user_avatar) { - // Try to get the thumbnail using our safe helper - const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar); - if (thumbnailUrl) { - userPortrait = thumbnailUrl; - } - } - - // Create gradient from low to high color - const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`; - - const html = ` -
-
-
- ${userName} -
-
- ${stats.inventory || 'None'} -
-
-
-
-
- Health: -
-
-
- ${stats.health}% -
- -
- Satiety: -
-
-
- ${stats.satiety}% -
- -
- Energy: -
-
-
- ${stats.energy}% -
- -
- Hygiene: -
-
-
- ${stats.hygiene}% -
- -
- Arousal: -
-
-
- ${stats.arousal}% -
-
- -
-
${stats.mood}
-
${stats.conditions}
-
-
- -
-
-
-
- STR -
- - ${extensionSettings.classicStats.str} - -
-
-
- DEX -
- - ${extensionSettings.classicStats.dex} - -
-
-
- CON -
- - ${extensionSettings.classicStats.con} - -
-
-
- INT -
- - ${extensionSettings.classicStats.int} - -
-
-
- WIS -
- - ${extensionSettings.classicStats.wis} - -
-
-
- CHA -
- - ${extensionSettings.classicStats.cha} - -
-
-
-
-
-
- `; - - $userStatsContainer.html(html); - - // Add event listeners for editable stat values - $('.rpg-editable-stat').on('blur', function() { - const field = $(this).data('field'); - const textValue = $(this).text().replace('%', '').trim(); - let value = parseInt(textValue); - - // Validate and clamp value between 0 and 100 - if (isNaN(value)) { - value = 0; - } - value = Math.max(0, Math.min(100, value)); - - // Update the setting - extensionSettings.userStats[field] = value; - - // Also update lastGeneratedData to keep it in sync - if (!lastGeneratedData.userStats) { - lastGeneratedData.userStats = ''; - } - // Regenerate the userStats text with updated value - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - - // Re-render to update the bar - renderUserStats(); - }); - - // Add event listener for inventory editing - $('.rpg-inventory-items.rpg-editable').on('blur', function() { - const value = $(this).text().trim(); - extensionSettings.userStats.inventory = value || 'None'; - - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }); - - // Add event listeners for mood/conditions editing - $('.rpg-mood-emoji.rpg-editable').on('blur', function() { - const value = $(this).text().trim(); - extensionSettings.userStats.mood = value || '😐'; - - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }); - - $('.rpg-mood-conditions.rpg-editable').on('blur', function() { - const value = $(this).text().trim(); - extensionSettings.userStats.conditions = value || 'None'; - - // Update lastGeneratedData - const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; - lastGeneratedData.userStats = statsText; - - saveSettings(); - saveChatData(); - updateMessageSwipeData(); - }); -} - -/** - * Renders the info box as a visual dashboard. - */ -function renderInfoBox() { - if (!extensionSettings.showInfoBox || !$infoBoxContainer) { - return; - } - - // Add updating class for animation - if (extensionSettings.enableAnimations) { - $infoBoxContainer.addClass('rpg-content-updating'); - } - - // If no data yet, show placeholder - if (!lastGeneratedData.infoBox) { - const placeholderHtml = ` -
-
-
No data yet
-
Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button
-
-
- `; - $infoBoxContainer.html(placeholderHtml); - if (extensionSettings.enableAnimations) { - setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); - } - return; - } - - // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox); - - // Parse the info box data - const lines = lastGeneratedData.infoBox.split('\n'); - // console.log('[RPG Companion] Info Box split into lines:', lines); - const data = { - date: '', - weekday: '', - month: '', - year: '', - weatherEmoji: '', - weatherForecast: '', - temperature: '', - tempValue: 0, - timeStart: '', - timeEnd: '', - location: '', - characters: [] - }; - - for (const line of lines) { - // console.log('[RPG Companion] Processing line:', line); - - if (line.includes('🗓️:')) { - // console.log('[RPG Companion] → Matched DATE'); - const dateStr = line.replace('🗓️:', '').trim(); - // Parse format: "Weekday, Month Day, Year" or "Weekday, Month, Year" - const dateParts = dateStr.split(',').map(p => p.trim()); - data.weekday = dateParts[0] || ''; - data.month = dateParts[1] || ''; - data.year = dateParts[2] || ''; - data.date = dateStr; - } else if (line.includes('🌡️:')) { - // console.log('[RPG Companion] → Matched TEMPERATURE'); - const tempStr = line.replace('🌡️:', '').trim(); - data.temperature = tempStr; - // Extract numeric value - const tempMatch = tempStr.match(/(-?\d+)/); - if (tempMatch) { - data.tempValue = parseInt(tempMatch[1]); - } - } else if (line.includes('🕒:')) { - // console.log('[RPG Companion] → Matched TIME'); - const timeStr = line.replace('🕒:', '').trim(); - data.time = timeStr; - // Parse "HH:MM → HH:MM" format - const timeParts = timeStr.split('→').map(t => t.trim()); - data.timeStart = timeParts[0] || ''; - data.timeEnd = timeParts[1] || ''; - } else if (line.includes('🗺️:')) { - // console.log('[RPG Companion] → Matched LOCATION'); - data.location = line.replace('🗺️:', '').trim(); - } else { - // Check if it's a weather line - // Since \p{Emoji} doesn't work reliably, use a simpler approach - const hasColon = line.includes(':'); - const notInfoBox = !line.includes('Info Box'); - const notDivider = !line.includes('---'); - const notCodeFence = !line.trim().startsWith('```'); - - // console.log('[RPG Companion] → Checking weather conditions:', { - // line: line, - // hasColon: hasColon, - // notInfoBox: notInfoBox, - // notDivider: notDivider - // }); - - if (hasColon && notInfoBox && notDivider && notCodeFence && line.trim().length > 0) { - // Match format: [Weather Emoji]: [Forecast] - // Capture everything before colon as emoji, everything after as forecast - // console.log('[RPG Companion] → Testing WEATHER match for:', line); - const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/); - if (weatherMatch) { - const potentialEmoji = weatherMatch[1].trim(); - const forecast = weatherMatch[2].trim(); - - // If the first part is short (likely emoji), treat as weather - if (potentialEmoji.length <= 5) { - data.weatherEmoji = potentialEmoji; - data.weatherForecast = forecast; - // console.log('[RPG Companion] ✓ Weather parsed:', data.weatherEmoji, data.weatherForecast); - } else { - // console.log('[RPG Companion] ✗ First part too long for emoji:', potentialEmoji); - } - } else { - // console.log('[RPG Companion] ✗ Weather regex did not match'); - } - } else { - // console.log('[RPG Companion] → No match for this line'); - } - } - } - - // console.log('[RPG Companion] Parsed Info Box data:', { - // date: data.date, - // weatherEmoji: data.weatherEmoji, - // weatherForecast: data.weatherForecast, - // temperature: data.temperature, - // timeStart: data.timeStart, - // location: data.location - // }); - - // Build visual dashboard HTML - // Row 1: Date, Weather, Temperature, Time widgets - let html = '
'; - - // Calendar widget - always show (editable even if empty) - const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON'; - const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY'; - const yearDisplay = data.year || 'YEAR'; - html += ` -
-
${monthShort}
-
${weekdayShort}
-
${yearDisplay}
-
- `; - - // Weather widget - always show (editable even if empty) - const weatherEmoji = data.weatherEmoji || '🌤️'; - const weatherForecast = data.weatherForecast || 'Weather'; - html += ` -
-
${weatherEmoji}
-
${weatherForecast}
-
- `; - - // Temperature widget - always show (editable even if empty) - const tempDisplay = data.temperature || '20°C'; - const tempValue = data.tempValue || 20; - const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100)); - const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560'; - html += ` -
-
-
-
-
-
-
-
${tempDisplay}
-
- `; - - // Time widget - always show (editable even if empty) - const timeDisplay = data.timeStart || '12:00'; - // Parse time for clock hands - const timeMatch = timeDisplay.match(/(\d+):(\d+)/); - let hourAngle = 0; - let minuteAngle = 0; - if (timeMatch) { - const hours = parseInt(timeMatch[1]); - const minutes = parseInt(timeMatch[2]); - hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute - minuteAngle = minutes * 6; // 6° per minute - } - html += ` -
-
-
-
-
-
-
-
-
${timeDisplay}
-
- `; - - html += '
'; - - // Row 2: Location widget (full width) - always show (editable even if empty) - const locationDisplay = data.location || 'Location'; - html += ` -
-
-
-
📍
-
-
${locationDisplay}
-
-
- `; - - $infoBoxContainer.html(html); - - // Add event handlers for editable Info Box fields - $infoBoxContainer.find('.rpg-editable').on('blur', function() { - const field = $(this).data('field'); - const value = $(this).text().trim(); - updateInfoBoxField(field, value); - }); - - // Remove updating class after animation - if (extensionSettings.enableAnimations) { - setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); - } -} - -/** - * Renders character thoughts (Present Characters). - */ -function renderThoughts() { - if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { - return; - } - - // Add updating class for animation - if (extensionSettings.enableAnimations) { - $thoughtsContainer.addClass('rpg-content-updating'); - } - - // Initialize if no data yet - if (!lastGeneratedData.characterThoughts) { - lastGeneratedData.characterThoughts = ''; - } - - const lines = lastGeneratedData.characterThoughts.split('\n'); - const presentCharacters = []; - - // console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts); - // console.log('[RPG Companion] Split into lines:', lines); - - // Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts] - for (const line of lines) { - // Skip empty lines, headers, dividers, and code fences - if (line.trim() && - !line.includes('Present Characters') && - !line.includes('---') && - !line.trim().startsWith('```')) { - - // Match the new format with pipes - const parts = line.split('|').map(p => p.trim()); - - if (parts.length >= 2) { - // First part: [Emoji]: [Name, Status, Demeanor] - const firstPart = parts[0].trim(); - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - const emoji = emojiMatch[1].trim(); - const info = emojiMatch[2].trim(); - const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover - const thoughts = parts[2] ? parts[2].trim() : ''; - - // Parse name from info (first part before comma) - const infoParts = info.split(',').map(p => p.trim()); - const name = infoParts[0] || ''; - const traits = infoParts.slice(1).join(', '); - - if (name && name.toLowerCase() !== 'unavailable') { - presentCharacters.push({ emoji, name, traits, relationship, thoughts }); - // console.log('[RPG Companion] Parsed character:', { name, relationship }); - } - } - } - } - } - - // Relationship status to emoji mapping - const relationshipEmojis = { - 'Enemy': '⚔️', - 'Neutral': '⚖️', - 'Friend': '⭐', - 'Lover': '❤️' - }; - - // Build HTML - let html = ''; - - // console.log('[RPG Companion] Total characters parsed:', presentCharacters.length); - // console.log('[RPG Companion] Characters array:', presentCharacters); - - // If no characters parsed, show a placeholder editable card - if (presentCharacters.length === 0) { - // Get default character portrait (try to use the current character if in 1-on-1 chat) - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let defaultPortrait = FALLBACK_AVATAR_DATA_URI; - let defaultName = 'Character'; - - if (this_chid !== undefined && characters[this_chid]) { - if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); - if (thumbnailUrl) { - defaultPortrait = thumbnailUrl; - } - } - defaultName = characters[this_chid].name || 'Character'; - } - - html += '
'; - html += ` -
-
- ${defaultName} -
⚖️
-
-
-
- 😊 - ${defaultName} -
-
Traits
-
-
- `; - html += '
'; - } else { - html += '
'; - for (const char of presentCharacters) { - // Find character portrait - // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors - let characterPortrait = FALLBACK_AVATAR_DATA_URI; - - // console.log('[RPG Companion] Looking for avatar for:', char.name); - - // For group chats, search through group members first - if (selected_group) { - const groupMembers = getGroupMembers(selected_group); - const matchingMember = groupMembers.find(member => - member && member.name && member.name.toLowerCase() === char.name.toLowerCase() - ); - - if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - } - } - } - - // For regular chats or if not found in group, search all characters - if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { - const matchingCharacter = characters.find(c => - c && c.name && c.name.toLowerCase() === char.name.toLowerCase() - ); - - if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { - const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - } - } - } - - // If this is the current character in a 1-on-1 chat, use their portrait - if (this_chid !== undefined && characters[this_chid] && - characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) { - const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); - if (thumbnailUrl) { - characterPortrait = thumbnailUrl; - } - } - - // Get relationship emoji - const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️'; - - html += ` -
-
- ${char.name} -
${relationshipEmoji}
-
-
-
- ${char.emoji} - ${char.name} -
-
${char.traits}
-
-
- `; - } - html += '
'; - } - - $thoughtsContainer.html(html); - - // Add event handlers for editable character fields - $thoughtsContainer.find('.rpg-editable').on('blur', function() { - const character = $(this).data('character'); - const field = $(this).data('field'); - const value = $(this).text().trim(); - updateCharacterField(character, field, value); - }); - - // Remove updating class after animation - if (extensionSettings.enableAnimations) { - setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); - } - - // Update chat overlay if enabled - if (extensionSettings.showThoughtsInChat) { - updateChatThoughts(); - } -} - -/** - * Updates a specific field in the Info Box data and re-renders. - */ -function updateInfoBoxField(field, value) { - if (!lastGeneratedData.infoBox) { - // Initialize with empty info box if it doesn't exist - lastGeneratedData.infoBox = 'Info Box\n---\n'; - } - - // Reconstruct the Info Box text with updated field - const lines = lastGeneratedData.infoBox.split('\n'); - let dateLineFound = false; - let dateLineIndex = -1; - - // Find the date line - for (let i = 0; i < lines.length; i++) { - if (lines[i].includes('🗓️:')) { - dateLineFound = true; - dateLineIndex = i; - break; - } - } - - const updatedLines = lines.map((line, index) => { - if (field === 'month' && line.includes('🗓️:')) { - const parts = line.split(','); - if (parts.length >= 2) { - // parts[0] = "🗓️: Weekday", parts[1] = " Month", parts[2] = " Year" - parts[1] = ' ' + value; - return parts.join(','); - } else if (parts.length === 1) { - // No existing month/year, add them - return `${parts[0]}, ${value}, YEAR`; - } - } else if (field === 'weekday' && line.includes('🗓️:')) { - const parts = line.split(','); - // Keep the emoji, just update the weekday - const month = parts[1] ? parts[1].trim() : 'Month'; - const year = parts[2] ? parts[2].trim() : 'YEAR'; - return `🗓️: ${value}, ${month}, ${year}`; - } else if (field === 'year' && line.includes('🗓️:')) { - const parts = line.split(','); - if (parts.length >= 3) { - parts[2] = ' ' + value; - return parts.join(','); - } else if (parts.length === 2) { - // No existing year, add it - return `${parts[0]}, ${parts[1]}, ${value}`; - } else if (parts.length === 1) { - // No existing month/year, add them - return `${parts[0]}, Month, ${value}`; - } - } else if (field === 'weatherEmoji' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { - // This is the weather line - const parts = line.split(':'); - if (parts.length >= 2) { - return `${value}: ${parts.slice(1).join(':').trim()}`; - } - } else if (field === 'weatherForecast' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { - // This is the weather line - const parts = line.split(':'); - if (parts.length >= 2) { - return `${parts[0].trim()}: ${value}`; - } - } else if (field === 'temperature' && line.includes('🌡️:')) { - return `🌡️: ${value}`; - } else if (field === 'timeStart' && line.includes('🕒:')) { - // Update time format: "HH:MM → HH:MM" - // When user edits, set both start and end time to the new value - return `🕒: ${value} → ${value}`; - } else if (field === 'location' && line.includes('🗺️:')) { - return `🗺️: ${value}`; - } - return line; - }); - - // If editing a date field but no date line exists, create one after the divider - if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) { - // Find the divider line - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - // Create initial date line with the edited field - let newDateLine = ''; - if (field === 'weekday') { - newDateLine = `🗓️: ${value}, Month, YEAR`; - } else if (field === 'month') { - newDateLine = `🗓️: Weekday, ${value}, YEAR`; - } else if (field === 'year') { - newDateLine = `🗓️: Weekday, Month, ${value}`; - } - // Insert after the divider - updatedLines.splice(dividerIndex + 1, 0, newDateLine); - } - } - - // If editing weather but no weather line exists, create one - if ((field === 'weatherEmoji' || field === 'weatherForecast')) { - let weatherLineFound = false; - for (const line of updatedLines) { - // Check if this is a weather line (has emoji and forecast, not one of the special fields) - if (line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { - weatherLineFound = true; - break; - } - } - - if (!weatherLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - let newWeatherLine = ''; - if (field === 'weatherEmoji') { - newWeatherLine = `${value}: Weather`; - } else if (field === 'weatherForecast') { - newWeatherLine = `🌤️: ${value}`; - } - // Insert after date line if it exists, otherwise after divider - const dateIndex = updatedLines.findIndex(line => line.includes('🗓️:')); - const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1; - updatedLines.splice(insertIndex, 0, newWeatherLine); - } - } - } - - // If editing temperature but no temperature line exists, create one - if (field === 'temperature') { - const tempLineFound = updatedLines.some(line => line.includes('🌡️:')); - if (!tempLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - const newTempLine = `🌡️: ${value}`; - // Find last non-empty line before creating position - let insertIndex = dividerIndex + 1; - for (let i = 0; i < updatedLines.length; i++) { - if (updatedLines[i].includes('🗓️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { - insertIndex = i + 1; - } - } - updatedLines.splice(insertIndex, 0, newTempLine); - } - } - } - - // If editing time but no time line exists, create one - if (field === 'timeStart') { - const timeLineFound = updatedLines.some(line => line.includes('🕒:')); - if (!timeLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - const newTimeLine = `🕒: ${value} → ${value}`; - // Find last non-empty line before creating position - let insertIndex = dividerIndex + 1; - for (let i = 0; i < updatedLines.length; i++) { - if (updatedLines[i].includes('🗓️:') || updatedLines[i].includes('🌡️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { - insertIndex = i + 1; - } - } - updatedLines.splice(insertIndex, 0, newTimeLine); - } - } - } - - // If editing location but no location line exists, create one - if (field === 'location') { - const locationLineFound = updatedLines.some(line => line.includes('🗺️:')); - if (!locationLineFound) { - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - const newLocationLine = `🗺️: ${value}`; - // Insert at the end (before any empty lines) - let insertIndex = updatedLines.length; - for (let i = updatedLines.length - 1; i >= 0; i--) { - if (updatedLines[i].trim() !== '') { - insertIndex = i + 1; - break; - } - } - updatedLines.splice(insertIndex, 0, newLocationLine); - } - } - } - - lastGeneratedData.infoBox = updatedLines.join('\n'); - - // Update the message's swipe data - const chat = getContext().chat; - if (chat && chat.length > 0) { - for (let i = chat.length - 1; i >= 0; i--) { - const message = chat[i]; - if (!message.is_user) { - if (message.extra && message.extra.rpg_companion_swipes) { - const swipeId = message.swipe_id || 0; - if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n'); - // console.log('[RPG Companion] Updated infoBox in message swipe data'); - } - } - break; - } - } - } - - saveChatData(); - renderInfoBox(); -} - -/** - * Updates a specific character field in Present Characters data and re-renders. - */ -function updateCharacterField(characterName, field, value) { - // console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value); - // console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - - // Initialize if it doesn't exist - if (!lastGeneratedData.characterThoughts) { - lastGeneratedData.characterThoughts = 'Present Characters\n---\n'; - } - - const lines = lastGeneratedData.characterThoughts.split('\n'); - let characterFound = false; - - const updatedLines = lines.map(line => { - // Case-insensitive character name matching - if (line.toLowerCase().includes(characterName.toLowerCase())) { - characterFound = true; - const parts = line.split('|').map(p => p.trim()); - if (parts.length >= 2) { - const firstPart = parts[0]; - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - let emoji = emojiMatch[1].trim(); - let info = emojiMatch[2].trim(); - let relationship = parts[1]; - let thoughts = parts[2] || ''; - - const infoParts = info.split(',').map(p => p.trim()); - let name = infoParts[0]; - let traits = infoParts.slice(1).join(', '); - - if (field === 'emoji') { - emoji = value; - } else if (field === 'name') { - name = value; - } else if (field === 'traits') { - traits = value; - } else if (field === 'thoughts') { - thoughts = value; - } else if (field === 'relationship') { - const emojiToRelationship = { - '⚔️': 'Enemy', - '⚖️': 'Neutral', - '⭐': 'Friend', - '❤️': 'Lover' - }; - relationship = emojiToRelationship[value] || value; - } - - const newInfo = traits ? `${name}, ${traits}` : name; - return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`; - } - } - } - return line; - }); - - // If character wasn't found, create a new character line - if (!characterFound) { - // Find the divider line - const dividerIndex = updatedLines.findIndex(line => line.includes('---')); - if (dividerIndex >= 0) { - // Create initial character line with the edited field - let emoji = '😊'; - let name = characterName; - let traits = 'Traits'; - let relationship = 'Neutral'; - let thoughts = ''; - - // Apply the edited field - if (field === 'emoji') { - emoji = value; - } else if (field === 'name') { - name = value; - } else if (field === 'traits') { - traits = value; - } else if (field === 'thoughts') { - thoughts = value; - } else if (field === 'relationship') { - const emojiToRelationship = { - '⚔️': 'Enemy', - '⚖️': 'Neutral', - '⭐': 'Friend', - '❤️': 'Lover' - }; - relationship = emojiToRelationship[value] || value; - } - - const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`; - // Insert after the divider - updatedLines.splice(dividerIndex + 1, 0, newCharacterLine); - } - } - - lastGeneratedData.characterThoughts = updatedLines.join('\n'); - // console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - - // Also update the last assistant message's swipe data - const chat = getContext().chat; - if (chat && chat.length > 0) { - // Find the last assistant message - for (let i = chat.length - 1; i >= 0; i--) { - const message = chat[i]; - if (!message.is_user) { - // Found last assistant message - update its swipe data - if (message.extra && message.extra.rpg_companion_swipes) { - const swipeId = message.swipe_id || 0; - if (message.extra.rpg_companion_swipes[swipeId]) { - message.extra.rpg_companion_swipes[swipeId].characterThoughts = updatedLines.join('\n'); - // console.log('[RPG Companion] Updated thoughts in message swipe data'); - } - } - break; - } - } - } - - saveChatData(); - - // Always update the sidebar panel - renderThoughts(); - - // For thoughts edited from the bubble, delay recreation to allow blur event to complete - // This ensures the edit is saved first, then the bubble is recreated with correct layout - if (field === 'thoughts') { - setTimeout(() => { - updateChatThoughts(); - }, 100); - } else { - // For other fields, recreate immediately - updateChatThoughts(); - } -} - -/** - * Updates or removes thought overlays in the chat. - */ -function updateChatThoughts() { - // console.log('[RPG Companion] ======== updateChatThoughts called ========'); - // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled); - // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat); - // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked')); - // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); - - // Remove existing thought panel and icon - $('#rpg-thought-panel').remove(); - $('#rpg-thought-icon').remove(); - $('#chat').off('scroll.thoughtPanel'); - $(window).off('resize.thoughtPanel'); - $(document).off('click.thoughtPanel'); - - // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return - if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { - // console.log('[RPG Companion] Thoughts in chat disabled or no data'); - return; - } - - // Parse the Present Characters data to get thoughts - const lines = lastGeneratedData.characterThoughts.split('\n'); - const thoughtsArray = []; // Array of {name, emoji, thought} - - // console.log('[RPG Companion] Parsing thoughts from lines:', lines); - - for (const line of lines) { - if (line.trim() && - !line.includes('Present Characters') && - !line.includes('---') && - !line.trim().startsWith('```')) { - - const parts = line.split('|').map(p => p.trim()); - // console.log('[RPG Companion] Line parts:', parts); - - if (parts.length >= 3) { - const firstPart = parts[0].trim(); - const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); - - if (emojiMatch) { - const emoji = emojiMatch[1].trim(); - const info = emojiMatch[2].trim(); - const thoughts = parts[2] ? parts[2].trim() : ''; - - const infoParts = info.split(',').map(p => p.trim()); - const name = infoParts[0] || ''; - - // console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts); - - if (name && thoughts && name.toLowerCase() !== 'unavailable') { - thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts }); - // console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase()); - } - } - } - } - } - - // If no thoughts parsed, return - if (thoughtsArray.length === 0) { - // console.log('[RPG Companion] No thoughts parsed, returning'); - return; - } - - // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); - // console.log('[RPG Companion] Thoughts array:', thoughtsArray); - - // Find the last message to position near - const $messages = $('#chat .mes'); - let $targetMessage = null; - - // Find the most recent non-user message - for (let i = $messages.length - 1; i >= 0; i--) { - const $message = $messages.eq(i); - if ($message.attr('is_user') !== 'true') { - $targetMessage = $message; - break; - } - } - - if (!$targetMessage) { - // console.log('[RPG Companion] No target message found'); - return; - } - - // Create the thought panel with all thoughts - createThoughtPanel($targetMessage, thoughtsArray); -} - -/** - * Creates or updates the floating thought panel positioned next to the character's avatar - */ -function createThoughtPanel($message, thoughtsArray) { - // Remove existing thought panel - $('#rpg-thought-panel').remove(); - $('#rpg-thought-icon').remove(); - - // Get the avatar position from the message - const $avatar = $message.find('.avatar img'); - if (!$avatar.length) { - // console.log('[RPG Companion] No avatar found in message'); - return; - } - - const avatarRect = $avatar[0].getBoundingClientRect(); - const panelPosition = extensionSettings.panelPosition; - const theme = extensionSettings.theme; - - // Build thought bubbles HTML - let thoughtsHtml = ''; - thoughtsArray.forEach((thought, index) => { - thoughtsHtml += ` -
-
- ${thought.emoji} -
-
- ${thought.thought} -
-
- `; - // Add divider between thoughts (except for last one) - if (index < thoughtsArray.length - 1) { - thoughtsHtml += '
'; - } - }); - - // Create the floating thought panel with theme - const $thoughtPanel = $(` -
- -
-
-
-
-
-
- ${thoughtsHtml} -
-
- `); - - // Create the collapsed thought icon - const $thoughtIcon = $(` -
- 💭 -
- `); - - // Apply custom theme colors if custom theme - if (theme === 'custom') { - const customStyles = { - '--rpg-bg': extensionSettings.customColors.bg, - '--rpg-accent': extensionSettings.customColors.accent, - '--rpg-text': extensionSettings.customColors.text, - '--rpg-highlight': extensionSettings.customColors.highlight - }; - $thoughtPanel.css(customStyles); - $thoughtIcon.css(customStyles); - } - - // Force a consistent width for the bubble to ensure proper positioning - $thoughtPanel.css('width', '350px'); - - // Append to body so it's not clipped by chat container - $('body').append($thoughtPanel); - $('body').append($thoughtIcon); - - // Position the panel next to the avatar - const panelWidth = 350; - const panelMargin = 20; - - let top = avatarRect.top + (avatarRect.height / 2); - let left; - let right; - let useRightPosition = false; - let iconTop = avatarRect.top; - let iconLeft; - - // Detect mobile viewport (matches CSS breakpoint) - const isMobile = window.innerWidth <= 1000; - - if (isMobile) { - // On mobile: position icon horizontally centered on avatar - // The CSS transform will shift it upward by 60px - iconTop = avatarRect.top; // Start at avatar top (CSS will move it up) - iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width) - - // Center the thought panel horizontally on mobile - left = window.innerWidth / 2 - panelWidth / 2; - top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing - - // No side-specific classes on mobile - $thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right'); - $thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right'); - - console.log('[RPG Companion] Mobile thought icon positioning:', { - isMobile, - windowWidth: window.innerWidth, - avatarLeft: avatarRect.left, - avatarWidth: avatarRect.width, - iconLeft, - iconTop - }); - } else if (panelPosition === 'left') { - // Main panel is on left, so thought bubble goes to RIGHT side - // Mirror the left side positioning: bubble should be same distance from avatar - // but on the opposite side, extending to the right - const chatContainer = $('#chat')[0]; - const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; - - // Position bubble starting from chat edge, extending right - left = chatRect.right + panelMargin; // Start at chat's right edge + margin - useRightPosition = false; // Use left positioning so it extends right - iconLeft = chatRect.right + 10; // Icon just at the chat edge - $thoughtPanel.addClass('rpg-thought-panel-right'); - $thoughtIcon.addClass('rpg-thought-icon-right'); - - // Position circles to flow from left (toward chat/avatar) to right (toward panel) - $thoughtPanel.find('.rpg-thought-circles').css({ - top: 'calc(50% - 50px)', - left: '-25px', - bottom: 'auto', - right: 'auto' - }); - // Mirror the circle flow for right side (left-to-right) - $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start'); - $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' }); - $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' }); - $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' }); - } else { - // Main panel is on right, so thought bubble goes on left (near avatar) - left = avatarRect.left - panelWidth - panelMargin; - iconLeft = avatarRect.left - 40; - $thoughtPanel.addClass('rpg-thought-panel-left'); - $thoughtIcon.addClass('rpg-thought-icon-left'); - - // Position circles to flow from avatar (left) to bubble (more left) - // Circles should flow right-to-left when bubble is on left - $thoughtPanel.find('.rpg-thought-circles').css({ - top: 'calc(50% - 50px)', - right: '-25px', - bottom: 'auto', - left: 'auto' - }); - // Keep the circle flow for left side (right-to-left) - default from CSS - $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end'); - $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' }); - $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' }); - $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' }); - } - - if (useRightPosition) { - $thoughtPanel.css({ - top: `${top}px`, - right: `${right}px`, - left: 'auto' // Clear left positioning - }); - } else { - $thoughtPanel.css({ - top: `${top}px`, - left: `${left}px`, - right: 'auto' // Clear right positioning - }); - } - - $thoughtIcon.css({ - top: `${iconTop}px`, - left: `${iconLeft}px`, - right: 'auto' // Clear any right positioning - }); - - // Initially hide the panel and show the icon - $thoughtPanel.hide(); - $thoughtIcon.show(); - - // console.log('[RPG Companion] Thought panel created at:', { top, left }); - - // Close button functionality - $thoughtPanel.find('.rpg-thought-close').on('click', function(e) { - e.stopPropagation(); - $thoughtPanel.fadeOut(200); - $thoughtIcon.fadeIn(200); - }); - - // Icon click to show panel - $thoughtIcon.on('click', function(e) { - e.stopPropagation(); - $thoughtIcon.fadeOut(200); - $thoughtPanel.fadeIn(200); - }); - - // Add event handlers for editable thoughts in the bubble - $thoughtPanel.find('.rpg-editable').on('blur', function() { - const character = $(this).data('character'); - const field = $(this).data('field'); - const value = $(this).text().trim(); - // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value); - updateCharacterField(character, field, value); - }); - - // RAF throttling for smooth position updates - let positionUpdateRaf = null; - - // Update position on scroll with RAF throttling - const updatePanelPosition = () => { - if (!$message.is(':visible')) { - $thoughtPanel.hide(); - $thoughtIcon.hide(); - return; - } - - // Cancel any pending RAF - if (positionUpdateRaf) { - cancelAnimationFrame(positionUpdateRaf); - } - - // Schedule update on next frame - positionUpdateRaf = requestAnimationFrame(() => { - const newAvatarRect = $avatar[0].getBoundingClientRect(); - const newTop = newAvatarRect.top + (newAvatarRect.height / 2); - const newIconTop = newAvatarRect.top; - let newLeft, newIconLeft; - - if (panelPosition === 'left') { - // Position at chat's right edge, extending right - const chatContainer = $('#chat')[0]; - const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; - newLeft = chatRect.right + panelMargin; - newIconLeft = chatRect.right + 10; - - $thoughtPanel.css({ - top: `${newTop}px`, - left: `${newLeft}px`, - right: 'auto' - }); - } else { - // Left position relative to avatar - newLeft = newAvatarRect.left - panelWidth - panelMargin; - newIconLeft = newAvatarRect.left - 40; - - $thoughtPanel.css({ - top: `${newTop}px`, - left: `${newLeft}px`, - right: 'auto' - }); - } - - $thoughtIcon.css({ - top: `${newIconTop}px`, - left: `${newIconLeft}px`, - right: 'auto' - }); - - if ($thoughtPanel.is(':visible')) { - $thoughtPanel.show(); - } - if ($thoughtIcon.is(':visible')) { - $thoughtIcon.show(); - } - - positionUpdateRaf = null; - }); - }; - - // Update position on scroll and resize - $('#chat').on('scroll.thoughtPanel', updatePanelPosition); - $(window).on('resize.thoughtPanel', updatePanelPosition); - - // Remove panel when clicking outside (but not when clicking icon or panel) - $(document).on('click.thoughtPanel', function(e) { - if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) { - // Hide the panel and show the icon instead of removing - $thoughtPanel.fadeOut(200); - $thoughtIcon.fadeIn(200); - } - }); -} /** * Commits the tracker data from the last assistant message to be used as source for next generation. diff --git a/src/systems/rendering/infoBox.js b/src/systems/rendering/infoBox.js new file mode 100644 index 0000000..281f388 --- /dev/null +++ b/src/systems/rendering/infoBox.js @@ -0,0 +1,452 @@ +/** + * Info Box Rendering Module + * Handles rendering of the info box dashboard with weather, date, time, and location widgets + */ + +import { getContext } from '../../../../../../extensions.js'; +import { + extensionSettings, + lastGeneratedData, + $infoBoxContainer +} from '../../core/state.js'; +import { saveChatData } from '../../core/persistence.js'; + +/** + * Renders the info box as a visual dashboard with calendar, weather, temperature, clock, and map widgets. + * Includes event listeners for editable fields. + */ +export function renderInfoBox() { + if (!extensionSettings.showInfoBox || !$infoBoxContainer) { + return; + } + + // Add updating class for animation + if (extensionSettings.enableAnimations) { + $infoBoxContainer.addClass('rpg-content-updating'); + } + + // If no data yet, show placeholder + if (!lastGeneratedData.infoBox) { + const placeholderHtml = ` +
+
+
No data yet
+
Generate a new response in the roleplay or switch to "Separate Generation" in Settings to access and click the "Refresh RPG Info" button
+
+
+ `; + $infoBoxContainer.html(placeholderHtml); + if (extensionSettings.enableAnimations) { + setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); + } + return; + } + + // console.log('[RPG Companion] renderInfoBox called with data:', lastGeneratedData.infoBox); + + // Parse the info box data + const lines = lastGeneratedData.infoBox.split('\n'); + // console.log('[RPG Companion] Info Box split into lines:', lines); + const data = { + date: '', + weekday: '', + month: '', + year: '', + weatherEmoji: '', + weatherForecast: '', + temperature: '', + tempValue: 0, + timeStart: '', + timeEnd: '', + location: '', + characters: [] + }; + + for (const line of lines) { + // console.log('[RPG Companion] Processing line:', line); + + if (line.includes('🗓️:')) { + // console.log('[RPG Companion] → Matched DATE'); + const dateStr = line.replace('🗓️:', '').trim(); + // Parse format: "Weekday, Month Day, Year" or "Weekday, Month, Year" + const dateParts = dateStr.split(',').map(p => p.trim()); + data.weekday = dateParts[0] || ''; + data.month = dateParts[1] || ''; + data.year = dateParts[2] || ''; + data.date = dateStr; + } else if (line.includes('🌡️:')) { + // console.log('[RPG Companion] → Matched TEMPERATURE'); + const tempStr = line.replace('🌡️:', '').trim(); + data.temperature = tempStr; + // Extract numeric value + const tempMatch = tempStr.match(/(-?\d+)/); + if (tempMatch) { + data.tempValue = parseInt(tempMatch[1]); + } + } else if (line.includes('🕒:')) { + // console.log('[RPG Companion] → Matched TIME'); + const timeStr = line.replace('🕒:', '').trim(); + data.time = timeStr; + // Parse "HH:MM → HH:MM" format + const timeParts = timeStr.split('→').map(t => t.trim()); + data.timeStart = timeParts[0] || ''; + data.timeEnd = timeParts[1] || ''; + } else if (line.includes('🗺️:')) { + // console.log('[RPG Companion] → Matched LOCATION'); + data.location = line.replace('🗺️:', '').trim(); + } else { + // Check if it's a weather line + // Since \p{Emoji} doesn't work reliably, use a simpler approach + const hasColon = line.includes(':'); + const notInfoBox = !line.includes('Info Box'); + const notDivider = !line.includes('---'); + const notCodeFence = !line.trim().startsWith('```'); + + // console.log('[RPG Companion] → Checking weather conditions:', { + // line: line, + // hasColon: hasColon, + // notInfoBox: notInfoBox, + // notDivider: notDivider + // }); + + if (hasColon && notInfoBox && notDivider && notCodeFence && line.trim().length > 0) { + // Match format: [Weather Emoji]: [Forecast] + // Capture everything before colon as emoji, everything after as forecast + // console.log('[RPG Companion] → Testing WEATHER match for:', line); + const weatherMatch = line.match(/^\s*([^:]+):\s*(.+)$/); + if (weatherMatch) { + const potentialEmoji = weatherMatch[1].trim(); + const forecast = weatherMatch[2].trim(); + + // If the first part is short (likely emoji), treat as weather + if (potentialEmoji.length <= 5) { + data.weatherEmoji = potentialEmoji; + data.weatherForecast = forecast; + // console.log('[RPG Companion] ✓ Weather parsed:', data.weatherEmoji, data.weatherForecast); + } else { + // console.log('[RPG Companion] ✗ First part too long for emoji:', potentialEmoji); + } + } else { + // console.log('[RPG Companion] ✗ Weather regex did not match'); + } + } else { + // console.log('[RPG Companion] → No match for this line'); + } + } + } + + // console.log('[RPG Companion] Parsed Info Box data:', { + // date: data.date, + // weatherEmoji: data.weatherEmoji, + // weatherForecast: data.weatherForecast, + // temperature: data.temperature, + // timeStart: data.timeStart, + // location: data.location + // }); + + // Build visual dashboard HTML + // Row 1: Date, Weather, Temperature, Time widgets + let html = '
'; + + // Calendar widget - always show (editable even if empty) + const monthShort = data.month ? data.month.substring(0, 3).toUpperCase() : 'MON'; + const weekdayShort = data.weekday ? data.weekday.substring(0, 3).toUpperCase() : 'DAY'; + const yearDisplay = data.year || 'YEAR'; + html += ` +
+
${monthShort}
+
${weekdayShort}
+
${yearDisplay}
+
+ `; + + // Weather widget - always show (editable even if empty) + const weatherEmoji = data.weatherEmoji || '🌤️'; + const weatherForecast = data.weatherForecast || 'Weather'; + html += ` +
+
${weatherEmoji}
+
${weatherForecast}
+
+ `; + + // Temperature widget - always show (editable even if empty) + const tempDisplay = data.temperature || '20°C'; + const tempValue = data.tempValue || 20; + const tempPercent = Math.min(100, Math.max(0, ((tempValue + 20) / 60) * 100)); + const tempColor = tempValue < 10 ? '#4a90e2' : tempValue < 25 ? '#67c23a' : '#e94560'; + html += ` +
+
+
+
+
+
+
+
${tempDisplay}
+
+ `; + + // Time widget - always show (editable even if empty) + const timeDisplay = data.timeStart || '12:00'; + // Parse time for clock hands + const timeMatch = timeDisplay.match(/(\d+):(\d+)/); + let hourAngle = 0; + let minuteAngle = 0; + if (timeMatch) { + const hours = parseInt(timeMatch[1]); + const minutes = parseInt(timeMatch[2]); + hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute + minuteAngle = minutes * 6; // 6° per minute + } + html += ` +
+
+
+
+
+
+
+
+
${timeDisplay}
+
+ `; + + html += '
'; + + // Row 2: Location widget (full width) - always show (editable even if empty) + const locationDisplay = data.location || 'Location'; + html += ` +
+
+
+
📍
+
+
${locationDisplay}
+
+
+ `; + + $infoBoxContainer.html(html); + + // Add event handlers for editable Info Box fields + $infoBoxContainer.find('.rpg-editable').on('blur', function() { + const field = $(this).data('field'); + const value = $(this).text().trim(); + updateInfoBoxField(field, value); + }); + + // Remove updating class after animation + if (extensionSettings.enableAnimations) { + setTimeout(() => $infoBoxContainer.removeClass('rpg-content-updating'), 500); + } +} + +/** + * Updates a specific field in the Info Box data and re-renders. + * Handles complex field reconstruction logic for date parts, weather, temperature, time, and location. + * + * @param {string} field - Field name to update + * @param {string} value - New value for the field + */ +export function updateInfoBoxField(field, value) { + if (!lastGeneratedData.infoBox) { + // Initialize with empty info box if it doesn't exist + lastGeneratedData.infoBox = 'Info Box\n---\n'; + } + + // Reconstruct the Info Box text with updated field + const lines = lastGeneratedData.infoBox.split('\n'); + let dateLineFound = false; + let dateLineIndex = -1; + + // Find the date line + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('🗓️:')) { + dateLineFound = true; + dateLineIndex = i; + break; + } + } + + const updatedLines = lines.map((line, index) => { + if (field === 'month' && line.includes('🗓️:')) { + const parts = line.split(','); + if (parts.length >= 2) { + // parts[0] = "🗓️: Weekday", parts[1] = " Month", parts[2] = " Year" + parts[1] = ' ' + value; + return parts.join(','); + } else if (parts.length === 1) { + // No existing month/year, add them + return `${parts[0]}, ${value}, YEAR`; + } + } else if (field === 'weekday' && line.includes('🗓️:')) { + const parts = line.split(','); + // Keep the emoji, just update the weekday + const month = parts[1] ? parts[1].trim() : 'Month'; + const year = parts[2] ? parts[2].trim() : 'YEAR'; + return `🗓️: ${value}, ${month}, ${year}`; + } else if (field === 'year' && line.includes('🗓️:')) { + const parts = line.split(','); + if (parts.length >= 3) { + parts[2] = ' ' + value; + return parts.join(','); + } else if (parts.length === 2) { + // No existing year, add it + return `${parts[0]}, ${parts[1]}, ${value}`; + } else if (parts.length === 1) { + // No existing month/year, add them + return `${parts[0]}, Month, ${value}`; + } + } else if (field === 'weatherEmoji' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { + // This is the weather line + const parts = line.split(':'); + if (parts.length >= 2) { + return `${value}: ${parts.slice(1).join(':').trim()}`; + } + } else if (field === 'weatherForecast' && line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { + // This is the weather line + const parts = line.split(':'); + if (parts.length >= 2) { + return `${parts[0].trim()}: ${value}`; + } + } else if (field === 'temperature' && line.includes('🌡️:')) { + return `🌡️: ${value}`; + } else if (field === 'timeStart' && line.includes('🕒:')) { + // Update time format: "HH:MM → HH:MM" + // When user edits, set both start and end time to the new value + return `🕒: ${value} → ${value}`; + } else if (field === 'location' && line.includes('🗺️:')) { + return `🗺️: ${value}`; + } + return line; + }); + + // If editing a date field but no date line exists, create one after the divider + if ((field === 'month' || field === 'weekday' || field === 'year') && !dateLineFound) { + // Find the divider line + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + // Create initial date line with the edited field + let newDateLine = ''; + if (field === 'weekday') { + newDateLine = `🗓️: ${value}, Month, YEAR`; + } else if (field === 'month') { + newDateLine = `🗓️: Weekday, ${value}, YEAR`; + } else if (field === 'year') { + newDateLine = `🗓️: Weekday, Month, ${value}`; + } + // Insert after the divider + updatedLines.splice(dividerIndex + 1, 0, newDateLine); + } + } + + // If editing weather but no weather line exists, create one + if ((field === 'weatherEmoji' || field === 'weatherForecast')) { + let weatherLineFound = false; + for (const line of updatedLines) { + // Check if this is a weather line (has emoji and forecast, not one of the special fields) + if (line.match(/^[^:]+:\s*.+$/) && !line.includes('🗓️') && !line.includes('🌡️') && !line.includes('🕒') && !line.includes('🗺️') && !line.includes('Info Box') && !line.includes('---')) { + weatherLineFound = true; + break; + } + } + + if (!weatherLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + let newWeatherLine = ''; + if (field === 'weatherEmoji') { + newWeatherLine = `${value}: Weather`; + } else if (field === 'weatherForecast') { + newWeatherLine = `🌤️: ${value}`; + } + // Insert after date line if it exists, otherwise after divider + const dateIndex = updatedLines.findIndex(line => line.includes('🗓️:')); + const insertIndex = dateIndex >= 0 ? dateIndex + 1 : dividerIndex + 1; + updatedLines.splice(insertIndex, 0, newWeatherLine); + } + } + } + + // If editing temperature but no temperature line exists, create one + if (field === 'temperature') { + const tempLineFound = updatedLines.some(line => line.includes('🌡️:')); + if (!tempLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + const newTempLine = `🌡️: ${value}`; + // Find last non-empty line before creating position + let insertIndex = dividerIndex + 1; + for (let i = 0; i < updatedLines.length; i++) { + if (updatedLines[i].includes('🗓️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { + insertIndex = i + 1; + } + } + updatedLines.splice(insertIndex, 0, newTempLine); + } + } + } + + // If editing time but no time line exists, create one + if (field === 'timeStart') { + const timeLineFound = updatedLines.some(line => line.includes('🕒:')); + if (!timeLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + const newTimeLine = `🕒: ${value} → ${value}`; + // Find last non-empty line before creating position + let insertIndex = dividerIndex + 1; + for (let i = 0; i < updatedLines.length; i++) { + if (updatedLines[i].includes('🗓️:') || updatedLines[i].includes('🌡️:') || updatedLines[i].match(/^[^:]+:\s*.+$/)) { + insertIndex = i + 1; + } + } + updatedLines.splice(insertIndex, 0, newTimeLine); + } + } + } + + // If editing location but no location line exists, create one + if (field === 'location') { + const locationLineFound = updatedLines.some(line => line.includes('🗺️:')); + if (!locationLineFound) { + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + const newLocationLine = `🗺️: ${value}`; + // Insert at the end (before any empty lines) + let insertIndex = updatedLines.length; + for (let i = updatedLines.length - 1; i >= 0; i--) { + if (updatedLines[i].trim() !== '') { + insertIndex = i + 1; + break; + } + } + updatedLines.splice(insertIndex, 0, newLocationLine); + } + } + } + + lastGeneratedData.infoBox = updatedLines.join('\n'); + + // Update the message's swipe data + const chat = getContext().chat; + if (chat && chat.length > 0) { + for (let i = chat.length - 1; i >= 0; i--) { + const message = chat[i]; + if (!message.is_user) { + if (message.extra && message.extra.rpg_companion_swipes) { + const swipeId = message.swipe_id || 0; + if (message.extra.rpg_companion_swipes[swipeId]) { + message.extra.rpg_companion_swipes[swipeId].infoBox = updatedLines.join('\n'); + // console.log('[RPG Companion] Updated infoBox in message swipe data'); + } + } + break; + } + } + } + + saveChatData(); + renderInfoBox(); +} diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js new file mode 100644 index 0000000..03d9eb4 --- /dev/null +++ b/src/systems/rendering/thoughts.js @@ -0,0 +1,745 @@ +/** + * Character Thoughts Rendering Module + * Handles rendering of character thoughts panel and floating thought bubbles in chat + */ + +import { getContext } from '../../../../../../extensions.js'; +import { this_chid, characters } from '../../../../../../../script.js'; +import { selected_group, getGroupMembers } from '../../../../../../group-chats.js'; +import { + extensionSettings, + lastGeneratedData, + $thoughtsContainer, + FALLBACK_AVATAR_DATA_URI +} from '../../core/state.js'; +import { saveChatData } from '../../core/persistence.js'; +import { getSafeThumbnailUrl } from '../../utils/avatars.js'; + +/** + * Renders character thoughts (Present Characters) panel. + * Displays character cards with avatars, relationship badges, and traits. + * Includes event listeners for editable character fields. + */ +export function renderThoughts() { + if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { + return; + } + + // Add updating class for animation + if (extensionSettings.enableAnimations) { + $thoughtsContainer.addClass('rpg-content-updating'); + } + + // Initialize if no data yet + if (!lastGeneratedData.characterThoughts) { + lastGeneratedData.characterThoughts = ''; + } + + const lines = lastGeneratedData.characterThoughts.split('\n'); + const presentCharacters = []; + + // console.log('[RPG Companion] Raw Present Characters:', lastGeneratedData.characterThoughts); + // console.log('[RPG Companion] Split into lines:', lines); + + // Parse format: [Emoji]: [Name, Status, Demeanor] | [Relationship] | [Thoughts] + for (const line of lines) { + // Skip empty lines, headers, dividers, and code fences + if (line.trim() && + !line.includes('Present Characters') && + !line.includes('---') && + !line.trim().startsWith('```')) { + + // Match the new format with pipes + const parts = line.split('|').map(p => p.trim()); + + if (parts.length >= 2) { + // First part: [Emoji]: [Name, Status, Demeanor] + const firstPart = parts[0].trim(); + const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); + + if (emojiMatch) { + const emoji = emojiMatch[1].trim(); + const info = emojiMatch[2].trim(); + const relationship = parts[1].trim(); // Enemy/Neutral/Friend/Lover + const thoughts = parts[2] ? parts[2].trim() : ''; + + // Parse name from info (first part before comma) + const infoParts = info.split(',').map(p => p.trim()); + const name = infoParts[0] || ''; + const traits = infoParts.slice(1).join(', '); + + if (name && name.toLowerCase() !== 'unavailable') { + presentCharacters.push({ emoji, name, traits, relationship, thoughts }); + // console.log('[RPG Companion] Parsed character:', { name, relationship }); + } + } + } + } + } + + // Relationship status to emoji mapping + const relationshipEmojis = { + 'Enemy': '⚔️', + 'Neutral': '⚖️', + 'Friend': '⭐', + 'Lover': '❤️' + }; + + // Build HTML + let html = ''; + + // console.log('[RPG Companion] Total characters parsed:', presentCharacters.length); + // console.log('[RPG Companion] Characters array:', presentCharacters); + + // If no characters parsed, show a placeholder editable card + if (presentCharacters.length === 0) { + // Get default character portrait (try to use the current character if in 1-on-1 chat) + // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors + let defaultPortrait = FALLBACK_AVATAR_DATA_URI; + let defaultName = 'Character'; + + if (this_chid !== undefined && characters[this_chid]) { + if (characters[this_chid].avatar && characters[this_chid].avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); + if (thumbnailUrl) { + defaultPortrait = thumbnailUrl; + } + } + defaultName = characters[this_chid].name || 'Character'; + } + + html += '
'; + html += ` +
+
+ ${defaultName} +
⚖️
+
+
+
+ 😊 + ${defaultName} +
+
Traits
+
+
+ `; + html += '
'; + } else { + html += '
'; + for (const char of presentCharacters) { + // Find character portrait + // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors + let characterPortrait = FALLBACK_AVATAR_DATA_URI; + + // console.log('[RPG Companion] Looking for avatar for:', char.name); + + // For group chats, search through group members first + if (selected_group) { + const groupMembers = getGroupMembers(selected_group); + const matchingMember = groupMembers.find(member => + member && member.name && member.name.toLowerCase() === char.name.toLowerCase() + ); + + if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); + if (thumbnailUrl) { + characterPortrait = thumbnailUrl; + } + } + } + + // For regular chats or if not found in group, search all characters + if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { + const matchingCharacter = characters.find(c => + c && c.name && c.name.toLowerCase() === char.name.toLowerCase() + ); + + if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); + if (thumbnailUrl) { + characterPortrait = thumbnailUrl; + } + } + } + + // If this is the current character in a 1-on-1 chat, use their portrait + if (this_chid !== undefined && characters[this_chid] && + characters[this_chid].name && characters[this_chid].name.toLowerCase() === char.name.toLowerCase()) { + const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); + if (thumbnailUrl) { + characterPortrait = thumbnailUrl; + } + } + + // Get relationship emoji + const relationshipEmoji = relationshipEmojis[char.relationship] || '⚖️'; + + html += ` +
+
+ ${char.name} +
${relationshipEmoji}
+
+
+
+ ${char.emoji} + ${char.name} +
+
${char.traits}
+
+
+ `; + } + html += '
'; + } + + $thoughtsContainer.html(html); + + // Add event handlers for editable character fields + $thoughtsContainer.find('.rpg-editable').on('blur', function() { + const character = $(this).data('character'); + const field = $(this).data('field'); + const value = $(this).text().trim(); + updateCharacterField(character, field, value); + }); + + // Remove updating class after animation + if (extensionSettings.enableAnimations) { + setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); + } + + // Update chat overlay if enabled + if (extensionSettings.showThoughtsInChat) { + updateChatThoughts(); + } +} + +/** + * Updates a specific character field in Present Characters data and re-renders. + * Handles character creation if character doesn't exist yet. + * + * @param {string} characterName - Name of the character to update + * @param {string} field - Field to update (emoji, name, traits, thoughts, relationship) + * @param {string} value - New value for the field + */ +export function updateCharacterField(characterName, field, value) { + // console.log('[RPG Companion] 📝 updateCharacterField called - character:', characterName, 'field:', field, 'value:', value); + // console.log('[RPG Companion] 📝 Current lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + + // Initialize if it doesn't exist + if (!lastGeneratedData.characterThoughts) { + lastGeneratedData.characterThoughts = 'Present Characters\n---\n'; + } + + const lines = lastGeneratedData.characterThoughts.split('\n'); + let characterFound = false; + + const updatedLines = lines.map(line => { + // Case-insensitive character name matching + if (line.toLowerCase().includes(characterName.toLowerCase())) { + characterFound = true; + const parts = line.split('|').map(p => p.trim()); + if (parts.length >= 2) { + const firstPart = parts[0]; + const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); + + if (emojiMatch) { + let emoji = emojiMatch[1].trim(); + let info = emojiMatch[2].trim(); + let relationship = parts[1]; + let thoughts = parts[2] || ''; + + const infoParts = info.split(',').map(p => p.trim()); + let name = infoParts[0]; + let traits = infoParts.slice(1).join(', '); + + if (field === 'emoji') { + emoji = value; + } else if (field === 'name') { + name = value; + } else if (field === 'traits') { + traits = value; + } else if (field === 'thoughts') { + thoughts = value; + } else if (field === 'relationship') { + const emojiToRelationship = { + '⚔️': 'Enemy', + '⚖️': 'Neutral', + '⭐': 'Friend', + '❤️': 'Lover' + }; + relationship = emojiToRelationship[value] || value; + } + + const newInfo = traits ? `${name}, ${traits}` : name; + return `${emoji}: ${newInfo} | ${relationship} | ${thoughts}`; + } + } + } + return line; + }); + + // If character wasn't found, create a new character line + if (!characterFound) { + // Find the divider line + const dividerIndex = updatedLines.findIndex(line => line.includes('---')); + if (dividerIndex >= 0) { + // Create initial character line with the edited field + let emoji = '😊'; + let name = characterName; + let traits = 'Traits'; + let relationship = 'Neutral'; + let thoughts = ''; + + // Apply the edited field + if (field === 'emoji') { + emoji = value; + } else if (field === 'name') { + name = value; + } else if (field === 'traits') { + traits = value; + } else if (field === 'thoughts') { + thoughts = value; + } else if (field === 'relationship') { + const emojiToRelationship = { + '⚔️': 'Enemy', + '⚖️': 'Neutral', + '⭐': 'Friend', + '❤️': 'Lover' + }; + relationship = emojiToRelationship[value] || value; + } + + const newCharacterLine = `${emoji}: ${name}, ${traits} | ${relationship} | ${thoughts}`; + // Insert after the divider + updatedLines.splice(dividerIndex + 1, 0, newCharacterLine); + } + } + + lastGeneratedData.characterThoughts = updatedLines.join('\n'); + // console.log('[RPG Companion] 💾 Updated lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + + // Also update the last assistant message's swipe data + const chat = getContext().chat; + if (chat && chat.length > 0) { + // Find the last assistant message + for (let i = chat.length - 1; i >= 0; i--) { + const message = chat[i]; + if (!message.is_user) { + // Found last assistant message - update its swipe data + if (message.extra && message.extra.rpg_companion_swipes) { + const swipeId = message.swipe_id || 0; + if (message.extra.rpg_companion_swipes[swipeId]) { + message.extra.rpg_companion_swipes[swipeId].characterThoughts = updatedLines.join('\n'); + // console.log('[RPG Companion] Updated thoughts in message swipe data'); + } + } + break; + } + } + } + + saveChatData(); + + // Always update the sidebar panel + renderThoughts(); + + // For thoughts edited from the bubble, delay recreation to allow blur event to complete + // This ensures the edit is saved first, then the bubble is recreated with correct layout + if (field === 'thoughts') { + setTimeout(() => { + updateChatThoughts(); + }, 100); + } else { + // For other fields, recreate immediately + updateChatThoughts(); + } +} + +/** + * Updates or removes thought overlays in the chat. + * Creates floating thought bubbles positioned near character avatars. + */ +export function updateChatThoughts() { + // console.log('[RPG Companion] ======== updateChatThoughts called ========'); + // console.log('[RPG Companion] Extension enabled:', extensionSettings.enabled); + // console.log('[RPG Companion] showThoughtsInChat setting:', extensionSettings.showThoughtsInChat); + // console.log('[RPG Companion] Toggle element checked:', $('#rpg-toggle-thoughts-in-chat').prop('checked')); + // console.log('[RPG Companion] lastGeneratedData.characterThoughts:', lastGeneratedData.characterThoughts); + + // Remove existing thought panel and icon + $('#rpg-thought-panel').remove(); + $('#rpg-thought-icon').remove(); + $('#chat').off('scroll.thoughtPanel'); + $(window).off('resize.thoughtPanel'); + $(document).off('click.thoughtPanel'); + + // If extension is disabled, thoughts in chat are disabled, or no thoughts, just return + if (!extensionSettings.enabled || !extensionSettings.showThoughtsInChat || !lastGeneratedData.characterThoughts) { + // console.log('[RPG Companion] Thoughts in chat disabled or no data'); + return; + } + + // Parse the Present Characters data to get thoughts + const lines = lastGeneratedData.characterThoughts.split('\n'); + const thoughtsArray = []; // Array of {name, emoji, thought} + + // console.log('[RPG Companion] Parsing thoughts from lines:', lines); + + for (const line of lines) { + if (line.trim() && + !line.includes('Present Characters') && + !line.includes('---') && + !line.trim().startsWith('```')) { + + const parts = line.split('|').map(p => p.trim()); + // console.log('[RPG Companion] Line parts:', parts); + + if (parts.length >= 3) { + const firstPart = parts[0].trim(); + const emojiMatch = firstPart.match(/^(.+?):\s*(.+)$/); + + if (emojiMatch) { + const emoji = emojiMatch[1].trim(); + const info = emojiMatch[2].trim(); + const thoughts = parts[2] ? parts[2].trim() : ''; + + const infoParts = info.split(',').map(p => p.trim()); + const name = infoParts[0] || ''; + + // console.log('[RPG Companion] Parsed thought - Name:', name, 'Thought:', thoughts); + + if (name && thoughts && name.toLowerCase() !== 'unavailable') { + thoughtsArray.push({ name: name.toLowerCase(), emoji, thought: thoughts }); + // console.log('[RPG Companion] Added to thoughtsArray:', name.toLowerCase()); + } + } + } + } + } + + // If no thoughts parsed, return + if (thoughtsArray.length === 0) { + // console.log('[RPG Companion] No thoughts parsed, returning'); + return; + } + + // console.log('[RPG Companion] Total thoughts:', thoughtsArray.length); + // console.log('[RPG Companion] Thoughts array:', thoughtsArray); + + // Find the last message to position near + const $messages = $('#chat .mes'); + let $targetMessage = null; + + // Find the most recent non-user message + for (let i = $messages.length - 1; i >= 0; i--) { + const $message = $messages.eq(i); + if ($message.attr('is_user') !== 'true') { + $targetMessage = $message; + break; + } + } + + if (!$targetMessage) { + // console.log('[RPG Companion] No target message found'); + return; + } + + // Create the thought panel with all thoughts + createThoughtPanel($targetMessage, thoughtsArray); +} + +/** + * Creates or updates the floating thought panel positioned next to the character's avatar. + * Handles responsive positioning for left/right panel modes and mobile viewports. + * + * @param {jQuery} $message - Message element to position the panel relative to + * @param {Array} thoughtsArray - Array of thought objects {name, emoji, thought} + */ +export function createThoughtPanel($message, thoughtsArray) { + // Remove existing thought panel + $('#rpg-thought-panel').remove(); + $('#rpg-thought-icon').remove(); + + // Get the avatar position from the message + const $avatar = $message.find('.avatar img'); + if (!$avatar.length) { + // console.log('[RPG Companion] No avatar found in message'); + return; + } + + const avatarRect = $avatar[0].getBoundingClientRect(); + const panelPosition = extensionSettings.panelPosition; + const theme = extensionSettings.theme; + + // Build thought bubbles HTML + let thoughtsHtml = ''; + thoughtsArray.forEach((thought, index) => { + thoughtsHtml += ` +
+
+ ${thought.emoji} +
+
+ ${thought.thought} +
+
+ `; + // Add divider between thoughts (except for last one) + if (index < thoughtsArray.length - 1) { + thoughtsHtml += '
'; + } + }); + + // Create the floating thought panel with theme + const $thoughtPanel = $(` +
+ +
+
+
+
+
+
+ ${thoughtsHtml} +
+
+ `); + + // Create the collapsed thought icon + const $thoughtIcon = $(` +
+ 💭 +
+ `); + + // Apply custom theme colors if custom theme + if (theme === 'custom') { + const customStyles = { + '--rpg-bg': extensionSettings.customColors.bg, + '--rpg-accent': extensionSettings.customColors.accent, + '--rpg-text': extensionSettings.customColors.text, + '--rpg-highlight': extensionSettings.customColors.highlight + }; + $thoughtPanel.css(customStyles); + $thoughtIcon.css(customStyles); + } + + // Force a consistent width for the bubble to ensure proper positioning + $thoughtPanel.css('width', '350px'); + + // Append to body so it's not clipped by chat container + $('body').append($thoughtPanel); + $('body').append($thoughtIcon); + + // Position the panel next to the avatar + const panelWidth = 350; + const panelMargin = 20; + + let top = avatarRect.top + (avatarRect.height / 2); + let left; + let right; + let useRightPosition = false; + let iconTop = avatarRect.top; + let iconLeft; + + // Detect mobile viewport (matches CSS breakpoint) + const isMobile = window.innerWidth <= 1000; + + if (isMobile) { + // On mobile: position icon horizontally centered on avatar + // The CSS transform will shift it upward by 60px + iconTop = avatarRect.top; // Start at avatar top (CSS will move it up) + iconLeft = avatarRect.left + (avatarRect.width / 2) - 18; // Centered horizontally (18px = half of 36px icon width) + + // Center the thought panel horizontally on mobile + left = window.innerWidth / 2 - panelWidth / 2; + top = avatarRect.top + avatarRect.height + 60; // Position below icon with spacing + + // No side-specific classes on mobile + $thoughtPanel.removeClass('rpg-thought-panel-left rpg-thought-panel-right'); + $thoughtIcon.removeClass('rpg-thought-icon-left rpg-thought-icon-right'); + + console.log('[RPG Companion] Mobile thought icon positioning:', { + isMobile, + windowWidth: window.innerWidth, + avatarLeft: avatarRect.left, + avatarWidth: avatarRect.width, + iconLeft, + iconTop + }); + } else if (panelPosition === 'left') { + // Main panel is on left, so thought bubble goes to RIGHT side + // Mirror the left side positioning: bubble should be same distance from avatar + // but on the opposite side, extending to the right + const chatContainer = $('#chat')[0]; + const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; + + // Position bubble starting from chat edge, extending right + left = chatRect.right + panelMargin; // Start at chat's right edge + margin + useRightPosition = false; // Use left positioning so it extends right + iconLeft = chatRect.right + 10; // Icon just at the chat edge + $thoughtPanel.addClass('rpg-thought-panel-right'); + $thoughtIcon.addClass('rpg-thought-icon-right'); + + // Position circles to flow from left (toward chat/avatar) to right (toward panel) + $thoughtPanel.find('.rpg-thought-circles').css({ + top: 'calc(50% - 50px)', + left: '-25px', + bottom: 'auto', + right: 'auto' + }); + // Mirror the circle flow for right side (left-to-right) + $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-start'); + $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '0' }); + $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '4px' }); + $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-start', 'margin-right': '0', 'margin-left': '8px' }); + } else { + // Main panel is on right, so thought bubble goes on left (near avatar) + left = avatarRect.left - panelWidth - panelMargin; + iconLeft = avatarRect.left - 40; + $thoughtPanel.addClass('rpg-thought-panel-left'); + $thoughtIcon.addClass('rpg-thought-icon-left'); + + // Position circles to flow from avatar (left) to bubble (more left) + // Circles should flow right-to-left when bubble is on left + $thoughtPanel.find('.rpg-thought-circles').css({ + top: 'calc(50% - 50px)', + right: '-25px', + bottom: 'auto', + left: 'auto' + }); + // Keep the circle flow for left side (right-to-left) - default from CSS + $thoughtPanel.find('.rpg-thought-circles').css('align-items', 'flex-end'); + $thoughtPanel.find('.rpg-circle-1').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '0' }); + $thoughtPanel.find('.rpg-circle-2').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '4px' }); + $thoughtPanel.find('.rpg-circle-3').css({ 'align-self': 'flex-end', 'margin-left': '0', 'margin-right': '8px' }); + } + + if (useRightPosition) { + $thoughtPanel.css({ + top: `${top}px`, + right: `${right}px`, + left: 'auto' // Clear left positioning + }); + } else { + $thoughtPanel.css({ + top: `${top}px`, + left: `${left}px`, + right: 'auto' // Clear right positioning + }); + } + + $thoughtIcon.css({ + top: `${iconTop}px`, + left: `${iconLeft}px`, + right: 'auto' // Clear any right positioning + }); + + // Initially hide the panel and show the icon + $thoughtPanel.hide(); + $thoughtIcon.show(); + + // console.log('[RPG Companion] Thought panel created at:', { top, left }); + + // Close button functionality + $thoughtPanel.find('.rpg-thought-close').on('click', function(e) { + e.stopPropagation(); + $thoughtPanel.fadeOut(200); + $thoughtIcon.fadeIn(200); + }); + + // Icon click to show panel + $thoughtIcon.on('click', function(e) { + e.stopPropagation(); + $thoughtIcon.fadeOut(200); + $thoughtPanel.fadeIn(200); + }); + + // Add event handlers for editable thoughts in the bubble + $thoughtPanel.find('.rpg-editable').on('blur', function() { + const character = $(this).data('character'); + const field = $(this).data('field'); + const value = $(this).text().trim(); + // console.log('[RPG Companion] 💭 Thought bubble blur event - character:', character, 'field:', field, 'value:', value); + updateCharacterField(character, field, value); + }); + + // RAF throttling for smooth position updates + let positionUpdateRaf = null; + + // Update position on scroll with RAF throttling + const updatePanelPosition = () => { + if (!$message.is(':visible')) { + $thoughtPanel.hide(); + $thoughtIcon.hide(); + return; + } + + // Cancel any pending RAF + if (positionUpdateRaf) { + cancelAnimationFrame(positionUpdateRaf); + } + + // Schedule update on next frame + positionUpdateRaf = requestAnimationFrame(() => { + const newAvatarRect = $avatar[0].getBoundingClientRect(); + const newTop = newAvatarRect.top + (newAvatarRect.height / 2); + const newIconTop = newAvatarRect.top; + let newLeft, newIconLeft; + + if (panelPosition === 'left') { + // Position at chat's right edge, extending right + const chatContainer = $('#chat')[0]; + const chatRect = chatContainer ? chatContainer.getBoundingClientRect() : { right: window.innerWidth }; + newLeft = chatRect.right + panelMargin; + newIconLeft = chatRect.right + 10; + + $thoughtPanel.css({ + top: `${newTop}px`, + left: `${newLeft}px`, + right: 'auto' + }); + } else { + // Left position relative to avatar + newLeft = newAvatarRect.left - panelWidth - panelMargin; + newIconLeft = newAvatarRect.left - 40; + + $thoughtPanel.css({ + top: `${newTop}px`, + left: `${newLeft}px`, + right: 'auto' + }); + } + + $thoughtIcon.css({ + top: `${newIconTop}px`, + left: `${newIconLeft}px`, + right: 'auto' + }); + + if ($thoughtPanel.is(':visible')) { + $thoughtPanel.show(); + } + if ($thoughtIcon.is(':visible')) { + $thoughtIcon.show(); + } + + positionUpdateRaf = null; + }); + }; + + // Update position on scroll and resize + $('#chat').on('scroll.thoughtPanel', updatePanelPosition); + $(window).on('resize.thoughtPanel', updatePanelPosition); + + // Remove panel when clicking outside (but not when clicking icon or panel) + $(document).on('click.thoughtPanel', function(e) { + if (!$(e.target).closest('#rpg-thought-panel, #rpg-thought-icon').length) { + // Hide the panel and show the icon instead of removing + $thoughtPanel.fadeOut(200); + $thoughtIcon.fadeIn(200); + } + }); +} diff --git a/src/systems/rendering/userStats.js b/src/systems/rendering/userStats.js new file mode 100644 index 0000000..78d7ea6 --- /dev/null +++ b/src/systems/rendering/userStats.js @@ -0,0 +1,242 @@ +/** + * User Stats Rendering Module + * Handles rendering of the user stats panel with progress bars and classic RPG stats + */ + +import { getContext } from '../../../../../../extensions.js'; +import { user_avatar } from '../../../../../../../script.js'; +import { + extensionSettings, + lastGeneratedData, + $userStatsContainer, + FALLBACK_AVATAR_DATA_URI +} from '../../core/state.js'; +import { + saveSettings, + saveChatData, + updateMessageSwipeData +} from '../../core/persistence.js'; +import { getSafeThumbnailUrl } from '../../utils/avatars.js'; + +/** + * Renders the user stats panel with health bars, mood, inventory, and classic stats. + * Includes event listeners for editable fields. + */ +export function renderUserStats() { + if (!extensionSettings.showUserStats || !$userStatsContainer) { + return; + } + + const stats = extensionSettings.userStats; + const userName = getContext().name1; + + // Initialize lastGeneratedData.userStats if it doesn't exist + if (!lastGeneratedData.userStats) { + lastGeneratedData.userStats = `Health: ${stats.health}%\nSatiety: ${stats.satiety}%\nEnergy: ${stats.energy}%\nHygiene: ${stats.hygiene}%\nArousal: ${stats.arousal}%\n${stats.mood}: ${stats.conditions}\nInventory: ${stats.inventory}`; + } + + // Get user portrait - handle both default-user and custom persona folders + // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors + let userPortrait = FALLBACK_AVATAR_DATA_URI; + + if (user_avatar) { + // Try to get the thumbnail using our safe helper + const thumbnailUrl = getSafeThumbnailUrl('persona', user_avatar); + if (thumbnailUrl) { + userPortrait = thumbnailUrl; + } + } + + // Create gradient from low to high color + const gradient = `linear-gradient(to right, ${extensionSettings.statBarColorLow}, ${extensionSettings.statBarColorHigh})`; + + const html = ` +
+
+
+ ${userName} +
+
+ ${stats.inventory || 'None'} +
+
+
+
+
+ Health: +
+
+
+ ${stats.health}% +
+ +
+ Satiety: +
+
+
+ ${stats.satiety}% +
+ +
+ Energy: +
+
+
+ ${stats.energy}% +
+ +
+ Hygiene: +
+
+
+ ${stats.hygiene}% +
+ +
+ Arousal: +
+
+
+ ${stats.arousal}% +
+
+ +
+
${stats.mood}
+
${stats.conditions}
+
+
+ +
+
+
+
+ STR +
+ + ${extensionSettings.classicStats.str} + +
+
+
+ DEX +
+ + ${extensionSettings.classicStats.dex} + +
+
+
+ CON +
+ + ${extensionSettings.classicStats.con} + +
+
+
+ INT +
+ + ${extensionSettings.classicStats.int} + +
+
+
+ WIS +
+ + ${extensionSettings.classicStats.wis} + +
+
+
+ CHA +
+ + ${extensionSettings.classicStats.cha} + +
+
+
+
+
+
+ `; + + $userStatsContainer.html(html); + + // Add event listeners for editable stat values + $('.rpg-editable-stat').on('blur', function() { + const field = $(this).data('field'); + const textValue = $(this).text().replace('%', '').trim(); + let value = parseInt(textValue); + + // Validate and clamp value between 0 and 100 + if (isNaN(value)) { + value = 0; + } + value = Math.max(0, Math.min(100, value)); + + // Update the setting + extensionSettings.userStats[field] = value; + + // Also update lastGeneratedData to keep it in sync + if (!lastGeneratedData.userStats) { + lastGeneratedData.userStats = ''; + } + // Regenerate the userStats text with updated value + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + + // Re-render to update the bar + renderUserStats(); + }); + + // Add event listener for inventory editing + $('.rpg-inventory-items.rpg-editable').on('blur', function() { + const value = $(this).text().trim(); + extensionSettings.userStats.inventory = value || 'None'; + + // Update lastGeneratedData + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + }); + + // Add event listeners for mood/conditions editing + $('.rpg-mood-emoji.rpg-editable').on('blur', function() { + const value = $(this).text().trim(); + extensionSettings.userStats.mood = value || '😐'; + + // Update lastGeneratedData + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + }); + + $('.rpg-mood-conditions.rpg-editable').on('blur', function() { + const value = $(this).text().trim(); + extensionSettings.userStats.conditions = value || 'None'; + + // Update lastGeneratedData + const statsText = `Health: ${extensionSettings.userStats.health}%\nSatiety: ${extensionSettings.userStats.satiety}%\nEnergy: ${extensionSettings.userStats.energy}%\nHygiene: ${extensionSettings.userStats.hygiene}%\nArousal: ${extensionSettings.userStats.arousal}%\n${extensionSettings.userStats.mood}: ${extensionSettings.userStats.conditions}\nInventory: ${extensionSettings.userStats.inventory}`; + lastGeneratedData.userStats = statsText; + + saveSettings(); + saveChatData(); + updateMessageSwipeData(); + }); +} diff --git a/src/utils/avatars.js b/src/utils/avatars.js new file mode 100644 index 0000000..382870c --- /dev/null +++ b/src/utils/avatars.js @@ -0,0 +1,46 @@ +/** + * Avatar Utilities Module + * Handles safe avatar/thumbnail URL generation with error handling + */ + +import { getThumbnailUrl } from '../../../../../../script.js'; + +/** + * Safely retrieves a thumbnail URL from SillyTavern's API with error handling. + * Returns null instead of throwing errors to prevent extension crashes. + * + * @param {string} type - Type of thumbnail ('avatar' or 'persona') + * @param {string} filename - Filename of the avatar/persona + * @returns {string|null} Thumbnail URL or null if unavailable/error + */ +export function getSafeThumbnailUrl(type, filename) { + // Return null if no filename provided + if (!filename || filename === 'none') { + console.log(`[RPG Companion] No valid filename provided for ${type} thumbnail`); + return null; + } + + try { + // Attempt to get thumbnail URL from SillyTavern API + const url = getThumbnailUrl(type, filename); + + // Validate that we got a string back + if (typeof url !== 'string' || url.trim() === '') { + console.warn(`[RPG Companion] getThumbnailUrl returned invalid result for ${type}:`, filename); + return null; + } + + console.log(`[RPG Companion] Successfully generated ${type} thumbnail URL for: ${filename}`); + return url; + } catch (error) { + // Log detailed error information for debugging + console.error(`[RPG Companion] Failed to get ${type} thumbnail for "${filename}":`, error); + console.error('[RPG Companion] Error details:', { + type, + filename, + errorMessage: error.message, + errorStack: error.stack + }); + return null; + } +}