/** * PolicyWise Chat Interface * Interactive web chat for the RAG application */ class ChatInterface { constructor() { this.messageInput = document.getElementById('messageInput'); this.sendButton = document.getElementById('sendButton'); this.chatForm = document.getElementById('chatForm'); this.messagesContainer = document.getElementById('messagesContainer'); this.includeSources = document.getElementById('includeSources'); this.loadingOverlay = document.getElementById('loadingOverlay'); this.statusIndicator = document.getElementById('statusIndicator'); this.charCount = document.getElementById('charCount'); this.sourcePanel = document.getElementById('sourcePanel'); this.conversationHistoryPanel = document.getElementById('conversationHistoryPanel'); this.swipeIndicator = document.getElementById('swipeIndicator'); // Search and filter elements this.searchConversations = document.getElementById('searchConversations'); this.exportConversationsBtn = document.getElementById('exportConversationsBtn'); this.importConversations = document.getElementById('importConversations'); this.messages = []; // Store messages in memory this.conversationId = this.loadOrCreateConversation(); this.isLoading = false; this.autoRetryCount = 0; this.maxAutoRetries = 3; // Track focused elements for keyboard navigation this.lastFocusedElement = null; // Touch handling for mobile swipe this.touchStartX = 0; this.touchEndX = 0; this.initializeEventListeners(); this.setupKeyboardNavigation(); this.setupTouchHandlers(); this.checkSystemHealth(); this.loadConversationHistory(); this.loadQuerySuggestions(); this.focusInput(); this.initializeSourcePanel(); // Setup initial policy suggestion buttons if they exist this.setupPolicySuggestionButtons(); } /** * Set up keyboard navigation and accessibility */ setupKeyboardNavigation() { // Trap focus within modal dialogs const handleTabKey = (e, containerElement) => { if (e.key !== 'Tab') return; const focusableElements = containerElement.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; // Add keyboard event listeners to side panels if (this.conversationHistoryPanel) { this.conversationHistoryPanel.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closeConversationPanel(); } else if (e.key === 'Tab') { handleTabKey(e, this.conversationHistoryPanel); } }); } if (this.sourcePanel) { document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.sourcePanel.classList.contains('show')) { this.closeSourcePanel(); } else if (e.key === 'Tab' && this.sourcePanel.classList.contains('show')) { handleTabKey(e, this.sourcePanel); } }); } // Add keyboard shortcuts document.addEventListener('keydown', (e) => { // Cmd+/ or Ctrl+/ to focus search if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); this.focusInput(); } // Cmd+Shift+E or Ctrl+Shift+E to export conversations if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'E') { e.preventDefault(); this.exportConversationsToFile(); } }); } /** * Set up touch handlers for mobile swipe gestures */ setupTouchHandlers() { // Only set up on mobile devices if (window.innerWidth <= 768) { document.addEventListener('touchstart', (e) => { this.touchStartX = e.changedTouches[0].screenX; }, { passive: true }); document.addEventListener('touchend', (e) => { this.touchEndX = e.changedTouches[0].screenX; this.handleSwipeGesture(); }, { passive: true }); // Show swipe indicator occasionally setTimeout(() => { if (this.swipeIndicator && !this.conversationHistoryPanel.classList.contains('show')) { this.swipeIndicator.style.display = 'flex'; setTimeout(() => { this.swipeIndicator.style.display = 'none'; }, 3000); } }, 5000); } } /** * Initialize source document panel */ /** * Load query suggestions from the server */ async loadQuerySuggestions() { const suggestionsContainer = document.getElementById('suggestedQueries'); if (!suggestionsContainer) return; try { const response = await fetch('/chat/suggestions'); const data = await response.json(); if (response.ok && data.status === 'success' && data.suggestions && data.suggestions.length > 0) { suggestionsContainer.innerHTML = ''; data.suggestions.forEach(suggestion => { const suggestionDiv = document.createElement('div'); suggestionDiv.className = 'query-suggestion'; const iconSpan = document.createElement('span'); iconSpan.className = 'suggestion-icon'; iconSpan.innerHTML = ` `; const textSpan = document.createElement('span'); textSpan.className = 'suggestion-text'; textSpan.textContent = suggestion; suggestionDiv.appendChild(iconSpan); suggestionDiv.appendChild(textSpan); // Add click event to populate the input suggestionDiv.addEventListener('click', () => { this.messageInput.value = suggestion; this.updateCharCount(); this.autoResizeTextarea(); this.updateSendButton(); this.focusInput(); }); suggestionsContainer.appendChild(suggestionDiv); }); } } catch (error) { console.warn('Failed to load query suggestions:', error); } } initializeSourcePanel() { // Create the source panel if it doesn't exist if (!this.sourcePanel) { const sourcePanelHTML = ` `; const tempDiv = document.createElement('div'); tempDiv.innerHTML = sourcePanelHTML; document.body.appendChild(tempDiv.firstElementChild); this.sourcePanel = document.getElementById('sourcePanel'); // Add ESC key handler for accessibility this.sourcePanel.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.closeSourcePanel(); } }); } } /** * Initialize all event listeners */ initializeEventListeners() { // Form submission this.chatForm.addEventListener('submit', (e) => { e.preventDefault(); this.sendMessage(); }); // Input validation and auto-resize this.messageInput.addEventListener('input', () => { this.updateCharCount(); this.autoResizeTextarea(); this.updateSendButton(); }); // Enter key handling (Shift+Enter for new line, Enter to send) this.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!this.isLoading && this.messageInput.value.trim()) { this.sendMessage(); } } }); // Clear welcome message on first input this.messageInput.addEventListener('focus', () => { this.clearWelcomeMessage(); }, { once: true }); // Conversation history panel const conversationHistoryBtn = document.getElementById('conversationHistoryBtn'); const closeConversationsBtn = document.getElementById('closeConversationsBtn'); const newConversationBtn = document.getElementById('newConversationBtn'); if (conversationHistoryBtn) { conversationHistoryBtn.addEventListener('click', () => { this.openConversationPanel(); }); } if (closeConversationsBtn) { closeConversationsBtn.addEventListener('click', () => { this.closeConversationPanel(); }); } if (newConversationBtn) { newConversationBtn.addEventListener('click', () => { this.startNewConversation(); }); } // Conversation search functionality if (this.searchConversations) { this.searchConversations.addEventListener('input', () => { this.filterConversations(this.searchConversations.value); }); } // Export/Import functionality if (this.exportConversationsBtn) { this.exportConversationsBtn.addEventListener('click', () => { this.exportConversationsToFile(); }); } if (this.importConversations) { this.importConversations.addEventListener('change', (e) => { this.importConversationsFromFile(e.target.files[0]); }); } } /** * Handle swipe gestures for mobile */ handleSwipeGesture() { const swipeThreshold = 100; // Right to left swipe (open conversation panel) if (this.touchEndX < this.touchStartX - swipeThreshold) { if (!this.conversationHistoryPanel.classList.contains('show')) { this.openConversationPanel(); } } // Left to right swipe (close conversation panel) if (this.touchEndX > this.touchStartX + swipeThreshold) { if (this.conversationHistoryPanel.classList.contains('show')) { this.closeConversationPanel(); } } } /** * Open conversation panel with focus management */ openConversationPanel() { this.updateConversationList(); this.conversationHistoryPanel.classList.add('show'); this.conversationHistoryPanel.setAttribute('aria-hidden', 'false'); // Store last focused element to restore focus when closing this.lastFocusedElement = document.activeElement; // Focus the close button const closeBtn = document.getElementById('closeConversationsBtn'); if (closeBtn) { setTimeout(() => closeBtn.focus(), 100); } // Hide the swipe indicator if (this.swipeIndicator) { this.swipeIndicator.style.display = 'none'; } // Add class to the body for potential styling document.body.classList.add('panel-open'); } /** * Close conversation panel with focus management */ closeConversationPanel() { this.conversationHistoryPanel.classList.remove('show'); this.conversationHistoryPanel.setAttribute('aria-hidden', 'true'); // Restore focus to previous element if (this.lastFocusedElement) { this.lastFocusedElement.focus(); } document.body.classList.remove('panel-open'); } /** * Close source document panel */ closeSourcePanel() { if (this.sourcePanel) { this.sourcePanel.classList.remove('show'); document.body.classList.remove('source-panel-open'); // Restore focus if (this.lastFocusedElement) { this.lastFocusedElement.focus(); } } } /** * Update the conversation list in the side panel */ updateConversationList() { const conversationList = document.getElementById('conversationList'); if (!conversationList) return; // Clear current list conversationList.innerHTML = ''; // Get conversations from storage const conversations = this.getConversationsFromStorage(); if (conversations.length === 0) { const emptyState = document.createElement('div'); emptyState.className = 'empty-state'; emptyState.innerHTML = '

No conversation history found.

'; conversationList.appendChild(emptyState); return; } // Sort conversations by last updated (newest first) conversations.sort((a, b) => { const dateA = new Date(a.metadata.last_updated); const dateB = new Date(b.metadata.last_updated); return dateB - dateA; }); // Add each conversation to the list conversations.forEach(conversation => { const item = document.createElement('div'); item.className = 'conversation-item'; item.setAttribute('role', 'listitem'); item.setAttribute('tabindex', '0'); item.setAttribute('aria-label', `${conversation.title || 'Untitled Conversation'}, ${conversation.metadata.message_count} messages, last updated ${this.formatDateTime(conversation.metadata.last_updated)}`); if (conversation.id === this.conversationId) { item.classList.add('active'); item.setAttribute('aria-current', 'true'); } const title = document.createElement('h4'); title.textContent = conversation.title || 'Untitled Conversation'; const meta = document.createElement('div'); meta.className = 'conversation-meta'; const date = new Date(conversation.metadata.last_updated); const formattedDate = date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); meta.textContent = `${conversation.metadata.message_count} messages · ${formattedDate}`; const actions = document.createElement('div'); actions.className = 'conversation-actions'; const deleteBtn = document.createElement('button'); deleteBtn.className = 'icon-button small'; deleteBtn.setAttribute('aria-label', `Delete conversation: ${conversation.title || 'Untitled Conversation'}`); deleteBtn.innerHTML = ` `; deleteBtn.title = 'Delete conversation'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); this.deleteConversation(conversation.id); }); actions.appendChild(deleteBtn); item.appendChild(title); item.appendChild(meta); item.appendChild(actions); // Add click event to load this conversation item.addEventListener('click', () => { this.loadConversation(conversation.id); this.closeConversationPanel(); }); // Add keyboard support item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.loadConversation(conversation.id); this.closeConversationPanel(); } }); conversationList.appendChild(item); }); } /** * Filter conversations based on search query */ filterConversations(query) { if (!query) { this.updateConversationList(); return; } query = query.toLowerCase(); const conversationList = document.getElementById('conversationList'); if (!conversationList) return; // Clear current list conversationList.innerHTML = ''; // Get conversations from storage const conversations = this.getConversationsFromStorage(); // Filter conversations const filteredConversations = conversations.filter(conv => { // Search in title if (conv.title && conv.title.toLowerCase().includes(query)) { return true; } // Search in messages if (conv.messages && conv.messages.some(msg => msg.content && msg.content.toLowerCase().includes(query))) { return true; } return false; }); if (filteredConversations.length === 0) { const emptyState = document.createElement('div'); emptyState.className = 'empty-state'; emptyState.innerHTML = `

No conversations matching "${query}"

`; conversationList.appendChild(emptyState); return; } // Sort and display filtered conversations filteredConversations.sort((a, b) => { const dateA = new Date(a.metadata.last_updated); const dateB = new Date(b.metadata.last_updated); return dateB - dateA; }); // Add each filtered conversation to the list filteredConversations.forEach(conversation => { const item = document.createElement('div'); item.className = 'conversation-item'; item.setAttribute('role', 'listitem'); item.setAttribute('tabindex', '0'); if (conversation.id === this.conversationId) { item.classList.add('active'); item.setAttribute('aria-current', 'true'); } const title = document.createElement('h4'); title.textContent = conversation.title || 'Untitled Conversation'; const meta = document.createElement('div'); meta.className = 'conversation-meta'; const date = new Date(conversation.metadata.last_updated); const formattedDate = this.formatDateTime(conversation.metadata.last_updated); meta.textContent = `${conversation.metadata.message_count} messages · ${formattedDate}`; item.appendChild(title); item.appendChild(meta); // Add click event to load this conversation item.addEventListener('click', () => { this.loadConversation(conversation.id); this.closeConversationPanel(); }); // Add keyboard support item.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.loadConversation(conversation.id); this.closeConversationPanel(); } }); conversationList.appendChild(item); }); } /** * Export conversations to a JSON file */ exportConversationsToFile() { try { const conversations = this.getConversationsFromStorage(); if (conversations.length === 0) { alert('No conversations to export.'); return; } const dataStr = JSON.stringify(conversations, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const downloadLink = document.createElement('a'); downloadLink.href = url; downloadLink.download = `policywise_conversations_${new Date().toISOString().split('T')[0]}.json`; // Append to the document, click it, then remove it document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); // Clean up the URL setTimeout(() => URL.revokeObjectURL(url), 100); // Show confirmation const statusText = this.statusIndicator.querySelector('.status-text'); const originalText = statusText.textContent; statusText.textContent = 'Conversations exported!'; setTimeout(() => { statusText.textContent = originalText; }, 3000); } catch (error) { console.error('Failed to export conversations:', error); alert('Failed to export conversations. Please try again.'); } } /** * Import conversations from a JSON file */ importConversationsFromFile(file) { if (!file) return; const reader = new FileReader(); reader.onload = (event) => { try { const importedData = JSON.parse(event.target.result); if (!Array.isArray(importedData)) { throw new Error('Invalid format: expected an array of conversations'); } // Validate the structure const validConversations = importedData.filter(conv => { return ( conv.id && conv.messages && Array.isArray(conv.messages) && conv.metadata ); }); if (validConversations.length === 0) { throw new Error('No valid conversations found in the file'); } // Merge with existing conversations const existingConversations = this.getConversationsFromStorage(); const existingIds = new Set(existingConversations.map(c => c.id)); // Add only conversations that don't exist const newConversations = validConversations.filter(c => !existingIds.has(c.id)); const mergedConversations = [...existingConversations, ...newConversations]; // Save to localStorage this.saveConversationsToStorage(mergedConversations); // Update UI this.updateConversationList(); // Show confirmation with count of imported conversations alert(`Successfully imported ${newConversations.length} conversation(s).`); } catch (error) { console.error('Failed to import conversations:', error); alert(`Failed to import conversations: ${error.message}`); } }; reader.readAsText(file); } /** * Start a new conversation */ startNewConversation() { // Generate new ID this.conversationId = this.generateConversationId(); // Clear messages this.messages = []; this.messagesContainer.innerHTML = ''; // Add welcome message this.addWelcomeMessage(); // Update URL without reloading const url = new URL(window.location.href); url.searchParams.set('conversation_id', this.conversationId); window.history.pushState({}, '', url); // Close conversation panel document.getElementById('conversationHistoryPanel').classList.remove('show'); // Focus input this.focusInput(); } /** * Load a conversation by ID */ loadConversation(conversationId) { const conversations = this.getConversationsFromStorage(); const conversation = conversations.find(c => c.id === conversationId); if (conversation) { // Update current conversation ID this.conversationId = conversationId; // Update URL without reloading const url = new URL(window.location.href); url.searchParams.set('conversation_id', this.conversationId); window.history.pushState({}, '', url); // Clear current messages this.messages = []; this.messagesContainer.innerHTML = ''; // Load messages conversation.messages.forEach(msg => { this.addMessage(msg.content, msg.sender, msg.metadata, false); }); // Add messages to memory this.messages = [...conversation.messages]; // Update conversation metadata conversation.metadata.last_accessed = new Date().toISOString(); this.saveConversationsToStorage(conversations); // Focus input this.focusInput(); } } /** * Delete a conversation by ID */ deleteConversation(conversationId) { if (confirm('Are you sure you want to delete this conversation? This cannot be undone.')) { const conversations = this.getConversationsFromStorage(); const updatedConversations = conversations.filter(c => c.id !== conversationId); this.saveConversationsToStorage(updatedConversations); // If we deleted the current conversation, start a new one if (conversationId === this.conversationId) { this.startNewConversation(); } // Update the conversation list this.updateConversationList(); } } /** * Add welcome message to the UI */ addWelcomeMessage() { const welcomeDiv = document.createElement('div'); welcomeDiv.className = 'welcome-message'; welcomeDiv.innerHTML = `
🤖

Welcome to PolicyWise!

I'm here to help you find information about company policies and procedures. Ask me anything about:

`; this.messagesContainer.appendChild(welcomeDiv); // Add click event listeners to policy suggestion buttons this.setupPolicySuggestionButtons(); } /** * Setup click handlers for policy suggestion buttons */ setupPolicySuggestionButtons() { const suggestionButtons = this.messagesContainer.querySelectorAll('.policy-suggestion-btn'); suggestionButtons.forEach(button => { button.addEventListener('click', () => { const topic = button.getAttribute('data-topic'); if (topic && topic !== "And much more...") { const prompt = `Tell me about ${topic.toLowerCase()}`; this.messageInput.value = prompt; this.sendMessage(); } }); // Add keyboard support button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); button.click(); } }); }); } /** * Generate a unique conversation ID */ generateConversationId() { return 'conv_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } /** * Load existing conversation or create new one */ loadOrCreateConversation() { // Check URL parameters for conversation ID const urlParams = new URLSearchParams(window.location.search); const conversationId = urlParams.get('conversation_id'); if (conversationId) { // If this conversation exists in localStorage, use it const conversations = this.getConversationsFromStorage(); if (conversations.some(conv => conv.id === conversationId)) { return conversationId; } } // Otherwise generate a new ID return this.generateConversationId(); } /** * Load conversation history from localStorage */ loadConversationHistory() { const conversations = this.getConversationsFromStorage(); const conversation = conversations.find(conv => conv.id === this.conversationId); if (conversation && conversation.messages && conversation.messages.length > 0) { // Clear welcome message if we're loading history this.clearWelcomeMessage(); // Load messages conversation.messages.forEach(msg => { this.addMessage(msg.content, msg.sender, msg.metadata, false); }); // Update conversation metadata conversation.metadata.last_accessed = new Date().toISOString(); this.saveConversationsToStorage(conversations); } } /** * Get conversations from localStorage */ getConversationsFromStorage() { try { return JSON.parse(localStorage.getItem('policywise_conversations') || '[]'); } catch (e) { console.error('Failed to parse conversations from localStorage:', e); return []; } } /** * Save conversations to localStorage */ saveConversationsToStorage(conversations) { try { localStorage.setItem('policywise_conversations', JSON.stringify(conversations)); } catch (e) { console.error('Failed to save conversations to localStorage:', e); } } /** * Save current conversation to localStorage */ saveCurrentConversation() { const conversations = this.getConversationsFromStorage(); const now = new Date().toISOString(); // Find existing conversation or create new one let conversation = conversations.find(conv => conv.id === this.conversationId); if (!conversation) { // Create new conversation conversation = { id: this.conversationId, title: this.getConversationTitle(), messages: this.messages, metadata: { created_at: now, last_updated: now, last_accessed: now, message_count: this.messages.length } }; conversations.push(conversation); } else { // Update existing conversation conversation.messages = this.messages; conversation.title = this.getConversationTitle(); conversation.metadata.last_updated = now; conversation.metadata.message_count = this.messages.length; } this.saveConversationsToStorage(conversations); } /** * Generate a title for the conversation based on first user message */ getConversationTitle() { const firstUserMessage = this.messages.find(msg => msg.sender === 'user'); if (firstUserMessage) { // Truncate to reasonable title length const title = firstUserMessage.content.trim(); return title.length > 50 ? title.substring(0, 50) + '...' : title; } return 'New Conversation'; } /** * Update character count and styling */ updateCharCount() { const count = this.messageInput.value.length; this.charCount.textContent = count; const counter = this.charCount.parentElement; counter.classList.remove('warning', 'error'); if (count > 900) { counter.classList.add('error'); } else if (count > 800) { counter.classList.add('warning'); } } /** * Auto-resize textarea based on content */ autoResizeTextarea() { this.messageInput.style.height = 'auto'; this.messageInput.style.height = Math.min(this.messageInput.scrollHeight, 120) + 'px'; } /** * Update send button state */ updateSendButton() { const hasText = this.messageInput.value.trim().length > 0; this.sendButton.disabled = !hasText || this.isLoading; } /** * Focus the input field */ focusInput() { this.messageInput.focus(); } /** * Check system health status */ async checkSystemHealth() { try { const response = await fetch('/chat/health'); const data = await response.json(); if (data.status === 'healthy') { this.updateStatus('Ready', 'ready'); } else { this.updateStatus('Degraded', 'warning'); } } catch (error) { console.warn('Health check failed:', error); this.updateStatus('Offline', 'error'); } } /** * Update status indicator */ updateStatus(text, type) { const statusText = this.statusIndicator.querySelector('.status-text'); const statusDot = this.statusIndicator.querySelector('.status-dot'); statusText.textContent = text; // Remove existing status classes statusDot.classList.remove('ready', 'warning', 'error', 'loading'); statusDot.classList.add(type); } /** * Clear the welcome message */ clearWelcomeMessage() { const welcomeMessage = this.messagesContainer.querySelector('.welcome-message'); if (welcomeMessage) { welcomeMessage.style.display = 'none'; } } /** * Send a message to the chat API */ async sendMessage() { const message = this.messageInput.value.trim(); if (!message || this.isLoading) return; // Add user message to chat this.addMessage(message, 'user'); // Clear input and reset this.messageInput.value = ''; this.updateCharCount(); this.autoResizeTextarea(); this.updateSendButton(); // Send the message to the API await this.sendMessageToAPI(message); } /** * Send a message to the chat API with enhanced error handling */ async sendMessageToAPI(message) { if (this.isLoading) return; // Show loading state this.setLoading(true); try { const requestData = { message: message, conversation_id: this.conversationId, include_sources: this.includeSources.checked, include_debug: false // Set to true for debugging }; // Set timeout for the request (30 seconds) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); const response = await fetch('/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(requestData), signal: controller.signal }); clearTimeout(timeoutId); const data = await response.json(); if (response.ok && data.status === 'success') { // Reset auto retry count on success this.autoRetryCount = 0; this.addMessage(data.answer || data.response, 'assistant', { sources: data.sources, citations: data.citations, confidence: data.confidence, processing_time: data.processing_time_ms, timestamp: new Date().toISOString() }); } else { // Handle API error const errorInfo = { status: response.status, message: data.message || 'Unknown error' }; let errorMessage = 'An error occurred while processing your request.'; let canRetry = true; // Customize message based on status code if (response.status === 400) { errorMessage = 'Invalid request. Please modify your query and try again.'; canRetry = false; } else if (response.status === 401 || response.status === 403) { errorMessage = 'You are not authorized to perform this action.'; canRetry = false; } else if (response.status === 404) { errorMessage = 'The requested resource could not be found.'; } else if (response.status === 429) { errorMessage = 'You\'ve sent too many requests. Please wait a moment and try again.'; canRetry = true; } else if (response.status >= 500) { errorMessage = 'The server encountered an error. We\'ll automatically retry shortly.'; canRetry = true; } if (data.message) { errorMessage += ` Details: ${data.message}`; } this.addErrorMessage(errorMessage, errorInfo, canRetry); } } catch (error) { console.error('Chat request failed:', error); const errorInfo = { code: error.name, message: error.message }; let errorMessage = 'Failed to connect to the server.'; if (error.name === 'AbortError') { errorMessage = 'The request took too long and was cancelled. The server might be experiencing high load.'; } else if (error.name === 'TypeError' && error.message.includes('NetworkError')) { errorMessage = 'Network error. Please check your internet connection and try again.'; } else if (error.name === 'SyntaxError') { errorMessage = 'Received an invalid response from the server.'; } this.addErrorMessage(errorMessage, errorInfo, true); } finally { this.setLoading(false); this.focusInput(); } } /** * Set loading state */ setLoading(loading) { this.isLoading = loading; if (loading) { this.loadingOverlay.classList.remove('hidden'); this.updateStatus('Processing...', 'loading'); } else { this.loadingOverlay.classList.add('hidden'); this.updateStatus('Ready', 'ready'); } this.updateSendButton(); } /** * Add a message to the chat interface */ addMessage(text, sender, metadata = {}, save = true) { const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); const timestamp = metadata.timestamp || new Date().toISOString(); // Create message element const messageDiv = document.createElement('div'); messageDiv.className = `message message-${sender}`; messageDiv.dataset.messageId = messageId; // Add header with timestamp const messageHeader = document.createElement('div'); messageHeader.className = 'message-header'; const senderLabel = document.createElement('span'); senderLabel.className = 'sender-label'; senderLabel.textContent = sender === 'user' ? 'You' : 'PolicyWise'; const timestampSpan = document.createElement('span'); timestampSpan.className = 'message-timestamp'; timestampSpan.setAttribute('aria-label', `Sent ${this.formatDateTime(timestamp)}`); timestampSpan.textContent = this.formatDateTime(timestamp); messageHeader.appendChild(senderLabel); messageHeader.appendChild(timestampSpan); messageDiv.appendChild(messageHeader); const contentDiv = document.createElement('div'); contentDiv.className = 'message-content'; const textDiv = document.createElement('div'); textDiv.className = 'message-text'; // Format assistant responses with markdown rendering for better readability if (sender === 'assistant') { textDiv.innerHTML = this.formatMarkdown(text); } else { textDiv.textContent = text; } contentDiv.appendChild(textDiv); // Add sources and citations for assistant messages if (sender === 'assistant' && metadata.sources && this.includeSources.checked) { this.addSourcesToMessage(contentDiv, metadata); } // Add confidence score for assistant messages if (sender === 'assistant' && metadata.confidence !== undefined) { this.addConfidenceScore(contentDiv, metadata.confidence); } // Add feedback controls for assistant messages if (sender === 'assistant' && save) { this.addFeedbackControls(contentDiv, messageId); } messageDiv.appendChild(contentDiv); this.messagesContainer.appendChild(messageDiv); // Store message in memory if (save) { this.messages.push({ id: messageId, sender, content: text, timestamp, metadata: {...metadata} }); // Save to localStorage this.saveCurrentConversation(); } // Scroll to bottom this.scrollToBottom(); } /** * Add feedback controls to assistant messages */ addFeedbackControls(contentDiv, messageId) { const feedbackDiv = document.createElement('div'); feedbackDiv.className = 'message-feedback'; const helpfulBtn = document.createElement('button'); helpfulBtn.className = 'feedback-btn'; helpfulBtn.title = 'Helpful'; helpfulBtn.innerHTML = ` Helpful `; const unhelpfulBtn = document.createElement('button'); unhelpfulBtn.className = 'feedback-btn'; unhelpfulBtn.title = 'Not helpful'; unhelpfulBtn.innerHTML = ` Not helpful `; // Add event listeners helpfulBtn.addEventListener('click', () => { this.submitFeedback(messageId, true); feedbackDiv.innerHTML = '
Thanks for your feedback!
'; }); unhelpfulBtn.addEventListener('click', () => { this.submitFeedback(messageId, false); // Replace with detailed feedback form feedbackDiv.innerHTML = `

What was the issue with this response?

`; // Add event listener to the new submit button const submitBtn = feedbackDiv.querySelector('.submit-feedback-btn'); submitBtn.addEventListener('click', () => { const reason = document.getElementById(`feedback-reason-${messageId}`).value; const detail = document.getElementById(`feedback-detail-${messageId}`).value; this.submitDetailedFeedback(messageId, reason, detail); feedbackDiv.innerHTML = '
Thanks for your detailed feedback!
'; }); }); feedbackDiv.appendChild(helpfulBtn); feedbackDiv.appendChild(unhelpfulBtn); contentDiv.appendChild(feedbackDiv); } /** * Add sources and citations to a message */ addSourcesToMessage(contentDiv, metadata) { if (!metadata.sources || metadata.sources.length === 0) return; const sourcesDiv = document.createElement('div'); sourcesDiv.className = 'message-sources'; sourcesDiv.setAttribute('aria-label', 'Source documents'); const headerDiv = document.createElement('div'); headerDiv.className = 'sources-header'; headerDiv.textContent = 'Sources:'; sourcesDiv.appendChild(headerDiv); metadata.sources.forEach(source => { const citationDiv = document.createElement('div'); citationDiv.className = 'source-citation'; const iconSvg = document.createElement('svg'); iconSvg.className = 'source-icon'; iconSvg.innerHTML = ''; iconSvg.setAttribute('width', '16'); iconSvg.setAttribute('height', '16'); iconSvg.setAttribute('viewBox', '0 0 24 24'); iconSvg.setAttribute('fill', 'none'); iconSvg.setAttribute('stroke', 'currentColor'); iconSvg.setAttribute('stroke-width', '2'); iconSvg.setAttribute('aria-hidden', 'true'); const textSpan = document.createElement('span'); textSpan.textContent = source.document || source.title || source.chunk_id || 'Unknown source'; // Make the source citation clickable to view the full document if (source.id) { citationDiv.classList.add('clickable'); citationDiv.setAttribute('role', 'button'); citationDiv.setAttribute('tabindex', '0'); citationDiv.setAttribute('aria-label', `View source: ${source.document || source.title || 'Source Document'}`); citationDiv.title = 'Click to view full source document'; citationDiv.dataset.sourceId = source.id; // Add click event citationDiv.addEventListener('click', () => { this.showSourceDocument(source.id, source.document || source.title || 'Source Document'); }); // Add keyboard support citationDiv.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.showSourceDocument(source.id, source.document || source.title || 'Source Document'); } }); } citationDiv.appendChild(iconSvg); citationDiv.appendChild(textSpan); sourcesDiv.appendChild(citationDiv); }); contentDiv.appendChild(sourcesDiv); } /** * Show the full source document in a side panel */ async showSourceDocument(sourceId, title) { // Get source panel elements let sourcePanel = document.getElementById('sourcePanel'); if (!sourcePanel) { this.initializeSourcePanel(); sourcePanel = document.getElementById('sourcePanel'); if (!sourcePanel) { console.error('Failed to create source panel'); return; } } const sourceContent = document.getElementById('sourceContent'); const closeBtn = document.getElementById('closeSourcePanel'); // Store last focused element for accessibility this.lastFocusedElement = document.activeElement; // Clear existing content sourceContent.innerHTML = '

Loading source document...

'; // Show the panel sourcePanel.classList.add('show'); sourcePanel.setAttribute('aria-hidden', 'false'); document.body.classList.add('source-panel-open'); // Set up close button if (closeBtn) { closeBtn.addEventListener('click', () => { this.closeSourcePanel(); }, { once: true }); // Focus the close button for keyboard accessibility setTimeout(() => closeBtn.focus(), 100); } try { // Set timeout for the request (15 seconds) const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); // Fetch source document content const response = await fetch(`/chat/source/${sourceId}`, { signal: controller.signal }); clearTimeout(timeoutId); const data = await response.json(); if (response.ok && data.status === 'success') { // Create content elements const documentTitle = data.metadata?.filename || data.metadata?.title || title; const contentHTML = `

${documentTitle}

${data.metadata?.last_updated ? `
Last Updated: ${data.metadata.last_updated}
` : ''} ${data.metadata?.author ? `
Author: ${data.metadata.author}
` : ''} ${data.metadata?.department ? `
Department: ${data.metadata.department}
` : ''}
${this.formatDocumentContent(data.content)}
`; sourceContent.innerHTML = contentHTML; // Update ARIA labels sourcePanel.setAttribute('aria-label', `Source document: ${documentTitle}`); // Make content keyboard navigable const docContent = sourceContent.querySelector('.document-content'); if (docContent) { docContent.addEventListener('keydown', (e) => { const scrollAmount = 100; if (e.key === 'ArrowDown') { e.preventDefault(); docContent.scrollTop += scrollAmount; } else if (e.key === 'ArrowUp') { e.preventDefault(); docContent.scrollTop -= scrollAmount; } }); } } else { // Show error with retry button this.renderSourceError(data.message || 'Failed to load document', sourceId, title, sourceContent); } } catch (error) { console.error('Failed to fetch source document:', error); let errorMessage = 'Failed to load the source document.'; if (error.name === 'AbortError') { errorMessage = 'The request took too long and was cancelled.'; } else if (error.name === 'TypeError' && error.message.includes('NetworkError')) { errorMessage = 'Network error. Please check your internet connection.'; } this.renderSourceError(`${errorMessage} Please try again later.`, sourceId, title, sourceContent); } } /** * Escape HTML characters to prevent XSS */ escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Format markdown text for better readability in chat responses * Safely converts markdown to HTML while preventing XSS attacks */ formatMarkdown(text) { if (!text) return ''; // First escape ALL HTML to prevent XSS - this is critical for security let escapedText = this.escapeHtml(text); // Now safely convert markdown formatting to HTML // Process line by line to maintain proper structure const lines = escapedText.split('\n'); const processedLines = []; let inList = false; let listType = ''; for (let i = 0; i < lines.length; i++) { let line = lines[i]; const trimmedLine = line.trim(); // Skip empty lines for now - we'll handle them in paragraph processing if (!trimmedLine) { processedLines.push(''); continue; } // Process headers (must be at start of line) - check from least to most specific if (trimmedLine.match(/^# (.+)$/)) { if (inList) { processedLines.push(``); inList = false; listType = ''; } const headerText = trimmedLine.replace(/^# /, ''); processedLines.push(`

${headerText}

`); continue; } else if (trimmedLine.match(/^## (.+)$/)) { if (inList) { processedLines.push(``); inList = false; listType = ''; } const headerText = trimmedLine.replace(/^## /, ''); processedLines.push(`

${headerText}

`); continue; } else if (trimmedLine.match(/^### (.+)$/)) { if (inList) { processedLines.push(``); inList = false; listType = ''; } const headerText = trimmedLine.replace(/^### /, ''); processedLines.push(`

${headerText}

`); continue; } // Process list items const bulletMatch = trimmedLine.match(/^[-*+]\s+(.+)$/); const numberMatch = trimmedLine.match(/^\d+\.\s+(.+)$/); if (bulletMatch) { if (!inList || listType !== 'ul') { if (inList) processedLines.push(``); processedLines.push('