// AutoAnalyst frontend — vanilla JS, no framework.
const $ = (sel) => document.querySelector(sel);
const state = { datasetId: null, sessionId: null, datasets: [], busy: false };
// ---- status pill (polls /api/health) ----------------------------------------
async function pollHealth() {
const el = $("#status");
try {
const h = await fetch("/api/health").then((r) => r.json());
const online = h.status === "online";
el.className = "status " + (online ? "online" : "offline");
el.querySelector(".label").textContent = online ? "Online" : "Offline";
el.title = online ? `model: ${h.model}` : (h.reason || "offline");
if (h.allow_upload) $("#uploadZone").classList.remove("hidden");
} catch {
el.className = "status offline";
el.querySelector(".label").textContent = "Offline";
}
}
// ---- datasets ----------------------------------------------------------------
async function loadDatasets() {
const grid = $("#datasetGrid");
try {
state.datasets = await fetch("/api/datasets").then((r) => r.json());
} catch {
grid.innerHTML = `
Could not load datasets.
`;
return;
}
grid.innerHTML = "";
state.datasets.forEach((d) => {
const card = document.createElement("button");
card.className = "ds-card";
card.innerHTML = `${esc(d.label)}
${esc(d.description)}
`;
card.onclick = () => selectDataset(d, card);
grid.appendChild(card);
});
}
async function selectDataset(d, card) {
document.querySelectorAll(".ds-card").forEach((c) => c.classList.remove("active"));
card.classList.add("active");
state.datasetId = d.id;
state.sessionId = null;
renderChips(d.questions || []);
$("#analyzeBtn").disabled = false;
await loadSchema({ dataset_id: d.id });
}
async function loadSchema(params) {
const panel = $("#schemaPanel");
const url = params.dataset_id
? `/api/schema?dataset_id=${encodeURIComponent(params.dataset_id)}`
: null;
let s;
try {
s = url ? await fetch(url).then((r) => r.json()) : params.uploaded;
} catch {
return;
}
$("#schemaLabel").textContent = s.label || "";
$("#schemaMeta").textContent =
`${s.row_count.toLocaleString()} rows · ${s.columns.length} columns`;
const head = s.preview_columns || s.columns.map((c) => c.name);
const rows = (s.preview || []).slice(0, 6);
const dtypes = Object.fromEntries(s.columns.map((c) => [c.name, c.dtype]));
let html = "" +
head.map((c) => `${esc(c)} ${esc(dtypes[c] || "")} | `).join("") +
"
";
rows.forEach((r) => {
html += "" + r.map((v, i) =>
`| ${esc(fmt(v))} | `).join("") + "
";
});
html += "";
$("#schemaTable").innerHTML = html;
panel.classList.remove("hidden");
}
// ---- upload (local only) -----------------------------------------------------
$("#uploadBtn") && ($("#uploadBtn").onclick = () => $("#fileInput").click());
$("#fileInput") && ($("#fileInput").onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const body = new FormData();
body.append("file", file);
try {
const r = await fetch("/api/upload", { method: "POST", body });
if (!r.ok) throw new Error((await r.json()).detail || "upload failed");
const s = await r.json();
state.sessionId = s.session_id;
state.datasetId = null;
document.querySelectorAll(".ds-card").forEach((c) => c.classList.remove("active"));
renderChips([]);
$("#analyzeBtn").disabled = false;
await loadSchema({ uploaded: s });
} catch (err) {
showBanner(`Upload failed: ${err.message}`);
}
});
// ---- example chips -----------------------------------------------------------
function renderChips(questions) {
const wrap = $("#chips");
wrap.innerHTML = "";
questions.forEach((q) => {
const c = document.createElement("button");
c.className = "chip";
c.textContent = q;
c.onclick = () => { $("#question").value = q; analyze(); };
wrap.appendChild(c);
});
}
// ---- analyze -----------------------------------------------------------------
$("#analyzeBtn").onclick = analyze;
$("#question").addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") analyze();
});
let stream = null;
function analyze() {
if (state.busy) return;
const question = $("#question").value.trim();
if (!question) return;
if (!state.datasetId && !state.sessionId) {
showBanner("Pick a dataset (or upload one) first.");
return;
}
setBusy(true);
const results = $("#results");
results.innerHTML = "";
const thinking = document.createElement("div");
thinking.className = "thinking";
thinking.innerHTML = `AutoAnalyst is reading the data…`;
const setThinking = (t) => thinking.querySelector(".t-label").textContent = t;
results.appendChild(thinking);
const params = new URLSearchParams({ question });
if (state.datasetId) params.set("dataset_id", state.datasetId);
else params.set("session_id", state.sessionId);
let finished = false;
const done = () => { finished = true; thinking.remove(); stream.close(); setBusy(false); };
stream = new EventSource(`/api/analyze/stream?${params}`);
stream.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch { return; }
if (msg.type === "step") {
results.insertBefore(renderStep(msg), thinking);
setThinking(`Ran step ${msg.n} — deciding the next move…`);
} else if (msg.type === "final") {
done();
results.appendChild(renderAnswer(msg));
} else if (msg.type === "error") {
done();
showBanner(msg.detail || "analysis failed");
}
};
stream.onerror = () => {
if (finished) return;
done();
showBanner("Connection lost while analyzing. Is the model online?");
};
}
function renderResult(data) {
const results = $("#results");
results.innerHTML = "";
(data.trace || []).forEach((step) => results.appendChild(renderStep(step)));
results.appendChild(renderAnswer(data));
}
function renderStep(step) {
const el = document.createElement("div");
el.className = "step";
let inner = `
${step.n}
run_python · step ${step.n}
${highlight(step.code || "")}`;
const io = [];
if (step.stdout && step.stdout.trim())
io.push(`Output
${esc(step.stdout.trim())} `);
if (step.error)
io.push(`Error
${esc(step.error.trim())} `);
if (io.length) inner += `${io.join("")}
`;
(step.charts || []).forEach((c) => {
inner += ``;
});
el.innerHTML = inner;
return el;
}
function renderAnswer(data) {
const el = document.createElement("div");
el.className = "answer";
const meta = data.stopped === "step_limit"
? `reached the ${data.steps_used}-step limit`
: `${data.steps_used} step${data.steps_used === 1 ? "" : "s"}`;
el.innerHTML = `▶
Answer
${esc(data.answer || "(no answer)")}
${esc(data.label || "")} · ${meta}
`;
return el;
}
// ---- helpers -----------------------------------------------------------------
function setBusy(b) {
state.busy = b;
const btn = $("#analyzeBtn");
btn.classList.toggle("busy", b);
btn.disabled = b;
}
function showBanner(msg) {
const results = $("#results");
const b = document.createElement("div");
b.className = "banner-err";
b.innerHTML = `⚠ ${esc(msg)}`;
results.prepend(b);
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function fmt(v) {
if (v === null || v === undefined) return "—";
if (typeof v === "number") return Number.isInteger(v) ? v : v.toFixed(2);
return v;
}
// minimal Python syntax highlighter — single-pass tokenizer so HTML entities
// are never re-processed (each token is escaped exactly once before wrapping).
const PY_KW = new Set(["import", "from", "as", "def", "return", "if", "elif",
"else", "for", "while", "in", "not", "and", "or", "is", "None", "True",
"False", "lambda", "with", "print"]);
const PY_BI = new Set(["df", "pd", "np", "plt", "sum", "mean", "count", "groupby",
"sort_values", "head", "describe", "value_counts", "plot", "title", "xlabel",
"ylabel", "len", "round", "sorted", "agg", "merge", "pivot_table"]);
function highlight(code) {
const re = /(#[^\n]*)|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(\b\d+\.?\d*\b)|([A-Za-z_]\w*)/g;
let out = "", last = 0, m;
while ((m = re.exec(code))) {
out += esc(code.slice(last, m.index));
const [full, com, str, num, ident] = m;
if (com) out += `${esc(com)}`;
else if (str) out += `${esc(str)}`;
else if (num) out += `${num}`;
else if (PY_KW.has(ident)) out += `${ident}`;
else if (PY_BI.has(ident)) out += `${ident}`;
else out += esc(ident);
last = re.lastIndex;
}
out += esc(code.slice(last));
return out;
}
// ---- boot --------------------------------------------------------------------
pollHealth();
setInterval(pollHealth, 15000);
loadDatasets();