Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| // 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'; | |
| import { CarbonTracker } from './carbon-tracker-component.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', m.role); | |
| // 1. Create a row for the bubble and copy button | |
| const bubbleRow = document.createElement('div'); | |
| bubbleRow.classList.add('bubble-row'); | |
| const bubble = document.createElement('div'); | |
| bubble.classList.add('msg-bubble', m.role); | |
| if (m.content === "no_reply") { | |
| bubble.dataset.i18n = "no_reply"; | |
| } else { | |
| // convert markdown to HTML safely | |
| bubble.innerHTML = DOMPurify.sanitize(marked.parse(m.content)); | |
| // Opening the links create a new tab | |
| bubble.querySelectorAll('a').forEach(link => { | |
| link.setAttribute('target', '_blank'); | |
| link.setAttribute('rel', 'noopener noreferrer'); | |
| }); | |
| } | |
| bubbleRow.appendChild(bubble); | |
| // Add copy button for all messages (except "no_reply") | |
| if (m.content !== "no_reply") { | |
| const copyButton = this.createCopyButton(m.content); | |
| bubbleRow.appendChild(copyButton); | |
| } | |
| messageContainer.appendChild(bubbleRow); | |
| // Add feedback buttons for assistant messages only | |
| if (m.role === 'assistant') { | |
| 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 copy button for a message | |
| * @param {string} content - Message content | |
| * @returns {HTMLElement} Copy button | |
| */ | |
| createCopyButton(content) { | |
| const copyBtn = document.createElement('button'); | |
| copyBtn.classList.add('copy-btn'); | |
| copyBtn.innerHTML = '<img src="/static/public/copy.svg" alt="Copy">'; | |
| copyBtn.dataset.i18nTitle = "copy_reply_btn"; | |
| copyBtn.title = translations[StateManager.currentLang]["copy_reply_btn"]; | |
| copyBtn.addEventListener('click', () => { | |
| this.copyMessage(content); | |
| }); | |
| return copyBtn; | |
| }, | |
| /** | |
| * 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'); | |
| const isRated = message.feedback?.rated; | |
| const currentRating = message.feedback?.rating; | |
| const messageId = message.replyId; | |
| // 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, messageId); | |
| }); | |
| // 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, messageId); | |
| }); | |
| // 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, messageId); | |
| }); | |
| container.appendChild(likeBtn); | |
| container.appendChild(dislikeBtn); | |
| container.appendChild(mixedBtn); | |
| return container; | |
| }, | |
| /** | |
| * Copy message content to clipboard | |
| * @param {string} content - Message content to copy | |
| */ | |
| async copyMessage(content) { | |
| // 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); | |
| // Show snackbar | |
| showSnackbar(translations[StateManager.currentLang]["message_copied"], 'success', 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 = ''; | |
| // this.elements.userInput.height = 'auto'; | |
| // Close mobile toolbar and show-more header on send | |
| const toggleBtn = document.getElementById('mobile-toolbar-toggle'); | |
| const controlsBar = document.getElementById('controls-bar'); | |
| if (toggleBtn && controlsBar && controlsBar.style.display === 'flex') { | |
| toggleBtn.classList.remove('open'); | |
| controlsBar.style.display = 'none'; | |
| } | |
| const detailsEl = document.querySelector('.chat-header details'); | |
| if (detailsEl) detailsEl.removeAttribute('open'); | |
| // 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"; | |
| const replyId = data.reply_id || ""; | |
| const gwpKgcoeq = data.gwp_kgcoeq || 0; | |
| const waterL = data.water_L || 0; | |
| const electricityKWh = data.electricity_kWh || 0; | |
| const nTokens = data.n_tokens || 0; | |
| StateManager.addMessage(modelType, { role: 'assistant', content: reply, replyId: replyId, gwpKgcoeq: gwpKgcoeq, nTokens: nTokens, waterL: waterL, electricityKWh: electricityKWh }); | |
| this.renderMessages(); | |
| } else { // Streaming response | |
| // The reply id is stored in the response headers. | |
| const replyId = res.headers.get("X-Reply-ID") | |
| const assistantMessage = { role: 'assistant', content: '', replyId: replyId}; | |
| StateManager.addMessage(modelType, assistantMessage); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let done = false; | |
| // Read the rest of the streaming data to get the message | |
| while (!done) { | |
| const { value, done: readerDone } = await reader.read(); | |
| done = readerDone; | |
| let chunk = decoder.decode(value, { stream: true }); | |
| // Check for emissions marker | |
| const emissionsMatch = chunk.match(/###EMISSIONS:([\d.eE+-]+)###/); | |
| if (emissionsMatch) { | |
| assistantMessage.gwpKgcoeq = parseFloat(emissionsMatch[1]); | |
| chunk = chunk.replace(/###EMISSIONS:[\d.eE+-]+###/, ''); | |
| } | |
| // Check for token count marker | |
| const tokenCountMatch = chunk.match(/###TOKEN_COUNT:(\d+)###/); | |
| if (tokenCountMatch) { | |
| assistantMessage.nTokens = parseInt(tokenCountMatch[1], 10); | |
| chunk = chunk.replace(/###TOKEN_COUNT:\d+###/, ''); | |
| } | |
| // Check for water marker | |
| const waterMatch = chunk.match(/###WATER:([\d.eE+-]+)###/); | |
| if (waterMatch) { | |
| assistantMessage.waterL = parseFloat(waterMatch[1]); | |
| chunk = chunk.replace(/###WATER:[\d.eE+-]+###/, ''); | |
| } | |
| // Check for energy marker | |
| const energyMatch = chunk.match(/###ENERGY:([\d.eE+-]+)###/); | |
| if (energyMatch) { | |
| assistantMessage.electricityKWh = parseFloat(energyMatch[1]); | |
| chunk = chunk.replace(/###ENERGY:[\d.eE+-]+###/, ''); | |
| } | |
| // Add remaining content (with markers removed) | |
| assistantMessage.content += chunk; | |
| this.renderMessages(); | |
| } | |
| } | |
| CarbonTracker.updateEmissions(); | |
| 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(); | |
| } | |
| }; |