sodabar / index.html
usmar's picture
Upload folder using huggingface_hub
9d89547 verified
Raw
History Blame Contribute Delete
11.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>sodabar — open data on tap</title>
<style>
:root {
--bg: #fcfcfb; --panel: #ffffff; --ink: #1c2733; --ink2: #5a6a7a; --ink3: #8b98a5;
--line: #e3e8ee; --blue: #2a78d6; --orange: #eb6834; --blue-soft: #eaf2fc;
--chip: #f1f4f8; --code-bg: #f6f8fa; --ok: #1a7f4e;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #15191f; --panel: #1c222b; --ink: #e8edf2; --ink2: #a7b3c0; --ink3: #6f7d8c;
--line: #2c3540; --blue: #4a8fd9; --orange: #e8632f; --blue-soft: #1e2c3d;
--chip: #242c37; --code-bg: #12161c; --ok: #3dbf7f;
}
}
* { box-sizing: border-box; margin: 0; }
body {
background: var(--bg); color: var(--ink);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 18px 22px;
}
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
h1 { font-size: 21px; letter-spacing: -0.02em; }
h1 .cup { color: var(--orange); }
.tag { color: var(--ink2); font-size: 13px; }
.live { margin-left: auto; font-size: 12px; color: var(--ok); white-space: nowrap; }
.live::before { content: "●"; margin-right: 5px; }
.grid { display: grid; grid-template-columns: 320px 1fr; gap: 14px; align-items: start; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
.tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; }
.tool {
border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; cursor: pointer;
background: none; color: var(--ink2); font: inherit; font-size: 12.5px; text-align: left;
}
.tool code { font-size: 11.5px; }
.tool.on { border-color: var(--blue); background: var(--blue-soft); color: var(--ink); }
label { display: block; font-size: 11.5px; color: var(--ink2); margin: 8px 0 3px; }
input, textarea {
width: 100%; border: 1px solid var(--line); border-radius: 7px; padding: 6px 8px;
background: var(--bg); color: var(--ink); font: 12.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
}
input:focus, textarea:focus { outline: 2px solid var(--blue); outline-offset: -1px; border-color: transparent; }
.row { display: flex; gap: 8px; margin-top: 12px; }
.btn {
border: 0; border-radius: 8px; padding: 8px 14px; font: 600 13px/1 inherit;
cursor: pointer; font-family: inherit;
}
.btn.run { background: var(--blue); color: #fff; flex: 1; }
.btn.sample { background: var(--chip); color: var(--ink); }
.btn:active { transform: translateY(1px); }
.callbox { margin-bottom: 10px; }
.callbox .lbl, .out .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink3); margin-bottom: 5px; }
pre.call {
background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px;
padding: 9px 11px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
overflow-x: auto; color: var(--ink2);
}
pre.call .k { color: var(--blue); }
pre.call .v { color: var(--orange); }
.meta { font-size: 12px; color: var(--ink2); margin: 8px 0 6px; }
.meta b { color: var(--ink); }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
th, td { text-align: left; padding: 5px 10px 5px 0; border-bottom: 1px solid var(--line); }
th { color: var(--ink2); font-weight: 600; font-size: 11.5px; }
td { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
th.num { text-align: right; }
.chart { margin: 10px 0 4px; }
.bar-row { display: grid; grid-template-columns: 120px 1fr 70px; gap: 8px; align-items: center; margin-bottom: 4px; }
.bar-label { font-size: 12px; color: var(--ink2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.bar-track { height: 14px; position: relative; }
.bar-fill { height: 100%; background: var(--blue); border-radius: 0 4px 4px 0; min-width: 2px; }
.bar-row:hover .bar-fill { background: var(--orange); }
.bar-val { font-size: 11.5px; color: var(--ink); font-variant-numeric: tabular-nums; }
.err { color: var(--orange); font-size: 13px; padding: 6px 0; }
.empty { color: var(--ink3); font-size: 13px; padding: 20px 0; text-align: center; }
.scroll { max-height: 300px; overflow: auto; }
footer { margin-top: 12px; font-size: 11.5px; color: var(--ink3); }
footer a { color: var(--blue); text-decoration: none; }
</style>
</head>
<body>
<header>
<h1>sodabar<span class="cup"></span></h1>
<span class="tag">open data on tap — the MCP tool console</span>
<span class="live" id="live">data.cityofnewyork.us</span>
</header>
<div class="grid">
<div class="panel">
<div class="tools" id="tools"></div>
<form id="form" onsubmit="return runTool(event)"><div id="fields"></div>
<div class="row">
<button class="btn run" type="submit">Call tool</button>
<button class="btn sample" type="button" onclick="loadSample()">Try a sample</button>
</div>
</form>
</div>
<div class="panel">
<div class="callbox"><div class="lbl">tools/call</div><pre class="call" id="call">{ }</pre></div>
<div class="out">
<div class="lbl">result</div>
<div id="meta" class="meta"></div>
<div id="result"><div class="empty">Pick a tool and call it — or press “Try a sample”.</div></div>
</div>
</div>
</div>
<footer>Four MCP tools · SoQL queries capped at 1000 rows · same responses an LLM client sees</footer>
<script src="shim.js"></script>
<script>
const TOOLS = {
query_dataset: {
fields: [
["dataset_id", "erm2-nwe9"], ["select", ""], ["where", ""],
["group", ""], ["order", ""], ["limit", "50"],
],
endpoint: (a) => ["/api/query", a],
},
search_datasets: {
fields: [["query", "rat sightings"], ["limit", "10"]],
endpoint: (a) => ["/api/search", { q: a.query, limit: a.limit }],
},
get_schema: {
fields: [["dataset_id", "erm2-nwe9"]],
endpoint: (a) => [`/api/schema/${encodeURIComponent(a.dataset_id)}`, {}],
},
profile_column: {
fields: [["dataset_id", "erm2-nwe9"], ["column", "borough"], ["top", "10"]],
endpoint: (a) => ["/api/profile", a],
},
};
let current = "query_dataset";
let sampleIndex = 0;
function renderTools() {
const box = document.getElementById("tools");
box.innerHTML = "";
for (const name of Object.keys(TOOLS)) {
const b = document.createElement("button");
b.type = "button";
b.className = "tool" + (name === current ? " on" : "");
b.innerHTML = `<code>${name}</code>`;
b.onclick = () => { current = name; renderTools(); renderFields({}); };
box.appendChild(b);
}
}
function renderFields(values) {
const box = document.getElementById("fields");
box.innerHTML = "";
for (const [name, placeholder] of TOOLS[current].fields) {
const l = document.createElement("label");
l.textContent = name;
const i = document.createElement("input");
i.name = name;
i.placeholder = placeholder;
if (values[name] !== undefined) i.value = values[name];
box.append(l, i);
}
}
function formArgs() {
const args = {};
for (const input of document.querySelectorAll("#fields input"))
if (input.value.trim()) args[input.name] = input.value.trim();
return args;
}
function showCall(tool, args) {
const argsJson = JSON.stringify(args, null, 2).replace(/"([^"]+)":/g, '<span class="k">"$1"</span>:')
.replace(/: "([^"]*)"/g, ': <span class="v">"$1"</span>');
document.getElementById("call").innerHTML =
`{ <span class="k">"name"</span>: <span class="v">"${tool}"</span>, <span class="k">"arguments"</span>: ${argsJson} }`;
}
async function runTool(ev) {
if (ev) ev.preventDefault();
const args = formArgs();
showCall(current, args);
const [path, params] = TOOLS[current].endpoint(args);
const qs = new URLSearchParams(params).toString();
const t0 = performance.now();
const resultBox = document.getElementById("result");
const meta = document.getElementById("meta");
meta.textContent = "…";
resultBox.innerHTML = "";
try {
const res = await fetch(path + (qs ? "?" + qs : ""));
const body = await res.json();
const ms = Math.round(performance.now() - t0);
if (!res.ok) throw new Error(body.detail || res.statusText);
render(body, ms);
} catch (e) {
meta.textContent = "";
resultBox.innerHTML = `<div class="err">✗ ${e.message}</div>`;
}
return false;
}
function render(body, ms) {
const meta = document.getElementById("meta");
const box = document.getElementById("result");
box.innerHTML = "";
if (Array.isArray(body)) {
meta.innerHTML = `<b>${body.length}</b> row${body.length === 1 ? "" : "s"} · ${ms} ms`;
if (!body.length) { box.innerHTML = '<div class="empty">No rows.</div>'; return; }
const cols = Object.keys(body[0]);
if (cols.length === 2 && body.length <= 30 && body.every(r => isFinite(+r[cols[1]]))) {
box.appendChild(barChart(body, cols));
}
box.appendChild(tableOf(body, cols));
} else if (body && body.columns) {
meta.innerHTML = `<b>${body.name || body.id}</b> · ${body.columns.length} columns`;
box.appendChild(tableOf(body.columns.map(c => ({ column: c.field_name, type: c.type })), ["column", "type"]));
} else {
meta.textContent = "";
box.innerHTML = `<pre class="call">${JSON.stringify(body, null, 2)}</pre>`;
}
}
function tableOf(rows, cols) {
const wrap = document.createElement("div");
wrap.className = "scroll";
const numeric = cols.map(c => rows.every(r => r[c] === undefined || isFinite(+r[c])));
let html = "<table><tr>" + cols.map((c, i) => `<th${numeric[i] ? ' class="num"' : ""}>${c}</th>`).join("") + "</tr>";
for (const r of rows.slice(0, 200))
html += "<tr>" + cols.map((c, i) => {
let v = r[c] === undefined ? "" : String(r[c]);
if (numeric[i] && v) v = (+v).toLocaleString();
return `<td${numeric[i] ? ' class="num"' : ""}>${escapeHtml(v).slice(0, 120)}</td>`;
}).join("") + "</tr>";
wrap.innerHTML = html + "</table>";
return wrap;
}
function barChart(rows, cols) {
const box = document.createElement("div");
box.className = "chart";
const max = Math.max(...rows.map(r => +r[cols[1]]));
for (const r of rows.slice(0, 12)) {
const v = +r[cols[1]];
const row = document.createElement("div");
row.className = "bar-row";
row.title = `${r[cols[0]]}: ${v.toLocaleString()}`;
row.innerHTML = `<div class="bar-label">${escapeHtml(String(r[cols[0]] ?? "—"))}</div>
<div class="bar-track"><div class="bar-fill" style="width:${Math.max(1, v / max * 100)}%"></div></div>
<div class="bar-val">${v.toLocaleString()}</div>`;
box.appendChild(row);
}
return box;
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
async function loadSample() {
const res = await fetch(`/sample?index=${sampleIndex}`);
const s = await res.json();
sampleIndex += 1;
current = s.tool;
renderTools();
const values = {};
for (const [k, v] of Object.entries(s.args)) values[k === "query" ? "query" : k] = v;
renderFields(values);
runTool();
}
renderTools();
renderFields({});
const qs = new URLSearchParams(location.search);
if (qs.get("demo")) {
if (qs.get("err")) {
// guardrail demo: a malformed tool call, answered with a readable error
current = "query_dataset";
renderTools();
renderFields({ dataset_id: "311-complaints", select: "borough, count(*)" });
runTool();
} else {
sampleIndex = +(qs.get("sample") || 0);
loadSample();
}
}
</script>
</body>
</html>