From 9c335d169bb3325c74c2d26e6b002c64b8723de5 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Sat, 20 Jun 2026 22:20:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(stt-client):=20portail=20de=20services=20d?= =?UTF-8?q?ans=20le=20HUD=20=E2=80=94=20tuiles=20cliquables=20+=20registre?= =?UTF-8?q?=20config-driven=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un panneau « Services » au HUD du client STT : une tuile par service du homelab (Ghostfolio, Grafana, ArgoCD, n8n, Open WebUI, Prometheus, Alertmanager, Traefik), un clic ouvre l'URL dans le navigateur habituel. - stt/portal/ : registre piloté par [[services]] de stt.toml (id, name, url, icon, aliases) + résolution floue (registry.match_service) prête pour la voix. Ajouter un service = quelques lignes de config, zéro code. - config.py : défauts homelab + doc dans stt.example.toml. - ui/app.py : pousse la liste au HUD à la connexion, action de contrôle open-service → _open_url_external (xdg-open, session navigateur normale) + toast. - hud/index.html : bouton header, drawer Services (voile partagé avec Réglages), grille de tuiles, toast de confirmation. - bump 0.7.0 → 0.8.0. Validé : registre (fuzzy-match FR/fautes/négatif), wiring backend, et HUD via Playwright (rendu tuiles + clic émet open-service + toast, aucune erreur JS). Co-authored-by: Claude Opus 4.8 --- admin/ia/stt.md | 1 + stt/client/config/stt.example.toml | 24 ++++ stt/client/pyproject.toml | 2 +- stt/client/stt/config.py | 46 +++++++ stt/client/stt/hud/index.html | 210 ++++++++++++++++++++++++++--- stt/client/stt/portal/__init__.py | 13 ++ stt/client/stt/portal/registry.py | 107 +++++++++++++++ stt/client/stt/ui/app.py | 56 ++++++++ 8 files changed, 438 insertions(+), 21 deletions(-) create mode 100644 stt/client/stt/portal/__init__.py create mode 100644 stt/client/stt/portal/registry.py diff --git a/admin/ia/stt.md b/admin/ia/stt.md index b897742..3056e0a 100644 --- a/admin/ia/stt.md +++ b/admin/ia/stt.md @@ -148,6 +148,7 @@ au prompt système (`STT_DISABLE_THINKING`, défaut `true` ; inoffensif hors Qwe | — ASR | `stt/client/stt/voice/asr/` | backend enfichable : `whisper` (faster-whisper CPU, défaut) \| `onnx` (Parakeet/Canary/Nemotron via onnx-asr, multilingue, streaming-ready). Choisi par `[voice] asr_engine` | | — 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 **et messages texte** → backend) ; HUD embarqué dans le package | +| — portail | `stt/client/stt/portal/` | registre de services (`registry.py`) piloté par `[[services]]` de `stt.toml` → panneau « Services » du HUD (tuiles cliquables) **et** commande vocale « ouvre <service> ». Ouvre l'URL dans le navigateur normal | | 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` | | Manifests | `k8s/apps/stt/` | Deployment, Service, IngressRoute (`stt.lab.local`) | diff --git a/stt/client/config/stt.example.toml b/stt/client/config/stt.example.toml index 9d2e0d5..7864969 100644 --- a/stt/client/config/stt.example.toml +++ b/stt/client/config/stt.example.toml @@ -32,3 +32,27 @@ 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" + +# --------------------------------------------------------------------------- +# Portail de services — tuiles du HUD + commande vocale « ouvre ». +# Définir [[services]] ici REMPLACE entièrement la liste par défaut (homelab Funk). +# Ajouter un service = un bloc de plus. Champs : +# id : identifiant unique (interne) +# name : libellé affiché sur la tuile +# url : ouverte dans ton navigateur normal au clic / à la voix +# icon : emoji de la tuile +# aliases : mots déclencheurs pour la voix (« ouvre les métriques » → grafana) +# --------------------------------------------------------------------------- +# [[services]] +# id = "grafana" +# name = "Grafana" +# url = "http://grafana.lab.local" +# icon = "📊" +# aliases = ["graphana", "metriques", "monitoring", "dashboard"] +# +# [[services]] +# id = "ghostfolio" +# name = "Ghostfolio" +# url = "http://ghostfolio.lab.local" +# icon = "💰" +# aliases = ["portefeuille", "bourse"] diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 687e825..5f7c218 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.7.0" +version = "0.8.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/config.py b/stt/client/stt/config.py index a112d16..5c98f05 100644 --- a/stt/client/stt/config.py +++ b/stt/client/stt/config.py @@ -70,6 +70,52 @@ DEFAULT_CONFIG: dict[str, Any] = { "enabled": True, "local_db": str(DATA_DIR / "memory.sqlite"), }, + # Portail de services — alimente le panneau « Services » du HUD ET la commande + # vocale « ouvre ». Ajouter un service = ajouter une entrée ici (ou + # un bloc [[services]] dans stt.toml, qui remplace alors toute cette liste). + # Champs : id (unique), name, url, icon (emoji), aliases (mots déclencheurs voix). + "services": [ + { + "id": "ghostfolio", "name": "Ghostfolio", + "url": "http://ghostfolio.lab.local", "icon": "💰", + "aliases": ["portefeuille", "portfolio", "ghosto", "bourse", "investissements"], + }, + { + "id": "grafana", "name": "Grafana", + "url": "http://grafana.lab.local", "icon": "📊", + "aliases": ["graphana", "metriques", "monitoring", "dashboard", "graphiques"], + }, + { + "id": "argocd", "name": "ArgoCD", + "url": "http://argocd.lab.local", "icon": "🚢", + "aliases": ["argo", "argo cd", "deploiements", "gitops"], + }, + { + "id": "n8n", "name": "n8n", + "url": "http://n8n.lab.local", "icon": "🔀", + "aliases": ["workflows", "automatisation", "nodes", "huit n huit"], + }, + { + "id": "openwebui", "name": "Open WebUI", + "url": "http://openwebui.lab.local", "icon": "💬", + "aliases": ["open web ui", "webui", "chat", "llm", "interface ia"], + }, + { + "id": "prometheus", "name": "Prometheus", + "url": "http://prometheus.lab.local", "icon": "🔥", + "aliases": ["prometheus", "metriques brutes", "tsdb"], + }, + { + "id": "alertmanager", "name": "Alertmanager", + "url": "http://alertmanager.lab.local", "icon": "🚨", + "aliases": ["alertes", "alert manager", "alarmes"], + }, + { + "id": "traefik", "name": "Traefik", + "url": "http://traefik.lab.local", "icon": "🛣️", + "aliases": ["reverse proxy", "ingress", "routeur", "proxy"], + }, + ], } diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index eeacd0e..9c827a4 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -632,6 +632,78 @@ } .saved-hint.show { opacity: 1; } + /* ============================================================ + PORTAIL — grille de tuiles de services + toast + ============================================================ */ + .svc-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + .svc-tile { + appearance: none; + font: inherit; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--bg); + color: var(--ink); + transition: border-color .2s var(--ease), box-shadow .2s var(--ease), transform .12s var(--ease); + } + .svc-tile:hover { + border-color: var(--accent-active); + box-shadow: 0 6px 18px -10px rgba(255,93,108,.5); + } + .svc-tile:active { transform: scale(.97); } + .svc-tile:focus-visible { + outline: none; + border-color: var(--accent-active); + box-shadow: 0 0 0 4px rgba(255,93,108,.14); + } + .svc-tile .svc-ico { + font-size: 22px; + line-height: 1; + flex: none; + width: 34px; height: 34px; + display: grid; + place-items: center; + border-radius: 10px; + background: var(--bg-soft); + } + .svc-tile .svc-name { + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* Toast — confirmation éphémère (clic / voix) */ + .toast { + position: fixed; + left: 50%; + bottom: 28px; + transform: translate(-50%, 16px); + background: var(--ink); + color: var(--bg); + font-size: 14px; + font-weight: 600; + padding: 12px 18px; + border-radius: 999px; + box-shadow: 0 10px 30px -12px rgba(40,20,24,.6); + opacity: 0; + visibility: hidden; + transition: opacity .25s var(--ease), transform .25s var(--ease), visibility .25s var(--ease); + z-index: 40; + pointer-events: none; + } + .toast.show { opacity: 1; visibility: visible; transform: translate(-50%, 0); } + @media (prefers-reduced-motion: reduce) { * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; } } @@ -681,6 +753,16 @@ + + + + +
+

Ouvre un service du homelab dans ton navigateur.

+
+ +
+ + + +
+ diff --git a/stt/client/stt/portal/__init__.py b/stt/client/stt/portal/__init__.py new file mode 100644 index 0000000..eacba8b --- /dev/null +++ b/stt/client/stt/portal/__init__.py @@ -0,0 +1,13 @@ +"""Portail de services du homelab — registre + résolution (clic HUD & voix). + +Le registre est **piloté par la config** (`[[services]]` dans stt.toml) : ajouter un +service = quelques lignes (id, name, url, icon, aliases), aucun changement de code. +La même source alimente le panneau « Services » du HUD et la commande vocale +« ouvre ». +""" + +from __future__ import annotations + +from .registry import Service, load_services, match_service + +__all__ = ["Service", "load_services", "match_service"] diff --git a/stt/client/stt/portal/registry.py b/stt/client/stt/portal/registry.py new file mode 100644 index 0000000..5b8293d --- /dev/null +++ b/stt/client/stt/portal/registry.py @@ -0,0 +1,107 @@ +"""Registre des services du homelab — chargement depuis la config + résolution floue. + +Chaque service est une entrée `[[services]]` de `stt.toml` : + + [[services]] + id = "grafana" + name = "Grafana" + url = "http://grafana.lab.local" + icon = "📊" + aliases = ["graphana", "metrics", "monitoring", "dashboard"] + +`match_service()` résout un texte libre (clic ou voix : « ouvre grafana », +« montre les métriques »…) vers un service, par id / nom / alias, en tolérant les +accents, la casse et les fautes légères. +""" + +from __future__ import annotations + +import difflib +import unicodedata +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Service: + id: str + name: str + url: str + icon: str = "🔗" + aliases: tuple[str, ...] = field(default_factory=tuple) + + @property + def keywords(self) -> list[str]: + """Tous les libellés qui peuvent désigner ce service (normalisés).""" + raw = [self.id, self.name, *self.aliases] + return [k for k in (_norm(x) for x in raw) if k] + + +def _norm(s: str) -> str: + """minuscule + sans accents + espaces compactés (comparaison robuste).""" + s = unicodedata.normalize("NFKD", str(s)) + s = "".join(c for c in s if not unicodedata.combining(c)) + return " ".join(s.lower().split()) + + +def load_services(cfg: dict) -> list[Service]: + """Construit la liste de services depuis `cfg['services']` (entrées invalides ignorées).""" + out: list[Service] = [] + seen: set[str] = set() + for raw in cfg.get("services") or []: + if not isinstance(raw, dict): + continue + url = str(raw.get("url") or "").strip() + sid = str(raw.get("id") or "").strip() + if not url or not sid or sid in seen: + continue # url + id uniques obligatoires + seen.add(sid) + aliases = tuple( + str(a).strip() for a in (raw.get("aliases") or []) if str(a).strip() + ) + out.append( + Service( + id=sid, + name=str(raw.get("name") or sid).strip(), + url=url, + icon=str(raw.get("icon") or "🔗").strip() or "🔗", + aliases=aliases, + ) + ) + return out + + +def match_service(services: list[Service], query: str) -> Service | None: + """Résout `query` vers un service ; None si rien d'assez proche. + + Ordre : correspondance exacte d'un mot-clé > inclusion (la requête contient un + mot-clé, ou inversement) > rapprochement flou (difflib, fautes légères). + """ + q = _norm(query) + if not q or not services: + return None + + # 1) exact : un des mots-clés == la requête entière + for svc in services: + if q in svc.keywords: + return svc + + # 2) inclusion : « ouvre le grafana » contient « grafana » ; ou requête ⊂ mot-clé + best_incl: tuple[int, Service] | None = None + for svc in services: + for kw in svc.keywords: + if len(kw) >= 3 and (kw in q or q in kw): + # on préfère le mot-clé le plus long (le plus spécifique) + if best_incl is None or len(kw) > best_incl[0]: + best_incl = (len(kw), svc) + if best_incl: + return best_incl[1] + + # 3) flou : tolère « graphana », « argo cd » → difflib sur chaque mot-clé + best_ratio = 0.0 + best_svc: Service | None = None + for svc in services: + for kw in svc.keywords: + r = difflib.SequenceMatcher(None, q, kw).ratio() + if r > best_ratio: + best_ratio, best_svc = r, svc + return best_svc if best_ratio >= 0.72 else None diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index 5e5d762..ea58225 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -50,12 +50,26 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: """ import websockets + from stt.portal import load_services + ui = cfg["ui"] loop = asyncio.get_running_loop() clients: set = set() last_state = {"type": "state", "state": "idle"} last_mic = {"type": "mic", "muted": False} + # Portail : registre des services (HUD + voix). Construit une fois ; le HUD le + # reçoit à la connexion et affiche une tuile par service. + services = load_services(cfg) + services_by_id = {s.id: s for s in services} + services_msg = { + "type": "services", + "services": [ + {"id": s.id, "name": s.name, "url": s.url, "icon": s.icon} + for s in services + ], + } + def broadcast(event: dict) -> None: nonlocal last_state, last_mic if event.get("type") == "state": @@ -76,11 +90,24 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: def emit(event: dict) -> None: loop.call_soon_threadsafe(broadcast, event) + def open_service(sid: str) -> None: + """Ouvre l'URL d'un service dans le navigateur NORMAL + confirme via toast. + + Best-effort : un service inconnu ou un navigateur absent ne casse rien. + """ + svc = services_by_id.get(sid) + if svc is None: + return + # ouverture bloquante (xdg-open / Popen) → hors boucle asyncio + loop.run_in_executor(None, _open_url_external, svc.url) + emit({"type": "toast", "text": f"{svc.icon} Ouverture de {svc.name}"}) + async def handler(ws): clients.add(ws) try: await ws.send(json.dumps(last_state, ensure_ascii=False)) await ws.send(json.dumps(last_mic, ensure_ascii=False)) # état micro courant + await ws.send(json.dumps(services_msg, ensure_ascii=False)) # tuiles du portail # Le HUD envoie {"type":"settings","data":{...}} (réglage), # {"type":"text","text":"…"} (message tapé) ou # {"type":"control","action":"stop|mute|unmute"} (boutons HUD). @@ -99,6 +126,9 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None: if txt and engine is not None: # responder = appel HTTP bloquant → hors boucle asyncio loop.run_in_executor(None, engine.respond_text, txt) + elif mtype == "control" and msg.get("action") == "open-service": + # indépendant du moteur vocal → cliquable même sans micro + open_service(str(msg.get("service") or "")) elif mtype == "control" and engine is not None: action = msg.get("action") if action == "stop": @@ -199,6 +229,32 @@ def _open_browser(url: str, mode: str) -> None: webbrowser.open(url) +def _open_url_external(url: str) -> None: + """Ouvre `url` dans le navigateur HABITUEL de l'utilisateur (onglet normal). + + Contrairement à `_open_browser` (fenêtre app dédiée du HUD, profil isolé), ici on + veut la session courante — favoris, connexions, onglets. `xdg-open` respecte le + navigateur par défaut du bureau ; repli sur `webbrowser` si absent. Best-effort. + """ + import shutil + import subprocess + import webbrowser + + try: + if shutil.which("xdg-open"): + subprocess.Popen( + ["xdg-open", url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + return + except Exception: # noqa: BLE001 — on retombe sur webbrowser + pass + try: + webbrowser.open(url, new=2) # new=2 : nouvel onglet + except Exception: # noqa: BLE001 — ouverture best-effort, jamais fatale + pass + + def _apply_settings(client, engine, data: dict, loop) -> None: """Applique les réglages renvoyés par le HUD — best-effort, jamais bloquant ni fatal.