diff --git a/src/core/state.js b/src/core/state.js index d3e0e99..3ec579e 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -164,7 +164,8 @@ export let extensionSettings = { assets: 'list' // 'list' or 'grid' view mode for Assets section }, debugMode: false, // Enable debug logging visible in UI (for mobile debugging) - memoryMessagesToProcess: 16 // Number of messages to process per batch in memory recollection + memoryMessagesToProcess: 16, // Number of messages to process per batch in memory recollection + npcAvatars: {} // Store custom avatar images for NPCs (key: character name, value: base64 data URI) }; /** diff --git a/src/systems/rendering/thoughts.js b/src/systems/rendering/thoughts.js index e79be3e..fa19cea 100644 --- a/src/systems/rendering/thoughts.js +++ b/src/systems/rendering/thoughts.js @@ -16,6 +16,7 @@ import { } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js'; +import { saveSettings } from '../../core/persistence.js'; /** * Helper to log to both console and debug logs array @@ -101,6 +102,132 @@ function namesMatch(cardName, aiName) { return wordBoundary.test(aiCore); } +/** + * Gets the avatar URL for a character, checking custom NPC avatars first + * @param {string} characterName - Name of the character + * @returns {string} Avatar URL or fallback + */ +function getCharacterAvatar(characterName) { + // First, check if there's a custom NPC avatar + if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) { + debugLog(`[RPG Thoughts] Found custom NPC avatar for: ${characterName}`); + return extensionSettings.npcAvatars[characterName]; + } + + // Use the existing avatar lookup logic + let characterPortrait = FALLBACK_AVATAR_DATA_URI; + + // For group chats, search through group members first + if (selected_group) { + try { + const groupMembers = getGroupMembers(selected_group); + if (groupMembers && groupMembers.length > 0) { + const matchingMember = groupMembers.find(member => + member && member.name && namesMatch(member.name, characterName) + ); + + if (matchingMember && matchingMember.avatar && matchingMember.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingMember.avatar); + if (thumbnailUrl) { + return thumbnailUrl; + } + } + } + } catch (groupError) { + debugLog('[RPG Thoughts] Error checking group members:', groupError.message); + } + } + + // For regular chats or if not found in group, search all characters + if (characters && characters.length > 0) { + const matchingCharacter = characters.find(c => + c && c.name && namesMatch(c.name, characterName) + ); + + if (matchingCharacter && matchingCharacter.avatar && matchingCharacter.avatar !== 'none') { + const thumbnailUrl = getSafeThumbnailUrl('avatar', matchingCharacter.avatar); + if (thumbnailUrl) { + return 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 && namesMatch(characters[this_chid].name, characterName)) { + const thumbnailUrl = getSafeThumbnailUrl('avatar', characters[this_chid].avatar); + if (thumbnailUrl) { + return thumbnailUrl; + } + } + + return characterPortrait; +} + +/** + * Handles uploading a custom avatar for an NPC character + * @param {string} characterName - Name of the character to set avatar for + */ +function uploadNpcAvatar(characterName) { + // Create a file input element + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Validate file size (max 2MB to keep settings file reasonable) + if (file.size > 2 * 1024 * 1024) { + console.error('[RPG Companion] Image file too large. Maximum size is 2MB.'); + // You could add a toast notification here if available + return; + } + + // Validate file type + if (!file.type.startsWith('image/')) { + console.error('[RPG Companion] Invalid file type. Please select an image.'); + return; + } + + try { + // Read the file and convert to base64 data URI + const reader = new FileReader(); + reader.onload = (event) => { + const dataUri = event.target.result; + + // Initialize npcAvatars if it doesn't exist + if (!extensionSettings.npcAvatars) { + extensionSettings.npcAvatars = {}; + } + + // Store the avatar + extensionSettings.npcAvatars[characterName] = dataUri; + + // Save settings + saveSettings(); + + console.log(`[RPG Companion] Avatar uploaded for NPC: ${characterName}`); + + // Re-render to show the new avatar + renderThoughts(); + }; + + reader.onerror = (error) => { + console.error('[RPG Companion] Error reading image file:', error); + }; + + reader.readAsDataURL(file); + } catch (error) { + console.error('[RPG Companion] Error uploading avatar:', error); + } + }; + + // Trigger the file input + input.click(); +} + /** * Renders character thoughts (Present Characters) panel. * Displays character cards with avatars, relationship badges, and traits. @@ -280,7 +407,7 @@ export function renderThoughts() { html += '
'; html += `
-
+
${escapedDefaultName}
⚖️
@@ -314,64 +441,8 @@ export function renderThoughts() { 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'); - } - } + // Find character portrait using the new helper function + const characterPortrait = getCharacterAvatar(char.name); debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...'); @@ -394,7 +465,7 @@ export function renderThoughts() { html += `
-
+
${escapedName} ${hasRelationshipEnabled ? `
${relationshipBadge}
` : ''}
@@ -466,6 +537,40 @@ export function renderThoughts() { updateCharacterField(character, field, value); }); + // Add event handler for avatar uploads + $thoughtsContainer.find('.rpg-avatar-upload').on('click', function(e) { + // Prevent triggering if clicking on the relationship badge + if ($(e.target).hasClass('rpg-relationship-badge') || $(e.target).closest('.rpg-relationship-badge').length > 0) { + return; + } + + const characterName = $(this).data('character'); + console.log('[RPG Companion] Avatar upload clicked for:', characterName); + uploadNpcAvatar(characterName); + }); + + // Add event handler for removing custom avatars (right-click) + $thoughtsContainer.find('.rpg-avatar-upload').on('contextmenu', function(e) { + // Prevent triggering if clicking on the relationship badge + if ($(e.target).hasClass('rpg-relationship-badge') || $(e.target).closest('.rpg-relationship-badge').length > 0) { + return; + } + + e.preventDefault(); // Prevent default context menu + const characterName = $(this).data('character'); + + // Check if this character has a custom avatar + if (extensionSettings.npcAvatars && extensionSettings.npcAvatars[characterName]) { + // Remove the custom avatar + delete extensionSettings.npcAvatars[characterName]; + saveSettings(); + console.log(`[RPG Companion] Removed custom avatar for: ${characterName}`); + + // Re-render to show the default avatar + renderThoughts(); + } + }); + // Remove updating class after animation if (extensionSettings.enableAnimations) { setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); diff --git a/style.css b/style.css index db72d6a..13b2bc0 100644 --- a/style.css +++ b/style.css @@ -1881,6 +1881,49 @@ body:has(.rpg-panel.rpg-position-left) #sheld { display: block; /* Prevent inline spacing issues */ } +/* Uploadable avatar - make it clickable */ +.rpg-avatar-upload { + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.rpg-avatar-upload::after { + content: '📷'; + position: absolute; + bottom: -2px; + right: -2px; + font-size: clamp(10px, 1.5vh, 14px); + background: var(--rpg-bg); + border: 1px solid var(--rpg-highlight); + border-radius: 50%; + width: clamp(18px, 2.5vh, 22px); + height: clamp(18px, 2.5vh, 22px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.rpg-avatar-upload:hover::after { + opacity: 1; +} + +.rpg-avatar-upload:hover { + transform: scale(1.05); +} + +.rpg-avatar-upload:hover img { + opacity: 0.8; + border-color: var(--rpg-text); +} + +.rpg-avatar-upload:active { + transform: scale(0.98); +} + /* Relationship badge in top-right corner */ .rpg-relationship-badge { position: absolute;