// 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;