/** * 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, committedTrackerData, $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); // Update BOTH lastGeneratedData AND committedTrackerData // This makes manual edits immediately visible to AI committedTrackerData.characterThoughts = updatedLines.join('\n'); // 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); } }); }