/* ═══════════════════════════════════════════════════════════════════════════ AI SQL Analyst — Chat Interface ═══════════════════════════════════════════════════════════════════════════ */ (function () { "use strict"; // ── DOM refs ─────────────────────────────────────────────────────────── const questionInput = document.getElementById("questionInput"); const submitBtn = document.getElementById("submitBtn"); const chatThread = document.getElementById("chatThread"); const welcomeState = document.getElementById("welcomeState"); const sidebar = document.getElementById("sidebar"); const sidebarList = document.getElementById("sidebarList"); const sidebarToggle = document.getElementById("sidebarToggle"); const newChatBtn = document.getElementById("newChatBtn"); const modelSwitcher = document.getElementById("modelSwitcher"); const topbarTitle = document.getElementById("topbarTitle"); let selectedProvider = "groq"; let isLoading = false; // ── Theme ────────────────────────────────────────────────────────────── const themeSwitcher = document.getElementById("themeSwitcher"); function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("sqlbot_theme", theme); themeSwitcher.querySelectorAll(".switcher-btn").forEach(b => { b.classList.toggle("active", b.dataset.theme === theme); }); } // Apply saved or system preference on load const savedTheme = localStorage.getItem("sqlbot_theme") || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); applyTheme(savedTheme); themeSwitcher.addEventListener("click", e => { const btn = e.target.closest(".switcher-btn"); if (btn) applyTheme(btn.dataset.theme); }); // ── Conversation management ──────────────────────────────────────────── // Each conversation: { id, title, created_at } function getConversations() { try { return JSON.parse(localStorage.getItem("sqlbot_conversations") || "[]"); } catch { return []; } } function saveConversations(list) { localStorage.setItem("sqlbot_conversations", JSON.stringify(list)); } let currentConvId = localStorage.getItem("sqlbot_conversation_id") || newConvId(); function newConvId() { return (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : "conv-" + Date.now().toString(36); } function setCurrentConv(id) { currentConvId = id; localStorage.setItem("sqlbot_conversation_id", id); } function addConversationToList(id, title) { const list = getConversations(); if (!list.find(c => c.id === id)) { list.unshift({ id, title, created_at: new Date().toISOString() }); saveConversations(list); } renderSidebarList(); } function updateConversationTitle(id, title) { const list = getConversations(); const conv = list.find(c => c.id === id); if (conv && conv.title !== title) { conv.title = title; saveConversations(list); renderSidebarList(); } } // ── Sidebar ──────────────────────────────────────────────────────────── let sidebarOpen = true; function setSidebar(open) { sidebarOpen = open; sidebar.classList.toggle("collapsed", !open); } sidebarToggle.addEventListener("click", () => setSidebar(!sidebarOpen)); function renderSidebarList() { const list = getConversations(); if (!list.length) { sidebarList.innerHTML = ''; return; } sidebarList.innerHTML = ""; list.forEach(conv => { const item = document.createElement("button"); item.className = "sidebar-item" + (conv.id === currentConvId ? " active" : ""); item.innerHTML = ` `; item.addEventListener("click", e => { if (e.target.closest(".sidebar-delete-btn")) return; loadConversation(conv.id, conv.title); }); item.querySelector(".sidebar-delete-btn").addEventListener("click", async e => { e.stopPropagation(); await deleteConversation(conv.id); }); sidebarList.appendChild(item); }); } async function deleteConversation(id) { // Delete all turns for this conversation from DB try { const res = await fetch(`/history?conversation_id=${encodeURIComponent(id)}`); if (res.ok) { const turns = await res.json(); for (const t of turns) { await fetch(`/history/${t.id}`, { method: "DELETE" }); } } } catch (_) {} // Remove from localStorage const list = getConversations().filter(c => c.id !== id); saveConversations(list); // If we deleted the active one, start a new chat if (id === currentConvId) { startNewChat(); } else { renderSidebarList(); } } async function loadConversation(id, title) { setCurrentConv(id); topbarTitle.textContent = title || "Chat"; clearChatThread(); try { const res = await fetch(`/history?conversation_id=${encodeURIComponent(id)}`); if (!res.ok) return; const turns = await res.json(); if (turns.length > 0) { hideWelcome(); turns.forEach(t => appendTurn(t.question, t.answer, t.sql_query, t.query_result)); } else { showWelcome(); } } catch (_) { showWelcome(); } renderSidebarList(); scrollToBottom(); } // ── New Chat ─────────────────────────────────────────────────────────── newChatBtn.addEventListener("click", startNewChat); function startNewChat() { const id = newConvId(); setCurrentConv(id); topbarTitle.textContent = "New Chat"; clearChatThread(); showWelcome(); questionInput.value = ""; questionInput.style.height = ""; renderSidebarList(); } // ── Welcome chips ────────────────────────────────────────────────────── document.querySelectorAll(".chip").forEach(chip => { chip.addEventListener("click", () => { questionInput.value = chip.dataset.q; questionInput.dispatchEvent(new Event("input")); handleSubmit(); }); }); // ── Model switcher ───────────────────────────────────────────────────── modelSwitcher.addEventListener("click", e => { const btn = e.target.closest(".switcher-btn"); if (!btn) return; modelSwitcher.querySelectorAll(".switcher-btn").forEach(b => b.classList.remove("active")); btn.classList.add("active"); selectedProvider = btn.dataset.provider; }); // ── Auto-resize textarea ─────────────────────────────────────────────── questionInput.addEventListener("input", () => { questionInput.style.height = "auto"; questionInput.style.height = Math.min(questionInput.scrollHeight, 160) + "px"; }); // ── Submit ───────────────────────────────────────────────────────────── submitBtn.addEventListener("click", handleSubmit); questionInput.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }); async function handleSubmit() { const question = questionInput.value.trim(); if (!question || isLoading) return; isLoading = true; submitBtn.disabled = true; // Hide welcome, show user message immediately hideWelcome(); appendUserMessage(question); questionInput.value = ""; questionInput.style.height = ""; scrollToBottom(); // Show typing indicator const typingEl = appendTypingIndicator(); scrollToBottom(); try { const res = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question, provider: selectedProvider, conversation_id: currentConvId, }), }); typingEl.remove(); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); appendErrorMessage(err.detail || `HTTP ${res.status}`); } else { const data = await res.json(); appendAIMessage(data); // Update sidebar: first question becomes the conversation title const convs = getConversations(); if (!convs.find(c => c.id === currentConvId)) { addConversationToList(currentConvId, question); topbarTitle.textContent = question.length > 40 ? question.slice(0, 40) + "…" : question; } } } catch (err) { typingEl.remove(); appendErrorMessage(err.message || "Something went wrong. Please try again."); } isLoading = false; submitBtn.disabled = false; scrollToBottom(); } // ── Chat rendering helpers ───────────────────────────────────────────── function appendUserMessage(text) { const el = document.createElement("div"); el.className = "msg msg-user"; el.innerHTML = `
${escapeHtml(text)}
`; chatThread.appendChild(el); } function appendTypingIndicator() { const el = document.createElement("div"); el.className = "msg msg-ai"; el.innerHTML = `
`; chatThread.appendChild(el); return el; } function appendAIMessage(data) { const el = document.createElement("div"); el.className = "msg msg-ai"; const hasData = data.data && data.data.length > 0; const hasSql = !!data.sql; const hasAnswer = !!data.answer; const rowLabel = hasData ? `${data.data.length} row${data.data.length !== 1 ? "s" : ""}` : "0 rows"; el.innerHTML = `
${hasAnswer ? `
${escapeHtml(data.answer)}
` : ""} ${hasSql ? `
${escapeHtml(data.sql)}
` : ""} ${hasData ? `
${buildTable(data.data)}
` : ""} ${data.insights ? `
${escapeHtml(data.insights)}
` : ""}
`; // Wire up section toggles // SQL and Results are open by default; Insights is collapsed el.querySelectorAll(".section-toggle").forEach(btn => { const targetId = btn.dataset.target; const body = el.querySelector(`#${targetId}`); if (!body) return; const isInsights = targetId.startsWith("ins-"); if (isInsights) { body.classList.add("collapsed"); } else { btn.classList.add("open"); // chevron rotated = open } btn.addEventListener("click", () => { body.classList.toggle("collapsed"); btn.classList.toggle("open"); }); }); chatThread.appendChild(el); } function appendErrorMessage(msg) { const el = document.createElement("div"); el.className = "msg msg-ai"; el.innerHTML = `
${escapeHtml(msg)}
`; chatThread.appendChild(el); } function appendTurn(question, answer, sql, queryResult) { appendUserMessage(question); appendAIMessage({ answer, sql: sql || "", data: queryResult || [], insights: "", }); } // ── Table builder ────────────────────────────────────────────────────── function buildTable(rows) { if (!rows || !rows.length) return '

No data returned.

'; const cols = Object.keys(rows[0]); const display = rows.slice(0, 200); let html = ""; cols.forEach(c => { html += ``; }); html += ""; display.forEach(row => { html += ""; cols.forEach(c => { const v = row[c]; html += ``; }); html += ""; }); html += "
${escapeHtml(c)}
${escapeHtml(v === null || v === undefined ? "NULL" : String(v))}
"; if (rows.length > 200) { html += `

Showing 200 of ${rows.length} rows

`; } return html; } // ── Helpers ──────────────────────────────────────────────────────────── function showWelcome() { welcomeState.classList.remove("hidden"); } function hideWelcome() { welcomeState.classList.add("hidden"); } function clearChatThread() { // Remove all msg elements, keep welcome state chatThread.querySelectorAll(".msg").forEach(e => e.remove()); } function scrollToBottom() { chatThread.scrollTop = chatThread.scrollHeight; } function formatDate(iso) { if (!iso) return ""; const d = new Date(iso); const now = new Date(); if (d.toDateString() === now.toDateString()) { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } return d.toLocaleDateString([], { month: "short", day: "numeric" }); } function escapeHtml(str) { const d = document.createElement("div"); d.appendChild(document.createTextNode(String(str))); return d.innerHTML; } // ── Startup: load current conversation ──────────────────────────────── (async function init() { renderSidebarList(); const convs = getConversations(); const existing = convs.find(c => c.id === currentConvId); if (existing) { topbarTitle.textContent = existing.title || "Chat"; try { const res = await fetch(`/history?conversation_id=${encodeURIComponent(currentConvId)}`); if (res.ok) { const turns = await res.json(); if (turns.length > 0) { hideWelcome(); turns.forEach(t => appendTurn(t.question, t.answer, t.sql_query, t.query_result)); scrollToBottom(); } } } catch (_) {} } else { showWelcome(); } })(); })();