+
+
Oriente Asa sur un domaine (injecte des données live). Voir l'icône ▤ pour le détail.
+
+
+
@@ -1046,6 +1089,43 @@
+
+
+
@@ -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"})