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:
+2
-1
@@ -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)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 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 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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user