diff --git a/stt/client/pyproject.toml b/stt/client/pyproject.toml index 4c5cf6a..adb3448 100644 --- a/stt/client/pyproject.toml +++ b/stt/client/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stt" -version = "0.10.0" +version = "0.11.0" description = "STT — client vocal Jarvis du homelab Funk (voix + HUD, parle au STT-server)" requires-python = ">=3.11" readme = "README.md" diff --git a/stt/client/stt/api.py b/stt/client/stt/api.py index 08c839d..5974a0f 100644 --- a/stt/client/stt/api.py +++ b/stt/client/stt/api.py @@ -16,25 +16,47 @@ class ServerClient: self.url = cfg["url"].rstrip("/") self.timeout = cfg.get("timeout_sec", 90) 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 self.session_id = uuid.uuid4().hex def ask(self, text: str, model: str | None = None) -> str: import requests - r = requests.post( - f"{self.url}/v1/ask", - json={ - "text": text, - "model": model or self.model, - "session_id": self.session_id, - }, - timeout=self.timeout, - ) + payload = { + "text": text, + "model": model or self.model, + "session_id": self.session_id, + } + 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: raise RuntimeError(r.json().get("detail", "requête invalide")) 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: import requests diff --git a/stt/client/stt/hud/index.html b/stt/client/stt/hud/index.html index 9fc3f64..5049990 100644 --- a/stt/client/stt/hud/index.html +++ b/stt/client/stt/hud/index.html @@ -753,6 +753,35 @@ .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 */ + /* ============================================================ + 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 { position: fixed; @@ -833,6 +862,13 @@ + + + + +
+

Exactement ce qui est envoyé à Asa au dernier tour : consigne, données live, doc et mémoire.

+ +

Aucun contexte encore. Pose une question à Asa pour voir ce qu'il reçoit.

+ + + + + +
+ +
@@ -1168,6 +1248,8 @@ function handleEvent(ev) { case 'mic': setMicUI(!!ev.muted); break; // état micro renvoyé par le backend case 'services': renderServices(ev.services || []); break; // tuiles du portail 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) default: console.warn('Événement inconnu :', ev); } @@ -1553,10 +1635,12 @@ function flashSaved() { const scrim = el('scrim'); const drawer = el('drawer'); const svcDrawer = el('servicesDrawer'); +const ctxDrawer = el('contextDrawer'); +const ALL_DRAWERS = [drawer, svcDrawer, ctxDrawer]; function closeDrawers() { scrim.classList.remove('open'); - for (const d of [drawer, svcDrawer]) { + for (const d of ALL_DRAWERS) { d.classList.remove('open'); d.setAttribute('aria-hidden', 'true'); } @@ -1570,15 +1654,18 @@ function openDrawer(which, onOpen) { } const openSettings = () => openDrawer(drawer, () => intensity.focus()); const openServices = () => { showHome(); openDrawer(svcDrawer); }; +const openContext = () => openDrawer(ctxDrawer); -/* ----- Écouteurs Réglages + Services ----- */ +/* ----- Écouteurs Réglages + Services + Contexte ----- */ el('openSettings').addEventListener('click', openSettings); el('closeSettings').addEventListener('click', () => { closeDrawers(); el('openSettings').focus(); }); el('openServices').addEventListener('click', openServices); 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); 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)); }); @@ -1591,6 +1678,84 @@ nothink.addEventListener('change', () => { persistLocal(); sendSettings(); }); brainLocal.addEventListener('click', () => { setBrain('local'); 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', () => { clearTranscript(); setState('idle'); @@ -1665,6 +1830,17 @@ if (DEMO) { argocd: { state:'down', health:'down', latency_ms:null, components:[], 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(); } else connectWS(); diff --git a/stt/client/stt/ui/app.py b/stt/client/stt/ui/app.py index 91c59a9..f846b19 100644 --- a/stt/client/stt/ui/app.py +++ b/stt/client/stt/ui/app.py @@ -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: nonlocal last_state, last_mic, last_status 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_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(contexts_msg, ensure_ascii=False)) # contextes Asa if last_status is not None: # santé connue → l'envoyer tout de suite await ws.send(json.dumps(last_status, ensure_ascii=False)) # 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"]) 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é 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 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() if wake and engine is not None: from stt.voice.engine import _deaccent diff --git a/stt/client/stt/voice/engine.py b/stt/client/stt/voice/engine.py index 33d2840..23d36bb 100644 --- a/stt/client/stt/voice/engine.py +++ b/stt/client/stt/voice/engine.py @@ -65,6 +65,9 @@ class VoiceEngine: self.no_tts = no_tts # callable renvoyant l'alias modèle courant → affiché en tag dans le HUD 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) self._asr = make_backend(cfg) self._stop = False @@ -165,6 +168,14 @@ class VoiceEngine: return mode = self.model_label() if self.model_label else None 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: self.memory.log("assistant", resp) self.emit({"type": "state", "state": "speaking"})