champ-chatbot / static /components /chat-component.js
qyle's picture
deployment
2d42370 verified
// 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();
}
};