mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 17:24:41 +02:00
* 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>
1242 lines
43 KiB
HTML
1242 lines
43 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; }
|
||
|
||
@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>
|