Spaces:
Sleeping
Sleeping
| /** | |
| * 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 = '<div class="loading-text">Loading chat history...</div>'; | |
| 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 = `<div class="error-text">${message}</div>`; | |
| 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. | |
| <button onclick="window.location.href='/'"> | |
| Start New Session | |
| </button> | |
| <button onclick="window.location.href='/register'"> | |
| Create Account | |
| </button> | |
| `; | |
| } else { | |
| warningDiv.innerHTML = ` | |
| Your session has expired. Please log in again to continue chatting. | |
| <button onclick="window.location.href='/login?next=' + encodeURIComponent(window.location.pathname)"> | |
| Login Again | |
| </button> | |
| `; | |
| } | |
| // 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 = ` | |
| <strong>⚠️ Rate limit approaching:</strong> You have ${remaining} message${remaining === 1 ? '' : 's'} remaining this hour. | |
| <a href="/login">Login</a> or <a href="/register">Register</a> 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 | |
| }); | |
| }); | |
| } |