Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@
# from whichever key is set (or force it with AGENT_LLM_PROVIDER).
# .env is gitignored and is the ONLY manual setup step. Never commit real keys.

# --- Database (defaults to a local SQLite file; set a Postgres URL for production) ---
# --- Database (primary metadata/cache/audit DB — PostgreSQL for production, SQLite for local dev) ---
AGENT_DATABASE_URL=sqlite:///./data/app.db

# --- LLM provider: auto | anthropic | gemini | openrouter (auto = detect from keys) ---
# --- LLM provider: auto | anthropic | gemini | openrouter | nim (auto = detect from keys) ---
AGENT_LLM_PROVIDER=auto
# Leave blank to use the provider's sensible default model, or pin one.
# (OpenRouter default is tencent/hy3 — cheap; frontier models 402 on unfunded keys.)
AGENT_LLM_MODEL=

# --- OpenAI-compatible / NIM path (covers OpenAI, NVIDIA NIM, local Ollama/vLLM, etc.) ---
# Set OPENAI_API_KEY when using NVIDIA NIM or any OpenAI-compatible provider.
# Use AGENT_LLM_PROVIDER=nim (or openrouter which also uses the OpenAI-compatible path).
OPENAI_API_KEY=
# Base URL for the OpenAI-compatible endpoint. For NVIDIA NIM this is typically:
# https://integrate.api.nvidia.com/v1
OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
# Model id sent in the OpenAI-compatible `model` field, e.g. meta/llama-3-8b-instruct.
OPENAI_MODEL=

# Set exactly ONE of these (matching the provider you want):
AGENT_ANTHROPIC_API_KEY=
AGENT_GEMINI_API_KEY=
Expand Down
279 changes: 220 additions & 59 deletions frontend/public/app.js
Original file line number Diff line number Diff line change
@@ -1,77 +1,238 @@
// Zero-build baseline frontend. Single-origin: the page is served by the
// backend at /app, so API calls are same-origin relative paths.
/* Phase 3 frontend: CSV upload + Q&A, live DB query, run history.
Single-origin against the FastAPI backend at /app.
*/
"use strict";

const $ = (id) => document.getElementById(id);
const q = (id) => document.getElementById(id);
const $ = q;
const hide = (el) => { if (el) el.hidden = true; };
const show = (el) => { if (el) el.hidden = false; };
const setStatus = (el, text) => { if (!el) return; el.textContent = text; show(el); };
const clearStatus = (el) => { if (el) el.hidden = true; };
const setError = (el, text) => { if (!el) return; el.textContent = text; hide(el); show(el); };
const setOk = (el, text) => { if (!el) return; el.textContent = text; hide(el); show(el); };

async function loadHealth() {
const badge = $("provider-badge");
try {
const res = await fetch("/health");
const body = await res.json();
const { provider, model, key_configured: keyed } = body.data;
if (!keyed) {
badge.textContent = "no API key — set one in .env";
badge.classList.add("stub");
} else {
badge.textContent = `${provider} · ${model}`;
}
} catch {
badge.textContent = "backend unreachable";
badge.classList.add("stub");
const badge = q("provider-badge");
try {
const res = await fetch("/health");
const body = await res.json();
const { provider, model, key_configured: keyed } = body.data;
if (!keyed) {
badge.textContent = `no API key — set one in .env`;
badge.classList.add("stub");
} else {
badge.textContent = `${provider} · ${model}`;
}
} catch {
badge.textContent = "backend unreachable";
badge.classList.add("stub");
}
}

async function runTransform() {
const btn = $("run-btn");
const status = $("status");
const errBox = $("error");
const wrap = $("result-wrap");

const text = $("text").value.trim();
const instruction = $("instruction").value.trim();
function switchTab(name) {
document.querySelectorAll(".tab").forEach((btn) => {
const active = btn.dataset.tab === name;
btn.classList.toggle("active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
});
document.querySelectorAll(".panel").forEach((panel) => {
panel.classList.toggle("hidden", panel.id !== `panel-${name}`);
});
}

errBox.hidden = true;
wrap.hidden = true;
// CSV upload
async function uploadCSV(file) {
const statusEl = q("csv-upload-status");
const errorEl = q("csv-upload-error");
const form = new FormData();
form.append("file", file);
setStatus(statusEl, "Uploading…");
clearStatus(errorEl);
try {
const res = await fetch("/csv/upload", { method: "POST", body: form });
if (!res.ok) {
const body = await res.json();
throw new Error(body?.detail?.message || `Upload failed (${res.status})`);
}
const body = await res.json();
const data = body.data;
setOk(statusEl, `Uploaded ${data.file_name} · ${data.row_count} rows · ${data.columns.length} columns`);
return data;
} catch (err) {
setError(errorEl, err.message);
return null;
}
}

if (!text) {
errBox.textContent = "Paste some text first — the input can't be empty.";
errBox.hidden = false;
return;
async function runCSVQuery(question, fileId) {
const statusEl = q("csv-query-status");
const errorEl = q("csv-query-error");
clearStatus(statusEl);
clearStatus(errorEl);
show(statusEl);
setStatus(statusEl, "Running…");
try {
const res = await fetch("/csv/query", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question, data_source: "csv", csv_file_ids: [fileId] }),
});
if (!res.ok) {
const body = await res.json();
throw new Error(body?.detail?.message || `Query failed (${res.status})`);
}
const body = await res.json();
const data = body.data;
if (data.status === "failed") {
throw new Error(data.error || "Agent run failed.");
}
setOk(statusEl, "Done");
q("csv-result").textContent = data.answer_text || "";
q("csv-result-meta").textContent = `run ${data.run_id} · ${data.provider || "unknown"} · ${data.model || "unknown"}`;
if (data.result_table && data.result_table.columns) {
renderTable("csv-table", "csv-table-wrap", data.result_table);
}
if (data.run_id) {
q("csv-download").href = `/csv/runs/${data.run_id}/download`;
}
show(q("csv-result-wrap"));
} catch (err) {
setError(errorEl, err.message);
}
}

btn.disabled = true;
status.textContent = "Running… (one real LLM call)";
status.hidden = false;
function renderTable(tableId, wrapId, resultTable) {
const table = q(tableId);
const wrap = q(wrapId);
if (!table || !resultTable) return;
const thead = table.querySelector("thead");
const tbody = table.querySelector("tbody");
thead.innerHTML = "";
tbody.innerHTML = "";
const header = document.createElement("tr");
(resultTable.columns || []).forEach((col) => {
const th = document.createElement("th");
th.textContent = col;
header.appendChild(th);
});
thead.appendChild(header);
(resultTable.rows || []).forEach((row) => {
const tr = document.createElement("tr");
(resultTable.columns || []).forEach((col) => {
const td = document.createElement("td");
td.textContent = row[col] === null || row[col] === undefined ? "" : row[col];
tr.appendChild(td);
});
tbody.appendChild(tr);
});
show(wrap);
}

try {
const res = await fetch("/runs", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text, instruction }),
});
const body = await res.json();
function renderHistoryTable(rows) {
const tbody = q("#history-table tbody");
if (!tbody) return;
tbody.innerHTML = "";
rows.forEach((row) => {
const tr = document.createElement("tr");
const dataSource = row.data_source || "transform";
tr.innerHTML = `
<td>${row.run_id}</td>
<td>${row.status}</td>
<td>${dataSource}</td>
<td>${row.created_at ? new Date(row.created_at).toLocaleString() : ""}</td>
<td><a class="secondary" href="/app/#history">detail</a></td>
`;
tbody.appendChild(tr);
});
}

if (!res.ok) {
const msg = body?.detail?.message || `HTTP ${res.status}`;
throw new Error(msg);
}
const run = body.data;
if (run.status === "failed") {
throw new Error(run.error_message || "The agent run failed.");
}
$("result").textContent = run.output_text;
$("result-meta").textContent =
`run ${run.run_id} · ${run.provider} · ${run.model}`;
wrap.hidden = false;
async function loadHistory() {
const statusEl = q("history-status");
const errorEl = q("history-error");
clearStatus(statusEl);
clearStatus(errorEl);
show(statusEl);
setStatus(statusEl, "Loading…");
try {
const res = await fetch("/runs");
if (!res.ok) throw new Error(`History failed (${res.status})`);
const body = await res.json();
renderHistoryTable(body.data || []);
setOk(statusEl, "");
} catch (err) {
setError(errorEl, err.message);
}
}

async function runLiveQuery(question, schemaSummary) {
const statusEl = q("live-query-status");
const errorEl = q("live-query-error");
clearStatus(statusEl);
clearStatus(errorEl);
show(statusEl);
setStatus(statusEl, "Running…");
try {
const res = await fetch("/live-db/query", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ question, schema_summary: schemaSummary }),
});
if (!res.ok) {
const body = await res.json();
throw new Error(body?.detail?.message || `Live query failed (${res.status})`);
}
const body = await res.json();
const data = body.data;
if (data.status === "failed") {
throw new Error(data.error || "Live query failed.");
}
setOk(statusEl, `Done in ${data.latency_ms ?? "?"} ms`);
q("live-result").textContent = data.answer_text || "";
q("live-result-meta").textContent = `run ${data.run_id} · ${data.provider || "unknown"} · served from cache: ${data.served_from_cache ? "yes" : "no"}`;
if (data.run_id) {
q("live-download").href = `/live-db/runs/${data.run_id}/download`;
}
if (data.result_table && data.result_table.columns) {
renderTable("live-table", "live-table-wrap", data.result_table);
}
show(q("live-result-wrap"));
} catch (err) {
errBox.textContent = err.message;
errBox.hidden = false;
} finally {
btn.disabled = false;
status.hidden = true;
setError(errorEl, err.message);
}
}

$("run-btn").addEventListener("click", runTransform);
// Event wiring
q("csv-upload-form").addEventListener("submit", async (e) => {
e.preventDefault();
const file = q("csv-file").files[0];
if (!file) return setError(q("csv-upload-error"), "Choose a CSV file first.");
const data = await uploadCSV(file);
if (data) q("csv-query-form").dataset.fileId = data.file_id;
});

q("csv-query-form").addEventListener("submit", async (e) => {
e.preventDefault();
const fileId = parseInt(q("csv-query-form").dataset.fileId || "0", 10);
if (!fileId) return setError(q("csv-query-error"), "Upload a CSV first.");
const question = q("csv-question").value.trim();
if (!question) return setError(q("csv-query-error"), "Enter a question.");
await runCSVQuery(question, fileId);
});

q("live-query-form").addEventListener("submit", async (e) => {
e.preventDefault();
const question = q("live-question").value.trim();
const schema = q("live-schema").value.trim();
if (!question || !schema) return setError(q("live-query-error"), "Question and schema summary are required.");
await runLiveQuery(question, schema);
});

q("refresh-history").addEventListener("click", loadHistory);

document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
});

loadHealth();
loadHistory();
Loading