Spaces:
Running
Running
| /** | |
| * 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 = ` | |
| <div class="message-avatar">U</div> | |
| <div class="message-content"> | |
| <div class="message-bubble"><p>${escapeHtml(content)}</p></div> | |
| <div class="message-time">${time}</div> | |
| </div> | |
| `; | |
| 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 = '<span class="cursor-blink"></span>'; | |
| bubbleEl.appendChild(contentEl); | |
| messageDiv.innerHTML = ` | |
| <div class="message-avatar">S</div> | |
| <div class="message-content"></div> | |
| `; | |
| 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 = ` | |
| <div class="message-avatar">S</div> | |
| <div class="message-content"> | |
| <div class="message-bubble"><p>${escapeHtml(content)}</p></div> | |
| <div class="message-time">${time}</div> | |
| </div> | |
| `; | |
| messagesList.appendChild(messageDiv); | |
| scrollToBottom(); | |
| } | |
| function createSourcesElement(sources) { | |
| const el = document.createElement('div'); | |
| el.classList.add('sources-container'); | |
| let html = '<div class="sources-header"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg> Sources</div>'; | |
| html += '<div class="sources-list">'; | |
| for (const src of sources) { | |
| const scorePercent = Math.round(src.score * 100); | |
| const scoreClass = scorePercent >= 60 ? 'high' : scorePercent >= 40 ? 'medium' : 'low'; | |
| html += ` | |
| <div class="source-item"> | |
| <span class="source-file">${escapeHtml(src.file)}</span> | |
| <span class="source-folder">${escapeHtml(src.folder)}</span> | |
| <span class="source-score ${scoreClass}">${scorePercent}%</span> | |
| </div> | |
| `; | |
| } | |
| html += '</div>'; | |
| 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 = ` | |
| <div class="follow-up-label">Suggested Follow-up</div> | |
| <div class="follow-up-text">${escapeHtml(question)}</div> | |
| `; | |
| return el; | |
| } | |
| function formatContent(text) { | |
| let html = escapeHtml(text); | |
| // Bold: **text** | |
| html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); | |
| // Bullet points | |
| html = html.replace(/^[-•]\s+(.+)$/gm, '<li>$1</li>'); | |
| html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>'); | |
| // Numbered lists | |
| html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>'); | |
| // Paragraphs | |
| html = html.replace(/\n\n/g, '</p><p>'); | |
| html = html.replace(/\n/g, '<br>'); | |
| if (!html.startsWith('<')) { | |
| html = '<p>' + html + '</p>'; | |
| } | |
| return html; | |
| } | |
| function showTypingIndicator() { | |
| const typing = document.createElement('div'); | |
| typing.classList.add('typing-indicator'); | |
| typing.innerHTML = ` | |
| <div class="message-avatar">S</div> | |
| <div class="typing-dots"> | |
| <span></span><span></span><span></span> | |
| </div> | |
| `; | |
| 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 = ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> | |
| <polyline points="17 8 12 3 7 8"></polyline> | |
| <line x1="12" y1="3" x2="12" y2="15"></line> | |
| </svg> | |
| 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); | |
| } | |