feat: Add custom avatar upload for NPCs in Present Characters panel

- Add npcAvatars storage to extension settings for custom NPC images
- Implement getCharacterAvatar() to check custom avatars first
- Add uploadNpcAvatar() function with file validation (2MB max, images only)
- Make character avatars clickable with visual feedback
- Support left-click to upload and right-click to remove custom avatars
- Add camera icon overlay on hover with smooth animations
- Store avatars as base64 data URIs for persistence across sessions
This commit is contained in:
Spicy_Marinara
2025-12-18 14:14:49 +01:00
parent 5bc7bfe22f
commit ab7dfeaf8b
3 changed files with 210 additions and 61 deletions
+2 -1
View File
@@ -164,7 +164,8 @@ export let extensionSettings = {
assets: 'list' // 'list' or 'grid' view mode for Assets section assets: 'list' // 'list' or 'grid' view mode for Assets section
}, },
debugMode: false, // Enable debug logging visible in UI (for mobile debugging) 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)
}; };
/** /**
+165 -60
View File
@@ -16,6 +16,7 @@ import {
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData } from '../../core/persistence.js';
import { getSafeThumbnailUrl } from '../../utils/avatars.js'; import { getSafeThumbnailUrl } from '../../utils/avatars.js';
import { saveSettings } from '../../core/persistence.js';
/** /**
* Helper to log to both console and debug logs array * Helper to log to both console and debug logs array
@@ -101,6 +102,132 @@ function namesMatch(cardName, aiName) {
return wordBoundary.test(aiCore); 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. * Renders character thoughts (Present Characters) panel.
* Displays character cards with avatars, relationship badges, and traits. * Displays character cards with avatars, relationship badges, and traits.
@@ -280,7 +407,7 @@ export function renderThoughts() {
html += '<div class="rpg-thoughts-content">'; html += '<div class="rpg-thoughts-content">';
html += ` html += `
<div class="rpg-character-card" data-character-name="${escapedDefaultName}"> <div class="rpg-character-card" data-character-name="${escapedDefaultName}">
<div class="rpg-character-avatar"> <div class="rpg-character-avatar rpg-avatar-upload" data-character="${escapedDefaultName}" title="Click to upload custom avatar&#10;Right-click to remove custom avatar">
<img src="${defaultPortrait}" alt="${escapedDefaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" /> <img src="${defaultPortrait}" alt="${escapedDefaultName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div> <div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedDefaultName}" data-field="relationship" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">⚖️</div>
</div> </div>
@@ -314,64 +441,8 @@ export function renderThoughts() {
try { try {
debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name); debugLog(`[RPG Thoughts] Building HTML for character ${characterIndex}/${presentCharacters.length}:`, char.name);
// Find character portrait // Find character portrait using the new helper function
// Use a base64-encoded SVG placeholder as fallback to avoid 400 errors const characterPortrait = getCharacterAvatar(char.name);
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) + '...'); debugLog(`[RPG Thoughts] Final avatar for ${char.name}:`, characterPortrait.substring(0, 50) + '...');
@@ -394,7 +465,7 @@ export function renderThoughts() {
html += ` html += `
<div class="rpg-character-card" data-character-name="${escapedName}"> <div class="rpg-character-card" data-character-name="${escapedName}">
<div class="rpg-character-avatar"> <div class="rpg-character-avatar rpg-avatar-upload" data-character="${escapedName}" title="Click to upload custom avatar&#10;Right-click to remove custom avatar">
<img src="${characterPortrait}" alt="${escapedName}" onerror="this.style.opacity='0.5';this.onerror=null;" /> <img src="${characterPortrait}" alt="${escapedName}" onerror="this.style.opacity='0.5';this.onerror=null;" />
${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipBadge}</div>` : ''} ${hasRelationshipEnabled ? `<div class="rpg-relationship-badge rpg-editable" contenteditable="true" data-character="${escapedName}" data-field="${relationshipFieldName}" title="Click to edit (use emoji: ⚔️ ⚖️ ⭐ ❤️)">${relationshipBadge}</div>` : ''}
</div> </div>
@@ -466,6 +537,40 @@ export function renderThoughts() {
updateCharacterField(character, field, value); 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 // Remove updating class after animation
if (extensionSettings.enableAnimations) { if (extensionSettings.enableAnimations) {
setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600); setTimeout(() => $thoughtsContainer.removeClass('rpg-content-updating'), 600);
+43
View File
@@ -1881,6 +1881,49 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
display: block; /* Prevent inline spacing issues */ 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 */ /* Relationship badge in top-right corner */
.rpg-relationship-badge { .rpg-relationship-badge {
position: absolute; position: absolute;