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:
1
agent_v0/.gitignore
vendored
Normal file
1
agent_v0/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.idea/
|
||||
1
agent_v0/__init__.py
Normal file
1
agent_v0/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# agent_v0 — Agent RPA Vision V3
|
||||
15
agent_v0/agent_config.json
Normal file
15
agent_v0/agent_config.json
Normal 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": ""
|
||||
}
|
||||
76
agent_v0/agent_v1/EVOLUTION_V1_README.md
Normal file
76
agent_v0/agent_v1/EVOLUTION_V1_README.md
Normal 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*
|
||||
0
agent_v0/agent_v1/__init__.py
Normal file
0
agent_v0/agent_v1/__init__.py
Normal file
43
agent_v0/agent_v1/config.py
Normal file
43
agent_v0/agent_v1/config.py
Normal 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)
|
||||
0
agent_v0/agent_v1/core/__init__.py
Normal file
0
agent_v0/agent_v1/core/__init__.py
Normal file
328
agent_v0/agent_v1/core/captor.py
Normal file
328
agent_v0/agent_v1/core/captor.py
Normal 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)
|
||||
523
agent_v0/agent_v1/core/executor.py
Normal file
523
agent_v0/agent_v1/core/executor.py
Normal 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 ""
|
||||
55
agent_v0/agent_v1/core/window_info.py
Normal file
55
agent_v0/agent_v1/core/window_info.py
Normal 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,
|
||||
}
|
||||
192
agent_v0/agent_v1/core/window_info_crossplatform.py
Normal file
192
agent_v0/agent_v1/core/window_info_crossplatform.py
Normal 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
384
agent_v0/agent_v1/main.py
Normal 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()
|
||||
0
agent_v0/agent_v1/monitoring/__init__.py
Normal file
0
agent_v0/agent_v1/monitoring/__init__.py
Normal file
0
agent_v0/agent_v1/network/__init__.py
Normal file
0
agent_v0/agent_v1/network/__init__.py
Normal file
398
agent_v0/agent_v1/network/streamer.py
Normal file
398
agent_v0/agent_v1/network/streamer.py
Normal 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
|
||||
16
agent_v0/agent_v1/requirements.txt
Normal file
16
agent_v0/agent_v1/requirements.txt
Normal 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'
|
||||
0
agent_v0/agent_v1/session/__init__.py
Normal file
0
agent_v0/agent_v1/session/__init__.py
Normal file
65
agent_v0/agent_v1/session/storage.py
Normal file
65
agent_v0/agent_v1/session/storage.py
Normal 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
|
||||
0
agent_v0/agent_v1/ui/__init__.py
Normal file
0
agent_v0/agent_v1/ui/__init__.py
Normal file
1127
agent_v0/agent_v1/ui/chat_window.py
Normal file
1127
agent_v0/agent_v1/ui/chat_window.py
Normal file
File diff suppressed because it is too large
Load Diff
206
agent_v0/agent_v1/ui/notifications.py
Normal file
206
agent_v0/agent_v1/ui/notifications.py
Normal 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,
|
||||
)
|
||||
190
agent_v0/agent_v1/ui/shared_state.py
Normal file
190
agent_v0/agent_v1/ui/shared_state.py
Normal 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)
|
||||
692
agent_v0/agent_v1/ui/smart_tray.py
Normal file
692
agent_v0/agent_v1/ui/smart_tray.py
Normal 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()
|
||||
0
agent_v0/agent_v1/vision/__init__.py
Normal file
0
agent_v0/agent_v1/vision/__init__.py
Normal file
84
agent_v0/agent_v1/vision/capturer.py
Normal file
84
agent_v0/agent_v1/vision/capturer.py
Normal 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()
|
||||
55
agent_v0/agent_v1/window_info.py
Normal file
55
agent_v0/agent_v1/window_info.py
Normal 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,
|
||||
}
|
||||
192
agent_v0/agent_v1/window_info_crossplatform.py
Normal file
192
agent_v0/agent_v1/window_info_crossplatform.py
Normal 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
58
agent_v0/config.py
Normal 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)
|
||||
136
agent_v0/deploy/test_replay_diag.py
Normal file
136
agent_v0/deploy/test_replay_diag.py
Normal 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...")
|
||||
17
agent_v0/deploy/windows_client/LISEZMOI.txt
Normal file
17
agent_v0/deploy/windows_client/LISEZMOI.txt
Normal 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
|
||||
1
agent_v0/deploy/windows_client/__init__.py
Normal file
1
agent_v0/deploy/windows_client/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# agent_v0 — Agent RPA Vision V3
|
||||
15
agent_v0/deploy/windows_client/agent_config.json
Normal file
15
agent_v0/deploy/windows_client/agent_config.json
Normal 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": ""
|
||||
}
|
||||
0
agent_v0/deploy/windows_client/agent_v1/__init__.py
Normal file
0
agent_v0/deploy/windows_client/agent_v1/__init__.py
Normal file
43
agent_v0/deploy/windows_client/agent_v1/config.py
Normal file
43
agent_v0/deploy/windows_client/agent_v1/config.py
Normal 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)
|
||||
319
agent_v0/deploy/windows_client/agent_v1/core/captor.py
Normal file
319
agent_v0/deploy/windows_client/agent_v1/core/captor.py
Normal 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)
|
||||
523
agent_v0/deploy/windows_client/agent_v1/core/executor.py
Normal file
523
agent_v0/deploy/windows_client/agent_v1/core/executor.py
Normal 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 ""
|
||||
55
agent_v0/deploy/windows_client/agent_v1/core/window_info.py
Normal file
55
agent_v0/deploy/windows_client/agent_v1/core/window_info.py
Normal 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,
|
||||
}
|
||||
@@ -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)
|
||||
325
agent_v0/deploy/windows_client/agent_v1/main.py
Normal file
325
agent_v0/deploy/windows_client/agent_v1/main.py
Normal 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()
|
||||
398
agent_v0/deploy/windows_client/agent_v1/network/streamer.py
Normal file
398
agent_v0/deploy/windows_client/agent_v1/network/streamer.py
Normal 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
|
||||
65
agent_v0/deploy/windows_client/agent_v1/session/storage.py
Normal file
65
agent_v0/deploy/windows_client/agent_v1/session/storage.py
Normal 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
|
||||
201
agent_v0/deploy/windows_client/agent_v1/ui/notifications.py
Normal file
201
agent_v0/deploy/windows_client/agent_v1/ui/notifications.py
Normal 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,
|
||||
)
|
||||
625
agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py
Normal file
625
agent_v0/deploy/windows_client/agent_v1/ui/smart_tray.py
Normal 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()
|
||||
84
agent_v0/deploy/windows_client/agent_v1/vision/capturer.py
Normal file
84
agent_v0/deploy/windows_client/agent_v1/vision/capturer.py
Normal 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()
|
||||
55
agent_v0/deploy/windows_client/agent_v1/window_info.py
Normal file
55
agent_v0/deploy/windows_client/agent_v1/window_info.py
Normal 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,
|
||||
}
|
||||
@@ -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/deploy/windows_client/config.py
Normal file
58
agent_v0/deploy/windows_client/config.py
Normal 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)
|
||||
13
agent_v0/deploy/windows_client/lea_ui/__init__.py
Normal file
13
agent_v0/deploy/windows_client/lea_ui/__init__.py
Normal 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"
|
||||
350
agent_v0/deploy/windows_client/lea_ui/server_client.py
Normal file
350
agent_v0/deploy/windows_client/lea_ui/server_client.py
Normal 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")
|
||||
15
agent_v0/deploy/windows_client/requirements.txt
Normal file
15
agent_v0/deploy/windows_client/requirements.txt
Normal 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'
|
||||
16
agent_v0/deploy/windows_client/run_agent_v1.py
Normal file
16
agent_v0/deploy/windows_client/run_agent_v1.py
Normal 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.")
|
||||
64
agent_v0/deploy/windows_client/setup.bat
Normal file
64
agent_v0/deploy/windows_client/setup.bat
Normal 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
235
agent_v0/deploy_windows.py
Normal 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()
|
||||
13
agent_v0/lea_ui/__init__.py
Normal file
13
agent_v0/lea_ui/__init__.py
Normal 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"
|
||||
6
agent_v0/lea_ui/__main__.py
Normal file
6
agent_v0/lea_ui/__main__.py
Normal 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()
|
||||
250
agent_v0/lea_ui/chat_widget.py
Normal file
250
agent_v0/lea_ui/chat_widget.py
Normal 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
218
agent_v0/lea_ui/launcher.py
Normal 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()
|
||||
772
agent_v0/lea_ui/main_window.py
Normal file
772
agent_v0/lea_ui/main_window.py
Normal 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
354
agent_v0/lea_ui/overlay.py
Normal 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}",
|
||||
)
|
||||
191
agent_v0/lea_ui/replay_integration.py
Normal file
191
agent_v0/lea_ui/replay_integration.py
Normal 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
|
||||
355
agent_v0/lea_ui/server_client.py
Normal file
355
agent_v0/lea_ui/server_client.py
Normal 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
200
agent_v0/lea_ui/styles.py
Normal 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
16
agent_v0/run_agent_v1.py
Normal 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.")
|
||||
0
agent_v0/server_v1/__init__.py
Normal file
0
agent_v0/server_v1/__init__.py
Normal file
1964
agent_v0/server_v1/api_stream.py
Normal file
1964
agent_v0/server_v1/api_stream.py
Normal file
File diff suppressed because it is too large
Load Diff
306
agent_v0/server_v1/live_session_manager.py
Normal file
306
agent_v0/server_v1/live_session_manager.py
Normal 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()))
|
||||
347
agent_v0/server_v1/replay_verifier.py
Normal file
347
agent_v0/server_v1/replay_verifier.py
Normal 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}%)"
|
||||
),
|
||||
)
|
||||
253
agent_v0/server_v1/session_worker.py
Normal file
253
agent_v0/server_v1/session_worker.py
Normal 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 "")
|
||||
)
|
||||
964
agent_v0/server_v1/stream_processor.py
Normal file
964
agent_v0/server_v1/stream_processor.py
Normal 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,
|
||||
}
|
||||
54
agent_v0/server_v1/visual_wait.py
Normal file
54
agent_v0/server_v1/visual_wait.py
Normal 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
|
||||
143
agent_v0/server_v1/vm_controller.py
Normal file
143
agent_v0/server_v1/vm_controller.py
Normal 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
|
||||
172
agent_v0/server_v1/worker_stream.py
Normal file
172
agent_v0/server_v1/worker_stream.py
Normal 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
64
agent_v0/setup_v1.bat
Normal 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
29
agent_v0/setup_v1.sh
Normal 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
55
agent_v0/window_info.py
Normal 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,
|
||||
}
|
||||
192
agent_v0/window_info_crossplatform.py
Normal file
192
agent_v0/window_info_crossplatform.py
Normal 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)
|
||||
Reference in New Issue
Block a user