champ-ed / static /components /chat-component.js
MalikS-343
squash
cbfe36d
Raw
History Blame Contribute Delete
12 kB
// 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();
}
};