Spaces:
Sleeping
Sleeping
| // 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 = ` | |
| <div class="msg-avatar">${avatarLabel === 'You' ? '👤' : '🏛️'}</div> | |
| <div class="msg-body"> | |
| <div class="msg-bubble">${escapeHtml(content)}</div> | |
| </div> | |
| `; | |
| container.appendChild(msgEl); | |
| scrollToBottom(); | |
| return msgEl; | |
| } | |
| function createStreamingMessage() { | |
| hideWelcome(); | |
| const container = DOM.messages(); | |
| const msgEl = document.createElement('div'); | |
| msgEl.className = 'message assistant'; | |
| msgEl.innerHTML = ` | |
| <div class="msg-avatar">🏛️</div> | |
| <div class="msg-body"> | |
| <div class="msg-bubble"> | |
| <div class="typing-indicator"> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| <div class="typing-dot"></div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| 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 = `<strong>Sources</strong>` + | |
| data.citations.map(c => | |
| `<div class="msg-citation-item">${escapeHtml(c.source)}, Page ${c.page}</div>` | |
| ).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 = ` | |
| <div class="upload-status">${icons[status]}</div> | |
| <div class="upload-info"> | |
| <div class="upload-filename">${escapeHtml(filename)}</div> | |
| <div class="upload-detail">${escapeHtml(detail)}</div> | |
| </div> | |
| `; | |
| 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<br>🧠 ${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; | |