feat(stt): HUD avancé — design Claude Design câblé au backend

Remplace le HUD minimal (cercle CSS) par le design Claude Design : avatar
portrait réactif (anneau + ping d'écoute + spinner de réflexion), transcript
à bulles (tag modèle), indicateur de connexion + reconnexion auto, et un
drawer Réglages.

- HUD (stt/hud/index.html) : HTML/CSS/JS sans dépendance externe, DEMO=false
  branché sur le websocket existant (états idle/listening/thinking/speaking,
  user/assistant/error). Portraits chargés depuis hud/avatars/ (repli « あ »
  si absent — voir avatars/README.md).
- Backend (ui/app.py) : le HUD renvoie {"type":"settings",...} ; le serveur
  applique à chaud reset / mode cerveau (modèle LiteLLM) / mot de réveil via
  _apply_settings (best-effort, non bloquant).
- engine/cli : tag du modèle courant ajouté aux réponses (events assistant).
- Docs : hud/README + avatars/README + admin/ia/stt.md (phase 4 ) + config.

Phase 4 de la roadmap STT. Reste : test sur poste + portraits réels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa
This commit is contained in:
Claude 2026-06-18 19:41:15 +00:00
parent 7821651cce
commit 08529b825a
No known key found for this signature in database
10 changed files with 1209 additions and 115 deletions

View file

@ -11,8 +11,11 @@ Objectif ultérieur : « agir sur le homelab » via les outils de Hermes (phase
> ✅ **2026-06-17** — déployé in-cluster (ArgoCD), mémoire court-terme (5a) + long-terme (5b)
> **validées sur cible**, embeddings dédiés nomic `:1238` (5d) + fix thinking Qwen3 `/no_think`
> (5e). Reste : test audio bout-en-bout sur le poste (phase 1), HUD avancé (4), auto-start (6),
> outils Hermes (7).
> (5e).
> ✅ **2026-06-18** — HUD avancé (4) implémenté (design **Claude Design**) : avatar portrait
> réactif (anneau/ping/spinner), transcript à bulles, drawer Réglages **câblé au backend**
> (reset / mode cerveau / mot de réveil à chaud). Reste : test audio bout-en-bout sur le poste
> (phase 1), portraits réels + test HUD sur cible, auto-start (6), outils Hermes (7).
---
@ -128,7 +131,7 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe
| Client `stt` | `stt/client/` (pipx) | `stt` (voix+HUD), `stt --text` (chat texte), `stt --setup`, `stt --server <url>`, `stt --model <hermes\|claude\|qwen\|opus>` |
| — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper |
| — api | `stt/client/stt/api.py` | `ServerClient``POST /v1/ask` |
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket d'états ; HUD embarqué dans le package |
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD, réglages → backend) ; HUD embarqué dans le package |
| STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM |
| Image | `ghcr.io/alkatrazz24/funk-stt-server` | construite par `.github/workflows/build-stt-server.yml` |
| Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`) |
@ -162,7 +165,7 @@ Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du
| **1 — Client voix + HUD** | `stt` : voix locale + HUD + websocket | ✅ |
| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ |
| **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD (LiteLLM en IP directe) | ✅ déployé |
| **4 — HUD avancé** | visualiseur arc-reactor + thèmes + écran réglages | ⏳ |
| **4 — HUD avancé** | avatar portrait réactif (anneau/ping/spinner) + transcript + drawer réglages (modèle/reset/wake à chaud) + thème accent | ✅ (à tester sur poste) |
| **5a — Mémoire court-terme** | historique de session côté serveur | ✅ |
| **5b — Mémoire long-terme** | Qdrant `stt-memory` + embeddings (dégrade si down) | ✅ validé 17/06 (rappel cross-session OK) |
| **5c — Réparation Qdrant** | drop `funk-docs` corrompu + restart (s01) | ✅ (17/06) |
@ -175,8 +178,9 @@ Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du
Serveur déployé in-cluster via ArgoCD (image `sha-<commit>` gérée par CI). Mémoire **5a + 5b
validées en prod** : court-terme (historique de session) et long-terme (rappel cross-session
« Felix », `points_count` confirmé via `/v1/memory/health`). Reste côté poste : test audio
bout-en-bout (micro → STT → serveur → TTS) et HUD avancé.
« Felix », `points_count` confirmé via `/v1/memory/health`). HUD avancé (4) **implémenté**
(design Claude Design, câblé au websocket backend). Reste côté poste : test audio bout-en-bout
(micro → STT → serveur → TTS) et test du HUD avec portraits réels.
---

View file

@ -5,21 +5,23 @@ Tout ce qui se change **sans recompiler**. `stt --setup` copie `stt.example.toml
```
config/
├── stt.example.toml # config de référence (modes cerveau, voix, thème, mémoire)
├── themes/ # thèmes HUD : couleurs, fond, style du visualiseur (CSS/JSON)
├── avatars/ # images / avatars affichés dans le HUD
└── voices/ # modèles Piper .onnx (TTS) — fr_FR par défaut
├── stt.example.toml # config de référence (serveur/modèle, voix, ui, mémoire)
└── README.md
```
> Les **portraits de l'avatar** et le **thème** ne vivent plus dans `config/` : le HUD les
> gère lui-même. Avatars → `stt/hud/avatars/` (servis par le HUD — voir son README).
> Thème → réglé en direct dans le drawer Réglages (intensité d'accent, mémorisé en localStorage).
## Points de personnalisation
| Quoi | Où | Note |
|---|---|---|
| Image / avatar | `avatars/` + `ui.avatar` | swappable à chaud |
| Thème / design | `themes/` + `ui.theme` | couleurs, fond, visualiseur |
| Voix | `voices/*.onnx` + `voice.piper_voice` | géré par `stt --setup` |
| Wake word | `voice.wake_word` | défaut « Ok Hermès » |
| Cerveau | `[brain]` | mode + override + endpoints |
| Avatar (portraits) | `stt/hud/avatars/<set>-{idle,think,speak}.png` | jeu choisi dans Réglages → Avatar |
| Thème / accent | Réglages → intensité (HUD) | rose doux → rouge, persisté en localStorage |
| Voix TTS | `~/.local/share/piper/<voix>.onnx` + `voice.piper_voice` | Piper |
| Mot de réveil | `voice.wake_word` (ou Réglages → mot de réveil) | défaut « hermes » |
| Modèle (cerveau) | `server.model` · `stt --model` · Réglages → mode cerveau | alias LiteLLM |
> 🔒 La clé Anthropic (mode `claude-direct`) passe par la variable d'env `ANTHROPIC_API_KEY`,
> **jamais** dans ce fichier (qui peut finir versionné).
> 🔒 Pas de clé API côté client : l'inférence (et donc Claude) passe par le STT-server
> in-cluster, jamais par le poste.

View file

@ -18,12 +18,14 @@ silence_sec = 1.2
min_speech_sec = 0.6
chat_timeout_sec = 60
# theme / avatar sont désormais réglés dans le HUD (drawer Réglages, mémorisé en
# localStorage) — ces deux clés sont gardées pour compat mais ignorées par le HUD.
[ui]
theme = "arc-reactor"
avatar = "default"
kiosk = true
kiosk = true # ouvre le HUD en plein écran (kiosk) au lancement
ws_host = "127.0.0.1"
ws_port = 9300 # websocket états HUD (doit matcher hud/index.html)
ws_port = 9300 # websocket états HUD (doit matcher hud/index.html)
http_port = 9301 # HTTP statique qui sert le HUD
[memory]

View file

@ -95,11 +95,12 @@ def _cmd_voice(args) -> int:
def make_engine(emit):
return VoiceEngine(
cfg["voice"], client.ask, emit, memory=memory, no_tts=args.no_tts
cfg["voice"], client.ask, emit, memory=memory, no_tts=args.no_tts,
model_label=lambda: client.model,
)
try:
asyncio.run(serve(cfg, make_engine))
asyncio.run(serve(cfg, make_engine, client))
except KeyboardInterrupt:
print("\n👋 Au revoir")
finally:

View file

@ -47,6 +47,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
"chat_timeout_sec": 60,
},
"ui": {
# theme / avatar : désormais pilotés dans le HUD (Réglages → localStorage),
# conservés pour compat mais ignorés par le HUD actuel.
"theme": "arc-reactor",
"avatar": "default",
"kiosk": True,

View file

@ -1,20 +1,54 @@
# stt/hud — interface HUD web (à implémenter, phase 3)
# stt/hud — interface HUD web (implémentée)
Front statique servi localement par le backend, ouvert en kiosk (`ui.kiosk = true`).
Se connecte au serveur websocket du backend.
Front statique **HTML/CSS/JS sans dépendance externe** (tout inline, SVG natifs),
servi localement par le backend (`stt/ui/app.py``SimpleHTTPRequestHandler` sur
`ui.http_port`), ouvert en kiosk si `ui.kiosk = true`. Se connecte au serveur
websocket du backend (`ui.ws_port`, défaut **9300**) pour recevoir les états et
renvoyer les réglages.
## Contenu prévu
## Ce qu'affiche le HUD
- **Visualiseur** : canvas/WebGL animé (arc-reactor / waveform) réagissant aux états
`idle / listening / thinking / speaking`.
- **Conversation** : transcript utilisateur + réponses de l'assistant.
- **Réglages** : thème, avatar, voix, wake word, mode cerveau — écrit dans `~/.config/stt/stt.toml`.
- **Avatar portrait réactif** (disque) — 3 images crossfadées selon l'état
(`idle/listening → -idle`, `thinking → -think`, `speaking → -speak`), avec
anneau fin, « ping » d'écoute et spinner de réflexion. Repli sur l'initiale
du personnage si aucune image (voir [`avatars/README.md`](avatars/README.md)).
- **Label d'état** : veille / écoute / réflexion / réponse.
- **Transcript** : bulles utilisateur / assistant (+ tag du modèle) / erreur.
- **Indicateur de connexion** websocket (point + libellé), reconnexion auto.
- **Drawer Réglages** (engrenage) : intensité de l'accent (thème), avatar, voix,
mot de réveil, mode cerveau (local/avancé), no-think, reset conversation.
## Design
## Protocole websocket
S'inspire de [AlexandreSajus/JARVIS](https://github.com/AlexandreSajus/JARVIS) (interface web)
et de l'esthétique Iron Man. Thèmes pilotés par `stt/config/themes/`, avatar par
`stt/config/avatars/`.
Reçus du backend (diffusés à tous les HUD connectés) :
Pas de framework lourd imposé — HTML/JS + canvas suffit pour le MVP. Le choix (vanilla vs
petit framework) sera tranché en phase 3.
| Événement | Effet |
|---|---|
| `{"type":"state","state":"idle\|listening\|thinking\|speaking"}` | animation + label |
| `{"type":"user","text":…}` | bulle utilisateur |
| `{"type":"assistant","text":…,"mode":…}` | bulle assistant (+ tag modèle) |
| `{"type":"error","text":…}` | bulle d'erreur |
Envoyés au backend à chaque changement de réglage :
```json
{"type":"settings","data":{"brainMode":"local","wakeword":"…","action":"reset"}}
```
Le serveur applique `action:reset`, `brainMode` (→ modèle LiteLLM) et `wakeword`
**à chaud** (`_apply_settings` dans `stt/ui/app.py`). Les réglages purement
visuels (intensité d'accent, avatar) sont persistés côté navigateur (localStorage).
## Prévisualiser sans backend
Le HUD embarque un **mode démo** : passer `const DEMO = false` à `true` en tête
du `<script>` rejoue une boucle d'événements simulés — pratique pour itérer le
design sans lancer le serveur. Défaut livré : `false` (branché sur le backend).
## Design & personnalisation
Esthétique douce (blanc + accent rose → rouge), origine **Claude Design**. Les
variables CSS en tête du `<style>` pilotent le rendu (`--accent-rest`,
`--accent-active`, `--avatar`, `--ring-gap`…) ; le slider « intensité » interpole
l'accent au repos. Portraits dans [`avatars/`](avatars/) (jeux `asa` / `mira` /
`kai`, swappables à chaud depuis les Réglages).

View file

@ -0,0 +1,34 @@
# hud/avatars — portraits de l'avatar (servis par le HUD)
Le HUD affiche le portrait du personnage et change d'image selon l'état :
| État HUD | Image attendue |
|---|---|
| `idle` / `listening` | `<set>-idle.png` |
| `thinking` | `<set>-think.png` |
| `speaking` | `<set>-speak.png` |
`<set>` est le jeu choisi dans **Réglages → Avatar** (`asa`, `mira`, `kai`…),
mémorisé côté navigateur (localStorage). Exemple pour le jeu `asa` :
```
avatars/
├── asa-idle.png
├── asa-think.png
└── asa-speak.png
```
## Format conseillé
- **Carré**, ~512×512 px (affiché en disque, recadrage `object-fit: cover`).
- PNG (transparence acceptée). Le HUD applique le masque circulaire lui-même.
## Repli sans image
Si une image manque (404), le HUD **dégrade proprement** : il affiche l'initiale
du personnage (« あ ») dans la couleur d'accent — aucune erreur, le HUD reste
pleinement fonctionnel. Dépose simplement tes portraits ici pour les activer
(pas de redémarrage : recharge la page).
> Ces fichiers sont livrés **dans le package** (`pyproject.toml` → package-data
> `hud/**/*`), donc inclus par `pipx install`. Garde-les légers.

File diff suppressed because it is too large Load diff

View file

@ -25,8 +25,12 @@ def _start_http(host: str, port: int) -> ThreadingHTTPServer:
return httpd
async def serve(cfg: dict[str, Any], make_engine) -> None:
"""make_engine(emit) -> VoiceEngine. emit est thread-safe (depuis le thread voix)."""
async def serve(cfg: dict[str, Any], make_engine, client) -> None:
"""Sert le HUD + diffuse les états ; applique les réglages renvoyés par le HUD.
make_engine(emit) -> VoiceEngine. emit est thread-safe (depuis le thread voix).
client : ServerClient muté à chaud par les réglages (modèle, reset).
"""
import websockets
ui = cfg["ui"]
@ -56,8 +60,14 @@ async def serve(cfg: dict[str, Any], make_engine) -> None:
clients.add(ws)
try:
await ws.send(json.dumps(last_state, ensure_ascii=False))
async for _ in ws: # le HUD n'envoie rien pour l'instant (phase 5 : réglages)
pass
# Le HUD renvoie {"type":"settings","data":{...}} quand on change un réglage.
async for raw in ws:
try:
msg = json.loads(raw)
except (ValueError, TypeError):
continue
if isinstance(msg, dict) and msg.get("type") == "settings":
_apply_settings(client, engine, msg.get("data") or {}, loop)
finally:
clients.discard(ws)
@ -94,3 +104,34 @@ def _open_browser(url: str, kiosk: bool) -> None:
subprocess.Popen([browser, flag, url])
return
webbrowser.open(url)
def _apply_settings(client, engine, data: dict, loop) -> None:
"""Applique les réglages renvoyés par le HUD — best-effort, jamais bloquant ni fatal.
Les réglages purement visuels (intensité d'accent, avatar) sont gérés côté
navigateur (localStorage). Ici on n'applique que ce qui touche le backend :
action "reset" vide la mémoire de session côté STT-server
brainMode bascule le modèle (local = Qwen gratuit / advanced = Claude)
wakeword mot de réveil, relu à chaud par le moteur vocal
La voix Piper et le no-think ne sont pas applicables à chaud (ignorés ici).
"""
if not isinstance(data, dict):
return
try:
if data.get("action") == "reset":
loop.run_in_executor(None, client.reset) # appel HTTP → hors boucle
return
brain = data.get("brainMode")
if brain in ("local", "advanced"):
from stt.config import resolve_model
client.model = resolve_model("hermes" if brain == "local" else "claude")
wake = (data.get("wakeword") or "").strip()
if wake and engine is not None:
from stt.voice.engine import _deaccent
parts = wake.split()
engine.cfg["wake_word"] = _deaccent(parts[-1] if parts else wake)
except Exception: # noqa: BLE001 — un réglage invalide ne doit jamais tuer la connexion
pass

View file

@ -53,12 +53,15 @@ class VoiceEngine:
emit: Callable[[dict], None],
memory=None,
no_tts: bool = False,
model_label: Callable[[], str] | None = None,
):
self.cfg = cfg
self.responder = responder
self.emit = emit
self.memory = memory
self.no_tts = no_tts
# callable renvoyant l'alias modèle courant → affiché en tag dans le HUD
self.model_label = model_label
self._whisper = None
self._stop = False
@ -130,7 +133,8 @@ class VoiceEngine:
self.emit({"type": "error", "text": str(e)})
self.emit({"type": "state", "state": "idle"})
return
self.emit({"type": "assistant", "text": resp})
mode = self.model_label() if self.model_label else None
self.emit({"type": "assistant", "text": resp, "mode": mode})
if self.memory:
self.memory.log("assistant", resp)
self.emit({"type": "state", "state": "speaking"})