/* 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 =
'' +
'' +
'
' +
'
A
' + TITLE + '
' +
' ' +
' ' +
' ' +
'
';
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();
})();