/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2). * * Parle à l'API du service d'agents (contrat de dracodev-commons) : * GET /api/v1/me — identité (cookie draco_session) * POST /api/v1/chat — conversation réelle du noyau (J2) : * LLM DeepSeek + outils MCP des apps ; * la réponse arrive avec son HTML * Markdown rendu côté service (v2) * GET /api/v1/conversations — discussions façon Muse (v2) : une * principale perpétuelle + des * parallèles créées/archivées/ * supprimées par l'utilisateur * GET /api/v1/conversations/… — historique d'une discussion (les * messages agent portent leur html) * GET /api/v1/executors — registre (noyau, sous-agents J3, * externes J4 : état de configuration) * POST /api/v1/missions — missions longues (J5) * Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris * (persistée), thème clair/sombre aligné sur l'app hôte * (html[data-theme], repli prefers-color-scheme, suivi en direct). * Bouton « Console » (barre des discussions) : ouvre la console web * du même service (agent.dracodev.net) DANS la page, en surimpression * pleine page (iframe) qui naît à la géométrie du panneau puis * s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion * courante, ?app= app d'origine déduite du sous-domaine) est celle * du premier chargement — l'iframe est créé paresseusement et * conservé, la console garde son état entre ouvertures. La barre * d'outils de la surimpression offre « Mode sidebar » (retour au * panneau seul, animation inverse) et « Fermer l'agent » (referme * surimpression et panneau). * États honnêtes : service injoignable / session non reconnue / LLM non * configuré / exécuteur non configuré sont affichés tels quels. */ (function () { "use strict"; /* URL du service : fournie par l'hôte via window.DRACO_URLS * (contrat d'injection dracodev-commons, #223) — le repli est le * défaut officiel ; aucun nom d'environnement n'est figé ici. */ var API = (window.DRACO_URLS && window.DRACO_URLS.agent) || "https://agent.dracodev.net"; var convId = null; var convKind = null; // 'principale' | 'parallele' var convTitre = ""; var etatConnu = false; var domaineCharge = null; // domaine des discussions affichées var consoleUrlCharge = null; // URL (disc+domaine+app) de l'iframe console var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var microEl = null, piecesEl = null, fichierEl = null; var titreEl = null, tiroirEl = null; var versionEl = null, versionConnue = "", versionMaj = 0; function domaine() { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } } function stockerConv() { try { if (convId) sessionStorage.setItem("draco_panneau_conv", convId); else sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* stockage indisponible */ } } function convStockee() { try { return sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { return null; } } /* ── Console web (même service, surimpression pleine page) ─── */ function appHote() { /* Premier label de l'hôte, suffixe d'environnement éventuel * retiré (ex. « og-dev » → « og » en dev, #223). */ var h = (window.location.hostname || "").split(".")[0].replace(/-dev$/, ""); var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck", kanbante: "kanbante", og: "obsigate" }; return correspondance[h] || null; } function urlConsole() { /* Le domaine de l'hôte est passé explicitement : la console * vit sur une autre origine, son localStorage ne voit jamais * celui de l'app hôte — sans ce paramètre, elle rouvrait * toujours le domaine de sa propre origine, pas celui du site. */ var params = []; var id = convId || convStockee(); if (id) params.push("disc=" + encodeURIComponent(id)); var app = appHote(); if (app) params.push("app=" + encodeURIComponent(app)); params.push("domain=" + encodeURIComponent(domaine())); return API + "/" + (params.length ? "?" + params.join("&") : ""); } /* La surimpression vit entièrement dans ce fichier (DOM + styles * injectés) : les apps hôtes n'ont que ce fichier à recopier. Son * thème suit celui du panneau (data-draco-theme synchronisé par * appliquerTheme) et sa palette reprend les mêmes valeurs que * draco-panneau.css. L'iframe est créé au premier clic puis * conservé : rouvrir la console ne le recharge pas. */ var surEl = null, surCadre = null, surOuverte = false; var STYLES_CONSOLE = ".draco-console-sur{" + "--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" + "--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" + "position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" + "background:var(--p-fond);color:var(--p-texte);" + "box-shadow:-12px 0 36px rgb(0 0 0 / .22);" + "display:flex;flex-direction:column;transition:width .3s ease;" + "font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" + ".draco-console-sur[data-draco-theme=\"dark\"]{" + "--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" + "--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" + ".draco-console-sur[hidden]{display:none}" + ".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" + "padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" + ".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" + "display:flex;align-items:center;gap:8px;white-space:nowrap}" + ".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" + ".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" + ".draco-console-barre button{border:1px solid var(--p-chip-bord);" + "background:var(--p-chip);color:var(--p-texte);border-radius:999px;" + "padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" + ".draco-console-barre button:hover{background:var(--p-chip-survol)}" + ".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}"; function assurerSurimpression() { if (surEl) return; if (!document.getElementById("draco-console-styles")) { var st = document.createElement("style"); st.id = "draco-console-styles"; st.textContent = STYLES_CONSOLE; document.head.appendChild(st); } surEl = document.createElement("div"); surEl.className = "draco-console-sur"; surEl.setAttribute("aria-label", "Console Spark"); surEl.hidden = true; surEl.innerHTML = '
' + ' ✦ Console Spark' + ' ' + ' ' + ' ' + '
' + ''; document.body.appendChild(surEl); surCadre = surEl.querySelector("iframe"); surEl.querySelector('[data-dc="sidebar"]') .addEventListener("click", modeSidebarConsole); surEl.querySelector('[data-dc="fermer"]') .addEventListener("click", fermerAgentConsole); appliquerTheme(); } function largeurPanneau() { if (root) { var w = root.getBoundingClientRect().width; if (w > 0) return Math.round(w); } return 388; } function ouvrirConsole() { assurerSurimpression(); /* L'iframe n'est rechargée que si sa cible a changé * (discussion, domaine ou app) : même cible = état conservé, * cible différente = la console s'ajuste, au lieu de garder * la discussion du premier chargement. */ var url = urlConsole(); if (!surCadre.getAttribute("src") || url !== consoleUrlCharge) { surCadre.src = url; consoleUrlCharge = url; } if (surOuverte) return; surOuverte = true; /* Naissance à la géométrie du panneau (ancrée à droite), puis * extension à toute la page ; le double rAF laisse le temps de * peindre la largeur de départ, sinon la transition ne joue pas. */ surEl.style.width = largeurPanneau() + "px"; surEl.hidden = false; requestAnimationFrame(function () { requestAnimationFrame(function () { surEl.style.width = "100%"; }); }); } function retrecirConsole() { if (!surEl || !surOuverte) return; surOuverte = false; var fini = false; function terminer() { if (fini) return; fini = true; surEl.removeEventListener("transitionend", surFin); surEl.hidden = true; // l'iframe reste chargé, juste masqué } function surFin(ev) { if (ev.propertyName === "width") terminer(); } surEl.addEventListener("transitionend", surFin); setTimeout(terminer, 380); // filet si transitionend ne vient pas surEl.style.width = largeurPanneau() + "px"; } function modeSidebarConsole() { retrecirConsole(); /* Retour au panneau : si le domaine a changé pendant que la * console occupait tout l'écran, le panneau s'ajuste aussi. */ resynchroniserDomaine(); } function fermerAgentConsole() { fermer(); retrecirConsole(); } /* ── Thème : suivre l'app hôte ─────────────────────────────── */ function themeHote() { var t = document.documentElement.getAttribute("data-theme"); if (t === "dark" || t === "light") return t; if (document.documentElement.classList.contains("dark") || document.body.classList.contains("dark")) return "dark"; try { if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark"; } catch (e) { /* pas de matchMedia */ } return "light"; } function appliquerTheme() { if (root) root.setAttribute("data-draco-theme", themeHote()); if (surEl) surEl.setAttribute("data-draco-theme", themeHote()); } function surveillerTheme() { try { new MutationObserver(appliquerTheme).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class"] }); window.matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", appliquerTheme); } catch (e) { /* surveillance facultative */ } } /* ── Largeur réglable ──────────────────────────────────────── */ function largeurStockee() { try { var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10); return isNaN(v) ? null : v; } catch (e) { return null; } } function appliquerLargeur() { if (!root) return; var v = largeurStockee(); root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : ""; } function brancherPoignee(poignee) { poignee.addEventListener("pointerdown", function (ev) { if (window.innerWidth <= 700) return; // mobile : pleine largeur ev.preventDefault(); var x0 = ev.clientX, w0 = root.getBoundingClientRect().width; function bouger(e2) { var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX))); root.style.width = w + "px"; } function lacher(e2) { document.removeEventListener("pointermove", bouger); document.removeEventListener("pointerup", lacher); var w = Math.round(root.getBoundingClientRect().width); try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ } } document.addEventListener("pointermove", bouger); document.addEventListener("pointerup", lacher); }); poignee.addEventListener("dblclick", function () { try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ } appliquerLargeur(); }); } function construire() { root = document.createElement("aside"); root.className = "draco-panneau"; root.setAttribute("aria-label", "Spark, l'agent commun"); root.innerHTML = '
' + '
' + ' ✦' + '
Spark — agent commun
' + '
Connexion au service…
' + ' ' + '
' + '
' + ' Discussion principale' + ' ' + ' ' + ' ' + '
' + '' + '
' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '
' + ' ' + ' ' + ' ' + '
' + '
' + ' ' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + ' ' + '
' + ' ' + '
'; document.body.appendChild(root); msgsEl = root.querySelector(".draco-panneau-msgs"); etatEl = root.querySelector(".draco-panneau-hd .soustitre"); inputEl = root.querySelector(".draco-composer textarea"); sendBtn = root.querySelector(".draco-composer button[type=submit]"); microEl = root.querySelector('[data-comp="micro"]'); piecesEl = root.querySelector(".draco-composer-pieces"); fichierEl = root.querySelector(".draco-composer input[type=file]"); titreEl = root.querySelector(".d-titre"); tiroirEl = root.querySelector(".draco-discussions"); versionEl = root.querySelector(".draco-panneau-version"); root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir); root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion); root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole); root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui); root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations); root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions); root.querySelector("form").addEventListener("submit", envoyer); inputEl.addEventListener("input", majComposer); inputEl.addEventListener("keydown", function (ev) { if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); envoyerComposer(); } }); root.querySelector('[data-comp="plus"]').addEventListener("click", function () { fichierEl.click(); }); fichierEl.addEventListener("change", function (ev) { ajouterFichiers(ev.target.files); ev.target.value = ""; }); microEl.addEventListener("click", demarrerDictee); root.querySelector('[data-comp="enreg-stop"]').addEventListener("click", function () { arreterDictee("stop"); }); root.querySelector('[data-comp="enreg-annuler"]').addEventListener("click", function () { arreterDictee("annuler"); }); root.querySelector('[data-comp="enreg-envoyer"]').addEventListener("click", function () { arreterDictee("envoyer"); }); Array.prototype.forEach.call(root.querySelectorAll("[data-sugg]"), function (b) { b.addEventListener("click", function () { var mode = b.getAttribute("data-sugg"); if (mode === "resume") { envoyerMessage("Résume ma journée : mes tâches du jour, les cartes en Révision et l'activité récente.", []); } else if (mode === "projets") { envoyerMessage("Où en sont mes projets ? Fais le point sur l'état des projets et du board.", []); } else { inputEl.value = "Cherche « » partout : "; majComposer(); inputEl.focus(); inputEl.setSelectionRange(9, 9); } }); }); /* Domaine changé dans un autre onglet pendant que le panneau * existe : l'événement storage ne part que vers les autres * documents, donc ici il arrive bien. Le changement dans le * même onglet est rattrapé à chaque ouverture (ouvrir()). */ window.addEventListener("storage", function (ev) { if (ev.key === "draco_domain") resynchroniserDomaine(); }); majComposer(); brancherPoignee(root.querySelector(".draco-poignee")); appliquerTheme(); surveillerTheme(); appliquerLargeur(); window.addEventListener("resize", appliquerLargeur); document.addEventListener("keydown", function (ev) { if (ev.key !== "Escape") return; /* Surimpression ouverte : Échap = retour au panneau seul * (« Mode sidebar »). Quand le focus est dans l'iframe de la * console (autre origine), l'événement ne remonte pas * jusqu'ici : il ne peut donc pas fermer par surprise. */ if (surOuverte) { modeSidebarConsole(); return; } if (root.classList.contains("open")) fermer(); }); } function ajouterMsg(kind, texte) { var d = document.createElement("div"); d.className = "draco-msg " + kind; d.textContent = texte; msgsEl.appendChild(d); msgsEl.scrollTop = msgsEl.scrollHeight; return d; } /* Message agent rendu en Markdown : le HTML vient du service * (rendu + sanitizer côté serveur) ; repli texte si absent. */ function ajouterMsgMd(texte, html) { var d = document.createElement("div"); d.className = "draco-msg agent md"; if (html) d.innerHTML = html; else d.textContent = texte; msgsEl.appendChild(d); msgsEl.scrollTop = msgsEl.scrollHeight; return d; } function majEtat(cls, texte) { /* Statut intégré au titre (sous-titre de l'en-tête) : plus de * barre d'état séparée. Le point vert/rouge/gris reste lisible * sans prendre une rangée entière. */ if (!etatEl) return; etatEl.className = "soustitre " + cls; var t = etatEl.querySelector(".txt"); if (t) t.textContent = texte; } function majTitre() { titreEl.textContent = convTitre || "Discussion principale"; titreEl.title = titreEl.textContent; } /* ── Version du service : lue en direct dans la santé de * l'agent (GET /healthz → champ « version », la même source que * la console) à l'origine que le panneau connaît déjà. Cache de * session (5 min), rafraîchie à l'ouverture du panneau, au même * rythme que la vérification d'identité. Jamais de valeur codée * en dur : source injoignable ou réponse illisible = la ligne * reste masquée — pas de fausse version, pas d'erreur visible. */ function afficherVersion() { if (!versionEl) return; if (versionConnue) { versionEl.textContent = "Service d'agents v" + versionConnue; versionEl.hidden = false; } } function chargerVersion() { if (!API || !versionEl) return; if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; } fetch(API + "/healthz") .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (d) { if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) { versionConnue = d.version; versionMaj = Date.now(); afficherVersion(); } }) .catch(function () { /* service injoignable : rien d'affiché */ }); } function verifierIdentite() { if (etatConnu) return; fetch(API + "/api/v1/me", { credentials: "include" }) .then(function (r) { if (r.status === 401) { majEtat("err", "Session partagée non reconnue"); ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme."); etatConnu = true; return null; } if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (me) { if (!me) return; etatConnu = true; majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub))); }) .catch(function () { majEtat("err", "Service d'agents injoignable"); ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré."); }); } function esc(s) { return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } /* ── Discussions (façon Muse) ────────────────────────────── */ function chargerListeDiscussions() { return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()), { credentials: "include" }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }); } /* Recharge la discussion du domaine courant quand il a changé * depuis le dernier chargement (ouverture du panneau, autre * onglet, retour du mode console). Sans changement : rien — * on ne casse jamais une conversation en cours pour rien. */ function resynchroniserDomaine() { if (!root || domaine() === domaineCharge) return; msgsEl.innerHTML = ""; tiroirEl.hidden = true; demarrerDiscussions(); } function demarrerDiscussions() { chargerListeDiscussions() .then(function (d) { domaineCharge = domaine(); var precedente = convStockee(); var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []); var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0]; var active = trouvee || d.principale; if (!active) return; convId = active.id; convKind = active.kind; convTitre = active.title; stockerConv(); majTitre(); chargerHistorique(); }) .catch(function () { domaineCharge = null; // échec : la prochaine ouverture réessaiera ajouterMsg("sys", "Discussions : service injoignable pour le moment."); }); } function basculerDiscussion(c) { convId = c.id; convKind = c.kind; convTitre = c.title; stockerConv(); majTitre(); tiroirEl.hidden = true; msgsEl.innerHTML = ""; ajouterMsg("sys", (c.kind === "principale" ? "Discussion principale — le fil sans fin." : "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + "."); chargerHistorique(); inputEl.focus(); } function nouvelleDiscussion() { fetch(API + "/api/v1/conversations", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ domain: domaine() }) }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (c) { basculerDiscussion(c); ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message."); }) .catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); }); } function actionDiscussion(id, action) { var methode = action === "supprimer" ? "DELETE" : "POST"; var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action); return fetch(url, { method: methode, credentials: "include" }) .then(function (r) { if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) { throw new Error(e && e.detail ? e.detail : "HTTP " + r.status); }); return r.json(); }); } function ligneDiscussion(c, actions) { var ligne = document.createElement("div"); ligne.className = "d-item" + (c.id === convId ? " actif" : ""); var meta = document.createElement("div"); meta.className = "d-meta"; var quand = ""; try { quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA", { day: "numeric", month: "short" }); } catch (e) { /* date illisible */ } meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title; ligne.appendChild(meta); var sous = document.createElement("div"); sous.className = "d-sous"; sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") + (c.kind === "principale" ? " · perpétuelle" : ""); ligne.appendChild(sous); meta.addEventListener("click", function () { basculerDiscussion(c); }); sous.addEventListener("click", function () { basculerDiscussion(c); }); if (actions && actions.length) { var btns = document.createElement("div"); btns.className = "d-actions"; actions.forEach(function (a) { var b = document.createElement("button"); b.type = "button"; b.textContent = a.label; if (a.danger) b.className = "danger"; b.addEventListener("click", function (ev) { ev.stopPropagation(); if (a.confirmer && !window.confirm(a.confirmer)) return; actionDiscussion(c.id, a.action) .then(function () { if (c.id === convId && a.action !== "desarchiver") { chargerListeDiscussions().then(function (d) { if (d.principale) { basculerDiscussion(d.principale); ajouterMsg("sys", a.action === "supprimer" ? "Discussion supprimée — retour à la discussion principale." : "Discussion archivée — retour à la discussion principale."); } }); } remplirTiroir(); }) .catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); }); }); btns.appendChild(b); }); ligne.appendChild(btns); } return ligne; } function remplirTiroir() { tiroirEl.innerHTML = "
Chargement des discussions…
"; chargerListeDiscussions() .then(function (d) { tiroirEl.innerHTML = ""; var h = document.createElement("div"); h.className = "d-section"; h.textContent = "Discussion principale"; tiroirEl.appendChild(h); if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, [])); h = document.createElement("div"); h.className = "d-section"; h.textContent = "Discussions parallèles"; tiroirEl.appendChild(h); if (!d.paralleles.length) { var vide = document.createElement("div"); vide.className = "d-vide"; vide.textContent = "Aucune pour le moment — bouton « + Nouvelle »."; tiroirEl.appendChild(vide); } d.paralleles.forEach(function (c) { tiroirEl.appendChild(ligneDiscussion(c, [ { label: "Archiver", action: "archive" }, { label: "Supprimer", action: "supprimer", danger: true, confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" } ])); }); if (d.archivees && d.archivees.length) { h = document.createElement("div"); h.className = "d-section"; h.textContent = "Archivées"; tiroirEl.appendChild(h); d.archivees.forEach(function (c) { tiroirEl.appendChild(ligneDiscussion(c, [ { label: "Désarchiver", action: "desarchiver" }, { label: "Supprimer", action: "supprimer", danger: true, confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" } ])); }); } }) .catch(function () { tiroirEl.innerHTML = "
Discussions : service injoignable.
"; }); } function basculerTiroir() { tiroirEl.hidden = !tiroirEl.hidden; if (!tiroirEl.hidden) remplirTiroir(); } function chargerHistorique() { if (!convId) return; fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" }) .then(function (r) { if (r.status === 404) { convId = null; stockerConv(); return null; } if (!r.ok) return null; return r.json(); }) .then(function (d) { if (!d) return; convKind = d.kind; convTitre = d.title || convTitre; majTitre(); if (!msgsEl.querySelector(".draco-msg")) { ajouterMsg("sys", (d.kind === "principale" ? "Discussion principale — le fil sans fin." : "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + "."); } var sys = msgsEl.querySelector(".draco-msg.sys"); (d.messages || []).forEach(function (m) { var el = m.role === "user" ? ajouterMsg("user", m.content) : ajouterMsgMd(m.content, m.html); if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; } }); msgsEl.scrollTop = msgsEl.scrollHeight; }) .catch(function () { /* historique indisponible : on repart à neuf */ }); } function chargerAujourdhui() { var bloc = document.createElement("div"); bloc.className = "draco-auj"; bloc.innerHTML = "
Aujourd'hui — vu du service d'agents
Chargement…
"; msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight; fetch(API + "/api/v1/aujourdhui", { credentials: "include" }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (d) { var h = "
Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "
"; var t = d.sources.tiktak; h += "
Tâches du jour — TikTak
"; if (t.status === "ok") { if (!t.tasks.length) h += "
Aucune tâche échue aujourd'hui. 🎉
"; else h += ""; } else if (t.status === "non_configure") { h += "
Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).
"; } else if (t.status === "non_autorise") { h += "
Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).
"; } else { h += "
Source TikTak " + esc(t.status) + ".
"; } var k = d.sources.kanbante; h += "
Cartes en Révision — Kanbante
"; if (k.status === "ok") { if (!k.review.length) h += "
Rien à valider. 🎉
"; else h += ""; } else if (k.status === "non_autorise") { h += "
Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.
"; } else { h += "
Source Kanbante " + esc(k.status) + ".
"; } bloc.innerHTML = h; msgsEl.scrollTop = msgsEl.scrollHeight; }) .catch(function () { bloc.innerHTML = "
Aujourd'hui
Service injoignable.
"; }); } function chargerApprobations() { fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (d) { if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; } d.approvals.forEach(function (a) { var bloc = document.createElement("div"); bloc.className = "draco-appro"; bloc.innerHTML = "
Approbation demandée — " + esc(a.action) + "
" + "
" + esc(a.summary) + "
" + "
" + "
"; bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); }); bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); }); msgsEl.appendChild(bloc); }); msgsEl.scrollTop = msgsEl.scrollHeight; }) .catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); }); } function decider(id, decision, bloc) { fetch(API + "/api/v1/approvals/" + id + "/decide", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ decision: decision }) }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (d) { bloc.style.opacity = ".55"; bloc.querySelector(".btns").innerHTML = "Décision enregistrée : " + esc(d.status) + ""; }) .catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); }); } /* ── Missions longues (J5) ─────────────────────────────────── * Sélecteur d'exécuteur alimenté par le registre du service : * les externes non configurés sont montrés mais désactivés — * jamais un lancement qui simule. La liste se rafraîchit à * l'ouverture et après chaque lancement/annulation. */ var STATUTS_MISSION = { queued: "En file", running: "En cours…", paused_question: "Question en attente", succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée" }; function ouvrirMissions() { var bloc = document.createElement("div"); bloc.className = "draco-missions"; bloc.innerHTML = "
Missions longues
" + "" + "" + "" + "" + "
" + "
" + "
Chargement des missions…
"; msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight; fetch(API + "/api/v1/executors", { credentials: "include" }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (d) { var sel = bloc.querySelector(".m-exec"); sel.innerHTML = ""; d.executors.forEach(function (e) { if (e.family !== "sous-agent" && e.family !== "externe") return; var opt = document.createElement("option"); opt.value = e.id; opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") + (e.status === "non_configure" ? " — non configuré" : ""); if (e.status === "non_configure") opt.disabled = true; sel.appendChild(opt); }); if (!sel.options.length) sel.innerHTML = ""; }) .catch(function () { bloc.querySelector(".m-exec").innerHTML = ""; }); bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); }); bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); }); chargerMissions(bloc); } function chargerMissions(bloc) { var zone = bloc.querySelector(".m-liste"); fetch(API + "/api/v1/missions", { credentials: "include" }) .then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then(function (d) { if (!d.missions.length) { zone.innerHTML = "
Aucune mission pour le moment.
"; return; } zone.innerHTML = ""; d.missions.slice(0, 10).forEach(function (m) { var ligne = document.createElement("div"); ligne.className = "m-item"; var entete = document.createElement("div"); entete.className = "m-entete"; entete.innerHTML = "" + esc(m.title) + "" + "" + esc(STATUTS_MISSION[m.status] || m.status) + ""; ligne.appendChild(entete); var meta = document.createElement("div"); meta.className = "m-meta"; meta.textContent = m.executor + (m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") + (m.meta && m.meta.cout ? " · coût : " + m.meta.cout : ""); ligne.appendChild(meta); if (m.result) { var rapport = document.createElement("div"); rapport.className = "m-rapport md"; if (m.result_html) rapport.innerHTML = m.result_html; else rapport.textContent = String(m.result).slice(0, 1200); ligne.appendChild(rapport); } if (m.status === "queued" || m.status === "running" || m.status === "paused_question") { var btn = document.createElement("button"); btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler"; btn.addEventListener("click", function () { fetch(API + "/api/v1/missions/" + m.id + "/cancel", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: "{}" }).then(function () { chargerMissions(bloc); }) .catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); }); }); ligne.appendChild(btn); } zone.appendChild(ligne); }); }) .catch(function () { zone.innerHTML = "
Missions : service injoignable.
"; }); } function lancerMission(bloc) { var executor = bloc.querySelector(".m-exec").value; var titre = bloc.querySelector(".m-titre").value.trim(); var prompt = bloc.querySelector(".m-prompt").value.trim(); var carte = bloc.querySelector(".m-carte").value; if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; } if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; } var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() }; if (carte) corps.kanbante_number = parseInt(carte, 10); fetch(API + "/api/v1/missions", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(corps) }) .then(function (r) { if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) { throw new Error(e && e.detail ? e.detail : "HTTP " + r.status); }); return r.json(); }) .then(function () { ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions."); chargerMissions(bloc); }) .catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); }); } /* ── Composer façon console ──────────────────────────────── * Mêmes règles que la console : + (images/documents, 4 max), * textarea auto-extensible, micro affiché seulement sans texte * ni pièce et si le navigateur le supporte, envoi rond bleu * affiché seulement avec texte ou pièce. */ var piecesEnAttente = []; var TYPES_IMAGES = ["image/png", "image/jpeg", "image/gif", "image/webp"]; var TYPES_DOCUMENTS = ["application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.oasis.opendocument.text", "text/plain", "text/markdown", "text/html", "text/csv"]; var TAILLE_MAX_IMAGE = 5 * 1024 * 1024; var TAILLE_MAX_DOC = 10 * 1024 * 1024; function majComposer() { if (!inputEl) return; var aTexte = !!(inputEl.value && inputEl.value.trim()); sendBtn.hidden = !(aTexte || piecesEnAttente.length); if (microEl) microEl.hidden = !dicteeSupportee || aTexte || piecesEnAttente.length; inputEl.style.height = "auto"; var sh = inputEl.scrollHeight; inputEl.style.height = sh ? Math.min(sh, 152) + "px" : ""; } function rendrePiecesEnAttente() { piecesEl.innerHTML = ""; piecesEl.hidden = !piecesEnAttente.length; piecesEnAttente.forEach(function (p, i) { var carte = document.createElement("div"); carte.className = "piece-carte"; var apercu; if (TYPES_IMAGES.indexOf(p.type) >= 0) { apercu = document.createElement("img"); apercu.src = p.dataUrl; apercu.alt = p.nom; } else { apercu = document.createElement("span"); apercu.className = "piece-doc-ext"; var ext = document.createElement("span"); ext.className = "piece-ext"; ext.textContent = (p.nom || "").split(".").pop() || "doc"; apercu.appendChild(ext); } var nom = document.createElement("span"); nom.className = "piece-nom"; nom.textContent = p.nom; nom.title = p.nom; var x = document.createElement("button"); x.type = "button"; x.className = "piece-retirer"; x.textContent = "✕"; x.title = "Retirer " + p.nom; x.setAttribute("aria-label", "Retirer " + p.nom); x.addEventListener("click", function () { piecesEnAttente.splice(i, 1); rendrePiecesEnAttente(); majComposer(); }); carte.appendChild(apercu); carte.appendChild(nom); carte.appendChild(x); piecesEl.appendChild(carte); }); } function ajouterFichiers(fichiers) { Array.prototype.forEach.call(fichiers, function (f) { if (piecesEnAttente.length >= 4) { ajouterMsg("sys", "Au plus 4 pièces par message."); return; } var estImage = TYPES_IMAGES.indexOf(f.type) >= 0; var estDoc = TYPES_DOCUMENTS.indexOf(f.type) >= 0; if (!estImage && !estDoc) { ajouterMsg("sys", "« " + f.name + " » refusée : images PNG, JPEG, GIF ou WebP, ou documents PDF, DOCX, ODT, TXT, MD, HTML ou CSV seulement."); return; } if (estImage && f.size > TAILLE_MAX_IMAGE) { ajouterMsg("sys", "« " + f.name + " » refusée : 5 Mo maximum par image."); return; } if (estDoc && f.size > TAILLE_MAX_DOC) { ajouterMsg("sys", "« " + f.name + " » refusée : 10 Mo maximum par document."); return; } var lecteur = new FileReader(); lecteur.onload = function () { var dataUrl = String(lecteur.result || ""); piecesEnAttente.push({ nom: f.name, type: f.type, base64: dataUrl.split(",")[1] || "", dataUrl: dataUrl }); rendrePiecesEnAttente(); majComposer(); }; lecteur.readAsDataURL(f); }); } function envoyerComposer() { var t = inputEl.value; var pieces = piecesEnAttente; piecesEnAttente = []; inputEl.value = ""; rendrePiecesEnAttente(); majComposer(); envoyerMessage(t, pieces); } /* ── Dictée vocale — Web Speech API, 100 % navigateur, comme * la console (fr-CA, résultats intérimaires en direct). */ var RecoVoc = window.SpeechRecognition || window.webkitSpeechRecognition; var dicteeSupportee = !!RecoVoc; var reco = null, enregEnCours = false, enregDebut = 0, enregMinuteur = null; var texteAvantDictee = "", transcriptionFinale = ""; var modeFinDictee = "stop", dicteeErreur = null; function demarrerDictee() { if (!dicteeSupportee || enregEnCours) return; texteAvantDictee = inputEl.value; transcriptionFinale = ""; modeFinDictee = "stop"; dicteeErreur = null; try { reco = new RecoVoc(); } catch (e) { reco = null; return; } reco.lang = "fr-CA"; reco.interimResults = true; reco.continuous = true; reco.onresult = function (ev) { var interim = ""; for (var i = ev.resultIndex; i < ev.results.length; i++) { var r = ev.results[i]; if (r.isFinal) transcriptionFinale += r[0].transcript; else interim += r[0].transcript; } var affiche = (texteAvantDictee + " " + transcriptionFinale + interim).trim(); root.querySelector(".enreg-texte").textContent = affiche; inputEl.value = affiche; }; reco.onerror = function (ev) { if (ev.error === "aborted") return; dicteeErreur = ev.error || "erreur"; modeFinDictee = "annuler"; }; reco.onend = terminerDictee; try { reco.start(); } catch (e) { reco = null; return; } enregEnCours = true; enregDebut = Date.now(); root.querySelector(".enreg-temps").textContent = "0:00"; root.querySelector(".enreg-texte").textContent = ""; var onde = root.querySelector(".enreg-onde"); if (!onde.children.length) { for (var j = 0; j < 14; j++) onde.appendChild(document.createElement("span")); } root.querySelector(".draco-composer-ligne").hidden = true; root.querySelector(".draco-composer-enreg").hidden = false; root.querySelector(".draco-composer").classList.add("en-cours"); enregMinuteur = window.setInterval(function () { var s = Math.floor((Date.now() - enregDebut) / 1000); root.querySelector(".enreg-temps").textContent = Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2); }, 250); } function arreterDictee(mode) { modeFinDictee = mode; if (reco) { try { reco.stop(); } catch (e) { terminerDictee(); } } else { terminerDictee(); } } function terminerDictee() { if (!enregEnCours) return; enregEnCours = false; if (enregMinuteur) { window.clearInterval(enregMinuteur); enregMinuteur = null; } reco = null; root.querySelector(".draco-composer-ligne").hidden = false; root.querySelector(".draco-composer-enreg").hidden = true; root.querySelector(".draco-composer").classList.remove("en-cours"); if (modeFinDictee === "annuler") { inputEl.value = texteAvantDictee; if (dicteeErreur) { ajouterMsg("sys", "Dictée vocale interrompue (" + dicteeErreur + ") — micro refusé ou indisponible ?"); dicteeErreur = null; } } else { inputEl.value = (texteAvantDictee + " " + transcriptionFinale).trim(); } var mode = modeFinDictee; modeFinDictee = "stop"; majComposer(); if (mode === "envoyer") envoyerComposer(); else inputEl.focus(); } function envoyer(ev) { if (ev) ev.preventDefault(); envoyerComposer(); } function envoyerMessage(texte, pieces) { texte = (texte || "").trim(); pieces = pieces || []; if (!texte && !pieces.length) return; var el = ajouterMsg("user", texte); if (pieces.length) { var zone = document.createElement("div"); zone.className = "draco-msg-pieces"; pieces.forEach(function (p) { if (TYPES_IMAGES.indexOf(p.type) >= 0) { var img = document.createElement("img"); img.src = p.dataUrl; img.alt = p.nom; zone.appendChild(img); } else { var past = document.createElement("span"); past.className = "piece-fichier"; past.textContent = p.nom; zone.appendChild(past); } }); el.appendChild(zone); } sendBtn.disabled = true; var attente = ajouterMsg("agent", "Spark réfléchit…"); var corps = { message: texte, domain: domaine(), conversation_id: convId }; if (pieces.length) { corps.pieces = pieces.map(function (p) { return { nom: p.nom, type: p.type, donnees_base64: p.base64 }; }); } fetch(API + "/api/v1/chat", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(corps) }) .then(function (r) { if (r.status === 401) { attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie."; majEtat("err", "Session partagée non reconnue"); return null; } if (!r.ok) { return r.json().catch(function () { return null; }).then(function (e) { attente.textContent = (e && e.detail) ? e.detail : "Le service a répondu HTTP " + r.status + " — réessaie dans un instant."; return null; }); } return r.json(); }) .then(function (d) { if (!d) return; convId = d.conversation_id; convKind = d.kind || convKind; if (d.title) convTitre = d.title; stockerConv(); majTitre(); if (d.html) { attente.className = "draco-msg agent md"; attente.innerHTML = d.html; } else { attente.textContent = d.reply; } msgsEl.scrollTop = msgsEl.scrollHeight; if (d.tools_used && d.tools_used.length) { ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?")); } majEtat("ok", "Connecté au service — domaine " + d.domain); }) .catch(function () { attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie."; majEtat("err", "Service d'agents injoignable"); }) .finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); }); } function ouvrir() { if (!root) { construire(); demarrerDiscussions(); } else resynchroniserDomaine(); root.classList.add("open"); appliquerTheme(); verifierIdentite(); chargerVersion(); setTimeout(function () { inputEl.focus(); }, 300); } function fermer() { if (root) root.classList.remove("open"); } window.DRACO_PANNEAU = { open: ouvrir, close: fermer, toggle: function () { if (root && root.classList.contains("open")) fermer(); else ouvrir(); } }; })();