feat(stt): entrée texte HUD + finitions client + doc incident LLM/GPU (#19)

Regroupe le travail postérieur à la PR #18 (squash-mergée), rebasé proprement
sur main :

- HUD : composer texte (chat clavier sans micro) — {"type":"text"} →
  VoiceEngine.respond_text (hors boucle audio), lock _respond (voix ⟂ texte)
- App autonome : fenêtre app/kiosk via profil + WM class dédiés (hors Brave)
- Auto-update non destructif : `stt --update` = pipx upgrade (ne laisse plus
  l'install cassée en cas d'échec réseau)
- Terminal propre : --log-level=3 + sortie navigateur vers DEVNULL
- Docs : admin/incidents-llm-gpu.md (journal d'inférence dédié) — incident
  2026-06-19 llama-server gelé (/health OK mais génération bloquée → 502),
  + HUD README / stt.md (composer), bump 0.4.x → 0.5.1

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-19 14:56:36 +02:00 committed by GitHub
parent 5f1e34dad9
commit 7025d7ae70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 293 additions and 40 deletions

View file

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

View file

@ -180,34 +180,49 @@ def _cmd_version() -> int:
def _cmd_update() -> int:
"""Met à jour STT depuis sa source git (pipx reinstall) — sans désinstall/réinstall manuel."""
import re
"""Met à jour STT depuis sa source git, sans désinstall/réinstall manuel.
Utilise `pipx upgrade` (non destructif : conserve l'install actuelle si l'upgrade échoue),
contrairement à `pipx reinstall` qui désinstalle d'abord et peut laisser sans rien.
"""
import json
import shutil
import subprocess
pipx = shutil.which("pipx")
if not pipx:
print("pipx introuvable — mise à jour automatique impossible.")
print("Réinstalle manuellement depuis la source git.")
return 1
# Source d'installation (pour la commande de secours en cas d'échec)
spec = None
try:
listing = subprocess.run(
[pipx, "list", "--json"], capture_output=True, text=True
).stdout or "{}"
spec = json.loads(listing)["venvs"]["stt"]["metadata"]["main_package"]["package_or_url"]
except Exception: # noqa: BLE001
pass
before = _installed_version()
if before:
print(f"Version installée : {before}")
print("Récupération de la dernière version depuis git (pipx reinstall)…\n")
res = subprocess.run([pipx, "reinstall", "stt"], capture_output=True, text=True)
print("Mise à jour depuis git (pipx upgrade)…\n")
res = subprocess.run([pipx, "upgrade", "stt"], capture_output=True, text=True)
out = ((res.stdout or "") + (res.stderr or "")).strip()
if out:
print(out)
m = re.search(r"installed package stt ([0-9][^\s,]*)", out)
after = m.group(1) if m else None
print()
if after and before and after != before:
print(f"✓ Mis à jour : {before}{after}")
elif after:
print(f"✓ Déjà à jour ({after}).")
else:
print("✓ Réinstallation terminée — vérifie avec : stt --version")
return res.returncode
if res.returncode != 0 or shutil.which("stt") is None:
print("❌ Échec de la mise à jour — ton install précédente est conservée.")
print(" Réessaie (souvent réseau/SSH), ou en dernier recours :")
print(" uv cache clean")
if spec:
print(f' pipx install --force "{spec}"')
return res.returncode or 1
print("✓ Mise à jour terminée — vérifie avec : stt --version")
return 0
def _make_client(args):

View file

@ -14,6 +14,9 @@ renvoyer les réglages.
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.
- **Composer texte** (sous le transcript) : champ + bouton « Envoyer » pour
dialoguer **au clavier** sans micro — Entrée envoie, Maj+Entrée saute une ligne.
Le texte passe par le même cerveau que le vocal (modèle local / avancé).
- **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.
@ -29,14 +32,18 @@ Reçus du backend (diffusés à tous les HUD connectés) :
| `{"type":"assistant","text":…,"mode":…}` | bulle assistant (+ tag modèle) |
| `{"type":"error","text":…}` | bulle d'erreur |
Envoyés au backend à chaque changement de réglage :
Envoyés au backend :
```json
{"type":"settings","data":{"brainMode":"local","wakeword":"…","action":"reset"}}
{"type":"text","text":"quelle heure est-il ?"}
```
Le serveur applique `action:reset`, `brainMode` (→ modèle LiteLLM) et `wakeword`
**à chaud** (`_apply_settings` dans `stt/ui/app.py`). Les réglages purement
**à chaud** (`_apply_settings` dans `stt/ui/app.py`). Un message `text` est traité
comme un tour de conversation (`VoiceEngine.respond_text`, hors boucle audio) : le
backend renvoie alors les événements `user` puis `assistant` — le HUD n'ajoute donc
pas la bulle utilisateur lui-même quand il est connecté. Les réglages purement
visuels (intensité d'accent, avatar) sont persistés côté navigateur (localStorage).
## Prévisualiser sans backend

View file

@ -341,6 +341,58 @@
max-width: 90%;
}
/* ============================================================
COMPOSER — entrée texte (alternative au vocal)
============================================================ */
.composer {
display: flex;
gap: 10px;
align-items: flex-end;
width: 100%;
max-width: 860px;
margin: 8px auto 0;
padding: 0 26px 4px;
}
.composer textarea {
flex: 1 1 auto;
resize: none;
min-height: 46px;
max-height: 140px;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--bg);
color: var(--ink);
font: inherit;
font-size: 15px;
line-height: 1.4;
outline: none;
transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
}
.composer textarea::placeholder { color: var(--ink-soft); }
.composer textarea:focus {
border-color: var(--accent-active);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-active) 22%, transparent);
}
.composer .send {
flex: 0 0 auto;
height: 46px;
padding: 0 18px;
border: none;
border-radius: 16px;
background: var(--accent-active);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: filter .15s var(--ease), transform .1s var(--ease);
}
.composer .send:hover { filter: brightness(1.05); }
.composer .send:active { transform: translateY(1px); }
/* ============================================================
PANNEAU RÉGLAGES (drawer + voile)
============================================================ */
@ -621,6 +673,17 @@
<div class="transcript-wrap">
<div class="transcript" id="transcript" aria-label="Conversation" role="log"></div>
</div>
<!-- Composer : entrée texte (alternative au vocal) -->
<form class="composer" id="composer" autocomplete="off">
<textarea id="textInput" rows="1" placeholder="Écris un message à Hermès… (Entrée pour envoyer, Maj+Entrée = nouvelle ligne)" aria-label="Message texte"></textarea>
<button type="submit" class="send" id="sendBtn" aria-label="Envoyer le message">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7z"></path>
</svg>
Envoyer
</button>
</form>
</main>
<!-- ===================== RÉGLAGES (voile + drawer) ===================== -->
@ -880,6 +943,43 @@ function sendSettings() {
flashSaved();
}
/* ============================================================================
COMPOSER — entrée texte (envoie {"type":"text","text":…} au backend)
Le backend renvoie ensuite les événements user + assistant : pas d'écho local
quand on est connecté (sinon double bulle utilisateur).
============================================================================ */
const composer = document.getElementById('composer');
const textInput = document.getElementById('textInput');
function autosize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
}
function sendText() {
const text = (textInput.value || '').trim();
if (!text) return;
const payload = { type: 'text', text };
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
console.log('[DÉMO] text →', payload);
addBubble('user', text); // écho local seulement hors-ligne / démo
} else {
ws.send(JSON.stringify(payload)); // connecté : le backend renvoie user + assistant
}
textInput.value = '';
autosize(textInput);
textInput.focus();
}
if (composer) {
composer.addEventListener('submit', (e) => { e.preventDefault(); sendText(); });
textInput.addEventListener('input', () => autosize(textInput));
textInput.addEventListener('keydown', (e) => {
// Entrée = envoyer ; Maj+Entrée = nouvelle ligne
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendText(); }
});
}
/* ============================================================================
RÉGLAGES — collecte, persistance (thème en localStorage), UI
============================================================================ */

View file

@ -77,14 +77,23 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
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.
# Le HUD envoie {"type":"settings","data":{...}} (réglage) ou
# {"type":"text","text":"…"} (message tapé, alternative au vocal).
async for raw in ws:
try:
msg = json.loads(raw)
except (ValueError, TypeError):
continue
if isinstance(msg, dict) and msg.get("type") == "settings":
if not isinstance(msg, dict):
continue
mtype = msg.get("type")
if mtype == "settings":
_apply_settings(client, engine, msg.get("data") or {}, loop)
elif mtype == "text":
txt = (msg.get("text") or "").strip()
if txt and engine is not None:
# responder = appel HTTP bloquant → hors boucle asyncio
loop.run_in_executor(None, engine.respond_text, txt)
finally:
clients.discard(ws)
@ -130,13 +139,23 @@ def _open_browser(url: str, mode: str) -> None:
profile = Path.home() / ".local" / "share" / "stt" / "app-profile"
profile.mkdir(parents=True, exist_ok=True)
win = ["--kiosk", url] if mode == "kiosk" else [f"--app={url}"]
# --log-level=3 : tait le bruit Chromium (viz/vaapi/RADV) à la source ;
# les flux sont en plus redirigés vers /dev/null (voir _spawn) pour ne pas
# polluer le terminal qui lance `stt`.
quiet = ["--log-level=3"]
# Instance autonome (profil + classe dédiés) — pour les navigateurs natifs.
native = [
"--class=STT-Funk", f"--user-data-dir={profile}",
"--no-first-run", "--no-default-browser-check", *win,
"--no-first-run", "--no-default-browser-check", *quiet, *win,
]
# Flatpak est déjà sandboxé : pas de --user-data-dir (chemin hôte inaccessible).
flatpak = ["--class=STT-Funk", *win]
flatpak = ["--class=STT-Funk", *quiet, *win]
def _spawn(argv: list[str]) -> None:
# Détaché et muet : la sortie du navigateur ne remonte pas dans `stt`.
subprocess.Popen(
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
# 1) Binaire natif (rpm/deb)
for browser in (
@ -146,7 +165,7 @@ def _open_browser(url: str, mode: str) -> None:
"microsoft-edge", "vivaldi",
):
if shutil.which(browser):
subprocess.Popen([browser, *native])
_spawn([browser, *native])
return
# 2) Flatpak (universel — souvent la voie sur Fedora/AlmaLinux)
@ -157,7 +176,7 @@ def _open_browser(url: str, mode: str) -> None:
["flatpak", "info", appid], capture_output=True
).returncode == 0
if installed:
subprocess.Popen(["flatpak", "run", appid, *flatpak])
_spawn(["flatpak", "run", appid, *flatpak])
return
# 3) Repli : onglet dans le navigateur par défaut

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import queue
import subprocess
import tempfile
import threading
import time
import unicodedata
from collections import deque
@ -64,6 +65,8 @@ class VoiceEngine:
self.model_label = model_label
self._whisper = None
self._stop = False
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes
self._respond_lock = threading.Lock()
# ── chargement modèle ────────────────────────────────────────────────
def load(self) -> None:
@ -123,22 +126,35 @@ class VoiceEngine:
# ── interrogation du serveur ─────────────────────────────────────────
def _respond(self, text: str) -> None:
self.emit({"type": "user", "text": text})
if self.memory:
self.memory.log("user", text)
self.emit({"type": "state", "state": "thinking"})
try:
resp = self.responder(text)
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
self.emit({"type": "error", "text": str(e)})
self.emit({"type": "state", "state": "idle"})
with self._respond_lock: # un seul tour à la fois (voix ⟂ texte)
self.emit({"type": "user", "text": text})
if self.memory:
self.memory.log("user", text)
self.emit({"type": "state", "state": "thinking"})
try:
resp = self.responder(text)
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
self.emit({"type": "error", "text": str(e)})
self.emit({"type": "state", "state": "idle"})
return
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"})
self._speak(resp)
def respond_text(self, text: str) -> None:
"""Traite une entrée texte du HUD comme un tour de conversation (sans micro).
Appelé hors boucle audio (depuis le serveur websocket, via un executor) : le
moteur n'a pas besoin d'être chargé/démarré pour répondre à du texte.
"""
text = (text or "").strip()
if not text:
return
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"})
self._speak(resp)
self._respond(text)
self.emit({"type": "state", "state": "idle"})
# ── boucle principale (VAD + wake word) ──────────────────────────────
def run(self) -> None: