Skip to main content
Glama
juanqui
by juanqui
app.js36.6 kB
/** * PDF Knowledgebase Frontend Application * Main application logic with routing, WebSocket, and API integration */ class PDFKnowledgebaseApp { constructor() { // Application state this.currentView = 'documents'; this.currentPage = 1; this.documentsPerPage = 20; this.currentDocument = null; this.searchQuery = ''; this.searchResults = []; this.documents = []; this.systemStats = { documents: 0, chunks: 0 }; // WebSocket connection this.websocket = null; this.wsReconnectAttempts = 0; this.maxReconnectAttempts = 10; this.wsReconnectDelay = 1000; // Start with 1 second // Track client-initiated actions to prevent duplicate notifications this.clientInitiatedActions = new Map(); // Action type -> timestamp this.actionIgnoreWindow = 3000; // 3 seconds to ignore duplicate WebSocket events // API base URL this.apiBaseUrl = '/api'; this.wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`; // Theme managemen this.theme = localStorage.getItem('theme') || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); // Components (will be initialized by component files) this.components = {}; // Initialize application this.init(); } /** * Initialize the application */ async init() { console.log('Initializing PDF Knowledgebase App...'); // Set up theme this.applyTheme(); // Set up event listeners this.setupEventListeners(); // Initialize WebSocket connection this.initWebSocket(); // Load initial data await this.loadSystemStatus(); await this.loadDocuments(); // Set up routing this.setupRouting(); console.log('PDF Knowledgebase App initialized successfully'); } /** * Set up all event listeners */ setupEventListeners() { // Navigation document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', (e) => { const view = e.currentTarget.dataset.view; if (view) this.navigateTo(view); }); }); // Theme toggle const themeToggle = document.getElementById('theme-toggle'); if (themeToggle) { themeToggle.addEventListener('click', () => this.toggleTheme()); } // Breadcrumb back button const breadcrumbBack = document.getElementById('breadcrumb-back'); if (breadcrumbBack) { breadcrumbBack.addEventListener('click', () => this.navigateBack()); } // Modal controls this.setupModalControls(); // Search input (documents view) const documentsSearch = document.getElementById('documents-search'); if (documentsSearch) { documentsSearch.addEventListener('input', this.debounce((e) => { this.filterDocuments(e.target.value); }, 300)); } // Refresh documents const refreshBtn = document.getElementById('refresh-documents'); if (refreshBtn) { refreshBtn.addEventListener('click', () => this.loadDocuments(true)); } // Rescan documents const rescanBtn = document.getElementById('rescan-documents'); if (rescanBtn) { rescanBtn.addEventListener('click', () => this.rescanDocuments()); } // Upload first document button const uploadFirstDoc = document.getElementById('upload-first-doc'); if (uploadFirstDoc) { uploadFirstDoc.addEventListener('click', () => this.navigateTo('upload')); } // Handle window events window.addEventListener('online', () => this.handleNetworkChange(true)); window.addEventListener('offline', () => this.handleNetworkChange(false)); window.addEventListener('beforeunload', () => this.cleanup()); } /** * Set up modal controls */ setupModalControls() { const modal = document.getElementById('confirm-modal'); const modalClose = document.getElementById('modal-close'); const modalCancel = document.getElementById('confirm-cancel'); const modalBackdrop = modal?.querySelector('.modal-backdrop'); const closeModal = () => { if (modal) { modal.classList.remove('active'); modal.classList.add('hidden'); } }; if (modalClose) modalClose.addEventListener('click', closeModal); if (modalCancel) modalCancel.addEventListener('click', closeModal); if (modalBackdrop) modalBackdrop.addEventListener('click', closeModal); // ESC key to close modal document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal && !modal.classList.contains('hidden')) { closeModal(); } }); } /** * Set up client-side routing */ setupRouting() { // Handle browser back/forward window.addEventListener('popstate', (e) => { const view = e.state?.view || 'documents'; this.navigateTo(view, false); // Don't push to history }); // Set initial route const hash = window.location.hash.slice(1); if (hash) { this.navigateTo(hash, false); } } /** * Navigate to a specific view */ navigateTo(view, pushHistory = true) { console.log(`Navigating to: ${view}`); // Update navigation state document.querySelectorAll('.nav-item').forEach(item => { item.classList.toggle('active', item.dataset.view === view); }); // Hide all views document.querySelectorAll('.view').forEach(viewEl => { viewEl.classList.remove('active'); }); // Show target view const targetView = document.getElementById(`${view}-view`); if (targetView) { targetView.classList.add('active'); } // Update breadcrumb this.updateBreadcrumb(view); // Update browser history if (pushHistory) { const url = view === 'documents' ? '/' : `/#${view}`; window.history.pushState({ view }, '', url); } // Update current view this.currentView = view; // View-specific initialization switch (view) { case 'documents': if (this.documents.length === 0) { this.loadDocuments(); } break; case 'search': // Focus search inpu setTimeout(() => { const searchInput = document.getElementById('search-input'); if (searchInput) searchInput.focus(); }, 100); break; } } /** * Navigate back from detail views */ navigateBack() { if (this.currentView === 'document-detail') { this.navigateTo('documents'); } else { window.history.back(); } } /** * Update breadcrumb navigation */ updateBreadcrumb(view) { const breadcrumb = document.getElementById('breadcrumb'); const breadcrumbText = document.getElementById('breadcrumb-text'); if (view === 'document-detail' && this.currentDocument) { breadcrumb.classList.remove('hidden'); breadcrumbText.textContent = `Documents / ${this.currentDocument.filename}`; } else { breadcrumb.classList.add('hidden'); } } /** * Initialize WebSocket connection */ initWebSocket() { console.log('Connecting to WebSocket...'); this.updateConnectionStatus('connecting'); try { this.websocket = new WebSocket(this.wsUrl); this.websocket.onopen = () => { console.log('WebSocket connected successfully'); this.updateConnectionStatus('connected'); this.wsReconnectAttempts = 0; this.wsReconnectDelay = 1000; }; this.websocket.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleWebSocketMessage(message); } catch (error) { console.error('Error parsing WebSocket message:', error); } }; this.websocket.onclose = (event) => { console.log('WebSocket connection closed:', event.code, event.reason); this.updateConnectionStatus('disconnected'); this.scheduleReconnect(); }; this.websocket.onerror = (error) => { console.error('WebSocket error:', error); this.updateConnectionStatus('disconnected'); }; } catch (error) { console.error('Failed to create WebSocket connection:', error); this.updateConnectionStatus('disconnected'); this.scheduleReconnect(); } } /** * Handle incoming WebSocket messages */ handleWebSocketMessage(message) { console.log('WebSocket message received:', message); const { event_type, data, message: msg } = message; switch (event_type) { case 'document_added': this.handleDocumentAdded(data); this.showToast('success', 'Document Added', `${data.filename} has been processed successfully.`); break; case 'document_removed': this.handleDocumentRemoved(data); // Check if this removal was client-initiated (to prevent duplicate notifications) if (!this.isClientInitiatedAction('document_removed', data.document_id)) { this.showToast('info', 'Document Removed', 'Document has been removed from the knowledgebase.'); } break; case 'processing_started': this.showToast('info', 'Processing Started', `Processing ${data.filename}...`); break; case 'processing_completed': this.handleProcessingCompleted(data); this.showToast('success', 'Document Added', `${data.filename} has been processed successfully with ${data.chunks_created} chunks.`); break; case 'processing_failed': this.handleProcessingFailed(data); this.showToast('error', 'Processing Failed', `Failed to process ${data.filename}: ${data.error || 'Unknown error'}`); break; case 'search_performed': // Optional: Update search analytics break; case 'system_status_changed': this.handleSystemStatusChanged(data); break; case 'error_occurred': this.showToast('error', 'Error', data.error || 'An error occurred'); break; default: console.log('Unknown WebSocket event type:', event_type); } } /** * Schedule WebSocket reconnection */ scheduleReconnect() { if (this.wsReconnectAttempts >= this.maxReconnectAttempts) { console.error('Max WebSocket reconnection attempts reached'); this.showToast('error', 'Connection Lost', 'Unable to reconnect. Please refresh the page.'); return; } this.wsReconnectAttempts++; const delay = Math.min(this.wsReconnectDelay * Math.pow(2, this.wsReconnectAttempts - 1), 30000); console.log(`Scheduling WebSocket reconnection in ${delay}ms (attempt ${this.wsReconnectAttempts})`); setTimeout(() => { if (this.websocket?.readyState === WebSocket.CLOSED) { this.initWebSocket(); } }, delay); } /** * Update connection status indicator */ updateConnectionStatus(status) { const statusIndicator = document.getElementById('connection-status'); const statusText = statusIndicator?.querySelector('.status-text'); const indicator = statusIndicator?.querySelector('.status-indicator'); if (indicator) { indicator.setAttribute('data-status', status); } if (statusText) { const statusMessages = { connecting: 'Connecting...', connected: 'Connected', disconnected: 'Disconnected' }; statusText.textContent = statusMessages[status] || status; } } /** * Handle network connectivity changes */ handleNetworkChange(isOnline) { if (isOnline) { console.log('Network connection restored'); if (this.websocket?.readyState === WebSocket.CLOSED) { this.initWebSocket(); } } else { console.log('Network connection lost'); this.updateConnectionStatus('disconnected'); } } /** * API Client Methods */ /** * Make API reques */ async apiRequest(endpoint, options = {}) { const url = `${this.apiBaseUrl}${endpoint}`; const defaultOptions = { headers: { 'Content-Type': 'application/json', }, }; try { const response = await fetch(url, { ...defaultOptions, ...options }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error(`API request failed: ${endpoint}`, error); throw error; } } /** * Load system status */ async loadSystemStatus() { try { const status = await this.apiRequest('/status'); this.systemStats = { documents: status.documents_count || 0, chunks: status.chunks_count || 0 }; this.updateSystemStats(); } catch (error) { console.error('Failed to load system status:', error); } } /** * Update system stats display */ updateSystemStats() { const documentsCount = document.getElementById('documents-count'); const chunksCount = document.getElementById('chunks-count'); const systemStats = document.getElementById('system-stats'); if (documentsCount) documentsCount.textContent = this.systemStats.documents; if (chunksCount) chunksCount.textContent = this.systemStats.chunks; if (systemStats && (this.systemStats.documents > 0 || this.systemStats.chunks > 0)) { systemStats.classList.remove('hidden'); } } /** * Load documents lis */ async loadDocuments(force = false) { if (this.documents.length > 0 && !force) return; this.showLoadingState('documents'); try { const response = await this.apiRequest( `/documents?page=${this.currentPage}&page_size=${this.documentsPerPage}` ); this.documents = response.documents || []; this.updateDocumentsList(); this.updatePagination(response); } catch (error) { console.error('Failed to load documents:', error); this.showToast('error', 'Load Error', 'Failed to load documents. Please try again.'); this.showEmptyState('documents'); } } /** * Search documents */ async searchDocuments(query, options = {}) { const searchOptions = { limit: 10, min_score: 0.0, include_chunks: true, ...options }; this.showLoadingState('search-results'); try { const response = await this.apiRequest('/search', { method: 'POST', body: JSON.stringify({ query, ...searchOptions }) }); this.searchResults = response.results || []; this.searchQuery = query; if (this.components.searchInterface) { this.components.searchInterface.updateResults(this.searchResults); } } catch (error) { console.error('Search failed:', error); this.showToast('error', 'Search Error', 'Failed to search documents. Please try again.'); this.showEmptyState('search-results'); } } /** * Get document details */ async getDocumentDetail(documentId, includeChunks = true) { try { const response = await this.apiRequest( `/documents/${documentId}?include_chunks=${includeChunks}` ); return response; } catch (error) { console.error('Failed to get document detail:', error); throw error; } } /** * Remove documen */ async removeDocument(documentId) { try { // Mark this as a client-initiated action this.markClientInitiatedAction('document_removed', documentId); await this.apiRequest(`/documents/${documentId}`, { method: 'DELETE' }); // Remove from local documents array const removedDoc = this.documents.find(doc => doc.id === documentId); this.documents = this.documents.filter(doc => doc.id !== documentId); this.updateDocumentsList(); // Show success notification (WebSocket duplicate will be ignored) const docName = removedDoc ? removedDoc.filename : 'Document'; this.showToast('success', 'Document Removed', `${docName} has been removed successfully.`); return true; } catch (error) { console.error('Failed to remove document:', error); // Remove the action marker on error this.clearClientInitiatedAction('document_removed', documentId); throw error; } } /** * Upload documen */ async uploadDocument(file, metadata = {}) { const formData = new FormData(); formData.append('file', file); if (Object.keys(metadata).length > 0) { formData.append('metadata', JSON.stringify(metadata)); } try { const response = await fetch(`${this.apiBaseUrl}/documents/upload`, { method: 'POST', body: formData }); if (!response.ok) { throw new Error(`Upload failed: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('Document upload failed:', error); throw error; } } /** * Add document by path */ async addDocumentByPath(path, metadata = {}) { try { const response = await this.apiRequest('/documents/add-path', { method: 'POST', body: JSON.stringify({ path, metadata }) }); return response; } catch (error) { console.error('Failed to add document by path:', error); throw error; } } /** * Rescan documents directory for new/modified/deleted files */ async rescanDocuments() { const rescanBtn = document.getElementById('rescan-documents'); try { // Update button state if (rescanBtn) { rescanBtn.disabled = true; rescanBtn.innerHTML = ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning"> <path d="M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9c1.08 0 2.13.19 3.1.54"/> </svg> Rescanning... `; } // Show loading toas this.showToast('info', 'Rescanning...', 'Scanning documents directory for changes...'); // Call rescan API const response = await this.apiRequest('/documents/rescan', { method: 'POST' }); // Handle successful rescan if (response.success) { const { results } = response; const totalChanges = results.new_files.length + results.modified_files.length + results.deleted_files.length; if (totalChanges > 0) { // Show detailed results let message = `Found ${totalChanges} changes:`; if (results.new_files.length > 0) { message += ` ${results.new_files.length} new`; } if (results.modified_files.length > 0) { message += ` ${results.modified_files.length} modified`; } if (results.deleted_files.length > 0) { message += ` ${results.deleted_files.length} deleted`; } this.showToast('success', 'Rescan Complete', message); // Refresh the documents list to show changes await this.loadDocuments(true); } else { this.showToast('info', 'Rescan Complete', 'No changes detected in documents directory.'); } } } catch (error) { console.error('Failed to rescan documents:', error); this.showToast('error', 'Rescan Failed', 'Failed to rescan documents. Please try again.'); } finally { // Restore button state if (rescanBtn) { rescanBtn.disabled = false; rescanBtn.innerHTML = ` <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M3 3v5h5"/> <path d="M3 8l1.88-1.88a9 9 0 0 1 12.56 0L21 8"/> <path d="M21 21v-5h-5"/> <path d="M21 16l-1.88 1.88a9 9 0 0 1-12.56 0L3 16"/> </svg> Rescan `; } } } /** * Event Handlers for WebSocket Messages */ handleDocumentAdded(data) { // Reload documents if we're on the documents view if (this.currentView === 'documents') { this.loadDocuments(true); } // Update system stats this.systemStats.documents++; this.updateSystemStats(); } handleDocumentRemoved(data) { // Remove from local documents array this.documents = this.documents.filter(doc => doc.document_id !== data.document_id); this.updateDocumentsList(); // Update system stats this.systemStats.documents--; this.updateSystemStats(); } handleProcessingCompleted(data) { // Reload documents if we're on the documents view (document was added) if (this.currentView === 'documents') { this.loadDocuments(true); } // Update system stats this.systemStats.documents++; this.updateSystemStats(); // Update document status in local array if it exists const doc = this.documents.find(d => d.id === data.document_id); if (doc) { doc.has_embeddings = true; doc.chunk_count = data.chunks_created; this.updateDocumentsList(); } } handleProcessingFailed(data) { // Could update document status to show error state console.error('Document processing failed:', data); } handleSystemStatusChanged(data) { if (data.status === 'shutting_down') { this.showToast('warning', 'Server Maintenance', 'Server is shutting down for maintenance.'); } } /** * UI Helper Methods */ /** * Show loading state for a container */ showLoadingState(containerId) { // Normalize container id to support both "xyz" and "xyz-container" const baseId = containerId.endsWith('-container') ? containerId.replace(/-container$/, '') : containerId; const loading = document.getElementById(`${baseId}-loading`); // Prefer "-list" content when present (e.g., documents-list), else fall back to baseId (e.g., search-results) const content = document.getElementById(`${baseId}-list`) || document.getElementById(baseId); const empty = document.getElementById(`${baseId}-empty`); if (loading) loading.classList.remove('hidden'); if (content) content.classList.add('hidden'); if (empty) empty.classList.add('hidden'); } /** * Show empty state for a container */ showEmptyState(containerId) { const baseId = containerId.endsWith('-container') ? containerId.replace(/-container$/, '') : containerId; const loading = document.getElementById(`${baseId}-loading`); const content = document.getElementById(`${baseId}-list`) || document.getElementById(baseId); const empty = document.getElementById(`${baseId}-empty`); if (loading) loading.classList.add('hidden'); if (content) content.classList.add('hidden'); if (empty) empty.classList.remove('hidden'); } /** * Hide loading and show conten */ showContent(containerId) { const baseId = containerId.endsWith('-container') ? containerId.replace(/-container$/, '') : containerId; const loading = document.getElementById(`${baseId}-loading`); const content = document.getElementById(`${baseId}-list`) || document.getElementById(baseId); const empty = document.getElementById(`${baseId}-empty`); if (loading) loading.classList.add('hidden'); if (content) content.classList.remove('hidden'); if (empty) empty.classList.add('hidden'); } /** * Update documents list display */ updateDocumentsList() { const listContainer = document.getElementById('documents-list'); const emptyState = document.getElementById('documents-empty'); if (!listContainer) return; if (this.documents.length === 0) { this.showEmptyState('documents'); return; } this.showContent('documents'); // Let DocumentList component handle the rendering if (this.components.documentList) { this.components.documentList.render(this.documents); } } /** * Update pagination controls */ updatePagination(response) { const pagination = document.getElementById('documents-pagination'); const paginationText = document.getElementById('pagination-text'); const prevBtn = document.getElementById('pagination-prev'); const nextBtn = document.getElementById('pagination-next'); if (!pagination) return; if (response.total_pages <= 1) { pagination.classList.add('hidden'); return; } pagination.classList.remove('hidden'); // Update pagination tex const start = (response.page - 1) * response.page_size + 1; const end = Math.min(start + response.page_size - 1, response.total_count); if (paginationText) { paginationText.textContent = `Showing ${start}-${end} of ${response.total_count}`; } // Update buttons if (prevBtn) { prevBtn.disabled = !response.has_previous; } if (nextBtn) { nextBtn.disabled = !response.has_next; } // Generate page numbers this.generatePageNumbers(response); } /** * Generate pagination page numbers */ generatePageNumbers(response) { const pagesContainer = document.getElementById('pagination-pages'); if (!pagesContainer) return; pagesContainer.innerHTML = ''; const currentPage = response.page; const totalPages = response.total_pages; const maxVisiblePages = 7; let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); if (endPage - startPage + 1 < maxVisiblePages) { startPage = Math.max(1, endPage - maxVisiblePages + 1); } for (let i = startPage; i <= endPage; i++) { const pageBtn = document.createElement('button'); pageBtn.className = `pagination-page ${i === currentPage ? 'active' : ''}`; pageBtn.textContent = i; pageBtn.addEventListener('click', () => this.goToPage(i)); pagesContainer.appendChild(pageBtn); } } /** * Go to specific page */ async goToPage(page) { this.currentPage = page; await this.loadDocuments(true); } /** * Filter documents by search term */ filterDocuments(searchTerm) { // This would typically filter the displayed documents // For now, we'll just store the term console.log('Filtering documents:', searchTerm); } /** * Theme Managemen */ /** * Apply current theme */ applyTheme() { document.documentElement.setAttribute('data-theme', this.theme); const lightIcon = document.querySelector('.theme-icon-light'); const darkIcon = document.querySelector('.theme-icon-dark'); if (lightIcon && darkIcon) { if (this.theme === 'dark') { lightIcon.classList.add('hidden'); darkIcon.classList.remove('hidden'); } else { lightIcon.classList.remove('hidden'); darkIcon.classList.add('hidden'); } } } /** * Toggle theme between light and dark */ toggleTheme() { this.theme = this.theme === 'light' ? 'dark' : 'light'; localStorage.setItem('theme', this.theme); this.applyTheme(); } /** * Toast Notification System */ /** * Show toast notification */ showToast(type, title, message, duration = 5000) { const toastContainer = document.getElementById('toast-container'); if (!toastContainer) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = ` <svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> ${this.getToastIcon(type)} </svg> <div class="toast-content"> <div class="toast-title">${title}</div> <p class="toast-message">${message}</p> </div> <button class="toast-close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <line x1="18" y1="6" x2="6" y2="18"/> <line x1="6" y1="6" x2="18" y2="18"/> </svg> </button> `; // Add close functionality const closeBtn = toast.querySelector('.toast-close'); closeBtn.addEventListener('click', () => { toast.remove(); }); toastContainer.appendChild(toast); // Auto remove after duration if (duration > 0) { setTimeout(() => { if (toast.parentNode) { toast.remove(); } }, duration); } } /** * Get SVG icon for toast type */ getToastIcon(type) { const icons = { success: '<polyline points="20,6 9,17 4,12"/>', error: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>', warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>', info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/>' }; return icons[type] || icons.info; } /** * Confirmation Modal */ /** * Show confirmation modal */ showConfirmModal(title, message, onConfirm, onCancel = null) { const modal = document.getElementById('confirm-modal'); const titleEl = document.getElementById('confirm-title'); const messageEl = document.getElementById('confirm-message'); const confirmBtn = document.getElementById('confirm-ok'); if (!modal || !titleEl || !messageEl || !confirmBtn) return; titleEl.textContent = title; messageEl.textContent = message; // Remove existing event listeners const newConfirmBtn = confirmBtn.cloneNode(true); confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); // Add new event listener newConfirmBtn.addEventListener('click', () => { modal.classList.remove('active'); modal.classList.add('hidden'); if (onConfirm) onConfirm(); }); // Show modal modal.classList.remove('hidden'); modal.classList.add('active'); } /** * Utility Methods */ /** * Debounce function */ debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } /** * Format file size */ formatFileSize(bytes) { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } /** * Format date */ formatDate(dateString) { if (!dateString) return 'Unknown'; const date = new Date(dateString); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } /** * Mark an action as client-initiated to prevent duplicate WebSocket notifications */ markClientInitiatedAction(actionType, identifier = null) { const key = identifier ? `${actionType}:${identifier}` : actionType; this.clientInitiatedActions.set(key, Date.now()); // Auto-cleanup after ignore window expires setTimeout(() => { this.clientInitiatedActions.delete(key); }, this.actionIgnoreWindow); } /** * Check if an action was recently initiated by this clien */ isClientInitiatedAction(actionType, identifier = null) { const key = identifier ? `${actionType}:${identifier}` : actionType; const timestamp = this.clientInitiatedActions.get(key); if (!timestamp) return false; const elapsed = Date.now() - timestamp; const isRecent = elapsed < this.actionIgnoreWindow; // Clean up expired actions if (!isRecent) { this.clientInitiatedActions.delete(key); } return isRecent; } /** * Clear a client-initiated action marker */ clearClientInitiatedAction(actionType, identifier = null) { const key = identifier ? `${actionType}:${identifier}` : actionType; this.clientInitiatedActions.delete(key); } /** * Cleanup resources */ cleanup() { if (this.websocket) { this.websocket.close(); } } } // Initialize the application when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.pdfkbApp = new PDFKnowledgebaseApp(); }); // Export for module usage if (typeof module !== 'undefined' && module.exports) { module.exports = PDFKnowledgebaseApp; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/juanqui/pdfkb-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server