KA Agent — widget de chat commun (agent/ka-agent.js) : bulle flottante accent du site, panneau + plein écran, flux SSE token par token, indicateur d outils, historique localStorage
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +233 −0
added
agent/ka-agent.js
+233 −0
@@ -0,0 +1,233 @@ | |||||
| 1 | +/* Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 2 | + * ka-ui/agent/ka-agent.js — KA AGENT : widget de chat commun de l'écosystème | ||||
| 3 | + * Groupe KA. Vanilla JS, zéro dépendance, injecté sur les 13 sites. | ||||
| 4 | + * <script>window.KA_AGENT={site:"lou-ka"}</script> | ||||
| 5 | + * <script src="/ka-agent.js" defer></script> | ||||
| 6 | + * Bulle flottante en bas à droite → panneau de chat (bulle) → plein écran (⛶). | ||||
| 7 | + * Flux SSE en direct depuis le backend central (texte token par token + | ||||
| 8 | + * indicateur d'outil). Historique conservé en localStorage (par site). | ||||
| 9 | + */ | ||||
| 10 | +(function () { | ||||
| 11 | + "use strict"; | ||||
| 12 | + var CFG = window.KA_AGENT || {}; | ||||
| 13 | + var SITE = CFG.site || "groupe-ka"; | ||||
| 14 | + var ENDPOINT = CFG.endpoint || "https://www.api-ka.com/api/agent/chat"; | ||||
| 15 | + var KEY = "ka-agent-" + SITE; | ||||
| 16 | + var MAXLOG = 20; | ||||
| 17 | + | ||||
| 18 | + /* ---------- styles ---------- */ | ||||
| 19 | + var css = ` | ||||
| 20 | + .kaa-btn{position:fixed;right:18px;bottom:18px;z-index:2147483000;width:58px;height:58px; | ||||
| 21 | + border-radius:50%;border:2px solid var(--ink,#141814);background:var(--accent,#d9f26b); | ||||
| 22 | + color:var(--on-accent,#141814);font:700 17px/1 "Space Grotesk",system-ui,sans-serif; | ||||
| 23 | + cursor:pointer;box-shadow:4px 4px 0 rgba(20,24,20,.85);display:flex;align-items:center; | ||||
| 24 | + justify-content:center;transition:transform .15s;padding-bottom:env(safe-area-inset-bottom,0)} | ||||
| 25 | + .kaa-btn:hover{transform:translate(-2px,-2px)} | ||||
| 26 | + .kaa-panel{position:fixed;right:16px;bottom:86px;z-index:2147483001;width:min(392px,calc(100vw - 24px)); | ||||
| 27 | + height:min(580px,calc(100dvh - 110px));display:none;flex-direction:column;background:var(--paper,#f5f3ee); | ||||
| 28 | + border:2px solid var(--ink,#141814);border-radius:14px;box-shadow:8px 8px 0 rgba(20,24,20,.85); | ||||
| 29 | + overflow:hidden;font-family:Inter,system-ui,sans-serif} | ||||
| 30 | + .kaa-panel.kaa-open{display:flex} | ||||
| 31 | + .kaa-panel.kaa-full{right:0;bottom:0;width:100vw;height:100dvh;border-radius:0;border:0;box-shadow:none} | ||||
| 32 | + .kaa-head{display:flex;align-items:center;gap:9px;padding:11px 14px;background:var(--ink,#141814); | ||||
| 33 | + color:var(--paper,#f5f3ee);flex:none} | ||||
| 34 | + .kaa-head b{font:700 15px "Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em} | ||||
| 35 | + .kaa-head .kaa-ka{display:inline-block;background:var(--accent,#d9f26b);color:var(--on-accent,#141814); | ||||
| 36 | + border-radius:6px;padding:1px 7px 2px;transform:rotate(-2deg);font-weight:700} | ||||
| 37 | + .kaa-head small{font:700 9.5px "JetBrains Mono",monospace;letter-spacing:.09em;text-transform:uppercase;opacity:.65} | ||||
| 38 | + .kaa-head .kaa-sp{flex:1} | ||||
| 39 | + .kaa-hbtn{width:34px;height:34px;border:1.5px solid rgba(245,243,238,.4);border-radius:8px;background:none; | ||||
| 40 | + color:var(--paper,#f5f3ee);font-size:15px;cursor:pointer;line-height:1} | ||||
| 41 | + .kaa-hbtn:hover{border-color:var(--accent,#d9f26b);color:var(--accent,#d9f26b)} | ||||
| 42 | + .kaa-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px; | ||||
| 43 | + overscroll-behavior:contain;-webkit-overflow-scrolling:touch} | ||||
| 44 | + .kaa-msg{max-width:86%;padding:9px 13px;border:1.5px solid var(--ink,#141814);border-radius:12px; | ||||
| 45 | + font-size:13.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word} | ||||
| 46 | + .kaa-msg.kaa-u{align-self:flex-end;background:var(--accent,#d9f26b);color:var(--on-accent,#141814); | ||||
| 47 | + border-bottom-right-radius:4px} | ||||
| 48 | + .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)} | ||||
| 49 | + .kaa-msg.kaa-a a{color:inherit;text-decoration:underline;text-underline-offset:3px} | ||||
| 50 | + .kaa-msg.kaa-a b{font-weight:700} | ||||
| 51 | + .kaa-tool{align-self:flex-start;font:700 10px "JetBrains Mono",monospace;letter-spacing:.07em; | ||||
| 52 | + text-transform:uppercase;color:var(--ink-2,#4d5551);padding:3px 10px;border:1.5px dashed var(--ink-2,#4d5551); | ||||
| 53 | + border-radius:999px;background:var(--surface-2,#faf9f5)} | ||||
| 54 | + .kaa-in{display:flex;gap:8px;padding:11px;border-top:2px solid var(--ink,#141814);background:#fff;flex:none; | ||||
| 55 | + padding-bottom:calc(11px + env(safe-area-inset-bottom,0))} | ||||
| 56 | + .kaa-in textarea{flex:1;resize:none;min-height:44px;max-height:110px;padding:11px 12px;font:13.5px/1.4 Inter,system-ui,sans-serif; | ||||
| 57 | + border:1.5px solid var(--ink,#141814);border-radius:9px;background:var(--paper,#f5f3ee);outline:none} | ||||
| 58 | + .kaa-in button{min-width:52px;min-height:44px;border:1.5px solid var(--ink,#141814);border-radius:9px; | ||||
| 59 | + background:var(--ink,#141814);color:var(--accent,#d9f26b);font:700 16px "Space Grotesk",system-ui;cursor:pointer} | ||||
| 60 | + .kaa-in button:disabled{opacity:.45} | ||||
| 61 | + .kaa-hello{font-size:12.5px;color:var(--ink-2,#4d5551);padding:4px 2px} | ||||
| 62 | + .kaa-dots::after{content:"…";animation:kaa-b 1.2s infinite} | ||||
| 63 | + @keyframes kaa-b{0%{opacity:.2}50%{opacity:1}100%{opacity:.2}} | ||||
| 64 | + @media (max-width:560px){.kaa-panel{right:8px;left:8px;width:auto}} | ||||
| 65 | + `; | ||||
| 66 | + | ||||
| 67 | + /* ---------- markdown minimal (gras, liens, puces) — texte échappé d'abord ---------- */ | ||||
| 68 | + function esc(s) { | ||||
| 69 | + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | ||||
| 70 | + } | ||||
| 71 | + function md(s) { | ||||
| 72 | + return esc(s) | ||||
| 73 | + .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>") | ||||
| 74 | + .replace(/(https?:\/\/[^\s<)"']+)/g, function (u) { | ||||
| 75 | + return '<a href="' + u + '" target="_blank" rel="noopener noreferrer">' + u.replace(/^https?:\/\/(www\.)?/, "").slice(0, 40) + "</a>"; | ||||
| 76 | + }); | ||||
| 77 | + } | ||||
| 78 | + | ||||
| 79 | + /* ---------- état ---------- */ | ||||
| 80 | + var log = []; | ||||
| 81 | + try { log = JSON.parse(localStorage.getItem(KEY) || "[]"); } catch (e) {} | ||||
| 82 | + function save() { try { localStorage.setItem(KEY, JSON.stringify(log.slice(-MAXLOG))); } catch (e) {} } | ||||
| 83 | + | ||||
| 84 | + /* ---------- DOM ---------- */ | ||||
| 85 | + var style = document.createElement("style"); | ||||
| 86 | + style.textContent = css; | ||||
| 87 | + document.head.appendChild(style); | ||||
| 88 | + | ||||
| 89 | + var btn = document.createElement("button"); | ||||
| 90 | + btn.className = "kaa-btn"; | ||||
| 91 | + btn.setAttribute("aria-label", "Ouvrir KA Agent — assistant de l'écosystème Groupe KA"); | ||||
| 92 | + btn.innerHTML = "Ka"; | ||||
| 93 | + | ||||
| 94 | + var panel = document.createElement("div"); | ||||
| 95 | + panel.className = "kaa-panel"; | ||||
| 96 | + panel.setAttribute("role", "dialog"); | ||||
| 97 | + panel.setAttribute("aria-label", "KA Agent"); | ||||
| 98 | + panel.innerHTML = | ||||
| 99 | + '<div class="kaa-head"><b>KA<span class="kaa-ka">Agent</span></b><small>Groupe KA · IA</small><span class="kaa-sp"></span>' + | ||||
| 100 | + '<button class="kaa-hbtn kaa-x1" title="Plein écran" aria-label="Plein écran">⛶</button>' + | ||||
| 101 | + '<button class="kaa-hbtn kaa-x2" title="Fermer" aria-label="Fermer">✕</button></div>' + | ||||
| 102 | + '<div class="kaa-log"></div>' + | ||||
| 103 | + '<div class="kaa-in"><textarea rows="1" placeholder="Posez votre question…" aria-label="Votre question"></textarea>' + | ||||
| 104 | + "<button aria-label=\"Envoyer\">➤</button></div>"; | ||||
| 105 | + document.body.appendChild(btn); | ||||
| 106 | + document.body.appendChild(panel); | ||||
| 107 | + | ||||
| 108 | + var logEl = panel.querySelector(".kaa-log"); | ||||
| 109 | + var ta = panel.querySelector("textarea"); | ||||
| 110 | + var send = panel.querySelector(".kaa-in button"); | ||||
| 111 | + | ||||
| 112 | + function scroll() { logEl.scrollTop = logEl.scrollHeight; } | ||||
| 113 | + function bubble(cls, html) { | ||||
| 114 | + var d = document.createElement("div"); | ||||
| 115 | + d.className = "kaa-msg " + cls; | ||||
| 116 | + d.innerHTML = html; | ||||
| 117 | + logEl.appendChild(d); | ||||
| 118 | + scroll(); | ||||
| 119 | + return d; | ||||
| 120 | + } | ||||
| 121 | + function toolChip(name) { | ||||
| 122 | + var d = document.createElement("div"); | ||||
| 123 | + d.className = "kaa-tool kaa-dots"; | ||||
| 124 | + d.textContent = "🔎 " + name.replace(/_/g, " "); | ||||
| 125 | + logEl.appendChild(d); | ||||
| 126 | + scroll(); | ||||
| 127 | + return d; | ||||
| 128 | + } | ||||
| 129 | + | ||||
| 130 | + function hello() { | ||||
| 131 | + if (log.length) return; | ||||
| 132 | + var d = document.createElement("div"); | ||||
| 133 | + d.className = "kaa-hello"; | ||||
| 134 | + d.textContent = "👋 Je suis KA Agent, l'assistant de l'écosystème Groupe KA. Posez-moi une question sur ce site, ses données (prix, annonces, stats…) ou sur le groupe."; | ||||
| 135 | + logEl.appendChild(d); | ||||
| 136 | + } | ||||
| 137 | + function render() { | ||||
| 138 | + logEl.innerHTML = ""; | ||||
| 139 | + hello(); | ||||
| 140 | + log.forEach(function (m) { bubble(m.role === "user" ? "kaa-u" : "kaa-a", md(m.content)); }); | ||||
| 141 | + scroll(); | ||||
| 142 | + } | ||||
| 143 | + | ||||
| 144 | + /* ---------- ouverture / plein écran ---------- */ | ||||
| 145 | + btn.addEventListener("click", function () { | ||||
| 146 | + panel.classList.toggle("kaa-open"); | ||||
| 147 | + if (panel.classList.contains("kaa-open")) { render(); ta.focus(); } | ||||
| 148 | + }); | ||||
| 149 | + panel.querySelector(".kaa-x2").addEventListener("click", function () { | ||||
| 150 | + panel.classList.remove("kaa-open", "kaa-full"); | ||||
| 151 | + }); | ||||
| 152 | + panel.querySelector(".kaa-x1").addEventListener("click", function () { | ||||
| 153 | + panel.classList.toggle("kaa-full"); | ||||
| 154 | + scroll(); | ||||
| 155 | + }); | ||||
| 156 | + | ||||
| 157 | + /* ---------- envoi + flux SSE ---------- */ | ||||
| 158 | + var busy = false; | ||||
| 159 | + function ask() { | ||||
| 160 | + var q = ta.value.trim(); | ||||
| 161 | + if (!q || busy) return; | ||||
| 162 | + busy = true; | ||||
| 163 | + send.disabled = true; | ||||
| 164 | + ta.value = ""; | ||||
| 165 | + log.push({ role: "user", content: q }); | ||||
| 166 | + save(); | ||||
| 167 | + bubble("kaa-u", md(q)); | ||||
| 168 | + var out = bubble("kaa-a", '<span class="kaa-dots"></span>'); | ||||
| 169 | + var acc = ""; | ||||
| 170 | + var chips = []; | ||||
| 171 | + | ||||
| 172 | + fetch(ENDPOINT, { | ||||
| 173 | + method: "POST", | ||||
| 174 | + headers: { "Content-Type": "application/json" }, | ||||
| 175 | + body: JSON.stringify({ site: SITE, messages: log.slice(-MAXLOG) }), | ||||
| 176 | + }) | ||||
| 177 | + .then(function (res) { | ||||
| 178 | + if (!res.ok || !res.body) throw new Error("HTTP " + res.status); | ||||
| 179 | + var reader = res.body.getReader(); | ||||
| 180 | + var dec = new TextDecoder(); | ||||
| 181 | + var buf = ""; | ||||
| 182 | + function pump() { | ||||
| 183 | + return reader.read().then(function (r) { | ||||
| 184 | + if (r.done) return finish(); | ||||
| 185 | + buf += dec.decode(r.value, { stream: true }); | ||||
| 186 | + var blocks = buf.split("\n\n"); | ||||
| 187 | + buf = blocks.pop(); | ||||
| 188 | + blocks.forEach(function (b) { | ||||
| 189 | + var ev = (b.match(/^event: (.+)$/m) || [])[1]; | ||||
| 190 | + var dm = (b.match(/^data: (.+)$/m) || [])[1]; | ||||
| 191 | + if (!ev || !dm) return; | ||||
| 192 | + var data = {}; | ||||
| 193 | + try { data = JSON.parse(dm); } catch (e) {} | ||||
| 194 | + if (ev === "delta" && data.text) { | ||||
| 195 | + acc += data.text; | ||||
| 196 | + out.innerHTML = md(acc); | ||||
| 197 | + scroll(); | ||||
| 198 | + } else if (ev === "tool") { | ||||
| 199 | + chips.push(toolChip(data.name || "recherche")); | ||||
| 200 | + logEl.appendChild(out); // la bulle reste après le chip | ||||
| 201 | + scroll(); | ||||
| 202 | + } else if (ev === "error") { | ||||
| 203 | + acc = acc || "Désolé, une erreur est survenue (" + (data.message || "inconnue") + "). Réessayez."; | ||||
| 204 | + out.innerHTML = md(acc); | ||||
| 205 | + } | ||||
| 206 | + }); | ||||
| 207 | + return pump(); | ||||
| 208 | + }); | ||||
| 209 | + } | ||||
| 210 | + return pump(); | ||||
| 211 | + }) | ||||
| 212 | + .catch(function (e) { | ||||
| 213 | + acc = acc || "Impossible de joindre KA Agent (" + e.message + "). Réessayez dans un instant."; | ||||
| 214 | + out.innerHTML = md(acc); | ||||
| 215 | + }) | ||||
| 216 | + .then(function () { finish(); }); | ||||
| 217 | + | ||||
| 218 | + function finish() { | ||||
| 219 | + if (busy === false) return; | ||||
| 220 | + busy = false; | ||||
| 221 | + send.disabled = false; | ||||
| 222 | + chips.forEach(function (c) { c.classList.remove("kaa-dots"); }); | ||||
| 223 | + if (!acc) { acc = "Je n'ai pas pu formuler de réponse — réessayez."; out.innerHTML = md(acc); } | ||||
| 224 | + log.push({ role: "assistant", content: acc }); | ||||
| 225 | + save(); | ||||
| 226 | + scroll(); | ||||
| 227 | + } | ||||
| 228 | + } | ||||
| 229 | + send.addEventListener("click", ask); | ||||
| 230 | + ta.addEventListener("keydown", function (e) { | ||||
| 231 | + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); } | ||||
| 232 | + }); | ||||
| 233 | +})(); | ||||
| 234 | |||||