SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
2 h agolast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
29.2 KB · 578 lines javascript
Raw Blame History
1/* UQO Éval — Éva, l'assistante pédagogique (widget de chat).2 * Dérivé du widget ka-agent (vanilla JS, zéro dépendance), rebranché sur3 * l'API LOCALE /api/ka (agent Claude + outils du moteur d'évaluation).4 *   <script>window.KA_AGENT={site:"uqo-eval"}</script>5 *   <script src="/assistant.js" defer></script>6 * v2 (2026-08-19) : ouverture directement PLEIN ÉCRAN (desktop et mobile,7 * bouton ▭ pour réduire en fenêtre sur desktop) ; sur téléphone le panneau se8 * cale sur window.visualViewport (le clavier ne fait plus grossir/défiler la9 * page — fond verrouillé par body position:fixed) ; rendu MARKDOWN complet en10 * streaming (titres, listes, tableaux, code, liens nommés, citations).11 * Flux SSE en direct depuis le backend central (texte token par token +12 * indicateur d'outil). Historique conservé en localStorage (par site).13 * v3 (2026-08-22) : bulle DÉPLAÇABLE au doigt/à la souris (pointer events,14 * seuil 6 px pour distinguer clic et glisser, bornée aux marges de l'écran,15 * position mémorisée par site en localStorage, re-bornée au resize) ; en mode16 * fenêtré le panneau s'ancre du côté où se trouve la bulle.17 * v4 (2026-08-25) : CARTES d'annonces cliquables (bloc ```ka-card — un JSON18 * par ligne : site/titre/sous_titre/prix/badge/image/lien) rendues aux19 * couleurs de la plateforme visée (palette écosystème embarquée), et CHOIX20 * de clarification (bloc ```ka-choix — {question, options[]}) rendus en21 * boutons : un clic envoie l'option comme message ; seuls les boutons de la22 * dernière bulle assistante restent actifs.23 * v4.2 (2026-08-25) : extraction des cartes/choix par ÉQUILIBRAGE D'ACCOLADES24 * (jsonObjects) — tous les objets JSON d'un bloc sont trouvés quelle que soit25 * la mise en forme (une ligne, aéré, tableau) et un bloc tronqué en plein26 * streaming rend toutes ses cartes complètes au lieu d'une seule.27 */28(function () {29  "use strict";30  var CFG = window.KA_AGENT || {};31  var SITE = CFG.site || "uqo-eval";32  var ENDPOINT = CFG.endpoint || "/api/ka"; // API locale — aucune dépendance externe33  var KEY = "ka-agent-" + SITE;34  var MAXLOG = 20;3536  /* ---------- styles ---------- */37  var css = `38  .kaa-btn{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;width:58px;height:58px;39    border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#006085);40    color:var(--on-accent,#ffffff);font:700 17px/1 "Space Grotesk",system-ui,sans-serif;41    cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center;42    justify-content:center;transition:transform .15s;touch-action:none}43  .kaa-btn:hover{transform:translate(-2px,-2px)}44  .kaa-btn.kaa-drag{transition:none;cursor:grabbing}45  .kaa-btn.kaa-drag:hover{transform:none}46  .kaa-panel{position:fixed;z-index:2147483001;display:none;flex-direction:column;background:var(--paper,#f5f3ee);47    overflow:hidden;font-family:Inter,system-ui,sans-serif;touch-action:manipulation}48  .kaa-panel.kaa-open{display:flex}49  .kaa-panel.kaa-full{left:0;top:0;right:0;bottom:0;width:100vw;height:100vh;height:100dvh;border:0;border-radius:0;box-shadow:none}50  .kaa-panel:not(.kaa-full){right:16px;bottom:calc(86px + env(safe-area-inset-bottom,0px));width:min(392px,calc(100vw - 24px));51    height:min(580px,calc(100vh - 110px));height:min(580px,calc(100dvh - 110px));52    border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85)}53  .kaa-panel.kaa-left:not(.kaa-full){right:auto;left:16px}54  html.kaa-lock,html.kaa-lock body{overflow:hidden!important;overscroll-behavior:none;touch-action:none}55  .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814);56    color:var(--paper,#f5f3ee);flex:none;padding-top:max(11px,env(safe-area-inset-top,0px))}57  .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}58  .kaa-head .kaa-ka{display:inline-block;background:var(--accent,#006085);color:var(--on-accent,#ffffff);59    border-radius:6px;padding:1px 7px 2px;transform:rotate(-2deg);font-weight:700}60  .kaa-head small{font:700 9.5px "JetBrains Mono",monospace;letter-spacing:.09em;text-transform:uppercase;opacity:.65}61  .kaa-head .kaa-sp{flex:1}62  .kaa-hbtn{width:34px;height:34px;border:1.5px solid rgba(245,243,238,.4);border-radius:8px;background:none;63    color:var(--paper,#f5f3ee);font-size:15px;cursor:pointer;line-height:1;flex:none}64  .kaa-hbtn:hover{border-color:var(--accent,#006085);color:var(--accent,#006085)}65  .kaa-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px;66    overscroll-behavior:contain;-webkit-overflow-scrolling:touch}67  .kaa-msg{max-width:86%;padding:9px 13px;border:1.5px solid var(--ink,#141814);border-radius:12px;68    font-size:13.5px;line-height:1.55;word-break:break-word}69  .kaa-full .kaa-msg{max-width:min(86%,760px)}70  .kaa-full .kaa-log{align-items:stretch}71  .kaa-msg.kaa-u{align-self:flex-end;background:var(--accent,#006085);color:var(--on-accent,#ffffff);72    border-bottom-right-radius:4px;white-space:pre-wrap}73  .kaa-msg.kaa-a{align-self:flex-start;background:#fff;border-bottom-left-radius:4px;box-shadow:3px 3px 0 rgba(20,24,20,.12)}74  .kaa-msg.kaa-a a{color:inherit;text-decoration:underline;text-underline-offset:3px;font-weight:600}75  .kaa-msg.kaa-a p{margin:.35em 0}76  .kaa-msg.kaa-a p:first-child{margin-top:0}77  .kaa-msg.kaa-a p:last-child{margin-bottom:0}78  .kaa-msg.kaa-a h1,.kaa-msg.kaa-a h2,.kaa-msg.kaa-a h3,.kaa-msg.kaa-a h4{79    font:700 14px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.01em;margin:.7em 0 .3em}80  .kaa-msg.kaa-a h1:first-child,.kaa-msg.kaa-a h2:first-child,.kaa-msg.kaa-a h3:first-child,.kaa-msg.kaa-a h4:first-child{margin-top:.1em}81  .kaa-msg.kaa-a h1{font-size:15.5px}.kaa-msg.kaa-a h2{font-size:14.5px}82  .kaa-msg.kaa-a ul,.kaa-msg.kaa-a ol{margin:.35em 0;padding-left:1.35em}83  .kaa-msg.kaa-a li{margin:.18em 0}84  .kaa-msg.kaa-a code{font:600 12px "JetBrains Mono",monospace;background:var(--surface-2,#f0eee7);85    border:1px solid rgba(20,24,20,.15);border-radius:5px;padding:.5px 4px}86  .kaa-msg.kaa-a pre{margin:.45em 0;padding:9px 11px;background:var(--ink,#141814);color:var(--paper,#f5f3ee);87    border-radius:9px;overflow-x:auto}88  .kaa-msg.kaa-a pre code{background:none;border:0;color:inherit;padding:0;font-weight:400;font-size:11.5px;line-height:1.5}89  .kaa-msg.kaa-a blockquote{margin:.45em 0;padding:.15em .8em;border-left:3px solid var(--accent,#006085);90    background:var(--surface-2,#faf9f5);color:var(--ink-2,#4d5551)}91  .kaa-msg.kaa-a hr{border:0;border-top:1.5px dashed rgba(20,24,20,.3);margin:.6em 0}92  .kaa-msg.kaa-a table{border-collapse:collapse;margin:.45em 0;font-size:12.5px;display:block;overflow-x:auto;max-width:100%}93  .kaa-msg.kaa-a th,.kaa-msg.kaa-a td{border:1.5px solid var(--ink,#141814);padding:4px 9px;text-align:left;white-space:nowrap}94  .kaa-msg.kaa-a th{background:var(--accent,#006085);color:var(--on-accent,#ffffff);95    font:700 11px "Space Grotesk",system-ui,sans-serif;letter-spacing:.02em}96  .kaa-msg.kaa-a td{background:#fff}97  .kaa-tool{align-self:flex-start;font:700 10px "JetBrains Mono",monospace;letter-spacing:.07em;98    text-transform:uppercase;color:var(--ink-2,#4d5551);padding:3px 10px;border:1.5px dashed var(--ink-2,#4d5551);99    border-radius:999px;background:var(--surface-2,#faf9f5)}100  .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none;101    padding-bottom:calc(11px + env(safe-area-inset-bottom,0))}102  .kaa-full .kaa-in{justify-content:center}103  .kaa-full .kaa-in textarea{max-width:700px}104  .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:10px 12px;font:16px/1.4 Inter,system-ui,sans-serif;105    border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none}106  .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px;107    background:var(--ink,#141814);color:var(--accent,#006085);font:700 16px "Space Grotesk",system-ui;cursor:pointer;touch-action:manipulation}108  .kaa-in button:disabled{opacity:.45}109  .kaa-cards{display:flex;flex-direction:column;gap:8px;margin:.5em 0 .2em}110  .kaa-card{display:flex;align-items:stretch;border:1.5px solid var(--ink,#141814);border-radius:11px;111    overflow:hidden;background:#fff;text-decoration:none!important;color:var(--ink,#141814);112    box-shadow:3px 3px 0 rgba(20,24,20,.14);border-left:5px solid var(--kaa-c,var(--accent,#006085));transition:transform .12s}113  .kaa-msg.kaa-a a.kaa-card{text-decoration:none;font-weight:400}114  .kaa-card:hover{transform:translate(-1px,-1px)}115  .kaa-card-img{flex:none;width:76px;min-height:76px;background-size:cover;background-position:center;116    border-right:1.5px solid rgba(20,24,20,.15)}117  .kaa-card-body{flex:1;display:flex;flex-direction:column;gap:2px;padding:8px 11px;min-width:0}118  .kaa-card-top{display:flex;gap:6px;align-items:center;flex-wrap:wrap}119  .kaa-card-site{font:700 9px "JetBrains Mono",monospace;letter-spacing:.08em;text-transform:uppercase;120    background:var(--kaa-c,var(--accent,#006085));color:var(--kaa-on,var(--on-accent,#ffffff));border-radius:5px;padding:1px 6px 2px}121  .kaa-card-badge{font:700 9px "JetBrains Mono",monospace;letter-spacing:.06em;text-transform:uppercase;122    border:1px dashed var(--ink-2,#4d5551);border-radius:999px;padding:1px 7px;color:var(--ink-2,#4d5551)}123  .kaa-card-title{font:700 13.5px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.01em;line-height:1.25}124  .kaa-card-sub{font-size:11.5px;color:var(--ink-2,#4d5551);line-height:1.35}125  .kaa-card-price{font:700 13px "Space Grotesk",system-ui,sans-serif;margin-top:1px}126  .kaa-card-go{flex:none;align-self:center;padding:0 10px;font-weight:700;opacity:.55}127  .kaa-choices{display:flex;flex-wrap:wrap;gap:7px;margin:.45em 0 .15em;align-items:center}128  .kaa-choices-q{flex-basis:100%;font-size:12px;color:var(--ink-2,#4d5551);font-weight:600}129  .kaa-choice{border:1.5px solid var(--ink,#141814);border-radius:999px;background:var(--paper,#f5f3ee);130    color:var(--ink,#141814);font:600 12.5px Inter,system-ui,sans-serif;padding:7px 13px;cursor:pointer;131    box-shadow:2px 2px 0 rgba(20,24,20,.35);transition:transform .1s;touch-action:manipulation}132  .kaa-choice:hover{background:var(--accent,#006085);color:var(--on-accent,#ffffff);transform:translate(-1px,-1px)}133  .kaa-choices.kaa-done .kaa-choice{opacity:.45;pointer-events:none;box-shadow:none}134  .kaa-hello{font-size:12.5px;color:var(--ink-2,#4d5551);padding:4px 2px;max-width:760px}135  .kaa-dots::after{content:"…";animation:kaa-b 1.2s infinite}136  @keyframes kaa-b{0%{opacity:.2}50%{opacity:1}100%{opacity:.2}}137  @media (max-width:640px){.kaa-x1{display:none}}138  `;139140  /* ---------- markdown complet (rendu en streaming, texte échappé d'abord) ---------- */141  function esc(s) {142    return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");143  }144145  /* ---------- cartes + choix (v4) ----------146     Palette de l'écosystème (ka-ui/ecosystem.json) embarquée : chaque carte147     prend les couleurs de la plateforme de SA fiche, pas du site hôte. */148  var ECO = {149    "uqo-eval": ["UQO Éval", "#006085", "#ffffff"],150  };151  function httpUrl(u) { return typeof u === "string" && /^https:\/\//.test(u) ? u : ""; }152  /* Extrait chaque objet JSON de premier niveau d'un bloc ka-card, quelle que153     soit sa mise en forme (un par ligne, aéré, tableau, bloc TRONQUÉ par le154     streaming) : balayage à équilibrage d'accolades, hors chaînes. */155  function jsonObjects(txt) {156    var objs = [], depth = 0, start = -1, inStr = false, escp = false;157    for (var k = 0; k < txt.length; k++) {158      var c = txt[k];159      if (inStr) {160        if (escp) escp = false;161        else if (c === "\\") escp = true;162        else if (c === '"') inStr = false;163        continue;164      }165      if (c === '"') { inStr = true; continue; }166      if (c === "{") { if (!depth) start = k; depth++; }167      else if (c === "}" && depth) {168        depth--;169        if (!depth && start >= 0) {170          try { objs.push(JSON.parse(txt.slice(start, k + 1))); } catch (e) {}171          start = -1;172        }173      }174    }175    return objs; // un objet incomplet en fin de bloc est simplement ignoré176  }177  function cards(lines) {178    var html = "";179    jsonObjects(lines.join("\n")).forEach(function (o) {180      if (!o || !o.titre || !httpUrl(o.lien)) return;181      var pal = ECO[o.site] || ["UQO Éval", "var(--accent,#006085)", "var(--on-accent,#ffffff)"];182      var img = httpUrl(o.image);183      html += '<a class="kaa-card" href="' + esc(o.lien) + '" target="_blank" rel="noopener noreferrer"' +184        ' style="--kaa-c:' + pal[1] + ";--kaa-on:" + pal[2] + '">' +185        (img ? '<span class="kaa-card-img" style="background-image:url(&quot;' + esc(img) + '&quot;)"></span>' : "") +186        '<span class="kaa-card-body"><span class="kaa-card-top">' +187        '<span class="kaa-card-site">' + esc(pal[0]) + "</span>" +188        (o.badge ? '<span class="kaa-card-badge">' + esc(String(o.badge)) + "</span>" : "") +189        "</span>" +190        '<span class="kaa-card-title">' + esc(String(o.titre)) + "</span>" +191        (o.sous_titre ? '<span class="kaa-card-sub">' + esc(String(o.sous_titre)) + "</span>" : "") +192        (o.prix ? '<span class="kaa-card-price">' + esc(String(o.prix)) + "</span>" : "") +193        '</span><span class="kaa-card-go">→</span></a>';194    });195    return html ? '<div class="kaa-cards">' + html + "</div>" : "";196  }197  function choices(lines) {198    var o = jsonObjects(lines.join("\n"))[0] || null;   // bloc incomplet (streaming) → rien199    if (!o || !Array.isArray(o.options) || !o.options.length) return "";200    var h = '<span class="kaa-choices">' +201      (o.question ? '<span class="kaa-choices-q">' + esc(String(o.question)) + "</span>" : "");202    o.options.slice(0, 6).forEach(function (op) {203      h += '<button type="button" class="kaa-choice">' + esc(String(op)) + "</button>";204    });205    return h + "</span>";206  }207  function inline(s) {208    s = esc(s);209    s = s.replace(/`([^`\n]+)`/g, "<code>$1</code>");210    s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g,211      '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');212    s = s.replace(/\*\*([^*\n]+)\*\*/g, "<b>$1</b>");213    s = s.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\*)/g, "$1<i>$2</i>");214    s = s.replace(/(^|[^"'>=\w])(https?:\/\/[^\s<)"']+)/g, function (m, pre, u) {215      return pre + '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' +216        u.replace(/^https?:\/\/(www\.)?/, "").slice(0, 42) + "</a>";217    });218    return s;219  }220  function md(src) {221    var lines = String(src).split("\n"), out = [], i = 0, m;222    function para(buf) { if (buf.length) out.push("<p>" + buf.map(inline).join("<br>") + "</p>"); }223    while (i < lines.length) {224      var l = lines[i];225      if (/^\s*```/.test(l)) {                                    /* bloc de code / carte / choix */226        var lang = (l.match(/^\s*```\s*([\w-]+)?/) || [])[1] || "";227        var code = []; i++;228        while (i < lines.length && !/^\s*```/.test(lines[i])) code.push(lines[i++]);229        i++;230        if (lang === "ka-card") out.push(cards(code));231        else if (lang === "ka-choix") out.push(choices(code));232        else out.push("<pre><code>" + esc(code.join("\n")) + "</code></pre>");233      } else if ((m = l.match(/^\s*(#{1,4})\s+(.*)$/))) {         /* titres */234        out.push("<h" + m[1].length + ">" + inline(m[2]) + "</h" + m[1].length + ">"); i++;235      } else if (/^\s*(---+|\*\*\*+)\s*$/.test(l)) {              /* filet */236        out.push("<hr>"); i++;237      } else if (/^\s*&gt;|^\s*>/.test(l)) {                      /* citation */238        var q = [];239        while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ""));240        out.push("<blockquote>" + q.map(inline).join("<br>") + "</blockquote>");241      } else if (/^\s*\|.*\|\s*$/.test(l)) {                      /* tableau */242        var rows = [];243        while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {244          var cells = lines[i].trim().replace(/^\||\|$/g, "").split("|").map(function (c) { return c.trim(); });245          if (!/^[:\-\s|]+$/.test(lines[i].replace(/\|/g, ""))) rows.push(cells);246          i++;247        }248        if (rows.length) {249          var t = "<table>";250          rows.forEach(function (r, ri) {251            var tag = ri === 0 ? "th" : "td";252            t += "<tr>" + r.map(function (c) { return "<" + tag + ">" + inline(c) + "</" + tag + ">"; }).join("") + "</tr>";253          });254          out.push(t + "</table>");255        }256      } else if (/^\s*[-*+]\s+/.test(l)) {                        /* liste à puces */257        var ul = [];258        while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i]))259          ul.push("<li>" + inline(lines[i++].replace(/^\s*[-*+]\s+/, "")) + "</li>");260        out.push("<ul>" + ul.join("") + "</ul>");261      } else if (/^\s*\d+[.)]\s+/.test(l)) {                      /* liste numérotée */262        var ol = [];263        while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i]))264          ol.push("<li>" + inline(lines[i++].replace(/^\s*\d+[.)]\s+/, "")) + "</li>");265        out.push("<ol>" + ol.join("") + "</ol>");266      } else if (!l.trim()) { i++; }                              /* ligne vide */267      else {                                                      /* paragraphe */268        var buf = [];269        while (i < lines.length && lines[i].trim() &&270          !/^\s*(```|#{1,4}\s|[-*+]\s|\d+[.)]\s|>|\||---+\s*$)/.test(lines[i]))271          buf.push(lines[i++]);272        if (!buf.length) buf.push(lines[i++] || "");273        para(buf);274      }275    }276    return out.join("");277  }278279  /* ---------- état ---------- */280  var log = [];281  try { log = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (e) {}282  function save() { try { localStorage.setItem(KEY, JSON.stringify(log.slice(-MAXLOG))); } catch (e) {} }283284  /* ---------- DOM ---------- */285  var style = document.createElement("style");286  style.textContent = css;287  document.head.appendChild(style);288289  var btn = document.createElement("button");290  btn.className = "kaa-btn";291  btn.setAttribute("aria-label", "Ouvrir Éva — l'assistante pédagogique d'UQO Éval");292  btn.innerHTML = "Éva";293294  var panel = document.createElement("div");295  panel.className = "kaa-panel";296  panel.setAttribute("role", "dialog");297  panel.setAttribute("aria-label", "Éva — assistante pédagogique");298  panel.innerHTML =299    '<div class="kaa-head"><b>UQO<span class="kaa-ka">Éval</span></b><small>Éva · assistante pédagogique</small><span class="kaa-sp"></span>' +300    '<button class="kaa-hbtn kaa-x1" title="Réduire / agrandir" aria-label="Réduire ou agrandir">▭</button>' +301    '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' +302    '<div class="kaa-log"></div>' +303    '<div class="kaa-in"><textarea rows="1" enterkeyhint="send" autocapitalize="sentences" autocomplete="off" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' +304    "<button aria-label=\"Envoyer\">➤</button></div>";305  document.body.appendChild(btn);306  document.body.appendChild(panel);307308  var logEl = panel.querySelector(".kaa-log");309  var ta = panel.querySelector("textarea");310  var send = panel.querySelector(".kaa-in button");311312  function scroll() { logEl.scrollTop = logEl.scrollHeight; }313  function bubble(cls, html) {314    var d = document.createElement("div");315    d.className = "kaa-msg " + cls;316    d.innerHTML = html;317    logEl.appendChild(d);318    scroll();319    return d;320  }321  function toolChip(name) {322    var d = document.createElement("div");323    d.className = "kaa-tool kaa-dots";324    d.textContent = "🔎 " + name.replace(/_/g, " ");325    logEl.appendChild(d);326    scroll();327    return d;328  }329330  function hello() {331    if (log.length) return;332    var d = document.createElement("div");333    d.className = "kaa-hello";334    d.textContent = "👋 Je suis Éva, l'assistante pédagogique d'UQO Éval. Donnez-moi une adresse du Québec : je la retrouve dans le registre, je l'évalue avec le moteur transparent de la plateforme et je vous explique le calcul — comparables, ajustements, marché local, indice de confiance. Posez votre question !";335    logEl.appendChild(d);336  }337  /* les boutons de choix ne restent actifs que dans la DERNIÈRE bulle assistante */338  function retireChoices(keepLast) {339    var groups = logEl.querySelectorAll(".kaa-choices:not(.kaa-done)");340    var msgs = logEl.querySelectorAll(".kaa-msg.kaa-a");341    var last = msgs.length ? msgs[msgs.length - 1] : null;342    groups.forEach(function (g) {343      if (!(keepLast && last && last.contains(g))) g.classList.add("kaa-done");344    });345  }346  function render() {347    logEl.innerHTML = "";348    hello();349    log.forEach(function (m) {350      m.role === "user" ? bubble("kaa-u", esc(m.content)) : bubble("kaa-a", md(m.content));351    });352    retireChoices(log.length && log[log.length - 1].role === "assistant");353    scroll();354  }355356  /* ---------- plein écran + clavier mobile ----------357     Ouverture = PLEIN ÉCRAN direct (desktop et mobile). ▭ réduit en fenêtre358     (desktop seulement — caché sur mobile). Tant que le plein écran est ouvert,359     l'arrière-plan est GELÉ (body position:fixed, scroll restauré à la360     fermeture) et le panneau se cale sur window.visualViewport : quand le361     clavier s'ouvre, le panneau rétrécit à la place de laisser iOS362     zoomer/pousser la page. Pas de focus clavier forcé au tap sur mobile. */363  var fineInput = window.matchMedia("(hover: hover) and (pointer: fine)");364  var vv = window.visualViewport;365  var savedY = 0;366367  function isOpen() { return panel.classList.contains("kaa-open"); }368  function isFull() { return panel.classList.contains("kaa-full"); }369370  function fitVV() {371    if (!vv || !isOpen() || !isFull()) { panel.style.height = ""; panel.style.top = ""; return; }372    panel.style.top = Math.round(vv.offsetTop) + "px";373    panel.style.height = Math.round(vv.height) + "px";374    scroll();375  }376  if (vv) {377    vv.addEventListener("resize", fitVV);378    vv.addEventListener("scroll", fitVV);379  }380381  function lockBg(on) {382    var b = document.body, h = document.documentElement;383    if (on && !h.classList.contains("kaa-lock")) {384      savedY = window.scrollY || 0;385      h.classList.add("kaa-lock");386      b.style.position = "fixed";387      b.style.top = -savedY + "px";388      b.style.left = "0"; b.style.right = "0"; b.style.width = "100%";389    } else if (!on && h.classList.contains("kaa-lock")) {390      h.classList.remove("kaa-lock");391      b.style.position = ""; b.style.top = ""; b.style.left = ""; b.style.right = ""; b.style.width = "";392      window.scrollTo(0, savedY);393    }394  }395  function sync() {396    lockBg(isOpen() && isFull());397    fitVV();398  }399400  /* ---------- bulle déplaçable (v3) ----------401     pointerdown sur la bulle → si le doigt/curseur bouge de plus de 6 px c'est402     un GLISSER (la bulle suit, bornée à 8 px des bords) ; sinon c'est un CLIC403     (ouverture normale). Position mémorisée par site (localStorage) et404     re-bornée quand le viewport change. En mode fenêtré, le panneau s'ancre à405     gauche si la bulle est dans la moitié gauche de l'écran. */406  var POSKEY = "ka-agent-pos-" + SITE;407  var drag = { on: false, moved: false, id: null, sx: 0, sy: 0, bx: 0, by: 0 };408409  function clampPos(l, t) {410    var m = 8, w = btn.offsetWidth || 58, h = btn.offsetHeight || 58;411    return {412      l: Math.min(Math.max(l, m), Math.max(m, window.innerWidth - w - m)),413      t: Math.min(Math.max(t, m), Math.max(m, window.innerHeight - h - m)),414    };415  }416  function applyPos(l, t) {417    var p = clampPos(l, t);418    btn.style.left = p.l + "px";419    btn.style.top = p.t + "px";420    btn.style.right = "auto";421    btn.style.bottom = "auto";422    panel.classList.toggle("kaa-left", p.l + (btn.offsetWidth || 58) / 2 < window.innerWidth / 2);423  }424  function savePos() {425    var r = btn.getBoundingClientRect();426    try { localStorage.setItem(POSKEY, JSON.stringify({ l: r.left, t: r.top })); } catch (e) {}427  }428  btn.addEventListener("pointerdown", function (e) {429    if (!e.isPrimary) return;430    drag.on = true; drag.moved = false; drag.id = e.pointerId;431    drag.sx = e.clientX; drag.sy = e.clientY;432    var r = btn.getBoundingClientRect();433    drag.bx = r.left; drag.by = r.top;434    try { btn.setPointerCapture(e.pointerId); } catch (err) {} // capture immédiate : un glisser rapide sort de la bulle avant le 1er move435  });436  btn.addEventListener("pointermove", function (e) {437    if (!drag.on || e.pointerId !== drag.id) return;438    var dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;439    if (!drag.moved && dx * dx + dy * dy < 36) return; // < 6 px : encore un clic440    if (!drag.moved) {441      drag.moved = true;442      btn.classList.add("kaa-drag");443    }444    applyPos(drag.bx + dx, drag.by + dy);445  });446  function dragEnd(e) {447    if (!drag.on || e.pointerId !== drag.id) return;448    drag.on = false;449    if (drag.moved) {450      btn.classList.remove("kaa-drag");451      savePos();452      setTimeout(function () { drag.moved = false; }, 0); // filet si le click ne suit pas453    }454  }455  btn.addEventListener("pointerup", dragEnd);456  btn.addEventListener("pointercancel", dragEnd);457  window.addEventListener("resize", function () {458    if (!btn.style.left) return;459    var r = btn.getBoundingClientRect();460    applyPos(r.left, r.top);461  });462  (function restorePos() {463    var p = null;464    try { p = JSON.parse(localStorage.getItem(POSKEY) || "null"); } catch (e) {}465    if (p && typeof p.l === "number" && typeof p.t === "number") applyPos(p.l, p.t);466  })();467468  btn.addEventListener("click", function (e) {469    if (drag.moved) { drag.moved = false; e.preventDefault(); return; } // fin de glisser, pas un clic470    panel.classList.add("kaa-open", "kaa-full"); // plein écran d'office471    render();472    if (fineInput.matches) ta.focus();473    sync();474  });475  panel.querySelector(".kaa-x2").addEventListener("click", function () {476    panel.classList.remove("kaa-open", "kaa-full");477    sync();478  });479  panel.querySelector(".kaa-x1").addEventListener("click", function () {480    panel.classList.toggle("kaa-full");481    sync();482    scroll();483  });484  document.addEventListener("keydown", function (e) {485    if (e.key === "Escape" && isOpen()) {486      panel.classList.remove("kaa-open", "kaa-full");487      sync();488    }489  });490491  /* ---------- envoi + flux SSE ---------- */492  var busy = false;493  function ask() {494    var q = ta.value.trim();495    if (!q || busy) return;496    busy = true;497    send.disabled = true;498    ta.value = "";499    retireChoices(false); // un nouveau message clôt les choix en suspens500    log.push({ role: "user", content: q });501    save();502    bubble("kaa-u", esc(q));503    var out = bubble("kaa-a", '<span class="kaa-dots"></span>');504    var acc = "";505    var chips = [];506507    fetch(ENDPOINT, {508      method: "POST",509      headers: { "Content-Type": "application/json" },510      body: JSON.stringify({ site: SITE, messages: log.slice(-MAXLOG) }),511    })512      .then(function (res) {513        if (!res.ok || !res.body) throw new Error("HTTP " + res.status);514        var reader = res.body.getReader();515        var dec = new TextDecoder();516        var buf = "";517        function pump() {518          return reader.read().then(function (r) {519            if (r.done) return finish();520            buf += dec.decode(r.value, { stream: true });521            var blocks = buf.split("\n\n");522            buf = blocks.pop();523            blocks.forEach(function (b) {524              var dm = (b.match(/^data: (.+)$/m) || [])[1];525              if (!dm) return;526              var data = {};527              try { data = JSON.parse(dm); } catch (e) {}528              var ev = (b.match(/^event: (.+)$/m) || [])[1] ||529                (data.type === "text" ? "delta" : data.type); // format local /api/ka530              if (ev === "delta" && data.text) {531                acc += data.text;532                out.innerHTML = md(acc);   // markdown re-rendu à chaque delta533                scroll();534              } else if (ev === "tool") {535                chips.push(toolChip(data.name || "recherche"));536                logEl.appendChild(out); // la bulle reste après le chip537                scroll();538              } else if (ev === "error") {539                acc = acc || "Désolé, une erreur est survenue (" + (data.message || "inconnue") + "). Réessayez.";540                out.innerHTML = md(acc);541              }542            });543            return pump();544          });545        }546        return pump();547      })548      .catch(function (e) {549        acc = acc || "Impossible de joindre Éva (" + e.message + "). Réessayez dans un instant.";550        out.innerHTML = md(acc);551      })552      .then(function () { finish(); });553554    function finish() {555      if (busy === false) return;556      busy = false;557      send.disabled = false;558      chips.forEach(function (c) { c.classList.remove("kaa-dots"); });559      if (!acc) { acc = "Je n'ai pas pu formuler de réponse — réessayez."; out.innerHTML = md(acc); }560      log.push({ role: "assistant", content: acc });561      save();562      scroll();563    }564  }565  send.addEventListener("click", ask);566  ta.addEventListener("keydown", function (e) {567    if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }568  });569  logEl.addEventListener("click", function (e) {                  /* clic sur un bouton de choix (v4) */570    var b = e.target && e.target.closest ? e.target.closest(".kaa-choice") : null;571    if (!b || busy) return;572    var g = b.closest(".kaa-choices");573    if (g && g.classList.contains("kaa-done")) return;574    ta.value = b.textContent;575    ask();576  });577})();578