class EveAIChatWidget extends HTMLElement { static get observedAttributes() { return [ 'tenant-id', 'session-token', 'language', 'languages', 'specialist-id', 'server-url' ]; } constructor() { super(); // Networking attributes this.socket = null; // Initialize socket to null this.room = null; this.lastRoom = null; // Store last known room this.userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Detect user's timezone this.heartbeatInterval = null; this.idleTime = 0; // in milliseconds this.maxConnectionIdleTime = 1 * 60 * 60 * 1000; // 1 hour in milliseconds this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; // EveAI specific attributes this.languages = [] this.currentLanguage = null; this.specialistId = null; console.log('EveAIChatWidget constructor called'); // Bind methods to ensure correct 'this' context this.handleSendMessage = this.handleSendMessage.bind(this); this.handleTokenUpdate = this.handleTokenUpdate.bind(this); this.updateAttributes = this.updateAttributes.bind(this); } connectedCallback() { console.log('Chat Widget Connected'); this.innerHTML = this.getTemplate(); this.setupElements() this.addEventListeners() if (this.areAllAttributesSet()) { console.log('All attributes are set, populating language dropdown'); this.populateLanguageDropdown() console.log('All attributes are set, initializing socket'); this.initializeSocket(); } else { console.warn('Not all required attributes are set yet'); } } setupElements() { // Centralizes element querying this.messagesArea = this.querySelector('.messages-area'); this.questionInput = this.querySelector('.question-area textarea'); this.sendButton = this.querySelector('.send-icon'); this.languageSelect = this.querySelector('.language-select'); this.statusLine = this.querySelector('.status-line'); this.statusMessage = this.querySelector('.status-message'); this.connectionStatusIcon = this.querySelector('.connection-status-icon'); } addEventListeners() { // Centralized event listener setup this.sendButton.addEventListener('click', this.handleSendMessage); this.questionInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); this.handleSendMessage(); } }); } populateLanguageDropdown() { // Clear existing options this.languageSelect.innerHTML = ''; console.log(`languages for options: ${this.languages}`) // Populate with new options this.languages.forEach(lang => { const option = document.createElement('option'); option.value = lang; option.textContent = lang.toUpperCase(); if (lang === this.currentLanguage) { option.selected = true; } console.log(`Adding option for language: ${lang}`) this.languageSelect.appendChild(option); }); // Add event listener for language change this.languageSelect.addEventListener('change', (e) => { this.currentLanguage = e.target.value; // You might want to emit an event or update the backend about the language change }); } attributeChangedCallback(name, oldValue, newValue) { console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`); // Handle token updates specially if (name === 'session-token' && oldValue !== newValue) { this.updateAttributes(); if (newValue) { console.log('Received new session token'); this.sessionToken = newValue; // If socket exists, reconnect with new token if (this.socket) { this.socket.disconnect(); this.initializeSocket(); } else if (this.areAllAttributesSet()) { // Initialize socket if all other attributes are ready this.initializeSocket(); } } return; } if (name === 'languages' || name === 'language') { this.updateAttributes(); this.populateLanguageDropdown(); return; } this.updateAttributes(); } updateAttributes() { this.tenantId = parseInt(this.getAttribute('tenant-id')); this.sessionToken = this.getAttribute('session-token'); this.language = this.getAttribute('language'); const languageAttr = this.getAttribute('languages'); this.languages = languageAttr ? languageAttr.split(',') : []; this.serverUrl = this.getAttribute('server-url'); this.currentLanguage = this.language; this.specialistId = this.getAttribute('specialist-id'); console.log('Updated attributes:', { tenantId: this.tenantId, sessionToken: this.sessionToken, language: this.language, currentLanguage: this.currentLanguage, languages: this.languages, serverUrl: this.serverUrl, specialistId: this.specialistId }); } areAllAttributesSet() { console.log('Checking if all attributes are set:', { tenantId: this.tenantId, sessionToken: this.sessionToken, language: this.language, languages: this.languages, serverUrl: this.serverUrl, specialistId: this.specialistId }); const requiredAttributes = [ 'tenant-id', 'session-token', 'language', 'languages', 'specialist-id', 'server-url' ]; return requiredAttributes.every(attr => this.getAttribute(attr)); } handleTokenUpdate(newToken) { if (this.socket && this.socket.connected) { console.log('Updating socket connection with new token'); // Emit token update event to server this.socket.emit('update_token', { token: newToken }); } else if (newToken && !this.socket) { // If we have a new token but no socket, try to initialize this.initializeSocket(); } } initializeSocket() { console.log(`Initializing socket connection to Evie at ${this.serverUrl}`); if (this.socket) { console.log('Socket already initialized'); return; } if (!this.sessionToken) { console.error('Cannot initialize socket without session token'); return; } this.socket = io(this.serverUrl, { path: '/chat/socket.io/', transports: ['websocket'], query: { // Change from auth to query token: this.sessionToken }, reconnectionAttempts: 5, // Infinite reconnection attempts reconnectionDelay: 5000, // Delay between reconnections timeout: 20000, // Connection timeout }); if (!this.socket) { console.error('Error initializing socket') } else { console.log('Socket initialized') } this.setupSocketEventHandlers(); } setupSocketEventHandlers() { // connect handler -------------------------------------------------------- this.socket.on('connect', (data) => { console.log('Socket connected OK'); this.setStatusMessage('Connected to EveAI.'); this.updateConnectionStatus(true); this.startHeartbeat(); if (data?.room) { this.room = data.room; this.lastRoom = this.room; console.log(`Joined room: ${this.room}`); } else { console.log('Room information not received on connect'); } }); // authenticated handler -------------------------------------------------- this.socket.on('authenticated', (data) => { console.log('Authenticated event received'); this.setStatusMessage('Authenticated.'); if (data?.room) { this.room = data.room; this.lastRoom = this.room; console.log(`Confirmed room: ${this.room}`); } else { console.log('Room information not received on authentication'); } }); // Room join handler ------------------------------------------------------ this.socket.on('room_join', (data) => { console.log('Room join event received:', data); if (data?.room) { this.room = data.room; this.lastRoom = this.room; console.log(`Joined room: ${this.room}`); } }); // connect-error handler -------------------------------------------------- this.socket.on('connect_error', (err) => { console.error('Socket connection error:', err); this.setStatusMessage('Connection Error: EveAI Chat Widget needs further configuration by site administrator.'); this.updateConnectionStatus(false); }); // connect-timeout handler ------------------------------------------------ this.socket.on('connect_timeout', () => { console.error('Socket connection timeout'); this.setStatusMessage('Connection Timeout: EveAI Chat Widget needs further configuration by site administrator.'); this.updateConnectionStatus(false); }); // disconnect handler ----------------------------------------------------- this.socket.on('disconnect', (reason) => { console.log('Socket disconnected: ', reason); if (reason === 'io server disconnect') { // Server disconnected the socket this.socket.connect(); // Attempt to reconnect } this.setStatusMessage('Disconnected from EveAI. Please refresh the page for further interaction.'); this.updateConnectionStatus(false); this.stopHeartbeat(); this.room = null; }); // Token related handlers ------------------------------------------------- this.socket.on('token_expired', () => { console.log('Token expired'); this.setStatusMessage('Session expired. Please refresh the page.'); this.updateConnectionStatus(false); }); // reconnect_attempt handler ---------------------------------------------- this.socket.on('reconnect_attempt', (attemptNumber) => { console.log(`Reconnection attempt ${attemptNumber}`); this.setStatusMessage(`Reconnecting... (Attempt ${attemptNumber})`); this.reconnectAttempts = attemptNumber; }); // reconnect handler ------------------------------------------------------ this.socket.on('reconnect', () => { console.log('Successfully reconnected to the server'); this.setStatusMessage('Reconnected to EveAI.'); this.updateConnectionStatus(true); this.startHeartbeat(); }); // reconnect failed ------------------------------------------------------- this.socket.on('reconnect_failed', () => { console.log('Reconnection failed'); this.setStatusMessage('Unable to reconnect. Please refresh the page.'); this.handleReconnectFailure(); }); // room rejoin result ----------------------------------------------------- this.socket.on('room_rejoin_result', (response) => { if (response.success) { console.log('Successfully rejoined room'); this.room = response.room; this.setStatusMessage('Reconnected successfully.'); } else { console.error('Failed to rejoin room'); this.handleRoomRejoinFailure(); } }); // bot_response handler --------------------------------------------------- this.socket.on('bot_response', (data) => { console.log('Bot response received: ', data); if (data.tenantId === this.tenantId && data?.room === this.room) { setTimeout(() => this.startTaskCheck(data.taskId), 1000); this.setStatusMessage('Processing...'); } else { console.log('Received message for different room or tenant, ignoring'); } }); // task_status handler ---------------------------------------------------- this.socket.on('task_status', (data) => { console.log('Task status received:', data); if (!this.room) { console.log('No room assigned, cannot process task status'); return; } this.handleTaskStatus(data); }); // Feedback handler ------------------------------------------------------- this.socket.on('feedback_received', (data) => { if (data?.room === this.room) { this.setStatusMessage(data.status === 'success' ? 'Feedback recorded.' : 'Failed to record feedback.'); } }); } attemptRoomRejoin() { console.log(`Attempting to rejoin room: ${this.lastRoom}`); this.socket.emit('rejoin_room', { token: this.sessionToken, tenantId: this.tenantId, previousRoom: this.lastRoom, timestamp: Date.now() }); } handleReconnectFailure() { this.room = null; this.lastRoom = null; this.reconnectAttempts = 0; this.updateConnectionStatus(false); // Optionally reload the widget if (confirm('Connection lost. Would you like to refresh the chat?')) { window.location.reload(); } } handleRoomRejoinFailure() { // Clear room state this.room = null; this.lastRoom = null; // Request new room this.socket.emit('request_new_room', { token: this.sessionToken, tenantId: this.tenantId }); } clearRoomState() { // Use when intentionally leaving/clearing a room this.room = null; this.lastRoom = null; this.reconnectAttempts = 0; } handleAuthError(error) { console.error('Authentication error:', error); this.setStatusMessage('Authentication failed. Please refresh the page.'); this.updateConnectionStatus(false); if (this.socket) { this.socket.disconnect(); } } setStatusMessage(message) { this.statusMessage.textContent = message; } updateConnectionStatus(isConnected) { if (isConnected) { this.connectionStatusIcon.textContent = 'link'; this.connectionStatusIcon.classList.remove('status-disconnected'); this.connectionStatusIcon.classList.add('status-connected'); } else { this.connectionStatusIcon.textContent = 'link_off'; this.connectionStatusIcon.classList.remove('status-connected'); this.connectionStatusIcon.classList.add('status-disconnected'); } } startHeartbeat() { this.stopHeartbeat(); // Clear any existing interval this.heartbeatInterval = setInterval(() => { if (this.socket && this.socket.connected) { this.socket.emit('heartbeat'); this.idleTime += 30000; if (this.idleTime >= this.maxConnectionIdleTime) { this.socket.disconnect(); this.setStatusMessage('Disconnected due to inactivity.'); this.updateConnectionStatus(false); this.stopHeartbeat(); } } }, 30000); // Send a heartbeat every 30 seconds } stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } } updateProgress() { if (!this.statusMessage.textContent) { this.statusMessage.textContent = 'Processing...'; } else { this.statusMessage.textContent += '.'; // Append a dot } } clearProgress() { this.statusMessage.textContent = ''; this.toggleSendButton(false); // Re-enable and revert send button to outlined version } checkTaskStatus(taskId) { this.updateProgress(); this.socket.emit('check_task_status', { task_id: taskId }); } getTemplate() { return `
Evie can make mistakes. Please double-check responses.
send
link_off
`; } addUserMessage(text) { const message = document.createElement('div'); message.classList.add('message', 'user'); message.innerHTML = `

${text}

`; this.messagesArea.appendChild(message); this.messagesArea.scrollTop = this.messagesArea.scrollHeight; } handleFeedback(feedback, interactionId) { // Send feedback to the backend console.log('handleFeedback called'); if (!this.socket) { console.error('Socket is not initialized'); return; } if (!this.validateRoom()) { console.log("No valid room to handle feedback") return; } console.log(`Sending feedback for ${interactionId}: ${feedback}`); this.socket.emit('feedback', { tenant_id: this.tenantId, token: this.sessionToken, feedback, interactionId, room: this.room }); this.setStatusMessage('Feedback sent.'); } addBotMessage(text, interactionId, algorithm = 'default', citations = []) { const message = document.createElement('div'); message.classList.add('message', 'bot'); let content = marked.parse(text); let citationsHtml = citations.map(url => `${url}`).join('
'); let algorithmClass; switch (algorithm) { case 'RAG_TENANT': algorithmClass = 'fingerprint-rag-tenant'; break; case 'RAG_WIKIPEDIA': algorithmClass = 'fingerprint-rag-wikipedia'; break; case 'RAG_GOOGLE': algorithmClass = 'fingerprint-rag-google'; break; case 'LLM': algorithmClass = 'fingerprint-llm'; break; default: algorithmClass = ''; } message.innerHTML = `

${content}

${citationsHtml ? `

${citationsHtml}

` : ''}
fingerprint thumb_up_off_alt thumb_down_off_alt
`; this.messagesArea.appendChild(message); // Add event listeners for feedback buttons const thumbsUp = message.querySelector('i[data-feedback="up"]'); const thumbsDown = message.querySelector('i[data-feedback="down"]'); thumbsUp.addEventListener('click', () => this.toggleFeedback(thumbsUp, thumbsDown, 'up', interactionId)); thumbsDown.addEventListener('click', () => this.toggleFeedback(thumbsUp, thumbsDown, 'down', interactionId)); this.messagesArea.scrollTop = this.messagesArea.scrollHeight; } toggleFeedback(thumbsUp, thumbsDown, feedback, interactionId) { console.log('feedback called'); this.idleTime = 0; // Reset idle time if (feedback === 'up') { thumbsUp.textContent = 'thumb_up'; // Change to filled icon thumbsUp.classList.remove('outlined'); thumbsUp.classList.add('filled'); thumbsDown.textContent = 'thumb_down_off_alt'; // Keep the other icon outlined thumbsDown.classList.add('outlined'); thumbsDown.classList.remove('filled'); } else { thumbsDown.textContent = 'thumb_down'; // Change to filled icon thumbsDown.classList.remove('outlined'); thumbsDown.classList.add('filled'); thumbsUp.textContent = 'thumb_up_off_alt'; // Keep the other icon outlined thumbsUp.classList.add('outlined'); thumbsUp.classList.remove('filled'); } // Send feedback to the backend this.handleFeedback(feedback, interactionId); } handleSendMessage() { console.log('handleSendMessage called'); this.idleTime = 0; // Reset idle time if (!this.socket?.connected) { console.error('Cannot send message: socket not connected'); this.setStatusMessage('Not connected to server. Please try again.'); return; } if (!this.room) { console.error('Cannot send message: no room assigned'); this.setStatusMessage('Connection not ready. Please wait...'); // Try to rejoin room if we have a last known room if (this.lastRoom) { this.attemptRoomRejoin(); } return; } const message = this.questionInput.value.trim(); if (message) { this.addUserMessage(message); this.questionInput.value = ''; this.sendMessageToBackend(message); this.toggleSendButton(true); // Disable and change send button to filled version } } startTaskCheck(taskId) { if (!this.validateRoom()) { console.error('Cannot check task status: no room assigned'); return; } console.log('Emitting check_task_status for:', taskId); this.socket.emit('check_task_status', { task_id: taskId, token: this.sessionToken, tenant_id: this.tenantId, room: this.room }); } handleTaskStatus(data) { if (data.status === 'pending') { this.updateProgress(); setTimeout(() => this.startTaskCheck(data.taskId), 1000); } else if (data.status === 'success') { if (data.results) { this.addBotMessage( data.results.answer, data.interaction_id, 'RAG_TENANT', data.results.citations || [] ); } else { console.error('Missing results in task status response:', data); } this.clearProgress(); } else { console.error('Task error:', data); this.setStatusMessage('Failed to process message.'); } } sendMessageToBackend(message) { if (!this.socket || !this.room) { console.error('Cannot send message: socket or room not available'); return; } if (!this.validateRoom()) { return; } const selectedLanguage = this.languageSelect.value; const messageData = { tenant_id: parseInt(this.tenantId), token: this.sessionToken, specialist_id: parseInt(this.specialistId), arguments: { language: selectedLanguage, query: message }, timezone: this.userTimezone, room: this.room }; console.log('Sending message to backend:', messageData); this.socket.emit('user_message', messageData); this.setStatusMessage('Processing started ...'); } toggleSendButton(isProcessing) { if (isProcessing) { this.sendButton.textContent = 'send'; // Filled send icon this.sendButton.classList.remove('outlined'); this.sendButton.classList.add('filled'); this.sendButton.classList.add('disabled'); // Add disabled class for styling this.sendButton.style.pointerEvents = 'none'; // Disable click events } else { this.sendButton.textContent = 'send'; // Outlined send icon this.sendButton.classList.add('outlined'); this.sendButton.classList.remove('filled'); this.sendButton.classList.remove('disabled'); // Remove disabled class this.sendButton.style.pointerEvents = 'auto'; // Re-enable click events } } validateRoom() { if (!this.room) { console.error('No room assigned'); this.setStatusMessage('Connection not ready. Please wait...'); // Try to rejoin room if we have a last known room if (this.lastRoom) { this.attemptRoomRejoin(); } return false; } return true; } } customElements.define('eveai-chat-widget', EveAIChatWidget);