// 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' ? 'π€' : 'ποΈ'}
`;
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;