diff --git a/agent_v0/.gitignore b/agent_v0/.gitignore new file mode 100644 index 000000000..9f11b755a --- /dev/null +++ b/agent_v0/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/agent_v0/__init__.py b/agent_v0/__init__.py new file mode 100644 index 000000000..fbc07f0ce --- /dev/null +++ b/agent_v0/__init__.py @@ -0,0 +1 @@ +# agent_v0 — Agent RPA Vision V3 diff --git a/agent_v0/agent_config.json b/agent_v0/agent_config.json new file mode 100644 index 000000000..b2d25e692 --- /dev/null +++ b/agent_v0/agent_config.json @@ -0,0 +1,15 @@ +{ + "user_id": "demo_user", + "user_label": "Démo agent_v0", + "customer": "Clinique Demo", + "training_label": "Facturation_T2A_demo", + "notes": "Session réelle avec clics + screenshots + key combos.", + "mode": "enriched", + "screenshot_mode": "crop", + "screenshot_crop_width": 900, + "screenshot_crop_height": 700, + "capture_hover": true, + "hover_min_idle_ms": 700, + "capture_scroll": true, + "network_save_path": "" +} \ No newline at end of file diff --git a/agent_v0/agent_v1/EVOLUTION_V1_README.md b/agent_v0/agent_v1/EVOLUTION_V1_README.md new file mode 100644 index 000000000..ed418f6d7 --- /dev/null +++ b/agent_v0/agent_v1/EVOLUTION_V1_README.md @@ -0,0 +1,76 @@ +# Évolution Agent V1 - Système d'Apprentissage "Stagiaire Fibre" +**Projet :** RPA Vision V3 +**Date :** 5 Mars 2026 +**Status :** 🚀 Prêt pour Test POC Clinique + +--- + +## 🎯 Philosophie : Le "Stagiaire" Apprenant + +Le système n'est pas un automate rigide, mais un **stagiaire cognitif** qui apprend par imitation. +1. **L'Expert (Humain) :** Travaille sur son PC (Windows/Mac/Linux) avec l'Agent V1. +2. **Le Stagiaire (IA qwen3-vl) :** Observe l'expert via la fibre, analyse les images sur une RTX 5070 et construit un **Graphe d'Intention**. +3. **L'Apprentissage :** Le stagiaire "réfléchit" en temps réel (Crops 400x400) et se corrige grâce aux interactions humaines. + +--- + +## 🛠️ Architecture Technique Agent V1 + +L'Agent V1 passe d'un mode "Enregistreur" (Batch) à un mode **"Capteur Intelligent" (Streaming)**. + +### 1. Vision Duale & Ciblée (Optimisation qwen3-vl) +- **Crops Contextuels :** Capture systématique d'une zone de **400x400 pixels** autour de chaque clic. +- **Contexte Global :** Screenshots plein écran pour l'identification de l'environnement. +- **Patience Post-Action :** Capture automatique 1s après chaque clic pour voir le résultat (animations, chargements). +- **Heartbeat :** Capture contextuelle toutes les 5s pour voir le logiciel "vivre" entre les clics. + +### 2. Conscience du Contexte UI +- **Focus Change :** Détection proactive des changements de fenêtre/application. +- **Métadonnées Sémantiques :** Capture systématique du titre de la fenêtre et du nom de l'exécutable. +- **Anonymisation Sélective :** Capacité de floutage local (GaussianBlur) sur les zones de texte sensibles détectées. + +### 3. Streaming Haute Performance (Fibre-Ready) +- **Async Streaming :** Envoi asynchrone des événements JSON et des images via une file d'attente non-bloquante. +- **Architecture Micro-Paquets :** Plus de gros fichiers ZIP. Le serveur reçoit les données au fil de l'eau sur le port 5002. + +--- + +## 🧠 Architecture Serveur (Le Cerveau) + +Le serveur (Machine Labo RTX 5070) a été adapté pour le flux temps réel : + +### 1. API Stream (`server_v1/api_stream.py`) +- **Endpoints Dédiés :** `/event` pour le JSON, `/image` pour les crops/full, `/finalize` pour clore la session. +- **Live Sessions :** Stockage temporaire en format `.jsonl` (robuste aux crashs) avant consolidation finale. + +### 2. Stream Worker (`server_v1/worker_stream.py`) +- **Analyse au fil de l'eau :** Le worker surveille le dossier `live_sessions` et lance l'inférence `qwen3-vl` dès qu'un crop arrive. +- **Construction de Graphe :** Le stagiaire commence à relier les points (actions) pour former un graphe de décision pendant que l'expert travaille encore. + +--- + +## 🖥️ Portabilité & Exécution Déportée + +L'Agent V1 est conçu pour être porté sur **Windows** et **macOS** : +- **Bibliothèques Cross-Plateforme :** `mss` (Vision), `pynput` (Events), `PyQt5` (UI). +- **Exécution Déportée :** L'architecture prépare le terrain pour que le rejeu puisse se faire sur un PC Windows distant, piloté par les ordres envoyés par la machine Labo via Fibre/WebSockets. + +--- + +## 📋 Checklist de Déploiement (Machine Labo) + +1. **Installer les dépendances :** `pip install PyQt5 pystray Pillow mss requests psutil` +2. **Lancer le Serveur de Streaming :** `python agent_v0/server_v1/api_stream.py` (Port 5002) +3. **Lancer le Stream Worker :** `python agent_v0/server_v1/worker_stream.py` +4. **Lancer l'Agent V1 :** `python run_agent_v1.py` sur le PC de test. + +--- + +## 🎨 Interface Utilisateur "Sympa" +L'Agent V1 n'est plus un outil technique froid : +- **Tray Icon dynamique :** Gris (Repos), Rouge (Apprentissage), Bleu (Sync Fibre). +- **Dialogues Humains :** Accueil personnalisé, compteur d'actions en temps réel et félicitations en fin de session. + +--- + +*Document généré par l'Assistant pour RPA Vision V3 - Mars 2026* diff --git a/agent_v0/agent_v1/__init__.py b/agent_v0/agent_v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/config.py b/agent_v0/agent_v1/config.py new file mode 100644 index 000000000..554896721 --- /dev/null +++ b/agent_v0/agent_v1/config.py @@ -0,0 +1,43 @@ +# agent_v1/config.py +""" +Configuration avancée pour Agent V1. +""" +from __future__ import annotations +import os +import platform +import socket +from pathlib import Path + +AGENT_VERSION = "1.0.0" + +# Identifiant unique de la machine (utilisé pour le multi-machine) +# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS +MACHINE_ID = os.environ.get( + "RPA_MACHINE_ID", + f"{socket.gethostname()}_{platform.system().lower()}", +) + +# Dossier racine de l'agent +BASE_DIR = Path(__file__).resolve().parent + +# Endpoint du serveur Streaming (port 5005) +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") +UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload" +STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" + +# Paramètres de session +MAX_SESSION_DURATION_S = 60 * 60 # 1 heure +SESSIONS_ROOT = BASE_DIR / "sessions" + +# Paramètres Vision (Crops pour qwen3-vl) +TARGETED_CROP_SIZE = (400, 400) +SCREENSHOT_QUALITY = 85 + +# Monitoring +PERF_MONITOR_INTERVAL_S = 30 +LOGS_DIR = BASE_DIR / "logs" +LOG_FILE = LOGS_DIR / "agent_v1.log" + +# Création des dossiers +os.makedirs(SESSIONS_ROOT, exist_ok=True) +os.makedirs(LOGS_DIR, exist_ok=True) diff --git a/agent_v0/agent_v1/core/__init__.py b/agent_v0/agent_v1/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/core/captor.py b/agent_v0/agent_v1/core/captor.py new file mode 100644 index 000000000..3b9d93402 --- /dev/null +++ b/agent_v0/agent_v1/core/captor.py @@ -0,0 +1,328 @@ +# agent_v1/core/captor.py +""" +Moteur de capture d'événements Agent V1. +Capture enrichie avec focus sur le contexte UI pour le stagiaire. + +Fonctionnalités : +- Capture clics souris (simple et double-clic) +- Capture scroll souris +- Capture combos clavier (Ctrl+C, Alt+Tab, etc.) +- Buffer de saisie texte : accumule les frappes et émet un événement + text_input après 500ms d'inactivité clavier +- Surveillance du focus fenêtre +""" + +import threading +import time +import logging +from typing import Callable, Optional, List, Dict, Any, Tuple +from pynput import mouse, keyboard +from pynput.mouse import Button +from pynput.keyboard import Key, KeyCode + +# Importation relative pour rester dans le module v1 +from ..vision.capturer import VisionCapturer +# from ..monitoring.system import SystemMonitor + +logger = logging.getLogger(__name__) + +# Délai d'inactivité avant flush du buffer texte (en secondes) +TEXT_FLUSH_DELAY = 0.5 +# Délai max entre deux clics pour un double-clic (en secondes) +DOUBLE_CLICK_DELAY = 0.3 +# Tolérance en pixels pour considérer deux clics au même endroit +DOUBLE_CLICK_TOLERANCE = 10 + + +class EventCaptorV1: + def __init__(self, on_event_callback: Callable[[Dict[str, Any]], None]): + self.on_event = on_event_callback + self.mouse_listener = None + self.keyboard_listener = None + self.running = False + + # État des touches modificatrices + self.modifiers = set() + + # Tracking du focus fenêtre + self.last_window = None + self._focus_thread = None + + # --- Buffer de saisie texte --- + # Lock pour accès thread-safe au buffer (le listener pynput + # tourne dans un thread séparé) + self._text_lock = threading.Lock() + self._text_buffer: list[str] = [] + # Position de la souris au moment de la première frappe du buffer + self._text_start_pos: Optional[Tuple[int, int]] = None + # Timer pour le flush après inactivité + self._text_flush_timer: Optional[threading.Timer] = None + # Dernière position connue de la souris (pour associer le texte + # au champ dans lequel l'utilisateur tape) + self._last_mouse_pos: Tuple[int, int] = (0, 0) + + # --- Détection double-clic --- + # Dernier clic : (x, y, timestamp, button) + self._last_click: Optional[Tuple[int, int, float, str]] = None + + def start(self): + self.running = True + self.mouse_listener = mouse.Listener( + on_click=self._on_click, + on_scroll=self._on_scroll, + on_move=self._on_move + ) + self.keyboard_listener = keyboard.Listener( + on_press=self._on_press, + on_release=self._on_release + ) + + self.mouse_listener.start() + self.keyboard_listener.start() + + # Thread de surveillance du focus fenêtre (Proactif) + self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True) + self._focus_thread.start() + + logger.info("Agent V1 Captor démarré") + + def stop(self): + self.running = False + # Flush du buffer texte restant avant arrêt + self._flush_text_buffer() + # Annuler le timer s'il est en cours + with self._text_lock: + if self._text_flush_timer is not None: + self._text_flush_timer.cancel() + self._text_flush_timer = None + if self.mouse_listener: self.mouse_listener.stop() + if self.keyboard_listener: self.keyboard_listener.stop() + logger.info("Agent V1 Captor arrêté") + + # ---------------------------------------------------------------- + # Souris + # ---------------------------------------------------------------- + + def _on_move(self, x, y): + """Mémorise la position souris pour l'associer aux événements texte.""" + self._last_mouse_pos = (x, y) + + def _on_click(self, x, y, button, pressed): + if not pressed: + return + + now = time.time() + + # --- Flush du buffer texte : l'utilisateur a cliqué, donc + # il change probablement de champ --- + self._flush_text_buffer() + + # --- Détection double-clic --- + if self._last_click is not None: + lx, ly, lt, lb = self._last_click + # Même bouton, même zone, délai court → double-clic + if (button.name == lb + and abs(x - lx) <= DOUBLE_CLICK_TOLERANCE + and abs(y - ly) <= DOUBLE_CLICK_TOLERANCE + and (now - lt) <= DOUBLE_CLICK_DELAY): + event = { + "type": "double_click", + "button": button.name, + "pos": (x, y), + "timestamp": now, + } + self.on_event(event) + # Réinitialiser pour éviter un triple-clic = 2 double-clics + self._last_click = None + return + + # Clic simple — on le mémorise pour comparer au prochain + self._last_click = (x, y, now, button.name) + event = { + "type": "mouse_click", + "button": button.name, + "pos": (x, y), + "timestamp": now, + } + self.on_event(event) + + def _on_scroll(self, x, y, dx, dy): + event = { + "type": "mouse_scroll", + "pos": (x, y), + "delta": (dx, dy), + "timestamp": time.time(), + } + self.on_event(event) + + # ---------------------------------------------------------------- + # Clavier + # ---------------------------------------------------------------- + + @staticmethod + def _get_key_name(key) -> Optional[str]: + """Convertit un objet pynput Key/KeyCode en nom lisible.""" + if isinstance(key, KeyCode): + return key.char if key.char else None + if isinstance(key, Key): + return key.name + return str(key) + + def _on_press(self, key): + # Gestion des touches modificatrices + if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r): + self.modifiers.add("ctrl") + elif key in (Key.alt, Key.alt_l, Key.alt_r): + self.modifiers.add("alt") + elif key in (Key.shift, Key.shift_l, Key.shift_r): + self.modifiers.add("shift") + + # --- Combos avec modificateur (sauf Shift seul) --- + # Shift seul n'est pas un « vrai » modificateur pour les combos : + # Shift+a = 'A' = saisie texte, pas un raccourci. + # On considère un combo seulement si Ctrl ou Alt est enfoncé. + has_real_modifier = self.modifiers & {"ctrl", "alt"} + if has_real_modifier: + key_name = self._get_key_name(key) + if key_name and key_name not in ("ctrl", "alt", "shift"): + # Un combo interrompt la saisie texte en cours + self._flush_text_buffer() + event = { + "type": "key_combo", + "keys": list(self.modifiers) + [key_name], + "timestamp": time.time(), + } + self.on_event(event) + return + + # --- Saisie texte (pas de Ctrl/Alt enfoncé) --- + self._handle_text_key(key) + + def _handle_text_key(self, key): + """Gère l'accumulation des frappes texte dans le buffer. + + Touches spéciales : + - Backspace : supprime le dernier caractère du buffer + - Enter / Tab : flush immédiat + émission de l'événement + - Escape : vide le buffer sans émettre + """ + with self._text_lock: + # --- Touches spéciales --- + if key == Key.backspace: + if self._text_buffer: + self._text_buffer.pop() + self._reset_flush_timer() + return + + if key == Key.esc: + # Annuler la saisie en cours + self._text_buffer.clear() + self._text_start_pos = None + self._cancel_flush_timer() + return + + if key in (Key.enter, Key.tab): + # Flush immédiat — on relâche le lock avant d'appeler + # _flush_text_buffer (qui prend aussi le lock) + pass # on sort du with et on flush après + + elif key == Key.space: + # Espace = caractère normal + if not self._text_buffer: + self._text_start_pos = self._last_mouse_pos + self._text_buffer.append(" ") + self._reset_flush_timer() + return + + elif isinstance(key, KeyCode) and key.char is not None: + # Caractère alphanumérique / ponctuation + # pynput renvoie déjà le bon caractère selon le layout + # (AZERTY inclus) — on ne convertit rien. + if not self._text_buffer: + self._text_start_pos = self._last_mouse_pos + self._text_buffer.append(key.char) + self._reset_flush_timer() + return + else: + # Touche spéciale non gérée (F1, Insert, etc.) — on ignore + return + + # Si on arrive ici, c'est Enter ou Tab → flush immédiat + self._flush_text_buffer() + + def _reset_flush_timer(self): + """Réarme le timer de flush après chaque frappe. + + Doit être appelé avec self._text_lock déjà acquis. + """ + if self._text_flush_timer is not None: + self._text_flush_timer.cancel() + self._text_flush_timer = threading.Timer( + TEXT_FLUSH_DELAY, self._flush_text_buffer + ) + self._text_flush_timer.daemon = True + self._text_flush_timer.start() + + def _cancel_flush_timer(self): + """Annule le timer de flush sans émettre. + + Doit être appelé avec self._text_lock déjà acquis. + """ + if self._text_flush_timer is not None: + self._text_flush_timer.cancel() + self._text_flush_timer = None + + def _flush_text_buffer(self): + """Émet un événement text_input avec le contenu du buffer, puis + le vide. Thread-safe — peut être appelé depuis le timer, le + listener souris ou le listener clavier.""" + with self._text_lock: + if not self._text_buffer: + # Rien à émettre + self._cancel_flush_timer() + return + text = "".join(self._text_buffer) + pos = self._text_start_pos or self._last_mouse_pos + self._text_buffer.clear() + self._text_start_pos = None + self._cancel_flush_timer() + + # Émission hors du lock pour éviter un deadlock si le callback + # est lent ou prend d'autres locks + event = { + "type": "text_input", + "text": text, + "pos": pos, + "timestamp": time.time(), + } + logger.debug(f"text_input émis : {len(text)} caractères") + self.on_event(event) + + def _on_release(self, key): + if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r): + self.modifiers.discard("ctrl") + elif key in (Key.alt, Key.alt_l, Key.alt_r): + self.modifiers.discard("alt") + elif key in (Key.shift, Key.shift_l, Key.shift_r): + self.modifiers.discard("shift") + + def _watch_window_focus(self): + """Surveille proactivement le changement de fenêtre pour le stagiaire.""" + # Importation relative simple + from ..window_info_crossplatform import get_active_window_info + + while self.running: + try: + info = get_active_window_info() + if info and info != self.last_window: + event = { + "type": "window_focus_change", + "from": self.last_window, + "to": info, + "timestamp": time.time() + } + self.last_window = info + self.on_event(event) + except Exception as e: + logger.error(f"Erreur focus window: {e}") + time.sleep(0.5) diff --git a/agent_v0/agent_v1/core/executor.py b/agent_v0/agent_v1/core/executor.py new file mode 100644 index 000000000..0645a6729 --- /dev/null +++ b/agent_v0/agent_v1/core/executor.py @@ -0,0 +1,523 @@ +# agent_v1/core/executor.py +""" +Executeur d'actions visuelles pour Agent V1. +Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (VM). + +Supporte deux modes : + - Watchdog fichier (command.json) — legacy + - Polling serveur (GET /replay/next) — mode replay P0-5 +""" + +import base64 +import io +import time +import logging + +import mss +from pynput.mouse import Button, Controller as MouseController +from pynput.keyboard import Controller as KeyboardController, Key + +logger = logging.getLogger(__name__) + +# Mapping des noms de touches spéciales vers pynput.Key +_SPECIAL_KEYS = { + "enter": Key.enter, + "return": Key.enter, + "tab": Key.tab, + "escape": Key.esc, + "esc": Key.esc, + "backspace": Key.backspace, + "delete": Key.delete, + "space": Key.space, + "up": Key.up, + "down": Key.down, + "left": Key.left, + "right": Key.right, + "home": Key.home, + "end": Key.end, + "page_up": Key.page_up, + "page_down": Key.page_down, + "f1": Key.f1, "f2": Key.f2, "f3": Key.f3, "f4": Key.f4, + "f5": Key.f5, "f6": Key.f6, "f7": Key.f7, "f8": Key.f8, + "f9": Key.f9, "f10": Key.f10, "f11": Key.f11, "f12": Key.f12, + "ctrl": Key.ctrl, "ctrl_l": Key.ctrl_l, "ctrl_r": Key.ctrl_r, + "alt": Key.alt, "alt_l": Key.alt_l, "alt_r": Key.alt_r, + "shift": Key.shift, "shift_l": Key.shift_l, "shift_r": Key.shift_r, + "cmd": Key.cmd, "win": Key.cmd, + "super": Key.cmd, "super_l": Key.cmd, "super_r": Key.cmd, + "windows": Key.cmd, "meta": Key.cmd, + "insert": Key.insert, "print_screen": Key.print_screen, + "caps_lock": Key.caps_lock, "num_lock": Key.num_lock, +} + + +class ActionExecutorV1: + def __init__(self): + self.mouse = MouseController() + self.keyboard = KeyboardController() + # NB: mss est initialise paresseusement pour eviter les problemes + # de thread-safety (le constructeur peut etre appele dans un thread + # different de celui qui utilise l'instance). + self._sct = None + self.running = True + # Backoff exponentiel pour le polling replay (evite de marteler le serveur) + self._poll_backoff = 1.0 # Delai actuel (secondes) + self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes) + self._poll_backoff_max = 30.0 # Delai maximal + self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec + + @property + def sct(self): + """Instance mss paresseuse, creee dans le thread appelant.""" + if self._sct is None: + self._sct = mss.mss() + return self._sct + + # ========================================================================= + # Execution legacy (watchdog command.json) + # ========================================================================= + + def execute_normalized_order(self, order: dict): + """ + Execute un ordre base sur des proportions (0.0 a 1.0). + Ex: {"action": "mouse_click", "x_pct": 0.5, "y_pct": 0.5} (Clic au centre) + """ + action = order.get("action") + try: + # Recuperation de la resolution actuelle de la VM + monitor = self.sct.monitors[1] + width, height = monitor["width"], monitor["height"] + + if action == "mouse_click": + # Traduction Proportions -> Pixels reels de la VM + real_x = int(order.get("x_pct", 0) * width) + real_y = int(order.get("y_pct", 0) * height) + self._click((real_x, real_y), order.get("button", "left")) + + elif action == "text_input": + self.keyboard.type(order.get("text", "")) + + logger.info(f"Ordre Visuel execute : {action} sur ({width}x{height})") + except Exception as e: + logger.error(f"Echec de l'ordre {action} : {e}") + + # ========================================================================= + # Execution replay (polling serveur) + # ========================================================================= + + def execute_replay_action(self, action: dict, server_url: str = "") -> dict: + """ + Execute une action normalisee recue du serveur de replay. + + Supporte deux modes : + - Visual mode (visual_mode=True + target_spec) : capture un screenshot, + l'envoie au serveur pour resolution visuelle, puis execute a la position trouvee. + - Blind mode (defaut) : utilise les coordonnees statiques x_pct/y_pct. + + Format d'entree : + { + "action_id": "act_xxxx", + "type": "click|type|key_combo|scroll|wait", + "x_pct": 0.5, + "y_pct": 0.3, + "text": "...", + "keys": [...], + "button": "left", + "duration_ms": 500, + "visual_mode": true, + "target_spec": {"by_role": "button", "by_text": "Submit"} + } + + Retourne : + { + "action_id": "act_xxxx", + "success": True/False, + "error": None ou message, + "screenshot": base64 du screenshot post-action, + "visual_resolved": True/False + } + """ + action_id = action.get("action_id", "unknown") + action_type = action.get("type", "unknown") + visual_mode = action.get("visual_mode", False) + target_spec = action.get("target_spec", {}) + result = { + "action_id": action_id, + "success": False, + "error": None, + "screenshot": None, + "visual_resolved": False, + } + + try: + monitor = self.sct.monitors[1] + width, height = monitor["width"], monitor["height"] + + # Resolution visuelle des coordonnees si demande + x_pct = action.get("x_pct", 0.0) + y_pct = action.get("y_pct", 0.0) + + if visual_mode and target_spec and server_url: + resolved = self._resolve_target_visual( + server_url, target_spec, x_pct, y_pct, width, height + ) + if resolved: + x_pct = resolved["x_pct"] + y_pct = resolved["y_pct"] + result["visual_resolved"] = resolved.get("resolved", False) + if resolved.get("resolved"): + logger.info( + f"Visual resolve OK: {resolved.get('matched_element', {}).get('label', '?')} " + f"-> ({x_pct:.4f}, {y_pct:.4f})" + ) + + if action_type == "click": + real_x = int(x_pct * width) + real_y = int(y_pct * height) + button = action.get("button", "left") + mode = "VISUAL" if result["visual_resolved"] else "BLIND" + print( + f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> " + f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}" + ) + self._click((real_x, real_y), button) + print(f" [CLICK] Termine.") + logger.info( + f"Replay click [{mode}] : ({x_pct:.3f}, {y_pct:.3f}) -> " + f"({real_x}, {real_y}) sur ({width}x{height})" + ) + + elif action_type == "type": + text = action.get("text", "") + print(f" [TYPE] Texte: '{text[:50]}' ({len(text)} chars)") + # Cliquer sur le champ avant de taper (si coordonnees disponibles) + if x_pct > 0 and y_pct > 0: + real_x = int(x_pct * width) + real_y = int(y_pct * height) + print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})") + self._click((real_x, real_y), "left") + time.sleep(0.3) + self.keyboard.type(text) + print(f" [TYPE] Termine.") + logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)") + + elif action_type == "key_combo": + keys = action.get("keys", []) + print(f" [KEY_COMBO] Touches: {keys}") + self._execute_key_combo(keys) + print(f" [KEY_COMBO] Termine.") + logger.info(f"Replay key_combo : {keys}") + + elif action_type == "scroll": + real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width) + real_y = int(y_pct * height) if y_pct > 0 else int(0.5 * height) + delta = action.get("delta", -3) + print(f" [SCROLL] delta={delta} a ({real_x}, {real_y})") + self.mouse.position = (real_x, real_y) + time.sleep(0.05) + self.mouse.scroll(0, delta) + print(f" [SCROLL] Termine.") + logger.info(f"Replay scroll : delta={delta} a ({real_x}, {real_y})") + + elif action_type == "wait": + duration_ms = action.get("duration_ms", 500) + print(f" [WAIT] {duration_ms}ms...") + time.sleep(duration_ms / 1000.0) + print(f" [WAIT] Termine.") + logger.info(f"Replay wait : {duration_ms}ms") + + else: + result["error"] = f"Type d'action inconnu : {action_type}" + logger.warning(result["error"]) + return result + + result["success"] = True + + # Capturer un screenshot post-action + time.sleep(0.5) + 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}") + + return result + + def _resolve_target_visual( + self, server_url: str, target_spec: dict, + fallback_x: float, fallback_y: float, + screen_width: int, screen_height: int, + ) -> dict: + """ + Envoyer un screenshot au serveur pour resolution visuelle de la cible. + + Capture l'ecran en haute resolution (pas de downscale pour le template + matching), l'encode en base64 JPEG, et POST au endpoint + /replay/resolve_target. Retourne les coordonnees resolues. + """ + import requests + + try: + # Capturer à 1280px max — assez pour le template matching + # et raisonnable pour le transfert réseau (~200-400Ko) + screenshot_b64 = self._capture_screenshot_b64( + max_width=1280, + quality=75, + ) + if not screenshot_b64: + logger.warning("Capture screenshot echouee pour visual resolve") + return None + + print( + f" [VISUAL] Envoi screenshot ({len(screenshot_b64) // 1024} Ko) " + f"au serveur pour resolution..." + ) + + # Appel au serveur + resolve_url = f"{server_url}/traces/stream/replay/resolve_target" + payload = { + "session_id": "", # Pas critique pour la resolution + "screenshot_b64": screenshot_b64, + "target_spec": target_spec, + "fallback_x_pct": fallback_x, + "fallback_y_pct": fallback_y, + "screen_width": screen_width, + "screen_height": screen_height, + } + + resp = requests.post(resolve_url, json=payload, timeout=60) + if resp.ok: + data = resp.json() + method = data.get("method", "?") + resolved = data.get("resolved", False) + print( + f" [VISUAL] Reponse serveur : resolved={resolved}, " + f"method={method}, score={data.get('score', 'N/A')}" + ) + return data + else: + logger.warning(f"Visual resolve HTTP {resp.status_code}: {resp.text[:200]}") + return None + + except requests.exceptions.Timeout: + logger.warning("Visual resolve timeout (30s)") + return None + except Exception as e: + logger.warning(f"Visual resolve echoue: {e}") + return None + + def poll_and_execute(self, session_id: str, server_url: str, machine_id: str = "default") -> bool: + """ + Poll le serveur pour recuperer et executer la prochaine action. + + 1. GET /replay/next pour recuperer l'action + 2. Execute l'action (clic, texte, etc.) + 3. POST /replay/result avec le resultat + screenshot + + Args: + session_id: Identifiant de la session courante + server_url: URL de base du serveur streaming + machine_id: Identifiant de la machine (pour le replay multi-machine) + + Retourne True si une action a ete executee, False sinon. + IMPORTANT: Si une action est recue, le resultat est TOUJOURS rapporte + au serveur (meme en cas d'erreur d'execution). + """ + import requests + + replay_next_url = f"{server_url}/traces/stream/replay/next" + replay_result_url = f"{server_url}/traces/stream/replay/result" + + # Phase 1 : Recuperer la prochaine action (filtree par machine_id) + try: + resp = requests.get( + replay_next_url, + params={"session_id": session_id, "machine_id": machine_id}, + timeout=5, + ) + if not resp.ok: + logger.debug(f"Poll replay echoue : HTTP {resp.status_code}") + return False + + data = resp.json() + action = data.get("action") + if action is None: + return False + + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + # Backoff exponentiel : augmenter le delai de polling + self._poll_backoff = min( + self._poll_backoff * self._poll_backoff_factor, + self._poll_backoff_max, + ) + if not hasattr(self, '_last_conn_error_logged'): + self._last_conn_error_logged = True + print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}") + logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}") + return False + except Exception as e: + self._poll_backoff = min( + self._poll_backoff * self._poll_backoff_factor, + self._poll_backoff_max, + ) + print(f"[REPLAY] ERREUR poll (GET) : {e}") + logger.error(f"Erreur poll GET : {e}") + return False + + # Reset du flag d'erreur connexion et du backoff (on a reussi le GET) + self._last_conn_error_logged = False + self._poll_backoff = self._poll_backoff_min + + # Phase 2 : Executer l'action et rapporter le resultat + # TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution + action_type = action.get('type', '?') + action_id = action.get('action_id', '?') + print(f"\n>>> REPLAY ACTION RECUE : {action_type} (id={action_id})") + print(f" Contenu: {action}") + logger.info(f"Action de replay recue : {action_type} (id={action_id})") + + result = None + try: + print(f">>> Execution de l'action {action_type}...") + result = self.execute_replay_action(action, server_url=server_url) + print( + f">>> Resultat execution : success={result['success']}, " + f"error={result.get('error')}" + ) + except Exception as e: + print(f">>> ERREUR EXECUTION : {e}") + logger.error(f"Erreur execute_replay_action: {e}") + import traceback + traceback.print_exc() + result = { + "action_id": action_id, + "success": False, + "error": f"Exception executor: {e}", + "screenshot": None, + } + + # Phase 3 : Rapporter le resultat au serveur (TOUJOURS) + report = { + "session_id": session_id, + "action_id": result["action_id"], + "success": result["success"], + "error": result.get("error"), + "screenshot": result.get("screenshot"), + } + try: + resp2 = requests.post( + replay_result_url, + json=report, + timeout=10, + ) + if resp2.ok: + server_resp = resp2.json() + msg = ( + f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, " + f"restant={server_resp.get('remaining_actions')}" + ) + print(f">>> {msg}") + logger.info(msg) + else: + print(f">>> Rapport resultat echoue : HTTP {resp2.status_code}") + logger.warning(f"Rapport resultat echoue : HTTP {resp2.status_code}") + except Exception as e: + print(f">>> Impossible de rapporter le resultat : {e}") + logger.warning(f"Impossible de rapporter le resultat : {e}") + + return True + + # ========================================================================= + # Helpers + # ========================================================================= + + def _click(self, pos, button_name): + """Deplacer la souris et cliquer. + + Supporte les boutons : left, right, double (double-clic gauche). + """ + self.mouse.position = pos + time.sleep(0.1) # Delai pour simuler le temps de reaction humain + + if button_name == "double": + self.mouse.click(Button.left, 2) + elif button_name == "right": + self.mouse.click(Button.right) + else: + self.mouse.click(Button.left) + + def _execute_key_combo(self, keys: list): + """ + Executer une combinaison de touches. + Ex: ["ctrl", "a"] -> Ctrl+A + Ex: ["enter"] -> Enter + """ + if not keys: + return + + # Resoudre les noms de touches vers les objets pynput + resolved = [] + for key_name in keys: + key_lower = key_name.lower() + if key_lower in _SPECIAL_KEYS: + resolved.append(_SPECIAL_KEYS[key_lower]) + elif len(key_name) == 1: + resolved.append(key_name) + else: + logger.warning(f"Touche inconnue : '{key_name}', ignoree") + + if not resolved: + return + + # Si une seule touche, simple press + if len(resolved) == 1: + self.keyboard.press(resolved[0]) + self.keyboard.release(resolved[0]) + return + + # Combo : maintenir les modificateurs, taper la derniere touche + modifiers = resolved[:-1] + final_key = resolved[-1] + + for mod in modifiers: + self.keyboard.press(mod) + time.sleep(0.05) + + self.keyboard.press(final_key) + self.keyboard.release(final_key) + + for mod in reversed(modifiers): + self.keyboard.release(mod) + + def _capture_screenshot_b64(self, max_width: int = 800, quality: int = 60) -> str: + """ + Capturer l'ecran et retourner le screenshot en base64. + + Args: + max_width: Largeur maximale en pixels (0 = pas de redimensionnement, + utile pour le template matching qui a besoin de la resolution native) + quality: Qualite JPEG (1-100, 60 pour preview, 85+ pour template matching) + """ + try: + from PIL import Image + + monitor = self.sct.monitors[1] + raw = self.sct.grab(monitor) + img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX") + + # Redimensionner si max_width > 0 + if max_width > 0 and img.width > max_width: + ratio = max_width / img.width + new_h = int(img.height * ratio) + img = img.resize((max_width, new_h), Image.LANCZOS) + + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=quality) + return base64.b64encode(buffer.getvalue()).decode("utf-8") + except ImportError: + # PIL non disponible — retourner None + logger.debug("PIL non disponible, pas de screenshot base64") + return "" + except Exception as e: + logger.debug(f"Capture screenshot base64 echouee : {e}") + return "" diff --git a/agent_v0/agent_v1/core/window_info.py b/agent_v0/agent_v1/core/window_info.py new file mode 100644 index 000000000..7e6be8744 --- /dev/null +++ b/agent_v0/agent_v1/core/window_info.py @@ -0,0 +1,55 @@ +# window_info.py +""" +Récupération des informations sur la fenêtre active (X11). + +v0 : +- utilise xdotool pour obtenir : + - le titre de la fenêtre active + - le PID de la fenêtre active, puis le nom du process via ps + +Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". +""" + +from __future__ import annotations + +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Nécessite xdotool installé sur le système. + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } diff --git a/agent_v0/agent_v1/core/window_info_crossplatform.py b/agent_v0/agent_v1/core/window_info_crossplatform.py new file mode 100644 index 000000000..ba059a3fc --- /dev/null +++ b/agent_v0/agent_v1/core/window_info_crossplatform.py @@ -0,0 +1,192 @@ +# window_info_crossplatform.py +""" +Récupération des informations sur la fenêtre active - CROSS-PLATFORM + +Supporte: +- Linux (X11 via xdotool) +- Windows (via pywin32) +- macOS (via pyobjc) + +Installation des dépendances: + pip install pywin32 # Windows + pip install pyobjc-framework-Cocoa # macOS + pip install psutil # Tous OS +""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Détecte automatiquement l'OS et utilise la méthode appropriée. + """ + system = platform.system() + + if system == "Linux": + return _get_window_info_linux() + elif system == "Windows": + return _get_window_info_windows() + elif system == "Darwin": # macOS + return _get_window_info_macos() + else: + return {"title": "unknown_window", "app_name": "unknown_app"} + + +def _get_window_info_linux() -> Dict[str, str]: + """ + Linux: utilise xdotool (X11) + + Nécessite: sudo apt-get install xdotool + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + +def _get_window_info_windows() -> Dict[str, str]: + """ + Windows: utilise pywin32 + psutil + + Nécessite: pip install pywin32 psutil + """ + try: + import win32gui + import win32process + import psutil + + # Fenêtre au premier plan + hwnd = win32gui.GetForegroundWindow() + + # Titre de la fenêtre + title = win32gui.GetWindowText(hwnd) + if not title: + title = "unknown_window" + + # PID du processus + _, pid = win32process.GetWindowThreadProcessId(hwnd) + + # Nom du processus + try: + process = psutil.Process(pid) + app_name = process.name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pywin32 ou psutil non installé + return { + "title": "unknown_window (pywin32 missing)", + "app_name": "unknown_app (pywin32 missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +def _get_window_info_macos() -> Dict[str, str]: + """ + macOS: utilise pyobjc (AppKit) + + Nécessite: pip install pyobjc-framework-Cocoa + + Note: Nécessite les permissions "Accessibility" dans System Preferences + """ + try: + from AppKit import NSWorkspace + from Quartz import ( + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + # Application active + active_app = NSWorkspace.sharedWorkspace().activeApplication() + app_name = active_app.get('NSApplicationName', 'unknown_app') + + # Titre de la fenêtre (via Quartz) + # On cherche la fenêtre de l'app active qui est au premier plan + window_list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + title = "unknown_window" + for window in window_list: + owner_name = window.get('kCGWindowOwnerName', '') + if owner_name == app_name: + window_title = window.get('kCGWindowName', '') + if window_title: + title = window_title + break + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pyobjc non installé + return { + "title": "unknown_window (pyobjc missing)", + "app_name": "unknown_app (pyobjc missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +# Test rapide +if __name__ == "__main__": + import time + + print(f"OS détecté: {platform.system()}") + print("\nTest de capture fenêtre active (5 secondes)...") + print("Changez de fenêtre pour tester!\n") + + for i in range(5): + info = get_active_window_info() + print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") + time.sleep(1) diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py new file mode 100644 index 000000000..fa1798c17 --- /dev/null +++ b/agent_v0/agent_v1/main.py @@ -0,0 +1,384 @@ +# agent_v1/main.py +""" +Point d'entree Agent V1 - Enrichi avec Intelligence de Contexte, Heartbeat et Replay. + +Boucles paralleles (threads daemon) : + - _heartbeat_loop : capture periodique toutes les 5s + - _command_watchdog_loop : surveillance du fichier command.json (legacy) + - _replay_poll_loop : polling du serveur pour les actions de replay (P0-5) +""" + +import sys +import os +import uuid +import time +import logging +import threading +from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID +from .core.captor import EventCaptorV1 +from .core.executor import ActionExecutorV1 +from .network.streamer import TraceStreamer +from .ui.shared_state import AgentState +from .ui.smart_tray import SmartTrayV1 +from .ui.chat_window import ChatWindow +from .session.storage import SessionStorage +from .vision.capturer import VisionCapturer + +# Import optionnel du client serveur (pour le chat et les workflows) +# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1) +try: + from ..lea_ui.server_client import LeaServerClient +except (ImportError, ValueError): + try: + from lea_ui.server_client import LeaServerClient + except ImportError: + LeaServerClient = None + +# Configuration du logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +# Intervalle de polling replay (secondes) +REPLAY_POLL_INTERVAL = 1.0 + + +class AgentV1: + def __init__(self, user_id="demo_user"): + self.user_id = user_id + self.machine_id = MACHINE_ID + self.session_id = None + self.session_dir = None + + # Gestion du stockage local et nettoyage + self.storage = SessionStorage(SESSIONS_ROOT) + threading.Thread(target=self._delayed_cleanup, daemon=True).start() + + self.vision = None + self.streamer = None + self.captor = None + self.shot_counter = 0 + self.running = False + + # Executeur partage entre watchdog et replay + self._executor = None + # Flag pour indiquer qu'un replay est en cours (eviter les conflits) + self._replay_active = False + + # Etat partage entre systray et chat (source de verite unique) + self._state = AgentState() + self._state.set_on_start(self.start_session) + self._state.set_on_stop(self.stop_session) + + # Client serveur pour le chat et les workflows + self._server_client = None + if LeaServerClient is not None: + self._server_client = LeaServerClient() + + # Fenetre de chat Lea (tkinter natif) + server_host = ( + self._server_client.server_host + if self._server_client is not None + else os.getenv("RPA_SERVER_HOST", "localhost") + ) + self._chat_window = ChatWindow( + server_client=self._server_client, + on_start_callback=self.start_session, + server_host=server_host, + chat_port=5004, + shared_state=self._state, + ) + + # Executeur pour le replay (doit exister avant le poll) + self._executor = ActionExecutorV1() + + # Boucles permanentes (pas besoin de session active) + self.running = True + self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background")) + threading.Thread(target=self._replay_poll_loop, daemon=True).start() + threading.Thread(target=self._background_heartbeat_loop, daemon=True).start() + + # UI Tray intelligent (remplace TrayAppV1, plus de PyQt5) + self.ui = SmartTrayV1( + self.start_session, + self.stop_session, + server_client=self._server_client, + chat_window=self._chat_window, + machine_id=self.machine_id, + shared_state=self._state, + ) + + def _delayed_cleanup(self): + """Nettoyage en arrière-plan après 30s pour ne pas bloquer le démarrage.""" + time.sleep(30) + self.storage.run_auto_cleanup() + + def start_session(self, workflow_name): + self.session_id = f"sess_{time.strftime('%Y%m%dT%H%M%S')}_{uuid.uuid4().hex[:6]}" + self.session_dir = self.storage.get_session_dir(self.session_id) + + self.vision = VisionCapturer(str(self.session_dir)) + + self.streamer = TraceStreamer(self.session_id, machine_id=self.machine_id) + self.captor = EventCaptorV1(self._on_event_bridge) + + # Initialiser l'executeur partage + self._executor = ActionExecutorV1() + + self.shot_counter = 0 + self.running = True + self._replay_active = False + self.streamer.start() + self.captor.start() + + # Heartbeat Contextuel (Toutes les 5s par defaut) + threading.Thread(target=self._heartbeat_loop, daemon=True).start() + + # Watchdog de Commandes (GHOST Replay — legacy fichier) + threading.Thread(target=self._command_watchdog_loop, daemon=True).start() + + # Boucle de polling replay (P0-5 — pull depuis le serveur) + threading.Thread(target=self._replay_poll_loop, daemon=True).start() + + logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...") + + def _command_watchdog_loop(self): + """Surveille un fichier de commande pour executer des ordres visuels (legacy).""" + import json + import platform + from .config import BASE_DIR + + # Chemin du fichier de commande selon l'OS + if platform.system() == "Windows": + cmd_path = "C:\\rpa_vision\\command.json" + else: + cmd_path = str(BASE_DIR / "command.json") + + while self.running: + # Ne pas traiter les commandes fichier pendant un replay serveur + if self._replay_active: + time.sleep(1) + continue + + if os.path.exists(cmd_path): + try: + with open(cmd_path, "r") as f: + order = json.load(f) + os.remove(cmd_path) # On consomme l'ordre + if self._executor: + self._executor.execute_normalized_order(order) + except Exception as e: + logger.error(f"Erreur Watchdog: {e}") + time.sleep(1) + + def _replay_poll_loop(self): + """ + Boucle de polling pour les actions de replay depuis le serveur (P0-5). + + Tourne en parallele du heartbeat et du watchdog. + Poll GET /replay/next toutes les REPLAY_POLL_INTERVAL secondes. + Quand une action est recue, l'execute via l'executor et rapporte le resultat. + """ + msg = ( + f"[REPLAY] Boucle replay demarree — poll toutes les " + f"{REPLAY_POLL_INTERVAL}s sur {SERVER_URL}" + ) + print(msg) + logger.info(msg) + + poll_count = 0 + while self.running: + if not self._executor: + time.sleep(REPLAY_POLL_INTERVAL) + continue + + # Utiliser la session active ou un ID par défaut pour le replay + poll_session = self.session_id or f"agent_{self.user_id}" + + # Log periodique pour confirmer que la boucle tourne (toutes les 60s) + poll_count += 1 + if poll_count % int(60 / REPLAY_POLL_INTERVAL) == 0: + print( + f"[REPLAY] Poll #{poll_count} — session={poll_session} " + f"— serveur={SERVER_URL}" + ) + + try: + # Tenter de recuperer et executer une action + had_action = self._executor.poll_and_execute( + session_id=poll_session, + server_url=SERVER_URL, + machine_id=self.machine_id, + ) + + if had_action: + if not self._replay_active: + self._replay_active = True + self.ui.set_replay_active(True) + self._state.set_replay_active(True) + # Si une action a ete executee, poll plus rapidement + # pour enchainer les actions du workflow + time.sleep(0.2) + else: + # Pas d'action en attente — utiliser le backoff de l'executor + # (augmente si le serveur est indisponible, reset a 1s sinon) + if self._replay_active: + print("[REPLAY] Replay termine — retour en mode capture") + logger.info("Replay termine — retour en mode capture") + self._replay_active = False + self.ui.set_replay_active(False) + self._state.set_replay_active(False) + poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL) + time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL)) + + except Exception as e: + print(f"[REPLAY] ERREUR boucle replay : {e}") + logger.error(f"Erreur replay poll loop : {e}") + self._replay_active = False + self._state.set_replay_active(False) + poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL) + time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL)) + + _last_bg_hash: str = "" + + def _background_heartbeat_loop(self): + """Heartbeat permanent — envoie un screenshot toutes les 5s au serveur. + Tourne même sans session active, pour que le VWB puisse capturer Windows. + """ + import requests as req + bg_session = f"bg_{self.machine_id}" + logger.info(f"[HEARTBEAT] Boucle permanente démarrée (session={bg_session})") + + while self.running: + try: + # Ne pas envoyer pendant un enregistrement (le heartbeat session s'en charge) + if self.session_id: + time.sleep(5) + continue + + full_path = self._bg_vision.capture_full_context("heartbeat") + if not full_path: + time.sleep(5) + continue + + # Dédup : skip si écran identique + img_hash = self._quick_hash(full_path) + if img_hash and img_hash == self._last_bg_hash: + time.sleep(5) + continue + self._last_bg_hash = img_hash + + # Envoyer au streaming server + with open(full_path, 'rb') as f: + req.post( + f"{SERVER_URL}/traces/stream/image", + params={ + "session_id": bg_session, + "shot_id": f"heartbeat_{int(time.time())}", + "machine_id": self.machine_id, + }, + files={"file": ("screenshot.png", f, "image/png")}, + timeout=10, + ) + except Exception as e: + logger.debug(f"[HEARTBEAT] Erreur: {e}") + time.sleep(5) + + def stop_session(self): + self.running = False + if self.captor: self.captor.stop() + if self.streamer: self.streamer.stop() + logger.info(f"Session {self.session_id} terminée.") + + _last_heartbeat_hash: str = "" + + def _heartbeat_loop(self): + """Capture périodique pour donner du contexte au stagiaire. + Déduplication : n'envoie que si l'écran a changé. + """ + while self.running: + try: + full_path = self.vision.capture_full_context("heartbeat") + if full_path: + # Hash rapide pour détecter les changements d'écran + img_hash = self._quick_hash(full_path) + if img_hash != self._last_heartbeat_hash: + self._last_heartbeat_hash = img_hash + self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}") + self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id}) + except Exception as e: + logger.error(f"Heartbeat error: {e}") + time.sleep(5) + + @staticmethod + def _quick_hash(image_path: str) -> str: + """Hash perceptuel rapide (16x16 niveaux de gris).""" + try: + from PIL import Image + import hashlib + img = Image.open(image_path).resize((16, 16)).convert('L') + return hashlib.md5(img.tobytes()).hexdigest() + except Exception: + return "" + + def _on_event_bridge(self, event): + """Pont intelligent avec capture duale et post-action monitoring.""" + if not self.session_id: + return + + # Injecter l'identifiant machine dans chaque événement (multi-machine) + event["machine_id"] = self.machine_id + + # Injecter le contexte fenêtre dans chaque événement (nécessaire + # pour que le serveur maintienne last_window_info) + if self.captor and self.captor.last_window: + event["window"] = self.captor.last_window + + # Capture Proactive sur changement de fenêtre + if event["type"] == "window_focus_change": + full_path = self.vision.capture_full_context("focus_change") + event["screenshot_context"] = full_path + self.streamer.push_image(full_path, f"focus_{int(time.time())}") + + # 🔴 Capture Interactive (Dual) + if event["type"] in ["mouse_click", "key_combo"]: + self.shot_counter += 1 + shot_id = f"shot_{self.shot_counter:04d}" + + pos = event.get("pos", (0, 0)) + capture_info = self.vision.capture_dual(pos[0], pos[1], shot_id) + + event["screenshot_id"] = shot_id + event["vision_info"] = capture_info + + self._stream_capture_info(capture_info, shot_id) + + # 🕒 POST-ACTION : Capture du résultat après 1s (pour voir le résultat du clic) + threading.Timer(1.0, self._capture_result, args=(shot_id,)).start() + + self.ui.update_stats(self.shot_counter) + self._state.update_actions_count(self.shot_counter) + print(f"📸 Action capturée : {event['type']}") + self.streamer.push_event(event) + + def _capture_result(self, base_shot_id: str): + """Capture l'état de l'écran 1s après l'action pour voir l'effet.""" + if not self.running: return + res_path = self.vision.capture_full_context(f"result_of_{base_shot_id}") + self.streamer.push_image(res_path, f"res_{base_shot_id}") + self.streamer.push_event({"type": "action_result", "base_shot_id": base_shot_id, "image": res_path}) + + def _stream_capture_info(self, capture_info, shot_id): + if "full" in capture_info: + self.streamer.push_image(capture_info["full"], f"{shot_id}_full") + if "crop" in capture_info: + self.streamer.push_image(capture_info["crop"], f"{shot_id}_crop") + + def run(self): + self.ui.run() + +def main(): + agent = AgentV1() + agent.run() + +if __name__ == "__main__": + main() diff --git a/agent_v0/agent_v1/monitoring/__init__.py b/agent_v0/agent_v1/monitoring/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/network/__init__.py b/agent_v0/agent_v1/network/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/network/streamer.py b/agent_v0/agent_v1/network/streamer.py new file mode 100644 index 000000000..65dd50f97 --- /dev/null +++ b/agent_v0/agent_v1/network/streamer.py @@ -0,0 +1,398 @@ +# agent_v1/network/streamer.py +""" +Streaming temps réel pour Agent V1. +Exploite la fibre pour envoyer les événements au fur et à mesure. + +Endpoints serveur (api_stream.py, port 5005) : + POST /api/v1/traces/stream/register — enregistrer la session + POST /api/v1/traces/stream/event — événement temps réel + POST /api/v1/traces/stream/image — screenshot (full ou crop) + POST /api/v1/traces/stream/finalize — clôturer et construire le workflow + +Robustesse (P0-2) : + - Retry avec backoff exponentiel (1s/2s/4s, max 3 tentatives) + - Health-check périodique (30s) pour recovery du flag _server_available + - Compression JPEG qualité 85 pour les images (réduction ~5-10x) + - Backpressure : queue bornée (maxsize=100), drop des heartbeat si pleine +""" + +import io +import logging +import queue +import threading +import time + +import requests +from PIL import Image + +from ..config import STREAMING_ENDPOINT + +logger = logging.getLogger(__name__) + +# Paramètres de retry +MAX_RETRIES = 3 +RETRY_DELAYS = [1.0, 2.0, 4.0] # Backoff exponentiel + +# Paramètres de health-check +HEALTH_CHECK_INTERVAL_S = 30 + +# Paramètres de compression +JPEG_QUALITY = 85 + +# Taille max de la queue (backpressure) +QUEUE_MAX_SIZE = 100 + +# Types d'événements à ne jamais dropper +PRIORITY_EVENT_TYPES = {"click", "key", "scroll", "action", "screenshot"} + + +class TraceStreamer: + def __init__(self, session_id: str, machine_id: str = "default"): + self.session_id = session_id + self.machine_id = machine_id # Identifiant machine pour le multi-machine + self.queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) + self.running = False + self._thread = None + self._health_thread = None + self._server_available = True # Désactivé après trop d'échecs + + def start(self): + """Démarrer le streaming et enregistrer la session côté serveur.""" + self.running = True + self._register_session() + # Thread principal d'envoi + self._thread = threading.Thread(target=self._stream_loop, daemon=True) + self._thread.start() + # Thread de health-check pour recovery + self._health_thread = threading.Thread( + target=self._health_check_loop, daemon=True + ) + self._health_thread.start() + logger.info(f"Streamer pour {self.session_id} démarré") + + def stop(self): + """Arrêter le streaming et finaliser la session côté serveur. + + Attend que la queue se vide (max 30s) avant de finaliser, + pour que toutes les images soient envoyées au serveur. + """ + self.running = False + + # Attendre que la queue se vide (les images doivent être envoyées) + if self._thread: + drain_start = time.time() + while not self.queue.empty() and (time.time() - drain_start) < 30: + time.sleep(0.5) + if not self.queue.empty(): + logger.warning( + f"Queue non vide après 30s ({self.queue.qsize()} items restants)" + ) + self._thread.join(timeout=5.0) + + if self._health_thread: + self._health_thread.join(timeout=2.0) + + self._finalize_session() + logger.info(f"Streamer pour {self.session_id} arrêté") + + def push_event(self, event_data: dict): + """Enfile un événement pour envoi immédiat. + + Si la queue est pleine (backpressure), les heartbeat sont droppés + tandis que les événements utilisateur (click, key, scroll, action) + et screenshots sont toujours conservés. + """ + self._enqueue_with_backpressure("event", event_data) + + def push_image(self, image_path: str, screenshot_id: str): + """Enfile une image pour envoi asynchrone.""" + if not image_path: + return # Ignorer les chemins vides (heartbeat sans changement) + self._enqueue_with_backpressure("image", (image_path, screenshot_id)) + + # ========================================================================= + # Backpressure — gestion de la queue bornée + # ========================================================================= + + def _enqueue_with_backpressure(self, item_type: str, data): + """Ajouter un item à la queue avec gestion du backpressure. + + Quand la queue est pleine : + - Les événements prioritaires (click, key, action, screenshot) sont + ajoutés en bloquant brièvement (0.5s) + - Les heartbeat sont silencieusement droppés + """ + is_priority = self._is_priority_item(item_type, data) + + try: + self.queue.put_nowait((item_type, data)) + except queue.Full: + if is_priority: + # Événement prioritaire : on attend un peu pour l'ajouter + try: + self.queue.put((item_type, data), timeout=0.5) + except queue.Full: + logger.warning( + f"Queue pleine — événement prioritaire droppé " + f"(type={item_type})" + ) + else: + # Heartbeat ou événement non-critique : on drop silencieusement + logger.debug( + f"Queue pleine — heartbeat/non-prioritaire droppé " + f"(type={item_type})" + ) + + def _is_priority_item(self, item_type: str, data) -> bool: + """Vérifie si un item est prioritaire (ne doit pas être droppé). + + Les images sont toujours prioritaires. Pour les événements, + on regarde le type d'événement (click, key, scroll, action). + """ + if item_type == "image": + return True + if item_type == "event" and isinstance(data, dict): + event_type = data.get("type", "").lower() + return event_type in PRIORITY_EVENT_TYPES + return False + + # ========================================================================= + # Boucle d'envoi + # ========================================================================= + + def _stream_loop(self): + """Boucle d'envoi asynchrone (thread daemon).""" + consecutive_failures = 0 + while self.running or not self.queue.empty(): + try: + item_type, data = self.queue.get(timeout=0.5) + success = False + if item_type == "event": + success = self._send_with_retry(self._send_event, data) + elif item_type == "image": + success = self._send_with_retry(self._send_image, *data) + self.queue.task_done() + + if success: + consecutive_failures = 0 + else: + consecutive_failures += 1 + if consecutive_failures >= 10: + logger.warning( + "10 échecs consécutifs — serveur marqué indisponible" + ) + self._server_available = False + consecutive_failures = 0 + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Erreur Streaming Loop: {e}") + + # ========================================================================= + # Retry avec backoff exponentiel + # ========================================================================= + + def _send_with_retry(self, send_fn, *args) -> bool: + """Tente l'envoi avec retry et backoff exponentiel. + + 3 tentatives max avec délais de 1s, 2s, 4s entre chaque. + Retourne True si l'envoi a réussi, False sinon. + """ + # Première tentative (sans délai) + if send_fn(*args): + return True + + # Retries avec backoff + for attempt, delay in enumerate(RETRY_DELAYS, start=1): + if not self.running: + # On arrête les retries si le streamer est en cours d'arrêt + break + logger.debug( + f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..." + ) + time.sleep(delay) + if send_fn(*args): + logger.debug(f"Retry {attempt} réussi") + return True + + logger.debug(f"Envoi échoué après {MAX_RETRIES} retries") + return False + + # ========================================================================= + # Health-check périodique pour recovery + # ========================================================================= + + def _health_check_loop(self): + """Vérifie périodiquement si le serveur est redevenu disponible. + + Toutes les 30s, tente un GET /stats. Si le serveur répond, + remet _server_available = True et ré-enregistre la session. + """ + while self.running: + time.sleep(HEALTH_CHECK_INTERVAL_S) + if not self.running: + break + if self._server_available: + # Serveur déjà disponible, rien à faire + continue + # Tenter un health-check + try: + resp = requests.get( + f"{STREAMING_ENDPOINT}/stats", + timeout=3, + ) + if resp.ok: + logger.info( + "Health-check OK — serveur redevenu disponible, " + "ré-enregistrement de la session" + ) + self._server_available = True + self._register_session() + except Exception: + logger.debug("Health-check échoué — serveur toujours indisponible") + + # ========================================================================= + # Compression JPEG + # ========================================================================= + + def _compress_image_to_jpeg(self, path: str) -> tuple: + """Compresse une image (PNG ou autre) en JPEG qualité 85 en mémoire. + + Retourne un tuple (bytes_io, content_type, filename_suffix). + Si la compression échoue, renvoie le fichier original en PNG. + """ + try: + img = Image.open(path) + # Convertir en RGB si nécessaire (JPEG ne supporte pas l'alpha) + if img.mode in ("RGBA", "LA", "P"): + img = img.convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True) + buf.seek(0) + return buf, "image/jpeg", ".jpg" + except FileNotFoundError: + # Fichier introuvable — propager l'erreur (pas de fallback possible) + logger.warning(f"Fichier image introuvable pour compression : {path}") + raise + except Exception as e: + logger.warning(f"Compression JPEG échouée, envoi PNG brut: {e}") + return None, None, None + + # ========================================================================= + # Envois HTTP + # ========================================================================= + + def _register_session(self): + """Enregistrer la session auprès du serveur (avec identifiant machine).""" + try: + resp = requests.post( + f"{STREAMING_ENDPOINT}/register", + params={ + "session_id": self.session_id, + "machine_id": self.machine_id, + }, + timeout=3, + ) + if resp.ok: + logger.info( + f"Session {self.session_id} enregistrée sur le serveur " + f"(machine={self.machine_id})" + ) + self._server_available = True + else: + logger.warning(f"Enregistrement session échoué: {resp.status_code}") + except Exception as e: + logger.debug(f"Serveur indisponible pour register: {e}") + self._server_available = False + + def _finalize_session(self): + """Finaliser la session (construction du workflow côté serveur). + + IMPORTANT : tente TOUJOURS l'envoi, indépendamment de _server_available. + C'est la dernière chance de sauver les données de la session. + """ + try: + resp = requests.post( + f"{STREAMING_ENDPOINT}/finalize", + params={ + "session_id": self.session_id, + "machine_id": self.machine_id, + }, + timeout=30, # Le build workflow peut prendre du temps + ) + if resp.ok: + result = resp.json() + logger.info(f"Session finalisée: {result}") + else: + logger.warning(f"Finalisation échouée: {resp.status_code}") + except Exception as e: + logger.debug(f"Finalisation échouée: {e}") + + def _send_event(self, event: dict) -> bool: + """Envoyer un événement au serveur (avec identifiant machine).""" + if not self._server_available: + return False + try: + payload = { + "session_id": self.session_id, + "timestamp": time.time(), + "event": event, + "machine_id": self.machine_id, + } + resp = requests.post( + f"{STREAMING_ENDPOINT}/event", + json=payload, + timeout=2, + ) + return resp.ok + except Exception as e: + logger.debug(f"Streaming Event échoué: {e}") + return False + + def _send_image(self, path: str, shot_id: str) -> bool: + """Envoyer un screenshot au serveur, compressé en JPEG. + + Utilise un context manager pour le fallback PNG afin d'éviter + les fuites de descripteurs de fichier. + """ + if not self._server_available: + return False + try: + # Tenter la compression JPEG (réduction ~5-10x vs PNG) + jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path) + + params = { + "session_id": self.session_id, + "shot_id": shot_id, + "machine_id": self.machine_id, + } + + if jpeg_buf is not None: + # Envoi du JPEG compressé (BytesIO, pas de fuite possible) + files = { + "file": (f"{shot_id}{suffix}", jpeg_buf, content_type) + } + resp = requests.post( + f"{STREAMING_ENDPOINT}/image", + files=files, + params=params, + timeout=5, + ) + return resp.ok + else: + # Fallback : envoi PNG original avec context manager + with open(path, "rb") as f: + files = { + "file": (f"{shot_id}.png", f, "image/png") + } + resp = requests.post( + f"{STREAMING_ENDPOINT}/image", + files=files, + params=params, + timeout=5, + ) + return resp.ok + except Exception as e: + logger.debug(f"Streaming Image échoué: {e}") + return False diff --git a/agent_v0/agent_v1/requirements.txt b/agent_v0/agent_v1/requirements.txt new file mode 100644 index 000000000..3e2dc0b9a --- /dev/null +++ b/agent_v0/agent_v1/requirements.txt @@ -0,0 +1,16 @@ +# agent_v1/requirements.txt +mss>=9.0.1 # Capture d'écran haute performance +pynput>=1.7.7 # Clavier/Souris Cross-plateforme +Pillow>=10.0.0 # Crops et processing image +requests>=2.31.0 # Streaming réseau +psutil>=5.9.0 # Monitoring CPU/RAM +pystray>=0.19.5 # Icône Tray UI +plyer>=2.1.0 # Notifications toast natives (remplace PyQt5) +pywebview>=5.0 # Fenêtre de chat Léa intégrée (Edge WebView2 sur Windows) + +# Windows spécifique +pywin32>=306 ; sys_platform == 'win32' + +# macOS spécifique +pyobjc-framework-Cocoa>=10.0 ; sys_platform == 'darwin' +pyobjc-framework-Quartz>=10.0 ; sys_platform == 'darwin' diff --git a/agent_v0/agent_v1/session/__init__.py b/agent_v0/agent_v1/session/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/session/storage.py b/agent_v0/agent_v1/session/storage.py new file mode 100644 index 000000000..ab2913a09 --- /dev/null +++ b/agent_v0/agent_v1/session/storage.py @@ -0,0 +1,65 @@ +# agent_v1/session/storage.py +""" +Gestionnaire de stockage local robuste pour Agent V1. +Gère le chiffrement des données au repos et l'auto-nettoyage du disque. +""" + +import os +import shutil +import time +import logging +from pathlib import Path +from datetime import datetime, timedelta + +logger = logging.getLogger("session_storage") + +class SessionStorage: + def __init__(self, base_dir: Path, max_size_gb: int = 5, retention_days: int = 1): + self.base_dir = base_dir + self.max_size_bytes = max_size_gb * 1024 * 1024 * 1024 + self.retention_days = retention_days + self.base_dir.mkdir(parents=True, exist_ok=True) + + def get_session_dir(self, session_id: str) -> Path: + """Retourne et crée le dossier pour une session.""" + session_path = self.base_dir / session_id + session_path.mkdir(exist_ok=True) + (session_path / "shots").mkdir(exist_ok=True) + return session_path + + def run_auto_cleanup(self): + """Lance le nettoyage automatique basé sur l'âge et la taille.""" + logger.info("🧹 Lancement du nettoyage automatique du stockage local...") + self._cleanup_by_age() + self._cleanup_by_size() + + def _cleanup_by_age(self): + """Supprime les sessions plus vieilles que retention_days.""" + threshold = datetime.now() - timedelta(days=self.retention_days) + for session_path in self.base_dir.iterdir(): + if session_path.is_dir(): + mtime = datetime.fromtimestamp(session_path.stat().st_mtime) + if mtime < threshold: + logger.info(f"🗑️ Purge session ancienne : {session_path.name}") + shutil.rmtree(session_path) + + def _cleanup_by_size(self): + """Supprime les sessions les plus anciennes si la taille totale dépasse max_size_bytes.""" + sessions = [] + total_size = 0 + for session_path in self.base_dir.iterdir(): + if session_path.is_dir(): + size = sum(f.stat().st_size for f in session_path.rglob('*') if f.is_file()) + sessions.append((session_path, session_path.stat().st_mtime, size)) + total_size += size + + if total_size > self.max_size_bytes: + logger.warning(f"⚠️ Stockage saturé ({total_size/1e9:.2f} GB). Purge nécessaire.") + # Trier par date de modif (plus ancien d'abord) + sessions.sort(key=lambda x: x[1]) + for path, _, size in sessions: + if total_size <= self.max_size_bytes * 0.8: # On libère jusqu'à 80% du max + break + logger.info(f"🗑️ Purge session pour libérer de l'espace : {path.name} ({size/1e6:.1f} MB)") + shutil.rmtree(path) + total_size -= size diff --git a/agent_v0/agent_v1/ui/__init__.py b/agent_v0/agent_v1/ui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/ui/chat_window.py b/agent_v0/agent_v1/ui/chat_window.py new file mode 100644 index 000000000..9594806a4 --- /dev/null +++ b/agent_v0/agent_v1/ui/chat_window.py @@ -0,0 +1,1127 @@ +# agent_v1/ui/chat_window.py +""" +Fenetre de chat Lea integree au systray — version tkinter native. + +Remplace l'approche Edge browser par une vraie fenetre tkinter integree. +Design professionnel, theme clair, ancree en bas a droite de l'ecran. +Tourne dans son propre thread daemon pour ne pas bloquer pystray. +""" + +import logging +import os +import threading +import time +from datetime import datetime +from typing import Any, Callable, Dict, Optional + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Theme — palette professionnelle claire +# --------------------------------------------------------------------------- +BG_COLOR = "#FAFBFC" # Fond principal +HEADER_BG = "#2563EB" # Bleu en-tete +HEADER_FG = "#FFFFFF" # Texte en-tete blanc +MSG_LEA_BG = "#EFF6FF" # Fond messages Lea (bleu clair) +MSG_USER_BG = "#E8F5E9" # Fond messages utilisateur (vert plus marque) +MSG_LEA_FG = "#1E40AF" # Texte Lea (bleu fonce) +MSG_USER_FG = "#1B5E20" # Texte utilisateur (vert plus fonce, meilleur contraste) +TIMESTAMP_FG = "#9CA3AF" # Horodatages gris +BTN_BG = "#3B82F6" # Boutons bleu +BTN_HOVER_BG = "#2563EB" # Boutons hover +BTN_FG = "#FFFFFF" # Texte boutons blanc +INPUT_BG = "#FFFFFF" # Champ de saisie blanc +INPUT_FG = "#1F2937" # Texte saisie noir +BORDER_COLOR = "#E5E7EB" # Bordures gris clair +STATUS_CONNECTED = "#22C55E" # Vert connecte +STATUS_DISCONNECTED = "#EF4444" # Rouge deconnecte +QUICK_BTN_BG = "#F3F4F6" # Fond boutons rapides +QUICK_BTN_FG = "#374151" # Texte boutons rapides +QUICK_BTN_HOVER = "#E5E7EB" # Hover boutons rapides +SCROLLBAR_BG = "#E5E7EB" # Fond scrollbar +SCROLLBAR_FG = "#9CA3AF" # Curseur scrollbar +MSG_BORDER_COLOR = "#D1D5DB" # Bordure subtile des bulles de messages + +# Dimensions — confortables +WIN_WIDTH = 600 +WIN_HEIGHT = 800 +MARGIN = 14 +MSG_WRAP_WIDTH = WIN_WIDTH - 90 + +# Tailles de police — bien lisibles +FONT_TITLE = ("Segoe UI", 15, "bold") +FONT_MSG = ("Segoe UI", 13) +FONT_MSG_ITALIC = ("Segoe UI", 12, "italic") +FONT_TIMESTAMP = ("Segoe UI", 10) +FONT_SYSTEM = ("Segoe UI", 10, "italic") +FONT_QUICK_BTN = ("Segoe UI", 11) +FONT_INPUT = ("Segoe UI", 13) +FONT_STATUS = ("Segoe UI", 10) +FONT_CLOSE_BTN = ("Segoe UI", 13) +FONT_SEND_BTN = ("Segoe UI", 13) +FONT_RESIZE_GRIP = ("Segoe UI", 10) + + +class ChatWindow: + """Fenetre de chat Lea en tkinter natif. + + Tourne dans un thread daemon independant. Thread-safe via root.after(). + Interface compatible avec l'ancien ChatWindow (toggle, show, hide, destroy). + """ + + def __init__( + self, + server_client: Optional[Any] = None, + on_start_callback: Optional[Callable[[str], None]] = None, + server_host: str = "localhost", + chat_port: int = 5004, + shared_state: Optional[Any] = None, + ) -> None: + self._server_client = server_client + self._on_start_callback = on_start_callback + self._server_host = server_host + self._chat_port = chat_port + + # Etat partage avec le systray (source de verite unique) + self._shared_state = shared_state + + # Etat + self._visible = False + self._destroyed = False + self._root = None + self._ready = threading.Event() + self._messages = [] # historique local + + # S'abonner aux changements de l'etat partage + if self._shared_state is not None: + self._shared_state.on_change(self._on_shared_state_change) + + # Demarrer tkinter dans un thread daemon + self._thread = threading.Thread( + target=self._run_tk_loop, + daemon=True, + name="chat-window-tk", + ) + self._thread.start() + + # Attendre que la fenetre soit prete (max 5s) + self._ready.wait(timeout=5.0) + logger.info("ChatWindow tkinter initialisee") + + # ====================================================================== + # Interface publique (thread-safe) + # ====================================================================== + + def toggle(self) -> None: + """Afficher/masquer la fenetre de chat.""" + if self._destroyed or self._root is None: + return + if self._visible: + self.hide() + else: + self.show() + + def show(self) -> None: + """Afficher la fenetre.""" + if self._destroyed or self._root is None: + return + self._root.after(0, self._do_show) + + def hide(self) -> None: + """Masquer la fenetre (sans la detruire).""" + if self._destroyed or self._root is None: + return + self._root.after(0, self._do_hide) + + def is_visible(self) -> bool: + """Verifie si la fenetre est affichee.""" + return self._visible + + def destroy(self) -> None: + """Fermer definitivement la fenetre et arreter le thread.""" + if self._destroyed: + return + self._destroyed = True + if self._root is not None: + try: + self._root.after(0, self._do_destroy) + except Exception: + pass + + def update_server_client(self, server_client: Any) -> None: + """Mettre a jour le client serveur (appele si cree apres la fenetre).""" + self._server_client = server_client + + def _on_shared_state_change(self, state) -> None: + """Callback appele quand l'etat partage change (depuis le systray ou ailleurs). + + Affiche un message dans le chat si un enregistrement demarre ou s'arrete + depuis le systray. + """ + if self._root is None or self._destroyed: + return + + # Detecter la transition enregistrement demarre (depuis le systray) + if state.is_recording and not getattr(self, "_last_known_recording", False): + name = state.recording_name + self._add_lea_message( + f"Enregistrement en cours : \u00ab {name} \u00bb\n" + "Montrez-moi les \u00e9tapes, j'observe !" + ) + + # Detecter la transition enregistrement arrete (depuis le systray) + if not state.is_recording and getattr(self, "_last_known_recording", False): + count = state.actions_count + self._add_lea_message( + f"C'est not\u00e9 ! J'ai m\u00e9moris\u00e9 {count} actions." + ) + + # Detecter la transition replay + if state.is_replay_active and not getattr(self, "_last_known_replay", False): + self._add_lea_message("Replay en cours...") + + if not state.is_replay_active and getattr(self, "_last_known_replay", False): + self._add_lea_message("Replay termin\u00e9. C'est fait !") + + # Memoriser l'etat pour detecter les transitions + self._last_known_recording = state.is_recording + self._last_known_replay = state.is_replay_active + + # ====================================================================== + # Construction de la fenetre (thread tkinter) + # ====================================================================== + + def _run_tk_loop(self) -> None: + """Boucle principale tkinter dans un thread daemon.""" + try: + # Activer le DPI awareness sur Windows AVANT de créer Tk() + # Sans ça, tkinter rend tout minuscule sur les écrans haute résolution + try: + import ctypes + ctypes.windll.shcore.SetProcessDpiAwareness(1) + except Exception: + pass + + import tkinter as tk + from tkinter import font as tkfont + + self._tk = tk + root = tk.Tk() + self._root = root + + # Appliquer le scaling DPI de Windows à tkinter + try: + dpi = root.winfo_fpixels('1i') + scale_factor = dpi / 72.0 + root.tk.call('tk', 'scaling', scale_factor) + except Exception: + pass + + # Fenetre avec barre de titre native (redimensionnable par l'utilisateur) + root.title("Léa — Assistante") + root.configure(bg=BG_COLOR) + root.attributes('-topmost', True) + root.minsize(400, 500) + + # Taille et position (bas-droite de l'ecran) + screen_w = root.winfo_screenwidth() + screen_h = root.winfo_screenheight() + x = screen_w - WIN_WIDTH - 12 + y = screen_h - WIN_HEIGHT - 50 + root.geometry(f"{WIN_WIDTH}x{WIN_HEIGHT}+{x}+{y}") + + # Intercepter la fermeture (X) pour masquer au lieu de fermer + root.protocol("WM_DELETE_WINDOW", self._do_hide) + + # Taille minimum pour eviter une fenetre trop petite + root.minsize(350, 450) + + # Ombre / bordure simulee + root.configure(highlightbackground="#CBD5E1", highlightthickness=1) + + # Construire les widgets + self._build_header(root) + self._build_status_bar(root) + self._build_messages_area(root) + self._build_quick_actions(root) + self._build_input_area(root) + self._build_resize_grip(root) + + # Message d'accueil + self._add_lea_message( + "Bonjour ! Je suis L\u00e9a.\n" + "Je peux apprendre vos t\u00e2ches r\u00e9p\u00e9titives " + "et les refaire \u00e0 votre place.\n" + "Que puis-je faire pour vous ?" + ) + + # Demarrer masquee + root.withdraw() + self._visible = False + + # Verification connexion periodique + self._check_connection_status() + + # Signaler que la fenetre est prete + self._ready.set() + + # Boucle tkinter + root.mainloop() + + except Exception as e: + logger.error("Erreur initialisation ChatWindow tkinter : %s", e) + self._ready.set() + + def _build_header(self, root) -> None: + """Barre de titre personnalisee (draggable).""" + tk = self._tk + + header = tk.Frame(root, bg=HEADER_BG, height=44) + header.pack(fill=tk.X, side=tk.TOP) + header.pack_propagate(False) + + # Titre — police agrandie + title_label = tk.Label( + header, + text="L\u00e9a \u2014 Assistante", + bg=HEADER_BG, + fg=HEADER_FG, + font=FONT_TITLE, + anchor="w", + padx=12, + ) + title_label.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Bouton fermer + close_btn = tk.Label( + header, + text="\u2715", + bg=HEADER_BG, + fg=HEADER_FG, + font=FONT_CLOSE_BTN, + padx=14, + cursor="hand2", + ) + close_btn.pack(side=tk.RIGHT) + close_btn.bind("", lambda e: self.hide()) + close_btn.bind("", lambda e: close_btn.configure(bg="#DC2626")) + close_btn.bind("", lambda e: close_btn.configure(bg=HEADER_BG)) + + # Drag support (deplacer la fenetre) + self._drag_data = {"x": 0, "y": 0} + header.bind("", self._on_drag_start) + header.bind("", self._on_drag_motion) + title_label.bind("", self._on_drag_start) + title_label.bind("", self._on_drag_motion) + + def _build_status_bar(self, root) -> None: + """Barre de statut avec indicateur de connexion.""" + tk = self._tk + + status_frame = tk.Frame(root, bg="#F8FAFC", height=26) + status_frame.pack(fill=tk.X, side=tk.TOP) + status_frame.pack_propagate(False) + + # Separateur + tk.Frame(root, bg=BORDER_COLOR, height=1).pack(fill=tk.X, side=tk.TOP) + + # Indicateur de connexion (point colore) + self._status_dot = tk.Label( + status_frame, + text="\u25CF", + fg=STATUS_DISCONNECTED, + bg="#F8FAFC", + font=FONT_STATUS, + padx=8, + ) + self._status_dot.pack(side=tk.LEFT) + + self._status_label = tk.Label( + status_frame, + text="D\u00e9connect\u00e9e", + bg="#F8FAFC", + fg=TIMESTAMP_FG, + font=FONT_STATUS, + ) + self._status_label.pack(side=tk.LEFT) + + def _build_messages_area(self, root) -> None: + """Zone de messages scrollable.""" + tk = self._tk + + # Conteneur avec scrollbar + msg_container = tk.Frame(root, bg=BG_COLOR) + msg_container.pack(fill=tk.BOTH, expand=True, side=tk.TOP) + + # Canvas + Scrollbar pour le scroll + self._canvas = tk.Canvas( + msg_container, + bg=BG_COLOR, + highlightthickness=0, + bd=0, + ) + + scrollbar = tk.Scrollbar( + msg_container, + orient=tk.VERTICAL, + command=self._canvas.yview, + bg=SCROLLBAR_BG, + troughcolor=BG_COLOR, + width=10, + ) + + self._canvas.configure(yscrollcommand=scrollbar.set) + + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self._canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # Frame interne scrollable + self._msg_frame = tk.Frame(self._canvas, bg=BG_COLOR) + self._msg_frame_id = self._canvas.create_window( + (0, 0), + window=self._msg_frame, + anchor=tk.NW, + width=WIN_WIDTH - 22, # largeur moins scrollbar + ) + + # Bindings pour le scroll + self._msg_frame.bind( + "", + lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all")), + ) + self._canvas.bind( + "", + lambda e: self._canvas.itemconfig(self._msg_frame_id, width=e.width), + ) + # Molette souris + self._canvas.bind_all( + "", + lambda e: self._canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"), + ) + + def _build_quick_actions(self, root) -> None: + """Barre de boutons d'actions rapides.""" + tk = self._tk + + # Separateur + tk.Frame(root, bg=BORDER_COLOR, height=1).pack(fill=tk.X, side=tk.TOP) + + actions_frame = tk.Frame(root, bg="#F8FAFC", height=48) + actions_frame.pack(fill=tk.X, side=tk.TOP, padx=0, pady=0) + actions_frame.pack_propagate(False) + + buttons = [ + ("\U0001f393 Apprenez-moi", self._on_quick_record), + ("\u25b6\ufe0f Lancer", self._on_quick_tasks), + ("\U0001f4ca Donn\u00e9es", self._on_quick_import), + ("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop), + ("\u2753 Aide", self._on_quick_help), + ] + + for text, cmd in buttons: + btn = tk.Label( + actions_frame, + text=text, + bg=QUICK_BTN_BG, + fg=QUICK_BTN_FG, + font=FONT_QUICK_BTN, + padx=8, + pady=5, + cursor="hand2", + relief=tk.FLAT, + ) + btn.pack(side=tk.LEFT, padx=4, pady=5) + btn.bind("", lambda e, c=cmd: c()) + btn.bind("", lambda e, b=btn: b.configure(bg=QUICK_BTN_HOVER)) + btn.bind("", lambda e, b=btn: b.configure(bg=QUICK_BTN_BG)) + + # Separateur + tk.Frame(root, bg=BORDER_COLOR, height=1).pack(fill=tk.X, side=tk.TOP) + + def _build_input_area(self, root) -> None: + """Zone de saisie avec bouton envoyer.""" + tk = self._tk + + input_frame = tk.Frame(root, bg=BG_COLOR, height=48) + input_frame.pack(fill=tk.X, side=tk.BOTTOM, padx=MARGIN, pady=(MARGIN, 4)) + input_frame.pack_propagate(False) + + # Bouton joindre un fichier (📎) + attach_btn = tk.Label( + input_frame, + text="\U0001f4ce", + bg=BG_COLOR, + fg=BTN_BG, + font=FONT_SEND_BTN, + padx=6, + pady=3, + cursor="hand2", + ) + attach_btn.pack(side=tk.LEFT, padx=(0, 4)) + attach_btn.bind("", lambda e: self._on_attach_file()) + attach_btn.bind("", lambda e: attach_btn.configure(fg=BTN_HOVER_BG)) + attach_btn.bind("", lambda e: attach_btn.configure(fg=BTN_BG)) + + # Champ de saisie — police agrandie + self._input_var = tk.StringVar() + self._input_entry = tk.Entry( + input_frame, + textvariable=self._input_var, + bg=INPUT_BG, + fg=INPUT_FG, + font=FONT_INPUT, + relief=tk.FLAT, + bd=0, + highlightthickness=1, + highlightbackground=BORDER_COLOR, + highlightcolor=BTN_BG, + insertbackground=INPUT_FG, + ) + self._input_entry.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 8)) + self._input_entry.insert(0, "") + self._input_entry.bind("", lambda e: self._on_send()) + + # Placeholder + self._input_entry.insert(0, "Votre message...") + self._input_entry.configure(fg=TIMESTAMP_FG) + self._input_entry.bind("", self._on_input_focus_in) + self._input_entry.bind("", self._on_input_focus_out) + + # Bouton envoyer + send_btn = tk.Label( + input_frame, + text="\u27A4", + bg=BTN_BG, + fg=BTN_FG, + font=FONT_SEND_BTN, + padx=12, + pady=3, + cursor="hand2", + ) + send_btn.pack(side=tk.RIGHT) + send_btn.bind("", lambda e: self._on_send()) + send_btn.bind("", lambda e: send_btn.configure(bg=BTN_HOVER_BG)) + send_btn.bind("", lambda e: send_btn.configure(bg=BTN_BG)) + + def _build_resize_grip(self, root) -> None: + """Poignee de redimensionnement en bas a droite de la fenetre.""" + tk = self._tk + + grip_frame = tk.Frame(root, bg=BG_COLOR, height=16) + grip_frame.pack(fill=tk.X, side=tk.BOTTOM) + + # Symbole de grip aligne a droite + self._resize_grip = tk.Label( + grip_frame, + text="\u25E2", # triangle bas-droite + bg=BG_COLOR, + fg="#B0B8C4", + font=FONT_RESIZE_GRIP, + cursor="bottom_right_corner", + padx=4, + ) + self._resize_grip.pack(side=tk.RIGHT, anchor=tk.SE) + + # Donnees de resize + self._resize_data = {"x": 0, "y": 0, "w": 0, "h": 0} + self._resize_grip.bind("", self._on_resize_start) + self._resize_grip.bind("", self._on_resize_motion) + + # ====================================================================== + # Placeholder input + # ====================================================================== + + def _on_input_focus_in(self, event) -> None: + """Retire le placeholder quand le champ prend le focus.""" + if self._input_entry.get() == "Votre message...": + self._input_entry.delete(0, self._tk.END) + self._input_entry.configure(fg=INPUT_FG) + + def _on_input_focus_out(self, event) -> None: + """Remet le placeholder si le champ est vide.""" + if not self._input_entry.get().strip(): + self._input_entry.delete(0, self._tk.END) + self._input_entry.insert(0, "Votre message...") + self._input_entry.configure(fg=TIMESTAMP_FG) + + # ====================================================================== + # Drag (deplacer la fenetre) + # ====================================================================== + + def _on_drag_start(self, event) -> None: + self._drag_data["x"] = event.x + self._drag_data["y"] = event.y + + def _on_drag_motion(self, event) -> None: + if self._root is None: + return + dx = event.x - self._drag_data["x"] + dy = event.y - self._drag_data["y"] + x = self._root.winfo_x() + dx + y = self._root.winfo_y() + dy + self._root.geometry(f"+{x}+{y}") + + # ====================================================================== + # Resize (redimensionner la fenetre via le grip) + # ====================================================================== + + def _on_resize_start(self, event) -> None: + """Debut du redimensionnement — memorise la position et la taille.""" + if self._root is None: + return + self._resize_data["x"] = event.x_root + self._resize_data["y"] = event.y_root + self._resize_data["w"] = self._root.winfo_width() + self._resize_data["h"] = self._root.winfo_height() + + def _on_resize_motion(self, event) -> None: + """Redimensionne la fenetre en suivant le curseur.""" + if self._root is None: + return + dx = event.x_root - self._resize_data["x"] + dy = event.y_root - self._resize_data["y"] + new_w = max(350, self._resize_data["w"] + dx) + new_h = max(450, self._resize_data["h"] + dy) + x = self._root.winfo_x() + y = self._root.winfo_y() + self._root.geometry(f"{new_w}x{new_h}+{x}+{y}") + + # ====================================================================== + # Actions (thread tkinter) + # ====================================================================== + + def _do_show(self) -> None: + """Affiche la fenetre (appele dans le thread tkinter).""" + if self._root is None: + return + self._root.deiconify() + self._root.lift() + self._root.focus_force() + self._input_entry.focus_set() + self._visible = True + + def _do_hide(self) -> None: + """Masque la fenetre (appele dans le thread tkinter).""" + if self._root is None: + return + self._root.withdraw() + self._visible = False + + def _do_destroy(self) -> None: + """Detruit la fenetre (appele dans le thread tkinter).""" + if self._root is not None: + try: + self._root.quit() + self._root.destroy() + except Exception: + pass + self._root = None + self._visible = False + + # ====================================================================== + # Ajout de messages dans la zone de chat + # ====================================================================== + + def _add_lea_message(self, text: str) -> None: + """Ajoute un message de Lea (cote gauche, fond bleu).""" + if self._root is None: + return + self._root.after(0, lambda: self._render_message( + sender="lea", + text=text, + bg=MSG_LEA_BG, + fg=MSG_LEA_FG, + prefix="\U0001f4ac ", + )) + + def _add_user_message(self, text: str) -> None: + """Ajoute un message utilisateur (cote droit, fond vert).""" + if self._root is None: + return + self._root.after(0, lambda: self._render_message( + sender="user", + text=text, + bg=MSG_USER_BG, + fg=MSG_USER_FG, + prefix="", + )) + + def _add_system_message(self, text: str) -> None: + """Ajoute un message systeme (centre, gris).""" + if self._root is None: + return + self._root.after(0, lambda: self._render_message( + sender="system", + text=text, + bg=BG_COLOR, + fg=TIMESTAMP_FG, + prefix="", + )) + + def _render_message( + self, sender: str, text: str, bg: str, fg: str, prefix: str + ) -> None: + """Rendu d'un message dans la zone de chat (thread tkinter).""" + tk = self._tk + if self._msg_frame is None: + return + + now = datetime.now().strftime("%H:%M") + + # Conteneur du message — espacement vertical accru + msg_container = tk.Frame(self._msg_frame, bg=BG_COLOR) + msg_container.pack(fill=tk.X, padx=MARGIN, pady=4) + + if sender == "system": + # Message systeme centre + label = tk.Label( + msg_container, + text=text, + bg=bg, + fg=fg, + font=FONT_SYSTEM, + wraplength=MSG_WRAP_WIDTH, + ) + label.pack(anchor=tk.CENTER) + elif sender == "user": + # Message utilisateur a droite — bulle avec bordure subtile + inner = tk.Frame( + msg_container, + bg=bg, + padx=12, + pady=8, + highlightbackground=MSG_BORDER_COLOR, + highlightthickness=1, + ) + inner.pack(anchor=tk.E, padx=(50, 0)) + + # Horodatage + tk.Label( + inner, + text=f"Vous \u2022 {now}", + bg=bg, + fg=TIMESTAMP_FG, + font=FONT_TIMESTAMP, + anchor=tk.E, + ).pack(fill=tk.X, anchor=tk.E) + + # Texte — police agrandie + tk.Label( + inner, + text=text, + bg=bg, + fg=fg, + font=FONT_MSG, + wraplength=MSG_WRAP_WIDTH - 40, + justify=tk.LEFT, + anchor=tk.W, + ).pack(fill=tk.X, anchor=tk.W, pady=(3, 0)) + else: + # Message Lea a gauche — bulle avec bordure subtile + inner = tk.Frame( + msg_container, + bg=bg, + padx=12, + pady=8, + highlightbackground=MSG_BORDER_COLOR, + highlightthickness=1, + ) + inner.pack(anchor=tk.W, padx=(0, 50)) + + # Horodatage + tk.Label( + inner, + text=f"L\u00e9a \u2022 {now}", + bg=bg, + fg=TIMESTAMP_FG, + font=FONT_TIMESTAMP, + anchor=tk.W, + ).pack(fill=tk.X, anchor=tk.W) + + # Texte — police agrandie + tk.Label( + inner, + text=f"{prefix}{text}", + bg=bg, + fg=fg, + font=FONT_MSG, + wraplength=MSG_WRAP_WIDTH - 40, + justify=tk.LEFT, + anchor=tk.W, + ).pack(fill=tk.X, anchor=tk.W, pady=(3, 0)) + + # Stocker dans l'historique + self._messages.append({"sender": sender, "text": text, "time": now}) + + # Scroll vers le bas + self._scroll_to_bottom() + + def _scroll_to_bottom(self) -> None: + """Scrolle la zone de messages vers le bas.""" + if self._canvas is not None: + self._canvas.update_idletasks() + self._canvas.yview_moveto(1.0) + + def _show_typing_indicator(self) -> None: + """Affiche un indicateur 'Lea reflechit...'.""" + if self._root is None: + return + self._root.after(0, self._do_show_typing) + + def _do_show_typing(self) -> None: + """Affiche l'indicateur dans le thread tkinter.""" + tk = self._tk + if self._msg_frame is None: + return + + self._typing_frame = tk.Frame(self._msg_frame, bg=BG_COLOR) + self._typing_frame.pack(fill=tk.X, padx=MARGIN, pady=4) + + inner = tk.Frame( + self._typing_frame, + bg=MSG_LEA_BG, + padx=12, + pady=8, + highlightbackground=MSG_BORDER_COLOR, + highlightthickness=1, + ) + inner.pack(anchor=tk.W, padx=(0, 50)) + + tk.Label( + inner, + text="L\u00e9a r\u00e9fl\u00e9chit...", + bg=MSG_LEA_BG, + fg=TIMESTAMP_FG, + font=FONT_MSG_ITALIC, + ).pack(anchor=tk.W) + + self._scroll_to_bottom() + + def _remove_typing_indicator(self) -> None: + """Retire l'indicateur de frappe.""" + if self._root is None: + return + self._root.after(0, self._do_remove_typing) + + def _do_remove_typing(self) -> None: + """Retire l'indicateur dans le thread tkinter.""" + if hasattr(self, "_typing_frame") and self._typing_frame is not None: + try: + self._typing_frame.destroy() + except Exception: + pass + self._typing_frame = None + + # ====================================================================== + # Envoi de messages + # ====================================================================== + + def _on_send(self) -> None: + """Envoie le message saisi par l'utilisateur.""" + text = self._input_var.get().strip() + if not text or text == "Votre message...": + return + + # Vider le champ + self._input_var.set("") + self._input_entry.configure(fg=INPUT_FG) + + # Afficher le message utilisateur + self._add_user_message(text) + + # Envoyer au serveur dans un thread + threading.Thread( + target=self._send_to_server, + args=(text,), + daemon=True, + ).start() + + def _send_to_server(self, message: str) -> None: + """Envoie le message au serveur et affiche la reponse (thread reseau).""" + self._show_typing_indicator() + + try: + if self._server_client is None: + self._remove_typing_indicator() + self._add_lea_message( + "Je ne suis pas encore connect\u00e9e au serveur.\n" + "V\u00e9rifiez la connexion r\u00e9seau." + ) + return + + response = self._server_client.send_chat_message(message) + + self._remove_typing_indicator() + + if response is None: + self._add_lea_message( + "\u26a0 Connexion perdue. Impossible de joindre le serveur." + ) + return + + # Extraire le message de la reponse + resp = response.get("response", {}) + if isinstance(resp, dict): + text = resp.get("message", str(resp)) + else: + text = str(resp) + + if text: + self._add_lea_message(text) + + # Traiter les suggestions + suggestions = [] + if isinstance(resp, dict): + suggestions = resp.get("suggestions", []) + if suggestions: + suggestions_text = "\n".join( + f"\u2022 {s}" for s in suggestions + ) + self._add_system_message(f"Suggestions :\n{suggestions_text}") + + except Exception as e: + self._remove_typing_indicator() + logger.error("Erreur envoi message : %s", e) + self._add_lea_message( + f"\u26a0 Erreur : {e}" + ) + + # ====================================================================== + # Actions rapides + # ====================================================================== + + def _on_quick_record(self) -> None: + """Bouton Apprenez-moi — lance une session d'enregistrement.""" + # Verifier si un enregistrement est deja en cours + if self._shared_state is not None and self._shared_state.is_recording: + self._add_lea_message( + "Un enregistrement est d\u00e9j\u00e0 en cours.\n" + "Cliquez \u00ab Arr\u00eater \u00bb pour terminer d'abord." + ) + return + + can_record = ( + self._shared_state is not None + or self._on_start_callback is not None + ) + if can_record: + self._add_system_message("Pr\u00e9paration de l'apprentissage...") + # Demander le nom dans un thread (dialogue tkinter) + threading.Thread(target=self._do_quick_record, daemon=True).start() + else: + self._add_lea_message( + "L'apprentissage n'est pas disponible pour le moment. " + "Essayez depuis le menu de la barre des t\u00e2ches." + ) + + def _do_quick_record(self) -> None: + """Demande le nom de la t\u00e2che et lance l'enregistrement.""" + import tkinter as tk + from tkinter import simpledialog + + # Creer un dialogue ephemere + tmp_root = tk.Tk() + tmp_root.withdraw() + tmp_root.attributes('-topmost', True) + name = simpledialog.askstring( + "Nouvelle t\u00e2che", + "D\u00e9crivez cette t\u00e2che en quelques mots :", + initialvalue="", + parent=tmp_root, + ) + tmp_root.destroy() + + if name and name.strip(): + name = name.strip() + self._add_lea_message( + f"C'est parti ! Montrez-moi comment faire \u00ab {name} \u00bb." + ) + try: + # Utiliser l'etat partage si disponible (synchronise le systray) + if self._shared_state is not None: + self._shared_state.start_recording(name) + elif self._on_start_callback is not None: + self._on_start_callback(name) + except Exception as e: + self._add_lea_message(f"Oups, un probl\u00e8me : {e}") + + def _on_quick_tasks(self) -> None: + """Bouton Lancer — demande ce que L\u00e9a sait faire.""" + self._add_user_message("Qu'est-ce que vous savez faire ?") + threading.Thread( + target=self._send_to_server, + args=("qu'est-ce que tu sais faire ?",), + daemon=True, + ).start() + + def _on_quick_import(self) -> None: + """Bouton Donn\u00e9es — affiche les tables / imports.""" + self._add_user_message("Montrez-moi les donn\u00e9es") + threading.Thread( + target=self._send_to_server, + args=("montre les tables",), + daemon=True, + ).start() + + def _on_quick_stop(self) -> None: + """Bouton Arr\u00eater — arr\u00eate l'enregistrement ou le replay en cours. + + Verifie l'etat partage et agit en consequence : + 1. Si enregistrement en cours → arrete via shared_state + 2. Si replay en cours → arrete via shared_state + 3. Sinon → informe l'utilisateur qu'il n'y a rien a arreter + """ + if self._shared_state is not None: + if self._shared_state.is_recording: + name = self._shared_state.recording_name + count = self._shared_state.actions_count + self._shared_state.stop_recording() + self._add_lea_message( + f"Enregistrement arr\u00eat\u00e9.\n" + f"J'ai m\u00e9moris\u00e9 {count} actions pour " + f"\u00ab {name} \u00bb. C'est not\u00e9 !" + ) + return + + if self._shared_state.is_replay_active: + self._shared_state.set_replay_active(False) + self._add_lea_message("Replay arr\u00eat\u00e9.") + return + + # Rien a arreter + self._add_lea_message( + "Il n'y a rien en cours \u00e0 arr\u00eater.\n" + "Cliquez \u00ab Apprenez-moi \u00bb pour d\u00e9marrer." + ) + + def _on_quick_help(self) -> None: + """Bouton Aide — demande l'aide.""" + self._add_user_message("Aide") + threading.Thread( + target=self._send_to_server, + args=("aide",), + daemon=True, + ).start() + + # ====================================================================== + # Envoi de fichier + # ====================================================================== + + def _on_attach_file(self) -> None: + """Ouvre un dialogue pour selectionner un fichier a envoyer.""" + from tkinter import filedialog + + filepath = filedialog.askopenfilename( + title="Choisir un fichier", + filetypes=[ + ("Tableurs", "*.xlsx *.xls *.csv"), + ("PDF", "*.pdf"), + ("Tous", "*.*"), + ], + parent=self._root, + ) + if filepath: + self._add_user_message(f"\U0001f4ce {os.path.basename(filepath)}") + threading.Thread( + target=self._upload_file, + args=(filepath,), + daemon=True, + ).start() + + def _upload_file(self, filepath: str) -> None: + """Envoie le fichier au serveur et affiche la reponse (thread reseau).""" + self._show_typing_indicator() + + try: + if self._server_client is None: + self._remove_typing_indicator() + self._add_lea_message( + "Je ne suis pas encore connect\u00e9e au serveur.\n" + "V\u00e9rifiez la connexion r\u00e9seau." + ) + return + + import requests + + # Determiner l'URL de base du serveur chat + host = self._server_host + port = self._chat_port + url = f"http://{host}:{port}/api/chat/upload" + + filename = os.path.basename(filepath) + + with open(filepath, "rb") as f: + files = {"file": (filename, f)} + resp = requests.post(url, files=files, timeout=60) + + self._remove_typing_indicator() + + if resp.ok: + data = resp.json() + # Construire un message de reponse lisible + msg_parts = [] + if data.get("filename"): + msg_parts.append(f"Fichier **{data['filename']}** re\u00e7u !") + + preview = data.get("preview", {}) + if preview: + rows = preview.get("total_rows", "?") + cols = preview.get("columns", []) + cols_str = ", ".join(cols[:8]) + if len(cols) > 8: + cols_str += f"... (+{len(cols) - 8})" + msg_parts.append(f"{rows} lignes, colonnes : {cols_str}") + + table_name = data.get("table_name", "") + if table_name: + msg_parts.append( + f"Je cr\u00e9e la table '{table_name}' ?" + ) + + if data.get("message"): + msg_parts.append(str(data["message"])) + + message = "\n".join(msg_parts) if msg_parts else "Fichier envoy\u00e9 !" + self._add_lea_message(message) + else: + error = "Erreur serveur" + try: + error = resp.json().get("error", error) + except Exception: + pass + self._add_lea_message(f"\u26a0 {error}") + + except Exception as e: + self._remove_typing_indicator() + logger.error("Erreur upload fichier : %s", e) + self._add_lea_message(f"\u26a0 Erreur d'envoi : {e}") + + # ====================================================================== + # Verification de la connexion + # ====================================================================== + + def _check_connection_status(self) -> None: + """Verifie l'etat de connexion et met a jour l'indicateur.""" + if self._destroyed or self._root is None: + return + + try: + connected = False + if self._server_client is not None: + connected = getattr(self._server_client, "connected", False) + + if connected: + self._status_dot.configure(fg=STATUS_CONNECTED) + self._status_label.configure(text="Connect\u00e9e") + else: + self._status_dot.configure(fg=STATUS_DISCONNECTED) + self._status_label.configure(text="D\u00e9connect\u00e9e") + except Exception: + pass + + # Reverifier toutes les 10 secondes + if not self._destroyed and self._root is not None: + try: + self._root.after(10000, self._check_connection_status) + except Exception: + pass diff --git a/agent_v0/agent_v1/ui/notifications.py b/agent_v0/agent_v1/ui/notifications.py new file mode 100644 index 000000000..11d8149d0 --- /dev/null +++ b/agent_v0/agent_v1/ui/notifications.py @@ -0,0 +1,206 @@ +# agent_v1/ui/notifications.py +""" +Gestionnaire de notifications toast natives (Windows/Linux/macOS). +Utilise plyer pour les notifications système, sans dépendance PyQt5. + +Remplace les dialogues Qt par des toasts non-bloquants. +Thread-safe avec rate limiting (1 notification / 2 secondes max). +""" + +import logging +import threading +import time +from typing import Optional + +logger = logging.getLogger(__name__) + +# Import conditionnel de plyer — fallback silencieux si absent +try: + from plyer import notification as _plyer_notification + _PLYER_AVAILABLE = True +except ImportError: + _plyer_notification = None + _PLYER_AVAILABLE = False + logger.warning( + "plyer non installé — les notifications toast sont désactivées. " + "Installer avec : pip install plyer" + ) + +# Nom de l'application affiché dans les toasts +APP_NAME = "Léa" + +# Intervalle minimum entre deux notifications (secondes) +RATE_LIMIT_SECONDS = 2 + + +class NotificationManager: + """ + Gestionnaire centralisé de notifications toast. + + Thread-safe : peut être appelé depuis n'importe quel thread. + Rate limiting : une seule notification toutes les 2 secondes, + les notifications excédentaires sont ignorées (pas de file d'attente + pour éviter un flood différé). + """ + + def __init__(self, icon_path: Optional[str] = None): + """ + Initialise le gestionnaire. + + Args: + icon_path: Chemin vers l'icône (.ico/.png) pour les toasts. + None = icône par défaut du système. + """ + self._icon_path = icon_path + self._lock = threading.Lock() + self._last_notification_time: float = 0.0 + + # ------------------------------------------------------------------ # + # Méthode générique + # ------------------------------------------------------------------ # + + def notify(self, title: str, message: str, timeout: int = 5) -> bool: + """ + Affiche une notification toast. + + Args: + title: Titre de la notification. + message: Corps du message. + timeout: Durée d'affichage en secondes. + + Returns: + True si la notification a été envoyée, False sinon + (plyer absent ou rate limit atteint). + """ + if not _PLYER_AVAILABLE: + logger.debug("Notification ignorée (plyer absent) : %s", title) + return False + + with self._lock: + now = time.monotonic() + elapsed = now - self._last_notification_time + if elapsed < RATE_LIMIT_SECONDS: + logger.debug( + "Notification ignorée (rate limit, %.1fs restantes) : %s", + RATE_LIMIT_SECONDS - elapsed, + title, + ) + return False + self._last_notification_time = now + + # Envoi dans un thread dédié pour ne jamais bloquer l'appelant + thread = threading.Thread( + target=self._send, + args=(title, message, timeout), + daemon=True, + ) + thread.start() + return True + + def _send(self, title: str, message: str, timeout: int) -> None: + """Envoi effectif de la notification (exécuté dans un thread dédié).""" + try: + # Windows limite les balloon tips à 256 caractères + if len(title) > 63: + title = title[:60] + "..." + if len(message) > 200: + message = message[:197] + "..." + _plyer_notification.notify( + title=title, + message=message, + app_name=APP_NAME, + app_icon=self._icon_path, + timeout=timeout, + ) + except Exception: + logger.exception("Erreur lors de l'envoi de la notification toast") + + # ------------------------------------------------------------------ # + # Méthodes métier + # ------------------------------------------------------------------ # + + def greet(self) -> bool: + """Notification de bienvenue au démarrage.""" + return self.notify( + title=APP_NAME, + message="Bonjour ! Léa est prête.", + timeout=5, + ) + + def session_started(self, workflow_name: str) -> bool: + """Notification de début de session.""" + return self.notify( + title=APP_NAME, + message="C'est parti ! Je regarde et je mémorise.", + timeout=5, + ) + + def session_ended(self, action_count: int) -> bool: + """Notification de fin de session avec le nombre d'actions.""" + return self.notify( + title=APP_NAME, + message=f"C'est noté ! J'ai bien compris les {action_count} étapes.", + timeout=5, + ) + + def workflow_learned(self, name: str) -> bool: + """Notification quand une tâche a été apprise.""" + return self.notify( + title=APP_NAME, + message=f"J'ai appris '{name}' ! Je peux la refaire quand vous voulez.", + timeout=7, + ) + + def replay_started(self, workflow_name: str, step_count: int) -> bool: + """Notification de début de replay.""" + return self.notify( + title=APP_NAME, + message=f"Je m'en occupe ! '{workflow_name}' en cours...", + timeout=5, + ) + + def replay_step(self, current: int, total: int, description: str) -> bool: + """Notification de progression d'une étape de replay.""" + return self.notify( + title=APP_NAME, + message=f"Étape {current}/{total} : {description}", + timeout=3, + ) + + def replay_finished(self, success: bool, workflow_name: str) -> bool: + """Notification de fin de replay (succès ou échec).""" + if success: + return self.notify( + title=APP_NAME, + message="C'est fait ! Tout s'est bien passé.", + timeout=5, + ) + else: + return self.notify( + title=APP_NAME, + message="Hmm, j'ai eu un souci. Vous pouvez me remontrer ?", + timeout=7, + ) + + def connection_changed(self, connected: bool, server_host: str) -> bool: + """Notification de changement d'état de la connexion serveur.""" + if connected: + return self.notify( + title=APP_NAME, + message="Connectée au serveur.", + timeout=5, + ) + else: + return self.notify( + title=APP_NAME, + message="J'ai perdu la connexion avec le serveur.", + timeout=7, + ) + + def error(self, message: str) -> bool: + """Notification d'erreur.""" + return self.notify( + title=APP_NAME, + message=f"Oups, un problème : {message}", + timeout=10, + ) diff --git a/agent_v0/agent_v1/ui/shared_state.py b/agent_v0/agent_v1/ui/shared_state.py new file mode 100644 index 000000000..e614f2fe4 --- /dev/null +++ b/agent_v0/agent_v1/ui/shared_state.py @@ -0,0 +1,190 @@ +# agent_v1/ui/shared_state.py +""" +Etat partage entre le systray et le chat Lea. Thread-safe. + +Point central de verite pour l'etat de l'agent : + - Enregistrement en cours (oui/non, nom de la tache) + - Replay en cours + - Compteur d'actions + +Les deux composants UI (SmartTrayV1 et ChatWindow) lisent et ecrivent +dans cet objet. Chaque changement notifie tous les listeners enregistres. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any, Callable, List, Optional + +logger = logging.getLogger(__name__) + + +class AgentState: + """Etat partage entre le systray et le chat Lea. Thread-safe.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + + # Etat d'enregistrement + self._recording = False + self._recording_name = "" + self._actions_count = 0 + + # Etat de replay + self._replay_active = False + + # Callbacks de demarrage/arret de session (relies au moteur agent) + self._on_start: Optional[Callable[[str], None]] = None + self._on_stop: Optional[Callable[[], None]] = None + + # Listeners notifies a chaque changement d'etat + self._listeners: List[Callable[["AgentState"], None]] = [] + + # ------------------------------------------------------------------ + # Proprietes en lecture seule (thread-safe) + # ------------------------------------------------------------------ + + @property + def is_recording(self) -> bool: + with self._lock: + return self._recording + + @property + def recording_name(self) -> str: + with self._lock: + return self._recording_name + + @property + def actions_count(self) -> int: + with self._lock: + return self._actions_count + + @property + def is_replay_active(self) -> bool: + with self._lock: + return self._replay_active + + # ------------------------------------------------------------------ + # Mutations (thread-safe, notifient les listeners) + # ------------------------------------------------------------------ + + def start_recording(self, name: str) -> None: + """Demarre un enregistrement (appele depuis systray OU chat). + + Appelle le callback on_start si defini, puis notifie les listeners. + """ + with self._lock: + if self._recording: + logger.warning("Enregistrement deja en cours, ignore") + return + self._recording = True + self._recording_name = name + self._actions_count = 0 + on_start = self._on_start + + logger.info("Enregistrement demarre : %s", name) + + # Appeler le callback moteur (hors du lock pour eviter deadlock) + if on_start is not None: + try: + on_start(name) + except Exception as e: + logger.error("Erreur demarrage session : %s", e) + # Annuler l'enregistrement si le moteur echoue + with self._lock: + self._recording = False + self._recording_name = "" + self._notify_listeners() + raise + + self._notify_listeners() + + def stop_recording(self) -> None: + """Arrete l'enregistrement (appele depuis systray OU chat). + + Appelle le callback on_stop si defini, puis notifie les listeners. + """ + with self._lock: + if not self._recording: + logger.debug("Pas d'enregistrement en cours, ignore") + return + self._recording = False + name = self._recording_name + count = self._actions_count + on_stop = self._on_stop + + logger.info("Enregistrement arrete : %s (%d actions)", name, count) + + # Appeler le callback moteur + if on_stop is not None: + try: + on_stop() + except Exception as e: + logger.error("Erreur arret session : %s", e) + + self._notify_listeners() + + def update_actions_count(self, count: int) -> None: + """Met a jour le compteur d'actions (appele par le moteur agent).""" + with self._lock: + self._actions_count = count + self._notify_listeners() + + def set_replay_active(self, active: bool) -> None: + """Active ou desactive le mode replay.""" + with self._lock: + if self._replay_active == active: + return + self._replay_active = active + logger.info("Replay %s", "actif" if active else "termine") + self._notify_listeners() + + # ------------------------------------------------------------------ + # Enregistrement des callbacks et listeners + # ------------------------------------------------------------------ + + def set_on_start(self, callback: Callable[[str], None]) -> None: + """Definit le callback appele quand un enregistrement demarre. + + Ce callback est le pont vers le moteur agent (AgentV1.start_session). + """ + with self._lock: + self._on_start = callback + + def set_on_stop(self, callback: Callable[[], None]) -> None: + """Definit le callback appele quand un enregistrement s'arrete. + + Ce callback est le pont vers le moteur agent (AgentV1.stop_session). + """ + with self._lock: + self._on_stop = callback + + def on_change(self, callback: Callable[["AgentState"], None]) -> None: + """Enregistre un listener notifie a chaque changement d'etat. + + Les listeners sont appeles dans un thread separe pour ne pas + bloquer l'appelant. + """ + with self._lock: + self._listeners.append(callback) + + # ------------------------------------------------------------------ + # Notification interne + # ------------------------------------------------------------------ + + def _notify_listeners(self) -> None: + """Notifie tous les listeners enregistres du changement d'etat.""" + with self._lock: + listeners = list(self._listeners) + + for listener in listeners: + try: + # Appel dans un thread pour ne pas bloquer + threading.Thread( + target=listener, + args=(self,), + daemon=True, + ).start() + except Exception as e: + logger.error("Erreur notification listener : %s", e) diff --git a/agent_v0/agent_v1/ui/smart_tray.py b/agent_v0/agent_v1/ui/smart_tray.py new file mode 100644 index 000000000..60af42ca7 --- /dev/null +++ b/agent_v0/agent_v1/ui/smart_tray.py @@ -0,0 +1,692 @@ +# agent_v1/ui/smart_tray.py +""" +Tray intelligent pour Agent V1 — remplace tray.py (plus de PyQt5). + +Utilise pystray pour l'icone systray et tkinter (stdlib) pour les dialogues. +Communication serveur via LeaServerClient (chat:5004, streaming:5005). +Notifications via NotificationManager (module parallele). +Fenetre de chat Lea integree via ChatWindow (pywebview). + +Architecture de threads : + - Thread principal : boucle pystray (icon.run) + - Thread daemon : verification connexion serveur (toutes les 30s) + - Thread daemon : rafraichissement cache workflows (toutes les 5 min) + - Thread daemon : pywebview (fenetre de chat Lea) + - Thread daemon : hotkey global Ctrl+Shift+L (si keyboard disponible) + - Threads ephemeres : dialogues tkinter (chaque dialogue cree son propre Tk()) +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +from PIL import Image, ImageDraw +import pystray +from pystray import MenuItem as item + +from .notifications import NotificationManager +from .shared_state import AgentState + +logger = logging.getLogger(__name__) + +# Intervalles (secondes) +_CONNECTION_CHECK_INTERVAL = 30 +_WORKFLOW_CACHE_TTL = 300 # 5 minutes + + +# --------------------------------------------------------------------------- +# Helpers tkinter (sans PyQt5) +# --------------------------------------------------------------------------- + +def _ask_string(title: str, prompt: str, default: str = "") -> Optional[str]: + """Dialogue de saisie texte via tkinter (sans PyQt5). + + Cree une instance Tk() ephemere, affiche le dialogue, puis la detruit. + Compatible avec la boucle pystray (pas de mainloop persistant). + """ + import tkinter as tk + from tkinter import simpledialog + + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + result = simpledialog.askstring(title, prompt, initialvalue=default, parent=root) + root.destroy() + return result + + +def _show_info(title: str, message: str) -> None: + """Affiche une boite d'information via tkinter (sans PyQt5).""" + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + messagebox.showinfo(title, message, parent=root) + root.destroy() + + +# --------------------------------------------------------------------------- +# SmartTrayV1 +# --------------------------------------------------------------------------- + +class SmartTrayV1: + """Tray systeme intelligent pour Agent V1. + + Remplace TrayAppV1 (PyQt5) par pystray + tkinter. + Meme interface constructeur pour compatibilite avec main.py. + """ + + def __init__( + self, + on_start_callback: Callable[[str], None], + on_stop_callback: Callable[[], None], + server_client: Optional[Any] = None, + chat_window: Optional[Any] = None, + machine_id: str = "default", + shared_state: Optional[AgentState] = None, + ) -> None: + self.on_start = on_start_callback + self.on_stop = on_stop_callback + self.server_client = server_client + self.machine_id = machine_id # Identifiant machine (multi-machine) + + # Fenetre de chat Lea (pywebview) + self._chat_window = chat_window + + # Etat partage avec le chat (source de verite unique) + self._shared_state = shared_state + + # Etat interne (synchronise avec shared_state si disponible) + self.icon: Optional[pystray.Icon] = None + self.is_recording = False + self.actions_count = 0 + + # Etat connexion serveur + self._connected = False + self._replay_active = False + + # Cache workflows + self._workflows: List[Dict[str, Any]] = [] + self._workflows_lock = threading.Lock() + self._workflows_last_fetch: float = 0.0 + + # Verrous + self._state_lock = threading.Lock() + self._stop_event = threading.Event() + + # Notifications + self._notifier = NotificationManager() + + # Icones d'etat (cercles colores) + self.icons = { + "idle": self._create_circle_icon("gray"), + "recording": self._create_circle_icon("red"), + "connected": self._create_circle_icon("green"), + "disconnected": self._create_circle_icon("orange"), + "replay": self._create_circle_icon("blue"), + } + + # Enregistrer le callback de changement de connexion sur le client + if self.server_client is not None: + self.server_client.set_on_connection_change(self._on_connection_change) + + # S'abonner aux changements de l'etat partage + if self._shared_state is not None: + self._shared_state.on_change(self._on_shared_state_change) + + logger.info("SmartTrayV1 initialise") + + # ------------------------------------------------------------------ + # Icones + # ------------------------------------------------------------------ + + @staticmethod + def _create_circle_icon(color: str) -> Image.Image: + """Genere une icone circulaire simple mais propre.""" + img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.ellipse((4, 4, 60, 60), fill=color, outline="white", width=2) + return img + + def _current_icon(self) -> Image.Image: + """Retourne l'icone correspondant a l'etat courant.""" + if self._replay_active: + return self.icons["replay"] + if self.is_recording: + return self.icons["recording"] + if self._connected: + return self.icons["connected"] + if self.server_client is not None: + return self.icons["disconnected"] + return self.icons["idle"] + + def _update_icon(self) -> None: + """Met a jour l'icone et le menu du tray.""" + if self.icon is not None: + self.icon.icon = self._current_icon() + self.icon.update_menu() + + # ------------------------------------------------------------------ + # Menu dynamique + # ------------------------------------------------------------------ + + def _get_menu_items(self): + """Retourne les items du menu (appele a chaque ouverture du menu).""" + # Ligne de statut (féminin : Léa est connectée/déconnectée) + if self.is_recording: + status_text = "\U0001f534 Apprentissage en cours..." + elif self._connected: + status_text = "\U0001f7e2 Connect\u00e9e" + else: + status_text = "\U0001f534 D\u00e9connect\u00e9e" + + # Compteur d'actions (visible uniquement en enregistrement) + actions_text = f"\U0001f4ca {self.actions_count} \u00e9tapes m\u00e9moris\u00e9es" + + # Sous-menu workflows + workflow_items = self._build_workflow_submenu() + + # Ligne d'identification machine (toujours visible) + machine_text = f"\U0001f4bb {self.machine_id}" + + items = [ + # --- Identite machine --- + item(machine_text, lambda: None, enabled=False), + # --- Statut --- + item(status_text, lambda: None, enabled=False), + item( + actions_text, + lambda: None, + enabled=False, + visible=lambda _i: self.is_recording, + ), + pystray.Menu.SEPARATOR, + # --- Actions session --- + item( + "\U0001f393 Apprenez-moi une t\u00e2che", + self._on_start_session, + visible=lambda _i: not self.is_recording, + ), + item( + "\u23f9\ufe0f C'est termin\u00e9", + self._on_stop_session, + visible=lambda _i: self.is_recording, + ), + pystray.Menu.SEPARATOR, + # --- Workflows --- + item( + "\U0001f4cb Mes t\u00e2ches", + pystray.Menu(*workflow_items) if workflow_items else pystray.Menu( + item("(aucune t\u00e2che apprise)", lambda: None, enabled=False), + ), + visible=lambda _i: self.server_client is not None, + ), + item( + "\U0001f504 Actualiser", + self._on_refresh_workflows, + visible=lambda _i: self.server_client is not None, + ), + pystray.Menu.SEPARATOR, + # --- Chat --- + item( + "\U0001f4ac Discuter avec L\u00e9a", + self._on_toggle_chat, + visible=lambda _i: self._chat_window is not None, + ), + pystray.Menu.SEPARATOR, + # --- Utilitaires --- + item("\U0001f4c2 Mes fichiers", self._on_open_folder), + item("\u274c Quitter L\u00e9a", self._on_quit), + ] + return items + + @staticmethod + def _human_workflow_name(wf: Dict[str, Any]) -> str: + """Retourne un nom lisible pour un workflow. + + Priorite : + 1. Champ 'display_name' (nom humain saisi par l'utilisateur) + 2. Champ 'name' ou 'workflow_name' + 3. Fallback : "Tache du " + """ + # Nom humain explicite (nouveau champ) + display = wf.get("display_name", "").strip() + if display: + return display + + # Nom technique existant + name = wf.get("name", wf.get("workflow_name", "")).strip() + if name: + return name + + # Fallback avec date de creation + created = wf.get("created_at", wf.get("timestamp", "")) + if created: + # Extraire juste la date (format ISO ou timestamp) + try: + from datetime import datetime + if isinstance(created, (int, float)): + dt = datetime.fromtimestamp(created) + else: + dt = datetime.fromisoformat(str(created).replace("Z", "+00:00")) + return f"T\u00e2che du {dt.strftime('%d %B')}" + except Exception: + pass + + return "T\u00e2che sans nom" + + def _build_workflow_submenu(self) -> List[pystray.MenuItem]: + """Construit la liste des workflows comme items de sous-menu.""" + with self._workflows_lock: + workflows = list(self._workflows) + + if not workflows: + return [item("(aucune t\u00e2che apprise)", lambda: None, enabled=False)] + + items = [] + for wf in workflows: + wf_name = self._human_workflow_name(wf) + wf_id = wf.get("id", wf.get("workflow_id", "")) + # Creer une closure avec les bonnes valeurs + items.append( + item(wf_name, self._make_replay_callback(wf_id, wf_name)) + ) + return items + + def _make_replay_callback( + self, workflow_id: str, workflow_name: str + ) -> Callable: + """Cree un callback de lancement de replay pour un workflow donne.""" + def _callback(_icon=None, _item=None): + self._launch_replay(workflow_id, workflow_name) + return _callback + + # ------------------------------------------------------------------ + # Actions utilisateur + # ------------------------------------------------------------------ + + def _on_shared_state_change(self, state: AgentState) -> None: + """Callback appele quand l'etat partage change (depuis le chat ou ailleurs). + + Met a jour l'etat local du systray pour refleter le changement. + """ + with self._state_lock: + self.is_recording = state.is_recording + self.actions_count = state.actions_count + self._replay_active = state.is_replay_active + self._update_icon() + + def _on_start_session(self, _icon=None, _item=None) -> None: + """Demande le nom de la t\u00e2che et demarre la session.""" + # Dialogue tkinter dans un thread dedie + def _dialog(): + name = _ask_string( + "Nouvelle t\u00e2che", + "D\u00e9crivez la t\u00e2che \u00e0 apprendre :", + default="", + ) + if name and name.strip(): + name = name.strip() + # Utiliser l'etat partage si disponible + if self._shared_state is not None: + try: + self._shared_state.start_recording(name) + except Exception as e: + self._notifier.notify("L\u00e9a", f"Oups : {e}") + return + else: + # Fallback sans etat partage + with self._state_lock: + self.is_recording = True + self.actions_count = 0 + self._update_icon() + self.on_start(name) + + self._notifier.notify( + "L\u00e9a", + "C'est parti ! Montrez-moi comment faire.", + ) + + threading.Thread(target=_dialog, daemon=True).start() + + def _on_stop_session(self, _icon=None, _item=None) -> None: + """Termine la session en cours et envoie les donnees.""" + count = self.actions_count + + # Utiliser l'etat partage si disponible + if self._shared_state is not None: + self._shared_state.stop_recording() + else: + with self._state_lock: + self.is_recording = False + self._update_icon() + self.on_stop() + + self._notifier.notify( + "L\u00e9a", + f"Merci ! J'ai bien m\u00e9moris\u00e9 vos {count} actions.", + ) + + def _on_refresh_workflows(self, _icon=None, _item=None) -> None: + """Rafraichit la liste des workflows depuis le serveur.""" + threading.Thread(target=self._fetch_workflows, daemon=True).start() + + def _on_ask_server(self, _icon=None, _item=None) -> None: + """Envoie 'Que dois-je faire ?' au serveur et affiche la reponse.""" + def _ask(): + if self.server_client is None: + return + response = self.server_client.send_chat_message( + "Que dois-je faire maintenant ?" + ) + if response: + # L'API renvoie {"response": {"message": "..."}} ou {"response": "..."} + resp = response.get("response", {}) + if isinstance(resp, dict): + text = resp.get("message", str(resp)) + else: + text = str(resp) + self._notifier.notify("Léa", text) + else: + self._notifier.notify( + "Erreur", + "Impossible de contacter le serveur.", + ) + + threading.Thread(target=_ask, daemon=True).start() + + def _on_toggle_chat(self, _icon=None, _item=None) -> None: + """Affiche ou masque la fenetre de chat Lea (pywebview).""" + if self._chat_window is None: + return + + def _toggle(): + try: + self._chat_window.toggle() + except Exception as e: + logger.error("Erreur toggle chat : %s", e) + self._notifier.notify( + "Erreur Chat", + f"Impossible d'ouvrir le chat : {e}", + ) + + threading.Thread(target=_toggle, daemon=True).start() + + def _launch_replay(self, workflow_id: str, workflow_name: str) -> None: + """Lance le replay d'un workflow.""" + def _replay(): + if self.server_client is None: + return + + with self._state_lock: + self._replay_active = True + self._update_icon() + self._notifier.notify( + "L\u00e9a", + f"Je m'en occupe ! '{workflow_name}' en cours...", + ) + + try: + import requests + resp = requests.post( + f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start", + json={"workflow_id": workflow_id}, + timeout=10, + ) + if resp.ok: + logger.info("Replay demarre pour workflow %s", workflow_id) + else: + self._notifier.notify( + "L\u00e9a", + "Hmm, le serveur a refus\u00e9. R\u00e9essayons plus tard.", + ) + except Exception as e: + logger.error("Erreur lancement replay : %s", e) + self._notifier.notify( + "L\u00e9a", + f"Oups, un probl\u00e8me : {e}", + ) + finally: + with self._state_lock: + self._replay_active = False + self._update_icon() + + threading.Thread(target=_replay, daemon=True).start() + + def _on_open_folder(self, _icon=None, _item=None) -> None: + """Ouvre le dossier des sessions dans l'explorateur de fichiers.""" + from ..config import SESSIONS_ROOT + + sessions_path = str(SESSIONS_ROOT) + if os.name == "nt": + os.startfile(sessions_path) + else: + os.system(f'xdg-open "{sessions_path}"') + + def _on_quit(self, _icon=None, _item=None) -> None: + """Arrete proprement l'agent et quitte.""" + logger.info("Arret demande par l'utilisateur") + + # Arreter la session si en cours + if self.is_recording: + self.on_stop() + + # Signaler l'arret aux threads de fond + self._stop_event.set() + + # Fermer la fenetre de chat si ouverte + if self._chat_window is not None: + try: + self._chat_window.destroy() + except Exception as e: + logger.debug("Erreur fermeture chat : %s", e) + + # Arreter le hotkey global si actif + self._stop_hotkey() + + # Arreter le client serveur si present + if self.server_client is not None: + self.server_client.shutdown() + + # Arreter l'icone pystray + if self.icon is not None: + self.icon.stop() + + # ------------------------------------------------------------------ + # Verification connexion serveur (thread daemon) + # ------------------------------------------------------------------ + + def _connection_checker_loop(self) -> None: + """Verifie la connexion au serveur toutes les 30 secondes.""" + logger.info("Thread de verification connexion demarre") + + while not self._stop_event.is_set(): + if self.server_client is not None: + try: + was_connected = self._connected + self._connected = self.server_client.check_connection() + + if self._connected != was_connected: + self._update_icon() + # La notification est geree par _on_connection_change + except Exception as e: + logger.error("Erreur verification connexion : %s", e) + + self._stop_event.wait(timeout=_CONNECTION_CHECK_INTERVAL) + + logger.info("Thread de verification connexion arrete") + + def _on_connection_change(self, connected: bool) -> None: + """Callback appelee par LeaServerClient quand l'etat de connexion change.""" + with self._state_lock: + self._connected = connected + self._update_icon() + + if connected: + self._notifier.notify( + "L\u00e9a", + "Connect\u00e9e au serveur.", + ) + # Rafraichir les taches a la connexion + threading.Thread(target=self._fetch_workflows, daemon=True).start() + else: + self._notifier.notify( + "L\u00e9a", + "J'ai perdu la connexion avec le serveur.", + ) + + # ------------------------------------------------------------------ + # Cache workflows (thread daemon) + # ------------------------------------------------------------------ + + def _workflow_cache_loop(self) -> None: + """Rafraichit le cache des workflows toutes les 5 minutes.""" + logger.info("Thread de cache workflows demarre") + + while not self._stop_event.is_set(): + if self.server_client is not None and self._connected: + self._fetch_workflows() + + self._stop_event.wait(timeout=_WORKFLOW_CACHE_TTL) + + logger.info("Thread de cache workflows arrete") + + def _fetch_workflows(self) -> None: + """Recupere la liste des workflows depuis le serveur.""" + if self.server_client is None: + return + + try: + workflows = self.server_client.list_workflows() + with self._workflows_lock: + self._workflows = workflows + self._workflows_last_fetch = time.time() + logger.debug( + "Cache workflows mis a jour : %d workflows", len(workflows) + ) + # Forcer la reconstruction du menu + self._update_icon() + except Exception as e: + logger.error("Erreur recuperation workflows : %s", e) + + # ------------------------------------------------------------------ + # Mise a jour du compteur (compatibilite main.py) + # ------------------------------------------------------------------ + + def update_stats(self, count: int) -> None: + """Met a jour le compteur d'actions en temps reel dans le menu.""" + with self._state_lock: + self.actions_count = count + if self.icon is not None: + self.icon.update_menu() + + def set_replay_active(self, active: bool) -> None: + """Signale qu'un replay est en cours (appele depuis main.py).""" + with self._state_lock: + self._replay_active = active + self._update_icon() + + if active: + self._notifier.notify("L\u00e9a", "Je m'en occupe...") + else: + self._notifier.notify("L\u00e9a", "C'est fait !") + + # ------------------------------------------------------------------ + # Hotkey global Ctrl+Shift+L (toggle chat) + # ------------------------------------------------------------------ + + _hotkey_hook = None # reference pour pouvoir le retirer + + def _start_hotkey(self) -> None: + """Enregistre le raccourci global Ctrl+Shift+L pour ouvrir le chat. + + Utilise la librairie 'keyboard' si disponible. + Silencieux si elle n'est pas installee (pas critique). + """ + if self._chat_window is None: + return + + try: + import keyboard + self._hotkey_hook = keyboard.add_hotkey( + "ctrl+shift+l", + self._on_toggle_chat, + suppress=False, + ) + logger.info("Hotkey Ctrl+Shift+L enregistre pour le chat Lea") + except ImportError: + logger.debug( + "keyboard non installe — hotkey Ctrl+Shift+L desactive. " + "Installer avec : pip install keyboard" + ) + except Exception as e: + logger.warning("Impossible d'enregistrer le hotkey : %s", e) + + def _stop_hotkey(self) -> None: + """Retire le raccourci global.""" + if self._hotkey_hook is not None: + try: + import keyboard + keyboard.remove_hotkey(self._hotkey_hook) + self._hotkey_hook = None + logger.debug("Hotkey Ctrl+Shift+L retire") + except Exception: + pass + + # ------------------------------------------------------------------ + # Point d'entree + # ------------------------------------------------------------------ + + def run(self) -> None: + """Demarre le tray, les threads de fond, et entre dans la boucle principale.""" + # Notification d'accueil (avec identifiant machine) + self._notifier.notify( + "L\u00e9a", + f"Bonjour ! L\u00e9a est pr\u00eate.", + ) + + # Enregistrer le hotkey global Ctrl+Shift+L (toggle chat) + self._start_hotkey() + + # Tooltip avec identifiant machine pour le multi-machine + tray_title = f"Agent V1 - {self.machine_id}" + + # Menu statique — reconstruit via _update_icon() quand l'état change + self.icon = pystray.Icon( + "AgentV1", + self._current_icon(), + tray_title, + menu=pystray.Menu(*self._get_menu_items()), + ) + + # Demarrer le thread de verification connexion + if self.server_client is not None: + conn_thread = threading.Thread( + target=self._connection_checker_loop, + daemon=True, + name="smart-tray-conn-check", + ) + conn_thread.start() + + # Demarrer le thread de cache workflows + wf_thread = threading.Thread( + target=self._workflow_cache_loop, + daemon=True, + name="smart-tray-wf-cache", + ) + wf_thread.start() + + # Premiere verification immediate + threading.Thread( + target=self._fetch_workflows, daemon=True + ).start() + + # Boucle principale pystray (bloquante) + logger.info("SmartTrayV1 demarre — entree dans la boucle pystray") + self.icon.run() diff --git a/agent_v0/agent_v1/vision/__init__.py b/agent_v0/agent_v1/vision/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/agent_v1/vision/capturer.py b/agent_v0/agent_v1/vision/capturer.py new file mode 100644 index 000000000..4557b7f18 --- /dev/null +++ b/agent_v0/agent_v1/vision/capturer.py @@ -0,0 +1,84 @@ +# agent_v1/vision/capturer.py +""" +Gestionnaire de vision avancé pour Agent V1. +Optimisé pour le streaming fibre avec détection de changement. +""" + +import os +import time +import logging +import hashlib +from PIL import Image, ImageFilter, ImageStat +import mss +from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY + +logger = logging.getLogger(__name__) + +class VisionCapturer: + def __init__(self, session_dir: str): + self.session_dir = session_dir + self.shots_dir = os.path.join(session_dir, "shots") + os.makedirs(self.shots_dir, exist_ok=True) + # On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows + self.last_img_hash = None + + def capture_full_context(self, name_suffix: str, force=False) -> str: + """ + Capture l'écran complet. + Si force=False, vérifie d'abord si l'écran a changé. + """ + try: + with mss.mss() as sct: + monitor = sct.monitors[1] + sct_img = sct.grab(monitor) + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + + # Détection de changement (pour Heartbeat) + if not force: + current_hash = self._compute_quick_hash(img) + if current_hash == self.last_img_hash: + return "" # Pas de changement, on économise la fibre + self.last_img_hash = current_hash + + path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png") + img.save(path, "PNG", quality=SCREENSHOT_QUALITY) + return path + except Exception as e: + logger.error(f"Erreur Context Capture: {e}") + return "" + + def capture_dual(self, x: int, y: int, screenshot_id: str, anonymize=False) -> dict: + """Capture duale (Full + Crop) systématique (forcée car liée à une action).""" + try: + with mss.mss() as sct: + full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png") + monitor = sct.monitors[1] + sct_img = sct.grab(monitor) + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + + # Capture du Crop (Cœur de l'apprentissage qwen3-vl) + crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png") + w, h = TARGETED_CROP_SIZE + left = max(0, x - w // 2) + top = max(0, y - h // 2) + crop_img = img.crop((left, top, left + w, top + h)) + + if anonymize: + crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4)) + + img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY) + crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY) + + # Mise à jour du hash pour le prochain heartbeat + self.last_img_hash = self._compute_quick_hash(img) + + return {"full": full_path, "crop": crop_path} + except Exception as e: + logger.error(f"Erreur Dual Capture: {e}") + return {} + + def _compute_quick_hash(self, img: Image) -> str: + """Calcule un hash rapide basé sur une vignette réduite pour détecter les changements.""" + # On réduit l'image à 64x64 pour comparer les masses de couleurs (très rapide) + small_img = img.resize((64, 64), Image.NEAREST).convert("L") + return hashlib.md5(small_img.tobytes()).hexdigest() diff --git a/agent_v0/agent_v1/window_info.py b/agent_v0/agent_v1/window_info.py new file mode 100644 index 000000000..7e6be8744 --- /dev/null +++ b/agent_v0/agent_v1/window_info.py @@ -0,0 +1,55 @@ +# window_info.py +""" +Récupération des informations sur la fenêtre active (X11). + +v0 : +- utilise xdotool pour obtenir : + - le titre de la fenêtre active + - le PID de la fenêtre active, puis le nom du process via ps + +Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". +""" + +from __future__ import annotations + +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Nécessite xdotool installé sur le système. + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } diff --git a/agent_v0/agent_v1/window_info_crossplatform.py b/agent_v0/agent_v1/window_info_crossplatform.py new file mode 100644 index 000000000..ba059a3fc --- /dev/null +++ b/agent_v0/agent_v1/window_info_crossplatform.py @@ -0,0 +1,192 @@ +# window_info_crossplatform.py +""" +Récupération des informations sur la fenêtre active - CROSS-PLATFORM + +Supporte: +- Linux (X11 via xdotool) +- Windows (via pywin32) +- macOS (via pyobjc) + +Installation des dépendances: + pip install pywin32 # Windows + pip install pyobjc-framework-Cocoa # macOS + pip install psutil # Tous OS +""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Détecte automatiquement l'OS et utilise la méthode appropriée. + """ + system = platform.system() + + if system == "Linux": + return _get_window_info_linux() + elif system == "Windows": + return _get_window_info_windows() + elif system == "Darwin": # macOS + return _get_window_info_macos() + else: + return {"title": "unknown_window", "app_name": "unknown_app"} + + +def _get_window_info_linux() -> Dict[str, str]: + """ + Linux: utilise xdotool (X11) + + Nécessite: sudo apt-get install xdotool + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + +def _get_window_info_windows() -> Dict[str, str]: + """ + Windows: utilise pywin32 + psutil + + Nécessite: pip install pywin32 psutil + """ + try: + import win32gui + import win32process + import psutil + + # Fenêtre au premier plan + hwnd = win32gui.GetForegroundWindow() + + # Titre de la fenêtre + title = win32gui.GetWindowText(hwnd) + if not title: + title = "unknown_window" + + # PID du processus + _, pid = win32process.GetWindowThreadProcessId(hwnd) + + # Nom du processus + try: + process = psutil.Process(pid) + app_name = process.name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pywin32 ou psutil non installé + return { + "title": "unknown_window (pywin32 missing)", + "app_name": "unknown_app (pywin32 missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +def _get_window_info_macos() -> Dict[str, str]: + """ + macOS: utilise pyobjc (AppKit) + + Nécessite: pip install pyobjc-framework-Cocoa + + Note: Nécessite les permissions "Accessibility" dans System Preferences + """ + try: + from AppKit import NSWorkspace + from Quartz import ( + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + # Application active + active_app = NSWorkspace.sharedWorkspace().activeApplication() + app_name = active_app.get('NSApplicationName', 'unknown_app') + + # Titre de la fenêtre (via Quartz) + # On cherche la fenêtre de l'app active qui est au premier plan + window_list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + title = "unknown_window" + for window in window_list: + owner_name = window.get('kCGWindowOwnerName', '') + if owner_name == app_name: + window_title = window.get('kCGWindowName', '') + if window_title: + title = window_title + break + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pyobjc non installé + return { + "title": "unknown_window (pyobjc missing)", + "app_name": "unknown_app (pyobjc missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +# Test rapide +if __name__ == "__main__": + import time + + print(f"OS détecté: {platform.system()}") + print("\nTest de capture fenêtre active (5 secondes)...") + print("Changez de fenêtre pour tester!\n") + + for i in range(5): + info = get_active_window_info() + print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") + time.sleep(1) diff --git a/agent_v0/config.py b/agent_v0/config.py new file mode 100644 index 000000000..29691ae7c --- /dev/null +++ b/agent_v0/config.py @@ -0,0 +1,58 @@ +# config.py +""" +Configuration de base pour agent_v0. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +AGENT_VERSION = "0.1.0" + +# Dossier racine du projet (là où se trouve ce fichier) +BASE_DIR = Path(__file__).resolve().parent + +# Chargement automatique de .env.local depuis le répertoire parent +def load_env_file(env_path): + """Charge un fichier .env dans les variables d'environnement""" + if not env_path.exists(): + return False + + with open(env_path, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + os.environ[key.strip()] = value.strip() + return True + +# Charger .env.local depuis le répertoire parent (racine du projet) +env_local_path = BASE_DIR.parent / ".env.local" +if load_env_file(env_local_path): + print(f"[agent_v0] Variables d'environnement chargées depuis {env_local_path}") + + + +# Endpoint du serveur RPA Vision V3 +# En développement local : http://localhost:8000/api/traces/upload +# En production : configurer via variable d'environnement +import os +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:8000/api/traces/upload") + +# Durée max d'une session en secondes (ex: 30 minutes) +MAX_SESSION_DURATION_S = 30 * 60 + +# Dossier racine local où stocker les sessions (chemin ABSOLU) +SESSIONS_ROOT = str(BASE_DIR / "sessions") + +# Dossier et fichier de logs +LOGS_DIR = BASE_DIR / "logs" +LOG_FILE = LOGS_DIR / "agent_v0.log" + +# Faut-il quitter l'application après un Stop session ? +EXIT_AFTER_SESSION = True + +# Création des dossiers si besoin +os.makedirs(SESSIONS_ROOT, exist_ok=True) +os.makedirs(LOGS_DIR, exist_ok=True) diff --git a/agent_v0/deploy/test_replay_diag.py b/agent_v0/deploy/test_replay_diag.py new file mode 100644 index 000000000..3e0dd142d --- /dev/null +++ b/agent_v0/deploy/test_replay_diag.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Diagnostic pour le replay Agent V1 sur Windows. + +Test en 3 etapes : +1. Verifie que pynput fonctionne (souris + clavier) +2. Verifie la connexion au serveur de replay +3. Execute un poll_and_execute de test + +Usage : python test_replay_diag.py +(Depuis C:\rpa_vision : .venv\Scripts\python.exe test_replay_diag.py) +""" +import os +import sys +import time + +# Charger .env si present +env_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env') +if os.path.exists(env_file): + with open(env_file, encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, val = line.split('=', 1) + os.environ.setdefault(key.strip(), val.strip()) + +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://192.168.1.40:5005/api/v1") + +print("=" * 60) +print(" DIAGNOSTIC REPLAY AGENT V1") +print("=" * 60) +print() + +# ---- Test 1 : pynput ---- +print("[TEST 1] Verification pynput...") +try: + from pynput.mouse import Controller as MouseController + from pynput.keyboard import Controller as KeyboardController + mouse = MouseController() + kb = KeyboardController() + + pos = mouse.position + print(f" Position souris actuelle : {pos}") + if pos is None: + print(" PROBLEME : mouse.position = None !") + print(" -> pynput n'a pas acces a la session graphique.") + print(" -> Le script doit etre lance DEPUIS le bureau Windows,") + print(" pas via SSH.") + else: + print(f" OK : souris detectee a {pos}") + + # Test deplacement souris (petit mouvement) + print(" Test deplacement souris dans 2s...") + time.sleep(2) + old_pos = mouse.position + if old_pos: + # Deplacement de 50px a droite puis retour + mouse.position = (old_pos[0] + 50, old_pos[1]) + time.sleep(0.3) + new_pos = mouse.position + mouse.position = old_pos # Retour + print(f" Deplacement: {old_pos} -> {new_pos} -> retour") + if new_pos and new_pos[0] != old_pos[0]: + print(" OK : deplacement souris fonctionne !") + else: + print(" PROBLEME : la souris n'a pas bouge.") + else: + print(" SKIP : pas de position souris disponible.") + +except Exception as e: + print(f" ERREUR pynput : {e}") + import traceback + traceback.print_exc() + +print() + +# ---- Test 2 : connexion serveur ---- +print(f"[TEST 2] Connexion au serveur : {SERVER_URL}") +try: + import requests + url = f"{SERVER_URL}/traces/stream/replay/next" + resp = requests.get(url, params={"session_id": "diag_test"}, timeout=5) + print(f" HTTP {resp.status_code} : {resp.text[:200]}") + if resp.ok: + data = resp.json() + if data.get("action") is None: + print(" OK : serveur accessible, pas d'action en attente.") + else: + print(f" OK : serveur accessible, ACTION RECUE : {data['action']}") + else: + print(f" PROBLEME : le serveur a repondu HTTP {resp.status_code}") +except requests.exceptions.ConnectionError as e: + print(f" ERREUR CONNEXION : {e}") + print(f" -> Verifiez que le serveur tourne sur {SERVER_URL}") +except Exception as e: + print(f" ERREUR : {e}") + +print() + +# ---- Test 3 : mss (capture ecran) ---- +print("[TEST 3] Capture ecran (mss)...") +try: + import mss + sct = mss.mss() + monitor = sct.monitors[1] + print(f" Moniteur principal : {monitor['width']}x{monitor['height']}") + raw = sct.grab(monitor) + print(f" Capture OK : {raw.size}") +except Exception as e: + print(f" ERREUR mss : {e}") + +print() + +# ---- Test 4 : typing test (5s delay) ---- +print("[TEST 4] Test de frappe clavier") +print(" -> Ouvrez le Bloc-Notes et placez le curseur dedans.") +print(" -> La frappe commencera dans 5 secondes...") +time.sleep(5) + +try: + from pynput.keyboard import Controller as KeyboardController + kb = KeyboardController() + test_text = "Hello RPA!" + print(f" Frappe de '{test_text}'...") + kb.type(test_text) + print(f" Frappe terminee. Verifiez si le texte apparait dans le Bloc-Notes.") +except Exception as e: + print(f" ERREUR frappe : {e}") + import traceback + traceback.print_exc() + +print() +print("=" * 60) +print(" DIAGNOSTIC TERMINE") +print("=" * 60) +input("Appuyez sur Entree pour fermer...") diff --git a/agent_v0/deploy/windows_client/LISEZMOI.txt b/agent_v0/deploy/windows_client/LISEZMOI.txt new file mode 100644 index 000000000..99b6754e4 --- /dev/null +++ b/agent_v0/deploy/windows_client/LISEZMOI.txt @@ -0,0 +1,17 @@ +=== Agent V1 — RPA Vision — Client Windows === + +Installation : + 1. Double-cliquer sur setup.bat + 2. Configurer le serveur : éditer agent_config.json + ou définir la variable RPA_SERVER_HOST=192.168.1.x + 3. Lancer : python run_agent_v1.py + +L'agent apparaît dans la zone de notification (systray). +Clic droit pour accéder au menu : démarrer une session, +lancer un replay, voir les workflows appris, etc. + +Léa communique par des notifications toast sur votre écran. + +Prérequis : + - Python 3.10 ou plus récent + - Connexion réseau vers le serveur Linux diff --git a/agent_v0/deploy/windows_client/__init__.py b/agent_v0/deploy/windows_client/__init__.py new file mode 100644 index 000000000..fbc07f0ce --- /dev/null +++ b/agent_v0/deploy/windows_client/__init__.py @@ -0,0 +1 @@ +# agent_v0 — Agent RPA Vision V3 diff --git a/agent_v0/deploy/windows_client/agent_config.json b/agent_v0/deploy/windows_client/agent_config.json new file mode 100644 index 000000000..b2d25e692 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_config.json @@ -0,0 +1,15 @@ +{ + "user_id": "demo_user", + "user_label": "Démo agent_v0", + "customer": "Clinique Demo", + "training_label": "Facturation_T2A_demo", + "notes": "Session réelle avec clics + screenshots + key combos.", + "mode": "enriched", + "screenshot_mode": "crop", + "screenshot_crop_width": 900, + "screenshot_crop_height": 700, + "capture_hover": true, + "hover_min_idle_ms": 700, + "capture_scroll": true, + "network_save_path": "" +} \ No newline at end of file diff --git a/agent_v0/deploy/windows_client/agent_v1/__init__.py b/agent_v0/deploy/windows_client/agent_v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/config.py b/agent_v0/deploy/windows_client/agent_v1/config.py new file mode 100644 index 000000000..554896721 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/config.py @@ -0,0 +1,43 @@ +# agent_v1/config.py +""" +Configuration avancée pour Agent V1. +""" +from __future__ import annotations +import os +import platform +import socket +from pathlib import Path + +AGENT_VERSION = "1.0.0" + +# Identifiant unique de la machine (utilisé pour le multi-machine) +# Configurable via variable d'environnement, sinon auto-généré depuis hostname + OS +MACHINE_ID = os.environ.get( + "RPA_MACHINE_ID", + f"{socket.gethostname()}_{platform.system().lower()}", +) + +# Dossier racine de l'agent +BASE_DIR = Path(__file__).resolve().parent + +# Endpoint du serveur Streaming (port 5005) +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1") +UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload" +STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream" + +# Paramètres de session +MAX_SESSION_DURATION_S = 60 * 60 # 1 heure +SESSIONS_ROOT = BASE_DIR / "sessions" + +# Paramètres Vision (Crops pour qwen3-vl) +TARGETED_CROP_SIZE = (400, 400) +SCREENSHOT_QUALITY = 85 + +# Monitoring +PERF_MONITOR_INTERVAL_S = 30 +LOGS_DIR = BASE_DIR / "logs" +LOG_FILE = LOGS_DIR / "agent_v1.log" + +# Création des dossiers +os.makedirs(SESSIONS_ROOT, exist_ok=True) +os.makedirs(LOGS_DIR, exist_ok=True) diff --git a/agent_v0/deploy/windows_client/agent_v1/core/__init__.py b/agent_v0/deploy/windows_client/agent_v1/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/core/captor.py b/agent_v0/deploy/windows_client/agent_v1/core/captor.py new file mode 100644 index 000000000..9d3244727 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/core/captor.py @@ -0,0 +1,319 @@ +# agent_v1/core/captor.py +""" +Moteur de capture d'événements Agent V1. +Capture enrichie avec focus sur le contexte UI pour le stagiaire. + +Fonctionnalités : +- Capture clics souris (simple et double-clic) +- Capture scroll souris +- Capture combos clavier (Ctrl+C, Alt+Tab, etc.) +- Buffer de saisie texte : accumule les frappes et émet un événement + text_input après 500ms d'inactivité clavier +- Surveillance du focus fenêtre +""" + +import threading +import time +import logging +from typing import Callable, Optional, List, Dict, Any, Tuple +from pynput import mouse, keyboard +from pynput.mouse import Button +from pynput.keyboard import Key, KeyCode + +# Importation relative pour rester dans le module v1 +from ..vision.capturer import VisionCapturer +# from ..monitoring.system import SystemMonitor + +logger = logging.getLogger(__name__) + +# Délai d'inactivité avant flush du buffer texte (en secondes) +TEXT_FLUSH_DELAY = 0.5 +# Délai max entre deux clics pour un double-clic (en secondes) +DOUBLE_CLICK_DELAY = 0.3 +# Tolérance en pixels pour considérer deux clics au même endroit +DOUBLE_CLICK_TOLERANCE = 10 + + +class EventCaptorV1: + def __init__(self, on_event_callback: Callable[[Dict[str, Any]], None]): + self.on_event = on_event_callback + self.mouse_listener = None + self.keyboard_listener = None + self.running = False + + # État des touches modificatrices + self.modifiers = set() + + # Tracking du focus fenêtre + self.last_window = None + self._focus_thread = None + + # --- Buffer de saisie texte --- + # Lock pour accès thread-safe au buffer (le listener pynput + # tourne dans un thread séparé) + self._text_lock = threading.Lock() + self._text_buffer: list[str] = [] + # Position de la souris au moment de la première frappe du buffer + self._text_start_pos: Optional[Tuple[int, int]] = None + # Timer pour le flush après inactivité + self._text_flush_timer: Optional[threading.Timer] = None + # Dernière position connue de la souris (pour associer le texte + # au champ dans lequel l'utilisateur tape) + self._last_mouse_pos: Tuple[int, int] = (0, 0) + + # --- Détection double-clic --- + # Dernier clic : (x, y, timestamp, button) + self._last_click: Optional[Tuple[int, int, float, str]] = None + + def start(self): + self.running = True + self.mouse_listener = mouse.Listener( + on_click=self._on_click, + on_scroll=self._on_scroll, + on_move=self._on_move + ) + self.keyboard_listener = keyboard.Listener( + on_press=self._on_press, + on_release=self._on_release + ) + + self.mouse_listener.start() + self.keyboard_listener.start() + + # Thread de surveillance du focus fenêtre (Proactif) + self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True) + self._focus_thread.start() + + logger.info("Agent V1 Captor démarré") + + def stop(self): + self.running = False + # Flush du buffer texte restant avant arrêt + self._flush_text_buffer() + # Annuler le timer s'il est en cours + with self._text_lock: + if self._text_flush_timer is not None: + self._text_flush_timer.cancel() + self._text_flush_timer = None + if self.mouse_listener: self.mouse_listener.stop() + if self.keyboard_listener: self.keyboard_listener.stop() + logger.info("Agent V1 Captor arrêté") + + # ---------------------------------------------------------------- + # Souris + # ---------------------------------------------------------------- + + def _on_move(self, x, y): + """Mémorise la position souris pour l'associer aux événements texte.""" + self._last_mouse_pos = (x, y) + + def _on_click(self, x, y, button, pressed): + if not pressed: + return + + now = time.time() + + # --- Flush du buffer texte : l'utilisateur a cliqué, donc + # il change probablement de champ --- + self._flush_text_buffer() + + # --- Détection double-clic --- + if self._last_click is not None: + lx, ly, lt, lb = self._last_click + # Même bouton, même zone, délai court → double-clic + if (button.name == lb + and abs(x - lx) <= DOUBLE_CLICK_TOLERANCE + and abs(y - ly) <= DOUBLE_CLICK_TOLERANCE + and (now - lt) <= DOUBLE_CLICK_DELAY): + event = { + "type": "double_click", + "button": button.name, + "pos": (x, y), + "timestamp": now, + } + self.on_event(event) + # Réinitialiser pour éviter un triple-clic = 2 double-clics + self._last_click = None + return + + # Clic simple — on le mémorise pour comparer au prochain + self._last_click = (x, y, now, button.name) + event = { + "type": "mouse_click", + "button": button.name, + "pos": (x, y), + "timestamp": now, + } + self.on_event(event) + + def _on_scroll(self, x, y, dx, dy): + event = { + "type": "mouse_scroll", + "pos": (x, y), + "delta": (dx, dy), + "timestamp": time.time(), + } + self.on_event(event) + + # ---------------------------------------------------------------- + # Clavier + # ---------------------------------------------------------------- + + def _on_press(self, key): + # Gestion des touches modificatrices + if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r): + self.modifiers.add("ctrl") + elif key in (Key.alt, Key.alt_l, Key.alt_r): + self.modifiers.add("alt") + elif key in (Key.shift, Key.shift_l, Key.shift_r): + self.modifiers.add("shift") + + # --- Combos avec modificateur (sauf Shift seul) --- + # Shift seul n'est pas un « vrai » modificateur pour les combos : + # Shift+a = 'A' = saisie texte, pas un raccourci. + # On considère un combo seulement si Ctrl ou Alt est enfoncé. + has_real_modifier = self.modifiers & {"ctrl", "alt"} + if has_real_modifier: + key_name = self._get_key_name(key) + if key_name and key_name not in ("ctrl", "alt", "shift"): + # Un combo interrompt la saisie texte en cours + self._flush_text_buffer() + event = { + "type": "key_combo", + "keys": list(self.modifiers) + [key_name], + "timestamp": time.time(), + } + self.on_event(event) + return + + # --- Saisie texte (pas de Ctrl/Alt enfoncé) --- + self._handle_text_key(key) + + def _handle_text_key(self, key): + """Gère l'accumulation des frappes texte dans le buffer. + + Touches spéciales : + - Backspace : supprime le dernier caractère du buffer + - Enter / Tab : flush immédiat + émission de l'événement + - Escape : vide le buffer sans émettre + """ + with self._text_lock: + # --- Touches spéciales --- + if key == Key.backspace: + if self._text_buffer: + self._text_buffer.pop() + self._reset_flush_timer() + return + + if key == Key.escape: + # Annuler la saisie en cours + self._text_buffer.clear() + self._text_start_pos = None + self._cancel_flush_timer() + return + + if key in (Key.enter, Key.tab): + # Flush immédiat — on relâche le lock avant d'appeler + # _flush_text_buffer (qui prend aussi le lock) + pass # on sort du with et on flush après + + elif key == Key.space: + # Espace = caractère normal + if not self._text_buffer: + self._text_start_pos = self._last_mouse_pos + self._text_buffer.append(" ") + self._reset_flush_timer() + return + + elif isinstance(key, KeyCode) and key.char is not None: + # Caractère alphanumérique / ponctuation + # pynput renvoie déjà le bon caractère selon le layout + # (AZERTY inclus) — on ne convertit rien. + if not self._text_buffer: + self._text_start_pos = self._last_mouse_pos + self._text_buffer.append(key.char) + self._reset_flush_timer() + return + else: + # Touche spéciale non gérée (F1, Insert, etc.) — on ignore + return + + # Si on arrive ici, c'est Enter ou Tab → flush immédiat + self._flush_text_buffer() + + def _reset_flush_timer(self): + """Réarme le timer de flush après chaque frappe. + + Doit être appelé avec self._text_lock déjà acquis. + """ + if self._text_flush_timer is not None: + self._text_flush_timer.cancel() + self._text_flush_timer = threading.Timer( + TEXT_FLUSH_DELAY, self._flush_text_buffer + ) + self._text_flush_timer.daemon = True + self._text_flush_timer.start() + + def _cancel_flush_timer(self): + """Annule le timer de flush sans émettre. + + Doit être appelé avec self._text_lock déjà acquis. + """ + if self._text_flush_timer is not None: + self._text_flush_timer.cancel() + self._text_flush_timer = None + + def _flush_text_buffer(self): + """Émet un événement text_input avec le contenu du buffer, puis + le vide. Thread-safe — peut être appelé depuis le timer, le + listener souris ou le listener clavier.""" + with self._text_lock: + if not self._text_buffer: + # Rien à émettre + self._cancel_flush_timer() + return + text = "".join(self._text_buffer) + pos = self._text_start_pos or self._last_mouse_pos + self._text_buffer.clear() + self._text_start_pos = None + self._cancel_flush_timer() + + # Émission hors du lock pour éviter un deadlock si le callback + # est lent ou prend d'autres locks + event = { + "type": "text_input", + "text": text, + "pos": pos, + "timestamp": time.time(), + } + logger.debug(f"text_input émis : {len(text)} caractères") + self.on_event(event) + + def _on_release(self, key): + if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r): + self.modifiers.discard("ctrl") + elif key in (Key.alt, Key.alt_l, Key.alt_r): + self.modifiers.discard("alt") + elif key in (Key.shift, Key.shift_l, Key.shift_r): + self.modifiers.discard("shift") + + def _watch_window_focus(self): + """Surveille proactivement le changement de fenêtre pour le stagiaire.""" + # Importation relative simple + from ..window_info_crossplatform import get_active_window_info + + while self.running: + try: + info = get_active_window_info() + if info and info != self.last_window: + event = { + "type": "window_focus_change", + "from": self.last_window, + "to": info, + "timestamp": time.time() + } + self.last_window = info + self.on_event(event) + except Exception as e: + logger.error(f"Erreur focus window: {e}") + time.sleep(0.5) diff --git a/agent_v0/deploy/windows_client/agent_v1/core/executor.py b/agent_v0/deploy/windows_client/agent_v1/core/executor.py new file mode 100644 index 000000000..0645a6729 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/core/executor.py @@ -0,0 +1,523 @@ +# agent_v1/core/executor.py +""" +Executeur d'actions visuelles pour Agent V1. +Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (VM). + +Supporte deux modes : + - Watchdog fichier (command.json) — legacy + - Polling serveur (GET /replay/next) — mode replay P0-5 +""" + +import base64 +import io +import time +import logging + +import mss +from pynput.mouse import Button, Controller as MouseController +from pynput.keyboard import Controller as KeyboardController, Key + +logger = logging.getLogger(__name__) + +# Mapping des noms de touches spéciales vers pynput.Key +_SPECIAL_KEYS = { + "enter": Key.enter, + "return": Key.enter, + "tab": Key.tab, + "escape": Key.esc, + "esc": Key.esc, + "backspace": Key.backspace, + "delete": Key.delete, + "space": Key.space, + "up": Key.up, + "down": Key.down, + "left": Key.left, + "right": Key.right, + "home": Key.home, + "end": Key.end, + "page_up": Key.page_up, + "page_down": Key.page_down, + "f1": Key.f1, "f2": Key.f2, "f3": Key.f3, "f4": Key.f4, + "f5": Key.f5, "f6": Key.f6, "f7": Key.f7, "f8": Key.f8, + "f9": Key.f9, "f10": Key.f10, "f11": Key.f11, "f12": Key.f12, + "ctrl": Key.ctrl, "ctrl_l": Key.ctrl_l, "ctrl_r": Key.ctrl_r, + "alt": Key.alt, "alt_l": Key.alt_l, "alt_r": Key.alt_r, + "shift": Key.shift, "shift_l": Key.shift_l, "shift_r": Key.shift_r, + "cmd": Key.cmd, "win": Key.cmd, + "super": Key.cmd, "super_l": Key.cmd, "super_r": Key.cmd, + "windows": Key.cmd, "meta": Key.cmd, + "insert": Key.insert, "print_screen": Key.print_screen, + "caps_lock": Key.caps_lock, "num_lock": Key.num_lock, +} + + +class ActionExecutorV1: + def __init__(self): + self.mouse = MouseController() + self.keyboard = KeyboardController() + # NB: mss est initialise paresseusement pour eviter les problemes + # de thread-safety (le constructeur peut etre appele dans un thread + # different de celui qui utilise l'instance). + self._sct = None + self.running = True + # Backoff exponentiel pour le polling replay (evite de marteler le serveur) + self._poll_backoff = 1.0 # Delai actuel (secondes) + self._poll_backoff_min = 1.0 # Delai minimal (reset apres succes) + self._poll_backoff_max = 30.0 # Delai maximal + self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec + + @property + def sct(self): + """Instance mss paresseuse, creee dans le thread appelant.""" + if self._sct is None: + self._sct = mss.mss() + return self._sct + + # ========================================================================= + # Execution legacy (watchdog command.json) + # ========================================================================= + + def execute_normalized_order(self, order: dict): + """ + Execute un ordre base sur des proportions (0.0 a 1.0). + Ex: {"action": "mouse_click", "x_pct": 0.5, "y_pct": 0.5} (Clic au centre) + """ + action = order.get("action") + try: + # Recuperation de la resolution actuelle de la VM + monitor = self.sct.monitors[1] + width, height = monitor["width"], monitor["height"] + + if action == "mouse_click": + # Traduction Proportions -> Pixels reels de la VM + real_x = int(order.get("x_pct", 0) * width) + real_y = int(order.get("y_pct", 0) * height) + self._click((real_x, real_y), order.get("button", "left")) + + elif action == "text_input": + self.keyboard.type(order.get("text", "")) + + logger.info(f"Ordre Visuel execute : {action} sur ({width}x{height})") + except Exception as e: + logger.error(f"Echec de l'ordre {action} : {e}") + + # ========================================================================= + # Execution replay (polling serveur) + # ========================================================================= + + def execute_replay_action(self, action: dict, server_url: str = "") -> dict: + """ + Execute une action normalisee recue du serveur de replay. + + Supporte deux modes : + - Visual mode (visual_mode=True + target_spec) : capture un screenshot, + l'envoie au serveur pour resolution visuelle, puis execute a la position trouvee. + - Blind mode (defaut) : utilise les coordonnees statiques x_pct/y_pct. + + Format d'entree : + { + "action_id": "act_xxxx", + "type": "click|type|key_combo|scroll|wait", + "x_pct": 0.5, + "y_pct": 0.3, + "text": "...", + "keys": [...], + "button": "left", + "duration_ms": 500, + "visual_mode": true, + "target_spec": {"by_role": "button", "by_text": "Submit"} + } + + Retourne : + { + "action_id": "act_xxxx", + "success": True/False, + "error": None ou message, + "screenshot": base64 du screenshot post-action, + "visual_resolved": True/False + } + """ + action_id = action.get("action_id", "unknown") + action_type = action.get("type", "unknown") + visual_mode = action.get("visual_mode", False) + target_spec = action.get("target_spec", {}) + result = { + "action_id": action_id, + "success": False, + "error": None, + "screenshot": None, + "visual_resolved": False, + } + + try: + monitor = self.sct.monitors[1] + width, height = monitor["width"], monitor["height"] + + # Resolution visuelle des coordonnees si demande + x_pct = action.get("x_pct", 0.0) + y_pct = action.get("y_pct", 0.0) + + if visual_mode and target_spec and server_url: + resolved = self._resolve_target_visual( + server_url, target_spec, x_pct, y_pct, width, height + ) + if resolved: + x_pct = resolved["x_pct"] + y_pct = resolved["y_pct"] + result["visual_resolved"] = resolved.get("resolved", False) + if resolved.get("resolved"): + logger.info( + f"Visual resolve OK: {resolved.get('matched_element', {}).get('label', '?')} " + f"-> ({x_pct:.4f}, {y_pct:.4f})" + ) + + if action_type == "click": + real_x = int(x_pct * width) + real_y = int(y_pct * height) + button = action.get("button", "left") + mode = "VISUAL" if result["visual_resolved"] else "BLIND" + print( + f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> " + f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}" + ) + self._click((real_x, real_y), button) + print(f" [CLICK] Termine.") + logger.info( + f"Replay click [{mode}] : ({x_pct:.3f}, {y_pct:.3f}) -> " + f"({real_x}, {real_y}) sur ({width}x{height})" + ) + + elif action_type == "type": + text = action.get("text", "") + print(f" [TYPE] Texte: '{text[:50]}' ({len(text)} chars)") + # Cliquer sur le champ avant de taper (si coordonnees disponibles) + if x_pct > 0 and y_pct > 0: + real_x = int(x_pct * width) + real_y = int(y_pct * height) + print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})") + self._click((real_x, real_y), "left") + time.sleep(0.3) + self.keyboard.type(text) + print(f" [TYPE] Termine.") + logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)") + + elif action_type == "key_combo": + keys = action.get("keys", []) + print(f" [KEY_COMBO] Touches: {keys}") + self._execute_key_combo(keys) + print(f" [KEY_COMBO] Termine.") + logger.info(f"Replay key_combo : {keys}") + + elif action_type == "scroll": + real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width) + real_y = int(y_pct * height) if y_pct > 0 else int(0.5 * height) + delta = action.get("delta", -3) + print(f" [SCROLL] delta={delta} a ({real_x}, {real_y})") + self.mouse.position = (real_x, real_y) + time.sleep(0.05) + self.mouse.scroll(0, delta) + print(f" [SCROLL] Termine.") + logger.info(f"Replay scroll : delta={delta} a ({real_x}, {real_y})") + + elif action_type == "wait": + duration_ms = action.get("duration_ms", 500) + print(f" [WAIT] {duration_ms}ms...") + time.sleep(duration_ms / 1000.0) + print(f" [WAIT] Termine.") + logger.info(f"Replay wait : {duration_ms}ms") + + else: + result["error"] = f"Type d'action inconnu : {action_type}" + logger.warning(result["error"]) + return result + + result["success"] = True + + # Capturer un screenshot post-action + time.sleep(0.5) + 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}") + + return result + + def _resolve_target_visual( + self, server_url: str, target_spec: dict, + fallback_x: float, fallback_y: float, + screen_width: int, screen_height: int, + ) -> dict: + """ + Envoyer un screenshot au serveur pour resolution visuelle de la cible. + + Capture l'ecran en haute resolution (pas de downscale pour le template + matching), l'encode en base64 JPEG, et POST au endpoint + /replay/resolve_target. Retourne les coordonnees resolues. + """ + import requests + + try: + # Capturer à 1280px max — assez pour le template matching + # et raisonnable pour le transfert réseau (~200-400Ko) + screenshot_b64 = self._capture_screenshot_b64( + max_width=1280, + quality=75, + ) + if not screenshot_b64: + logger.warning("Capture screenshot echouee pour visual resolve") + return None + + print( + f" [VISUAL] Envoi screenshot ({len(screenshot_b64) // 1024} Ko) " + f"au serveur pour resolution..." + ) + + # Appel au serveur + resolve_url = f"{server_url}/traces/stream/replay/resolve_target" + payload = { + "session_id": "", # Pas critique pour la resolution + "screenshot_b64": screenshot_b64, + "target_spec": target_spec, + "fallback_x_pct": fallback_x, + "fallback_y_pct": fallback_y, + "screen_width": screen_width, + "screen_height": screen_height, + } + + resp = requests.post(resolve_url, json=payload, timeout=60) + if resp.ok: + data = resp.json() + method = data.get("method", "?") + resolved = data.get("resolved", False) + print( + f" [VISUAL] Reponse serveur : resolved={resolved}, " + f"method={method}, score={data.get('score', 'N/A')}" + ) + return data + else: + logger.warning(f"Visual resolve HTTP {resp.status_code}: {resp.text[:200]}") + return None + + except requests.exceptions.Timeout: + logger.warning("Visual resolve timeout (30s)") + return None + except Exception as e: + logger.warning(f"Visual resolve echoue: {e}") + return None + + def poll_and_execute(self, session_id: str, server_url: str, machine_id: str = "default") -> bool: + """ + Poll le serveur pour recuperer et executer la prochaine action. + + 1. GET /replay/next pour recuperer l'action + 2. Execute l'action (clic, texte, etc.) + 3. POST /replay/result avec le resultat + screenshot + + Args: + session_id: Identifiant de la session courante + server_url: URL de base du serveur streaming + machine_id: Identifiant de la machine (pour le replay multi-machine) + + Retourne True si une action a ete executee, False sinon. + IMPORTANT: Si une action est recue, le resultat est TOUJOURS rapporte + au serveur (meme en cas d'erreur d'execution). + """ + import requests + + replay_next_url = f"{server_url}/traces/stream/replay/next" + replay_result_url = f"{server_url}/traces/stream/replay/result" + + # Phase 1 : Recuperer la prochaine action (filtree par machine_id) + try: + resp = requests.get( + replay_next_url, + params={"session_id": session_id, "machine_id": machine_id}, + timeout=5, + ) + if not resp.ok: + logger.debug(f"Poll replay echoue : HTTP {resp.status_code}") + return False + + data = resp.json() + action = data.get("action") + if action is None: + return False + + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + # Backoff exponentiel : augmenter le delai de polling + self._poll_backoff = min( + self._poll_backoff * self._poll_backoff_factor, + self._poll_backoff_max, + ) + if not hasattr(self, '_last_conn_error_logged'): + self._last_conn_error_logged = True + print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}") + logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}") + return False + except Exception as e: + self._poll_backoff = min( + self._poll_backoff * self._poll_backoff_factor, + self._poll_backoff_max, + ) + print(f"[REPLAY] ERREUR poll (GET) : {e}") + logger.error(f"Erreur poll GET : {e}") + return False + + # Reset du flag d'erreur connexion et du backoff (on a reussi le GET) + self._last_conn_error_logged = False + self._poll_backoff = self._poll_backoff_min + + # Phase 2 : Executer l'action et rapporter le resultat + # TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution + action_type = action.get('type', '?') + action_id = action.get('action_id', '?') + print(f"\n>>> REPLAY ACTION RECUE : {action_type} (id={action_id})") + print(f" Contenu: {action}") + logger.info(f"Action de replay recue : {action_type} (id={action_id})") + + result = None + try: + print(f">>> Execution de l'action {action_type}...") + result = self.execute_replay_action(action, server_url=server_url) + print( + f">>> Resultat execution : success={result['success']}, " + f"error={result.get('error')}" + ) + except Exception as e: + print(f">>> ERREUR EXECUTION : {e}") + logger.error(f"Erreur execute_replay_action: {e}") + import traceback + traceback.print_exc() + result = { + "action_id": action_id, + "success": False, + "error": f"Exception executor: {e}", + "screenshot": None, + } + + # Phase 3 : Rapporter le resultat au serveur (TOUJOURS) + report = { + "session_id": session_id, + "action_id": result["action_id"], + "success": result["success"], + "error": result.get("error"), + "screenshot": result.get("screenshot"), + } + try: + resp2 = requests.post( + replay_result_url, + json=report, + timeout=10, + ) + if resp2.ok: + server_resp = resp2.json() + msg = ( + f"Resultat rapporte : replay_status={server_resp.get('replay_status')}, " + f"restant={server_resp.get('remaining_actions')}" + ) + print(f">>> {msg}") + logger.info(msg) + else: + print(f">>> Rapport resultat echoue : HTTP {resp2.status_code}") + logger.warning(f"Rapport resultat echoue : HTTP {resp2.status_code}") + except Exception as e: + print(f">>> Impossible de rapporter le resultat : {e}") + logger.warning(f"Impossible de rapporter le resultat : {e}") + + return True + + # ========================================================================= + # Helpers + # ========================================================================= + + def _click(self, pos, button_name): + """Deplacer la souris et cliquer. + + Supporte les boutons : left, right, double (double-clic gauche). + """ + self.mouse.position = pos + time.sleep(0.1) # Delai pour simuler le temps de reaction humain + + if button_name == "double": + self.mouse.click(Button.left, 2) + elif button_name == "right": + self.mouse.click(Button.right) + else: + self.mouse.click(Button.left) + + def _execute_key_combo(self, keys: list): + """ + Executer une combinaison de touches. + Ex: ["ctrl", "a"] -> Ctrl+A + Ex: ["enter"] -> Enter + """ + if not keys: + return + + # Resoudre les noms de touches vers les objets pynput + resolved = [] + for key_name in keys: + key_lower = key_name.lower() + if key_lower in _SPECIAL_KEYS: + resolved.append(_SPECIAL_KEYS[key_lower]) + elif len(key_name) == 1: + resolved.append(key_name) + else: + logger.warning(f"Touche inconnue : '{key_name}', ignoree") + + if not resolved: + return + + # Si une seule touche, simple press + if len(resolved) == 1: + self.keyboard.press(resolved[0]) + self.keyboard.release(resolved[0]) + return + + # Combo : maintenir les modificateurs, taper la derniere touche + modifiers = resolved[:-1] + final_key = resolved[-1] + + for mod in modifiers: + self.keyboard.press(mod) + time.sleep(0.05) + + self.keyboard.press(final_key) + self.keyboard.release(final_key) + + for mod in reversed(modifiers): + self.keyboard.release(mod) + + def _capture_screenshot_b64(self, max_width: int = 800, quality: int = 60) -> str: + """ + Capturer l'ecran et retourner le screenshot en base64. + + Args: + max_width: Largeur maximale en pixels (0 = pas de redimensionnement, + utile pour le template matching qui a besoin de la resolution native) + quality: Qualite JPEG (1-100, 60 pour preview, 85+ pour template matching) + """ + try: + from PIL import Image + + monitor = self.sct.monitors[1] + raw = self.sct.grab(monitor) + img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX") + + # Redimensionner si max_width > 0 + if max_width > 0 and img.width > max_width: + ratio = max_width / img.width + new_h = int(img.height * ratio) + img = img.resize((max_width, new_h), Image.LANCZOS) + + buffer = io.BytesIO() + img.save(buffer, format="JPEG", quality=quality) + return base64.b64encode(buffer.getvalue()).decode("utf-8") + except ImportError: + # PIL non disponible — retourner None + logger.debug("PIL non disponible, pas de screenshot base64") + return "" + except Exception as e: + logger.debug(f"Capture screenshot base64 echouee : {e}") + return "" diff --git a/agent_v0/deploy/windows_client/agent_v1/core/window_info.py b/agent_v0/deploy/windows_client/agent_v1/core/window_info.py new file mode 100644 index 000000000..7e6be8744 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/core/window_info.py @@ -0,0 +1,55 @@ +# window_info.py +""" +Récupération des informations sur la fenêtre active (X11). + +v0 : +- utilise xdotool pour obtenir : + - le titre de la fenêtre active + - le PID de la fenêtre active, puis le nom du process via ps + +Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". +""" + +from __future__ import annotations + +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Nécessite xdotool installé sur le système. + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } diff --git a/agent_v0/deploy/windows_client/agent_v1/core/window_info_crossplatform.py b/agent_v0/deploy/windows_client/agent_v1/core/window_info_crossplatform.py new file mode 100644 index 000000000..ba059a3fc --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/core/window_info_crossplatform.py @@ -0,0 +1,192 @@ +# window_info_crossplatform.py +""" +Récupération des informations sur la fenêtre active - CROSS-PLATFORM + +Supporte: +- Linux (X11 via xdotool) +- Windows (via pywin32) +- macOS (via pyobjc) + +Installation des dépendances: + pip install pywin32 # Windows + pip install pyobjc-framework-Cocoa # macOS + pip install psutil # Tous OS +""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Détecte automatiquement l'OS et utilise la méthode appropriée. + """ + system = platform.system() + + if system == "Linux": + return _get_window_info_linux() + elif system == "Windows": + return _get_window_info_windows() + elif system == "Darwin": # macOS + return _get_window_info_macos() + else: + return {"title": "unknown_window", "app_name": "unknown_app"} + + +def _get_window_info_linux() -> Dict[str, str]: + """ + Linux: utilise xdotool (X11) + + Nécessite: sudo apt-get install xdotool + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + +def _get_window_info_windows() -> Dict[str, str]: + """ + Windows: utilise pywin32 + psutil + + Nécessite: pip install pywin32 psutil + """ + try: + import win32gui + import win32process + import psutil + + # Fenêtre au premier plan + hwnd = win32gui.GetForegroundWindow() + + # Titre de la fenêtre + title = win32gui.GetWindowText(hwnd) + if not title: + title = "unknown_window" + + # PID du processus + _, pid = win32process.GetWindowThreadProcessId(hwnd) + + # Nom du processus + try: + process = psutil.Process(pid) + app_name = process.name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pywin32 ou psutil non installé + return { + "title": "unknown_window (pywin32 missing)", + "app_name": "unknown_app (pywin32 missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +def _get_window_info_macos() -> Dict[str, str]: + """ + macOS: utilise pyobjc (AppKit) + + Nécessite: pip install pyobjc-framework-Cocoa + + Note: Nécessite les permissions "Accessibility" dans System Preferences + """ + try: + from AppKit import NSWorkspace + from Quartz import ( + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + # Application active + active_app = NSWorkspace.sharedWorkspace().activeApplication() + app_name = active_app.get('NSApplicationName', 'unknown_app') + + # Titre de la fenêtre (via Quartz) + # On cherche la fenêtre de l'app active qui est au premier plan + window_list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + title = "unknown_window" + for window in window_list: + owner_name = window.get('kCGWindowOwnerName', '') + if owner_name == app_name: + window_title = window.get('kCGWindowName', '') + if window_title: + title = window_title + break + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pyobjc non installé + return { + "title": "unknown_window (pyobjc missing)", + "app_name": "unknown_app (pyobjc missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +# Test rapide +if __name__ == "__main__": + import time + + print(f"OS détecté: {platform.system()}") + print("\nTest de capture fenêtre active (5 secondes)...") + print("Changez de fenêtre pour tester!\n") + + for i in range(5): + info = get_active_window_info() + print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") + time.sleep(1) diff --git a/agent_v0/deploy/windows_client/agent_v1/main.py b/agent_v0/deploy/windows_client/agent_v1/main.py new file mode 100644 index 000000000..b0cc13ded --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/main.py @@ -0,0 +1,325 @@ +# agent_v1/main.py +""" +Point d'entree Agent V1 - Enrichi avec Intelligence de Contexte, Heartbeat et Replay. + +Boucles paralleles (threads daemon) : + - _heartbeat_loop : capture periodique toutes les 5s + - _command_watchdog_loop : surveillance du fichier command.json (legacy) + - _replay_poll_loop : polling du serveur pour les actions de replay (P0-5) +""" + +import sys +import os +import uuid +import time +import logging +import threading +from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID +from .core.captor import EventCaptorV1 +from .core.executor import ActionExecutorV1 +from .network.streamer import TraceStreamer +from .ui.smart_tray import SmartTrayV1 +from .ui.chat_window import ChatWindow +from .session.storage import SessionStorage +from .vision.capturer import VisionCapturer + +# Import optionnel du client serveur (pour le chat et les workflows) +# Deux chemins : relatif (depuis agent_v0.agent_v1) ou absolu (depuis C:\rpa_vision\agent_v1) +try: + from ..lea_ui.server_client import LeaServerClient +except (ImportError, ValueError): + try: + from lea_ui.server_client import LeaServerClient + except ImportError: + LeaServerClient = None + +# Configuration du logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger(__name__) + +# Intervalle de polling replay (secondes) +REPLAY_POLL_INTERVAL = 1.0 + + +class AgentV1: + def __init__(self, user_id="demo_user"): + self.user_id = user_id + self.machine_id = MACHINE_ID + self.session_id = None + self.session_dir = None + + # Gestion du stockage local et nettoyage + self.storage = SessionStorage(SESSIONS_ROOT) + threading.Thread(target=self._delayed_cleanup, daemon=True).start() + + self.vision = None + self.streamer = None + self.captor = None + self.shot_counter = 0 + self.running = False + + # Executeur partage entre watchdog et replay + self._executor = None + # Flag pour indiquer qu'un replay est en cours (eviter les conflits) + self._replay_active = False + + # Client serveur pour le chat et les workflows + self._server_client = None + if LeaServerClient is not None: + self._server_client = LeaServerClient() + + # Fenetre de chat Lea (tkinter natif) + server_host = ( + self._server_client.server_host + if self._server_client is not None + else os.getenv("RPA_SERVER_HOST", "localhost") + ) + self._chat_window = ChatWindow( + server_client=self._server_client, + on_start_callback=self.start_session, + server_host=server_host, + chat_port=5004, + ) + + # Executeur pour le replay (doit exister avant le poll) + self._executor = ActionExecutorV1() + + # Boucle de polling replay PERMANENTE (pas besoin de session active) + self.running = True + threading.Thread(target=self._replay_poll_loop, daemon=True).start() + + # UI Tray intelligent (remplace TrayAppV1, plus de PyQt5) + self.ui = SmartTrayV1( + self.start_session, + self.stop_session, + server_client=self._server_client, + chat_window=self._chat_window, + machine_id=self.machine_id, + ) + + def _delayed_cleanup(self): + """Nettoyage en arrière-plan après 30s pour ne pas bloquer le démarrage.""" + time.sleep(30) + self.storage.run_auto_cleanup() + + def start_session(self, workflow_name): + self.session_id = f"sess_{time.strftime('%Y%m%dT%H%M%S')}_{uuid.uuid4().hex[:6]}" + self.session_dir = self.storage.get_session_dir(self.session_id) + + self.vision = VisionCapturer(str(self.session_dir)) + + self.streamer = TraceStreamer(self.session_id, machine_id=self.machine_id) + self.captor = EventCaptorV1(self._on_event_bridge) + + # Initialiser l'executeur partage + self._executor = ActionExecutorV1() + + self.shot_counter = 0 + self.running = True + self._replay_active = False + self.streamer.start() + self.captor.start() + + # Heartbeat Contextuel (Toutes les 5s par defaut) + threading.Thread(target=self._heartbeat_loop, daemon=True).start() + + # Watchdog de Commandes (GHOST Replay — legacy fichier) + threading.Thread(target=self._command_watchdog_loop, daemon=True).start() + + # Boucle de polling replay (P0-5 — pull depuis le serveur) + threading.Thread(target=self._replay_poll_loop, daemon=True).start() + + logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...") + + def _command_watchdog_loop(self): + """Surveille un fichier de commande pour executer des ordres visuels (legacy).""" + import json + import platform + from .config import BASE_DIR + + # Chemin du fichier de commande selon l'OS + if platform.system() == "Windows": + cmd_path = "C:\\rpa_vision\\command.json" + else: + cmd_path = str(BASE_DIR / "command.json") + + while self.running: + # Ne pas traiter les commandes fichier pendant un replay serveur + if self._replay_active: + time.sleep(1) + continue + + if os.path.exists(cmd_path): + try: + with open(cmd_path, "r") as f: + order = json.load(f) + os.remove(cmd_path) # On consomme l'ordre + if self._executor: + self._executor.execute_normalized_order(order) + except Exception as e: + logger.error(f"Erreur Watchdog: {e}") + time.sleep(1) + + def _replay_poll_loop(self): + """ + Boucle de polling pour les actions de replay depuis le serveur (P0-5). + + Tourne en parallele du heartbeat et du watchdog. + Poll GET /replay/next toutes les REPLAY_POLL_INTERVAL secondes. + Quand une action est recue, l'execute via l'executor et rapporte le resultat. + """ + msg = ( + f"[REPLAY] Boucle replay demarree — poll toutes les " + f"{REPLAY_POLL_INTERVAL}s sur {SERVER_URL}" + ) + print(msg) + logger.info(msg) + + poll_count = 0 + while self.running: + if not self._executor: + time.sleep(REPLAY_POLL_INTERVAL) + continue + + # Utiliser la session active ou un ID par défaut pour le replay + poll_session = self.session_id or f"agent_{self.user_id}" + + # Log periodique pour confirmer que la boucle tourne (toutes les 60s) + poll_count += 1 + if poll_count % int(60 / REPLAY_POLL_INTERVAL) == 0: + print( + f"[REPLAY] Poll #{poll_count} — session={poll_session} " + f"— serveur={SERVER_URL}" + ) + + try: + # Tenter de recuperer et executer une action + had_action = self._executor.poll_and_execute( + session_id=poll_session, + server_url=SERVER_URL, + machine_id=self.machine_id, + ) + + if had_action: + if not self._replay_active: + self._replay_active = True + self.ui.set_replay_active(True) + # Si une action a ete executee, poll plus rapidement + # pour enchainer les actions du workflow + time.sleep(0.2) + else: + # Pas d'action en attente — utiliser le backoff de l'executor + # (augmente si le serveur est indisponible, reset a 1s sinon) + if self._replay_active: + print("[REPLAY] Replay termine — retour en mode capture") + logger.info("Replay termine — retour en mode capture") + self._replay_active = False + self.ui.set_replay_active(False) + poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL) + time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL)) + + except Exception as e: + print(f"[REPLAY] ERREUR boucle replay : {e}") + logger.error(f"Erreur replay poll loop : {e}") + self._replay_active = False + poll_delay = getattr(self._executor, '_poll_backoff', REPLAY_POLL_INTERVAL) + time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL)) + + def stop_session(self): + self.running = False + if self.captor: self.captor.stop() + if self.streamer: self.streamer.stop() + logger.info(f"Session {self.session_id} terminée.") + + _last_heartbeat_hash: str = "" + + def _heartbeat_loop(self): + """Capture périodique pour donner du contexte au stagiaire. + Déduplication : n'envoie que si l'écran a changé. + """ + while self.running: + try: + full_path = self.vision.capture_full_context("heartbeat") + if full_path: + # Hash rapide pour détecter les changements d'écran + img_hash = self._quick_hash(full_path) + if img_hash != self._last_heartbeat_hash: + self._last_heartbeat_hash = img_hash + self.streamer.push_image(full_path, f"heartbeat_{int(time.time())}") + self.streamer.push_event({"type": "heartbeat", "image": full_path, "timestamp": time.time(), "machine_id": self.machine_id}) + except Exception as e: + logger.error(f"Heartbeat error: {e}") + time.sleep(5) + + @staticmethod + def _quick_hash(image_path: str) -> str: + """Hash perceptuel rapide (16x16 niveaux de gris).""" + try: + from PIL import Image + import hashlib + img = Image.open(image_path).resize((16, 16)).convert('L') + return hashlib.md5(img.tobytes()).hexdigest() + except Exception: + return "" + + def _on_event_bridge(self, event): + """Pont intelligent avec capture duale et post-action monitoring.""" + if not self.session_id: + return + + # Injecter l'identifiant machine dans chaque événement (multi-machine) + event["machine_id"] = self.machine_id + + # Injecter le contexte fenêtre dans chaque événement (nécessaire + # pour que le serveur maintienne last_window_info) + if self.captor and self.captor.last_window: + event["window"] = self.captor.last_window + + # Capture Proactive sur changement de fenêtre + if event["type"] == "window_focus_change": + full_path = self.vision.capture_full_context("focus_change") + event["screenshot_context"] = full_path + self.streamer.push_image(full_path, f"focus_{int(time.time())}") + + # 🔴 Capture Interactive (Dual) + if event["type"] in ["mouse_click", "key_combo"]: + self.shot_counter += 1 + shot_id = f"shot_{self.shot_counter:04d}" + + pos = event.get("pos", (0, 0)) + capture_info = self.vision.capture_dual(pos[0], pos[1], shot_id) + + event["screenshot_id"] = shot_id + event["vision_info"] = capture_info + + self._stream_capture_info(capture_info, shot_id) + + # 🕒 POST-ACTION : Capture du résultat après 1s (pour voir le résultat du clic) + threading.Timer(1.0, self._capture_result, args=(shot_id,)).start() + + self.ui.update_stats(self.shot_counter) + print(f"📸 Action capturée : {event['type']}") + self.streamer.push_event(event) + + def _capture_result(self, base_shot_id: str): + """Capture l'état de l'écran 1s après l'action pour voir l'effet.""" + if not self.running: return + res_path = self.vision.capture_full_context(f"result_of_{base_shot_id}") + self.streamer.push_image(res_path, f"res_{base_shot_id}") + self.streamer.push_event({"type": "action_result", "base_shot_id": base_shot_id, "image": res_path}) + + def _stream_capture_info(self, capture_info, shot_id): + if "full" in capture_info: + self.streamer.push_image(capture_info["full"], f"{shot_id}_full") + if "crop" in capture_info: + self.streamer.push_image(capture_info["crop"], f"{shot_id}_crop") + + def run(self): + self.ui.run() + +def main(): + agent = AgentV1() + agent.run() + +if __name__ == "__main__": + main() diff --git a/agent_v0/deploy/windows_client/agent_v1/monitoring/__init__.py b/agent_v0/deploy/windows_client/agent_v1/monitoring/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/network/__init__.py b/agent_v0/deploy/windows_client/agent_v1/network/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/network/streamer.py b/agent_v0/deploy/windows_client/agent_v1/network/streamer.py new file mode 100644 index 000000000..65dd50f97 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/network/streamer.py @@ -0,0 +1,398 @@ +# agent_v1/network/streamer.py +""" +Streaming temps réel pour Agent V1. +Exploite la fibre pour envoyer les événements au fur et à mesure. + +Endpoints serveur (api_stream.py, port 5005) : + POST /api/v1/traces/stream/register — enregistrer la session + POST /api/v1/traces/stream/event — événement temps réel + POST /api/v1/traces/stream/image — screenshot (full ou crop) + POST /api/v1/traces/stream/finalize — clôturer et construire le workflow + +Robustesse (P0-2) : + - Retry avec backoff exponentiel (1s/2s/4s, max 3 tentatives) + - Health-check périodique (30s) pour recovery du flag _server_available + - Compression JPEG qualité 85 pour les images (réduction ~5-10x) + - Backpressure : queue bornée (maxsize=100), drop des heartbeat si pleine +""" + +import io +import logging +import queue +import threading +import time + +import requests +from PIL import Image + +from ..config import STREAMING_ENDPOINT + +logger = logging.getLogger(__name__) + +# Paramètres de retry +MAX_RETRIES = 3 +RETRY_DELAYS = [1.0, 2.0, 4.0] # Backoff exponentiel + +# Paramètres de health-check +HEALTH_CHECK_INTERVAL_S = 30 + +# Paramètres de compression +JPEG_QUALITY = 85 + +# Taille max de la queue (backpressure) +QUEUE_MAX_SIZE = 100 + +# Types d'événements à ne jamais dropper +PRIORITY_EVENT_TYPES = {"click", "key", "scroll", "action", "screenshot"} + + +class TraceStreamer: + def __init__(self, session_id: str, machine_id: str = "default"): + self.session_id = session_id + self.machine_id = machine_id # Identifiant machine pour le multi-machine + self.queue: queue.Queue = queue.Queue(maxsize=QUEUE_MAX_SIZE) + self.running = False + self._thread = None + self._health_thread = None + self._server_available = True # Désactivé après trop d'échecs + + def start(self): + """Démarrer le streaming et enregistrer la session côté serveur.""" + self.running = True + self._register_session() + # Thread principal d'envoi + self._thread = threading.Thread(target=self._stream_loop, daemon=True) + self._thread.start() + # Thread de health-check pour recovery + self._health_thread = threading.Thread( + target=self._health_check_loop, daemon=True + ) + self._health_thread.start() + logger.info(f"Streamer pour {self.session_id} démarré") + + def stop(self): + """Arrêter le streaming et finaliser la session côté serveur. + + Attend que la queue se vide (max 30s) avant de finaliser, + pour que toutes les images soient envoyées au serveur. + """ + self.running = False + + # Attendre que la queue se vide (les images doivent être envoyées) + if self._thread: + drain_start = time.time() + while not self.queue.empty() and (time.time() - drain_start) < 30: + time.sleep(0.5) + if not self.queue.empty(): + logger.warning( + f"Queue non vide après 30s ({self.queue.qsize()} items restants)" + ) + self._thread.join(timeout=5.0) + + if self._health_thread: + self._health_thread.join(timeout=2.0) + + self._finalize_session() + logger.info(f"Streamer pour {self.session_id} arrêté") + + def push_event(self, event_data: dict): + """Enfile un événement pour envoi immédiat. + + Si la queue est pleine (backpressure), les heartbeat sont droppés + tandis que les événements utilisateur (click, key, scroll, action) + et screenshots sont toujours conservés. + """ + self._enqueue_with_backpressure("event", event_data) + + def push_image(self, image_path: str, screenshot_id: str): + """Enfile une image pour envoi asynchrone.""" + if not image_path: + return # Ignorer les chemins vides (heartbeat sans changement) + self._enqueue_with_backpressure("image", (image_path, screenshot_id)) + + # ========================================================================= + # Backpressure — gestion de la queue bornée + # ========================================================================= + + def _enqueue_with_backpressure(self, item_type: str, data): + """Ajouter un item à la queue avec gestion du backpressure. + + Quand la queue est pleine : + - Les événements prioritaires (click, key, action, screenshot) sont + ajoutés en bloquant brièvement (0.5s) + - Les heartbeat sont silencieusement droppés + """ + is_priority = self._is_priority_item(item_type, data) + + try: + self.queue.put_nowait((item_type, data)) + except queue.Full: + if is_priority: + # Événement prioritaire : on attend un peu pour l'ajouter + try: + self.queue.put((item_type, data), timeout=0.5) + except queue.Full: + logger.warning( + f"Queue pleine — événement prioritaire droppé " + f"(type={item_type})" + ) + else: + # Heartbeat ou événement non-critique : on drop silencieusement + logger.debug( + f"Queue pleine — heartbeat/non-prioritaire droppé " + f"(type={item_type})" + ) + + def _is_priority_item(self, item_type: str, data) -> bool: + """Vérifie si un item est prioritaire (ne doit pas être droppé). + + Les images sont toujours prioritaires. Pour les événements, + on regarde le type d'événement (click, key, scroll, action). + """ + if item_type == "image": + return True + if item_type == "event" and isinstance(data, dict): + event_type = data.get("type", "").lower() + return event_type in PRIORITY_EVENT_TYPES + return False + + # ========================================================================= + # Boucle d'envoi + # ========================================================================= + + def _stream_loop(self): + """Boucle d'envoi asynchrone (thread daemon).""" + consecutive_failures = 0 + while self.running or not self.queue.empty(): + try: + item_type, data = self.queue.get(timeout=0.5) + success = False + if item_type == "event": + success = self._send_with_retry(self._send_event, data) + elif item_type == "image": + success = self._send_with_retry(self._send_image, *data) + self.queue.task_done() + + if success: + consecutive_failures = 0 + else: + consecutive_failures += 1 + if consecutive_failures >= 10: + logger.warning( + "10 échecs consécutifs — serveur marqué indisponible" + ) + self._server_available = False + consecutive_failures = 0 + + except queue.Empty: + continue + except Exception as e: + logger.error(f"Erreur Streaming Loop: {e}") + + # ========================================================================= + # Retry avec backoff exponentiel + # ========================================================================= + + def _send_with_retry(self, send_fn, *args) -> bool: + """Tente l'envoi avec retry et backoff exponentiel. + + 3 tentatives max avec délais de 1s, 2s, 4s entre chaque. + Retourne True si l'envoi a réussi, False sinon. + """ + # Première tentative (sans délai) + if send_fn(*args): + return True + + # Retries avec backoff + for attempt, delay in enumerate(RETRY_DELAYS, start=1): + if not self.running: + # On arrête les retries si le streamer est en cours d'arrêt + break + logger.debug( + f"Retry {attempt}/{MAX_RETRIES} dans {delay}s..." + ) + time.sleep(delay) + if send_fn(*args): + logger.debug(f"Retry {attempt} réussi") + return True + + logger.debug(f"Envoi échoué après {MAX_RETRIES} retries") + return False + + # ========================================================================= + # Health-check périodique pour recovery + # ========================================================================= + + def _health_check_loop(self): + """Vérifie périodiquement si le serveur est redevenu disponible. + + Toutes les 30s, tente un GET /stats. Si le serveur répond, + remet _server_available = True et ré-enregistre la session. + """ + while self.running: + time.sleep(HEALTH_CHECK_INTERVAL_S) + if not self.running: + break + if self._server_available: + # Serveur déjà disponible, rien à faire + continue + # Tenter un health-check + try: + resp = requests.get( + f"{STREAMING_ENDPOINT}/stats", + timeout=3, + ) + if resp.ok: + logger.info( + "Health-check OK — serveur redevenu disponible, " + "ré-enregistrement de la session" + ) + self._server_available = True + self._register_session() + except Exception: + logger.debug("Health-check échoué — serveur toujours indisponible") + + # ========================================================================= + # Compression JPEG + # ========================================================================= + + def _compress_image_to_jpeg(self, path: str) -> tuple: + """Compresse une image (PNG ou autre) en JPEG qualité 85 en mémoire. + + Retourne un tuple (bytes_io, content_type, filename_suffix). + Si la compression échoue, renvoie le fichier original en PNG. + """ + try: + img = Image.open(path) + # Convertir en RGB si nécessaire (JPEG ne supporte pas l'alpha) + if img.mode in ("RGBA", "LA", "P"): + img = img.convert("RGB") + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=JPEG_QUALITY, optimize=True) + buf.seek(0) + return buf, "image/jpeg", ".jpg" + except FileNotFoundError: + # Fichier introuvable — propager l'erreur (pas de fallback possible) + logger.warning(f"Fichier image introuvable pour compression : {path}") + raise + except Exception as e: + logger.warning(f"Compression JPEG échouée, envoi PNG brut: {e}") + return None, None, None + + # ========================================================================= + # Envois HTTP + # ========================================================================= + + def _register_session(self): + """Enregistrer la session auprès du serveur (avec identifiant machine).""" + try: + resp = requests.post( + f"{STREAMING_ENDPOINT}/register", + params={ + "session_id": self.session_id, + "machine_id": self.machine_id, + }, + timeout=3, + ) + if resp.ok: + logger.info( + f"Session {self.session_id} enregistrée sur le serveur " + f"(machine={self.machine_id})" + ) + self._server_available = True + else: + logger.warning(f"Enregistrement session échoué: {resp.status_code}") + except Exception as e: + logger.debug(f"Serveur indisponible pour register: {e}") + self._server_available = False + + def _finalize_session(self): + """Finaliser la session (construction du workflow côté serveur). + + IMPORTANT : tente TOUJOURS l'envoi, indépendamment de _server_available. + C'est la dernière chance de sauver les données de la session. + """ + try: + resp = requests.post( + f"{STREAMING_ENDPOINT}/finalize", + params={ + "session_id": self.session_id, + "machine_id": self.machine_id, + }, + timeout=30, # Le build workflow peut prendre du temps + ) + if resp.ok: + result = resp.json() + logger.info(f"Session finalisée: {result}") + else: + logger.warning(f"Finalisation échouée: {resp.status_code}") + except Exception as e: + logger.debug(f"Finalisation échouée: {e}") + + def _send_event(self, event: dict) -> bool: + """Envoyer un événement au serveur (avec identifiant machine).""" + if not self._server_available: + return False + try: + payload = { + "session_id": self.session_id, + "timestamp": time.time(), + "event": event, + "machine_id": self.machine_id, + } + resp = requests.post( + f"{STREAMING_ENDPOINT}/event", + json=payload, + timeout=2, + ) + return resp.ok + except Exception as e: + logger.debug(f"Streaming Event échoué: {e}") + return False + + def _send_image(self, path: str, shot_id: str) -> bool: + """Envoyer un screenshot au serveur, compressé en JPEG. + + Utilise un context manager pour le fallback PNG afin d'éviter + les fuites de descripteurs de fichier. + """ + if not self._server_available: + return False + try: + # Tenter la compression JPEG (réduction ~5-10x vs PNG) + jpeg_buf, content_type, suffix = self._compress_image_to_jpeg(path) + + params = { + "session_id": self.session_id, + "shot_id": shot_id, + "machine_id": self.machine_id, + } + + if jpeg_buf is not None: + # Envoi du JPEG compressé (BytesIO, pas de fuite possible) + files = { + "file": (f"{shot_id}{suffix}", jpeg_buf, content_type) + } + resp = requests.post( + f"{STREAMING_ENDPOINT}/image", + files=files, + params=params, + timeout=5, + ) + return resp.ok + else: + # Fallback : envoi PNG original avec context manager + with open(path, "rb") as f: + files = { + "file": (f"{shot_id}.png", f, "image/png") + } + resp = requests.post( + f"{STREAMING_ENDPOINT}/image", + files=files, + params=params, + timeout=5, + ) + return resp.ok + except Exception as e: + logger.debug(f"Streaming Image échoué: {e}") + return False diff --git a/agent_v0/deploy/windows_client/agent_v1/session/__init__.py b/agent_v0/deploy/windows_client/agent_v1/session/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/session/storage.py b/agent_v0/deploy/windows_client/agent_v1/session/storage.py new file mode 100644 index 000000000..ab2913a09 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/session/storage.py @@ -0,0 +1,65 @@ +# agent_v1/session/storage.py +""" +Gestionnaire de stockage local robuste pour Agent V1. +Gère le chiffrement des données au repos et l'auto-nettoyage du disque. +""" + +import os +import shutil +import time +import logging +from pathlib import Path +from datetime import datetime, timedelta + +logger = logging.getLogger("session_storage") + +class SessionStorage: + def __init__(self, base_dir: Path, max_size_gb: int = 5, retention_days: int = 1): + self.base_dir = base_dir + self.max_size_bytes = max_size_gb * 1024 * 1024 * 1024 + self.retention_days = retention_days + self.base_dir.mkdir(parents=True, exist_ok=True) + + def get_session_dir(self, session_id: str) -> Path: + """Retourne et crée le dossier pour une session.""" + session_path = self.base_dir / session_id + session_path.mkdir(exist_ok=True) + (session_path / "shots").mkdir(exist_ok=True) + return session_path + + def run_auto_cleanup(self): + """Lance le nettoyage automatique basé sur l'âge et la taille.""" + logger.info("🧹 Lancement du nettoyage automatique du stockage local...") + self._cleanup_by_age() + self._cleanup_by_size() + + def _cleanup_by_age(self): + """Supprime les sessions plus vieilles que retention_days.""" + threshold = datetime.now() - timedelta(days=self.retention_days) + for session_path in self.base_dir.iterdir(): + if session_path.is_dir(): + mtime = datetime.fromtimestamp(session_path.stat().st_mtime) + if mtime < threshold: + logger.info(f"🗑️ Purge session ancienne : {session_path.name}") + shutil.rmtree(session_path) + + def _cleanup_by_size(self): + """Supprime les sessions les plus anciennes si la taille totale dépasse max_size_bytes.""" + sessions = [] + total_size = 0 + for session_path in self.base_dir.iterdir(): + if session_path.is_dir(): + size = sum(f.stat().st_size for f in session_path.rglob('*') if f.is_file()) + sessions.append((session_path, session_path.stat().st_mtime, size)) + total_size += size + + if total_size > self.max_size_bytes: + logger.warning(f"⚠️ Stockage saturé ({total_size/1e9:.2f} GB). Purge nécessaire.") + # Trier par date de modif (plus ancien d'abord) + sessions.sort(key=lambda x: x[1]) + for path, _, size in sessions: + if total_size <= self.max_size_bytes * 0.8: # On libère jusqu'à 80% du max + break + logger.info(f"🗑️ Purge session pour libérer de l'espace : {path.name} ({size/1e6:.1f} MB)") + shutil.rmtree(path) + total_size -= size diff --git a/agent_v0/deploy/windows_client/agent_v1/ui/__init__.py b/agent_v0/deploy/windows_client/agent_v1/ui/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/ui/notifications.py b/agent_v0/deploy/windows_client/agent_v1/ui/notifications.py new file mode 100644 index 000000000..be13e39ac --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/ui/notifications.py @@ -0,0 +1,201 @@ +# agent_v1/ui/notifications.py +""" +Gestionnaire de notifications toast natives (Windows/Linux/macOS). +Utilise plyer pour les notifications système, sans dépendance PyQt5. + +Remplace les dialogues Qt par des toasts non-bloquants. +Thread-safe avec rate limiting (1 notification / 2 secondes max). +""" + +import logging +import threading +import time +from typing import Optional + +logger = logging.getLogger(__name__) + +# Import conditionnel de plyer — fallback silencieux si absent +try: + from plyer import notification as _plyer_notification + _PLYER_AVAILABLE = True +except ImportError: + _plyer_notification = None + _PLYER_AVAILABLE = False + logger.warning( + "plyer non installé — les notifications toast sont désactivées. " + "Installer avec : pip install plyer" + ) + +# Nom de l'application affiché dans les toasts +APP_NAME = "Léa - RPA Vision" + +# Intervalle minimum entre deux notifications (secondes) +RATE_LIMIT_SECONDS = 2 + + +class NotificationManager: + """ + Gestionnaire centralisé de notifications toast. + + Thread-safe : peut être appelé depuis n'importe quel thread. + Rate limiting : une seule notification toutes les 2 secondes, + les notifications excédentaires sont ignorées (pas de file d'attente + pour éviter un flood différé). + """ + + def __init__(self, icon_path: Optional[str] = None): + """ + Initialise le gestionnaire. + + Args: + icon_path: Chemin vers l'icône (.ico/.png) pour les toasts. + None = icône par défaut du système. + """ + self._icon_path = icon_path + self._lock = threading.Lock() + self._last_notification_time: float = 0.0 + + # ------------------------------------------------------------------ # + # Méthode générique + # ------------------------------------------------------------------ # + + def notify(self, title: str, message: str, timeout: int = 5) -> bool: + """ + Affiche une notification toast. + + Args: + title: Titre de la notification. + message: Corps du message. + timeout: Durée d'affichage en secondes. + + Returns: + True si la notification a été envoyée, False sinon + (plyer absent ou rate limit atteint). + """ + if not _PLYER_AVAILABLE: + logger.debug("Notification ignorée (plyer absent) : %s", title) + return False + + with self._lock: + now = time.monotonic() + elapsed = now - self._last_notification_time + if elapsed < RATE_LIMIT_SECONDS: + logger.debug( + "Notification ignorée (rate limit, %.1fs restantes) : %s", + RATE_LIMIT_SECONDS - elapsed, + title, + ) + return False + self._last_notification_time = now + + # Envoi dans un thread dédié pour ne jamais bloquer l'appelant + thread = threading.Thread( + target=self._send, + args=(title, message, timeout), + daemon=True, + ) + thread.start() + return True + + def _send(self, title: str, message: str, timeout: int) -> None: + """Envoi effectif de la notification (exécuté dans un thread dédié).""" + try: + _plyer_notification.notify( + title=title, + message=message, + app_name=APP_NAME, + app_icon=self._icon_path, + timeout=timeout, + ) + except Exception: + logger.exception("Erreur lors de l'envoi de la notification toast") + + # ------------------------------------------------------------------ # + # Méthodes métier + # ------------------------------------------------------------------ # + + def greet(self) -> bool: + """Notification de bienvenue au démarrage.""" + return self.notify( + title=APP_NAME, + message="Bonjour ! Léa est prête à travailler.", + timeout=5, + ) + + def session_started(self, workflow_name: str) -> bool: + """Notification de début de session.""" + return self.notify( + title="Session démarrée", + message=f"Session démarrée : {workflow_name}", + timeout=5, + ) + + def session_ended(self, action_count: int) -> bool: + """Notification de fin de session avec le nombre d'actions.""" + return self.notify( + title="Session terminée", + message=f"Session terminée : {action_count} actions capturées.", + timeout=5, + ) + + def workflow_learned(self, name: str) -> bool: + """Notification quand un workflow a été appris.""" + return self.notify( + title="Nouveau workflow appris", + message=f"J'ai appris '{name}' ! Je peux essayer quand vous voulez.", + timeout=7, + ) + + def replay_started(self, workflow_name: str, step_count: int) -> bool: + """Notification de début de replay.""" + return self.notify( + title="Replay en cours", + message=f"Replay de '{workflow_name}' ({step_count} étapes)", + timeout=5, + ) + + def replay_step(self, current: int, total: int, description: str) -> bool: + """Notification de progression d'une étape de replay.""" + return self.notify( + title=f"Étape {current}/{total}", + message=f"Étape {current}/{total} : {description}", + timeout=3, + ) + + def replay_finished(self, success: bool, workflow_name: str) -> bool: + """Notification de fin de replay (succès ou échec).""" + if success: + return self.notify( + title="Replay terminé !", + message=f"Replay de '{workflow_name}' terminé avec succès.", + timeout=5, + ) + else: + return self.notify( + title="Replay échoué", + message=f"Le replay de '{workflow_name}' a échoué.", + timeout=7, + ) + + def connection_changed(self, connected: bool, server_host: str) -> bool: + """Notification de changement d'état de la connexion serveur.""" + if connected: + return self.notify( + title="Connexion établie", + message=f"Connecté au serveur {server_host}", + timeout=5, + ) + else: + return self.notify( + title="Connexion perdue", + message=f"Connexion perdue avec le serveur {server_host}", + timeout=7, + ) + + def error(self, message: str) -> bool: + """Notification d'erreur.""" + return self.notify( + title="Erreur - Léa", + message=message, + timeout=10, + ) diff --git a/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py b/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py new file mode 100644 index 000000000..918473634 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py @@ -0,0 +1,625 @@ +# agent_v1/ui/smart_tray.py +""" +Tray intelligent pour Agent V1 — remplace tray.py (plus de PyQt5). + +Utilise pystray pour l'icone systray et tkinter (stdlib) pour les dialogues. +Communication serveur via LeaServerClient (chat:5004, streaming:5005). +Notifications via NotificationManager (module parallele). +Fenetre de chat Lea integree via ChatWindow (pywebview). + +Architecture de threads : + - Thread principal : boucle pystray (icon.run) + - Thread daemon : verification connexion serveur (toutes les 30s) + - Thread daemon : rafraichissement cache workflows (toutes les 5 min) + - Thread daemon : pywebview (fenetre de chat Lea) + - Thread daemon : hotkey global Ctrl+Shift+L (si keyboard disponible) + - Threads ephemeres : dialogues tkinter (chaque dialogue cree son propre Tk()) +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +from PIL import Image, ImageDraw +import pystray +from pystray import MenuItem as item + +from .notifications import NotificationManager + +logger = logging.getLogger(__name__) + +# Intervalles (secondes) +_CONNECTION_CHECK_INTERVAL = 30 +_WORKFLOW_CACHE_TTL = 300 # 5 minutes + + +# --------------------------------------------------------------------------- +# Helpers tkinter (sans PyQt5) +# --------------------------------------------------------------------------- + +def _ask_string(title: str, prompt: str, default: str = "") -> Optional[str]: + """Dialogue de saisie texte via tkinter (sans PyQt5). + + Cree une instance Tk() ephemere, affiche le dialogue, puis la detruit. + Compatible avec la boucle pystray (pas de mainloop persistant). + """ + import tkinter as tk + from tkinter import simpledialog + + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + result = simpledialog.askstring(title, prompt, initialvalue=default, parent=root) + root.destroy() + return result + + +def _show_info(title: str, message: str) -> None: + """Affiche une boite d'information via tkinter (sans PyQt5).""" + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + root.attributes('-topmost', True) + messagebox.showinfo(title, message, parent=root) + root.destroy() + + +# --------------------------------------------------------------------------- +# SmartTrayV1 +# --------------------------------------------------------------------------- + +class SmartTrayV1: + """Tray systeme intelligent pour Agent V1. + + Remplace TrayAppV1 (PyQt5) par pystray + tkinter. + Meme interface constructeur pour compatibilite avec main.py. + """ + + def __init__( + self, + on_start_callback: Callable[[str], None], + on_stop_callback: Callable[[], None], + server_client: Optional[Any] = None, + chat_window: Optional[Any] = None, + machine_id: str = "default", + ) -> None: + self.on_start = on_start_callback + self.on_stop = on_stop_callback + self.server_client = server_client + self.machine_id = machine_id # Identifiant machine (multi-machine) + + # Fenetre de chat Lea (pywebview) + self._chat_window = chat_window + + # Etat interne + self.icon: Optional[pystray.Icon] = None + self.is_recording = False + self.actions_count = 0 + + # Etat connexion serveur + self._connected = False + self._replay_active = False + + # Cache workflows + self._workflows: List[Dict[str, Any]] = [] + self._workflows_lock = threading.Lock() + self._workflows_last_fetch: float = 0.0 + + # Verrous + self._state_lock = threading.Lock() + self._stop_event = threading.Event() + + # Notifications + self._notifier = NotificationManager() + + # Icones d'etat (cercles colores) + self.icons = { + "idle": self._create_circle_icon("gray"), + "recording": self._create_circle_icon("red"), + "connected": self._create_circle_icon("green"), + "disconnected": self._create_circle_icon("orange"), + "replay": self._create_circle_icon("blue"), + } + + # Enregistrer le callback de changement de connexion sur le client + if self.server_client is not None: + self.server_client.set_on_connection_change(self._on_connection_change) + + logger.info("SmartTrayV1 initialise") + + # ------------------------------------------------------------------ + # Icones + # ------------------------------------------------------------------ + + @staticmethod + def _create_circle_icon(color: str) -> Image.Image: + """Genere une icone circulaire simple mais propre.""" + img = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.ellipse((4, 4, 60, 60), fill=color, outline="white", width=2) + return img + + def _current_icon(self) -> Image.Image: + """Retourne l'icone correspondant a l'etat courant.""" + if self._replay_active: + return self.icons["replay"] + if self.is_recording: + return self.icons["recording"] + if self._connected: + return self.icons["connected"] + if self.server_client is not None: + return self.icons["disconnected"] + return self.icons["idle"] + + def _update_icon(self) -> None: + """Met a jour l'icone et le menu du tray.""" + if self.icon is not None: + self.icon.icon = self._current_icon() + self.icon.update_menu() + + # ------------------------------------------------------------------ + # Menu dynamique + # ------------------------------------------------------------------ + + def _get_menu_items(self): + """Retourne les items du menu (appele a chaque ouverture du menu).""" + # Ligne de statut + if self.is_recording: + status_text = "\U0001f534 Enregistrement..." + elif self._connected: + status_text = "\U0001f7e2 Connect\u00e9" + else: + status_text = "\U0001f534 D\u00e9connect\u00e9" + + # Compteur d'actions (visible uniquement en enregistrement) + actions_text = f"\U0001f4ca {self.actions_count} actions captur\u00e9es" + + # Sous-menu workflows + workflow_items = self._build_workflow_submenu() + + # Ligne d'identification machine (toujours visible) + machine_text = f"\U0001f4bb {self.machine_id}" + + items = [ + # --- Identite machine --- + item(machine_text, lambda: None, enabled=False), + # --- Statut --- + item(status_text, lambda: None, enabled=False), + item( + actions_text, + lambda: None, + enabled=False, + visible=lambda _i: self.is_recording, + ), + pystray.Menu.SEPARATOR, + # --- Actions session --- + item( + "\U0001f680 D\u00e9marrer une session", + self._on_start_session, + visible=lambda _i: not self.is_recording, + ), + item( + "\u23f9\ufe0f Terminer et Envoyer", + self._on_stop_session, + visible=lambda _i: self.is_recording, + ), + pystray.Menu.SEPARATOR, + # --- Workflows --- + item( + "\U0001f4cb Workflows connus", + pystray.Menu(*workflow_items) if workflow_items else pystray.Menu( + item("(aucun workflow)", lambda: None, enabled=False), + ), + visible=lambda _i: self.server_client is not None, + ), + item( + "\U0001f504 Rafra\u00eechir les workflows", + self._on_refresh_workflows, + visible=lambda _i: self.server_client is not None, + ), + pystray.Menu.SEPARATOR, + # --- Chat --- + item( + "\U0001f4ac Que dois-je faire ?", + self._on_ask_server, + visible=lambda _i: self.server_client is not None and self._connected, + ), + item( + "\U0001f4ac Discuter avec L\u00e9a", + self._on_toggle_chat, + visible=lambda _i: self._chat_window is not None, + ), + pystray.Menu.SEPARATOR, + # --- Utilitaires --- + item("\U0001f4c2 Ouvrir le dossier sessions", self._on_open_folder), + item("\u274c Quitter", self._on_quit), + ] + return items + + def _build_workflow_submenu(self) -> List[pystray.MenuItem]: + """Construit la liste des workflows comme items de sous-menu.""" + with self._workflows_lock: + workflows = list(self._workflows) + + if not workflows: + return [item("(aucun workflow)", lambda: None, enabled=False)] + + items = [] + for wf in workflows: + wf_name = wf.get("name", wf.get("workflow_name", "Sans nom")) + wf_id = wf.get("id", wf.get("workflow_id", "")) + # Creer une closure avec les bonnes valeurs + items.append( + item(wf_name, self._make_replay_callback(wf_id, wf_name)) + ) + return items + + def _make_replay_callback( + self, workflow_id: str, workflow_name: str + ) -> Callable: + """Cree un callback de lancement de replay pour un workflow donne.""" + def _callback(_icon=None, _item=None): + self._launch_replay(workflow_id, workflow_name) + return _callback + + # ------------------------------------------------------------------ + # Actions utilisateur + # ------------------------------------------------------------------ + + def _on_start_session(self, _icon=None, _item=None) -> None: + """Demande le nom du workflow et demarre la session.""" + # Dialogue tkinter dans un thread dedie + def _dialog(): + name = _ask_string( + "Nouvelle Session", + "Quel workflow allons-nous apprendre aujourd'hui ?", + default="Ma_Tache_Quotidienne", + ) + if name: + with self._state_lock: + self.is_recording = True + self.actions_count = 0 + self._update_icon() + self._notifier.notify( + "Session d\u00e9marr\u00e9e", + f"Enregistrement du workflow \u00ab {name} \u00bb en cours.", + ) + self.on_start(name) + + threading.Thread(target=_dialog, daemon=True).start() + + def _on_stop_session(self, _icon=None, _item=None) -> None: + """Termine la session en cours et envoie les donnees.""" + count = self.actions_count + with self._state_lock: + self.is_recording = False + self._update_icon() + self.on_stop() + self._notifier.notify( + "Session termin\u00e9e", + f"Bravo ! {count} actions enregistr\u00e9es et envoy\u00e9es au serveur.", + ) + + def _on_refresh_workflows(self, _icon=None, _item=None) -> None: + """Rafraichit la liste des workflows depuis le serveur.""" + threading.Thread(target=self._fetch_workflows, daemon=True).start() + + def _on_ask_server(self, _icon=None, _item=None) -> None: + """Envoie 'Que dois-je faire ?' au serveur et affiche la reponse.""" + def _ask(): + if self.server_client is None: + return + response = self.server_client.send_chat_message( + "Que dois-je faire maintenant ?" + ) + if response: + # L'API renvoie {"response": {"message": "..."}} ou {"response": "..."} + resp = response.get("response", {}) + if isinstance(resp, dict): + text = resp.get("message", str(resp)) + else: + text = str(resp) + self._notifier.notify("Léa", text) + else: + self._notifier.notify( + "Erreur", + "Impossible de contacter le serveur.", + ) + + threading.Thread(target=_ask, daemon=True).start() + + def _on_toggle_chat(self, _icon=None, _item=None) -> None: + """Affiche ou masque la fenetre de chat Lea (pywebview).""" + if self._chat_window is None: + return + + def _toggle(): + try: + self._chat_window.toggle() + except Exception as e: + logger.error("Erreur toggle chat : %s", e) + self._notifier.notify( + "Erreur Chat", + f"Impossible d'ouvrir le chat : {e}", + ) + + threading.Thread(target=_toggle, daemon=True).start() + + def _launch_replay(self, workflow_id: str, workflow_name: str) -> None: + """Lance le replay d'un workflow.""" + def _replay(): + if self.server_client is None: + return + + with self._state_lock: + self._replay_active = True + self._update_icon() + self._notifier.notify( + "Replay", + f"Lancement du workflow \u00ab {workflow_name} \u00bb...", + ) + + try: + import requests + resp = requests.post( + f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start", + json={"workflow_id": workflow_id}, + timeout=10, + ) + if resp.ok: + logger.info("Replay demarre pour workflow %s", workflow_id) + else: + self._notifier.notify( + "Erreur Replay", + f"Le serveur a refus\u00e9 : HTTP {resp.status_code}", + ) + except Exception as e: + logger.error("Erreur lancement replay : %s", e) + self._notifier.notify( + "Erreur Replay", + f"Impossible de lancer le replay : {e}", + ) + finally: + with self._state_lock: + self._replay_active = False + self._update_icon() + + threading.Thread(target=_replay, daemon=True).start() + + def _on_open_folder(self, _icon=None, _item=None) -> None: + """Ouvre le dossier des sessions dans l'explorateur de fichiers.""" + from ..config import SESSIONS_ROOT + + sessions_path = str(SESSIONS_ROOT) + if os.name == "nt": + os.startfile(sessions_path) + else: + os.system(f'xdg-open "{sessions_path}"') + + def _on_quit(self, _icon=None, _item=None) -> None: + """Arrete proprement l'agent et quitte.""" + logger.info("Arret demande par l'utilisateur") + + # Arreter la session si en cours + if self.is_recording: + self.on_stop() + + # Signaler l'arret aux threads de fond + self._stop_event.set() + + # Fermer la fenetre de chat si ouverte + if self._chat_window is not None: + try: + self._chat_window.destroy() + except Exception as e: + logger.debug("Erreur fermeture chat : %s", e) + + # Arreter le hotkey global si actif + self._stop_hotkey() + + # Arreter le client serveur si present + if self.server_client is not None: + self.server_client.shutdown() + + # Arreter l'icone pystray + if self.icon is not None: + self.icon.stop() + + # ------------------------------------------------------------------ + # Verification connexion serveur (thread daemon) + # ------------------------------------------------------------------ + + def _connection_checker_loop(self) -> None: + """Verifie la connexion au serveur toutes les 30 secondes.""" + logger.info("Thread de verification connexion demarre") + + while not self._stop_event.is_set(): + if self.server_client is not None: + try: + was_connected = self._connected + self._connected = self.server_client.check_connection() + + if self._connected != was_connected: + self._update_icon() + # La notification est geree par _on_connection_change + except Exception as e: + logger.error("Erreur verification connexion : %s", e) + + self._stop_event.wait(timeout=_CONNECTION_CHECK_INTERVAL) + + logger.info("Thread de verification connexion arrete") + + def _on_connection_change(self, connected: bool) -> None: + """Callback appelee par LeaServerClient quand l'etat de connexion change.""" + with self._state_lock: + self._connected = connected + self._update_icon() + + if connected: + self._notifier.notify( + "Connexion \u00e9tablie", + f"Connect\u00e9 au serveur {self.server_client.server_host}.", + ) + # Rafraichir les workflows a la connexion + threading.Thread(target=self._fetch_workflows, daemon=True).start() + else: + self._notifier.notify( + "Connexion perdue", + "Le serveur n'est plus accessible.", + ) + + # ------------------------------------------------------------------ + # Cache workflows (thread daemon) + # ------------------------------------------------------------------ + + def _workflow_cache_loop(self) -> None: + """Rafraichit le cache des workflows toutes les 5 minutes.""" + logger.info("Thread de cache workflows demarre") + + while not self._stop_event.is_set(): + if self.server_client is not None and self._connected: + self._fetch_workflows() + + self._stop_event.wait(timeout=_WORKFLOW_CACHE_TTL) + + logger.info("Thread de cache workflows arrete") + + def _fetch_workflows(self) -> None: + """Recupere la liste des workflows depuis le serveur.""" + if self.server_client is None: + return + + try: + workflows = self.server_client.list_workflows() + with self._workflows_lock: + self._workflows = workflows + self._workflows_last_fetch = time.time() + logger.debug( + "Cache workflows mis a jour : %d workflows", len(workflows) + ) + # Forcer la reconstruction du menu + self._update_icon() + except Exception as e: + logger.error("Erreur recuperation workflows : %s", e) + + # ------------------------------------------------------------------ + # Mise a jour du compteur (compatibilite main.py) + # ------------------------------------------------------------------ + + def update_stats(self, count: int) -> None: + """Met a jour le compteur d'actions en temps reel dans le menu.""" + with self._state_lock: + self.actions_count = count + if self.icon is not None: + self.icon.update_menu() + + def set_replay_active(self, active: bool) -> None: + """Signale qu'un replay est en cours (appele depuis main.py).""" + with self._state_lock: + self._replay_active = active + self._update_icon() + + if active: + self._notifier.notify("Replay", "Execution du replay en cours...") + else: + self._notifier.notify("Replay termin\u00e9", "Le replay est termin\u00e9.") + + # ------------------------------------------------------------------ + # Hotkey global Ctrl+Shift+L (toggle chat) + # ------------------------------------------------------------------ + + _hotkey_hook = None # reference pour pouvoir le retirer + + def _start_hotkey(self) -> None: + """Enregistre le raccourci global Ctrl+Shift+L pour ouvrir le chat. + + Utilise la librairie 'keyboard' si disponible. + Silencieux si elle n'est pas installee (pas critique). + """ + if self._chat_window is None: + return + + try: + import keyboard + self._hotkey_hook = keyboard.add_hotkey( + "ctrl+shift+l", + self._on_toggle_chat, + suppress=False, + ) + logger.info("Hotkey Ctrl+Shift+L enregistre pour le chat Lea") + except ImportError: + logger.debug( + "keyboard non installe — hotkey Ctrl+Shift+L desactive. " + "Installer avec : pip install keyboard" + ) + except Exception as e: + logger.warning("Impossible d'enregistrer le hotkey : %s", e) + + def _stop_hotkey(self) -> None: + """Retire le raccourci global.""" + if self._hotkey_hook is not None: + try: + import keyboard + keyboard.remove_hotkey(self._hotkey_hook) + self._hotkey_hook = None + logger.debug("Hotkey Ctrl+Shift+L retire") + except Exception: + pass + + # ------------------------------------------------------------------ + # Point d'entree + # ------------------------------------------------------------------ + + def run(self) -> None: + """Demarre le tray, les threads de fond, et entre dans la boucle principale.""" + # Notification d'accueil (avec identifiant machine) + self._notifier.notify( + "Agent V1", + f"Bonjour ! Agent RPA Vision pr\u00eat.\nMachine : {self.machine_id}", + ) + + # Enregistrer le hotkey global Ctrl+Shift+L (toggle chat) + self._start_hotkey() + + # Tooltip avec identifiant machine pour le multi-machine + tray_title = f"Agent V1 - {self.machine_id}" + + # Menu statique — reconstruit via _update_icon() quand l'état change + self.icon = pystray.Icon( + "AgentV1", + self._current_icon(), + tray_title, + menu=pystray.Menu(*self._get_menu_items()), + ) + + # Demarrer le thread de verification connexion + if self.server_client is not None: + conn_thread = threading.Thread( + target=self._connection_checker_loop, + daemon=True, + name="smart-tray-conn-check", + ) + conn_thread.start() + + # Demarrer le thread de cache workflows + wf_thread = threading.Thread( + target=self._workflow_cache_loop, + daemon=True, + name="smart-tray-wf-cache", + ) + wf_thread.start() + + # Premiere verification immediate + threading.Thread( + target=self._fetch_workflows, daemon=True + ).start() + + # Boucle principale pystray (bloquante) + logger.info("SmartTrayV1 demarre — entree dans la boucle pystray") + self.icon.run() diff --git a/agent_v0/deploy/windows_client/agent_v1/vision/__init__.py b/agent_v0/deploy/windows_client/agent_v1/vision/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/deploy/windows_client/agent_v1/vision/capturer.py b/agent_v0/deploy/windows_client/agent_v1/vision/capturer.py new file mode 100644 index 000000000..4557b7f18 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/vision/capturer.py @@ -0,0 +1,84 @@ +# agent_v1/vision/capturer.py +""" +Gestionnaire de vision avancé pour Agent V1. +Optimisé pour le streaming fibre avec détection de changement. +""" + +import os +import time +import logging +import hashlib +from PIL import Image, ImageFilter, ImageStat +import mss +from ..config import TARGETED_CROP_SIZE, SCREENSHOT_QUALITY + +logger = logging.getLogger(__name__) + +class VisionCapturer: + def __init__(self, session_dir: str): + self.session_dir = session_dir + self.shots_dir = os.path.join(session_dir, "shots") + os.makedirs(self.shots_dir, exist_ok=True) + # On ne crée plus self.sct ici car mss n'est pas thread-safe sous Windows + self.last_img_hash = None + + def capture_full_context(self, name_suffix: str, force=False) -> str: + """ + Capture l'écran complet. + Si force=False, vérifie d'abord si l'écran a changé. + """ + try: + with mss.mss() as sct: + monitor = sct.monitors[1] + sct_img = sct.grab(monitor) + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + + # Détection de changement (pour Heartbeat) + if not force: + current_hash = self._compute_quick_hash(img) + if current_hash == self.last_img_hash: + return "" # Pas de changement, on économise la fibre + self.last_img_hash = current_hash + + path = os.path.join(self.shots_dir, f"context_{int(time.time())}_{name_suffix}.png") + img.save(path, "PNG", quality=SCREENSHOT_QUALITY) + return path + except Exception as e: + logger.error(f"Erreur Context Capture: {e}") + return "" + + def capture_dual(self, x: int, y: int, screenshot_id: str, anonymize=False) -> dict: + """Capture duale (Full + Crop) systématique (forcée car liée à une action).""" + try: + with mss.mss() as sct: + full_path = os.path.join(self.shots_dir, f"{screenshot_id}_full.png") + monitor = sct.monitors[1] + sct_img = sct.grab(monitor) + img = Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRX") + + # Capture du Crop (Cœur de l'apprentissage qwen3-vl) + crop_path = os.path.join(self.shots_dir, f"{screenshot_id}_crop.png") + w, h = TARGETED_CROP_SIZE + left = max(0, x - w // 2) + top = max(0, y - h // 2) + crop_img = img.crop((left, top, left + w, top + h)) + + if anonymize: + crop_img = crop_img.filter(ImageFilter.GaussianBlur(radius=4)) + + img.save(full_path, "PNG", quality=SCREENSHOT_QUALITY) + crop_img.save(crop_path, "PNG", quality=SCREENSHOT_QUALITY) + + # Mise à jour du hash pour le prochain heartbeat + self.last_img_hash = self._compute_quick_hash(img) + + return {"full": full_path, "crop": crop_path} + except Exception as e: + logger.error(f"Erreur Dual Capture: {e}") + return {} + + def _compute_quick_hash(self, img: Image) -> str: + """Calcule un hash rapide basé sur une vignette réduite pour détecter les changements.""" + # On réduit l'image à 64x64 pour comparer les masses de couleurs (très rapide) + small_img = img.resize((64, 64), Image.NEAREST).convert("L") + return hashlib.md5(small_img.tobytes()).hexdigest() diff --git a/agent_v0/deploy/windows_client/agent_v1/window_info.py b/agent_v0/deploy/windows_client/agent_v1/window_info.py new file mode 100644 index 000000000..7e6be8744 --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/window_info.py @@ -0,0 +1,55 @@ +# window_info.py +""" +Récupération des informations sur la fenêtre active (X11). + +v0 : +- utilise xdotool pour obtenir : + - le titre de la fenêtre active + - le PID de la fenêtre active, puis le nom du process via ps + +Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". +""" + +from __future__ import annotations + +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Nécessite xdotool installé sur le système. + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } diff --git a/agent_v0/deploy/windows_client/agent_v1/window_info_crossplatform.py b/agent_v0/deploy/windows_client/agent_v1/window_info_crossplatform.py new file mode 100644 index 000000000..ba059a3fc --- /dev/null +++ b/agent_v0/deploy/windows_client/agent_v1/window_info_crossplatform.py @@ -0,0 +1,192 @@ +# window_info_crossplatform.py +""" +Récupération des informations sur la fenêtre active - CROSS-PLATFORM + +Supporte: +- Linux (X11 via xdotool) +- Windows (via pywin32) +- macOS (via pyobjc) + +Installation des dépendances: + pip install pywin32 # Windows + pip install pyobjc-framework-Cocoa # macOS + pip install psutil # Tous OS +""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Détecte automatiquement l'OS et utilise la méthode appropriée. + """ + system = platform.system() + + if system == "Linux": + return _get_window_info_linux() + elif system == "Windows": + return _get_window_info_windows() + elif system == "Darwin": # macOS + return _get_window_info_macos() + else: + return {"title": "unknown_window", "app_name": "unknown_app"} + + +def _get_window_info_linux() -> Dict[str, str]: + """ + Linux: utilise xdotool (X11) + + Nécessite: sudo apt-get install xdotool + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + +def _get_window_info_windows() -> Dict[str, str]: + """ + Windows: utilise pywin32 + psutil + + Nécessite: pip install pywin32 psutil + """ + try: + import win32gui + import win32process + import psutil + + # Fenêtre au premier plan + hwnd = win32gui.GetForegroundWindow() + + # Titre de la fenêtre + title = win32gui.GetWindowText(hwnd) + if not title: + title = "unknown_window" + + # PID du processus + _, pid = win32process.GetWindowThreadProcessId(hwnd) + + # Nom du processus + try: + process = psutil.Process(pid) + app_name = process.name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pywin32 ou psutil non installé + return { + "title": "unknown_window (pywin32 missing)", + "app_name": "unknown_app (pywin32 missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +def _get_window_info_macos() -> Dict[str, str]: + """ + macOS: utilise pyobjc (AppKit) + + Nécessite: pip install pyobjc-framework-Cocoa + + Note: Nécessite les permissions "Accessibility" dans System Preferences + """ + try: + from AppKit import NSWorkspace + from Quartz import ( + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + # Application active + active_app = NSWorkspace.sharedWorkspace().activeApplication() + app_name = active_app.get('NSApplicationName', 'unknown_app') + + # Titre de la fenêtre (via Quartz) + # On cherche la fenêtre de l'app active qui est au premier plan + window_list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + title = "unknown_window" + for window in window_list: + owner_name = window.get('kCGWindowOwnerName', '') + if owner_name == app_name: + window_title = window.get('kCGWindowName', '') + if window_title: + title = window_title + break + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pyobjc non installé + return { + "title": "unknown_window (pyobjc missing)", + "app_name": "unknown_app (pyobjc missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +# Test rapide +if __name__ == "__main__": + import time + + print(f"OS détecté: {platform.system()}") + print("\nTest de capture fenêtre active (5 secondes)...") + print("Changez de fenêtre pour tester!\n") + + for i in range(5): + info = get_active_window_info() + print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") + time.sleep(1) diff --git a/agent_v0/deploy/windows_client/config.py b/agent_v0/deploy/windows_client/config.py new file mode 100644 index 000000000..29691ae7c --- /dev/null +++ b/agent_v0/deploy/windows_client/config.py @@ -0,0 +1,58 @@ +# config.py +""" +Configuration de base pour agent_v0. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +AGENT_VERSION = "0.1.0" + +# Dossier racine du projet (là où se trouve ce fichier) +BASE_DIR = Path(__file__).resolve().parent + +# Chargement automatique de .env.local depuis le répertoire parent +def load_env_file(env_path): + """Charge un fichier .env dans les variables d'environnement""" + if not env_path.exists(): + return False + + with open(env_path, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + os.environ[key.strip()] = value.strip() + return True + +# Charger .env.local depuis le répertoire parent (racine du projet) +env_local_path = BASE_DIR.parent / ".env.local" +if load_env_file(env_local_path): + print(f"[agent_v0] Variables d'environnement chargées depuis {env_local_path}") + + + +# Endpoint du serveur RPA Vision V3 +# En développement local : http://localhost:8000/api/traces/upload +# En production : configurer via variable d'environnement +import os +SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:8000/api/traces/upload") + +# Durée max d'une session en secondes (ex: 30 minutes) +MAX_SESSION_DURATION_S = 30 * 60 + +# Dossier racine local où stocker les sessions (chemin ABSOLU) +SESSIONS_ROOT = str(BASE_DIR / "sessions") + +# Dossier et fichier de logs +LOGS_DIR = BASE_DIR / "logs" +LOG_FILE = LOGS_DIR / "agent_v0.log" + +# Faut-il quitter l'application après un Stop session ? +EXIT_AFTER_SESSION = True + +# Création des dossiers si besoin +os.makedirs(SESSIONS_ROOT, exist_ok=True) +os.makedirs(LOGS_DIR, exist_ok=True) diff --git a/agent_v0/deploy/windows_client/lea_ui/__init__.py b/agent_v0/deploy/windows_client/lea_ui/__init__.py new file mode 100644 index 000000000..b8fb82ffd --- /dev/null +++ b/agent_v0/deploy/windows_client/lea_ui/__init__.py @@ -0,0 +1,13 @@ +# agent_v0.lea_ui — Interface utilisateur "Lea" +# +# Panneau PyQt5 integre qui remplace le system tray + navigateur web +# par une interface unifiee pour piloter l'Agent RPA Vision V3. +# +# Composants : +# - LeaMainWindow : fenetre principale ancree a droite +# - ChatWidget : zone de conversation avec le serveur +# - OverlayWidget : feedback visuel pendant le replay +# - LeaServerClient : client API vers le serveur Linux +# - styles : theme et couleurs + +__version__ = "0.1.0" diff --git a/agent_v0/deploy/windows_client/lea_ui/server_client.py b/agent_v0/deploy/windows_client/lea_ui/server_client.py new file mode 100644 index 000000000..a88e6c403 --- /dev/null +++ b/agent_v0/deploy/windows_client/lea_ui/server_client.py @@ -0,0 +1,350 @@ +# agent_v0/lea_ui/server_client.py +""" +Client API pour communiquer avec le serveur Linux RPA Vision V3. + +Endpoints cibles : + - Agent Chat (port 5004) : /api/chat, /api/workflows + - Streaming Server (port 5005) : /api/v1/traces/stream/replay/next, etc. + +Le polling tourne dans un thread separe pour ne pas bloquer la UI Qt. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger("lea_ui.server_client") + + +def _get_server_host() -> str: + """Recuperer l'adresse du serveur Linux. + + Ordre de resolution : + 1. Variable d'environnement RPA_SERVER_HOST + 2. Fichier de config agent_config.json (cle "server_host") + 3. Fallback localhost + """ + # 1. Variable d'environnement + host = os.environ.get("RPA_SERVER_HOST", "").strip() + if host: + return host + + # 2. Fichier de config + config_paths = [ + os.path.join(os.path.dirname(__file__), "..", "agent_config.json"), + os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"), + ] + for config_path in config_paths: + try: + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + host = cfg.get("server_host", "").strip() + if host: + return host + except (OSError, json.JSONDecodeError): + continue + + # 3. Fallback + return "localhost" + + +class LeaServerClient: + """Client API thread-safe vers le serveur RPA Vision V3. + + Gere la communication HTTP avec le serveur chat (port 5004) + et le serveur de streaming (port 5005). + Le polling replay tourne dans un thread daemon separe. + """ + + def __init__( + self, + server_host: Optional[str] = None, + chat_port: int = 5004, + stream_port: int = 5005, + ) -> None: + self._host = server_host or _get_server_host() + self._chat_port = chat_port + self._stream_port = stream_port + + self._chat_base = f"http://{self._host}:{self._chat_port}" + self._stream_base = f"http://{self._host}:{self._stream_port}" + + # Etat de connexion + self._connected = False + self._last_error: Optional[str] = None + + # Callbacks UI (appelees depuis le thread de polling) + self._on_connection_change: Optional[Callable[[bool], None]] = None + self._on_replay_action: Optional[Callable[[Dict[str, Any]], None]] = None + self._on_chat_response: Optional[Callable[[Dict[str, Any]], None]] = None + + # Thread de polling + self._polling = False + self._poll_thread: Optional[threading.Thread] = None + self._poll_interval = 1.0 # secondes + + # Session de chat + self._chat_session_id: Optional[str] = None + + logger.info( + "LeaServerClient initialise : chat=%s, stream=%s", + self._chat_base, self._stream_base, + ) + + # --------------------------------------------------------------------------- + # Proprietes + # --------------------------------------------------------------------------- + + @property + def connected(self) -> bool: + return self._connected + + @property + def server_host(self) -> str: + return self._host + + @property + def last_error(self) -> Optional[str]: + return self._last_error + + # --------------------------------------------------------------------------- + # Callbacks + # --------------------------------------------------------------------------- + + def set_on_connection_change(self, callback: Callable[[bool], None]) -> None: + """Callback appelee quand l'etat de connexion change.""" + self._on_connection_change = callback + + def set_on_replay_action(self, callback: Callable[[Dict[str, Any]], None]) -> None: + """Callback appelee quand une action de replay est recue.""" + self._on_replay_action = callback + + def set_on_chat_response(self, callback: Callable[[Dict[str, Any]], None]) -> None: + """Callback appelee quand une reponse chat est recue.""" + self._on_chat_response = callback + + # --------------------------------------------------------------------------- + # Connexion + # --------------------------------------------------------------------------- + + def check_connection(self) -> bool: + """Tester la connexion au serveur chat.""" + try: + import requests + resp = requests.get( + f"{self._chat_base}/api/status", + timeout=5, + ) + was_connected = self._connected + self._connected = resp.ok + self._last_error = None + + if self._connected != was_connected and self._on_connection_change: + self._on_connection_change(self._connected) + + return self._connected + except Exception as e: + was_connected = self._connected + self._connected = False + self._last_error = str(e) + + if was_connected and self._on_connection_change: + self._on_connection_change(False) + + return False + + # --------------------------------------------------------------------------- + # Chat API (port 5004) + # --------------------------------------------------------------------------- + + def send_chat_message(self, message: str) -> Optional[Dict[str, Any]]: + """Envoyer un message au chat et retourner la reponse. + + Retourne None en cas d'erreur reseau. + """ + try: + import requests + payload = { + "message": message, + } + if self._chat_session_id: + payload["session_id"] = self._chat_session_id + + resp = requests.post( + f"{self._chat_base}/api/chat", + json=payload, + timeout=30, + ) + + if resp.ok: + data = resp.json() + # Sauvegarder le session_id pour le contexte multi-tour + if "session_id" in data: + self._chat_session_id = data["session_id"] + self._connected = True + return data + else: + self._last_error = f"HTTP {resp.status_code}" + logger.warning("Chat API erreur : %s", self._last_error) + return None + + except Exception as e: + self._last_error = str(e) + self._connected = False + logger.error("Chat API exception : %s", e) + return None + + def list_workflows(self) -> List[Dict[str, Any]]: + """Recuperer la liste des workflows depuis le serveur chat.""" + try: + import requests + resp = requests.get( + f"{self._chat_base}/api/workflows", + timeout=10, + ) + if resp.ok: + data = resp.json() + self._connected = True + return data.get("workflows", []) + return [] + except Exception as e: + self._last_error = str(e) + logger.error("List workflows erreur : %s", e) + return [] + + def list_gestures(self) -> List[Dict[str, Any]]: + """Recuperer la liste des gestes depuis le serveur chat.""" + try: + import requests + resp = requests.get( + f"{self._chat_base}/api/workflows", + timeout=10, + ) + if resp.ok: + data = resp.json() + return data.get("workflows", []) + return [] + except Exception as e: + logger.error("List gestures erreur : %s", e) + return [] + + # --------------------------------------------------------------------------- + # Replay Polling (port 5005) + # --------------------------------------------------------------------------- + + def start_polling(self, session_id: str) -> None: + """Demarrer le polling des actions de replay dans un thread daemon.""" + if self._polling: + return + + self._polling = True + self._poll_session_id = session_id + self._poll_thread = threading.Thread( + target=self._poll_loop, + daemon=True, + name="lea-replay-poll", + ) + self._poll_thread.start() + logger.info("Polling replay demarre pour session %s", session_id) + + def stop_polling(self) -> None: + """Arreter le polling.""" + self._polling = False + if self._poll_thread: + self._poll_thread.join(timeout=3) + self._poll_thread = None + logger.info("Polling replay arrete") + + def _poll_loop(self) -> None: + """Boucle de polling dans un thread separe.""" + import requests as req_lib + + while self._polling: + try: + resp = req_lib.get( + f"{self._stream_base}/api/v1/traces/stream/replay/next", + params={"session_id": self._poll_session_id}, + timeout=5, + ) + + if resp.ok: + data = resp.json() + action = data.get("action") + if action and self._on_replay_action: + self._on_replay_action(action) + # Apres une action, poll plus rapidement + time.sleep(0.2) + continue + + except req_lib.exceptions.ConnectionError: + # Serveur non disponible — silencieux + pass + except req_lib.exceptions.Timeout: + pass + except Exception as e: + logger.error("Erreur poll replay : %s", e) + + time.sleep(self._poll_interval) + + # --------------------------------------------------------------------------- + # Replay Status + # --------------------------------------------------------------------------- + + def get_replay_status(self) -> Optional[Dict[str, Any]]: + """Recuperer l'etat des replays en cours.""" + try: + import requests + resp = requests.get( + f"{self._stream_base}/api/v1/traces/stream/replays", + timeout=5, + ) + if resp.ok: + data = resp.json() + replays = data.get("replays", []) + # Retourner le premier replay actif + for r in replays: + if r.get("status") == "running": + return r + return None + return None + except Exception: + return None + + def report_action_result( + self, + session_id: str, + action_id: str, + success: bool, + error: Optional[str] = None, + screenshot: Optional[str] = None, + ) -> None: + """Rapporter le resultat d'execution d'une action au serveur.""" + try: + import requests + requests.post( + f"{self._stream_base}/api/v1/traces/stream/replay/result", + json={ + "session_id": session_id, + "action_id": action_id, + "success": success, + "error": error, + "screenshot": screenshot, + }, + timeout=5, + ) + except Exception as e: + logger.error("Report action result erreur : %s", e) + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + def shutdown(self) -> None: + """Arreter proprement le client.""" + self.stop_polling() + logger.info("LeaServerClient arrete") diff --git a/agent_v0/deploy/windows_client/requirements.txt b/agent_v0/deploy/windows_client/requirements.txt new file mode 100644 index 000000000..c3c43160e --- /dev/null +++ b/agent_v0/deploy/windows_client/requirements.txt @@ -0,0 +1,15 @@ +# agent_v1/requirements.txt +mss>=9.0.1 # Capture d'écran haute performance +pynput>=1.7.7 # Clavier/Souris Cross-plateforme +Pillow>=10.0.0 # Crops et processing image +requests>=2.31.0 # Streaming réseau +psutil>=5.9.0 # Monitoring CPU/RAM +pystray>=0.19.5 # Icône Tray UI +plyer>=2.1.0 # Notifications toast natives (remplace PyQt5) + +# Windows spécifique +pywin32>=306 ; sys_platform == 'win32' + +# macOS spécifique +pyobjc-framework-Cocoa>=10.0 ; sys_platform == 'darwin' +pyobjc-framework-Quartz>=10.0 ; sys_platform == 'darwin' diff --git a/agent_v0/deploy/windows_client/run_agent_v1.py b/agent_v0/deploy/windows_client/run_agent_v1.py new file mode 100644 index 000000000..ec442f211 --- /dev/null +++ b/agent_v0/deploy/windows_client/run_agent_v1.py @@ -0,0 +1,16 @@ +# run_agent_v1.py +import sys +import os + +# Ajout du répertoire courant au PYTHONPATH pour permettre les imports de modules +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.append(current_dir) + +try: + from agent_v1.main import main + if __name__ == "__main__": + main() +except ImportError as e: + print(f"Erreur d'importation : {e}") + print("Assurez-vous d'être dans le répertoire racine du projet et que agent_v1 est bien un package Python.") diff --git a/agent_v0/deploy/windows_client/setup.bat b/agent_v0/deploy/windows_client/setup.bat new file mode 100644 index 000000000..cdd0e77d8 --- /dev/null +++ b/agent_v0/deploy/windows_client/setup.bat @@ -0,0 +1,64 @@ +@echo off +:: setup_v1.bat - Installation conviviale pour Windows + +echo ================================================== +echo Agent V1 - RPA Vision - Installation Windows +echo ================================================== +echo. + +:: 0. Verifier que Python est installe +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERREUR] Python n'est pas installe ou pas dans le PATH. + echo Telecharger Python 3.10+ depuis https://python.org + pause + exit /b 1 +) + +:: 1. Creation de l'environnement virtuel +if not exist ".venv_v1_win" ( + echo [1/4] Creation de l'environnement virtuel... + python -m venv .venv_v1_win +) else ( + echo [1/4] Environnement virtuel existant detecte. +) + +:: 2. Activation +call .venv_v1_win\Scripts\activate.bat + +:: 3. Mise a jour pip et installation des dependances +echo [2/4] Installation des dependances... +python -m pip install --upgrade pip --quiet +pip install -r agent_v1\requirements.txt --quiet + +:: 4. Post-installation Windows (pywin32) +echo [3/4] Configuration Windows... +python -c "import win32api" >nul 2>&1 +if errorlevel 1 ( + echo pywin32 post-install... + python .venv_v1_win\Scripts\pywin32_postinstall.py -install >nul 2>&1 +) + +:: 5. Verification rapide +echo [4/4] Verification... +python -c "import pystray; import plyer; import mss; import pynput; print(' Toutes les dependances OK')" +if errorlevel 1 ( + echo [ERREUR] Certaines dependances sont manquantes. + echo Relancer : pip install -r agent_v1\requirements.txt + pause + exit /b 1 +) + +echo. +echo ================================================== +echo Installation terminee ! +echo. +echo Pour lancer l'agent : +echo .venv_v1_win\Scripts\activate.bat +echo python run_agent_v1.py +echo. +echo Configuration serveur : +echo Editer agent_config.json +echo ou definir RPA_SERVER_HOST=192.168.1.x +echo ================================================== +pause diff --git a/agent_v0/deploy_windows.py b/agent_v0/deploy_windows.py new file mode 100644 index 000000000..23d1bdb17 --- /dev/null +++ b/agent_v0/deploy_windows.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +deploy_windows.py — Script de packaging du client Windows pour Agent V1. + +Copie uniquement les fichiers nécessaires au fonctionnement de l'agent +sur le PC cible (Windows), sans le serveur ni les dépendances lourdes. + +Usage : + python deploy_windows.py # Crée le dossier deploy/windows_client/ + python deploy_windows.py --zip # Idem + archive .zip +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import sys +from datetime import datetime +from pathlib import Path + +# Répertoire racine de agent_v0 (là où vit ce script) +AGENT_V0_DIR = Path(__file__).resolve().parent + +# Répertoire de sortie +DEPLOY_DIR = AGENT_V0_DIR / "deploy" / "windows_client" + +# ───────────────────────────────────────────────────────── +# Manifeste des fichiers à déployer +# Chaque entrée : (source relative à agent_v0, destination relative à windows_client) +# ───────────────────────────────────────────────────────── +FILE_MANIFEST: list[tuple[str, str]] = [ + # === agent_v1 — package principal === + ("agent_v1/__init__.py", "agent_v1/__init__.py"), + ("agent_v1/config.py", "agent_v1/config.py"), + ("agent_v1/main.py", "agent_v1/main.py"), + ("agent_v1/window_info.py", "agent_v1/window_info.py"), + ("agent_v1/window_info_crossplatform.py", "agent_v1/window_info_crossplatform.py"), + + # agent_v1/core + ("agent_v1/core/__init__.py", "agent_v1/core/__init__.py"), + ("agent_v1/core/captor.py", "agent_v1/core/captor.py"), + ("agent_v1/core/executor.py", "agent_v1/core/executor.py"), + ("agent_v1/core/window_info.py", "agent_v1/core/window_info.py"), + ("agent_v1/core/window_info_crossplatform.py", "agent_v1/core/window_info_crossplatform.py"), + + # agent_v1/network + ("agent_v1/network/__init__.py", "agent_v1/network/__init__.py"), + ("agent_v1/network/streamer.py", "agent_v1/network/streamer.py"), + + # agent_v1/session + ("agent_v1/session/__init__.py", "agent_v1/session/__init__.py"), + ("agent_v1/session/storage.py", "agent_v1/session/storage.py"), + + # agent_v1/ui — smart_tray (PAS tray.py, c'est l'ancien PyQt5) + ("agent_v1/ui/__init__.py", "agent_v1/ui/__init__.py"), + ("agent_v1/ui/notifications.py", "agent_v1/ui/notifications.py"), + ("agent_v1/ui/smart_tray.py", "agent_v1/ui/smart_tray.py"), + + # agent_v1/vision + ("agent_v1/vision/__init__.py", "agent_v1/vision/__init__.py"), + ("agent_v1/vision/capturer.py", "agent_v1/vision/capturer.py"), + + # agent_v1/monitoring + ("agent_v1/monitoring/__init__.py", "agent_v1/monitoring/__init__.py"), + + # === lea_ui — communication chat/workflow (utilisée par smart_tray) === + ("lea_ui/__init__.py", "lea_ui/__init__.py"), + ("lea_ui/server_client.py", "lea_ui/server_client.py"), + + # === Racine agent_v0 — fichiers nécessaires à l'exécution === + ("__init__.py", "__init__.py"), + ("config.py", "config.py"), + ("agent_config.json", "agent_config.json"), + ("run_agent_v1.py", "run_agent_v1.py"), + ("setup_v1.bat", "setup.bat"), + ("agent_v1/requirements.txt", "requirements.txt"), +] + +# Contenu du fichier LISEZMOI.txt +LISEZMOI_CONTENT = """\ +=== Agent V1 — RPA Vision — Client Windows === + +Installation : + 1. Double-cliquer sur setup.bat + 2. Configurer le serveur : éditer agent_config.json + ou définir la variable RPA_SERVER_HOST=192.168.1.x + 3. Lancer : python run_agent_v1.py + +L'agent apparaît dans la zone de notification (systray). +Clic droit pour accéder au menu : démarrer une session, +lancer un replay, voir les workflows appris, etc. + +Léa communique par des notifications toast sur votre écran. + +Prérequis : + - Python 3.10 ou plus récent + - Connexion réseau vers le serveur Linux +""" + + +def nettoyer_deploy_dir() -> None: + """Supprime le répertoire de déploiement s'il existe déjà.""" + if DEPLOY_DIR.exists(): + print(f" Nettoyage de {DEPLOY_DIR} ...") + shutil.rmtree(DEPLOY_DIR) + + +def copier_fichiers() -> tuple[list[str], list[str]]: + """ + Copie tous les fichiers du manifeste vers le répertoire de déploiement. + + Retourne : + (fichiers_copies, fichiers_manquants) — deux listes de chemins relatifs + """ + copies: list[str] = [] + manquants: list[str] = [] + + for src_rel, dst_rel in FILE_MANIFEST: + src = AGENT_V0_DIR / src_rel + dst = DEPLOY_DIR / dst_rel + + if not src.exists(): + print(f" [ATTENTION] Fichier manquant, ignoré : {src_rel}") + manquants.append(src_rel) + continue + + # Créer les répertoires parents si nécessaire + dst.parent.mkdir(parents=True, exist_ok=True) + + shutil.copy2(src, dst) + copies.append(dst_rel) + + return copies, manquants + + +def creer_lisezmoi() -> None: + """Crée le fichier LISEZMOI.txt dans le répertoire de déploiement.""" + lisezmoi_path = DEPLOY_DIR / "LISEZMOI.txt" + lisezmoi_path.write_text(LISEZMOI_CONTENT, encoding="utf-8") + print(f" LISEZMOI.txt créé") + + +def creer_archive_zip() -> Path: + """ + Crée une archive .zip du répertoire de déploiement. + + Retourne le chemin de l'archive créée. + """ + horodatage = datetime.now().strftime("%Y%m%d_%H%M%S") + nom_archive = f"windows_client_{horodatage}" + chemin_archive = DEPLOY_DIR.parent / nom_archive + + # shutil.make_archive ajoute automatiquement l'extension .zip + archive_path = shutil.make_archive( + str(chemin_archive), + "zip", + root_dir=str(DEPLOY_DIR.parent), + base_dir="windows_client", + ) + return Path(archive_path) + + +def afficher_resume(copies: list[str], manquants: list[str], archive: Path | None) -> None: + """Affiche un résumé des opérations effectuées.""" + print() + print("=" * 55) + print(" Résumé du déploiement — Agent V1 Client Windows") + print("=" * 55) + print(f" Destination : {DEPLOY_DIR}") + print(f" Fichiers copiés : {len(copies)}") + + if manquants: + print(f" Fichiers manquants : {len(manquants)}") + for f in manquants: + print(f" - {f}") + + if archive: + taille_mo = archive.stat().st_size / (1024 * 1024) + print(f" Archive : {archive.name} ({taille_mo:.1f} Mo)") + + print() + + # Afficher l'arborescence simplifiée + print(" Arborescence :") + for f in sorted(copies): + print(f" {f}") + + print() + print(" Terminé.") + print("=" * 55) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Prépare le package de déploiement Windows pour Agent V1" + ) + parser.add_argument( + "--zip", + action="store_true", + help="Créer aussi une archive .zip du package", + ) + args = parser.parse_args() + + print() + print("=== Déploiement Agent V1 — Client Windows ===") + print() + + # Étape 1 : Nettoyage + nettoyer_deploy_dir() + + # Étape 2 : Création du répertoire de déploiement + DEPLOY_DIR.mkdir(parents=True, exist_ok=True) + print(f" Répertoire créé : {DEPLOY_DIR}") + + # Étape 3 : Copie des fichiers + print(" Copie des fichiers...") + copies, manquants = copier_fichiers() + + # Étape 4 : Création du LISEZMOI.txt + creer_lisezmoi() + + # Étape 5 : Archive .zip (optionnel) + archive = None + if args.zip: + print(" Création de l'archive .zip...") + archive = creer_archive_zip() + print(f" Archive créée : {archive}") + + # Résumé + afficher_resume(copies, manquants, archive) + + +if __name__ == "__main__": + main() diff --git a/agent_v0/lea_ui/__init__.py b/agent_v0/lea_ui/__init__.py new file mode 100644 index 000000000..b8fb82ffd --- /dev/null +++ b/agent_v0/lea_ui/__init__.py @@ -0,0 +1,13 @@ +# agent_v0.lea_ui — Interface utilisateur "Lea" +# +# Panneau PyQt5 integre qui remplace le system tray + navigateur web +# par une interface unifiee pour piloter l'Agent RPA Vision V3. +# +# Composants : +# - LeaMainWindow : fenetre principale ancree a droite +# - ChatWidget : zone de conversation avec le serveur +# - OverlayWidget : feedback visuel pendant le replay +# - LeaServerClient : client API vers le serveur Linux +# - styles : theme et couleurs + +__version__ = "0.1.0" diff --git a/agent_v0/lea_ui/__main__.py b/agent_v0/lea_ui/__main__.py new file mode 100644 index 000000000..875a978ab --- /dev/null +++ b/agent_v0/lea_ui/__main__.py @@ -0,0 +1,6 @@ +# agent_v0/lea_ui/__main__.py +"""Permet le lancement via: python -m agent_v0.lea_ui""" + +from .launcher import main + +main() diff --git a/agent_v0/lea_ui/chat_widget.py b/agent_v0/lea_ui/chat_widget.py new file mode 100644 index 000000000..f66b68669 --- /dev/null +++ b/agent_v0/lea_ui/chat_widget.py @@ -0,0 +1,250 @@ +# agent_v0/lea_ui/chat_widget.py +""" +Widget de chat pour l'interface Lea. + +Affiche les messages avec des bulles : + - Utilisateur a droite (fond indigo) + - Lea a gauche (fond blanc) + +Communique avec le serveur Linux via LeaServerClient. +""" + +from __future__ import annotations + +import logging +from typing import List, Optional + +from PyQt5.QtCore import ( + QPropertyAnimation, + QSize, + Qt, + QTimer, + pyqtSignal, + pyqtSlot, +) +from PyQt5.QtGui import QColor, QFont, QPainter, QPainterPath, QPen +from PyQt5.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from . import styles + +logger = logging.getLogger("lea_ui.chat") + + +class ChatBubble(QFrame): + """Bulle de message individuelle.""" + + def __init__( + self, + text: str, + is_user: bool = False, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(parent) + self._is_user = is_user + + # Style de la bulle + if is_user: + bg_color = styles.COLOR_BUBBLE_USER + text_color = styles.COLOR_TEXT_ON_ACCENT + align = Qt.AlignRight + else: + bg_color = styles.COLOR_BUBBLE_LEA + text_color = styles.COLOR_TEXT + align = Qt.AlignLeft + + self.setStyleSheet(f""" + QFrame {{ + background-color: {bg_color}; + border-radius: {styles.BUBBLE_RADIUS}px; + padding: {styles.PADDING}px; + border: {"none" if is_user else f"1px solid {styles.COLOR_BORDER}"}; + }} + """) + + layout = QVBoxLayout(self) + layout.setContentsMargins( + styles.PADDING, styles.PADDING // 2, + styles.PADDING, styles.PADDING // 2, + ) + + label = QLabel(text) + label.setWordWrap(True) + label.setFont(QFont(styles.FONT_FAMILY, styles.FONT_SIZE_NORMAL)) + label.setStyleSheet(f"color: {text_color}; background: transparent; border: none;") + label.setTextFormat(Qt.RichText) + label.setOpenExternalLinks(True) + layout.addWidget(label) + + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) + self.setMaximumWidth(280) + + +class ChatWidget(QWidget): + """Widget de chat complet avec zone de messages et champ de saisie. + + Signals : + message_sent(str) : emis quand l'utilisateur envoie un message + """ + + message_sent = pyqtSignal(str) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._messages: List[dict] = [] + self._setup_ui() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Zone de messages (scrollable) + self._scroll_area = QScrollArea() + self._scroll_area.setWidgetResizable(True) + self._scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll_area.setStyleSheet(styles.CHAT_AREA_STYLE) + + self._messages_container = QWidget() + self._messages_container.setObjectName("ChatContainer") + self._messages_layout = QVBoxLayout(self._messages_container) + self._messages_layout.setContentsMargins( + styles.PADDING, styles.PADDING, + styles.PADDING, styles.PADDING, + ) + self._messages_layout.setSpacing(styles.SPACING) + self._messages_layout.addStretch() + + self._scroll_area.setWidget(self._messages_container) + layout.addWidget(self._scroll_area, stretch=1) + + # Separateur + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setStyleSheet(f"background-color: {styles.COLOR_BORDER}; max-height: 1px;") + layout.addWidget(sep) + + # Zone de saisie + input_layout = QHBoxLayout() + input_layout.setContentsMargins( + styles.PADDING, styles.SPACING, + styles.PADDING, styles.SPACING, + ) + input_layout.setSpacing(styles.SPACING) + + self._input = QLineEdit() + self._input.setObjectName("ChatInput") + self._input.setPlaceholderText("Ecrivez un message...") + self._input.setStyleSheet(styles.INPUT_STYLE) + self._input.returnPressed.connect(self._on_send) + input_layout.addWidget(self._input, stretch=1) + + self._send_btn = QPushButton("Envoyer") + self._send_btn.setObjectName("SendButton") + self._send_btn.setStyleSheet(styles.SEND_BUTTON_STYLE) + self._send_btn.setCursor(Qt.PointingHandCursor) + self._send_btn.clicked.connect(self._on_send) + input_layout.addWidget(self._send_btn) + + layout.addLayout(input_layout) + + def _on_send(self) -> None: + """Envoyer le message saisi.""" + text = self._input.text().strip() + if not text: + return + + self._input.clear() + self.add_user_message(text) + self.message_sent.emit(text) + + # --------------------------------------------------------------------------- + # API publique + # --------------------------------------------------------------------------- + + def add_user_message(self, text: str) -> None: + """Ajouter un message utilisateur (bulle a droite).""" + self._add_bubble(text, is_user=True) + + def add_lea_message(self, text: str) -> None: + """Ajouter un message de Lea (bulle a gauche).""" + self._add_bubble(text, is_user=False) + + def add_system_message(self, text: str) -> None: + """Ajouter un message systeme (centre, discret).""" + label = QLabel(text) + label.setFont(QFont(styles.FONT_FAMILY, styles.FONT_SIZE_SMALL)) + label.setStyleSheet( + f"color: {styles.COLOR_TEXT_SECONDARY}; " + f"background: transparent; padding: 4px;" + ) + label.setAlignment(Qt.AlignCenter) + label.setWordWrap(True) + + # Inserer avant le stretch final + count = self._messages_layout.count() + self._messages_layout.insertWidget(count - 1, label) + self._scroll_to_bottom() + + def set_input_enabled(self, enabled: bool) -> None: + """Activer/desactiver la saisie (pendant le chargement).""" + self._input.setEnabled(enabled) + self._send_btn.setEnabled(enabled) + if not enabled: + self._input.setPlaceholderText("Lea reflechit...") + else: + self._input.setPlaceholderText("Ecrivez un message...") + + def clear_messages(self) -> None: + """Effacer tous les messages.""" + while self._messages_layout.count() > 1: + item = self._messages_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + self._messages = [] + + # --------------------------------------------------------------------------- + # Internals + # --------------------------------------------------------------------------- + + def _add_bubble(self, text: str, is_user: bool) -> None: + """Ajouter une bulle au conteneur de messages.""" + bubble = ChatBubble(text, is_user=is_user) + + # Conteneur d'alignement + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + if is_user: + row.addStretch() + row.addWidget(bubble) + else: + row.addWidget(bubble) + row.addStretch() + + # Inserer avant le stretch final + count = self._messages_layout.count() + wrapper = QWidget() + wrapper.setLayout(row) + wrapper.setStyleSheet("background: transparent;") + self._messages_layout.insertWidget(count - 1, wrapper) + + self._messages.append({"text": text, "is_user": is_user}) + self._scroll_to_bottom() + + def _scroll_to_bottom(self) -> None: + """Scroller vers le bas apres l'ajout d'un message.""" + QTimer.singleShot(50, lambda: ( + self._scroll_area.verticalScrollBar().setValue( + self._scroll_area.verticalScrollBar().maximum() + ) + )) diff --git a/agent_v0/lea_ui/launcher.py b/agent_v0/lea_ui/launcher.py new file mode 100644 index 000000000..786b0183f --- /dev/null +++ b/agent_v0/lea_ui/launcher.py @@ -0,0 +1,218 @@ +# agent_v0/lea_ui/launcher.py +""" +Point d'entree pour le panneau Lea. + +Lancement autonome : + python -m agent_v0.lea_ui.launcher + +Ou integre dans agent_v0/agent_v1/main.py avec flag --ui lea. + +Ce module : + 1. Cree l'application Qt + 2. Instancie LeaServerClient + 3. Instancie LeaMainWindow + 4. Enregistre un raccourci global (Ctrl+Shift+L) via keyboard hook + 5. Lance la boucle Qt +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +from typing import Optional + +logger = logging.getLogger("lea_ui.launcher") + + +def _setup_logging(verbose: bool = False) -> None: + """Configurer le logging pour le panneau Lea.""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + +def _setup_global_hotkey(window) -> Optional[object]: + """Enregistrer le raccourci global Ctrl+Shift+L pour afficher/cacher le panneau. + + Utilise la librairie keyboard si disponible (Windows/Linux). + Retourne le hook pour pouvoir le desinscrire a l'arret. + """ + try: + import keyboard + + def on_hotkey(): + # Appeler toggle_visibility dans le thread Qt + from PyQt5.QtCore import QTimer + QTimer.singleShot(0, window.toggle_visibility) + + keyboard.add_hotkey("ctrl+shift+l", on_hotkey) + logger.info("Raccourci global Ctrl+Shift+L enregistre") + return True + except ImportError: + logger.info( + "Librairie 'keyboard' non disponible — " + "raccourci global Ctrl+Shift+L non enregistre. " + "Installez-la avec: pip install keyboard" + ) + return None + except Exception as e: + logger.warning("Impossible d'enregistrer le raccourci global : %s", e) + return None + + +def _load_environment() -> None: + """Charger les variables d'environnement depuis .env.local.""" + env_paths = [ + os.path.join(os.path.dirname(__file__), "..", "..", ".env.local"), + os.path.join(os.path.dirname(__file__), "..", ".env.local"), + ] + for env_path in env_paths: + env_path = os.path.abspath(env_path) + if os.path.exists(env_path): + try: + from dotenv import load_dotenv + load_dotenv(env_path) + logger.info("Variables d'environnement chargees depuis %s", env_path) + return + except ImportError: + # Fallback : chargement manuel + with open(env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + value = value.strip("\"'") + os.environ[key.strip()] = value + logger.info("Variables chargees manuellement depuis %s", env_path) + return + + +def launch_lea( + server_host: Optional[str] = None, + chat_port: int = 5004, + stream_port: int = 5005, + verbose: bool = False, + session_id: Optional[str] = None, +) -> None: + """Lancer le panneau Lea. + + Args: + server_host: adresse du serveur Linux (None = auto-detection) + chat_port: port du serveur chat + stream_port: port du serveur streaming + verbose: mode debug + session_id: identifiant de session pour le polling replay + """ + _setup_logging(verbose) + _load_environment() + + # Import PyQt5 ici pour un message d'erreur clair si absent + try: + from PyQt5.QtWidgets import QApplication + from PyQt5.QtCore import Qt + except ImportError: + logger.error( + "PyQt5 n'est pas installe. Installez-le avec :\n" + " pip install PyQt5" + ) + sys.exit(1) + + from .server_client import LeaServerClient + from .main_window import LeaMainWindow + + # Creer ou recuperer l'application Qt + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + app.setQuitOnLastWindowClosed(False) + + # Client serveur + client = LeaServerClient( + server_host=server_host, + chat_port=chat_port, + stream_port=stream_port, + ) + + # Fenetre principale + window = LeaMainWindow(server_client=client) + window.show() + + # Raccourci global + hotkey = _setup_global_hotkey(window) + + # Polling replay (si session_id fourni) + if session_id: + client.start_polling(session_id) + + logger.info( + "Panneau Lea demarre — serveur=%s, chat_port=%d, stream_port=%d", + client.server_host, chat_port, stream_port, + ) + + # Boucle Qt + try: + exit_code = app.exec_() + finally: + window.shutdown() + if hotkey: + try: + import keyboard + keyboard.unhook_all() + except Exception: + pass + + sys.exit(exit_code) + + +def main() -> None: + """Point d'entree CLI.""" + parser = argparse.ArgumentParser( + description="Panneau Lea — Interface utilisateur RPA Vision V3", + ) + parser.add_argument( + "--server", "-s", + dest="server_host", + default=None, + help="Adresse du serveur Linux (defaut: RPA_SERVER_HOST ou localhost)", + ) + parser.add_argument( + "--chat-port", + type=int, + default=5004, + help="Port du serveur chat (defaut: 5004)", + ) + parser.add_argument( + "--stream-port", + type=int, + default=5005, + help="Port du serveur streaming (defaut: 5005)", + ) + parser.add_argument( + "--session-id", + default=None, + help="Identifiant de session pour le polling replay", + ) + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Mode debug (logs verbeux)", + ) + + args = parser.parse_args() + + launch_lea( + server_host=args.server_host, + chat_port=args.chat_port, + stream_port=args.stream_port, + verbose=args.verbose, + session_id=args.session_id, + ) + + +if __name__ == "__main__": + main() diff --git a/agent_v0/lea_ui/main_window.py b/agent_v0/lea_ui/main_window.py new file mode 100644 index 000000000..ed5055275 --- /dev/null +++ b/agent_v0/lea_ui/main_window.py @@ -0,0 +1,772 @@ +# agent_v0/lea_ui/main_window.py +""" +Fenetre principale du panneau Lea. + +Panneau semi-transparent, ancre a droite de l'ecran, toujours visible. +Peut etre reduit en mini-barre flottante (avatar + indicateur status). + +Sections : + - Header : avatar "L" + status connexion + - Zone de chat : messages entrants/sortants (natif PyQt5) + - Zone de status : progression du replay + - Boutons rapides : "Apprends-moi", "Que sais-tu faire ?" +""" + +from __future__ import annotations + +import logging +from typing import Dict, Any, Optional + +from PyQt5.QtCore import ( + QPoint, + QPropertyAnimation, + QRect, + QSize, + Qt, + QTimer, + pyqtSignal, + pyqtSlot, +) +from PyQt5.QtGui import ( + QColor, + QFont, + QIcon, + QKeySequence, + QPainter, + QPainterPath, + QPen, +) +from PyQt5.QtWidgets import ( + QAction, + QApplication, + QDesktopWidget, + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QShortcut, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from . import styles +from .chat_widget import ChatWidget +from .overlay import OverlayWidget +from .server_client import LeaServerClient + +logger = logging.getLogger("lea_ui.main_window") + + +class LeaAvatar(QWidget): + """Avatar rond avec l'initiale 'L'.""" + + def __init__(self, size: int = 40, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._size = size + self._connected = False + self.setFixedSize(size, size) + + def set_connected(self, connected: bool) -> None: + self._connected = connected + self.update() + + def paintEvent(self, event) -> None: # noqa: N802 + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + + # Cercle de fond + painter.setBrush(QColor(styles.COLOR_ACCENT)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(2, 2, self._size - 4, self._size - 4) + + # Initiale "L" + painter.setPen(QColor(styles.COLOR_TEXT_ON_ACCENT)) + font = QFont(styles.FONT_FAMILY, self._size // 3, QFont.Bold) + painter.setFont(font) + painter.drawText( + QRect(0, 0, self._size, self._size), + Qt.AlignCenter, + "L", + ) + + # Indicateur de connexion (petit cercle en bas a droite) + indicator_size = 12 + ix = self._size - indicator_size - 1 + iy = self._size - indicator_size - 1 + indicator_color = ( + QColor(styles.COLOR_SUCCESS) if self._connected + else QColor(styles.COLOR_ERROR) + ) + painter.setBrush(indicator_color) + painter.setPen(QPen(QColor(styles.COLOR_BG), 2)) + painter.drawEllipse(ix, iy, indicator_size, indicator_size) + + painter.end() + + +class LeaMainWindow(QWidget): + """Panneau principal de l'interface Lea. + + Fenetre semi-transparente, ancree a droite de l'ecran. + Peut basculer en mode mini-barre. + """ + + # Signal pour les actions de replay a afficher sur l'overlay + replay_action_received = pyqtSignal(dict) + + def __init__( + self, + server_client: Optional[LeaServerClient] = None, + parent: Optional[QWidget] = None, + ) -> None: + super().__init__(parent) + + # Client serveur + self._client = server_client or LeaServerClient() + + # Overlay de feedback + self._overlay = OverlayWidget() + + # Mode courant + self._minimized = False + + # Setup + self._setup_window() + self._setup_ui() + self._setup_shortcuts() + self._connect_signals() + self._start_connection_check() + + # Message d'accueil + QTimer.singleShot(500, self._show_welcome) + + # --------------------------------------------------------------------------- + # Setup + # --------------------------------------------------------------------------- + + def _setup_window(self) -> None: + """Configurer les proprietes de la fenetre.""" + self.setWindowFlags( + Qt.WindowStaysOnTopHint + | Qt.FramelessWindowHint + | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setObjectName("LeaMainWindow") + + # Dimensions et position (ancre a droite) + self.setFixedWidth(styles.PANEL_WIDTH) + self.setMinimumHeight(styles.PANEL_MIN_HEIGHT) + self._anchor_to_right() + + # Ombre portee + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 0, 0, 60)) + shadow.setOffset(0, 4) + self.setGraphicsEffect(shadow) + + def _anchor_to_right(self) -> None: + """Positionner le panneau ancre a droite de l'ecran.""" + desktop = QApplication.desktop() + if desktop: + screen_rect = desktop.availableGeometry(desktop.primaryScreen()) + x = screen_rect.right() - styles.PANEL_WIDTH - 10 + y = screen_rect.top() + 40 + height = screen_rect.height() - 80 + self.setGeometry(x, y, styles.PANEL_WIDTH, height) + + def _setup_ui(self) -> None: + """Construire l'interface du panneau.""" + # Conteneur principal avec fond et coins arrondis + self._main_layout = QVBoxLayout(self) + self._main_layout.setContentsMargins(0, 0, 0, 0) + self._main_layout.setSpacing(0) + + # Widget de fond (pour appliquer le style) + self._bg_widget = QWidget() + self._bg_widget.setObjectName("LeaPanelBg") + self._bg_widget.setStyleSheet(f""" + QWidget#LeaPanelBg {{ + background-color: {styles.COLOR_BG}; + border-radius: {styles.BORDER_RADIUS}px; + border: 1px solid {styles.COLOR_BORDER}; + }} + """) + + bg_layout = QVBoxLayout(self._bg_widget) + bg_layout.setContentsMargins(0, 0, 0, 0) + bg_layout.setSpacing(0) + + # --- Header --- + self._header = self._create_header() + bg_layout.addWidget(self._header) + + # --- Chat --- + self._chat = ChatWidget() + bg_layout.addWidget(self._chat, stretch=1) + + # --- Zone de status replay --- + self._status_bar = self._create_status_bar() + bg_layout.addWidget(self._status_bar) + + # --- Boutons rapides --- + self._quick_buttons = self._create_quick_buttons() + bg_layout.addWidget(self._quick_buttons) + + self._main_layout.addWidget(self._bg_widget) + + # --- Mini-barre (cachee par defaut) --- + self._mini_bar = self._create_mini_bar() + self._mini_bar.hide() + self._main_layout.addWidget(self._mini_bar) + + def _create_header(self) -> QWidget: + """Creer le header avec avatar et status.""" + header = QWidget() + header.setObjectName("LeaHeader") + header.setStyleSheet(styles.HEADER_STYLE) + header.setFixedHeight(60) + + layout = QHBoxLayout(header) + layout.setContentsMargins( + styles.PADDING, styles.SPACING, + styles.PADDING, styles.SPACING, + ) + + # Avatar + self._avatar = LeaAvatar(styles.AVATAR_SIZE) + layout.addWidget(self._avatar) + + # Titre + status + text_layout = QVBoxLayout() + text_layout.setSpacing(2) + + title = QLabel("Lea") + title.setObjectName("LeaTitle") + title.setStyleSheet(styles.HEADER_STYLE) + text_layout.addWidget(title) + + self._status_label = QLabel("Connexion...") + self._status_label.setObjectName("LeaStatus") + self._status_label.setStyleSheet(styles.HEADER_STYLE) + text_layout.addWidget(self._status_label) + + layout.addLayout(text_layout, stretch=1) + + # Bouton reduire + minimize_btn = QPushButton("_") + minimize_btn.setFixedSize(30, 30) + minimize_btn.setCursor(Qt.PointingHandCursor) + minimize_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + color: {styles.COLOR_TEXT_SECONDARY}; + border: none; + border-radius: 15px; + font-size: 16px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {styles.COLOR_BORDER}; + }} + """) + minimize_btn.clicked.connect(self.toggle_minimize) + layout.addWidget(minimize_btn) + + return header + + def _create_status_bar(self) -> QWidget: + """Creer la barre de status du replay.""" + container = QWidget() + container.setFixedHeight(50) + layout = QVBoxLayout(container) + layout.setContentsMargins( + styles.PADDING, styles.SPACING, + styles.PADDING, styles.SPACING, + ) + layout.setSpacing(4) + + self._replay_label = QLabel("") + self._replay_label.setObjectName("StatusLabel") + self._replay_label.setStyleSheet(styles.STATUS_LABEL_STYLE) + self._replay_label.hide() + layout.addWidget(self._replay_label) + + self._progress_bar = QProgressBar() + self._progress_bar.setStyleSheet(styles.PROGRESS_STYLE) + self._progress_bar.setTextVisible(False) + self._progress_bar.hide() + layout.addWidget(self._progress_bar) + + container.hide() + self._status_container = container + return container + + def _create_quick_buttons(self) -> QWidget: + """Creer les boutons d'action rapide.""" + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins( + styles.PADDING, styles.SPACING, + styles.PADDING, styles.PADDING, + ) + layout.setSpacing(styles.SPACING) + + btn_learn = QPushButton("Apprends-moi") + btn_learn.setObjectName("QuickButton") + btn_learn.setStyleSheet(styles.QUICK_BUTTON_STYLE) + btn_learn.setCursor(Qt.PointingHandCursor) + btn_learn.clicked.connect(self._on_learn_clicked) + layout.addWidget(btn_learn) + + btn_list = QPushButton("Que sais-tu faire ?") + btn_list.setObjectName("QuickButton") + btn_list.setStyleSheet(styles.QUICK_BUTTON_STYLE) + btn_list.setCursor(Qt.PointingHandCursor) + btn_list.clicked.connect(self._on_list_clicked) + layout.addWidget(btn_list) + + return container + + def _create_mini_bar(self) -> QWidget: + """Creer la mini-barre flottante (mode reduit).""" + bar = QWidget() + bar.setObjectName("MiniBar") + bar.setStyleSheet(styles.MINI_BAR_STYLE) + bar.setFixedSize(80, 50) + + layout = QHBoxLayout(bar) + layout.setContentsMargins(8, 4, 8, 4) + + mini_avatar = LeaAvatar(32) + self._mini_avatar = mini_avatar + layout.addWidget(mini_avatar) + + expand_btn = QPushButton(">") + expand_btn.setFixedSize(24, 24) + expand_btn.setCursor(Qt.PointingHandCursor) + expand_btn.setStyleSheet(f""" + QPushButton {{ + background: transparent; + color: {styles.COLOR_TEXT_SECONDARY}; + border: none; + font-size: 14px; + font-weight: bold; + }} + QPushButton:hover {{ + color: {styles.COLOR_ACCENT}; + }} + """) + expand_btn.clicked.connect(self.toggle_minimize) + layout.addWidget(expand_btn) + + return bar + + def _setup_shortcuts(self) -> None: + """Configurer les raccourcis globaux.""" + # Ctrl+Shift+L pour afficher/cacher + # Note : Sur Windows, les raccourcis globaux necessitent + # un mecanisme supplementaire (keyboard hook). Ici on utilise + # le raccourci local qui fonctionne quand le panneau a le focus. + # Un hook global sera ajoute dans le launcher. + shortcut = QShortcut(QKeySequence("Ctrl+Shift+L"), self) + shortcut.activated.connect(self.toggle_visibility) + + def _connect_signals(self) -> None: + """Connecter les signaux internes.""" + # Chat + self._chat.message_sent.connect(self._on_message_sent) + + # Client serveur + self._client.set_on_connection_change(self._on_connection_changed) + self._client.set_on_replay_action(self._on_replay_action) + + # Overlay + self._overlay.action_display_finished.connect(self._on_overlay_finished) + + # Replay via signal (thread-safe) + self.replay_action_received.connect(self._handle_replay_action) + + def _start_connection_check(self) -> None: + """Demarrer le timer de verification de connexion.""" + self._conn_timer = QTimer(self) + self._conn_timer.timeout.connect(self._check_connection) + self._conn_timer.start(10000) # Toutes les 10 secondes + # Premiere verification immediatement + QTimer.singleShot(1000, self._check_connection) + + # --------------------------------------------------------------------------- + # Actions + # --------------------------------------------------------------------------- + + def _show_welcome(self) -> None: + """Afficher le message d'accueil.""" + self._chat.add_lea_message( + "Bonjour ! Je suis Lea, votre assistante RPA.
" + "Je peux apprendre vos taches, les rejouer, " + "et vous montrer ce que je fais.

" + "Que souhaitez-vous faire ?" + ) + + @pyqtSlot(str) + def _on_message_sent(self, message: str) -> None: + """Traiter un message envoye par l'utilisateur.""" + self._chat.set_input_enabled(False) + + # Envoyer au serveur dans un timer pour ne pas bloquer + QTimer.singleShot(100, lambda: self._send_to_server(message)) + + def _send_to_server(self, message: str) -> None: + """Envoyer le message au serveur et afficher la reponse.""" + response = self._client.send_chat_message(message) + + if response is None: + self._chat.add_lea_message( + "Je n'arrive pas a joindre le serveur. " + "Verifiez que le serveur Linux est demarre." + ) + elif "error" in response: + self._chat.add_lea_message( + f"Erreur : {response['error']}" + ) + else: + # Extraire la reponse textuelle + reply_text = response.get("response", "") + if not reply_text: + # Construire une reponse a partir des donnees structurees + reply_text = self._format_response(response) + + self._chat.add_lea_message(reply_text) + + # Si un workflow a ete lance, mettre a jour la status bar + if response.get("success") and response.get("workflow"): + self._show_replay_status( + f"Execution : {response['workflow']}", + 0, 1, + ) + + self._chat.set_input_enabled(True) + + def _format_response(self, data: Dict[str, Any]) -> str: + """Formater une reponse structuree du serveur en texte lisible.""" + # Reponse de confirmation + if data.get("needs_confirmation"): + conf = data.get("confirmation", {}) + return ( + f"Voulez-vous que j'execute {conf.get('workflow_name', '?')} ?
" + f"Risque : {conf.get('risk_level', 'normal')}
" + "Repondez oui ou non." + ) + + # Liste de workflows + if "workflows" in data: + workflows = data["workflows"] + if not workflows: + return "Je ne connais aucun workflow pour le moment." + items = [] + for wf in workflows[:10]: + name = wf.get("name", wf.get("id", "?")) + desc = wf.get("description", "") + items.append(f"- {name}{': ' + desc if desc else ''}") + result = "Voici ce que je sais faire :
" + "
".join(items) + if len(workflows) > 10: + result += f"
... et {len(workflows) - 10} autres" + return result + + # Workflow non trouve + if data.get("not_found"): + return ( + f"Je ne trouve pas de workflow correspondant a " + f"'{data.get('query', '?')}'.
" + "Essayez 'Que sais-tu faire ?' pour voir la liste." + ) + + # Execution reussie + if data.get("success"): + return ( + f"C'est parti ! J'execute {data.get('workflow', '?')}.
" + "Regardez l'ecran, je vais vous montrer ce que je fais." + ) + + # Confirmation/refus + if data.get("confirmed"): + return f"D'accord, je lance {data.get('workflow', '?')} !" + if data.get("denied"): + return "Pas de probleme, j'annule." + + # Fallback + return str(data) + + def _on_learn_clicked(self) -> None: + """Action du bouton 'Apprends-moi'.""" + self._chat.add_user_message("Apprends-moi une nouvelle tache") + self._chat.add_lea_message( + "D'accord ! Pour m'apprendre une tache :
" + "1. Cliquez sur Demarrer dans le tray Agent V1
" + "2. Effectuez votre tache normalement
" + "3. Cliquez sur Terminer quand c'est fini

" + "Je vais observer et apprendre automatiquement." + ) + + def _on_list_clicked(self) -> None: + """Action du bouton 'Que sais-tu faire ?'.""" + self._chat.add_user_message("Que sais-tu faire ?") + self._chat.set_input_enabled(False) + QTimer.singleShot(100, self._fetch_workflows) + + def _fetch_workflows(self) -> None: + """Recuperer et afficher la liste des workflows.""" + workflows = self._client.list_workflows() + if workflows: + items = [] + for wf in workflows[:15]: + name = wf.get("name", wf.get("id", "?")) + desc = wf.get("description", "") + items.append(f"- {name}{': ' + desc if desc else ''}") + text = "Voici les workflows que je connais :
" + "
".join(items) + if len(workflows) > 15: + text += f"
... et {len(workflows) - 15} autres" + else: + text = ( + "Je ne connais aucun workflow pour le moment.
" + "Apprenez-moi une tache avec le bouton 'Apprends-moi' !" + ) + self._chat.add_lea_message(text) + self._chat.set_input_enabled(True) + + # --------------------------------------------------------------------------- + # Connexion + # --------------------------------------------------------------------------- + + def _check_connection(self) -> None: + """Verifier la connexion au serveur (dans un timer).""" + connected = self._client.check_connection() + self._update_connection_ui(connected) + + def _on_connection_changed(self, connected: bool) -> None: + """Callback quand l'etat de connexion change.""" + # Appeler dans le thread principal via QTimer + QTimer.singleShot(0, lambda: self._update_connection_ui(connected)) + + def _update_connection_ui(self, connected: bool) -> None: + """Mettre a jour l'UI selon l'etat de connexion.""" + self._avatar.set_connected(connected) + if hasattr(self, '_mini_avatar'): + self._mini_avatar.set_connected(connected) + + if connected: + self._status_label.setText( + f"Connecte a {self._client.server_host}" + ) + self._status_label.setStyleSheet( + f"color: {styles.COLOR_SUCCESS}; " + f"font-family: '{styles.FONT_FAMILY}'; " + f"font-size: {styles.FONT_SIZE_SMALL}px; " + f"background: transparent; border: none;" + ) + else: + error = self._client.last_error or "Serveur injoignable" + self._status_label.setText(f"Deconnecte ({error[:30]})") + self._status_label.setStyleSheet( + f"color: {styles.COLOR_ERROR}; " + f"font-family: '{styles.FONT_FAMILY}'; " + f"font-size: {styles.FONT_SIZE_SMALL}px; " + f"background: transparent; border: none;" + ) + + # --------------------------------------------------------------------------- + # Replay & Overlay + # --------------------------------------------------------------------------- + + def _on_replay_action(self, action: Dict[str, Any]) -> None: + """Callback appelee depuis le thread de polling (pas thread-safe). + + Emettre un signal pour traiter dans le thread Qt. + """ + self.replay_action_received.emit(action) + + @pyqtSlot(dict) + def _handle_replay_action(self, action: Dict[str, Any]) -> None: + """Traiter une action de replay dans le thread Qt. + + Afficher l'overlay AVANT l'execution pour que l'utilisateur + voie ce qui va se passer. + """ + action_type = action.get("type", "?") + action_text = self._describe_action(action) + + # Calculer les coordonnees ecran + desktop = QApplication.desktop() + screen = desktop.screenGeometry(desktop.primaryScreen()) if desktop else None + if screen: + sw, sh = screen.width(), screen.height() + else: + sw, sh = 1920, 1080 + + target_x = int(action.get("x_pct", 0.5) * sw) + target_y = int(action.get("y_pct", 0.5) * sh) + + # Recuperer la progression depuis le replay status + replay = self._client.get_replay_status() + step_current = 0 + step_total = 0 + if replay: + step_total = replay.get("total_actions", 0) + step_current = replay.get("completed_actions", 0) + 1 + + # Mettre a jour la status bar + self._show_replay_status(action_text, step_current, step_total) + + # Afficher l'overlay + self._overlay.show_action( + target_x, target_y, + action_text, + step_current, step_total, + duration_ms=1500, + ) + + # Ajouter dans le chat + self._chat.add_system_message( + f"Etape {step_current}/{step_total} : {action_text}" + ) + + def _describe_action(self, action: Dict[str, Any]) -> str: + """Generer une description lisible d'une action de replay.""" + action_type = action.get("type", "?") + target_text = action.get("target_text", "") + target_role = action.get("target_role", "") + + if action_type == "click": + target = target_text or target_role or "cet element" + return f"Je clique sur [{target}]" + elif action_type == "type": + text = action.get("text", "") + preview = text[:30] + "..." if len(text) > 30 else text + return f"Je tape : {preview}" + elif action_type == "key_combo": + keys = action.get("keys", []) + return f"Je tape : {'+'.join(keys)}" + elif action_type == "scroll": + return "Je fais defiler la page" + elif action_type == "wait": + ms = action.get("duration_ms", 500) + return f"J'attends {ms}ms" + else: + return f"Action : {action_type}" + + def _on_overlay_finished(self) -> None: + """Callback quand l'overlay a fini d'afficher une action.""" + pass # L'executor continue de son cote + + def _show_replay_status( + self, text: str, current: int, total: int, + ) -> None: + """Afficher la barre de progression du replay.""" + self._status_container.show() + self._replay_label.show() + self._replay_label.setText(text) + + if total > 0: + self._progress_bar.show() + self._progress_bar.setMaximum(total) + self._progress_bar.setValue(current) + else: + self._progress_bar.hide() + + def hide_replay_status(self) -> None: + """Masquer la barre de progression du replay.""" + self._status_container.hide() + + # --------------------------------------------------------------------------- + # Visibilite + # --------------------------------------------------------------------------- + + def toggle_visibility(self) -> None: + """Afficher/cacher le panneau (raccourci Ctrl+Shift+L).""" + if self.isVisible(): + self.hide() + else: + self.show() + self.raise_() + self.activateWindow() + + def toggle_minimize(self) -> None: + """Basculer entre panneau complet et mini-barre.""" + if self._minimized: + # Restaurer + self._mini_bar.hide() + self._bg_widget.show() + self._minimized = False + self._anchor_to_right() + else: + # Reduire + self._bg_widget.hide() + self._mini_bar.show() + self._minimized = True + # Positionner la mini-barre en haut a droite + desktop = QApplication.desktop() + if desktop: + screen = desktop.availableGeometry(desktop.primaryScreen()) + x = screen.right() - 90 + y = screen.top() + 10 + self.setGeometry(x, y, 80, 50) + + # --------------------------------------------------------------------------- + # Drag (deplacer la fenetre sans barre de titre) + # --------------------------------------------------------------------------- + + def mousePressEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.LeftButton: + self._drag_pos = event.globalPos() - self.frameGeometry().topLeft() + event.accept() + + def mouseMoveEvent(self, event) -> None: # noqa: N802 + if event.buttons() == Qt.LeftButton and hasattr(self, '_drag_pos'): + self.move(event.globalPos() - self._drag_pos) + event.accept() + + # --------------------------------------------------------------------------- + # Painting (fond arrondi semi-transparent) + # --------------------------------------------------------------------------- + + def paintEvent(self, event) -> None: # noqa: N802 + """Peindre le fond semi-transparent avec coins arrondis.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + + path = QPainterPath() + path.addRoundedRect( + 0, 0, self.width(), self.height(), + styles.BORDER_RADIUS, styles.BORDER_RADIUS, + ) + + # Fond semi-transparent + bg = QColor(styles.COLOR_BG) + bg.setAlpha(245) # Legerement transparent + painter.fillPath(path, bg) + + # Bordure + painter.setPen(QPen(QColor(styles.COLOR_BORDER), 1)) + painter.drawPath(path) + + painter.end() + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + def closeEvent(self, event) -> None: # noqa: N802 + """Ne pas fermer, juste cacher.""" + event.ignore() + self.hide() + + def shutdown(self) -> None: + """Arret propre.""" + self._conn_timer.stop() + self._overlay.hide_overlay() + self._client.shutdown() + logger.info("LeaMainWindow arretee") diff --git a/agent_v0/lea_ui/overlay.py b/agent_v0/lea_ui/overlay.py new file mode 100644 index 000000000..c2e850786 --- /dev/null +++ b/agent_v0/lea_ui/overlay.py @@ -0,0 +1,354 @@ +# agent_v0/lea_ui/overlay.py +""" +Overlay de feedback visuel pour le replay. + +Fenetre transparente plein ecran, click-through, qui affiche : + - Cercle rouge pulsant autour de la cible du clic + - Texte descriptif de l'action en cours + - Fleche pointant vers la cible + - Barre de progression etape X/Y + +Le overlay ne capture JAMAIS les clics (Qt.WA_TransparentForMouseEvents). +""" + +from __future__ import annotations + +import logging +import math +from typing import Optional, Tuple + +from PyQt5.QtCore import ( + QPoint, + QPropertyAnimation, + QRect, + QRectF, + QSize, + Qt, + QTimer, + pyqtProperty, + pyqtSignal, +) +from PyQt5.QtGui import ( + QBrush, + QColor, + QFont, + QFontMetrics, + QPainter, + QPainterPath, + QPen, + QPolygonF, +) +from PyQt5.QtWidgets import QApplication, QDesktopWidget, QWidget + +from . import styles + +logger = logging.getLogger("lea_ui.overlay") + + +class OverlayWidget(QWidget): + """Overlay plein ecran transparent pour le feedback visuel du replay. + + Flags critiques : + - WindowStaysOnTopHint : toujours au-dessus + - FramelessWindowHint : pas de decoration + - Tool : n'apparait pas dans la barre des taches + - WA_TranslucentBackground : fond transparent + - WA_TransparentForMouseEvents : CLICK-THROUGH COMPLET + """ + + # Signal emis quand l'animation d'une action est terminee + action_display_finished = pyqtSignal() + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + + # Flags de fenetre pour click-through complet + self.setWindowFlags( + Qt.WindowStaysOnTopHint + | Qt.FramelessWindowHint + | Qt.Tool + ) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setAttribute(Qt.WA_TransparentForMouseEvents, True) + + # Etat de l'affichage + self._target_pos: Optional[Tuple[int, int]] = None + self._action_text: str = "" + self._progress_current: int = 0 + self._progress_total: int = 0 + self._action_done: bool = False + self._visible = False + + # Animation du cercle pulsant + self._pulse_radius: float = 30.0 + self._pulse_growing = True + self._pulse_opacity: float = 0.8 + + # Timer d'animation + self._anim_timer = QTimer(self) + self._anim_timer.timeout.connect(self._animate_pulse) + self._anim_timer.setInterval(30) # ~33 FPS + + # Timer d'effacement automatique + self._fade_timer = QTimer(self) + self._fade_timer.setSingleShot(True) + self._fade_timer.timeout.connect(self._on_fade) + + # Couvrir tout l'ecran + self._update_geometry() + + def _update_geometry(self) -> None: + """Positionner l'overlay sur tout l'ecran principal.""" + desktop = QApplication.desktop() + if desktop: + screen_rect = desktop.screenGeometry(desktop.primaryScreen()) + self.setGeometry(screen_rect) + + # --------------------------------------------------------------------------- + # API publique + # --------------------------------------------------------------------------- + + def show_action( + self, + target_x: int, + target_y: int, + text: str, + step_current: int = 0, + step_total: int = 0, + duration_ms: int = 1500, + ) -> None: + """Afficher le feedback pour une action de replay. + + Args: + target_x: position X du clic cible (pixels ecran) + target_y: position Y du clic cible (pixels ecran) + text: description de l'action (ex: "Je clique sur [Valider]") + step_current: etape courante (1-indexed) + step_total: nombre total d'etapes + duration_ms: duree d'affichage en ms (defaut 1500ms) + """ + self._target_pos = (target_x, target_y) + self._action_text = text + self._progress_current = step_current + self._progress_total = step_total + self._action_done = False + self._pulse_radius = 30.0 + self._pulse_opacity = 0.8 + self._visible = True + + self._update_geometry() + self.show() + self.raise_() + self._anim_timer.start() + + # Programmer l'effacement + self._fade_timer.start(duration_ms) + self.update() + + def show_done(self, text: Optional[str] = None) -> None: + """Marquer l'action courante comme terminee (coche verte).""" + self._action_done = True + if text: + self._action_text = text + self.update() + + # Effacer apres 800ms + self._fade_timer.start(800) + + def hide_overlay(self) -> None: + """Masquer immediatement l'overlay.""" + self._anim_timer.stop() + self._fade_timer.stop() + self._visible = False + self._target_pos = None + self.hide() + + # --------------------------------------------------------------------------- + # Animations + # --------------------------------------------------------------------------- + + def _animate_pulse(self) -> None: + """Animer le cercle pulsant.""" + if self._action_done: + # Pas d'animation en mode "done" + return + + pulse_speed = 0.8 + if self._pulse_growing: + self._pulse_radius += pulse_speed + if self._pulse_radius >= 45.0: + self._pulse_growing = False + else: + self._pulse_radius -= pulse_speed + if self._pulse_radius <= 25.0: + self._pulse_growing = True + + # Opacite qui suit le pulse + self._pulse_opacity = 0.5 + 0.3 * ( + (self._pulse_radius - 25.0) / 20.0 + ) + + self.update() + + def _on_fade(self) -> None: + """Callback apres le timer d'effacement.""" + self._anim_timer.stop() + self._visible = False + self._target_pos = None + self.hide() + self.action_display_finished.emit() + + # --------------------------------------------------------------------------- + # Rendu + # --------------------------------------------------------------------------- + + def paintEvent(self, event) -> None: # noqa: N802 + """Dessiner l'overlay.""" + if not self._visible or not self._target_pos: + return + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing, True) + + tx, ty = self._target_pos + + if self._action_done: + self._draw_done_indicator(painter, tx, ty) + else: + self._draw_pulse_circle(painter, tx, ty) + self._draw_arrow(painter, tx, ty) + + self._draw_action_text(painter, tx, ty) + self._draw_progress_bar(painter) + + painter.end() + + def _draw_pulse_circle(self, painter: QPainter, cx: int, cy: int) -> None: + """Dessiner le cercle rouge pulsant autour de la cible.""" + # Cercle exterieur (pulsant, semi-transparent) + color = QColor(styles.COLOR_OVERLAY_PULSE) + color.setAlphaF(self._pulse_opacity * 0.4) + painter.setBrush(QBrush(color)) + painter.setPen(Qt.NoPen) + painter.drawEllipse( + QPoint(cx, cy), + int(self._pulse_radius), + int(self._pulse_radius), + ) + + # Cercle interieur (fixe, plus opaque) + color_inner = QColor(styles.COLOR_OVERLAY_PULSE) + color_inner.setAlphaF(0.7) + pen = QPen(color_inner, 3) + painter.setPen(pen) + painter.setBrush(Qt.NoBrush) + painter.drawEllipse(QPoint(cx, cy), 20, 20) + + # Point central + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(QColor(styles.COLOR_OVERLAY_PULSE))) + painter.drawEllipse(QPoint(cx, cy), 4, 4) + + def _draw_done_indicator(self, painter: QPainter, cx: int, cy: int) -> None: + """Dessiner l'indicateur de succes (cercle vert + coche).""" + # Cercle vert + color = QColor(styles.COLOR_SUCCESS) + color.setAlphaF(0.8) + painter.setBrush(QBrush(color)) + painter.setPen(Qt.NoPen) + painter.drawEllipse(QPoint(cx, cy), 25, 25) + + # Coche blanche + pen = QPen(QColor(styles.COLOR_TEXT_ON_ACCENT), 3) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.NoBrush) + + path = QPainterPath() + path.moveTo(cx - 10, cy) + path.lineTo(cx - 3, cy + 8) + path.lineTo(cx + 12, cy - 8) + painter.drawPath(path) + + def _draw_arrow(self, painter: QPainter, tx: int, ty: int) -> None: + """Dessiner une fleche pointant vers la cible depuis le texte.""" + # Position du texte (au-dessus ou en dessous selon l'espace) + text_y = ty - 80 if ty > 120 else ty + 80 + text_x = max(100, min(tx, self.width() - 200)) + + # Ligne de la fleche + color = QColor(styles.COLOR_OVERLAY_PULSE) + color.setAlphaF(0.6) + pen = QPen(color, 2, Qt.DashLine) + painter.setPen(pen) + painter.drawLine(text_x, text_y + (15 if text_y < ty else -15), tx, ty) + + def _draw_action_text(self, painter: QPainter, tx: int, ty: int) -> None: + """Dessiner le texte descriptif de l'action.""" + if not self._action_text: + return + + # Positionner le texte au-dessus ou en dessous de la cible + text_y = ty - 90 if ty > 140 else ty + 70 + + font = QFont(styles.FONT_FAMILY, styles.FONT_SIZE_LARGE, QFont.Bold) + painter.setFont(font) + metrics = QFontMetrics(font) + + # Mesurer le texte + text_rect = metrics.boundingRect(self._action_text) + text_width = text_rect.width() + 30 + text_height = text_rect.height() + 16 + + # Centrer horizontalement sur la cible (avec limites d'ecran) + box_x = max(10, min(tx - text_width // 2, self.width() - text_width - 10)) + box_y = text_y - text_height // 2 + + # Fond semi-transparent arrondi + bg_color = QColor(31, 41, 55, 200) # Gris fonce semi-transparent + painter.setBrush(QBrush(bg_color)) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(box_x, box_y, text_width, text_height, 8, 8) + + # Texte blanc + painter.setPen(QPen(QColor(styles.COLOR_OVERLAY_TEXT))) + painter.drawText( + QRect(box_x, box_y, text_width, text_height), + Qt.AlignCenter, + self._action_text, + ) + + def _draw_progress_bar(self, painter: QPainter) -> None: + """Dessiner la barre de progression en bas de l'ecran.""" + if self._progress_total <= 0: + return + + bar_width = 300 + bar_height = 6 + bar_x = (self.width() - bar_width) // 2 + bar_y = self.height() - 50 + + # Fond + bg_color = QColor(255, 255, 255, 80) + painter.setBrush(QBrush(bg_color)) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(bar_x, bar_y, bar_width, bar_height, 3, 3) + + # Progression + progress_pct = self._progress_current / self._progress_total + fill_width = int(bar_width * progress_pct) + accent_color = QColor(styles.COLOR_ACCENT) + accent_color.setAlphaF(0.9) + painter.setBrush(QBrush(accent_color)) + painter.drawRoundedRect(bar_x, bar_y, fill_width, bar_height, 3, 3) + + # Label "Etape X/Y" + label_font = QFont(styles.FONT_FAMILY, styles.FONT_SIZE_SMALL) + painter.setFont(label_font) + painter.setPen(QPen(QColor(255, 255, 255, 200))) + painter.drawText( + QRect(bar_x, bar_y + bar_height + 4, bar_width, 20), + Qt.AlignCenter, + f"Etape {self._progress_current}/{self._progress_total}", + ) diff --git a/agent_v0/lea_ui/replay_integration.py b/agent_v0/lea_ui/replay_integration.py new file mode 100644 index 000000000..da29ce418 --- /dev/null +++ b/agent_v0/lea_ui/replay_integration.py @@ -0,0 +1,191 @@ +# agent_v0/lea_ui/replay_integration.py +""" +Integration du feedback visuel (overlay) dans la boucle de replay de l'Agent V1. + +Ce module fournit un wrapper autour de ActionExecutorV1.execute_replay_action +qui affiche l'overlay AVANT chaque action et la marque comme terminee APRES. + +Sequence pour chaque action : + 1. Afficher l'overlay avec la description de l'action (1.5s) + 2. Attendre que l'overlay ait ete vu par l'utilisateur + 3. Executer l'action + 4. Mettre a jour l'overlay (coche verte) + 5. Passer a l'action suivante +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Callable, Dict, Optional, Tuple + +logger = logging.getLogger("lea_ui.replay_integration") + +# Delai d'affichage de l'overlay avant execution (secondes) +PRE_ACTION_DELAY = 1.5 +# Delai apres la coche verte (secondes) +POST_ACTION_DELAY = 0.5 + + +class ReplayOverlayBridge: + """Pont entre la boucle de replay et l'overlay. + + Fonctionne de maniere thread-safe : la boucle de replay tourne dans + un thread daemon, et l'overlay est controle via des signaux Qt. + + L'overlay est optionnel — si non connecte, l'execution continue normalement. + """ + + def __init__(self) -> None: + self._overlay = None + self._show_callback: Optional[Callable] = None + self._done_callback: Optional[Callable] = None + self._hide_callback: Optional[Callable] = None + self._enabled = False + + # Compteur de progression + self._step_current = 0 + self._step_total = 0 + + def connect_overlay( + self, + show_fn: Callable[[int, int, str, int, int, int], None], + done_fn: Callable[[Optional[str]], None], + hide_fn: Callable[[], None], + ) -> None: + """Connecter les callbacks de l'overlay. + + Args: + show_fn: overlay.show_action(target_x, target_y, text, step, total, duration_ms) + done_fn: overlay.show_done(text) + hide_fn: overlay.hide_overlay() + """ + self._show_callback = show_fn + self._done_callback = done_fn + self._hide_callback = hide_fn + self._enabled = True + logger.info("Overlay connecte au bridge de replay") + + def disconnect_overlay(self) -> None: + """Deconnecter l'overlay.""" + self._show_callback = None + self._done_callback = None + self._hide_callback = None + self._enabled = False + + def set_total_steps(self, total: int) -> None: + """Definir le nombre total d'etapes du replay.""" + self._step_total = total + self._step_current = 0 + + def wrap_execute( + self, + action: Dict[str, Any], + executor_fn: Callable[[Dict[str, Any]], Dict[str, Any]], + screen_width: int = 1920, + screen_height: int = 1080, + ) -> Dict[str, Any]: + """Wrapper autour de l'execution d'une action avec feedback overlay. + + Args: + action: action normalisee (type, x_pct, y_pct, text, keys, ...) + executor_fn: fonction d'execution (ex: ActionExecutorV1.execute_replay_action) + screen_width: largeur de l'ecran en pixels + screen_height: hauteur de l'ecran en pixels + + Returns: + Resultat de l'execution (dict avec success, error, screenshot, ...) + """ + self._step_current += 1 + + if not self._enabled or not self._show_callback: + # Pas d'overlay — execution directe + return executor_fn(action) + + # --- 1. Afficher l'overlay --- + action_text = self._describe_action(action) + target_x, target_y = self._get_target_coords(action, screen_width, screen_height) + + try: + self._show_callback( + target_x, target_y, + action_text, + self._step_current, + self._step_total, + int(PRE_ACTION_DELAY * 1000), + ) + except Exception as e: + logger.warning("Erreur affichage overlay : %s", e) + + # --- 2. Attendre que l'utilisateur ait vu --- + time.sleep(PRE_ACTION_DELAY) + + # --- 3. Executer l'action --- + result = executor_fn(action) + + # --- 4. Marquer comme terminee --- + if result.get("success"): + done_text = f"{action_text} OK" + else: + done_text = f"{action_text} ECHEC" + + try: + if self._done_callback: + self._done_callback(done_text) + except Exception as e: + logger.warning("Erreur overlay done : %s", e) + + time.sleep(POST_ACTION_DELAY) + + # --- 5. Cacher si c'etait la derniere etape --- + if self._step_current >= self._step_total and self._hide_callback: + try: + self._hide_callback() + except Exception: + pass + + return result + + def _describe_action(self, action: Dict[str, Any]) -> str: + """Generer une description lisible d'une action.""" + action_type = action.get("type", "?") + target_text = action.get("target_text", "") + target_role = action.get("target_role", "") + + if action_type == "click": + target = target_text or target_role or "cet element" + return f"Je clique sur [{target}]" + elif action_type == "type": + text = action.get("text", "") + preview = text[:25] + "..." if len(text) > 25 else text + return f"Je tape : {preview}" + elif action_type == "key_combo": + keys = action.get("keys", []) + return f"Combinaison : {'+'.join(keys)}" + elif action_type == "scroll": + return "Defilement" + elif action_type == "wait": + ms = action.get("duration_ms", 500) + return f"Attente {ms}ms" + else: + return f"Action : {action_type}" + + def _get_target_coords( + self, action: Dict[str, Any], sw: int, sh: int, + ) -> Tuple[int, int]: + """Calculer les coordonnees cible en pixels.""" + x_pct = action.get("x_pct", 0.5) + y_pct = action.get("y_pct", 0.5) + return int(x_pct * sw), int(y_pct * sh) + + +# Instance globale (singleton) pour l'integration +_bridge: Optional[ReplayOverlayBridge] = None + + +def get_replay_bridge() -> ReplayOverlayBridge: + """Obtenir l'instance globale du bridge overlay/replay.""" + global _bridge + if _bridge is None: + _bridge = ReplayOverlayBridge() + return _bridge diff --git a/agent_v0/lea_ui/server_client.py b/agent_v0/lea_ui/server_client.py new file mode 100644 index 000000000..a0aab386f --- /dev/null +++ b/agent_v0/lea_ui/server_client.py @@ -0,0 +1,355 @@ +# agent_v0/lea_ui/server_client.py +""" +Client API pour communiquer avec le serveur Linux RPA Vision V3. + +Endpoints cibles : + - Agent Chat (port 5004) : /api/chat, /api/workflows + - Streaming Server (port 5005) : /api/v1/traces/stream/replay/next, etc. + +Le polling tourne dans un thread separe pour ne pas bloquer la UI Qt. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger("lea_ui.server_client") + + +def _get_server_host() -> str: + """Recuperer l'adresse du serveur Linux. + + Ordre de resolution : + 1. Variable d'environnement RPA_SERVER_HOST + 2. Fichier de config agent_config.json (cle "server_host") + 3. Fallback localhost + """ + # 1. Variable d'environnement + host = os.environ.get("RPA_SERVER_HOST", "").strip() + if host: + return host + + # 2. Fichier de config + config_paths = [ + os.path.join(os.path.dirname(__file__), "..", "agent_config.json"), + os.path.join(os.path.dirname(__file__), "..", "..", "agent_config.json"), + ] + for config_path in config_paths: + try: + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + host = cfg.get("server_host", "").strip() + if host: + return host + except (OSError, json.JSONDecodeError): + continue + + # 3. Fallback + return "localhost" + + +class LeaServerClient: + """Client API thread-safe vers le serveur RPA Vision V3. + + Gere la communication HTTP avec le serveur chat (port 5004) + et le serveur de streaming (port 5005). + Le polling replay tourne dans un thread daemon separe. + """ + + def __init__( + self, + server_host: Optional[str] = None, + chat_port: int = 5004, + stream_port: int = 5005, + ) -> None: + self._host = server_host or _get_server_host() + self._chat_port = chat_port + self._stream_port = stream_port + + self._chat_base = f"http://{self._host}:{self._chat_port}" + self._stream_base = f"http://{self._host}:{self._stream_port}" + + # Etat de connexion + self._connected = False + self._last_error: Optional[str] = None + + # Callbacks UI (appelees depuis le thread de polling) + self._on_connection_change: Optional[Callable[[bool], None]] = None + self._on_replay_action: Optional[Callable[[Dict[str, Any]], None]] = None + self._on_chat_response: Optional[Callable[[Dict[str, Any]], None]] = None + + # Thread de polling + self._polling = False + self._poll_thread: Optional[threading.Thread] = None + self._poll_interval = 1.0 # secondes + + # Session de chat + self._chat_session_id: Optional[str] = None + + logger.info( + "LeaServerClient initialise : chat=%s, stream=%s", + self._chat_base, self._stream_base, + ) + + # --------------------------------------------------------------------------- + # Proprietes + # --------------------------------------------------------------------------- + + @property + def connected(self) -> bool: + return self._connected + + @property + def server_host(self) -> str: + return self._host + + @property + def last_error(self) -> Optional[str]: + return self._last_error + + # --------------------------------------------------------------------------- + # Callbacks + # --------------------------------------------------------------------------- + + def set_on_connection_change(self, callback: Callable[[bool], None]) -> None: + """Callback appelee quand l'etat de connexion change.""" + self._on_connection_change = callback + + def set_on_replay_action(self, callback: Callable[[Dict[str, Any]], None]) -> None: + """Callback appelee quand une action de replay est recue.""" + self._on_replay_action = callback + + def set_on_chat_response(self, callback: Callable[[Dict[str, Any]], None]) -> None: + """Callback appelee quand une reponse chat est recue.""" + self._on_chat_response = callback + + # --------------------------------------------------------------------------- + # Connexion + # --------------------------------------------------------------------------- + + def check_connection(self) -> bool: + """Tester la connexion au serveur chat.""" + try: + import requests + resp = requests.get( + f"{self._chat_base}/api/workflows", + timeout=5, + ) + was_connected = self._connected + self._connected = resp.ok + self._last_error = None + + if self._connected != was_connected and self._on_connection_change: + self._on_connection_change(self._connected) + + return self._connected + except Exception as e: + was_connected = self._connected + self._connected = False + self._last_error = str(e) + + if was_connected and self._on_connection_change: + self._on_connection_change(False) + + return False + + # --------------------------------------------------------------------------- + # Chat API (port 5004) + # --------------------------------------------------------------------------- + + def send_chat_message(self, message: str) -> Optional[Dict[str, Any]]: + """Envoyer un message au chat et retourner la reponse. + + Retourne None en cas d'erreur reseau. + """ + try: + import requests + payload = { + "message": message, + } + if self._chat_session_id: + payload["session_id"] = self._chat_session_id + + resp = requests.post( + f"{self._chat_base}/api/chat", + json=payload, + timeout=30, + ) + + if resp.ok: + data = resp.json() + # Sauvegarder le session_id pour le contexte multi-tour + if "session_id" in data: + self._chat_session_id = data["session_id"] + self._connected = True + return data + else: + self._last_error = f"HTTP {resp.status_code}" + logger.warning("Chat API erreur : %s", self._last_error) + return None + + except Exception as e: + self._last_error = str(e) + self._connected = False + logger.error("Chat API exception : %s", e) + return None + + def list_workflows(self) -> List[Dict[str, Any]]: + """Recuperer la liste des workflows depuis le serveur chat.""" + try: + import requests + resp = requests.get( + f"{self._chat_base}/api/workflows", + timeout=10, + ) + if resp.ok: + data = resp.json() + self._connected = True + # L'API renvoie directement une liste ou un dict avec clé "workflows" + if isinstance(data, list): + return data + return data.get("workflows", []) + return [] + except Exception as e: + self._last_error = str(e) + logger.error("List workflows erreur : %s", e) + return [] + + def list_gestures(self) -> List[Dict[str, Any]]: + """Recuperer la liste des gestes depuis le serveur chat.""" + try: + import requests + resp = requests.get( + f"{self._chat_base}/api/gestures", + timeout=10, + ) + if resp.ok: + data = resp.json() + if isinstance(data, list): + return data + return data.get("gestures", []) + return [] + except Exception as e: + logger.error("List gestures erreur : %s", e) + return [] + + # --------------------------------------------------------------------------- + # Replay Polling (port 5005) + # --------------------------------------------------------------------------- + + def start_polling(self, session_id: str) -> None: + """Demarrer le polling des actions de replay dans un thread daemon.""" + if self._polling: + return + + self._polling = True + self._poll_session_id = session_id + self._poll_thread = threading.Thread( + target=self._poll_loop, + daemon=True, + name="lea-replay-poll", + ) + self._poll_thread.start() + logger.info("Polling replay demarre pour session %s", session_id) + + def stop_polling(self) -> None: + """Arreter le polling.""" + self._polling = False + if self._poll_thread: + self._poll_thread.join(timeout=3) + self._poll_thread = None + logger.info("Polling replay arrete") + + def _poll_loop(self) -> None: + """Boucle de polling dans un thread separe.""" + import requests as req_lib + + while self._polling: + try: + resp = req_lib.get( + f"{self._stream_base}/api/v1/traces/stream/replay/next", + params={"session_id": self._poll_session_id}, + timeout=5, + ) + + if resp.ok: + data = resp.json() + action = data.get("action") + if action and self._on_replay_action: + self._on_replay_action(action) + # Apres une action, poll plus rapidement + time.sleep(0.2) + continue + + except req_lib.exceptions.ConnectionError: + # Serveur non disponible — silencieux + pass + except req_lib.exceptions.Timeout: + pass + except Exception as e: + logger.error("Erreur poll replay : %s", e) + + time.sleep(self._poll_interval) + + # --------------------------------------------------------------------------- + # Replay Status + # --------------------------------------------------------------------------- + + def get_replay_status(self) -> Optional[Dict[str, Any]]: + """Recuperer l'etat des replays en cours.""" + try: + import requests + resp = requests.get( + f"{self._stream_base}/api/v1/traces/stream/replays", + timeout=5, + ) + if resp.ok: + data = resp.json() + replays = data.get("replays", []) + # Retourner le premier replay actif + for r in replays: + if r.get("status") == "running": + return r + return None + return None + except Exception: + return None + + def report_action_result( + self, + session_id: str, + action_id: str, + success: bool, + error: Optional[str] = None, + screenshot: Optional[str] = None, + ) -> None: + """Rapporter le resultat d'execution d'une action au serveur.""" + try: + import requests + requests.post( + f"{self._stream_base}/api/v1/traces/stream/replay/result", + json={ + "session_id": session_id, + "action_id": action_id, + "success": success, + "error": error, + "screenshot": screenshot, + }, + timeout=5, + ) + except Exception as e: + logger.error("Report action result erreur : %s", e) + + # --------------------------------------------------------------------------- + # Lifecycle + # --------------------------------------------------------------------------- + + def shutdown(self) -> None: + """Arreter proprement le client.""" + self.stop_polling() + logger.info("LeaServerClient arrete") diff --git a/agent_v0/lea_ui/styles.py b/agent_v0/lea_ui/styles.py new file mode 100644 index 000000000..524c56856 --- /dev/null +++ b/agent_v0/lea_ui/styles.py @@ -0,0 +1,200 @@ +# agent_v0/lea_ui/styles.py +""" +Theme et couleurs pour l'interface Lea. + +Palette douce et moderne, pensee pour ne pas fatiguer les yeux +lors d'une utilisation prolongee sur un poste de travail Windows. +""" + +# --------------------------------------------------------------------------- +# Palette de couleurs +# --------------------------------------------------------------------------- + +# Fond principal +COLOR_BG = "#F5F7FA" +# Fond secondaire (sidebar, header) +COLOR_BG_SECONDARY = "#EEF1F6" +# Fond des bulles utilisateur +COLOR_BUBBLE_USER = "#6366F1" +# Fond des bulles Lea +COLOR_BUBBLE_LEA = "#FFFFFF" +# Accent principal (indigo) +COLOR_ACCENT = "#6366F1" +# Accent hover +COLOR_ACCENT_HOVER = "#4F46E5" +# Texte principal +COLOR_TEXT = "#1F2937" +# Texte secondaire +COLOR_TEXT_SECONDARY = "#6B7280" +# Texte sur accent (blanc) +COLOR_TEXT_ON_ACCENT = "#FFFFFF" +# Bordure legere +COLOR_BORDER = "#E5E7EB" +# Succes (vert) +COLOR_SUCCESS = "#10B981" +# Erreur (rouge) +COLOR_ERROR = "#EF4444" +# Avertissement (orange) +COLOR_WARNING = "#F59E0B" +# Overlay rouge pulsant +COLOR_OVERLAY_PULSE = "#EF4444" +# Overlay texte +COLOR_OVERLAY_TEXT = "#FFFFFF" +# Overlay fond info +COLOR_OVERLAY_INFO_BG = "rgba(31, 41, 55, 200)" + +# --------------------------------------------------------------------------- +# Typographie +# --------------------------------------------------------------------------- + +FONT_FAMILY = "Segoe UI" +FONT_SIZE_SMALL = 11 +FONT_SIZE_NORMAL = 13 +FONT_SIZE_LARGE = 15 +FONT_SIZE_TITLE = 18 + +# --------------------------------------------------------------------------- +# Dimensions +# --------------------------------------------------------------------------- + +# Largeur du panneau Lea +PANEL_WIDTH = 380 +# Hauteur minimale +PANEL_MIN_HEIGHT = 500 +# Rayon des coins arrondis +BORDER_RADIUS = 12 +# Rayon des bulles de chat +BUBBLE_RADIUS = 16 +# Padding interne +PADDING = 12 +# Taille de l'avatar +AVATAR_SIZE = 40 +# Marge entre les elements +SPACING = 8 + +# --------------------------------------------------------------------------- +# Stylesheet global du panneau Lea +# --------------------------------------------------------------------------- + +MAIN_WINDOW_STYLE = f""" + QWidget#LeaMainWindow {{ + background-color: {COLOR_BG}; + border-radius: {BORDER_RADIUS}px; + border: 1px solid {COLOR_BORDER}; + }} +""" + +HEADER_STYLE = f""" + QWidget#LeaHeader {{ + background-color: {COLOR_BG_SECONDARY}; + border-top-left-radius: {BORDER_RADIUS}px; + border-top-right-radius: {BORDER_RADIUS}px; + border-bottom: 1px solid {COLOR_BORDER}; + }} + QLabel#LeaTitle {{ + color: {COLOR_TEXT}; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_TITLE}px; + font-weight: bold; + }} + QLabel#LeaStatus {{ + color: {COLOR_TEXT_SECONDARY}; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_SMALL}px; + }} +""" + +CHAT_AREA_STYLE = f""" + QScrollArea {{ + border: none; + background-color: {COLOR_BG}; + }} + QWidget#ChatContainer {{ + background-color: {COLOR_BG}; + }} +""" + +INPUT_STYLE = f""" + QLineEdit#ChatInput {{ + background-color: {COLOR_BUBBLE_LEA}; + border: 1px solid {COLOR_BORDER}; + border-radius: 20px; + padding: 8px 16px; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_NORMAL}px; + color: {COLOR_TEXT}; + }} + QLineEdit#ChatInput:focus {{ + border-color: {COLOR_ACCENT}; + }} +""" + +SEND_BUTTON_STYLE = f""" + QPushButton#SendButton {{ + background-color: {COLOR_ACCENT}; + color: {COLOR_TEXT_ON_ACCENT}; + border: none; + border-radius: 20px; + padding: 8px 16px; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_NORMAL}px; + font-weight: bold; + min-width: 50px; + }} + QPushButton#SendButton:hover {{ + background-color: {COLOR_ACCENT_HOVER}; + }} + QPushButton#SendButton:pressed {{ + background-color: #3730A3; + }} +""" + +QUICK_BUTTON_STYLE = f""" + QPushButton#QuickButton {{ + background-color: {COLOR_BUBBLE_LEA}; + color: {COLOR_ACCENT}; + border: 1px solid {COLOR_ACCENT}; + border-radius: 18px; + padding: 6px 14px; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_SMALL}px; + }} + QPushButton#QuickButton:hover {{ + background-color: {COLOR_ACCENT}; + color: {COLOR_TEXT_ON_ACCENT}; + }} +""" + +PROGRESS_STYLE = f""" + QProgressBar {{ + border: none; + border-radius: 4px; + background-color: {COLOR_BORDER}; + text-align: center; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_SMALL}px; + color: {COLOR_TEXT}; + max-height: 8px; + }} + QProgressBar::chunk {{ + background-color: {COLOR_ACCENT}; + border-radius: 4px; + }} +""" + +STATUS_LABEL_STYLE = f""" + QLabel#StatusLabel {{ + color: {COLOR_TEXT_SECONDARY}; + font-family: "{FONT_FAMILY}"; + font-size: {FONT_SIZE_SMALL}px; + padding: 4px 8px; + }} +""" + +MINI_BAR_STYLE = f""" + QWidget#MiniBar {{ + background-color: {COLOR_BG_SECONDARY}; + border-radius: 20px; + border: 1px solid {COLOR_BORDER}; + }} +""" diff --git a/agent_v0/run_agent_v1.py b/agent_v0/run_agent_v1.py new file mode 100644 index 000000000..ec442f211 --- /dev/null +++ b/agent_v0/run_agent_v1.py @@ -0,0 +1,16 @@ +# run_agent_v1.py +import sys +import os + +# Ajout du répertoire courant au PYTHONPATH pour permettre les imports de modules +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.append(current_dir) + +try: + from agent_v1.main import main + if __name__ == "__main__": + main() +except ImportError as e: + print(f"Erreur d'importation : {e}") + print("Assurez-vous d'être dans le répertoire racine du projet et que agent_v1 est bien un package Python.") diff --git a/agent_v0/server_v1/__init__.py b/agent_v0/server_v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py new file mode 100644 index 000000000..a61d1cbf5 --- /dev/null +++ b/agent_v0/server_v1/api_stream.py @@ -0,0 +1,1964 @@ +# agent_v0/server_v1/api_stream.py +""" +API de Streaming Temps Réel pour RPA Vision V3. + +Connecte l'Agent V1 au core pipeline via StreamProcessor. +Tous les calculs GPU (ScreenAnalyzer, CLIP, FAISS) tournent ici sur le serveur. + +Inclut les endpoints de replay pour renvoyer des ordres d'exécution à l'Agent V1. +""" + +import json +import logging +import os +import threading +import time +import uuid +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from .replay_verifier import ReplayVerifier, VerificationResult +from .session_worker import SessionWorker +from .stream_processor import StreamProcessor +from .worker_stream import StreamWorker + +# Instance globale du vérificateur de replay (comparaison screenshots avant/après) +_replay_verifier = ReplayVerifier() + +# Nombre maximum de retries par action avant de déclarer un échec +MAX_RETRIES_PER_ACTION = 3 + +# Limites de sécurité pour les queues de replay +MAX_ACTIONS_PER_REPLAY = 500 # Max actions par requête de replay +MAX_REPLAY_STATES = 1000 # Max entrées dans _replay_states +REPLAY_STATE_TTL_SECONDS = 3600 # Nettoyage auto des replays terminés après 1h + +# Actions en cours de retry : action_id -> {"action": ..., "retry_count": N, "replay_id": ...} +_retry_pending: Dict[str, Dict[str, Any]] = {} + +# Callbacks d'erreur par replay_id : replay_id -> callback_url +_error_callbacks: Dict[str, str] = {} + +# Optimisation des actions replay par gestes primitifs +try: + from agent_chat.gesture_catalog import get_gesture_catalog + _gesture_catalog = get_gesture_catalog() +except ImportError: + _gesture_catalog = None + +logger = logging.getLogger("api_stream") +app = FastAPI(title="RPA Vision V3 - Streaming API v1") + +# CORS — origines autorisées (VWB frontend, Agent Chat, Dashboard) +# Configurable via variable d'environnement CORS_ORIGINS (séparées par des virgules) +_DEFAULT_CORS_ORIGINS = ( + "http://localhost:3002," # VWB Frontend (Vite/React) + "http://localhost:5002," # VWB Backend (Flask) + "http://localhost:5004," # Agent Chat + "http://localhost:5001," # Web Dashboard + "http://192.168.1.40:3002," # VWB Frontend depuis le réseau local + "http://192.168.1.40:5004" # Agent Chat depuis le réseau local +) +CORS_ORIGINS = os.environ.get("CORS_ORIGINS", _DEFAULT_CORS_ORIGINS).split(",") +CORS_ORIGINS = [o.strip() for o in CORS_ORIGINS if o.strip()] + +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_methods=["GET", "POST"], + allow_headers=["Content-Type", "Authorization"], +) + +# Dossier des sessions live +ROOT_DIR = Path(__file__).parent.parent.parent +LIVE_SESSIONS_DIR = ROOT_DIR / "data" / "training" / "live_sessions" +LIVE_SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + +# Instance globale partagée +processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR)) +worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor) + +# Worker asynchrone pour le traitement des sessions finalisées +# (analyse VLM + construction workflow en arrière-plan) +session_worker = SessionWorker(processor=processor, poll_interval=10) + +# ========================================================================= +# Compteur d'analyses en cours par session (pour attendre avant finalize) +# ========================================================================= +_pending_analyses: Dict[str, int] = defaultdict(int) +_pending_lock = threading.Lock() + +# ========================================================================= +# File d'attente de replay par session +# Chaque session a une queue d'actions à exécuter et un état de replay +# ========================================================================= +_replay_lock = threading.Lock() +# session_id -> liste d'actions en attente (FIFO) +_replay_queues: Dict[str, List[Dict[str, Any]]] = defaultdict(list) +# replay_id -> état du replay (workflow_id, session_id, status, progress) +_replay_states: Dict[str, Dict[str, Any]] = {} +# machine_id -> session_id (mapping pour le replay ciblé par machine) +_machine_replay_target: Dict[str, str] = {} + + +class StreamEvent(BaseModel): + session_id: str + timestamp: float + event: Dict[str, Any] + machine_id: str = "default" # Identifiant machine (multi-machine, rétrocompatible) + + +class ReplayRequest(BaseModel): + """Requête de lancement de replay d'un workflow.""" + workflow_id: str + session_id: str + machine_id: Optional[str] = None # Machine cible pour le replay (multi-machine) + params: Optional[Dict[str, Any]] = None + + +class RawReplayRequest(BaseModel): + """Requête de replay avec actions brutes (mode Agent Libre).""" + actions: List[Dict[str, Any]] + session_id: str = "" + machine_id: Optional[str] = None # Machine cible (multi-machine) + task_description: str = "" + + +class SingleActionRequest(BaseModel): + """Requête d'exécution d'une seule action (mode Copilot).""" + action: Dict[str, Any] + session_id: str = "" + machine_id: Optional[str] = None # Machine cible (multi-machine) + + +class ReplayResultReport(BaseModel): + """Rapport de résultat d'exécution d'une action par l'Agent V1.""" + session_id: str + action_id: str + success: bool + error: Optional[str] = None + screenshot: Optional[str] = None # Chemin ou base64 du screenshot post-action + screenshot_after: Optional[str] = None # Chemin ou base64 du screenshot APRES l'action + actual_position: Optional[Dict[str, float]] = None # {"x": px, "y": py} position réelle du clic + + +class ErrorCallbackConfig(BaseModel): + """Configuration du callback d'erreur pour un replay.""" + replay_id: str + callback_url: str # URL à appeler en cas d'erreur non-récupérable + + +# Thread de nettoyage périodique des replays terminés et sessions expirées +_cleanup_thread: Optional[threading.Thread] = None +_cleanup_running = False + + +def _cleanup_loop(): + """Nettoyage périodique des replay states terminés et des sessions expirées. + + Tourne en arrière-plan toutes les 10 minutes : + - Supprime les replay states completed/error/failed plus vieux que REPLAY_STATE_TTL_SECONDS + - Nettoie les sessions en mémoire via LiveSessionManager.cleanup_old_sessions() + - Borne _replay_states à MAX_REPLAY_STATES entrées + """ + while _cleanup_running: + time.sleep(600) # 10 minutes + if not _cleanup_running: + break + try: + _cleanup_replay_states() + # Nettoyage des sessions expirées en mémoire (toutes les heures = 6 cycles) + processor.session_manager.cleanup_old_sessions(max_age_hours=24) + except Exception as e: + logger.error(f"Erreur dans la boucle de nettoyage : {e}") + + +def _cleanup_replay_states(): + """Supprimer les replay states terminés (completed/error/failed) plus vieux que le TTL.""" + now = time.time() + to_delete = [] + + with _replay_lock: + for replay_id, state in _replay_states.items(): + if state["status"] in ("completed", "error", "failed"): + # Vérifier l'âge via le dernier résultat ou le timestamp du dernier event + last_result = state.get("results", []) + last_time = last_result[-1].get("timestamp", 0) if last_result else 0 + if not last_time: + # Pas de timestamp dans les résultats, utiliser les error_log + error_log = state.get("error_log", []) + last_time = error_log[-1].get("timestamp", 0) if error_log else 0 + if not last_time: + # Aucun timestamp trouvé, marquer pour suppression (orphelin) + to_delete.append(replay_id) + continue + if now - last_time > REPLAY_STATE_TTL_SECONDS: + to_delete.append(replay_id) + + # Supprimer les entrées expirées + for replay_id in to_delete: + del _replay_states[replay_id] + _error_callbacks.pop(replay_id, None) + + # Borne de sécurité : si trop d'entrées, supprimer les plus anciens terminés + if len(_replay_states) > MAX_REPLAY_STATES: + finished = [ + (rid, s) for rid, s in _replay_states.items() + if s["status"] in ("completed", "error", "failed") + ] + # Trier par nombre de résultats (les plus anciens ont typiquement tous leurs résultats) + excess = len(_replay_states) - MAX_REPLAY_STATES + for rid, _ in finished[:excess]: + del _replay_states[rid] + _error_callbacks.pop(rid, None) + + if to_delete: + logger.info(f"Nettoyage replay states : {len(to_delete)} entrées supprimées") + + +@app.on_event("startup") +async def startup(): + """Démarrer le worker, le session_worker et charger les workflows existants.""" + global _cleanup_running, _cleanup_thread + + worker.start(blocking=False) + + # Charger les workflows existants depuis le disque + _load_existing_workflows() + + # Démarrer le worker de traitement asynchrone des sessions finalisées + session_worker.start() + + # Scanner les sessions finalisées mais jamais traitées (0 states, workflow non construit) + # et les ajouter à la queue de traitement automatiquement + _enqueue_pending_sessions() + + # Démarrer le thread de nettoyage périodique + _cleanup_running = True + _cleanup_thread = threading.Thread(target=_cleanup_loop, daemon=True, name="replay_cleanup") + _cleanup_thread.start() + + logger.info( + "API Streaming démarrée — StreamProcessor, Worker, SessionWorker et Cleanup prêts." + ) + + +def _enqueue_pending_sessions(): + """Scanner et ajouter les sessions finalisées en attente à la queue du SessionWorker. + + Appelé au démarrage du serveur pour rattraper les sessions finalisées + dont l'analyse VLM n'a jamais été complétée (crash, redémarrage, etc.). + """ + try: + pending = processor.find_pending_sessions() + if pending: + logger.info( + f"Sessions en attente trouvées au démarrage : {len(pending)} " + f"({', '.join(pending[:5])}{'...' if len(pending) > 5 else ''})" + ) + for sid in pending: + session_worker.enqueue(sid) + else: + logger.info("Aucune session en attente au démarrage") + except Exception as e: + logger.error(f"Erreur scan sessions en attente : {e}") + + +def _load_existing_workflows(): + """Charger les workflows JSON existants dans processor._workflows. + + Supporte deux formats : + - Workflow.load_from_file (format complet avec workflow_id) + - JSON brut avec clé 'name' (format simplifié VWB/manuels) + """ + from core.models.workflow_graph import Workflow + + workflow_dirs = [ + ROOT_DIR / "data" / "workflows", + ROOT_DIR / "data" / "training" / "workflows", + LIVE_SESSIONS_DIR / "workflows", + ] + + loaded = 0 + for wf_dir in workflow_dirs: + if not wf_dir.exists(): + continue + for wf_file in wf_dir.glob("*.json"): + try: + wf = Workflow.load_from_file(str(wf_file)) + if wf and hasattr(wf, 'workflow_id'): + with processor._data_lock: + processor._workflows[wf.workflow_id] = wf + loaded += 1 + continue + except Exception: + pass + + # Fallback : charger comme JSON brut et injecter un workflow_id + try: + wf_data = json.loads(wf_file.read_text(encoding="utf-8")) + wf_id = wf_data.get("workflow_id") or wf_file.stem + # Stocker le dict brut (suffisant pour _workflow_to_actions) + with processor._data_lock: + processor._workflows[wf_id] = wf_data + loaded += 1 + except Exception as e: + logger.debug(f"Skip workflow {wf_file.name}: {e}") + + logger.info(f"Workflows chargés depuis disque: {loaded}") + + +@app.on_event("shutdown") +async def shutdown(): + global _cleanup_running + _cleanup_running = False + session_worker.stop() + worker.stop() + processor.session_manager.flush() + logger.info("API Streaming arrêtée.") + + +# ========================================================================= +# Session management +# ========================================================================= + +@app.post("/api/v1/traces/stream/register") +async def register_session(session_id: str, machine_id: str = "default"): + """Enregistrer une nouvelle session de streaming. + + Args: + session_id: Identifiant unique de la session + machine_id: Identifiant de la machine source (multi-machine, défaut: "default") + """ + processor.session_manager.register_session(session_id, machine_id=machine_id) + # Reset des compteurs pour cette session (évite les reliquats d'une session précédente) + with _pending_lock: + _pending_analyses[session_id] = 0 + _analyzed_shots[session_id] = set() + logger.info(f"Session {session_id} enregistrée (machine={machine_id}, compteurs réinitialisés)") + return {"status": "session_registered", "session_id": session_id, "machine_id": machine_id} + + +def _ensure_session_registered(session_id: str, machine_id: str = "default"): + """Auto-enregistrer une session si elle n'existe pas encore. + + Robustesse au redémarrage du serveur : l'Agent V1 ne re-register pas + sa session, mais continue d'envoyer des events/images. On l'enregistre + automatiquement à la première réception. + + Args: + session_id: Identifiant de la session + machine_id: Identifiant machine (propagé depuis l'agent) + """ + session = processor.session_manager.get_session(session_id) + if session is None: + logger.info(f"Auto-enregistrement de la session {session_id} (machine={machine_id})") + processor.session_manager.register_session(session_id, machine_id=machine_id) + with _pending_lock: + _pending_analyses[session_id] = 0 + _analyzed_shots[session_id] = set() + elif machine_id != "default" and session.machine_id == "default": + # Mettre à jour le machine_id si l'agent l'envoie et qu'on ne l'avait pas + session.machine_id = machine_id + + +# ========================================================================= +# Événements +# ========================================================================= + +@app.post("/api/v1/traces/stream/event") +async def stream_event(data: StreamEvent): + """Reçoit un événement et l'enregistre dans la session.""" + session_id = data.session_id + machine_id = data.machine_id or "default" + + # Auto-enregistrer la session si inconnue (robustesse au redémarrage serveur) + _ensure_session_registered(session_id, machine_id=machine_id) + + # Persister sur disque (journal JSONL, dans un sous-dossier par machine si multi-machine) + if machine_id and machine_id != "default": + session_path = LIVE_SESSIONS_DIR / machine_id / session_id + else: + session_path = LIVE_SESSIONS_DIR / session_id + session_path.mkdir(parents=True, exist_ok=True) + event_file = session_path / "live_events.jsonl" + with open(event_file, "a", encoding="utf-8") as f: + f.write(json.dumps(data.dict()) + "\n") + + # Traitement direct via StreamProcessor + result = worker.process_event_direct(session_id, data.event) + return {"status": "event_synced", "session_id": session_id, **result} + + +# ========================================================================= +# Images +# ========================================================================= + +# Ensemble des screenshots déjà analysés (évite les doublons de retry) +_analyzed_shots: Dict[str, set] = defaultdict(set) + +# Hash du dernier screenshot analysé par session (déduplication par similarité) +_last_screenshot_hash: Dict[str, str] = {} + +# ThreadPool pour l'analyse GPU (évite de bloquer le event loop async) +_gpu_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="gpu_analysis") + + +def _image_hash(file_path: str) -> str: + """Hash rapide d'une image pour détecter les doublons (~identiques).""" + try: + from PIL import Image + import hashlib + img = Image.open(file_path) + # Réduire à 16x16 et convertir en niveaux de gris pour un hash perceptuel + thumb = img.resize((16, 16)).convert('L') + return hashlib.md5(thumb.tobytes()).hexdigest() + except Exception: + return "" + + +@app.post("/api/v1/traces/stream/image") +async def stream_image( + session_id: str, + shot_id: str, + machine_id: str = "default", + file: UploadFile = File(...), + background_tasks: BackgroundTasks = None, +): + """Reçoit une image et déclenche l'analyse via le core pipeline.""" + # Auto-enregistrer la session si inconnue (robustesse au redémarrage serveur) + _ensure_session_registered(session_id, machine_id=machine_id) + + # Sauvegarder sur disque (dans un sous-dossier par machine si multi-machine) + if machine_id and machine_id != "default": + session_path = LIVE_SESSIONS_DIR / machine_id / session_id + else: + session_path = LIVE_SESSIONS_DIR / session_id + shots_dir = session_path / "shots" + shots_dir.mkdir(parents=True, exist_ok=True) + + file_path = shots_dir / f"{shot_id}.png" + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + file_path_str = str(file_path) + + # Crops : traitement léger (pas d'analyse ScreenAnalyzer) + if "_crop" in shot_id: + result = worker.process_crop_direct(session_id, shot_id, file_path_str) + return {"status": "crop_stored", "shot_id": shot_id, **result} + + # Filtrer les screenshots qui ne nécessitent PAS d'analyse GPU. + # Seuls les shot_XXXX_full (screenshots d'action) sont analysés. + # Les autres (heartbeat, focus, res_shot) sont stockés sur disque + # mais pas envoyés au GPU — sinon le ThreadPool (1 worker, ~10-30s/analyse) + # est submergé et la finalisation timeout avec 0 states. + if shot_id.startswith("heartbeat_"): + return {"status": "heartbeat_stored", "shot_id": shot_id} + if shot_id.startswith("focus_"): + return {"status": "focus_stored", "shot_id": shot_id} + if shot_id.startswith("res_shot_"): + return {"status": "res_stored", "shot_id": shot_id} + if not shot_id.startswith("shot_") or "_full" not in shot_id: + # Tout ce qui n'est pas shot_XXXX_full → stocker sans analyser + logger.debug(f"Screenshot {shot_id} stocké sans analyse GPU") + return {"status": "stored_no_analysis", "shot_id": shot_id} + + # Déduplication par ID : ne pas réanalyser un screenshot déjà traité + with _pending_lock: + if shot_id in _analyzed_shots[session_id]: + logger.debug(f"Screenshot {shot_id} déjà analysé, skip") + return {"status": "already_analyzed", "shot_id": shot_id} + + # Déduplication par similarité : si l'image est quasi identique à la précédente, skip + img_hash = _image_hash(file_path_str) + if img_hash and img_hash == _last_screenshot_hash.get(session_id): + logger.info(f"Screenshot {shot_id} identique au précédent, skip analyse GPU") + with _pending_lock: + _analyzed_shots[session_id].add(shot_id) + return {"status": "duplicate_skipped", "shot_id": shot_id} + if img_hash: + _last_screenshot_hash[session_id] = img_hash + + with _pending_lock: + _analyzed_shots[session_id].add(shot_id) + + # Screenshots full : analyse GPU dans un thread séparé (ne bloque pas l'event loop) + with _pending_lock: + _pending_analyses[session_id] += 1 + _gpu_executor.submit(_process_screenshot_thread, session_id, shot_id, file_path_str) + return {"status": "image_queued", "shot_id": shot_id} + + +def _process_screenshot_thread(session_id: str, shot_id: str, path: str): + """Analyse GPU d'un screenshot dans un thread séparé (ne bloque pas FastAPI).""" + try: + import traceback + logger.info(f"[GPU] Début analyse {shot_id} pour {session_id}") + result = worker.process_screenshot_direct(session_id, shot_id, path) + logger.info( + f"[GPU] Screenshot {shot_id} analysé: " + f"{result.get('ui_elements_count', 0)} UI, " + f"{result.get('text_detected', 0)} textes, " + f"indexed={result.get('embedding_indexed', False)}" + ) + except Exception as e: + import traceback + logger.error(f"[GPU] Erreur analyse {shot_id}: {e}\n{traceback.format_exc()}") + finally: + with _pending_lock: + _pending_analyses[session_id] = max(0, _pending_analyses[session_id] - 1) + + +# ========================================================================= +# Finalisation +# ========================================================================= + +@app.post("/api/v1/traces/stream/finalize") +async def finalize(session_id: str, machine_id: str = "default"): + """Clôture la session et place le traitement en file d'attente. + + Ne bloque plus : marque la session comme finalisée et l'ajoute à la queue + du SessionWorker pour analyse VLM + construction workflow en arrière-plan. + + Le client peut suivre la progression via GET /api/v1/traces/stream/processing/status. + + Args: + session_id: Identifiant de la session à finaliser + machine_id: Identifiant machine (informatif, le machine_id est déjà dans la session) + """ + # Vérifier que la session existe + session = processor.session_manager.get_session(session_id) + if not session: + raise HTTPException( + status_code=404, + detail=f"Session {session_id} non trouvée", + ) + + # Marquer la session comme finalisée (persistée sur disque) + processor.session_manager.finalize(session_id) + logger.info(f"Session {session_id} finalisée, ajout à la queue de traitement") + + # Ajouter à la queue du SessionWorker pour traitement asynchrone + session_worker.enqueue(session_id) + + # Compter les screenshots full disponibles pour donner une estimation + session_dir = processor._find_session_dir(session_id) + full_shots_count = 0 + if session_dir: + shots_dir = session_dir / "shots" + if shots_dir.exists(): + full_shots_count = len(list(shots_dir.glob("shot_*_full.png"))) + + return { + "status": "queued_for_processing", + "session_id": session_id, + "machine_id": session.machine_id, + "screenshots_to_analyze": full_shots_count, + "message": ( + f"Session finalisée. {full_shots_count} screenshots seront analysés " + "en arrière-plan. Suivez la progression via " + "GET /api/v1/traces/stream/processing/status" + ), + } + + +# ========================================================================= +# Traitement asynchrone — Suivi de la queue de processing +# ========================================================================= + +@app.get("/api/v1/traces/stream/processing/status") +async def get_processing_status(): + """État du worker de traitement asynchrone des sessions finalisées. + + Retourne : + - queue_length : nombre de sessions en attente + - queue : liste des session_ids en attente + - current_session : session en cours de traitement (ou null) + - current_progress : progression de la session en cours + - completed : historique des sessions traitées avec succès + - failed : historique des sessions échouées + """ + return session_worker.get_status() + + +@app.post("/api/v1/traces/stream/processing/requeue") +async def requeue_session(session_id: str): + """Relancer le traitement d'une session (manuellement). + + Utile pour : + - Relancer une session échouée après correction + - Forcer le retraitement d'une session déjà traitée + """ + session = processor.session_manager.get_session(session_id) + if not session: + raise HTTPException( + status_code=404, + detail=f"Session {session_id} non trouvée", + ) + + session_worker.enqueue(session_id) + + return { + "status": "requeued", + "session_id": session_id, + "queue_status": session_worker.get_status(), + } + + +# ========================================================================= +# Monitoring +# ========================================================================= + +@app.get("/api/v1/traces/stream/stats") +async def get_stats(): + """Statistiques du serveur de streaming.""" + stats = worker.stats + # Ajouter les machines connues + stats["machines"] = processor.session_manager.get_machine_ids() + return stats + + +@app.get("/api/v1/traces/stream/machines") +async def list_machines(): + """Lister toutes les machines connues avec leurs sessions actives. + + Utile pour le dashboard et l'agent chat (Léa) pour savoir quelles + machines sont connectées et cibler un replay spécifique. + """ + machine_ids = processor.session_manager.get_machine_ids() + machines = [] + for mid in machine_ids: + machine_sessions = processor.session_manager.get_sessions_by_machine(mid) + active = [s for s in machine_sessions if not s.finalized] + machines.append({ + "machine_id": mid, + "total_sessions": len(machine_sessions), + "active_sessions": len(active), + "last_activity": max( + (s.last_activity for s in machine_sessions), + default=None, + ).isoformat() if machine_sessions else None, + }) + return {"machines": machines} + + +@app.get("/api/v1/traces/stream/sessions") +async def list_sessions(machine_id: Optional[str] = None): + """Lister les sessions (actives et finalisées). + + Args: + machine_id: Si fourni, filtre par machine. Si absent, retourne toutes les sessions. + """ + sessions = processor.list_sessions(machine_id=machine_id) + result = {"sessions": sessions} + # Ajouter la liste des machines connues pour l'UI + result["machines"] = processor.session_manager.get_machine_ids() + return result + + +@app.get("/api/v1/traces/stream/workflows") +async def list_workflows(machine_id: Optional[str] = None): + """Lister les workflows construits. + + Args: + machine_id: Si fourni, filtre par machine. Si absent, retourne tous les workflows. + """ + workflows = processor.list_workflows(machine_id=machine_id) + result = {"workflows": workflows} + # Ajouter la liste des machines connues pour l'UI + result["machines"] = processor.session_manager.get_machine_ids() + return result + + +@app.get("/api/v1/traces/stream/session/{session_id}") +async def get_session(session_id: str): + """État d'une session.""" + session = processor.session_manager.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} non trouvée") + return { + "session_id": session.session_id, + "machine_id": session.machine_id, + "events_count": len(session.events), + "screenshots_count": len(session.shot_paths), + "last_window": session.last_window_info, + "created_at": session.created_at.isoformat(), + "last_activity": session.last_activity.isoformat(), + "finalized": session.finalized, + } + + +# ========================================================================= +# Replay — Exécution de workflows sur l'Agent V1 +# ========================================================================= + + +def _find_active_agent_session(machine_id: Optional[str] = None) -> Optional[str]: + """Trouver la dernière session Agent V1 pour le replay. + + Stratégie en 2 passes : + 1. D'abord chercher une session non-finalisée (Agent V1 actif) + 2. Sinon, prendre la plus récente même finalisée (Agent V1 peut avoir + redémarré et créé une nouvelle session, ou la session a été finalisée + par timeout mais l'agent est toujours là) + + Dans les deux cas, on ne considère que les sessions 'sess_*' (Agent V1). + + Args: + machine_id: Si fourni, ne chercher que les sessions de cette machine. + Si None, chercher toutes les sessions (rétrocompatible). + """ + with processor.session_manager._lock: + all_agent_sessions = [ + s for s in processor.session_manager._sessions.values() + if s.session_id.startswith("sess_") + and (machine_id is None or s.machine_id == machine_id) + ] + + if not all_agent_sessions: + return None + + # Trier par session_id (contient un timestamp) — plus récent d'abord + all_agent_sessions.sort(key=lambda s: s.session_id, reverse=True) + + # Passe 1 : préférer une session non-finalisée + for s in all_agent_sessions: + if not s.finalized: + return s.session_id + + # Passe 2 : fallback sur la plus récente (même finalisée) + # L'Agent V1 poll /replay/next indépendamment de l'état finalized + return all_agent_sessions[0].session_id + + +def _workflow_to_actions(workflow, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """ + Convertir un workflow (nodes + edges ordonnés) en liste d'actions normalisées. + + Parcourt le graphe depuis les entry_nodes en suivant les edges. + Chaque edge produit une action normalisée avec coordonnées en pourcentage. + """ + actions = [] + params = params or {} + + # Construire un index des edges sortants par node + outgoing: Dict[str, list] = defaultdict(list) + for edge in workflow.edges: + outgoing[edge.from_node].append(edge) + + # Parcours linéaire depuis le premier entry_node + visited = set() + current_nodes = list(workflow.entry_nodes) if workflow.entry_nodes else [] + + # Fallback : si pas d'entry_nodes, prendre le premier node + if not current_nodes and workflow.nodes: + current_nodes = [workflow.nodes[0].node_id] + + while current_nodes: + node_id = current_nodes.pop(0) + if node_id in visited: + continue + visited.add(node_id) + + edges = outgoing.get(node_id, []) + for edge in edges: + edge_actions = _edge_to_normalized_actions(edge, params) + actions.extend(edge_actions) + # Suivre le graphe vers le prochain node + if edge.to_node not in visited: + current_nodes.append(edge.to_node) + + # Optimisation : substituer les actions visuelles par des gestes clavier si possible + if _gesture_catalog and actions: + actions = _gesture_catalog.optimize_replay_actions(actions) + + return actions + + +def _edge_to_normalized_actions(edge, params: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Convertir un WorkflowEdge en liste d'actions normalisées pour l'Agent V1. + + Un edge simple produit 1 action, un edge compound produit N actions (une par step). + """ + action = edge.action + if action is None: + logger.warning(f"Edge {edge.edge_id} sans action, skip") + return [] + action_type = action.type + target = action.target + action_params = action.parameters or {} + + # Extraire les coordonnées normalisées depuis TargetSpec.by_position + x_pct = 0.0 + y_pct = 0.0 + if target and target.by_position: + px, py = target.by_position + if px <= 1.0 and py <= 1.0: + x_pct = px + y_pct = py + else: + ref_w = action_params.get("ref_width", 1920) or 1920 + ref_h = action_params.get("ref_height", 1080) or 1080 + x_pct = round(px / ref_w, 6) + y_pct = round(py / ref_h, 6) + + base = {"edge_id": edge.edge_id, "from_node": edge.from_node, "to_node": edge.to_node} + + # Compound : décomposer en actions individuelles + if action_type == "compound": + return _expand_compound_steps(action_params.get("steps", []), base, params) + + # Actions simples + normalized = {**base, "action_id": f"act_{uuid.uuid4().hex[:8]}"} + + if action_type == "mouse_click": + normalized["type"] = "click" + normalized["x_pct"] = x_pct + normalized["y_pct"] = y_pct + normalized["button"] = action_params.get("button", "left") + + elif action_type == "text_input": + normalized["type"] = "type" + text = action_params.get("text", "") + text = _substitute_variables(text, params, action_params.get("defaults", {})) + normalized["text"] = text + normalized["x_pct"] = x_pct + normalized["y_pct"] = y_pct + + elif action_type == "key_press": + normalized["type"] = "key_combo" + keys = action_params.get("keys", []) + if not keys and action_params.get("key"): + keys = [action_params["key"]] + normalized["keys"] = keys + + else: + logger.warning(f"Type d'action inconnu : {action_type}") + return [] + + # Ajouter le target_spec complet pour la résolution visuelle + target_spec = {} + if target and target.by_role: + target_spec["by_role"] = target.by_role + normalized["target_role"] = target.by_role # Compat debug + if target and target.by_text: + target_spec["by_text"] = target.by_text + normalized["target_text"] = target.by_text # Compat debug + if target and hasattr(target, 'context_hints') and target.context_hints: + target_spec["context_hints"] = target.context_hints + if target_spec: + normalized["target_spec"] = target_spec + normalized["visual_mode"] = True # Signal à l'agent d'utiliser la résolution visuelle + + return [normalized] + + +def _substitute_variables(text: str, params: Dict[str, Any], defaults: Dict[str, Any]) -> str: + """Substituer les variables ${var} dans un texte. + + Priorité : params utilisateur > defaults du workflow > texte brut inchangé. + Supporte ${var} dans un texte plus long (ex: "${expression}="). + """ + import re + + def replacer(match): + var_name = match.group(1) + return str(params.get(var_name, defaults.get(var_name, match.group(0)))) + + return re.sub(r'\$\{(\w+)\}', replacer, text) + + +def _expand_compound_steps( + steps: List[Dict[str, Any]], base: Dict[str, Any], params: Dict[str, Any] +) -> List[Dict[str, Any]]: + """Décomposer les steps d'un compound en actions individuelles.""" + actions = [] + for step in steps: + step_type = step.get("type", "unknown") + action = { + **base, + "action_id": f"act_{uuid.uuid4().hex[:8]}", + } + + if step_type == "key_press": + action["type"] = "key_combo" + keys = step.get("keys", []) + if not keys and step.get("key"): + keys = [step["key"]] + action["keys"] = keys + + elif step_type == "text_input": + action["type"] = "type" + text = step.get("text", "") + text = _substitute_variables(text, params, {}) + action["text"] = text + + elif step_type == "wait": + action["type"] = "wait" + action["duration_ms"] = step.get("duration_ms", 500) + + elif step_type == "mouse_click": + action["type"] = "click" + action["x_pct"] = step.get("x_pct", 0.0) + action["y_pct"] = step.get("y_pct", 0.0) + action["button"] = step.get("button", "left") + + else: + logger.debug(f"Step compound inconnu : {step_type}") + continue + + actions.append(action) + + return actions + + +@app.post("/api/v1/traces/stream/replay") +async def start_replay(request: ReplayRequest): + """ + Lancer le replay d'un workflow sur une session Agent V1 active. + + Le serveur charge le workflow, le convertit en liste d'actions normalisées, + et les place dans la queue de la session. L'Agent V1 les récupérera + via GET /replay/next (modèle pull). + + Si session_id commence par "chat_" ou est vide, on détecte automatiquement + la dernière session Agent V1 active (non finalisée, préfixe "sess_"). + Si machine_id est fourni, on cible spécifiquement cette machine. + """ + workflow_id = request.workflow_id + session_id = request.session_id + target_machine_id = request.machine_id + params = request.params or {} + + # Auto-détection de la session Agent V1 active (avec filtre machine optionnel) + if not session_id or session_id.startswith("chat_"): + active_session = _find_active_agent_session(machine_id=target_machine_id) + if active_session: + logger.info( + f"Auto-détection session Agent V1 : {active_session} " + f"(demandé: {session_id}, machine={target_machine_id})" + ) + session_id = active_session + else: + machine_hint = f" sur la machine '{target_machine_id}'" if target_machine_id else "" + raise HTTPException( + status_code=404, + detail=f"Aucune session Agent V1 active{machine_hint}. " + "Lancez l'Agent V1 et démarrez une session d'abord." + ) + + # Vérifier que le workflow existe + with processor._data_lock: + workflow = processor._workflows.get(workflow_id) + + if not workflow: + raise HTTPException( + status_code=404, + detail=f"Workflow '{workflow_id}' non trouvé. " + f"Workflows disponibles : {list(processor._workflows.keys())}" + ) + + # Convertir le workflow en actions normalisées + actions = _workflow_to_actions(workflow, params) + if not actions: + raise HTTPException( + status_code=400, + detail=f"Le workflow '{workflow_id}' ne contient aucune action exécutable." + ) + + # Limite de sécurité sur le nombre d'actions + if len(actions) > MAX_ACTIONS_PER_REPLAY: + raise HTTPException( + status_code=400, + detail=f"Trop d'actions ({len(actions)} > {MAX_ACTIONS_PER_REPLAY}). " + "Découpez le workflow en parties plus petites." + ) + + # Créer l'identifiant de replay + replay_id = f"replay_{uuid.uuid4().hex[:8]}" + + # Résoudre le machine_id de la session cible + session_obj = processor.session_manager.get_session(session_id) + resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default") + + # Injecter les actions dans la queue de la session + with _replay_lock: + _replay_queues[session_id] = list(actions) # Remplacer la queue existante + _replay_states[replay_id] = _create_replay_state( + replay_id=replay_id, + workflow_id=workflow_id, + session_id=session_id, + total_actions=len(actions), + params=params, + machine_id=resolved_machine_id, + ) + # Enregistrer le mapping machine -> session pour le replay ciblé + if resolved_machine_id and resolved_machine_id != "default": + _machine_replay_target[resolved_machine_id] = session_id + + logger.info( + f"Replay démarré : {replay_id} | workflow={workflow_id} | " + f"session={session_id} | machine={resolved_machine_id} | " + f"{len(actions)} actions à exécuter" + ) + + return { + "replay_id": replay_id, + "status": "running", + "workflow_id": workflow_id, + "session_id": session_id, + "machine_id": resolved_machine_id, + "total_actions": len(actions), + } + + +@app.post("/api/v1/traces/stream/replay/raw") +async def start_raw_replay(request: RawReplayRequest): + """ + Lancer un replay avec des actions brutes (mode Agent Libre). + + Au lieu de charger un workflow, accepte directement une liste d'actions + normalisées générées par le LLM planner. Les actions sont injectées + dans la queue de replay de l'Agent V1. + """ + session_id = request.session_id + actions = request.actions + target_machine_id = request.machine_id + task = request.task_description or "Tâche libre" + + if not actions: + raise HTTPException(status_code=400, detail="Aucune action fournie.") + + # Limite de sécurité sur le nombre d'actions + if len(actions) > MAX_ACTIONS_PER_REPLAY: + raise HTTPException( + status_code=400, + detail=f"Trop d'actions ({len(actions)} > {MAX_ACTIONS_PER_REPLAY}). " + "Réduisez le plan d'exécution." + ) + + # Auto-détection de la session Agent V1 (avec filtre machine optionnel) + if not session_id or session_id.startswith("chat_"): + active_session = _find_active_agent_session(machine_id=target_machine_id) + if active_session: + session_id = active_session + else: + machine_hint = f" sur la machine '{target_machine_id}'" if target_machine_id else "" + raise HTTPException( + status_code=404, + detail=f"Aucune session Agent V1 active{machine_hint}. " + "Lancez l'Agent V1 sur le PC cible." + ) + + # Assigner des action_id si manquants + for i, action in enumerate(actions): + if "action_id" not in action: + action["action_id"] = f"act_free_{uuid.uuid4().hex[:6]}" + + replay_id = f"replay_free_{uuid.uuid4().hex[:8]}" + + # Résoudre le machine_id de la session cible + session_obj = processor.session_manager.get_session(session_id) + resolved_machine_id = target_machine_id or (session_obj.machine_id if session_obj else "default") + + with _replay_lock: + _replay_queues[session_id] = list(actions) + _replay_states[replay_id] = _create_replay_state( + replay_id=replay_id, + workflow_id=f"free_task:{task[:50]}", + session_id=session_id, + total_actions=len(actions), + params={}, + machine_id=resolved_machine_id, + ) + # Enregistrer le mapping machine -> session pour le replay ciblé + if resolved_machine_id and resolved_machine_id != "default": + _machine_replay_target[resolved_machine_id] = session_id + + logger.info( + f"Replay libre démarré : {replay_id} | task='{task}' | " + f"session={session_id} | machine={resolved_machine_id} | {len(actions)} actions" + ) + + return { + "replay_id": replay_id, + "status": "running", + "task": task, + "session_id": session_id, + "machine_id": resolved_machine_id, + "total_actions": len(actions), + } + + +@app.post("/api/v1/traces/stream/replay/single") +async def enqueue_single_action(request: SingleActionRequest): + """ + Enqueue une seule action pour exécution (mode Copilot). + + Contrairement à /replay et /replay/raw qui injectent toute une liste, + cet endpoint n'enqueue qu'UNE action à la fois. L'agent chat Copilot + appelle cet endpoint étape par étape après validation utilisateur. + + Retourne un action_id pour le tracking du résultat via /replay/result. + """ + session_id = request.session_id + action = dict(request.action) + target_machine_id = request.machine_id + + # Auto-détection de la session Agent V1 (avec filtre machine optionnel) + if not session_id or session_id.startswith("chat_"): + active_session = _find_active_agent_session(machine_id=target_machine_id) + if active_session: + session_id = active_session + else: + machine_hint = f" sur la machine '{target_machine_id}'" if target_machine_id else "" + raise HTTPException( + status_code=404, + detail=f"Aucune session Agent V1 active{machine_hint}. " + "Lancez l'Agent V1 sur le PC cible." + ) + + # Assigner un action_id si manquant + if "action_id" not in action: + action["action_id"] = f"act_copilot_{uuid.uuid4().hex[:8]}" + + action_id = action["action_id"] + + with _replay_lock: + _replay_queues[session_id].append(action) + + logger.info( + f"Action Copilot enqueued: {action_id} | type={action.get('type')} | " + f"session={session_id} | machine={target_machine_id}" + ) + + return { + "action_id": action_id, + "session_id": session_id, + "machine_id": target_machine_id, + "status": "enqueued", + } + + +@app.get("/api/v1/traces/stream/replay/next") +async def get_next_action(session_id: str, machine_id: str = "default"): + """ + L'Agent V1 poll cet endpoint pour récupérer la prochaine action à exécuter. + + Retourne la prochaine action de la queue ou {"action": null} si rien. + Modèle pull : l'agent demande, pas de WebSocket nécessaire. + + Multi-machine : si machine_id est fourni, ne retourne que les actions + destinées à cette machine (évite les fuites cross-machine). + + Si la session de l'agent n'a pas d'actions en attente, cherche dans les + autres queues de la MÊME machine (pas cross-machine). + """ + with _replay_lock: + queue = _replay_queues.get(session_id, []) + + if not queue: + # Seul le lookup machine_replay_target est conservé (sûr : mapping explicite + # créé lors du POST /replay). Le cross-session stealing a été supprimé + # car il causait des race conditions entre agents. + if machine_id != "default": + target_sid = _machine_replay_target.get(machine_id) + if target_sid and target_sid != session_id: + target_queue = _replay_queues.get(target_sid, []) + if target_queue: + logger.info( + f"Replay machine-target: {machine_id} -> " + f"transfert queue {target_sid} -> {session_id}" + ) + queue = target_queue + _replay_queues[session_id] = target_queue + del _replay_queues[target_sid] + for state in _replay_states.values(): + if state["session_id"] == target_sid and state["status"] == "running": + state["session_id"] = session_id + _machine_replay_target[machine_id] = session_id + + if not queue: + return {"action": None, "session_id": session_id, "machine_id": machine_id} + + # Retirer la première action de la queue (FIFO) + action = queue.pop(0) + + logger.info( + f"Action envoyée à {session_id} (machine={machine_id}) : " + f"{action.get('type')} (id={action.get('action_id')})" + ) + + return {"action": action, "session_id": session_id, "machine_id": machine_id} + + +@app.post("/api/v1/traces/stream/replay/result") +async def report_action_result(report: ReplayResultReport): + """ + L'Agent V1 renvoie le résultat d'exécution d'une action. + + Permet au serveur de suivre la progression et de détecter les échecs. + Intègre la vérification post-action (comparaison screenshots) et le retry + automatique (max 3 tentatives) avant de déclarer un échec. + + Stratégie de retry : + - Retry 1 : re-résoudre la cible visuellement et réinjecter l'action + - Retry 2 : attendre 2s (wait) puis réinjecter l'action (possible loading) + - Retry 3 : dernier essai identique, si échec → erreur non-récupérable + """ + session_id = report.session_id + action_id = report.action_id + + # Trouver le replay correspondant à cette session + with _replay_lock: + replay_state = None + for state in _replay_states.values(): + if state["session_id"] == session_id and state["status"] == "running": + replay_state = state + break + + if not replay_state: + logger.warning( + f"Résultat reçu pour session {session_id} mais aucun replay actif" + ) + return {"status": "no_active_replay", "session_id": session_id} + + # Récupérer l'info de retry pour cette action (si c'est un retry) + retry_info = _retry_pending.pop(action_id, None) + retry_count = retry_info["retry_count"] if retry_info else 0 + original_action = retry_info["action"] if retry_info else None + + # Mettre à jour le dernier screenshot reçu + screenshot_after = report.screenshot_after or report.screenshot + if screenshot_after: + with _replay_lock: + replay_state["last_screenshot"] = screenshot_after + + # === Vérification post-action === + verification = None + if report.success and screenshot_after: + # Chercher le screenshot avant (dernier connu de la session) + screenshot_before = replay_state.get("_last_screenshot_before") + if screenshot_before: + try: + action_dict = original_action or {"type": "unknown", "action_id": action_id} + result_dict = { + "success": report.success, + "error": report.error, + } + verification = _replay_verifier.verify_action( + action=action_dict, + result=result_dict, + screenshot_before=screenshot_before, + screenshot_after=screenshot_after, + ) + except Exception as e: + logger.warning(f"Vérification post-action échouée: {e}") + + # Stocker le screenshot actuel comme "before" pour la prochaine action + if screenshot_after: + with _replay_lock: + replay_state["_last_screenshot_before"] = screenshot_after + + # === Enregistrer le résultat === + with _replay_lock: + result_entry = { + "action_id": action_id, + "success": report.success, + "error": report.error, + "has_screenshot": bool(screenshot_after), + "actual_position": report.actual_position, + "retry_count": retry_count, + "verification": verification.to_dict() if verification else None, + } + replay_state["results"].append(result_entry) + + # === Logique de retry / success / failure === + if report.success and (verification is None or verification.verified): + # Action réussie (vérification OK ou pas de vérification) + replay_state["completed_actions"] += 1 + replay_state["current_action_index"] += 1 + + elif report.success and verification and not verification.verified: + # Agent dit "success" mais la vérification échoue (rien n'a changé) + replay_state["unverified_actions"] += 1 + logger.warning( + f"Action {action_id} marquée success mais non vérifiée: " + f"{verification.detail}" + ) + if verification.suggestion == "retry" and retry_count < MAX_RETRIES_PER_ACTION: + # Réinjecter pour retry + _schedule_retry( + session_id, replay_state, original_action or {"action_id": action_id}, + retry_count, "verification_failed" + ) + else: + # Continuer malgré tout (action non vérifiée) + replay_state["completed_actions"] += 1 + replay_state["current_action_index"] += 1 + + elif not report.success and retry_count < MAX_RETRIES_PER_ACTION: + # Échec mais on a encore des retries + action_to_retry = original_action or {"action_id": action_id, "type": "unknown"} + _schedule_retry( + session_id, replay_state, action_to_retry, + retry_count, report.error or "unknown_error" + ) + + else: + # Échec définitif (retries épuisés) + replay_state["failed_actions"] += 1 + error_entry = { + "action_id": action_id, + "error": report.error or "Retries épuisés", + "retry_count": retry_count, + "timestamp": time.time(), + } + replay_state["error_log"].append(error_entry) + + # Marquer le replay en erreur et vider la queue + replay_state["status"] = "error" + _replay_queues[session_id] = [] + logger.error( + f"Replay {replay_state['replay_id']} échoué à l'action {action_id} " + f"après {retry_count} retries: {report.error}" + ) + + # Notifier via callback si configuré + _notify_error_callback(replay_state, action_id, report.error) + + # Vérifier si le replay est terminé (queue vide + dernière action réussie) + remaining = len(_replay_queues.get(session_id, [])) + if remaining == 0 and replay_state["status"] == "running": + replay_state["status"] = "completed" + logger.info( + f"Replay {replay_state['replay_id']} terminé avec succès : " + f"{replay_state['completed_actions']}/{replay_state['total_actions']} actions" + f" ({replay_state['retried_actions']} retries, " + f"{replay_state['unverified_actions']} non vérifiées)" + ) + + return { + "status": "recorded", + "action_id": action_id, + "success": report.success, + "replay_status": replay_state["status"], + "remaining_actions": remaining, + "retry_count": retry_count, + "verification": verification.to_dict() if verification else None, + } + + +def _create_replay_state( + replay_id: str, + workflow_id: str, + session_id: str, + total_actions: int, + params: Optional[Dict[str, Any]] = None, + machine_id: Optional[str] = None, +) -> Dict[str, Any]: + """Créer un état de replay enrichi avec les champs de suivi d'erreur.""" + return { + "replay_id": replay_id, + "workflow_id": workflow_id, + "session_id": session_id, + "machine_id": machine_id or "default", # Machine cible du replay + "status": "running", + "total_actions": total_actions, + "completed_actions": 0, + "failed_actions": 0, + "current_action_index": 0, + "params": params or {}, + "results": [], # Historique des résultats action par action + # Champs enrichis pour le suivi d'erreur (#7) + "retried_actions": 0, + "unverified_actions": 0, + "error_log": [], # Liste des erreurs rencontrées + "last_screenshot": None, # Path du dernier screenshot reçu + "_last_screenshot_before": None, # Interne: screenshot avant la dernière action + } + + +def _schedule_retry( + session_id: str, + replay_state: Dict[str, Any], + action: Dict[str, Any], + current_retry: int, + reason: str, +): + """ + Programmer un retry pour une action échouée. + + Stratégie : + - Retry 1 : réinjecter l'action directement (re-résolution visuelle par l'agent) + - Retry 2 : injecter un wait de 2s avant l'action (possible loading en cours) + - Retry 3 : dernier essai direct + + L'action est réinsérée en tête de la queue pour être la prochaine exécutée. + _replay_lock doit être acquis par l'appelant. + """ + next_retry = current_retry + 1 + replay_state["retried_actions"] += 1 + + # Créer une copie de l'action avec un nouveau action_id pour le tracking + retry_action = dict(action) + retry_action_id = f"{action.get('action_id', 'unknown')}_retry{next_retry}" + retry_action["action_id"] = retry_action_id + + # Stocker l'info de retry pour le prochain report_action_result + _retry_pending[retry_action_id] = { + "action": action, + "retry_count": next_retry, + "replay_id": replay_state["replay_id"], + "reason": reason, + } + + # Stratégie de retry selon le numéro + actions_to_insert = [] + + if next_retry == 2: + # Retry 2 : injecter un wait de 2s avant l'action + wait_action = { + "action_id": f"wait_retry_{uuid.uuid4().hex[:6]}", + "type": "wait", + "duration_ms": 2000, + } + actions_to_insert.append(wait_action) + + actions_to_insert.append(retry_action) + + # Insérer en tête de la queue (prochaine action à exécuter) + queue = _replay_queues.get(session_id, []) + _replay_queues[session_id] = actions_to_insert + queue + + logger.info( + f"Retry {next_retry}/{MAX_RETRIES_PER_ACTION} programmé pour {action.get('action_id')} " + f"(raison: {reason}) | nouveau id: {retry_action_id}" + ) + + +def _notify_error_callback( + replay_state: Dict[str, Any], + action_id: str, + error: Optional[str], +): + """ + Notifier le callback d'erreur si configuré pour ce replay. + + Appel HTTP POST non-bloquant vers l'URL de callback. + En cas d'échec de notification, on log mais on ne bloque pas. + """ + replay_id = replay_state["replay_id"] + callback_url = _error_callbacks.get(replay_id) + if not callback_url: + return + + def _send_callback(): + try: + import urllib.request + payload = json.dumps({ + "replay_id": replay_id, + "workflow_id": replay_state.get("workflow_id"), + "session_id": replay_state.get("session_id"), + "action_id": action_id, + "error": error or "Erreur inconnue", + "retried_actions": replay_state.get("retried_actions", 0), + "error_log": replay_state.get("error_log", []), + "status": replay_state.get("status"), + }).encode("utf-8") + + req = urllib.request.Request( + callback_url, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + logger.info( + f"Error callback envoyé à {callback_url}: {resp.status}" + ) + except Exception as e: + logger.warning( + f"Échec envoi error callback à {callback_url}: {e}" + ) + + # Envoyer en arrière-plan pour ne pas bloquer + threading.Thread(target=_send_callback, daemon=True).start() + + +@app.post("/api/v1/traces/stream/replay/error_callback") +async def register_error_callback(config: ErrorCallbackConfig): + """ + Enregistrer une URL de callback pour les erreurs non-récupérables d'un replay. + + Le chat server configure cette URL lors du lancement du replay. + Quand une erreur non-récupérable se produit (retries épuisés), + le serveur POST vers cette URL avec les détails de l'erreur. + """ + replay_id = config.replay_id + callback_url = config.callback_url + + with _replay_lock: + if replay_id not in _replay_states: + raise HTTPException( + status_code=404, + detail=f"Replay '{replay_id}' non trouvé" + ) + + _error_callbacks[replay_id] = callback_url + logger.info(f"Error callback enregistré pour {replay_id}: {callback_url}") + + return { + "status": "callback_registered", + "replay_id": replay_id, + "callback_url": callback_url, + } + + +@app.get("/api/v1/traces/stream/replay/{replay_id}") +async def get_replay_status(replay_id: str): + """Consulter l'état d'un replay en cours ou terminé.""" + with _replay_lock: + state = _replay_states.get(replay_id) + + if not state: + raise HTTPException( + status_code=404, detail=f"Replay '{replay_id}' non trouvé" + ) + + # Filtrer les champs internes (préfixés par _) + return {k: v for k, v in state.items() if not k.startswith("_")} + + +@app.get("/api/v1/traces/stream/replays") +async def list_replays(): + """Lister tous les replays (actifs, terminés, en erreur).""" + with _replay_lock: + # Filtrer les champs internes (préfixés par _) + return { + "replays": [ + {k: v for k, v in state.items() if not k.startswith("_")} + for state in _replay_states.values() + ] + } + + +# ========================================================================= +# Visual Replay — Résolution visuelle des cibles +# ========================================================================= + + +class ResolveTargetRequest(BaseModel): + """Requête de résolution visuelle d'une cible.""" + session_id: str + screenshot_b64: str # Screenshot JPEG en base64 + target_spec: Dict[str, Any] # {by_role, by_text, by_position, ...} + fallback_x_pct: float = 0.0 # Coordonnées de fallback + fallback_y_pct: float = 0.0 + screen_width: int = 1920 + screen_height: int = 1080 + + +@app.post("/api/v1/traces/stream/replay/resolve_target") +async def resolve_target(request: ResolveTargetRequest): + """ + Résoudre visuellement une cible UI à partir d'un screenshot. + + L'Agent V1 envoie un screenshot + target_spec AVANT d'exécuter l'action. + Le serveur analyse l'image avec UIDetector/OCR et retourne les coordonnées + de l'élément trouvé. + + Stratégie de matching (par priorité) : + 1. by_text — chercher un élément dont le label contient le texte + 2. by_role — chercher un élément avec le bon rôle sémantique + 3. by_text + by_role — intersection des deux + 4. fallback — utiliser les coordonnées statiques + """ + import base64 + import io + import tempfile + + from PIL import Image + + # Décoder le screenshot + try: + img_bytes = base64.b64decode(request.screenshot_b64) + img = Image.open(io.BytesIO(img_bytes)) + except Exception as e: + logger.error(f"Décodage screenshot échoué: {e}") + return _fallback_response(request, "decode_error", str(e)) + + # Sauver temporairement pour les analyseurs (ils attendent un chemin fichier) + with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: + img.save(tmp, format="JPEG", quality=90) + tmp_path = tmp.name + + try: + # Lancer la résolution visuelle dans le thread GPU + import asyncio + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + _gpu_executor, + _resolve_target_sync, + tmp_path, + request.target_spec, + request.screen_width, + request.screen_height, + request.fallback_x_pct, + request.fallback_y_pct, + ) + return result + except Exception as e: + logger.error(f"Résolution visuelle échouée: {e}") + return _fallback_response(request, "analysis_error", str(e)) + finally: + import os + try: + os.unlink(tmp_path) + except OSError: + pass + + +def _resolve_by_template_matching( + screenshot_path: str, + anchor_image_b64: str, + screen_width: int, + screen_height: int, + confidence_threshold: float = 0.7, +) -> Optional[Dict[str, Any]]: + """Résoudre la position d'une ancre par template matching OpenCV. + + Compare l'image de l'ancre (crop) avec le screenshot actuel pour trouver + la meilleure correspondance. Utilise cv2.matchTemplate avec TM_CCOEFF_NORMED. + + Args: + screenshot_path: Chemin du screenshot de l'écran actuel + anchor_image_b64: Image de l'ancre encodée en base64 (PNG) + screen_width: Largeur de l'écran en pixels + screen_height: Hauteur de l'écran en pixels + confidence_threshold: Seuil minimum de confiance (0.0 à 1.0) + + Returns: + Dict avec resolved=True et coordonnées, ou None si pas de match + """ + import base64 + import io + + try: + import cv2 + import numpy as np + except ImportError: + logger.warning("OpenCV non disponible pour template matching") + return None + + try: + # Charger le screenshot + screenshot = cv2.imread(screenshot_path) + if screenshot is None: + logger.warning("Impossible de lire le screenshot : %s", screenshot_path) + return None + + # Décoder l'image de l'ancre depuis base64 + anchor_bytes = base64.b64decode(anchor_image_b64) + anchor_array = np.frombuffer(anchor_bytes, dtype=np.uint8) + anchor_img = cv2.imdecode(anchor_array, cv2.IMREAD_COLOR) + if anchor_img is None: + logger.warning("Impossible de décoder l'image de l'ancre") + return None + + # Convertir en niveaux de gris pour le matching + screenshot_gray = cv2.cvtColor(screenshot, cv2.COLOR_BGR2GRAY) + anchor_gray = cv2.cvtColor(anchor_img, cv2.COLOR_BGR2GRAY) + + # Vérifier que l'ancre n'est pas plus grande que le screenshot + sh, sw = screenshot_gray.shape[:2] + ah, aw = anchor_gray.shape[:2] + if ah > sh or aw > sw: + logger.warning( + "Ancre (%dx%d) plus grande que le screenshot (%dx%d)", + aw, ah, sw, sh, + ) + return None + + # Template matching multi-échelle : essayer l'échelle 1.0 d'abord, + # puis quelques variations si la résolution a changé + best_val = -1.0 + best_loc = None + 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]: + if scale != 1.0: + new_w = int(aw * scale) + new_h = int(ah * scale) + if new_w < 10 or new_h < 10 or new_w > sw or new_h > sh: + continue + scaled_anchor = cv2.resize(anchor_gray, (new_w, new_h)) + else: + scaled_anchor = anchor_gray + new_w, new_h = aw, ah + + result = cv2.matchTemplate(screenshot_gray, scaled_anchor, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(result) + + if max_val > best_val: + best_val = max_val + best_loc = max_loc + best_scale = scale + best_anchor_size = (new_w, new_h) + + # Si on a un très bon match, pas besoin de continuer + if best_val >= 0.95: + break + + if best_val < confidence_threshold: + logger.info( + "Template matching : meilleur score=%.3f < seuil=%.3f (ancre %dx%d, écran %dx%d)", + best_val, confidence_threshold, aw, ah, sw, sh, + ) + return None + + # Calculer le centre du match + match_w, match_h = best_anchor_size + cx = best_loc[0] + match_w / 2.0 + cy = best_loc[1] + match_h / 2.0 + + # Convertir en proportions normalisées + x_pct = round(cx / sw, 6) if sw > 0 else 0.0 + y_pct = round(cy / sh, 6) if sh > 0 else 0.0 + + logger.info( + "Template matching OK : score=%.3f, échelle=%.2f, " + "centre=(%d, %d) → (%.4f, %.4f) sur %dx%d", + best_val, best_scale, int(cx), int(cy), x_pct, y_pct, sw, sh, + ) + + return { + "resolved": True, + "method": "template_matching", + "x_pct": x_pct, + "y_pct": y_pct, + "matched_element": { + "label": f"anchor_template", + "type": "visual_anchor", + "role": "anchor", + "center": [int(cx), int(cy)], + "confidence": best_val, + }, + "score": best_val, + "scale": best_scale, + "match_box": { + "x": best_loc[0], + "y": best_loc[1], + "width": match_w, + "height": match_h, + }, + } + + except Exception as e: + logger.error("Erreur template matching : %s", e) + return None + + +def _resolve_target_sync( + screenshot_path: str, + target_spec: Dict[str, Any], + screen_width: int, + screen_height: int, + fallback_x_pct: float, + fallback_y_pct: float, +) -> Dict[str, Any]: + """Résoudre la cible visuellement (exécuté dans le thread GPU). + + Stratégies de matching (par priorité) : + 1. anchor_image_base64 — template matching OpenCV (pour ancres VWB) + 2. by_text / by_role — matching sémantique via ScreenAnalyzer + 3. fallback — coordonnées statiques + """ + # --------------------------------------------------------------- + # Stratégie 1 : Template matching par image d'ancre + # --------------------------------------------------------------- + anchor_image_b64 = target_spec.get("anchor_image_base64", "") + if anchor_image_b64: + result = _resolve_by_template_matching( + screenshot_path=screenshot_path, + anchor_image_b64=anchor_image_b64, + screen_width=screen_width, + screen_height=screen_height, + confidence_threshold=0.7, + ) + if result: + return result + logger.info( + "Template matching échoué pour ancre '%s', fallback", + target_spec.get("anchor_id", "?"), + ) + # Pas de ScreenAnalyzer fallback — trop lent pour le replay interactif + return { + "resolved": False, + "method": "fallback", + "reason": "template_matching_failed", + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + # --------------------------------------------------------------- + # Stratégie 2 : Matching sémantique via ScreenAnalyzer + # --------------------------------------------------------------- + by_text = target_spec.get("by_text", "") + by_role = target_spec.get("by_role", "") + + # Si aucun critère sémantique et pas d'ancre, fallback direct + if not by_text and not by_role and not anchor_image_b64: + return { + "resolved": False, + "method": "fallback", + "reason": "no_target_criteria", + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + processor._ensure_initialized() + + if processor._screen_analyzer is None: + return { + "resolved": False, + "method": "fallback", + "reason": "screen_analyzer_unavailable", + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + # Analyser le screenshot (Niveaux 1-3 : raw, OCR, UI elements) + try: + screen_state = processor._screen_analyzer.analyze(screenshot_path) + except Exception as e: + logger.warning(f"Analyse screenshot échouée: {e}") + return { + "resolved": False, + "method": "fallback", + "reason": f"analysis_failed: {e}", + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + ui_elements = screen_state.ui_elements or [] + if not ui_elements: + logger.info("Aucun élément UI détecté, fallback coordonnées") + return { + "resolved": False, + "method": "fallback", + "reason": "no_ui_elements", + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + } + + # Matching de la cible parmi les éléments détectés + candidates = [] + + for elem in ui_elements: + score = 0.0 + + # Score par texte (label) + if by_text and elem.label: + text_lower = by_text.lower() + label_lower = elem.label.lower() + if text_lower in label_lower or label_lower in text_lower: + score += 0.6 + elif _fuzzy_match(text_lower, label_lower): + score += 0.3 + + # Score par rôle + if by_role: + role_lower = by_role.lower() + if elem.role and role_lower in elem.role.lower(): + score += 0.3 + if elem.type and role_lower in elem.type.lower(): + score += 0.2 + + if score > 0: + candidates.append((elem, score)) + + if not candidates: + logger.info( + f"Aucun match visuel pour target(text='{by_text}', role='{by_role}') " + f"parmi {len(ui_elements)} éléments" + ) + return { + "resolved": False, + "method": "fallback", + "reason": "no_match", + "x_pct": fallback_x_pct, + "y_pct": fallback_y_pct, + "ui_elements_count": len(ui_elements), + } + + # Trier par score décroissant et prendre le meilleur + candidates.sort(key=lambda c: c[1], reverse=True) + best_elem, best_score = candidates[0] + + # Convertir les coordonnées pixel en proportions + cx, cy = best_elem.center + x_pct = round(cx / screen_width, 6) if screen_width > 0 else 0.0 + y_pct = round(cy / screen_height, 6) if screen_height > 0 else 0.0 + + logger.info( + f"Cible résolue visuellement: '{best_elem.label}' ({best_elem.type}/{best_elem.role}) " + f"score={best_score:.2f} → ({x_pct:.4f}, {y_pct:.4f})" + ) + + return { + "resolved": True, + "method": "visual", + "x_pct": x_pct, + "y_pct": y_pct, + "matched_element": { + "label": best_elem.label, + "type": best_elem.type, + "role": best_elem.role, + "center": list(best_elem.center), + "confidence": best_elem.label_confidence, + }, + "score": best_score, + "candidates_count": len(candidates), + "ui_elements_count": len(ui_elements), + } + + +def _fuzzy_match(a: str, b: str, threshold: float = 0.6) -> bool: + """Match approximatif par ratio de caractères communs.""" + if not a or not b: + return False + common = sum(1 for c in a if c in b) + return (common / max(len(a), len(b))) >= threshold + + +def _fallback_response(request: ResolveTargetRequest, reason: str, detail: str) -> Dict: + """Réponse de fallback quand la résolution visuelle échoue.""" + return { + "resolved": False, + "method": "fallback", + "reason": reason, + "detail": detail, + "x_pct": request.fallback_x_pct, + "y_pct": request.fallback_y_pct, + } + + +if __name__ == "__main__": + import uvicorn + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [API-STREAM] %(message)s", + ) + uvicorn.run(app, host="0.0.0.0", port=5005) diff --git a/agent_v0/server_v1/live_session_manager.py b/agent_v0/server_v1/live_session_manager.py new file mode 100644 index 000000000..d66964cf7 --- /dev/null +++ b/agent_v0/server_v1/live_session_manager.py @@ -0,0 +1,306 @@ +""" +LiveSessionManager — Gestion d'état des sessions de streaming avec persistance disque. + +Accumule les événements et screenshots reçus de l'Agent V1 en temps réel. +Persiste les sessions sur disque (JSON) pour survivre aux redémarrages serveur. +Fournit la conversion vers RawSession pour le traitement batch (GraphBuilder). +""" + +import json +import logging +import threading +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class LiveSessionState: + """État d'une session active en mémoire.""" + session_id: str + machine_id: str = "default" # Identifiant machine (multi-machine) + events: List[Dict[str, Any]] = field(default_factory=list) + shot_paths: Dict[str, str] = field(default_factory=dict) # shot_id -> file_path + last_window_info: Dict[str, str] = field(default_factory=lambda: {"title": "Unknown", "app_name": "unknown"}) + created_at: datetime = field(default_factory=datetime.now) + last_activity: datetime = field(default_factory=datetime.now) + finalized: bool = False + # Compteur des titres de fenêtre vus → contextualisation automatique + window_titles_seen: Dict[str, int] = field(default_factory=dict) + app_names_seen: Dict[str, int] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "session_id": self.session_id, + "machine_id": self.machine_id, + "events": self.events, + "shot_paths": self.shot_paths, + "last_window_info": self.last_window_info, + "created_at": self.created_at.isoformat(), + "last_activity": self.last_activity.isoformat(), + "finalized": self.finalized, + "window_titles_seen": self.window_titles_seen, + "app_names_seen": self.app_names_seen, + } + + @classmethod + def from_dict(cls, data: dict) -> 'LiveSessionState': + return cls( + session_id=data["session_id"], + machine_id=data.get("machine_id", "default"), + events=data.get("events", []), + shot_paths=data.get("shot_paths", {}), + last_window_info=data.get("last_window_info", {"title": "Unknown", "app_name": "unknown"}), + created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else datetime.now(), + last_activity=datetime.fromisoformat(data["last_activity"]) if data.get("last_activity") else datetime.now(), + finalized=data.get("finalized", False), + window_titles_seen=data.get("window_titles_seen", {}), + app_names_seen=data.get("app_names_seen", {}), + ) + + +class LiveSessionManager: + """Gère les sessions live en mémoire côté serveur avec persistance disque.""" + + def __init__(self, persist_dir: str = "data/streaming_sessions"): + self._sessions: Dict[str, LiveSessionState] = {} + self._lock = threading.Lock() + self._persist_dir = Path(persist_dir) + self._persist_dir.mkdir(parents=True, exist_ok=True) + self._dirty: set = set() # Sessions modifiées depuis la dernière sauvegarde + self._persist_counter = 0 # Compteur pour limiter la fréquence de persistance + self._persist_interval = 10 # Persister toutes les N modifications + + # Charger les sessions persistées au démarrage + self._load_persisted_sessions() + + def _load_persisted_sessions(self): + """Charger les sessions sauvegardées au démarrage.""" + count = 0 + for session_file in sorted(self._persist_dir.glob("sess_*.json")): + try: + with open(session_file, 'r', encoding='utf-8') as f: + data = json.load(f) + session = LiveSessionState.from_dict(data) + self._sessions[session.session_id] = session + count += 1 + except Exception as e: + logger.warning(f"Impossible de charger la session {session_file.name}: {e}") + if count: + logger.info(f"{count} session(s) restaurée(s) depuis {self._persist_dir}") + + def _persist_session(self, session_id: str): + """Sauvegarder une session sur disque (appelé périodiquement).""" + session = self._sessions.get(session_id) + if not session: + return + try: + filepath = self._persist_dir / f"{session_id}.json" + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(session.to_dict(), f, ensure_ascii=False) + except Exception as e: + logger.debug(f"Erreur persistance session {session_id}: {e}") + + def _maybe_persist(self, session_id: str): + """Persister si le compteur atteint l'intervalle.""" + self._dirty.add(session_id) + self._persist_counter += 1 + if self._persist_counter >= self._persist_interval: + self._persist_counter = 0 + for sid in list(self._dirty): + self._persist_session(sid) + self._dirty.clear() + + def flush(self): + """Forcer la persistance de toutes les sessions dirty.""" + with self._lock: + for sid in list(self._dirty): + self._persist_session(sid) + self._dirty.clear() + + def register_session(self, session_id: str, machine_id: str = "default") -> LiveSessionState: + with self._lock: + if session_id not in self._sessions: + self._sessions[session_id] = LiveSessionState( + session_id=session_id, + machine_id=machine_id, + ) + logger.info(f"Session enregistrée: {session_id} (machine={machine_id})") + self._persist_session(session_id) + else: + # Mettre à jour le machine_id si la session existe déjà + # (cas de re-register après redémarrage agent) + if machine_id != "default": + self._sessions[session_id].machine_id = machine_id + return self._sessions[session_id] + + def get_session(self, session_id: str) -> Optional[LiveSessionState]: + with self._lock: + return self._sessions.get(session_id) + + def get_or_create(self, session_id: str, machine_id: str = "default") -> LiveSessionState: + with self._lock: + if session_id not in self._sessions: + self._sessions[session_id] = LiveSessionState( + session_id=session_id, + machine_id=machine_id, + ) + elif machine_id != "default": + self._sessions[session_id].machine_id = machine_id + return self._sessions[session_id] + + def add_event(self, session_id: str, event_data: Dict[str, Any]) -> None: + session = self.get_or_create(session_id) + with self._lock: + session.events.append(event_data) + session.last_activity = datetime.now() + # Extraire le contexte fenêtre si présent + window = event_data.get("window") + if window and isinstance(window, dict): + session.last_window_info = window + # Accumuler les titres/apps pour le nommage automatique + title = window.get("title", "").strip() + app_name = window.get("app_name", "").strip() + if title and title != "Unknown": + session.window_titles_seen[title] = session.window_titles_seen.get(title, 0) + 1 + if app_name and app_name != "unknown": + session.app_names_seen[app_name] = session.app_names_seen.get(app_name, 0) + 1 + self._maybe_persist(session_id) + + def add_screenshot(self, session_id: str, shot_id: str, file_path: str) -> None: + session = self.get_or_create(session_id) + with self._lock: + session.shot_paths[shot_id] = file_path + session.last_activity = datetime.now() + self._maybe_persist(session_id) + + def finalize(self, session_id: str) -> Optional[LiveSessionState]: + with self._lock: + session = self._sessions.get(session_id) + if session: + session.finalized = True + self._persist_session(session_id) + return session + + def remove_session(self, session_id: str) -> None: + with self._lock: + self._sessions.pop(session_id, None) + # Supprimer aussi le fichier persisté + filepath = self._persist_dir / f"{session_id}.json" + filepath.unlink(missing_ok=True) + + def to_raw_session(self, session_id: str) -> Optional[dict]: + """Convertir une session live en dict compatible RawSession.""" + session = self.get_session(session_id) + if not session: + return None + + import platform + import socket + + # Construire les événements au format RawSession + events = [] + for evt in session.events: + window_info = { + "title": evt.get("window_title", session.last_window_info.get("title", "")), + "app_name": evt.get("app_name", session.last_window_info.get("app_name", "unknown")), + } + events.append({ + "t": evt.get("timestamp", 0), + "type": evt.get("type", "unknown"), + "window": window_info, + "screenshot_id": evt.get("screenshot_id"), + }) + + # Construire les screenshots au format RawSession + screenshots = [] + for shot_id, path in sorted(session.shot_paths.items()): + # Ne garder que les full screenshots pour le GraphBuilder + if "_crop" in shot_id: + continue + screenshots.append({ + "screenshot_id": shot_id, + "relative_path": path, + "captured_at": datetime.now().isoformat(), + }) + + return { + "schema_version": "rawsession_v1", + "session_id": session.session_id, + "agent_version": "agent_v1_stream", + "environment": { + "os": platform.system().lower(), + "hostname": socket.gethostname(), + "machine_id": session.machine_id, + "screen": {"primary_resolution": [1920, 1080]}, + }, + "user": {"id": "remote_agent"}, + "context": { + "workflow": session.last_window_info.get("title", ""), + "tags": "streaming,agent_v1", + "machine_id": session.machine_id, + }, + "started_at": session.created_at.isoformat(), + "ended_at": datetime.now().isoformat(), + "events": events, + "screenshots": screenshots, + } + + @property + def active_session_count(self) -> int: + with self._lock: + return sum(1 for s in self._sessions.values() if not s.finalized) + + @property + def session_ids(self) -> List[str]: + with self._lock: + return list(self._sessions.keys()) + + def get_sessions_by_machine(self, machine_id: str) -> List[LiveSessionState]: + """Retourner toutes les sessions d'une machine donnée.""" + with self._lock: + return [ + s for s in self._sessions.values() + if s.machine_id == machine_id + ] + + def cleanup_old_sessions(self, max_age_hours: int = 24) -> int: + """Supprimer de la mémoire les sessions finalisées plus vieilles que max_age_hours. + + Ne supprime PAS les fichiers sur disque (juste la RAM). + Les sessions non finalisées (actives) ne sont jamais nettoyées. + + Args: + max_age_hours: Age maximum en heures avant nettoyage (défaut: 24h) + + Returns: + Nombre de sessions nettoyées + """ + from datetime import timedelta + cutoff = datetime.now() - timedelta(hours=max_age_hours) + to_remove = [] + + with self._lock: + for sid, session in self._sessions.items(): + if session.finalized and session.last_activity < cutoff: + to_remove.append(sid) + + for sid in to_remove: + del self._sessions[sid] + self._dirty.discard(sid) + + if to_remove: + logger.info( + f"Nettoyage mémoire : {len(to_remove)} session(s) finalisée(s) " + f"supprimée(s) (> {max_age_hours}h) — fichiers conservés sur disque" + ) + + return len(to_remove) + + def get_machine_ids(self) -> List[str]: + """Retourner la liste des identifiants machines uniques.""" + with self._lock: + return list(set(s.machine_id for s in self._sessions.values())) diff --git a/agent_v0/server_v1/replay_verifier.py b/agent_v0/server_v1/replay_verifier.py new file mode 100644 index 000000000..ef51a8ba2 --- /dev/null +++ b/agent_v0/server_v1/replay_verifier.py @@ -0,0 +1,347 @@ +# agent_v0/server_v1/replay_verifier.py +""" +ReplayVerifier — Vérification post-action pour le replay de workflows. + +Compare les screenshots avant/après une action pour détecter si elle a eu +un effet visible. Utilisé par l'API de replay pour décider si une action +a réussi ou si un retry est nécessaire. + +Stratégies de vérification : +1. Différence d'image globale (avant == après → probablement rien ne s'est passé) +2. Zone locale autour du clic (si l'action est un clic) +3. Détection de texte apparu (si l'action est une frappe) +""" + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +# Seuils de détection configurables +DEFAULT_GLOBAL_CHANGE_THRESHOLD = 0.005 # 0.5% de pixels différents = changement détecté +DEFAULT_LOCAL_CHANGE_THRESHOLD = 0.02 # 2% de la zone locale doit changer pour un clic +DEFAULT_LOCAL_RADIUS_PCT = 0.05 # 5% de la taille d'image autour du point de clic +DEFAULT_PIXEL_DIFF_THRESHOLD = 30 # Différence minimale par canal pour compter un pixel comme "changé" + + +@dataclass +class VerificationResult: + """Résultat de vérification d'une action de replay.""" + verified: bool # L'action semble avoir fonctionné + confidence: float # 0.0-1.0 + changes_detected: bool # Des pixels ont changé + change_area_pct: float # % de l'image qui a changé (0.0-100.0) + suggestion: str # "retry", "skip", "abort", "continue" + detail: str = "" # Description humaine du résultat + local_change_pct: float = 0.0 # % de changement dans la zone locale (si applicable) + + def to_dict(self) -> Dict[str, Any]: + return { + "verified": self.verified, + "confidence": round(self.confidence, 3), + "changes_detected": self.changes_detected, + "change_area_pct": round(self.change_area_pct, 3), + "suggestion": self.suggestion, + "detail": self.detail, + "local_change_pct": round(self.local_change_pct, 3), + } + + +class ReplayVerifier: + """Vérifie que les actions de replay ont produit l'effet attendu.""" + + def __init__( + self, + global_change_threshold: float = DEFAULT_GLOBAL_CHANGE_THRESHOLD, + local_change_threshold: float = DEFAULT_LOCAL_CHANGE_THRESHOLD, + local_radius_pct: float = DEFAULT_LOCAL_RADIUS_PCT, + pixel_diff_threshold: int = DEFAULT_PIXEL_DIFF_THRESHOLD, + ): + self.global_change_threshold = global_change_threshold + self.local_change_threshold = local_change_threshold + self.local_radius_pct = local_radius_pct + self.pixel_diff_threshold = pixel_diff_threshold + + def verify_action( + self, + action: Dict[str, Any], + result: Dict[str, Any], + screenshot_before: Optional[str] = None, + screenshot_after: Optional[str] = None, + ) -> VerificationResult: + """ + Compare les screenshots avant/après pour détecter si l'action a eu un effet. + + Stratégies : + 1. Différence d'image (si avant == après, l'action n'a probablement rien fait) + 2. Si l'action est un clic, vérifier que la zone autour du clic a changé + 3. Si l'action est une frappe, vérifier que du texte est apparu + + Args: + action: L'action exécutée (type, x_pct, y_pct, text, etc.) + result: Le résultat rapporté par l'Agent V1 (success, error, etc.) + screenshot_before: Chemin du screenshot avant l'action (optionnel) + screenshot_after: Chemin du screenshot après l'action (optionnel) + + Returns: + VerificationResult avec la conclusion et la suggestion de suite + """ + # Si l'agent a rapporté une erreur explicite, pas besoin de vérifier visuellement + if not result.get("success", True): + return VerificationResult( + verified=False, + confidence=0.9, + changes_detected=False, + change_area_pct=0.0, + suggestion="retry", + detail=f"Action échouée: {result.get('error', 'erreur inconnue')}", + ) + + # Si pas de screenshots, on ne peut pas vérifier + if not screenshot_before or not screenshot_after: + return VerificationResult( + verified=True, + confidence=0.3, + changes_detected=True, # On ne sait pas, on assume que ça a marché + change_area_pct=0.0, + suggestion="continue", + detail="Vérification impossible (pas de screenshots avant/après)", + ) + + # Charger les images + try: + img_before, img_after = self._load_images(screenshot_before, screenshot_after) + except Exception as e: + logger.warning(f"Impossible de charger les screenshots: {e}") + return VerificationResult( + verified=True, + confidence=0.2, + changes_detected=True, + change_area_pct=0.0, + suggestion="continue", + detail=f"Erreur chargement images: {e}", + ) + + # Vérifier les dimensions + if img_before.size != img_after.size: + # Résolutions différentes = probablement un changement d'écran + return VerificationResult( + verified=True, + confidence=0.7, + changes_detected=True, + change_area_pct=100.0, + suggestion="continue", + detail="Résolution d'écran modifiée (changement de contexte)", + ) + + # 1. Calcul de la différence globale + global_change_pct = self._compute_global_diff(img_before, img_after) + + # 2. Calcul de la différence locale (zone autour du clic si applicable) + action_type = action.get("type", "") + local_change_pct = 0.0 + + if action_type in ("click", "type") and "x_pct" in action and "y_pct" in action: + local_change_pct = self._compute_local_diff( + img_before, img_after, + action["x_pct"], action["y_pct"], + ) + + # 3. Décision + return self._decide( + action_type=action_type, + global_change_pct=global_change_pct, + local_change_pct=local_change_pct, + ) + + def _load_images(self, path_before: str, path_after: str): + """Charger deux images PIL depuis des chemins fichier ou base64.""" + from PIL import Image + + img_before = self._load_single_image(path_before) + img_after = self._load_single_image(path_after) + return img_before, img_after + + def _load_single_image(self, source: str): + """Charger une image depuis un chemin fichier ou une string base64.""" + from PIL import Image + + # Détection base64 (commence par /9j pour JPEG ou iVBOR pour PNG en base64) + if source.startswith(("/9j", "iVBOR", "data:image")): + import base64 + import io + # Retirer le préfixe data:image/...;base64, si présent + if source.startswith("data:image"): + source = source.split(",", 1)[1] + img_bytes = base64.b64decode(source) + return Image.open(io.BytesIO(img_bytes)).convert("RGB") + else: + return Image.open(source).convert("RGB") + + def _compute_global_diff(self, img_before, img_after) -> float: + """ + Calculer le pourcentage de pixels qui ont changé significativement. + + Returns: + Pourcentage de pixels changés (0.0-100.0) + """ + import numpy as np + + arr_before = np.array(img_before, dtype=np.int16) + arr_after = np.array(img_after, dtype=np.int16) + + # Différence absolue par canal, puis max par pixel + diff = np.abs(arr_after - arr_before) + max_diff_per_pixel = diff.max(axis=2) # (H, W) + + # Compter les pixels dont la différence dépasse le seuil + changed_pixels = (max_diff_per_pixel > self.pixel_diff_threshold).sum() + total_pixels = max_diff_per_pixel.size + + return (changed_pixels / total_pixels) * 100.0 + + def _compute_local_diff( + self, + img_before, + img_after, + x_pct: float, + y_pct: float, + ) -> float: + """ + Calculer le pourcentage de changement dans une zone locale autour d'un point. + + Args: + img_before, img_after: Images PIL (même taille) + x_pct, y_pct: Coordonnées du point en pourcentage (0.0-1.0) + + Returns: + Pourcentage de pixels changés dans la zone locale (0.0-100.0) + """ + import numpy as np + + w, h = img_before.size + cx = int(x_pct * w) + cy = int(y_pct * h) + radius_x = int(self.local_radius_pct * w) + radius_y = int(self.local_radius_pct * h) + + # Borner la zone au cadre de l'image + x1 = max(0, cx - radius_x) + y1 = max(0, cy - radius_y) + x2 = min(w, cx + radius_x) + y2 = min(h, cy + radius_y) + + if x2 <= x1 or y2 <= y1: + return 0.0 + + # Extraire les zones locales + crop_before = img_before.crop((x1, y1, x2, y2)) + crop_after = img_after.crop((x1, y1, x2, y2)) + + arr_before = np.array(crop_before, dtype=np.int16) + arr_after = np.array(crop_after, dtype=np.int16) + + diff = np.abs(arr_after - arr_before) + max_diff = diff.max(axis=2) + + changed = (max_diff > self.pixel_diff_threshold).sum() + total = max_diff.size + + return (changed / total) * 100.0 if total > 0 else 0.0 + + def _decide( + self, + action_type: str, + global_change_pct: float, + local_change_pct: float, + ) -> VerificationResult: + """ + Prendre une décision basée sur les métriques de changement. + + Logique : + - Changement global > seuil → action vérifiée (confiance haute) + - Changement local > seuil (pour clic/frappe) → action vérifiée (confiance moyenne) + - Aucun changement → action non vérifiée, suggestion retry + - Changement massif (>50%) → possible popup/erreur, marquer pour attention + """ + global_threshold_pct = self.global_change_threshold * 100 + local_threshold_pct = self.local_change_threshold * 100 + + has_global_change = global_change_pct > global_threshold_pct + has_local_change = local_change_pct > local_threshold_pct + + # Cas 1 : Changement massif (possible popup/erreur/crash) + if global_change_pct > 50.0: + return VerificationResult( + verified=True, + confidence=0.6, + changes_detected=True, + change_area_pct=global_change_pct, + local_change_pct=local_change_pct, + suggestion="continue", + detail=( + f"Changement massif détecté ({global_change_pct:.1f}%) — " + "possible changement de contexte (popup, nouvelle page)" + ), + ) + + # Cas 2 : Changement global détecté + if has_global_change: + confidence = min(0.9, 0.5 + global_change_pct / 100.0) + return VerificationResult( + verified=True, + confidence=confidence, + changes_detected=True, + change_area_pct=global_change_pct, + local_change_pct=local_change_pct, + suggestion="continue", + detail=f"Changement global détecté ({global_change_pct:.2f}%)", + ) + + # Cas 3 : Pas de changement global, mais changement local (clic/frappe) + if has_local_change and action_type in ("click", "type"): + confidence = min(0.7, 0.3 + local_change_pct / 100.0) + return VerificationResult( + verified=True, + confidence=confidence, + changes_detected=True, + change_area_pct=global_change_pct, + local_change_pct=local_change_pct, + suggestion="continue", + detail=( + f"Changement local détecté ({local_change_pct:.2f}%) " + f"autour de ({action_type})" + ), + ) + + # Cas 4 : Pas de changement (key_combo, wait) + # Pour les raccourcis clavier et attentes, l'absence de changement + # n'est pas forcément un problème (ex: Ctrl+C ne change pas l'écran) + if action_type in ("key_combo", "wait"): + return VerificationResult( + verified=True, + confidence=0.4, + changes_detected=False, + change_area_pct=global_change_pct, + local_change_pct=local_change_pct, + suggestion="continue", + detail=( + f"Aucun changement visible pour {action_type} " + "(normal pour ce type d'action)" + ), + ) + + # Cas 5 : Aucun changement détecté pour un clic/frappe → suspect + return VerificationResult( + verified=False, + confidence=0.6, + changes_detected=False, + change_area_pct=global_change_pct, + local_change_pct=local_change_pct, + suggestion="retry", + detail=( + f"Aucun changement détecté après {action_type} " + f"(global={global_change_pct:.3f}%, local={local_change_pct:.3f}%)" + ), + ) diff --git a/agent_v0/server_v1/session_worker.py b/agent_v0/server_v1/session_worker.py new file mode 100644 index 000000000..015e21f1d --- /dev/null +++ b/agent_v0/server_v1/session_worker.py @@ -0,0 +1,253 @@ +# agent_v0/server_v1/session_worker.py +""" +SessionWorker — Traitement asynchrone des sessions finalisées en arrière-plan. + +Résout le problème de finalize qui retourne "insufficient_data" : l'analyse VLM +prend plusieurs minutes, et le client n'attend plus. Le worker traite les sessions +à son rythme et notifie quand le workflow est prêt. + +Tourne dans un thread daemon. Traite une session à la fois. +Pour chaque session : +1. Analyse les screenshots via ScreenAnalyzer + VLM +2. Construit le workflow via GraphBuilder +3. Sauvegarde le workflow +4. Notifie que c'est prêt (callback) +""" + +import logging +import threading +import time +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger("session_worker") + + +class SessionWorker: + """Worker qui traite les sessions finalisées en arrière-plan. + + Tourne dans un thread daemon. Traite une session à la fois. + Pour chaque session : + 1. Analyse les screenshots via ScreenAnalyzer + VLM + 2. Construit le workflow via GraphBuilder + 3. Sauvegarde le workflow + 4. Notifie que c'est prêt (callback on_complete) + """ + + def __init__(self, processor, poll_interval: int = 10): + """ + Args: + processor: Instance de StreamProcessor partagée avec l'API. + poll_interval: Intervalle de polling en secondes quand la queue est vide. + """ + from .stream_processor import StreamProcessor + self._processor: StreamProcessor = processor + self._queue: List[str] = [] # session_ids à traiter + self._lock = threading.Lock() + self._running = False + self._current_session: Optional[str] = None + self._current_progress: Optional[Dict[str, Any]] = None + self._on_complete: Optional[Callable[[str, Dict[str, Any]], None]] = None + self._poll_interval = poll_interval + + # Historique des traitements (succès et échecs) + self._completed: List[Dict[str, Any]] = [] + self._failed: List[Dict[str, Any]] = [] + + self._thread: Optional[threading.Thread] = None + + def start(self): + """Démarre le worker dans un thread daemon.""" + if self._running: + logger.warning("[WORKER] Déjà en cours d'exécution") + return + + self._running = True + self._thread = threading.Thread( + target=self._process_loop, + name="SessionWorker", + daemon=True, + ) + self._thread.start() + logger.info("[WORKER] Démarré — traitement asynchrone des sessions finalisées") + + def stop(self): + """Arrête proprement le worker.""" + self._running = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=10) + logger.info("[WORKER] Arrêté") + + def enqueue(self, session_id: str): + """Ajoute une session à la file d'attente. + + Évite les doublons : si la session est déjà dans la queue ou en cours + de traitement, elle n'est pas ré-ajoutée. + """ + with self._lock: + if session_id in self._queue: + logger.info(f"[WORKER] Session {session_id} déjà dans la queue, skip") + return + if self._current_session == session_id: + logger.info(f"[WORKER] Session {session_id} en cours de traitement, skip") + return + # Vérifier si déjà traitée avec succès + for item in self._completed: + if item.get("session_id") == session_id: + logger.info(f"[WORKER] Session {session_id} déjà traitée avec succès, skip") + return + self._queue.append(session_id) + logger.info( + f"[WORKER] Session {session_id} ajoutée à la queue " + f"(position {len(self._queue)})" + ) + + def get_status(self) -> Dict[str, Any]: + """Retourne l'état complet du worker.""" + with self._lock: + return { + "running": self._running, + "queue_length": len(self._queue), + "queue": list(self._queue), + "current_session": self._current_session, + "current_progress": dict(self._current_progress) if self._current_progress else None, + "completed_count": len(self._completed), + "completed": list(self._completed[-10:]), # 10 derniers + "failed_count": len(self._failed), + "failed": list(self._failed[-10:]), # 10 derniers + } + + def _dequeue(self) -> Optional[str]: + """Retire et retourne le prochain session_id de la queue.""" + with self._lock: + if self._queue: + return self._queue.pop(0) + return None + + def _process_loop(self): + """Boucle principale — prend la prochaine session et la traite.""" + logger.info("[WORKER] Boucle de traitement démarrée") + while self._running: + session_id = self._dequeue() + if session_id: + self._process_session(session_id) + else: + time.sleep(self._poll_interval) + + def _process_session(self, session_id: str): + """Traite une session complète (analyse screenshots + build workflow). + + Utilise StreamProcessor.reprocess_session() qui : + 1. Liste les screenshots shot_*_full.png sur disque + 2. Appelle process_screenshot() pour chaque (VLM + CLIP) + 3. Appelle finalize_session() pour construire le workflow + """ + with self._lock: + self._current_session = session_id + self._current_progress = { + "session_id": session_id, + "status": "starting", + "started_at": datetime.now().isoformat(), + "screenshots_total": 0, + "screenshots_processed": 0, + } + + logger.info(f"[WORKER] === Début traitement session {session_id} ===") + start_time = time.time() + + try: + result = self._processor.reprocess_session( + session_id, + progress_callback=self._update_progress, + ) + + elapsed = time.time() - start_time + + if result.get("error"): + # Erreur pendant le traitement + logger.error( + f"[WORKER] Échec traitement session {session_id} " + f"après {elapsed:.1f}s : {result['error']}" + ) + with self._lock: + self._failed.append({ + "session_id": session_id, + "error": result["error"], + "elapsed_seconds": round(elapsed, 1), + "timestamp": datetime.now().isoformat(), + }) + elif result.get("status") == "insufficient_data": + # Pas assez de screenshots valides + logger.warning( + f"[WORKER] Session {session_id} : données insuffisantes " + f"({result.get('states_count', 0)} states) après {elapsed:.1f}s" + ) + with self._lock: + self._failed.append({ + "session_id": session_id, + "error": "insufficient_data", + "states_count": result.get("states_count", 0), + "elapsed_seconds": round(elapsed, 1), + "timestamp": datetime.now().isoformat(), + }) + else: + # Succès + logger.info( + f"[WORKER] Session {session_id} traitée avec succès en {elapsed:.1f}s | " + f"workflow={result.get('workflow_id', '?')} | " + f"{result.get('nodes', 0)} nodes, {result.get('edges', 0)} edges" + ) + with self._lock: + self._completed.append({ + "session_id": session_id, + "workflow_id": result.get("workflow_id"), + "workflow_name": result.get("workflow_name"), + "nodes": result.get("nodes", 0), + "edges": result.get("edges", 0), + "states_analyzed": result.get("states_analyzed", 0), + "elapsed_seconds": round(elapsed, 1), + "timestamp": datetime.now().isoformat(), + }) + + # Callback de notification + if self._on_complete: + try: + self._on_complete(session_id, result) + except Exception as e: + logger.error(f"[WORKER] Erreur callback on_complete: {e}") + + except Exception as e: + elapsed = time.time() - start_time + logger.error( + f"[WORKER] Exception inattendue pour session {session_id} " + f"après {elapsed:.1f}s : {e}", + exc_info=True, + ) + with self._lock: + self._failed.append({ + "session_id": session_id, + "error": f"exception: {e}", + "elapsed_seconds": round(elapsed, 1), + "timestamp": datetime.now().isoformat(), + }) + + finally: + with self._lock: + self._current_session = None + self._current_progress = None + + logger.info(f"[WORKER] === Fin traitement session {session_id} ===") + + def _update_progress(self, session_id: str, current: int, total: int, shot_id: str = ""): + """Callback de progression appelé par reprocess_session.""" + with self._lock: + if self._current_progress: + self._current_progress["screenshots_total"] = total + self._current_progress["screenshots_processed"] = current + self._current_progress["status"] = "processing" + self._current_progress["current_shot"] = shot_id + + logger.info( + f"[WORKER] Session {session_id} : screenshot {current}/{total}" + + (f" ({shot_id})" if shot_id else "") + ) diff --git a/agent_v0/server_v1/stream_processor.py b/agent_v0/server_v1/stream_processor.py new file mode 100644 index 000000000..892a01291 --- /dev/null +++ b/agent_v0/server_v1/stream_processor.py @@ -0,0 +1,964 @@ +""" +StreamProcessor — Pont entre le streaming Agent V1 et le core pipeline RPA Vision V3. + +Orchestre les composants core (ScreenAnalyzer, CLIP, FAISS, GraphBuilder) +pour traiter en temps réel les screenshots et événements reçus via fibre. + +Tous les calculs GPU tournent ici (serveur RTX 5070). +""" + +import logging +import threading +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np + +from .live_session_manager import LiveSessionManager + +logger = logging.getLogger(__name__) + + +class StreamProcessor: + """ + Processeur de streaming qui connecte les données Agent V1 au core pipeline. + + Cycle de vie : + 1. register_session() — crée l'état mémoire + 2. process_event() — accumule événements, extrait contexte fenêtre + 3. process_screenshot() — analyse via ScreenAnalyzer + CLIP embedding + 4. finalize_session() — construit le Workflow via GraphBuilder (DBSCAN) + """ + + def __init__(self, data_dir: str = "data/training"): + self.data_dir = Path(data_dir) + persist_dir = str(self.data_dir / "streaming_sessions") + self.session_manager = LiveSessionManager(persist_dir=persist_dir) + self._lock = threading.Lock() + + # Core components (chargés paresseusement pour éviter les imports lourds au démarrage) + self._screen_analyzer = None + self._clip_embedder = None + self._state_embedding_builder = None # P0-3 : pipeline d'embedding unifié (fusion multi-modale) + self._faiss_manager = None + self._initialized = False + + # Lock pour l'accès concurrent aux données de session (screen_states, embeddings, workflows) + self._data_lock = threading.Lock() + + # Résultats d'analyse par session + self._screen_states: Dict[str, list] = {} # session_id -> List[ScreenState] + self._embeddings: Dict[str, list] = {} # session_id -> List[np.ndarray] + + # Workflows construits (pour le matching) + self._workflows: Dict[str, Any] = {} + + # Charger les workflows existants depuis le disque + self._load_persisted_workflows() + + def _load_persisted_workflows(self): + """Charger les workflows sauvegardés depuis le disque au démarrage. + + Scanne le dossier workflows/ principal et les sous-dossiers par machine + (workflows/{machine_id}/) pour la rétrocompatibilité. + """ + workflows_dir = self.data_dir / "workflows" + if not workflows_dir.exists(): + return + + try: + from core.models.workflow_graph import Workflow + + count = 0 + # Charger les workflows du dossier racine (rétrocompatibilité) + for wf_file in sorted(workflows_dir.glob("*.json")): + try: + wf = Workflow.load_from_file(wf_file) + self._workflows[wf.workflow_id] = wf + count += 1 + except Exception as e: + logger.warning(f"Impossible de charger {wf_file.name}: {e}") + + # Charger les workflows des sous-dossiers par machine + for machine_dir in sorted(workflows_dir.iterdir()): + if not machine_dir.is_dir(): + continue + for wf_file in sorted(machine_dir.glob("*.json")): + try: + wf = Workflow.load_from_file(wf_file) + # Stocker le machine_id dans les métadonnées du workflow + if not hasattr(wf, '_machine_id'): + wf._machine_id = machine_dir.name + self._workflows[wf.workflow_id] = wf + count += 1 + except Exception as e: + logger.warning(f"Impossible de charger {wf_file.name}: {e}") + + if count: + logger.info(f"{count} workflow(s) chargé(s) depuis {workflows_dir}") + except ImportError: + logger.debug("core.models.workflow_graph non disponible, skip chargement") + + def _ensure_initialized(self): + """Charger les composants core GPU si pas encore fait.""" + if self._initialized: + return + + with self._lock: + if self._initialized: + return + + logger.info("Initialisation des composants core (GPU)...") + + try: + from core.pipeline.screen_analyzer import ScreenAnalyzer + self._screen_analyzer = ScreenAnalyzer(session_id="stream_server") + logger.info(" ScreenAnalyzer prêt") + except Exception as e: + logger.error(f" Erreur init ScreenAnalyzer: {e}") + self._screen_analyzer = None + + try: + from core.embedding.clip_embedder import CLIPEmbedder + self._clip_embedder = CLIPEmbedder() + logger.info(" CLIPEmbedder prêt (singleton, ne sera plus rechargé)") + except Exception as e: + logger.error(f" Erreur init CLIPEmbedder: {e}") + self._clip_embedder = None + + # P0-3 : Initialiser le StateEmbeddingBuilder pour unifier l'espace d'embedding + # Utilise le même CLIPEmbedder (pas de rechargement du modèle) + FusionEngine + # pour produire des vecteurs fusionnés (image+text+title+ui) identiques à GraphBuilder + try: + from core.embedding.state_embedding_builder import StateEmbeddingBuilder + if self._clip_embedder is not None: + # Injecter le CLIPEmbedder déjà chargé pour éviter un double chargement + self._state_embedding_builder = StateEmbeddingBuilder( + embedders={ + "image": self._clip_embedder, + "text": self._clip_embedder, + "title": self._clip_embedder, + "ui": self._clip_embedder, + }, + output_dir=self.data_dir / "embeddings", + use_clip=False, # Pas besoin, on fournit les embedders directement + ) + else: + # Fallback : laisser le builder créer son propre CLIPEmbedder + self._state_embedding_builder = StateEmbeddingBuilder( + output_dir=self.data_dir / "embeddings", + use_clip=True, + ) + logger.info(" StateEmbeddingBuilder prêt (fusion multi-modale unifiée)") + except Exception as e: + logger.warning(f" StateEmbeddingBuilder non disponible, fallback CLIP pur: {e}") + self._state_embedding_builder = None + + try: + from core.embedding.faiss_manager import FAISSManager + self._faiss_manager = FAISSManager( + dimensions=512, + index_type="Flat", + metric="cosine", + ) + logger.info(" FAISSManager prêt (512 dims, cosine)") + except Exception as e: + logger.error(f" Erreur init FAISSManager: {e}") + self._faiss_manager = None + + self._initialized = True + logger.info("Composants core initialisés.") + + # ========================================================================= + # Événements + # ========================================================================= + + def process_event(self, session_id: str, event_data: Dict[str, Any]) -> Dict[str, Any]: + """Enregistrer un événement dans la session live.""" + self.session_manager.add_event(session_id, event_data) + return {"status": "event_recorded", "session_id": session_id} + + # ========================================================================= + # Screenshots + # ========================================================================= + + def process_screenshot(self, session_id: str, shot_id: str, file_path: str) -> Dict[str, Any]: + """ + Analyser un screenshot full via le core pipeline. + + 1. ScreenAnalyzer → ScreenState (OCR, UI detection) + 2. StateEmbeddingBuilder → vecteur fusionné 512d (image+text+title+ui) + Même espace d'embedding que GraphBuilder (P0-3) + Fallback : CLIP embed_image() si StateEmbeddingBuilder échoue + 3. FAISS indexation → matching temps réel + """ + self._ensure_initialized() + self.session_manager.add_screenshot(session_id, shot_id, file_path) + + result = { + "shot_id": shot_id, + "session_id": session_id, + "state_id": None, + "ui_elements_count": 0, + "text_detected": 0, + "embedding_indexed": False, + "match": None, + } + + # 1. Construire le ScreenState + if self._screen_analyzer is None: + logger.warning("ScreenAnalyzer non disponible, skip analyse") + return result + + session = self.session_manager.get_session(session_id) + window_info = session.last_window_info if session else {} + + try: + screen_state = self._screen_analyzer.analyze( + screenshot_path=file_path, + window_info=window_info, + ) + result["state_id"] = screen_state.screen_state_id + result["ui_elements_count"] = len(screen_state.ui_elements) + result["text_detected"] = len( + getattr(screen_state.perception, "detected_text", []) + ) + + # Stocker le ScreenState pour le build final + with self._data_lock: + if session_id not in self._screen_states: + self._screen_states[session_id] = [] + self._screen_states[session_id].append(screen_state) + + logger.info( + f"Screenshot analysé: {shot_id} | " + f"{result['ui_elements_count']} UI elements, " + f"{result['text_detected']} textes" + ) + except Exception as e: + logger.error(f"Erreur analyse screenshot {shot_id}: {e}") + return result + + # 2. Construire l'embedding fusionné via StateEmbeddingBuilder (P0-3) + # Utilise le même pipeline que GraphBuilder : fusion image+text+title+ui + # pour garantir que les vecteurs FAISS sont dans le même espace d'embedding + embedding_vector = None + + if self._state_embedding_builder is not None: + try: + state_embedding = self._state_embedding_builder.build(screen_state) + # Récupérer le vecteur fusionné depuis le StateEmbedding + fused_vec = state_embedding.get_vector() + if fused_vec is not None: + embedding_vector = fused_vec.astype(np.float32) + logger.debug( + f"Embedding fusionné multi-modal calculé pour {shot_id} " + f"(dim={embedding_vector.shape[0]})" + ) + except Exception as e: + logger.warning( + f"StateEmbeddingBuilder échoué pour {shot_id}: {e}, " + f"fallback sur CLIP pur" + ) + + # Fallback 1 : embedding pré-calculé dans le ScreenState (si disponible) + if embedding_vector is None: + if hasattr(screen_state, "perception") and screen_state.perception: + emb_ref = getattr(screen_state.perception, "embedding", None) + if emb_ref and hasattr(emb_ref, "vector") and emb_ref.vector is not None: + embedding_vector = np.array(emb_ref.vector, dtype=np.float32) + + # Fallback 2 : utiliser le CLIPEmbedder singleton (embedding image seul) + if embedding_vector is None and self._clip_embedder is not None: + try: + from PIL import Image + pil_image = Image.open(file_path) + embedding_vector = self._clip_embedder.embed_image(pil_image) + except Exception as e: + logger.debug(f"CLIP embedding échoué: {e}") + + if embedding_vector is not None: + # Stocker pour le build final + with self._data_lock: + if session_id not in self._embeddings: + self._embeddings[session_id] = [] + self._embeddings[session_id].append(embedding_vector) + + # 3. Indexer dans FAISS + if self._faiss_manager is not None: + try: + self._faiss_manager.add_embedding( + embedding_id=screen_state.screen_state_id, + vector=embedding_vector, + metadata={ + "session_id": session_id, + "shot_id": shot_id, + "window_title": window_info.get("title", ""), + }, + ) + result["embedding_indexed"] = True + except Exception as e: + logger.error(f"Erreur FAISS indexation: {e}") + + # 4. Matching temps réel contre les workflows connus + with self._data_lock: + has_workflows = bool(self._workflows) + if embedding_vector is not None and has_workflows: + result["match"] = self._try_match(embedding_vector) + + return result + + def process_crop(self, session_id: str, shot_id: str, file_path: str) -> Dict[str, Any]: + """ + Enregistrer un crop (400x400). Pas d'analyse ScreenAnalyzer + (un crop est un fragment, pas un écran complet). + """ + self.session_manager.add_screenshot(session_id, shot_id, file_path) + return {"status": "crop_stored", "shot_id": shot_id} + + # ========================================================================= + # Finalisation + # ========================================================================= + + def finalize_session(self, session_id: str) -> Dict[str, Any]: + """ + Construire un Workflow depuis les données accumulées. + + Utilise le GraphBuilder du core avec les ScreenStates et embeddings + collectés pendant le streaming. + """ + self._ensure_initialized() + + session = self.session_manager.finalize(session_id) + if not session: + return {"error": f"Session {session_id} non trouvée"} + + with self._data_lock: + states = list(self._screen_states.get(session_id, [])) + embeddings = list(self._embeddings.get(session_id, [])) + + if len(states) < 2: + logger.warning( + f"Session {session_id}: seulement {len(states)} states, " + f"pas assez pour construire un workflow" + ) + return { + "session_id": session_id, + "status": "insufficient_data", + "states_count": len(states), + "min_required": 2, + } + + # Convertir en RawSession pour le GraphBuilder + raw_dict = self.session_manager.to_raw_session(session_id) + if not raw_dict: + return {"error": "Conversion RawSession échouée"} + + try: + from core.models.raw_session import RawSession + raw_session = RawSession.from_dict(raw_dict) + except Exception as e: + logger.error(f"Erreur construction RawSession: {e}") + # Fallback : construire manuellement + try: + raw_session = self._build_raw_session_fallback(session, raw_dict) + except Exception as e2: + return {"error": f"Erreur RawSession: {e2}"} + + # Construire le workflow via GraphBuilder + try: + from core.graph.graph_builder import GraphBuilder + + n = len(states) + min_reps = 2 if n < 10 else 3 if n <= 30 else min(5, n // 10) + + builder = GraphBuilder( + min_pattern_repetitions=min_reps, + clustering_eps=0.15, + clustering_min_samples=2, + ) + + # Nommer le workflow intelligemment à partir des titres de fenêtre + workflow_name = self._generate_workflow_name(session_id) + + # Injecter les ScreenStates pré-calculés pour éviter de re-analyser + workflow = builder.build_from_session( + raw_session, + workflow_name=workflow_name, + precomputed_states=states, + ) + + with self._data_lock: + self._workflows[workflow.workflow_id] = workflow + + # Persister sur disque (dans le dossier de la machine source) + machine_id = session.machine_id if hasattr(session, 'machine_id') else "default" + saved_path = self._persist_workflow(workflow, session_id, machine_id=machine_id) + # Stocker le machine_id dans le workflow pour le filtrage + workflow._machine_id = machine_id + + # Récupérer les métadonnées applicatives de la session + session_state = self.session_manager.get_session(session_id) + app_context = {} + if session_state: + app_context = { + "window_titles": dict(session_state.window_titles_seen), + "app_names": dict(session_state.app_names_seen), + "primary_app": sorted( + session_state.app_names_seen.items(), + key=lambda x: -x[1] + )[0][0] if session_state.app_names_seen else None, + "multi_app": len(session_state.app_names_seen) >= 3, + } + + result = { + "session_id": session_id, + "machine_id": machine_id, + "status": "workflow_built", + "workflow_id": workflow.workflow_id, + "workflow_name": workflow_name, + "nodes": len(workflow.nodes), + "edges": len(workflow.edges), + "states_analyzed": len(states), + "embeddings_indexed": len(embeddings), + "saved_path": str(saved_path) if saved_path else None, + "app_context": app_context, + } + + logger.info( + f"Workflow construit: '{workflow_name}' ({workflow.workflow_id}) | " + f"{result['nodes']} nodes, {result['edges']} edges" + + (f" | apps: {list(app_context.get('app_names', {}).keys())}" if app_context.get('app_names') else "") + ) + + # Libérer la mémoire des données de session (peuvent être lourdes) + self._cleanup_session_data(session_id) + + return result + + except Exception as e: + logger.error(f"Erreur construction workflow: {e}") + return {"error": f"GraphBuilder: {e}", "session_id": session_id} + + # ========================================================================= + # Matching + # ========================================================================= + + def _try_match(self, embedding_vector: np.ndarray) -> Optional[Dict[str, Any]]: + """Matcher un embedding contre les workflows connus.""" + if self._faiss_manager is None or self._faiss_manager.index.ntotal == 0: + return None + + try: + results = self._faiss_manager.search_similar( + query_vector=embedding_vector, + k=1, + min_similarity=0.85, + ) + if results: + best = results[0] + return { + "matched_id": best.embedding_id, + "similarity": round(best.similarity, 4), + "metadata": best.metadata, + } + except Exception as e: + logger.debug(f"Erreur matching: {e}") + + return None + + # ========================================================================= + # Retraitement (appelé par le SessionWorker) + # ========================================================================= + + def reprocess_session( + self, + session_id: str, + progress_callback=None, + ) -> Dict[str, Any]: + """Retraiter une session finalisée : analyser tous les screenshots puis construire le workflow. + + Utilisé par le SessionWorker pour traiter les sessions en arrière-plan. + Cherche les fichiers shot_*_full.png sur disque, les analyse un par un + via process_screenshot(), puis appelle finalize_session() pour construire + le workflow. + + Args: + session_id: Identifiant de la session à retraiter. + progress_callback: Callable(session_id, current, total, shot_id) pour la progression. + + Returns: + Dict avec le résultat de finalize_session() ou un dict d'erreur. + """ + logger.info(f"Retraitement de la session {session_id}") + + # Trouver le dossier de la session sur disque + # Les screenshots peuvent être dans : + # - data/training/live_sessions/{session_id}/shots/ + # - data/training/live_sessions/{machine_id}/{session_id}/shots/ + session_dir = self._find_session_dir(session_id) + if not session_dir: + return {"error": f"Dossier session {session_id} introuvable sur disque"} + + shots_dir = session_dir / "shots" + if not shots_dir.exists(): + return {"error": f"Dossier shots/ introuvable pour {session_id}"} + + # Lister les screenshots full (shot_XXXX_full.png), triés par nom + full_shots = sorted(shots_dir.glob("shot_*_full.png")) + if not full_shots: + return { + "error": f"Aucun screenshot shot_*_full.png trouvé dans {shots_dir}", + "session_id": session_id, + } + + total = len(full_shots) + logger.info( + f"Session {session_id} : {total} screenshots full à analyser " + f"dans {shots_dir}" + ) + + # S'assurer que la session est enregistrée dans le session_manager + self.session_manager.get_or_create(session_id) + + # Nettoyer les données en mémoire (au cas où un traitement précédent a échoué) + with self._data_lock: + self._screen_states.pop(session_id, None) + self._embeddings.pop(session_id, None) + + # Analyser chaque screenshot full + errors = 0 + for i, shot_file in enumerate(full_shots): + shot_id = shot_file.stem # ex: "shot_0001_full" + file_path = str(shot_file) + + if progress_callback: + try: + progress_callback(session_id, i + 1, total, shot_id) + except Exception: + pass + + try: + result = self.process_screenshot(session_id, shot_id, file_path) + if result.get("state_id") is None: + logger.warning( + f"Screenshot {shot_id} : analyse échouée (pas de state_id)" + ) + errors += 1 + except Exception as e: + logger.error(f"Erreur analyse screenshot {shot_id}: {e}") + errors += 1 + + # Vérifier combien de states ont été produits + with self._data_lock: + states_count = len(self._screen_states.get(session_id, [])) + + logger.info( + f"Session {session_id} : {states_count}/{total} screenshots analysés " + f"({errors} erreurs)" + ) + + # Construire le workflow via finalize_session() + # Note: finalize() du session_manager a déjà été appelé quand la session + # a été marquée comme finalisée. On n'a pas besoin de le refaire. + # finalize_session() utilise les screen_states accumulés. + result = self.finalize_session(session_id) + return result + + def _find_session_dir(self, session_id: str) -> Optional[Path]: + """Trouver le dossier d'une session sur disque. + + Cherche dans : + 1. data/training/live_sessions/{session_id}/ + 2. data/training/live_sessions/{machine_id}/{session_id}/ (multi-machine) + """ + # Chemin direct + direct = self.data_dir / session_id + if direct.is_dir() and (direct / "shots").exists(): + return direct + + # Chercher dans les sous-dossiers (machine_id) + parent = self.data_dir + if parent.exists(): + for subdir in parent.iterdir(): + if subdir.is_dir(): + candidate = subdir / session_id + if candidate.is_dir() and (candidate / "shots").exists(): + return candidate + + # Chercher aussi dans le parent du data_dir (cas où data_dir = streaming_sessions) + parent_parent = self.data_dir.parent + if parent_parent.exists() and parent_parent != self.data_dir: + direct2 = parent_parent / session_id + if direct2.is_dir() and (direct2 / "shots").exists(): + return direct2 + for subdir in parent_parent.iterdir(): + if subdir.is_dir() and subdir.name != self.data_dir.name: + candidate = subdir / session_id + if candidate.is_dir() and (candidate / "shots").exists(): + return candidate + + return None + + def find_pending_sessions(self) -> List[str]: + """Trouver les sessions finalisées qui n'ont pas encore été traitées. + + Une session est "pending" si : + - Elle est marquée comme finalisée dans le session_manager + - Elle a 0 ScreenStates en mémoire (jamais analysée ou analyse perdue) + - Elle a des screenshots full sur disque + + Returns: + Liste de session_ids à traiter. + """ + pending = [] + for sid in self.session_manager.session_ids: + session = self.session_manager.get_session(sid) + if session is None: + continue + if not session.finalized: + continue + + # Vérifier si des states existent déjà + with self._data_lock: + states_count = len(self._screen_states.get(sid, [])) + if states_count > 0: + continue + + # Vérifier si un workflow existe déjà pour cette session + # (parcourir les workflows et checker la session_id dans les métadonnées) + with self._data_lock: + has_workflow = any( + getattr(wf, '_source_session', None) == sid + for wf in self._workflows.values() + ) + if has_workflow: + continue + + # Vérifier qu'il y a des screenshots full sur disque + session_dir = self._find_session_dir(sid) + if session_dir: + shots_dir = session_dir / "shots" + if shots_dir.exists(): + full_shots = list(shots_dir.glob("shot_*_full.png")) + if full_shots: + logger.info( + f"Session pending trouvée : {sid} " + f"({len(full_shots)} screenshots full)" + ) + pending.append(sid) + + return pending + + def _cleanup_session_data(self, session_id: str): + """Libérer la mémoire des ScreenStates et embeddings après finalization.""" + with self._data_lock: + states = self._screen_states.pop(session_id, []) + embeddings = self._embeddings.pop(session_id, []) + logger.info( + f"Mémoire libérée pour {session_id}: " + f"{len(states)} states, {len(embeddings)} embeddings" + ) + + # ========================================================================= + # Helpers + # ========================================================================= + + def _generate_workflow_name(self, session_id: str) -> str: + """ + Générer un nom de tâche lisible et humain à partir des titres de fenêtre. + + Analyse les titres vus pendant la session pour extraire : + - L'application principale (la plus fréquente) + - Le contexte documentaire (après le tiret dans le titre) + - Une description d'action déduite du contexte + + Exemples de résultats : + "Chrome - Facturation DPI" → "Chrome — Facturation DPI" + "Excel - Budget_2026.xlsx" → "Excel — Budget 2026" + 3 apps → "Chrome, Excel et Word" + Aucun contexte → "Tâche du 17 mars à 14h" + """ + import re + + session = self.session_manager.get_session(session_id) + if not session: + return self._fallback_task_name() + + titles = session.window_titles_seen + apps = session.app_names_seen + + if not titles and not apps: + return self._fallback_task_name() + + # Trier par fréquence décroissante + sorted_titles = sorted(titles.items(), key=lambda x: -x[1]) + sorted_apps = sorted(apps.items(), key=lambda x: -x[1]) + + # Extraire le nom d'app depuis le titre le plus fréquent + primary_title = sorted_titles[0][0] if sorted_titles else "" + primary_app = sorted_apps[0][0] if sorted_apps else "" + + # Nettoyer le nom d'application pour l'affichage humain + app_display = self._humanize_app_name(primary_app) if primary_app else "" + + # Extraire la partie contextuelle du titre (après/avant le séparateur) + context_part = "" + for sep in [" - ", " — ", " – ", " | ", ": "]: + if sep in primary_title: + parts = primary_title.split(sep) + if len(parts) >= 2: + candidates = [p.strip() for p in parts] + app_lower = primary_app.lower() + context_candidates = [ + c for c in candidates + if app_lower not in c.lower() + and c.lower() not in app_lower + ] + if context_candidates: + context_part = context_candidates[0] + else: + context_part = candidates[0] + break + + # Construire le nom lisible + distinct_apps = [a for a, _ in sorted_apps if a.lower() not in ("unknown", "explorer")] + + if len(distinct_apps) >= 3: + # Multi-app : "Chrome, Excel et Word" + app_names = [self._humanize_app_name(a) for a in distinct_apps[:3]] + if len(app_names) == 3: + name = f"{app_names[0]}, {app_names[1]} et {app_names[2]}" + else: + name = " et ".join(app_names) + elif context_part: + # Nettoyer le contexte pour le rendre lisible + clean_context = re.sub(r'[<>:"/\\|?*\[\]]', '', context_part) + # Retirer les extensions de fichier courantes + clean_context = re.sub(r'\.(xlsx?|csv|docx?|pdf|txt)$', '', clean_context, flags=re.IGNORECASE) + # Remplacer les underscores par des espaces + clean_context = clean_context.replace('_', ' ').strip()[:40] + if app_display: + name = f"{app_display} \u2014 {clean_context}" + else: + name = clean_context + elif app_display: + name = f"{app_display} \u2014 session" + else: + name = self._fallback_task_name() + + # Dédoublonner si une tâche avec ce nom existe déjà + base_name = name + counter = 1 + with self._data_lock: + existing_names = { + getattr(w, 'name', '') for w in self._workflows.values() + } + while name in existing_names: + counter += 1 + name = f"{base_name} ({counter})" + + return name + + @staticmethod + def _fallback_task_name() -> str: + """Générer un nom de tâche par défaut basé sur la date et l'heure.""" + now = datetime.now() + # Noms de mois en français + mois = [ + "", "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre" + ] + return f"Tâche du {now.day} {mois[now.month]} à {now.hour}h{now.minute:02d}" + + @staticmethod + def _humanize_app_name(app_name: str) -> str: + """Convertir un nom d'application technique en nom lisible. + + Exemples : + "notepad.exe" → "Bloc-notes" + "chrome.exe" → "Chrome" + "WindowsTerminal" → "Terminal" + """ + import re + # Supprimer l'extension .exe et les chemins + name = app_name.split("\\")[-1].split("/")[-1] + name = re.sub(r'\.exe$', '', name, flags=re.IGNORECASE).strip() + + # Dictionnaire de noms humains pour les applications courantes + app_human_names = { + "notepad": "Bloc-notes", + "notepad++": "Notepad++", + "chrome": "Chrome", + "msedge": "Edge", + "firefox": "Firefox", + "explorer": "Explorateur", + "windowsterminal": "Terminal", + "cmd": "Invite de commandes", + "powershell": "PowerShell", + "excel": "Excel", + "winword": "Word", + "powerpnt": "PowerPoint", + "outlook": "Outlook", + "teams": "Teams", + "code": "VS Code", + "searchhost": "Recherche", + "applicationframehost": "Application", + "calc": "Calculatrice", + "mspaint": "Paint", + "snippingtool": "Capture d'écran", + } + + name_lower = name.lower() + if name_lower in app_human_names: + return app_human_names[name_lower] + + # Capitaliser le nom si pas dans le dictionnaire + return name.capitalize() if name else "Application" + + @staticmethod + def _clean_app_name(app_name: str) -> str: + """Nettoyer un nom d'application pour l'utiliser dans un nom de workflow.""" + import re + # Supprimer l'extension .exe et les chemins + name = app_name.split("\\")[-1].split("/")[-1] + name = re.sub(r'\.exe$', '', name, flags=re.IGNORECASE) + # Capitaliser + name = name.strip().capitalize() + # Supprimer les caractères spéciaux + name = re.sub(r'[^a-zA-Z0-9àâäéèêëïîôùûüÿçÀÂÄÉÈÊËÏÎÔÙÛÜŸÇ_]', '', name) + return name or "App" + + def _persist_workflow(self, workflow, session_id: str, machine_id: str = "default") -> Optional[Path]: + """Sauvegarder le workflow JSON sur disque. + + Les workflows sont sauvegardés dans un sous-dossier par machine : + data/training/workflows/{machine_id}/wf_xxx.json + + Cela permet de distinguer les workflows appris sur des machines différentes. + """ + try: + # Dossier par machine (ou racine pour "default") + if machine_id and machine_id != "default": + workflows_dir = self.data_dir / "workflows" / machine_id + else: + workflows_dir = self.data_dir / "workflows" + workflows_dir.mkdir(parents=True, exist_ok=True) + filepath = workflows_dir / f"{workflow.workflow_id}.json" + workflow.save_to_file(filepath) + # Stocker le machine_id dans le workflow pour référence + if not hasattr(workflow, '_machine_id'): + workflow._machine_id = machine_id + logger.info(f"Workflow sauvegardé: {filepath} (machine={machine_id})") + return filepath + except Exception as e: + logger.error(f"Erreur sauvegarde workflow {session_id}: {e}") + return None + + def _build_raw_session_fallback(self, session, raw_dict): + """Construire un RawSession manuellement si from_dict échoue.""" + from core.models.raw_session import RawSession, Event, Screenshot, RawWindowContext + + events = [] + for evt_dict in raw_dict.get("events", []): + window_data = evt_dict.get("window", {"title": "", "app_name": "unknown"}) + window = RawWindowContext( + title=window_data.get("title", ""), + app_name=window_data.get("app_name", "unknown"), + ) + events.append(Event( + t=evt_dict.get("t", 0.0), + type=evt_dict.get("type", "unknown"), + window=window, + data={k: v for k, v in evt_dict.items() + if k not in ("t", "type", "window", "screenshot_id")}, + screenshot_id=evt_dict.get("screenshot_id"), + )) + + screenshots = [] + for ss_dict in raw_dict.get("screenshots", []): + screenshots.append(Screenshot( + screenshot_id=ss_dict["screenshot_id"], + relative_path=ss_dict.get("relative_path", ss_dict.get("path", "")), + captured_at=ss_dict.get("captured_at", datetime.now().isoformat()), + )) + + return RawSession( + session_id=session.session_id, + agent_version="agent_v1_stream", + environment=raw_dict.get("environment", {}), + user=raw_dict.get("user", {"id": "remote_agent"}), + context=raw_dict.get("context", {}), + started_at=session.created_at, + ended_at=datetime.now(), + events=events, + screenshots=screenshots, + ) + + def list_sessions(self, machine_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Lister les sessions avec leur état. + + Args: + machine_id: Si fourni, filtre par machine. Si None, retourne toutes les sessions. + """ + sessions = [] + for sid in self.session_manager.session_ids: + session = self.session_manager.get_session(sid) + if session is None: + continue + # Filtre par machine si demandé + if machine_id and session.machine_id != machine_id: + continue + with self._data_lock: + states_count = len(self._screen_states.get(sid, [])) + embeddings_count = len(self._embeddings.get(sid, [])) + sessions.append({ + "session_id": session.session_id, + "machine_id": session.machine_id, + "events_count": len(session.events), + "screenshots_count": len(session.shot_paths), + "states_count": states_count, + "embeddings_count": embeddings_count, + "last_window": session.last_window_info, + "created_at": session.created_at.isoformat(), + "last_activity": session.last_activity.isoformat(), + "finalized": session.finalized, + }) + return sessions + + def list_workflows(self, machine_id: Optional[str] = None) -> List[Dict[str, Any]]: + """Lister les workflows construits. + + Args: + machine_id: Si fourni, filtre par machine. Si None, retourne tous les workflows. + """ + with self._data_lock: + workflows_snapshot = list(self._workflows.items()) + result = [] + for wf_id, wf in workflows_snapshot: + wf_machine = getattr(wf, '_machine_id', 'default') + # Filtre par machine si demandé + if machine_id and wf_machine != machine_id: + continue + result.append({ + "workflow_id": wf_id, + "machine_id": wf_machine, + "nodes": len(wf.nodes) if hasattr(wf, "nodes") else 0, + "edges": len(wf.edges) if hasattr(wf, "edges") else 0, + "name": getattr(wf, "name", wf_id), + }) + return result + + @property + def stats(self) -> Dict[str, Any]: + """Statistiques du processeur.""" + with self._data_lock: + total_workflows = len(self._workflows) + return { + "active_sessions": self.session_manager.active_session_count, + "total_sessions": len(self.session_manager.session_ids), + "total_workflows": total_workflows, + "faiss_vectors": self._faiss_manager.index.ntotal if self._faiss_manager else 0, + "initialized": self._initialized, + } diff --git a/agent_v0/server_v1/visual_wait.py b/agent_v0/server_v1/visual_wait.py new file mode 100644 index 000000000..187dc4b9e --- /dev/null +++ b/agent_v0/server_v1/visual_wait.py @@ -0,0 +1,54 @@ +# server_v1/visual_wait.py +""" +Module de patience visuelle pour le Stagiaire. +Permet d'attendre l'apparition d'un élément UI avant d'agir. +""" + +import time +import logging +from pathlib import Path +from .vm_controller import VMController + +# On suppose l'existence du moteur de vision dans core +# from core.vision.vlm_service import VLMService + +logger = logging.getLogger("visual_wait") + +class VisualWaitManager: + def __init__(self, vm_controller: VMController): + self.vm = vm_controller + # self.vlm = VLMService() + + def wait_for_element(self, element_id: str, target_embedding, timeout: int = 30, threshold: float = 0.85): + """ + Attend qu'un élément visuel soit détecté à l'écran de la VM. + """ + logger.info(f"⏳ Patience visuelle : attente de '{element_id}'...") + start_time = time.time() + + while (time.time() - start_time) < timeout: + # 1. Capture de l'état actuel de la VM (via le flux vidéo/SPICE) + # current_screen = self.vm.get_current_frame() + + # 2. Comparaison avec l'élément attendu (via l'IA) + # score = self.vlm.compare(current_screen, target_embedding) + score = 0.0 # Placeholder simulation + + if score >= threshold: + logger.info(f"✅ Élément '{element_id}' détecté ! Score: {score}") + return True + + logger.debug(f" ... toujours en attente (score actuel: {score})") + time.sleep(1) # On ne surcharge pas le GPU + + logger.warning(f"❌ Timeout : Élément '{element_id}' non trouvé après {timeout}s.") + return False + + def wait_for_stable_screen(self, duration: float = 2.0, threshold: float = 0.98): + """ + Attend que l'écran de la VM arrête de bouger (fin d'une animation ou chargement). + """ + logger.info("⏳ Attente de stabilisation de l'écran...") + # Logique de comparaison de hashs successifs + time.sleep(duration) + return True diff --git a/agent_v0/server_v1/vm_controller.py b/agent_v0/server_v1/vm_controller.py new file mode 100644 index 000000000..28d2c1eb5 --- /dev/null +++ b/agent_v0/server_v1/vm_controller.py @@ -0,0 +1,143 @@ +# server_v1/vm_controller.py +""" +Contrôleur de VM Windows via libvirt (virsh). +Injection d'événements HID (souris/clavier) au niveau de l'hyperviseur. +C'est le "bras armé" du Stagiaire pour l'exécution GHOST (sans agent). +""" + +import subprocess +import logging +import time + +logger = logging.getLogger("vm_controller") + +class VMController: + def __init__(self, domain_name: str): + self.domain_name = domain_name + + def start_vm(self): + """Démarre la VM si elle est éteinte.""" + try: + logger.info(f"🚀 Démarrage de la VM {self.domain_name}...") + subprocess.run(f"virsh start {self.domain_name}", shell=True, check=True) + return True + except Exception as e: + logger.error(f"❌ Impossible de démarrer la VM: {e}") + return False + + def stop_vm(self, force=False): + """Arrête la VM proprement (ou force l'arrêt).""" + cmd = "destroy" if force else "shutdown" + try: + logger.info(f"🛑 Arrêt de la VM {self.domain_name} ({cmd})...") + subprocess.run(f"virsh {cmd} {self.domain_name}", shell=True, check=True) + return True + except Exception as e: + logger.error(f"❌ Erreur lors de l'arrêt: {e}") + return False + + def get_status(self) -> str: + """Retourne l'état actuel de la VM (running, shut off, etc.).""" + try: + res = subprocess.check_output(f"virsh domstate {self.domain_name}", shell=True) + return res.decode().strip() + except: + return "unknown" + + def create_checkpoint(self, checkpoint_name: str = "before_workflow"): + """Crée un snapshot de la VM pour pouvoir revenir en arrière en cas d'erreur.""" + try: + logger.info(f"📸 Création du checkpoint '{checkpoint_name}' pour {self.domain_name}...") + # On utilise --atomic pour garantir l'intégrité + subprocess.run(f"virsh snapshot-create-as {self.domain_name} {checkpoint_name} --atomic", shell=True, check=True) + return True + except Exception as e: + logger.error(f"❌ Échec de création du checkpoint: {e}") + return False + + def restore_checkpoint(self, checkpoint_name: str = "before_workflow"): + """Restaure la VM à un état précédent instantanément.""" + try: + logger.warning(f"🔄 Restauration du checkpoint '{checkpoint_name}' pour {self.domain_name}...") + # On force la restauration + subprocess.run(f"virsh snapshot-revert {self.domain_name} {checkpoint_name} --force", shell=True, check=True) + return True + except Exception as e: + logger.error(f"❌ Échec de la restauration: {e}") + return False + + def inject_click(self, x_pct: float, y_pct: float, button: str = "left"): + """ + Injecte un clic de souris aux coordonnées proportionnelles (0.0-1.0). + Utilise l'interface QEMU via virsh pour la précision absolue. + """ + try: + # Note: Pour QEMU/KVM, on utilise souvent l'interface moniteur 'qemu-monitor-command' + # pour envoyer des coordonnées absolues si une tablette USB est présente (évite le drift). + + # Exemple de commande QEMU Monitor pour un clic absolu + # (nécessite que la VM ait un périphérique tablette USB configuré) + cmd = f"virsh qemu-monitor-command {self.domain_name} --hmp 'mouse_set 0 0 0 {x_pct} {y_pct}'" + subprocess.run(cmd, shell=True, check=True) + + # Simulation du clic bouton + click_cmd = f"virsh qemu-monitor-command {self.domain_name} --hmp 'mouse_button 1'" # 1 = Left + subprocess.run(click_cmd, shell=True, check=True) + time.sleep(0.05) + release_cmd = f"virsh qemu-monitor-command {self.domain_name} --hmp 'mouse_button 0'" + subprocess.run(release_cmd, shell=True, check=True) + + logger.info(f"🖱️ Clic GHOST injecté dans {self.domain_name} à ({x_pct}, {y_pct})") + except Exception as e: + logger.error(f"❌ Erreur Injection Clic: {e}") + + def inject_text(self, text: str): + """ + Injecte du texte dans la VM en traduisant les caractères en séquences de touches. + Gère les majuscules (via Shift) et les caractères standards. + """ + try: + logger.info(f"⌨️ Saisie GHOST dans {self.domain_name} : '{text}'") + for char in text: + self._send_char(char) + # Petit délai pour simuler une frappe humaine et éviter la saturation du buffer + time.sleep(0.02) + except Exception as e: + logger.error(f"❌ Erreur Injection Texte: {e}") + + def inject_key_combo(self, keys: list): + """ + Exécute une combinaison de touches (ex: ['ctrl', 'alt', 'delete']). + """ + try: + combo = "+".join(keys) + cmd = f"virsh sendkey {self.domain_name} {combo}" + subprocess.run(cmd, shell=True, check=True) + logger.info(f"⌨️ Combo GHOST : {combo}") + except Exception as e: + logger.error(f"❌ Erreur Combo: {e}") + + def _send_char(self, char: str): + """Traduit un caractère unique en commande virsh sendkey.""" + # Mapping des caractères spéciaux pour virsh + special_map = { + " ": "space", "\n": "enter", "\t": "tab", ".": "dot", + ",": "comma", "-": "minus", "_": "underscore", "/": "slash" + } + + if char in special_map: + key = special_map[char] + cmd = f"virsh sendkey {self.domain_name} {key}" + elif char.isupper(): + key = char.lower() + cmd = f"virsh sendkey {self.domain_name} shift+{key}" + else: + key = char + cmd = f"virsh sendkey {self.domain_name} {key}" + + subprocess.run(cmd, shell=True, check=True) + +if __name__ == "__main__": + # Test rapide sur une VM de démo + controller = VMController("win10_demo") + # controller.inject_click(0.5, 0.5) # Clic au centre diff --git a/agent_v0/server_v1/worker_stream.py b/agent_v0/server_v1/worker_stream.py new file mode 100644 index 000000000..1b5cfb5ec --- /dev/null +++ b/agent_v0/server_v1/worker_stream.py @@ -0,0 +1,172 @@ +# agent_v0/server_v1/worker_stream.py +""" +Worker de Streaming Temps Réel — délègue au StreamProcessor (core pipeline). + +Surveille les sessions live, analyse screenshots et crops via ScreenAnalyzer + CLIP, +et met à jour le graphe d'intention en temps réel. + +Tous les calculs GPU tournent sur le serveur (RTX 5070). +""" + +import logging +import threading +import time +from pathlib import Path +from typing import Set + +from .stream_processor import StreamProcessor + +logger = logging.getLogger("worker_stream") + + +class StreamWorker: + """ + Worker qui surveille les sessions live et délègue au StreamProcessor. + + Deux modes de fonctionnement : + - Polling (start) : boucle qui surveille le dossier live_sessions + - Direct (process_*) : appelé directement par l'API pour traitement immédiat + """ + + def __init__(self, live_dir: str = "data/training/live_sessions", processor: StreamProcessor = None): + self.live_dir = Path(live_dir) + self.live_dir.mkdir(parents=True, exist_ok=True) + self.running = False + self.processed_files: Set[str] = set() + + # StreamProcessor partagé (créé si non fourni) + self.processor = processor or StreamProcessor(data_dir=str(self.live_dir)) + + self._thread: threading.Thread = None + + def start(self, blocking: bool = True): + """Démarrer le worker en mode polling.""" + self.running = True + logger.info("StreamWorker démarré — surveillance des sessions live.") + + if blocking: + self._poll_loop() + else: + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + + def stop(self): + """Arrêter proprement le worker.""" + self.running = False + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + logger.info("StreamWorker arrêté.") + + def _poll_loop(self): + """Boucle de polling pour les sessions live sur disque.""" + while self.running: + try: + self._check_live_sessions() + time.sleep(0.5) + except KeyboardInterrupt: + self.stop() + except Exception as e: + logger.error(f"Erreur worker loop: {e}") + + def _check_live_sessions(self): + """Parcourir les sessions en cours pour trouver du travail.""" + if not self.live_dir.exists(): + return + for session_path in self.live_dir.iterdir(): + if session_path.is_dir(): + self._process_session_incremental(session_path) + + def _process_session_incremental(self, session_path: Path): + """Analyser les nouveaux éléments d'une session active.""" + session_id = session_path.name + shots_dir = session_path / "shots" + + # Enregistrer la session si pas encore fait + self.processor.session_manager.get_or_create(session_id) + + # Traiter les nouveaux screenshots full + for shot_file in sorted(shots_dir.glob("*.png")) if shots_dir.exists() else []: + file_key = str(shot_file) + if file_key in self.processed_files: + continue + + shot_id = shot_file.stem + if "_crop" in shot_id: + result = self.processor.process_crop(session_id, shot_id, str(shot_file)) + logger.debug(f"Crop traité: {shot_id}") + elif shot_id.startswith("heartbeat_") or shot_id.startswith("focus_") or shot_id.startswith("res_shot_"): + # Pas d'analyse GPU pour les heartbeats, focus et res_shot + self.processor.session_manager.add_screenshot(session_id, shot_id, str(shot_file)) + elif shot_id.startswith("shot_") and "_full" in shot_id: + result = self.processor.process_screenshot(session_id, shot_id, str(shot_file)) + logger.info( + f"Screenshot analysé: {shot_id} | " + f"{result.get('ui_elements_count', 0)} UI, " + f"{result.get('text_detected', 0)} textes" + ) + else: + # Autres screenshots non reconnus : stocker sans analyser + self.processor.session_manager.add_screenshot(session_id, shot_id, str(shot_file)) + + self.processed_files.add(file_key) + + # Traiter les événements + event_file = session_path / "live_events.jsonl" + if event_file.exists(): + self._ingest_events(session_id, event_file) + + def _ingest_events(self, session_id: str, event_file: Path): + """Lire et ingérer les événements depuis un fichier JSONL.""" + import json + + event_key = f"{session_id}:events:{event_file.stat().st_size}" + if event_key in self.processed_files: + return + + try: + with open(event_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + event_data = json.loads(line) + self.processor.process_event(session_id, event_data) + except json.JSONDecodeError: + continue + self.processed_files.add(event_key) + except Exception as e: + logger.error(f"Erreur lecture événements {event_file}: {e}") + + # ========================================================================= + # API directe (appelé par api_stream.py) + # ========================================================================= + + def process_screenshot_direct(self, session_id: str, shot_id: str, file_path: str): + """Traitement direct d'un screenshot (appelé par l'API).""" + return self.processor.process_screenshot(session_id, shot_id, file_path) + + def process_crop_direct(self, session_id: str, shot_id: str, file_path: str): + """Traitement direct d'un crop (appelé par l'API).""" + return self.processor.process_crop(session_id, shot_id, file_path) + + def process_event_direct(self, session_id: str, event_data: dict): + """Traitement direct d'un événement (appelé par l'API).""" + return self.processor.process_event(session_id, event_data) + + def finalize_session(self, session_id: str): + """Finaliser une session et construire le workflow.""" + return self.processor.finalize_session(session_id) + + @property + def stats(self): + return self.processor.stats + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [STREAM-WORKER] %(message)s", + ) + worker = StreamWorker() + worker.start() diff --git a/agent_v0/setup_v1.bat b/agent_v0/setup_v1.bat new file mode 100644 index 000000000..cdd0e77d8 --- /dev/null +++ b/agent_v0/setup_v1.bat @@ -0,0 +1,64 @@ +@echo off +:: setup_v1.bat - Installation conviviale pour Windows + +echo ================================================== +echo Agent V1 - RPA Vision - Installation Windows +echo ================================================== +echo. + +:: 0. Verifier que Python est installe +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERREUR] Python n'est pas installe ou pas dans le PATH. + echo Telecharger Python 3.10+ depuis https://python.org + pause + exit /b 1 +) + +:: 1. Creation de l'environnement virtuel +if not exist ".venv_v1_win" ( + echo [1/4] Creation de l'environnement virtuel... + python -m venv .venv_v1_win +) else ( + echo [1/4] Environnement virtuel existant detecte. +) + +:: 2. Activation +call .venv_v1_win\Scripts\activate.bat + +:: 3. Mise a jour pip et installation des dependances +echo [2/4] Installation des dependances... +python -m pip install --upgrade pip --quiet +pip install -r agent_v1\requirements.txt --quiet + +:: 4. Post-installation Windows (pywin32) +echo [3/4] Configuration Windows... +python -c "import win32api" >nul 2>&1 +if errorlevel 1 ( + echo pywin32 post-install... + python .venv_v1_win\Scripts\pywin32_postinstall.py -install >nul 2>&1 +) + +:: 5. Verification rapide +echo [4/4] Verification... +python -c "import pystray; import plyer; import mss; import pynput; print(' Toutes les dependances OK')" +if errorlevel 1 ( + echo [ERREUR] Certaines dependances sont manquantes. + echo Relancer : pip install -r agent_v1\requirements.txt + pause + exit /b 1 +) + +echo. +echo ================================================== +echo Installation terminee ! +echo. +echo Pour lancer l'agent : +echo .venv_v1_win\Scripts\activate.bat +echo python run_agent_v1.py +echo. +echo Configuration serveur : +echo Editer agent_config.json +echo ou definir RPA_SERVER_HOST=192.168.1.x +echo ================================================== +pause diff --git a/agent_v0/setup_v1.sh b/agent_v0/setup_v1.sh new file mode 100644 index 000000000..1139e5dff --- /dev/null +++ b/agent_v0/setup_v1.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# setup_v1.sh - Installation conviviale pour Linux et macOS + +echo "--------------------------------------------------" +echo "🚀 Installation de l'Agent V1 - RPA Vision V3" +echo "--------------------------------------------------" + +# 1. Création de l'environnement virtuel +if [ ! -d ".venv_v1" ]; then + echo "📦 Création de l'environnement virtuel (.venv_v1)..." + python3 -m venv .venv_v1 +fi + +# 2. Activation et Installation +source .venv_v1/bin/activate +echo "🛠️ Installation des dépendances (Fibre-ready)..." +pip install --upgrade pip +pip install -r agent_v1/requirements.txt + +# 3. Vérifications spécifiques OS +if [[ "$OSTYPE" == "darwin"* ]]; then + echo "🍎 Note macOS : N'oubliez pas d'autoriser 'Accessibilité' et 'Enregistrement d'écran' pour votre terminal." +fi + +echo "--------------------------------------------------" +echo "✅ Installation terminée avec succès !" +echo "Pour lancer l'agent, utilisez la commande suivante :" +echo "source .venv_v1/bin/activate && python run_agent_v1.py" +echo "--------------------------------------------------" diff --git a/agent_v0/window_info.py b/agent_v0/window_info.py new file mode 100644 index 000000000..7e6be8744 --- /dev/null +++ b/agent_v0/window_info.py @@ -0,0 +1,55 @@ +# window_info.py +""" +Récupération des informations sur la fenêtre active (X11). + +v0 : +- utilise xdotool pour obtenir : + - le titre de la fenêtre active + - le PID de la fenêtre active, puis le nom du process via ps + +Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". +""" + +from __future__ import annotations + +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Nécessite xdotool installé sur le système. + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } diff --git a/agent_v0/window_info_crossplatform.py b/agent_v0/window_info_crossplatform.py new file mode 100644 index 000000000..ba059a3fc --- /dev/null +++ b/agent_v0/window_info_crossplatform.py @@ -0,0 +1,192 @@ +# window_info_crossplatform.py +""" +Récupération des informations sur la fenêtre active - CROSS-PLATFORM + +Supporte: +- Linux (X11 via xdotool) +- Windows (via pywin32) +- macOS (via pyobjc) + +Installation des dépendances: + pip install pywin32 # Windows + pip install pyobjc-framework-Cocoa # macOS + pip install psutil # Tous OS +""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Dict, Optional + + +def _run_cmd(cmd: list[str]) -> Optional[str]: + """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" + try: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return out.decode("utf-8", errors="ignore").strip() + except Exception: + return None + + +def get_active_window_info() -> Dict[str, str]: + """ + Renvoie un dict : + { + "title": "...", + "app_name": "..." + } + + Détecte automatiquement l'OS et utilise la méthode appropriée. + """ + system = platform.system() + + if system == "Linux": + return _get_window_info_linux() + elif system == "Windows": + return _get_window_info_windows() + elif system == "Darwin": # macOS + return _get_window_info_macos() + else: + return {"title": "unknown_window", "app_name": "unknown_app"} + + +def _get_window_info_linux() -> Dict[str, str]: + """ + Linux: utilise xdotool (X11) + + Nécessite: sudo apt-get install xdotool + """ + title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) + pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) + + app_name: Optional[str] = None + if pid_str: + pid_str = pid_str.strip() + # On récupère le nom du binaire via ps + app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) + + if not title: + title = "unknown_window" + if not app_name: + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + +def _get_window_info_windows() -> Dict[str, str]: + """ + Windows: utilise pywin32 + psutil + + Nécessite: pip install pywin32 psutil + """ + try: + import win32gui + import win32process + import psutil + + # Fenêtre au premier plan + hwnd = win32gui.GetForegroundWindow() + + # Titre de la fenêtre + title = win32gui.GetWindowText(hwnd) + if not title: + title = "unknown_window" + + # PID du processus + _, pid = win32process.GetWindowThreadProcessId(hwnd) + + # Nom du processus + try: + process = psutil.Process(pid) + app_name = process.name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + app_name = "unknown_app" + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pywin32 ou psutil non installé + return { + "title": "unknown_window (pywin32 missing)", + "app_name": "unknown_app (pywin32 missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +def _get_window_info_macos() -> Dict[str, str]: + """ + macOS: utilise pyobjc (AppKit) + + Nécessite: pip install pyobjc-framework-Cocoa + + Note: Nécessite les permissions "Accessibility" dans System Preferences + """ + try: + from AppKit import NSWorkspace + from Quartz import ( + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + # Application active + active_app = NSWorkspace.sharedWorkspace().activeApplication() + app_name = active_app.get('NSApplicationName', 'unknown_app') + + # Titre de la fenêtre (via Quartz) + # On cherche la fenêtre de l'app active qui est au premier plan + window_list = CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID + ) + + title = "unknown_window" + for window in window_list: + owner_name = window.get('kCGWindowOwnerName', '') + if owner_name == app_name: + window_title = window.get('kCGWindowName', '') + if window_title: + title = window_title + break + + return { + "title": title, + "app_name": app_name, + } + + except ImportError: + # pyobjc non installé + return { + "title": "unknown_window (pyobjc missing)", + "app_name": "unknown_app (pyobjc missing)", + } + except Exception as e: + return { + "title": f"error: {e}", + "app_name": "unknown_app", + } + + +# Test rapide +if __name__ == "__main__": + import time + + print(f"OS détecté: {platform.system()}") + print("\nTest de capture fenêtre active (5 secondes)...") + print("Changez de fenêtre pour tester!\n") + + for i in range(5): + info = get_active_window_info() + print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") + time.sleep(1)