Seth McKnight
Comprehensive memory optimizations and embedding service updates (#74)
32e4125
/**
* 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);
});
}