/** * Chatty - Ethics Chat Application * Frontend JavaScript for the chat interface * * Features: * - Real-time chat with findEthics-Atlas API * - Message history management * - Error handling and user feedback * - Responsive UI interactions */ // Chat application JavaScript class ChatApp { constructor() { this.chatMessages = document.getElementById('chatMessages'); this.chatForm = document.getElementById('chatForm'); this.messageInput = document.getElementById('messageInput'); this.sendButton = document.getElementById('sendButton'); this.loadingIndicator = document.getElementById('loadingIndicator'); this.chatHistory = []; this.userId = null; // Will be set from API responses this.historyLoaded = false; this.sessionWarningShown = false; // Check if anonymous mode this.isAnonymous = window.chatConfig && window.chatConfig.isAnonymous; this.anonymousRateLimit = window.chatConfig ? window.chatConfig.anonymousRateLimit : 0; this.anonymousId = window.chatConfig ? window.chatConfig.anonymousId : ''; this.messageCount = 0; // Track message count for anonymous users this.init(); } init() { // Add event listeners this.chatForm.addEventListener('submit', (e) => this.handleSubmit(e)); this.messageInput.addEventListener('keypress', (e) => this.handleKeyPress(e)); this.messageInput.addEventListener('input', (e) => this.handleInputResize(e)); // Add clear chat button listener const clearButton = document.getElementById('clearChatButton'); if (clearButton) { clearButton.addEventListener('click', () => this.handleClearChat()); } // Mobile-specific optimizations this.setupMobileOptimizations(); // Load chat history this.loadChatHistory(); // Set up session monitoring this.setupSessionMonitoring(); // Focus on input when page loads (but not on mobile to prevent keyboard popup) if (!this.isMobile()) { this.messageInput.focus(); } } isMobile() { return window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); } setupMobileOptimizations() { // Handle viewport changes for mobile keyboards if (this.isMobile()) { let initialViewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight; const handleViewportChange = () => { if (window.visualViewport) { const currentHeight = window.visualViewport.height; const heightDiff = initialViewportHeight - currentHeight; // Adjust container when keyboard appears if (heightDiff > 150) { // Keyboard is likely open document.body.style.height = `${currentHeight}px`; } else { document.body.style.height = '100vh'; } } }; if (window.visualViewport) { window.visualViewport.addEventListener('resize', handleViewportChange); } // Prevent zoom on double tap let lastTouchEnd = 0; document.addEventListener('touchend', (event) => { const now = (new Date()).getTime(); if (now - lastTouchEnd <= 300) { event.preventDefault(); } lastTouchEnd = now; }, false); } } handleInputResize(e) { // Auto-resize textarea const input = e.target; input.style.height = 'auto'; const newHeight = Math.min(input.scrollHeight, 120); input.style.height = newHeight + 'px'; // Scroll to bottom if needed when textarea expands if (newHeight > 44) { setTimeout(() => this.scrollToBottom(), 100); } } handleKeyPress(e) { // Allow Enter key to submit form (but not on mobile where Enter should create new line) if (e.key === 'Enter' && !e.shiftKey && !this.isMobile()) { e.preventDefault(); this.chatForm.dispatchEvent(new Event('submit')); } } async handleSubmit(e) { e.preventDefault(); const message = this.messageInput.value.trim(); if (!message) return; // Add user message to chat this.addMessage(message, 'user'); // Clear input and disable send button this.messageInput.value = ''; this.messageInput.style.height = 'auto'; // Reset textarea height this.setLoading(true); try { // Send message to backend const response = await this.sendMessage(message); if (response.success) { // Add assistant response to chat this.addMessage(response.response, 'assistant'); // Update chat history this.chatHistory.push({ user: message, assistant: response.response }); // Store user_id for future use (if provided) if (response.user_id) { this.userId = response.user_id; } // Update message counter for anonymous users if (this.isAnonymous && response.message_count !== undefined) { this.updateMessageCounter(response.message_count); // Show warning if approaching limit if (response.messages_remaining !== undefined && response.messages_remaining <= 3 && response.messages_remaining > 0) { this.showRateLimitWarning(response.messages_remaining); } } } else { // Show error message this.showError(response.error || 'Failed to get response'); } } catch (error) { // Error already handled with user-friendly messages // Provide specific error messages based on error type if (error.message.includes('401') || error.message.includes('Unauthorized')) { this.handleSessionExpiry(); this.showError('Your session has expired. Please log in again to continue.'); } else if (error.message.includes('429')) { if (this.isAnonymous) { this.showError(`Rate limit exceeded. Anonymous users are limited to ${this.anonymousRateLimit} messages per hour.`); } else { this.showError('You are sending messages too quickly. Please wait a moment and try again.'); } } else if (error.message.includes('400')) { this.showError('There was a problem with your message. Please check it and try again.'); } else if (error.message.includes('500')) { this.showError('The chat service is temporarily unavailable. Please try again in a few moments.'); } else if (error.name === 'TypeError' && error.message.includes('fetch')) { this.showError('Unable to connect to the chat service. Please check your internet connection.'); } else { this.showError('An unexpected error occurred. Please try again.'); } } finally { this.setLoading(false); } } async sendMessage(message) { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message: message, history: this.chatHistory }) }); // Handle different response statuses if (response.status === 401) { this.handleSessionExpiry(); return { success: false, error: 'Your session has expired. Please log in again.' }; } if (response.status === 429) { return { success: false, error: 'You are sending messages too quickly. Please wait a moment and try again.' }; } if (response.status === 400) { const errorData = await response.json().catch(() => ({})); return { success: false, error: errorData.error || 'There was a problem with your message. Please check it and try again.' }; } if (response.status === 500) { return { success: false, error: 'The chat service is temporarily unavailable. Please try again in a few moments.' }; } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } addMessage(content, sender, shouldScroll = true) { const messageDiv = document.createElement('div'); messageDiv.className = `message ${sender}`; const messageContent = document.createElement('div'); messageContent.className = 'message-content'; messageContent.textContent = content; messageDiv.appendChild(messageContent); this.chatMessages.appendChild(messageDiv); // Scroll to bottom only if requested (not for history loading) if (shouldScroll) { this.scrollToBottom(); } } showError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'error-message'; errorDiv.textContent = message; this.chatMessages.appendChild(errorDiv); this.scrollToBottom(); // Remove error message after 5 seconds setTimeout(() => { if (errorDiv.parentNode) { errorDiv.parentNode.removeChild(errorDiv); } }, 5000); } setLoading(loading) { if (loading) { this.sendButton.disabled = true; this.messageInput.disabled = true; this.loadingIndicator.style.display = 'flex'; } else { this.sendButton.disabled = false; this.messageInput.disabled = false; this.loadingIndicator.style.display = 'none'; this.messageInput.focus(); } } async loadChatHistory() { // Skip loading history for anonymous users (they start fresh) if (this.isAnonymous) { this.historyLoaded = true; // Initialize message counter for anonymous users this.updateMessageCounter(0); return; } try { // Show loading state this.showHistoryLoading(true); const response = await fetch('/api/user-history?limit=50'); if (response.status === 401) { // Session expired - redirect to login window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); return; } if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (data.success && data.history && data.history.length > 0) { // Display history messages this.displayHistoryMessages(data.history); // Build chat history for API context (last 10 exchanges) this.buildChatHistoryContext(data.history); } this.historyLoaded = true; } catch (error) { // Error handled with showHistoryError this.showHistoryError('Failed to load chat history'); } finally { this.showHistoryLoading(false); } } displayHistoryMessages(history) { // Clear any existing messages this.chatMessages.innerHTML = ''; // Add each message pair from history history.forEach(session => { if (session.message) { this.addMessage(session.message, 'user', false); // Don't scroll for history } if (session.response) { this.addMessage(session.response, 'assistant', false); // Don't scroll for history } }); // Scroll to bottom after all messages are loaded setTimeout(() => this.scrollToBottom(), 100); } buildChatHistoryContext(history) { // Build context for API calls (last 10 exchanges) this.chatHistory = []; const recentHistory = history.slice(-10); // Get last 10 exchanges recentHistory.forEach(session => { if (session.message && session.response) { this.chatHistory.push({ user: session.message, assistant: session.response }); } }); } showHistoryLoading(loading) { if (loading) { // Show loading message in chat area const loadingDiv = document.createElement('div'); loadingDiv.className = 'history-loading'; loadingDiv.id = 'historyLoading'; loadingDiv.innerHTML = '
Loading chat history...
'; this.chatMessages.appendChild(loadingDiv); } else { // Remove loading message const loadingDiv = document.getElementById('historyLoading'); if (loadingDiv) { loadingDiv.remove(); } } } showHistoryError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'history-error'; errorDiv.innerHTML = `
${message}
`; this.chatMessages.appendChild(errorDiv); // Remove error message after 5 seconds setTimeout(() => { if (errorDiv.parentNode) { errorDiv.parentNode.removeChild(errorDiv); } }, 5000); } scrollToBottom() { // Instant scroll to bottom (no animations) this.chatMessages.scrollTop = this.chatMessages.scrollHeight; } setupSessionMonitoring() { // Check session status periodically (every 5 minutes) setInterval(() => { this.checkSessionStatus(); }, 5 * 60 * 1000); // Check session on page visibility change document.addEventListener('visibilitychange', () => { if (!document.hidden) { this.checkSessionStatus(); } }); } async checkSessionStatus() { try { const response = await fetch('/api/user-history?limit=1'); if (response.status === 401) { this.handleSessionExpiry(); } else if (response.status === 429) { this.showSessionWarning('You are being rate limited. Please wait before making more requests.'); } } catch (error) { // Network error - don't show warning as it might be temporary // Network error - don't show warning as it might be temporary } } handleSessionExpiry() { if (!this.sessionWarningShown) { this.showSessionExpiryWarning(); this.sessionWarningShown = true; } } showSessionExpiryWarning() { // Create session expiry warning banner const warningDiv = document.createElement('div'); warningDiv.className = 'session-expiry-warning'; if (this.isAnonymous) { warningDiv.innerHTML = ` Your anonymous session has expired. `; } else { warningDiv.innerHTML = ` Your session has expired. Please log in again to continue chatting. `; } // Insert at top of page document.body.insertBefore(warningDiv, document.body.firstChild); // Disable chat interface this.messageInput.disabled = true; this.sendButton.disabled = true; this.messageInput.placeholder = this.isAnonymous ? 'Session expired - start a new anonymous session' : 'Session expired - please log in again'; } showSessionWarning(message) { const warningDiv = document.createElement('div'); warningDiv.className = 'session-warning'; warningDiv.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; background-color: #fbbf24; color: #92400e; padding: 0.75rem; text-align: center; font-weight: 500; z-index: 1000; border-bottom: 2px solid #f59e0b; `; warningDiv.textContent = message; document.body.insertBefore(warningDiv, document.body.firstChild); // Remove warning after 5 seconds setTimeout(() => { if (warningDiv.parentNode) { warningDiv.parentNode.removeChild(warningDiv); } }, 5000); } updateMessageCounter(count) { if (!this.isAnonymous) return; this.messageCount = count; const messagesUsedEl = document.getElementById('messagesUsed'); const messageCounterEl = document.getElementById('messageCounter'); if (messagesUsedEl) { messagesUsedEl.textContent = count; } // Show warning colors as approaching limit if (messageCounterEl) { if (count >= this.anonymousRateLimit) { messageCounterEl.style.backgroundColor = '#ffcccc'; messageCounterEl.style.borderColor = '#ff6666'; } else if (count >= this.anonymousRateLimit * 0.8) { messageCounterEl.style.backgroundColor = '#fff5cc'; messageCounterEl.style.borderColor = '#ffcc66'; } else { messageCounterEl.style.backgroundColor = 'white'; messageCounterEl.style.borderColor = 'rgb(200, 200, 200)'; } } } showRateLimitWarning(remaining) { const warningDiv = document.createElement('div'); warningDiv.className = 'system-message warning'; warningDiv.innerHTML = ` ⚠️ Rate limit approaching: You have ${remaining} message${remaining === 1 ? '' : 's'} remaining this hour. Login or Register for unlimited messages. `; this.chatMessages.appendChild(warningDiv); this.scrollToBottom(); } async handleClearChat() { try { // Call the clear chat API const response = await this.clearChatHistory(); if (response.success) { // Clear the UI immediately this.clearChatUI(); // Show success message this.showSuccessMessage(response.message || 'Chat history cleared successfully'); } else { // Show error message this.showError(response.error || 'Failed to clear chat history'); } } catch (error) { // Handle network or other errors this.showError('Unable to clear chat history. Please check your connection and try again.'); } } async clearChatHistory() { const response = await fetch('/api/clear-chat', { method: 'POST', headers: { 'Content-Type': 'application/json', } }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } clearChatUI() { // Clear the chat messages display this.chatMessages.innerHTML = ''; // Clear the local chat history this.chatHistory = []; // Reset message counter for anonymous users if (this.isAnonymous) { this.updateMessageCounter(0); } } showSuccessMessage(message) { const successDiv = document.createElement('div'); successDiv.className = 'success-message'; successDiv.textContent = message; this.chatMessages.appendChild(successDiv); this.scrollToBottom(); // Remove success message after 3 seconds setTimeout(() => { if (successDiv.parentNode) { successDiv.parentNode.removeChild(successDiv); } }, 3000); } } // Initialize the chat application when the page loads document.addEventListener('DOMContentLoaded', () => { new ChatApp(); }); // Optional: Register service worker for better mobile experience if ('serviceWorker' in navigator && window.location.protocol === 'https:') { window.addEventListener('load', () => { // Only register if we have a service worker file fetch('/static/sw.js', { method: 'HEAD' }) .then(response => { if (response.ok) { navigator.serviceWorker.register('/static/sw.js') .then(registration => { // Service worker registered successfully }) .catch(registrationError => { // Service worker registration failed - that's fine }); } }) .catch(() => { // Service worker file doesn't exist, that's fine }); }); }