backup: snapshot post-démo GHT 2026-05-19
Backup état complet après enregistrement vidéo démo de bout en bout. À utiliser comme point de référence pour la consolidation post-démo. Changements majeurs de la session 18-19 mai : - AIVA-URGENCE : page autonome avec preset URL + auto-focus chain - Workflow Demo_urgence_3_db : merge linux_db + steps AIVA + pause humaine NoMachine - Bypass LLM (static_result / static_text) dans replay_engine pour démos déterministes sans appel Ollama - Fix api_stream:3013 — replay_paused au premier polling /next - dag_execute : lift duration_ms vers top-level pour wait runtime - NPM bypass auth /aiva-urgence/ via location ^~ (proxy_host/10.conf hors git) - scripts/cancel-replays.sh — workaround Stop VWB qui ne purge pas la queue Anchors visuels (468) forcés dans le commit pour garantir restorabilité. DB workflows actuelle + ~12 .bak DB de la journée incluses. Sujets identifiés pour consolidation post-démo (TODO) : 1. Bug VWB recapture anchor ne régénère pas le PNG 2. Léa client accumule état mémoire (restart périodique requis) 3. Stop VWB ne purge pas la queue serveur (lien manquant vers /replay/cancel) 4. Bug coord client mss tronqué 2560x60 → mapping Y cassé 5. delay_before/delay_after ignorés au runtime (fix partiel duration_ms) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1148,8 +1148,9 @@ class ActionExecutorV1:
|
||||
if raw_keys:
|
||||
self._replay_raw_keys(raw_keys)
|
||||
else:
|
||||
# Fallback copier-coller (anciens enregistrements sans raw_keys)
|
||||
self._type_text(text)
|
||||
# Fallback (anciens enregistrements ou templating runtime sans raw_keys)
|
||||
# paste=True (opt-in via action.paste) → clipboard + Ctrl+V (non-Citrix)
|
||||
self._type_text(text, paste=bool(action.get("paste", False)))
|
||||
print(f" [TYPE] Termine.")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
@@ -1287,6 +1288,13 @@ class ActionExecutorV1:
|
||||
pass
|
||||
else:
|
||||
print(f" [OK] Changement d'ecran detecte apres {action_type}")
|
||||
# Stabilisation page avant capture screenshot_after :
|
||||
# _wait_for_screen_change retourne dès le 1er tick de diff
|
||||
# (200ms), or sur une transition de page (fade, render API)
|
||||
# le DOM n'est pas encore stable → screenshot ambigu →
|
||||
# verify_action serveur déclenche retry fantôme. Cf. démo
|
||||
# GHT 2026-05-11.
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
|
||||
time.sleep(0.5)
|
||||
@@ -1295,8 +1303,11 @@ class ActionExecutorV1:
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
logger.error(f"Echec replay action {action_id} ({action_type}) : {e}")
|
||||
result["error"] = f"{type(e).__name__}: {e}"
|
||||
logger.error(
|
||||
f"Echec replay action {action_id} ({action_type}) : {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1783,7 +1794,15 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
replay_next_url,
|
||||
params={"session_id": session_id, "machine_id": machine_id},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
# 8 mai 2026 — démo GHT : 5s → 30s. Le serveur peut exécuter
|
||||
# extract_text (5-7s) PUIS dispatcher l'action suivante dans
|
||||
# la même réponse HTTP. À 5s, le client coupait avant la
|
||||
# réponse, le serveur stockait l'action dans _retry_pending
|
||||
# mais aucun watchdog ne la republiait → action perdue
|
||||
# silencieusement. Cf. docs/REPLAY_BLOCAGE_NOTES_MEDICALES_2026-05-08.md.
|
||||
# Le serveur a sa propre borne 180s + retour server_busy à 4.5s,
|
||||
# 30s côté client est sûr.
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
|
||||
@@ -1824,23 +1843,17 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
"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)
|
||||
|
||||
# UX fix 8 mai 2026 (démo GHT) : un seul affichage. La
|
||||
# bulle ChatWindow EST l'affichage canonique (force show +
|
||||
# topmost + bell sonore). Plus de toast paused_toast en
|
||||
# double — Dom ne voulait qu'un seul popup. Si la ChatWindow
|
||||
# n'est pas câblée (mode headless / tests), on retombe sur
|
||||
# le toast Tkinter custom comme dernier recours visuel.
|
||||
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
|
||||
# et force l'affichage + topmost + bell sonore.
|
||||
chat_window._add_paused_bubble(payload)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
@@ -1848,16 +1861,17 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
# Fallback notifier (tests headless / chat fermé)
|
||||
# Fallback : pas de ChatWindow câblée → toast Tkinter
|
||||
# autonome (headless / tests). C'est le SEUL chemin qui
|
||||
# déclenche encore show_paused_toast.
|
||||
try:
|
||||
self.notifier.notify(
|
||||
title="Léa — j'ai besoin de vous",
|
||||
from ..ui.paused_toast import show_paused_toast
|
||||
show_paused_toast(
|
||||
title="Léa a besoin de votre aide",
|
||||
message=pause_msg[:300],
|
||||
timeout=15,
|
||||
bypass_rate_limit=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("notifier.notify pause silenced", exc_info=True)
|
||||
logger.debug("paused_toast fallback silenced", exc_info=True)
|
||||
return False
|
||||
|
||||
action = data.get("action")
|
||||
@@ -2535,20 +2549,48 @@ Example: x_pct=0.50, y_pct=0.30"""
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
def _type_text(self, text: str):
|
||||
"""Saisir du texte caractère par caractère (anti-détection robot).
|
||||
def _type_text(self, text: str, paste: bool = False):
|
||||
"""Saisir du texte caractère par caractère (anti-détection robot)
|
||||
OU via clipboard + Ctrl+V si paste=True (opt-in workflow non-Citrix).
|
||||
|
||||
Chaque caractère est tapé individuellement avec un délai aléatoire
|
||||
pour simuler une frappe humaine. Les caractères spéciaux AZERTY
|
||||
(@ # € etc.) utilisent les bons VK codes via KeyCode.from_char().
|
||||
Mode char-by-char (défaut) :
|
||||
Chaque caractère tapé individuellement avec délai aléatoire
|
||||
(anti-détection robot, requis pour Citrix).
|
||||
Caractères spéciaux AZERTY via KeyCode.from_char().
|
||||
|
||||
Pas de copier-coller (détectable par les systèmes anti-robot Citrix).
|
||||
Mode paste (opt-in via action.paste=True dans le workflow) :
|
||||
Texte poussé dans le clipboard Windows (CF_UNICODETEXT) puis Ctrl+V.
|
||||
Gain massif sur textes longs (~100ms total vs ~80ms/char).
|
||||
Réservé aux workflows hors-Citrix (maquette Easily web OK, Citrix bloque).
|
||||
Fallback char-by-char automatique si exception clipboard.
|
||||
"""
|
||||
import random
|
||||
|
||||
if not text:
|
||||
return
|
||||
|
||||
if paste:
|
||||
try:
|
||||
import win32clipboard
|
||||
win32clipboard.OpenClipboard()
|
||||
win32clipboard.EmptyClipboard()
|
||||
win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT)
|
||||
win32clipboard.CloseClipboard()
|
||||
# 16 mai 2026 : 0.05s → 0.5s pour laisser NoMachine sync
|
||||
# le clipboard Windows → VM Ubuntu avant le Ctrl+V (sinon
|
||||
# le paste arrive avant que le clipboard guest soit à jour).
|
||||
time.sleep(0.5)
|
||||
self.keyboard.press(Key.ctrl_l)
|
||||
self.keyboard.press(KeyCode.from_char('v'))
|
||||
self.keyboard.release(KeyCode.from_char('v'))
|
||||
self.keyboard.release(Key.ctrl_l)
|
||||
time.sleep(0.1)
|
||||
logger.debug(f"Texte saisi via clipboard paste ({len(text)} chars)")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Paste clipboard échoué ({e}) → fallback char-by-char")
|
||||
|
||||
# Chemin par défaut (ou fallback paste) : char-by-char
|
||||
for char in text:
|
||||
try:
|
||||
# Taper le caractère via from_char (respecte le layout clavier)
|
||||
|
||||
88
agent_v0/agent_v1/tools/test_lea_pause_flow.py
Normal file
88
agent_v0/agent_v1/tools/test_lea_pause_flow.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# agent_v1/tools/test_lea_pause_flow.py
|
||||
"""Smoke test : simuler un lea:paused localement et vérifier la bulle ChatWindow.
|
||||
|
||||
À lancer SUR WINDOWS (PC démo) :
|
||||
cd C:/rpa_vision
|
||||
.venv\\Scripts\\python.exe -m agent_v1.tools.test_lea_pause_flow
|
||||
|
||||
Ce script ouvre une ChatWindow, simule l'arrivée d'un payload paused_need_help
|
||||
avec un message LONG (350+ chars pour tester le scroll interne), puis attend
|
||||
les clics utilisateur sur Continuer/Annuler. Le test vérifie qu'il y a UN SEUL
|
||||
affichage (la bulle chat), pas de toast supplémentaire.
|
||||
|
||||
Exit code 0 si succès. Logs dans la console.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Configurer le logging avant tout import du package
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
|
||||
)
|
||||
logger = logging.getLogger("test_lea_pause_flow")
|
||||
|
||||
# Forcer le bus feedback (pour que les boutons puissent émettre vers
|
||||
# rpa-agent-chat — port 5004). Si on ne veut PAS du bus, mettre LEA_FEEDBACK_BUS=0.
|
||||
os.environ.setdefault("LEA_FEEDBACK_BUS", "1")
|
||||
os.environ.setdefault("RPA_API_TOKEN", "") # à remplir si serveur exige Bearer
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
from agent_v1.ui.chat_window import ChatWindow
|
||||
except ImportError as e:
|
||||
print(f"[TEST] Import ChatWindow KO : {e}")
|
||||
return 2
|
||||
|
||||
print("[TEST] Création ChatWindow...")
|
||||
cw = ChatWindow(
|
||||
server_client=None,
|
||||
on_start_callback=None,
|
||||
server_host=os.environ.get("RPA_SERVER_HOST", "192.168.1.40"),
|
||||
chat_port=5004,
|
||||
)
|
||||
# Attendre que le tk loop soit prêt
|
||||
time.sleep(2.0)
|
||||
cw.show()
|
||||
time.sleep(0.5)
|
||||
|
||||
print("[TEST] Simulation lea:paused avec long message (350 chars)...")
|
||||
long_msg = (
|
||||
"Je n'arrive pas à trouver le champ « Numéro de dossier patient » "
|
||||
"sur l'écran courant. J'ai essayé 3 stratégies de grounding visuel "
|
||||
"(template matching, OCR, VLM) sans succès. Pouvez-vous me montrer "
|
||||
"l'emplacement exact du champ, ou cliquer dessus à ma place ? "
|
||||
"Quand vous avez fini, cliquez sur Continuer pour que je reprenne."
|
||||
)
|
||||
payload = {
|
||||
"replay_id": "test_replay_pause_flow_001",
|
||||
"workflow": "Démo UHCD",
|
||||
"reason": long_msg,
|
||||
"completed": 5,
|
||||
"total": 12,
|
||||
}
|
||||
cw._add_paused_bubble(payload)
|
||||
print(f"[TEST] Bulle envoyée. Message len={len(long_msg)} chars.")
|
||||
print("[TEST] Vérifiez visuellement :")
|
||||
print(" 1) UN SEUL popup (la bulle chat dans la fenêtre Léa)")
|
||||
print(" 2) Le message long s'affiche en intégralité (scroll interne si besoin)")
|
||||
print(" 3) Boutons Continuer / Annuler visibles")
|
||||
print(" 4) Cliquez sur Annuler → bulle fermée + feedback '✗ Annulé'")
|
||||
print("[TEST] La fenêtre reste ouverte 30s. Ctrl+C pour quitter avant.")
|
||||
|
||||
try:
|
||||
for i in range(30):
|
||||
time.sleep(1.0)
|
||||
except KeyboardInterrupt:
|
||||
print("[TEST] Interruption clavier.")
|
||||
|
||||
print("[TEST] Test terminé. Vérifier visuellement les 4 points ci-dessus.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -833,6 +833,9 @@ class ChatWindow:
|
||||
font=FONT_TIMESTAMP, anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(2, 0))
|
||||
|
||||
# UX fix 8 mai 2026 : auto-scroll après chaque bulle d'action
|
||||
self._scroll_to_bottom()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Bulle paused_need_help interactive (J3.5)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -844,6 +847,9 @@ class ChatWindow:
|
||||
(`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, ...)`.
|
||||
|
||||
UX fix 8 mai 2026 : un seul affichage (la bulle chat). Plus de toast
|
||||
en double — on force juste la chat window au premier plan.
|
||||
"""
|
||||
if self._root is None:
|
||||
return
|
||||
@@ -854,23 +860,40 @@ class ChatWindow:
|
||||
# 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)
|
||||
# Bell sonore léger pour attirer l'attention (Tkinter natif)
|
||||
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],
|
||||
)
|
||||
self._root.bell()
|
||||
except Exception:
|
||||
logger.debug("paused_toast launch silenced", exc_info=True)
|
||||
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)
|
||||
|
||||
self._root.after(0, _show_and_render)
|
||||
|
||||
def _clear_chat_history(self) -> None:
|
||||
"""Vide la zone d'affichage du chat (widgets enfants de _msg_frame).
|
||||
|
||||
Détruit uniquement les widgets visuels — self._messages (liste mémoire)
|
||||
reste intact pour la traçabilité debug. Cohérent avec _do_remove_typing
|
||||
qui détruit aussi le widget sans toucher à l'historique.
|
||||
"""
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
return
|
||||
try:
|
||||
for child in list(self._msg_frame.winfo_children()):
|
||||
child.destroy()
|
||||
self._active_paused_bubble = None
|
||||
if hasattr(self, "_typing_frame"):
|
||||
self._typing_frame = None
|
||||
self._scroll_to_bottom()
|
||||
except Exception:
|
||||
logger.debug("clear chat history silenced", exc_info=True)
|
||||
|
||||
def _render_paused_bubble(self, payload: Dict[str, Any]) -> None:
|
||||
tk = self._tk
|
||||
if getattr(self, "_msg_frame", None) is None:
|
||||
@@ -898,11 +921,30 @@ class ChatWindow:
|
||||
font=("Segoe UI", 12, "bold"), anchor="w",
|
||||
).pack(fill=tk.X, anchor=tk.W)
|
||||
|
||||
tk.Label(
|
||||
inner, text=reason, bg=PAUSED_BG, fg=PAUSED_FG,
|
||||
font=FONT_MSG, wraplength=MSG_WRAP_WIDTH - 30,
|
||||
anchor="w", justify=tk.LEFT,
|
||||
).pack(fill=tk.X, anchor=tk.W, pady=(6, 0))
|
||||
# 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.
|
||||
# Au-delà de 280 chars, scrollbar interne ; sinon Text auto-fitté.
|
||||
reason_str = str(reason)
|
||||
# Estimation simple : ~70 chars/ligne avec wraplength
|
||||
approx_lines = max(2, min(8, (len(reason_str) // 60) + 1))
|
||||
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=approx_lines,
|
||||
highlightthickness=0, relief=tk.FLAT, cursor="arrow",
|
||||
)
|
||||
reason_text.insert("1.0", reason_str)
|
||||
reason_text.configure(state="disabled")
|
||||
reason_text.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||
# Scrollbar interne uniquement si le contenu déborde (long messages)
|
||||
if len(reason_str) > 280:
|
||||
reason_scroll = tk.Scrollbar(
|
||||
msg_frame, orient=tk.VERTICAL,
|
||||
command=reason_text.yview, width=8,
|
||||
)
|
||||
reason_text.configure(yscrollcommand=reason_scroll.set)
|
||||
reason_scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
|
||||
tk.Label(
|
||||
inner, text=f"{workflow} — étape {completed}/{total}",
|
||||
@@ -930,12 +972,23 @@ class ChatWindow:
|
||||
)
|
||||
btn_abort.pack(side=tk.LEFT)
|
||||
|
||||
# Zone de feedback (mise à jour après clic, avant l'ack du bus)
|
||||
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,
|
||||
}
|
||||
|
||||
# Scroll automatique vers la nouvelle bulle (visible immédiatement)
|
||||
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
|
||||
@@ -961,28 +1014,79 @@ class ChatWindow:
|
||||
self._active_paused_bubble = None
|
||||
|
||||
def _on_paused_resume(self, replay_id: str) -> None:
|
||||
if not replay_id or self._bus is None or not self._bus.connected:
|
||||
self._add_lea_message("⚠ Bus indisponible — impossible de relancer")
|
||||
"""Bouton Continuer : émettre lea:replay_resume + feedback immédiat UI.
|
||||
|
||||
UX fix 8 mai 2026 : on désactive les 2 boutons et on affiche un message
|
||||
de feedback dès le clic, sans attendre l'ack serveur. Le bus émet en
|
||||
arrière-plan ; si la connexion est tombée, on log un warning visible.
|
||||
"""
|
||||
if not replay_id:
|
||||
self._update_paused_feedback("⚠ replay_id manquant — impossible de relancer")
|
||||
return
|
||||
self._bus.resume_replay(replay_id)
|
||||
if self._active_paused_bubble:
|
||||
try:
|
||||
self._active_paused_bubble["btn_resume"].config(state="disabled")
|
||||
self._active_paused_bubble["btn_abort"].config(state="disabled")
|
||||
except Exception:
|
||||
pass
|
||||
emitted = False
|
||||
if self._bus is not None and self._bus.connected:
|
||||
emitted = self._bus.resume_replay(replay_id)
|
||||
# Feedback immédiat : disable boutons + message
|
||||
self._disable_paused_buttons()
|
||||
if emitted:
|
||||
self._update_paused_feedback("→ Reprise demandée…")
|
||||
logger.info("paused_bubble: lea:replay_resume émis pour %s", replay_id)
|
||||
else:
|
||||
self._update_paused_feedback("⚠ Bus indisponible — réessayez dans 5s")
|
||||
logger.warning("paused_bubble: bus déconnecté, resume non émis")
|
||||
# UX fix mai 2026 : minimiser la fenêtre vers le systray après 500ms
|
||||
# (laisse à l'utilisateur le temps de voir "Reprise demandée…").
|
||||
try:
|
||||
self._root.after(500, self._do_hide)
|
||||
except Exception:
|
||||
logger.debug("auto-hide on resume silenced", exc_info=True)
|
||||
|
||||
def _on_paused_abort(self, replay_id: str) -> None:
|
||||
if self._bus is None or not self._bus.connected:
|
||||
self._add_lea_message("⚠ Bus indisponible — impossible d'annuler")
|
||||
"""Bouton Annuler : émettre lea:replay_abort + fermeture locale immédiate.
|
||||
|
||||
UX fix 8 mai 2026 : on ferme la bulle localement dès le clic (le serveur
|
||||
n'envoie pas de lea:resumed pour un abort, donc sans cette fermeture
|
||||
locale la bulle restait coincée — c'était la cause de "Annuler ne
|
||||
fonctionne pas" rapportée par Dom).
|
||||
"""
|
||||
emitted = False
|
||||
if self._bus is not None and self._bus.connected:
|
||||
emitted = self._bus.abort_replay(replay_id)
|
||||
self._disable_paused_buttons()
|
||||
if emitted:
|
||||
self._update_paused_feedback("✗ Annulé")
|
||||
logger.info("paused_bubble: lea:replay_abort émis pour %s", replay_id)
|
||||
else:
|
||||
self._update_paused_feedback("✗ Annulé (bus indisponible)")
|
||||
logger.warning("paused_bubble: bus déconnecté, abort non émis")
|
||||
# Fermer la bulle en local (l'abort n'a pas de lea:resumed associé)
|
||||
self._close_active_paused_bubble(reason="abort_local")
|
||||
# UX fix mai 2026 : minimiser la fenêtre après 500ms (cohérence
|
||||
# avec _on_paused_resume, demandé explicitement par Dom).
|
||||
try:
|
||||
self._root.after(500, self._do_hide)
|
||||
except Exception:
|
||||
logger.debug("auto-hide on abort silenced", exc_info=True)
|
||||
|
||||
def _disable_paused_buttons(self) -> None:
|
||||
if not self._active_paused_bubble:
|
||||
return
|
||||
self._bus.abort_replay(replay_id)
|
||||
if self._active_paused_bubble:
|
||||
try:
|
||||
self._active_paused_bubble["btn_resume"].config(state="disabled")
|
||||
self._active_paused_bubble["btn_abort"].config(state="disabled")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._active_paused_bubble["btn_resume"].config(state="disabled")
|
||||
self._active_paused_bubble["btn_abort"].config(state="disabled")
|
||||
except Exception:
|
||||
logger.debug("disable paused buttons silenced", exc_info=True)
|
||||
|
||||
def _update_paused_feedback(self, text: str) -> None:
|
||||
if not self._active_paused_bubble:
|
||||
return
|
||||
label = self._active_paused_bubble.get("feedback_label")
|
||||
if label is None:
|
||||
return
|
||||
try:
|
||||
label.config(text=text)
|
||||
except Exception:
|
||||
logger.debug("update paused feedback silenced", exc_info=True)
|
||||
|
||||
# ======================================================================
|
||||
# Ajout de messages dans la zone de chat
|
||||
|
||||
@@ -140,27 +140,18 @@ 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.
|
||||
UX fix 8 mai 2026 (démo GHT) : la bulle ChatWindow Léa V1 (Tkinter
|
||||
topmost + bell + force-show) est désormais l'affichage canonique pour
|
||||
les BLOCAGE de pause supervisée. On NE déclenche PLUS show_paused_toast
|
||||
depuis ici — Dom rapportait 3 popups en parallèle (toast executor,
|
||||
toast bubble, toast notifications). Plyer reste actif comme
|
||||
notification système discrète. Le toast Tkinter custom est conservé
|
||||
pour les fallbacks sans ChatWindow (cf. executor.Plan B).
|
||||
"""
|
||||
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,
|
||||
|
||||
@@ -227,6 +227,9 @@ from .replay_engine import (
|
||||
_handle_extract_text_action,
|
||||
_handle_extract_table_action,
|
||||
_handle_t2a_decision_action,
|
||||
_handle_llm_generate_action,
|
||||
_handle_concat_text_vars_action,
|
||||
_handle_paste_and_execute_action,
|
||||
_expand_compound_steps,
|
||||
_pre_check_screen_state as _pre_check_screen_state_impl,
|
||||
_detect_popup_hint as _detect_popup_hint_impl,
|
||||
@@ -2946,7 +2949,8 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
if not queue:
|
||||
return {"action": None, "session_id": session_id, "machine_id": machine_id}
|
||||
|
||||
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision)
|
||||
# ── Boucle de traitement : actions serveur (extract_text, t2a_decision,
|
||||
# llm_generate)
|
||||
# exécutées entièrement côté serveur jusqu'à trouver une action visuelle
|
||||
# à transmettre à l'Agent V1 ou un pause_for_human qui bloque le replay.
|
||||
action = None
|
||||
@@ -3006,7 +3010,18 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
owning_replay["status"] = "paused_need_help"
|
||||
queue.pop(0)
|
||||
_replay_queues[session_id] = queue
|
||||
return {"action": None, "session_id": session_id, "machine_id": machine_id}
|
||||
# Inclure replay_paused/pause_message/replay_id dès ce return
|
||||
# pour que la bulle Léa s'affiche au PREMIER polling (sinon
|
||||
# gap 1-2s où le chat est vide). Aligné sur la branche
|
||||
# target_not_found ligne 2869-2876.
|
||||
return {
|
||||
"action": None,
|
||||
"session_id": session_id,
|
||||
"machine_id": machine_id,
|
||||
"replay_paused": True,
|
||||
"pause_message": owning_replay.get("pause_message", "Validation requise"),
|
||||
"replay_id": owning_replay["replay_id"],
|
||||
}
|
||||
|
||||
# Mode autonome sans safety_checks → skip (comportement legacy)
|
||||
logger.info(
|
||||
@@ -3056,6 +3071,39 @@ async def get_next_action(session_id: str, machine_id: str = "default"):
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "llm_generate":
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_llm_generate_action,
|
||||
action, owning_replay,
|
||||
),
|
||||
timeout=180,
|
||||
)
|
||||
elif type_ == "_concat_text_vars":
|
||||
# Action serveur interne (générée par expansion
|
||||
# extract_text_scroll). Concat 2 vars runtime → 1 var finale.
|
||||
# Quasi-instantanée (pas de I/O), mais on la gère via le
|
||||
# même chemin pour cohérence (timeout généreux par sécurité).
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_concat_text_vars_action,
|
||||
action, owning_replay,
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
elif type_ == "paste_and_execute":
|
||||
# Invoque scripts/paste_and_execute_linuxdb.sh
|
||||
# (Ctrl+V + Ctrl+Enter via ydotool dans la VM).
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(
|
||||
None,
|
||||
_handle_paste_and_execute_action,
|
||||
action, owning_replay,
|
||||
),
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Action serveur {type_} a levé : {e}")
|
||||
queue.pop(0)
|
||||
|
||||
@@ -41,6 +41,7 @@ _ALLOWED_ACTION_TYPES = {
|
||||
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
|
||||
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
|
||||
"llm_generate", # Génération texte libre côté serveur → variable workflow
|
||||
"paste_and_execute", # Bypass NoMachine : ydotool Ctrl+V+Ctrl+Enter dans VM via SSH
|
||||
}
|
||||
|
||||
# Types d'actions exécutées CÔTÉ SERVEUR (jamais transmises à l'Agent V1).
|
||||
@@ -52,6 +53,7 @@ _SERVER_SIDE_ACTION_TYPES = {
|
||||
"t2a_decision",
|
||||
"llm_generate",
|
||||
"_concat_text_vars",
|
||||
"paste_and_execute",
|
||||
}
|
||||
|
||||
# Pause par défaut entre Ctrl+End/Home et la capture suivante (ms).
|
||||
@@ -890,6 +892,11 @@ def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str,
|
||||
}
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "paste_and_execute":
|
||||
normalized["type"] = "paste_and_execute"
|
||||
normalized["parameters"] = {}
|
||||
return [normalized]
|
||||
|
||||
elif action_type == "extract_table":
|
||||
normalized["type"] = "extract_table"
|
||||
normalized["parameters"] = {
|
||||
@@ -1216,6 +1223,18 @@ def _handle_t2a_decision_action(
|
||||
dpi_text = (params.get("input_template") or params.get("dpi") or "").strip()
|
||||
model = params.get("model") or None # None → DEFAULT_MODEL
|
||||
|
||||
# Bypass LLM : si static_result est fourni dans les params, on l'utilise
|
||||
# tel quel comme résultat. Utile pour les démos déterministes (pas de
|
||||
# hallucination, pas de latence, pas de truncation de prompt).
|
||||
static_result = params.get("static_result")
|
||||
if isinstance(static_result, dict) and static_result.get("decision"):
|
||||
replay_state.setdefault("variables", {})[output_var] = static_result
|
||||
logger.info(
|
||||
"t2a_decision (STATIC) → variable '%s' decision=%s replay %s",
|
||||
output_var, static_result.get("decision"), replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return True
|
||||
|
||||
if not dpi_text:
|
||||
logger.warning(
|
||||
"t2a_decision : input vide — variable '%s' = {decision: 'INDETERMINE'}", output_var,
|
||||
@@ -1289,6 +1308,18 @@ def _handle_llm_generate_action(
|
||||
or params.get("variable_name")
|
||||
or "generated_text"
|
||||
).strip()
|
||||
|
||||
# Bypass LLM : si static_text est fourni dans les params, on l'utilise
|
||||
# tel quel. Utile pour les démos déterministes.
|
||||
static_text = params.get("static_text")
|
||||
if isinstance(static_text, str) and static_text.strip():
|
||||
replay_state.setdefault("variables", {})[output_var] = static_text
|
||||
logger.info(
|
||||
"llm_generate (STATIC) → variable '%s' (%d chars) replay %s",
|
||||
output_var, len(static_text), replay_state.get("replay_id", "?"),
|
||||
)
|
||||
return True
|
||||
|
||||
prompt = str(params.get("prompt") or "").strip()
|
||||
context = str(params.get("context") or "")
|
||||
model = params.get("model") or None
|
||||
@@ -1384,6 +1415,40 @@ def _handle_concat_text_vars_action(
|
||||
return bool(merged)
|
||||
|
||||
|
||||
def _handle_paste_and_execute_action(
|
||||
action: Dict[str, Any],
|
||||
replay_state: Dict[str, Any],
|
||||
) -> bool:
|
||||
"""Action serveur : invoque scripts/paste_and_execute_linuxdb.sh pour
|
||||
déclencher Ctrl+V + Ctrl+Enter dans DBeaver de la VM via ydotool.
|
||||
Bypasse Léa/NoMachine (Ctrl mangé par NoMachine passive grab).
|
||||
Cf. handoff 2026-05-16_handoff_ydotool_clipboard.md.
|
||||
"""
|
||||
import subprocess
|
||||
script_path = "/home/dom/ai/rpa_vision_v3/scripts/paste_and_execute_linuxdb.sh"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[script_path],
|
||||
timeout=30,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"paste_and_execute échoué (rc=%d) stderr=%s",
|
||||
result.returncode, (result.stderr or "")[:500],
|
||||
)
|
||||
return False
|
||||
logger.info("paste_and_execute OK replay %s", replay_state.get("replay_id", "?"))
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("paste_and_execute timeout (30s)")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("paste_and_execute exception : %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def _expand_extract_text_scroll(
|
||||
base: Dict[str, Any],
|
||||
final_var: str,
|
||||
|
||||
@@ -127,7 +127,7 @@ def _resolve_by_template_matching(
|
||||
best_scale = 1.0
|
||||
best_anchor_size = (aw, ah)
|
||||
|
||||
for scale in [1.0, 0.9, 1.1, 0.8, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 1.75, 2.0]:
|
||||
for scale in [1.0, 0.95, 0.93, 0.9, 1.05, 1.1, 0.85, 0.8, 1.15, 1.2, 0.75, 1.25, 0.6, 1.5, 0.5, 0.4, 0.35, 0.3, 0.25, 1.75, 2.0]:
|
||||
if scale != 1.0:
|
||||
new_w = int(aw * scale)
|
||||
new_h = int(ah * scale)
|
||||
@@ -2107,7 +2107,10 @@ _RESOLUTION_MIN_SCORES: Dict[str, float] = {
|
||||
# Écart maximum toléré entre coords résolues et coords enregistrées
|
||||
# (en fraction d'écran, dans chaque axe). Au-delà, on considère que la
|
||||
# résolution a trouvé un faux positif ailleurs sur l'écran.
|
||||
_RESOLUTION_MAX_DRIFT: float = 0.20
|
||||
# 15 mai 2026 : élargi 0.20 → 0.95 pour respecter le principe "100% vision"
|
||||
# (Dom). Le score template/VLM seul est désormais la source de confiance,
|
||||
# la position de la capture initiale n'est plus contraignante.
|
||||
_RESOLUTION_MAX_DRIFT: float = 0.95
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
Reference in New Issue
Block a user