From cb468b0f3c0621fe8f4ff84d81262c6c34b20a70 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 08:48:54 -0400 Subject: [PATCH] fix(sidebar): style console, statut dans le titre, composer console, synchro domaine --- frontend/draco-panneau.js | 355 +++++++++++++++++++++++++++++++++++--- 1 file changed, 332 insertions(+), 23 deletions(-) diff --git a/frontend/draco-panneau.js b/frontend/draco-panneau.js index ff2ce2d..f8f9610 100644 --- a/frontend/draco-panneau.js +++ b/frontend/draco-panneau.js @@ -40,7 +40,10 @@ 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; @@ -68,11 +71,16 @@ 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("&") : ""); } @@ -147,7 +155,15 @@ function ouvrirConsole() { assurerSurimpression(); - if (!surCadre.getAttribute("src")) surCadre.src = urlConsole(); + /* 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 @@ -175,7 +191,12 @@ setTimeout(terminer, 380); // filet si transitionend ne vient pas surEl.style.width = largeurPanneau() + "px"; } - function modeSidebarConsole() { retrecirConsole(); } + 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 ─────────────────────────────── */ @@ -246,13 +267,10 @@ '
' + '
' + ' ✦' + - '
Spark — agent commun
' + - '
Service d\'agents dracodev
' + + '
Spark — agent commun
' + + '
Connexion au service…
' + ' ' + '
' + - '
' + - ' Connexion au service…' + - '
' + '
' + ' Discussion principale' + ' ' + @@ -266,17 +284,40 @@ ' ' + '
' + '
' + - '
' + - ' ' + - '
' + - '
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.
' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + ' ' + + '
' + ' ' + '
'; 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]"); + 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"); @@ -288,6 +329,42 @@ 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(); @@ -326,8 +403,13 @@ } function majEtat(cls, texte) { - etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls; - etatEl.querySelector(".txt").textContent = 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"; @@ -400,9 +482,21 @@ .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]; @@ -413,6 +507,7 @@ chargerHistorique(); }) .catch(function () { + domaineCharge = null; // échec : la prochaine ouverture réessaiera ajouterMsg("sys", "Discussions : service injoignable pour le moment."); }); } @@ -780,19 +875,232 @@ .catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); }); } - function envoyer(ev) { - ev.preventDefault(); - var texte = inputEl.value.trim(); - if (!texte) return; + /* ── 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 = ""; - ajouterMsg("user", texte); + 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({ message: texte, domain: domaine(), conversation_id: convId }) + body: JSON.stringify(corps) }) .then(function (r) { if (r.status === 401) { @@ -831,11 +1139,12 @@ 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(); }); + .finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); }); } function ouvrir() { if (!root) { construire(); demarrerDiscussions(); } + else resynchroniserDomaine(); root.classList.add("open"); appliquerTheme(); verifierIdentite();