// js/ui.js // Gestion de l'interface utilisateur (rendu, événements) import chat from './chat.js'; import storage from './storage.js'; class UIManager { constructor() { // Éléments DOM this.messagesContainer = document.getElementById('messagesContainer'); this.messageInput = document.getElementById('messageInput'); this.sendBtn = document.getElementById('sendBtn'); this.newChatBtn = document.getElementById('newChatBtn'); this.historyList = document.getElementById('historyList'); this.typingIndicator = document.getElementById('typingIndicator'); this.sidebar = document.getElementById('sidebar'); this.menuToggle = document.getElementById('menuToggle'); this.themeToggle = document.getElementById('themeToggle'); this.settingsBtn = document.getElementById('settingsBtn'); this.settingsModal = document.getElementById('settingsModal'); this.closeSettings = document.getElementById('closeSettings'); this.themeSelect = document.getElementById('themeSelect'); this.attachBtn = document.getElementById('attachBtn'); this.micBtn = document.getElementById('micBtn'); // Stockage des éléments de message pour interaction this.messageElements = new Map(); // index -> élément DOM this.editingMessageIndex = -1; // État du thème this.currentTheme = localStorage.getItem('nixaura_theme') || 'dark'; this.applyTheme(this.currentTheme); // Initialiser les écouteurs this._bindEvents(); } // Appliquer le thème applyTheme(theme) { this.currentTheme = theme; document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('nixaura_theme', theme); // Mettre à jour le texte du bouton if (this.themeToggle) { const icon = theme === 'dark' ? 'fa-moon' : 'fa-sun'; const label = theme === 'dark' ? 'Mode sombre' : 'Mode clair'; this.themeToggle.innerHTML = ` ${label}`; } if (this.themeSelect) { this.themeSelect.value = theme; } } // Basculer le thème toggleTheme() { const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark'; this.applyTheme(newTheme); } // Afficher un message dans l'interface appendMessage(message, index = -1) { const div = document.createElement('div'); const isUser = message.role === 'user'; div.className = `message ${isUser ? 'user-message' : 'ai-message'}`; div.dataset.index = index; const avatar = document.createElement('div'); avatar.className = `avatar ${isUser ? 'user-avatar' : 'ai-avatar'}`; avatar.textContent = isUser ? 'U' : 'N'; const contentWrapper = document.createElement('div'); contentWrapper.className = 'message-wrapper'; const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; // Ajouter le contenu const paragraphs = message.content.split('\n').filter(p => p.trim() !== ''); if (paragraphs.length === 0) { const p = document.createElement('p'); p.textContent = message.content || ' '; contentDiv.appendChild(p); } else { paragraphs.forEach(text => { const p = document.createElement('p'); p.textContent = text; contentDiv.appendChild(p); }); } contentWrapper.appendChild(contentDiv); // Ajouter les actions (pour les messages IA) if (!isUser) { const actionsDiv = document.createElement('div'); actionsDiv.className = 'message-actions'; // Bouton copier const copyBtn = document.createElement('button'); copyBtn.className = 'action-btn copy-btn'; copyBtn.innerHTML = ''; copyBtn.title = 'Copier le message'; copyBtn.addEventListener('click', (e) => { e.stopPropagation(); this.copyMessage(message.content); }); // Bouton régénérer (seulement pour le dernier message IA) const regenerateBtn = document.createElement('button'); regenerateBtn.className = 'action-btn regenerate-btn'; regenerateBtn.innerHTML = ''; regenerateBtn.title = 'Régénérer la réponse'; regenerateBtn.addEventListener('click', (e) => { e.stopPropagation(); this.regenerateMessage(); }); actionsDiv.appendChild(copyBtn); // Ajouter le bouton régénérer seulement si c'est le dernier message IA if (index === chat.getLastAIMessageIndex()) { actionsDiv.appendChild(regenerateBtn); } contentWrapper.appendChild(actionsDiv); } // Pour les messages utilisateur, ajouter la possibilité d'édition if (isUser) { contentDiv.style.cursor = 'pointer'; contentDiv.title = 'Cliquez pour modifier'; contentDiv.addEventListener('click', () => { this.startEditing(index, message.content); }); } // Ajouter l'indicateur de tokens si disponible if (message.usage) { const usageDiv = document.createElement('div'); usageDiv.className = 'usage-indicator'; usageDiv.textContent = `Tokens: ${message.usage.total_tokens || 0} (${message.usage.prompt_tokens || 0} prompt + ${message.usage.completion_tokens || 0} completion)`; contentWrapper.appendChild(usageDiv); } if (isUser) { div.appendChild(contentWrapper); div.appendChild(avatar); } else { div.appendChild(avatar); div.appendChild(contentWrapper); } this.messagesContainer.appendChild(div); this.scrollToBottom(); // Stocker la référence if (index >= 0) { this.messageElements.set(index, div); } return div; } // Copier un message copyMessage(content) { if (!content) return; navigator.clipboard.writeText(content).then(() => { // Feedback visuel const copyBtn = document.querySelector('.copy-btn'); if (copyBtn) { const originalHtml = copyBtn.innerHTML; copyBtn.innerHTML = ''; setTimeout(() => { copyBtn.innerHTML = originalHtml; }, 1500); } }).catch(err => { console.error('Erreur copie:', err); alert('Impossible de copier le texte.'); }); } // Régénérer le dernier message async regenerateMessage() { if (chat.isProcessing) return; // Supprimer le dernier message IA de l'UI const lastIndex = chat.getLastAIMessageIndex(); if (lastIndex >= 0) { const lastElement = this.messageElements.get(lastIndex); if (lastElement) { lastElement.remove(); this.messageElements.delete(lastIndex); } } // Afficher l'indicateur de réflexion this.showTyping(true); // Créer un message vide qui sera rempli const emptyMsg = { role: 'assistant', content: '' }; const newIndex = chat.getCurrentMessages().length; const aiElement = this.appendMessage(emptyMsg, newIndex); try { await chat.regenerateLastResponse( // onToken (token, fullContent) => { this.updateMessage(aiElement, fullContent); }, // onComplete (fullContent, usage) => { this.updateMessage(aiElement, fullContent); // Ajouter l'usage if (usage) { const usageDiv = aiElement.querySelector('.usage-indicator'); if (!usageDiv) { const newUsageDiv = document.createElement('div'); newUsageDiv.className = 'usage-indicator'; newUsageDiv.textContent = `Tokens: ${usage.total_tokens || 0} (${usage.prompt_tokens || 0} prompt + ${usage.completion_tokens || 0} completion)`; aiElement.querySelector('.message-wrapper').appendChild(newUsageDiv); } } this.showTyping(false); this.renderHistory(); this.scrollToBottom(); }, // onError (error) => { console.error('Erreur régénération:', error); this.updateMessage(aiElement, "Désolé, une erreur s'est produite lors de la régénération."); this.showTyping(false); } ); } catch (error) { console.error('Erreur régénération:', error); this.showTyping(false); } } // Éditer un message utilisateur startEditing(index, content) { if (chat.isProcessing) return; if (this.editingMessageIndex === index) return; // Réinitialiser toute édition en cours this.cancelEditing(); const element = this.messageElements.get(index); if (!element) return; const contentDiv = element.querySelector('.message-content'); if (!contentDiv) return; // Remplacer le contenu par un champ de saisie const textarea = document.createElement('textarea'); textarea.className = 'edit-input'; textarea.value = content; textarea.rows = Math.min(content.split('\n').length + 1, 6); // Boutons d'action const actionsDiv = document.createElement('div'); actionsDiv.className = 'edit-actions'; const saveBtn = document.createElement('button'); saveBtn.className = 'edit-save-btn'; saveBtn.innerHTML = ' Enregistrer'; saveBtn.addEventListener('click', () => { this.saveEdit(index, textarea.value); }); const cancelBtn = document.createElement('button'); cancelBtn.className = 'edit-cancel-btn'; cancelBtn.innerHTML = ' Annuler'; cancelBtn.addEventListener('click', () => { this.cancelEditing(); }); actionsDiv.appendChild(saveBtn); actionsDiv.appendChild(cancelBtn); // Remplacer le contenu contentDiv.innerHTML = ''; contentDiv.appendChild(textarea); contentDiv.appendChild(actionsDiv); // Focus et sélection textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); this.editingMessageIndex = index; } // Enregistrer l'édition async saveEdit(index, newContent) { if (chat.isProcessing) return; if (!newContent.trim()) { alert('Le message ne peut pas être vide.'); return; } // Supprimer tous les messages après celui-ci dans l'UI const messages = chat.getCurrentMessages(); for (let i = index + 1; i < messages.length; i++) { const element = this.messageElements.get(i); if (element) { element.remove(); this.messageElements.delete(i); } } // Afficher l'indicateur de réflexion this.showTyping(true); // Créer un message vide pour la nouvelle réponse const emptyMsg = { role: 'assistant', content: '' }; const newIndex = index + 1; const aiElement = this.appendMessage(emptyMsg, newIndex); try { await chat.editUserMessage( index, newContent, // onToken (token, fullContent) => { this.updateMessage(aiElement, fullContent); }, // onComplete (fullContent, usage) => { this.updateMessage(aiElement, fullContent); if (usage) { const usageDiv = aiElement.querySelector('.usage-indicator'); if (!usageDiv) { const newUsageDiv = document.createElement('div'); newUsageDiv.className = 'usage-indicator'; newUsageDiv.textContent = `Tokens: ${usage.total_tokens || 0} (${usage.prompt_tokens || 0} prompt + ${usage.completion_tokens || 0} completion)`; aiElement.querySelector('.message-wrapper').appendChild(newUsageDiv); } } this.showTyping(false); this.renderHistory(); this.scrollToBottom(); this.editingMessageIndex = -1; }, // onError (error) => { console.error('Erreur édition:', error); this.updateMessage(aiElement, "Désolé, une erreur s'est produite lors de l'édition."); this.showTyping(false); this.editingMessageIndex = -1; } ); } catch (error) { console.error('Erreur édition:', error); this.showTyping(false); this.editingMessageIndex = -1; } } // Annuler l'édition cancelEditing() { if (this.editingMessageIndex < 0) return; const element = this.messageElements.get(this.editingMessageIndex); if (element) { // Restaurer le contenu original depuis chat const messages = chat.getCurrentMessages(); const msg = messages[this.editingMessageIndex]; if (msg) { const contentDiv = element.querySelector('.message-content'); if (contentDiv) { contentDiv.innerHTML = ''; const paragraphs = msg.content.split('\n').filter(p => p.trim() !== ''); if (paragraphs.length === 0) { const p = document.createElement('p'); p.textContent = msg.content || ' '; contentDiv.appendChild(p); } else { paragraphs.forEach(text => { const p = document.createElement('p'); p.textContent = text; contentDiv.appendChild(p); }); } contentDiv.style.cursor = 'pointer'; contentDiv.title = 'Cliquez pour modifier'; } } } this.editingMessageIndex = -1; } // Mettre à jour un message existant (pour le streaming) updateMessage(messageDiv, newContent) { const contentDiv = messageDiv.querySelector('.message-content'); if (!contentDiv) return; // Mettre à jour le contenu contentDiv.innerHTML = ''; const paragraphs = newContent.split('\n').filter(p => p.trim() !== ''); if (paragraphs.length === 0) { const p = document.createElement('p'); p.textContent = newContent || ' '; contentDiv.appendChild(p); } else { paragraphs.forEach(text => { const p = document.createElement('p'); p.textContent = text; contentDiv.appendChild(p); }); } this.scrollToBottom(); } // Afficher plusieurs messages (pour chargement) renderMessages(messages) { this.messagesContainer.innerHTML = ''; this.messageElements.clear(); this.editingMessageIndex = -1; // Message de bienvenue si vide if (!messages || messages.length === 0) { const welcome = { role: 'assistant', content: 'Bonjour ! Je suis **Nixaura**, votre assistant IA. Comment puis-je vous aider aujourd\'hui ?' }; this.appendMessage(welcome, -1); return; } messages.forEach((msg, index) => this.appendMessage(msg, index)); this.scrollToBottom(); } // Afficher l'indicateur de réflexion showTyping(show) { this.typingIndicator.style.display = show ? 'flex' : 'none'; if (show) this.scrollToBottom(); } // Faire défiler vers le bas scrollToBottom() { const container = this.messagesContainer.parentElement; container.scrollTop = container.scrollHeight; } // Mettre à jour la liste de l'historique renderHistory() { const conversations = storage.getAllConversations(); const activeId = storage.getActiveConversationId(); this.historyList.innerHTML = ''; if (conversations.length === 0) { const empty = document.createElement('li'); empty.className = 'history-item'; empty.textContent = 'Aucune conversation'; empty.style.color = 'var(--text-muted)'; empty.style.cursor = 'default'; this.historyList.appendChild(empty); return; } // Trier par date de mise à jour (plus récent en premier) const sorted = [...conversations].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt) ); sorted.forEach(conv => { const li = document.createElement('li'); li.className = 'history-item'; if (conv.id === activeId) li.classList.add('active'); const titleSpan = document.createElement('span'); titleSpan.textContent = conv.title || 'Sans titre'; titleSpan.style.flex = '1'; titleSpan.style.overflow = 'hidden'; titleSpan.style.textOverflow = 'ellipsis'; titleSpan.style.whiteSpace = 'nowrap'; const deleteBtn = document.createElement('button'); deleteBtn.className = 'delete-btn'; deleteBtn.innerHTML = ''; deleteBtn.setAttribute('aria-label', 'Supprimer cette conversation'); deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); this.deleteConversation(conv.id); }); li.appendChild(titleSpan); li.appendChild(deleteBtn); li.addEventListener('click', () => { this.loadConversation(conv.id); }); this.historyList.appendChild(li); }); } // Charger une conversation loadConversation(convId) { const conv = storage.getConversation(convId); if (!conv) return; chat.loadConversation(conv); this.renderMessages(chat.getCurrentMessages()); this.renderHistory(); // Fermer le menu mobile this.sidebar.classList.remove('open'); } // Supprimer une conversation deleteConversation(convId) { const activeId = storage.getActiveConversationId(); storage.deleteConversation(convId); if (activeId === convId) { // La conversation active a été supprimée, en créer une nouvelle chat.createNewConversation(); this.renderMessages(chat.getCurrentMessages()); } this.renderHistory(); } // Créer une nouvelle conversation newConversation() { chat.createNewConversation(); this.renderMessages(chat.getCurrentMessages()); this.renderHistory(); // Fermer le menu mobile this.sidebar.classList.remove('open'); } // Envoyer un message (appelé par l'UI) - Version streaming async handleSend() { const text = this.messageInput.value.trim(); if (!text) return; if (chat.isProcessing) return; // Annuler toute édition en cours this.cancelEditing(); // Effacer le champ this.messageInput.value = ''; // Afficher le message utilisateur const userMsg = { role: 'user', content: text }; const userIndex = chat.getCurrentMessages().length; this.appendMessage(userMsg, userIndex); // Montrer l'indicateur de réflexion this.showTyping(true); // Créer un message IA vide qui sera rempli progressivement const emptyAIMsg = { role: 'assistant', content: '' }; const aiIndex = userIndex + 1; const aiMessageElement = this.appendMessage(emptyAIMsg, aiIndex); // Cacher l'indicateur de réflexion une fois que le streaming commence this.showTyping(false); try { // Envoyer le message avec callbacks pour le streaming await chat.sendUserMessage( text, // onToken: chaque token reçu (token, fullContent) => { this.updateMessage(aiMessageElement, fullContent); }, // onComplete: streaming terminé (fullContent, usage) => { this.updateMessage(aiMessageElement, fullContent); // Ajouter l'indicateur de tokens if (usage) { const usageDiv = aiMessageElement.querySelector('.usage-indicator'); if (!usageDiv) { const newUsageDiv = document.createElement('div'); newUsageDiv.className = 'usage-indicator'; newUsageDiv.textContent = `Tokens: ${usage.total_tokens || 0} (${usage.prompt_tokens || 0} prompt + ${usage.completion_tokens || 0} completion)`; aiMessageElement.querySelector('.message-wrapper').appendChild(newUsageDiv); } } this.renderHistory(); // Mise à jour du titre this.scrollToBottom(); }, // onError: erreur (error) => { console.error('Erreur streaming:', error); this.updateMessage(aiMessageElement, "Désolé, une erreur s'est produite lors du streaming."); } ); } catch (error) { console.error('Erreur envoi:', error); this.updateMessage(aiMessageElement, "Erreur inattendue. Veuillez réessayer."); } this.scrollToBottom(); } // Initialiser l'application init() { chat.init(); this.renderMessages(chat.getCurrentMessages()); this.renderHistory(); } // --- Écouteurs d'événements --- _bindEvents() { // Envoi de message this.sendBtn.addEventListener('click', () => this.handleSend()); this.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.handleSend(); } }); // Nouvelle conversation this.newChatBtn.addEventListener('click', () => this.newConversation()); // Menu mobile this.menuToggle.addEventListener('click', () => { this.sidebar.classList.toggle('open'); }); // Fermer le menu au clic en dehors (mobile) document.addEventListener('click', (e) => { if (window.innerWidth <= 768) { const isSidebar = this.sidebar.contains(e.target); const isToggle = this.menuToggle.contains(e.target); if (!isSidebar && !isToggle) { this.sidebar.classList.remove('open'); } } }); // Thème this.themeToggle.addEventListener('click', () => this.toggleTheme()); // Paramètres this.settingsBtn.addEventListener('click', () => { this.settingsModal.classList.add('open'); this.themeSelect.value = this.currentTheme; }); this.closeSettings.addEventListener('click', () => { this.settingsModal.classList.remove('open'); }); this.settingsModal.addEventListener('click', (e) => { if (e.target === this.settingsModal) { this.settingsModal.classList.remove('open'); } }); this.themeSelect.addEventListener('change', (e) => { this.applyTheme(e.target.value); }); // Boutons d'action (préparation future) this.attachBtn.addEventListener('click', () => { alert('Fonctionnalité "Joindre un fichier" à venir.'); }); this.micBtn.addEventListener('click', () => { alert('Fonctionnalité "Microphone" à venir.'); }); } } // Instance unique const ui = new UIManager(); export default ui;