// components/chat-component.js - Chat functionality import { StateManager } from '../services/state-manager.js'; import { ApiService } from '../services/api-service.js'; import { TranslationService } from '../services/translation-service.js'; export const ChatComponent = { elements: { chatWindow: null, userInput: null, sendBtn: null, clearBtn: null, systemPresetSelect: null, statusEl: null }, /** * Initialize the chat component */ init() { this.elements.chatWindow = document.getElementById('chatWindow'); this.elements.userInput = document.getElementById('userInput'); this.elements.sendBtn = document.getElementById('sendBtn'); this.elements.clearBtn = document.getElementById('clearBtn'); this.elements.systemPresetSelect = document.getElementById('systemPreset'); this.elements.statusEl = document.getElementById('status'); // This event is dispatched when the user rates a reply. The system // must then mark that reply and re-render it. window.addEventListener('feedbackSubmitted', () => { this.renderMessages(); }); this.attachEventListeners(); this.renderMessages(); }, /** * Attach event listeners */ attachEventListeners() { this.elements.sendBtn.addEventListener('click', () => this.sendMessage()); this.elements.clearBtn.addEventListener('click', () => this.clearConversation()); this.elements.systemPresetSelect.addEventListener('change', () => this.onModelChange()); // Enter to send, Shift+Enter = newline this.elements.userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.sendMessage(); } }); }, /** * Render all messages in the chat window */ renderMessages() { this.elements.chatWindow.innerHTML = ''; const modelType = this.elements.systemPresetSelect.value; const messages = StateManager.getMessages(modelType); messages.forEach((m, index) => { const messageContainer = document.createElement('div'); messageContainer.classList.add('message-container'); const bubble = document.createElement('div'); bubble.classList.add( 'msg-bubble', m.role === 'user' ? 'user' : 'assistant' ); if (m.content === "no_reply") { bubble.dataset.i18n = "no_reply"; } else { // convert markdown to HTML safely bubble.innerHTML = DOMPurify.sanitize(marked.parse(m.content)); } messageContainer.appendChild(bubble); // Add feedback buttons for assistant messages only if (m.role === 'assistant' && m.content !== "no_reply") { const feedbackButtons = this.createFeedbackButtons(index, modelType, m); messageContainer.appendChild(feedbackButtons); } this.elements.chatWindow.appendChild(messageContainer); }); TranslationService.applyTranslation(); this.elements.chatWindow.scrollTop = this.elements.chatWindow.scrollHeight; }, /** * Create feedback buttons for a message * @param {number} index - Message index * @param {string} modelType - Model type * @param {Object} message - Message object * @returns {HTMLElement} Feedback buttons container */ createFeedbackButtons(index, modelType, message) { const container = document.createElement('div'); container.classList.add('feedback-buttons'); // Check if already rated const isRated = message.feedback?.rated; const currentRating = message.feedback?.rating; // Copy button const copyBtn = document.createElement('button'); copyBtn.classList.add('feedback-btn', 'copy-btn'); copyBtn.innerHTML = '📋'; copyBtn.dataset.i18nTitle = "copy_reply_btn"; copyBtn.title = translations[StateManager.currentLang]["copy_reply_btn"]; copyBtn.addEventListener('click', () => { this.copyMessage(message.content, copyBtn); }); // Like button const likeBtn = document.createElement('button'); likeBtn.classList.add('feedback-btn', 'like-feedback-btn'); if (isRated && currentRating === 'like') likeBtn.classList.add('active'); likeBtn.innerHTML = '👍'; likeBtn.dataset.i18nTitle = "feedback_like_btn"; likeBtn.title = translations[StateManager.currentLang]["feedback_like_btn"]; likeBtn.addEventListener('click', () => { window.FeedbackComponent.openModal(index, modelType, 'like', message.content); }); // Dislike button const dislikeBtn = document.createElement('button'); dislikeBtn.classList.add('feedback-btn', 'dislike-feedback-btn'); if (isRated && currentRating === 'dislike') dislikeBtn.classList.add('active'); dislikeBtn.innerHTML = '👎'; dislikeBtn.dataset.i18nTitle = "feedback_dislike_btn"; dislikeBtn.title = translations[StateManager.currentLang]["feedback_dislike_btn"]; dislikeBtn.addEventListener('click', () => { window.FeedbackComponent.openModal(index, modelType, 'dislike', message.content); }); // Mixed button const mixedBtn = document.createElement('button'); mixedBtn.classList.add('feedback-btn', 'mixed-feedback-btn'); if (isRated && currentRating === 'mixed') mixedBtn.classList.add('active'); mixedBtn.innerHTML = '~'; mixedBtn.dataset.i18nTitle = "feedback_mixed_btn"; mixedBtn.title = translations[StateManager.currentLang]["feedback_mixed_btn"]; mixedBtn.addEventListener('click', () => { window.FeedbackComponent.openModal(index, modelType, 'mixed', message.content); }); // TODO: 4 buttons is a lot. The copy button should be isolated in some way. container.appendChild(copyBtn); container.appendChild(likeBtn); container.appendChild(dislikeBtn); container.appendChild(mixedBtn); return container; }, /** * Copy message content to clipboard * @param {string} content - Message content to copy * @param {HTMLElement} button - The copy button element */ async copyMessage(content, button) { // Strip HTML and get plain text const tempDiv = document.createElement('div'); tempDiv.innerHTML = DOMPurify.sanitize(marked.parse(content)); const plainText = tempDiv.innerText || tempDiv.textContent; // Copy to clipboard await navigator.clipboard.writeText(plainText); // Visual feedback - change icon temporarily const originalIcon = button.innerHTML; button.innerHTML = '✓'; button.classList.add('copied'); // Show snackbar showSnackbar(translations[StateManager.currentLang]["message_copied"], 'success', 2000); // Reset after 2 seconds setTimeout(() => { button.innerHTML = originalIcon; button.classList.remove('copied'); }, 2000); }, /** * Send a message to the chat */ async sendMessage() { const text = this.elements.userInput.value.trim(); if (!text) return; const modelType = this.elements.systemPresetSelect.value; // Add user message locally StateManager.addMessage(modelType, { role: 'user', content: text }); this.renderMessages(); this.elements.userInput.value = ''; // Update status this.setStatus('thinking', 'info'); try { const res = await ApiService.sendChatMessage(text, modelType); const contentType = res.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { // Batch response const data = await res.json(); const reply = data.reply || "no_reply"; StateManager.addMessage(modelType, { role: 'assistant', content: reply }); this.renderMessages(); } else { // Streaming response const assistantMessage = { role: 'assistant', content: '' }; StateManager.addMessage(modelType, assistantMessage); const reader = res.body.getReader(); const decoder = new TextDecoder(); let done = false; while (!done) { const { value, done: readerDone } = await reader.read(); done = readerDone; const chunk = decoder.decode(value, { stream: true }); assistantMessage.content += chunk; this.renderMessages(); } } this.setStatus('ready', 'ok'); } catch (err) { if (err.message === 'HTTP 400') { this.setStatus('empty_message_error', 'error'); } else if (err.message.startsWith('HTTP')) { this.setStatus('server_error', 'error'); } else { this.setStatus('network_error', 'error'); } } }, /** * Clear the conversation */ clearConversation() { const modelType = this.elements.systemPresetSelect.value; StateManager.clearConversation(modelType); this.renderMessages(); this.setStatus('conversation_cleared', 'ok'); }, /** * Handle model change */ onModelChange() { this.setStatus('model_changed', 'ok'); this.renderMessages(); }, /** * Set status message * @param {string} messageKey - Translation key for the message * @param {string} type - Status type ('ok', 'info', 'error') */ setStatus(messageKey, type) { this.elements.statusEl.dataset.i18n = messageKey; this.elements.statusEl.className = `status status-${type}`; TranslationService.applyTranslation(); } };