feat(stt-client): sélecteur de contexte + visualiseur dans le HUD (#39)

Moitié client de la fonctionnalité contextes (le serveur #38 est déployé). Tu choisis
un contexte pour Asa et tu vois exactement ce qu'on lui envoie.

- Sélecteur (Réglages → « Contexte d'Asa ») : liste peuplée depuis GET /v1/contexts ;
  le choix est envoyé en settings{context}, persisté en localStorage, et réappliqué au
  backend à la connexion. Masqué si le serveur ne supporte pas (dégradation).
- Visualiseur (icône ▤ de la barre) : drawer affichant le contexte assemblé renvoyé
  après chaque tour — consigne (system prompt), données live injectées, doc RAG, mémoire.
- api.py : ServerClient envoie `context`, expose last_context + contexts().
- engine.py : context_provider → émet {type:context} après chaque réponse.
- app.py : récupère /v1/contexts au démarrage, diffuse à la connexion, applique le
  changement de contexte (client.context).
- bump 0.10.0 → 0.11.0.

Validé via Playwright : sélecteur (5 contextes), changement → settings{context},
visualiseur (consigne + bloc live Ghostfolio + mémoire), drawer partagé. Rendu conforme.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-21 02:57:03 +02:00 committed by GitHub
parent 89aeb418ff
commit de4b66ed56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 239 additions and 14 deletions

View file

@ -1,6 +1,6 @@
[project] [project]
name = "stt" name = "stt"
version = "0.10.0" version = "0.11.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

@ -16,25 +16,47 @@ class ServerClient:
self.url = cfg["url"].rstrip("/") self.url = cfg["url"].rstrip("/")
self.timeout = cfg.get("timeout_sec", 90) self.timeout = cfg.get("timeout_sec", 90)
self.model = cfg.get("model", "hermes-default") self.model = cfg.get("model", "hermes-default")
# contexte présélectionné (None = défaut serveur). Piloté par le HUD.
self.context = cfg.get("context")
# dernier contexte assemblé renvoyé par le serveur (pour le visualiseur)
self.last_context: dict | None = None
self.last_context_id: str | None = None
# une session par run → mémoire court-terme côté serveur # une session par run → mémoire court-terme côté serveur
self.session_id = uuid.uuid4().hex self.session_id = uuid.uuid4().hex
def ask(self, text: str, model: str | None = None) -> str: def ask(self, text: str, model: str | None = None) -> str:
import requests import requests
r = requests.post( payload = {
f"{self.url}/v1/ask",
json={
"text": text, "text": text,
"model": model or self.model, "model": model or self.model,
"session_id": self.session_id, "session_id": self.session_id,
}, }
timeout=self.timeout, if self.context:
) payload["context"] = self.context
r = requests.post(f"{self.url}/v1/ask", json=payload, timeout=self.timeout)
if r.status_code == 400: if r.status_code == 400:
raise RuntimeError(r.json().get("detail", "requête invalide")) raise RuntimeError(r.json().get("detail", "requête invalide"))
r.raise_for_status() r.raise_for_status()
return r.json()["reply"].strip() data = r.json()
# contexte assemblé (système + sources live + RAG + mémoire) → visualiseur HUD
self.last_context = data.get("context")
self.last_context_id = data.get("context_id")
return data["reply"].strip()
def contexts(self) -> dict | None:
"""{'default':…, 'contexts':[{id,label,icon,description}]} ; None si indisponible.
Dégrade en None sur un serveur antérieur (endpoint absent) pas de sélecteur.
"""
import requests
try:
r = requests.get(f"{self.url}/v1/contexts", timeout=10)
r.raise_for_status()
return r.json()
except requests.RequestException:
return None
def reset(self) -> None: def reset(self) -> None:
import requests import requests

View file

@ -753,6 +753,35 @@
.svc-actions .btn { width: auto; font-size: 14px; padding: 11px 10px; } .svc-actions .btn { width: auto; font-size: 14px; padding: 11px 10px; }
.svc-actions .btn#svcStop { grid-column: 1 / -1; } /* Arrêter sur toute la largeur */ .svc-actions .btn#svcStop { grid-column: 1 / -1; } /* Arrêter sur toute la largeur */
/* ============================================================
VISUALISEUR DE CONTEXTE
============================================================ */
.ctx-current {
display: flex; align-items: center; gap: 10px;
padding: 10px 14px; margin-bottom: 16px;
border-radius: 12px; background: var(--bg-soft); border: 1px solid var(--line);
}
.ctx-current .ctx-ico { font-size: 20px; }
.ctx-current .ctx-label { font-size: 14px; font-weight: 600; }
.ctx-section { padding: 14px 0; border-bottom: 1px solid var(--line); }
.ctx-section .flabel { display: block; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ink-soft); margin-bottom: 8px; }
.ctx-pre {
margin: 0; white-space: pre-wrap; word-break: break-word;
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 12px; line-height: 1.5; color: var(--ink);
background: var(--bg-soft); border: 1px solid var(--line);
border-radius: 10px; padding: 12px; max-height: 220px; overflow-y: auto;
}
.ctx-block { margin-bottom: 10px; }
.ctx-block .ctx-block-title { font-size: 12px; font-weight: 600; color: var(--accent-active); margin-bottom: 4px; }
.ctx-block .ctx-pre { max-height: 160px; }
.ctx-item {
font-size: 12.5px; line-height: 1.5; color: var(--ink-soft);
padding: 8px 10px; margin-bottom: 6px;
background: var(--bg-soft); border: 1px solid var(--line);
border-radius: 8px; white-space: pre-wrap; word-break: break-word;
}
/* Toast — confirmation éphémère (clic / voix) */ /* Toast — confirmation éphémère (clic / voix) */
.toast { .toast {
position: fixed; position: fixed;
@ -833,6 +862,13 @@
</svg> </svg>
</button> </button>
<!-- Visualiseur : voir le contexte envoyé à Asa -->
<button class="icon-btn context" id="openContext" aria-label="Voir le contexte d'Asa" title="Contexte envoyé à Asa">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 5h16M4 10h16M4 15h10M4 20h7"></path>
</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>
@ -961,6 +997,13 @@
</div> </div>
</div> </div>
<!-- Contexte d'Asa -->
<div class="field" id="contextField" hidden>
<label for="contextSelect" class="flabel">Contexte d'Asa</label>
<p class="hint">Oriente Asa sur un domaine (injecte des données live). Voir l'icône ▤ pour le détail.</p>
<select id="contextSelect" aria-label="Contexte d'Asa"></select>
</div>
<!-- No-think --> <!-- No-think -->
<div class="field"> <div class="field">
<div class="row"> <div class="row">
@ -1046,6 +1089,43 @@
</div> </div>
</aside> </aside>
<!-- ===================== VISUALISEUR DE CONTEXTE (voile partagé) ===================== -->
<aside class="drawer" id="contextDrawer" role="dialog" aria-modal="true" aria-labelledby="contextTitle" aria-hidden="true">
<header>
<h2 id="contextTitle">Contexte d'Asa</h2>
<button class="icon-btn" id="closeContext" aria-label="Fermer le visualiseur" 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 14px">Exactement ce qui est envoyé à Asa au dernier tour : consigne, données live, doc et mémoire.</p>
<div class="ctx-current" id="ctxCurrent" hidden>
<span class="ctx-ico" id="ctxIco">🛠️</span>
<span class="ctx-label" id="ctxLabel"></span>
</div>
<p class="ctx-empty hint" id="ctxEmpty">Aucun contexte encore. Pose une question à Asa pour voir ce qu'il reçoit.</p>
<div class="ctx-section" id="ctxSysWrap" hidden>
<span class="flabel">Consigne (system prompt)</span>
<pre class="ctx-pre" id="ctxSystem"></pre>
</div>
<div class="ctx-section" id="ctxBlocksWrap" hidden>
<span class="flabel">Données live injectées</span>
<div id="ctxBlocks"></div>
</div>
<div class="ctx-section" id="ctxDocsWrap" hidden>
<span class="flabel">Documentation (RAG)</span>
<div id="ctxDocs"></div>
</div>
<div class="ctx-section" id="ctxMemWrap" hidden>
<span class="flabel">Mémoire long-terme</span>
<div id="ctxMem"></div>
</div>
</div>
</aside>
<!-- Toast (confirmation d'ouverture, clic ou voix) --> <!-- Toast (confirmation d'ouverture, clic ou voix) -->
<div class="toast" id="toast" role="status" aria-live="polite"></div> <div class="toast" id="toast" role="status" aria-live="polite"></div>
@ -1168,6 +1248,8 @@ function handleEvent(ev) {
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 'services': renderServices(ev.services || []); break; // tuiles du portail
case 'portal-status': applyPortalStatus(ev); break; // santé live des services case 'portal-status': applyPortalStatus(ev); break; // santé live des services
case 'contexts': applyContexts(ev); break; // contextes présélectionnables
case 'context': renderContextViewer(ev.context); break; // contexte assemblé (visualiseur)
case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix) case 'toast': showToast(ev.text || ''); break; // confirmation (clic/voix)
default: console.warn('Événement inconnu :', ev); default: console.warn('Événement inconnu :', ev);
} }
@ -1553,10 +1635,12 @@ function flashSaved() {
const scrim = el('scrim'); const scrim = el('scrim');
const drawer = el('drawer'); const drawer = el('drawer');
const svcDrawer = el('servicesDrawer'); const svcDrawer = el('servicesDrawer');
const ctxDrawer = el('contextDrawer');
const ALL_DRAWERS = [drawer, svcDrawer, ctxDrawer];
function closeDrawers() { function closeDrawers() {
scrim.classList.remove('open'); scrim.classList.remove('open');
for (const d of [drawer, svcDrawer]) { for (const d of ALL_DRAWERS) {
d.classList.remove('open'); d.classList.remove('open');
d.setAttribute('aria-hidden', 'true'); d.setAttribute('aria-hidden', 'true');
} }
@ -1570,15 +1654,18 @@ function openDrawer(which, onOpen) {
} }
const openSettings = () => openDrawer(drawer, () => intensity.focus()); const openSettings = () => openDrawer(drawer, () => intensity.focus());
const openServices = () => { showHome(); openDrawer(svcDrawer); }; const openServices = () => { showHome(); openDrawer(svcDrawer); };
const openContext = () => openDrawer(ctxDrawer);
/* ----- Écouteurs Réglages + Services ----- */ /* ----- Écouteurs Réglages + Services + Contexte ----- */
el('openSettings').addEventListener('click', openSettings); el('openSettings').addEventListener('click', openSettings);
el('closeSettings').addEventListener('click', () => { closeDrawers(); el('openSettings').focus(); }); el('closeSettings').addEventListener('click', () => { closeDrawers(); el('openSettings').focus(); });
el('openServices').addEventListener('click', openServices); el('openServices').addEventListener('click', openServices);
el('closeServices').addEventListener('click', () => { closeDrawers(); el('openServices').focus(); }); el('closeServices').addEventListener('click', () => { closeDrawers(); el('openServices').focus(); });
el('openContext').addEventListener('click', openContext);
el('closeContext').addEventListener('click', () => { closeDrawers(); el('openContext').focus(); });
scrim.addEventListener('click', closeDrawers); scrim.addEventListener('click', closeDrawers);
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && (drawer.classList.contains('open') || svcDrawer.classList.contains('open'))) closeDrawers(); if (e.key === 'Escape' && ALL_DRAWERS.some(d => d.classList.contains('open'))) closeDrawers();
}); });
intensity.addEventListener('input', () => { applyIntensity(Number(intensity.value)); }); intensity.addEventListener('input', () => { applyIntensity(Number(intensity.value)); });
@ -1591,6 +1678,84 @@ nothink.addEventListener('change', () => { persistLocal(); sendSettings(); });
brainLocal.addEventListener('click', () => { setBrain('local'); persistLocal(); sendSettings(); }); brainLocal.addEventListener('click', () => { setBrain('local'); persistLocal(); sendSettings(); });
brainAdv.addEventListener('click', () => { setBrain('advanced'); persistLocal(); sendSettings(); }); brainAdv.addEventListener('click', () => { setBrain('advanced'); persistLocal(); sendSettings(); });
/* ============================================================================
CONTEXTE D'ASA — sélecteur (Réglages) + visualiseur (drawer dédié)
Le backend pousse {"type":"contexts",...} à la connexion (liste + courant) et
{"type":"context","context":{...}} après chaque tour (contexte assemblé).
============================================================================ */
const CTX_LS = 'stt-context';
function sendContext(id) {
if (DEMO || !ws || ws.readyState !== WebSocket.OPEN) { console.log('[DÉMO] context →', id); return; }
ws.send(JSON.stringify({ type: 'settings', data: { context: id } }));
}
function applyContexts(msg) {
const list = (msg && msg.contexts) || [];
const field = document.getElementById('contextField');
const sel = document.getElementById('contextSelect');
if (!list.length) { field.hidden = true; return; } // serveur antérieur → pas de sélecteur
sel.innerHTML = '';
for (const c of list) {
const o = document.createElement('option');
o.value = c.id;
o.textContent = `${c.icon || ''} ${c.label}`.trim();
o.title = c.description || '';
sel.appendChild(o);
}
field.hidden = false;
let saved = null;
try { saved = localStorage.getItem(CTX_LS); } catch {}
const want = (saved && list.some(c => c.id === saved)) ? saved : (msg.current || msg.default);
if (want) sel.value = want;
// si la sauvegarde diffère du backend, on l'aligne tout de suite
if (saved && saved !== msg.current) sendContext(saved);
}
document.getElementById('contextSelect').addEventListener('change', (e) => {
const id = e.target.value;
try { localStorage.setItem(CTX_LS, id); } catch {}
sendContext(id);
flashSaved();
});
function _ctxSection(id, show) { document.getElementById(id).hidden = !show; }
function _ctxItems(wrapId, listId, items) {
_ctxSection(wrapId, items.length > 0);
const c = document.getElementById(listId); c.innerHTML = '';
for (const it of items) {
const d = document.createElement('div');
d.className = 'ctx-item';
d.textContent = (typeof it === 'string') ? it : JSON.stringify(it);
c.appendChild(d);
}
}
function renderContextViewer(ctx) {
if (!ctx) return;
document.getElementById('ctxEmpty').hidden = true;
document.getElementById('ctxCurrent').hidden = false;
document.getElementById('ctxIco').textContent = ctx.icon || '🧠';
document.getElementById('ctxLabel').textContent = ctx.label || ctx.id || 'Contexte';
_ctxSection('ctxSysWrap', !!ctx.system_prompt);
document.getElementById('ctxSystem').textContent = ctx.system_prompt || '';
const blocks = ctx.blocks || [];
_ctxSection('ctxBlocksWrap', blocks.length > 0);
const bc = document.getElementById('ctxBlocks'); bc.innerHTML = '';
for (const b of blocks) {
const wrap = document.createElement('div'); wrap.className = 'ctx-block';
const t = document.createElement('div'); t.className = 'ctx-block-title';
t.textContent = b.title || b.source || 'source';
const pre = document.createElement('pre'); pre.className = 'ctx-pre';
pre.textContent = b.text || '';
wrap.appendChild(t); wrap.appendChild(pre); bc.appendChild(wrap);
}
_ctxItems('ctxDocsWrap', 'ctxDocs', ctx.docs || []);
_ctxItems('ctxMemWrap', 'ctxMem', ctx.memories || []);
}
el('resetConv').addEventListener('click', () => { el('resetConv').addEventListener('click', () => {
clearTranscript(); clearTranscript();
setState('idle'); setState('idle');
@ -1665,6 +1830,17 @@ if (DEMO) {
argocd: { state:'down', health:'down', latency_ms:null, components:[], alerts:[] }, argocd: { state:'down', health:'down', latency_ms:null, components:[], alerts:[] },
n8n: { state:'up', health:'ok', latency_ms:16, components:[{name:'Application',state:'up'}], alerts:[] }, n8n: { state:'up', health:'ok', latency_ms:16, components:[{name:'Application',state:'up'}], alerts:[] },
}}); }});
applyContexts({ default:'funk', current:'ghostfolio', contexts:[
{ id:'funk', label:'Funk · cluster', icon:'🛠️', description:'Doc homelab' },
{ id:'ghostfolio', label:'Ghostfolio', icon:'💰', description:'Portefeuille' },
{ id:'grafana', label:'Grafana · métriques', icon:'📊', description:'Observabilité' },
{ id:'alerting', label:'Alerting', icon:'🚨', description:'Alertes actives' },
{ id:'cluster', label:'Gestion cluster', icon:'🚢', description:'État cluster' },
]});
renderContextViewer({ id:'ghostfolio', label:'Ghostfolio', icon:'💰',
system_prompt:'Tu es Hermes… Tu aides sur le portefeuille d\'investissement. Appuie-toi sur les données Ghostfolio fournies ci-dessous.',
blocks:[{ source:'ghostfolio', title:'Portefeuille Ghostfolio (données live)', text:'Valeur actuelle : 12 345 (devise de base).\n- ETF Monde : 8 100\n- Bitcoin : 2 400' }],
docs:[], memories:['aime les ETF capitalisants'] });
runDemo(); runDemo();
} }
else connectWS(); else connectWS();

View file

@ -72,6 +72,16 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
], ],
} }
# Contextes présélectionnables (récupérés du serveur ; None = serveur antérieur).
# Appel HTTP bloquant → hors boucle. Le HUD reçoit la liste à la connexion.
contexts_info = await loop.run_in_executor(None, client.contexts)
contexts_msg = {
"type": "contexts",
"contexts": (contexts_info or {}).get("contexts", []),
"default": (contexts_info or {}).get("default"),
"current": client.context or (contexts_info or {}).get("default"),
}
def broadcast(event: dict) -> None: def broadcast(event: dict) -> None:
nonlocal last_state, last_mic, last_status nonlocal last_state, last_mic, last_status
if event.get("type") == "state": if event.get("type") == "state":
@ -142,6 +152,7 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
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 await ws.send(json.dumps(services_msg, ensure_ascii=False)) # tuiles du portail
await ws.send(json.dumps(contexts_msg, ensure_ascii=False)) # contextes Asa
if last_status is not None: # santé connue → l'envoyer tout de suite if last_status is not None: # santé connue → l'envoyer tout de suite
await ws.send(json.dumps(last_status, ensure_ascii=False)) await ws.send(json.dumps(last_status, ensure_ascii=False))
# Le HUD envoie {"type":"settings","data":{...}} (réglage), # Le HUD envoie {"type":"settings","data":{...}} (réglage),
@ -186,6 +197,8 @@ async def serve(cfg: dict[str, Any], make_engine, client) -> None:
_start_http(ui["ws_host"], ui["http_port"]) _start_http(ui["ws_host"], ui["http_port"])
engine = make_engine(emit) engine = make_engine(emit)
# le moteur expose au HUD le dernier contexte assemblé renvoyé par le serveur
engine.context_provider = lambda: client.last_context
# chargement Whisper + boucle audio dans un thread dédié # chargement Whisper + boucle audio dans un thread dédié
threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start() threading.Thread(target=_run_engine, args=(engine, emit), daemon=True).start()
@ -340,6 +353,9 @@ def _apply_settings(client, engine, data: dict, loop) -> None:
from stt.config import resolve_model from stt.config import resolve_model
client.model = resolve_model("hermes" if brain == "local" else "claude") client.model = resolve_model("hermes" if brain == "local" else "claude")
ctx = (data.get("context") or "").strip()
if ctx:
client.context = ctx # contexte présélectionné, envoyé au prochain /v1/ask
wake = (data.get("wakeword") or "").strip() wake = (data.get("wakeword") or "").strip()
if wake and engine is not None: if wake and engine is not None:
from stt.voice.engine import _deaccent from stt.voice.engine import _deaccent

View file

@ -65,6 +65,9 @@ class VoiceEngine:
self.no_tts = no_tts self.no_tts = no_tts
# callable renvoyant l'alias modèle courant → affiché en tag dans le HUD # callable renvoyant l'alias modèle courant → affiché en tag dans le HUD
self.model_label = model_label self.model_label = model_label
# callable renvoyant le dernier contexte assemblé (serveur) → visualiseur HUD.
# Renseigné après coup par le serveur (app.py) ; None tant qu'absent.
self.context_provider: Callable[[], dict | None] | None = None
# backend ASR enfichable (whisper par défaut, onnx/parakeet en option) # backend ASR enfichable (whisper par défaut, onnx/parakeet en option)
self._asr = make_backend(cfg) self._asr = make_backend(cfg)
self._stop = False self._stop = False
@ -165,6 +168,14 @@ class VoiceEngine:
return return
mode = self.model_label() if self.model_label else None mode = self.model_label() if self.model_label else None
self.emit({"type": "assistant", "text": resp, "mode": mode}) self.emit({"type": "assistant", "text": resp, "mode": mode})
# contexte assemblé renvoyé par le serveur → visualiseur HUD (best-effort)
if self.context_provider is not None:
try:
ctx = self.context_provider()
except Exception: # noqa: BLE001
ctx = None
if ctx:
self.emit({"type": "context", "context": ctx})
if self.memory: if self.memory:
self.memory.log("assistant", resp) self.memory.log("assistant", resp)
self.emit({"type": "state", "state": "speaking"}) self.emit({"type": "state", "state": "speaking"})