mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 11:04: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` |
|
| — 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 <service> ». 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`) |
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue