mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:34:43 +02:00
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:
parent
89aeb418ff
commit
de4b66ed56
5 changed files with 239 additions and 14 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</svg>
|
||||
</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">
|
||||
<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>
|
||||
|
|
@ -961,6 +997,13 @@
|
|||
</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 -->
|
||||
<div class="field">
|
||||
<div class="row">
|
||||
|
|
@ -1046,6 +1089,43 @@
|
|||
</div>
|
||||
</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) -->
|
||||
<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 '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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue