Compare commits

..
Author SHA1 Message Date
bruno dcec510451 fix(sidebar): CSS composer console, statut dans le titre, pied épuré
CI / lint (pull_request) Failing after 1s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:49:05 -04:00
bruno cb468b0f3c fix(sidebar): style console, statut dans le titre, composer console, synchro domaine 2026-10-28 08:48:54 -04:00
bruno 6713510ce9 Merge pull request 'fix(ci): tag OpenAPI 'Coffres Secrets' declare + compteur de themes 15 -> 16' (#34) from fix/ci-tests-openapi-themes into main
CI / lint (push) Successful in 2m57s
CI / test (push) Successful in 5m37s
CI / build (push) Successful in 3m15s
CI / e2e (push) Successful in 18m7s
CI / security (push) Successful in 1m58s
2026-10-28 08:27:31 -04:00
bruno bf5b32de16 Merge pull request 'feat(ui): icone coffre-fort SVG pour le noeud Secrets' (#33) from fix/icone-secrets-coffre-fort into main
CI / security (push) Successful in 1m58s
CI / lint (push) Successful in 2m55s
CI / test (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:25:45 -04:00
bruno 2a1780b43d docs: commentaire 15 -> 16 themes integres (meme compteur obsolete que test_themes.py)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m11s
CI / test (pull_request) Failing after 5m8s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:25:38 -04:00
bruno be05ec99b5 fix(tests): test_theme_count 15 -> 16 (theme spark ajoute en 2.80.2) 2026-10-28 08:25:35 -04:00
bruno 3a42bc034d fix(tests): declare le tag OpenAPI 'Coffres Secrets' (test_used_tags_are_declared) 2026-10-28 08:25:32 -04:00
5 changed files with 426 additions and 44 deletions
+1
View File
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
+89 -17
View File
@@ -37,16 +37,19 @@
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-titres { min-width: 0; flex: 1; }
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.draco-panneau-hd .soustitre {
font-size: 12px; color: var(--p-doux); display: flex; align-items: center; gap: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-panneau-hd .soustitre .point { width: 8px; height: 8px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-hd .soustitre.ok .point { background: #2ea36b; }
.draco-panneau-hd .soustitre.err .point { background: #cf4b32; }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; flex: none;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */
.draco-disc-bar {
display: flex; align-items: center; gap: 7px;
@@ -112,18 +115,87 @@
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
.draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
/* Puces de suggestions, comme la console : au-dessus du composer. */
.draco-puces {
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
.draco-puces 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;
}
.draco-puces button:hover { background: var(--p-chip-survol); }
/* ── Composer façon console (pilule) ─────────────────────────
* +, textarea auto-extensible, micro ou envoi rond bleu ↑ ;
* pendant la dictée, la rangée d'enregistrement remplace la saisie. */
.draco-composer {
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
background: var(--p-surface); border: 1px solid var(--p-bord);
border-radius: 24px; padding: 8px 10px;
box-shadow: 0 1px 4px rgb(0 0 0 / .05);
}
.draco-composer [hidden] { display: none !important; }
.draco-composer-ligne { display: flex; align-items: flex-end; gap: 6px; }
.draco-composer textarea {
flex: 1; border: 0; background: none; resize: none; outline: none;
padding: 8px 2px; max-height: 152px; overflow-y: auto; line-height: 1.45;
min-height: calc(1.45em + 16px); font: inherit; color: var(--p-texte);
scrollbar-width: none;
}
.draco-composer textarea::-webkit-scrollbar { width: 0; height: 0; display: none; }
.draco-composer .composer-btn {
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
background: var(--p-chip); color: var(--p-texte); font-size: 17px;
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.draco-composer .composer-btn:hover { background: var(--p-chip-survol); }
.draco-composer .composer-btn.danger { color: var(--p-danger); }
.draco-composer .composer-envoi {
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
background: #2f6fed; color: #fff; font-size: 17px; font-weight: 700;
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.draco-composer .composer-envoi:disabled { opacity: .5; }
.draco-composer .composer-btn .ic, .draco-composer .composer-envoi .ic { width: 19px; height: 19px; }
.draco-composer-pieces { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 2px 0; }
.draco-composer-pieces .piece-carte {
position: relative; display: flex; align-items: center; gap: 8px;
background: var(--p-chip); border: 1px solid var(--p-chip-bord);
border-radius: 12px; padding: 5px 9px 5px 5px; max-width: 230px;
}
.draco-composer-pieces .piece-carte img { width: 42px; height: 42px; object-fit: cover; border-radius: 8px; display: block; }
.draco-composer-pieces .piece-nom { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draco-composer-pieces .piece-doc-ext {
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
background: var(--p-chip); border: 1px solid var(--p-chip-bord); border-radius: 8px;
}
.draco-composer-pieces .piece-ext { font-weight: 700; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
.draco-composer-pieces .piece-retirer {
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
border-radius: 50%; border: 0; background: var(--p-texte); color: var(--p-fond);
font-size: 10px; line-height: 1; padding: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.draco-composer-enreg { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
.draco-composer-enreg .enreg-temps { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 36px; }
.draco-composer-enreg .enreg-texte {
flex: 1; min-width: 0; color: var(--p-doux); font-size: 13px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-composer-enreg .enreg-onde { display: flex; align-items: center; gap: 3px; height: 26px; flex: none; }
.draco-composer-enreg .enreg-onde span { width: 4px; height: 30%; border-radius: 2px; background: #2f6fed; }
.draco-composer.en-cours .enreg-onde span { animation: onde 1.05s ease-in-out infinite; }
.draco-composer-enreg .enreg-onde span:nth-child(3n+2) { animation-delay: .14s; }
.draco-composer-enreg .enreg-onde span:nth-child(3n) { animation-delay: .28s; }
@keyframes onde { 0%, 100% { height: 22%; } 50% { height: 96%; } }
/* Pièces jointes dans une bulle utilisateur */
.draco-msg-pieces { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
.draco-msg-pieces img { max-width: 150px; max-height: 120px; border-radius: 10px; display: block; }
.draco-msg-pieces .piece-fichier {
display: inline-flex; align-items: center; gap: 7px;
background: var(--p-chip); border: 1px solid var(--p-chip-bord); color: var(--p-texte);
border-radius: 9px; padding: 6px 9px; font-size: 12px;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
+332 -23
View File
@@ -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 @@
'<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>' +
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre"><span class="point"></span><span class="txt">Connexion au service…</span></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>' +
@@ -266,17 +284,40 @@
' <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 class="draco-panneau-ft">' +
' <div class="draco-puces">' +
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
' <button type="button" data-sugg="projets">Où en sont mes projets ?</button>' +
' <button type="button" data-sugg="chercher">Chercher partout…</button>' +
' </div>' +
' <form class="draco-panneau-form draco-composer">' +
' <div class="draco-composer-pieces" hidden></div>' +
' <div class="draco-composer-ligne">' +
' <button type="button" class="composer-btn" data-comp="plus" title="Joindre une image ou un document" aria-label="Joindre une image ou un document"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>' +
' <textarea rows="1" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark"></textarea>' +
' <input type="file" accept="image/*,.pdf,.docx,.odt,.txt,.md,.html,.csv" multiple hidden>' +
' <button type="button" class="composer-btn" data-comp="micro" title="Dictée vocale" aria-label="Dictée vocale" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg></button>' +
' <button type="submit" class="composer-envoi" title="Envoyer" aria-label="Envoyer" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
' </div>' +
' <div class="draco-composer-enreg" hidden>' +
' <button type="button" class="composer-btn danger" data-comp="enreg-annuler" title="Annuler la dictée" aria-label="Annuler la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>' +
' <span class="enreg-temps">0:00</span>' +
' <div class="enreg-onde" aria-hidden="true"></div>' +
' <span class="enreg-texte"></span>' +
' <button type="button" class="composer-btn" data-comp="enreg-stop" title="Arrêter et corriger la transcription" aria-label="Arrêter la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/></svg></button>' +
' <button type="button" class="composer-envoi" data-comp="enreg-envoyer" title="Arrêter et envoyer" aria-label="Arrêter et envoyer"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
' </div>' +
' </form>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></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]");
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();
+1 -1
View File
@@ -653,7 +653,7 @@ function applyTheme(themeKey, mode) {
root.style.setProperty(key, vars[key]);
});
// #180 — the spreadsheet grid follows the active theme: derive its tokens
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
// imports) gets a matching grid instead of the hardcoded dark/light pair.
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
+3 -3
View File
@@ -2,7 +2,7 @@
Validates:
- Theme JSON structure validity
- All 15 built-in themes have required keys and valid CSS colors
- All 16 built-in themes have required keys and valid CSS colors
- High-contrast and sepia mode generators produce valid CSS vars
- Import/export round-trip
- AVAILABLE_MODES includes all 4 modes
@@ -80,9 +80,9 @@ class TestThemeStructure:
assert '_genSepia' in self.content
def test_theme_count(self):
"""Should have 15 built-in themes."""
"""Should have 16 built-in themes."""
names = _extract_theme_names(self.content)
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
def test_required_export_functions(self):
"""All required functions should be exported."""