const { invoke } = window.__TAURI__.core; let messageInput; let messagesContainer; let chatForm; let sendBtn; let statusText; let settingsPanel; let chatView; let characterSelect; let characterHeaderName; let newCharacterBtn; let currentCharacter = null; let pendingAvatarPath = null; // Theme definitions const themes = { dark: { name: 'Dark (Default)', bgPrimary: '#1a1a1a', bgSecondary: '#252525', bgTertiary: '#2f2f2f', textPrimary: '#e8e8e8', textSecondary: '#a0a0a0', accent: '#6366f1', accentHover: '#4f46e5', userMsg: '#4f46e5', assistantMsg: '#2f2f2f', border: '#3a3a3a', gradient: 'linear-gradient(135deg, #1a1a1a 0%, #2a1a2a 100%)', glow: 'rgba(99, 102, 241, 0.1)' }, darker: { name: 'Darker', bgPrimary: '#0a0a0a', bgSecondary: '#141414', bgTertiary: '#1a1a1a', textPrimary: '#e0e0e0', textSecondary: '#909090', accent: '#7c3aed', accentHover: '#6d28d9', userMsg: '#6d28d9', assistantMsg: '#1a1a1a', border: '#2a2a2a', gradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a0a1a 100%)', glow: 'rgba(124, 58, 237, 0.1)' }, midnight: { name: 'Midnight Blue', bgPrimary: '#0f1419', bgSecondary: '#1a2332', bgTertiary: '#243447', textPrimary: '#e6f1ff', textSecondary: '#8892a0', accent: '#3b82f6', accentHover: '#2563eb', userMsg: '#1e40af', assistantMsg: '#243447', border: '#2d3e54', gradient: 'linear-gradient(135deg, #0f1419 0%, #1a2845 100%)', glow: 'rgba(59, 130, 246, 0.1)' }, forest: { name: 'Forest', bgPrimary: '#0d1b14', bgSecondary: '#162820', bgTertiary: '#1f352b', textPrimary: '#e8f5e9', textSecondary: '#90a89f', accent: '#10b981', accentHover: '#059669', userMsg: '#047857', assistantMsg: '#1f352b', border: '#2d4a3a', gradient: 'linear-gradient(135deg, #0d1b14 0%, #1a2820 100%)', glow: 'rgba(16, 185, 129, 0.1)' }, sunset: { name: 'Sunset', bgPrimary: '#1a1214', bgSecondary: '#261a1e', bgTertiary: '#332228', textPrimary: '#fde8e8', textSecondary: '#b89090', accent: '#f97316', accentHover: '#ea580c', userMsg: '#c2410c', assistantMsg: '#332228', border: '#4a3238', gradient: 'linear-gradient(135deg, #1a1214 0%, #2a1a1e 100%)', glow: 'rgba(249, 115, 22, 0.1)' }, light: { name: 'Light', bgPrimary: '#ffffff', bgSecondary: '#f5f5f5', bgTertiary: '#e8e8e8', textPrimary: '#1a1a1a', textSecondary: '#666666', accent: '#6366f1', accentHover: '#4f46e5', userMsg: '#6366f1', assistantMsg: '#f0f0f0', border: '#d0d0d0', gradient: 'linear-gradient(135deg, #ffffff 0%, #f5f0ff 100%)', glow: 'rgba(99, 102, 241, 0.05)' } }; // Apply theme function applyTheme(themeName) { const theme = themes[themeName]; if (!theme) return; const root = document.documentElement; root.style.setProperty('--bg-primary', theme.bgPrimary); root.style.setProperty('--bg-secondary', theme.bgSecondary); root.style.setProperty('--bg-tertiary', theme.bgTertiary); root.style.setProperty('--text-primary', theme.textPrimary); root.style.setProperty('--text-secondary', theme.textSecondary); root.style.setProperty('--accent', theme.accent); root.style.setProperty('--accent-hover', theme.accentHover); root.style.setProperty('--user-msg', theme.userMsg); root.style.setProperty('--assistant-msg', theme.assistantMsg); root.style.setProperty('--border', theme.border); // Update gradient and glow const appContainer = document.querySelector('.app-container'); if (appContainer) { appContainer.style.background = theme.gradient; const glow = appContainer.querySelector('::before'); } // Store preference localStorage.setItem('claudia-theme', themeName); } // Load saved theme function loadSavedTheme() { const savedTheme = localStorage.getItem('claudia-theme') || 'dark'; const themeSelect = document.getElementById('theme-select'); if (themeSelect) { themeSelect.value = savedTheme; } applyTheme(savedTheme); } // 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); }); } // Format timestamp for display function formatTimestamp(timestamp) { if (!timestamp) return ''; const date = new Date(timestamp); const now = new Date(); const diff = now - date; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); // Just now (less than 1 minute) if (seconds < 60) { return 'Just now'; } // Minutes ago (less than 1 hour) if (minutes < 60) { return `${minutes}m ago`; } // Today (show time) if (days === 0) { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } // Yesterday if (days === 1) { return `Yesterday at ${date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })}`; } // This week (show day name) if (days < 7) { return date.toLocaleDateString('en-US', { weekday: 'short', hour: 'numeric', minute: '2-digit', hour12: true }); } // Older (show date) return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); } // Auto-resize textarea function autoResize(textarea) { textarea.style.height = 'auto'; textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; } // Helper function to render assistant message content with character name function renderAssistantContent(contentDiv, messageText) { // Clear existing content contentDiv.innerHTML = ''; // Add character name indicator if (currentCharacter && currentCharacter.name) { const nameIndicator = document.createElement('div'); nameIndicator.className = 'character-name-indicator'; nameIndicator.textContent = currentCharacter.name; contentDiv.appendChild(nameIndicator); } // Add message content const messageContent = document.createElement('div'); messageContent.innerHTML = marked.parse(messageText); contentDiv.appendChild(messageContent); // Apply syntax highlighting to code blocks messageContent.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); addCopyButtonToCode(block); }); return messageContent; } // Add message to chat function addMessage(content, isUser = false, skipActions = false, timestamp = null) { const messageDiv = document.createElement('div'); messageDiv.className = `message ${isUser ? 'user' : 'assistant'}`; const avatar = document.createElement('div'); avatar.className = 'avatar-circle'; // 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'; if (isUser) { // User messages: plain text const p = document.createElement('p'); p.textContent = content; contentDiv.appendChild(p); // Add timestamp if provided if (timestamp) { const timestampDiv = document.createElement('div'); timestampDiv.className = 'message-timestamp'; timestampDiv.textContent = formatTimestamp(timestamp); contentDiv.appendChild(timestampDiv); } } else { // Assistant messages: render as markdown // Add character name indicator if character exists if (currentCharacter && currentCharacter.name) { const nameIndicator = document.createElement('div'); nameIndicator.className = 'character-name-indicator'; nameIndicator.textContent = currentCharacter.name; contentDiv.appendChild(nameIndicator); } const messageContent = document.createElement('div'); messageContent.innerHTML = marked.parse(content); contentDiv.appendChild(messageContent); // Apply syntax highlighting to code blocks messageContent.querySelectorAll('pre code').forEach((block) => { hljs.highlightElement(block); // Add copy button to code blocks const pre = block.parentElement; if (!pre.querySelector('.copy-btn')) { const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.innerHTML = ` `; copyBtn.title = 'Copy code'; copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(block.textContent); copyBtn.innerHTML = ` `; copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.innerHTML = ` `; copyBtn.classList.remove('copied'); }, 2000); }); pre.style.position = 'relative'; pre.appendChild(copyBtn); } }); // Add timestamp if provided if (timestamp) { const timestampDiv = document.createElement('div'); timestampDiv.className = 'message-timestamp'; timestampDiv.textContent = formatTimestamp(timestamp); contentDiv.appendChild(timestampDiv); } } // Build message structure if (!skipActions) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'message-actions'; if (isUser) { // User message: simple structure with edit button const editBtn = document.createElement('button'); editBtn.className = 'message-action-btn'; editBtn.innerHTML = ` `; editBtn.title = 'Edit message'; editBtn.addEventListener('click', () => handleEditMessage(messageDiv, content)); actionsDiv.appendChild(editBtn); messageDiv.appendChild(contentDiv); messageDiv.appendChild(actionsDiv); } else { // Assistant message: structure with swipe controls const regenerateBtn = document.createElement('button'); regenerateBtn.className = 'message-action-btn'; regenerateBtn.innerHTML = ` `; regenerateBtn.title = 'Regenerate response'; regenerateBtn.addEventListener('click', () => handleRegenerateMessage(messageDiv)); actionsDiv.appendChild(regenerateBtn); // Create swipe wrapper const swipeWrapper = document.createElement('div'); swipeWrapper.style.display = 'flex'; swipeWrapper.style.flexDirection = 'column'; swipeWrapper.appendChild(contentDiv); const swipeControls = createSwipeControls(messageDiv); swipeWrapper.appendChild(swipeControls); messageDiv.appendChild(swipeWrapper); messageDiv.appendChild(actionsDiv); } } else { messageDiv.appendChild(contentDiv); } if (!isUser) { messageDiv.insertBefore(avatar, messageDiv.firstChild); } messagesContainer.appendChild(messageDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; return messageDiv; } // Create swipe controls for assistant messages function createSwipeControls(messageDiv) { const swipeControls = document.createElement('div'); swipeControls.className = 'swipe-controls'; const prevBtn = document.createElement('button'); prevBtn.className = 'swipe-btn swipe-prev'; prevBtn.innerHTML = ` `; prevBtn.title = 'Previous response'; prevBtn.addEventListener('click', () => handleSwipeNavigation(messageDiv, -1)); const counter = document.createElement('span'); counter.className = 'swipe-counter'; counter.textContent = '1/1'; const nextBtn = document.createElement('button'); nextBtn.className = 'swipe-btn swipe-next'; nextBtn.innerHTML = ` `; nextBtn.title = 'Next response'; nextBtn.addEventListener('click', () => handleSwipeNavigation(messageDiv, 1)); swipeControls.appendChild(prevBtn); swipeControls.appendChild(counter); swipeControls.appendChild(nextBtn); // Initially hide if only one swipe updateSwipeControls(messageDiv, 0, 1); return swipeControls; } // Update swipe controls state function updateSwipeControls(messageDiv, current, total) { const swipeControls = messageDiv.querySelector('.swipe-controls'); if (!swipeControls) return; const counter = swipeControls.querySelector('.swipe-counter'); const prevBtn = swipeControls.querySelector('.swipe-prev'); const nextBtn = swipeControls.querySelector('.swipe-next'); counter.textContent = `${current + 1}/${total}`; prevBtn.disabled = current === 0; nextBtn.disabled = current === total - 1; // Show controls if more than one swipe if (total > 1) { swipeControls.classList.add('always-visible'); } else { swipeControls.classList.remove('always-visible'); } } // Handle swipe navigation async function handleSwipeNavigation(messageDiv, direction) { const allMessages = Array.from(messagesContainer.querySelectorAll('.message')); const messageIndex = allMessages.indexOf(messageDiv); console.log('handleSwipeNavigation called:', { messageIndex, direction }); try { const swipeInfo = await invoke('navigate_swipe', { messageIndex, direction }); console.log('Received swipeInfo:', swipeInfo); // Update message content const contentDiv = messageDiv.querySelector('.message-content'); console.log('Found contentDiv:', contentDiv); console.log('Setting content to:', swipeInfo.content); renderAssistantContent(contentDiv, swipeInfo.content); // Update swipe controls updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total); } catch (error) { console.error('Failed to navigate swipe:', error); } } // Handle editing a user message async function handleEditMessage(messageDiv, originalContent) { const contentDiv = messageDiv.querySelector('.message-content'); const actionsDiv = messageDiv.querySelector('.message-actions'); // Hide action buttons during edit actionsDiv.style.display = 'none'; // Create edit form const editForm = document.createElement('form'); editForm.className = 'message-edit-form'; const textarea = document.createElement('textarea'); textarea.className = 'message-edit-textarea'; textarea.value = originalContent; textarea.rows = 3; autoResize(textarea); const editActions = document.createElement('div'); editActions.className = 'message-edit-actions'; const saveBtn = document.createElement('button'); saveBtn.type = 'submit'; saveBtn.className = 'message-edit-btn'; saveBtn.textContent = 'Save & Resend'; const cancelBtn = document.createElement('button'); cancelBtn.type = 'button'; cancelBtn.className = 'message-edit-btn'; cancelBtn.textContent = 'Cancel'; editActions.appendChild(saveBtn); editActions.appendChild(cancelBtn); editForm.appendChild(textarea); editForm.appendChild(editActions); // Auto-resize on input textarea.addEventListener('input', () => autoResize(textarea)); // Replace content with edit form const originalHTML = contentDiv.innerHTML; contentDiv.innerHTML = ''; contentDiv.appendChild(editForm); textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); // Handle cancel cancelBtn.addEventListener('click', () => { contentDiv.innerHTML = originalHTML; actionsDiv.style.display = 'flex'; }); // Handle save editForm.addEventListener('submit', async (e) => { e.preventDefault(); const newContent = textarea.value.trim(); if (!newContent || newContent === originalContent) { contentDiv.innerHTML = originalHTML; actionsDiv.style.display = 'flex'; return; } // Get the index of this message const allMessages = Array.from(messagesContainer.querySelectorAll('.message')); const messageIndex = allMessages.indexOf(messageDiv); // Disable form saveBtn.disabled = true; cancelBtn.disabled = true; saveBtn.textContent = 'Saving...'; try { // Truncate history from this point await invoke('truncate_history_from', { index: messageIndex }); // Remove all messages from this point forward in UI while (messagesContainer.children[messageIndex]) { messagesContainer.children[messageIndex].remove(); } // Send the edited message await sendMessage(newContent); } catch (error) { console.error('Failed to edit message:', error); contentDiv.innerHTML = originalHTML; actionsDiv.style.display = 'flex'; addMessage(`Error editing message: ${error}`, false); } }); } // Handle regenerating an assistant message async function handleRegenerateMessage(messageDiv) { const regenerateBtn = messageDiv.querySelector('.message-action-btn'); regenerateBtn.disabled = true; regenerateBtn.classList.add('loading'); try { // Get the last user message const lastUserMessage = await invoke('get_last_user_message'); // Generate new response await generateSwipe(messageDiv, lastUserMessage); } catch (error) { console.error('Failed to regenerate message:', error); regenerateBtn.disabled = false; regenerateBtn.classList.remove('loading'); addMessage(`Error regenerating message: ${error}`, false); } } // Generate a new swipe for an existing assistant message async function generateSwipe(messageDiv, userMessage) { setStatus('Regenerating response...', 'default'); // Check if streaming is enabled let streamEnabled = false; try { const config = await invoke('get_api_config'); streamEnabled = config.stream || false; } catch (error) { console.error('Failed to get config:', error); } if (streamEnabled) { await generateSwipeStream(messageDiv, userMessage); } else { await generateSwipeNonStream(messageDiv, userMessage); } } // Generate swipe using non-streaming async function generateSwipeNonStream(messageDiv, userMessage) { try { const response = await invoke('generate_response_only'); // Add as a swipe const swipeInfo = await invoke('add_swipe_to_last_assistant', { content: response }); // Update the message content const contentDiv = messageDiv.querySelector('.message-content'); renderAssistantContent(contentDiv, swipeInfo.content); // Update swipe controls updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total); setStatus('Regeneration complete', 'success'); setTimeout(() => setStatus('Ready'), 2000); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.classList.remove('loading'); } } catch (error) { setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error'); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.classList.remove('loading'); } addMessage(`Error regenerating message: ${error}`, false); } } // Generate swipe using streaming async function generateSwipeStream(messageDiv, userMessage) { setStatus('Streaming regeneration...', 'streaming'); let fullContent = ''; const contentDiv = messageDiv.querySelector('.message-content'); // Set up event listeners for streaming const { listen } = window.__TAURI__.event; const tokenUnlisten = await listen('chat-token', (event) => { const token = event.payload; fullContent += token; // Update content with markdown rendering renderAssistantContent(contentDiv, fullContent); messagesContainer.scrollTop = messagesContainer.scrollHeight; }); const completeUnlisten = await listen('chat-complete', async () => { // Add as a swipe try { const swipeInfo = await invoke('add_swipe_to_last_assistant', { content: fullContent }); // Update swipe controls updateSwipeControls(messageDiv, swipeInfo.current, swipeInfo.total); } catch (error) { console.error('Failed to add swipe:', error); } setStatus('Regeneration complete', 'success'); setTimeout(() => setStatus('Ready'), 2000); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.classList.remove('loading'); } tokenUnlisten(); completeUnlisten(); }); try { await invoke('generate_response_stream'); } catch (error) { tokenUnlisten(); completeUnlisten(); setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error'); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); if (regenerateBtn) { regenerateBtn.disabled = false; regenerateBtn.classList.remove('loading'); } addMessage(`Error: ${error}`, false); } } // Helper to add copy button to code blocks function addCopyButtonToCode(block) { const pre = block.parentElement; if (pre && !pre.querySelector('.copy-btn')) { const copyBtn = document.createElement('button'); copyBtn.className = 'copy-btn'; copyBtn.innerHTML = ` `; copyBtn.title = 'Copy code'; copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(block.textContent); copyBtn.innerHTML = ` `; copyBtn.classList.add('copied'); setTimeout(() => { copyBtn.innerHTML = ` `; copyBtn.classList.remove('copied'); }, 2000); }); pre.style.position = 'relative'; pre.appendChild(copyBtn); } } // Extract message sending logic into separate function async function sendMessage(message, isRegenerate = false) { if (!isRegenerate) { addMessage(message, true, false, Date.now()); } sendBtn.disabled = true; messageInput.disabled = true; setStatus('Connecting to API...', 'default'); // Check if streaming is enabled let streamEnabled = false; try { const config = await invoke('get_api_config'); streamEnabled = config.stream || false; } catch (error) { console.error('Failed to get config:', error); } if (streamEnabled) { // Use streaming setStatus('Streaming response...', 'streaming'); let streamingMessageDiv = null; let streamingContentDiv = null; let fullContent = ''; // Create streaming message container const messageDiv = document.createElement('div'); messageDiv.className = 'message assistant'; 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'; // Create swipe wrapper for assistant messages const swipeWrapper = document.createElement('div'); swipeWrapper.style.display = 'flex'; swipeWrapper.style.flexDirection = 'column'; swipeWrapper.appendChild(contentDiv); const swipeControls = createSwipeControls(messageDiv); swipeWrapper.appendChild(swipeControls); messageDiv.appendChild(avatar); messageDiv.appendChild(swipeWrapper); messagesContainer.appendChild(messageDiv); streamingMessageDiv = messageDiv; streamingContentDiv = contentDiv; // Set up event listeners for streaming const { listen } = window.__TAURI__.event; const tokenUnlisten = await listen('chat-token', (event) => { const token = event.payload; fullContent += token; // Update content with markdown rendering renderAssistantContent(streamingContentDiv, fullContent); messagesContainer.scrollTop = messagesContainer.scrollHeight; }); const completeUnlisten = await listen('chat-complete', () => { // Add regenerate button after streaming completes const actionsDiv = document.createElement('div'); actionsDiv.className = 'message-actions'; const regenerateBtn = document.createElement('button'); regenerateBtn.className = 'message-action-btn'; regenerateBtn.innerHTML = ` `; regenerateBtn.title = 'Regenerate response'; regenerateBtn.addEventListener('click', () => handleRegenerateMessage(streamingMessageDiv)); actionsDiv.appendChild(regenerateBtn); streamingMessageDiv.appendChild(actionsDiv); setStatus('Response complete', 'success'); setTimeout(() => setStatus('Ready'), 2000); sendBtn.disabled = false; messageInput.disabled = false; messageInput.focus(); tokenUnlisten(); completeUnlisten(); }); try { await invoke('chat_stream', { message }); } catch (error) { tokenUnlisten(); completeUnlisten(); if (streamingMessageDiv) { streamingMessageDiv.remove(); } if (error.includes('not configured')) { addMessage('API not configured. Please configure your API settings.', false); setStatus('API not configured', 'error'); setTimeout(showSettings, 1000); } else { addMessage(`Error: ${error}`, false); setStatus(`Error: ${error.substring(0, 50)}...`, 'error'); } sendBtn.disabled = false; messageInput.disabled = false; messageInput.focus(); } } else { // Use non-streaming showTypingIndicator(); try { const response = await invoke('chat', { message }); removeTypingIndicator(); addMessage(response, false); setStatus('Response complete', 'success'); setTimeout(() => setStatus('Ready'), 2000); } catch (error) { removeTypingIndicator(); if (error.includes('not configured')) { addMessage('API not configured. Please configure your API settings.', false); setStatus('API not configured', 'error'); setTimeout(showSettings, 1000); } else { addMessage(`Error: ${error}`, false); setStatus(`Error: ${error.substring(0, 50)}...`, 'error'); } } finally { sendBtn.disabled = false; messageInput.disabled = false; messageInput.focus(); } } } // Show typing indicator function showTypingIndicator() { const typingDiv = document.createElement('div'); typingDiv.className = 'message assistant'; typingDiv.id = 'typing-indicator'; const indicatorDiv = document.createElement('div'); indicatorDiv.className = 'typing-indicator'; for (let i = 0; i < 3; i++) { const dot = document.createElement('div'); dot.className = 'typing-dot'; indicatorDiv.appendChild(dot); } typingDiv.appendChild(indicatorDiv); messagesContainer.appendChild(typingDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; } // Remove typing indicator function removeTypingIndicator() { const indicator = document.getElementById('typing-indicator'); if (indicator) { indicator.remove(); } } // Update status with optional styling function setStatus(text, type = 'default') { statusText.textContent = text; // Remove all status classes statusText.classList.remove('streaming', 'error', 'success'); // Add appropriate class based on type if (type === 'streaming') { statusText.classList.add('streaming'); } else if (type === 'error') { statusText.classList.add('error'); } else if (type === 'success') { statusText.classList.add('success'); } } // Show/hide settings async function showSettings() { const overlay = document.getElementById('settings-overlay'); settingsPanel.classList.add('open'); overlay.classList.add('show'); await loadCharacterSettings(); } function hideSettings() { const overlay = document.getElementById('settings-overlay'); settingsPanel.classList.remove('open'); overlay.classList.remove('show'); } // Tab switching function setupTabs() { const tabBtns = document.querySelectorAll('.tab-btn'); const tabContents = document.querySelectorAll('.tab-content'); tabBtns.forEach(btn => { btn.addEventListener('click', () => { const targetTab = btn.getAttribute('data-tab'); // Remove active class from all tabs and contents tabBtns.forEach(b => b.classList.remove('active')); tabContents.forEach(c => c.classList.remove('active')); // Add active class to clicked tab and corresponding content btn.classList.add('active'); document.getElementById(`${targetTab}-tab`).classList.add('active'); }); }); } // Handle form submission async function handleSubmit(e) { e.preventDefault(); const message = messageInput.value.trim(); if (!message) return; messageInput.value = ''; autoResize(messageInput); await sendMessage(message); } // Settings functionality async function handleValidate() { const baseUrl = document.getElementById('api-base-url').value.trim(); const apiKey = document.getElementById('api-key').value.trim(); const validateBtn = document.getElementById('validate-btn'); const modelsGroup = document.getElementById('models-group'); const modelSelect = document.getElementById('model-select'); const saveBtn = document.getElementById('save-settings-btn'); const validationMsg = document.getElementById('validation-message'); if (!baseUrl || !apiKey) { validationMsg.textContent = 'Please fill in all fields'; validationMsg.className = 'validation-message error'; return; } validateBtn.disabled = true; validateBtn.classList.add('loading'); validateBtn.textContent = 'Validating...'; validationMsg.style.display = 'none'; setStatus('Validating API...', 'default'); try { const models = await invoke('validate_api', { baseUrl, apiKey }); validationMsg.textContent = `Found ${models.length} models`; validationMsg.className = 'validation-message success'; setStatus('API validated successfully', 'success'); setTimeout(() => setStatus('Ready'), 2000); modelSelect.innerHTML = ''; models.forEach(model => { const option = document.createElement('option'); option.value = model; option.textContent = model; modelSelect.appendChild(option); }); modelsGroup.style.display = 'flex'; modelsGroup.classList.add('fade-in'); saveBtn.disabled = false; } catch (error) { validationMsg.textContent = `Validation failed: ${error}`; validationMsg.className = 'validation-message error'; setStatus('API validation failed', 'error'); modelsGroup.style.display = 'none'; saveBtn.disabled = true; } finally { validateBtn.disabled = false; validateBtn.classList.remove('loading'); validateBtn.textContent = 'Validate'; } } async function handleSaveSettings(e) { e.preventDefault(); const baseUrl = document.getElementById('api-base-url').value.trim(); const apiKey = document.getElementById('api-key').value.trim(); const model = document.getElementById('model-select').value; const stream = document.getElementById('stream-toggle').checked; const saveBtn = document.getElementById('save-settings-btn'); const validationMsg = document.getElementById('validation-message'); if (!model) { validationMsg.textContent = 'Please select a model'; validationMsg.className = 'validation-message error'; return; } saveBtn.disabled = true; saveBtn.classList.add('loading'); saveBtn.textContent = 'Saving...'; setStatus('Saving configuration...', 'default'); try { await invoke('save_api_config', { baseUrl, apiKey, model, stream }); validationMsg.textContent = 'Configuration saved successfully'; validationMsg.className = 'validation-message success'; setStatus('Configuration saved', 'success'); setTimeout(() => { hideSettings(); messagesContainer.innerHTML = ''; addMessage('API configured. Ready to chat.', false, true); setStatus('Ready'); }, 1000); } catch (error) { validationMsg.textContent = `Failed to save: ${error}`; validationMsg.className = 'validation-message error'; setStatus('Failed to save configuration', 'error'); } finally { saveBtn.disabled = false; saveBtn.classList.remove('loading'); saveBtn.textContent = 'Save Configuration'; } } // 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); document.getElementById('close-settings-btn').addEventListener('click', hideSettings); document.getElementById('settings-overlay').addEventListener('click', hideSettings); document.getElementById('clear-btn').addEventListener('click', clearHistory); characterSelect.addEventListener('change', handleCharacterSwitch); newCharacterBtn.addEventListener('click', handleNewCharacter); document.getElementById('delete-character-btn').addEventListener('click', handleDeleteCharacter); document.getElementById('character-settings-select').addEventListener('change', async () => { const characterId = document.getElementById('character-settings-select').value; await invoke('set_active_character', { characterId }); await loadCharacterSettings(); }); document.getElementById('upload-avatar-btn').addEventListener('click', handleAvatarUpload); document.getElementById('remove-avatar-btn').addEventListener('click', handleAvatarRemove); document.getElementById('import-character-btn').addEventListener('click', handleImportCharacter); document.getElementById('export-character-btn').addEventListener('click', handleExportCharacter); // Setup collapsible sections document.querySelectorAll('.settings-section-header').forEach(header => { header.addEventListener('click', () => { const section = header.parentElement; section.classList.toggle('collapsed'); }); }); // Setup theme selector const themeSelect = document.getElementById('theme-select'); if (themeSelect) { themeSelect.addEventListener('change', (e) => { applyTheme(e.target.value); }); } } // Keyboard shortcuts function setupKeyboardShortcuts() { messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }); messageInput.addEventListener('input', () => { autoResize(messageInput); }); } // Load characters and populate dropdown async function loadCharacters() { console.log('Loading characters...'); try { const characters = await invoke('list_characters'); console.log('Loaded characters:', characters); characterSelect.innerHTML = ''; characters.forEach(char => { const option = document.createElement('option'); option.value = char.id; option.textContent = char.name; characterSelect.appendChild(option); }); const activeCharacter = await invoke('get_character'); console.log('Active character:', activeCharacter); characterSelect.value = activeCharacter.id; 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); addMessage(`Failed to load characters: ${error}`, false); } } // Handle character switching async function handleCharacterSwitch() { const characterId = characterSelect.value; setStatus('Switching character...', 'default'); try { await invoke('set_active_character', { characterId }); messagesContainer.innerHTML = ''; await loadCharacters(); setStatus('Character switched', 'success'); setTimeout(() => setStatus('Ready'), 2000); } catch (error) { console.error('Failed to switch character:', error); setStatus('Failed to switch character', 'error'); addMessage(`Failed to switch character: ${error}`, false); } } // Handle new character creation async function handleNewCharacter() { const name = prompt('Enter a name for the new character:'); if (!name) return; const systemPrompt = prompt('Enter the system prompt for the new character:', 'You are a helpful AI assistant.'); if (!systemPrompt) return; try { const newCharacter = await invoke('create_character', { name, systemPrompt }); await loadCharacters(); characterSelect.value = newCharacter.id; } catch (error) { console.error('Failed to create character:', error); addMessage(`Failed to create character: ${error}`, false); } } // Handle character deletion async function handleDeleteCharacter() { if (!currentCharacter || currentCharacter.id === 'default') { addMessage('Cannot delete the default character.', false); return; } if (confirm(`Are you sure you want to delete ${currentCharacter.name}? This cannot be undone.`)) { try { await invoke('delete_character', { characterId: currentCharacter.id }); await loadCharacters(); hideSettings(); } catch (error) { console.error('Failed to delete character:', error); addMessage(`Failed to delete character: ${error}`, false); } } } // Handle character card import async function handleImportCharacter() { const characterMsg = document.getElementById('character-message'); try { const importedCharacter = await invoke('import_character_card'); characterMsg.textContent = `Successfully imported ${importedCharacter.name}!`; characterMsg.className = 'validation-message success'; // Reload characters and switch to the imported one await loadCharacters(); await loadCharacterSettings(); setTimeout(() => { characterMsg.style.display = 'none'; }, 3000); } catch (error) { console.error('Failed to import character:', error); if (error && !error.toString().includes('No file selected') && !error.toString().includes('cancelled')) { characterMsg.textContent = `Failed to import: ${error}`; characterMsg.className = 'validation-message error'; } } } // Handle character card export async function handleExportCharacter() { const characterMsg = document.getElementById('character-message'); try { const characterId = document.getElementById('character-settings-select').value; const outputPath = await invoke('export_character_card', { characterId }); characterMsg.textContent = `Successfully exported to ${outputPath}`; characterMsg.className = 'validation-message success'; setTimeout(() => { characterMsg.style.display = 'none'; }, 3000); } catch (error) { console.error('Failed to export character:', error); if (error && !error.toString().includes('cancelled')) { characterMsg.textContent = `Failed to export: ${error}`; characterMsg.className = 'validation-message error'; } } } // Load chat history async function loadChatHistory() { try { const history = await invoke('get_chat_history'); messagesContainer.innerHTML = ''; if (history.length === 0) { if (currentCharacter && currentCharacter.greeting) { addMessage(currentCharacter.greeting, false, true); } else { addMessage('API configured. Ready to chat.', false, true); } } else { history.forEach((msg, index) => { const messageDiv = addMessage(msg.content, msg.role === 'user', false, msg.timestamp); // Update swipe controls for assistant messages with swipe info if (msg.role === 'assistant' && messageDiv && msg.swipes && msg.swipes.length > 0) { updateSwipeControls(messageDiv, msg.current_swipe || 0, msg.swipes.length); } }); } } catch (error) { console.error('Failed to load chat history:', error); messagesContainer.innerHTML = ''; addMessage('API configured. Ready to chat.', false, true); } } // Clear chat history async function clearHistory() { if (!confirm('Clear conversation history? This cannot be undone.')) { return; } setStatus('Clearing history...', 'default'); try { await invoke('clear_chat_history'); messagesContainer.innerHTML = ''; if (currentCharacter && currentCharacter.greeting) { addMessage(currentCharacter.greeting, false, true); } else { addMessage('Conversation cleared. Ready to chat.', false, true); } setStatus('History cleared', 'success'); setTimeout(() => setStatus('Ready'), 2000); } catch (error) { setStatus('Failed to clear history', 'error'); addMessage(`Failed to clear history: ${error}`, false); } } // Load character settings async function loadCharacterSettings() { try { const characters = await invoke('list_characters'); const characterSettingsSelect = document.getElementById('character-settings-select'); characterSettingsSelect.innerHTML = ''; characters.forEach(char => { const option = document.createElement('option'); option.value = char.id; option.textContent = char.name; characterSettingsSelect.appendChild(option); }); const character = await invoke('get_character'); characterSettingsSelect.value = character.id; document.getElementById('character-name').value = character.name; document.getElementById('character-system-prompt').value = character.system_prompt; document.getElementById('character-greeting').value = character.greeting || ''; document.getElementById('character-personality').value = character.personality || ''; document.getElementById('character-description').value = character.description || ''; document.getElementById('character-scenario').value = character.scenario || ''; document.getElementById('character-mes-example').value = character.mes_example || ''; document.getElementById('character-post-history').value = character.post_history_instructions || ''; document.getElementById('character-alt-greetings').value = character.alternate_greetings ? character.alternate_greetings.join('\n') : ''; document.getElementById('character-tags').value = character.tags ? character.tags.join(', ') : ''; document.getElementById('character-creator').value = character.creator || ''; document.getElementById('character-version').value = character.character_version || ''; document.getElementById('character-creator-notes').value = character.creator_notes || ''; // 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); } } // Save character settings async function handleSaveCharacter(e) { e.preventDefault(); const name = document.getElementById('character-name').value.trim(); const systemPrompt = document.getElementById('character-system-prompt').value.trim(); const greeting = document.getElementById('character-greeting').value.trim() || null; const personality = document.getElementById('character-personality').value.trim() || null; const description = document.getElementById('character-description').value.trim() || null; const scenario = document.getElementById('character-scenario').value.trim() || null; const mesExample = document.getElementById('character-mes-example').value.trim() || null; const postHistory = document.getElementById('character-post-history').value.trim() || null; const altGreetingsText = document.getElementById('character-alt-greetings').value.trim(); const altGreetings = altGreetingsText ? altGreetingsText.split('\n').map(s => s.trim()).filter(s => s) : null; const tagsText = document.getElementById('character-tags').value.trim(); const tags = tagsText ? tagsText.split(',').map(s => s.trim()).filter(s => s) : null; const creator = document.getElementById('character-creator').value.trim() || null; const characterVersion = document.getElementById('character-version').value.trim() || null; const creatorNotes = document.getElementById('character-creator-notes').value.trim() || null; const saveBtn = document.getElementById('save-character-btn'); const characterMsg = document.getElementById('character-message'); if (!name || !systemPrompt) { characterMsg.textContent = 'Name and System Prompt are required'; characterMsg.className = 'validation-message error'; return; } saveBtn.disabled = true; saveBtn.classList.add('loading'); saveBtn.textContent = 'Saving...'; try { await invoke('update_character', { name, systemPrompt, greeting, personality, description, scenario, mesExample, postHistory, altGreetings, tags, creator, characterVersion, creatorNotes, avatarPath: pendingAvatarPath }); characterMsg.textContent = 'Character saved successfully'; characterMsg.className = 'validation-message success'; await loadCharacters(); setTimeout(() => { characterMsg.style.display = 'none'; }, 3000); } catch (error) { characterMsg.textContent = `Failed to save: ${error}`; characterMsg.className = 'validation-message error'; } finally { saveBtn.disabled = false; saveBtn.classList.remove('loading'); saveBtn.textContent = 'Save Character'; } } // Load existing config if available async function loadExistingConfig() { console.log('Loading existing config...'); try { const config = await invoke('get_api_config'); console.log('Loaded config:', config); document.getElementById('api-base-url').value = config.base_url; document.getElementById('api-key').value = config.api_key; document.getElementById('stream-toggle').checked = config.stream || false; const modelSelect = document.getElementById('model-select'); modelSelect.innerHTML = ''; // Clear existing options const option = document.createElement('option'); option.value = config.model; option.textContent = config.model; option.selected = true; modelSelect.appendChild(option); // Show the model group since we have a saved model document.getElementById('models-group').style.display = 'flex'; document.getElementById('save-settings-btn').disabled = false; // Load characters await loadCharacters(); } catch (error) { console.error('Failed to load existing config:', error); addMessage('API not configured. Please configure your API settings.', false); showSettings(); } } // Initialize app window.addEventListener('DOMContentLoaded', () => { messageInput = document.getElementById('message-input'); messagesContainer = document.getElementById('messages'); chatForm = document.getElementById('chat-form'); sendBtn = document.getElementById('send-btn'); statusText = document.getElementById('status-text'); settingsPanel = document.getElementById('settings-panel'); chatView = document.getElementById('chat-view'); characterSelect = document.getElementById('character-select'); characterHeaderName = document.getElementById('character-header-name'); newCharacterBtn = document.getElementById('new-character-btn'); chatForm.addEventListener('submit', handleSubmit); document.getElementById('settings-form').addEventListener('submit', handleSaveSettings); document.getElementById('character-form').addEventListener('submit', handleSaveCharacter); document.getElementById('validate-btn').addEventListener('click', handleValidate); setupAppControls(); 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'); // Load saved theme before anything else loadSavedTheme(); loadExistingConfig(); });