Files
neuronetz-gateway/playground/index.html
Stephan Berbig b47a09db91 demo + playground + docs
One-command demo so the gateway can be exercised end-to-end without a GPU or a
real model download:

- demo/mock-ollama/ — tiny FastAPI service emulating Ollama (/api/tags,
  /api/chat + /api/generate NDJSON streaming with realistic prompt_eval_count
  and eval_count on the final frame, /api/embed, /api/show, /api/version).
  Non-root multi-stage Dockerfile, never published (internal network only).
- docker-compose.demo.yml — postgres + redis + mock-ollama + gateway, with
  PLAYGROUND_ENABLED=true and ./playground mounted read-only at /app/playground.
  Mirrors the prod posture (mock-ollama not exposed).
- demo.sh — brings the stack up, waits on /healthz, creates a demo tenant with
  allow_all_models and a fresh API key via the bootstrap CLI inside the
  container, then prints the key, the playground URL, and five ready-to-paste
  curl commands (SSE chat, NDJSON chat, /v1/models, a 401, a 403 /api/pull).
  ./demo.sh --down tears everything back down with volumes.
- playground/index.html — single-file dark-themed UI served same-origin by
  the gateway at /playground (CORS-free). Per-endpoint About card with method/
  auth/streaming badges, a real description, sample request body, sample
  response, and a footer note. Live SSE/NDJSON rendering of the response.
  A live, copyable curl box that mirrors exactly what Run sends. Run + Refresh
  are visibly gated until an API key is in the field; the Base URL is
  force-pinned to location.origin three times to defeat browser autofill.
- docs/ — API.md (full endpoint reference with curl, streaming formats, error
  model, SPEC §6.5 response headers), ARCHITECTURE.md (incl. §4.6 discovery
  + the request lifecycle), DEPLOYMENT.md (Ollama-never-exposed rule,
  pointing at a real Ollama backend, env reference), THREAT_MODEL.md
  (SPEC §3 table + the allow_all_models opt-in notes), OPERATIONS.md
  (key/budget/model/usage runbook + fail-closed table), PLAYGROUND.md.
  mkdocs.yml (Material theme) wires them together.
2026-05-26 20:52:33 +02:00

717 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>neuronetz-gateway · playground</title>
<style>
:root {
--bg: #0a0e16;
--bg-2: #10151f;
--panel: #141b27;
--panel-2: #1a2333;
--border: #243047;
--text: #e6edf6;
--muted: #8b9bb4;
--accent: #4f8cff;
--accent-2: #7c5cff;
--good: #3fcf8e;
--warn: #f0b429;
--bad: #ff5d6c;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background:
radial-gradient(1200px 600px at 80% -10%, rgba(124,92,255,.10), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(79,140,255,.10), transparent 55%),
var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); }
header {
display: flex; align-items: center; gap: 14px;
padding: 18px 26px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,.02), transparent);
position: sticky; top: 0; z-index: 5;
backdrop-filter: blur(6px);
}
.logo {
width: 34px; height: 34px; border-radius: 9px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
display: grid; place-items: center;
font-weight: 800; color: #fff; letter-spacing: -1px;
box-shadow: 0 6px 20px rgba(79,140,255,.35);
}
header h1 { font-size: 16px; margin: 0; font-weight: 700; letter-spacing: .2px; }
header .sub { color: var(--muted); font-size: 12px; }
.grow { flex: 1; }
.pill {
font-size: 11px; color: var(--muted);
border: 1px solid var(--border); border-radius: 999px;
padding: 4px 10px; font-family: var(--mono);
}
main {
display: grid;
grid-template-columns: 380px 1fr;
gap: 18px;
padding: 18px 26px 40px;
max-width: 1400px; margin: 0 auto;
}
@media (max-width: 920px) { main { grid-template-columns: 1fr; } }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.panel h2 {
font-size: 12px; text-transform: uppercase; letter-spacing: .12em;
color: var(--muted); margin: 0 0 12px;
}
label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 5px; }
label:first-of-type { margin-top: 0; }
input, select, textarea {
width: 100%; background: var(--bg-2); color: var(--text);
border: 1px solid var(--border); border-radius: 9px;
padding: 9px 11px; font-size: 13px; font-family: var(--sans);
outline: none; transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(79,140,255,.18);
}
textarea { resize: vertical; min-height: 90px; font-family: var(--mono); font-size: 12.5px; }
.row { display: flex; gap: 8px; }
.row > * { flex: 1; }
.inline { display: flex; align-items: center; gap: 8px; }
.inline input[type=checkbox] { width: auto; }
.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tab {
font-family: var(--mono); font-size: 11.5px;
padding: 6px 10px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--border); background: var(--bg-2); color: var(--muted);
transition: all .12s;
}
.tab:hover { color: var(--text); border-color: #34425f; }
.tab.active {
color: #fff; border-color: transparent;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
}
button.run {
margin-top: 14px; width: 100%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; border: none; border-radius: 10px;
padding: 12px; font-size: 14px; font-weight: 700; cursor: pointer;
box-shadow: 0 8px 22px rgba(79,140,255,.3);
transition: transform .08s, filter .15s;
}
button.run:hover { filter: brightness(1.07); }
button.run:active { transform: translateY(1px); }
button.run:disabled { filter: grayscale(.6) brightness(.8); cursor: progress; }
.ghost {
background: var(--panel-2); color: var(--muted);
border: 1px solid var(--border); border-radius: 8px;
padding: 7px 10px; font-size: 12px; cursor: pointer; transition: all .12s;
}
.ghost:hover { color: var(--text); border-color: #34425f; }
.field-with-btn { display: flex; gap: 8px; align-items: stretch; }
.field-with-btn select { flex: 1; }
.out-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.status {
font-family: var(--mono); font-size: 12px; padding: 3px 9px; border-radius: 7px;
border: 1px solid var(--border); color: var(--muted);
}
.status.s2 { color: var(--good); border-color: rgba(63,207,142,.4); background: rgba(63,207,142,.08); }
.status.s4 { color: var(--warn); border-color: rgba(240,180,41,.4); background: rgba(240,180,41,.08); }
.status.s5 { color: var(--bad); border-color: rgba(255,93,108,.4); background: rgba(255,93,108,.08); }
pre, .codebox {
background: #0b0f17; border: 1px solid var(--border); border-radius: 10px;
padding: 13px; font-family: var(--mono); font-size: 12.5px;
white-space: pre-wrap; word-break: break-word; margin: 0;
max-height: 460px; overflow: auto;
}
.codebox.curl { color: #c9d6ea; }
.out-body { min-height: 120px; }
.headers {
margin-top: 12px; font-family: var(--mono); font-size: 11.5px;
border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
}
.headers .hrow { display: flex; border-top: 1px solid var(--border); }
.headers .hrow:first-child { border-top: none; }
.headers .hk { width: 46%; padding: 6px 10px; color: var(--muted); background: var(--bg-2); }
.headers .hv { flex: 1; padding: 6px 10px; color: var(--text); word-break: break-all; }
.section-title {
display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px;
}
.section-title .copy { font-size: 11px; }
.hint { color: var(--muted); font-size: 11.5px; margin-top: 6px; }
.stack { display: grid; gap: 16px; }
/* "About this endpoint" panel */
.ep-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
#endpointInfo h2 { font-family: ui-monospace, "JetBrains Mono", "Fira Code", monospace; font-size: 14px; letter-spacing: 0.2px; }
.summary { margin: 4px 0 12px; color: var(--text); font-size: 13.5px; line-height: 1.55; }
.sub-title { margin: 10px 0 6px; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.8px; }
.codebox.sample { max-height: 200px; overflow: auto; font-size: 11.5px; color: #c9d6ea; }
.badge {
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.6px;
padding: 2px 7px; border-radius: 999px; border: 1px solid var(--border);
color: var(--muted); background: var(--bg-2);
}
.badge-post { color: #ffb84a; border-color: rgba(255,184,74,.35); background: rgba(255,184,74,.08); }
.badge-get { color: #5fc8ff; border-color: rgba(95,200,255,.35); background: rgba(95,200,255,.08); }
.badge-auth { color: #c9b6ff; border-color: rgba(201,182,255,.35); background: rgba(201,182,255,.08); }
.badge-open { color: #3fcf8e; border-color: rgba(63,207,142,.35); background: rgba(63,207,142,.08); }
.blink { animation: blink 1s steps(2,start) infinite; }
@keyframes blink { to { opacity: .25; } }
</style>
</head>
<body>
<header>
<div class="logo">N</div>
<div>
<h1>neuronetz-gateway <span class="sub">· playground</span></h1>
<div class="sub">Authenticated, rate-limited, audited access to the model backend</div>
</div>
<div class="grow"></div>
<div class="pill" id="originPill">same-origin</div>
</header>
<main>
<!-- ── Left: request builder ─────────────────────────────────────────── -->
<section class="panel">
<h2>Request</h2>
<label for="baseUrl">Base URL</label>
<div class="field-with-btn">
<input id="baseUrl" type="text" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" />
<button class="ghost" id="resetBase" title="Reset Base URL to this page's origin">⟳ This origin</button>
</div>
<label for="apiKey">API key (Bearer)</label>
<input id="apiKey" type="password" placeholder="nz_…" spellcheck="false" autocomplete="off" />
<div class="hint" id="keyHint">Created by <code>./demo.sh</code> and printed once in your terminal.</div>
<label>Endpoint</label>
<div class="tabs" id="tabs"></div>
<div id="modelWrap">
<label for="model">Model</label>
<div class="field-with-btn">
<select id="model"><option value="">— enter a key, then refresh —</option></select>
<button class="ghost" id="refreshModels" title="Load /v1/models with your key">↻ Refresh</button>
</div>
</div>
<div id="promptWrap">
<label for="prompt" id="promptLabel">Prompt</label>
<textarea id="prompt" spellcheck="false">Say hello in one sentence.</textarea>
<label class="inline" id="streamWrap" style="margin-top:10px">
<input id="stream" type="checkbox" checked /> Stream the response
</label>
</div>
<button class="run" id="run">▶ Run</button>
<div class="hint" id="methodHint"></div>
</section>
<!-- ── Right: about + response + curl ────────────────────────────────── -->
<div class="stack">
<section class="panel" id="endpointInfo">
<div class="ep-head">
<h2 id="epTitle" style="margin:0">POST /v1/chat/completions</h2>
<div class="grow"></div>
<span class="badge" id="epMethod">POST</span>
<span class="badge" id="epAuth">auth: bearer</span>
<span class="badge" id="epStream">streams · SSE</span>
</div>
<p class="summary" id="epSummary"></p>
<div class="sub-title">Sample request body</div>
<pre class="codebox sample" id="epSampleReq"></pre>
<div class="sub-title">Sample response</div>
<pre class="codebox sample" id="epSampleResp"></pre>
<div class="hint" id="epNote"></div>
</section>
<section class="panel">
<div class="out-head">
<h2 style="margin:0">Response</h2>
<div class="grow"></div>
<span class="status" id="status">idle</span>
</div>
<pre class="codebox out-body" id="output">Run a request to see the response stream here.</pre>
<div class="headers" id="headers" style="display:none"></div>
</section>
<section class="panel">
<div class="section-title">
<h2 style="margin:0">Exact curl</h2>
<button class="ghost copy" id="copyCurl">Copy</button>
</div>
<pre class="codebox curl" id="curl"></pre>
<div class="hint">This is exactly what <b>Run</b> sends — paste it into a terminal to reproduce.</div>
</section>
</div>
</main>
<script>
"use strict";
// ── Endpoint catalogue ──────────────────────────────────────────────────
// Each endpoint knows its method, format, body shape, and how to render itself
// in the "About this endpoint" panel: summary, sample request, sample response,
// and an optional note. Mirrors SPEC §6.
const ENDPOINTS = {
"/v1/chat/completions": {
method: "POST", canStream: true, format: "sse", needsModel: true, needsPrompt: true,
summary: "OpenAI-compatible Chat Completions — a drop-in replacement for OpenAI's endpoint. Point any OpenAI SDK at this gateway's base URL with your nz_ key and existing client code works unchanged. Streaming uses Server-Sent Events terminated by `data: [DONE]`.",
body: (s) => ({ model: s.model, stream: s.stream, messages: [{ role: "user", content: s.prompt }] }),
sampleRequest: { model: "llama3.1:8b", stream: true, messages: [{ role: "user", content: "Say hello in one sentence." }] },
sampleResponse:
`data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1779492441,"model":"llama3.1:8b","choices":[{"index":0,"delta":{"content":"Echo:"},"finish_reason":null}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1779492441,"model":"llama3.1:8b","choices":[{"index":0,"delta":{"content":" hi"},"finish_reason":null}]}
data: {"id":"chatcmpl-abc","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}
data: [DONE]`,
note: "Non-streaming (`stream: false`) returns one `chat.completion` JSON object — same shape as OpenAI.",
},
"/api/chat": {
method: "POST", canStream: true, format: "ndjson", needsModel: true, needsPrompt: true,
summary: "Native Ollama chat. Streams NDJSON — one JSON object per line; the final object carries `prompt_eval_count` + `eval_count` for exact token accounting in the audit log.",
body: (s) => ({ model: s.model, stream: s.stream, messages: [{ role: "user", content: s.prompt }] }),
sampleRequest: { model: "llama3.1:8b", stream: true, messages: [{ role: "user", content: "Say hello in one sentence." }] },
sampleResponse:
`{"model":"llama3.1:8b","created_at":"…","message":{"role":"assistant","content":"Echo:"},"done":false}
{"model":"llama3.1:8b","created_at":"…","message":{"role":"assistant","content":" hi"},"done":false}
{"model":"llama3.1:8b","created_at":"…","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":2,"total_duration":12345678}`,
note: "Errors are sanitized but every response carries an X-Request-ID; upstream internals never leak.",
},
"/api/generate": {
method: "POST", canStream: true, format: "ndjson", needsModel: true, needsPrompt: true,
summary: "Native Ollama text generation. Takes a plain `prompt` string (no chat message structure) and streams NDJSON `response` chunks plus a final done frame with token counts.",
body: (s) => ({ model: s.model, stream: s.stream, prompt: s.prompt }),
sampleRequest: { model: "mistral:7b", stream: true, prompt: "Say hello in one sentence." },
sampleResponse:
`{"model":"mistral:7b","created_at":"…","response":"Echo:","done":false}
{"model":"mistral:7b","created_at":"…","response":" hi","done":false}
{"model":"mistral:7b","created_at":"…","response":"","done":true,"prompt_eval_count":1,"eval_count":2}`,
note: "Use this when you don't need chat-message structure; otherwise prefer `/api/chat` or `/v1/chat/completions`.",
},
"/v1/models": {
method: "GET", canStream: false, format: "json", needsModel: false, needsPrompt: false,
summary: "Lists the tenant's effective model set in OpenAI format: (live-discovered ∩ allowed_models), or all discovered models when the tenant has allow_all_models enabled. There is no static list — discovery polls the Ollama backend in the background.",
sampleRequest: null,
sampleResponse:
`{
"object": "list",
"data": [
{"id": "llama3.1:8b", "object": "model", "created": 1779492441, "owned_by": "neuronetz"},
{"id": "mistral:7b", "object": "model", "created": 1779492441, "owned_by": "neuronetz"},
{"id": "qwen2.5:3b", "object": "model", "created": 1779492441, "owned_by": "neuronetz"},
{"id": "nomic-embed-text", "object": "model", "created": 1779492441, "owned_by": "neuronetz"}
]
}`,
note: "Refreshed automatically every MODEL_DISCOVERY_REFRESH_S (default 60s). Cached fail-closed.",
},
"/api/tags": {
method: "GET", canStream: false, format: "json", needsModel: false, needsPrompt: false,
summary: "Native Ollama model list, filtered to the tenant's effective set. Same data as /v1/models but in Ollama's `models` shape — includes size, digest, modified_at, family and quantization details.",
sampleRequest: null,
sampleResponse:
`{
"models": [
{
"name": "llama3.1:8b",
"model": "llama3.1:8b",
"modified_at": "2026-04-01T12:00:00Z",
"size": 4920624384,
"digest": "sha256:…",
"details": {"family": "llama", "parameter_size": "8B", "quantization_level": "Q4_K_M"}
}
]
}`,
note: "",
},
"/healthz": {
method: "GET", canStream: false, format: "json", needsModel: false, needsPrompt: false, noAuth: true,
summary: "Liveness probe. Returns 200 as long as the gateway process can respond — does NOT check downstream dependencies. Safe for load-balancer health checks. No authentication required.",
sampleRequest: null,
sampleResponse: `{"status": "ok"}`,
note: "",
},
"/readyz": {
method: "GET", canStream: false, format: "json", needsModel: false, needsPrompt: false, noAuth: true,
summary: "Readiness probe. Returns 200 only when Postgres + Redis + the Ollama backend are all reachable; 503 otherwise with which dependencies are down. No authentication required.",
sampleRequest: null,
sampleResponse:
`# 200 OK
{"status": "ready", "checks": {"postgres": true, "redis": true, "ollama": true}}
# 503 Service Unavailable
{"status": "not_ready", "checks": {"postgres": true, "redis": true, "ollama": false}}`,
note: "In this demo, /readyz will return 200 — the mock Ollama is reachable. In dev-only stacks without an Ollama backend, /readyz fails closed.",
},
};
// Response headers worth surfacing (SPEC §6.5).
const SURFACE_HEADERS = [
"x-request-id",
"x-ratelimit-limit-requests", "x-ratelimit-remaining-requests",
"x-ratelimit-limit-tokens", "x-ratelimit-remaining-tokens",
"x-budget-period", "x-budget-tokens-remaining",
"retry-after", "content-type",
];
const $ = (id) => document.getElementById(id);
let current = "/v1/chat/completions";
// ── State helpers ───────────────────────────────────────────────────────
function state() {
return {
base: $("baseUrl").value.replace(/\/+$/, ""),
key: $("apiKey").value.trim(),
model: $("model").value,
prompt: $("prompt").value,
stream: $("stream").checked,
};
}
function buildTabs() {
const tabs = $("tabs");
tabs.innerHTML = "";
for (const path of Object.keys(ENDPOINTS)) {
const el = document.createElement("div");
el.className = "tab" + (path === current ? " active" : "");
el.textContent = path;
el.onclick = () => { current = path; buildTabs(); syncForm(); updateCurl(); };
tabs.appendChild(el);
}
}
function syncForm() {
const ep = ENDPOINTS[current];
$("modelWrap").style.display = ep.needsModel ? "" : "none";
$("promptWrap").style.display = ep.needsPrompt ? "" : "none";
$("streamWrap").style.display = ep.canStream ? "" : "none";
$("promptLabel").textContent = current === "/api/generate" ? "Prompt" : "Message";
$("methodHint").textContent = `${ep.method} · ${ep.canStream ? `streams ${ep.format.toUpperCase()}` : ep.format.toUpperCase()} · ${ep.noAuth ? "no auth" : "requires Bearer"}`;
renderEndpointInfo();
refreshGating();
}
// Populate the "About this endpoint" panel from the current endpoint's metadata.
function renderEndpointInfo() {
const ep = ENDPOINTS[current];
$("epTitle").textContent = ep.method + " " + current;
const method = $("epMethod");
method.textContent = ep.method;
method.className = "badge badge-" + ep.method.toLowerCase();
const auth = $("epAuth");
auth.textContent = ep.noAuth ? "no auth" : "auth: bearer";
auth.className = "badge " + (ep.noAuth ? "badge-open" : "badge-auth");
const streamBadge = $("epStream");
if (ep.canStream) {
streamBadge.style.display = "";
streamBadge.textContent = "streams · " + (ep.format === "sse" ? "SSE" : "NDJSON");
} else {
streamBadge.style.display = "none";
}
$("epSummary").textContent = ep.summary;
$("epSampleReq").textContent = ep.sampleRequest
? JSON.stringify(ep.sampleRequest, null, 2)
: "(no request body — GET)";
$("epSampleResp").textContent = ep.sampleResponse;
const note = $("epNote");
if (ep.note) { note.textContent = ep.note; note.style.display = ""; }
else { note.style.display = "none"; }
}
// Visibly disable Run/Refresh when no key is present (most endpoints need auth)
// and surface the reason RIGHT next to the API-key field — not just in the right
// pane where it's easy to miss.
function refreshGating() {
const ep = ENDPOINTS[current];
const hasKey = $("apiKey").value.trim().length > 0;
const needsKey = !ep.noAuth;
const run = $("run");
const refresh = $("refreshModels");
const blocked = needsKey && !hasKey;
run.disabled = blocked;
refresh.disabled = !hasKey; // refresh always needs a key
run.style.opacity = blocked ? "0.45" : "";
run.style.cursor = blocked ? "not-allowed" : "";
refresh.style.opacity = !hasKey ? "0.45" : "";
refresh.style.cursor = !hasKey ? "not-allowed" : "";
const hint = $("keyHint");
if (blocked) {
hint.innerHTML = "⚠ <b style=\"color:#ffb84a\">Paste your API key above</b> to enable Run and Refresh. Get one by running <code>./demo.sh</code>.";
} else {
hint.innerHTML = "Created by <code>./demo.sh</code> and printed once in your terminal.";
}
}
// ── curl preview (must match exactly what Run sends) ────────────────────
function buildRequest() {
const s = state();
const ep = ENDPOINTS[current];
const url = (s.base || location.origin) + current;
const headers = {};
if (!ep.noAuth) headers["Authorization"] = "Bearer " + (s.key || "nz_YOUR_KEY");
let body = null;
if (ep.method === "POST") {
headers["Content-Type"] = "application/json";
body = JSON.stringify(ep.body(s));
}
return { url, method: ep.method, headers, body, ep };
}
function updateCurl() {
const r = buildRequest();
const parts = ["curl"];
if (r.ep.canStream && state().stream && r.method === "POST") parts.push("-N");
if (r.method === "GET") parts.push("-i");
parts.push(shellQuote(r.url));
for (const [k, v] of Object.entries(r.headers)) {
parts.push("\\\n -H " + shellQuote(k + ": " + v));
}
if (r.body) parts.push("\\\n -d " + shellQuote(r.body));
$("curl").textContent = parts.join(" ");
}
function shellQuote(s) {
if (/^[A-Za-z0-9_\-:/.@]+$/.test(s)) return s;
return "'" + s.replace(/'/g, "'\\''") + "'";
}
// ── Status + header rendering ───────────────────────────────────────────
function setStatus(text, code) {
const el = $("status");
el.textContent = text;
el.className = "status" + (code ? " s" + String(code)[0] : "");
}
function renderHeaders(resp) {
const box = $("headers");
const rows = [];
for (const h of SURFACE_HEADERS) {
const v = resp.headers.get(h);
if (v != null) rows.push([h, v]);
}
if (!rows.length) { box.style.display = "none"; return; }
box.innerHTML = rows.map(([k, v]) =>
`<div class="hrow"><div class="hk">${k}</div><div class="hv">${escapeHtml(v)}</div></div>`
).join("");
box.style.display = "";
}
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
}
// ── Model dropdown population ───────────────────────────────────────────
async function refreshModels() {
const s = state();
if (!s.key) { setOutput("Enter an API key first, then refresh models."); return; }
const sel = $("model");
const btn = $("refreshModels");
btn.disabled = true; btn.textContent = "…";
try {
const resp = await fetch((s.base || location.origin) + "/v1/models", {
headers: { "Authorization": "Bearer " + s.key },
});
if (!resp.ok) { setOutput("Could not load models: HTTP " + resp.status); return; }
const data = await resp.json();
const names = (data.data || []).map((m) => m.id).filter(Boolean);
const prev = sel.value;
sel.innerHTML = "";
if (!names.length) {
sel.innerHTML = '<option value="">(no models in your effective set)</option>';
} else {
for (const n of names) {
const o = document.createElement("option");
o.value = n; o.textContent = n;
sel.appendChild(o);
}
if (names.includes(prev)) sel.value = prev;
}
updateCurl();
} catch (e) {
setOutput("Network error loading models: " + e.message);
} finally {
btn.disabled = false; btn.textContent = "↻ Refresh";
}
}
function setOutput(text) { $("output").textContent = text; }
function appendOutput(text) { $("output").textContent += text; }
// ── Run ─────────────────────────────────────────────────────────────────
let running = false;
async function run() {
if (running) return;
running = true;
const btn = $("run");
btn.disabled = true;
setStatus("connecting…");
setOutput("");
$("headers").style.display = "none";
const r = buildRequest();
const willStream = r.ep.canStream && state().stream && r.method === "POST";
try {
const resp = await fetch(r.url, { method: r.method, headers: r.headers, body: r.body });
setStatus(resp.status + " " + resp.statusText, resp.status);
renderHeaders(resp);
if (willStream && resp.body && resp.ok) {
await consumeStream(resp, r.ep.format);
} else {
const text = await resp.text();
setOutput(prettyMaybeJson(text));
}
} catch (e) {
setStatus("network error", 5);
setOutput("Request failed: " + e.message + "\n\n(Check the Base URL and that the gateway is running.)");
} finally {
running = false;
btn.disabled = false;
}
}
function prettyMaybeJson(text) {
try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text || "(empty response)"; }
}
// Parse SSE (data: {...} … data: [DONE]) or NDJSON (one JSON object per line),
// rendering text deltas live as they arrive.
async function consumeStream(resp, format) {
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
setOutput("");
const cursor = "▌";
const render = (txt) => { $("output").textContent = txt + cursor; };
let acc = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
// SSE events are separated by blank lines; NDJSON by single newlines.
const sep = format === "sse" ? "\n\n" : "\n";
while ((idx = buffer.indexOf(sep)) >= 0) {
const raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + sep.length);
acc += handleEvent(raw, format);
render(acc);
}
}
if (buffer.trim()) acc += handleEvent(buffer, format);
$("output").textContent = acc || "(stream produced no text)";
}
// Returns the text delta extracted from one event/line.
function handleEvent(raw, format) {
if (format === "sse") {
let out = "";
for (let line of raw.split("\n")) {
line = line.trim();
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") continue;
try {
const obj = JSON.parse(payload);
const delta = obj.choices && obj.choices[0] && obj.choices[0].delta;
if (delta && typeof delta.content === "string") out += delta.content;
} catch { /* ignore keep-alives / partial */ }
}
return out;
}
// NDJSON
const line = raw.trim();
if (!line) return "";
try {
const obj = JSON.parse(line);
if (obj.message && typeof obj.message.content === "string") return obj.message.content; // /api/chat
if (typeof obj.response === "string") return obj.response; // /api/generate
} catch { /* partial line */ }
return "";
}
// ── Wiring ──────────────────────────────────────────────────────────────
function init() {
// Set the base URL to this page's origin. Browsers love to autofill text
// inputs from history *after* the page scripts run, so we ALSO re-assert it on
// the next microtask and again after a short delay — that wins against
// chromium/firefox autofill, which can otherwise replace the value with a
// stale entry like https://api.neuronetz.ai.
const setOrigin = () => { $("baseUrl").value = location.origin; };
setOrigin();
$("originPill").textContent = location.origin;
queueMicrotask(setOrigin);
setTimeout(setOrigin, 250);
buildTabs();
syncForm();
updateCurl();
refreshGating();
for (const id of ["baseUrl", "apiKey", "model", "prompt"]) {
$(id).addEventListener("input", updateCurl);
}
$("apiKey").addEventListener("input", refreshGating);
$("stream").addEventListener("change", updateCurl);
$("run").addEventListener("click", run);
$("refreshModels").addEventListener("click", refreshModels);
$("resetBase").addEventListener("click", () => {
$("baseUrl").value = location.origin;
updateCurl();
});
$("copyCurl").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText($("curl").textContent);
const b = $("copyCurl"); b.textContent = "Copied!"; setTimeout(() => (b.textContent = "Copy"), 1200);
} catch { /* clipboard may be blocked; ignore */ }
});
// Convenience: refresh models when a key is pasted/typed (debounced).
let t = null;
$("apiKey").addEventListener("input", () => {
clearTimeout(t);
if ($("apiKey").value.trim().length > 8) t = setTimeout(refreshModels, 500);
});
}
document.addEventListener("DOMContentLoaded", init);
</script>
</body>
</html>