Spaces:
Sleeping
Sleeping
Download frontend/static/js/app.js from HarryDataScience/documind-ai: direct link, hf CLI and curl.
- Browser
- Download file 20.3 kB
-
https://huggingface.co/spaces/HarryDataScience/documind-ai/resolve/main/frontend/static/js/app.js
- Command line
-
hf download hf://spaces/HarryDataScience/documind-ai/frontend/static/js/app.js
-
curl -L -o app.js https://huggingface.co/spaces/HarryDataScience/documind-ai/resolve/main/frontend/static/js/app.js
20.3 kB
| /** | |
| * 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 = ` | |
| <span class="toast-icon">${icons[type]}</span> | |
| <span class="toast-msg">${escapeHtml(message)}</span> | |
| `; | |
| 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 = ` | |
| <div class="empty-docs"> | |
| π No documents yet.<br>Upload a PDF to get started. | |
| </div>`; | |
| renderDocFilter(); | |
| return; | |
| } | |
| dom.docList.innerHTML = docs.map(doc => ` | |
| <div class="doc-item ${state.selectedDocIds.includes(doc.doc_id) ? 'selected' : ''}" | |
| data-doc-id="${escapeHtml(doc.doc_id)}" | |
| title="${escapeHtml(doc.filename)}"> | |
| <div class="doc-icon">π</div> | |
| <div class="doc-info"> | |
| <div class="doc-name">${escapeHtml(doc.filename)}</div> | |
| <div class="doc-meta">${doc.total_pages} pages Β· ${doc.total_chunks} chunks Β· ${doc.file_size_kb.toFixed(1)} KB</div> | |
| </div> | |
| <button class="doc-delete" data-action="delete" | |
| title="Remove document">β</button> | |
| </div> | |
| `).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 ? `<span class="doc-filter-chip">π ${escapeHtml(doc.filename)}</span>` : ''; | |
| }).join(''); | |
| dom.docFilter.innerHTML = ` | |
| <span style="color:var(--text-muted);font-size:12px;">Searching in:</span> | |
| ${names} | |
| <button onclick="clearDocFilter()" style="border:none;background:none;color:var(--text-muted);cursor:pointer;font-size:12px;margin-left:4px;">β Clear</button> | |
| `; | |
| } | |
| 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 = ` | |
| <div class="msg-label">${label}</div> | |
| <div class="msg-bubble">${escapeHtml(text)}</div> | |
| `; | |
| 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 | |
| ? '<span class="meta-badge badge-green">β Grounded</span>' | |
| : '<span class="meta-badge badge-amber">β May not be fully grounded</span>'; | |
| // Sources HTML | |
| const sourcesHtml = data.sources && data.sources.length > 0 | |
| ? buildSourcesHtml(data.sources) | |
| : ''; | |
| wrap.innerHTML = ` | |
| <div class="msg-label">DocuMind AI</div> | |
| <div class="msg-bubble"> | |
| <div class="md-body">${renderMarkdown(data.answer)}</div> | |
| <div class="answer-meta"> | |
| <span class="meta-badge ${confClass}">${confLabel}</span> | |
| ${groundedBadge} | |
| <span class="meta-badge badge-blue">π ${data.chunks_retrieved} ${data.chunks_retrieved === 1 ? 'source' : 'sources'}</span> | |
| <span class="meta-badge badge-purple">β‘ ${data.model_used}</span> | |
| <span class="meta-badge badge-gray">β± ${data.latency_ms}ms</span> | |
| </div> | |
| </div> | |
| ${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) => ` | |
| <div class="source-item"> | |
| <div class="source-meta"> | |
| <span class="source-file">π ${escapeHtml(src.doc_name)}</span> | |
| <span class="source-page">Page ${src.page_number}</span> | |
| <span class="source-sim">${(src.similarity * 100).toFixed(1)}% match</span> | |
| </div> | |
| <div class="source-text">"${escapeHtml(src.text)}"</div> | |
| </div> | |
| `).join(''); | |
| return ` | |
| <div class="sources-panel"> | |
| <div class="sources-header"> | |
| <span class="sources-title">π ${sources.length} Source${sources.length > 1 ? 's' : ''} used</span> | |
| <span class="sources-toggle">βΌ</span> | |
| </div> | |
| <div class="sources-list"> | |
| ${items} | |
| </div> | |
| </div> | |
| `; | |
| } | |
| function appendTypingIndicator() { | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'message assistant'; | |
| wrap.innerHTML = ` | |
| <div class="msg-label">DocuMind AI</div> | |
| <div class="typing-indicator"> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| </div> | |
| `; | |
| 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, '"') | |
| .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); | |