/** * 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 = '