mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 06:24:42 +02:00
Transforme le portail (tuiles ouvrant un lien) en mini-pages par service, avec état
de santé temps réel — comme demandé.
Page détail (clic sur une tuile) : icône+nom, pilule d'état (en ligne/dégradé/hors
ligne + latence), description, composants (pastilles), alertes actives, infos (URL),
bouton « Ouvrir ». Tuiles : pastille de santé live + badge global d'alertes.
- registry.py : Service enrichi (description, health_url, components[{name,prom}],
alerts_match) — toujours config-driven.
- health.py : StatusPoller en thread. Probe HTTP (up/down+latence) + Prometheus
/api/v1/query (composants via up{}/kube-state-metrics) + Alertmanager /api/v2/alerts
(hors Watchdog). Parallélisé (ThreadPoolExecutor), pousse portal-status au HUD.
- config.py : métadonnées homelab vérifiées en live (7 services ; traefik retiré —
URL injoignable) + section [portal] (intervalle, URLs, timeouts).
- app.py : démarre le poller, diffuse portal-status (+ dernier état au connect).
- HUD : vues liste⟷détail, pastilles, badge alertes, mise à jour live.
- bump 0.9.0 → 0.10.0.
Validé : StatusPoller en direct contre le homelab (cycle 0.12s, 7 services ok,
composants Prometheus résolus, 0 alerte) ; HUD via Playwright (pastilles ok/dégradé/
hors-ligne, page détail complète, alerte rendue, bouton Ouvrir → open-service).
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1673 lines
61 KiB
HTML
1673 lines
61 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||
<title>STT — Assistant vocal</title>
|
||
<link rel="icon" href="icon.svg" />
|
||
<style>
|
||
/* ============================================================
|
||
THÈME — variables CSS faciles à régler
|
||
L'intensité de l'accent rouge est pilotée par --accent-mix
|
||
(0 = doux au repos … 1 = rouge soutenu) via le slider Réglages.
|
||
============================================================ */
|
||
:root {
|
||
/* Surfaces */
|
||
--bg: #ffffff;
|
||
--bg-soft: #fbf7f8; /* fond légèrement rosé pour les zones douces */
|
||
--ink: #1c1b1d; /* texte principal */
|
||
--ink-soft: #8a8589; /* texte secondaire */
|
||
--line: #ececee; /* filets / bordures discrètes */
|
||
|
||
/* Accent rouge — deux pôles, l'app interpole entre les deux */
|
||
--accent-rest: #f0c9cf; /* rose-rouge doux (repos) */
|
||
--accent-active: #ff5d6c; /* rouge franc (états actifs) */
|
||
|
||
/* Couleur d'anneau effective au repos (recalculée par JS selon le slider) */
|
||
--ring-rest: #f0c9cf;
|
||
|
||
/* Statut connexion */
|
||
--ok: #2fbf71;
|
||
--down: #ff5d6c;
|
||
|
||
/* Métriques */
|
||
--avatar: clamp(180px, 26vmin, 300px);
|
||
--ring-gap: 14px;
|
||
--ease: cubic-bezier(.22,.61,.36,1);
|
||
|
||
color-scheme: light;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
margin: 0;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||
-webkit-font-smoothing: antialiased;
|
||
text-rendering: optimizeLegibility;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Focus visible accessible sur tous les contrôles */
|
||
:focus-visible {
|
||
outline: 3px solid var(--accent-active);
|
||
outline-offset: 3px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
/* ============================================================
|
||
MISE EN PAGE PRINCIPALE
|
||
============================================================ */
|
||
.stage {
|
||
position: relative;
|
||
height: 100dvh;
|
||
display: grid;
|
||
grid-template-rows: auto 1fr auto;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ---- Barre supérieure : connexion + engrenage ---- */
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 22px 26px;
|
||
z-index: 6;
|
||
}
|
||
.conn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
font-size: 12px;
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
user-select: none;
|
||
}
|
||
.conn .dot {
|
||
width: 9px; height: 9px;
|
||
border-radius: 50%;
|
||
background: var(--down);
|
||
box-shadow: 0 0 0 0 rgba(0,0,0,0);
|
||
transition: background .3s var(--ease);
|
||
}
|
||
.conn.is-on .dot {
|
||
background: var(--ok);
|
||
animation: connpulse 2.4s ease-in-out infinite;
|
||
}
|
||
@keyframes connpulse {
|
||
0%,100% { box-shadow: 0 0 0 0 rgba(47,191,113,.45); }
|
||
50% { box-shadow: 0 0 0 6px rgba(47,191,113,0); }
|
||
}
|
||
|
||
.icon-btn {
|
||
appearance: none;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg);
|
||
color: var(--ink-soft);
|
||
width: 42px; height: 42px;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
cursor: pointer;
|
||
transition: transform .35s var(--ease), color .25s var(--ease), border-color .25s var(--ease), box-shadow .25s var(--ease);
|
||
}
|
||
.icon-btn:hover {
|
||
color: var(--ink);
|
||
border-color: var(--accent-rest);
|
||
box-shadow: 0 6px 20px rgba(255,93,108,.12);
|
||
}
|
||
.icon-btn svg { width: 20px; height: 20px; display: block; }
|
||
.icon-btn.gear:hover svg { transform: rotate(40deg); transition: transform .5s var(--ease); }
|
||
|
||
/* Groupe de contrôles à droite (stop · micro · réglages) */
|
||
.controls { display: inline-flex; align-items: center; gap: 10px; }
|
||
|
||
/* Bouton « stop » : rouge franc au survol (action d'interruption) */
|
||
.icon-btn.stop:hover {
|
||
color: #fff;
|
||
background: var(--accent-active);
|
||
border-color: var(--accent-active);
|
||
}
|
||
|
||
/* Bouton micro : on n'affiche qu'une des deux icônes selon l'état */
|
||
.icon-btn.mic .mic-off { display: none; }
|
||
.icon-btn.mic.is-muted .mic-on { display: none; }
|
||
.icon-btn.mic.is-muted .mic-off { display: block; }
|
||
/* Micro coupé : état « actif/alerte » rouge soutenu, pour qu'on le voie d'un coup d'œil */
|
||
.icon-btn.mic.is-muted {
|
||
color: #fff;
|
||
background: var(--accent-active);
|
||
border-color: var(--accent-active);
|
||
box-shadow: 0 6px 20px rgba(255,93,108,.28);
|
||
}
|
||
.icon-btn.mic.is-muted:hover { filter: brightness(1.05); }
|
||
|
||
/* ---- Centre : avatar + anneau + label ---- */
|
||
.center {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 30px;
|
||
padding: 10px 24px 0;
|
||
min-height: 0;
|
||
}
|
||
|
||
.avatar-wrap {
|
||
position: relative;
|
||
width: var(--avatar);
|
||
height: var(--avatar);
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
|
||
/* Le disque portrait */
|
||
.portrait {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
background:
|
||
radial-gradient(120% 120% at 50% 18%, #fff 0%, var(--bg-soft) 70%);
|
||
box-shadow:
|
||
0 18px 50px -18px rgba(40,20,24,.28),
|
||
inset 0 0 0 1px rgba(0,0,0,.02);
|
||
transition: transform .6s var(--ease), filter .4s var(--ease);
|
||
z-index: 2;
|
||
will-change: transform;
|
||
}
|
||
.portrait img {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
opacity: 0;
|
||
transition: opacity .55s var(--ease);
|
||
}
|
||
.portrait img.show { opacity: 1; }
|
||
|
||
/* Repli propre quand aucune image n'est chargée : initiale douce */
|
||
.portrait .fallback {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: calc(var(--avatar) * .34);
|
||
font-weight: 300;
|
||
letter-spacing: -.02em;
|
||
color: var(--accent-rest);
|
||
opacity: 1;
|
||
transition: opacity .4s var(--ease);
|
||
}
|
||
.portrait.has-img .fallback { opacity: 0; }
|
||
|
||
/* Anneau fin réactif — dessiné en SVG (cercle) pour des transitions nettes */
|
||
.ring {
|
||
position: absolute;
|
||
inset: calc(var(--ring-gap) * -1);
|
||
z-index: 1;
|
||
pointer-events: none;
|
||
}
|
||
.ring svg { width: 100%; height: 100%; display: block; transform: rotate(-90deg); }
|
||
.ring circle {
|
||
fill: none;
|
||
stroke: var(--ring-rest);
|
||
stroke-width: 3;
|
||
transition: stroke .4s var(--ease), stroke-width .4s var(--ease);
|
||
}
|
||
|
||
/* Couche « ping » pour l'écoute */
|
||
.ping {
|
||
position: absolute;
|
||
inset: calc(var(--ring-gap) * -1);
|
||
border-radius: 50%;
|
||
border: 2px solid var(--accent-active);
|
||
opacity: 0;
|
||
z-index: 0;
|
||
}
|
||
|
||
/* Couche spinner pour la réflexion (arc qui tourne) */
|
||
.spinner {
|
||
position: absolute;
|
||
inset: calc(var(--ring-gap) * -1);
|
||
z-index: 3;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity .4s var(--ease);
|
||
}
|
||
.spinner svg { width: 100%; height: 100%; display: block; }
|
||
.spinner circle {
|
||
fill: none;
|
||
stroke: var(--accent-active);
|
||
stroke-width: 3;
|
||
stroke-linecap: round;
|
||
transform-origin: 50% 50%;
|
||
}
|
||
|
||
/* Label d'état */
|
||
.state-label {
|
||
font-size: 13px;
|
||
letter-spacing: .42em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-soft);
|
||
padding-left: .42em; /* compense le letter-spacing pour rester centré */
|
||
transition: color .4s var(--ease);
|
||
min-height: 1.2em;
|
||
}
|
||
|
||
/* ---- États appliqués sur .stage[data-state] ---- */
|
||
|
||
/* idle : respiration douce, anneau au repos */
|
||
.stage[data-state="idle"] .portrait { animation: breathe 6s ease-in-out infinite; }
|
||
@keyframes breathe {
|
||
0%,100% { transform: translateY(0) scale(1); }
|
||
50% { transform: translateY(-7px) scale(1.012); }
|
||
}
|
||
|
||
/* listening : zoom léger, anneau rouge franc, ping */
|
||
.stage[data-state="listening"] .portrait { transform: scale(1.04); }
|
||
.stage[data-state="listening"] .ring circle { stroke: var(--accent-active); stroke-width: 4; }
|
||
.stage[data-state="listening"] .ping { animation: ping 1.6s var(--ease) infinite; }
|
||
.stage[data-state="listening"] .state-label { color: var(--accent-active); }
|
||
@keyframes ping {
|
||
0% { transform: scale(.98); opacity: .55; }
|
||
80% { transform: scale(1.22); opacity: 0; }
|
||
100% { transform: scale(1.22); opacity: 0; }
|
||
}
|
||
|
||
/* thinking : spinner qui tourne */
|
||
.stage[data-state="thinking"] .ring circle { stroke: color-mix(in srgb, var(--accent-active) 30%, var(--ring-rest)); }
|
||
.stage[data-state="thinking"] .spinner { opacity: 1; }
|
||
.stage[data-state="thinking"] .spinner circle { animation: spin 1.1s linear infinite; }
|
||
.stage[data-state="thinking"] .state-label { color: var(--ink-soft); }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* speaking : anneau rouge, légère saturation, pulsation lente */
|
||
.stage[data-state="speaking"] .ring circle { stroke: var(--accent-active); stroke-width: 4; }
|
||
.stage[data-state="speaking"] .portrait { filter: saturate(1.12); animation: speak 1.4s ease-in-out infinite; }
|
||
.stage[data-state="speaking"] .state-label { color: var(--accent-active); }
|
||
@keyframes speak {
|
||
0%,100% { transform: scale(1); }
|
||
50% { transform: scale(1.025); }
|
||
}
|
||
|
||
/* ============================================================
|
||
TRANSCRIPT
|
||
============================================================ */
|
||
.transcript-wrap {
|
||
position: relative;
|
||
min-height: 0;
|
||
}
|
||
.transcript {
|
||
max-height: min(38vh, 360px);
|
||
overflow-y: auto;
|
||
padding: 40px 26px 26px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
max-width: 860px;
|
||
margin: 0 auto;
|
||
scroll-behavior: smooth;
|
||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 46px);
|
||
mask-image: linear-gradient(to bottom, transparent 0, #000 46px);
|
||
}
|
||
.transcript::-webkit-scrollbar { width: 8px; }
|
||
.transcript::-webkit-scrollbar-thumb { background: var(--line); border-radius: 8px; }
|
||
|
||
.bubble {
|
||
max-width: 76%;
|
||
padding: 12px 16px;
|
||
border-radius: 18px;
|
||
font-size: 16px;
|
||
line-height: 1.5;
|
||
animation: pop .35s var(--ease) both;
|
||
}
|
||
@keyframes pop {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.bubble.user {
|
||
align-self: flex-end;
|
||
background: var(--accent-rest);
|
||
color: #4a1f25;
|
||
border-bottom-right-radius: 6px;
|
||
}
|
||
.bubble.assistant {
|
||
align-self: flex-start;
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-bottom-left-radius: 6px;
|
||
}
|
||
.bubble .tag {
|
||
display: inline-block;
|
||
margin-top: 8px;
|
||
font-size: 11px;
|
||
letter-spacing: .05em;
|
||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||
color: var(--ink-soft);
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
padding: 2px 9px;
|
||
}
|
||
.bubble.error {
|
||
align-self: center;
|
||
background: #fff0f1;
|
||
border: 1px solid #ffd5da;
|
||
color: #b22330;
|
||
font-size: 14px;
|
||
text-align: center;
|
||
max-width: 90%;
|
||
}
|
||
|
||
/* ============================================================
|
||
COMPOSER — entrée texte (alternative au vocal)
|
||
============================================================ */
|
||
.composer {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: flex-end;
|
||
width: 100%;
|
||
max-width: 860px;
|
||
margin: 8px auto 0;
|
||
padding: 0 26px 4px;
|
||
}
|
||
.composer textarea {
|
||
flex: 1 1 auto;
|
||
resize: none;
|
||
min-height: 46px;
|
||
max-height: 140px;
|
||
padding: 12px 16px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 16px;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
font: inherit;
|
||
font-size: 15px;
|
||
line-height: 1.4;
|
||
outline: none;
|
||
transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
|
||
}
|
||
.composer textarea::placeholder { color: var(--ink-soft); }
|
||
.composer textarea:focus {
|
||
border-color: var(--accent-active);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-active) 22%, transparent);
|
||
}
|
||
.composer .send {
|
||
flex: 0 0 auto;
|
||
height: 46px;
|
||
padding: 0 18px;
|
||
border: none;
|
||
border-radius: 16px;
|
||
background: var(--accent-active);
|
||
color: #fff;
|
||
font: inherit;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
transition: filter .15s var(--ease), transform .1s var(--ease);
|
||
}
|
||
.composer .send:hover { filter: brightness(1.05); }
|
||
.composer .send:active { transform: translateY(1px); }
|
||
|
||
/* ============================================================
|
||
PANNEAU RÉGLAGES (drawer + voile)
|
||
============================================================ */
|
||
.scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(28,20,22,.28);
|
||
backdrop-filter: blur(2px);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity .35s var(--ease), visibility .35s var(--ease);
|
||
z-index: 20;
|
||
}
|
||
.scrim.open { opacity: 1; visibility: visible; }
|
||
|
||
.drawer {
|
||
position: fixed;
|
||
top: 0; right: 0;
|
||
height: 100dvh;
|
||
width: min(420px, 92vw);
|
||
background: var(--bg);
|
||
box-shadow: -24px 0 60px -30px rgba(40,20,24,.5);
|
||
transform: translateX(100%);
|
||
transition: transform .42s var(--ease);
|
||
z-index: 21;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.drawer.open { transform: translateX(0); }
|
||
|
||
.drawer header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 22px 24px 16px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.drawer header h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
letter-spacing: -.01em;
|
||
}
|
||
.drawer .body {
|
||
padding: 8px 24px 28px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.field { padding: 18px 0; border-bottom: 1px solid var(--line); }
|
||
.field:last-child { border-bottom: none; }
|
||
.field > label,
|
||
.field > .flabel {
|
||
display: block;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
.field .hint {
|
||
font-size: 12.5px;
|
||
color: var(--ink-soft);
|
||
margin: 0 0 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
/* Contrôles de formulaire */
|
||
input[type="text"],
|
||
select {
|
||
width: 100%;
|
||
font: inherit;
|
||
font-size: 15px;
|
||
color: var(--ink);
|
||
background: var(--bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 12px;
|
||
padding: 11px 13px;
|
||
transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
|
||
}
|
||
input[type="text"]:focus,
|
||
select:focus {
|
||
border-color: var(--accent-active);
|
||
box-shadow: 0 0 0 4px rgba(255,93,108,.14);
|
||
outline: none;
|
||
}
|
||
|
||
/* Slider intensité */
|
||
.intensity {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.swatch {
|
||
width: 34px; height: 34px;
|
||
border-radius: 50%;
|
||
flex: none;
|
||
border: 1px solid var(--line);
|
||
background: var(--ring-rest);
|
||
transition: background .25s var(--ease);
|
||
}
|
||
input[type="range"] {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
flex: 1;
|
||
height: 6px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(to right, var(--accent-rest), var(--accent-active));
|
||
cursor: pointer;
|
||
}
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
width: 22px; height: 22px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
border: 2px solid var(--accent-active);
|
||
box-shadow: 0 2px 8px rgba(255,93,108,.4);
|
||
cursor: pointer;
|
||
}
|
||
input[type="range"]::-moz-range-thumb {
|
||
width: 22px; height: 22px;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
border: 2px solid var(--accent-active);
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Segmented control (mode cerveau) */
|
||
.segmented {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 4px;
|
||
background: var(--bg-soft);
|
||
border: 1px solid var(--line);
|
||
border-radius: 14px;
|
||
padding: 4px;
|
||
}
|
||
.segmented button {
|
||
appearance: none;
|
||
border: none;
|
||
background: transparent;
|
||
font: inherit;
|
||
font-size: 13.5px;
|
||
color: var(--ink-soft);
|
||
padding: 10px 8px;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
line-height: 1.2;
|
||
transition: background .25s var(--ease), color .25s var(--ease), box-shadow .25s var(--ease);
|
||
}
|
||
.segmented button .sub { display: block; font-size: 11px; opacity: .8; margin-top: 2px; }
|
||
.segmented button[aria-pressed="true"] {
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
box-shadow: 0 2px 8px rgba(40,20,24,.1);
|
||
}
|
||
|
||
/* Ligne avec interrupteur */
|
||
.row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
.row .txt { flex: 1; }
|
||
.row .flabel { margin-bottom: 2px; }
|
||
|
||
/* Toggle switch */
|
||
.switch { position: relative; display: inline-block; width: 50px; height: 30px; flex: none; }
|
||
.switch input { opacity: 0; width: 0; height: 0; }
|
||
.switch .track {
|
||
position: absolute; inset: 0;
|
||
background: var(--line);
|
||
border-radius: 999px;
|
||
transition: background .25s var(--ease);
|
||
}
|
||
.switch .track::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 3px; left: 3px;
|
||
width: 24px; height: 24px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,.25);
|
||
transition: transform .25s var(--ease);
|
||
}
|
||
.switch input:checked + .track { background: var(--accent-active); }
|
||
.switch input:checked + .track::before { transform: translateX(20px); }
|
||
.switch input:focus-visible + .track { outline: 3px solid var(--accent-active); outline-offset: 2px; }
|
||
|
||
/* Bouton plein / réinitialiser */
|
||
.btn {
|
||
width: 100%;
|
||
appearance: none;
|
||
font: inherit;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
padding: 13px;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
transition: background .2s var(--ease), border-color .2s var(--ease), color .2s var(--ease);
|
||
}
|
||
.btn.danger { color: #b22330; border-color: #ffd5da; }
|
||
.btn.danger:hover { background: #fff0f1; }
|
||
|
||
.saved-hint {
|
||
font-size: 12px;
|
||
color: var(--ok);
|
||
text-align: center;
|
||
height: 16px;
|
||
opacity: 0;
|
||
transition: opacity .3s var(--ease);
|
||
}
|
||
.saved-hint.show { opacity: 1; }
|
||
|
||
/* ============================================================
|
||
PORTAIL — grille de tuiles de services + toast
|
||
============================================================ */
|
||
.svc-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
.svc-tile {
|
||
appearance: none;
|
||
font: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--line);
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
transition: border-color .2s var(--ease), box-shadow .2s var(--ease), transform .12s var(--ease);
|
||
}
|
||
.svc-tile:hover {
|
||
border-color: var(--accent-active);
|
||
box-shadow: 0 6px 18px -10px rgba(255,93,108,.5);
|
||
}
|
||
.svc-tile:active { transform: scale(.97); }
|
||
.svc-tile:focus-visible {
|
||
outline: none;
|
||
border-color: var(--accent-active);
|
||
box-shadow: 0 0 0 4px rgba(255,93,108,.14);
|
||
}
|
||
.svc-tile .svc-ico {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
flex: none;
|
||
width: 34px; height: 34px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 10px;
|
||
background: var(--bg-soft);
|
||
}
|
||
.svc-tile .svc-meta { min-width: 0; flex: 1; }
|
||
.svc-tile .svc-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Pastille de santé (tuile + pilule détail). Couleurs par état. */
|
||
.svc-dot {
|
||
width: 9px; height: 9px; border-radius: 50%;
|
||
flex: none; background: var(--ink-soft);
|
||
box-shadow: 0 0 0 3px rgba(0,0,0,.04);
|
||
}
|
||
.svc-tile .svc-dot { margin-left: auto; }
|
||
[data-health="ok"] .svc-dot, .svc-dot[data-health="ok"] { background: #2bb673; }
|
||
[data-health="degraded"] .svc-dot, .svc-dot[data-health="degraded"] { background: #e8a13a; }
|
||
[data-health="down"] .svc-dot, .svc-dot[data-health="down"] { background: #e0556b; }
|
||
[data-health="unknown"] .svc-dot, .svc-dot[data-health="unknown"] { background: #c2bcbe; }
|
||
|
||
/* ============================================================
|
||
PORTAIL — vue détail (« page » par service)
|
||
============================================================ */
|
||
.svc-back {
|
||
appearance: none; border: none; background: none; cursor: pointer;
|
||
font: inherit; font-size: 13.5px; font-weight: 600;
|
||
color: var(--ink-soft); padding: 0 0 14px; display: inline-flex; align-items: center;
|
||
}
|
||
.svc-back:hover { color: var(--accent-active); }
|
||
.svc-detail-head { display: flex; align-items: center; gap: 14px; margin-bottom: 4px; }
|
||
.svc-detail-ico {
|
||
font-size: 30px; line-height: 1; flex: none;
|
||
width: 52px; height: 52px; display: grid; place-items: center;
|
||
border-radius: 14px; background: var(--bg-soft);
|
||
}
|
||
.svc-detail-title h3 { margin: 0 0 4px; font-size: 19px; font-weight: 650; }
|
||
.svc-pill {
|
||
display: inline-flex; align-items: center; gap: 0;
|
||
font-size: 12.5px; font-weight: 600; color: var(--ink-soft);
|
||
}
|
||
.svc-pill[data-health="ok"] { color: #2bb673; }
|
||
.svc-pill[data-health="degraded"] { color: #c5841f; }
|
||
.svc-pill[data-health="down"] { color: #d23a52; }
|
||
.svc-detail-desc { margin: 14px 0 4px; font-size: 13.5px; line-height: 1.5; }
|
||
.svc-section { padding: 16px 0; border-bottom: 1px solid var(--line); }
|
||
.svc-section .flabel { display: block; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-soft); margin-bottom: 10px; }
|
||
.svc-row {
|
||
display: flex; align-items: center; gap: 10px;
|
||
font-size: 13.5px; padding: 5px 0;
|
||
}
|
||
.svc-row .svc-row-name { flex: 1; min-width: 0; }
|
||
.svc-row .svc-state { font-size: 12px; font-weight: 600; color: var(--ink-soft); }
|
||
.svc-alert-row {
|
||
display: flex; gap: 8px; align-items: baseline;
|
||
padding: 8px 10px; margin-bottom: 6px; border-radius: 10px;
|
||
background: #fff3f4; border: 1px solid #ffd9de; font-size: 13px;
|
||
}
|
||
.svc-alert-row .sev { font-weight: 700; text-transform: uppercase; font-size: 10.5px; letter-spacing: .04em; flex: none; }
|
||
.svc-alert-row.sev-warning { background: #fff7ec; border-color: #ffe2b8; }
|
||
.svc-alert-none { font-size: 13px; color: var(--ink-soft); }
|
||
.svc-info { font-size: 13px; color: var(--ink-soft); line-height: 1.6; word-break: break-all; }
|
||
.svc-info a { color: var(--accent-active); text-decoration: none; }
|
||
.svc-open-btn { margin-top: 18px; }
|
||
|
||
/* Badge alertes global (en tête de la liste) */
|
||
.svc-alert-badge {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 10px 14px; margin-bottom: 14px;
|
||
border-radius: 12px; background: #fff3f4; border: 1px solid #ffd9de;
|
||
font-size: 13px; font-weight: 600; color: #c0344a;
|
||
}
|
||
|
||
/* Actions de service (redémarrer / mettre à jour / arrêter) */
|
||
.svc-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.svc-actions .btn { width: auto; font-size: 14px; padding: 11px 10px; }
|
||
.svc-actions .btn#svcStop { grid-column: 1 / -1; } /* Arrêter sur toute la largeur */
|
||
|
||
/* Toast — confirmation éphémère (clic / voix) */
|
||
.toast {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: 28px;
|
||
transform: translate(-50%, 16px);
|
||
background: var(--ink);
|
||
color: var(--bg);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
padding: 12px 18px;
|
||
border-radius: 999px;
|
||
box-shadow: 0 10px 30px -12px rgba(40,20,24,.6);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: opacity .25s var(--ease), transform .25s var(--ease), visibility .25s var(--ease);
|
||
z-index: 40;
|
||
pointer-events: none;
|
||
}
|
||
.toast.show { opacity: 1; visibility: visible; transform: translate(-50%, 0); }
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
* { animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
|
||
}
|
||
|
||
/* Petits écrans : on resserre sans casser */
|
||
@media (max-width: 520px) {
|
||
.topbar { padding: 16px 16px; }
|
||
.transcript { padding: 36px 14px 16px; }
|
||
.bubble { max-width: 86%; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- ===================== ÉCRAN PRINCIPAL (HUD) ===================== -->
|
||
<main class="stage" data-state="idle">
|
||
|
||
<!-- Barre supérieure -->
|
||
<div class="topbar">
|
||
<div class="conn" id="conn" aria-live="polite">
|
||
<span class="dot" aria-hidden="true"></span>
|
||
<span class="conn-txt">déconnecté</span>
|
||
</div>
|
||
<div class="controls">
|
||
<!-- Couper la parole de l'IA (stop la lecture/le tour en cours) -->
|
||
<button class="icon-btn stop" id="stopBtn" aria-label="Stopper la réponse" title="Stopper la réponse de l'IA">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<rect x="7" y="7" width="10" height="10" rx="2"></rect>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Couper / réactiver le micro -->
|
||
<button class="icon-btn mic" id="micBtn" aria-label="Couper le micro" aria-pressed="false" title="Couper le micro">
|
||
<!-- micro actif -->
|
||
<svg class="mic-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<rect x="9" y="3" width="6" height="11" rx="3"></rect>
|
||
<path d="M5 11a7 7 0 0 0 14 0"></path>
|
||
<path d="M12 18v3"></path>
|
||
</svg>
|
||
<!-- micro coupé (barré) -->
|
||
<svg class="mic-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="M9 9V6a3 3 0 0 1 5.12-2.12"></path>
|
||
<path d="M15 11.3V6"></path>
|
||
<path d="M5 11a7 7 0 0 0 10.7 5.98"></path>
|
||
<path d="M19 11a6.97 6.97 0 0 1-.42 2.4"></path>
|
||
<path d="M12 18v3"></path>
|
||
<path d="M3 3l18 18"></path>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Portail : ouvrir le panneau des services du homelab -->
|
||
<button class="icon-btn services" id="openServices" aria-label="Ouvrir les services" title="Services du homelab">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<rect x="4" y="4" width="6" height="6" rx="1.4"></rect>
|
||
<rect x="14" y="4" width="6" height="6" rx="1.4"></rect>
|
||
<rect x="4" y="14" width="6" height="6" rx="1.4"></rect>
|
||
<rect x="14" y="14" width="6" height="6" rx="1.4"></rect>
|
||
</svg>
|
||
</button>
|
||
|
||
<button class="icon-btn gear" id="openSettings" aria-label="Ouvrir les réglages" title="Réglages">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="3.2"></circle>
|
||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Centre : avatar -->
|
||
<div class="center">
|
||
<div class="avatar-wrap">
|
||
<!-- couche ping (écoute) -->
|
||
<span class="ping" aria-hidden="true"></span>
|
||
|
||
<!-- anneau fin de base -->
|
||
<div class="ring" aria-hidden="true">
|
||
<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="48.5"></circle></svg>
|
||
</div>
|
||
|
||
<!-- spinner (réflexion) : arc de ~25% -->
|
||
<div class="spinner" aria-hidden="true">
|
||
<svg viewBox="0 0 100 100">
|
||
<circle cx="50" cy="50" r="48.5" pathLength="100" stroke-dasharray="26 100"></circle>
|
||
</svg>
|
||
</div>
|
||
|
||
<!-- portrait : 3 images empilées + repli -->
|
||
<div class="portrait" id="portrait">
|
||
<span class="fallback" id="fallback" aria-hidden="true">あ</span>
|
||
<img id="img-idle" alt="" data-key="idle" />
|
||
<img id="img-think" alt="" data-key="think" />
|
||
<img id="img-speak" alt="" data-key="speak" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="state-label" id="stateLabel" role="status" aria-live="polite">veille</div>
|
||
</div>
|
||
|
||
<!-- Transcript -->
|
||
<div class="transcript-wrap">
|
||
<div class="transcript" id="transcript" aria-label="Conversation" role="log"></div>
|
||
</div>
|
||
|
||
<!-- Composer : entrée texte (alternative au vocal) -->
|
||
<form class="composer" id="composer" autocomplete="off">
|
||
<textarea id="textInput" rows="1" placeholder="Écris un message à Hermès… (Entrée pour envoyer, Maj+Entrée = nouvelle ligne)" aria-label="Message texte"></textarea>
|
||
<button type="submit" class="send" id="sendBtn" aria-label="Envoyer le message">
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"></path>
|
||
</svg>
|
||
Envoyer
|
||
</button>
|
||
</form>
|
||
</main>
|
||
|
||
<!-- ===================== RÉGLAGES (voile + drawer) ===================== -->
|
||
<div class="scrim" id="scrim"></div>
|
||
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="settingsTitle" aria-hidden="true">
|
||
<header>
|
||
<h2 id="settingsTitle">Réglages</h2>
|
||
<button class="icon-btn" id="closeSettings" aria-label="Fermer les réglages" title="Fermer">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||
<path d="M6 6l12 12M18 6L6 18"></path>
|
||
</svg>
|
||
</button>
|
||
</header>
|
||
|
||
<div class="body">
|
||
|
||
<!-- Intensité de l'accent -->
|
||
<div class="field">
|
||
<label for="intensity" class="flabel">Intensité de l'accent</label>
|
||
<p class="hint">Du rose discret au rouge soutenu.</p>
|
||
<div class="intensity">
|
||
<span class="swatch" id="swatch" aria-hidden="true"></span>
|
||
<input type="range" id="intensity" min="0" max="100" value="50" aria-label="Intensité de l'accent rouge" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Avatar -->
|
||
<div class="field">
|
||
<label for="charName" class="flabel">Avatar</label>
|
||
<p class="hint">Nom du personnage et jeu d'images (asa-idle / think / speak).</p>
|
||
<input type="text" id="charName" value="Asa" placeholder="Nom du personnage" />
|
||
<div style="height:10px"></div>
|
||
<select id="avatarSet" aria-label="Jeu d'images de l'avatar">
|
||
<option value="asa">Asa (asa-*.png)</option>
|
||
<option value="mira">Mira (mira-*.png)</option>
|
||
<option value="kai">Kai (kai-*.png)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Voix TTS -->
|
||
<div class="field">
|
||
<label for="voice" class="flabel">Voix de synthèse</label>
|
||
<p class="hint">Voix utilisée pour la lecture des réponses.</p>
|
||
<select id="voice">
|
||
<option>Aurélie — FR douce</option>
|
||
<option>Léo — FR neutre</option>
|
||
<option>Nова — FR vive</option>
|
||
<option>Claude — EN calme</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Mot de réveil -->
|
||
<div class="field">
|
||
<label for="wakeword" class="flabel">Mot de réveil</label>
|
||
<p class="hint">Mot-clé qui active l'écoute.</p>
|
||
<input type="text" id="wakeword" value="Dis Asa" placeholder="ex. Dis Asa" />
|
||
</div>
|
||
|
||
<!-- Mode cerveau -->
|
||
<div class="field">
|
||
<span class="flabel">Mode cerveau</span>
|
||
<p class="hint">Modèle utilisé pour générer les réponses.</p>
|
||
<div class="segmented" role="group" aria-label="Mode cerveau">
|
||
<button id="brainLocal" aria-pressed="true">
|
||
Local
|
||
<span class="sub">qwen3-8b · gratuit</span>
|
||
</button>
|
||
<button id="brainAdv" aria-pressed="false">
|
||
Avancé
|
||
<span class="sub">claude · payant</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- No-think -->
|
||
<div class="field">
|
||
<div class="row">
|
||
<div class="txt">
|
||
<span class="flabel">Réduction du raisonnement</span>
|
||
<p class="hint" style="margin-bottom:0">Réponses plus rapides, moins de « réflexion » (no-think).</p>
|
||
</div>
|
||
<label class="switch">
|
||
<input type="checkbox" id="nothink" aria-label="Réduction du raisonnement" />
|
||
<span class="track" aria-hidden="true"></span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Réinitialiser -->
|
||
<div class="field">
|
||
<button class="btn danger" id="resetConv">Réinitialiser la conversation</button>
|
||
<div class="saved-hint" id="savedHint">Réglages envoyés ✓</div>
|
||
</div>
|
||
|
||
<!-- Service (systemd --user, sur cette machine) -->
|
||
<div class="field">
|
||
<span class="flabel">Service</span>
|
||
<p class="hint">Gérer le service STT sur cette machine (systemd --user).</p>
|
||
<div class="svc-actions">
|
||
<button class="btn" id="svcRestart" title="Recharge la config : modèle ASR, wake word…">↻ Redémarrer</button>
|
||
<button class="btn" id="svcUpdate" title="Met à jour STT puis redémarre">⬆ Mettre à jour</button>
|
||
<button class="btn danger" id="svcStop" title="Arrête le service">⏻ Arrêter</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ===================== SERVICES (réutilise le voile #scrim) ===================== -->
|
||
<aside class="drawer" id="servicesDrawer" role="dialog" aria-modal="true" aria-labelledby="servicesTitle" aria-hidden="true">
|
||
<header>
|
||
<h2 id="servicesTitle">Services</h2>
|
||
<button class="icon-btn" id="closeServices" aria-label="Fermer les services" title="Fermer">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
||
<path d="M6 6l12 12M18 6L6 18"></path>
|
||
</svg>
|
||
</button>
|
||
</header>
|
||
<div class="body">
|
||
<!-- Vue liste -->
|
||
<div id="svcHome">
|
||
<div class="svc-alert-badge" id="svcAlertBadge" hidden></div>
|
||
<p class="hint" style="margin:6px 0 16px">Choisis un service pour voir son état et l'ouvrir.</p>
|
||
<div class="svc-grid" id="svcGrid" role="list"></div>
|
||
<p class="svc-empty hint" id="svcEmpty" hidden>Aucun service configuré (voir <code>[[services]]</code> dans stt.toml).</p>
|
||
</div>
|
||
|
||
<!-- Vue détail (une « page » par service) -->
|
||
<div id="svcDetail" hidden>
|
||
<button class="svc-back" id="svcBack">← Tous les services</button>
|
||
<div class="svc-detail-head">
|
||
<span class="svc-ico svc-detail-ico" id="svcDetailIco" aria-hidden="true">🔗</span>
|
||
<div class="svc-detail-title">
|
||
<h3 id="svcDetailName">Service</h3>
|
||
<span class="svc-pill" id="svcDetailPill" data-health="unknown">● Inconnu</span>
|
||
</div>
|
||
</div>
|
||
<p class="svc-detail-desc hint" id="svcDetailDesc"></p>
|
||
|
||
<div class="svc-section" id="svcDetailCompWrap" hidden>
|
||
<span class="flabel">Composants</span>
|
||
<div id="svcCompList"></div>
|
||
</div>
|
||
|
||
<div class="svc-section" id="svcDetailAlertsWrap">
|
||
<span class="flabel">Alertes</span>
|
||
<div id="svcAlertList"></div>
|
||
</div>
|
||
|
||
<div class="svc-section">
|
||
<span class="flabel">Infos</span>
|
||
<div class="svc-info" id="svcInfo"></div>
|
||
</div>
|
||
|
||
<button class="btn svc-open-btn" id="svcOpenBtn">↗ Ouvrir le service</button>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- Toast (confirmation d'ouverture, clic ou voix) -->
|
||
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
||
|
||
<script>
|
||
/* ============================================================================
|
||
STT — HUD assistant vocal
|
||
- Mode DÉMO (DEMO=true) : simule une boucle d'événements pour visualiser l'UI.
|
||
- Sinon : se connecte au WebSocket ws://<hostname>:9300 et réagit aux JSON reçus.
|
||
- Le panneau Réglages envoie {"type":"settings","data":{...}} au backend
|
||
(le serveur applique reset / mode cerveau / mot de réveil — voir stt/ui/app.py).
|
||
============================================================================ */
|
||
|
||
/* ----- Configuration ----- */
|
||
const DEMO = false; // true = prévisualiser l'UI sans backend (boucle démo)
|
||
const WS_PORT = 9300;
|
||
const WS_URL = `ws://${location.hostname || 'localhost'}:${WS_PORT}`;
|
||
const LS_KEY = 'stt-hud-settings';
|
||
|
||
/* ----- Références DOM ----- */
|
||
const stage = document.querySelector('.stage');
|
||
const stateLabel = document.getElementById('stateLabel');
|
||
const transcript = document.getElementById('transcript');
|
||
const conn = document.getElementById('conn');
|
||
const connTxt = conn.querySelector('.conn-txt');
|
||
const portrait = document.getElementById('portrait');
|
||
const imgs = {
|
||
idle: document.getElementById('img-idle'),
|
||
think: document.getElementById('img-think'),
|
||
speak: document.getElementById('img-speak'),
|
||
};
|
||
|
||
/* Libellés d'état FR */
|
||
const STATE_LABELS = {
|
||
idle: 'veille',
|
||
listening: 'écoute',
|
||
thinking: 'réflexion',
|
||
speaking: 'réponse',
|
||
};
|
||
/* Quelle image montrer selon l'état (idle + listening → idle) */
|
||
const STATE_IMG = {
|
||
idle: 'idle', listening: 'idle', thinking: 'think', speaking: 'speak',
|
||
};
|
||
|
||
/* ============================================================================
|
||
GESTION DE L'AVATAR (3 images + crossfade + repli si manquante)
|
||
============================================================================ */
|
||
let avatarPrefix = 'asa';
|
||
|
||
function loadAvatar(prefix) {
|
||
avatarPrefix = prefix;
|
||
const map = { idle: `avatars/${prefix}-idle.png`, think: `avatars/${prefix}-think.png`, speak: `avatars/${prefix}-speak.png` };
|
||
for (const key in imgs) {
|
||
const el = imgs[key];
|
||
el.classList.remove('loaded', 'show');
|
||
el.onload = () => { el.classList.add('loaded'); refreshAvatar(); };
|
||
el.onerror = () => { el.classList.remove('loaded'); el.removeAttribute('src'); refreshAvatar(); }; // dégradation propre
|
||
el.src = map[key];
|
||
}
|
||
}
|
||
|
||
/* Affiche l'image correspondant à l'état courant si elle est chargée */
|
||
function refreshAvatar() {
|
||
const want = STATE_IMG[currentState] || 'idle';
|
||
let anyLoaded = false;
|
||
for (const key in imgs) {
|
||
const el = imgs[key];
|
||
const ok = el.classList.contains('loaded');
|
||
if (ok) anyLoaded = true;
|
||
el.classList.toggle('show', ok && key === want);
|
||
}
|
||
// si l'image voulue manque mais qu'une autre existe, on retombe sur idle
|
||
if (anyLoaded && !imgs[want].classList.contains('loaded') && imgs.idle.classList.contains('loaded')) {
|
||
imgs.idle.classList.add('show');
|
||
}
|
||
portrait.classList.toggle('has-img', anyLoaded);
|
||
}
|
||
|
||
/* ============================================================================
|
||
MACHINE À ÉTATS
|
||
============================================================================ */
|
||
let currentState = 'idle';
|
||
function setState(s) {
|
||
if (!STATE_LABELS[s]) return;
|
||
currentState = s;
|
||
stage.dataset.state = s;
|
||
stateLabel.textContent = STATE_LABELS[s];
|
||
refreshAvatar();
|
||
}
|
||
|
||
/* ============================================================================
|
||
TRANSCRIPT
|
||
============================================================================ */
|
||
function addBubble(kind, text, modelTag) {
|
||
const b = document.createElement('div');
|
||
b.className = `bubble ${kind}`;
|
||
b.textContent = text;
|
||
if (kind === 'assistant' && modelTag) {
|
||
const tag = document.createElement('span');
|
||
tag.className = 'tag';
|
||
tag.textContent = modelTag;
|
||
b.appendChild(document.createElement('br'));
|
||
b.appendChild(tag);
|
||
}
|
||
transcript.appendChild(b);
|
||
// auto-scroll vers le bas (sans scrollIntoView)
|
||
transcript.scrollTop = transcript.scrollHeight;
|
||
}
|
||
function clearTranscript() { transcript.innerHTML = ''; }
|
||
|
||
/* ============================================================================
|
||
TRAITEMENT DES ÉVÉNEMENTS ENTRANTS (format imposé)
|
||
============================================================================ */
|
||
function handleEvent(ev) {
|
||
if (!ev || !ev.type) return;
|
||
switch (ev.type) {
|
||
case 'state': setState(ev.state); break;
|
||
case 'user': addBubble('user', ev.text || ''); break;
|
||
case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break;
|
||
case 'error': addBubble('error', ev.text || 'Erreur'); break;
|
||
case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend
|
||
case 'services': renderServices(ev.services || []); break; // tuiles du portail
|
||
case 'portal-status': applyPortalStatus(ev); break; // santé live des services
|
||
case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix)
|
||
default: console.warn('Événement inconnu :', ev);
|
||
}
|
||
}
|
||
|
||
/* ============================================================================
|
||
CONNEXION WEBSOCKET
|
||
============================================================================ */
|
||
let ws = null;
|
||
function setConnected(on) {
|
||
conn.classList.toggle('is-on', on);
|
||
connTxt.textContent = on ? 'connecté' : 'déconnecté';
|
||
}
|
||
|
||
function connectWS() {
|
||
try {
|
||
ws = new WebSocket(WS_URL);
|
||
} catch (e) {
|
||
setConnected(false);
|
||
return;
|
||
}
|
||
ws.addEventListener('open', () => setConnected(true));
|
||
ws.addEventListener('close', () => { setConnected(false); scheduleReconnect(); });
|
||
ws.addEventListener('error', () => setConnected(false));
|
||
ws.addEventListener('message', (m) => {
|
||
try { handleEvent(JSON.parse(m.data)); }
|
||
catch { console.warn('Message non-JSON :', m.data); }
|
||
});
|
||
}
|
||
let reconnectTimer = null;
|
||
function scheduleReconnect() {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(connectWS, 2500); // reconnexion automatique
|
||
}
|
||
|
||
/* Envoi des réglages au backend (loggué seulement en démo) */
|
||
function sendSettings() {
|
||
const data = collectSettings();
|
||
const payload = { type: 'settings', data };
|
||
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
|
||
console.log('[DÉMO] settings →', payload);
|
||
} else {
|
||
ws.send(JSON.stringify(payload));
|
||
}
|
||
flashSaved();
|
||
}
|
||
|
||
/* ============================================================================
|
||
COMPOSER — entrée texte (envoie {"type":"text","text":…} au backend)
|
||
Le backend renvoie ensuite les événements user + assistant : pas d'écho local
|
||
quand on est connecté (sinon double bulle utilisateur).
|
||
============================================================================ */
|
||
const composer = document.getElementById('composer');
|
||
const textInput = document.getElementById('textInput');
|
||
|
||
function autosize(el) {
|
||
el.style.height = 'auto';
|
||
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
||
}
|
||
|
||
function sendText() {
|
||
const text = (textInput.value || '').trim();
|
||
if (!text) return;
|
||
const payload = { type: 'text', text };
|
||
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
|
||
console.log('[DÉMO] text →', payload);
|
||
addBubble('user', text); // écho local seulement hors-ligne / démo
|
||
} else {
|
||
ws.send(JSON.stringify(payload)); // connecté : le backend renvoie user + assistant
|
||
}
|
||
textInput.value = '';
|
||
autosize(textInput);
|
||
textInput.focus();
|
||
}
|
||
|
||
if (composer) {
|
||
composer.addEventListener('submit', (e) => { e.preventDefault(); sendText(); });
|
||
textInput.addEventListener('input', () => autosize(textInput));
|
||
textInput.addEventListener('keydown', (e) => {
|
||
// Entrée = envoyer ; Maj+Entrée = nouvelle ligne
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendText(); }
|
||
});
|
||
}
|
||
|
||
/* ============================================================================
|
||
CONTRÔLES — stopper la réponse de l'IA + couper le micro
|
||
Le backend (stt/voice/engine.py) reçoit {"type":"control","action":…} et
|
||
renvoie {"type":"mic","muted":…} qui fait foi pour l'état du bouton micro.
|
||
============================================================================ */
|
||
const stopBtn = document.getElementById('stopBtn');
|
||
const micBtn = document.getElementById('micBtn');
|
||
let micMuted = false;
|
||
|
||
function setMicUI(muted) {
|
||
micMuted = !!muted;
|
||
micBtn.classList.toggle('is-muted', micMuted);
|
||
micBtn.setAttribute('aria-pressed', String(micMuted));
|
||
micBtn.title = micMuted ? 'Micro coupé — cliquer pour réactiver' : 'Couper le micro';
|
||
micBtn.setAttribute('aria-label', micMuted ? 'Réactiver le micro' : 'Couper le micro');
|
||
}
|
||
|
||
function sendControl(action) {
|
||
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
|
||
console.log('[DÉMO] control →', action);
|
||
return false; // pas de backend → l'appelant gère l'état localement
|
||
}
|
||
ws.send(JSON.stringify({ type: 'control', action }));
|
||
return true;
|
||
}
|
||
|
||
stopBtn.addEventListener('click', () => {
|
||
sendControl('stop');
|
||
setState('idle'); // retour visuel immédiat (le backend confirme aussi)
|
||
});
|
||
|
||
micBtn.addEventListener('click', () => {
|
||
const next = !micMuted;
|
||
// En connecté, on attend l'écho {"type":"mic"} ; hors-ligne/démo, on bascule localement.
|
||
if (!sendControl(next ? 'mute' : 'unmute')) setMicUI(next);
|
||
});
|
||
|
||
/* ============================================================================
|
||
PORTAIL — tuiles + pages détail + santé live
|
||
Le backend pousse à la connexion {"type":"services","services":[…]} puis,
|
||
périodiquement, {"type":"portal-status","services":{id:{state,health,latency_ms,
|
||
components,alerts}},"alerts_total":N}. Un clic ouvre la PAGE détail (description,
|
||
santé, composants, alertes, bouton Ouvrir). « Ouvrir » envoie open-service.
|
||
============================================================================ */
|
||
const svcGrid = document.getElementById('svcGrid');
|
||
const svcEmpty = document.getElementById('svcEmpty');
|
||
const svcHome = document.getElementById('svcHome');
|
||
const svcDetail = document.getElementById('svcDetail');
|
||
const svcAlertBadge = document.getElementById('svcAlertBadge');
|
||
const toastEl = document.getElementById('toast');
|
||
let toastTimer = null;
|
||
let servicesById = {}; // id → {id,name,url,icon,description}
|
||
let portalStatus = {}; // id → {state,health,latency_ms,components,alerts}
|
||
let alertsTotal = null;
|
||
let detailId = null; // service affiché en détail, ou null (vue liste)
|
||
|
||
const HEALTH_LABELS = { ok:'En ligne', degraded:'Dégradé', down:'Hors ligne', unknown:'Inconnu' };
|
||
const COMP_LABELS = { up:'en ligne', down:'en panne', unknown:'—', info:'' };
|
||
const compHealth = (st) => st === 'up' ? 'ok' : (st === 'down' ? 'down' : 'unknown');
|
||
|
||
/* Services de secours en mode DÉMO (le backend n'envoie rien sans WS) */
|
||
const DEMO_SERVICES = [
|
||
{ id:'ghostfolio', name:'Ghostfolio', icon:'💰', description:'Suivi de portefeuille.' },
|
||
{ id:'grafana', name:'Grafana', icon:'📊', description:'Tableaux de bord.' },
|
||
{ id:'argocd', name:'ArgoCD', icon:'🚢', description:'Déploiement GitOps.' },
|
||
{ id:'n8n', name:'n8n', icon:'🔀', description:'Workflows.' },
|
||
];
|
||
|
||
function renderServices(list) {
|
||
const items = Array.isArray(list) ? list : [];
|
||
servicesById = {};
|
||
svcGrid.innerHTML = '';
|
||
svcEmpty.hidden = items.length > 0;
|
||
for (const s of items) {
|
||
servicesById[s.id] = s;
|
||
const tile = document.createElement('button');
|
||
tile.className = 'svc-tile';
|
||
tile.type = 'button';
|
||
tile.dataset.id = s.id;
|
||
tile.dataset.health = 'unknown';
|
||
tile.setAttribute('role', 'listitem');
|
||
tile.title = `Ouvrir la fiche ${s.name}`;
|
||
tile.innerHTML =
|
||
`<span class="svc-ico" aria-hidden="true">${s.icon || '🔗'}</span>` +
|
||
`<span class="svc-meta"><span class="svc-name"></span></span>` +
|
||
`<span class="svc-dot" aria-hidden="true"></span>`;
|
||
tile.querySelector('.svc-name').textContent = s.name || s.id;
|
||
tile.addEventListener('click', () => showDetail(s.id));
|
||
svcGrid.appendChild(tile);
|
||
}
|
||
updateTileDots();
|
||
}
|
||
|
||
/* ----- Santé live ----- */
|
||
function applyPortalStatus(msg) {
|
||
portalStatus = (msg && msg.services) || {};
|
||
alertsTotal = msg ? msg.alerts_total : null;
|
||
updateTileDots();
|
||
updateAlertBadge();
|
||
if (detailId && servicesById[detailId]) renderDetail(detailId);
|
||
}
|
||
|
||
function updateTileDots() {
|
||
for (const tile of svcGrid.querySelectorAll('.svc-tile')) {
|
||
const st = portalStatus[tile.dataset.id];
|
||
const h = st ? st.health : 'unknown';
|
||
tile.dataset.health = h;
|
||
const dot = tile.querySelector('.svc-dot');
|
||
if (dot) dot.title = HEALTH_LABELS[h] || 'Inconnu';
|
||
}
|
||
}
|
||
|
||
function updateAlertBadge() {
|
||
if (!alertsTotal || alertsTotal <= 0) { svcAlertBadge.hidden = true; return; }
|
||
svcAlertBadge.hidden = false;
|
||
const s = alertsTotal > 1 ? 's' : '';
|
||
svcAlertBadge.textContent = `⚠ ${alertsTotal} alerte${s} active${s} dans le homelab`;
|
||
}
|
||
|
||
/* ----- Navigation liste ⟷ détail ----- */
|
||
function showDetail(id) {
|
||
if (!servicesById[id]) return;
|
||
detailId = id;
|
||
svcHome.hidden = true;
|
||
svcDetail.hidden = false;
|
||
svcDetail.parentElement.scrollTop = 0;
|
||
renderDetail(id);
|
||
}
|
||
function showHome() {
|
||
detailId = null;
|
||
svcDetail.hidden = true;
|
||
svcHome.hidden = false;
|
||
}
|
||
|
||
function renderDetail(id) {
|
||
const s = servicesById[id]; if (!s) return;
|
||
const st = portalStatus[id] || {};
|
||
document.getElementById('svcDetailIco').textContent = s.icon || '🔗';
|
||
document.getElementById('svcDetailName').textContent = s.name || id;
|
||
|
||
const health = st.health || 'unknown';
|
||
const pill = document.getElementById('svcDetailPill');
|
||
pill.dataset.health = health;
|
||
let txt = HEALTH_LABELS[health] || 'Inconnu';
|
||
if (st.state === 'up' && st.latency_ms != null) txt += ` · ${st.latency_ms} ms`;
|
||
pill.textContent = '● ' + txt;
|
||
|
||
const desc = document.getElementById('svcDetailDesc');
|
||
desc.textContent = s.description || '';
|
||
desc.hidden = !s.description;
|
||
|
||
// Composants
|
||
const comps = st.components || [];
|
||
document.getElementById('svcDetailCompWrap').hidden = comps.length === 0;
|
||
const cl = document.getElementById('svcCompList'); cl.innerHTML = '';
|
||
for (const c of comps) {
|
||
const row = document.createElement('div');
|
||
row.className = 'svc-row';
|
||
row.dataset.health = compHealth(c.state);
|
||
row.innerHTML = `<span class="svc-dot" aria-hidden="true"></span>` +
|
||
`<span class="svc-row-name"></span><span class="svc-state"></span>`;
|
||
row.querySelector('.svc-row-name').textContent = c.name;
|
||
row.querySelector('.svc-state').textContent = COMP_LABELS[c.state] ?? c.state;
|
||
cl.appendChild(row);
|
||
}
|
||
|
||
// Alertes
|
||
const alerts = st.alerts || [];
|
||
const al = document.getElementById('svcAlertList'); al.innerHTML = '';
|
||
if (alerts.length === 0) {
|
||
const none = document.createElement('div');
|
||
none.className = 'svc-alert-none';
|
||
none.textContent = (st.health ? 'Aucune alerte active ✓' : 'État en cours de récupération…');
|
||
al.appendChild(none);
|
||
} else {
|
||
for (const a of alerts) {
|
||
const row = document.createElement('div');
|
||
row.className = 'svc-alert-row' + (a.severity === 'warning' ? ' sev-warning' : '');
|
||
row.innerHTML = `<span class="sev"></span><span class="svc-alert-txt"></span>`;
|
||
row.querySelector('.sev').textContent = a.severity || 'alerte';
|
||
row.querySelector('.svc-alert-txt').textContent =
|
||
a.summary ? `${a.name} — ${a.summary}` : a.name;
|
||
al.appendChild(row);
|
||
}
|
||
}
|
||
|
||
// Infos
|
||
const info = document.getElementById('svcInfo'); info.innerHTML = '';
|
||
const link = document.createElement('a');
|
||
link.textContent = s.url; link.href = s.url; link.target = '_blank'; link.rel = 'noopener';
|
||
info.appendChild(document.createTextNode('URL : ')); info.appendChild(link);
|
||
|
||
document.getElementById('svcOpenBtn').onclick = () => openService(s);
|
||
}
|
||
|
||
function openService(s) {
|
||
showToast(`${s.icon || '🔗'} Ouverture de ${s.name || s.id}`);
|
||
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
|
||
console.log('[DÉMO] open-service →', s.id);
|
||
return;
|
||
}
|
||
ws.send(JSON.stringify({ type: 'control', action: 'open-service', service: s.id }));
|
||
}
|
||
|
||
document.getElementById('svcBack').addEventListener('click', showHome);
|
||
|
||
function showToast(text) {
|
||
if (!text) return;
|
||
toastEl.textContent = text;
|
||
toastEl.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2200);
|
||
}
|
||
|
||
/* ============================================================================
|
||
RÉGLAGES — collecte, persistance (thème en localStorage), UI
|
||
============================================================================ */
|
||
const el = (id) => document.getElementById(id);
|
||
const intensity = el('intensity');
|
||
const swatch = el('swatch');
|
||
const charName = el('charName');
|
||
const avatarSet = el('avatarSet');
|
||
const voice = el('voice');
|
||
const wakeword = el('wakeword');
|
||
const nothink = el('nothink');
|
||
const brainLocal= el('brainLocal');
|
||
const brainAdv = el('brainAdv');
|
||
|
||
let brainMode = 'local'; // 'local' | 'advanced'
|
||
|
||
/* Interpolation linéaire entre deux couleurs hex */
|
||
function lerpHex(a, b, t) {
|
||
const pa = [parseInt(a.slice(1,3),16), parseInt(a.slice(3,5),16), parseInt(a.slice(5,7),16)];
|
||
const pb = [parseInt(b.slice(1,3),16), parseInt(b.slice(3,5),16), parseInt(b.slice(5,7),16)];
|
||
const c = pa.map((v,i)=> Math.round(v + (pb[i]-v)*t));
|
||
return '#' + c.map(v => v.toString(16).padStart(2,'0')).join('');
|
||
}
|
||
|
||
/* Applique l'intensité : recalcule l'accent au repos et la couleur d'anneau */
|
||
function applyIntensity(val) {
|
||
const t = val / 100; // 0 → doux, 1 → soutenu
|
||
const root = document.documentElement.style;
|
||
// couleur d'anneau au repos : du rose très clair vers le rouge franc
|
||
const ringRest = lerpHex('#f6dce0', '#ff5d6c', t);
|
||
// accent « rest » (bulles utilisateur) suit doucement
|
||
const accentRest = lerpHex('#f6dce0', '#f0c9cf', t);
|
||
root.setProperty('--ring-rest', ringRest);
|
||
root.setProperty('--accent-rest', accentRest);
|
||
swatch.style.background = ringRest;
|
||
}
|
||
|
||
function setBrain(mode) {
|
||
brainMode = mode;
|
||
brainLocal.setAttribute('aria-pressed', mode === 'local');
|
||
brainAdv.setAttribute('aria-pressed', mode === 'advanced');
|
||
}
|
||
|
||
function collectSettings() {
|
||
return {
|
||
intensity: Number(intensity.value),
|
||
character: charName.value.trim(),
|
||
avatarSet: avatarSet.value,
|
||
voice: voice.value,
|
||
wakeword: wakeword.value.trim(),
|
||
brainMode: brainMode, // 'local' (qwen3-8b) | 'advanced' (claude)
|
||
noThink: nothink.checked,
|
||
};
|
||
}
|
||
|
||
/* Sauvegarde locale (le thème surtout, mais on garde tout pour la commodité) */
|
||
function persistLocal() {
|
||
try { localStorage.setItem(LS_KEY, JSON.stringify(collectSettings())); } catch {}
|
||
}
|
||
|
||
function restoreLocal() {
|
||
let s = null;
|
||
try { s = JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch {}
|
||
if (!s) { applyIntensity(Number(intensity.value)); loadAvatar('asa'); return; }
|
||
if (s.intensity != null) intensity.value = s.intensity;
|
||
if (s.character) charName.value = s.character;
|
||
if (s.avatarSet) avatarSet.value = s.avatarSet;
|
||
if (s.voice) voice.value = s.voice;
|
||
if (s.wakeword) wakeword.value = s.wakeword;
|
||
if (s.brainMode) setBrain(s.brainMode);
|
||
if (typeof s.noThink === 'boolean') nothink.checked = s.noThink;
|
||
applyIntensity(Number(intensity.value));
|
||
loadAvatar(s.avatarSet || 'asa');
|
||
}
|
||
|
||
let savedTimer = null;
|
||
function flashSaved() {
|
||
const h = el('savedHint');
|
||
h.classList.add('show');
|
||
clearTimeout(savedTimer);
|
||
savedTimer = setTimeout(() => h.classList.remove('show'), 1600);
|
||
}
|
||
|
||
/* ----- Ouverture / fermeture des drawers (Réglages + Services, voile partagé) ----- */
|
||
const scrim = el('scrim');
|
||
const drawer = el('drawer');
|
||
const svcDrawer = el('servicesDrawer');
|
||
|
||
function closeDrawers() {
|
||
scrim.classList.remove('open');
|
||
for (const d of [drawer, svcDrawer]) {
|
||
d.classList.remove('open');
|
||
d.setAttribute('aria-hidden', 'true');
|
||
}
|
||
}
|
||
function openDrawer(which, onOpen) {
|
||
closeDrawers(); // jamais deux panneaux ouverts à la fois
|
||
scrim.classList.add('open');
|
||
which.classList.add('open');
|
||
which.setAttribute('aria-hidden', 'false');
|
||
if (onOpen) setTimeout(onOpen, 200);
|
||
}
|
||
const openSettings = () => openDrawer(drawer, () => intensity.focus());
|
||
const openServices = () => { showHome(); openDrawer(svcDrawer); };
|
||
|
||
/* ----- Écouteurs Réglages + Services ----- */
|
||
el('openSettings').addEventListener('click', openSettings);
|
||
el('closeSettings').addEventListener('click', () => { closeDrawers(); el('openSettings').focus(); });
|
||
el('openServices').addEventListener('click', openServices);
|
||
el('closeServices').addEventListener('click', () => { closeDrawers(); el('openServices').focus(); });
|
||
scrim.addEventListener('click', closeDrawers);
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape' && (drawer.classList.contains('open') || svcDrawer.classList.contains('open'))) closeDrawers();
|
||
});
|
||
|
||
intensity.addEventListener('input', () => { applyIntensity(Number(intensity.value)); });
|
||
intensity.addEventListener('change', () => { persistLocal(); sendSettings(); });
|
||
|
||
avatarSet.addEventListener('change', () => { loadAvatar(avatarSet.value); persistLocal(); sendSettings(); });
|
||
[charName, wakeword].forEach(i => i.addEventListener('change', () => { persistLocal(); sendSettings(); }));
|
||
voice.addEventListener('change', () => { persistLocal(); sendSettings(); });
|
||
nothink.addEventListener('change', () => { persistLocal(); sendSettings(); });
|
||
brainLocal.addEventListener('click', () => { setBrain('local'); persistLocal(); sendSettings(); });
|
||
brainAdv.addEventListener('click', () => { setBrain('advanced'); persistLocal(); sendSettings(); });
|
||
|
||
el('resetConv').addEventListener('click', () => {
|
||
clearTranscript();
|
||
setState('idle');
|
||
// signale aussi au backend (loggué en démo)
|
||
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) console.log('[DÉMO] reset conversation');
|
||
else ws.send(JSON.stringify({ type: 'settings', data: { action: 'reset' } }));
|
||
flashSaved();
|
||
});
|
||
|
||
/* ----- Actions de service (backend → `stt --restart|--stop|--update`) -----
|
||
Après restart/stop le WS tombe (le process meurt) : le HUD passe « déconnecté »
|
||
puis se reconnecte tout seul quand le service revient. */
|
||
function serviceAction(action, label) {
|
||
showToast(label);
|
||
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { console.log('[DÉMO] service →', action); return; }
|
||
ws.send(JSON.stringify({ type: 'control', action }));
|
||
}
|
||
el('svcRestart').addEventListener('click', () => serviceAction('service-restart', '↻ Redémarrage du service…'));
|
||
el('svcUpdate').addEventListener('click', () => serviceAction('service-update', '⬆ Mise à jour en cours…'));
|
||
el('svcStop').addEventListener('click', () => serviceAction('service-stop', '⏻ Arrêt du service…'));
|
||
|
||
/* ============================================================================
|
||
MODE DÉMO — boucle d'événements simulée
|
||
============================================================================ */
|
||
const DEMO_SCRIPT = [
|
||
{ delay: 1400, ev: { type:'state', state:'listening' } },
|
||
{ delay: 1800, ev: { type:'user', text:"Quel temps fera-t-il demain à Lyon ?" } },
|
||
{ delay: 400, ev: { type:'state', state:'thinking' } },
|
||
{ delay: 1700, ev: { type:'state', state:'speaking' } },
|
||
{ delay: 200, ev: { type:'assistant', text:"Demain à Lyon : ciel voilé le matin, éclaircies l'après-midi, 19 °C environ. Pense à une veste légère.", mode:'qwen3-8b' } },
|
||
{ delay: 2600, ev: { type:'state', state:'idle' } },
|
||
|
||
{ delay: 2200, ev: { type:'state', state:'listening' } },
|
||
{ delay: 1900, ev: { type:'user', text:"Mets un minuteur de 10 minutes." } },
|
||
{ delay: 350, ev: { type:'state', state:'thinking' } },
|
||
{ delay: 1200, ev: { type:'state', state:'speaking' } },
|
||
{ delay: 200, ev: { type:'assistant', text:"C'est noté — minuteur de 10 minutes lancé. Je te préviendrai.", mode:'qwen3-8b' } },
|
||
{ delay: 2400, ev: { type:'state', state:'idle' } },
|
||
|
||
{ delay: 2400, ev: { type:'state', state:'listening' } },
|
||
{ delay: 2100, ev: { type:'user', text:"Résume-moi la photosynthèse en une phrase." } },
|
||
{ delay: 350, ev: { type:'state', state:'thinking' } },
|
||
{ delay: 2100, ev: { type:'state', state:'speaking' } },
|
||
{ delay: 200, ev: { type:'assistant', text:"La photosynthèse, c'est le processus par lequel les plantes convertissent lumière, eau et CO₂ en sucres et en oxygène.", mode:'claude' } },
|
||
{ delay: 2800, ev: { type:'state', state:'idle' } },
|
||
];
|
||
|
||
function runDemo() {
|
||
setConnected(true); // en démo on simule un lien actif
|
||
let i = 0;
|
||
function step() {
|
||
if (i >= DEMO_SCRIPT.length) { i = 0; } // boucle infinie
|
||
const { delay, ev } = DEMO_SCRIPT[i++];
|
||
setTimeout(() => { handleEvent(ev); step(); }, delay);
|
||
}
|
||
step();
|
||
}
|
||
|
||
/* ============================================================================
|
||
INITIALISATION
|
||
============================================================================ */
|
||
restoreLocal();
|
||
setState('idle');
|
||
if (DEMO) {
|
||
renderServices(DEMO_SERVICES);
|
||
applyPortalStatus({ alerts_total: 1, services: {
|
||
ghostfolio: { state:'up', health:'ok', latency_ms:44,
|
||
components:[{name:'Application',state:'up'},{name:'Redis (cache)',state:'up'}], alerts:[] },
|
||
grafana: { state:'up', health:'degraded', latency_ms:24,
|
||
components:[{name:'Pod',state:'down'}],
|
||
alerts:[{name:'KubePodNotReady',severity:'warning',summary:'Le pod grafana n\'est pas prêt'}] },
|
||
argocd: { state:'down', health:'down', latency_ms:null, components:[], alerts:[] },
|
||
n8n: { state:'up', health:'ok', latency_ms:16, components:[{name:'Application',state:'up'}], alerts:[] },
|
||
}});
|
||
runDemo();
|
||
}
|
||
else connectWS();
|
||
</script>
|
||
</body>
|
||
</html>
|