feat(stt-client): portail de services dans le HUD — tuiles cliquables + registre config-driven (#34)

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 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-20 22:20:11 +02:00 committed by GitHub
parent a7b5c44e82
commit 9c335d169b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 438 additions and 21 deletions

View file

@ -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` | | — 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` | | — 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 | | — 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 &lt;service&gt; ». Ouvre l'URL dans le navigateur normal |
| 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`) |

View file

@ -32,3 +32,27 @@ http_port = 9301 # HTTP statique qui sert le HUD
[memory] [memory]
enabled = true # cache local de conversation (brut, gitignoré) enabled = true # cache local de conversation (brut, gitignoré)
local_db = "~/.local/share/stt/memory.sqlite" local_db = "~/.local/share/stt/memory.sqlite"
# ---------------------------------------------------------------------------
# Portail de services — tuiles du HUD + commande vocale « ouvre <service> ».
# 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"]

View file

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

View file

@ -70,6 +70,52 @@ DEFAULT_CONFIG: dict[str, Any] = {
"enabled": True, "enabled": True,
"local_db": str(DATA_DIR / "memory.sqlite"), "local_db": str(DATA_DIR / "memory.sqlite"),
}, },
# Portail de services — alimente le panneau « Services » du HUD ET la commande
# vocale « ouvre <service> ». 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"],
},
],
} }

View file

@ -632,6 +632,78 @@
} }
.saved-hint.show { opacity: 1; } .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) { @media (prefers-reduced-motion: reduce) {
* { animation-duration: .001ms !important; animation-iteration-count: 1 !important; } * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
} }
@ -681,6 +753,16 @@
</svg> </svg>
</button> </button>
<!-- Portail : ouvrir le panneau des services du homelab -->
<button class="icon-btn services" id="openServices" aria-label="Ouvrir les services" title="Services du homelab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="4" width="6" height="6" rx="1.4"></rect>
<rect x="14" y="4" width="6" height="6" rx="1.4"></rect>
<rect x="4" y="14" width="6" height="6" rx="1.4"></rect>
<rect x="14" y="14" width="6" height="6" rx="1.4"></rect>
</svg>
</button>
<button class="icon-btn gear" id="openSettings" aria-label="Ouvrir les réglages" title="Réglages"> <button class="icon-btn gear" id="openSettings" aria-label="Ouvrir les réglages" title="Réglages">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3.2"></circle> <circle cx="12" cy="12" r="3.2"></circle>
@ -832,6 +914,26 @@
</div> </div>
</aside> </aside>
<!-- ===================== SERVICES (réutilise le voile #scrim) ===================== -->
<aside class="drawer" id="servicesDrawer" role="dialog" aria-modal="true" aria-labelledby="servicesTitle" aria-hidden="true">
<header>
<h2 id="servicesTitle">Services</h2>
<button class="icon-btn" id="closeServices" aria-label="Fermer les services" title="Fermer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18"></path>
</svg>
</button>
</header>
<div class="body">
<p class="hint" style="margin:6px 0 16px">Ouvre un service du homelab dans ton navigateur.</p>
<div class="svc-grid" id="svcGrid" role="list"></div>
<p class="svc-empty hint" id="svcEmpty" hidden>Aucun service configuré (voir <code>[[services]]</code> dans stt.toml).</p>
</div>
</aside>
<!-- Toast (confirmation d'ouverture, clic ou voix) -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script> <script>
/* ============================================================================ /* ============================================================================
STT — HUD assistant vocal STT — HUD assistant vocal
@ -949,6 +1051,8 @@ function handleEvent(ev) {
case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break; case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break;
case 'error': addBubble('error', ev.text || 'Erreur'); break; case 'error': addBubble('error', ev.text || 'Erreur'); break;
case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend
case 'services': renderServices(ev.services || []); break; // tuiles du portail
case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix)
default: console.warn('Événement inconnu :', ev); default: console.warn('Événement inconnu :', ev);
} }
} }
@ -1069,6 +1173,62 @@ micBtn.addEventListener('click', () => {
if (!sendControl(next ? 'mute' : 'unmute')) setMicUI(next); if (!sendControl(next ? 'mute' : 'unmute')) setMicUI(next);
}); });
/* ============================================================================
PORTAIL — tuiles de services + toast
Le backend pousse {"type":"services","services":[{id,name,url,icon}]} à la
connexion ; un clic envoie {"type":"control","action":"open-service","service":id}
et le backend ouvre l'URL dans le navigateur habituel (+ {"type":"toast"}).
============================================================================ */
const svcGrid = document.getElementById('svcGrid');
const svcEmpty = document.getElementById('svcEmpty');
const toastEl = document.getElementById('toast');
let toastTimer = null;
/* Services de secours en mode DÉMO (le backend n'envoie rien sans WS) */
const DEMO_SERVICES = [
{ id:'ghostfolio', name:'Ghostfolio', icon:'💰' },
{ id:'grafana', name:'Grafana', icon:'📊' },
{ id:'argocd', name:'ArgoCD', icon:'🚢' },
{ id:'n8n', name:'n8n', icon:'🔀' },
];
function renderServices(list) {
svcGrid.innerHTML = '';
const items = Array.isArray(list) ? list : [];
svcEmpty.hidden = items.length > 0;
for (const s of items) {
const tile = document.createElement('button');
tile.className = 'svc-tile';
tile.type = 'button';
tile.setAttribute('role', 'listitem');
tile.title = `Ouvrir ${s.name}`;
tile.innerHTML =
`<span class="svc-ico" aria-hidden="true">${s.icon || '🔗'}</span>` +
`<span class="svc-name"></span>`;
tile.querySelector('.svc-name').textContent = s.name || s.id;
tile.addEventListener('click', () => openService(s));
svcGrid.appendChild(tile);
}
}
function openService(s) {
// Optimiste : toast immédiat + on laisse le panneau ouvert (multi-ouverture facile)
showToast(`${s.icon || '🔗'} Ouverture de ${s.name || s.id}`);
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) {
console.log('[DÉMO] open-service →', s.id);
return;
}
ws.send(JSON.stringify({ type: 'control', action: 'open-service', service: s.id }));
}
function showToast(text) {
if (!text) return;
toastEl.textContent = text;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2200);
}
/* ============================================================================ /* ============================================================================
RÉGLAGES — collecte, persistance (thème en localStorage), UI RÉGLAGES — collecte, persistance (thème en localStorage), UI
============================================================================ */ ============================================================================ */
@ -1152,27 +1312,37 @@ function flashSaved() {
savedTimer = setTimeout(() => h.classList.remove('show'), 1600); savedTimer = setTimeout(() => h.classList.remove('show'), 1600);
} }
/* ----- Ouverture / fermeture du drawer ----- */ /* ----- Ouverture / fermeture des drawers (Réglages + Services, voile partagé) ----- */
const scrim = el('scrim'); const scrim = el('scrim');
const drawer = el('drawer'); const drawer = el('drawer');
function openSettings() { const svcDrawer = el('servicesDrawer');
scrim.classList.add('open');
drawer.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
setTimeout(() => intensity.focus(), 200);
}
function closeSettings() {
scrim.classList.remove('open');
drawer.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
el('openSettings').focus();
}
/* ----- Écouteurs Réglages ----- */ function closeDrawers() {
scrim.classList.remove('open');
for (const d of [drawer, svcDrawer]) {
d.classList.remove('open');
d.setAttribute('aria-hidden', 'true');
}
}
function openDrawer(which, onOpen) {
closeDrawers(); // jamais deux panneaux ouverts à la fois
scrim.classList.add('open');
which.classList.add('open');
which.setAttribute('aria-hidden', 'false');
if (onOpen) setTimeout(onOpen, 200);
}
const openSettings = () => openDrawer(drawer, () => intensity.focus());
const openServices = () => openDrawer(svcDrawer);
/* ----- Écouteurs Réglages + Services ----- */
el('openSettings').addEventListener('click', openSettings); el('openSettings').addEventListener('click', openSettings);
el('closeSettings').addEventListener('click', closeSettings); el('closeSettings').addEventListener('click', () => { closeDrawers(); el('openSettings').focus(); });
scrim.addEventListener('click', closeSettings); el('openServices').addEventListener('click', openServices);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && drawer.classList.contains('open')) closeSettings(); }); el('closeServices').addEventListener('click', () => { closeDrawers(); el('openServices').focus(); });
scrim.addEventListener('click', closeDrawers);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && (drawer.classList.contains('open') || svcDrawer.classList.contains('open'))) closeDrawers();
});
intensity.addEventListener('input', () => { applyIntensity(Number(intensity.value)); }); intensity.addEventListener('input', () => { applyIntensity(Number(intensity.value)); });
intensity.addEventListener('change', () => { persistLocal(); sendSettings(); }); intensity.addEventListener('change', () => { persistLocal(); sendSettings(); });
@ -1235,7 +1405,7 @@ function runDemo() {
============================================================================ */ ============================================================================ */
restoreLocal(); restoreLocal();
setState('idle'); setState('idle');
if (DEMO) runDemo(); if (DEMO) { renderServices(DEMO_SERVICES); runDemo(); }
else connectWS(); else connectWS();
</script> </script>
</body> </body>

View file

@ -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 <service> ».
"""
from __future__ import annotations
from .registry import Service, load_services, match_service
__all__ = ["Service", "load_services", "match_service"]

View file

@ -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

View file

@ -50,12 +50,26 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
""" """
import websockets import websockets
from stt.portal import load_services
ui = cfg["ui"] ui = cfg["ui"]
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
clients: set = set() clients: set = set()
last_state = {"type": "state", "state": "idle"} last_state = {"type": "state", "state": "idle"}
last_mic = {"type": "mic", "muted": False} 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: def broadcast(event: dict) -> None:
nonlocal last_state, last_mic nonlocal last_state, last_mic
if event.get("type") == "state": 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: def emit(event: dict) -> None:
loop.call_soon_threadsafe(broadcast, event) 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): async def handler(ws):
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))
await ws.send(json.dumps(last_mic, ensure_ascii=False)) # état micro courant 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), # Le HUD envoie {"type":"settings","data":{...}} (réglage),
# {"type":"text","text":"…"} (message tapé) ou # {"type":"text","text":"…"} (message tapé) ou
# {"type":"control","action":"stop|mute|unmute"} (boutons HUD). # {"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: if txt and engine is not None:
# responder = appel HTTP bloquant → hors boucle asyncio # responder = appel HTTP bloquant → hors boucle asyncio
loop.run_in_executor(None, engine.respond_text, txt) 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: elif mtype == "control" and engine is not None:
action = msg.get("action") action = msg.get("action")
if action == "stop": if action == "stop":
@ -199,6 +229,32 @@ def _open_browser(url: str, mode: str) -> None:
webbrowser.open(url) 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: def _apply_settings(client, engine, data: dict, loop) -> None:
"""Applique les réglages renvoyés par le HUD — best-effort, jamais bloquant ni fatal. """Applique les réglages renvoyés par le HUD — best-effort, jamais bloquant ni fatal.