/** * kb-controller.js — Knowledge Base Management Controller * ========================================================= * Handles specialized navigation and multi-file upload for policy documents. * * Hardened for Enterprise MVC: * - Integrates globally with `apiFetch` for automatic CSRF & HTTP-Only cookies. * - Utilises `SecurityUtils` for XSS immunity on dynamic DOM generation. * - Enforces Vercel Serverless batch constraints (max 5 files). */ (function () { 'use strict'; // ── Elements & State ─────────────────────────────────────────────────── const kbTabs = document.querySelectorAll('.nav-tab'); const kbPanels = document.querySelectorAll('.content-panel'); const kbDropzone = document.getElementById('kb-dropzone'); const kbFileInput = document.getElementById('pdf-input'); const kbChipsContainer = document.getElementById('kb-file-chips'); const kbUploadBtn = document.getElementById('process-upload-btn'); const kbLoader = document.getElementById('loader'); const kbLoaderText = document.getElementById('loader-text'); const kbEmptyState = document.getElementById('empty-state'); const kbDocsGrid = document.getElementById('documents-grid'); const kbDocCount = document.getElementById('doc-count'); let queuedFiles = []; let pollingInterval = null; console.info('[KBController] Initializing Knowledge Base specialized controller...'); // ── Tabs Logic ────────────────────────────────────────────────────────── function initTabs() { if (!kbTabs.length) return; kbTabs.forEach(tab => { tab.addEventListener('click', (e) => { const targetId = tab.getAttribute('aria-controls'); const targetPanel = document.getElementById(targetId); if (!targetPanel) return; // Toggle Active States kbTabs.forEach(t => { t.classList.remove('active'); t.setAttribute('aria-selected', 'false'); }); kbPanels.forEach(p => p.classList.remove('active')); tab.classList.add('active'); tab.setAttribute('aria-selected', 'true'); targetPanel.classList.add('active'); // Trigger actions if (tab.dataset.tab === 'documents') { loadDocuments(); } else { stopPolling(); } }); }); } // ── File Selection & Chips ────────────────────────────────────────────── function initFileHandlers() { if (kbDropzone) { kbDropzone.addEventListener('dragover', e => { e.preventDefault(); kbDropzone.classList.add('drag-over'); }); kbDropzone.addEventListener('dragleave', () => kbDropzone.classList.remove('drag-over')); kbDropzone.addEventListener('drop', e => { e.preventDefault(); kbDropzone.classList.remove('drag-over'); handleFileSelect(Array.from(e.dataTransfer.files)); }); } if (kbFileInput) { kbFileInput.addEventListener('change', e => { handleFileSelect(Array.from(e.target.files)); }); } if (kbUploadBtn) { kbUploadBtn.addEventListener('click', () => { if (queuedFiles.length > 0) { uploadDocuments(queuedFiles); } }); } } function handleFileSelect(files) { if (!files || files.length === 0) return; const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv']; // Vercel Timeout Guard Enforcement (Max 5 files per batch) if (queuedFiles.length + files.length > 5) { SecurityUtils.showToast('Maximum 5 files allowed per upload batch to prevent server timeouts.', 'warning'); files = files.slice(0, Math.max(0, 5 - queuedFiles.length)); } for (const file of files) { const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!allowedExtensions.includes(ext)) { SecurityUtils.showToast(`Unsupported format: ${file.name}`, 'error'); continue; } if (file.size > 50 * 1024 * 1024) { SecurityUtils.showToast(`File too large (Max 50MB): ${file.name}`, 'error'); continue; } if (!queuedFiles.some(f => f.name === file.name && f.size === file.size)) { queuedFiles.push(file); } } renderFileChips(); updateUploadButton(); } function renderFileChips() { if (!kbChipsContainer) return; kbChipsContainer.textContent = ''; if (queuedFiles.length === 0) { kbChipsContainer.style.display = 'none'; return; } kbChipsContainer.style.display = 'flex'; queuedFiles.forEach((file, index) => { const chip = document.createElement('div'); chip.className = 'file-chip'; const icon = document.createElement('span'); icon.className = 'material-symbols-rounded'; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); icon.textContent = (ext === '.pdf') ? 'picture_as_pdf' : (ext === '.csv') ? 'table_chart' : 'description'; const name = document.createElement('span'); name.className = 'chip-name'; name.textContent = file.name; const removeBtn = document.createElement('button'); removeBtn.className = 'chip-remove'; removeBtn.type = 'button'; removeBtn.innerHTML = 'close'; removeBtn.title = 'Remove file'; removeBtn.addEventListener('click', (e) => { e.stopPropagation(); queuedFiles.splice(index, 1); renderFileChips(); updateUploadButton(); }); chip.appendChild(icon); chip.appendChild(name); chip.appendChild(removeBtn); kbChipsContainer.appendChild(chip); }); } function updateUploadButton() { if (kbUploadBtn) { kbUploadBtn.disabled = (queuedFiles.length === 0); } } // ── API Operations ────────────────────────────────────────────────────── async function uploadDocuments(files) { if (!files.length) return; const formData = new FormData(); files.forEach(file => formData.append('file', file)); if (kbLoader) { kbLoader.hidden = false; if (kbLoaderText) kbLoaderText.textContent = `Uploading & Indexing ${files.length} document(s)...`; } SecurityUtils.setButtonLoading(kbUploadBtn, true); try { // Use global apiFetch for automatic CSRF injection and HTTP-Only cookie forwarding const data = await apiFetch('/kb/upload', { method: 'POST', body: formData }); if (data.success) { SecurityUtils.showToast(`Successfully queued ${data.results.length} document(s) for indexing`, 'success'); queuedFiles = []; renderFileChips(); updateUploadButton(); // Automatically Switch to Manage tab to view indexing progress const docTab = document.querySelector('[data-tab="documents"]'); if (docTab) docTab.click(); } } catch (err) { ErrorHandler.showError(err); } finally { if (kbLoader) kbLoader.hidden = true; if (kbFileInput) kbFileInput.value = ''; SecurityUtils.setButtonLoading(kbUploadBtn, false); } } async function loadDocuments(quiet = false) { if (!quiet && kbLoader) { kbLoader.hidden = false; if (kbLoaderText) kbLoaderText.textContent = 'Loading policy library...'; } try { const data = await apiFetch('/kb/documents'); if (data.success) { renderDocuments(data.documents); // Cache documents locally for offline viewing try { localStorage.setItem('qualora_kb_docs_v1', JSON.stringify(data.documents)); } catch (e) { /* ignore storage errors */ } // Check if any docs are actively indexing to start/stop polling const isIndexing = data.documents.some(d => ['indexing', 'pending'].includes(d.status)); if (isIndexing) startPolling(); else stopPolling(); } } catch (err) { console.error('[KB] loadDocuments failed:', err); // Fallback to cached documents in localStorage try { const cached = localStorage.getItem('qualora_kb_docs_v1'); if (cached) { const docs = JSON.parse(cached); renderDocuments(docs); if (window.SecurityUtils && !quiet) window.SecurityUtils.showToast('Using cached KB documents (offline)', 'info'); // If any doc shows indexing, start polling to try refreshing status const isIndexing = docs.some(d => ['indexing', 'pending'].includes(d.status)); if (isIndexing) startPolling(); else stopPolling(); return; } } catch (cacheErr) { console.error('[KB] cached docs parse failed:', cacheErr); } if (!quiet) ErrorHandler.showError(err); } finally { if (!quiet && kbLoader) kbLoader.hidden = true; } } // ── UI Renderers ──────────────────────────────────────────────────────── function renderDocuments(documents) { if (!kbDocsGrid || !kbEmptyState) return; if (!documents || documents.length === 0) { kbEmptyState.hidden = false; kbDocsGrid.hidden = true; if (kbDocCount) kbDocCount.textContent = '0 documents'; return; } kbEmptyState.hidden = true; kbDocsGrid.hidden = false; if (kbDocCount) kbDocCount.textContent = `${documents.length} document${documents.length !== 1 ? 's' : ''}`; // Clear existing content and use fragment for performance kbDocsGrid.textContent = ''; const fragment = document.createDocumentFragment(); documents.forEach(doc => { fragment.appendChild(createDocCard(doc)); }); kbDocsGrid.appendChild(fragment); } function createDocCard(doc) { const card = document.createElement('div'); const safeStatus = SecurityUtils.escapeHTML(doc.status); card.className = `doc-card ${safeStatus}`; const ext = doc.filename.substring(doc.filename.lastIndexOf('.')).toLowerCase(); const iconName = (ext === '.pdf') ? 'picture_as_pdf' : (ext === '.csv') ? 'table_chart' : 'description'; // XSS Safeguard: Encode values before HTML injection const safeName = SecurityUtils.escapeHTML(doc.filename); const safeDate = doc.created_at ? SecurityUtils.escapeHTML(new Date(doc.created_at).toLocaleDateString()) : 'Unknown'; const chunkCount = SecurityUtils.escapeHTML(doc.chunk_count || 0); const displayStatus = safeStatus.charAt(0).toUpperCase() + safeStatus.slice(1); const atlasIndexed = Boolean(doc.vector_indexed); const chromaIndexed = Boolean(doc.chroma_indexed); const atlasStatusText = atlasIndexed ? '✅ Indexed' : '⏳ Pending'; const chromaStatusText = chromaIndexed ? '✅ Indexed' : '⏳ Pending'; card.innerHTML = `
${iconName}

${safeName}

${safeDate}

Status: ${displayStatus}

Total Vector Chunks: ${chunkCount}

Atlas DB (Primary): ${atlasStatusText}

ChromaDB (Local): ${chromaStatusText}

`; card.querySelector('.view-btn').addEventListener('click', () => viewDocumentFile(doc._id)); card.querySelector('.delete-btn').addEventListener('click', () => deleteDocument(doc._id)); return card; } // ── Action Handlers ───────────────────────────────────────────────────── async function deleteDocument(docId) { const confirmed = await SecurityUtils.showConfirmDialog( 'Delete Document', 'Permanently delete this policy document? All vector index chunks will be removed.', 'Delete', 'Cancel' ); if (!confirmed) return; try { await apiFetch(`/kb/${docId}`, { method: 'DELETE' }); SecurityUtils.showToast('Document deleted successfully', 'success'); loadDocuments(); } catch (err) { ErrorHandler.showError(err); } } function viewDocumentFile(docId) { window.open(`/api/kb/${encodeURIComponent(docId)}/file`, '_blank', 'noopener,noreferrer'); } // ── Polling ───────────────────────────────────────────────────────────── function startPolling() { if (pollingInterval) return; // 5s polling interval keeps backend CPU strain minimal pollingInterval = setInterval(() => loadDocuments(true), 5000); } function stopPolling() { if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; } } // ── Initialization ────────────────────────────────────────────────────────── initTabs(); initFileHandlers(); loadDocuments(); })();