""" core/grounding/dialog_handler.py — Gestion intelligente des dialogues Quand un dialogue inattendu apparaît (pHash change après une action) : 1. Lire le titre de la fenêtre (EasyOCR crop 45px, ~130ms) 2. Si titre connu (Enregistrer sous, Confirmer, etc.) → action connue 3. Demander à InfiGUI de cliquer sur le bon bouton (~3s) 4. Vérifier que le dialogue a disparu (pHash) Pas de patterns prédéfinis pour les boutons. InfiGUI comprend visuellement le dialogue et clique au bon endroit. Utilisation : from core.grounding.dialog_handler import DialogHandler handler = DialogHandler() result = handler.handle_if_dialog(screenshot_pil) if result['handled']: print(f"Dialogue '{result['title']}' géré → {result['action']}") """ from __future__ import annotations import time from typing import Any, Dict, Optional # Titres connus → quelle action demander à InfiGUI. # # IMPORTANT — ordre du dict = priorité de matching. # L'OCR est full-screen et capte souvent le texte du dialog parent ET du popup # modal qui apparaît par-dessus (ex: "Enregistrer sous" reste visible derrière # "Confirmer l'enregistrement"). Les popups modaux DOIVENT matcher avant les # fenêtres principales, sinon Léa clique sur le bouton du parent qui n'a pas # le focus. KNOWN_DIALOGS = { # ── Popups modaux de confirmation (priorité HAUTE) ────────────────── "voulez-vous le remplacer": {"target": "Oui", "description": "Clique sur Oui pour confirmer le remplacement du fichier"}, "do you want to replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"}, "existe déjà": {"target": "Oui", "description": "Clique sur Oui, le fichier existe déjà et doit être remplacé"}, "already exists": {"target": "Yes", "description": "Click Yes, the file already exists"}, "remplacer": {"target": "Oui", "description": "Clique sur le bouton Oui pour confirmer le remplacement du fichier"}, "replace": {"target": "Yes", "description": "Click Yes to confirm file replacement"}, "écraser": {"target": "Oui", "description": "Clique sur Oui pour écraser le fichier"}, "overwrite": {"target": "Yes", "description": "Click Yes to overwrite"}, "confirmer l'enregistrement": {"target": "Oui", "description": "Clique sur Oui dans le popup de confirmation d'enregistrement"}, "confirmer": {"target": "Oui", "description": "Clique sur le bouton Oui dans le dialogue de confirmation"}, # ── Avertissements/erreurs (priorité haute, 1 seul bouton OK) ─────── "erreur": {"target": "OK", "description": "Clique sur OK pour fermer le message d'erreur"}, "error": {"target": "OK", "description": "Click OK to close the error message"}, "avertissement": {"target": "OK", "description": "Clique sur OK pour fermer l'avertissement"}, "warning": {"target": "OK", "description": "Click OK to close the warning"}, # ── Dialogs principaux de sauvegarde (priorité BASSE — fenêtres parents) ─ "voulez-vous enregistrer": {"target": "Enregistrer", "description": "Clique sur Enregistrer pour sauvegarder les modifications"}, "do you want to save": {"target": "Save", "description": "Click Save to save changes"}, "enregistrer sous": {"target": "Enregistrer", "description": "Clique sur le bouton Enregistrer dans le dialogue Enregistrer sous"}, "save as": {"target": "Save", "description": "Click the Save button in the Save As dialog"}, } class DialogHandler: """Gestion intelligente des dialogues via titre + InfiGUI.""" def __init__(self): self._easyocr_reader = None def handle_if_dialog( self, screenshot_pil, previous_title: str = "", ) -> Dict[str, Any]: """Vérifie si l'écran montre un dialogue et le gère. Args: screenshot_pil: Screenshot PIL actuel. previous_title: Titre de la fenêtre avant l'action (pour comparaison). Returns: Dict avec 'handled' (bool), 'title', 'action', 'position'. """ t0 = time.time() # 1. Lire le titre de la fenêtre title = self._read_title(screenshot_pil) if not title or len(title) < 3: return {'handled': False, 'title': '', 'reason': 'Titre illisible'} print(f"🔍 [Dialog] Titre lu: '{title}'") # 2. Chercher si c'est un dialogue connu matched_dialog = None for key, action_info in KNOWN_DIALOGS.items(): if key in title.lower(): matched_dialog = (key, action_info) break if not matched_dialog: # Pas un dialogue connu — le workflow continue normalement return {'handled': False, 'title': title, 'reason': 'Pas un dialogue connu'} dialog_key, action_info = matched_dialog target = action_info['target'] description = action_info['description'] print(f"🧠 [Dialog] Dialogue détecté: '{dialog_key}' → clic '{target}'") # 3. Demander à InfiGUI de cliquer sur le bouton click_result = self._click_via_infigui( target, description, screenshot_pil ) dt = (time.time() - t0) * 1000 if click_result: print(f"✅ [Dialog] Clic '{target}' à ({click_result['x']}, {click_result['y']}) ({dt:.0f}ms)") return { 'handled': True, 'title': title, 'dialog_type': dialog_key, 'action': f"click '{target}'", 'position': (click_result['x'], click_result['y']), 'time_ms': dt, } else: # InfiGUI n'a pas trouvé le bouton — essayer le clic direct via OCR print(f"⚠️ [Dialog] InfiGUI n'a pas trouvé '{target}', essai OCR direct") ocr_result = self._click_via_ocr(target, screenshot_pil) dt = (time.time() - t0) * 1000 if ocr_result: print(f"✅ [Dialog] OCR clic '{target}' à ({ocr_result[0]}, {ocr_result[1]}) ({dt:.0f}ms)") return { 'handled': True, 'title': title, 'dialog_type': dialog_key, 'action': f"click '{target}' (OCR)", 'position': ocr_result, 'time_ms': dt, } print(f"❌ [Dialog] Impossible de cliquer '{target}' ({dt:.0f}ms)") return { 'handled': False, 'title': title, 'dialog_type': dialog_key, 'reason': f"Bouton '{target}' introuvable", 'time_ms': dt, } # ------------------------------------------------------------------ # Lecture titre # ------------------------------------------------------------------ def _read_title(self, screenshot_pil) -> str: """Lit TOUT le texte visible via EasyOCR full-screen (~500ms). En VM QEMU, la barre de titre Windows est à l'intérieur du framebuffer, pas en haut absolu de l'écran. On fait l'OCR full-screen et on cherche les mots-clés des dialogues connus dans le texte complet. """ try: import numpy as np reader = self._get_easyocr() if reader is None: return "" results = reader.readtext(np.array(screenshot_pil)) full_text = ' '.join(r[1] for r in results if r[1].strip()) return full_text except Exception as e: print(f"⚠️ [Dialog] Erreur lecture écran: {e}") return "" # ------------------------------------------------------------------ # Clic via InfiGUI (serveur grounding) # ------------------------------------------------------------------ def _click_via_infigui( self, target: str, description: str, screenshot_pil ) -> Optional[Dict]: """Demande à InfiGUI (subprocess one-shot) de localiser et cliquer sur le bouton.""" try: from core.grounding.ui_tars_grounder import UITarsGrounder grounder = UITarsGrounder.get_instance() result = grounder.ground( target_text=target, target_description=description, screen_pil=screenshot_pil, ) if result and result.x is not None: import pyautogui pyautogui.click(result.x, result.y) return {'x': result.x, 'y': result.y} return None except Exception as e: print(f"⚠️ [Dialog/InfiGUI] Erreur: {e}") return None # ------------------------------------------------------------------ # Clic via OCR (fallback rapide) # ------------------------------------------------------------------ def _click_via_ocr(self, target: str, screenshot_pil) -> Optional[tuple]: """Cherche le bouton par OCR et clique dessus.""" try: import numpy as np reader = self._get_easyocr() if reader is None: return None results = reader.readtext(np.array(screenshot_pil)) target_lower = target.lower() matches = [] for (bbox_pts, text, conf) in results: if target_lower in text.lower() or text.lower() in target_lower: x = int(sum(p[0] for p in bbox_pts) / 4) y = int(sum(p[1] for p in bbox_pts) / 4) matches.append((x, y, text)) if matches: # Prendre le match le plus bas (boutons = bas du dialogue) best = max(matches, key=lambda m: m[1]) import pyautogui pyautogui.click(best[0], best[1]) return (best[0], best[1]) return None except Exception as e: print(f"⚠️ [Dialog/OCR] Erreur: {e}") return None # ------------------------------------------------------------------ # EasyOCR singleton # ------------------------------------------------------------------ def _get_easyocr(self): if self._easyocr_reader is not None: return self._easyocr_reader try: import easyocr self._easyocr_reader = easyocr.Reader( ['fr', 'en'], gpu=True, verbose=False ) return self._easyocr_reader except ImportError: return None