stacklogix / static /script.js
Deploy Bot
Deployment commit
6ca2339
/**
* 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);
}