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
bruno 27e7664ef0 feat(ui): dimensionnement de l'icone coffre-fort du noeud Secrets
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-28 08:24:23 -04:00
bruno ccbf1d3269 feat(ui): icone coffre-fort SVG pour le noeud Secrets (remplace le cadenas emoji) 2026-10-28 08:24:16 -04:00
bruno 0ca14739f9 Merge pull request 'fix(lint): tri des imports dans backend/routers/coffres.py (I001)' (#32) from fix/ruff-coffres-imports into main
CI / security (push) Successful in 1m56s
CI / lint (push) Successful in 3m14s
CI / test (push) Failing after 5m5s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:16:47 -04:00
bruno d9192ef355 fix(lint): tri des imports dans backend/routers/coffres.py (I001)
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m59s
2026-10-28 08:13:35 -04:00
bruno 4c9bfd576c Merge pull request 'chore(release): version 2.81.0 (Coffre Secrets Phases 2-5)' (#31) from fix/version-2.81.0 into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:16:49 -04:00
8 changed files with 431 additions and 48 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)."},
+1 -1
View File
@@ -34,10 +34,10 @@ from __future__ import annotations
import logging
import os
from backend.auth.middleware import require_admin, require_auth
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from backend.audit import get_recent_entries, log_coffre_event
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import (
CryptoIndisponibleError,
KEKAbsenteError,
+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();
+2 -2
View File
@@ -59,11 +59,11 @@ function _sharedIcon(size = 16) {
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
// ---------------------------------------------------------------------------
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
function _secretsIcon() {
const s = document.createElement("span");
s.className = "coffres-icon-lock";
s.textContent = "\u{1F510}";
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
return s;
}
+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
+2 -1
View File
@@ -14372,7 +14372,8 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
.coffres-alerts { margin: 12px 0; }
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
.coffres-icon-lock { font-size: 16px; line-height: 1; }
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
+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."""