semif-api-rocm

SemIf HTTP API and rocm flake
Log | Files | Refs | README | LICENSE

app.js (33299B)


      1 /* semif-api web UI — plain DOM, no dependencies.
      2  *
      3  * Conventions that matter:
      4  *   - Option rows and decision cards are UNCONTROLLED: the DOM is the source of
      5  *     truth and is read on submit. Nothing re-renders a list from state, so
      6  *     focus and caret survive typing.
      7  *   - Every string that comes from the API or from user input is written with
      8  *     textContent. innerHTML is never used with interpolated data.
      9  */
     10 "use strict";
     11 
     12 const $ = (sel, root) => (root || document).querySelector(sel);
     13 const el = (tag, cls, text) => {
     14   const node = document.createElement(tag);
     15   if (cls) node.className = cls;
     16   if (text !== undefined && text !== null) node.textContent = text;
     17   return node;
     18 };
     19 const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };
     20 
     21 const MAX_OPTIONS = 16;
     22 const DRAFT_KEY = "semif-ui-draft-v1";
     23 
     24 /* ── numeric helpers ───────────────────────────────────────────────── */
     25 
     26 const argmax = (values) =>
     27   values.reduce((best, value, i) => (value > values[best] ? i : best), 0);
     28 const p4 = (value) => (typeof value === "number" ? value.toFixed(4) : String(value));
     29 const p3 = (value) => (typeof value === "number" ? value.toFixed(3) : String(value));
     30 const ms = (seconds) =>
     31   typeof seconds === "number" ? (seconds * 1000).toFixed(0) + " ms" : "—";
     32 
     33 /* ── HTTP ──────────────────────────────────────────────────────────── */
     34 
     35 async function api(path, body) {
     36   const started = performance.now();
     37   let response;
     38   try {
     39     response = await fetch(path, {
     40       method: body ? "POST" : "GET",
     41       headers: body ? { "Content-Type": "application/json" } : undefined,
     42       body: body ? JSON.stringify(body) : undefined,
     43     });
     44   } catch (networkError) {
     45     return { ok: false, kind: "network", text:
     46       "Could not reach " + location.origin + path + " — the server is down, or the request went to the wrong origin." };
     47   }
     48   const elapsedMs = performance.now() - started;
     49   let payload = null;
     50   const raw = await response.text();
     51   try { payload = raw ? JSON.parse(raw) : null; } catch (_) { payload = null; }
     52   if (!response.ok) return { ok: false, kind: response.status >= 500 ? "server" : "client",
     53                              status: response.status, payload: payload, raw: raw, elapsedMs: elapsedMs };
     54   if (payload === null) return { ok: false, kind: "client", status: response.status,
     55                                  text: "Response was not JSON (" + raw.slice(0, 200) + ")" };
     56   return { ok: true, payload: payload, elapsedMs: elapsedMs };
     57 }
     58 
     59 /* FastAPI returns two different 422 shapes and both are part of the contract:
     60  * a plain string from the upstream validate_row messages, and pydantic's list
     61  * of {loc, msg} objects for malformed bodies. Handle both.               */
     62 function errorText(outcome) {
     63   if (outcome.text) return outcome.text;
     64   const detail = outcome.payload && outcome.payload.detail;
     65   if (typeof detail === "string") return detail;
     66   if (Array.isArray(detail)) {
     67     const lines = detail.map((item) => {
     68       const loc = Array.isArray(item.loc) ? item.loc.filter((p) => p !== "body").join(".") : "";
     69       return (loc ? loc + ": " : "") + (item.msg || JSON.stringify(item));
     70     });
     71     return "Request body rejected:\n" + lines.join("\n");
     72   }
     73   if (outcome.kind === "server") {
     74     return "HTTP " + outcome.status + " from the server -- an unexpected fault, not a rejected " +
     75       "input. Input problems come back as 422 with the upstream message; if a long state or " +
     76       "duplicate batch ids landed here instead, the server predates that mapping. The reason is " +
     77       "in the server log:\n" +
     78       "  journalctl -u semif-api -n 40     # systemd\n" +
     79       "  tail -n 40 uvicorn.log            # dev shell";
     80   }
     81   return "HTTP " + outcome.status + (outcome.raw ? "\n" + outcome.raw.slice(0, 400) : "");
     82 }
     83 
     84 /* ── client-side mirror of upstream validate_row ───────────────────────
     85  * Mirrors semif_phase1.core.validate_row so an obvious mistake is instant
     86  * instead of a round trip. The server stays the authority: whatever passes
     87  * here is still sent and its 422 is still displayed.                    */
     88 function validateRow(row, label) {
     89   const problems = [];
     90   if (!row.id) problems.push(label + ": id must be a nonempty string");
     91   if (!row.question) problems.push(label + ": question must be a nonempty string");
     92   const state = row.state;
     93   const stateEmpty = typeof state === "string" ? !state : !(state && Object.keys(state).length);
     94   if (stateEmpty) problems.push(label + ": state must be a nonempty string, object, or array");
     95   const n = row.options.length;
     96   if (n < 2 || n > MAX_OPTIONS) problems.push(label + ": options must contain 2-16 entries (have " + n + ")");
     97   if (!row.options.every((o) => o.id && o.description)) problems.push(label + ": each option needs id and description");
     98   const ids = row.options.map((o) => o.id);
     99   if (new Set(ids).size !== ids.length) problems.push(label + ": option ids must be unique");
    100   return problems;
    101 }
    102 
    103 /* Rough token guard: the browser cannot tokenize, so this is deliberately
    104  * worded as an estimate. It exists because encode_prompt rejects an over-budget
    105  * prompt before any scoring happens -- warning first saves a round trip and
    106  * explains a rejection you would otherwise have to go find.               */
    107 function stateWarning(chars, maxTokens) {
    108   if (!maxTokens || !chars) return "";
    109   const estimate = Math.round(chars / 3);
    110   if (estimate > maxTokens * 0.8) {
    111     return "~" + estimate + " estimated input tokens against a " + maxTokens +
    112       " limit. Long states are rejected without truncation — trim the evidence or raise SEMIF_MAX_TOKENS.";
    113   }
    114   return "";
    115 }
    116 
    117 /* ── option rows (uncontrolled) ───────────────────────────────────── */
    118 
    119 function makeOptionRow(idValue, descValue) {
    120   const row = el("div", "option-row");
    121   const id = el("input", "mono opt-id");
    122   id.value = idValue || "";
    123   id.placeholder = "id";
    124   id.setAttribute("aria-label", "option id");
    125   const desc = el("input", "opt-desc");
    126   desc.value = descValue || "";
    127   desc.placeholder = "description — a complete, independent restatement of the option";
    128   desc.setAttribute("aria-label", "option description");
    129   const remove = el("button", "ghost remove-option", "×");
    130   remove.type = "button";
    131   remove.title = "Remove option";
    132   remove.addEventListener("click", () => {
    133     row.remove();
    134     scheduleDraftSave();
    135   });
    136   row.append(id, desc, remove);
    137   return row;
    138 }
    139 
    140 const optionHost = (form) => $(".options", form);
    141 const readOptions = (form) =>
    142   Array.from(optionHost(form).children).map((row) => ({
    143     id: $(".opt-id", row).value.trim(),
    144     description: $(".opt-desc", row).value.trim(),
    145   }));
    146 const fillOptions = (form, options) => {
    147   const host = optionHost(form);
    148   clear(host);
    149   options.forEach((option) => host.appendChild(makeOptionRow(option.id, option.description)));
    150 };
    151 
    152 /* ── batch decision cards (also uncontrolled) ─────────────────────── */
    153 
    154 function makeDecisionCard(decision) {
    155   const card = el("div", "decision");
    156   const head = el("div", "decision-head");
    157   const id = el("input", "mono d-id");
    158   id.value = (decision && decision.id) || "";
    159   id.placeholder = "decision id";
    160   id.setAttribute("aria-label", "decision id");
    161   const remove = el("button", "ghost remove-decision", "×");
    162   remove.type = "button";
    163   remove.title = "Remove decision";
    164   remove.addEventListener("click", () => { card.remove(); scheduleDraftSave(); });
    165   head.append(el("span", "decision-n", "id"), id, remove);
    166 
    167   const question = el("input", "d-question");
    168   question.value = (decision && decision.question) || "";
    169   question.placeholder = "One criterion for this state";
    170   question.setAttribute("aria-label", "question");
    171 
    172   const options = el("div", "options");
    173   const add = el("button", "ghost add-decision-option", "+ option");
    174   add.type = "button";
    175   add.addEventListener("click", () => options.appendChild(makeOptionRow("", "")));
    176   const optHead = el("div", "field-head", "");
    177   optHead.append(el("label", null, "options"), add);
    178   (decision ? decision.options : [{ id: "", description: "" }]).forEach((option) =>
    179     options.appendChild(makeOptionRow(option.id, option.description)));
    180 
    181   card.append(head, question, optHead, options);
    182   return card;
    183 }
    184 
    185 const readDecisions = () =>
    186   Array.from($("#b-decisions").children).map((card) => ({
    187     id: $(".d-id", card).value.trim(),
    188     question: $(".d-question", card).value.trim(),
    189     options: Array.from($(".options", card).children).map((row) => ({
    190       id: $(".opt-id", row).value.trim(),
    191       description: $(".opt-desc", row).value.trim(),
    192     })),
    193   }));
    194 
    195 /* ── state field: text or JSON ─────────────────────────────────────── */
    196 
    197 const stateMode = (name) => $('input[name="' + name + '"]:checked').value;
    198 
    199 /* Radio restore tolerates an unknown stored value by leaving the default. */
    200 function checkStateMode(name, value) {
    201   const radio = $('input[name="' + name + '"][value="' + value + '"]');
    202   if (radio) radio.checked = true;
    203 }
    204 
    205 function readState(textarea, modeName, noteNode) {
    206   const raw = textarea.value;
    207   noteNode.textContent = stateWarning(raw.length, healthInfo.max_tokens);
    208   noteNode.classList.toggle("warn", Boolean(noteNode.textContent));
    209   if (stateMode(modeName) === "text") return { value: raw };
    210   if (!raw.trim()) return { value: raw };
    211   try {
    212     const parsed = JSON.parse(raw);
    213     if (typeof parsed === "string" || parsed === null || typeof parsed !== "object") {
    214       return { error: "JSON mode expects an object or array (or switch back to text mode)." };
    215     }
    216     return { value: parsed };
    217   } catch (error) {
    218     return { error: "state is not valid JSON: " + error.message };
    219   }
    220 }
    221 
    222 /* ── request bodies from the DOM ───────────────────────────────────── */
    223 
    224 function singleBody() {
    225   const form = $("#panel-single");
    226   const state = readState($("#s-state"), "s-state-mode", $("#s-state-note"));
    227   if (state.error) return { error: state.error };
    228   const row = {
    229     id: $("#s-id").value.trim(),
    230     state: state.value,
    231     question: $("#s-question").value.trim(),
    232     options: readOptions(form),
    233   };
    234   const problems = validateRow(row, "decision");
    235   return problems.length ? { error: problems.join("\n") } : { body: row };
    236 }
    237 
    238 function batchBody() {
    239   const state = readState($("#b-state"), "b-state-mode", $("#b-state-note"));
    240   if (state.error) return { error: state.error };
    241   const decisions = readDecisions();
    242   const problems = [];
    243   if (!decisions.length) problems.push("batch needs at least one decision");
    244   const batchIds = decisions.map((d) => d.id);
    245   if (new Set(batchIds).size !== batchIds.length) problems.push("Decision IDs must be unique (batch-level)");
    246   decisions.forEach((decision, index) =>
    247     problems.push(...validateRow({ ...decision, state: state.value }, "decision " + (index + 1))));
    248   if (problems.length) return { error: [...new Set(problems)].join("\n") };
    249   return { body: { state: state.value, decisions: decisions } };
    250 }
    251 
    252 /* ── result rendering ─────────────────────────────────────────────── */
    253 
    254 function optionTable(optionIds, probabilities, logits) {
    255   const order = optionIds.map((_, i) => i).sort((a, b) => probabilities[b] - probabilities[a]);
    256   const winner = order[0];
    257   const table = el("table", "probs");
    258   const head = el("tr");
    259   ["option", "score", "logit"].forEach((label) => head.appendChild(el("th", null, label)));
    260   table.appendChild(head);
    261   order.forEach((i) => {
    262     const tr = el("tr", i === winner ? "is-winner" : null);
    263     const nameCell = el("td");
    264     const bar = el("span", "bar");
    265     bar.style.setProperty("--w", (probabilities[i] * 100).toFixed(2) + "%");
    266     nameCell.append(bar, el("code", null, optionIds[i]));
    267     tr.append(nameCell, el("td", "num", p4(probabilities[i])), el("td", "num dim", p3(logits[i])));
    268     table.appendChild(tr);
    269   });
    270   return table;
    271 }
    272 
    273 function metaGrid(entries) {
    274   const grid = el("dl", "meta");
    275   entries.forEach(([key, value, title]) => {
    276     grid.appendChild(el("dt", null, key));
    277     const dd = el("dd", "mono", value);
    278     if (title) dd.title = title;
    279     grid.appendChild(dd);
    280   });
    281   return grid;
    282 }
    283 
    284 function renderResult(result, host, extra) {
    285   const winner = argmax(result.probabilities);
    286   const card = el("div", "result-card");
    287 
    288   const headline = el("div", "headline");
    289   headline.append(
    290     el("span", "chosen mono", result.option_ids[winner]),
    291     el("span", "score mono", "p = " + p4(result.probabilities[winner])));
    292   if (result.id !== undefined) headline.appendChild(el("span", "rid mono", result.id));
    293   card.appendChild(headline);
    294   card.appendChild(optionTable(result.option_ids, result.probabilities, result.option_logits));
    295 
    296   const model = result.model || {};
    297   const entries = [
    298     ["input_tokens", String(result.input_tokens)],
    299     ["prompt_sha256", String(result.prompt_sha256).slice(0, 16) + "…", String(result.prompt_sha256)],
    300     ["prompt_version", String(result.prompt_version)],
    301     ["readout", String(result.readout)],
    302     ["model", model.source + (model.revision ? " @" + String(model.revision).slice(0, 12) : "")],
    303     ["backend", model.backend || "torch"],
    304     ["dtype / torch", (model.dtype || "—") + " / " + (model.torch_version || "—")],
    305   ];
    306   if (result.llama) {
    307     entries.push(["cached prompt tokens reused", String(result.llama.cache_n ?? "—")]);
    308     entries.push(["score retrieval attempts", String(result.llama.attempts ?? 1)]);
    309   }
    310   if (model.serving_config) entries.push(["serving_config", model.serving_config]);
    311   if (extra) entries.push(...extra);
    312   card.appendChild(metaGrid(entries));
    313 
    314   card.appendChild(el("p", "disclaimer", String(result.probability_status) +
    315     " — ranking and coarse thresholds only."));
    316   host.appendChild(card);
    317 }
    318 
    319 function renderTiming(timing, host) {
    320   const table = el("table", "probs timing");
    321   Object.keys(timing).forEach((key) => {
    322     const tr = el("tr");
    323     const value = timing[key];
    324     tr.append(el("td", "dim", key),
    325               el("td", "num", typeof value === "number"
    326                 ? (key.endsWith("seconds") ? value.toFixed(4) : String(value))
    327                 : String(value)));
    328     table.appendChild(tr);
    329   });
    330   host.appendChild(el("h3", "section-title", "batch timing"));
    331   host.appendChild(table);
    332 }
    333 
    334 /* ── submit flow ──────────────────────────────────────────────────── */
    335 
    336 let inFlight = false;
    337 
    338 function setPending(active, text) {
    339   $("#idle").hidden = true;
    340   $("#error").hidden = true;
    341   $("#result").hidden = true;
    342   $("#pending").hidden = !active;
    343   if (text) $("#pending-text").textContent = text;
    344 }
    345 
    346 function showError(text) {
    347   $("#idle").hidden = true;
    348   $("#pending").hidden = true;
    349   $("#result").hidden = true;
    350   const box = $("#error");
    351   clear(box);
    352   box.hidden = false;
    353   box.appendChild(el("h3", null, "Rejected"));
    354   const pre = el("pre", null, text);
    355   box.appendChild(pre);
    356 }
    357 
    358 function showResult(hostBuilder) {
    359   $("#idle").hidden = true;
    360   $("#pending").hidden = true;
    361   $("#error").hidden = true;
    362   const host = $("#result");
    363   clear(host);
    364   host.hidden = false;
    365   hostBuilder(host);
    366 }
    367 
    368 async function submitSingle(event) {
    369   event.preventDefault();
    370   if (inFlight) return;
    371   const built = singleBody();
    372   if (built.error) return showError(built.error);
    373   inFlight = true;
    374   $("#s-submit").disabled = true;
    375   setPending(true, "scoring one decision…");
    376   const outcome = await api("/decide", built.body);
    377   $("#s-submit").disabled = false;
    378   inFlight = false;
    379   if (!outcome.ok) return showError(errorText(outcome));
    380   showResult((host) => {
    381     host.appendChild(el("h2", "section-title",
    382       "browser " + outcome.elapsedMs.toFixed(0) + " ms · server total " + ms(outcome.payload.total_seconds) +
    383       " · forward " + ms(outcome.payload.forward_seconds)));
    384     renderResult(outcome.payload, host, [["browser wall clock", outcome.elapsedMs.toFixed(0) + " ms"]]);
    385     host.appendChild(rawJSON(built.body, outcome.payload));
    386   });
    387 }
    388 
    389 async function submitBatch(event) {
    390   event.preventDefault();
    391   if (inFlight) return;
    392   const built = batchBody();
    393   if (built.error) return showError(built.error);
    394   inFlight = true;
    395   $("#b-submit").disabled = true;
    396   setPending(true, built.body.decisions.length + " decisions against one state…");
    397   const outcome = await api("/decide-batch", built.body);
    398   $("#b-submit").disabled = false;
    399   inFlight = false;
    400   if (!outcome.ok) return showError(errorText(outcome));
    401   const timing = outcome.payload.timing || {};
    402   showResult((host) => {
    403     host.appendChild(el("h2", "section-title",
    404       "browser " + outcome.elapsedMs.toFixed(0) + " ms · server total " + ms(timing.total_seconds) +
    405       " · " + (timing.batch_size || "?") + " decisions · " + (timing.mode || "torch shared")));
    406     renderTiming(timing, host);
    407     (outcome.payload.results || []).forEach((result) => renderResult(result, host));
    408     host.appendChild(rawJSON(built.body, outcome.payload));
    409   });
    410 }
    411 
    412 function rawJSON(request, response) {
    413   const details = el("details", "raw");
    414   details.appendChild(el("summary", null, "raw request / response"));
    415   details.appendChild(el("pre", null,
    416     "→ " + JSON.stringify(request, null, 2) + "\n\n← " + JSON.stringify(response, null, 2)));
    417   return details;
    418 }
    419 
    420 /* ── curl export (matches docs/usage.md so a UI finding becomes a bug report) */
    421 
    422 function copyCurl(path, built) {
    423   if (built.error) return showError(built.error);
    424   const body = JSON.stringify(built.body, null, 2).replace(/'/g, "'\\''");
    425   const command = "curl -s -X POST " + location.origin + path +
    426     " \\\n  -H 'Content-Type: application/json' \\\n  -d '" + body + "'";
    427   copyText(command, "curl command copied");
    428 }
    429 
    430 function copyText(text, confirmation) {
    431   const done = () => flash(confirmation);
    432   if (navigator.clipboard && navigator.clipboard.writeText) {
    433     navigator.clipboard.writeText(text).then(done, () => fallbackCopy(text, done));
    434   } else {
    435     fallbackCopy(text, done);
    436   }
    437 }
    438 
    439 function fallbackCopy(text, done) {
    440   const area = el("textarea", "sr-only");
    441   area.value = text;
    442   document.body.appendChild(area);
    443   area.select();
    444   try { document.execCommand("copy"); done(); } catch (_) { showError("Copy failed; select the text manually."); }
    445   area.remove();
    446 }
    447 
    448 function flash(message) {
    449   const box = $("#result");
    450   $("#idle").hidden = true;
    451   $("#pending").hidden = true;
    452   $("#error").hidden = true;
    453   box.hidden = false;
    454   clear(box);
    455   box.appendChild(el("p", "flash", message));
    456 }
    457 
    458 /* ── health strip ─────────────────────────────────────────────────── */
    459 
    460 const healthInfo = { max_tokens: 0 };
    461 
    462 async function checkHealth() {
    463   const dot = $("#health-dot");
    464   const text = $("#health-text");
    465   dot.className = "dot pending";
    466   text.textContent = "checking…";
    467   const outcome = await api("/healthz");
    468   if (!outcome.ok) {
    469     dot.className = "dot down";
    470     text.textContent = "unreachable — " + errorText(outcome).split("\n")[0];
    471     $("#model-picker").hidden = true;
    472     setModelNote("");
    473     return;
    474   }
    475   const model = outcome.payload.model || {};
    476   healthInfo.max_tokens = outcome.payload.max_tokens || 0;
    477   dot.className = "dot up";
    478   clear(text);
    479   const isLlama = outcome.payload.backend === "llama";
    480   text.append(
    481     el("span", null, isLlama ? "API ready · llama · " : "warm · torch · "),
    482     el("code", null, String(model.source)),
    483     el("span", null, " @ "),
    484     el("code", null, String(model.revision || "").slice(0, 12)),
    485     el("span", null, " · max_tokens "),
    486     el("code", null, String(outcome.payload.max_tokens)),
    487     el("span", null, " · "),
    488     el("code", null, String(model.dtype || "—") + " / " + String(model.torch_version || "—")));
    489   if (isLlama && outcome.payload.model_state === "pending") {
    490     text.appendChild(el("span", "warn-text", " · selected model loads on first request"));
    491   }
    492   $("#foot-max-tokens").textContent = "max_tokens " + outcome.payload.max_tokens;
    493   if (isLlama) loadModelPicker();
    494   else { $("#model-picker").hidden = true; setModelNote(""); }
    495 }
    496 
    497 /* ── model picker (llama backend only; torch has no runtime switching) ──
    498  * The dropdown lists the model aliases the llama-server knows about (its
    499  * models dir plus whatever is loaded). Selecting one POSTs /models: the
    500  * server-side switch unloads the current weights and the new model loads
    501  * lazily, on the next scored request. Runtime state only — nothing here
    502  * survives a restart of either process.                                       */
    503 
    504 let modelSwitching = false;
    505 
    506 function setModelNote(message, kind) {
    507   const note = $("#model-note");
    508   note.textContent = message;
    509   note.classList.toggle("is-error", kind === "error");
    510   note.classList.toggle("is-warn", kind === "warn");
    511 }
    512 
    513 async function loadModelPicker() {
    514   const picker = $("#model-picker");
    515   if (modelSwitching) return;   // don't clobber a switch that is in flight
    516   const outcome = await api("/models");
    517   if (!outcome.ok) { picker.hidden = true; return; }
    518   const payload = outcome.payload;
    519   if (!payload.switching) { picker.hidden = true; return; }
    520   const previous = picker.dataset.current || payload.current;
    521   clear(picker);
    522   (payload.models || []).forEach((id) => {
    523     const option = el("option", null, id);
    524     option.value = id;
    525     picker.appendChild(option);
    526   });
    527   picker.dataset.current = payload.current;
    528   picker.value = (payload.models || []).includes(previous) ? previous : payload.current;
    529   picker.hidden = false;
    530   if (payload.model_state === "pending") {
    531     setModelNote(payload.current + " selected — loads on first request");
    532   } else if (picker.value === payload.current) {
    533     setModelNote("");
    534   }
    535 }
    536 
    537 async function selectModel(model) {
    538   const picker = $("#model-picker");
    539   if (!model || model === picker.dataset.current) return;
    540   const previous = picker.dataset.current;
    541   modelSwitching = true;
    542   picker.disabled = true;
    543   setModelNote("switching to " + model + " — unloading the current model…");
    544   const outcome = await api("/models", { model: model });
    545   picker.disabled = false;
    546   modelSwitching = false;
    547   if (!outcome.ok) {
    548     picker.value = previous;
    549     setModelNote("switch failed — " + errorText(outcome).split("\n")[0], "error");
    550     return;
    551   }
    552   picker.dataset.current = model;
    553   if (outcome.payload.warning) {
    554     // Selection succeeded; only freeing the old weights failed. Not an error:
    555     // the new model still loads on first request, the old one stays resident.
    556     setModelNote(model + " selected — loads on first request · ⚠ " + outcome.payload.warning, "warn");
    557   } else {
    558     setModelNote(outcome.payload.model_state === "pending"
    559       ? model + " selected — loads on first request"
    560       : model + " selected");
    561   }
    562   checkHealth();
    563 }
    564 
    565 /* ── examples (from docs/usage.md) ─────────────────────────────────── */
    566 
    567 const PRESETS = [
    568   {
    569     name: "support triage",
    570     mode: "single",
    571     row: {
    572       id: "ticket-1042",
    573       state: "Customer asks to reset a forgotten password and says the reset email never arrived.",
    574       question: "Which queue should handle this request?",
    575       options: [
    576         { id: "account_access", description: "Account access and authentication support." },
    577         { id: "billing", description: "Billing and payment support." },
    578         { id: "sales", description: "Sales and product evaluation." },
    579       ],
    580     },
    581   },
    582   {
    583     name: "deploy gate",
    584     mode: "single",
    585     row: {
    586       id: "deploy-gate",
    587       state: "pytest 214 passed, 0 failed, 3 skipped in 41.2s\ncoverage: 88%\nlint: no issues found",
    588       question: "Does this test output provide evidence that the suite passed with no failures?",
    589       options: [
    590         { id: "pass", description: "All tests passed." },
    591         { id: "fail", description: "One or more tests failed." },
    592         { id: "insufficient", description: "The output does not clearly show pass or fail." },
    593       ],
    594     },
    595   },
    596   {
    597     name: "incident review (batch)",
    598     mode: "batch",
    599     state: "Postmortem: API latency spiked from 14:02 to 14:40 UTC after a config push removed the " +
    600            "rate-limit cache key. Error rate stayed below 0.1%. No customer data was affected. " +
    601            "Rollback completed at 14:40 UTC.",
    602     decisions: [
    603       { id: "customer-impact", question: "Was there customer-visible impact?", options: [
    604         { id: "yes", description: "Customers were affected." },
    605         { id: "no", description: "No customer-visible impact." },
    606         { id: "insufficient", description: "Cannot be determined from the evidence." }] },
    607       { id: "action-required", question: "Does the postmortem identify a concrete follow-up action?", options: [
    608         { id: "yes", description: "A follow-up action is identified." },
    609         { id: "no", description: "No follow-up action is identified." }] },
    610       { id: "sev", question: "What severity best fits this incident per the described scope?", options: [
    611         { id: "sev1", description: "Critical: data loss or outage." },
    612         { id: "sev2", description: "Major: degraded functionality with partial customer impact." },
    613         { id: "sev3", description: "Minor: brief internal degradation, no customer impact." }] },
    614     ],
    615   },
    616 ];
    617 
    618 function loadPreset(preset) {
    619   switchMode(preset.mode);
    620   if (preset.mode === "single") {
    621     $("#s-id").value = preset.row.id;
    622     $("#s-state").value = preset.row.state;
    623     $("#s-question").value = preset.row.question;
    624     fillOptions($("#panel-single"), preset.row.options);
    625   } else {
    626     $("#b-state").value = preset.state;
    627     clear($("#b-decisions"));
    628     preset.decisions.forEach((decision) => $("#b-decisions").appendChild(makeDecisionCard(decision)));
    629   }
    630   $("#s-state-note").textContent = "";
    631   $("#b-state-note").textContent = "";
    632   saveDraft();
    633 }
    634 
    635 /* ── draft persistence ────────────────────────────────────────────── */
    636 
    637 let draftTimer = null;
    638 function scheduleDraftSave() {
    639   clearTimeout(draftTimer);
    640   draftTimer = setTimeout(saveDraft, 400);
    641 }
    642 
    643 function saveDraft() {
    644   const draft = {
    645     mode: currentMode,
    646     single: {
    647       id: $("#s-id").value,
    648       state: $("#s-state").value,
    649       stateMode: stateMode("s-state-mode"),
    650       question: $("#s-question").value,
    651       options: readOptions($("#panel-single")),
    652     },
    653     batch: {
    654       state: $("#b-state").value,
    655       stateMode: stateMode("b-state-mode"),
    656       decisions: readDecisions(),
    657     },
    658   };
    659   try { localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch (_) { /* quota / private mode */ }
    660 }
    661 
    662 function restoreDraft() {
    663   let draft = null;
    664   try {
    665     draft = JSON.parse(localStorage.getItem(DRAFT_KEY) || "null");
    666     // A draft from an incompatible shape must not blank the page.
    667     if (draft && (!draft.single || !draft.batch || !Array.isArray(draft.single.options))) draft = null;
    668   } catch (_) { draft = null; }
    669   if (!draft) {
    670     fillOptions($("#panel-single"), [
    671       { id: "yes", description: "" },
    672       { id: "no", description: "" },
    673       { id: "insufficient", description: "The evidence is insufficient to decide." },
    674     ]);
    675     $("#b-decisions").appendChild(makeDecisionCard({ id: "check-1", options: [
    676       { id: "yes", description: "" }, { id: "no", description: "" }] }));
    677     return;
    678   }
    679   $("#s-id").value = draft.single.id || "";
    680   $("#s-state").value = draft.single.state || "";
    681   $("#s-question").value = draft.single.question || "";
    682   checkStateMode("s-state-mode", draft.single.stateMode);
    683   fillOptions($("#panel-single"), draft.single.options.length ? draft.single.options
    684     : [{ id: "", description: "" }, { id: "", description: "" }]);
    685   $("#b-state").value = draft.batch.state || "";
    686   checkStateMode("b-state-mode", draft.batch.stateMode);
    687   clear($("#b-decisions"));
    688   (draft.batch.decisions.length ? draft.batch.decisions : [{ id: "", question: "", options: [] }])
    689     .forEach((decision) => $("#b-decisions").appendChild(makeDecisionCard(decision)));
    690   switchMode(draft.mode === "batch" ? "batch" : "single");
    691 }
    692 
    693 /* ── mode switching ───────────────────────────────────────────────── */
    694 
    695 let currentMode = "single";
    696 
    697 function switchMode(mode) {
    698   currentMode = mode;
    699   const batch = mode === "batch";
    700   const game = mode === "game";
    701   const self = mode === "self";
    702   const room = mode === "room";
    703   const roomself = mode === "roomself";
    704   const demo = game || self || room || roomself;
    705   // Single & batch share the request/response columns; the demos replace them.
    706   $("#request").hidden = demo;
    707   $("#response").hidden = demo;
    708   $("#panel-game").hidden = !game;
    709   $("#panel-self").hidden = !self;
    710   $("#panel-room").hidden = !room;
    711   $("#panel-roomself").hidden = !roomself;
    712   $("#panel-single").hidden = batch || demo;
    713   $("#panel-batch").hidden = !batch;
    714   $("#tab-single").classList.toggle("is-active", mode === "single");
    715   $("#tab-batch").classList.toggle("is-active", batch);
    716   $("#tab-game").classList.toggle("is-active", game);
    717   $("#tab-self").classList.toggle("is-active", self);
    718   $("#tab-room").classList.toggle("is-active", room);
    719   $("#tab-roomself").classList.toggle("is-active", roomself);
    720   $("#tab-single").setAttribute("aria-selected", String(mode === "single"));
    721   $("#tab-batch").setAttribute("aria-selected", String(batch));
    722   $("#tab-game").setAttribute("aria-selected", String(game));
    723   $("#tab-self").setAttribute("aria-selected", String(self));
    724   $("#tab-room").setAttribute("aria-selected", String(room));
    725   $("#tab-roomself").setAttribute("aria-selected", String(roomself));
    726 }
    727 
    728 /* ── wiring ───────────────────────────────────────────────────────── */
    729 
    730 function init() {
    731   $("#panel-single").addEventListener("submit", submitSingle);
    732   $("#panel-batch").addEventListener("submit", submitBatch);
    733   $("#tab-single").addEventListener("click", () => { switchMode("single"); saveDraft(); });
    734   $("#tab-batch").addEventListener("click", () => { switchMode("batch"); saveDraft(); });
    735   $("#tab-game").addEventListener("click", () => switchMode("game"));
    736   $("#tab-self").addEventListener("click", () => switchMode("self"));
    737   $("#tab-room").addEventListener("click", () => switchMode("room"));
    738   $("#tab-roomself").addEventListener("click", () => switchMode("roomself"));
    739   $("#health-refresh").addEventListener("click", checkHealth);
    740   $("#model-picker").addEventListener("change", (event) => selectModel(event.target.value));
    741 
    742   $(".add-option", $("#panel-single")).addEventListener("click", (event) => {
    743     const host = optionHost($("#panel-single"));
    744     if (host.children.length >= MAX_OPTIONS) return showError("At most " + MAX_OPTIONS + " options.");
    745     host.appendChild(makeOptionRow("", ""));
    746     scheduleDraftSave();
    747   });
    748   $("#b-add-decision").addEventListener("click", () => {
    749     $("#b-decisions").appendChild(makeDecisionCard({ options: [
    750       { id: "", description: "" }, { id: "", description: "" }] }));
    751     scheduleDraftSave();
    752   });
    753 
    754   $("#s-curl").addEventListener("click", () => copyCurl("/decide", singleBody()));
    755   $("#b-curl").addEventListener("click", () => copyCurl("/decide-batch", batchBody()));
    756   $("#s-clear").addEventListener("click", () => {
    757     $("#s-state").value = ""; $("#s-question").value = ""; $("#s-id").value = "adhoc";
    758     fillOptions($("#panel-single"), [{ id: "", description: "" }, { id: "", description: "" }]);
    759     saveDraft();
    760   });
    761   $("#b-clear").addEventListener("click", () => {
    762     $("#b-state").value = "";
    763     clear($("#b-decisions"));
    764     $("#b-decisions").appendChild(makeDecisionCard({ options: [
    765       { id: "", description: "" }, { id: "", description: "" }] }));
    766     saveDraft();
    767   });
    768 
    769   const presetHost = $("#preset-buttons");
    770   PRESETS.forEach((preset) => {
    771     const button = el("button", "preset", preset.name);
    772     button.type = "button";
    773     button.addEventListener("click", () => loadPreset(preset));
    774     presetHost.appendChild(button);
    775   });
    776 
    777   document.addEventListener("input", scheduleDraftSave);
    778   restoreDraft();
    779   if (location.hash === "#game") switchMode("game");   // deep-link the demo tabs
    780   else if (location.hash === "#self") switchMode("self");
    781   else if (location.hash === "#room") switchMode("room");
    782   else if (location.hash === "#roomself") switchMode("roomself");
    783   checkHealth();
    784 }
    785 
    786 document.addEventListener("DOMContentLoaded", init);