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:
@@ -94,6 +94,11 @@ class ActionExecutorV1:
|
|||||||
# pause supervisée au serveur (`paused_need_help`).
|
# pause supervisée au serveur (`paused_need_help`).
|
||||||
# Cf. core/system_dialog_guard.py
|
# Cf. core/system_dialog_guard.py
|
||||||
self._system_dialog_pause: Optional[Dict[str, Any]] = None
|
self._system_dialog_pause: Optional[Dict[str, Any]] = None
|
||||||
|
# Référence à la ChatWindow Léa V1 (Tkinter) pour afficher les bulles
|
||||||
|
# paused interactives quand le serveur signale une pause supervisée.
|
||||||
|
# Câblée depuis main.py après instanciation des deux objets.
|
||||||
|
# Si None (mode headless / tests), fallback sur self.notifier.
|
||||||
|
self._chat_window_ref = None
|
||||||
# Log de la resolution physique pour le diagnostic DPI
|
# Log de la resolution physique pour le diagnostic DPI
|
||||||
self._log_screen_info()
|
self._log_screen_info()
|
||||||
|
|
||||||
@@ -1796,6 +1801,65 @@ Example: x_pct=0.50, y_pct=0.30"""
|
|||||||
self._last_conn_error_logged = False
|
self._last_conn_error_logged = False
|
||||||
|
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
|
# Plan B (8 mai 2026 — démo GHT) : si le serveur signale une pause
|
||||||
|
# supervisée, afficher le pause_message dans la ChatWindow Léa V1
|
||||||
|
# (Tkinter, déjà ouverte sur Windows) sous forme de bulle interactive
|
||||||
|
# avec boutons Continuer / Annuler. Permet à l'utilisateur Windows de
|
||||||
|
# voir physiquement ce que Léa attend (pause_for_human ou échec
|
||||||
|
# résolution). Fallback notifier.notify si la ChatWindow n'est pas
|
||||||
|
# câblée (mode headless / tests).
|
||||||
|
if data.get("replay_paused"):
|
||||||
|
pause_msg = data.get("pause_message") or "Léa a besoin de votre aide"
|
||||||
|
replay_id = data.get("replay_id") or ""
|
||||||
|
pause_key = (replay_id, pause_msg)
|
||||||
|
if getattr(self, "_last_pause_msg_shown", None) != pause_key:
|
||||||
|
self._last_pause_msg_shown = pause_key
|
||||||
|
completed = data.get("current_action_index", 0)
|
||||||
|
total = data.get("total_actions", "?")
|
||||||
|
payload = {
|
||||||
|
"replay_id": replay_id,
|
||||||
|
"workflow": "Replay en cours",
|
||||||
|
"reason": pause_msg,
|
||||||
|
"completed": completed,
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
# Toast Tkinter custom topmost — visible même si la
|
||||||
|
# ChatWindow est withdraw()-cachée par défaut. Sans dépendance
|
||||||
|
# plyer (Focus Assist Windows 11 filtre les balloons système).
|
||||||
|
try:
|
||||||
|
from ..ui.paused_toast import show_paused_toast
|
||||||
|
show_paused_toast(
|
||||||
|
title="Léa a besoin de votre aide",
|
||||||
|
message=pause_msg[:300],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("paused_toast launch silenced", exc_info=True)
|
||||||
|
|
||||||
|
chat_window = getattr(self, "_chat_window_ref", None)
|
||||||
|
if chat_window is not None:
|
||||||
|
try:
|
||||||
|
# _add_paused_bubble est thread-safe (utilise root.after)
|
||||||
|
# et force l'affichage de la fenêtre + toast topmost
|
||||||
|
chat_window._add_paused_bubble(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"chat_window._add_paused_bubble pause silenced",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback notifier (tests headless / chat fermé)
|
||||||
|
try:
|
||||||
|
self.notifier.notify(
|
||||||
|
title="Léa — j'ai besoin de vous",
|
||||||
|
message=pause_msg[:300],
|
||||||
|
timeout=15,
|
||||||
|
bypass_rate_limit=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("notifier.notify pause silenced", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
action = data.get("action")
|
action = data.get("action")
|
||||||
if action is None:
|
if action is None:
|
||||||
return False
|
return False
|
||||||
@@ -2297,7 +2361,7 @@ Example: x_pct=0.50, y_pct=0.30"""
|
|||||||
|
|
||||||
best_match = None
|
best_match = None
|
||||||
best_val = 0.0
|
best_val = 0.0
|
||||||
threshold = 0.50 # Seuil équilibré
|
threshold = 0.75 # Démo GHT 8 mai — éviter faux positifs (placeholders italiques, tabs voisins). En dessous, mieux vaut tomber en mode apprentissage humain qu'un clic au pif.
|
||||||
|
|
||||||
# Essayer plusieurs tailles de police pour couvrir différentes résolutions
|
# Essayer plusieurs tailles de police pour couvrir différentes résolutions
|
||||||
for font_size in [14, 16, 18, 20, 22, 24, 12, 26, 28, 10]:
|
for font_size in [14, 16, 18, 20, 22, 24, 12, 26, 28, 10]:
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ class AgentV1:
|
|||||||
# Executeur pour le replay (doit exister avant le poll)
|
# Executeur pour le replay (doit exister avant le poll)
|
||||||
self._executor = ActionExecutorV1()
|
self._executor = ActionExecutorV1()
|
||||||
|
|
||||||
|
# Wiring ChatWindow → Executor pour Plan B (pause_message → bulle interactive)
|
||||||
|
# Permet à l'executor d'afficher une bulle paused dans la fenêtre Léa V1
|
||||||
|
# quand le serveur signale replay_paused=True via /replay/next.
|
||||||
|
try:
|
||||||
|
self._executor._chat_window_ref = self._chat_window
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Wiring chat_window→executor échoué (non bloquant)", exc_info=True)
|
||||||
|
|
||||||
# Boucles permanentes (pas besoin de session active)
|
# Boucles permanentes (pas besoin de session active)
|
||||||
self.running = True
|
self.running = True
|
||||||
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
|
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
|
||||||
|
|||||||
0
agent_v0/agent_v1/tools/__init__.py
Normal file
0
agent_v0/agent_v1/tools/__init__.py
Normal file
87
agent_v0/agent_v1/tools/test_lea_toast.py
Normal file
87
agent_v0/agent_v1/tools/test_lea_toast.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# agent_v1/tools/test_lea_toast.py
|
||||||
|
"""
|
||||||
|
Test visuel rapide du toast Léa (démo GHT 8 mai 2026).
|
||||||
|
|
||||||
|
Lance trois scénarios de toast successifs pour valider l'affichage Windows :
|
||||||
|
1. Toast simple « pause supervisée »
|
||||||
|
2. Toast avec message long (vérifier wraplength)
|
||||||
|
3. Toast type BLOCAGE (= ce que voit l'utilisateur quand Léa est perdue)
|
||||||
|
|
||||||
|
Usage Windows :
|
||||||
|
C:\\rpa_vision\\.venv\\Scripts\\python.exe C:\\rpa_vision\\agent_v1\\tools\\test_lea_toast.py
|
||||||
|
|
||||||
|
Le script s'attend à voir trois toasts successifs en haut-droite de l'écran
|
||||||
|
principal, espacés de ~6 s, fond bleu Léa, autodismiss après 15 s ou clic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_path() -> None:
|
||||||
|
"""Autoriser l'exécution directe sans -m : ajouter C:\\rpa_vision au sys.path."""
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
# On remonte : tools -> agent_v1 -> rpa_vision (parent du package agent_v1)
|
||||||
|
rpa_root = here.parent.parent.parent
|
||||||
|
if str(rpa_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(rpa_root))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_bootstrap_path()
|
||||||
|
|
||||||
|
# Import après ajout du path (les deux variantes fonctionnent)
|
||||||
|
try:
|
||||||
|
from agent_v1.ui.paused_toast import show_paused_toast
|
||||||
|
except Exception as e: # pragma: no cover (debug only)
|
||||||
|
print(f"[TEST] ERREUR import agent_v1.ui.paused_toast : {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
scenarios = [
|
||||||
|
(
|
||||||
|
"Toast 1/3 : pause simple",
|
||||||
|
"Léa a besoin de votre aide",
|
||||||
|
"Test 1/3 — Pause supervisée. Cliquez sur 'Continuer' dans la chat.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Toast 2/3 : message long",
|
||||||
|
"Léa — j'attends votre validation",
|
||||||
|
(
|
||||||
|
"Test 2/3 — J'ai trouvé 11 dossiers correspondant à vos critères "
|
||||||
|
"(UHCD, Forfait 1, PE2). Je vais traiter le dossier de M. DUPONT "
|
||||||
|
"Jean en premier. Pouvez-vous valider que c'est le bon ordre "
|
||||||
|
"avant que je continue ?"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Toast 3/3 : blocage cible non trouvée",
|
||||||
|
"Léa — je ne vois pas l'élément",
|
||||||
|
(
|
||||||
|
"Test 3/3 — Je n'arrive pas à trouver « Examens cliniques » à "
|
||||||
|
"l'écran. Pouvez-vous me montrer où cliquer ?"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for label, title, message in scenarios:
|
||||||
|
print(f"[TEST] {label}")
|
||||||
|
ok = show_paused_toast(title=title, message=message)
|
||||||
|
print(f" show_paused_toast() = {ok}")
|
||||||
|
if not ok:
|
||||||
|
print(f" ECHEC : {label}")
|
||||||
|
# Espacer pour que Dom voit chaque toast distinctement
|
||||||
|
# (rate limit interne = 3s pour message identique, mais ici les
|
||||||
|
# messages diffèrent, le rate limit ne s'applique pas)
|
||||||
|
time.sleep(6)
|
||||||
|
|
||||||
|
print("[TEST] Attente 12s supplémentaires pour laisser le dernier toast vivre...")
|
||||||
|
time.sleep(12)
|
||||||
|
print("[TEST] OK — fin du test. Si vous avez vu 3 toasts bleus en haut-droite,")
|
||||||
|
print(" le mécanisme Léa pause est validé.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
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:
|
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:
|
if self._root is None:
|
||||||
return
|
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:
|
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||||
tk = self._tk
|
tk = self._tk
|
||||||
|
|||||||
@@ -139,10 +139,28 @@ class NotificationManager:
|
|||||||
|
|
||||||
Les messages BLOCAGE bypass le rate limit pour garantir que
|
Les messages BLOCAGE bypass le rate limit pour garantir que
|
||||||
l'utilisateur voit qu'on a besoin de lui.
|
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
|
bypass = msg.niveau == NiveauMessage.BLOCAGE
|
||||||
# Log aussi pour tracer dans les logs fichiers
|
# Log aussi pour tracer dans les logs fichiers
|
||||||
self._log_message(msg)
|
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(
|
return self.notify(
|
||||||
title=msg.titre,
|
title=msg.titre,
|
||||||
message=msg.corps,
|
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