mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 14:24:42 +02:00
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:
parent
5f1e34dad9
commit
7025d7ae70
10 changed files with 293 additions and 40 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,3 +25,6 @@ keys.txt
|
||||||
# Divers
|
# Divers
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Playwright MCP session artifacts
|
||||||
|
.playwright-mcp/
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,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 bidirectionnel (états → HUD, réglages → backend) ; HUD embarqué dans le package |
|
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket bidirectionnel (états → HUD ; réglages **et messages texte** → 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`) |
|
||||||
|
|
@ -231,7 +231,7 @@ optionnel — son absence n'empêche rien.
|
||||||
| **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é** | avatar portrait réactif (anneau/ping/spinner) + transcript + drawer réglages (modèle/reset/wake à chaud) + thème accent | ✅ (à tester sur poste) |
|
| **4 — HUD avancé** | avatar portrait réactif (anneau/ping/spinner) + transcript + **composer texte** (chat clavier sans micro) + 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) |
|
||||||
|
|
@ -246,6 +246,8 @@ Serveur déployé in-cluster via ArgoCD (image `sha-<commit>` gérée par CI). M
|
||||||
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`). HUD avancé (4) **implémenté**
|
« Felix », `points_count` confirmé via `/v1/memory/health`). HUD avancé (4) **implémenté**
|
||||||
(design Claude Design, câblé au websocket backend) ; auto-start (6) via `stt --install-service`.
|
(design Claude Design, câblé au websocket backend) ; auto-start (6) via `stt --install-service`.
|
||||||
|
Le HUD inclut un **composer texte** (`{"type":"text"}` → `VoiceEngine.respond_text`) pour
|
||||||
|
dialoguer au clavier sans micro — utile tant que le bout-en-bout audio n'est pas validé.
|
||||||
Reste côté poste : test audio bout-en-bout (micro → STT → serveur → TTS) et test du HUD/kiosk
|
Reste côté poste : test audio bout-en-bout (micro → STT → serveur → TTS) et test du HUD/kiosk
|
||||||
avec portraits réels.
|
avec portraits réels.
|
||||||
|
|
||||||
|
|
|
||||||
87
admin/incidents-llm-gpu.md
Normal file
87
admin/incidents-llm-gpu.md
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
# Incidents LLM / GPU — llama-server (gpu-01)
|
||||||
|
|
||||||
|
Journal dédié des incidents d'**inférence** : `llama-server` (ROCm / RX 6700XT sur
|
||||||
|
gpu-01) et chaîne LLM `STT-server / Hermes → LiteLLM → llama-server`. Voir aussi
|
||||||
|
`incidents.md` (matériel & services storage-01/k8s) et `admin/ia/llama_server.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-06-19 — llama-server gelé : `/health` OK mais inférence bloquée → 502 sur tout le LLM
|
||||||
|
|
||||||
|
### Symptômes
|
||||||
|
|
||||||
|
- STT (HUD, **composer texte**) : `502 Server Error: Bad Gateway for url: http://stt.lab.local/v1/ask`
|
||||||
|
- Petite **activité GPU** visible sur gpu-01 (le slot d'inférence tourne en rond)
|
||||||
|
- STT-server : `GET /healthz` → 200, mais `POST /v1/ask` → **502**
|
||||||
|
- llama-server `:1234` : `/health` → `{"status":"ok"}` et `/v1/models` → `qwen3-8b` listé
|
||||||
|
→ **l'apparence est « up »**, ce qui masque la panne
|
||||||
|
- Mais une **génération réelle ne renvoie jamais rien** : `curl /v1/chat/completions`
|
||||||
|
hang (45 s sans un seul token)
|
||||||
|
- LiteLLM (storage-01) :
|
||||||
|
`litellm.Timeout: APITimeoutError - Request timed out. timeout value=600.0, time taken=600.0s.
|
||||||
|
Received Model Group=hermes-default` (après 2 retries)
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
Le `llama-server` (gpu-01, `:1234`, `qwen3-8b`) était dans un état **wedgé** après
|
||||||
|
~2 jours d'uptime (démarré le 2026-06-17 21:04). Les endpoints de contrôle
|
||||||
|
(`/health`, `/v1/models`) répondaient encore, mais le **slot d'inférence ROCm était
|
||||||
|
bloqué** (vraisemblablement une requête avortée laissée dans le slot). Chaîne du 502 :
|
||||||
|
|
||||||
|
```
|
||||||
|
HUD composer → STT-server (/v1/ask) → LiteLLM :4000 (hermes-default) → llama-server gpu-01 :1234 ← GELÉ
|
||||||
|
```
|
||||||
|
|
||||||
|
LiteLLM attend `request_timeout = 600 s`, échoue, et le STT-server renvoie 502.
|
||||||
|
|
||||||
|
### Diagnostic (commandes utiles)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) STT-server : confirmer que le 502 vient de lui (et pas de Traefik)
|
||||||
|
kubectl -n ai logs <pod stt-server> --tail=50 # → "POST /v1/ask HTTP/1.1" 502
|
||||||
|
|
||||||
|
# 2) LiteLLM : voir le timeout
|
||||||
|
ssh storage-01 'journalctl -u litellm -n 40 --no-pager --output=cat'
|
||||||
|
# → litellm.Timeout ... timeout value=600.0 ... Model Group=hermes-default
|
||||||
|
|
||||||
|
# 3) llama-server : /health MENT — tester une VRAIE génération
|
||||||
|
ssh gpu-01 'curl -s -m 30 http://127.0.0.1:1234/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"model\":\"qwen3-8b\",\"messages\":[{\"role\":\"user\",\"content\":\"Dis bonjour. /no_think\"}],\"max_tokens\":30}"'
|
||||||
|
# gelé (aucune sortie) = wedgé | réponse JSON = sain
|
||||||
|
```
|
||||||
|
|
||||||
|
### Résolution
|
||||||
|
|
||||||
|
Redémarrer le service d'inférence sur gpu-01 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh gpu-01 'sudo systemctl restart llama-server'
|
||||||
|
```
|
||||||
|
|
||||||
|
- L'ancienne instance gelée met **~40 s à mourir** (état `deactivating` le temps que
|
||||||
|
le slot bloqué se libère) — `systemctl restart` peut dépasser un timeout SSH court,
|
||||||
|
c'est normal ; vérifier avec `systemctl is-active llama-server` jusqu'à `active`.
|
||||||
|
- Au redémarrage, `/v1/chat/completions` renvoie `503 {"error":"Loading model"}`
|
||||||
|
pendant ~30 s (rechargement du modèle en VRAM) — **normal, attendre**.
|
||||||
|
- **Validation** : génération directe (`"Bonjour."` en ~80 ms) puis bout-en-bout
|
||||||
|
`POST http://stt.lab.local/v1/ask` → `{"reply":"Bonjour.","model":"hermes-default"}`.
|
||||||
|
|
||||||
|
### Observations / suites (hors présent incident)
|
||||||
|
|
||||||
|
- ⚠️ **Piège du health-check** : `/health` et `/v1/models` restent verts quand le
|
||||||
|
serveur est wedgé → un check qui ne fait **pas une vraie génération** ne voit pas la
|
||||||
|
panne (même angle mort que le crash-loop Qdrant en `activating`, cf. `incidents.md`).
|
||||||
|
- **Latence cerveau** : post-restart, 30-45 s pour une réponse triviale via STT-server
|
||||||
|
alors que le llama-server direct est à ~80 ms → le surcoût vient du pipeline du
|
||||||
|
cerveau (mémoire/embeddings Qdrant `stt-memory` + LiteLLM). À investiguer séparément.
|
||||||
|
- **Bruit LiteLLM** : `Invalid model name model=qwen3-1.7b-monitor` (profil monitor
|
||||||
|
Hermes) dans les logs — pré-existant, non lié à cet incident.
|
||||||
|
|
||||||
|
### Prévention (pistes)
|
||||||
|
|
||||||
|
- **Probe applicative** : sonde qui fait une vraie génération `/v1/chat/completions`
|
||||||
|
(pas seulement `/health`) → alerte si le LLM ne produit plus de tokens.
|
||||||
|
- **Watchdog** : si LiteLLM accumule des `Timeout` sur `hermes-default`, redémarrer
|
||||||
|
automatiquement `llama-server` sur gpu-01.
|
||||||
|
- Réduire le `request_timeout` LiteLLM (600 s est très long → un 502 tardif et opaque).
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
# Incidents — Historique et résolutions
|
# Incidents — Historique et résolutions
|
||||||
|
|
||||||
|
> Incidents **inférence LLM / GPU** (llama-server, ROCm gpu-01, chaîne LiteLLM) :
|
||||||
|
> journal dédié dans [`incidents-llm-gpu.md`](incidents-llm-gpu.md).
|
||||||
|
|
||||||
## 2026-06-05 — Qdrant en crash-loop : segment funk-docs corrompu (RÉSOLU 2026-06-17)
|
## 2026-06-05 — Qdrant en crash-loop : segment funk-docs corrompu (RÉSOLU 2026-06-17)
|
||||||
|
|
||||||
### Symptômes
|
### Symptômes
|
||||||
|
|
@ -48,6 +51,7 @@ curl -s http://127.0.0.1:6333/collections # doit répondre
|
||||||
|
|
||||||
| Date | Composant | Résumé | Sévérité |
|
| Date | Composant | Résumé | Sévérité |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 2026-06-19 | llama-server (gpu-01) | Inférence gelée (`/health` OK, génération bloquée) → 502 LLM — **résolu** par restart. Détail : [`incidents-llm-gpu.md`](incidents-llm-gpu.md) | Important |
|
||||||
| 2026-06-05 | Qdrant | **Résolu 2026-06-17** — crash-loop corrigé (drop `funk-docs`) ; RAG à re-ingérer | Important |
|
| 2026-06-05 | Qdrant | **Résolu 2026-06-17** — crash-loop corrigé (drop `funk-docs`) ; RAG à re-ingérer | Important |
|
||||||
| 2026-05-29 | RAID5 / sdb + storage-01 | sdb ATA bus errors sous I/O — migration /srv/data du RAID vers NVMe | Important |
|
| 2026-05-29 | RAID5 / sdb + storage-01 | sdb ATA bus errors sous I/O — migration /srv/data du RAID vers NVMe | Important |
|
||||||
| 2026-05-27 | RAID5 / sdd | Disque sdd défaillant (44 secteurs morts) — corruptions multiples en cascade | Critique |
|
| 2026-05-27 | RAID5 / sdd | Disque sdd défaillant (44 secteurs morts) — corruptions multiples en cascade | Critique |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "stt"
|
name = "stt"
|
||||||
version = "0.4.2"
|
version = "0.5.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"
|
||||||
|
|
|
||||||
|
|
@ -180,34 +180,49 @@ def _cmd_version() -> int:
|
||||||
|
|
||||||
|
|
||||||
def _cmd_update() -> int:
|
def _cmd_update() -> int:
|
||||||
"""Met à jour STT depuis sa source git (pipx reinstall) — sans désinstall/réinstall manuel."""
|
"""Met à jour STT depuis sa source git, sans désinstall/réinstall manuel.
|
||||||
import re
|
|
||||||
|
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 shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
pipx = shutil.which("pipx")
|
pipx = shutil.which("pipx")
|
||||||
if not pipx:
|
if not pipx:
|
||||||
print("pipx introuvable — mise à jour automatique impossible.")
|
print("pipx introuvable — mise à jour automatique impossible.")
|
||||||
print("Réinstalle manuellement depuis la source git.")
|
|
||||||
return 1
|
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()
|
before = _installed_version()
|
||||||
if before:
|
if before:
|
||||||
print(f"Version installée : {before}")
|
print(f"Version installée : {before}")
|
||||||
print("Récupération de la dernière version depuis git (pipx reinstall)…\n")
|
print("Mise à jour depuis git (pipx upgrade)…\n")
|
||||||
res = subprocess.run([pipx, "reinstall", "stt"], capture_output=True, text=True)
|
res = subprocess.run([pipx, "upgrade", "stt"], capture_output=True, text=True)
|
||||||
out = ((res.stdout or "") + (res.stderr or "")).strip()
|
out = ((res.stdout or "") + (res.stderr or "")).strip()
|
||||||
if out:
|
if out:
|
||||||
print(out)
|
print(out)
|
||||||
m = re.search(r"installed package stt ([0-9][^\s,]*)", out)
|
|
||||||
after = m.group(1) if m else None
|
|
||||||
print()
|
print()
|
||||||
if after and before and after != before:
|
if res.returncode != 0 or shutil.which("stt") is None:
|
||||||
print(f"✓ Mis à jour : {before} → {after}")
|
print("❌ Échec de la mise à jour — ton install précédente est conservée.")
|
||||||
elif after:
|
print(" Réessaie (souvent réseau/SSH), ou en dernier recours :")
|
||||||
print(f"✓ Déjà à jour ({after}).")
|
print(" uv cache clean")
|
||||||
else:
|
if spec:
|
||||||
print("✓ Réinstallation terminée — vérifie avec : stt --version")
|
print(f' pipx install --force "{spec}"')
|
||||||
return res.returncode
|
return res.returncode or 1
|
||||||
|
print("✓ Mise à jour terminée — vérifie avec : stt --version")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _make_client(args):
|
def _make_client(args):
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ renvoyer les réglages.
|
||||||
du personnage si aucune image (voir [`avatars/README.md`](avatars/README.md)).
|
du personnage si aucune image (voir [`avatars/README.md`](avatars/README.md)).
|
||||||
- **Label d'état** : veille / écoute / réflexion / réponse.
|
- **Label d'état** : veille / écoute / réflexion / réponse.
|
||||||
- **Transcript** : bulles utilisateur / assistant (+ tag du modèle) / erreur.
|
- **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.
|
- **Indicateur de connexion** websocket (point + libellé), reconnexion auto.
|
||||||
- **Drawer Réglages** (engrenage) : intensité de l'accent (thème), avatar, voix,
|
- **Drawer Réglages** (engrenage) : intensité de l'accent (thème), avatar, voix,
|
||||||
mot de réveil, mode cerveau (local/avancé), no-think, reset conversation.
|
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":"assistant","text":…,"mode":…}` | bulle assistant (+ tag modèle) |
|
||||||
| `{"type":"error","text":…}` | bulle d'erreur |
|
| `{"type":"error","text":…}` | bulle d'erreur |
|
||||||
|
|
||||||
Envoyés au backend à chaque changement de réglage :
|
Envoyés au backend :
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"settings","data":{"brainMode":"local","wakeword":"…","action":"reset"}}
|
{"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`
|
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).
|
visuels (intensité d'accent, avatar) sont persistés côté navigateur (localStorage).
|
||||||
|
|
||||||
## Prévisualiser sans backend
|
## Prévisualiser sans backend
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,58 @@
|
||||||
max-width: 90%;
|
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)
|
PANNEAU RÉGLAGES (drawer + voile)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
@ -621,6 +673,17 @@
|
||||||
<div class="transcript-wrap">
|
<div class="transcript-wrap">
|
||||||
<div class="transcript" id="transcript" aria-label="Conversation" role="log"></div>
|
<div class="transcript" id="transcript" aria-label="Conversation" role="log"></div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
<!-- ===================== RÉGLAGES (voile + drawer) ===================== -->
|
<!-- ===================== RÉGLAGES (voile + drawer) ===================== -->
|
||||||
|
|
@ -880,6 +943,43 @@ function sendSettings() {
|
||||||
flashSaved();
|
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
|
RÉGLAGES — collecte, persistance (thème en localStorage), UI
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
|
||||||
|
|
@ -77,14 +77,23 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> 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))
|
||||||
# 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:
|
async for raw in ws:
|
||||||
try:
|
try:
|
||||||
msg = json.loads(raw)
|
msg = json.loads(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
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)
|
_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:
|
finally:
|
||||||
clients.discard(ws)
|
clients.discard(ws)
|
||||||
|
|
||||||
|
|
@ -130,13 +139,23 @@ def _open_browser(url: str, mode: str) -> None:
|
||||||
profile = Path.home() / ".local" / "share" / "stt" / "app-profile"
|
profile = Path.home() / ".local" / "share" / "stt" / "app-profile"
|
||||||
profile.mkdir(parents=True, exist_ok=True)
|
profile.mkdir(parents=True, exist_ok=True)
|
||||||
win = ["--kiosk", url] if mode == "kiosk" else [f"--app={url}"]
|
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.
|
# Instance autonome (profil + classe dédiés) — pour les navigateurs natifs.
|
||||||
native = [
|
native = [
|
||||||
"--class=STT-Funk", f"--user-data-dir={profile}",
|
"--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 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)
|
# 1) Binaire natif (rpm/deb)
|
||||||
for browser in (
|
for browser in (
|
||||||
|
|
@ -146,7 +165,7 @@ def _open_browser(url: str, mode: str) -> None:
|
||||||
"microsoft-edge", "vivaldi",
|
"microsoft-edge", "vivaldi",
|
||||||
):
|
):
|
||||||
if shutil.which(browser):
|
if shutil.which(browser):
|
||||||
subprocess.Popen([browser, *native])
|
_spawn([browser, *native])
|
||||||
return
|
return
|
||||||
|
|
||||||
# 2) Flatpak (universel — souvent la voie sur Fedora/AlmaLinux)
|
# 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
|
["flatpak", "info", appid], capture_output=True
|
||||||
).returncode == 0
|
).returncode == 0
|
||||||
if installed:
|
if installed:
|
||||||
subprocess.Popen(["flatpak", "run", appid, *flatpak])
|
_spawn(["flatpak", "run", appid, *flatpak])
|
||||||
return
|
return
|
||||||
|
|
||||||
# 3) Repli : onglet dans le navigateur par défaut
|
# 3) Repli : onglet dans le navigateur par défaut
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
||||||
import queue
|
import queue
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
@ -64,6 +65,8 @@ class VoiceEngine:
|
||||||
self.model_label = model_label
|
self.model_label = model_label
|
||||||
self._whisper = None
|
self._whisper = None
|
||||||
self._stop = False
|
self._stop = False
|
||||||
|
# sérialise un tour (voix ou texte) : pas deux réponses concurrentes
|
||||||
|
self._respond_lock = threading.Lock()
|
||||||
|
|
||||||
# ── chargement modèle ────────────────────────────────────────────────
|
# ── chargement modèle ────────────────────────────────────────────────
|
||||||
def load(self) -> None:
|
def load(self) -> None:
|
||||||
|
|
@ -123,6 +126,7 @@ class VoiceEngine:
|
||||||
|
|
||||||
# ── interrogation du serveur ─────────────────────────────────────────
|
# ── interrogation du serveur ─────────────────────────────────────────
|
||||||
def _respond(self, text: str) -> None:
|
def _respond(self, text: str) -> None:
|
||||||
|
with self._respond_lock: # un seul tour à la fois (voix ⟂ texte)
|
||||||
self.emit({"type": "user", "text": text})
|
self.emit({"type": "user", "text": text})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("user", text)
|
self.memory.log("user", text)
|
||||||
|
|
@ -140,6 +144,18 @@ class VoiceEngine:
|
||||||
self.emit({"type": "state", "state": "speaking"})
|
self.emit({"type": "state", "state": "speaking"})
|
||||||
self._speak(resp)
|
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
|
||||||
|
self._respond(text)
|
||||||
|
self.emit({"type": "state", "state": "idle"})
|
||||||
|
|
||||||
# ── boucle principale (VAD + wake word) ──────────────────────────────
|
# ── boucle principale (VAD + wake word) ──────────────────────────────
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue