// Gapura RAG β€” Chat Application Logic // Handles SSE streaming, file upload, and UI interactions const DOM = { chatContainer: () => document.getElementById('chat-container'), messages: () => document.getElementById('messages'), welcome: () => document.getElementById('chat-welcome'), chatForm: () => document.getElementById('chat-form'), chatInput: () => document.getElementById('chat-input'), btnSend: () => document.getElementById('btn-send'), btnClear: () => document.getElementById('btn-clear-chat'), langSelect: () => document.getElementById('lang-select'), dropZone: () => document.getElementById('drop-zone'), fileInput: () => document.getElementById('file-input'), uploadLog: () => document.getElementById('upload-log'), statsMini: () => document.getElementById('stats-mini'), }; const chatHistory = []; function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('open'); } function openSidebar() { document.getElementById('sidebar').classList.add('open'); document.getElementById('sidebar-overlay').classList.add('open'); } function switchPanel(panelName) { document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.bottom-nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); document.querySelectorAll(`[data-panel="${panelName}"]`).forEach(b => b.classList.add('active')); const panel = document.getElementById(`panel-${panelName}`); if (panel) panel.classList.add('active'); } // Complexity: Time O(n) | Space O(1) β€” n = number of nav buttons function initNavigation() { document.querySelectorAll('.nav-btn').forEach(btn => { btn.addEventListener('click', () => { switchPanel(btn.dataset.panel); closeSidebar(); }); }); document.querySelectorAll('.bottom-nav-btn[data-panel]').forEach(btn => { btn.addEventListener('click', () => switchPanel(btn.dataset.panel)); }); document.getElementById('btn-menu')?.addEventListener('click', openSidebar); document.getElementById('btn-close-sidebar')?.addEventListener('click', closeSidebar); document.getElementById('sidebar-overlay')?.addEventListener('click', closeSidebar); document.getElementById('btn-bottom-settings')?.addEventListener('click', openSidebar); } // Complexity: Time O(1) | Space O(1) function initChatInput() { const input = DOM.chatInput(); const btn = DOM.btnSend(); input.addEventListener('input', () => { btn.disabled = !input.value.trim(); input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 120) + 'px'; }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (input.value.trim()) DOM.chatForm().requestSubmit(); } }); DOM.chatForm().addEventListener('submit', (e) => { e.preventDefault(); const question = input.value.trim(); if (!question) return; input.value = ''; input.style.height = 'auto'; btn.disabled = true; sendMessage(question); }); DOM.btnClear().addEventListener('click', clearChat); } // Complexity: Time O(1) | Space O(1) function hideWelcome() { const welcome = DOM.welcome(); if (welcome) welcome.classList.add('hidden'); } function clearChat() { DOM.messages().innerHTML = ''; chatHistory.length = 0; const welcome = DOM.welcome(); if (welcome) welcome.classList.remove('hidden'); } function appendMessage(role, content) { hideWelcome(); const container = DOM.messages(); const avatarLabel = role === 'user' ? 'You' : 'AI'; const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; msgEl.innerHTML = `
${avatarLabel === 'You' ? 'πŸ‘€' : 'πŸ›οΈ'}
${escapeHtml(content)}
`; container.appendChild(msgEl); scrollToBottom(); return msgEl; } function createStreamingMessage() { hideWelcome(); const container = DOM.messages(); const msgEl = document.createElement('div'); msgEl.className = 'message assistant'; msgEl.innerHTML = `
πŸ›οΈ
`; container.appendChild(msgEl); scrollToBottom(); return msgEl; } function scrollToBottom() { const container = DOM.chatContainer(); requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } // Complexity: Time O(n) | Space O(n) β€” n = response tokens async function sendMessage(question) { appendMessage('user', question); chatHistory.push({ role: 'user', content: question }); const msgEl = createStreamingMessage(); const bubble = msgEl.querySelector('.msg-bubble'); const body = msgEl.querySelector('.msg-body'); const lang = DOM.langSelect().value; const historySlice = chatHistory.slice(-10); try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, language: lang, history: historySlice }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullText = ''; let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; const raw = line.slice(6).trim(); if (!raw) continue; try { const data = JSON.parse(raw); if (data.type === 'token') { fullText += data.content; bubble.textContent = fullText; scrollToBottom(); } if (data.type === 'done' && data.citations?.length) { const citEl = document.createElement('div'); citEl.className = 'msg-citations'; citEl.innerHTML = `Sources` + data.citations.map(c => `
${escapeHtml(c.source)}, Page ${c.page}
` ).join(''); body.appendChild(citEl); } if (data.type === 'error') { bubble.textContent = `Error: ${data.content}`; bubble.style.color = 'oklch(0.55 0.20 25)'; } } catch { // skip malformed JSON chunks } } } if (!fullText) { bubble.textContent = 'No response received.'; } else { chatHistory.push({ role: 'assistant', content: fullText }); } } catch (err) { bubble.textContent = `Connection error: ${err.message}`; bubble.style.color = 'oklch(0.55 0.20 25)'; } scrollToBottom(); DOM.btnSend().disabled = !DOM.chatInput().value.trim(); } function sendHint(el) { const text = el.textContent; DOM.chatInput().value = text; DOM.btnSend().disabled = false; DOM.chatForm().requestSubmit(); } // Complexity: Time O(f) | Space O(f) β€” f = file count function initUpload() { const zone = DOM.dropZone(); const input = DOM.fileInput(); zone.addEventListener('click', () => input.click()); zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); }); zone.addEventListener('dragleave', () => { zone.classList.remove('dragover'); }); zone.addEventListener('drop', (e) => { e.preventDefault(); zone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); }); input.addEventListener('change', () => { if (input.files.length) handleFiles(input.files); input.value = ''; }); } async function handleFiles(fileList) { const files = Array.from(fileList).filter(f => f.name.toLowerCase().endsWith('.pdf')); if (!files.length) return; for (const file of files) { const item = addUploadItem(file.name, 'Uploading…', 'pending'); const formData = new FormData(); formData.append('file', file); try { const res = await fetch('/api/upload', { method: 'POST', body: formData, }); const data = await res.json(); if (!res.ok) { updateUploadItem(item, data.error || 'Upload failed', 'error'); } else if (data.skipped) { updateUploadItem(item, 'Already ingested', 'success'); } else { updateUploadItem(item, `${data.pages} pages β†’ ${data.chunks} chunks`, 'success'); } } catch (err) { updateUploadItem(item, err.message, 'error'); } } loadStats(); } function addUploadItem(filename, detail, status) { const log = DOM.uploadLog(); const icons = { pending: '⏳', success: 'βœ…', error: '❌' }; const el = document.createElement('div'); el.className = `upload-item ${status}`; el.innerHTML = `
${icons[status]}
${escapeHtml(filename)}
${escapeHtml(detail)}
`; log.prepend(el); return el; } function updateUploadItem(el, detail, status) { const icons = { pending: '⏳', success: 'βœ…', error: '❌' }; el.className = `upload-item ${status}`; el.querySelector('.upload-status').textContent = icons[status]; el.querySelector('.upload-detail').textContent = detail; } async function loadStats() { try { const res = await fetch('/api/stats'); const data = await res.json(); DOM.statsMini().innerHTML = `πŸ“Š ${data.total_vectors} vectors
🧠 ${data.embedding_model.split('/').pop()}`; } catch { DOM.statsMini().textContent = ''; } } function escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, c => map[c]); } // ── Init ── document.addEventListener('DOMContentLoaded', () => { initNavigation(); initChatInput(); initUpload(); loadStats(); }); // Expose for inline onclick window.sendHint = sendHint;