LiquidAI/prompt-routing
113
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, "&")
219 .replace(/"/g, """)
220 .replace(/</g, "<")
221 .replace(/>/g, ">");
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 