Files
rpa_vision_v3/agent_v0/agent_v1/ui/activity_panel.py
Dom a6eb4c168f feat: Léa UX — messages français naturels + feedback temps réel
Aspect 1/4 de Léa (agent Windows) : rendre Léa humaine.

Nouveaux modules :
- agent_v1/ui/messages.py : 11 formatters (cible non trouvée, mauvaise fenêtre,
  écran inchangé, connexion, workflow, retry, ralentissement, erreur générique)
- agent_v1/ui/activity_panel.py : panneau tkinter lazy avec état courant,
  action, progression X/Y, temps écoulé, 7 états (OBSERVE/CHERCHE/AGIT/VERIFIE...)

Hiérarchie de notifications :
- INFO (4s, vert) — début workflow, étape en cours
- ATTENTION (7s, orange) — retry, ralentissement
- BLOCAGE (15s, rouge, persistent, bypass rate-limit) — cible introuvable, mauvaise fenêtre

Transformations de messages :
  AVANT : "target_not_found: dans *bonjour, – Bloc-notes"
  APRÈS : "Léa a besoin d'aide"
          "Je ne trouve pas « bonjour » dans Bloc-notes.
           Peux-tu cliquer dessus toi-même ? Je reprends ensuite."

Robustesse :
- Détection fenêtre Léa via regex word-boundaries (évite cléa.txt, leapfrog.exe)
- Centralisée dans messages.est_fenetre_lea() — source unique de vérité
- Noop stub universel via __getattr__ (plus besoin de lister les méthodes)
- Thread-safe (RLock + snapshots immutables)
- Fallback silencieux si tkinter/plyer absent

101 nouveaux tests, aucune régression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:42:01 +02:00

419 lines
14 KiB
Python

# agent_v1/ui/activity_panel.py
"""
Panel d'activité temps réel de Léa.
Affiche à l'utilisateur ce que Léa fait *maintenant* :
- État courant (Observe / Cherche / Agit / Vérifie / Bloquée)
- Action en cours (ex: "Clic sur Rechercher")
- Progression (ex: "3/15")
- Temps écoulé depuis le début du workflow
Contraintes :
- Fallback silencieux si tkinter absent (ne crash jamais)
- Thread-safe (mises à jour depuis les threads de replay)
- Pas de dépendance à PyQt5 (seulement tkinter, déjà utilisé par chat_window)
Utilisation :
panel = ActivityPanel()
panel.definir_workflow("Saisie patient", nb_etapes=15)
panel.mettre_a_jour(etat=EtatLea.AGIT, action="Clic sur Valider", etape=3)
panel.masquer()
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
logger = logging.getLogger(__name__)
class EtatLea(Enum):
"""États macroscopiques de Léa pendant un replay."""
INACTIVE = ("inactive", "Prête", "#808080") # Gris
OBSERVE = ("observe", "Observe", "#4A90E2") # Bleu
CHERCHE = ("cherche", "Cherche", "#F5A623") # Orange
AGIT = ("agit", "Agit", "#7ED321") # Vert
VERIFIE = ("verifie", "Vérifie", "#9013FE") # Violet
BLOQUEE = ("bloquee", "Bloquée", "#D0021B") # Rouge
TERMINE = ("termine", "Terminé", "#50E3C2") # Turquoise
def __init__(self, code: str, libelle: str, couleur: str) -> None:
self.code = code
self.libelle = libelle
self.couleur = couleur
@dataclass
class EtatActivite:
"""Instantané de l'activité courante de Léa.
Utilisé par le panel et exposé par `ActivityPanel.snapshot()` pour les
tests (sans dépendre de tkinter).
"""
etat: EtatLea = EtatLea.INACTIVE
action_courante: str = ""
nom_workflow: str = ""
etape: int = 0
nb_etapes: int = 0
debut_timestamp: float = 0.0
dernier_message: str = ""
def temps_ecoule_s(self) -> float:
"""Temps écoulé depuis le début du workflow (secondes)."""
if self.debut_timestamp <= 0:
return 0.0
return max(0.0, time.time() - self.debut_timestamp)
def progression_texte(self) -> str:
"""Représentation textuelle de la progression (ex: '3/15')."""
if self.nb_etapes <= 0:
return ""
return f"{self.etape}/{self.nb_etapes}"
def temps_ecoule_texte(self) -> str:
"""Représentation humaine du temps écoulé (ex: '12s', '1m24s')."""
s = int(self.temps_ecoule_s())
if s < 60:
return f"{s}s"
return f"{s // 60}m{s % 60:02d}s"
def to_dict(self) -> dict:
"""Sérialiser pour le logging et les tests."""
return {
"etat": self.etat.code,
"etat_libelle": self.etat.libelle,
"action_courante": self.action_courante,
"nom_workflow": self.nom_workflow,
"etape": self.etape,
"nb_etapes": self.nb_etapes,
"progression": self.progression_texte(),
"temps_ecoule_s": round(self.temps_ecoule_s(), 1),
"dernier_message": self.dernier_message,
}
class ActivityPanel:
"""Panel d'activité de Léa.
Thread-safe. Le panel tkinter est créé à la demande (lazy) et uniquement
si tkinter est disponible. Toutes les méthodes sont safe à appeler même
si l'UI n'est pas dispo (fallback silencieux).
"""
def __init__(self, activer_ui: bool = True) -> None:
self._lock = threading.RLock()
self._etat = EtatActivite()
self._activer_ui = activer_ui
# UI tkinter (créée à la demande dans le thread UI)
self._tk_root = None
self._tk_labels: dict = {}
self._ui_disponible = None # Lazy : résolu au premier usage
self._listeners = [] # Callbacks pour les changements d'état
# ------------------------------------------------------------------
# API publique (thread-safe)
# ------------------------------------------------------------------
def definir_workflow(self, nom: str, nb_etapes: int = 0) -> None:
"""Démarrer le suivi d'un nouveau workflow."""
with self._lock:
self._etat = EtatActivite(
etat=EtatLea.OBSERVE,
nom_workflow=nom,
nb_etapes=nb_etapes,
debut_timestamp=time.time(),
)
self._notifier_changement()
self._rafraichir_ui()
logger.info(f"[ACTIVITY] Workflow démarré : {nom} ({nb_etapes} étapes)")
def mettre_a_jour(
self,
etat: Optional[EtatLea] = None,
action: Optional[str] = None,
etape: Optional[int] = None,
message: Optional[str] = None,
) -> None:
"""Mettre à jour l'état affiché.
Tous les paramètres sont optionnels — on ne met à jour que ce qui est
fourni. Les autres champs conservent leur valeur actuelle.
"""
with self._lock:
if etat is not None:
self._etat.etat = etat
if action is not None:
self._etat.action_courante = action
if etape is not None:
self._etat.etape = etape
if message is not None:
self._etat.dernier_message = message
self._notifier_changement()
self._rafraichir_ui()
def terminer(self, succes: bool = True) -> None:
"""Marquer le workflow comme terminé."""
with self._lock:
self._etat.etat = EtatLea.TERMINE if succes else EtatLea.BLOQUEE
if not succes:
self._etat.dernier_message = (
self._etat.dernier_message or "Léa a rendu la main"
)
self._notifier_changement()
self._rafraichir_ui()
def reinitialiser(self) -> None:
"""Remettre le panel en état inactif."""
with self._lock:
self._etat = EtatActivite()
self._notifier_changement()
self._rafraichir_ui()
def snapshot(self) -> EtatActivite:
"""Obtenir un instantané immuable de l'état courant (pour les tests)."""
with self._lock:
return EtatActivite(
etat=self._etat.etat,
action_courante=self._etat.action_courante,
nom_workflow=self._etat.nom_workflow,
etape=self._etat.etape,
nb_etapes=self._etat.nb_etapes,
debut_timestamp=self._etat.debut_timestamp,
dernier_message=self._etat.dernier_message,
)
def masquer(self) -> None:
"""Masquer le panel UI si affiché."""
if self._tk_root is not None:
try:
self._tk_root.withdraw()
except Exception:
pass
def afficher(self) -> None:
"""Afficher le panel UI si disponible."""
self._creer_ui_si_besoin()
if self._tk_root is not None:
try:
self._tk_root.deiconify()
except Exception:
pass
def on_change(self, callback) -> None:
"""Enregistrer un listener appelé à chaque changement d'état."""
with self._lock:
self._listeners.append(callback)
# ------------------------------------------------------------------
# Gestion UI tkinter (lazy, fallback silencieux)
# ------------------------------------------------------------------
def _creer_ui_si_besoin(self) -> None:
"""Créer la fenêtre tkinter au premier usage (lazy)."""
if not self._activer_ui:
return
if self._tk_root is not None:
return
if self._ui_disponible is False:
return # Déjà testé et indisponible
try:
import tkinter as tk
except Exception as e:
logger.debug(f"[ACTIVITY] tkinter indisponible : {e}")
self._ui_disponible = False
return
try:
self._tk_root = tk.Toplevel() if _tk_root_existe() else tk.Tk()
self._tk_root.title("Léa — Activité")
self._tk_root.geometry("340x180+40+40")
self._tk_root.attributes("-topmost", True)
self._tk_root.resizable(False, False)
self._tk_root.configure(bg="#1E1E1E")
titre = tk.Label(
self._tk_root,
text="Léa",
font=("Segoe UI", 14, "bold"),
fg="#FFFFFF",
bg="#1E1E1E",
)
titre.pack(pady=(10, 2))
self._tk_labels["etat"] = tk.Label(
self._tk_root,
text="Prête",
font=("Segoe UI", 11),
fg="#808080",
bg="#1E1E1E",
)
self._tk_labels["etat"].pack()
self._tk_labels["action"] = tk.Label(
self._tk_root,
text="",
font=("Segoe UI", 10),
fg="#FFFFFF",
bg="#1E1E1E",
wraplength=300,
)
self._tk_labels["action"].pack(pady=(8, 2))
self._tk_labels["progression"] = tk.Label(
self._tk_root,
text="",
font=("Segoe UI", 9),
fg="#B0B0B0",
bg="#1E1E1E",
)
self._tk_labels["progression"].pack()
self._tk_labels["temps"] = tk.Label(
self._tk_root,
text="",
font=("Segoe UI", 9),
fg="#808080",
bg="#1E1E1E",
)
self._tk_labels["temps"].pack(pady=(4, 0))
self._tk_labels["message"] = tk.Label(
self._tk_root,
text="",
font=("Segoe UI", 9, "italic"),
fg="#B0B0B0",
bg="#1E1E1E",
wraplength=300,
)
self._tk_labels["message"].pack(pady=(6, 10))
# Masquer par défaut : on affiche seulement pendant un workflow
self._tk_root.withdraw()
self._ui_disponible = True
except Exception as e:
logger.debug(f"[ACTIVITY] Impossible de créer l'UI : {e}")
self._ui_disponible = False
self._tk_root = None
def _rafraichir_ui(self) -> None:
"""Mettre à jour les labels tkinter (safe si l'UI n'existe pas)."""
if not self._activer_ui or self._ui_disponible is False:
return
self._creer_ui_si_besoin()
if self._tk_root is None:
return
try:
with self._lock:
snap = self.snapshot()
# Utiliser after(0) pour rester dans le thread UI tkinter
def _update():
try:
self._tk_labels["etat"].config(
text=snap.etat.libelle,
fg=snap.etat.couleur,
)
if snap.action_courante:
self._tk_labels["action"].config(text=snap.action_courante)
else:
self._tk_labels["action"].config(text="")
prog = snap.progression_texte()
if prog and snap.nom_workflow:
self._tk_labels["progression"].config(
text=f"« {snap.nom_workflow} » — {prog}"
)
elif snap.nom_workflow:
self._tk_labels["progression"].config(
text=f"« {snap.nom_workflow} »"
)
else:
self._tk_labels["progression"].config(text="")
if snap.debut_timestamp > 0:
self._tk_labels["temps"].config(
text=f"{snap.temps_ecoule_texte()}"
)
else:
self._tk_labels["temps"].config(text="")
self._tk_labels["message"].config(text=snap.dernier_message)
# Afficher automatiquement si actif
if snap.etat != EtatLea.INACTIVE:
self._tk_root.deiconify()
except Exception:
pass
try:
self._tk_root.after(0, _update)
except Exception:
# Si le root a été détruit
self._tk_root = None
self._ui_disponible = False
except Exception as e:
logger.debug(f"[ACTIVITY] Erreur rafraîchissement UI : {e}")
def _notifier_changement(self) -> None:
"""Notifier tous les listeners du changement d'état."""
with self._lock:
listeners = list(self._listeners)
snap = self.snapshot()
for cb in listeners:
try:
cb(snap)
except Exception as e:
logger.debug(f"[ACTIVITY] Listener erreur : {e}")
def _tk_root_existe() -> bool:
"""Vérifier si un root tkinter existe déjà (pour créer un Toplevel)."""
try:
import tkinter as tk
default_root = getattr(tk, "_default_root", None)
return default_root is not None
except Exception:
return False
# ============================================================================
# Singleton global (optionnel)
# ============================================================================
_INSTANCE_GLOBALE: Optional[ActivityPanel] = None
_LOCK_SINGLETON = threading.Lock()
def get_activity_panel(activer_ui: bool = True) -> ActivityPanel:
"""Obtenir l'instance globale du panel d'activité (lazy)."""
global _INSTANCE_GLOBALE
with _LOCK_SINGLETON:
if _INSTANCE_GLOBALE is None:
_INSTANCE_GLOBALE = ActivityPanel(activer_ui=activer_ui)
return _INSTANCE_GLOBALE
def reset_activity_panel() -> None:
"""Réinitialiser le singleton (utile pour les tests)."""
global _INSTANCE_GLOBALE
with _LOCK_SINGLETON:
if _INSTANCE_GLOBALE is not None:
try:
_INSTANCE_GLOBALE.masquer()
except Exception:
pass
_INSTANCE_GLOBALE = None