817 lines
38 KiB
JavaScript
817 lines
38 KiB
JavaScript
/* 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";
|
||
var API = "https://agent.dracodev.net";
|
||
var convId = null;
|
||
var convKind = null; // 'principale' | 'parallele'
|
||
var convTitre = "";
|
||
var etatConnu = false;
|
||
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||
var titreEl = null, tiroirEl = null;
|
||
|
||
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() {
|
||
var h = (window.location.hostname || "").split(".")[0];
|
||
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||
kanbante: "kanbante", og: "obsigate" };
|
||
return correspondance[h] || null;
|
||
}
|
||
function urlConsole() {
|
||
var params = [];
|
||
var id = convId || convStockee();
|
||
if (id) params.push("disc=" + encodeURIComponent(id));
|
||
var app = appHote();
|
||
if (app) params.push("app=" + encodeURIComponent(app));
|
||
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 =
|
||
'<div class="draco-console-barre">' +
|
||
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||
' <span class="dc-actions">' +
|
||
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||
' </span></div>' +
|
||
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||
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();
|
||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||
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(); }
|
||
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 =
|
||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||
'<div class="draco-panneau-hd">' +
|
||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||
' <div><div class="titre">Spark — agent commun</div>' +
|
||
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||
'</div>' +
|
||
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||
'</div>' +
|
||
'<div class="draco-disc-bar">' +
|
||
' <span class="d-titre">Discussion principale</span>' +
|
||
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||
'</div>' +
|
||
'<div class="draco-discussions" hidden></div>' +
|
||
'<div class="draco-panneau-outils">' +
|
||
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||
'</div>' +
|
||
'<div class="draco-panneau-msgs"></div>' +
|
||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||
' <button type="submit">Envoyer</button></form>' +
|
||
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||
'</div>';
|
||
document.body.appendChild(root);
|
||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||
etatEl = root.querySelector(".draco-panneau-etat");
|
||
inputEl = root.querySelector("input");
|
||
sendBtn = root.querySelector("button[type=submit]");
|
||
titreEl = root.querySelector(".d-titre");
|
||
tiroirEl = root.querySelector(".draco-discussions");
|
||
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);
|
||
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) {
|
||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||
etatEl.querySelector(".txt").textContent = texte;
|
||
}
|
||
function majTitre() {
|
||
titreEl.textContent = convTitre || "Discussion principale";
|
||
titreEl.title = titreEl.textContent;
|
||
}
|
||
|
||
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(); });
|
||
}
|
||
|
||
function demarrerDiscussions() {
|
||
chargerListeDiscussions()
|
||
.then(function (d) {
|
||
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 () {
|
||
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 = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||
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 = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||
});
|
||
}
|
||
|
||
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 = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
|
||
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 = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
|
||
var t = d.sources.tiktak;
|
||
h += "<h5>Tâches du jour — TikTak</h5>";
|
||
if (t.status === "ok") {
|
||
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
|
||
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||
} else if (t.status === "non_configure") {
|
||
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
|
||
} else if (t.status === "non_autorise") {
|
||
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
|
||
} else {
|
||
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
|
||
}
|
||
var k = d.sources.kanbante;
|
||
h += "<h5>Cartes en Révision — Kanbante</h5>";
|
||
if (k.status === "ok") {
|
||
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
|
||
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||
} else if (k.status === "non_autorise") {
|
||
h += "<div class='etat-source'>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.</div>";
|
||
} else {
|
||
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
|
||
}
|
||
bloc.innerHTML = h;
|
||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||
})
|
||
.catch(function () {
|
||
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
|
||
});
|
||
}
|
||
|
||
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 = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
|
||
"<div class='resume'>" + esc(a.summary) + "</div>" +
|
||
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
|
||
"<button type='button' class='non'>Rejeter</button></div>";
|
||
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 = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||
})
|
||
.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 =
|
||
"<h5>Missions longues</h5>" +
|
||
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
|
||
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
|
||
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
|
||
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
|
||
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
|
||
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
|
||
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
|
||
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 = "<option value=''>Aucun exécuteur lançable</option>";
|
||
})
|
||
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
|
||
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 = "<div class='etat-source'>Aucune mission pour le moment.</div>"; 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 = "<b>" + esc(m.title) + "</b>" +
|
||
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||
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 = "<div class='etat-source'>Missions : service injoignable.</div>"; });
|
||
}
|
||
|
||
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); });
|
||
}
|
||
|
||
function envoyer(ev) {
|
||
ev.preventDefault();
|
||
var texte = inputEl.value.trim();
|
||
if (!texte) return;
|
||
inputEl.value = "";
|
||
ajouterMsg("user", texte);
|
||
sendBtn.disabled = true;
|
||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||
fetch(API + "/api/v1/chat", {
|
||
method: "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||
})
|
||
.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; inputEl.focus(); });
|
||
}
|
||
|
||
function ouvrir() {
|
||
if (!root) { construire(); demarrerDiscussions(); }
|
||
root.classList.add("open");
|
||
appliquerTheme();
|
||
verifierIdentite();
|
||
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();
|
||
}
|
||
};
|
||
})();
|