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 += `
-
+
⚖️
@@ -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 += `
-
+

${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;