CoolFace
Apppublic

LiquidAI/prompt-routing

sourceHugging Faceupdated 2mo agoView on Hugging Face
113likes
index.html541 linesDownload Raw Back to root
1<!doctype html>
2<html lang="en">
3<head>
4<meta charset="utf-8">
5<meta name="viewport" content="width=device-width, initial-scale=1">
6<title>Prompt routing — Liquid AI</title>
7<link rel="preconnect" href="https://fonts.googleapis.com">
8<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10<link rel="stylesheet" href="style.css">
11<script>
12  // Private Space auth: HF signs the embedded iframe URL with ?__sign=<token>.
13  // Incognito / Safari block the third-party .hf.space cookie, so that token is
14  // the only credential — carry it on same-origin API requests, else /api/*
15  // returns 404 and the model-ready poll hangs.
16  (() => {
17    const sign = new URLSearchParams(location.search).get("__sign");
18    if (!sign) return;
19    const orig = self.fetch.bind(self);
20    self.fetch = (input, init) => {
21      try {
22        const u = new URL(typeof input === "string" ? input : input.url, location.href);
23        if (u.origin === location.origin && !u.searchParams.has("__sign")) {
24          u.searchParams.set("__sign", sign);
25          input = u.toString();
26        }
27      } catch (e) { /* non-URL input: pass through */ }
28      return orig(input, init);
29    };
30  })();
31</script>
32<script defer src="shared-ui.js"></script>
33</head>
34<body>
35<div class="wrap">
36<header class="site-header">
37  <h1>Zero-shot <em>prompt routing</em></h1>
38  <p class="sub">Route prompts across customizable categories with LFM2.5 Encoder.</p>
39</header>
40
41<section class="model-strip is-loading" id="model-strip" aria-label="Model status">
42  <span class="model-logo-frame" aria-hidden="true">
43    <img class="model-logo" width="56" height="59" alt="" src="lliquid.gif">
44  </span>
45  <div class="model-copy">
46    <div class="model-name-row">
47      <a href="https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-Prompt-Router" target="_blank" rel="noopener">LFM2.5 Encoder</a>48      <span class="model-variant">350M parameters</span>
49    </div>
50  </div>
51  <div class="load-block">
52    <div class="load-meta">
53      <span id="status" role="status" aria-live="polite">Connecting to server…</span>
54      <span id="load-percent">0%</span>
55    </div>
56    <div class="load-track" role="progressbar" aria-label="Model loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span id="load-progress"></span></div>
57  </div>
58</section>
59
60<main>
61  <section class="flow-grid" id="router-stage" aria-label="Prompt router">
62    <svg id="connectors" class="flow-connectors" aria-hidden="true"></svg>
63
64    <article class="flow-card prompt-card">
65      <label for="text">Prompt</label>
66      <textarea id="text" rows="7" placeholder="Enter a prompt…">Write a Fibonacci sequence.</textarea>
67    </article>
68
69    <article class="model-card" id="model-node">
70      <div class="model-top"><span class="model-label">Model</span></div>
71      <div class="model-core">
72        <span class="model-orbit" aria-hidden="true"><span></span></span>
73        <div class="model-identity">
74          <a class="model-name" href="https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M-Prompt-Router" target="_blank" rel="noopener">LFM2.5 Encoder</a>75          <span class="model-size">350M parameters</span>
76        </div>
77      </div>
78      <div class="model-status"><span class="status-dot" aria-hidden="true"></span><span>Server · CPU</span></div>
79    </article>
80
81    <article class="flow-card route-card">
82      <div class="route-heading">
83        <h2>Categories</h2>
84        <div class="stats" id="perf"></div>
85      </div>
86      <div class="route" id="route" aria-live="polite"></div>
87    </article>
88  </section>
89
90  <section class="below-stage">
91    <section class="example-browser" aria-label="Routing examples">
92      <div class="scenario-control">
93        <label for="preset-select">Routing scenario</label>
94        <span class="select-shell"><select id="preset-select" aria-label="Routing scenario"></select></span>
95      </div>
96      <demo-examples options-id="exwrap" label="Examples"></demo-examples>
97    </section>
98    <textarea id="cats" hidden aria-hidden="true">Simple function call
99Simple tool use
100Complex multi-step agentic task
101Simple reasoning
102Complex reasoning
103Simple coding task
104Complex coding task
105Simple math
106Complex math
107Differential equations
108Translation
109Summarization
110Creative writing
111Factual lookup</textarea>
112  </section>
113</main>
114</div>
115
116<script>
117window.addEventListener("error", e => {
118  const el = document.getElementById("status");
119  if (el) el.textContent = "· ERROR: " + (e.message || (e.target && (e.target.src || e.target.href)) || "resource failed");
120}, true);
121window.addEventListener("unhandledrejection", e => {
122  const el = document.getElementById("status");
123  if (el) el.textContent = "· ERROR: " + (e.reason && e.reason.message ? e.reason.message : String(e.reason).slice(0, 120));
124});
125</script>
126<script type="module">
127// The router runs on the server (CPU). This script drives the UI and asks
128// /api/route for the routing distribution — nothing is downloaded to the client.
129
130const $ = (id) => document.getElementById(id);
131
132// ---------- model status strip (matches the masked-diffusion Space) ----------
133const setStatus = (message, state = "loading") => {
134  $("status").textContent = message;
135  const strip = $("model-strip");
136  strip.classList.toggle("is-loading", state === "loading");
137  strip.classList.toggle("is-preparing", state === "preparing");
138  strip.classList.toggle("is-ready", state === "ready");
139  strip.classList.toggle("is-error", state === "error");
140};
141const setLoadProgress = (percent) => {
142  const value = Math.max(0, Math.min(100, percent));
143  $("load-progress").style.width = `${value}%`;
144  $("load-percent").textContent = `${Math.round(value)}%`;
145  $("model-strip").querySelector(".load-track").setAttribute("aria-valuenow", String(Math.round(value)));
146};
147
148document.querySelectorAll(".ex[data-t]").forEach(e => e.onclick = () => { markCategoriesChanging(); $("text").value = e.dataset.t; routeSoon(); });
149
150let connectorFrame = null;
151function scheduleConnectors() {
152  cancelAnimationFrame(connectorFrame);
153  connectorFrame = requestAnimationFrame(drawConnectors);
154}
155function drawConnectors() {
156  const svg = $("connectors");
157  const flow = document.querySelector(".flow-grid");
158  const source = document.querySelector(".prompt-card");
159  const modelNode = $("model-node");
160  const rows = [...document.querySelectorAll(".rrow")];
161  if (!svg || !flow || !source || !modelNode || !rows.length || getComputedStyle(svg).display === "none") {
162    if (svg) svg.innerHTML = "";
163    return;
164  }
165
166  const box = flow.getBoundingClientRect();
167  const src = source.getBoundingClientRect();
168  const mdl = modelNode.getBoundingClientRect();
169  const sx = src.right - box.left + 2;
170  const mlx = mdl.left - box.left - 2;
171  const mrx = mdl.right - box.left + 2;
172  const sy = box.height / 2;
173  const my = sy;
174  svg.setAttribute("viewBox", `0 0 ${box.width} ${box.height}`);
175  svg.setAttribute("width", box.width);
176  svg.setAttribute("height", box.height);
177
178  const inputD = `M ${sx.toFixed(1)} ${sy.toFixed(1)} L ${mlx.toFixed(1)} ${sy.toFixed(1)}`;
179  const inputPath = `<path class="connector-base input-base" d="${inputD}"></path><circle class="travel-light input-light" r="5.5" opacity="0"><animate class="light-trigger" attributeName="opacity" values="0;1;1;0" keyTimes="0;.15;.72;1" dur="720ms" begin="indefinite" fill="freeze"></animate><animateMotion class="motion-trigger" path="${inputD}" dur="720ms" begin="indefinite" fill="freeze"></animateMotion></circle>`;
180  const pieces = rows.map((row, i) => {
181    const r = row.getBoundingClientRect();
182    const ex = r.left - box.left;
183    const ey = r.top + r.height / 2 - box.top;
184    const dx = Math.max(36, ex - mrx);
185    const c1x = mrx + Math.min(dx * 0.48, 120);
186    const c2x = ex - Math.min(dx * 0.42, 86);
187    const top = row.classList.contains("top");
188    const d = `M ${mrx.toFixed(1)} ${my.toFixed(1)} C ${c1x.toFixed(1)} ${my.toFixed(1)}, ${c2x.toFixed(1)} ${ey.toFixed(1)}, ${ex.toFixed(1)} ${ey.toFixed(1)}`;
189    return `<path class="connector-base output-base ${top ? "top" : ""}" d="${d}"></path><circle class="travel-light output-light ${top ? "top" : ""}" r="${top ? 6.5 : 4.5}" opacity="0"><animate class="light-trigger" attributeName="opacity" values="0;1;1;0" keyTimes="0;.12;.75;1" dur="760ms" begin="indefinite" fill="freeze"></animate><animateMotion class="motion-trigger" path="${d}" dur="760ms" begin="indefinite" fill="freeze"></animateMotion></circle><circle class="endpoint ${top ? "top" : ""}" cx="${ex.toFixed(1)}" cy="${ey.toFixed(1)}" r="${top ? 4.5 : 3}"></circle>`;
190  }).join("");
191  svg.innerHTML = inputPath + pieces;
192}
193
194function runConnectorLights(kind) {
195  const lights = [...document.querySelectorAll(`.${kind}-light`)];
196  lights.forEach((light, i) => setTimeout(() => {
197    light.querySelector(".motion-trigger")?.beginElement?.();
198    light.querySelector(".light-trigger")?.beginElement?.();
199  }, kind === "output" ? i * 24 : 0));
200}
201
202function markCategoriesChanging() {
203  const stage = $("router-stage");
204  const rows = [...document.querySelectorAll(".rrow")];
205  if (!stage || !rows.length) return;
206  stage.classList.remove("has-result");
207  stage.classList.add("categories-changing");
208  rows.forEach(row => {
209    row.classList.remove("top");
210    const fill = row.querySelector(".rfill");
211    const pct = row.querySelector(".rpct");
212    if (fill) fill.style.width = "0%";
213    if (pct) pct.textContent = "–";
214  });
215}
216
217const escapeAttr = (value) => String(value)
218  .replace(/&/g, "&amp;")
219  .replace(/"/g, "&quot;")
220  .replace(/</g, "&lt;")
221  .replace(/>/g, "&gt;");
222
223function categoryRowHTML(category, i, top = false, extraClass = "") {
224  return `<div class="rrow ${top ? 'top' : ''} ${extraClass}" style="--row-index:${i}" data-index="${i}">
225       <div class="category-name">
226         <input class="rname-input" aria-label="Category ${i + 1}" value="${escapeAttr(category)}" autocomplete="off" spellcheck="false" title="Edit category">
227         <span class="edit-hint" aria-hidden="true">✎</span>
228       </div>
229       <div class="rbar"><div class="rfill"></div></div>
230       <div class="rpct">0%</div>
231       <button class="remove-category" type="button" data-index="${i}" aria-label="Remove ${escapeAttr(category)}" title="Remove category">×</button>
232     </div>`;
233}
234
235function renderCategoryRows(cats, top = -1, quiet = false) {
236  $("route").innerHTML = cats.map((category, i) => categoryRowHTML(category, i, i === top, quiet ? "no-arrive" : "")).join("") +
237    `<button class="add-category" type="button">+ Add category</button>`;
238}
239
240function categoriesFromRows() {
241  return [...document.querySelectorAll(".rname-input")].map(input => input.value.trim());
242}
243
244function syncCategoriesFromRows() {
245  $("cats").value = categoriesFromRows().join("\n");
246}
247
248const routeList = $("route");
249routeList.addEventListener("input", event => {
250  if (!event.target.classList.contains("rname-input")) return;
251  syncCategoriesFromRows();
252  const remove = event.target.closest(".rrow")?.querySelector(".remove-category");
253  if (remove) remove.setAttribute("aria-label", `Remove ${event.target.value.trim() || "category"}`);
254});
255routeList.addEventListener("change", event => {
256  if (!event.target.classList.contains("rname-input")) return;
257  const quiet = event.target.closest(".just-added") !== null;
258  if (!event.target.value.trim()) event.target.value = "Untitled category";
259  syncCategoriesFromRows();
260  if (!quiet) markCategoriesChanging();
261  scheduleConnectors();
262  routeSoon(quiet);
263});
264routeList.addEventListener("keydown", event => {
265  if (event.target.classList.contains("rname-input") && event.key === "Enter") {
266    event.preventDefault();
267    const quiet = event.target.closest(".just-added") !== null;
268    if (!event.target.value.trim()) event.target.value = "Untitled category";
269    syncCategoriesFromRows();
270    if (!quiet) markCategoriesChanging();
271    scheduleConnectors();
272    routeSoon(quiet);
273    event.target.blur();
274  }
275});
276routeList.addEventListener("click", event => {
277  const remove = event.target.closest(".remove-category");
278  if (remove) {
279    const cats = categoriesFromRows().map(c => c || "Untitled category");
280    cats.splice(Number(remove.dataset.index), 1);
281    $("cats").value = cats.join("\n");
282    renderCategoryRows(cats);
283    markCategoriesChanging();
284    scheduleConnectors();
285    routeSoon();
286    return;
287  }
288  if (!event.target.closest(".add-category")) return;
289  const cats = categoriesFromRows().map(c => c || "Untitled category");
290  cats.push("New category");
291  $("cats").value = cats.join("\n");
292  const addButton = event.target.closest(".add-category");
293  addButton.insertAdjacentHTML("beforebegin", categoryRowHTML("New category", cats.length - 1, false, "just-added"));
294  scheduleConnectors();
295  requestAnimationFrame(() => {
296    const inputs = [...document.querySelectorAll(".rname-input")];
297    const input = inputs[inputs.length - 1];
298    input?.focus();
299    input?.select();
300  });
301});
302
303let routingRun = 0;
304function animateResults(probs, instant = false) {
305  const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
306  const rows = [...document.querySelectorAll(".rrow")];
307  requestAnimationFrame(() => requestAnimationFrame(() => {
308    rows.forEach((row, i) => {
309      const target = probs[i] * 100;
310      const fill = row.querySelector(".rfill");
311      const pct = row.querySelector(".rpct");
312      if (fill) fill.style.width = `${target.toFixed(1)}%`;
313      if (!pct) return;
314      if (instant) { pct.textContent = `${Math.round(target)}%`; return; }
315      if (reduceMotion) { pct.textContent = `${Math.round(target)}%`; return; }
316      const start = performance.now() + 120 + i * 34;
317      const duration = 720;
318      const tick = (now) => {
319        if (!pct.isConnected) return;
320        const progress = Math.max(0, Math.min(1, (now - start) / duration));
321        const eased = 1 - Math.pow(1 - progress, 3);
322        pct.textContent = `${Math.round(target * eased)}%`;
323        if (progress < 1) requestAnimationFrame(tick);
324      };
325      requestAnimationFrame(tick);
326    });
327  }));
328}
329
330let READY = false;
331
332function getCats() {
333  return $("cats").value.split("\n").map(s => s.replace(/^\s*-\s*/, "").trim()).filter(Boolean);
334}
335
336let inflight = null;
337async function route({ instant = false } = {}) {
338  if (!READY) return;
339  const runId = ++routingRun;
340  const visualStart = performance.now();
341  const text = $("text").value.trim();
342  const cats = getCats();
343  const stage = $("router-stage");
344  if (!text || !cats.length) {
345    renderCategoryRows(cats);
346    $("model-node").classList.remove("busy");
347    stage.classList.remove("is-routing", "categories-changing", "has-result");
348    $("perf").textContent = "";
349    drawConnectors();
350    return;
351  }
352  stage.classList.remove("has-result");
353  stage.classList.add("is-routing");
354  $("model-node").classList.add("busy");
355  drawConnectors();
356  if (!instant) runConnectorLights("input");
357
358  let data;
359  try {
360    inflight?.abort();
361    inflight = new AbortController();
362    const resp = await fetch("api/route", {
363      method: "POST",
364      headers: { "Content-Type": "application/json" },
365      body: JSON.stringify({ text, cats }),
366      signal: inflight.signal,
367    });
368    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
369    data = await resp.json();
370  } catch (e) {
371    if (e.name === "AbortError") return;
372    $("model-node").classList.remove("busy");
373    stage.classList.remove("is-routing");
374    $("perf").textContent = "routing failed";
375    return;
376  }
377  if (runId !== routingRun) return;
378  const { probs, top, tokens, ms } = data;
379
380  // Preserve the left-to-right visual sequence even when inference is very fast.
381  const visualRemainder = instant ? 0 : 520 - (performance.now() - visualStart);
382  if (visualRemainder > 0) await new Promise(resolve => setTimeout(resolve, visualRemainder));
383  if (runId !== routingRun) return;
384
385  renderCategoryRows(cats, top, instant);
386  $("model-node").classList.remove("busy");
387  stage.classList.remove("is-routing", "categories-changing");
388  stage.classList.add("has-result");
389  drawConnectors();
390  if (!instant) runConnectorLights("output");
391  $("perf").textContent = `${ms} ms · ${tokens} tokens`;
392  animateResults(probs, instant);
393}
394
395// ---- curated capability showcase — each preset carries matched example prompts
396// so every example routes cleanly against its own lanes (no cross-preset misroutes).
397const PRESETS = {
398  device: { name: "Device assistant",
399    lanes: ["Simple function call","Simple tool use","Complex multi-step agentic task","Quick factual question","Casual conversation","Creative writing","Translation","Needs a bigger model"],
400    ex: [["What's the temperature in San Francisco?","Weather query"],
401         ["Set a timer for 12 minutes.","Set a timer"],
402         ["Plan and book a full trip to Italy, comparing flights and hotels.","Plan & book a trip"],
403         ["Port llama.cpp to a Qualcomm NPU backend.","Port llama.cpp to NPU"],
404         ["Translate 'Where is the train station?' into Japanese.","Translate to Japanese"]] },
405  swe: { name: "Software complexity",
406    lanes: ["Small bug fix","Complex refactor","Large multi-file change","Needs a bigger model"],
407    ex: [["Fix a typo in the error message: 'recieved' should be 'received'.","One-line typo fix"],
408         ["Refactor the authentication module to use dependency injection across all handlers.","Refactor to DI"],
409         ["Redesign the query planner to support distributed joins across shards and rewrite the optimizer cost model.","Rewrite query planner"],
410         ["Add pagination to the users endpoint and update its unit tests.","Add API pagination"],
411         ["Rename this local variable from x to retryCount.","Rename a variable"]] },
412  lang: { name: "Code language",
413    lanes: ["Python","JavaScript","TypeScript","Go","Rust","Java","C++","PHP","Ruby"],
414    ex: [["In the jrpc2 Go package, requests with an invalid ID structure are wrongly flagged as notifications.","Go · jrpc2 bug"],
415         ["The Laravel Eloquent whereHas closure ignores the second-level relationship constraint.","PHP · Laravel (zero-shot)"],
416         ["The borrow checker rejects this mutable reference inside the iterator loop.","Rust · borrow checker"],
417         ["np.einsum returns the wrong shape when broadcasting over the batch axis.","Python · numpy"],
418         ["A TypeScript discriminated union is not narrowing inside this callback.","TypeScript narrowing"]] },
419  math: { name: "Math & difficulty",
420    lanes: ["Simple math","Complex math","Differential equations","Linear algebra","Probability","Word problem"],
421    ex: [["What is 12 times 8?","12 × 8"],
422         ["Prove that there are infinitely many prime numbers.","prove infinitely many primes"],
423         ["Solve dy/dx = 3y with y(0) = 1.","dy/dx = 3y (ODE)"],
424         ["Solve the heat equation u_t = α·u_xx on [0, π].","heat equation u_t (PDE)"],
425         ["Find the eigenvalues of a 3×3 symmetric matrix.","3×3 eigenvalues"]] },
426  data: { name: "Data workflow",
427    lanes: ["SQL query","Data analysis","Spreadsheet task","Data visualization","General question"],
428    ex: [["Get the top 10 customers by spend from the orders table.","Top 10 from orders table"],
429         ["What are the top 10 customers by spend in this dataset?","Top 10 in this dataset"],
430         ["SELECT count(*) FROM users GROUP BY country.","SELECT … GROUP BY"],
431         ["Plot revenue by month as a line chart.","Plot revenue by month"],
432         ["Create a pivot table of sales by region and quarter.","Sales pivot table"]] },
433  anyintent: { name: "Any intent (zero-shot)",
434    lanes: ["Chess question","Parenting advice","Legal question","Recipe request","Travel planning","Fitness advice","General question"],
435    ex: [["How do I castle on the queenside in chess?","Chess (never trained)"],
436         ["My toddler refuses to sleep through the night — any advice?","Parenting (never trained)"],
437         ["Is a verbal agreement legally binding in California?","Legal (never trained)"],
438         ["How do I make a classic carbonara from scratch?","Recipe (never trained)"],
439         ["Build a three-day walking itinerary for Kyoto.","Travel (never trained)"]] },
440  tiers: { name: "Capability tiers",
441    lanes: ["Trivial (on-device tiny model)","Everyday (on-device model)","Complex (escalate to a large model)","Needs step-by-step reasoning","Needs a tool or function call","Multi-step agentic task"],
442    ex: [["What is 34 + 58?","34 + 58"],
443         ["Prove that the square root of 2 is irrational.","prove √2 irrational"],
444         ["What's the weather in Tokyo?","Weather in Tokyo"],
445         ["Port llama.cpp to a Qualcomm NPU.","Port llama.cpp"],
446         ["Summarize this paragraph in one sentence.","Quick summary"]] },
447  support: { name: "Support triage",
448    lanes: ["Billing issue","Technical bug report","Feature request","Account access problem","Refund request","Angry escalation","Spam"],
449    ex: [["I was charged twice for my subscription this month.","Charged twice"],
450         ["The export button throws a 500 error every time I click it.","500 on export"],
451         ["Can you add a dark mode to the dashboard?","Add dark mode"],
452         ["I can't log in — password reset email never arrives.","Can’t log in"],
453         ["I need a refund for an accidental annual renewal.","Annual renewal refund"]] },
454  dev: { name: "Dev tools",
455    lanes: ["Simple coding task","Complex coding task","Code debugging","Code review","Write tests","Explain this code","DevOps or deployment","Documentation"],
456    ex: [["Write a function to reverse a linked list.","Reverse a linked list"],
457         ["Why does this recursion overflow the stack for n > 1000?","Stack overflow n>1000"],
458         ["Review this PR for concurrency issues.","Review PR concurrency"],
459         ["Set up a GitHub Actions pipeline to build and deploy on tag.","CI/CD on tag"],
460         ["Write integration tests for the OAuth callback flow.","OAuth integration tests"]] },
461};
462const PRESET_ORDER = ["device","swe","lang","math","data","anyintent","tiers","support","dev"];
463
464function loadPreset(key) {
465  const p = PRESETS[key];
466  $("cats").value = p.lanes.join("\n");
467  renderCategoryRows(p.lanes);
468  markCategoriesChanging();
469  if ($("preset-select").value !== key) $("preset-select").value = key;
470  const wrap = $("exwrap");
471  wrap.innerHTML = "";
472  p.ex.forEach(([t, label], i) => {
473    const s = document.createElement("button");
474    s.type = "button";
475    s.className = "ex"; s.textContent = label; s.title = t;
476    s.onclick = () => {
477      markCategoriesChanging();
478      wrap.querySelectorAll(".ex").forEach(x => x.classList.toggle("active", x === s));
479      $("text").value = t;
480      scheduleConnectors();
481      routeSoon();
482    };
483    wrap.appendChild(s);
484    if (i === 0) {
485      s.classList.add("active");
486      $("text").value = t;
487    }
488  });
489  routeSoon();
490  scheduleConnectors();
491}
492
493const scenarioSelect = $("preset-select");
494PRESET_ORDER.forEach(key => {
495  const p = PRESETS[key];
496  const option = document.createElement("option");
497  option.value = key;
498  option.textContent = p.name;
499  scenarioSelect.appendChild(option);
500});
501scenarioSelect.addEventListener("change", () => loadPreset(scenarioSelect.value));
502
503let timer = null;
504function routeSoon(instant = false) { clearTimeout(timer); timer = setTimeout(() => route({ instant }).catch(console.error), 300); }
505$("text").addEventListener("input", () => { markCategoriesChanging(); scheduleConnectors(); routeSoon(); });
506window.addEventListener("resize", scheduleConnectors);
507document.fonts?.ready?.then(scheduleConnectors);
508
509loadPreset("device");   // open on the device-assistant showcase
510scheduleConnectors();
511
512// ---------- wait for the server-side model, then run the first route ----------
513async function waitForServer() {
514  setStatus("Connecting to server…", "loading");
515  setLoadProgress(12);
516  for (let attempt = 0; attempt < 900; attempt++) {
517    try {
518      const r = await fetch("api/ready", { cache: "no-store" });
519      if (r.ok && (await r.json()).ready) return true;
520    } catch (e) { /* server still starting */ }
521    setStatus("Warming up model on the server…", "preparing");
522    setLoadProgress(Math.min(92, 12 + attempt * 4));
523    await new Promise((r) => setTimeout(r, 1000));
524  }
525  return false;
526}
527
528(async () => {
529  const ok = await waitForServer();
530  if (!ok) { setStatus("Model unavailable", "error"); setLoadProgress(100); $("load-percent").textContent = "Error"; return; }
531  READY = true;
532  setLoadProgress(100);
533  setStatus("Model ready", "ready");
534  $("model-node").classList.add("ready");
535  await route({ instant: true });
536  window.__ROUTE_READY = true;
537})();
538</script>
539</body>
540</html>
541