mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 09:54:43 +02:00
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:
parent
a7b5c44e82
commit
9c335d169b
8 changed files with 438 additions and 21 deletions
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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 <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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <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"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</svg>
|
||||
</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">
|
||||
<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>
|
||||
|
|
@ -832,6 +914,26 @@
|
|||
</div>
|
||||
</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>
|
||||
/* ============================================================================
|
||||
STT — HUD assistant vocal
|
||||
|
|
@ -949,6 +1051,8 @@ function handleEvent(ev) {
|
|||
case 'assistant': addBubble('assistant', ev.text || '', ev.mode || ''); break;
|
||||
case 'error': addBubble('error', ev.text || 'Erreur'); break;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1069,6 +1173,62 @@ micBtn.addEventListener('click', () => {
|
|||
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
|
||||
============================================================================ */
|
||||
|
|
@ -1152,27 +1312,37 @@ function flashSaved() {
|
|||
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 drawer = el('drawer');
|
||||
function openSettings() {
|
||||
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();
|
||||
}
|
||||
const svcDrawer = el('servicesDrawer');
|
||||
|
||||
/* ----- É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('closeSettings').addEventListener('click', closeSettings);
|
||||
scrim.addEventListener('click', closeSettings);
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && drawer.classList.contains('open')) closeSettings(); });
|
||||
el('closeSettings').addEventListener('click', () => { closeDrawers(); el('openSettings').focus(); });
|
||||
el('openServices').addEventListener('click', openServices);
|
||||
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('change', () => { persistLocal(); sendSettings(); });
|
||||
|
|
@ -1235,7 +1405,7 @@ function runDemo() {
|
|||
============================================================================ */
|
||||
restoreLocal();
|
||||
setState('idle');
|
||||
if (DEMO) runDemo();
|
||||
if (DEMO) { renderServices(DEMO_SERVICES); runDemo(); }
|
||||
else connectWS();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
13
stt/client/stt/portal/__init__.py
Normal file
13
stt/client/stt/portal/__init__.py
Normal 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"]
|
||||
107
stt/client/stt/portal/registry.py
Normal file
107
stt/client/stt/portal/registry.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue