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

@ -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) > ✅ **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` > **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), > (5e).
> outils Hermes (7). > ✅ **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>` | | 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 | | — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper |
| — api | `stt/client/stt/api.py` | `ServerClient``POST /v1/ask` | | — 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 | | 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` | | 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`) | | 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 | ✅ | | **1 — Client voix + HUD** | `stt` : voix locale + HUD + websocket | ✅ |
| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ | | **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ |
| **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD (LiteLLM en IP directe) | ✅ déployé | | **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 | ✅ | | **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) | | **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) | | **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 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 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 « Felix », `points_count` confirmé via `/v1/memory/health`). HUD avancé (4) **implémenté**
bout-en-bout (micro → STT → serveur → TTS) et HUD avancé. (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/ config/
├── stt.example.toml # config de référence (modes cerveau, voix, thème, mémoire) ├── stt.example.toml # config de référence (serveur/modèle, voix, ui, mémoire)
├── themes/ # thèmes HUD : couleurs, fond, style du visualiseur (CSS/JSON) └── README.md
├── avatars/ # images / avatars affichés dans le HUD
└── voices/ # modèles Piper .onnx (TTS) — fr_FR par défaut
``` ```
> 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 ## Points de personnalisation
| Quoi | Où | Note | | Quoi | Où | Note |
|---|---|---| |---|---|---|
| Image / avatar | `avatars/` + `ui.avatar` | swappable à chaud | | Avatar (portraits) | `stt/hud/avatars/<set>-{idle,think,speak}.png` | jeu choisi dans Réglages → Avatar |
| Thème / design | `themes/` + `ui.theme` | couleurs, fond, visualiseur | | Thème / accent | Réglages → intensité (HUD) | rose doux → rouge, persisté en localStorage |
| Voix | `voices/*.onnx` + `voice.piper_voice` | géré par `stt --setup` | | Voix TTS | `~/.local/share/piper/<voix>.onnx` + `voice.piper_voice` | Piper |
| Wake word | `voice.wake_word` | défaut « Ok Hermès » | | Mot de réveil | `voice.wake_word` (ou Réglages → mot de réveil) | défaut « hermes » |
| Cerveau | `[brain]` | mode + override + endpoints | | 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`, > 🔒 Pas de clé API côté client : l'inférence (et donc Claude) passe par le STT-server
> **jamais** dans ce fichier (qui peut finir versionné). > in-cluster, jamais par le poste.

View file

@ -18,12 +18,14 @@ silence_sec = 1.2
min_speech_sec = 0.6 min_speech_sec = 0.6
chat_timeout_sec = 60 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] [ui]
theme = "arc-reactor" theme = "arc-reactor"
avatar = "default" avatar = "default"
kiosk = true kiosk = true # ouvre le HUD en plein écran (kiosk) au lancement
ws_host = "127.0.0.1" 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 http_port = 9301 # HTTP statique qui sert le HUD
[memory] [memory]

View file

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

View file

@ -95,11 +95,12 @@ def _cmd_voice(args) -> int:
def make_engine(emit): def make_engine(emit):
return VoiceEngine( 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: try:
asyncio.run(serve(cfg, make_engine)) asyncio.run(serve(cfg, make_engine, client))
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n👋 Au revoir") print("\n👋 Au revoir")
finally: finally:

View file

@ -47,6 +47,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
"chat_timeout_sec": 60, "chat_timeout_sec": 60,
}, },
"ui": { "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", "theme": "arc-reactor",
"avatar": "default", "avatar": "default",
"kiosk": True, "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`). Front statique **HTML/CSS/JS sans dépendance externe** (tout inline, SVG natifs),
Se connecte au serveur websocket du backend. 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 - **Avatar portrait réactif** (disque) — 3 images crossfadées selon l'état
`idle / listening / thinking / speaking`. (`idle/listening → -idle`, `thinking → -think`, `speaking → -speak`), avec
- **Conversation** : transcript utilisateur + réponses de l'assistant. anneau fin, « ping » d'écoute et spinner de réflexion. Repli sur l'initiale
- **Réglages** : thème, avatar, voix, wake word, mode cerveau — écrit dans `~/.config/stt/stt.toml`. 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) Reçus du backend (diffusés à tous les HUD connectés) :
et de l'esthétique Iron Man. Thèmes pilotés par `stt/config/themes/`, avatar par
`stt/config/avatars/`.
Pas de framework lourd imposé — HTML/JS + canvas suffit pour le MVP. Le choix (vanilla vs | Événement | Effet |
petit framework) sera tranché en phase 3. |---|---|
| `{"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 asyncio
import json import json
import threading import threading
import time
from functools import partial from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@ -18,15 +19,35 @@ from typing import Any
HUD_DIR = Path(__file__).resolve().parent.parent / "hud" 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: 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) httpd = ThreadingHTTPServer((host, port), handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start() threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd return httpd
async def serve(cfg: dict[str, Any], make_engine) -> None: async def serve(cfg: dict[str, Any], make_engine, client) -> None:
"""make_engine(emit) -> VoiceEngine. emit est thread-safe (depuis le thread voix).""" """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 import websockets
ui = cfg["ui"] ui = cfg["ui"]
@ -56,8 +77,14 @@ async def serve(cfg: dict[str, Any], make_engine) -> None:
clients.add(ws) clients.add(ws)
try: try:
await ws.send(json.dumps(last_state, ensure_ascii=False)) 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) # Le HUD renvoie {"type":"settings","data":{...}} quand on change un réglage.
pass 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: finally:
clients.discard(ws) 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']}/" url = f"http://{ui['ws_host']}:{ui['http_port']}/"
print(f" HUD : {url}") print(f" HUD : {url}")
if ui.get("kiosk"): 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 await asyncio.Future() # tourne indéfiniment
@ -94,3 +123,34 @@ def _open_browser(url: str, kiosk: bool) -> None:
subprocess.Popen([browser, flag, url]) subprocess.Popen([browser, flag, url])
return return
webbrowser.open(url) 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], emit: Callable[[dict], None],
memory=None, memory=None,
no_tts: bool = False, no_tts: bool = False,
model_label: Callable[[], str] | None = None,
): ):
self.cfg = cfg self.cfg = cfg
self.responder = responder self.responder = responder
self.emit = emit self.emit = emit
self.memory = memory self.memory = memory
self.no_tts = no_tts 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._whisper = None
self._stop = False self._stop = False
@ -130,7 +133,8 @@ class VoiceEngine:
self.emit({"type": "error", "text": str(e)}) self.emit({"type": "error", "text": str(e)})
self.emit({"type": "state", "state": "idle"}) self.emit({"type": "state", "state": "idle"})
return 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: if self.memory:
self.memory.log("assistant", resp) self.memory.log("assistant", resp)
self.emit({"type": "state", "state": "speaking"}) self.emit({"type": "state", "state": "speaking"})