qualora / static /js /kb-controller.js
Qualora QA Bot
final 4/4
9121aae
Raw
History Blame Contribute Delete
15.9 kB
/**
* 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();
})();