diff --git a/admin/ia/stt.md b/admin/ia/stt.md index 7ee32d4..2e839ba 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -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 `, `stt --model ` | | — 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-` 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. --- diff --git a/stt/client/config/README.md b/stt/client/config/README.md index 0bc2138..67eb822 100644 --- a/stt/client/config/README.md +++ b/stt/client/config/README.md @@ -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/-{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/.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. diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index 77675a9..f93b116 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -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] diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 4583a9a..f9be9fb 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -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" diff --git a/stt/client/stt/cli.py b/stt/client/stt/cli.py index 75b342e..8a590ed 100644 --- a/stt/client/stt/cli.py +++ b/stt/client/stt/cli.py @@ -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: diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index 731d817..2d79f8e 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -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, diff --git a/stt/client/stt/hud/README.md b/stt/client/stt/hud/README.md index 553e4ad..c77cd9e 100644 --- a/stt/client/stt/hud/README.md +++ b/stt/client/stt/hud/README.md @@ -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 ` + +
+
+ + + + + + + + + + +
+ + + + +
+
+ +
veille
+
+ + +
+
+
+ + + +
+ + + diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index ca25f20..ceb90ed 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -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= : 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 diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 77542b0..2debfa5 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -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"})