feat(agent): add learn action flow and grounding guards
This commit is contained in:
@@ -9,6 +9,7 @@ Tourne dans son propre thread daemon pour ne pas bloquer pystray.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
@@ -121,7 +122,7 @@ def _tpl_done(payload: Dict[str, Any]) -> tuple:
|
||||
def _tpl_need_confirm(payload: Dict[str, Any]) -> tuple:
|
||||
action = payload.get("action") or {}
|
||||
desc = action.get("description") if isinstance(action, dict) else None
|
||||
title = desc or "Validation requise"
|
||||
title = desc or "J'attends ton accord avant de continuer"
|
||||
return ("?", ACTION_ICON_RUN, str(title))
|
||||
|
||||
|
||||
@@ -867,11 +868,19 @@ class ChatWindow:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("force-show chat_window silenced", exc_info=True)
|
||||
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
|
||||
# l'attention sur la question (clear visuel uniquement,
|
||||
# self._messages reste intact pour la traçabilité debug).
|
||||
self._clear_chat_history()
|
||||
self._render_paused_bubble(payload)
|
||||
try:
|
||||
# UX fix mai 2026 : repartir d'un chat vide pour focaliser
|
||||
# l'attention sur la question (clear visuel uniquement,
|
||||
# self._messages reste intact pour la traçabilité debug).
|
||||
self._clear_chat_history()
|
||||
self._render_paused_bubble(payload)
|
||||
except Exception:
|
||||
logger.exception("render paused bubble failed; using fallback")
|
||||
try:
|
||||
self._clear_chat_history()
|
||||
self._render_paused_fallback_bubble(payload)
|
||||
except Exception:
|
||||
logger.debug("render paused fallback silenced", exc_info=True)
|
||||
|
||||
self._root.after(0, _show_and_render)
|
||||
|
||||
@@ -895,7 +904,11 @@ class ChatWindow:
|
||||
logger.debug("clear chat history silenced", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _compute_paused_bubble_height(reason_str: str) -> tuple:
|
||||
def _compute_paused_bubble_height(
|
||||
reason_str: str,
|
||||
chars_per_line: int = 52,
|
||||
max_rows: int = 14,
|
||||
) -> tuple:
|
||||
"""Calcule la hauteur du Text (en lignes) + si une scrollbar est
|
||||
nécessaire pour le message d'une bulle paused.
|
||||
|
||||
@@ -910,11 +923,11 @@ class ChatWindow:
|
||||
if not reason_str:
|
||||
return 2, False
|
||||
text = str(reason_str)
|
||||
# Estimation : ~60 chars/ligne effectifs avec wraplength.
|
||||
wrapped_lines = (len(text) // 60) + 1
|
||||
explicit_lines = text.count("\n") + 1
|
||||
estimated = max(wrapped_lines, explicit_lines)
|
||||
cap = 12
|
||||
chars_per_line = max(24, int(chars_per_line or 52))
|
||||
estimated = 0
|
||||
for raw_line in text.splitlines() or [""]:
|
||||
estimated += max(1, math.ceil(len(raw_line) / chars_per_line))
|
||||
cap = max(2, int(max_rows or 14))
|
||||
height = max(2, min(cap, estimated))
|
||||
# Scrollbar dès que le cap est atteint OU contenu long (filet
|
||||
# textuel : ≥ 200 chars implique souvent un débordement visuel
|
||||
@@ -922,6 +935,46 @@ class ChatWindow:
|
||||
needs_scroll = (estimated >= cap) or (len(text) > 200)
|
||||
return height, needs_scroll
|
||||
|
||||
def _paused_text_layout(self) -> tuple:
|
||||
"""Retourne ``(wrap_px, chars_per_line, max_rows)`` pour la bulle pause.
|
||||
|
||||
La fenêtre Léa est souvent redimensionnée à ~380px de large sur le
|
||||
poste Windows. Les anciennes estimations fixes calculaient trop peu
|
||||
de lignes et tronquaient le message. On part donc des dimensions
|
||||
réelles du canvas et de la métrique de la police Tk.
|
||||
"""
|
||||
canvas_w = 0
|
||||
canvas_h = 0
|
||||
try:
|
||||
canvas_w = int(self._canvas.winfo_width()) if self._canvas is not None else 0
|
||||
canvas_h = int(self._canvas.winfo_height()) if self._canvas is not None else 0
|
||||
except Exception:
|
||||
canvas_w = canvas_h = 0
|
||||
|
||||
# Marges: container + padding inner + petite marge droite. La bulle
|
||||
# de pause est une alerte critique, elle utilise donc presque toute
|
||||
# la largeur disponible sur les fenêtres étroites.
|
||||
wrap_px = max(220, canvas_w - (2 * MARGIN) - 52) if canvas_w else 360
|
||||
|
||||
avg_char = 8
|
||||
line_px = 22
|
||||
try:
|
||||
from tkinter import font as tkfont
|
||||
font = tkfont.Font(font=FONT_MSG)
|
||||
avg_char = max(6, font.measure("n"))
|
||||
line_px = max(18, font.metrics("linespace"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
chars_per_line = max(24, int(wrap_px / avg_char))
|
||||
# Réserver titre, metadata, boutons, feedback et padding. Même sur
|
||||
# une petite fenêtre, on garde assez de lignes pour ne pas couper un
|
||||
# message d'erreur standard.
|
||||
max_rows = 14
|
||||
if canvas_h:
|
||||
max_rows = max(5, min(18, int((canvas_h - 145) / line_px)))
|
||||
return wrap_px, chars_per_line, max_rows
|
||||
|
||||
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
@@ -941,7 +994,7 @@ class ChatWindow:
|
||||
container, bg=PAUSED_BG, padx=14, pady=12,
|
||||
highlightbackground=PAUSED_BORDER, highlightthickness=2,
|
||||
)
|
||||
inner.pack(anchor=tk.W, padx=(0, 50), fill=tk.X)
|
||||
inner.pack(anchor=tk.W, padx=(0, 12), fill=tk.X)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"⏸ Pause supervisée • {now}",
|
||||
@@ -949,31 +1002,44 @@ class ChatWindow:
|
||||
font=("Segoe UI", 12, "bold"), anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
# Message scrollable pour les longs reasons (ex: 200+ chars depuis le serveur).
|
||||
# On utilise un Text en mode read-only avec hauteur calculée selon la longueur.
|
||||
# Patch 22 mai 2026 : prendre en compte les \n explicites (titres
|
||||
# fenêtre / patterns) et activer la scrollbar dès que le cap de
|
||||
# hauteur est atteint — sinon les bulles de pause étaient
|
||||
# tronquées visuellement sans aucun ascenseur visible.
|
||||
# Message borné et scrollable : sur une fenêtre Léa étroite, une
|
||||
# bulle trop haute fait disparaître le début du diagnostic hors du
|
||||
# viewport. On garde donc la bulle compacte et on scrolle le texte.
|
||||
reason_str = str(reason)
|
||||
height_lines, needs_scroll = self._compute_paused_bubble_height(reason_str)
|
||||
msg_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
msg_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
reason_text = tk.Text(
|
||||
msg_frame, bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_MSG, wrap=tk.WORD, bd=0, height=height_lines,
|
||||
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
|
||||
_wrap_px, chars_per_line, max_rows = self._paused_text_layout()
|
||||
text_rows, needs_text_scroll = self._compute_paused_bubble_height(
|
||||
reason_str,
|
||||
chars_per_line=chars_per_line,
|
||||
max_rows=max_rows,
|
||||
)
|
||||
reason_text.insert("1.0", reason_str)
|
||||
reason_text.configure(state="disabled")
|
||||
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
if needs_scroll:
|
||||
reason_scroll = tk.Scrollbar(
|
||||
msg_frame, orient=tk.VERTICAL,
|
||||
command=reason_text.yview, width=8,
|
||||
text_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
text_frame.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
reason_msg = tk.Text(
|
||||
text_frame,
|
||||
height=text_rows,
|
||||
wrap=tk.WORD,
|
||||
bg=PAUSED_BG,
|
||||
fg=PAUSED_FG,
|
||||
font=FONT_MSG,
|
||||
bd=0,
|
||||
highlightthickness=0,
|
||||
relief=tk.FLAT,
|
||||
padx=0,
|
||||
pady=0,
|
||||
cursor="arrow",
|
||||
)
|
||||
reason_msg.insert("1.0", reason_str)
|
||||
reason_msg.configure(state="disabled")
|
||||
reason_msg.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
if needs_text_scroll:
|
||||
scrollbar = tk.Scrollbar(
|
||||
text_frame,
|
||||
orient=tk.VERTICAL,
|
||||
command=reason_msg.yview,
|
||||
width=12,
|
||||
)
|
||||
reason_text.configure(yscrollcommand=reason_scroll.set)
|
||||
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
reason_msg.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y, padx=(6, 0))
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"{workflow} — étape {completed}/{total}",
|
||||
@@ -1018,6 +1084,89 @@ class ChatWindow:
|
||||
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _render_paused_fallback_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
"""Rendu minimal de secours si la bulle riche echoue."""
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
return
|
||||
|
||||
replay_id = str(payload.get("replay_id", "") or "")
|
||||
workflow = payload.get("workflow", "?")
|
||||
reason = str(
|
||||
payload.get("reason")
|
||||
or "Action incertaine - j'ai besoin de votre validation."
|
||||
)
|
||||
completed = payload.get("completed", 0)
|
||||
total = payload.get("total", "?")
|
||||
now = datetime.now().strftime("%H:%M")
|
||||
|
||||
container = tk.Frame(self._msg_frame, bg=BG_COLOR)
|
||||
container.pack(fill=tk.X, padx=MARGIN, pady=6)
|
||||
|
||||
inner = tk.Frame(
|
||||
container, bg=PAUSED_BG, padx=14, pady=12,
|
||||
highlightbackground=PAUSED_BORDER, highlightthickness=2,
|
||||
)
|
||||
inner.pack(anchor=tk.W, padx=(0, 12), fill=tk.X)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"Pause supervisee - {now}",
|
||||
bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=("Segoe UI", 12, "bold"), anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
wrap_px = 360
|
||||
try:
|
||||
if self._canvas is not None:
|
||||
wrap_px = max(220, int(self._canvas.winfo_width()) - 80)
|
||||
except Exception:
|
||||
pass
|
||||
tk.Label(
|
||||
inner, text=reason, bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_MSG, wraplength=wrap_px, justify=tk.LEFT,
|
||||
anchor=tk.W,
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"{workflow} - etape {completed}/{total}",
|
||||
bg=PAUSED_BG, fg=TIMESTAMP_FG, font=FONT_TIMESTAMP, anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(4, 8))
|
||||
|
||||
btn_frame = tk.Frame(inner, bg=PAUSED_BG)
|
||||
btn_frame.pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
btn_resume = tk.Button(
|
||||
btn_frame, text="Continuer",
|
||||
bg=PAUSED_BTN_RESUME_BG, fg="white", font=FONT_QUICK_BTN,
|
||||
padx=14, pady=4, bd=0, cursor="hand2",
|
||||
activebackground=PAUSED_BTN_RESUME_HOVER, activeforeground="white",
|
||||
command=lambda: self._on_paused_resume(replay_id),
|
||||
)
|
||||
btn_resume.pack(side=tk.LEFT, padx=(0, 8))
|
||||
|
||||
btn_abort = tk.Button(
|
||||
btn_frame, text="Annuler",
|
||||
bg=PAUSED_BTN_ABORT_BG, fg="white", font=FONT_QUICK_BTN,
|
||||
padx=14, pady=4, bd=0, cursor="hand2",
|
||||
activebackground=PAUSED_BTN_ABORT_HOVER, activeforeground="white",
|
||||
command=lambda: self._on_paused_abort(replay_id),
|
||||
)
|
||||
btn_abort.pack(side=tk.LEFT)
|
||||
|
||||
feedback_label = tk.Label(
|
||||
inner, text="", bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_TIMESTAMP, anchor="w",
|
||||
)
|
||||
feedback_label.pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
|
||||
self._active_paused_bubble = {
|
||||
"container": container, "inner": inner,
|
||||
"btn_resume": btn_resume, "btn_abort": btn_abort,
|
||||
"feedback_label": feedback_label,
|
||||
"replay_id": replay_id,
|
||||
}
|
||||
self._scroll_to_bottom()
|
||||
|
||||
def _close_active_paused_bubble(self, reason: str) -> None:
|
||||
if self._active_paused_bubble is None or self._root is None:
|
||||
return
|
||||
@@ -1524,8 +1673,19 @@ class ChatWindow:
|
||||
self._add_lea_message(
|
||||
f"C'est parti ! Montrez-moi comment faire \u00ab {name} \u00bb."
|
||||
)
|
||||
|
||||
# --- P1-LEA-SHADOW : d\u00e9clencher d'abord l'orchestrateur L\u00e9a Linux ---
|
||||
# On contacte agent-chat AVANT la capture locale : si la session
|
||||
# serveur d\u00e9marre, on r\u00e9cup\u00e8re un session_id + un message d'accueil
|
||||
# de L\u00e9a qu'on affiche dans le chat. Si \u00e9chec : mode d\u00e9grad\u00e9
|
||||
# (capture locale uniquement, sans assistance conversationnelle).
|
||||
self._start_lea_orchestrator_session(name)
|
||||
|
||||
# --- Comportement historique pr\u00e9serv\u00e9 : capture locale ---
|
||||
# Le pipeline streaming (frames/\u00e9v\u00e9nements) reste pilot\u00e9 par
|
||||
# agent_v1 local. L'orchestrateur Linux ne touche PAS \u00e0 la
|
||||
# capture, il pilote uniquement le dialogue de fin de session.
|
||||
try:
|
||||
# Utiliser l'etat partage si disponible (synchronise le systray)
|
||||
if self._shared_state is not None:
|
||||
self._shared_state.start_recording(name)
|
||||
elif self._on_start_callback is not None:
|
||||
@@ -1533,6 +1693,60 @@ class ChatWindow:
|
||||
except Exception as e:
|
||||
self._add_lea_message(f"Oups, un probl\u00e8me : {e}")
|
||||
|
||||
def _start_lea_orchestrator_session(self, session_name: str) -> None:
|
||||
"""Appelle POST /api/learn/start c\u00f4t\u00e9 agent-chat Linux (P1-LEA-SHADOW).
|
||||
|
||||
Fail-safe : toute erreur (config absente, httpx manquant, timeout,
|
||||
500 serveur...) bascule en mode d\u00e9grad\u00e9 sans bloquer la capture
|
||||
locale. Un message clair est affich\u00e9 dans le chat.
|
||||
"""
|
||||
try:
|
||||
from ..config import AGENT_CHAT_URL, API_TOKEN, MACHINE_ID
|
||||
from ..network.lea_orchestrator_client import (
|
||||
LeaOrchestratorError,
|
||||
start_learning_session,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover (import-time)
|
||||
logger.error("Impossible de charger le client orchestrateur L\u00e9a : %s", exc)
|
||||
self._add_lea_message(
|
||||
"\u26a0 Impossible de joindre L\u00e9a serveur. "
|
||||
"L'apprentissage continue localement, mais sans assistance "
|
||||
"conversationnelle."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
resp = start_learning_session(
|
||||
AGENT_CHAT_URL,
|
||||
machine_id=MACHINE_ID,
|
||||
session_name=session_name,
|
||||
api_token=API_TOKEN,
|
||||
trigger_source="windows_button",
|
||||
)
|
||||
except LeaOrchestratorError as exc:
|
||||
logger.error("Orchestrateur L\u00e9a injoignable : %s", exc)
|
||||
self._add_lea_message(
|
||||
"\u26a0 Impossible de joindre L\u00e9a serveur. "
|
||||
"L'apprentissage continue localement, mais sans assistance "
|
||||
"conversationnelle."
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 \u2014 d\u00e9fensif
|
||||
logger.exception("Erreur inattendue orchestrateur L\u00e9a")
|
||||
self._add_lea_message(
|
||||
f"\u26a0 Erreur orchestrateur L\u00e9a : {exc}. "
|
||||
"L'apprentissage continue localement."
|
||||
)
|
||||
return
|
||||
|
||||
# Affichage du message d'accueil renvoy\u00e9 par L\u00e9a (si pr\u00e9sent)
|
||||
if resp.message:
|
||||
self._add_lea_message(resp.message)
|
||||
logger.info(
|
||||
"Session orchestrateur L\u00e9a OK : id=%s state=%s",
|
||||
resp.session_id, resp.state,
|
||||
)
|
||||
|
||||
def _on_quick_tasks(self) -> None:
|
||||
"""Bouton Lancer — demande ce que L\u00e9a sait faire."""
|
||||
self._add_user_message("Qu'est-ce que vous savez faire ?")
|
||||
|
||||
Reference in New Issue
Block a user