Spaces:
Sleeping
Sleeping
| <!-- ====================================================================== | |
| ANTERN BOT — embeddable chat widget (inline build for ngrok free tier) | |
| HOW TO USE: | |
| Copy the entire <script>...</script> block below and paste it just | |
| before the closing </body> tag on any page of your website. | |
| Why inline? ngrok's free tier shows a browser-warning page on a normal | |
| <script src="...ngrok...">, so the widget code is inlined here instead. | |
| The only call that touches ngrok is the chat API request, which sends the | |
| "ngrok-skip-browser-warning" header and works fine. (Chart.js, when a chart | |
| is shown, loads from the jsDelivr CDN — not from ngrok.) | |
| To change the bot URL, title, colour, or greeting, edit the four CONFIG | |
| lines at the top of the script. | |
| ====================================================================== --> | |
| <script> | |
| (function () { | |
| /* ---- CONFIG (edit these) ---- */ | |
| var API = "https://duckling-bovine-blitz.ngrok-free.dev"; // your stable bot URL | |
| var TITLE = "Antern Bot"; | |
| var ACCENT = "#4f8cff"; | |
| var 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"; | |
| API = API.replace(/\/$/, ""); | |
| var host = document.createElement("div"); | |
| host.id = "antern-bot-widget"; | |
| document.body.appendChild(host); | |
| var root = host.attachShadow({ mode: "open" }); | |
| root.innerHTML = | |
| '<style>' + | |
| ':host{all:initial;}' + | |
| '*{box-sizing:border-box;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}' + | |
| '.launcher{position:fixed;bottom:24px;right:24px;width:62px;height:62px;border-radius:50%;border:none;cursor:pointer;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);color:#fff;z-index:2147483000;display:flex;align-items:center;justify-content:center;box-shadow:0 10px 28px rgba(0,0,0,.30),0 4px 12px rgba(79,140,255,.45);transition:transform .25s cubic-bezier(.2,.9,.3,1.2),box-shadow .25s;animation:antern-pop .5s cubic-bezier(.2,.9,.3,1.2);}' + | |
| '.launcher::after{content:"";position:absolute;inset:0;border-radius:50%;background:linear-gradient(135deg,' + ACCENT + ',#8a5cff);z-index:-1;animation:antern-pulse 2.6s ease-out infinite;}' + | |
| '.launcher.open::after{animation:none;opacity:0;}' + | |
| '.launcher:hover{transform:translateY(-3px) scale(1.05);box-shadow:0 16px 38px rgba(0,0,0,.34),0 6px 18px rgba(79,140,255,.55);}' + | |
| '.launcher:active{transform:translateY(-1px) scale(.97);}' + | |
| '.launcher svg{width:27px;height:27px;}' + | |
| '.launcher .ic-close{display:none;}' + | |
| '.launcher.open .ic-chat{display:none;}' + | |
| '.launcher.open .ic-close{display:block;}' + | |
| '@keyframes antern-pop{0%{transform:scale(0) rotate(-25deg);}100%{transform:scale(1) rotate(0);}}' + | |
| '@keyframes antern-pulse{0%{transform:scale(1);opacity:.45;}70%{transform:scale(1.7);opacity:0;}100%{opacity:0;}}' + | |
| '.panel{position:fixed;bottom:92px;right:22px;width:760px;max-width:calc(100vw - 32px);height:1120px;max-height:calc(100vh - 120px);background:#0f1115;border:1px solid #2a303c;border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.4);z-index:2147483000;display:none;flex-direction:column;overflow:hidden;}' + | |
| '.panel.open{display:flex;}' + | |
| '.head{background:#171a21;border-bottom:1px solid #2a303c;padding:13px 16px;display:flex;align-items:center;gap:10px;color:#e8eaed;}' + | |
| '.head .badge{width:28px;height:28px;border-radius:8px;background:' + ACCENT + ';display:flex;align-items:center;justify-content:center;font-weight:700;}' + | |
| '.head h3{margin:0;font-size:15px;flex:1;}' + | |
| '.head button{background:none;border:none;color:#9aa3b2;cursor:pointer;font-size:20px;line-height:1;}' + | |
| '.body{flex:1;overflow-y:auto;padding:16px;background:#0f1115;}' + | |
| '.msg{display:flex;gap:9px;margin-bottom:14px;}' + | |
| '.msg .av{width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;}' + | |
| '.msg.bot .av{background:' + ACCENT + ';}' + | |
| '.msg.user .av{background:#374151;}' + | |
| '.bubble{background:#232936;border:1px solid #2a303c;border-radius:11px;padding:10px 13px;color:#e8eaed;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;max-width:100%;}' + | |
| '.msg.user .bubble{background:#1f242e;}' + | |
| '.typing{color:#9aa3b2;font-style:italic;}' + | |
| '.foot{border-top:1px solid #2a303c;background:#171a21;padding:11px;display:flex;gap:8px;}' + | |
| '.foot textarea{flex:1;resize:none;background:#1f242e;color:#e8eaed;border:1px solid #2a303c;border-radius:9px;padding:9px 11px;font-size:14px;max-height:120px;}' + | |
| '.foot textarea:focus{outline:none;border-color:' + ACCENT + ';}' + | |
| '.foot button{background:' + ACCENT + ';color:#fff;border:none;border-radius:9px;padding:0 16px;font-weight:600;cursor:pointer;font-size:14px;}' + | |
| '.foot button:disabled{opacity:.5;cursor:not-allowed;}' + | |
| '.cap{font-size:11.5px;color:#9aa3b2;margin:10px 0 4px;}' + | |
| '.tbl-wrap{margin-top:8px;max-height:260px;overflow:auto;border:1px solid #2a303c;border-radius:9px;}' + | |
| 'table.tbl{border-collapse:collapse;width:100%;font-size:12.5px;}' + | |
| 'table.tbl th,table.tbl td{padding:6px 9px;border-bottom:1px solid #2a303c;text-align:left;white-space:nowrap;}' + | |
| 'table.tbl th{position:sticky;top:0;background:#1f242e;color:#cbd5e1;font-weight:600;}' + | |
| 'table.tbl td{color:#e8eaed;}' + | |
| 'table.tbl tr:hover td{background:#1a1f29;}' + | |
| '.chart-wrap{margin-top:8px;background:#fff;border-radius:9px;padding:10px;}' + | |
| '.chart-wrap canvas{max-width:100%;}' + | |
| '.stats{padding:7px 12px;background:#12151b;border-top:1px solid #2a303c;color:#9aa3b2;font-size:11px;text-align:center;}' + | |
| '</style>' + | |
| '<button class="launcher" aria-label="Open chat">' + | |
| '<svg class="ic-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.5 7.48L3 21l2.02-5.5A8.5 8.5 0 1 1 21 11.5z"/><circle cx="8.5" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="12" cy="11.5" r="1" fill="currentColor" stroke="none"/><circle cx="15.5" cy="11.5" r="1" fill="currentColor" stroke="none"/></svg>' + | |
| '<svg class="ic-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' + | |
| '</button>' + | |
| '<div class="panel" role="dialog" aria-label="' + TITLE + '">' + | |
| ' <div class="head"><div class="badge">A</div><h3>' + TITLE + '</h3><button class="close" aria-label="Close">×</button></div>' + | |
| ' <div class="body"></div>' + | |
| ' <div class="foot"><textarea rows="1" placeholder="Type your question…"></textarea><button class="send">Send</button></div>' + | |
| ' <div class="stats"></div>' + | |
| '</div>'; | |
| 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; } | |
| 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 = | |
| '<div class="av">' + (role === "user" ? "You" : "A") + "</div>" + | |
| '<div class="bubble"></div>'; | |
| 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 = '<span class="typing">Thinking…</span>'; | |
| try { | |
| var r = await fetch(API + "/api/chat", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "ngrok-skip-browser-warning": "true", | |
| }, | |
| body: JSON.stringify({ message: text, session_id: sessionId }), | |
| }); | |
| var d = await r.json(); | |
| if (!r.ok) { | |
| bubble.innerHTML = '<span class="typing">⚠ ' + esc(d.error || "Error") + "</span>"; | |
| } 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 = '<span class="typing">⚠ Network error</span>'; | |
| } 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(); | |
| })(); | |
| </script> | |