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);