HarryDataScience's picture
Replace with new DocuMind AI (FastAPI + FAISS)
5959a78
Raw History Blame Contribute Delete
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── 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);