Funk-lab/stt/client/stt/hud/index.html
ALI YESILKAYA 9feb02ed70
feat(stt): boutons stop/mute (HUD) + stt --stop + RAG funk-docs nettoyé (#24)
* fix(rag): exclure hermes/builtin de l'index funk-docs

Les rapports auto-générés par hermes-auto-improve (admin/hermes/builtin/,
"ne pas éditer") représentaient ~84% des points de la collection et noyaient
la vraie doc → rag-query remontait du bruit. On les élague à l'ingestion
(os.walk), surchargeable via RAG_EXCLUDE. Collection re-bâtie : 403 points
propres (0 builtin), rag-query remonte de nouveau les bons documents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(stt-client): boutons stop réponse + mute micro (HUD) + `stt --stop`

HUD : deux contrôles dans la barre du haut.
- « stop » coupe la réponse en cours — interrompt la lecture TTS (kill aplay)
  et saute la synthèse si pressé pendant la génération (Event _interrupt).
- « micro » coupe/réactive l'entrée audio (la boucle VAD ignore les trames) ;
  l'état fait foi côté backend, renvoyé au HUD via {"type":"mic"} (ré-émis à
  la connexion d'un nouveau client).

Protocole WS : nouveau message {"type":"control","action":"stop|mute|unmute"}.

CLI : `stt --stop` éteint le service — systemd --user (stt.service) si actif,
sinon SIGTERM aux process vocaux trouvés via /proc (s'exclut + ignore les
sous-commandes utilitaires).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:17:32 +02:00

1242 lines
43 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
@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>
<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>
</div>
</aside>
<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
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);
});
/* ============================================================================
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 du drawer ----- */
const scrim = el('scrim');
const drawer = el('drawer');
function openSettings() {
scrim.classList.add('open');
drawer.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
setTimeout(() => intensity.focus(), 200);
}
function closeSettings() {
scrim.classList.remove('open');
drawer.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
el('openSettings').focus();
}
/* ----- Écouteurs Réglages ----- */
el('openSettings').addEventListener('click', openSettings);
el('closeSettings').addEventListener('click', closeSettings);
scrim.addEventListener('click', closeSettings);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && drawer.classList.contains('open')) closeSettings(); });
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();
});
/* ============================================================================
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) runDemo();
else connectWS();
</script>
</body>
</html>