mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:34:43 +02:00
feat(stt-client): intentions vocales lecture seule — ouvrir, état du cluster, valeur Ghostfolio (#37)
Asa répond/agit en local (sans LLM) sur trois intentions déterministes, interceptées dans engine._respond avant l'appel au STT-server : • « ouvre <service> » → ouvre la page dans le navigateur • « état du cluster / des services » → résumé de santé parlé (depuis le poller) • « combien sur mon ghostfolio » → valeur du portefeuille (API Ghostfolio) - portal/intents.py : route_intent (ordre ghostfolio > statut > ouvrir) + summarize_status. - portal/ghostfolio.py : client API config-driven (auth/anonymous → JWT → portfolio/details), lecture seule, dégradation propre si pas de token. - voice/engine.py : attribut intent_router branché dans _respond (mode « portail », court-circuite le LLM). - ui/app.py : construit le routeur (services, ouverture, dernière santé, Ghostfolio). - config : section [ghostfolio] (url, access_token vide par défaut, timeout). - bump 0.10.0 → 0.11.0. Stacked sur #36 (le résumé d'état utilise le StatusPoller). Validé : routage des intents (ouvrir via alias, ghostfolio avec qualificatif, statut, hors-sujet → LLM), résumé d'état, et interception moteur de bout en bout (LLM non appelé sur intent). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
704d212e15
commit
6e30f9535c
10 changed files with 259 additions and 10 deletions
|
|
@ -148,7 +148,8 @@ 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/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller). Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager**. Ouvre l'URL dans le navigateur normal. Aussi : commande vocale « ouvre <service> » |
|
| — portail | `stt/client/stt/portal/` | `registry.py` (services config-driven `[[services]]`) + `health.py` (StatusPoller) + `intents.py` (intentions vocales) + `ghostfolio.py`. Panneau « Services » du HUD : tuiles + **pages détail** (description, **santé live**, composants, alertes, bouton Ouvrir). Santé = probe HTTP + **Prometheus** `up{}`/kube-state-metrics + **Alertmanager** |
|
||||||
|
| — intentions vocales | `stt/client/stt/portal/intents.py` | lecture seule, court-circuitent le LLM (interceptées dans `engine._respond`) : « ouvre <service> » (ouvre la page), « état du cluster/services » (résumé santé parlé), « combien sur mon ghostfolio » (API Ghostfolio, `[ghostfolio] access_token`) |
|
||||||
| 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`) |
|
||||||
|
|
|
||||||
|
|
@ -63,3 +63,13 @@ local_db = "~/.local/share/stt/memory.sqlite"
|
||||||
# probe_timeout_sec = 4
|
# probe_timeout_sec = 4
|
||||||
# prometheus_url = "http://prometheus.lab.local"
|
# prometheus_url = "http://prometheus.lab.local"
|
||||||
# alertmanager_url = "http://alertmanager.lab.local"
|
# alertmanager_url = "http://alertmanager.lab.local"
|
||||||
|
|
||||||
|
# Intentions vocales (lecture seule), reconnues avant le LLM :
|
||||||
|
# « ouvre <service> » → ouvre la page
|
||||||
|
# « état du cluster / des services » → résumé de santé (parlé)
|
||||||
|
# « combien sur mon ghostfolio » → valeur du portefeuille (ci-dessous)
|
||||||
|
# Ghostfolio : access_token = « Security token » du compte (Réglages Ghostfolio).
|
||||||
|
# [ghostfolio]
|
||||||
|
# url = "http://ghostfolio.lab.local"
|
||||||
|
# access_token = "TON_SECURITY_TOKEN"
|
||||||
|
# timeout_sec = 6
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,14 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
"prometheus_url": "http://prometheus.lab.local",
|
"prometheus_url": "http://prometheus.lab.local",
|
||||||
"alertmanager_url": "http://alertmanager.lab.local",
|
"alertmanager_url": "http://alertmanager.lab.local",
|
||||||
},
|
},
|
||||||
|
# Ghostfolio — pour l'intention vocale « combien sur mon portefeuille ».
|
||||||
|
# access_token = « Security token » du compte (Réglages → vide par défaut →
|
||||||
|
# l'assistant répondra de le configurer). Lecture seule.
|
||||||
|
"ghostfolio": {
|
||||||
|
"url": "http://ghostfolio.lab.local",
|
||||||
|
"access_token": "",
|
||||||
|
"timeout_sec": 6,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1172,6 +1172,8 @@ function handleEvent(ev) {
|
||||||
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 'meta': setVersion(ev.version || '?'); break; // version installée
|
case 'meta': setVersion(ev.version || '?'); break; // version installée
|
||||||
|
// (les intentions vocales « ouvre X » / « état cluster » / « ghostfolio »
|
||||||
|
// sont traitées côté backend ; elles arrivent ici comme bulles assistant)
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ La même source alimente le panneau « Services » du HUD et la commande vocale
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .ghostfolio import GhostfolioClient
|
||||||
from .health import StatusPoller
|
from .health import StatusPoller
|
||||||
|
from .intents import route_intent, summarize_status
|
||||||
from .registry import Component, Service, load_services, match_service
|
from .registry import Component, Service, load_services, match_service
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Component", "Service", "load_services", "match_service", "StatusPoller",
|
"Component", "Service", "load_services", "match_service", "StatusPoller",
|
||||||
|
"GhostfolioClient", "route_intent", "summarize_status",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
76
stt/client/stt/portal/ghostfolio.py
Normal file
76
stt/client/stt/portal/ghostfolio.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""Client minimal de l'API Ghostfolio — valeur du portefeuille (lecture seule).
|
||||||
|
|
||||||
|
Flux Ghostfolio : POST /api/v1/auth/anonymous {accessToken} → JWT, puis
|
||||||
|
GET /api/v1/portfolio/details (Bearer) → summary.currentValueInBaseCurrency.
|
||||||
|
|
||||||
|
Le token (« Security token » du compte Ghostfolio) est config-driven, jamais en
|
||||||
|
dur. Sans token → message d'aide ; toute erreur → message parlable, jamais d'exception.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_money(value: float) -> str:
|
||||||
|
"""12345.6 → « 12 345 » (séparateur de milliers fin, arrondi à l'euro)."""
|
||||||
|
try:
|
||||||
|
return f"{round(float(value)):,}".replace(",", " ")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class GhostfolioClient:
|
||||||
|
def __init__(self, cfg: dict[str, Any]):
|
||||||
|
self.url = (cfg.get("url") or "http://ghostfolio.lab.local").rstrip("/")
|
||||||
|
self.token = cfg.get("access_token") or ""
|
||||||
|
self.timeout = float(cfg.get("timeout_sec", 6) or 6)
|
||||||
|
|
||||||
|
def _auth_token(self) -> str | None:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
r = requests.post(
|
||||||
|
f"{self.url}/api/v1/auth/anonymous",
|
||||||
|
json={"accessToken": self.token}, timeout=self.timeout,
|
||||||
|
)
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return None
|
||||||
|
return (r.json() or {}).get("authToken")
|
||||||
|
|
||||||
|
def _base_currency(self) -> str:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{self.url}/api/v1/info", timeout=self.timeout)
|
||||||
|
return (r.json() or {}).get("baseCurrency") or "EUR"
|
||||||
|
except (requests.RequestException, ValueError):
|
||||||
|
return "EUR"
|
||||||
|
|
||||||
|
def value_summary(self) -> str:
|
||||||
|
"""Phrase parlable donnant la valeur du portefeuille (ou un message d'erreur clair)."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
if not self.token:
|
||||||
|
return ("Je n'ai pas de jeton Ghostfolio configuré. "
|
||||||
|
"Ajoute ton « Security token » dans la section ghostfolio de la config.")
|
||||||
|
try:
|
||||||
|
jwt = self._auth_token()
|
||||||
|
if not jwt:
|
||||||
|
return "Je n'arrive pas à m'authentifier auprès de Ghostfolio — le jeton est peut-être invalide."
|
||||||
|
r = requests.get(
|
||||||
|
f"{self.url}/api/v1/portfolio/details",
|
||||||
|
headers={"Authorization": f"Bearer {jwt}"}, timeout=self.timeout,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
summary = (r.json() or {}).get("summary", {}) or {}
|
||||||
|
val = (summary.get("currentValueInBaseCurrency")
|
||||||
|
or summary.get("currentValue")
|
||||||
|
or summary.get("netWorth"))
|
||||||
|
if val is None:
|
||||||
|
return "Je n'ai pas trouvé la valeur du portefeuille dans la réponse de Ghostfolio."
|
||||||
|
cur = self._base_currency()
|
||||||
|
return f"Ton portefeuille Ghostfolio vaut environ {_fmt_money(val)} {cur}."
|
||||||
|
except requests.RequestException:
|
||||||
|
return "Ghostfolio est injoignable pour le moment."
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
return "Réponse inattendue de Ghostfolio — je n'ai pas pu lire la valeur."
|
||||||
118
stt/client/stt/portal/intents.py
Normal file
118
stt/client/stt/portal/intents.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
"""Intentions vocales locales (lecture seule) — court-circuitent le LLM.
|
||||||
|
|
||||||
|
Trois intentions déterministes, reconnues sur le transcript avant l'appel au
|
||||||
|
STT-server :
|
||||||
|
|
||||||
|
• « ouvre / lance / montre <service> » → ouvre la page dans le navigateur
|
||||||
|
• « état du cluster / des services » → résumé de la santé du portail
|
||||||
|
• « combien sur mon ghostfolio » → valeur du portefeuille (API Ghostfolio)
|
||||||
|
|
||||||
|
`route_intent()` renvoie une phrase à dire si une intention matche, sinon None
|
||||||
|
(la question part alors au LLM comme d'habitude).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from .registry import Service, _norm, match_service
|
||||||
|
|
||||||
|
# Verbes d'ouverture (normalisés, sans accent)
|
||||||
|
_OPEN_VERBS = (
|
||||||
|
"ouvre", "ouvrir", "ouvres", "lance", "lancer", "affiche", "afficher",
|
||||||
|
"montre", "montrer", "accede", "acceder", "va sur", "amene moi sur",
|
||||||
|
)
|
||||||
|
# Mots vides retirés en tête de la cible (« ouvre LE grafana » → « grafana »)
|
||||||
|
_FILLERS = {"le", "la", "les", "l", "mon", "ma", "mes", "moi", "sur", "vers", "du", "de", "des", "un", "une"}
|
||||||
|
|
||||||
|
_GHOSTFOLIO_WORDS = ("ghostfolio", "portefeuille", "portfolio")
|
||||||
|
_GHOSTFOLIO_QUALIFIERS = (
|
||||||
|
"combien", "valeur", "vaut", "vaux", "solde", "montant", "total",
|
||||||
|
"cote", "riche", "argent", "capital",
|
||||||
|
)
|
||||||
|
_STATUS_WORDS = ("etat", "statut", "sante", "forme", "fonctionne", "marche", "va")
|
||||||
|
_STATUS_SCOPE = ("cluster", "service", "services", "homelab", "infra", "pods", "tout")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_open_target(norm: str) -> str | None:
|
||||||
|
"""Renvoie ce qui suit un verbe d'ouverture, nettoyé, ou None si pas de verbe."""
|
||||||
|
for verb in _OPEN_VERBS:
|
||||||
|
m = re.search(rf"\b{re.escape(verb)}\b\s+(.*)", norm)
|
||||||
|
if m:
|
||||||
|
tail = m.group(1).strip()
|
||||||
|
words = [w for w in tail.split() if w not in _FILLERS]
|
||||||
|
return " ".join(words).strip() or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def route_intent(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
services: list[Service],
|
||||||
|
open_fn: Callable[[str], None],
|
||||||
|
status_fn: Callable[[], str],
|
||||||
|
ghostfolio_fn: Callable[[], str],
|
||||||
|
) -> str | None:
|
||||||
|
norm = _norm(text)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 1) Ghostfolio — valeur (avant « ouvre », sinon « ouvre ghostfolio » serait capté)
|
||||||
|
if any(w in norm for w in _GHOSTFOLIO_WORDS) and any(q in norm for q in _GHOSTFOLIO_QUALIFIERS):
|
||||||
|
return ghostfolio_fn()
|
||||||
|
|
||||||
|
# 2) État du cluster / des services
|
||||||
|
if any(w in norm for w in _STATUS_WORDS) and any(s in norm for s in _STATUS_SCOPE):
|
||||||
|
return status_fn()
|
||||||
|
|
||||||
|
# 3) Ouvrir un service
|
||||||
|
target = _extract_open_target(norm)
|
||||||
|
if target:
|
||||||
|
svc = match_service(services, target)
|
||||||
|
if svc is not None:
|
||||||
|
open_fn(svc.id)
|
||||||
|
return f"J'ouvre {svc.name}."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_status(status_msg: dict[str, Any] | None, services: list[Service]) -> str:
|
||||||
|
"""Résumé parlable de la santé du portail (depuis le dernier portal-status)."""
|
||||||
|
if not status_msg or not status_msg.get("services"):
|
||||||
|
return "Je récupère encore l'état des services, réessaie dans un instant."
|
||||||
|
names = {s.id: s.name for s in services}
|
||||||
|
svc_status = status_msg.get("services", {})
|
||||||
|
down, degraded, ok = [], [], 0
|
||||||
|
for sid, st in svc_status.items():
|
||||||
|
name = names.get(sid, sid)
|
||||||
|
h = (st or {}).get("health")
|
||||||
|
if h == "down":
|
||||||
|
down.append(name)
|
||||||
|
elif h == "degraded":
|
||||||
|
degraded.append(name)
|
||||||
|
elif h == "ok":
|
||||||
|
ok += 1
|
||||||
|
total = len(svc_status)
|
||||||
|
alerts = status_msg.get("alerts_total")
|
||||||
|
|
||||||
|
if not down and not degraded:
|
||||||
|
msg = f"Tout va bien : les {total} services du homelab sont en ligne."
|
||||||
|
else:
|
||||||
|
parts = []
|
||||||
|
if down:
|
||||||
|
parts.append(_enum(down) + (" est hors ligne" if len(down) == 1 else " sont hors ligne"))
|
||||||
|
if degraded:
|
||||||
|
parts.append(_enum(degraded) + (" est dégradé" if len(degraded) == 1 else " sont dégradés"))
|
||||||
|
msg = "Attention : " + " ; ".join(parts) + "."
|
||||||
|
if ok:
|
||||||
|
msg += f" Les {ok} autres sont en ligne."
|
||||||
|
if alerts:
|
||||||
|
msg += f" Il y a {alerts} alerte{'s' if alerts > 1 else ''} active{'s' if alerts > 1 else ''}."
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _enum(items: list[str]) -> str:
|
||||||
|
"""['A','B','C'] → 'A, B et C'."""
|
||||||
|
if len(items) <= 1:
|
||||||
|
return items[0] if items else ""
|
||||||
|
return ", ".join(items[:-1]) + " et " + items[-1]
|
||||||
|
|
@ -197,6 +197,21 @@ 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)
|
||||||
|
|
||||||
|
# Intentions vocales locales (lecture seule) : « ouvre X », « état du cluster »,
|
||||||
|
# « combien sur ghostfolio ». Court-circuitent le LLM. Le routeur a accès au
|
||||||
|
# portail (services, ouverture, dernière santé connue, Ghostfolio).
|
||||||
|
from stt.portal import GhostfolioClient, route_intent, summarize_status
|
||||||
|
|
||||||
|
_gf = GhostfolioClient(cfg.get("ghostfolio") or {})
|
||||||
|
engine.intent_router = lambda text: route_intent(
|
||||||
|
text,
|
||||||
|
services=services,
|
||||||
|
open_fn=open_service,
|
||||||
|
status_fn=lambda: summarize_status(last_status, services),
|
||||||
|
ghostfolio_fn=_gf.value_summary,
|
||||||
|
)
|
||||||
|
|
||||||
# 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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,10 @@ 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
|
||||||
|
# routeur d'intentions locales (portail) : (text) -> réponse | None.
|
||||||
|
# Renseigné après coup par le serveur (app.py) qui a accès au portail.
|
||||||
|
# Si une intention matche, on répond SANS appeler le LLM.
|
||||||
|
self.intent_router: Callable[[str], str | 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
|
||||||
|
|
@ -153,17 +157,29 @@ class VoiceEngine:
|
||||||
self.emit({"type": "user", "text": text})
|
self.emit({"type": "user", "text": text})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("user", text)
|
self.memory.log("user", text)
|
||||||
self.emit({"type": "state", "state": "thinking"})
|
# 1) intention locale (portail) : réponse instantanée, sans LLM
|
||||||
try:
|
resp = None
|
||||||
resp = self.responder(text)
|
mode = None
|
||||||
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
|
if self.intent_router is not None:
|
||||||
self.emit({"type": "error", "text": str(e)})
|
try:
|
||||||
self.emit({"type": "state", "state": "idle"})
|
resp = self.intent_router(text)
|
||||||
return
|
except Exception: # noqa: BLE001 — un intent cassé ne bloque pas le tour
|
||||||
|
resp = None
|
||||||
|
if resp is not None:
|
||||||
|
mode = "portail"
|
||||||
|
else:
|
||||||
|
# 2) sinon : on interroge le LLM via le STT-server
|
||||||
|
self.emit({"type": "state", "state": "thinking"})
|
||||||
|
try:
|
||||||
|
resp = self.responder(text)
|
||||||
|
except Exception as e: # noqa: BLE001 — on remonte au HUD, on ne crash pas
|
||||||
|
self.emit({"type": "error", "text": str(e)})
|
||||||
|
self.emit({"type": "state", "state": "idle"})
|
||||||
|
return
|
||||||
|
mode = self.model_label() if self.model_label else None
|
||||||
if self._interrupt.is_set(): # « stop » pressé pendant la génération
|
if self._interrupt.is_set(): # « stop » pressé pendant la génération
|
||||||
self.emit({"type": "state", "state": "idle"})
|
self.emit({"type": "state", "state": "idle"})
|
||||||
return
|
return
|
||||||
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})
|
||||||
if self.memory:
|
if self.memory:
|
||||||
self.memory.log("assistant", resp)
|
self.memory.log("assistant", resp)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue