/* Antern Bot embeddable chat widget. * * Add to any page: * * * The widget renders a floating button + chat panel. It calls * `${data-api}/api/chat` and keeps a session id in localStorage. */ (function () { var script = document.currentScript; // data-api is the bot's base URL. Empty/omitted => same origin as this page // (only works when the page is served by the bot itself, e.g. the demo). var API = (script.getAttribute("data-api") || "").replace(/\/$/, ""); var TITLE = script.getAttribute("data-title") || "Antern Bot"; var ACCENT = script.getAttribute("data-accent") || "#4f8cff"; var GREETING = script.getAttribute("data-greeting") || "👋 Hi! I'm Antern Bot, your data assistant.\n\nAsk me anything about your recruitment data — candidates, requirements, applications, interviews — in plain English.\n\nTry: “How many active candidates do we have?”"; var STORAGE_KEY = "antern_bot_session"; if (!script.hasAttribute("data-api")) { console.warn( "[antern-bot] No data-api attribute set; calling same origin. " + "When embedding on another site, set data-api to your bot's URL." ); } // --- Host element + shadow root (style isolation) --- var host = document.createElement("div"); host.id = "antern-bot-widget"; document.body.appendChild(host); var root = host.attachShadow({ mode: "open" }); root.innerHTML = '' + '' + ''; var launcher = root.querySelector(".launcher"); var panel = root.querySelector(".panel"); var closeBtn = root.querySelector(".close"); var body = root.querySelector(".body"); var input = root.querySelector("textarea"); var sendBtn = root.querySelector(".send"); var statsEl = root.querySelector(".stats"); var sessionId = localStorage.getItem(STORAGE_KEY) || null; var greeted = false; var PALETTE = ["#4f8cff","#8a5cff","#38d39f","#ffb020","#ff6b6b","#22b8cf","#f783ac","#a0d911"]; var chartJsPromise = null; function loadChartJs() { if (window.Chart) return Promise.resolve(window.Chart); if (chartJsPromise) return chartJsPromise; chartJsPromise = new Promise(function (resolve, reject) { var s = document.createElement("script"); s.src = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"; s.onload = function () { resolve(window.Chart); }; s.onerror = function () { reject(new Error("chart lib failed")); }; document.head.appendChild(s); }); return chartJsPromise; } function renderTable(container, p) { var cols = p.columns || [], rows = p.rows || []; if (p.title) { var c = document.createElement("div"); c.className = "cap"; c.textContent = p.title; container.appendChild(c); } var wrap = document.createElement("div"); wrap.className = "tbl-wrap"; var t = document.createElement("table"); t.className = "tbl"; var thead = document.createElement("thead"), trh = document.createElement("tr"); cols.forEach(function (col) { var th = document.createElement("th"); th.textContent = col; trh.appendChild(th); }); thead.appendChild(trh); t.appendChild(thead); var tb = document.createElement("tbody"); rows.forEach(function (r) { var tr = document.createElement("tr"); r.forEach(function (v) { var td = document.createElement("td"); td.textContent = (v === null || v === undefined) ? "" : v; tr.appendChild(td); }); tb.appendChild(tr); }); t.appendChild(tb); wrap.appendChild(t); container.appendChild(wrap); if (p.truncated) { var n = document.createElement("div"); n.className = "cap"; n.textContent = "(showing first " + rows.length + " rows)"; container.appendChild(n); } } function renderChart(container, p) { var cols = p.columns || [], rows = p.rows || []; var xi = cols.indexOf(p.x_field); var yIdx = (p.y_fields || []).map(function (f) { return cols.indexOf(f); }).filter(function (i) { return i >= 0; }); if (xi < 0 || yIdx.length === 0) { renderTable(container, p); return; } // fall back if (p.title) { var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = p.title; container.appendChild(cap); } var wrap = document.createElement("div"); wrap.className = "chart-wrap"; var canvas = document.createElement("canvas"); wrap.appendChild(canvas); container.appendChild(wrap); var labels = rows.map(function (r) { return r[xi]; }); loadChartJs().then(function (Chart) { if (p.format === "pie") { var fi = yIdx[0]; new Chart(canvas, { type: "pie", data: { labels: labels, datasets: [{ data: rows.map(function (r) { return Number(r[fi]) || 0; }), backgroundColor: labels.map(function (_, i) { return PALETTE[i % PALETTE.length]; }) }] }, options: { responsive: true, plugins: { legend: { position: "right" } } }, }); } else { var datasets = yIdx.map(function (ci, k) { return { label: cols[ci], data: rows.map(function (r) { return Number(r[ci]) || 0; }), backgroundColor: PALETTE[k % PALETTE.length], borderColor: PALETTE[k % PALETTE.length], fill: false, tension: 0.25 }; }); new Chart(canvas, { type: p.format === "line" ? "line" : "bar", data: { labels: labels, datasets: datasets }, options: { responsive: true, plugins: { legend: { display: yIdx.length > 1 } }, scales: { y: { beginAtZero: true } } }, }); } body.scrollTop = body.scrollHeight; }).catch(function () { var e = document.createElement("div"); e.className = "cap"; e.textContent = "(couldn't load chart — showing table)"; container.appendChild(e); renderTable(container, p); }); } function renderPresentation(bubble, p) { if (!p || !p.format || p.format === "text") return; var box = document.createElement("div"); bubble.appendChild(box); if (p.format === "table") renderTable(box, p); else renderChart(box, p); } var STATS_KEY = "antern_bot_daystats"; var lastTokens = null; function _today() { return new Date().toISOString().slice(0, 10); } function getStats() { var s; try { s = JSON.parse(localStorage.getItem(STATS_KEY) || "{}"); } catch (e) { s = {}; } if (s.date !== _today()) s = { date: _today(), chats: 0, cost: 0 }; return s; } function bumpStats(cost) { var s = getStats(); s.chats += 1; s.cost += (cost || 0); localStorage.setItem(STATS_KEY, JSON.stringify(s)); } function renderStats() { var s = getStats(); var avg = s.chats ? s.cost / s.chats : 0; var parts = ["Today: " + s.chats + " chat" + (s.chats === 1 ? "" : "s"), "avg $" + avg.toFixed(4) + "/chat"]; if (lastTokens != null) parts.push("last: " + lastTokens.toLocaleString() + " tokens"); statsEl.textContent = parts.join(" · "); } function esc(s) { return (s == null ? "" : String(s)).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } function addMsg(role, text) { var m = document.createElement("div"); m.className = "msg " + role; m.innerHTML = '
' + (role === "user" ? "You" : "A") + "
" + '
'; m.querySelector(".bubble").textContent = text; body.appendChild(m); body.scrollTop = body.scrollHeight; return m; } function setOpen(open) { panel.classList.toggle("open", open); launcher.classList.toggle("open", open); launcher.setAttribute("aria-label", open ? "Close chat" : "Open chat"); if (open && !greeted) { addMsg("bot", GREETING); greeted = true; } if (open) { renderStats(); input.focus(); } } async function send(text) { addMsg("user", text); sendBtn.disabled = true; var m = addMsg("bot", ""); var bubble = m.querySelector(".bubble"); bubble.innerHTML = 'Thinking…'; try { var r = await fetch(API + "/api/chat", { method: "POST", headers: { "Content-Type": "application/json", // Skip ngrok's free-tier browser-warning interstitial on API calls. // Harmless on other hosts. "ngrok-skip-browser-warning": "true", }, body: JSON.stringify({ message: text, session_id: sessionId }), }); var d = await r.json(); if (!r.ok) { bubble.innerHTML = '⚠ ' + esc(d.error || "Error") + ""; } else { sessionId = d.session_id; localStorage.setItem(STORAGE_KEY, sessionId); bubble.textContent = d.answer || "(no answer)"; renderPresentation(bubble, d.presentation); if (typeof d.cost_usd === "number") bumpStats(d.cost_usd); if (typeof d.tokens === "number") lastTokens = d.tokens; renderStats(); } } catch (e) { bubble.innerHTML = '⚠ Network error'; } finally { sendBtn.disabled = false; body.scrollTop = body.scrollHeight; } } function submit() { var t = input.value.trim(); if (!t) return; input.value = ""; input.style.height = "auto"; send(t); } launcher.addEventListener("click", function () { setOpen(!panel.classList.contains("open")); }); closeBtn.addEventListener("click", function () { setOpen(false); }); sendBtn.addEventListener("click", submit); input.addEventListener("keydown", function (e) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } }); input.addEventListener("input", function () { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 120) + "px"; }); renderStats(); })();