381 lines
19 KiB
JavaScript
381 lines
19 KiB
JavaScript
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5).
|
|
*
|
|
* 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
|
|
* GET /api/v1/conversations/… — historique de la conversation
|
|
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
|
* externes J4 : état de configuration)
|
|
* POST /api/v1/missions — missions longues (J5) : lancement,
|
|
* suivi, rapport, annulation
|
|
* É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;
|
|
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ }
|
|
var etatConnu = false;
|
|
var historiqueCharge = false;
|
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
|
|
|
function domaine() {
|
|
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
|
}
|
|
|
|
function construire() {
|
|
root = document.createElement("aside");
|
|
root.className = "draco-panneau";
|
|
root.setAttribute("aria-label", "Spark, l'agent commun");
|
|
root.innerHTML =
|
|
'<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="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' +
|
|
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
|
'</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]");
|
|
root.querySelector(".fermer").addEventListener("click", fermer);
|
|
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);
|
|
document.addEventListener("keydown", function (ev) {
|
|
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
|
|
});
|
|
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function majEtat(cls, texte) {
|
|
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
|
etatEl.querySelector(".txt").textContent = texte;
|
|
}
|
|
|
|
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];
|
|
});
|
|
}
|
|
|
|
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;color:#6c7268'>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 rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : "";
|
|
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" +
|
|
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" +
|
|
"<div class='m-meta'>" + esc(m.executor) +
|
|
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") +
|
|
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + 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 chargerHistorique() {
|
|
if (historiqueCharge || !convId) return;
|
|
historiqueCharge = true;
|
|
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
|
.then(function (r) {
|
|
if (r.status === 404) {
|
|
convId = null;
|
|
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
|
|
return null;
|
|
}
|
|
if (!r.ok) return null;
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
if (!d || !d.messages || !d.messages.length) return;
|
|
var sys = msgsEl.querySelector(".draco-msg.sys");
|
|
d.messages.forEach(function (m) {
|
|
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
|
|
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
|
|
});
|
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
|
})
|
|
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
|
|
}
|
|
|
|
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;
|
|
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ }
|
|
attente.textContent = d.reply;
|
|
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();
|
|
root.classList.add("open");
|
|
verifierIdentite();
|
|
chargerHistorique();
|
|
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();
|
|
}
|
|
};
|
|
})();
|