/** * StackLogix Chatbot — Frontend Logic with Streaming SSE */ // --- Config --- const API_BASE = window.location.origin; let sessionId = generateSessionId(); let isStreaming = false; // --- DOM Elements --- const messagesContainer = document.getElementById('messagesContainer'); const messagesList = document.getElementById('messagesList'); const welcomeScreen = document.getElementById('welcomeScreen'); const userInput = document.getElementById('userInput'); const sendBtn = document.getElementById('sendBtn'); const newChatBtn = document.getElementById('newChatBtn'); const ingestBtn = document.getElementById('ingestBtn'); const statusDot = document.getElementById('statusDot'); const docsCount = document.getElementById('docsCount'); const sessionDisplay = document.getElementById('sessionDisplay'); // --- Init --- document.addEventListener('DOMContentLoaded', () => { sessionDisplay.textContent = sessionId.slice(0, 12) + '...'; checkHealth(); fetchCollectionInfo(); setupEventListeners(); }); // --- Event Listeners --- function setupEventListeners() { sendBtn.addEventListener('click', sendMessage); userInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); userInput.addEventListener('input', () => { userInput.style.height = 'auto'; userInput.style.height = Math.min(userInput.scrollHeight, 120) + 'px'; sendBtn.disabled = !userInput.value.trim() || isStreaming; }); newChatBtn.addEventListener('click', startNewChat); ingestBtn.addEventListener('click', triggerIngest); document.querySelectorAll('.prompt-chip, .suggestion-card').forEach(btn => { btn.addEventListener('click', () => { const prompt = btn.getAttribute('data-prompt'); if (prompt && !isStreaming) { userInput.value = prompt; sendBtn.disabled = false; sendMessage(); } }); }); } // --- Core: Streaming Chat --- async function sendMessage() { const message = userInput.value.trim(); if (!message || isStreaming) return; // Hide welcome screen welcomeScreen.classList.add('hidden'); // Clear input userInput.value = ''; userInput.style.height = 'auto'; sendBtn.disabled = true; isStreaming = true; // Add user message appendUserMessage(message); // Show typing indicator const typingEl = showTypingIndicator(); try { const response = await fetch(`${API_BASE}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, user_message: message, }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); // Remove typing indicator, create assistant message bubble typingEl.remove(); const { bubbleEl, contentEl, messageDiv } = createAssistantMessage(); const reader = response.body.getReader(); const decoder = new TextDecoder(); let fullText = ''; let sources = null; let followUp = null; let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Process complete SSE lines const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep incomplete line in buffer for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const event = JSON.parse(line.slice(6)); if (event.type === 'token') { fullText += event.content; contentEl.innerHTML = formatContent(fullText); scrollToBottom(); } else if (event.type === 'sources') { sources = event.sources; } else if (event.type === 'follow_up') { followUp = event.content; } else if (event.type === 'error') { fullText = event.content; contentEl.innerHTML = formatContent(fullText); } else if (event.type === 'done') { // Append sources below the bubble if (sources && sources.length > 0) { const sourcesEl = createSourcesElement(sources); messageDiv.querySelector('.message-content').appendChild(sourcesEl); } // Append follow-up question if (followUp) { const followUpEl = createFollowUpElement(followUp); messageDiv.querySelector('.message-content').appendChild(followUpEl); } // Add timestamp const timeEl = document.createElement('div'); timeEl.classList.add('message-time'); timeEl.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); messageDiv.querySelector('.message-content').appendChild(timeEl); scrollToBottom(); } } catch (e) { // Skip malformed JSON } } } } catch (error) { console.error('Chat error:', error); typingEl?.remove(); appendSimpleAssistantMessage('Sorry, something went wrong. Please try again.'); showToast('Failed to get response. Check if the server is running.', 'error'); } finally { isStreaming = false; sendBtn.disabled = !userInput.value.trim(); } } // --- Message Rendering --- function appendUserMessage(content) { const messageDiv = document.createElement('div'); messageDiv.classList.add('message', 'user'); const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); messageDiv.innerHTML = `
U

${escapeHtml(content)}

${time}
`; messagesList.appendChild(messageDiv); scrollToBottom(); } function createAssistantMessage() { const messageDiv = document.createElement('div'); messageDiv.classList.add('message', 'assistant'); const bubbleEl = document.createElement('div'); bubbleEl.classList.add('message-bubble'); const contentEl = document.createElement('div'); contentEl.classList.add('streaming-content'); contentEl.innerHTML = ''; bubbleEl.appendChild(contentEl); messageDiv.innerHTML = `
S
`; messageDiv.querySelector('.message-content').appendChild(bubbleEl); messagesList.appendChild(messageDiv); scrollToBottom(); return { bubbleEl, contentEl, messageDiv }; } function appendSimpleAssistantMessage(content) { const messageDiv = document.createElement('div'); messageDiv.classList.add('message', 'assistant'); const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); messageDiv.innerHTML = `
S

${escapeHtml(content)}

${time}
`; messagesList.appendChild(messageDiv); scrollToBottom(); } function createSourcesElement(sources) { const el = document.createElement('div'); el.classList.add('sources-container'); let html = '
Sources
'; html += '
'; for (const src of sources) { const scorePercent = Math.round(src.score * 100); const scoreClass = scorePercent >= 60 ? 'high' : scorePercent >= 40 ? 'medium' : 'low'; html += `
${escapeHtml(src.file)} ${escapeHtml(src.folder)} ${scorePercent}%
`; } html += '
'; el.innerHTML = html; return el; } function createFollowUpElement(question) { const el = document.createElement('div'); el.classList.add('follow-up'); el.setAttribute('data-question', question); el.onclick = function () { askFollowUp(this); }; el.innerHTML = `
Suggested Follow-up
${escapeHtml(question)}
`; return el; } function formatContent(text) { let html = escapeHtml(text); // Bold: **text** html = html.replace(/\*\*(.*?)\*\*/g, '$1'); // Bullet points html = html.replace(/^[-•]\s+(.+)$/gm, '
  • $1
  • '); html = html.replace(/(
  • .*<\/li>)/s, ''); // Numbered lists html = html.replace(/^\d+\.\s+(.+)$/gm, '
  • $1
  • '); // Paragraphs html = html.replace(/\n\n/g, '

    '); html = html.replace(/\n/g, '
    '); if (!html.startsWith('<')) { html = '

    ' + html + '

    '; } return html; } function showTypingIndicator() { const typing = document.createElement('div'); typing.classList.add('typing-indicator'); typing.innerHTML = `
    S
    `; messagesList.appendChild(typing); scrollToBottom(); return typing; } function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; } // --- Follow-up --- function askFollowUp(el) { const question = el.getAttribute('data-question'); if (question && !isStreaming) { userInput.value = question; sendBtn.disabled = false; sendMessage(); } } window.askFollowUp = askFollowUp; // --- New Chat --- function startNewChat() { if (isStreaming) return; sessionId = generateSessionId(); sessionDisplay.textContent = sessionId.slice(0, 12) + '...'; messagesList.innerHTML = ''; welcomeScreen.classList.remove('hidden'); showToast('New conversation started', 'info'); } // --- Ingest --- async function triggerIngest() { if (ingestBtn.classList.contains('loading')) return; ingestBtn.classList.add('loading'); ingestBtn.textContent = 'Ingesting...'; showToast('Document ingestion started. This may take a minute...', 'info'); try { const response = await fetch(`${API_BASE}/ingest`, { method: 'POST' }); const data = await response.json(); if (response.ok) { showToast('Documents ingested successfully!', 'success'); fetchCollectionInfo(); } else { showToast(`Ingestion failed: ${data.detail}`, 'error'); } } catch (error) { showToast('Failed to trigger ingestion. Is the server running?', 'error'); } finally { ingestBtn.classList.remove('loading'); ingestBtn.innerHTML = ` Re-ingest Documents `; } } // --- Health & Info --- async function checkHealth() { try { const response = await fetch(`${API_BASE}/health`); if (response.ok) { statusDot.textContent = 'Online'; statusDot.classList.add('online'); } else { throw new Error(); } } catch { statusDot.textContent = 'Offline'; statusDot.classList.add('offline'); } } async function fetchCollectionInfo() { try { const response = await fetch(`${API_BASE}/collection-info`); const data = await response.json(); if (data.points_count !== undefined) { docsCount.textContent = `${data.points_count} chunks`; } else { docsCount.textContent = 'Not loaded'; } } catch { docsCount.textContent = '—'; } } // --- Utilities --- function generateSessionId() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function showToast(message, type = 'info') { const toast = document.createElement('div'); toast.classList.add('toast', type); toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.classList.add('hiding'); setTimeout(() => toast.remove(), 300); }, 4000); }