/** * 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, addDebugLog } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; /** * Helper to log to both console and debug logs array */ function debugLog(message, data = null) { console.log(message, data || ''); if (extensionSettings.debugMode) { addDebugLog(message, data); } } /** * Escapes HTML attribute values to prevent quotes from breaking HTML */ function escapeHtmlAttr(str) { if (!str) return ''; return String(str).replace(/"/g, '"').replace(/'/g, '''); } /** * Interpolates color based on percentage value between low and high colors * @param {number} percentage - Value from 0-100 * @param {string} lowColor - Hex color for low values (e.g., '#ff0000') * @param {string} highColor - Hex color for high values (e.g., '#00ff00') * @returns {string} Interpolated hex color */ function getStatColor(percentage, lowColor, highColor) { // Clamp percentage to 0-100 const percent = Math.max(0, Math.min(100, percentage)) / 100; // Parse hex colors const parsehex = (hex) => { const clean = hex.replace('#', ''); return { r: parseInt(clean.substring(0, 2), 16), g: parseInt(clean.substring(2, 4), 16), b: parseInt(clean.substring(4, 6), 16) }; }; const low = parsehex(lowColor); const high = parsehex(highColor); // Interpolate each channel const r = Math.round(low.r + (high.r - low.r) * percent); const g = Math.round(low.g + (high.g - low.g) * percent); const b = Math.round(low.b + (high.b - low.b) * percent); // Convert back to hex const toHex = (n) => n.toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } /** * Fuzzy name matching that handles: * - Exact matches: "Sabrina" === "Sabrina" * - Parenthetical additions: "Sabrina" matches "Sabrina (Margrokha's Avatar)" * - Title additions: "Sabrina" matches "Princess Sabrina" * - Word boundaries: "Sabrina" won't match "Sabrina's Mother" * * @param {string} cardName - Name from the character card * @param {string} aiName - Name generated by the AI * @returns {boolean} True if names match */ function namesMatch(cardName, aiName) { if (!cardName || !aiName) return false; // 1. Exact match (fast path) if (cardName.toLowerCase() === aiName.toLowerCase()) return true; // 2. Strip parentheses and quotes from both names and match // This allows "Dottore (Prime)" to match "Dottore" card for avatar lookup // and "Marianna "Mari"" to match "Marianna" or "Mari" cards const stripParensAndQuotes = (s) => s.replace(/\s*\([^)]*\)/g, '').replace(/["']/g, '').trim(); const cardCore = stripParensAndQuotes(cardName).toLowerCase(); const aiCore = stripParensAndQuotes(aiName).toLowerCase(); if (cardCore === aiCore) return true; // 3. Check if card name appears as complete word in AI name // Escape special regex characters to prevent "Invalid regular expression" errors const escapedCardCore = cardCore.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const wordBoundary = new RegExp(`\\b${escapedCardCore}\\b`); return wordBoundary.test(aiCore); } /** * Gets relationship emoji from relationship string * Returns a default emoji (⚖️) if relationship is not in the predefined map */ function getRelationshipEmoji(relationship) { if (!relationship) return null; const map = { 'Enemy': '⚔️', 'Neutral': '⚖️', 'Friend': '⭐', 'Lover': '❤️', 'Ally': '🤝', 'Rival': '🎯', 'Family': '👨‍👩‍👧', 'Stranger': '❓' }; // Return mapped emoji or default '⚖️' for unknown relationships return map[relationship] || '⚖️'; } /** * Gets character avatar URL */ function getCharacterAvatarUrl(characterName) { // Try to find matching character from SillyTavern try { const context = getContext(); if (context && characters) { const char = characters.find(c => namesMatch(c.name, characterName)); if (char && char.avatar) { return getSafeThumbnailUrl('avatar', char.avatar); } } } catch (e) { debugLog('[RPG Thoughts] Error getting avatar:', e); } return FALLBACK_AVATAR_DATA_URI; } export function renderThoughts() { if (!extensionSettings.showCharacterThoughts || !$thoughtsContainer) { return; } debugLog('[RPG Thoughts] ==================== RENDERING PRESENT CHARACTERS ===================='); debugLog('[RPG Thoughts] showCharacterThoughts setting:', extensionSettings.showCharacterThoughts); debugLog('[RPG Thoughts] Container exists:', !!$thoughtsContainer); // Add updating class for animation if (extensionSettings.enableAnimations) { $thoughtsContainer.addClass('rpg-content-updating'); } // Get tracker configuration const config = extensionSettings.trackerConfig?.presentCharacters; const enabledFields = config?.customFields?.filter(f => f && f.enabled && f.name) || []; const characterStatsConfig = config?.characterStats; const enabledCharStats = characterStatsConfig?.enabled && characterStatsConfig?.customStats?.filter(s => s && s.enabled && s.name) || []; const relationshipFields = config?.relationshipFields || []; const hasRelationshipEnabled = relationshipFields.length > 0; // Convert structured character data to text format for the original fancy renderer let characterThoughtsData = lastGeneratedData.characterThoughts || committedTrackerData.characterThoughts || ''; // If we have structured data, convert it to text format if (extensionSettings.charactersData && Array.isArray(extensionSettings.charactersData) && extensionSettings.charactersData.length > 0) { const lines = []; for (const char of extensionSettings.charactersData) { // Character name line lines.push(`- ${char.name || 'Unknown'}`); // Details line with emoji and fields const details = [char.emoji || '😶']; const charFields = char.fields || {}; for (const [key, value] of Object.entries(charFields)) { if (value) details.push(`${key}: ${value}`); } lines.push(`Details: ${details.join(' | ')}`); // Relationship line if (char.relationship) { lines.push(`Relationship: ${char.relationship}`); } // Stats line const charStats = char.stats || {}; if (Object.keys(charStats).length > 0) { const statsStr = Object.entries(charStats).map(([k, v]) => `${k}: ${v}%`).join(' | '); lines.push(`Stats: ${statsStr}`); } // Thoughts line if (char.thoughts) { const thoughtsFieldName = config?.thoughts?.name || 'Thoughts'; lines.push(`${thoughtsFieldName}: ${char.thoughts}`); } } if (lines.length > 0) { characterThoughtsData = lines.join('\n'); debugLog('[RPG Thoughts] Converted structured data to text format'); } } debugLog('[RPG Thoughts] Raw characterThoughts data:', characterThoughtsData); debugLog('[RPG Thoughts] Data length:', characterThoughtsData.length + ' chars'); debugLog('[RPG Thoughts] Enabled custom fields:', enabledFields.map(f => f.name)); debugLog('[RPG Thoughts] Enabled character stats:', enabledCharStats.map(s => s.name)); const lines = characterThoughtsData.split('\n'); const presentCharacters = []; debugLog('[RPG Thoughts] Split into lines count:', lines.length); debugLog('[RPG Thoughts] Lines:', lines); // Parse new multi-line format: // - [Name] // Details: [Emoji] | [Field1] | [Field2] | ... // Relationship: [Relationship] // Stats: Stat1: X% | Stat2: X% | ... // Thoughts: [Description] let lineNumber = 0; let currentCharacter = null; // Pre-process: normalize the format to handle cases where "- char" appears mid-line // This handles: "Thoughts: ... - char 2" by splitting it into separate lines const normalizedLines = []; for (let line of lines) { // Check if line contains "- [name]" pattern after some content (not at start) // Match pattern like "some text - CharName" where there's content before the dash const midLineCharMatch = line.match(/^(.+?)\s+-\s+([A-Z][a-zA-Z\s]+)$/); if (midLineCharMatch && !line.trim().startsWith('- ')) { // Split: first part stays as one line, "- Name" becomes new line normalizedLines.push(midLineCharMatch[1].trim()); normalizedLines.push('- ' + midLineCharMatch[2].trim()); } else { normalizedLines.push(line); } } for (const line of normalizedLines) { lineNumber++; // Skip empty lines, headers, dividers, and code fences if (!line.trim() || line.includes('Present Characters') || line.includes('---') || line.trim().startsWith('```') || line.trim() === '- …' || line.includes('(Repeat the format')) { continue; } debugLog(`[RPG Thoughts] Processing line ${lineNumber}:`, line); // Check if this is a character name line (starts with "- ") if (line.trim().startsWith('- ')) { const name = line.trim().substring(2).trim(); if (name && name.toLowerCase() !== 'unavailable') { currentCharacter = { name }; presentCharacters.push(currentCharacter); debugLog(`[RPG Thoughts] ✓ Started new character: ${name}`); } else { currentCharacter = null; debugLog(`[RPG Thoughts] ✗ Rejected character - name: "${name}" (unavailable or empty)`); } } // Check if this is a Details line else if (line.trim().startsWith('Details:') && currentCharacter) { const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const parts = detailsContent.split('|').map(p => p.trim()); // First part is the emoji if (parts.length > 0) { currentCharacter.emoji = parts[0]; debugLog(`[RPG Thoughts] Parsed emoji: ${parts[0]}`); } // Remaining parts are custom fields for (let i = 0; i < enabledFields.length && i + 1 < parts.length; i++) { const fieldName = enabledFields[i].name; currentCharacter[fieldName] = parts[i + 1]; debugLog(`[RPG Thoughts] Parsed field ${fieldName}: ${parts[i + 1]}`); } } // Check if this is a Relationship line else if (line.trim().startsWith('Relationship:') && currentCharacter) { const relationship = line.substring(line.indexOf(':') + 1).trim(); currentCharacter.Relationship = relationship; debugLog(`[RPG Thoughts] Parsed relationship: ${relationship}`); } // Check if this is a Stats line else if (line.trim().startsWith('Stats:') && currentCharacter && enabledCharStats.length > 0) { const statsContent = line.substring(line.indexOf(':') + 1).trim(); const statParts = statsContent.split('|').map(p => p.trim()); for (const statPart of statParts) { const statMatch = statPart.match(/^(.+?):\s*(\d+)%$/); if (statMatch) { const statName = statMatch[1].trim(); const statValue = parseInt(statMatch[2]); currentCharacter[statName] = statValue; debugLog(`[RPG Thoughts] Parsed stat: ${statName} = ${statValue}%`); } } } // Check if this is a Thoughts line (handled separately for thought bubbles) else if (line.trim().match(/^[A-Z][a-z]+:/) && currentCharacter) { // This could be Thoughts, Feelings, etc. - skip for now, handled in thought bubble rendering debugLog(`[RPG Thoughts] Skipping thoughts/feelings line (handled in bubble rendering)`); } } // Get relationship emojis from config (with fallback defaults) const relationshipEmojis = config?.relationshipEmojis || { 'Enemy': '⚔️', 'Neutral': '⚖️', 'Friend': '⭐', 'Lover': '❤️' }; debugLog('[RPG Thoughts] ==================== PARSING COMPLETE ===================='); debugLog('[RPG Thoughts] Total characters parsed:', presentCharacters.length); debugLog('[RPG Thoughts] Characters array:', presentCharacters); // Build HTML let html = ''; debugLog('[RPG Thoughts] ==================== BUILDING HTML ===================='); debugLog('[RPG Thoughts] Starting HTML generation for', presentCharacters.length + ' characters'); // If no characters parsed, show a placeholder editable card if (presentCharacters.length === 0) { debugLog('[RPG Thoughts] ⚠ No characters parsed - showing placeholder card'); // Get default character portrait 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'; } const escapedDefaultName = escapeHtmlAttr(defaultName); html += '
'; html += `
${escapedDefaultName}
⚖️
😊 ${defaultName}
`; // Add custom fields dynamically for (const field of enabledFields) { const fieldId = field.name.toLowerCase().replace(/\s+/g, '-'); html += `
`; } html += `
`; html += '
'; } else { html += '
'; let characterIndex = 0; for (const char of presentCharacters) { characterIndex++; try { debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name); // Find character portrait // Use a base64-encoded SVG placeholder as fallback to avoid 400 errors let characterPortrait = FALLBACK_AVATAR_DATA_URI; debugLog(`[RPG Thoughts] Looking up avatar for: ${char.name}`); // For group chats, search through group members first if (selected_group) { debugLog('[RPG Thoughts] In group chat, checking group members...'); try { const groupMembers = getGroupMembers(selected_group); debugLog('[RPG Thoughts] Group members count:', groupMembers ? groupMembers.length : 0); if (groupMembers && groupMembers.length > 0) { const matchingMember = groupMembers.find(member => member && member.name && namesMatch(member.name, char.name) ); if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; debugLog('[RPG Thoughts] Found avatar in group members'); } } } } catch (groupError) { debugLog('[RPG Thoughts] Error checking group members:', groupError.message); } } // For regular chats or if not found in group, search all characters if (characterPortrait === FALLBACK_AVATAR_DATA_URI && characters && characters.length > 0) { debugLog('[RPG Thoughts] Searching all characters...'); const matchingCharacter = characters.find(c => c && c.name && namesMatch(c.name, char.name) ); if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; debugLog('[RPG Thoughts] Found avatar in all characters'); } } } // 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 && namesMatch(characters[this_chid].name, char.name)) { const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); if (thumbnailUrl) { characterPortrait = thumbnailUrl; debugLog('[RPG Thoughts] Found avatar from current character'); } } debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...'); // Get relationship badge - only if relationships are enabled in config let relationshipBadge = '⚖️'; // Default emoji let relationshipText = 'Neutral'; // Default text for tooltip let relationshipFieldName = 'Relationship'; if (hasRelationshipEnabled) { // In the new format, relationship is always stored in char.Relationship if (char.Relationship) { relationshipText = char.Relationship; // Try to map text to emoji, fall back to default link emoji for unknown types relationshipBadge = relationshipEmojis[char.Relationship] || '⚖️'; } } debugLog(`[RPG Thoughts] Building HTML card for ${char.name}...`); // Escape character name for use in HTML attributes const escapedName = escapeHtmlAttr(char.name); html += `
${escapedName} ${hasRelationshipEnabled ? `
${relationshipBadge}
` : ''}
${char.emoji} ${char.name}
`; // Render custom fields dynamically for (const field of enabledFields) { const fieldValue = char[field.name] || ''; const fieldId = field.name.toLowerCase().replace(/\s+/g, '-'); html += `
${fieldValue}
`; } html += `
`; // Render character stats if enabled (outside rpg-character-info) if (enabledCharStats.length > 0) { html += `
`; for (const stat of enabledCharStats) { const statValue = char[stat.name] || 0; const statColor = getStatColor(statValue, extensionSettings.statBarColorLow, extensionSettings.statBarColorHigh); html += `
${stat.name}: ${statValue}%
`; } html += `
`; } html += `
`; debugLog(`[RPG Thoughts] ✓ Successfully built HTML for ${char.name}`); } catch (charError) { debugLog(`[RPG Thoughts] ✗ ERROR building HTML for ${char.name}:`, charError.message); debugLog('[RPG Thoughts] Error stack:', charError.stack); // Continue with next character instead of crashing } } debugLog('[RPG Thoughts] Finished building all character cards'); html += '
'; } $thoughtsContainer.html(html); debugLog('[RPG Thoughts] ✓ HTML rendered to container'); debugLog('[RPG Thoughts] ======================================================='); // 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(); console.log('[RPG Companion] Character stat edit:', { character, field, value }); updateCharacterField(character, field, value); }); // Add event handlers for remove character buttons $thoughtsContainer.find('.rpg-character-remove').on('click', function(e) { e.stopPropagation(); const characterName = $(this).data('character'); if (characterName && confirm(`Remove ${characterName} from present characters?`)) { removeCharacter(characterName); } }); // 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. * Works with the new multi-line format. * * @param {string} characterName - Name of the character to update * @param {string} field - Field to update (emoji, name, custom field name, Relationship, stat name) * @param {string} value - New value for the field */ export function updateCharacterField(characterName, field, value) { // Initialize if it doesn't exist if (!lastGeneratedData.characterThoughts) { lastGeneratedData.characterThoughts = 'Present Characters\n---\n'; } const lines = lastGeneratedData.characterThoughts.split('\n'); const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters; const enabledFields = presentCharsConfig?.customFields?.filter(f => f && f.enabled && f.name) || []; const characterStats = presentCharsConfig?.characterStats; const enabledCharStats = characterStats?.enabled && characterStats?.customStats?.filter(s => s && s.enabled && s.name) || []; let characterFound = false; let inTargetCharacter = false; let characterStartIndex = -1; let characterEndIndex = -1; // Find the character block for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('- ')) { const name = line.substring(2).trim(); if (name.toLowerCase() === characterName.toLowerCase()) { characterFound = true; inTargetCharacter = true; characterStartIndex = i; } else if (inTargetCharacter) { characterEndIndex = i; break; } } } if (characterFound && characterEndIndex === -1) { characterEndIndex = lines.length; } if (characterFound) { // Check if we're updating a character stat const isStatField = enabledCharStats.findIndex(s => s.name === field) !== -1; let statsLineExists = false; let statsLineIndex = -1; // Get the configured thoughts field name const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; const isThoughtsField = field.toLowerCase() === 'thoughts' || field === thoughtsFieldName; // First pass: check if Stats line exists and update other fields for (let i = characterStartIndex; i < characterEndIndex; i++) { const line = lines[i].trim(); if (line.startsWith('Stats:')) { statsLineExists = true; statsLineIndex = i; } if (field === 'name' && line.startsWith('- ')) { lines[i] = `- ${value}`; } else if (field === 'emoji' && line.startsWith('Details:')) { const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim()); parts[0] = value; lines[i] = `Details: ${parts.join(' | ')}`; } else if (line.startsWith('Details:')) { const fieldIndex = enabledFields.findIndex(f => f.name === field); if (fieldIndex !== -1) { const parts = line.substring(line.indexOf(':') + 1).split('|').map(p => p.trim()); if (parts.length > fieldIndex + 1) { parts[fieldIndex + 1] = value; lines[i] = `Details: ${parts.join(' | ')}`; } } } else if (field === 'Relationship' && line.startsWith('Relationship:')) { const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' }; const relationshipValue = emojiToRelationship[value] || value; lines[i] = `Relationship: ${relationshipValue}`; } else if (isThoughtsField && line.startsWith(thoughtsFieldName + ':')) { // Update thoughts field lines[i] = `${thoughtsFieldName}: ${value}`; console.log('[RPG Companion] Updated thoughts:', lines[i]); } } // Handle stat updates if (isStatField) { // Clean the value: remove % if present, parse as integer, clamp 0-100 let cleanValue = value.replace('%', '').trim(); let numValue = parseInt(cleanValue); if (isNaN(numValue)) { numValue = 0; } numValue = Math.max(0, Math.min(100, numValue)); console.log('[RPG Companion] Updating stat:', { field, rawValue: value, cleanValue, numValue }); if (statsLineExists) { // Update existing Stats line const line = lines[statsLineIndex]; const statsContent = line.substring(line.indexOf(':') + 1).trim(); const statParts = statsContent.split('|').map(p => p.trim()); let statFound = false; for (let j = 0; j < statParts.length; j++) { if (statParts[j].startsWith(field + ':')) { statParts[j] = `${field}: ${numValue}%`; statFound = true; console.log('[RPG Companion] Updated stat part:', statParts[j]); break; } } // If stat wasn't found in existing parts, add it if (!statFound) { statParts.push(`${field}: ${numValue}%`); console.log('[RPG Companion] Added new stat to existing line:', `${field}: ${numValue}%`); } lines[statsLineIndex] = `Stats: ${statParts.join(' | ')}`; console.log('[RPG Companion] Updated stats line:', lines[statsLineIndex]); } else { // Create new Stats line with all enabled stats (defaulting to 0% except the one being edited) const statsParts = enabledCharStats.map(s => { if (s.name === field) { return `${s.name}: ${numValue}%`; } return `${s.name}: 0%`; }); const newStatsLine = `Stats: ${statsParts.join(' | ')}`; // Insert before Thoughts line or at end of character block let insertIndex = characterEndIndex; for (let i = characterStartIndex; i < characterEndIndex; i++) { const line = lines[i].trim(); const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; if (line.startsWith(thoughtsFieldName + ':')) { insertIndex = i; break; } } lines.splice(insertIndex, 0, newStatsLine); console.log('[RPG Companion] Created new stats line:', newStatsLine); characterEndIndex++; // Adjust end index since we inserted a line } } } else { // Create new character block const dividerIndex = lines.findIndex(line => line.includes('---')); if (dividerIndex >= 0) { const newCharacterLines = [`- ${characterName}`]; let detailsParts = [field === 'emoji' ? value : '😊']; for (let i = 0; i < enabledFields.length; i++) { detailsParts.push(field === enabledFields[i].name ? value : ''); } newCharacterLines.push(`Details: ${detailsParts.join(' | ')}`); if (presentCharsConfig?.relationshipFields?.length > 0) { const emojiToRelationship = { '⚔️': 'Enemy', '⚖️': 'Neutral', '⭐': 'Friend', '❤️': 'Lover' }; const relationshipValue = field === 'Relationship' ? (emojiToRelationship[value] || value) : 'Neutral'; newCharacterLines.push(`Relationship: ${relationshipValue}`); } if (enabledCharStats.length > 0) { const statsParts = enabledCharStats.map(s => { if (field === s.name) { // Clean the value: remove % if present, parse as integer, clamp 0-100 let cleanValue = value.replace('%', '').trim(); let numValue = parseInt(cleanValue); if (isNaN(numValue)) { numValue = 0; } numValue = Math.max(0, Math.min(100, numValue)); return `${s.name}: ${numValue}%`; } return `${s.name}: 0%`; }); newCharacterLines.push(`Stats: ${statsParts.join(' | ')}`); } lines.splice(dividerIndex + 1, 0, ...newCharacterLines); } } lastGeneratedData.characterThoughts = lines.join('\n'); committedTrackerData.characterThoughts = lines.join('\n'); console.log('[RPG Companion] Updated characterThoughts data:', lastGeneratedData.characterThoughts); 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].characterThoughts = lines.join('\n'); } } break; } } } saveChatData(); renderThoughts(); const thoughtsFieldName = presentCharsConfig?.thoughts?.name || 'Thoughts'; if (field === thoughtsFieldName) { setTimeout(() => updateChatThoughts(), 100); } else { updateChatThoughts(); } } /** * Removes a character from Present Characters data and re-renders. * Works with both structured (charactersData) and text (characterThoughts) formats. * * @param {string} characterName - Name of the character to remove */ export function removeCharacter(characterName) { console.log('[RPG Companion] Removing character:', characterName); // Remove from structured data if it exists if (extensionSettings.charactersData && Array.isArray(extensionSettings.charactersData)) { const initialLength = extensionSettings.charactersData.length; extensionSettings.charactersData = extensionSettings.charactersData.filter( char => char.name && char.name.toLowerCase() !== characterName.toLowerCase() ); if (extensionSettings.charactersData.length < initialLength) { console.log('[RPG Companion] Removed character from structured data'); } } // Remove from text format if (!lastGeneratedData.characterThoughts) { console.log('[RPG Companion] No characterThoughts data to remove from'); return; } const lines = lastGeneratedData.characterThoughts.split('\n'); const presentCharsConfig = extensionSettings.trackerConfig?.presentCharacters; let characterFound = false; let inTargetCharacter = false; let characterStartIndex = -1; let characterEndIndex = -1; const linesToRemove = []; // Find the character block for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('- ')) { const name = line.substring(2).trim(); if (name.toLowerCase() === characterName.toLowerCase()) { characterFound = true; inTargetCharacter = true; characterStartIndex = i; linesToRemove.push(i); } else if (inTargetCharacter) { characterEndIndex = i; break; } } else if (inTargetCharacter) { // Include all lines until the next character or end of file linesToRemove.push(i); // Check if this is a character name line (next character) if (line.startsWith('- ')) { characterEndIndex = i; break; } } } if (characterFound && characterEndIndex === -1) { characterEndIndex = lines.length; } if (characterFound && linesToRemove.length > 0) { // Remove lines in reverse order to maintain indices for (let i = linesToRemove.length - 1; i >= 0; i--) { lines.splice(linesToRemove[i], 1); } // Clean up any trailing empty lines after removal while (lines.length > 0 && lines[lines.length - 1].trim() === '') { lines.pop(); } lastGeneratedData.characterThoughts = lines.join('\n'); committedTrackerData.characterThoughts = lines.join('\n'); console.log('[RPG Companion] Removed character from text format'); // Update chat 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].characterThoughts = lines.join('\n'); } } break; } } } saveChatData(); renderThoughts(); updateChatThoughts(); } else { console.log('[RPG Companion] Character not found in text format:', characterName); } } /** * 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} const thoughtsConfig = extensionSettings.trackerConfig?.presentCharacters?.thoughts; const thoughtsLabel = thoughtsConfig?.name || 'Thoughts'; // console.log('[RPG Companion] Parsing thoughts from lines:', lines); // Parse new format to build character map and thoughts let currentCharName = null; let currentCharEmoji = null; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line || line.includes('Present Characters') || line.includes('---') || line.startsWith('```') || line.trim() === '- …' || line.includes('(Repeat the format')) { continue; } // Check if this is a character name line (starts with "- ") if (line.startsWith('- ')) { const name = line.substring(2).trim(); if (name && name.toLowerCase() !== 'unavailable') { currentCharName = name; currentCharEmoji = null; // Reset emoji for new character } else { currentCharName = null; currentCharEmoji = null; } } // Check if this is a Details line (contains the emoji) else if (line.startsWith('Details:') && currentCharName) { const detailsContent = line.substring(line.indexOf(':') + 1).trim(); const parts = detailsContent.split('|').map(p => p.trim()); // First part is the emoji if (parts.length > 0) { currentCharEmoji = parts[0]; } } // Check if this is a Thoughts line else if (line.startsWith(thoughtsLabel + ':') && currentCharName && currentCharEmoji) { const thoughtContent = line.substring(thoughtsLabel.length + 1).trim(); // The thought content is just the text (no emoji prefix in new format) if (thoughtContent) { thoughtsArray.push({ name: currentCharName.toLowerCase(), emoji: currentCharEmoji, thought: thoughtContent }); } } } debugLog('[RPG Thoughts] Parsed thoughts:', thoughtsArray); // 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) => { const escapedThoughtName = escapeHtmlAttr(thought.name); 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 }); // Check if always show bubble is enabled if (extensionSettings.alwaysShowThoughtBubble) { // Always show panel expanded, hide both close button and icon $thoughtPanel.show(); $thoughtPanel.find('.rpg-thought-close').hide(); $thoughtIcon.hide(); } else { // Initially hide the panel and show the icon $thoughtPanel.hide(); $thoughtIcon.show(); // Close button functionality - only when always show is disabled $thoughtPanel.find('.rpg-thought-close').on('click', function(e) { e.stopPropagation(); $thoughtPanel.fadeOut(200); $thoughtIcon.fadeIn(200); }); // Icon click to show panel - only when always show is disabled $thoughtIcon.on('click', function(e) { e.stopPropagation(); $thoughtIcon.fadeOut(200); $thoughtPanel.fadeIn(200); }); } // console.log('[RPG Companion] Thought panel created at:', { top, left }); // 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 - only if always show is disabled if (!extensionSettings.alwaysShowThoughtBubble) { $(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); } }); } }