Download web/index.html from robynsd/Laya-demo: direct link, hf CLI and curl.
- Browser
- Download file 45.7 kB
-
https://huggingface.co/spaces/robynsd/Laya-demo/resolve/main/web/index.html
- Command line
-
hf download hf://spaces/robynsd/Laya-demo/web/index.html
-
curl -L -o index.html https://huggingface.co/spaces/robynsd/Laya-demo/resolve/main/web/index.html
45.7 kB
| <html lang="fr"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Laya Démo</title> | |
| <script> | |
| // Applied before first paint to avoid a flash of the wrong theme. | |
| try { const t = localStorage.getItem("theme"); if (t) document.documentElement.dataset.theme = t; } catch {} | |
| </script> | |
| <style> | |
| :root { | |
| --bg: #f6f5f2; --surface: #ffffff; --text: #1d1d1b; --muted: #6b6b66; | |
| --border: #e2e0da; --accent: #2f6fed; --accent-soft: #e8efff; | |
| --bad: #d64545; --mid: #e0a72e; --good: #2f9e62; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root:not([data-theme="light"]) { --bg: #161615; --surface: #1f1f1d; --text: #ecebe6; | |
| --muted: #9a9a93; --border: #33332f; --accent: #6b9bff; --accent-soft: #1e2a44; color-scheme: dark; } | |
| } | |
| :root[data-theme="dark"] { --bg: #161615; --surface: #1f1f1d; --text: #ecebe6; | |
| --muted: #9a9a93; --border: #33332f; --accent: #6b9bff; --accent-soft: #1e2a44; color-scheme: dark; } | |
| * { box-sizing: border-box; } | |
| body { margin: 0; background: var(--bg); color: var(--text); | |
| font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Inter", sans-serif; } | |
| main { max-width: 980px; margin: 0 auto; padding: 32px 16px; } | |
| [hidden] { display: none ; } | |
| header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; } | |
| h1 { font-size: 20px; margin: 0 0 4px; } | |
| .sub { color: var(--muted); margin: 0 0 24px; } | |
| .controls { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .seg { display: flex; border: 1px solid var(--border); border-radius: 999px; padding: 2px; | |
| background: var(--surface); } | |
| .seg button, .seg a { border: 0; background: none; color: var(--muted); font: inherit; font-size: 12px; | |
| padding: 4px 10px; border-radius: 999px; cursor: pointer; text-decoration: none; } | |
| .seg [aria-pressed="true"], .seg [aria-current="page"] { background: var(--border); color: var(--text); } | |
| nav.cases { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; } | |
| nav.cases button { border: 1px solid var(--border); background: var(--surface); color: var(--text); | |
| padding: 6px 14px; border-radius: 999px; font: inherit; cursor: pointer; } | |
| nav.cases button[aria-selected="true"] { border-color: var(--accent); color: var(--accent); } | |
| nav.cases button:disabled { opacity: .45; cursor: default; } | |
| .grid { display: grid; grid-template-columns: 1fr 300px; gap: 20px; } | |
| @media (max-width: 760px) { .grid { grid-template-columns: 1fr; } } | |
| .card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; } | |
| textarea { width: 100%; min-height: 320px; resize: vertical; border: 0; outline: none; | |
| background: transparent; color: var(--text); font: inherit; font-size: 16px; } | |
| .examples { margin-top: 12px; display: flex; gap: 6px; flex-wrap: wrap; } | |
| .examples button { font: inherit; font-size: 12px; border: 1px dashed var(--border); | |
| background: none; color: var(--muted); padding: 3px 10px; border-radius: 6px; cursor: pointer; } | |
| .gauge { font-size: 64px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; | |
| transition: color .2s; } | |
| .gauge small { font-size: 20px; color: var(--muted); font-weight: 500; } | |
| .bar { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 14px 0 6px; } | |
| .bar > div { height: 100%; width: 0; transition: width .2s, background .2s; } | |
| .verdict { color: var(--muted); margin-bottom: 20px; } | |
| .sig { display: grid; grid-template-columns: 80px 1fr 40px; gap: 10px; align-items: center; | |
| font-size: 13px; margin: 8px 0; } | |
| .sig .bar { margin: 0; height: 6px; } | |
| .sig span:last-child { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); } | |
| .stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 20px; | |
| padding-top: 16px; border-top: 1px solid var(--border); } | |
| .stats div { display: flex; flex-direction: column; gap: 2px; } | |
| .stats b { font-size: 18px; font-variant-numeric: tabular-nums; } | |
| .stats span { color: var(--muted); font-size: 11px; } | |
| .prose { margin-top: 20px; } | |
| .prose h2 { font-size: 17px; margin: 0 0 6px; } | |
| .prose h3 { font-size: 14px; margin: 28px 0 6px; } | |
| .prose p, .prose li { color: var(--muted); font-size: 14px; } | |
| .prose p { margin: 6px 0; } | |
| .prose ol, .prose ul { padding-left: 20px; margin: 6px 0; } | |
| .prose b { color: var(--text); font-weight: 600; } | |
| .prose a { color: var(--accent); } | |
| code { font-size: 12px; background: var(--border); padding: 1px 5px; border-radius: 4px; color: var(--text); } | |
| .step { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; | |
| color: var(--accent); margin-right: 6px; } | |
| .quote { border-left: 3px solid var(--border); padding: 4px 0 4px 12px; margin: 8px 0; | |
| font-size: 13px; color: var(--text); } | |
| .scroll { overflow-x: auto; margin: 8px 0; } | |
| table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; | |
| letter-spacing: .03em; padding: 6px 8px; border-bottom: 1px solid var(--border); } | |
| td { padding: 7px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } | |
| td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } | |
| tr.lead td:first-child { box-shadow: inset 3px 0 0 var(--accent); } | |
| tr.top td { background: var(--accent-soft); } | |
| tfoot td { font-weight: 600; border-bottom: 0; } | |
| .en { color: var(--muted); } | |
| pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px; | |
| font-size: 12.5px; overflow-x: auto; margin: 8px 0; } | |
| .flow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 12px 0; font-size: 13px; } | |
| .flow span { border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; background: var(--bg); } | |
| .flow i { color: var(--muted); font-style: normal; } | |
| .live { color: var(--accent); } | |
| details.prose > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px; } | |
| details.prose > summary::-webkit-details-marker { display: none; } | |
| details.prose > summary::before { content: "›"; color: var(--muted); font-size: 18px; line-height: 1; | |
| transition: transform .15s; } | |
| details.prose[open] > summary::before { transform: rotate(90deg); } | |
| details.prose > summary h2 { margin: 0; } | |
| details.prose[open] > summary { margin-bottom: 8px; } | |
| .current { border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; | |
| padding: 12px 14px; margin: 14px 0 4px; background: var(--bg); } | |
| .current .label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; | |
| color: var(--accent); margin-bottom: 4px; } | |
| .current .body { font-size: 14px; color: var(--text); white-space: pre-wrap; } | |
| .current .meta { font-size: 12px; color: var(--muted); margin-top: 6px; } | |
| .current.empty .body { color: var(--muted); font-style: italic; } | |
| .models { color: var(--muted); font-size: 11px; margin-top: 10px; } | |
| .headline { font-size: 44px; font-weight: 700; line-height: 1.05; } | |
| .headline small { font-size: 18px; color: var(--muted); font-weight: 500; } | |
| .warning { background: var(--accent-soft); border-radius: 8px; padding: 8px 10px; font-size: 13px; | |
| margin: 12px 0; } | |
| .check { display: grid; grid-template-columns: 22px 1fr; gap: 6px; margin: 12px 0; font-size: 14px; } | |
| .check .icon { font-weight: 700; } | |
| .check .ok { color: var(--good); } .check .ko { color: var(--bad); } | |
| .check .detail, .check .tip { color: var(--muted); font-size: 12px; } | |
| .check .tip { margin-top: 2px; } | |
| .tag { font-size: 10px; border: 1px solid var(--border); border-radius: 4px; padding: 0 4px; | |
| color: var(--muted); margin-left: 4px; vertical-align: 1px; } | |
| .house { font-size: 34px; font-weight: 700; line-height: 1.1; margin: 4px 0; } | |
| .hesit { color: var(--muted); font-size: 13px; margin-bottom: 12px; } | |
| .extra { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 14px; } | |
| .extra div { margin: 6px 0; } | |
| .extra span { color: var(--muted); font-size: 12px; display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <main> | |
| <header> | |
| <div> | |
| <h1>Laya-MLX, décisions typées en local</h1> | |
| <p class="sub">Le modèle re-score à chaque frappe, sans génération de texte, sur ta machine.</p> | |
| </div> | |
| <div class="controls"> | |
| <div class="seg" role="navigation" aria-label="Vue"> | |
| <a href="#" data-view-link="demo">Démo</a> | |
| <a href="#modele" data-view-link="modele">Comment ça marche ?</a> | |
| </div> | |
| <div class="seg" role="group" aria-label="Thème"> | |
| <button data-theme-choice="auto">Auto</button> | |
| <button data-theme-choice="light">Clair</button> | |
| <button data-theme-choice="dark">Sombre</button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- ============ Vue démo ============ --> | |
| <section id="view-demo"> | |
| <nav class="cases" id="cases"></nav> | |
| <div class="grid"> | |
| <div class="card"> | |
| <textarea id="text" autofocus></textarea> | |
| <div class="examples" id="examples"></div> | |
| </div> | |
| <div class="card"> | |
| <div id="result"></div> | |
| <div class="stats"> | |
| <div><b id="ms">-</b><span>Inférence</span></div> | |
| <div><b id="tin">-</b><span>Tokens en entrée</span></div> | |
| <div><b id="tout">-</b><span>Tokens générés</span></div> | |
| </div> | |
| <div class="models" id="models"></div> | |
| </div> | |
| </div> | |
| <details class="card prose" id="case-explain"> | |
| <summary><h2>Comment cet exemple est construit</h2></summary> | |
| <div id="case-body"><p>Chargement...</p></div> | |
| </details> | |
| </section> | |
| <!-- ============ Vue modèle ============ --> | |
| <section id="view-modele" hidden> | |
| <div class="card prose" style="margin-top:0"> | |
| <h2>Comment fonctionne le modèle</h2> | |
| <h3>Un modèle qui décide, sans rédiger</h3> | |
| <p>Un LLM classique (ChatGPT, Claude) écrit sa réponse mot après mot. Pour obtenir un simple chiffre, | |
| il faut attendre qu'il finisse sa phrase, puis espérer qu'il a respecté le format demandé.</p> | |
| <p>Laya fonctionne comme un <b>questionnaire à choix multiples</b> : on lui donne un texte et une | |
| question avec ses réponses possibles, et il renvoie directement une probabilité pour chaque réponse, | |
| en une seule passe. Aucun mot n'est généré. Conséquences :</p> | |
| <ul> | |
| <li><b>rapide</b> : une lecture du texte au lieu d'une rédaction mot par mot ;</li> | |
| <li><b>fiable dans la forme</b> : la réponse est forcément l'une des options prévues ;</li> | |
| <li><b>local</b> : le modèle est assez petit pour tourner sur un Mac, aucune donnée ne quitte la machine.</li> | |
| </ul> | |
| <div class="flow"> | |
| <span>texte + question + options</span><i>→</i> | |
| <span>encodeur bidirectionnel</span><i>→</i> | |
| <span>tête de décision</span><i>→</i> | |
| <span>probabilités calibrées</span> | |
| </div> | |
| <h3>Trois types de questions</h3> | |
| <div class="scroll"><table> | |
| <thead><tr><th>Type</th><th>Exemple</th><th>Réponse du modèle</th></tr></thead> | |
| <tbody> | |
| <tr><td><code>noul</code> oui / non</td><td>« Le texte mentionne-t-il un budget ? »</td><td>P(vrai), par exemple 74 %</td></tr> | |
| <tr><td><code>choice</code> choix</td><td>« Service concerné : facturation / technique / commercial »</td><td>Un pourcentage par option</td></tr> | |
| <tr><td><code>score</code> échelle</td><td>« Urgence : faible / moyenne / critique »</td><td>Le niveau attendu, Σ i · pᵢ, par exemple 1,6 sur 2</td></tr> | |
| </tbody> | |
| </table></div> | |
| <h3>Sous le capot</h3> | |
| <p>Chaque question est mise en forme ainsi, puis lue d'un seul bloc :</p> | |
| <pre>[CLS] <type> question [SEP] [MASK] option₀ [MASK] option₁ ... [SEP] texte [SEP]</pre> | |
| <ol> | |
| <li><b>Encodeur</b> : ModernBERT-large, 28 couches, dimension 1 024. Il est <b>bidirectionnel</b> : | |
| chaque mot est interprété à la lumière de tous les autres, avant comme après lui, contrairement aux | |
| LLM génératifs qui ne regardent que vers la gauche.</li> | |
| <li><b>Type de question</b> : un vecteur propre à <code>choice</code>, <code>score</code> ou | |
| <code>noul</code> est ajouté, pour que le modèle sache quel genre de réponse on attend.</li> | |
| <li><b>Tête de décision</b> : 2 couches Transformer, puis un petit réseau (le <i>scorer</i>) lu à la | |
| position de chaque <code>[MASK]</code>. Chaque option obtient ainsi un score qui tient compte de | |
| la question et du texte.</li> | |
| <li><b>Calibration</b> : les scores sont divisés par une <i>température</i>, puis transformés en | |
| probabilités (softmax). But : qu'un « 80 % » soit correct environ 8 fois sur 10.</li> | |
| </ol> | |
| <h3>La calibration dépend de la forme de la question</h3> | |
| <p>La température a été ajustée à l'entraînement selon le type de question et le nombre d'options. | |
| Plus elle est haute, plus les probabilités sont tassées vers l'incertitude :</p> | |
| <div class="scroll"><table> | |
| <thead><tr><th>Forme de la question</th><th class="num">Température</th><th>Effet</th></tr></thead> | |
| <tbody> | |
| <tr><td>Oui / non</td><td class="num">1,98</td><td>Fortement tassé : dépasse rarement 70 %</td></tr> | |
| <tr><td>Choix à 2 options</td><td class="num">1,91</td><td>Fortement tassé</td></tr> | |
| <tr><td>Choix à 3 à 5 options</td><td class="num">1,76</td><td>Tassé</td></tr> | |
| <tr><td>Échelle à 3 à 5 niveaux</td><td class="num">1,25</td><td>Légèrement tassé</td></tr> | |
| <tr><td>Choix à 6 à 10 options, échelle à 6 niveaux ou plus</td><td class="num">≈ 1,0</td><td>Neutre</td></tr> | |
| <tr><td>Choix à 11 options ou plus</td><td class="num">0,5</td><td>Accentué</td></tr> | |
| </tbody> | |
| </table></div> | |
| <p>La forme d'une question influence donc la netteté de ses réponses, indépendamment de son contenu.</p> | |
| <h3>Une question, une passe</h3> | |
| <p>Le texte n'est pas lu une seule fois pour toutes les questions : chaque question est encodée avec | |
| le texte, séparément. Les questions passent en parallèle dans un même lot, mais le nombre de tokens | |
| en entrée est à peu près <b>nombre de questions × (texte + question)</b>. Le nombre de tokens | |
| générés, lui, est toujours 0.</p> | |
| <h3>Les modèles utilisés ici</h3> | |
| <p>Des checkpoints de 421 millions de paramètres (des centaines de fois moins qu'un grand LLM), sans API | |
| cloud. Deux moteurs exécutent les mêmes poids : <b>laya-mlx</b> sur Mac (MLX, le framework d'Apple | |
| pour le GPU des puces M) pour les tests en local, et le <b>Laya d'origine</b> (PyTorch) sur serveur. | |
| Quand les deux modèles sont chargés, chaque question est envoyée à celui qui y répond le mieux :</p> | |
| <ul> | |
| <li><code>laya-typed-decisions</code>, spécialisé dans les décisions typées : cas 1, cas 3, et la | |
| prochaine étape et les garde-fous du cas 2 ;</li> | |
| <li><code>laya</code> (anglais généraliste) : les détecteurs de défauts du cas 2, qu'il sépare bien | |
| mieux. Une instance légère peut ne charger que <code>laya-typed-decisions</code>.</li> | |
| </ul> | |
| <p id="instance"></p> | |
| <ul> | |
| <li><a href="https://github.com/NandhaKishorM/laya" target="_blank" rel="noopener">Laya</a> : le modèle d'origine, créé et entraîné par Convai Innovations</li> | |
| <li><a href="https://github.com/mizorewww/laya-mlx" target="_blank" rel="noopener">laya-mlx</a> : le portage indépendant pour Apple Silicon utilisé ici</li> | |
| <li>Poids sur Hugging Face : <a href="https://huggingface.co/aac6fef/laya-typed-decisions-mlx" target="_blank" rel="noopener">laya-typed-decisions-mlx</a> | |
| et <a href="https://huggingface.co/aac6fef/laya-mlx" target="_blank" rel="noopener">laya-mlx</a></li> | |
| </ul> | |
| </div> | |
| </section> | |
| </main> | |
| <script src="config.js"></script> | |
| <script> | |
| const API = window.LAYA_API || ""; | |
| const $ = (id) => document.getElementById(id); | |
| const text = $("text"); | |
| const pct = (p) => `${Math.round(p * 100)} %`; | |
| const p1 = (p) => `${(p * 100).toFixed(1).replace(".", ",")} %`; | |
| const num = (x) => x.toFixed(2).replace(".", ","); | |
| const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); | |
| const argmax = (obj) => Object.keys(obj).reduce((a, k) => obj[k] > obj[a] ? k : a); | |
| // ---- Theme ---- | |
| function setTheme(choice) { | |
| const root = document.documentElement; | |
| if (choice === "auto") delete root.dataset.theme; else root.dataset.theme = choice; | |
| try { choice === "auto" ? localStorage.removeItem("theme") : localStorage.setItem("theme", choice); } catch {} | |
| document.querySelectorAll("[data-theme-choice]").forEach(b => | |
| b.setAttribute("aria-pressed", b.dataset.themeChoice === choice)); | |
| } | |
| document.querySelectorAll("[data-theme-choice]").forEach(b => | |
| b.addEventListener("click", () => setTheme(b.dataset.themeChoice))); | |
| setTheme(document.documentElement.dataset.theme || "auto"); | |
| // ---- Views ---- | |
| function showView() { | |
| const view = location.hash === "#modele" ? "modele" : "demo"; | |
| $("view-demo").hidden = view !== "demo"; | |
| $("view-modele").hidden = view !== "modele"; | |
| document.querySelectorAll("[data-view-link]").forEach(a => { | |
| if (a.dataset.viewLink === view) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); | |
| }); | |
| window.scrollTo(0, 0); | |
| } | |
| window.addEventListener("hashchange", showView); | |
| showView(); | |
| // Shared pieces of the "Comment cet exemple est construit" section. | |
| const liveCell = (p) => `<td class="num live">${p == null ? "-" : pct(p)}</td>`; | |
| const liveHead = `<th class="num live">En cours</th>`; | |
| function currentBox(label, r, summary) { | |
| const current = r ? r.text : ""; | |
| return `<div class="current ${current ? "" : "empty"}"> | |
| <div class="label">${label} en cours</div> | |
| <div class="body">${current ? esc(current) : "Rien d'analysé pour l'instant : écris un texte ou choisis un exemple ci-dessus."}</div> | |
| ${r ? `<div class="meta">Résultat : ${esc(summary(r))}</div>` : ""} | |
| </div>`; | |
| } | |
| function optionsTable(options, probs, { keyHead = "Clé", labelHead = "Affiché comme", rowClass = () => "" } = {}) { | |
| const top = probs ? argmax(probs) : null; | |
| return `<div class="scroll"><table> | |
| <thead><tr><th>${keyHead}</th><th>Description envoyée au modèle</th><th>${labelHead}</th>${liveHead}</tr></thead> | |
| <tbody>${options.map(o => `<tr class="${rowClass(o)} ${o.key === top ? "top" : ""}"> | |
| <td><code>${esc(o.key)}</code></td><td class="en">${esc(o.description)}</td><td>${esc(o.label)}</td> | |
| ${liveCell(probs ? probs[o.key] : null)}</tr>`).join("")}</tbody> | |
| </table></div>`; | |
| } | |
| const intro = (n) => `<p>Laya ne connaît pas la tâche : il sait seulement répondre à des questions fermées. | |
| Ce cas la traduit en <b>${n} questions</b>, envoyées ensemble à chaque frappe, puis combine les réponses. | |
| Les questions et options ci-dessous sont le texte exact envoyé au modèle, en anglais car les checkpoints | |
| ont été entraînés en anglais ; le texte analysé peut être en français. La colonne | |
| <span class="live">En cours</span> montre ce que le modèle a répondu pour le texte saisi, et la ligne | |
| surlignée est l'option la plus probable.</p>`; | |
| // ===================================================================== | |
| // Case 1: prospect quality | |
| // ===================================================================== | |
| // Upper bound (exclusive) of each verdict band, for leads. | |
| const VERDICTS = [[35, "Prospect froid"], [55, "Peu qualifié"], [68, "À creuser"], | |
| [80, "Bon prospect"], [Infinity, "Prospect chaud"]]; | |
| const scoreColor = (v) => v < 55 ? "var(--bad)" : v < 68 ? "var(--mid)" : "var(--good)"; | |
| const prospectVerdict = (r) => r.kind !== "Prospect" ? `Pas un prospect : ${r.kind.toLowerCase()}` | |
| : VERDICTS.find(([max]) => r.score < max)[1]; | |
| function prospectResult(r) { | |
| if (!r) return `<div class="gauge">-<small>/100</small></div><div class="bar"><div></div></div> | |
| <div class="verdict">Commence à écrire.</div>`; | |
| return `<div class="gauge" style="color:${scoreColor(r.score)}">${r.score}<small>/100</small></div> | |
| <div class="bar"><div style="width:${r.score}%;background:${scoreColor(r.score)}"></div></div> | |
| <div class="verdict">${esc(prospectVerdict(r))}</div> | |
| ${r.signals.map(s => { | |
| const p = Math.round(s.p * 100); | |
| return `<div class="sig"><span>${esc(s.label)}</span> | |
| <div class="bar"><div style="width:${p}%;background:${scoreColor(p)}"></div></div><span>${p}%</span></div>`; | |
| }).join("")}`; | |
| } | |
| function prospectCalc(spec, d) { | |
| const lines = []; | |
| const coherent = d.coherence >= spec.coherence.threshold; | |
| lines.push(`1. Cohérence : ${p1(d.coherence)} ${coherent ? "≥" : "<"} ${pct(spec.coherence.threshold)} ${coherent ? "✓" : "✗"}`); | |
| if (!coherent) return lines.concat(` → note = 0, « texte incompréhensible »`).join("\n"); | |
| const leads = spec.kind.options.filter(o => o.lead); | |
| const pLead = leads.reduce((s, o) => s + d.kinds[o.key], 0); | |
| const isLead = pLead >= spec.kind.threshold; | |
| lines.push(`2. P(prospect) : ${leads.map(o => p1(d.kinds[o.key])).join(" + ")}`, | |
| ` = ${p1(pLead)} ${isLead ? "≥" : "<"} ${pct(spec.kind.threshold)} ${isLead ? "✓" : "✗"}`); | |
| if (!isLead) { | |
| const other = spec.kind.options.filter(o => !o.lead).reduce((a, o) => d.kinds[o.key] > d.kinds[a.key] ? o : a); | |
| return lines.concat(` → note = 0, type le plus probable : ${other.label} (${p1(d.kinds[other.key])})`).join("\n"); | |
| } | |
| const n = d.levels.length - 1; | |
| const expected = d.levels.reduce((s, p, i) => s + p * i, 0); | |
| lines.push(`3. Niveau attendu = ${d.levels.map((p, i) => `${i} × ${num(p)}`).join(" + ")}`, | |
| ` = ${num(expected)} sur ${n}`, | |
| ` Note = ${num(expected)} ÷ ${n} × 100 = ${Math.round(expected / n * 100)}`); | |
| return lines.join("\n"); | |
| } | |
| function prospectExplain(spec, r) { | |
| const d = r && r.details; | |
| const kinds = spec.kind.options; | |
| const nonLead = kinds.filter(o => !o.lead), leads = kinds.filter(o => o.lead); | |
| const pLead = d ? leads.reduce((s, o) => s + d.kinds[o.key], 0) : null; | |
| const levels = spec.quality.levels, n = levels.length - 1; | |
| const expected = d ? d.levels.reduce((s, p, i) => s + p * i, 0) : null; | |
| const topLevel = d ? d.levels.indexOf(Math.max(...d.levels)) : null; | |
| const kindOptions = [...nonLead, ...leads].map(o => ({ ...o, label: o.lead ? "Prospect" : o.label })); | |
| return `${intro(7)} | |
| ${currentBox("Email", r, x => `${x.score}/100, ${prospectVerdict(x)}`)} | |
| <h3><span class="step">Étape 1</span>Quel type d'email ? <code>choice</code>, ${kinds.length} options</h3> | |
| <div class="quote">${esc(spec.kind.instructions)}</div> | |
| <p>Chaque option est un <b>KIND</b> : une clé (ce que le modèle voit comme nom d'option) et une courte | |
| description (qui l'aide à comprendre ce que la clé recouvre). Le modèle répartit 100 % de probabilité | |
| entre toutes les options. Il y a deux familles :</p> | |
| <ul> | |
| <li><b>${nonLead.length} types hors-sujet</b> : ${nonLead.map(o => esc(o.label.toLowerCase())).join(", ")}. | |
| Ce sont les emails qu'une équipe commerciale reçoit en réalité, et qu'il faut savoir écarter.</li> | |
| <li><b>${leads.length} niveaux de prospect</b> (marqués d'un trait bleu), du plus froid au plus chaud.</li> | |
| </ul> | |
| ${optionsTable(kindOptions, d && d.kinds, { rowClass: o => o.lead ? "lead" : "" })} | |
| <p>P(prospect) = somme des ${leads.length} niveaux de prospect = <span class="live">${d ? pct(pLead) : "-"}</span> | |
| (seuil : ${pct(spec.kind.threshold)}).</p> | |
| <h3><span class="step">Étape 2</span>Le texte est-il cohérent ? <code>noul</code></h3> | |
| <div class="quote">${esc(spec.coherence.instructions)}</div> | |
| <p>Le modèle choisit toujours une option, même face à du charabia : « azerty qsdf » était classé | |
| « prospect » à 80 % à l'étape 1, faute de mieux. Ajouter une option « charabia » déséquilibrait les | |
| autres, alors cette question séparée sert de garde-fou.</p> | |
| <p><b>Règle :</b> sous ${pct(spec.coherence.threshold)}, la note est 0 et le texte est marqué | |
| « incompréhensible ». En cours : <span class="live">${d ? pct(d.coherence) : "-"}</span>.</p> | |
| <h3><span class="step">Étape 3</span>Quelle est la qualité du prospect ? <code>score</code>, ${levels.length} niveaux</h3> | |
| <div class="quote">${esc(spec.quality.instructions)}</div> | |
| <p>C'est cette question qui donne la note. Chaque niveau décrit <b>ce que l'email doit contenir</b> : | |
| des étiquettes nues (« bon », « excellent ») séparaient beaucoup moins bien les emails.</p> | |
| <div class="scroll"><table> | |
| <thead><tr><th class="num">Niveau</th><th>Description envoyée au modèle</th><th class="num">Points</th>${liveHead}</tr></thead> | |
| <tbody>${levels.map((l, i) => `<tr class="${i === topLevel ? "top" : ""}"><td class="num">${i}</td> | |
| <td class="en">${esc(l)}</td><td class="num">${Math.round(i / n * 100)}</td>${liveCell(d ? d.levels[i] : null)}</tr>`).join("")}</tbody> | |
| <tfoot><tr><td colspan="3">Niveau attendu = Σ niveau × probabilité, ramené sur 100</td> | |
| <td class="num live">${d ? Math.round(expected / n * 100) : "-"}</td></tr></tfoot> | |
| </table></div> | |
| <p>La note n'est pas le niveau le plus probable, mais la <b>moyenne pondérée</b> : si le modèle hésite | |
| entre « bon » et « parfait », la note tombe entre les deux. C'est aussi pourquoi elle plafonne vers 85 : | |
| le modèle ne met jamais toute sa probabilité sur le niveau le plus haut.</p> | |
| <h3><span class="step">Étape 4</span>Le détail BANT : ${spec.signals.length} questions <code>noul</code></h3> | |
| <p>Budget, Authority (décideur), Need (besoin), Timing : la grille de qualification commerciale classique. | |
| Ces réponses sont affichées en barres pour <b>expliquer</b> la note, mais <b>n'entrent pas dans son | |
| calcul</b>.</p> | |
| <div class="scroll"><table> | |
| <thead><tr><th>Barre</th><th>Affirmation envoyée au modèle</th>${liveHead}</tr></thead> | |
| <tbody>${spec.signals.map((s, i) => `<tr><td>${esc(s.label)}</td><td class="en">${esc(s.instructions)}</td> | |
| ${liveCell(r ? r.signals[i].p : null)}</tr>`).join("")}</tbody> | |
| </table></div> | |
| <p>Elles sont formulées comme des <b>faits vérifiables dans le texte</b> (« l'email mentionne une | |
| échéance ») plutôt que comme des jugements (« veut-il démarrer bientôt ? ») : la version jugement | |
| répondait oui à 66 % pour un simple « Hello, comment ça va ? ». Comme toute question oui/non, elles | |
| dépassent rarement 70 %.</p> | |
| <h3>Calcul final</h3> | |
| <pre>si cohérence < ${pct(spec.coherence.threshold)} → note = 0, « texte incompréhensible » | |
| si P(prospect) < ${pct(spec.kind.threshold)} → note = 0, type hors-sujet le plus probable | |
| sinon → note = niveau attendu (étape 3) sur 100</pre> | |
| ${d ? `<p>Appliqué au <span class="live">texte en cours</span> :</p><pre>${prospectCalc(spec, d)}</pre>` : ""} | |
| <p>Verdict affiché pour un prospect : ${VERDICTS.map(([max, label], i) => | |
| `${label} ${i === 0 ? `(moins de ${max})` : max === Infinity ? `(${VERDICTS[i - 1][0]} et plus)` : `(${VERDICTS[i - 1][0]} à ${max - 1})`}`).join(", ")}.</p>`; | |
| } | |
| // ===================================================================== | |
| // Case 2: review a sales email before sending | |
| // ===================================================================== | |
| const reviewColor = (r) => r.passed === r.total ? "var(--good)" : r.passed >= r.total - 1 ? "var(--mid)" : "var(--bad)"; | |
| const reviewSummary = (r) => `${r.passed}/${r.total} contrôles réussis${r.warning ? ` (${r.warning})` : ""}`; | |
| function reviewResult(r) { | |
| if (!r) return `<div class="headline">-<small>/4</small></div><div class="verdict">Commence à écrire ton email.</div>`; | |
| return `<div class="headline" style="color:${reviewColor(r)}">${r.passed}<small>/${r.total} contrôles réussis</small></div> | |
| ${r.warning ? `<div class="warning">${esc(r.warning)}</div>` : ""} | |
| ${r.checks.map(c => `<div class="check"> | |
| <span class="icon ${c.ok ? "ok" : "ko"}">${c.ok ? "✓" : "✗"}</span> | |
| <div>${esc(c.label)}${c.approx ? `<span class="tag">approximatif</span>` : ""} | |
| ${c.detail ? `<div class="detail">Détecté : ${esc(c.detail)}</div>` : ""} | |
| ${c.tip ? `<div class="tip">${esc(c.tip)}</div>` : ""}</div> | |
| </div>`).join("")}`; | |
| } | |
| function reviewCalc(spec, r) { | |
| const lines = spec.checks.map((c, i) => { | |
| const p = r.checks[i].p, present = p >= spec.threshold; | |
| return `${c.label.padEnd(22)}: ${p1(p).padStart(7)} ${present ? "≥" : "<"} ${pct(spec.threshold)} → ${present ? "présent" : "absent "} ${r.checks[i].ok ? "✓" : "✗"}`; | |
| }); | |
| const step = r.checks[r.checks.length - 1]; | |
| lines.push(`${"Prochaine étape claire".padEnd(22)}: option la plus probable = « ${step.detail} » (${p1(step.p)}) ${step.ok ? "✓" : "✗"}`); | |
| lines.push("", `Résultat : ${r.passed}/${r.total}`); | |
| return lines.join("\n"); | |
| } | |
| function reviewExplain(spec, r) { | |
| const d = r && r.details; | |
| const salesOptions = Object.entries(spec.sales.options).map(([k, v]) => ({ key: k, description: v, | |
| label: k === "sales email" ? "Email commercial" : "Avertissement" })); | |
| return `<p>Contrairement au cas 1, il n'y a <b>pas de note sur 100</b> : sur une trentaine de formulations | |
| testées, aucun modèle ne savait juger la qualité d'ensemble d'un email sortant (l'email le mieux écrit | |
| était classé « brochure générique »). En revanche, Laya repère bien certains <b>faits précis</b>. Ce cas | |
| est donc une liste de contrôles, chacun vérifié par une question séparée.</p> | |
| ${intro(6)} | |
| ${currentBox("Email", r, reviewSummary)} | |
| <h3><span class="step">Étape 1</span>Trois détecteurs de défauts : ${spec.checks.length} questions <code>noul</code></h3> | |
| <p>${spec.routing && !spec.routing[spec.checks[0].key].includes("typed") | |
| ? `Ces trois questions sont envoyées au modèle anglais généraliste <code>laya</code> : sur les emails de test, | |
| il séparait les emails concernés des autres avec des écarts de 52 à 66 points, contre moins de 35 pour | |
| <code>laya-typed-decisions</code>.` | |
| : `Sur les emails de test, le modèle anglais généraliste <code>laya</code> séparait bien mieux ces défauts | |
| (écarts de 52 à 66 points) que <code>laya-typed-decisions</code>. Cette instance ne charge que | |
| <code>laya-typed-decisions</code> pour rester légère : ces trois contrôles y sont donc moins fiables.`} | |
| Un fait est considéré présent si sa probabilité atteint ${pct(spec.threshold)}.</p> | |
| <div class="scroll"><table> | |
| <thead><tr><th>Contrôle</th><th>Affirmation envoyée au modèle</th><th>Réussi si</th>${liveHead}</tr></thead> | |
| <tbody>${spec.checks.map((c, i) => `<tr><td>${esc(c.label)}</td><td class="en">${esc(c.instructions)}</td> | |
| <td>${c.good_when ? "vrai" : "faux"}</td>${liveCell(r ? r.checks[i].p : null)}</tr>`).join("")}</tbody> | |
| </table></div> | |
| <p>Comme dans le cas 1, les affirmations portent sur <b>ce qui est écrit</b> (« contient un pourcentage ») | |
| plutôt que sur un jugement (« l'email est-il convaincant ? »).</p> | |
| <h3><span class="step">Étape 2</span>Quelle prochaine étape l'email propose-t-il ? <code>choice</code>, ${spec.next_step.options.length} options</h3> | |
| <div class="quote">${esc(spec.next_step.instructions)}</div> | |
| <p>Toutes les affirmations oui/non testées pour l'appel à l'action échouaient. Un choix entre plusieurs | |
| demandes possibles fonctionne mieux, avec <code>laya-typed-decisions</code> : le contrôle est réussi si | |
| l'option la plus probable est « ${esc(spec.next_step.options.find(o => o.good).label)} ». Il reste | |
| <b>approximatif</b> : environ 8 emails de test sur 10 étaient bien classés.</p> | |
| ${optionsTable(spec.next_step.options, d && d.steps, { rowClass: o => o.good ? "lead" : "" })} | |
| <h3><span class="step">Garde-fous</span>Est-ce bien un email commercial ?</h3> | |
| <p>Deux questions ne changent pas le résultat mais affichent un avertissement.</p> | |
| <p><b>Cohérence</b> (<code>noul</code>, comme au cas 1) : « ${esc(spec.coherence.instructions)} » | |
| Sous ${pct(spec.coherence.threshold)}, le texte est signalé comme incompréhensible. | |
| En cours : <span class="live">${d ? pct(d.coherence) : "-"}</span>.</p> | |
| <p><b>Type de texte</b> (<code>choice</code>) : « ${esc(spec.sales.instructions)} » Si « sales email » est | |
| sous ${pct(spec.sales.threshold)}, le texte est signalé comme n'étant pas un email commercial.</p> | |
| ${optionsTable(salesOptions, d && d.text_kind)} | |
| <p>Ce filtre ne bloque pas les contrôles, car il distingue mal un email commercial vague ou timide d'un | |
| texte hors-sujet : il repère les salutations et messages personnels, mais laisse passer une recette.</p> | |
| <h3>Calcul final</h3> | |
| <pre>chaque contrôle réussi compte 1 point, sur ${spec.checks.length + 1}</pre> | |
| ${r ? `<p>Appliqué au <span class="live">texte en cours</span> :</p><pre>${esc(reviewCalc(spec, r))}</pre>` : ""}`; | |
| } | |
| // ===================================================================== | |
| // Case 3: the Sorting Hat | |
| // ===================================================================== | |
| const HOUSE_COLORS = { gryffindor: "#b3261e", ravenclaw: "#2a5db0", hufflepuff: "#c99a06", slytherin: "#1f7a4d" }; | |
| const HESITATION = 0.10; | |
| const sortingSummary = (r) => `${r.house[0].label}, ${r.role[0].label.toLowerCase()}, ${r.language[0].label}`; | |
| function sortingResult(r) { | |
| if (!r) return `<div class="verdict">Décris-toi en quelques lignes : tes goûts, ton caractère, ce qui te motive.</div>`; | |
| const [h1, h2] = r.house; | |
| return `<div class="verdict" style="margin:0">Le Choixpeau a parlé :</div> | |
| <div class="house" style="color:${HOUSE_COLORS[h1.key]}">${esc(h1.label)}</div> | |
| <div class="hesit">${h1.p - h2.p < HESITATION ? `Il a hésité avec ${esc(h2.label)}...` : "Sans hésitation."}</div> | |
| ${r.house.map(h => { | |
| const p = Math.round(h.p * 100); | |
| return `<div class="sig"><span>${esc(h.label)}</span> | |
| <div class="bar"><div style="width:${p}%;background:${HOUSE_COLORS[h.key]}"></div></div><span>${p}%</span></div>`; | |
| }).join("")} | |
| <div class="extra"> | |
| <div>Dans un braquage : <b>${esc(r.role[0].label)}</b><span>puis ${esc(r.role[1].label.toLowerCase())} (${pct(r.role[1].p)})</span></div> | |
| <div>En langage de programmation : <b>${esc(r.language[0].label)}</b><span>puis ${esc(r.language[1].label)} (${pct(r.language[1].p)})</span></div> | |
| </div>`; | |
| } | |
| function sortingExplain(spec, r) { | |
| const probs = (key) => r ? Object.fromEntries(r[key].map(o => [o.key, o.p])) : null; | |
| const section = (key, step, title, why) => ` | |
| <h3><span class="step">${step}</span>${title} <code>choice</code>, ${spec[key].options.length} options</h3> | |
| <div class="quote">${esc(spec[key].instructions)}</div> | |
| ${why} | |
| ${optionsTable(spec[key].options, probs(key))}`; | |
| const calc = r ? ["house", "role", "language"].map(k => { | |
| const [a, b] = r[k]; | |
| return `${({ house: "Maison", role: "Braquage", language: "Langage" })[k].padEnd(9)}: ${a.label} (${p1(a.p)}) devant ${b.label} (${p1(b.p)})`; | |
| }).join("\n") + `\n\nÉcart maison : ${p1(r.house[0].p - r.house[1].p)} ${r.house[0].p - r.house[1].p < HESITATION ? "<" : "≥"} ${pct(HESITATION)} → ${r.house[0].p - r.house[1].p < HESITATION ? "« Il a hésité »" : "« Sans hésitation »"}` : ""; | |
| return `<p>Un cas purement ludique, mais qui montre ce que Laya fait le mieux : <b>choisir parmi des options | |
| nommées</b>. Pas de seuil ni de note, juste trois questions à choix, toutes envoyées à | |
| <code>laya-typed-decisions</code>.</p> | |
| ${intro(3)} | |
| ${currentBox("Description", r, sortingSummary)} | |
| ${section("house", "Question 1", "Quelle maison de Poudlard ?", | |
| `<p>Chaque maison est décrite par ses valeurs. Sur 4 profils types (fonceur, rat de bibliothèque, ami | |
| fidèle, ambitieux), le modèle trouvait la bonne maison 4 fois sur 4.</p>`)} | |
| ${section("role", "Question 2", "Quel rôle dans un braquage ?", | |
| `<p>Avec des descriptions courtes et abstraites (« leader stratégique »), presque tous les profils | |
| devenaient « le cerveau ». Décrire <b>ce que fait la personne pendant le braquage</b> donne des réponses | |
| bien plus variées.</p>`)} | |
| ${section("language", "Question 3", "Quel langage de programmation ?", | |
| `<p>Chaque langage est décrit par une personnalité. Une description trop large (« présent partout ») | |
| attirait tous les profils vers JavaScript : chaque option doit évoquer un trait distinct.</p>`)} | |
| <h3>Calcul final</h3> | |
| <pre>verdict = option la plus probable de chaque question | |
| « Il a hésité » si les deux premières maisons sont à moins de ${pct(HESITATION)} d'écart</pre> | |
| ${r ? `<p>Appliqué au <span class="live">texte en cours</span> :</p><pre>${esc(calc)}</pre>` : ""}`; | |
| } | |
| // ===================================================================== | |
| // Cases and wiring | |
| // ===================================================================== | |
| const CASES = { | |
| prospect: { | |
| title: "1. Qualité du prospect", | |
| placeholder: "Colle ou écris un email reçu d'un prospect...", | |
| result: prospectResult, explain: prospectExplain, | |
| examples: { | |
| "Spam": "Félicitations ! Vous avez gagné un iPhone 17. Cliquez ici pour récupérer votre cadeau avant minuit.", | |
| "Démarchage fournisseur": "Bonjour, nous sommes une agence de génération de leads. Seriez-vous disponible 15 minutes pour découvrir comment nous pouvons doubler vos ventes ?", | |
| "Étudiant curieux": "Bonjour, je suis étudiant et je fais un mémoire sur l'IA. Est-ce que je pourrais vous poser quelques questions ? Merci !", | |
| "Gérant de PME, sans budget": "Bonjour, je gère une PME de 15 personnes. On pense à refaire notre site vitrine un jour, rien d'urgent et pas encore de budget. Vous faites ce genre de projet ?", | |
| "Directrice commerciale, projet flou": "Bonjour, je suis directrice commerciale d'une PME industrielle de 60 personnes. Nous cherchons un CRM pour suivre nos commerciaux. Le budget n'est pas encore validé et nous n'avons pas de date. Pouvez-vous nous présenter votre offre ?", | |
| "CFO avec échéance": "Bonjour, je suis CFO d'une scale-up de 120 personnes. Nous devons remplacer notre outil de reporting avant la clôture de décembre et nous avons prévu une enveloppe de 40k€. Pouvons-nous échanger cette semaine ?", | |
| "DG, budget validé et urgence": "Bonjour, je suis directrice générale d'un groupe de distribution de 1 200 salariés. Notre plateforme e-commerce plante à chaque pic de ventes et nous perdons du chiffre d'affaires. Le conseil a approuvé un budget de 250k€ pour la refondre, je suis seule décisionnaire et la mise en ligne doit se faire avant le Black Friday. Pouvez-vous démarrer dans deux semaines ? Appelez-moi dès que possible.", | |
| }, | |
| }, | |
| review: { | |
| title: "2. Relecture avant envoi", | |
| placeholder: "Écris l'email de prospection que tu t'apprêtes à envoyer...", | |
| result: reviewResult, explain: reviewExplain, | |
| examples: { | |
| "Brochure générique": "Bonjour, nous sommes une agence digitale leader sur notre marché. Nous proposons des sites web, du SEO, des applications mobiles, du marketing digital et bien plus encore. N'hésitez pas à consulter notre site pour en savoir plus. Cordialement.", | |
| "Trop insistant": "Bonjour, DERNIÈRE CHANCE !!! Notre offre exceptionnelle expire ce soir. Vous devez absolument nous appeler aujourd'hui, sinon vous allez perdre des clients face à vos concurrents. Répondez immédiatement.", | |
| "Trop timide": "Bonjour Madame Martin, désolé de vous déranger, je ne sais pas si cela peut vous intéresser, mais peut-être qu'éventuellement notre outil pourrait vous être utile un jour, si vous avez le temps. Pas de souci si ce n'est pas le cas, encore désolé.", | |
| "Sans prochaine étape": "Bonjour Madame Martin, j'ai vu que Boulangeries Martin venait d'ouvrir sa cinquième boutique à Lyon, félicitations ! Avec autant de points de vente, la gestion des commandes en ligne devient vite un casse-tête. Nous avons aidé une chaîne de boulangeries similaire à réduire de 30 % les erreurs de commande grâce à un outil de click and collect.", | |
| "Excellent": "Bonjour Madame Martin, j'ai vu que Boulangeries Martin venait d'ouvrir sa cinquième boutique à Lyon, félicitations ! Avec autant de points de vente, la gestion des commandes en ligne devient vite un casse-tête. Nous avons aidé une chaîne de boulangeries similaire à réduire de 30 % les erreurs de commande grâce à un outil de click and collect. Seriez-vous disponible 15 minutes jeudi ou vendredi pour que je vous montre comment cela fonctionnerait chez vous ?", | |
| }, | |
| }, | |
| sorting: { | |
| title: "3. Le Choixpeau", | |
| placeholder: "Décris-toi en quelques lignes : ton caractère, ce que tu aimes, ce qui te motive...", | |
| result: sortingResult, explain: sortingExplain, | |
| examples: { | |
| "Fonceur": "Je fonce toujours tête baissée, j'adore l'aventure et je défends les autres même quand j'ai peur. Je préfère agir que réfléchir.", | |
| "Rat de bibliothèque": "Je passe mes soirées à lire des livres de philosophie et à résoudre des énigmes. J'adore apprendre et comprendre comment les choses fonctionnent.", | |
| "Ami fidèle": "Je suis fidèle à mes amis, patient et travailleur. Je préfère aider les autres et je n'aime pas être au centre de l'attention.", | |
| "Ambitieux": "Je veux réussir à tout prix et devenir le patron. Je suis stratège, ambitieux et je sais utiliser les bonnes personnes pour atteindre mes objectifs.", | |
| "Dev rigoureux": "Je suis très rigoureux, je déteste les bugs et j'aime que tout soit prouvé et typé. La performance compte énormément pour moi.", | |
| "Artiste": "Je suis rêveur et créatif, je dessine, j'écris des poèmes et je trouve de la beauté partout. Je déteste les règles.", | |
| "Bavard": "Je peux convaincre n'importe qui de n'importe quoi, je parle beaucoup et je me fais des amis partout où je vais.", | |
| }, | |
| }, | |
| }; | |
| let current = "prospect", seq = 0, timer; | |
| const state = Object.fromEntries(Object.keys(CASES).map(k => [k, { text: "", result: null, spec: null }])); | |
| function renderAll() { | |
| const c = CASES[current], st = state[current], r = st.result; | |
| $("result").innerHTML = c.result(r); | |
| $("ms").textContent = r ? `${Math.round(r.ms)} ms` : "-"; | |
| $("tin").textContent = r ? r.input_tokens : "-"; | |
| $("tout").textContent = r ? r.output_tokens : "-"; | |
| $("models").textContent = r ? `Modèle${r.models.length > 1 ? "s" : ""} : ${r.models.map(m => m.split("/")[1]).join(", ")}` : ""; | |
| $("case-body").innerHTML = st.spec ? c.explain(st.spec, r) : "<p>Chargement...</p>"; | |
| } | |
| function selectCase(key) { | |
| state[current].text = text.value; | |
| current = key; | |
| const c = CASES[key]; | |
| document.querySelectorAll("#cases button").forEach(b => b.setAttribute("aria-selected", b.dataset.case === key)); | |
| text.placeholder = c.placeholder; | |
| text.value = state[key].text; | |
| $("examples").innerHTML = ""; | |
| for (const [name, value] of Object.entries(c.examples)) { | |
| const b = document.createElement("button"); | |
| b.textContent = name; | |
| b.onclick = () => { text.value = value; schedule(0); }; | |
| $("examples").append(b); | |
| } | |
| if (!state[key].spec) { | |
| fetch(`${API}/cases/${key}`).then(r => r.json()).then(s => { state[key].spec = s; if (current === key) renderAll(); }); | |
| } | |
| renderAll(); | |
| } | |
| for (const [key, c] of Object.entries(CASES)) { | |
| const b = document.createElement("button"); | |
| b.dataset.case = key; | |
| b.textContent = c.title; | |
| b.onclick = () => selectCase(key); | |
| $("cases").append(b); | |
| } | |
| async function run() { | |
| const value = text.value.trim(), key = current; | |
| if (!value) return; | |
| const id = ++seq; | |
| const res = await fetch(`${API}/predict`, { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ case: key, text: value }), | |
| }); | |
| const data = await res.json(); | |
| if (id !== seq) return; // ignore responses that arrive out of order | |
| state[key].result = { ...data, text: value }; | |
| if (current === key) renderAll(); | |
| } | |
| // Wait for a pause in typing before calling the model; a CPU server needs a longer pause. | |
| const DEBOUNCE = window.LAYA_DEBOUNCE || 150; | |
| function schedule(delay = DEBOUNCE) { clearTimeout(timer); timer = setTimeout(run, delay); } | |
| text.addEventListener("input", () => schedule()); | |
| selectCase("prospect"); | |
| fetch(`${API}/health`).then(r => r.json()).then(h => { | |
| $("instance").innerHTML = `<b>Cette instance</b> : moteur ${h.backend === "mlx" ? "laya-mlx (MLX)" : "Laya d'origine (PyTorch)"}, | |
| modèle${h.models.length > 1 ? "s" : ""} ${h.models.map(m => `<code>${esc(m)}</code>`).join(" et ")}.`; | |
| }).catch(() => {}); | |
| </script> | |
| </body> | |
| </html> | |