Upload folder using huggingface_hub
Browse files- README.md +12 -5
- index.html +283 -18
- shim.js +118 -0
README.md
CHANGED
|
@@ -1,10 +1,17 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: sodabar
|
| 3 |
+
emoji: 🥤
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
short_description: NYC Open Data on tap — MCP tool console demo
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# sodabar — open data on tap
|
| 13 |
+
|
| 14 |
+
Static demo of the [sodabar](https://github.com/UsmarHaider/sodabar) MCP
|
| 15 |
+
server's tool console. The four MCP tools are answered **in your browser**,
|
| 16 |
+
straight from the live NYC Open Data APIs (Socrata sends CORS `*`).
|
| 17 |
+
The full project — MCP server, FastAPI playground, tests — lives on GitHub.
|
index.html
CHANGED
|
@@ -1,19 +1,284 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>sodabar — open data on tap</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #fcfcfb; --panel: #ffffff; --ink: #1c2733; --ink2: #5a6a7a; --ink3: #8b98a5;
|
| 10 |
+
--line: #e3e8ee; --blue: #2a78d6; --orange: #eb6834; --blue-soft: #eaf2fc;
|
| 11 |
+
--chip: #f1f4f8; --code-bg: #f6f8fa; --ok: #1a7f4e;
|
| 12 |
+
}
|
| 13 |
+
@media (prefers-color-scheme: dark) {
|
| 14 |
+
:root {
|
| 15 |
+
--bg: #15191f; --panel: #1c222b; --ink: #e8edf2; --ink2: #a7b3c0; --ink3: #6f7d8c;
|
| 16 |
+
--line: #2c3540; --blue: #4a8fd9; --orange: #e8632f; --blue-soft: #1e2c3d;
|
| 17 |
+
--chip: #242c37; --code-bg: #12161c; --ok: #3dbf7f;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
* { box-sizing: border-box; margin: 0; }
|
| 21 |
+
body {
|
| 22 |
+
background: var(--bg); color: var(--ink);
|
| 23 |
+
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
| 24 |
+
padding: 18px 22px;
|
| 25 |
+
}
|
| 26 |
+
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 14px; }
|
| 27 |
+
h1 { font-size: 21px; letter-spacing: -0.02em; }
|
| 28 |
+
h1 .cup { color: var(--orange); }
|
| 29 |
+
.tag { color: var(--ink2); font-size: 13px; }
|
| 30 |
+
.live { margin-left: auto; font-size: 12px; color: var(--ok); white-space: nowrap; }
|
| 31 |
+
.live::before { content: "●"; margin-right: 5px; }
|
| 32 |
+
.grid { display: grid; grid-template-columns: 320px 1fr; gap: 14px; align-items: start; }
|
| 33 |
+
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
|
| 34 |
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; }
|
| 35 |
+
.tools { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; }
|
| 36 |
+
.tool {
|
| 37 |
+
border: 1px solid var(--line); border-radius: 8px; padding: 7px 9px; cursor: pointer;
|
| 38 |
+
background: none; color: var(--ink2); font: inherit; font-size: 12.5px; text-align: left;
|
| 39 |
+
}
|
| 40 |
+
.tool code { font-size: 11.5px; }
|
| 41 |
+
.tool.on { border-color: var(--blue); background: var(--blue-soft); color: var(--ink); }
|
| 42 |
+
label { display: block; font-size: 11.5px; color: var(--ink2); margin: 8px 0 3px; }
|
| 43 |
+
input, textarea {
|
| 44 |
+
width: 100%; border: 1px solid var(--line); border-radius: 7px; padding: 6px 8px;
|
| 45 |
+
background: var(--bg); color: var(--ink); font: 12.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 46 |
+
}
|
| 47 |
+
input:focus, textarea:focus { outline: 2px solid var(--blue); outline-offset: -1px; border-color: transparent; }
|
| 48 |
+
.row { display: flex; gap: 8px; margin-top: 12px; }
|
| 49 |
+
.btn {
|
| 50 |
+
border: 0; border-radius: 8px; padding: 8px 14px; font: 600 13px/1 inherit;
|
| 51 |
+
cursor: pointer; font-family: inherit;
|
| 52 |
+
}
|
| 53 |
+
.btn.run { background: var(--blue); color: #fff; flex: 1; }
|
| 54 |
+
.btn.sample { background: var(--chip); color: var(--ink); }
|
| 55 |
+
.btn:active { transform: translateY(1px); }
|
| 56 |
+
.callbox { margin-bottom: 10px; }
|
| 57 |
+
.callbox .lbl, .out .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink3); margin-bottom: 5px; }
|
| 58 |
+
pre.call {
|
| 59 |
+
background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px;
|
| 60 |
+
padding: 9px 11px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
| 61 |
+
overflow-x: auto; color: var(--ink2);
|
| 62 |
+
}
|
| 63 |
+
pre.call .k { color: var(--blue); }
|
| 64 |
+
pre.call .v { color: var(--orange); }
|
| 65 |
+
.meta { font-size: 12px; color: var(--ink2); margin: 8px 0 6px; }
|
| 66 |
+
.meta b { color: var(--ink); }
|
| 67 |
+
table { border-collapse: collapse; width: 100%; font-size: 12.5px; }
|
| 68 |
+
th, td { text-align: left; padding: 5px 10px 5px 0; border-bottom: 1px solid var(--line); }
|
| 69 |
+
th { color: var(--ink2); font-weight: 600; font-size: 11.5px; }
|
| 70 |
+
td { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
| 71 |
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
| 72 |
+
th.num { text-align: right; }
|
| 73 |
+
.chart { margin: 10px 0 4px; }
|
| 74 |
+
.bar-row { display: grid; grid-template-columns: 120px 1fr 70px; gap: 8px; align-items: center; margin-bottom: 4px; }
|
| 75 |
+
.bar-label { font-size: 12px; color: var(--ink2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
| 76 |
+
.bar-track { height: 14px; position: relative; }
|
| 77 |
+
.bar-fill { height: 100%; background: var(--blue); border-radius: 0 4px 4px 0; min-width: 2px; }
|
| 78 |
+
.bar-row:hover .bar-fill { background: var(--orange); }
|
| 79 |
+
.bar-val { font-size: 11.5px; color: var(--ink); font-variant-numeric: tabular-nums; }
|
| 80 |
+
.err { color: var(--orange); font-size: 13px; padding: 6px 0; }
|
| 81 |
+
.empty { color: var(--ink3); font-size: 13px; padding: 20px 0; text-align: center; }
|
| 82 |
+
.scroll { max-height: 300px; overflow: auto; }
|
| 83 |
+
footer { margin-top: 12px; font-size: 11.5px; color: var(--ink3); }
|
| 84 |
+
footer a { color: var(--blue); text-decoration: none; }
|
| 85 |
+
</style>
|
| 86 |
+
</head>
|
| 87 |
+
<body>
|
| 88 |
+
<header>
|
| 89 |
+
<h1>sodabar<span class="cup"> ▙</span></h1>
|
| 90 |
+
<span class="tag">open data on tap — the MCP tool console</span>
|
| 91 |
+
<span class="live" id="live">data.cityofnewyork.us</span>
|
| 92 |
+
</header>
|
| 93 |
+
|
| 94 |
+
<div class="grid">
|
| 95 |
+
<div class="panel">
|
| 96 |
+
<div class="tools" id="tools"></div>
|
| 97 |
+
<form id="form" onsubmit="return runTool(event)"><div id="fields"></div>
|
| 98 |
+
<div class="row">
|
| 99 |
+
<button class="btn run" type="submit">Call tool</button>
|
| 100 |
+
<button class="btn sample" type="button" onclick="loadSample()">Try a sample</button>
|
| 101 |
+
</div>
|
| 102 |
+
</form>
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<div class="panel">
|
| 106 |
+
<div class="callbox"><div class="lbl">tools/call</div><pre class="call" id="call">{ }</pre></div>
|
| 107 |
+
<div class="out">
|
| 108 |
+
<div class="lbl">result</div>
|
| 109 |
+
<div id="meta" class="meta"></div>
|
| 110 |
+
<div id="result"><div class="empty">Pick a tool and call it — or press “Try a sample”.</div></div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<footer>Four MCP tools · SoQL queries capped at 1000 rows · same responses an LLM client sees</footer>
|
| 116 |
+
|
| 117 |
+
<script src="shim.js"></script>
|
| 118 |
+
<script>
|
| 119 |
+
const TOOLS = {
|
| 120 |
+
query_dataset: {
|
| 121 |
+
fields: [
|
| 122 |
+
["dataset_id", "erm2-nwe9"], ["select", ""], ["where", ""],
|
| 123 |
+
["group", ""], ["order", ""], ["limit", "50"],
|
| 124 |
+
],
|
| 125 |
+
endpoint: (a) => ["/api/query", a],
|
| 126 |
+
},
|
| 127 |
+
search_datasets: {
|
| 128 |
+
fields: [["query", "rat sightings"], ["limit", "10"]],
|
| 129 |
+
endpoint: (a) => ["/api/search", { q: a.query, limit: a.limit }],
|
| 130 |
+
},
|
| 131 |
+
get_schema: {
|
| 132 |
+
fields: [["dataset_id", "erm2-nwe9"]],
|
| 133 |
+
endpoint: (a) => [`/api/schema/${encodeURIComponent(a.dataset_id)}`, {}],
|
| 134 |
+
},
|
| 135 |
+
profile_column: {
|
| 136 |
+
fields: [["dataset_id", "erm2-nwe9"], ["column", "borough"], ["top", "10"]],
|
| 137 |
+
endpoint: (a) => ["/api/profile", a],
|
| 138 |
+
},
|
| 139 |
+
};
|
| 140 |
+
let current = "query_dataset";
|
| 141 |
+
let sampleIndex = 0;
|
| 142 |
+
|
| 143 |
+
function renderTools() {
|
| 144 |
+
const box = document.getElementById("tools");
|
| 145 |
+
box.innerHTML = "";
|
| 146 |
+
for (const name of Object.keys(TOOLS)) {
|
| 147 |
+
const b = document.createElement("button");
|
| 148 |
+
b.type = "button";
|
| 149 |
+
b.className = "tool" + (name === current ? " on" : "");
|
| 150 |
+
b.innerHTML = `<code>${name}</code>`;
|
| 151 |
+
b.onclick = () => { current = name; renderTools(); renderFields({}); };
|
| 152 |
+
box.appendChild(b);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
function renderFields(values) {
|
| 156 |
+
const box = document.getElementById("fields");
|
| 157 |
+
box.innerHTML = "";
|
| 158 |
+
for (const [name, placeholder] of TOOLS[current].fields) {
|
| 159 |
+
const l = document.createElement("label");
|
| 160 |
+
l.textContent = name;
|
| 161 |
+
const i = document.createElement("input");
|
| 162 |
+
i.name = name;
|
| 163 |
+
i.placeholder = placeholder;
|
| 164 |
+
if (values[name] !== undefined) i.value = values[name];
|
| 165 |
+
box.append(l, i);
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
function formArgs() {
|
| 169 |
+
const args = {};
|
| 170 |
+
for (const input of document.querySelectorAll("#fields input"))
|
| 171 |
+
if (input.value.trim()) args[input.name] = input.value.trim();
|
| 172 |
+
return args;
|
| 173 |
+
}
|
| 174 |
+
function showCall(tool, args) {
|
| 175 |
+
const argsJson = JSON.stringify(args, null, 2).replace(/"([^"]+)":/g, '<span class="k">"$1"</span>:')
|
| 176 |
+
.replace(/: "([^"]*)"/g, ': <span class="v">"$1"</span>');
|
| 177 |
+
document.getElementById("call").innerHTML =
|
| 178 |
+
`{ <span class="k">"name"</span>: <span class="v">"${tool}"</span>, <span class="k">"arguments"</span>: ${argsJson} }`;
|
| 179 |
+
}
|
| 180 |
+
async function runTool(ev) {
|
| 181 |
+
if (ev) ev.preventDefault();
|
| 182 |
+
const args = formArgs();
|
| 183 |
+
showCall(current, args);
|
| 184 |
+
const [path, params] = TOOLS[current].endpoint(args);
|
| 185 |
+
const qs = new URLSearchParams(params).toString();
|
| 186 |
+
const t0 = performance.now();
|
| 187 |
+
const resultBox = document.getElementById("result");
|
| 188 |
+
const meta = document.getElementById("meta");
|
| 189 |
+
meta.textContent = "…";
|
| 190 |
+
resultBox.innerHTML = "";
|
| 191 |
+
try {
|
| 192 |
+
const res = await fetch(path + (qs ? "?" + qs : ""));
|
| 193 |
+
const body = await res.json();
|
| 194 |
+
const ms = Math.round(performance.now() - t0);
|
| 195 |
+
if (!res.ok) throw new Error(body.detail || res.statusText);
|
| 196 |
+
render(body, ms);
|
| 197 |
+
} catch (e) {
|
| 198 |
+
meta.textContent = "";
|
| 199 |
+
resultBox.innerHTML = `<div class="err">✗ ${e.message}</div>`;
|
| 200 |
+
}
|
| 201 |
+
return false;
|
| 202 |
+
}
|
| 203 |
+
function render(body, ms) {
|
| 204 |
+
const meta = document.getElementById("meta");
|
| 205 |
+
const box = document.getElementById("result");
|
| 206 |
+
box.innerHTML = "";
|
| 207 |
+
if (Array.isArray(body)) {
|
| 208 |
+
meta.innerHTML = `<b>${body.length}</b> row${body.length === 1 ? "" : "s"} · ${ms} ms`;
|
| 209 |
+
if (!body.length) { box.innerHTML = '<div class="empty">No rows.</div>'; return; }
|
| 210 |
+
const cols = Object.keys(body[0]);
|
| 211 |
+
if (cols.length === 2 && body.length <= 30 && body.every(r => isFinite(+r[cols[1]]))) {
|
| 212 |
+
box.appendChild(barChart(body, cols));
|
| 213 |
+
}
|
| 214 |
+
box.appendChild(tableOf(body, cols));
|
| 215 |
+
} else if (body && body.columns) {
|
| 216 |
+
meta.innerHTML = `<b>${body.name || body.id}</b> · ${body.columns.length} columns`;
|
| 217 |
+
box.appendChild(tableOf(body.columns.map(c => ({ column: c.field_name, type: c.type })), ["column", "type"]));
|
| 218 |
+
} else {
|
| 219 |
+
meta.textContent = "";
|
| 220 |
+
box.innerHTML = `<pre class="call">${JSON.stringify(body, null, 2)}</pre>`;
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
function tableOf(rows, cols) {
|
| 224 |
+
const wrap = document.createElement("div");
|
| 225 |
+
wrap.className = "scroll";
|
| 226 |
+
const numeric = cols.map(c => rows.every(r => r[c] === undefined || isFinite(+r[c])));
|
| 227 |
+
let html = "<table><tr>" + cols.map((c, i) => `<th${numeric[i] ? ' class="num"' : ""}>${c}</th>`).join("") + "</tr>";
|
| 228 |
+
for (const r of rows.slice(0, 200))
|
| 229 |
+
html += "<tr>" + cols.map((c, i) => {
|
| 230 |
+
let v = r[c] === undefined ? "" : String(r[c]);
|
| 231 |
+
if (numeric[i] && v) v = (+v).toLocaleString();
|
| 232 |
+
return `<td${numeric[i] ? ' class="num"' : ""}>${escapeHtml(v).slice(0, 120)}</td>`;
|
| 233 |
+
}).join("") + "</tr>";
|
| 234 |
+
wrap.innerHTML = html + "</table>";
|
| 235 |
+
return wrap;
|
| 236 |
+
}
|
| 237 |
+
function barChart(rows, cols) {
|
| 238 |
+
const box = document.createElement("div");
|
| 239 |
+
box.className = "chart";
|
| 240 |
+
const max = Math.max(...rows.map(r => +r[cols[1]]));
|
| 241 |
+
for (const r of rows.slice(0, 12)) {
|
| 242 |
+
const v = +r[cols[1]];
|
| 243 |
+
const row = document.createElement("div");
|
| 244 |
+
row.className = "bar-row";
|
| 245 |
+
row.title = `${r[cols[0]]}: ${v.toLocaleString()}`;
|
| 246 |
+
row.innerHTML = `<div class="bar-label">${escapeHtml(String(r[cols[0]] ?? "—"))}</div>
|
| 247 |
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.max(1, v / max * 100)}%"></div></div>
|
| 248 |
+
<div class="bar-val">${v.toLocaleString()}</div>`;
|
| 249 |
+
box.appendChild(row);
|
| 250 |
+
}
|
| 251 |
+
return box;
|
| 252 |
+
}
|
| 253 |
+
function escapeHtml(s) {
|
| 254 |
+
return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
| 255 |
+
}
|
| 256 |
+
async function loadSample() {
|
| 257 |
+
const res = await fetch(`/sample?index=${sampleIndex}`);
|
| 258 |
+
const s = await res.json();
|
| 259 |
+
sampleIndex += 1;
|
| 260 |
+
current = s.tool;
|
| 261 |
+
renderTools();
|
| 262 |
+
const values = {};
|
| 263 |
+
for (const [k, v] of Object.entries(s.args)) values[k === "query" ? "query" : k] = v;
|
| 264 |
+
renderFields(values);
|
| 265 |
+
runTool();
|
| 266 |
+
}
|
| 267 |
+
renderTools();
|
| 268 |
+
renderFields({});
|
| 269 |
+
const qs = new URLSearchParams(location.search);
|
| 270 |
+
if (qs.get("demo")) {
|
| 271 |
+
if (qs.get("err")) {
|
| 272 |
+
// guardrail demo: a malformed tool call, answered with a readable error
|
| 273 |
+
current = "query_dataset";
|
| 274 |
+
renderTools();
|
| 275 |
+
renderFields({ dataset_id: "311-complaints", select: "borough, count(*)" });
|
| 276 |
+
runTool();
|
| 277 |
+
} else {
|
| 278 |
+
sampleIndex = +(qs.get("sample") || 0);
|
| 279 |
+
loadSample();
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
</script>
|
| 283 |
+
</body>
|
| 284 |
</html>
|
shim.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Static-Space shim: answer the playground's own /api/* and /sample routes
|
| 2 |
+
// in the browser by calling the Socrata APIs directly (they send
|
| 3 |
+
// Access-Control-Allow-Origin: *). This keeps index.html identical to the
|
| 4 |
+
// FastAPI-served version — only the transport changes.
|
| 5 |
+
(function () {
|
| 6 |
+
const DOMAIN = "data.cityofnewyork.us";
|
| 7 |
+
const ID_RE = /^[a-z0-9]{4}-[a-z0-9]{4}$/;
|
| 8 |
+
const MAX_LIMIT = 1000;
|
| 9 |
+
|
| 10 |
+
const SAMPLES = [
|
| 11 |
+
{
|
| 12 |
+
title: "311 complaints by borough, 2026",
|
| 13 |
+
tool: "query_dataset",
|
| 14 |
+
args: {
|
| 15 |
+
dataset_id: "erm2-nwe9",
|
| 16 |
+
select: "borough, count(*) as n",
|
| 17 |
+
where: "created_date > '2026-01-01T00:00:00'",
|
| 18 |
+
group: "borough",
|
| 19 |
+
order: "n DESC",
|
| 20 |
+
},
|
| 21 |
+
},
|
| 22 |
+
{ title: "Search the catalog for squirrels", tool: "search_datasets", args: { query: "squirrel census" } },
|
| 23 |
+
{
|
| 24 |
+
title: "Top complaint types this year",
|
| 25 |
+
tool: "profile_column",
|
| 26 |
+
args: { dataset_id: "erm2-nwe9", column: "complaint_type", top: 10 },
|
| 27 |
+
},
|
| 28 |
+
{ title: "Schema of the 2015 Street Tree Census", tool: "get_schema", args: { dataset_id: "uvpi-gqnh" } },
|
| 29 |
+
];
|
| 30 |
+
|
| 31 |
+
const ok = (body) => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
|
| 32 |
+
const fail = (status, detail) => new Response(JSON.stringify({ detail }), { status, headers: { "Content-Type": "application/json" } });
|
| 33 |
+
|
| 34 |
+
function checkId(id) {
|
| 35 |
+
if (!ID_RE.test((id || "").trim().toLowerCase()))
|
| 36 |
+
throw { status: 422, detail: `invalid dataset id '${id}': expected the Socrata 4x4 form, e.g. 'erm2-nwe9'` };
|
| 37 |
+
return id.trim().toLowerCase();
|
| 38 |
+
}
|
| 39 |
+
const clamp = (v, dflt) => Math.max(1, Math.min(parseInt(v || dflt, 10) || dflt, MAX_LIMIT));
|
| 40 |
+
|
| 41 |
+
async function upstream(url) {
|
| 42 |
+
const res = await fetch(url);
|
| 43 |
+
if (!res.ok) {
|
| 44 |
+
let detail = "";
|
| 45 |
+
try { detail = (await res.json()).message || ""; } catch (e) { /* not json */ }
|
| 46 |
+
if (res.status === 404) throw { status: 502, detail: "not found — check the dataset id and domain" };
|
| 47 |
+
if (res.status === 400) throw { status: 502, detail: `the API rejected the query (${detail || "bad request"}) — check your SoQL syntax` };
|
| 48 |
+
throw { status: 502, detail: `HTTP ${res.status} from Socrata: ${detail}` };
|
| 49 |
+
}
|
| 50 |
+
return res.json();
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function route(path, params) {
|
| 54 |
+
if (path === "/sample") {
|
| 55 |
+
const i = parseInt(params.get("index") || "0", 10) || 0;
|
| 56 |
+
return SAMPLES[((i % SAMPLES.length) + SAMPLES.length) % SAMPLES.length];
|
| 57 |
+
}
|
| 58 |
+
if (path === "/api/search") {
|
| 59 |
+
const data = await upstream(
|
| 60 |
+
"https://api.us.socrata.com/api/catalog/v1?domains=" + DOMAIN +
|
| 61 |
+
"&only=dataset&q=" + encodeURIComponent(params.get("q") || "") +
|
| 62 |
+
"&limit=" + clamp(params.get("limit"), 10)
|
| 63 |
+
);
|
| 64 |
+
return (data.results || []).map((r) => ({
|
| 65 |
+
id: r.resource.id,
|
| 66 |
+
name: r.resource.name,
|
| 67 |
+
description: (r.resource.description || "").slice(0, 500),
|
| 68 |
+
updated: r.resource.data_updated_at,
|
| 69 |
+
domain: DOMAIN,
|
| 70 |
+
}));
|
| 71 |
+
}
|
| 72 |
+
if (path.startsWith("/api/schema/")) {
|
| 73 |
+
const id = checkId(path.split("/").pop());
|
| 74 |
+
const data = await upstream(`https://${DOMAIN}/api/views/${id}.json`);
|
| 75 |
+
return {
|
| 76 |
+
id,
|
| 77 |
+
domain: DOMAIN,
|
| 78 |
+
name: data.name,
|
| 79 |
+
description: (data.description || "").slice(0, 1000),
|
| 80 |
+
columns: (data.columns || [])
|
| 81 |
+
.filter((c) => !String(c.fieldName || "").startsWith(":"))
|
| 82 |
+
.map((c) => ({ field_name: c.fieldName, type: c.dataTypeName, description: (c.description || "").slice(0, 300) })),
|
| 83 |
+
};
|
| 84 |
+
}
|
| 85 |
+
if (path === "/api/query" || path === "/api/profile") {
|
| 86 |
+
const id = checkId(params.get("dataset_id"));
|
| 87 |
+
const q = new URLSearchParams();
|
| 88 |
+
if (path === "/api/profile") {
|
| 89 |
+
const col = params.get("column") || "";
|
| 90 |
+
q.set("$select", `${col}, count(*) as count`);
|
| 91 |
+
q.set("$group", col);
|
| 92 |
+
q.set("$order", "count DESC");
|
| 93 |
+
q.set("$limit", clamp(params.get("top"), 20));
|
| 94 |
+
} else {
|
| 95 |
+
for (const [key, dollar] of [["select", "$select"], ["where", "$where"], ["group", "$group"], ["order", "$order"]])
|
| 96 |
+
if (params.get(key)) q.set(dollar, params.get(key));
|
| 97 |
+
q.set("$limit", clamp(params.get("limit"), 50));
|
| 98 |
+
if (+params.get("offset")) q.set("$offset", +params.get("offset"));
|
| 99 |
+
}
|
| 100 |
+
return upstream(`https://${DOMAIN}/resource/${id}.json?` + q.toString());
|
| 101 |
+
}
|
| 102 |
+
throw { status: 404, detail: "unknown route " + path };
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const realFetch = window.fetch.bind(window);
|
| 106 |
+
window.fetch = async function (input, init) {
|
| 107 |
+
const url = typeof input === "string" ? input : input.url;
|
| 108 |
+
if (url.startsWith("/api/") || url.startsWith("/sample")) {
|
| 109 |
+
const u = new URL(url, location.origin);
|
| 110 |
+
try {
|
| 111 |
+
return ok(await route(u.pathname, u.searchParams));
|
| 112 |
+
} catch (e) {
|
| 113 |
+
return fail(e.status || 502, e.detail || String(e));
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
return realFetch(input, init);
|
| 117 |
+
};
|
| 118 |
+
})();
|