mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-12 05:24:43 +02:00
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:
parent
7821651cce
commit
2658f92c2f
11 changed files with 1231 additions and 118 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
34
stt/client/stt/hud/avatars/README.md
Normal file
34
stt/client/stt/hud/avatars/README.md
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue