Funk-lab/stt/client/stt/ui/app.py
Claude 08529b825a
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
2026-06-18 19:41:15 +00:00

137 lines
5 KiB
Python

"""Serveur asyncio : sert le HUD (HTTP statique) et diffuse les états (websocket).
Le moteur vocal tourne dans un thread (audio bloquant) et pousse ses événements via
`emit()`, relayés sur la boucle asyncio puis diffusés à tous les clients HUD connectés.
"""
from __future__ import annotations
import asyncio
import json
import threading
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
# Le HUD est embarqué DANS le package (stt/hud) pour être livré par pipx.
HUD_DIR = Path(__file__).resolve().parent.parent / "hud"
def _start_http(host: str, port: int) -> ThreadingHTTPServer:
handler = partial(SimpleHTTPRequestHandler, 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, 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"]
loop = asyncio.get_running_loop()
clients: set = set()
last_state = {"type": "state", "state": "idle"}
def broadcast(event: dict) -> None:
nonlocal last_state
if event.get("type") == "state":
last_state = event
data = json.dumps(event, ensure_ascii=False)
for ws in list(clients):
asyncio.create_task(_safe_send(ws, data))
async def _safe_send(ws, data: str) -> None:
try:
await ws.send(data)
except Exception:
clients.discard(ws)
# emit appelé depuis le thread voix → on repasse sur la boucle asyncio
def emit(event: dict) -> None:
loop.call_soon_threadsafe(broadcast, event)
async def handler(ws):
clients.add(ws)
try:
await ws.send(json.dumps(last_state, ensure_ascii=False))
# 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)
_start_http(ui["ws_host"], ui["http_port"])
engine = make_engine(emit)
# chargement Whisper + boucle audio dans un thread dédié
threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start()
async with websockets.serve(handler, ui["ws_host"], ui["ws_port"]):
url = f"http://{ui['ws_host']}:{ui['http_port']}/"
print(f" HUD : {url}")
if ui.get("kiosk"):
_open_browser(url, kiosk=True)
await asyncio.Future() # tourne indéfiniment
def _run_engine(engine, emit) -> None:
try:
engine.load()
engine.run()
except Exception as e: # noqa: BLE001
emit({"type": "error", "text": f"moteur vocal : {e}"})
def _open_browser(url: str, kiosk: bool) -> None:
import shutil
import subprocess
import webbrowser
for browser in ("chromium", "google-chrome", "chromium-browser"):
if shutil.which(browser):
flag = "--kiosk" if kiosk else "--new-window"
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