Spaces:
Sleeping
Sleeping
| /** | |
| * 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 = '<span class="material-symbols-rounded" style="font-size:1.1rem">close</span>'; | |
| 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 = ` | |
| <div class="doc-card-header"> | |
| <span class="material-symbols-rounded doc-icon">${iconName}</span> | |
| <div class="doc-info"> | |
| <h4 class="doc-name">${safeName}</h4> | |
| <div class="doc-meta"> | |
| <span>${safeDate}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="doc-status-details"> | |
| <p><strong>Status:</strong> ${displayStatus}</p> | |
| <p><strong>Total Vector Chunks:</strong> ${chunkCount}</p> | |
| <p><strong>Atlas DB (Primary):</strong> ${atlasStatusText}</p> | |
| <p><strong>ChromaDB (Local):</strong> ${chromaStatusText}</p> | |
| </div> | |
| <div class="doc-actions"> | |
| <button class="doc-action-btn view-btn" data-id="${doc._id}">view</button> | |
| <button class="doc-action-btn delete-btn" data-id="${doc._id}" title="Delete"> | |
| <span class="material-symbols-rounded" style="font-size:1.1rem">delete</span> | |
| </button> | |
| </div> | |
| `; | |
| 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(); | |
| })(); |