feat(agent_v1): toast paused supervisée Tkinter + Plan B + threshold FIND-TEXT 0.75
Démo GHT 8 mai 2026 — Dom utilise UNIQUEMENT Léa V1 sur Windows pendant la démo (pas le frontend VWB Linux), donc les pause_message du serveur doivent être visuellement évidents sur l'écran Windows. Modifications client validées par Dom + redéployées via SCP (procédure 2026-04-28). 1. ui/paused_toast.py (NEW) — Toast Tkinter custom autonome : Toplevel topmost overrideredirect, fond bleu Léa (#2563EB), 380px, haut-droite, auto-close 15s, click-to-close. Re-pin -topmost à 100/500/2000 ms (Windows démet le flag quand le focus part). Rate limit 3s sur message identique. Aucune dépendance externe (tkinter stdlib uniquement). Thread-safe : root.after si Tk root existe, sinon Tk dédié dans un daemon thread. Remplace plyer qui s'avère silencieux sur Windows 11 (Focus Assist + manque app-id COM). 2. ui/chat_window.py — _add_paused_bubble force la visibilité : La fenêtre Léa démarrait avec root.withdraw() — la bulle paused était bien rendue mais invisible. Ajout deiconify+lift+focus_force avant render, plus appel à show_paused_toast en complément. 3. ui/notifications.py — niveau BLOCAGE déclenche aussi le toast : Quand notify_message reçoit un MessageUtilisateur.BLOCAGE (cible non trouvée, mode apprentissage, fenêtre incorrecte), appelle show_paused_toast en plus de plyer. Couvre la branche supervision client (executor.py:1012) qui ne passe pas par Plan B serveur. 4. core/executor.py — Plan B replay_paused (lignes 1812-1850) : Intercepte data["replay_paused"]=True dans la réponse /replay/next, appelle chat_window._add_paused_bubble si _chat_window_ref défini, sinon fallback notifier.notify. Idempotence via _last_pause_msg_shown pour ne pas spammer (1 toast par (replay_id, message) unique). Threshold FIND-TEXT _find_text_on_screen : 0.50 → 0.75 pour rejeter les faux positifs (placeholders italiques, tabs voisins) et tomber en mode apprentissage humain plutôt qu'un clic au pif. 5. main.py — Wiring ChatWindow → Executor pour Plan B. 6. tools/test_lea_toast.py + ui/_test_paused_toast.py (NEW) — Scripts de test isolé pour validation visuelle rapide sans relancer un replay complet (commande dans les docstrings). Validé visuellement sur DESKTOP-58D5CAC. Toasts apparaissent en haut- droite, fond bleu, auto-close 15s. Test isolé Dom : 3 toasts successifs visibles sans accroc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
53
agent_v0/agent_v1/ui/_test_paused_toast.py
Normal file
53
agent_v0/agent_v1/ui/_test_paused_toast.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# agent_v1/ui/_test_paused_toast.py
|
||||
"""
|
||||
Test isolé du toast paused — à exécuter directement sur Windows.
|
||||
|
||||
Usage (sur Windows, depuis C:\\rpa_vision\\agent_v1) :
|
||||
python -m agent_v1.ui._test_paused_toast
|
||||
|
||||
OU plus simple :
|
||||
python C:\\rpa_vision\\agent_v1\\ui\\_test_paused_toast.py
|
||||
|
||||
Le toast doit s'afficher en haut à droite de l'écran principal pendant ~15s.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("[TEST] Lancement du toast paused...")
|
||||
|
||||
try:
|
||||
# Import flexible : essai relatif puis absolu
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
except ImportError:
|
||||
from paused_toast import show_paused_toast
|
||||
except Exception as e:
|
||||
print(f"[TEST] ERREUR import : {e}")
|
||||
return 1
|
||||
|
||||
ok = show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=(
|
||||
"Test isolé — démo GHT 8 mai 2026.\n"
|
||||
"Si vous voyez ce toast, le mécanisme de pause supervisée "
|
||||
"fonctionne correctement."
|
||||
),
|
||||
)
|
||||
print(f"[TEST] show_paused_toast() retour = {ok}")
|
||||
|
||||
if not ok:
|
||||
print("[TEST] ÉCHEC : toast non déclenché.")
|
||||
return 2
|
||||
|
||||
print("[TEST] Toast déclenché. Attente de 18s pour le voir s'afficher puis se fermer...")
|
||||
time.sleep(18)
|
||||
print("[TEST] OK — fin du test.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -838,10 +838,38 @@ class ChatWindow:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _add_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide)."""
|
||||
"""Ajouter une bulle paused interactive (asset démo : Léa demande de l'aide).
|
||||
|
||||
IMPORTANT (8 mai 2026, démo GHT) : par défaut la fenêtre démarre cachée
|
||||
(`root.withdraw()`). Il FAUT la rendre visible et la forcer au premier
|
||||
plan, sinon Dom ne voit jamais la bulle. On exécute dans le thread
|
||||
tkinter via `root.after(0, ...)`.
|
||||
"""
|
||||
if self._root is None:
|
||||
return
|
||||
self._root.after(0, lambda: self._render_paused_bubble(payload))
|
||||
|
||||
def _show_and_render():
|
||||
try:
|
||||
self._do_show()
|
||||
# Re-pin topmost pour passer devant les apps actives
|
||||
self._root.attributes("-topmost", True)
|
||||
self._root.lift()
|
||||
# Toast topmost en complément (visible même si la chat est
|
||||
# masquée par une fenêtre d'app)
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
reason = payload.get("reason") or "Action en attente."
|
||||
show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=str(reason)[:300],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("paused_toast launch silenced", exc_info=True)
|
||||
except Exception:
|
||||
logger.debug("force-show chat_window silenced", exc_info=True)
|
||||
self._render_paused_bubble(payload)
|
||||
|
||||
self._root.after(0, _show_and_render)
|
||||
|
||||
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
tk = self._tk
|
||||
|
||||
@@ -139,10 +139,28 @@ class NotificationManager:
|
||||
|
||||
Les messages BLOCAGE bypass le rate limit pour garantir que
|
||||
l'utilisateur voit qu'on a besoin de lui.
|
||||
|
||||
Démo GHT 8 mai 2026 : pour les BLOCAGE, on déclenche en complément
|
||||
un toast Tkinter custom topmost (paused_toast). Plyer est silencieux
|
||||
sur Windows 11 quand Focus Assist / Quiet Hours / app-id manquante
|
||||
bloquent les balloons. Le toast custom est 100 % autonome et garantit
|
||||
que Dom voit le message en démo.
|
||||
"""
|
||||
bypass = msg.niveau == NiveauMessage.BLOCAGE
|
||||
# Log aussi pour tracer dans les logs fichiers
|
||||
self._log_message(msg)
|
||||
|
||||
# Toast Tkinter custom — uniquement BLOCAGE pour ne pas spammer
|
||||
if msg.niveau == NiveauMessage.BLOCAGE:
|
||||
try:
|
||||
from .paused_toast import show_paused_toast
|
||||
show_paused_toast(
|
||||
title=str(msg.titre)[:80] or "Léa a besoin de votre aide",
|
||||
message=str(msg.corps)[:300],
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("paused_toast (BLOCAGE) silenced", exc_info=True)
|
||||
|
||||
return self.notify(
|
||||
title=msg.titre,
|
||||
message=msg.corps,
|
||||
|
||||
290
agent_v0/agent_v1/ui/paused_toast.py
Normal file
290
agent_v0/agent_v1/ui/paused_toast.py
Normal file
@@ -0,0 +1,290 @@
|
||||
# agent_v1/ui/paused_toast.py
|
||||
"""
|
||||
Toast Tkinter custom pour la pause supervisée (« Léa a besoin de votre aide »).
|
||||
|
||||
Démo GHT 8 mai 2026 — Fallback robuste 100 % autonome quand :
|
||||
- plyer.notification est silencieux sous Windows 11 (Focus Assist, balloon tips
|
||||
bloqués par la stratégie système),
|
||||
- la ChatWindow Léa V1 est `withdraw()`-cachée par défaut (Dom ne la voit pas),
|
||||
- aucune autre UI ne peut garantir que Dom verra physiquement le message.
|
||||
|
||||
Stratégie :
|
||||
- Toplevel topmost overrideredirect en haut à droite de l'écran principal,
|
||||
- fond bleu Léa, titre + message, auto-close après TOAST_DURATION_S,
|
||||
- thread-safe : peut être appelé depuis n'importe quel thread (le polling
|
||||
replay tourne dans un daemon thread, pas le thread principal),
|
||||
- aucune dépendance externe (juste tkinter stdlib),
|
||||
- rate limit interne pour éviter le flood (1 toast / 3s minimum).
|
||||
|
||||
Si un Tk root existe déjà dans le process (ChatWindow), on attache le Toplevel
|
||||
à ce root via `root.after(0, ...)` — c'est l'idiome thread-safe officiel de
|
||||
tkinter. Sinon on crée un Tk() dédié dans un daemon thread.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Couleurs cohérentes avec le thème Léa (cf. chat_window.py)
|
||||
TOAST_BG = "#2563EB" # Bleu Léa (HEADER_BG)
|
||||
TOAST_FG = "#FFFFFF"
|
||||
TOAST_TITLE_BG = "#1E40AF" # Bleu plus foncé pour le bandeau titre
|
||||
TOAST_BORDER = "#1E3A8A"
|
||||
|
||||
TOAST_WIDTH = 380
|
||||
TOAST_PAD_X = 18
|
||||
TOAST_PAD_Y = 14
|
||||
TOAST_DURATION_MS = 15000
|
||||
TOAST_RATE_LIMIT_S = 3.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last_shown_at: float = 0.0
|
||||
_last_message: str = ""
|
||||
|
||||
|
||||
def _resolve_existing_root() -> Optional[Any]:
|
||||
"""Tente de récupérer le Tk root déjà créé par la ChatWindow.
|
||||
|
||||
On évite tk._default_root (deprecated) et on remonte plutôt via les
|
||||
threads existants : la ChatWindow garde une référence dans son instance
|
||||
mais n'expose rien de global. On se rabat donc sur la création d'un Tk
|
||||
indépendant si on n'a rien — c'est sûr, tkinter supporte plusieurs Tk()
|
||||
concurrents tant qu'ils sont chacun dans leur propre thread.
|
||||
"""
|
||||
try:
|
||||
import tkinter as tk
|
||||
# tk._default_root est interne mais c'est le moyen le plus simple
|
||||
# de partager un mainloop existant. Si ChatWindow tourne, ce sera
|
||||
# son root.
|
||||
root = getattr(tk, "_default_root", None)
|
||||
if root is not None:
|
||||
# Vérifier qu'il est encore vivant
|
||||
try:
|
||||
root.winfo_exists()
|
||||
return root
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _build_toast(parent: Any, title: str, message: str) -> Any:
|
||||
"""Construit le Toplevel toast (appelé dans le thread tkinter)."""
|
||||
import tkinter as tk
|
||||
|
||||
top = tk.Toplevel(parent)
|
||||
top.withdraw() # éviter le flash pendant la construction
|
||||
top.overrideredirect(True) # pas de barre de titre
|
||||
top.attributes("-topmost", True)
|
||||
try:
|
||||
# Petit boost de visibilité Windows : alpha légèrement transparent
|
||||
top.attributes("-alpha", 0.97)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bordure visuelle (cadre extérieur foncé)
|
||||
outer = tk.Frame(top, bg=TOAST_BORDER, padx=2, pady=2)
|
||||
outer.pack(fill="both", expand=True)
|
||||
|
||||
# Bandeau titre
|
||||
title_frame = tk.Frame(outer, bg=TOAST_TITLE_BG)
|
||||
title_frame.pack(fill="x")
|
||||
tk.Label(
|
||||
title_frame,
|
||||
text=f" ⏸ {title}",
|
||||
bg=TOAST_TITLE_BG,
|
||||
fg=TOAST_FG,
|
||||
font=("Segoe UI", 12, "bold"),
|
||||
anchor="w",
|
||||
padx=10,
|
||||
pady=8,
|
||||
).pack(fill="x")
|
||||
|
||||
# Corps du message
|
||||
body_frame = tk.Frame(outer, bg=TOAST_BG)
|
||||
body_frame.pack(fill="both", expand=True)
|
||||
tk.Label(
|
||||
body_frame,
|
||||
text=message,
|
||||
bg=TOAST_BG,
|
||||
fg=TOAST_FG,
|
||||
font=("Segoe UI", 11),
|
||||
wraplength=TOAST_WIDTH - 40,
|
||||
justify="left",
|
||||
anchor="w",
|
||||
padx=TOAST_PAD_X,
|
||||
pady=TOAST_PAD_Y,
|
||||
).pack(fill="both", expand=True)
|
||||
|
||||
# Pied de page : "Cliquez pour fermer"
|
||||
footer = tk.Label(
|
||||
outer,
|
||||
text="Cliquez pour fermer",
|
||||
bg=TOAST_BG,
|
||||
fg="#BFDBFE",
|
||||
font=("Segoe UI", 9, "italic"),
|
||||
anchor="e",
|
||||
padx=10,
|
||||
pady=4,
|
||||
)
|
||||
footer.pack(fill="x", side="bottom")
|
||||
|
||||
# Position : haut-droite de l'écran principal
|
||||
top.update_idletasks()
|
||||
height = top.winfo_reqheight()
|
||||
screen_w = top.winfo_screenwidth()
|
||||
x = screen_w - TOAST_WIDTH - 16
|
||||
y = 16
|
||||
top.geometry(f"{TOAST_WIDTH}x{height}+{x}+{y}")
|
||||
|
||||
# Click anywhere to close
|
||||
def _close(_=None):
|
||||
try:
|
||||
top.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
top.bind("<Button-1>", _close)
|
||||
for child in (outer, title_frame, body_frame, footer):
|
||||
try:
|
||||
child.bind("<Button-1>", _close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Afficher + boost focus brut pour passer devant Focus Assist
|
||||
top.deiconify()
|
||||
top.lift()
|
||||
try:
|
||||
top.focus_force()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-pin topmost après 100 ms (Windows désactive parfois -topmost
|
||||
# quand le focus est pris par une autre app)
|
||||
def _repin():
|
||||
try:
|
||||
top.attributes("-topmost", True)
|
||||
top.lift()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
top.after(100, _repin)
|
||||
top.after(500, _repin)
|
||||
top.after(2000, _repin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Auto-close
|
||||
try:
|
||||
top.after(TOAST_DURATION_MS, _close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return top
|
||||
|
||||
|
||||
def _show_in_dedicated_thread(title: str, message: str) -> None:
|
||||
"""Crée un Tk() indépendant dans un daemon thread.
|
||||
|
||||
Utilisé en fallback quand aucun Tk root n'existe. Le thread vit le
|
||||
temps du toast (~15s) puis se termine proprement.
|
||||
"""
|
||||
def _run():
|
||||
try:
|
||||
# DPI awareness (Windows haute résolution)
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
dpi = root.winfo_fpixels("1i")
|
||||
root.tk.call("tk", "scaling", dpi / 72.0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
top = _build_toast(root, title, message)
|
||||
|
||||
# Quitter mainloop quand le toast est détruit
|
||||
def _watch():
|
||||
try:
|
||||
if not top.winfo_exists():
|
||||
root.quit()
|
||||
return
|
||||
except Exception:
|
||||
root.quit()
|
||||
return
|
||||
root.after(200, _watch)
|
||||
|
||||
root.after(200, _watch)
|
||||
root.mainloop()
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("paused_toast dedicated thread failed", exc_info=True)
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True, name="paused-toast-tk")
|
||||
t.start()
|
||||
|
||||
|
||||
def show_paused_toast(
|
||||
title: str = "Léa a besoin de votre aide",
|
||||
message: str = "",
|
||||
) -> bool:
|
||||
"""Affiche un toast paused topmost.
|
||||
|
||||
Thread-safe, rate-limité, sans dépendance externe. Retourne True si le
|
||||
toast a été déclenché, False s'il a été ignoré (rate limit ou erreur).
|
||||
"""
|
||||
global _last_shown_at, _last_message
|
||||
|
||||
if not message:
|
||||
message = "Action en attente de votre validation."
|
||||
|
||||
# Rate limit basique : éviter qu'un poll en boucle ouvre 50 toasts
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
same_message = (message == _last_message)
|
||||
elapsed = now - _last_shown_at
|
||||
if same_message and elapsed < TOAST_RATE_LIMIT_S:
|
||||
logger.debug(
|
||||
"paused_toast rate-limited (%.1fs since last identical)", elapsed
|
||||
)
|
||||
return False
|
||||
_last_shown_at = now
|
||||
_last_message = message
|
||||
|
||||
# Tentative 1 : utiliser le Tk root existant (ChatWindow) via after()
|
||||
root = _resolve_existing_root()
|
||||
if root is not None:
|
||||
try:
|
||||
root.after(0, lambda: _build_toast(root, title, message))
|
||||
logger.info("paused_toast scheduled on existing Tk root")
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("paused_toast existing-root path failed", exc_info=True)
|
||||
|
||||
# Tentative 2 : créer un Tk() dans un daemon thread
|
||||
try:
|
||||
_show_in_dedicated_thread(title, message)
|
||||
logger.info("paused_toast scheduled in dedicated thread")
|
||||
return True
|
||||
except Exception:
|
||||
logger.error("paused_toast dedicated-thread path failed", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["show_paused_toast"]
|
||||
Reference in New Issue
Block a user