/** * DocuMind AI — Frontend Application * ───────────────────────────────────── * Handles: * - PDF upload (drag & drop + click) * - Document list management * - Chat interface (send query, display answer) * - Source citations rendering * - Toast notifications * - API health monitoring */ const API_BASE = window.location.origin; // ── State ────────────────────────────────────────────────────────────────────── const state = { documents: [], // all indexed documents selectedDocIds: [], // filter queries to these docs (empty = all) isQuerying: false, // prevent double-submit apiKey: '', authPrompted: false, history: [], }; // ── DOM References ───────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); const dom = { uploadArea: $('uploadArea'), uploadInput: $('uploadInput'), uploadProgress:$('uploadProgress'), progressBar: $('progressBar'), progressLabel: $('progressLabel'), docList: $('docList'), chatArea: $('chatArea'), welcome: $('welcome'), queryInput: $('queryInput'), sendBtn: $('sendBtn'), statusDot: $('statusDot'), statusText: $('statusText'), docFilter: $('docFilter'), toastContainer:$('toastContainer'), }; // ── API Helpers ──────────────────────────────────────────────────────────────── async function apiFetch(endpoint, options = {}) { const url = `${API_BASE}${endpoint}`; try { const headers = { 'Content-Type': 'application/json', ...options.headers }; if (state.apiKey) headers['X-API-Key'] = state.apiKey; let res = await fetch(url, { ...options, headers }); if (res.status === 401 && !state.authPrompted) { state.authPrompted = true; state.apiKey = prompt('Enter the DocuMind API key:') || ''; if (state.apiKey) res = await fetch(url, { ...options, headers: { ...headers, 'X-API-Key': state.apiKey } }); } if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(formatError(err.detail, `HTTP ${res.status}`)); } return await res.json(); } catch (err) { if (err.name === 'TypeError') { throw new Error('Cannot connect to API. Is the backend running?'); } throw err; } } async function streamQuery(payload, onToken) { const headers = { 'Content-Type': 'application/json' }; if (state.apiKey) headers['X-API-Key'] = state.apiKey; let response = await fetch(`${API_BASE}/api/query/stream`, { method: 'POST', headers, body: JSON.stringify(payload), }); if (response.status === 401 && !state.authPrompted) { state.authPrompted = true; state.apiKey = prompt('Enter the DocuMind API key:') || ''; if (state.apiKey) response = await fetch(`${API_BASE}/api/query/stream`, { method: 'POST', headers: { ...headers, 'X-API-Key': state.apiKey }, body: JSON.stringify(payload), }); } if ([404, 405, 501].includes(response.status)) return null; if (!response.ok || !response.body) return null; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = '', finalData = null, answer = ''; while (true) { const { value, done } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); const events = buffer.split(/\r?\n\r?\n/); buffer = events.pop() || ''; for (const event of events) { const type = event.match(/^event:\s*(.+)$/m)?.[1]; const dataLine = event.match(/^data:\s*(.+)$/m)?.[1]; if (!dataLine) continue; const data = JSON.parse(dataLine); if (type === 'token') { answer += data.token || ''; onToken(answer); } if (type === 'final') finalData = data; } if (done) break; } return finalData; } // ── Toast Notifications ──────────────────────────────────────────────────────── function showToast(message, type = 'info', duration = 4000) { const icons = { success: '✅', error: '❌', info: 'ℹ️' }; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.innerHTML = ` ${icons[type]} ${escapeHtml(message)} `; dom.toastContainer.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, duration); } // ── Health Check ─────────────────────────────────────────────────────────────── async function checkHealth() { try { const data = await apiFetch('/api/health'); dom.statusDot.className = 'status-dot online'; dom.statusText.textContent = 'API Online'; if (!data.groq_configured) { showToast( '⚠️ Groq API key not set. Add GROQ_API_KEY to your .env file for AI answers.', 'info', 8000 ); } } catch { dom.statusDot.className = 'status-dot offline'; dom.statusText.textContent = 'API Offline'; } } // ── Document Management ──────────────────────────────────────────────────────── async function loadDocuments() { try { const data = await apiFetch('/api/documents'); state.documents = data.documents; renderDocumentList(); } catch (err) { console.error('Failed to load documents:', err); } } function renderDocumentList() { const docs = state.documents; if (docs.length === 0) { dom.docList.innerHTML = `
📄 No documents yet.
Upload a PDF to get started.
`; renderDocFilter(); return; } dom.docList.innerHTML = docs.map(doc => `
📄
${escapeHtml(doc.filename)}
${doc.total_pages} pages · ${doc.total_chunks} chunks · ${doc.file_size_kb.toFixed(1)} KB
`).join(''); renderDocFilter(); } function setupDocList() { // One delegated listener instead of inline onclick attributes (no JS in HTML strings). dom.docList.addEventListener('click', event => { const item = event.target.closest('.doc-item'); if (!item) return; const docId = item.dataset.docId; if (event.target.closest('[data-action="delete"]')) { const doc = state.documents.find(d => d.doc_id === docId); deleteDocument(event, docId, doc ? doc.filename : docId); } else { toggleDocSelection(docId); } }); } function toggleDocSelection(docId) { const idx = state.selectedDocIds.indexOf(docId); if (idx === -1) { state.selectedDocIds.push(docId); } else { state.selectedDocIds.splice(idx, 1); } renderDocumentList(); } function renderDocFilter() { if (state.selectedDocIds.length === 0 || state.documents.length === 0) { dom.docFilter.innerHTML = ''; return; } const names = state.selectedDocIds.map(id => { const doc = state.documents.find(d => d.doc_id === id); return doc ? `📄 ${escapeHtml(doc.filename)}` : ''; }).join(''); dom.docFilter.innerHTML = ` Searching in: ${names} `; } function clearDocFilter() { state.selectedDocIds = []; renderDocumentList(); } async function deleteDocument(event, docId, filename) { event.stopPropagation(); // don't trigger toggleDocSelection if (!confirm(`Remove "${filename}" from the knowledge base?`)) return; try { await apiFetch(`/api/documents/${docId}`, { method: 'DELETE' }); state.documents = state.documents.filter(d => d.doc_id !== docId); state.selectedDocIds = state.selectedDocIds.filter(id => id !== docId); renderDocumentList(); showToast(`"${filename}" removed successfully.`, 'success'); } catch (err) { showToast(`Failed to remove document: ${err.message}`, 'error'); } } // ── PDF Upload ───────────────────────────────────────────────────────────────── function setupUpload() { // Click to upload dom.uploadInput.addEventListener('change', e => { if (e.target.files[0]) handleUpload(e.target.files[0]); }); // Drag & drop dom.uploadArea.addEventListener('dragover', e => { e.preventDefault(); dom.uploadArea.classList.add('drag-over'); }); dom.uploadArea.addEventListener('dragleave', () => { dom.uploadArea.classList.remove('drag-over'); }); dom.uploadArea.addEventListener('drop', e => { e.preventDefault(); dom.uploadArea.classList.remove('drag-over'); const file = e.dataTransfer.files[0]; if (file) handleUpload(file); }); } async function handleUpload(file) { // Validate if (!file.name.toLowerCase().endsWith('.pdf')) { showToast('Please upload a PDF file only.', 'error'); return; } if (file.size > 50 * 1024 * 1024) { showToast('File too large. Maximum size is 50MB.', 'error'); return; } // Show progress UI dom.uploadProgress.style.display = 'block'; dom.progressBar.style.width = '15%'; dom.progressLabel.textContent = 'Uploading...'; const formData = new FormData(); formData.append('file', file); try { // Animate progress (fake progress for UX — real progress needs streaming) let progress = 15; const progressInterval = setInterval(() => { progress = Math.min(progress + Math.random() * 8, 85); dom.progressBar.style.width = progress + '%'; if (progress > 30) dom.progressLabel.textContent = 'Extracting text...'; if (progress > 55) dom.progressLabel.textContent = 'Generating embeddings...'; if (progress > 75) dom.progressLabel.textContent = 'Indexing chunks...'; }, 400); let result = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData, headers: state.apiKey ? { 'X-API-Key': state.apiKey } : {}, }); if (result.status === 401 && !state.authPrompted) { state.authPrompted = true; state.apiKey = prompt('Enter the DocuMind API key:') || ''; if (state.apiKey) { const retry = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData, headers: { 'X-API-Key': state.apiKey } }); if (!retry.ok) throw new Error(formatError((await retry.json()).detail, 'Upload failed')); result = retry; } else throw new Error('API key is required.'); } clearInterval(progressInterval); if (!result.ok) { const err = await result.json(); throw new Error(formatError(err.detail, 'Upload failed')); } const data = await result.json(); if (data.job_id) { dom.progressLabel.textContent = 'Queued for processing...'; let job; do { await new Promise(resolve => setTimeout(resolve, 1500)); job = await apiFetch(`/api/jobs/${encodeURIComponent(data.job_id)}`); dom.progressLabel.textContent = job.status === 'processing' ? 'Processing PDF...' : 'Queued for processing...'; } while (job.status === 'queued' || job.status === 'processing'); if (job.status !== 'done') throw new Error(job.error || 'Background processing failed.'); data.message = `Indexed ${job.total_chunks} chunks across ${job.total_pages} pages.`; } dom.progressBar.style.width = '100%'; dom.progressLabel.textContent = 'Complete!'; setTimeout(() => { dom.uploadProgress.style.display = 'none'; dom.progressBar.style.width = '0%'; }, 1500); await loadDocuments(); showToast(data.message, 'success', 5000); // Reset file input dom.uploadInput.value = ''; } catch (err) { dom.uploadProgress.style.display = 'none'; dom.progressBar.style.width = '0%'; showToast(`Upload failed: ${err.message}`, 'error', 6000); } } // ── Chat Interface ───────────────────────────────────────────────────────────── function setupChat() { // Send on button click dom.sendBtn.addEventListener('click', sendQuery); // Send on Enter (Shift+Enter for newline) dom.queryInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendQuery(); } }); // Auto-resize textarea dom.queryInput.addEventListener('input', () => { dom.queryInput.style.height = 'auto'; dom.queryInput.style.height = Math.min(dom.queryInput.scrollHeight, 120) + 'px'; }); } async function sendQuery() { const question = dom.queryInput.value.trim(); if (!question || state.isQuerying) return; if (state.documents.length === 0) { showToast('Please upload a PDF document first.', 'info'); return; } state.isQuerying = true; dom.sendBtn.disabled = true; dom.queryInput.value = ''; dom.queryInput.style.height = 'auto'; // Hide welcome screen on first message if (dom.welcome) dom.welcome.style.display = 'none'; // Render user message appendMessage('user', question); // Show typing indicator const typingEl = appendTypingIndicator(); try { const payload = { question, doc_ids: state.selectedDocIds.length > 0 ? state.selectedDocIds : null, history: state.history.slice(-10).map(t => ({ role: t.role, content: String(t.content).slice(0, 4000) })), }; let streamed = false; let data; try { data = await streamQuery(payload, partial => { streamed = true; const indicator = typingEl.querySelector('.typing-indicator'); if (indicator) indicator.textContent = partial; }); } catch (streamError) { if (streamed) throw streamError; } if (!data) data = await apiFetch('/api/query', { method: 'POST', body: JSON.stringify(payload), }); typingEl.remove(); appendAssistantMessage(data); state.history.push({ role: 'user', content: question }, { role: 'assistant', content: data.answer }); } catch (err) { typingEl.remove(); appendMessage('assistant', `⚠️ Error: ${err.message}`); showToast(err.message, 'error'); } finally { state.isQuerying = false; dom.sendBtn.disabled = false; dom.queryInput.focus(); } } function appendMessage(role, text) { const wrap = document.createElement('div'); wrap.className = `message ${role}`; const label = role === 'user' ? 'You' : 'DocuMind AI'; wrap.innerHTML = `
${label}
${escapeHtml(text)}
`; dom.chatArea.appendChild(wrap); scrollToBottom(); return wrap; } function appendAssistantMessage(data) { const wrap = document.createElement('div'); wrap.className = 'message assistant'; // Confidence badge const confMap = { HIGH: ['badge-green', '● High confidence'], MEDIUM: ['badge-amber', '● Medium confidence'], LOW: ['badge-red', '● Low confidence'], }; const [confClass, confLabel] = confMap[data.confidence] || ['badge-gray', '● Unknown']; // Grounded badge const groundedBadge = data.is_grounded ? '✓ Grounded' : '⚠ May not be fully grounded'; // Sources HTML const sourcesHtml = data.sources && data.sources.length > 0 ? buildSourcesHtml(data.sources) : ''; wrap.innerHTML = `
DocuMind AI
${renderMarkdown(data.answer)}
${confLabel} ${groundedBadge} 🔍 ${data.chunks_retrieved} ${data.chunks_retrieved === 1 ? 'source' : 'sources'} ⚡ ${data.model_used} ⏱ ${data.latency_ms}ms
${sourcesHtml} `; dom.chatArea.appendChild(wrap); scrollToBottom(); // Attach source toggle handlers const header = wrap.querySelector('.sources-header'); if (header) { header.addEventListener('click', () => { const list = wrap.querySelector('.sources-list'); const toggle = wrap.querySelector('.sources-toggle'); list.classList.toggle('open'); toggle.classList.toggle('open'); }); } } function buildSourcesHtml(sources) { const items = sources.map((src, i) => `
📄 ${escapeHtml(src.doc_name)} Page ${src.page_number} ${(src.similarity * 100).toFixed(1)}% match
"${escapeHtml(src.text)}"
`).join(''); return `
📚 ${sources.length} Source${sources.length > 1 ? 's' : ''} used ▼
${items}
`; } function appendTypingIndicator() { const wrap = document.createElement('div'); wrap.className = 'message assistant'; wrap.innerHTML = `
DocuMind AI
`; dom.chatArea.appendChild(wrap); scrollToBottom(); return wrap; } function scrollToBottom() { dom.chatArea.scrollTop = dom.chatArea.scrollHeight; } function formatError(detail, fallback) { if (typeof detail === 'string') return detail; if (Array.isArray(detail)) { return detail.map(d => (d && d.msg) ? d.msg : String(d)).join('; ') || fallback; } return fallback; } function escapeHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ── Sample Questions ──────────────────────────────────────────────────────────── function askSample(question) { dom.queryInput.value = question; sendQuery(); } // ── Init ─────────────────────────────────────────────────────────────────────── async function init() { setupUpload(); setupDocList(); setupChat(); await checkHealth(); await loadDocuments(); // Poll health every 30 seconds setInterval(checkHealth, 30000); } document.addEventListener('DOMContentLoaded', init);