mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-10 22:44:41 +02:00
refactor(stt): pivot client-serveur — STT-server in-cluster + client pipx
Sépare STT en deux : - stt/client/ : commande `stt` (pipx), voix locale (Whisper/Piper) + HUD ; envoie le texte au serveur via api.py (ServerClient → POST /v1/ask). URL serveur paramétrable, pas de cerveau local (suppression du routeur 3 modes). - stt/server/ : STT-server FastAPI (conteneur), /healthz + /v1/ask → LiteLLM (Qwen3/Claude). Déploiement cluster : - k8s/apps/stt/ : Deployment, Service, IngressRoute (stt.lab.local), litellm-ext (Service + Endpoints → 192.168.10.1:4000 pour joindre LiteLLM hors cluster) - k8s/apps-of-apps/apps/stt.yaml : Application ArgoCD (depuis main) - .github/workflows/build-stt-server.yml : build/push image → ghcr.io/alkatrazz24/funk-stt-server Inférence/chat seulement (outils Hermes 'agir sur Funk' = phase ultérieure, API :8080 à spécifier). Vérifié : py_compile client+serveur, YAML manifests, ServerClient. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013FmcxGsyXZXogiAHQLjnZT
This commit is contained in:
parent
1ff3fd9bed
commit
6947b394f1
38 changed files with 566 additions and 356 deletions
46
.github/workflows/build-stt-server.yml
vendored
Normal file
46
.github/workflows/build-stt-server.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
name: build-stt-server
|
||||||
|
|
||||||
|
# Construit et pousse l'image du STT-server vers ghcr.io quand stt/server/ change.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main", "claude/**"]
|
||||||
|
paths:
|
||||||
|
- "stt/server/**"
|
||||||
|
- ".github/workflows/build-stt-server.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/alkatrazz24/funk-stt-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Metadata (tags)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Build & push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: stt/server
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
159
admin/ia/stt.md
159
admin/ia/stt.md
|
|
@ -1,15 +1,16 @@
|
||||||
# STT — Assistant vocal "Jarvis" du homelab Funk
|
# STT — Assistant vocal "Jarvis" du homelab Funk
|
||||||
|
|
||||||
**STT** est l'interface vocale et graphique de Funk : un assistant type *Jarvis / Iron Man*
|
**STT** est l'interface vocale et graphique de Funk : un assistant type *Jarvis / Iron Man*
|
||||||
qui écoute, parle, affiche un HUD animé, et **agit sur le homelab** via Hermes Agent.
|
qui écoute, parle et affiche un HUD animé. **Architecture client-serveur** : un client `stt`
|
||||||
Il s'installe sur le poste perso comme **une commande unique** (`stt`) et démarre au boot.
|
sur le poste (voix + HUD) qui interroge un **STT-server in-cluster** (orchestration AI).
|
||||||
|
Objectif ultérieur : « agir sur le homelab » via les outils de Hermes (phase 7).
|
||||||
|
|
||||||
> **Nom de code** : `STT`. À l'origine « Speech-To-Text », mais le projet couvre toute la
|
> **Nom de code** : `STT`. À l'origine « Speech-To-Text », mais le projet couvre toute la
|
||||||
> chaîne voix → cerveau → voix + interface + mémoire. Renommable plus tard sans impact
|
> chaîne voix → cerveau → voix + interface + mémoire. Renommable plus tard sans impact
|
||||||
> technique (FRIDAY, etc.) — c'est juste un identifiant de répertoire/commande.
|
> technique (FRIDAY, etc.) — c'est juste un identifiant de répertoire/commande.
|
||||||
|
|
||||||
> ⚠️ **Conception validée (2026-06-17)** — squelette en place, implémentation par phases
|
> ⚠️ **2026-06-17** — client (phase 1) + serveur (phase 2) + manifests cluster (phase 3) écrits.
|
||||||
> (voir Roadmap). Aucun composant n'est encore déployé sur le poste.
|
> Reste : build image (CI), déploiement ArgoCD (après merge sur `main`), test audio sur poste.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -34,74 +35,53 @@ Maillon réellement manquant : **l'interface graphique HUD** + le packaging + la
|
||||||
|
|
||||||
| Décision | Choix retenu |
|
| Décision | Choix retenu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Interface | **HUD web sur-mesure** (canvas/WebGL) + backend Python local |
|
| Architecture | **Client-serveur** : client sur le poste, STT-server **in-cluster** (révise le tout-local) |
|
||||||
| STT / TTS | **Local sur le poste** (faster-whisper CPU + Piper) |
|
| Interface | **HUD web sur-mesure** côté client (canvas/WebGL) |
|
||||||
| Packaging | **Commande `stt` via pipx** (`pipx install git+…#subdirectory=stt`) |
|
| STT / TTS | **Local sur le poste** (faster-whisper CPU + Piper) — le serveur ne touche pas à l'audio |
|
||||||
| Cerveau | **3 modes + auto-détection LAN** (voir ci-dessous) |
|
| Packaging | Client : **commande `stt` via pipx** (`#subdirectory=stt/client`). Serveur : **conteneur** (ghcr) déployé par ArgoCD |
|
||||||
| Mémoire | **3 tiers** : local (SQLite) + s01 (Qdrant) + GitHub (distillée, versionnée) |
|
| Cerveau | **Côté serveur** : route vers LiteLLM `:4000` (Qwen3 / Claude). Outils Hermes = phase ultérieure |
|
||||||
|
| Client → serveur | Client **serveur-only** (pas de repli Claude). URL serveur **paramétrable** |
|
||||||
|
| Mémoire | **Côté serveur** (futur) : Qdrant s01 + distillée GitHub. Client : cache local SQLite |
|
||||||
|
|
||||||
|
> Pivot 2026-06-17 (post-test) : on est passé du tout-local à un modèle client-serveur.
|
||||||
|
> Le « cerveau » (ex-routeur 3 modes côté client) a migré côté serveur.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─ POSTE PERSO — commande `stt` (systemd --user, démarrage auto) ─────┐
|
LAN / *.lab.local
|
||||||
│ │
|
┌─ POSTE — client `stt` (pipx) ─┐ ┌─ CLUSTER k8s (namespace ai) ─────────┐
|
||||||
│ ┌──────────────┐ websocket ┌───────────────────────────┐ │
|
│ • micro + VAD + wake word │ HTTP │ STT-server (Deployment + Service) │
|
||||||
│ │ HUD web │ ◀────────────▶ │ backend `stt` (Python) │ │
|
│ • faster-whisper (STT) │ ─────▶ │ GET /healthz │
|
||||||
│ │ (kiosk) │ états + │ • micro + VAD + wake word │ │
|
│ • Piper (TTS) │ POST │ POST /v1/ask {text} → {reply} │
|
||||||
│ │ • visualiseur│ transcript │ • faster-whisper (STT) │ │
|
│ • HUD web (ui/ + hud/) │ /v1/ask│ brain → LiteLLM (httpx) │
|
||||||
│ │ • avatar │ + réponses │ • Piper (TTS) │ │
|
│ • api.py → ServerClient │ ◀───── │ IngressRoute : stt.lab.local │
|
||||||
│ │ • thème │ │ • routeur cerveau (3 modes)│ │
|
└────────────────────────────────┘ reply │ Service Endpoints : litellm-ext │
|
||||||
│ │ • réglages │ │ • gestionnaire mémoire │ │
|
└──────────────┬─────────────────────────┘
|
||||||
│ └──────────────┘ └───┬─────────────┬──────────┘ │
|
▼
|
||||||
│ │ │ │
|
LiteLLM :4000 (storage-01, hors cluster)
|
||||||
│ mémoire │ │ cerveau │
|
→ Qwen3 (g01) / Claude (hermes-default)
|
||||||
│ ┌────────────────────┘ │ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ SQLite local + cache (selon mode + LAN) │
|
|
||||||
│ git pull/push GitHub │
|
|
||||||
└───────────────────────────────────────────────────┼─────────────────┘
|
|
||||||
│ LAN (192.168.1.200)
|
|
||||||
┌───────────────────────────────────┼──────────────────┐
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
Hermes :8080 LiteLLM :4000 Qdrant :6333 API Anthropic
|
|
||||||
(mode hermes) (mode local-direct) (mémoire s01) (mode claude-direct,
|
|
||||||
agit sur Funk chat g01/Claude sémantique hors-LAN, chat seul)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Le cerveau — 3 modes + auto-détection
|
### Le cerveau — côté serveur
|
||||||
|
|
||||||
Au démarrage (et périodiquement), `stt` ping s01 (`192.168.1.200`) :
|
Le STT-server appelle **LiteLLM `:4000`** (OpenAI-compatible), joint depuis le cluster via
|
||||||
|
un Service sans sélecteur + Endpoints manuel (`litellm-ext` → `192.168.10.1:4000`). LiteLLM
|
||||||
|
route lui-même vers Qwen3 (g01) ou Claude selon l'alias `hermes-default` / `hermes-switch`.
|
||||||
|
|
||||||
| Mode | Déclenché quand | Chemin | Agit sur Funk ? | Coût |
|
> **« Agir sur Funk »** (outils de l'agent Hermes via le gateway `:8080`) n'est **pas** dans le
|
||||||
|---|---|---|---|---|
|
> MVP : l'API `:8080` n'est pas spécifiée, et depuis un pod le SSH vers Hermes est impraticable.
|
||||||
| **`hermes`** | LAN OK (défaut maison) | PC → Hermes `:8080` (route Qwen3 g01 ou Claude via `hermes-switch`) | ✅ oui | gratuit/local sauf si switch claude |
|
> MVP = inférence/chat. L'intégration Hermes est une étape ultérieure.
|
||||||
| **`local-direct`** | LAN OK, chat pur voulu | PC → LiteLLM `:4000` (Qwen3 g01) | ❌ chat seul | gratuit |
|
|
||||||
| **`claude-direct`** | hors-LAN (nomade) | PC → API Anthropic (clé locale) | ❌ (Funk injoignable) | facturé |
|
|
||||||
|
|
||||||
> **Clé de lecture** : « agir sur Funk » vit dans Hermes (sur s01). À la maison, même pour
|
### La mémoire — côté serveur (futur)
|
||||||
> l'intelligence de Claude, on passe par Hermes (`hermes-switch claude`) → pouvoirs Funk
|
|
||||||
> conservés. Le mode `claude-direct` est le fallback nomade (Funk inatteignable de toute façon).
|
|
||||||
> Auto-détection par défaut, **override manuel** dans les réglages.
|
|
||||||
|
|
||||||
### La mémoire — 3 tiers
|
- **Client** : cache local SQLite (`~/.local/share/stt/`, gitignoré) — historique brut.
|
||||||
|
- **Serveur** (à venir) : Qdrant s01 (sémantique, partagé RAG) + mémoire **distillée**
|
||||||
|
versionnée dans `stt/server/memory/distilled/`. Sessions multi-clients.
|
||||||
|
|
||||||
| Tier | Support | Contenu | Disponibilité |
|
> ⚠️ **Vie privée** : seule la mémoire distillée serait committée. Repo privé impératif.
|
||||||
|---|---|---|---|
|
|
||||||
| **Local** | SQLite + cache (`stt/memory/` local, gitignoré) | Historique brut récent, working memory | Toujours (offline) |
|
|
||||||
| **s01** | Qdrant `:6333`, collection dédiée STT | Mémoire sémantique long-terme, partagée avec le RAG Funk | Sur LAN |
|
|
||||||
| **GitHub** | `stt/memory/distilled/` (versionné) | Mémoire **distillée** : faits, préférences, résumés — source de vérité portable | Via git pull/push |
|
|
||||||
|
|
||||||
**Flux de synchro :**
|
|
||||||
1. Démarrage → `git pull` la mémoire distillée GitHub → charge en local.
|
|
||||||
2. Pendant l'usage → écriture immédiate en SQLite local.
|
|
||||||
3. Périodiquement / à l'arrêt → distillation (résumé via le cerveau) → `git commit` GitHub
|
|
||||||
**+** (si LAN) embedding dans Qdrant s01.
|
|
||||||
4. Sur nouveau poste / réinstall → GitHub re-seed local + Qdrant.
|
|
||||||
|
|
||||||
> ⚠️ **Vie privée** : seule la mémoire **distillée** est committée (faits/préférences).
|
|
||||||
> Les transcripts bruts restent locaux et **gitignorés**. Repo privé impératif.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -109,56 +89,53 @@ Au démarrage (et périodiquement), `stt` ping s01 (`192.168.1.200`) :
|
||||||
|
|
||||||
| Composant | Emplacement | Description |
|
| Composant | Emplacement | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Commande `stt` | `stt/pyproject.toml` → entrypoint | `stt` (lance), `stt --setup` (install Whisper/Piper/voix), `stt --stop`, `stt --mode <hermes\|local\|claude>` |
|
| Client `stt` | `stt/client/` (pipx) | `stt`, `stt --setup`, `stt --server <url>`, `stt --no-tts` |
|
||||||
| Backend | `stt/stt/` (package Python) | Extension de `tools/hermes-voice.py` : wake word, STT, TTS, routeur cerveau, mémoire, serveur websocket |
|
| — voix | `stt/client/stt/voice/` | wake word, STT faster-whisper, TTS Piper |
|
||||||
| HUD web | `stt/stt/hud/` | Front statique (HTML/JS/canvas) embarqué dans le package (livré par pipx) : visualiseur animé, conversation, écran de réglages. Servi localement, ouvert en kiosk |
|
| — api | `stt/client/stt/api.py` | `ServerClient` → `POST /v1/ask` |
|
||||||
| Config | `stt/config/` | `stt.example.toml` + thèmes, avatars, voix Piper |
|
| — UI/HUD | `stt/client/stt/ui/` + `hud/` | HTTP statique + websocket d'états ; HUD embarqué dans le package |
|
||||||
| Mémoire | `stt/memory/` | `distilled/` versionné + raw local gitignoré |
|
| STT-server | `stt/server/` (conteneur) | FastAPI : `/healthz`, `/v1/ask` ; `brain.py` → LiteLLM |
|
||||||
| Auto-start | `stt/install-service.sh` | `systemd --user` : backend + ouverture HUD au login |
|
| Image | `ghcr.io/alkatrazz24/funk-stt-server` | construite par `.github/workflows/build-stt-server.yml` |
|
||||||
| (opt.) cluster | `k8s/apps/stt/` | Phase 6 : STT/TTS déportés en Wyoming sur gpu-01 |
|
| Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`), `litellm-ext` |
|
||||||
|
| Application ArgoCD | `k8s/apps-of-apps/apps/stt.yaml` | déploie depuis `main` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Personnalisation (« l'image, le design, la voix »)
|
## Personnalisation (« l'image, le design, la voix »)
|
||||||
|
|
||||||
Pilotable depuis `stt/config/` + l'écran de réglages du HUD, sans recompiler :
|
Côté client, pilotable depuis `stt/client/config/` + l'écran de réglages du HUD :
|
||||||
|
|
||||||
- **Avatar / image** : asset swappable (`config/avatars/`).
|
- **Avatar / image** (`config/avatars/`), **thème** (`config/themes/`),
|
||||||
- **Thème / design** : couleurs, fond, style du visualiseur (`config/themes/`).
|
**voix** Piper `.onnx` (`config/voices/`), **wake word**, **URL serveur**.
|
||||||
- **Voix** : modèle Piper `.onnx` interchangeable (`config/voices/`), fr_FR par défaut.
|
|
||||||
- **Wake word** : configurable (défaut « Ok Hermès »).
|
|
||||||
- **Cerveau** : mode par défaut + override, clé API Anthropic locale (pour `claude-direct`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prérequis / dépendances
|
## Prérequis / dépendances
|
||||||
|
|
||||||
- **Poste** : micro, Python 3.11+, pipx, navigateur (kiosk). `stt --setup` gère Whisper/Piper/voix.
|
- **Poste** : micro, Python 3.11+, pipx, navigateur. Piper + voix dans `~/.local/share/piper/`, `aplay`.
|
||||||
- **LAN** : accès `192.168.1.200` (s01) pour modes `hermes`/`local-direct` + Qdrant.
|
- **Cluster** : image poussée sur ghcr ; ArgoCD déploie **depuis `main`** (donc merge requis) ;
|
||||||
- **Qdrant réparé** requis pour la mémoire tier s01 (crash-loop depuis 05/06, voir
|
ghcr privé ⇒ éventuel `imagePullSecret`.
|
||||||
`admin/incidents.md`). **Non bloquant** : MVP fonctionne avec mémoire locale + GitHub seules.
|
- **LiteLLM** joignable depuis le cluster (`192.168.10.1:4000`).
|
||||||
- **Clé Anthropic** locale (poste) pour le mode `claude-direct` nomade.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
| Phase | Objectif | Livrable | État |
|
| Phase | Objectif | État |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| **0 — Cadrage** | Conception validée | doc + squelette `stt/` | ✅ fait |
|
| **0 — Cadrage** | Conception validée | ✅ |
|
||||||
| **1 — Commande + backend** | `stt`/`stt --setup` lancent le moteur vocal + websocket | `pyproject.toml`, `stt/` (cli, config, voice, server) | ✅ fait |
|
| **1 — Client voix + HUD** | `stt` : voix locale + HUD + websocket | ✅ |
|
||||||
| **2 — Routeur cerveau** | 3 modes + auto-détection LAN | module `brain` (hermes/local/claude) | ✅ fait |
|
| **2 — STT-server** | FastAPI `/v1/ask` → LiteLLM | ✅ |
|
||||||
| **3 — HUD** | Visualiseur + conversation | `stt/stt/hud/index.html` (MVP) → visualiseur avancé | 🟡 MVP fait |
|
| **3 — Déploiement cluster** | image ghcr + manifests k8s + ArgoCD + `litellm-ext` | ✅ (à merger sur `main`) |
|
||||||
| **4 — Mémoire 3 tiers** | local ✅ + GitHub + Qdrant | `memory` : SQLite ✅ ; distillation/sync git + Qdrant | 🟡 local fait |
|
| **4 — HUD avancé** | visualiseur arc-reactor + thèmes + écran réglages | ⏳ |
|
||||||
| **5 — Personnalisation** | Thèmes / avatar / voix / wake word | écran réglages + `stt/config/` | ⏳ |
|
| **5 — Mémoire serveur** | Qdrant s01 + distillée GitHub + sessions | ⏳ |
|
||||||
| **6 — Auto-start** | Démarre au boot du poste | `install-service.sh` + kiosk | ⏳ |
|
| **6 — Auto-start client** | `install-service.sh` (systemd --user) + kiosk | ⏳ |
|
||||||
| **7 — Cluster (opt.)** | STT/TTS sur GPU | Wyoming sur gpu-01 + `k8s/apps/stt/` | ⏳ |
|
| **7 — Outils Hermes** | « agir sur Funk » via gateway `:8080` (API à spécifier) | ⏳ |
|
||||||
|
|
||||||
### État phase 1-2 (testé hors-LAN)
|
### État (testé hors-LAN)
|
||||||
|
|
||||||
`py_compile` OK sur tout le package ; `stt --help`, chargement config, routeur cerveau
|
`py_compile` OK (client + serveur) ; client : `stt --help`, config, `ServerClient` vérifiés ;
|
||||||
(auto-détection → `claude` hors-LAN) et mémoire SQLite locale vérifiés. Le test bout-en-bout
|
serveur : import/compile OK. Reste à valider sur cible : build image (CI), déploiement ArgoCD
|
||||||
audio (Whisper/Piper/micro) se fait sur le poste après `pipx install` + `stt --setup`.
|
après merge, et test audio bout-en-bout sur le poste.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
24
k8s/apps-of-apps/apps/stt.yaml
Normal file
24
k8s/apps-of-apps/apps/stt.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: stt
|
||||||
|
namespace: argocd
|
||||||
|
finalizers:
|
||||||
|
- resources-finalizer.argocd.argoproj.io
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: git@github.com:Alkatrazz24/Funk-lab.git
|
||||||
|
targetRevision: main
|
||||||
|
path: k8s/apps/stt
|
||||||
|
directory:
|
||||||
|
recurse: true
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: ai
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
47
k8s/apps/stt/deployment.yaml
Normal file
47
k8s/apps/stt/deployment.yaml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: stt-server
|
||||||
|
namespace: ai
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: stt-server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: stt-server
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: stt-server
|
||||||
|
image: ghcr.io/alkatrazz24/funk-stt-server:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
# LiteLLM (s01) joint via le Service Endpoints litellm-ext
|
||||||
|
- name: STT_LITELLM_URL
|
||||||
|
value: "http://litellm-ext:4000/v1/chat/completions"
|
||||||
|
- name: STT_LITELLM_KEY
|
||||||
|
value: "lm-studio"
|
||||||
|
- name: STT_MODEL
|
||||||
|
value: "hermes-default"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 20
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
14
k8s/apps/stt/ingress.yaml
Normal file
14
k8s/apps/stt/ingress.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: IngressRoute
|
||||||
|
metadata:
|
||||||
|
name: stt-server
|
||||||
|
namespace: ai
|
||||||
|
spec:
|
||||||
|
entryPoints:
|
||||||
|
- web
|
||||||
|
routes:
|
||||||
|
- match: Host(`stt.lab.local`)
|
||||||
|
kind: Rule
|
||||||
|
services:
|
||||||
|
- name: stt-server
|
||||||
|
port: 8000
|
||||||
22
k8s/apps/stt/litellm-external.yaml
Normal file
22
k8s/apps/stt/litellm-external.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# LiteLLM tourne sur storage-01 (HORS cluster) — on l'expose comme un Service interne
|
||||||
|
# via un Service sans sélecteur + Endpoints manuel pointant vers l'IP LAN cluster de s01.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: litellm-ext
|
||||||
|
namespace: ai
|
||||||
|
spec:
|
||||||
|
ports:
|
||||||
|
- port: 4000
|
||||||
|
targetPort: 4000
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Endpoints
|
||||||
|
metadata:
|
||||||
|
name: litellm-ext
|
||||||
|
namespace: ai
|
||||||
|
subsets:
|
||||||
|
- addresses:
|
||||||
|
- ip: 192.168.10.1 # storage-01, IP LAN cluster (cf. CLAUDE.md)
|
||||||
|
ports:
|
||||||
|
- port: 4000
|
||||||
11
k8s/apps/stt/service.yaml
Normal file
11
k8s/apps/stt/service.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: stt-server
|
||||||
|
namespace: ai
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: stt-server
|
||||||
|
ports:
|
||||||
|
- port: 8000
|
||||||
|
targetPort: 8000
|
||||||
|
|
@ -1,59 +1,56 @@
|
||||||
# STT — Assistant vocal "Jarvis" de Funk
|
# STT — Assistant vocal "Jarvis" de Funk
|
||||||
|
|
||||||
Assistant vocal + HUD pour le poste perso, branché sur le homelab Funk.
|
Assistant vocal type *Jarvis* pour le homelab Funk, en **architecture client-serveur** :
|
||||||
Écoute (wake word « Ok Hermès »), parle (Piper), affiche un HUD animé, et **agit sur Funk**
|
|
||||||
via Hermes Agent — ou discute avec Claude directement en déplacement.
|
|
||||||
|
|
||||||
> 📐 **Conception complète** : `admin/ia/stt.md`. Ce dossier est le squelette du projet ;
|
- **`server/`** — STT-server : orchestrateur AI **dans le cluster k8s**. Reçoit les requêtes
|
||||||
> l'implémentation se fait par phases (voir la Roadmap dans le doc de conception).
|
des clients et route l'inférence vers LiteLLM (Qwen3 / Claude). Mémoire centralisée (futur).
|
||||||
|
- **`client/`** — commande `stt` installable sur le poste : capture micro, STT (Whisper),
|
||||||
|
TTS (Piper), **HUD graphique**. Envoie le texte au serveur (URL paramétrable).
|
||||||
|
|
||||||
## Installation (cible : poste perso)
|
> 📐 Conception complète : `admin/ia/stt.md`.
|
||||||
|
|
||||||
```bash
|
```
|
||||||
pipx install "git+https://github.com/Alkatrazz24/Funk-lab.git#subdirectory=stt"
|
LAN / *.lab.local
|
||||||
stt --setup # télécharge le modèle Whisper, Piper et la voix fr_FR
|
┌─ POSTE ──────────┐ ┌─ CLUSTER k8s (ns ai) ──────────────┐
|
||||||
stt # lance le backend + ouvre le HUD
|
│ client `stt` │ HTTP │ STT-server (Deployment) │
|
||||||
|
│ • micro + Whisper│ ─────▶ │ POST /v1/ask → LiteLLM │
|
||||||
|
│ • Piper (TTS) │ ◀───── │ (litellm-ext → s01:4000) │
|
||||||
|
│ • HUD web │ reply │ Ingress: stt.lab.local │
|
||||||
|
└───────────────────┘ └──────────────┬──────────────────────┘
|
||||||
|
▼
|
||||||
|
LiteLLM :4000 (s01)
|
||||||
|
→ Qwen3 (g01) / Claude
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commandes prévues
|
## Client — installation (poste)
|
||||||
|
|
||||||
| Commande | Effet |
|
```bash
|
||||||
|---|---|
|
pipx install "git+ssh://git@github.com/Alkatrazz24/Funk-lab.git@<branche>#subdirectory=stt/client"
|
||||||
| `stt` | Lance le backend vocal + ouvre le HUD en kiosk |
|
stt --setup # génère ~/.config/stt/stt.toml (dont [server] url)
|
||||||
| `stt --setup` | Première install : Whisper, Piper, voix, config par défaut |
|
stt # lance la voix + ouvre le HUD ; dis "Ok Hermès …"
|
||||||
| `stt --mode hermes\|local\|claude` | Force le mode cerveau (sinon auto-détection LAN) |
|
```
|
||||||
| `stt --no-tts` | Réponses texte seulement (debug) |
|
Prérequis poste : micro, Piper + voix dans `~/.local/share/piper/`, `aplay` (alsa-utils).
|
||||||
| `stt --stop` | Arrête le service |
|
|
||||||
|
## Serveur — déploiement (cluster)
|
||||||
|
|
||||||
|
1. **Image** : poussée sur `ghcr.io/alkatrazz24/funk-stt-server` par le workflow
|
||||||
|
`.github/workflows/build-stt-server.yml` (sur push touchant `stt/server/`).
|
||||||
|
2. **ArgoCD** : `k8s/apps-of-apps/apps/stt.yaml` → déploie `k8s/apps/stt/` **depuis `main`**.
|
||||||
|
Tant que ce n'est pas mergé sur `main`, le cluster ne le prend pas.
|
||||||
|
3. Le serveur joint LiteLLM (s01, hors cluster) via le Service `litellm-ext`
|
||||||
|
(Endpoints manuel → `192.168.10.1:4000`).
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
stt/
|
stt/
|
||||||
├── pyproject.toml # définit la commande `stt` (entrypoint pipx)
|
├── client/ # commande `stt` (pipx) — voix + HUD
|
||||||
├── stt/ # package Python — backend
|
│ ├── pyproject.toml
|
||||||
│ ├── cli.py # entrypoint `stt`
|
│ ├── config/ # stt.example.toml + thèmes/avatars/voix
|
||||||
│ ├── config.py # défauts + ~/.config/stt/stt.toml
|
│ └── stt/ # cli, config, voice/, api.py (→ serveur), ui/ (HUD), hud/
|
||||||
│ ├── voice/ # wake word, STT (faster-whisper), TTS (Piper)
|
└── server/ # STT-server (conteneur) — API + orchestration AI
|
||||||
│ ├── brain/ # routeur 3 modes : hermes / local-direct / claude-direct
|
├── pyproject.toml
|
||||||
│ ├── memory/ # mémoire 3 tiers (local SQLite ✅, s01/GitHub phase 4)
|
├── Dockerfile
|
||||||
│ ├── server/ # HTTP statique (HUD) + websocket (états → HUD)
|
├── memory/ # mémoire distillée GitHub (futur, server-side)
|
||||||
│ └── hud/ # front web embarqué dans le package (livré par pipx)
|
└── stt_server/ # app.py (FastAPI), brain.py (→ LiteLLM), config.py
|
||||||
├── config/ # stt.example.toml + thèmes / avatars / voix
|
|
||||||
├── memory/ # distilled/ (versionné) + raw local (gitignoré)
|
|
||||||
└── install-service.sh # systemd --user (démarrage auto) — phase 6
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Cerveau — 3 modes
|
|
||||||
|
|
||||||
Auto-détection au démarrage (ping s01 `192.168.1.200`) :
|
|
||||||
- **`hermes`** (LAN) → Hermes `:8080`, agit sur Funk.
|
|
||||||
- **`local-direct`** (LAN) → LiteLLM `:4000`, chat Qwen3 sur g01.
|
|
||||||
- **`claude-direct`** (hors-LAN) → API Anthropic, chat seul.
|
|
||||||
|
|
||||||
## Mémoire — 3 tiers
|
|
||||||
|
|
||||||
- **Local** : SQLite, historique brut, offline (gitignoré).
|
|
||||||
- **s01** : Qdrant `:6333`, sémantique long-terme (partagée RAG Funk, sur LAN).
|
|
||||||
- **GitHub** : `memory/distilled/`, mémoire distillée versionnée, portable.
|
|
||||||
|
|
||||||
> ⚠️ Seule la mémoire distillée est committée. Transcripts bruts gitignorés. Repo privé.
|
|
||||||
|
|
|
||||||
28
stt/client/config/stt.example.toml
Normal file
28
stt/client/config/stt.example.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Configuration du CLIENT STT — copier vers ~/.config/stt/stt.toml (`stt --setup` le génère).
|
||||||
|
# Reflète les défauts embarqués (stt/client/stt/config.py : DEFAULT_CONFIG).
|
||||||
|
# Le client n'a plus de cerveau : il envoie le texte au STT-server (in-cluster).
|
||||||
|
|
||||||
|
[server]
|
||||||
|
url = "http://stt.lab.local" # endpoint du STT-server (paramétrable)
|
||||||
|
timeout_sec = 90
|
||||||
|
|
||||||
|
[voice]
|
||||||
|
wake_word = "hermes" # "Ok Hermès"
|
||||||
|
whisper_model = "large-v3" # STT faster-whisper (CPU int8) — "small" pour tester
|
||||||
|
piper_voice = "fr_FR-upmc-medium" # TTS — ~/.local/share/piper/<voix>.onnx
|
||||||
|
language = "fr"
|
||||||
|
silence_sec = 1.2
|
||||||
|
min_speech_sec = 0.6
|
||||||
|
chat_timeout_sec = 60
|
||||||
|
|
||||||
|
[ui]
|
||||||
|
theme = "arc-reactor"
|
||||||
|
avatar = "default"
|
||||||
|
kiosk = true
|
||||||
|
ws_host = "127.0.0.1"
|
||||||
|
ws_port = 9300 # websocket états → HUD (doit matcher hud/index.html)
|
||||||
|
http_port = 9301 # HTTP statique qui sert le HUD
|
||||||
|
|
||||||
|
[memory]
|
||||||
|
enabled = true # cache local de conversation (brut, gitignoré)
|
||||||
|
local_db = "~/.local/share/stt/memory.sqlite"
|
||||||
|
|
@ -1,20 +1,18 @@
|
||||||
[project]
|
[project]
|
||||||
name = "stt"
|
name = "stt"
|
||||||
version = "0.0.1"
|
version = "0.1.0"
|
||||||
description = "STT — assistant vocal Jarvis du homelab Funk (voix + HUD + Hermes/Claude)"
|
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"
|
||||||
|
|
||||||
# Réutilise les dépendances éprouvées de tools/hermes-voice + ajouts STT
|
# Client : voix locale + HUD. L'inférence est déléguée au STT-server (pas d'anthropic/qdrant ici).
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"faster-whisper>=1.0.0", # STT local
|
"faster-whisper>=1.0.0", # STT local
|
||||||
"sounddevice>=0.4.6", # capture micro
|
"sounddevice>=0.4.6", # capture micro
|
||||||
"numpy>=1.24.0",
|
"numpy>=1.24.0",
|
||||||
"requests>=2.31.0", # appels Hermes / LiteLLM
|
"requests>=2.31.0", # appels HTTP vers le STT-server
|
||||||
"webrtcvad-wheels>=2.0.10",# VAD / wake word
|
"webrtcvad-wheels>=2.0.10",# VAD / wake word
|
||||||
"websockets>=12.0", # backend ↔ HUD
|
"websockets>=12.0", # backend ↔ HUD
|
||||||
"anthropic>=0.40.0", # mode claude-direct (hors-LAN)
|
|
||||||
"qdrant-client>=1.13.0", # mémoire tier s01
|
|
||||||
"tomli-w>=1.0.0", # écriture config TOML
|
"tomli-w>=1.0.0", # écriture config TOML
|
||||||
]
|
]
|
||||||
|
|
||||||
35
stt/client/stt/api.py
Normal file
35
stt/client/stt/api.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Client HTTP vers le STT-server (in-cluster).
|
||||||
|
|
||||||
|
Le client n'a plus de cerveau : il envoie le texte transcrit au serveur, qui
|
||||||
|
orchestre l'inférence (LiteLLM → Qwen3/Claude) et renvoie la réponse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ServerClient:
|
||||||
|
def __init__(self, cfg: dict[str, Any]):
|
||||||
|
self.url = cfg["url"].rstrip("/")
|
||||||
|
self.timeout = cfg.get("timeout_sec", 90)
|
||||||
|
|
||||||
|
def ask(self, text: str) -> str:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
r = requests.post(
|
||||||
|
f"{self.url}/v1/ask",
|
||||||
|
json={"text": text},
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["reply"].strip()
|
||||||
|
|
||||||
|
def healthy(self) -> bool:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{self.url}/healthz", timeout=5)
|
||||||
|
return r.ok
|
||||||
|
except requests.RequestException:
|
||||||
|
return False
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Entrypoint de la commande `stt`."""
|
"""Entrypoint de la commande `stt` (client)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -12,33 +12,37 @@ def _cmd_setup() -> int:
|
||||||
|
|
||||||
path = write_default_config()
|
path = write_default_config()
|
||||||
print(f"✓ Config : {path}")
|
print(f"✓ Config : {path}")
|
||||||
print("\nÀ installer séparément sur le poste :")
|
print("\nÀ installer / configurer sur le poste :")
|
||||||
print(" • modèle Whisper (téléchargé au 1er lancement par faster-whisper)")
|
print(" • modèle Whisper (téléchargé au 1er lancement par faster-whisper)")
|
||||||
print(" • Piper + voix → ~/.local/share/piper/<voix>.onnx")
|
print(" • Piper + voix → ~/.local/share/piper/<voix>.onnx")
|
||||||
print(" • clé ANTHROPIC_API_KEY (env) pour le mode claude-direct")
|
print(f" • URL du STT-server dans {path} ([server] url)")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_run(args) -> int:
|
def _cmd_run(args) -> int:
|
||||||
from stt.brain import BrainRouter
|
from stt.api import ServerClient
|
||||||
from stt.config import load_config
|
from stt.config import load_config
|
||||||
from stt.memory import LocalMemory
|
from stt.memory import LocalMemory
|
||||||
from stt.server import serve
|
from stt.ui import serve
|
||||||
from stt.voice import VoiceEngine
|
from stt.voice import VoiceEngine
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
if args.mode:
|
if args.server:
|
||||||
cfg["brain"]["mode"] = args.mode
|
cfg["server"]["url"] = args.server
|
||||||
|
|
||||||
brain = BrainRouter(cfg["brain"])
|
client = ServerClient(cfg["server"])
|
||||||
print(f"Cerveau : mode '{brain.mode}' (configuré : '{brain._configured}')")
|
print(f"STT-server : {client.url}")
|
||||||
|
if not client.healthy():
|
||||||
|
print(" ⚠️ serveur injoignable — vérifie [server] url et le réseau")
|
||||||
|
|
||||||
memory = None
|
memory = None
|
||||||
if cfg["memory"].get("enabled"):
|
if cfg["memory"].get("enabled"):
|
||||||
memory = LocalMemory(cfg["memory"]["local_db"])
|
memory = LocalMemory(cfg["memory"]["local_db"])
|
||||||
|
|
||||||
def make_engine(emit):
|
def make_engine(emit):
|
||||||
return VoiceEngine(cfg["voice"], brain, emit, memory=memory, no_tts=args.no_tts)
|
return VoiceEngine(
|
||||||
|
cfg["voice"], client.ask, emit, memory=memory, no_tts=args.no_tts
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(serve(cfg, make_engine))
|
asyncio.run(serve(cfg, make_engine))
|
||||||
|
|
@ -51,10 +55,9 @@ def _cmd_run(args) -> int:
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
p = argparse.ArgumentParser(prog="stt", description="Assistant vocal Jarvis de Funk")
|
p = argparse.ArgumentParser(prog="stt", description="Client vocal Jarvis de Funk")
|
||||||
p.add_argument("--setup", action="store_true", help="Écrit la config par défaut")
|
p.add_argument("--setup", action="store_true", help="Écrit la config par défaut")
|
||||||
p.add_argument("--mode", choices=["hermes", "local", "claude"],
|
p.add_argument("--server", help="Force l'URL du STT-server (sinon config)")
|
||||||
help="Force le mode cerveau (sinon auto-détection LAN)")
|
|
||||||
p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement")
|
p.add_argument("--no-tts", action="store_true", help="Réponses texte seulement")
|
||||||
args = p.parse_args(argv)
|
args = p.parse_args(argv)
|
||||||
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"""Chargement / écriture de la configuration STT.
|
"""Configuration du client STT.
|
||||||
|
|
||||||
Les défauts sont embarqués (pas de data-file → robuste pour pipx). `stt --setup`
|
Défauts embarqués (robuste pour pipx). `stt --setup` les écrit dans
|
||||||
écrit ces défauts dans ~/.config/stt/stt.toml ; au lancement on fusionne le fichier
|
~/.config/stt/stt.toml ; au lancement on fusionne le fichier utilisateur par-dessus.
|
||||||
utilisateur par-dessus les défauts.
|
|
||||||
|
Le client ne contient PLUS le cerveau : il envoie le texte au STT-server (URL paramétrable).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -16,22 +17,10 @@ CONFIG_PATH = CONFIG_DIR / "stt.toml"
|
||||||
DATA_DIR = Path.home() / ".local" / "share" / "stt"
|
DATA_DIR = Path.home() / ".local" / "share" / "stt"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict[str, Any] = {
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"brain": {
|
"server": {
|
||||||
"mode": "auto", # auto | hermes | local | claude
|
# STT-server (in-cluster) — joignable via Traefik / wildcard *.lab.local
|
||||||
"lan_probe_host": "192.168.1.200",
|
"url": "http://stt.lab.local",
|
||||||
"lan_probe_port": 4000, # LiteLLM — présent dès que s01 est joignable
|
"timeout_sec": 90,
|
||||||
"hermes": { # mode hermes (LAN) — agit sur Funk
|
|
||||||
"ssh_host": "s01", # accès SSH éprouvé (cf. tools/hermes-voice)
|
|
||||||
"profile": "funk-ai",
|
|
||||||
},
|
|
||||||
"local": { # mode local-direct (LAN) — chat Qwen3 g01
|
|
||||||
"url": "http://192.168.1.200:4000/v1/chat/completions",
|
|
||||||
"api_key": "lm-studio",
|
|
||||||
"model": "hermes-default",
|
|
||||||
},
|
|
||||||
"claude": { # mode claude-direct (hors-LAN) — chat seul
|
|
||||||
"model": "claude-sonnet-4-6", # clé via env ANTHROPIC_API_KEY
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"voice": {
|
"voice": {
|
||||||
"wake_word": "hermes",
|
"wake_word": "hermes",
|
||||||
|
|
@ -51,11 +40,9 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"http_port": 9301,
|
"http_port": 9301,
|
||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
|
# cache local de conversation (brut, gitignoré) — la vraie mémoire est côté serveur
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"local_db": str(DATA_DIR / "memory.sqlite"),
|
"local_db": str(DATA_DIR / "memory.sqlite"),
|
||||||
"distill_on_exit": True,
|
|
||||||
"qdrant_url": "http://192.168.1.200:6333",
|
|
||||||
"qdrant_collection": "stt-memory",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,7 +58,6 @@ def _deep_merge(base: dict, over: dict) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict[str, Any]:
|
def load_config() -> dict[str, Any]:
|
||||||
"""Défauts + surcharge du fichier utilisateur s'il existe."""
|
|
||||||
if CONFIG_PATH.exists():
|
if CONFIG_PATH.exists():
|
||||||
with CONFIG_PATH.open("rb") as f:
|
with CONFIG_PATH.open("rb") as f:
|
||||||
user = tomllib.load(f)
|
user = tomllib.load(f)
|
||||||
|
|
@ -80,7 +66,6 @@ def load_config() -> dict[str, Any]:
|
||||||
|
|
||||||
|
|
||||||
def write_default_config(force: bool = False) -> Path:
|
def write_default_config(force: bool = False) -> Path:
|
||||||
"""Écrit la config par défaut dans ~/.config/stt/stt.toml (idempotent)."""
|
|
||||||
import tomli_w
|
import tomli_w
|
||||||
|
|
||||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
5
stt/client/stt/ui/__init__.py
Normal file
5
stt/client/stt/ui/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""UI locale : sert le HUD (HTTP statique) + diffuse les états (websocket)."""
|
||||||
|
|
||||||
|
from stt.ui.app import serve
|
||||||
|
|
||||||
|
__all__ = ["serve"]
|
||||||
|
|
@ -37,24 +37,25 @@ def _deaccent(s: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
class VoiceEngine:
|
class VoiceEngine:
|
||||||
"""Écoute le micro, transcrit, appelle le cerveau, synthétise la réponse.
|
"""Écoute le micro, transcrit, interroge le STT-server, synthétise la réponse.
|
||||||
|
|
||||||
|
`responder(text) -> str` envoie le texte au serveur et renvoie la réponse.
|
||||||
`emit(event: dict)` est appelé à chaque changement d'état / message, pour le HUD.
|
`emit(event: dict)` est appelé à chaque changement d'état / message, pour le HUD.
|
||||||
Événements : {"type": "state", "state": idle|listening|thinking|speaking},
|
Événements : {"type": "state", "state": idle|listening|thinking|speaking},
|
||||||
{"type": "user", "text": …}, {"type": "assistant", "text": …, "mode": …},
|
{"type": "user", "text": …}, {"type": "assistant", "text": …},
|
||||||
{"type": "error", "text": …}.
|
{"type": "error", "text": …}.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
cfg: dict[str, Any],
|
cfg: dict[str, Any],
|
||||||
brain,
|
responder: Callable[[str], str],
|
||||||
emit: Callable[[dict], None],
|
emit: Callable[[dict], None],
|
||||||
memory=None,
|
memory=None,
|
||||||
no_tts: bool = False,
|
no_tts: bool = False,
|
||||||
):
|
):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
self.brain = brain
|
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
|
||||||
|
|
@ -117,21 +118,21 @@ class VoiceEngine:
|
||||||
finally:
|
finally:
|
||||||
out.unlink(missing_ok=True)
|
out.unlink(missing_ok=True)
|
||||||
|
|
||||||
# ── cerveau ──────────────────────────────────────────────────────────
|
# ── interrogation du serveur ─────────────────────────────────────────
|
||||||
def _respond(self, text: str) -> None:
|
def _respond(self, text: str) -> None:
|
||||||
self.emit({"type": "user", "text": text})
|
self.emit({"type": "user", "text": text})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("user", text, self.brain.mode)
|
self.memory.log("user", text)
|
||||||
self.emit({"type": "state", "state": "thinking"})
|
self.emit({"type": "state", "state": "thinking"})
|
||||||
try:
|
try:
|
||||||
resp = self.brain.ask(text)
|
resp = self.responder(text)
|
||||||
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
|
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
|
||||||
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.brain.mode})
|
self.emit({"type": "assistant", "text": resp})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("assistant", resp, self.brain.mode)
|
self.memory.log("assistant", resp)
|
||||||
self.emit({"type": "state", "state": "speaking"})
|
self.emit({"type": "state", "state": "speaking"})
|
||||||
self._speak(resp)
|
self._speak(resp)
|
||||||
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# Configuration STT — copier vers ~/.config/stt/stt.toml (`stt --setup` le génère).
|
|
||||||
# Ces valeurs reflètent les défauts embarqués (stt/stt/config.py : DEFAULT_CONFIG).
|
|
||||||
|
|
||||||
[brain]
|
|
||||||
# Mode cerveau : "auto" (ping s01 → hermes si LAN, sinon claude), "hermes", "local", "claude"
|
|
||||||
mode = "auto"
|
|
||||||
lan_probe_host = "192.168.1.200"
|
|
||||||
lan_probe_port = 4000 # LiteLLM — présent dès que s01 est joignable
|
|
||||||
|
|
||||||
[brain.hermes] # mode hermes (LAN) — agit sur Funk (via SSH, éprouvé)
|
|
||||||
ssh_host = "s01"
|
|
||||||
profile = "funk-ai"
|
|
||||||
|
|
||||||
[brain.local] # mode local-direct (LAN) — chat Qwen3 sur g01
|
|
||||||
url = "http://192.168.1.200:4000/v1/chat/completions"
|
|
||||||
api_key = "lm-studio"
|
|
||||||
model = "hermes-default"
|
|
||||||
|
|
||||||
[brain.claude] # mode claude-direct (hors-LAN) — chat seul
|
|
||||||
model = "claude-sonnet-4-6" # clé via env ANTHROPIC_API_KEY (jamais ici)
|
|
||||||
|
|
||||||
[voice]
|
|
||||||
wake_word = "hermes" # "Ok Hermès"
|
|
||||||
whisper_model = "large-v3" # STT faster-whisper (CPU int8)
|
|
||||||
piper_voice = "fr_FR-upmc-medium" # TTS — ~/.local/share/piper/<voix>.onnx
|
|
||||||
language = "fr"
|
|
||||||
silence_sec = 1.2
|
|
||||||
min_speech_sec = 0.6
|
|
||||||
chat_timeout_sec = 60
|
|
||||||
|
|
||||||
[ui]
|
|
||||||
theme = "arc-reactor"
|
|
||||||
avatar = "default"
|
|
||||||
kiosk = true
|
|
||||||
ws_host = "127.0.0.1"
|
|
||||||
ws_port = 9300 # websocket états → HUD (doit matcher hud/index.html)
|
|
||||||
http_port = 9301 # HTTP statique qui sert le HUD
|
|
||||||
|
|
||||||
[memory]
|
|
||||||
enabled = true
|
|
||||||
local_db = "~/.local/share/stt/memory.sqlite" # tier local (gitignoré)
|
|
||||||
distill_on_exit = true # phase 4
|
|
||||||
qdrant_url = "http://192.168.1.200:6333" # tier s01 (phase 4)
|
|
||||||
qdrant_collection = "stt-memory"
|
|
||||||
10
stt/server/Dockerfile
Normal file
10
stt/server/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY stt_server ./stt_server
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
# Service interne au cluster (exposé via Traefik). uvicorn multi-workers.
|
||||||
|
CMD ["uvicorn", "stt_server.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||||
44
stt/server/README.md
Normal file
44
stt/server/README.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# STT-server — orchestrateur AI (in-cluster)
|
||||||
|
|
||||||
|
API FastAPI déployée dans le cluster k8s (namespace `ai`). Reçoit les requêtes des clients
|
||||||
|
STT et route l'inférence vers LiteLLM (s01) → Qwen3 (g01) / Claude.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Méthode | Route | Corps | Réponse |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/healthz` | — | `{status, version}` |
|
||||||
|
| `POST` | `/v1/ask` | `{text}` | `{reply}` |
|
||||||
|
|
||||||
|
## Configuration (variables d'env)
|
||||||
|
|
||||||
|
| Var | Défaut | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `STT_LITELLM_URL` | `http://litellm-ext:4000/v1/chat/completions` | endpoint LiteLLM (via Service Endpoints) |
|
||||||
|
| `STT_LITELLM_KEY` | `lm-studio` | clé LiteLLM (valeur exacte attendue) |
|
||||||
|
| `STT_MODEL` | `hermes-default` | alias LiteLLM (bascule Qwen/Claude via `hermes-switch`) |
|
||||||
|
| `STT_SYSTEM_PROMPT` | prompt vocal FR concis | persona |
|
||||||
|
| `STT_MAX_TOKENS` / `STT_TEMPERATURE` | `200` / `0.7` | génération |
|
||||||
|
|
||||||
|
## Dev local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd stt/server
|
||||||
|
pip install -e .
|
||||||
|
STT_LITELLM_URL=http://192.168.1.200:4000/v1/chat/completions stt-server # :8000
|
||||||
|
curl -s localhost:8000/healthz
|
||||||
|
curl -s localhost:8000/v1/ask -H 'content-type: application/json' -d '{"text":"bonjour"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
- Image construite/poussée par `.github/workflows/build-stt-server.yml` → `ghcr.io/alkatrazz24/funk-stt-server`.
|
||||||
|
- Manifests : `k8s/apps/stt/` ; Application ArgoCD : `k8s/apps-of-apps/apps/stt.yaml` (depuis `main`).
|
||||||
|
- Accès LiteLLM (hors cluster) : Service + Endpoints `litellm-ext` → `192.168.10.1:4000`.
|
||||||
|
|
||||||
|
## À venir
|
||||||
|
|
||||||
|
- Intégration des **outils Hermes** (« agir sur Funk ») via le gateway `:8080` — nécessite
|
||||||
|
de spécifier son API. Aujourd'hui : inférence/chat seulement (via LiteLLM).
|
||||||
|
- **Mémoire** centralisée : Qdrant (s01) + distillation versionnée (`server/memory/`).
|
||||||
|
- Sessions multi-clients + historique.
|
||||||
22
stt/server/pyproject.toml
Normal file
22
stt/server/pyproject.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[project]
|
||||||
|
name = "stt-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "STT-server — orchestrateur AI du homelab Funk (API pour les clients STT)"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
stt-server = "stt_server.app:run"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["stt_server*"]
|
||||||
3
stt/server/stt_server/__init__.py
Normal file
3
stt/server/stt_server/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""STT-server — orchestrateur AI in-cluster pour les clients STT."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
49
stt/server/stt_server/app.py
Normal file
49
stt/server/stt_server/app.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""STT-server — API FastAPI pour les clients STT.
|
||||||
|
|
||||||
|
Endpoints :
|
||||||
|
GET /healthz → état du service
|
||||||
|
POST /v1/ask {text} → {reply} (requête AI, orchestrée vers LiteLLM)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from stt_server import __version__
|
||||||
|
from stt_server.brain import ask as brain_ask
|
||||||
|
|
||||||
|
app = FastAPI(title="STT-server", version=__version__)
|
||||||
|
|
||||||
|
|
||||||
|
class AskRequest(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class AskReply(BaseModel):
|
||||||
|
reply: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz() -> dict:
|
||||||
|
return {"status": "ok", "version": __version__}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/ask", response_model=AskReply)
|
||||||
|
async def v1_ask(req: AskRequest) -> AskReply:
|
||||||
|
text = req.text.strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="text vide")
|
||||||
|
try:
|
||||||
|
reply = await brain_ask(text)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"upstream LiteLLM : {e}") from e
|
||||||
|
return AskReply(reply=reply)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Entrypoint `stt-server` (dev local). En prod : uvicorn via le conteneur."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000) # noqa: S104 — service interne au cluster
|
||||||
29
stt/server/stt_server/brain.py
Normal file
29
stt/server/stt_server/brain.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Orchestration AI : route les requêtes des clients vers LiteLLM (s01).
|
||||||
|
|
||||||
|
LiteLLM (:4000) est OpenAI-compatible et route lui-même vers Qwen3 (g01) ou Claude
|
||||||
|
selon l'alias `hermes-default` / `hermes-switch`. L'intégration des outils Hermes
|
||||||
|
(« agir sur Funk » via le gateway :8080) est une étape ultérieure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from stt_server.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def ask(text: str) -> str:
|
||||||
|
payload = {
|
||||||
|
"model": settings.model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": settings.system_prompt},
|
||||||
|
{"role": "user", "content": text},
|
||||||
|
],
|
||||||
|
"max_tokens": settings.max_tokens,
|
||||||
|
"temperature": settings.temperature,
|
||||||
|
}
|
||||||
|
headers = {"Authorization": f"Bearer {settings.litellm_key}"}
|
||||||
|
async with httpx.AsyncClient(timeout=settings.request_timeout) as client:
|
||||||
|
r = await client.post(settings.litellm_url, json=payload, headers=headers)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()["choices"][0]["message"]["content"].strip()
|
||||||
27
stt/server/stt_server/config.py
Normal file
27
stt/server/stt_server/config.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""Configuration du STT-server — via variables d'environnement (12-factor / k8s)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
# LiteLLM (s01) — joint via un Service ExternalName/Endpoints dans le cluster.
|
||||||
|
litellm_url: str = os.getenv(
|
||||||
|
"STT_LITELLM_URL", "http://litellm-ext:4000/v1/chat/completions"
|
||||||
|
)
|
||||||
|
litellm_key: str = os.getenv("STT_LITELLM_KEY", "lm-studio")
|
||||||
|
model: str = os.getenv("STT_MODEL", "hermes-default")
|
||||||
|
|
||||||
|
system_prompt: str = os.getenv(
|
||||||
|
"STT_SYSTEM_PROMPT",
|
||||||
|
"Tu es Hermes, l'assistant vocal du homelab Funk. "
|
||||||
|
"Réponds toujours en français, de façon concise (2-3 phrases maximum), "
|
||||||
|
"sans markdown ni listes.",
|
||||||
|
)
|
||||||
|
max_tokens: int = int(os.getenv("STT_MAX_TOKENS", "200"))
|
||||||
|
temperature: float = float(os.getenv("STT_TEMPERATURE", "0.7"))
|
||||||
|
request_timeout: float = float(os.getenv("STT_REQUEST_TIMEOUT", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""Routeur cerveau — 3 modes + auto-détection LAN."""
|
|
||||||
|
|
||||||
from stt.brain.router import BrainRouter
|
|
||||||
|
|
||||||
__all__ = ["BrainRouter"]
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
"""Routeur cerveau — décide où envoyer le texte transcrit.
|
|
||||||
|
|
||||||
3 modes :
|
|
||||||
- hermes : SSH vers s01 → `hermes --profile … -z` (agit sur Funk). Éprouvé dans
|
|
||||||
tools/hermes-voice. Le gateway HTTP :8080 pourra le remplacer plus tard.
|
|
||||||
- local-direct : HTTP LiteLLM :4000 (Qwen3 sur g01) — chat seul.
|
|
||||||
- claude-direct : API Anthropic depuis le poste (hors-LAN) — chat seul.
|
|
||||||
|
|
||||||
mode "auto" : ping TCP s01 → joignable ⇒ hermes, sinon ⇒ claude.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
from collections import deque
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
VOICE_INJECT = (
|
|
||||||
"[Réponse vocale : courte, 1-2 phrases max, sans markdown ni listes] "
|
|
||||||
)
|
|
||||||
SYSTEM_PROMPT = (
|
|
||||||
"Tu es Hermes, l'assistant vocal du homelab Funk. "
|
|
||||||
"Réponds toujours en français, de façon concise (2-3 phrases maximum)."
|
|
||||||
)
|
|
||||||
MAX_HISTORY = 10
|
|
||||||
|
|
||||||
|
|
||||||
class BrainRouter:
|
|
||||||
def __init__(self, cfg: dict[str, Any]):
|
|
||||||
self.cfg = cfg
|
|
||||||
self._configured = cfg.get("mode", "auto")
|
|
||||||
self._history: deque[dict] = deque(maxlen=MAX_HISTORY * 2)
|
|
||||||
self.mode = self.resolve_mode()
|
|
||||||
|
|
||||||
# ── détection de mode ────────────────────────────────────────────────
|
|
||||||
def lan_available(self) -> bool:
|
|
||||||
host = self.cfg.get("lan_probe_host", "192.168.1.200")
|
|
||||||
port = int(self.cfg.get("lan_probe_port", 4000))
|
|
||||||
try:
|
|
||||||
with socket.create_connection((host, port), timeout=1.5):
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def resolve_mode(self) -> str:
|
|
||||||
if self._configured != "auto":
|
|
||||||
return self._configured
|
|
||||||
return "hermes" if self.lan_available() else "claude"
|
|
||||||
|
|
||||||
def refresh_mode(self) -> str:
|
|
||||||
"""Re-évalue le mode (ex. changement de réseau). Retourne le mode courant."""
|
|
||||||
self.mode = self.resolve_mode()
|
|
||||||
return self.mode
|
|
||||||
|
|
||||||
# ── dispatch ─────────────────────────────────────────────────────────
|
|
||||||
def ask(self, text: str) -> str:
|
|
||||||
if self.mode == "hermes":
|
|
||||||
return self._ask_hermes(text)
|
|
||||||
if self.mode == "local":
|
|
||||||
return self._ask_openai(text, self.cfg["local"])
|
|
||||||
return self._ask_claude(text)
|
|
||||||
|
|
||||||
def _ask_hermes(self, text: str) -> str:
|
|
||||||
c = self.cfg["hermes"]
|
|
||||||
query = (VOICE_INJECT + text).replace("'", "'\\''")
|
|
||||||
cmd = [
|
|
||||||
"ssh", c.get("ssh_host", "s01"),
|
|
||||||
"sudo -i -u hermes bash -c "
|
|
||||||
f"'HERMES_HOME=/srv/data/hermes hermes --profile {c.get('profile', 'funk-ai')} "
|
|
||||||
f'-z "{query}"\'',
|
|
||||||
]
|
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
|
||||||
out = r.stdout.strip()
|
|
||||||
if not out and r.returncode != 0:
|
|
||||||
raise RuntimeError(r.stderr.strip() or "hermes -z a échoué")
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _ask_openai(self, text: str, c: dict) -> str:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
self._history.append({"role": "user", "content": text})
|
|
||||||
r = requests.post(
|
|
||||||
c["url"],
|
|
||||||
json={
|
|
||||||
"model": c["model"],
|
|
||||||
"messages": [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
||||||
+ list(self._history),
|
|
||||||
"max_tokens": 200,
|
|
||||||
"temperature": 0.7,
|
|
||||||
},
|
|
||||||
headers={"Authorization": f"Bearer {c['api_key']}"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
resp = r.json()["choices"][0]["message"]["content"].strip()
|
|
||||||
self._history.append({"role": "assistant", "content": resp})
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def _ask_claude(self, text: str) -> str:
|
|
||||||
from anthropic import Anthropic
|
|
||||||
|
|
||||||
self._history.append({"role": "user", "content": text})
|
|
||||||
client = Anthropic() # lit ANTHROPIC_API_KEY
|
|
||||||
msg = client.messages.create(
|
|
||||||
model=self.cfg["claude"]["model"],
|
|
||||||
max_tokens=300,
|
|
||||||
system=SYSTEM_PROMPT,
|
|
||||||
messages=list(self._history),
|
|
||||||
)
|
|
||||||
resp = "".join(b.text for b in msg.content if b.type == "text").strip()
|
|
||||||
self._history.append({"role": "assistant", "content": resp})
|
|
||||||
return resp
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
"""Serveur : websocket (états → HUD) + petit HTTP statique pour servir le HUD."""
|
|
||||||
|
|
||||||
from stt.server.app import serve
|
|
||||||
|
|
||||||
__all__ = ["serve"]
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue