chore: ajouter agent_v0/ au tracking git (était un repo embarqué)

Suppression du .git embarqué dans agent_v0/ — le code est maintenant
tracké normalement dans le repo principal.
Inclut : agent_v1 (client), server_v1 (streaming), lea_ui (chat client)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 11:12:23 +01:00
parent af83552923
commit ae65be2555
82 changed files with 15616 additions and 0 deletions

1
agent_v0/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.idea/

1
agent_v0/__init__.py Normal file
View File

@@ -0,0 +1 @@
# agent_v0 — Agent RPA Vision V3

View File

@@ -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": ""
}

View File

@@ -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*

View File

View File

@@ -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)

View File

View File

@@ -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)

View File

@@ -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 ""

View File

@@ -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,
}

View File

@@ -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)

384
agent_v0/agent_v1/main.py Normal file
View File

@@ -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()

View File

View File

View File

@@ -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

View File

@@ -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'

View File

View File

@@ -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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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 <date>"
"""
# 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()

View File

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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)

58
agent_v0/config.py Normal file
View File

@@ -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)

View File

@@ -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...")

View File

@@ -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

View File

@@ -0,0 +1 @@
# agent_v0 — Agent RPA Vision V3

View File

@@ -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": ""
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 ""

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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")

View File

@@ -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'

View File

@@ -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.")

View File

@@ -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

235
agent_v0/deploy_windows.py Normal file
View File

@@ -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()

View File

@@ -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"

View File

@@ -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()

View File

@@ -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()
)
))

218
agent_v0/lea_ui/launcher.py Normal file
View File

@@ -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()

View File

@@ -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 <b>Lea</b>, votre assistante RPA.<br>"
"Je peux apprendre vos taches, les rejouer, "
"et vous montrer ce que je fais.<br><br>"
"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 <b>{conf.get('workflow_name', '?')}</b> ?<br>"
f"Risque : {conf.get('risk_level', 'normal')}<br>"
"Repondez <b>oui</b> ou <b>non</b>."
)
# 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"- <b>{name}</b>{': ' + desc if desc else ''}")
result = "Voici ce que je sais faire :<br>" + "<br>".join(items)
if len(workflows) > 10:
result += f"<br><i>... et {len(workflows) - 10} autres</i>"
return result
# Workflow non trouve
if data.get("not_found"):
return (
f"Je ne trouve pas de workflow correspondant a "
f"'{data.get('query', '?')}'.<br>"
"Essayez 'Que sais-tu faire ?' pour voir la liste."
)
# Execution reussie
if data.get("success"):
return (
f"C'est parti ! J'execute <b>{data.get('workflow', '?')}</b>.<br>"
"Regardez l'ecran, je vais vous montrer ce que je fais."
)
# Confirmation/refus
if data.get("confirmed"):
return f"D'accord, je lance <b>{data.get('workflow', '?')}</b> !"
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 :<br>"
"1. Cliquez sur <b>Demarrer</b> dans le tray Agent V1<br>"
"2. Effectuez votre tache normalement<br>"
"3. Cliquez sur <b>Terminer</b> quand c'est fini<br><br>"
"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"- <b>{name}</b>{': ' + desc if desc else ''}")
text = "Voici les workflows que je connais :<br>" + "<br>".join(items)
if len(workflows) > 15:
text += f"<br><i>... et {len(workflows) - 15} autres</i>"
else:
text = (
"Je ne connais aucun workflow pour le moment.<br>"
"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")

354
agent_v0/lea_ui/overlay.py Normal file
View File

@@ -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}",
)

View File

@@ -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

View File

@@ -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")

200
agent_v0/lea_ui/styles.py Normal file
View File

@@ -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};
}}
"""

16
agent_v0/run_agent_v1.py Normal file
View File

@@ -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.")

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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()))

View File

@@ -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}%)"
),
)

View File

@@ -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 "")
)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

64
agent_v0/setup_v1.bat Normal file
View File

@@ -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

29
agent_v0/setup_v1.sh Normal file
View File

@@ -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 "--------------------------------------------------"

55
agent_v0/window_info.py Normal file
View File

@@ -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,
}

View File

@@ -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)