feat: add character avatars with upload and zoom functionality

- Add avatar upload with file picker dialog
- Display avatars in header, chat messages, and settings
- Implement clickable avatars with full-screen zoom modal
- Enable asset protocol for local file access in Tauri config
- Add tauri-plugin-dialog for native file selection
- Store avatars in ~/.config/claudia/avatars/
- Support PNG, JPG, JPEG, and WEBP formats
- Modal closes on overlay click or ESC key
This commit is contained in:
2025-10-13 18:49:34 -07:00
parent 6a8f1e0996
commit 90bbeb4468
7 changed files with 595 additions and 5 deletions

View File

@@ -12,6 +12,66 @@ let characterHeaderName;
let newCharacterBtn;
let currentCharacter = null;
let pendingAvatarPath = null;
// Helper function to get avatar URL
async function getAvatarUrl(avatarFilename) {
if (!avatarFilename) return null;
try {
const fullPath = await invoke('get_avatar_full_path', { avatarFilename });
console.log('Avatar full path:', fullPath);
// Try to use convertFileSrc if available
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.convertFileSrc) {
const url = window.__TAURI__.core.convertFileSrc(fullPath);
console.log('Converted URL:', url);
return url;
} else {
// Fallback to using the path directly with proper protocol
const url = `asset://localhost/${fullPath}`;
console.log('Using asset protocol URL:', url);
return url;
}
} catch (error) {
console.error('Failed to get avatar URL for', avatarFilename, ':', error);
return null;
}
}
// Show avatar in modal
function showAvatarModal(avatarUrl) {
const modal = document.getElementById('avatar-modal');
const modalImg = document.getElementById('avatar-modal-img');
modalImg.src = avatarUrl;
modal.style.display = 'flex';
// Fade in animation
modal.style.opacity = '0';
setTimeout(() => {
modal.style.opacity = '1';
modal.style.transition = 'opacity 0.2s ease';
}, 10);
}
// Hide avatar modal
function hideAvatarModal() {
const modal = document.getElementById('avatar-modal');
modal.style.opacity = '0';
setTimeout(() => {
modal.style.display = 'none';
}, 200);
}
// Make avatar clickable
function makeAvatarClickable(avatarElement, avatarUrl) {
if (!avatarUrl) return;
avatarElement.addEventListener('click', (e) => {
e.stopPropagation();
showAvatarModal(avatarUrl);
});
}
// Auto-resize textarea
function autoResize(textarea) {
@@ -26,7 +86,16 @@ function addMessage(content, isUser = false) {
const avatar = document.createElement('div');
avatar.className = 'avatar-circle';
// TODO: Set avatar image
// Set avatar image for assistant messages
if (!isUser && currentCharacter && currentCharacter.avatar_path) {
getAvatarUrl(currentCharacter.avatar_path).then(url => {
if (url) {
avatar.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(avatar, url);
}
});
}
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
@@ -187,6 +256,16 @@ async function handleSubmit(e) {
const avatar = document.createElement('div');
avatar.className = 'avatar-circle';
// Set avatar image for streaming messages
if (currentCharacter && currentCharacter.avatar_path) {
getAvatarUrl(currentCharacter.avatar_path).then(url => {
if (url) {
avatar.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(avatar, url);
}
});
}
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
@@ -384,6 +463,55 @@ async function handleSaveSettings(e) {
}
}
// Avatar upload handling
async function handleAvatarUpload() {
const characterMsg = document.getElementById('character-message');
try {
const characterId = document.getElementById('character-settings-select').value;
const avatarFilename = await invoke('select_and_upload_avatar', {
characterId: characterId
});
pendingAvatarPath = avatarFilename;
// Update preview
const avatarPreview = document.querySelector('.avatar-circle-large');
const avatarUrl = await getAvatarUrl(avatarFilename);
if (avatarUrl) {
avatarPreview.style.backgroundImage = `url('${avatarUrl}')`;
}
document.getElementById('remove-avatar-btn').style.display = 'inline-block';
characterMsg.textContent = 'Avatar uploaded. Click "Save Character" to apply.';
characterMsg.className = 'validation-message success';
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
} catch (error) {
console.error('Avatar upload error:', error);
// Don't show error if user just cancelled the dialog
if (error && !error.toString().includes('No file selected')) {
characterMsg.textContent = `Failed to upload avatar: ${error}`;
characterMsg.className = 'validation-message error';
}
}
}
function handleAvatarRemove() {
pendingAvatarPath = null;
const avatarPreview = document.querySelector('.avatar-circle-large');
avatarPreview.style.backgroundImage = '';
document.getElementById('remove-avatar-btn').style.display = 'none';
const characterMsg = document.getElementById('character-message');
characterMsg.textContent = 'Avatar removed. Click "Save Character" to apply.';
characterMsg.className = 'validation-message success';
setTimeout(() => {
characterMsg.style.display = 'none';
}, 3000);
}
// App controls
function setupAppControls() {
document.getElementById('settings-btn').addEventListener('click', showSettings);
@@ -397,6 +525,8 @@ function setupAppControls() {
await invoke('set_active_character', { characterId });
await loadCharacterSettings();
});
document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload);
document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove);
}
// Keyboard shortcuts
@@ -433,6 +563,19 @@ async function loadCharacters() {
characterHeaderName.textContent = activeCharacter.name;
currentCharacter = activeCharacter;
// Update header avatar
const headerAvatar = document.querySelector('.avatar-circle');
if (headerAvatar && activeCharacter.avatar_path) {
getAvatarUrl(activeCharacter.avatar_path).then(url => {
if (url) {
headerAvatar.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(headerAvatar, url);
}
});
} else if (headerAvatar) {
headerAvatar.style.backgroundImage = '';
}
await loadChatHistory();
} catch (error) {
console.error('Failed to load characters:', error);
@@ -552,6 +695,24 @@ async function loadCharacterSettings() {
document.getElementById('character-system-prompt').value = character.system_prompt;
document.getElementById('character-greeting').value = character.greeting || '';
document.getElementById('character-personality').value = character.personality || '';
// Load avatar preview
const avatarPreview = document.querySelector('.avatar-circle-large');
const removeAvatarBtn = document.getElementById('remove-avatar-btn');
if (character.avatar_path) {
getAvatarUrl(character.avatar_path).then(url => {
if (url) {
avatarPreview.style.backgroundImage = `url('${url}')`;
makeAvatarClickable(avatarPreview, url);
}
});
removeAvatarBtn.style.display = 'inline-block';
pendingAvatarPath = character.avatar_path;
} else {
avatarPreview.style.backgroundImage = '';
removeAvatarBtn.style.display = 'none';
pendingAvatarPath = null;
}
} catch (error) {
console.error('Failed to load character:', error);
}
@@ -582,7 +743,8 @@ async function handleSaveCharacter(e) {
name,
systemPrompt,
greeting,
personality
personality,
avatarPath: pendingAvatarPath
});
characterMsg.textContent = 'Character saved successfully';
characterMsg.className = 'validation-message success';
@@ -654,6 +816,19 @@ window.addEventListener('DOMContentLoaded', () => {
setupKeyboardShortcuts();
setupTabs();
// Avatar modal close handlers
const avatarModal = document.getElementById('avatar-modal');
const avatarModalOverlay = document.querySelector('.avatar-modal-overlay');
avatarModalOverlay.addEventListener('click', hideAvatarModal);
// ESC key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && avatarModal.style.display !== 'none') {
hideAvatarModal();
}
});
messageInput.focus();
setStatus('Ready');