Spaces:
Sleeping
Sleeping
| /** | |
| * 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 = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <line x1="12" y1="8" x2="12" y2="12"></line> | |
| <line x1="12" y1="16" x2="12" y2="16"></line> | |
| </svg> | |
| `; | |
| 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 = ` | |
| <div id="sourcePanel" class="side-panel source-document-panel" | |
| role="dialog" | |
| aria-labelledby="sourcePanelHeader" | |
| aria-hidden="true"> | |
| <div class="panel-header"> | |
| <h3 id="sourcePanelHeader">Source Document</h3> | |
| <button id="closeSourcePanel" class="icon-button" aria-label="Close source document"> | |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> | |
| <line x1="18" y1="6" x2="6" y2="18"></line> | |
| <line x1="6" y1="6" x2="18" y2="18"></line> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="panel-body"> | |
| <div id="sourceContent" class="source-document-content"> | |
| <!-- Document content will be loaded here --> | |
| <p class="sr-only">Document content will be loaded here.</p> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| 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 = '<p>No conversation history found.</p>'; | |
| 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 = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> | |
| <path d="M3 6h18"></path> | |
| <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path> | |
| </svg> | |
| `; | |
| 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 = `<p>No conversations matching "${query}"</p>`; | |
| 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 = ` | |
| <div class="welcome-icon">🤖</div> | |
| <h2>Welcome to PolicyWise!</h2> | |
| <p>I'm here to help you find information about company policies and procedures. Ask me anything about:</p> | |
| <div class="policy-topics" role="group" aria-label="Suggested policy topics"> | |
| <button class="policy-suggestion-btn" data-topic="Remote work policies">Remote work policies</button> | |
| <button class="policy-suggestion-btn" data-topic="PTO and leave policies">PTO and leave policies</button> | |
| <button class="policy-suggestion-btn" data-topic="Expense reimbursement">Expense reimbursement</button> | |
| <button class="policy-suggestion-btn" data-topic="Information security">Information security</button> | |
| <button class="policy-suggestion-btn" data-topic="Employee benefits">Employee benefits</button> | |
| <button class="policy-suggestion-btn" data-topic="And much more...">And much more...</button> | |
| </div> | |
| `; | |
| 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 = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"></path> | |
| </svg> | |
| <span>Helpful</span> | |
| `; | |
| const unhelpfulBtn = document.createElement('button'); | |
| unhelpfulBtn.className = 'feedback-btn'; | |
| unhelpfulBtn.title = 'Not helpful'; | |
| unhelpfulBtn.innerHTML = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"></path> | |
| </svg> | |
| <span>Not helpful</span> | |
| `; | |
| // Add event listeners | |
| helpfulBtn.addEventListener('click', () => { | |
| this.submitFeedback(messageId, true); | |
| feedbackDiv.innerHTML = '<div class="feedback-thanks">Thanks for your feedback!</div>'; | |
| }); | |
| unhelpfulBtn.addEventListener('click', () => { | |
| this.submitFeedback(messageId, false); | |
| // Replace with detailed feedback form | |
| feedbackDiv.innerHTML = ` | |
| <div class="feedback-form"> | |
| <p>What was the issue with this response?</p> | |
| <select id="feedback-reason-${messageId}" class="feedback-select"> | |
| <option value="inaccurate">Information is inaccurate</option> | |
| <option value="incomplete">Information is incomplete</option> | |
| <option value="irrelevant">Response is irrelevant</option> | |
| <option value="sources">Sources are missing or incorrect</option> | |
| <option value="other">Other issue</option> | |
| </select> | |
| <textarea id="feedback-detail-${messageId}" class="feedback-textarea" | |
| placeholder="Optional: Provide more details about the issue"></textarea> | |
| <div class="feedback-actions"> | |
| <button class="primary-button submit-feedback-btn">Submit</button> | |
| </div> | |
| </div> | |
| `; | |
| // 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 = '<div class="feedback-thanks">Thanks for your detailed feedback!</div>'; | |
| }); | |
| }); | |
| 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 = '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14,2 14,8 20,8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10,9 9,9 8,9"></polyline>'; | |
| 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 = '<div class="loading-spinner" role="status" aria-label="Loading source document"></div><p>Loading source document...</p>'; | |
| // 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 = ` | |
| <h3 id="sourceTitle">${documentTitle}</h3> | |
| <div class="metadata" aria-label="Document metadata"> | |
| ${data.metadata?.last_updated ? | |
| `<div class="metadata-item"><span>Last Updated:</span> ${data.metadata.last_updated}</div>` : ''} | |
| ${data.metadata?.author ? | |
| `<div class="metadata-item"><span>Author:</span> ${data.metadata.author}</div>` : ''} | |
| ${data.metadata?.department ? | |
| `<div class="metadata-item"><span>Department:</span> ${data.metadata.department}</div>` : ''} | |
| </div> | |
| <div class="document-content" tabindex="0"> | |
| ${this.formatDocumentContent(data.content)} | |
| </div> | |
| `; | |
| 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(`</${listType}>`); | |
| inList = false; | |
| listType = ''; | |
| } | |
| const headerText = trimmedLine.replace(/^# /, ''); | |
| processedLines.push(`<h1>${headerText}</h1>`); | |
| continue; | |
| } else if (trimmedLine.match(/^## (.+)$/)) { | |
| if (inList) { | |
| processedLines.push(`</${listType}>`); | |
| inList = false; | |
| listType = ''; | |
| } | |
| const headerText = trimmedLine.replace(/^## /, ''); | |
| processedLines.push(`<h2>${headerText}</h2>`); | |
| continue; | |
| } else if (trimmedLine.match(/^### (.+)$/)) { | |
| if (inList) { | |
| processedLines.push(`</${listType}>`); | |
| inList = false; | |
| listType = ''; | |
| } | |
| const headerText = trimmedLine.replace(/^### /, ''); | |
| processedLines.push(`<h3>${headerText}</h3>`); | |
| 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(`</${listType}>`); | |
| processedLines.push('<ul>'); | |
| inList = true; | |
| listType = 'ul'; | |
| } | |
| let listContent = bulletMatch[1]; | |
| // Apply inline formatting to list content | |
| listContent = this.applyInlineFormatting(listContent); | |
| processedLines.push(`<li>${listContent}</li>`); | |
| continue; | |
| } else if (numberMatch) { | |
| if (!inList || listType !== 'ol') { | |
| if (inList) processedLines.push(`</${listType}>`); | |
| processedLines.push('<ol>'); | |
| inList = true; | |
| listType = 'ol'; | |
| } | |
| let listContent = numberMatch[1]; | |
| // Apply inline formatting to list content | |
| listContent = this.applyInlineFormatting(listContent); | |
| processedLines.push(`<li>${listContent}</li>`); | |
| continue; | |
| } | |
| // Close any open list for regular text | |
| if (inList) { | |
| processedLines.push(`</${listType}>`); | |
| inList = false; | |
| listType = ''; | |
| } | |
| // Apply inline formatting (bold, italic) to regular text | |
| line = this.applyInlineFormatting(trimmedLine); | |
| processedLines.push(line); | |
| } | |
| // Close any remaining open list | |
| if (inList) { | |
| processedLines.push(`</${listType}>`); | |
| } | |
| // Convert to paragraph structure | |
| const content = processedLines.join('\n'); | |
| return this.convertToParagraphs(content); | |
| } | |
| /** | |
| * Apply inline formatting (bold, italic) to text that's already HTML-escaped | |
| */ | |
| applyInlineFormatting(text) { | |
| // First handle bold formatting (**text**) and replace with a placeholder | |
| const boldPlaceholder = '___BOLD_PLACEHOLDER___'; | |
| const boldMatches = []; | |
| text = text.replace(/\*\*([^*]+)\*\*/g, (match, content) => { | |
| boldMatches.push(`<strong>${content}</strong>`); | |
| return `${boldPlaceholder}${boldMatches.length - 1}${boldPlaceholder}`; | |
| }); | |
| // Now handle italic formatting (*text*) - won't conflict with bold placeholders | |
| text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>'); | |
| // Restore bold formatting | |
| const restoreRegex = new RegExp(`${boldPlaceholder}(\\d+)${boldPlaceholder}`, 'g'); | |
| text = text.replace(restoreRegex, (match, index) => boldMatches[parseInt(index)]); | |
| return text; | |
| } | |
| /** | |
| * Convert processed lines to proper paragraph structure | |
| */ | |
| convertToParagraphs(content) { | |
| // Split by double line breaks for paragraphs | |
| const sections = content.split('\n\n'); | |
| const formattedSections = []; | |
| for (const section of sections) { | |
| const trimmed = section.trim(); | |
| if (!trimmed) continue; | |
| // Check if this section contains only block elements (headers, lists) | |
| if (trimmed.match(/^<(h[1-6]|ul|ol)/)) { | |
| formattedSections.push(trimmed); | |
| } else { | |
| // Regular text content - wrap in paragraph and handle line breaks | |
| const withBreaks = trimmed.replace(/\n/g, '<br>'); | |
| formattedSections.push(`<p>${withBreaks}</p>`); | |
| } | |
| } | |
| return formattedSections.join('\n'); | |
| } | |
| /** | |
| * Render error message with retry functionality for source documents | |
| */ | |
| renderSourceError(message, sourceId, title, sourceContent) { | |
| const errorHtml = ` | |
| <div class="error-message" role="alert"> | |
| <strong>Error loading source document</strong> | |
| <p>${this.escapeHtml(message)}</p> | |
| <button class="retry-button" id="retrySourceLoad" aria-label="Retry loading the source document"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> | |
| <polyline points="23 4 23 10 17 10"></polyline> | |
| <polyline points="1 20 1 14 7 14"></polyline> | |
| <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path> | |
| </svg> | |
| Retry | |
| </button> | |
| </div> | |
| `; | |
| sourceContent.innerHTML = errorHtml; | |
| // Add retry functionality | |
| document.getElementById('retrySourceLoad')?.addEventListener('click', () => { | |
| this.showSourceDocument(sourceId, title); | |
| }); | |
| } | |
| /** | |
| * Format document content for display | |
| */ | |
| formatDocumentContent(content) { | |
| if (!content) return '<p>No content available</p>'; | |
| // First escape HTML to prevent XSS | |
| const escapedContent = this.escapeHtml(content); | |
| // Check if content is markdown and convert if needed | |
| if (content.includes('#') || content.includes('*')) { | |
| // Simple markdown formatting on escaped content | |
| return escapedContent | |
| .replace(/^# (.+)$/gm, '<h1>$1</h1>') | |
| .replace(/^## (.+)$/gm, '<h2>$1</h2>') | |
| .replace(/^### (.+)$/gm, '<h3>$1</h3>') | |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| .replace(/\n\n/g, '</p><p>') | |
| .replace(/\n/g, '<br>') | |
| .replace(/^(.+)$/gm, function(match) { | |
| if (!match.startsWith('<')) return '<p>' + match + '</p>'; | |
| return match; | |
| }); | |
| } | |
| // If not markdown, wrap escaped content in paragraphs | |
| return '<p>' + escapedContent + '</p>'; | |
| } | |
| /** | |
| * Add confidence score visualization | |
| */ | |
| addConfidenceScore(contentDiv, confidence) { | |
| const confidenceDiv = document.createElement('div'); | |
| confidenceDiv.className = 'confidence-score'; | |
| const labelSpan = document.createElement('span'); | |
| labelSpan.textContent = `Confidence: ${Math.round(confidence * 100)}%`; | |
| const barDiv = document.createElement('div'); | |
| barDiv.className = 'confidence-bar'; | |
| const fillDiv = document.createElement('div'); | |
| fillDiv.className = 'confidence-fill'; | |
| fillDiv.style.width = `${confidence * 100}%`; | |
| barDiv.appendChild(fillDiv); | |
| confidenceDiv.appendChild(labelSpan); | |
| confidenceDiv.appendChild(barDiv); | |
| contentDiv.appendChild(confidenceDiv); | |
| } | |
| /** | |
| * Add an error message to the chat with retry options | |
| */ | |
| addErrorMessage(errorText, error = null, canRetry = true) { | |
| const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); | |
| const timestamp = new Date().toISOString(); | |
| const messageDiv = document.createElement('div'); | |
| messageDiv.className = 'message message-assistant'; | |
| 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 = 'System'; | |
| const timestampSpan = document.createElement('span'); | |
| timestampSpan.className = 'message-timestamp'; | |
| timestampSpan.textContent = this.formatDateTime(timestamp); | |
| messageHeader.appendChild(senderLabel); | |
| messageHeader.appendChild(timestampSpan); | |
| messageDiv.appendChild(messageHeader); | |
| const contentDiv = document.createElement('div'); | |
| contentDiv.className = 'message-content error-message'; | |
| const strongElement = document.createElement('strong'); | |
| strongElement.textContent = 'Error:'; | |
| strongElement.setAttribute('aria-hidden', 'true'); | |
| const textSpan = document.createElement('span'); | |
| textSpan.textContent = ` ${errorText}`; | |
| textSpan.setAttribute('role', 'alert'); | |
| contentDiv.appendChild(strongElement); | |
| contentDiv.appendChild(textSpan); | |
| // Add retry button if applicable | |
| if (canRetry) { | |
| const retryButton = document.createElement('button'); | |
| retryButton.className = 'retry-button'; | |
| retryButton.innerHTML = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> | |
| <polyline points="23 4 23 10 17 10"></polyline> | |
| <polyline points="1 20 1 14 7 14"></polyline> | |
| <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path> | |
| </svg> | |
| Retry | |
| `; | |
| retryButton.setAttribute('aria-label', 'Retry sending your last message'); | |
| retryButton.addEventListener('click', () => { | |
| // Remove the error message | |
| messageDiv.remove(); | |
| // Retry the last user message | |
| this.retryLastMessage(); | |
| }); | |
| contentDiv.appendChild(retryButton); | |
| } | |
| // Add more detailed error info if available | |
| if (error && (error.status || error.code)) { | |
| const detailsDiv = document.createElement('div'); | |
| detailsDiv.className = 'error-details'; | |
| let detailsText = ''; | |
| if (error.status) detailsText += `Status code: ${error.status}. `; | |
| if (error.code) detailsText += `Error code: ${error.code}. `; | |
| if (error.message) detailsText += error.message; | |
| detailsDiv.textContent = detailsText; | |
| contentDiv.appendChild(detailsDiv); | |
| } | |
| messageDiv.appendChild(contentDiv); | |
| this.messagesContainer.appendChild(messageDiv); | |
| // Store in messages array | |
| this.messages.push({ | |
| id: messageId, | |
| sender: 'system', | |
| content: errorText, | |
| timestamp, | |
| metadata: { error: true } | |
| }); | |
| // Save to localStorage | |
| this.saveCurrentConversation(); | |
| this.scrollToBottom(); | |
| // Auto retry for server errors (5xx) if retry count is under the limit | |
| if (error && error.status && error.status >= 500 && this.autoRetryCount < this.maxAutoRetries) { | |
| this.autoRetryCount++; | |
| // Show auto-retry status | |
| const retryStatus = document.createElement('div'); | |
| retryStatus.className = 'auto-retry-status'; | |
| retryStatus.innerHTML = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> | |
| <circle cx="12" cy="12" r="10"></circle> | |
| <polyline points="12 6 12 12 16 14"></polyline> | |
| </svg> | |
| Retrying in <span class="retry-countdown">5</span> seconds... | |
| `; | |
| contentDiv.appendChild(retryStatus); | |
| // Countdown timer | |
| const countdownEl = retryStatus.querySelector('.retry-countdown'); | |
| let countdown = 5; | |
| const countdownInterval = setInterval(() => { | |
| countdown--; | |
| if (countdownEl) countdownEl.textContent = countdown.toString(); | |
| if (countdown <= 0) { | |
| clearInterval(countdownInterval); | |
| messageDiv.remove(); | |
| this.retryLastMessage(); | |
| } | |
| }, 1000); | |
| } | |
| } | |
| /** | |
| * Retry sending the last user message | |
| */ | |
| retryLastMessage() { | |
| // Find the last user message | |
| const lastUserMessage = [...this.messages].reverse().find(msg => msg.sender === 'user'); | |
| if (lastUserMessage) { | |
| // Reset auto retry count if this is a manual retry | |
| this.autoRetryCount = 0; | |
| // Resend the message | |
| this.sendMessageToAPI(lastUserMessage.content); | |
| } else { | |
| this.addErrorMessage("Couldn't find a previous message to retry.", null, false); | |
| } | |
| } | |
| /** | |
| * Submit simple feedback (helpful/not helpful) | |
| */ | |
| submitFeedback(messageId, isHelpful) { | |
| const message = this.messages.find(msg => msg.id === messageId); | |
| if (!message) return; | |
| // Update message with feedback | |
| message.feedback = { | |
| rating: isHelpful ? 5 : 1, | |
| timestamp: new Date().toISOString() | |
| }; | |
| // Save to localStorage | |
| this.saveCurrentConversation(); | |
| // Send to server if available | |
| this.sendFeedbackToServer(messageId, isHelpful); | |
| } | |
| /** | |
| * Submit detailed feedback | |
| */ | |
| submitDetailedFeedback(messageId, reason, detail) { | |
| const message = this.messages.find(msg => msg.id === messageId); | |
| if (!message) return; | |
| // Update message with detailed feedback | |
| message.feedback = { | |
| rating: 1, // Not helpful | |
| reason: reason, | |
| detail: detail, | |
| timestamp: new Date().toISOString() | |
| }; | |
| // Save to localStorage | |
| this.saveCurrentConversation(); | |
| // Send to server if available | |
| this.sendDetailedFeedbackToServer(messageId, reason, detail); | |
| } | |
| /** | |
| * Send feedback to server | |
| */ | |
| sendFeedbackToServer(messageId, isHelpful) { | |
| try { | |
| const feedback = { | |
| feedback_id: 'feedback_' + Date.now(), | |
| conversation_id: this.conversationId, | |
| message_id: messageId, | |
| feedback_type: 'response_rating', | |
| rating: isHelpful ? 5 : 1, | |
| timestamp: new Date().toISOString() | |
| }; | |
| fetch('/chat/feedback', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(feedback) | |
| }) | |
| .then(response => { | |
| if (!response.ok) { | |
| console.warn('Failed to send feedback to server:', response.status); | |
| } | |
| }) | |
| .catch(error => { | |
| console.warn('Error sending feedback to server:', error); | |
| }); | |
| } catch (error) { | |
| console.warn('Error preparing feedback:', error); | |
| } | |
| } | |
| /** | |
| * Send detailed feedback to server | |
| */ | |
| sendDetailedFeedbackToServer(messageId, reason, detail) { | |
| try { | |
| const feedback = { | |
| feedback_id: 'feedback_' + Date.now(), | |
| conversation_id: this.conversationId, | |
| message_id: messageId, | |
| feedback_type: 'detailed', | |
| rating: 1, | |
| reason: reason, | |
| comment: detail, | |
| timestamp: new Date().toISOString() | |
| }; | |
| fetch('/chat/feedback', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(feedback) | |
| }) | |
| .then(response => { | |
| if (!response.ok) { | |
| console.warn('Failed to send detailed feedback to server:', response.status); | |
| } | |
| }) | |
| .catch(error => { | |
| console.warn('Error sending detailed feedback to server:', error); | |
| }); | |
| } catch (error) { | |
| console.warn('Error preparing detailed feedback:', error); | |
| } | |
| } | |
| /** | |
| * Scroll to the bottom of the messages container | |
| */ | |
| scrollToBottom() { | |
| this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; | |
| } | |
| /** | |
| * Format ISO datetime string to a user-friendly format | |
| */ | |
| formatDateTime(isoString) { | |
| try { | |
| const date = new Date(isoString); | |
| // For messages from today, just show the time | |
| const today = new Date(); | |
| if (date.toDateString() === today.toDateString()) { | |
| return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| // For messages from this year, show month, day and time | |
| if (date.getFullYear() === today.getFullYear()) { | |
| return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) + | |
| ' at ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } | |
| // For older messages, show full date | |
| return date.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' }) + | |
| ' at ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); | |
| } catch (e) { | |
| console.warn('Invalid timestamp format:', isoString); | |
| return 'Unknown time'; | |
| } | |
| } | |
| } | |
| // CSS for additional status states | |
| const additionalStyles = ` | |
| .status-dot.loading { | |
| background: #f59e0b; | |
| } | |
| .status-dot.warning { | |
| background: #f59e0b; | |
| } | |
| .status-dot.error { | |
| background: #ef4444; | |
| } | |
| .status-dot.ready { | |
| background: #10b981; | |
| } | |
| /* Source document panel styles */ | |
| .source-document-panel { | |
| position: fixed; | |
| top: 0; | |
| right: -500px; | |
| width: 450px; | |
| max-width: 90vw; | |
| height: 100vh; | |
| background: white; | |
| box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); | |
| z-index: 101; | |
| transition: right 0.3s ease; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .source-document-panel.show { | |
| right: 0; | |
| } | |
| .source-document-content { | |
| padding: 1rem; | |
| overflow-y: auto; | |
| } | |
| .source-document-content h1 { | |
| font-size: 1.5rem; | |
| margin-bottom: 1rem; | |
| color: #1e293b; | |
| } | |
| .source-document-content h2 { | |
| font-size: 1.25rem; | |
| margin: 1.5rem 0 0.75rem 0; | |
| color: #1e293b; | |
| } | |
| .source-document-content h3 { | |
| font-size: 1.125rem; | |
| margin: 1.25rem 0 0.5rem 0; | |
| color: #1e293b; | |
| } | |
| .source-document-content p { | |
| margin: 0.75rem 0; | |
| color: #4b5563; | |
| line-height: 1.6; | |
| } | |
| .source-document-content .metadata { | |
| margin: 1rem 0; | |
| padding: 0.75rem; | |
| background: #f1f5f9; | |
| border-radius: 6px; | |
| font-size: 0.875rem; | |
| color: #64748b; | |
| } | |
| .source-document-content .metadata-item { | |
| margin: 0.25rem 0; | |
| } | |
| .source-document-content .metadata-item span { | |
| font-weight: 600; | |
| } | |
| .source-citation.clickable { | |
| cursor: pointer; | |
| transition: background-color 0.2s; | |
| } | |
| .source-citation.clickable:hover { | |
| background: #e2e8f0; | |
| } | |
| `; | |
| // Add additional styles to document | |
| const styleSheet = document.createElement('style'); | |
| styleSheet.textContent = additionalStyles; | |
| document.head.appendChild(styleSheet); | |
| // Initialize the chat interface when the DOM is loaded | |
| document.addEventListener('DOMContentLoaded', () => { | |
| new ChatInterface(); | |
| }); | |
| // Service worker registration for potential offline functionality | |
| if ('serviceWorker' in navigator) { | |
| window.addEventListener('load', () => { | |
| // Optional: register a service worker for offline functionality | |
| // navigator.serviceWorker.register('/sw.js').catch(console.warn); | |
| }); | |
| } | |