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

* 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

* fix(stt): HUD servi sans cache navigateur (anti ancienne version)

Le HUD est un dashboard live servi en statique : sans en-tête anti-cache, le
navigateur garde l'ancien index.html après mise à jour (« je vois encore la
version bleue »). Ajoute Cache-Control: no-store + Pragma: no-cache via un
handler dédié, et silence le log HTTP par requête.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

* chore(stt): bump client 0.1.0 → 0.2.0 (HUD avancé)

La version inchangée faisait que pipx/uv réutilisait l'ancien build en cache
lors d'un réinstall (HUD bleu persistant malgré le nouveau code). Le bump force
une résolution propre vers la nouvelle version (HUD design Claude Design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

* fix(stt): cache-bust de l'URL HUD en kiosk + bump 0.2.1

Ouvre le HUD avec ?v=<timestamp> (URL unique par lancement) pour que le
navigateur ne puisse jamais servir une version cachée de l'ancien HUD.
Complète Cache-Control: no-store côté serveur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qjq5jHiqNepnobJpHYpCa

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-18 22:03:39 +02:00 committed by GitHub
parent 7821651cce
commit 2658f92c2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1231 additions and 118 deletions

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

@ -1,6 +1,6 @@
[project]
name = "stt"
version = "0.1.0"
version = "0.2.1"
description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)"
requires-python = ">=3.11"
readme = "README.md"

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

@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import json
import threading
import time
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@ -18,15 +19,35 @@ from typing import Any
HUD_DIR = Path(__file__).resolve().parent.parent / "hud"
class _HudHandler(SimpleHTTPRequestHandler):
"""Sert le HUD en interdisant le cache navigateur.
Sans ça, le navigateur garde l'ancien index.html après une mise à jour
(symptôme classique : « je vois encore l'ancienne version »).
"""
def end_headers(self):
self.send_header("Cache-Control", "no-store, max-age=0")
self.send_header("Pragma", "no-cache")
super().end_headers()
def log_message(self, *args): # silence le log HTTP par requête
pass
def _start_http(host: str, port: int) -> ThreadingHTTPServer:
handler = partial(SimpleHTTPRequestHandler, directory=str(HUD_DIR))
handler = partial(_HudHandler, directory=str(HUD_DIR))
httpd = ThreadingHTTPServer((host, port), handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
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 +77,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)
@ -71,7 +98,9 @@ async def serve(cfg: dict[str, Any], make_engine) -> None:
url = f"http://{ui['ws_host']}:{ui['http_port']}/"
print(f" HUD : {url}")
if ui.get("kiosk"):
_open_browser(url, kiosk=True)
# ?v=<timestamp> : URL unique à chaque lancement → le navigateur ne peut
# pas servir une version cachée du HUD (« ancien HUD persistant »).
_open_browser(f"{url}?v={int(time.time())}", kiosk=True)
await asyncio.Future() # tourne indéfiniment
@ -94,3 +123,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"})