Spaces:
Paused
Paused
| // 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(); | |
| } | |
| }; |