diff --git a/agent_rust/Cargo.toml b/agent_rust/Cargo.toml index 5af368fda..ab988b3af 100644 --- a/agent_rust/Cargo.toml +++ b/agent_rust/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "rpa-agent" -version = "0.1.0" +version = "0.2.0" edition = "2021" -description = "Agent RPA Vision - Lea (Phase 1 headless)" +description = "Agent RPA Vision - Lea (Phases 1-5)" [dependencies] # Capture d'ecran @@ -11,12 +11,18 @@ xcap = "0.7" # Simulation souris/clavier (replay) enigo = { version = "0.3", features = ["serde"] } +# Capture evenements souris/clavier (recording) — Phase 5 +rdev = "0.5" + # Client HTTP (mode bloquant, pas de tokio) reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] } -# Traitement d'images (JPEG encode, resize) +# Traitement d'images (JPEG encode, resize, crop) image = "0.25" +# Floutage zones sensibles — Phase 5 +imageproc = "0.25" + # Encodage base64 base64 = "0.22" @@ -33,11 +39,47 @@ hostname = "0.4" # Date/heure chrono = "0.4" +# Canaux inter-threads performants +crossbeam-channel = "0.5" + +# Logging +log = "0.4" +env_logger = "0.11" + # Signal handling Unix (Ctrl+C) [target.'cfg(unix)'.dependencies] libc = "0.2" +# Dependances Windows uniquement — Phases 3-5 +[target.'cfg(windows)'.dependencies] +# Systray — Phase 3 +tray-icon = "0.19" +muda = "0.15" + +# Boucle d'evenements — Phase 3 +winit = { version = "0.30", features = ["rwh_06"] } + +# Notifications toast — Phase 3 +winrt-notification = "0.5" + +# Chat WebView2 — Phase 4 +wry = "0.48" + +# Raw window handle pour wry + fenetre native +raw-window-handle = "0.6" + +# Win32 API (info fenetre, dialogues, etc.) +windows-sys = { version = "0.59", features = [ + "Win32_UI_WindowsAndMessaging", + "Win32_System_Threading", + "Win32_System_LibraryLoader", + "Win32_Foundation", + "Win32_Graphics_Gdi", +] } + [profile.release] opt-level = "z" lto = true strip = true +codegen-units = 1 +panic = "abort" diff --git a/agent_rust/README.md b/agent_rust/README.md index eaec01133..ee820516a 100644 --- a/agent_rust/README.md +++ b/agent_rust/README.md @@ -1,52 +1,58 @@ -# RPA Vision Agent (Rust) — Phase 1 +# RPA Vision Agent (Rust) — Phases 1-5 -Agent headless pour RPA Vision V3, ecrit en Rust. -Capture des screenshots, les envoie au serveur streaming, poll les actions de replay et les execute. +Agent complet pour RPA Vision V3, ecrit en Rust. +Parite fonctionnelle avec l'agent Python (`agent_v0/agent_v1/`) en un seul executable de 2.4 Mo. -Equivalent fonctionnel de `agent_v0/agent_v1/` (Python) mais en un seul executable sans dependance. +## Fonctionnalites -## Fonctionnalites (Phase 1) +### Phase 1 — Agent minimal (headless) +- **Heartbeat** : capture ecran toutes les 5s, JPEG, dedup par hash perceptuel +- **Replay** : poll serveur, execute actions (click, type, key_combo, scroll, wait) +- **Resolution visuelle** : resolution de cibles via le serveur (template matching) +- **Serveur de capture** : port 5006 (GET /capture, GET /health, POST /file-action) -- **Heartbeat** : capture l'ecran toutes les 5s, encode en JPEG, envoie au serveur avec deduplication par hash perceptuel -- **Replay** : poll GET /replay/next toutes les secondes, execute les actions (click, type, key_combo, scroll, wait), rapporte le resultat avec screenshot post-action -- **Serveur de capture** : mini serveur HTTP sur port 5006 pour screenshots a la demande (GET /capture, GET /health, POST /file-action) -- **Configuration** : via variables d'environnement ou valeurs par defaut +### Phase 3 — Systray + Notifications +- **Systray** : icone avec cercle colore (gris=idle, rouge=enregistrement, vert=connecte, bleu=replay) +- **Menu contextuel** : Machine ID, statut, Apprenez-moi, C'est termine, Mes taches, ARRET D'URGENCE, Chat, Fichiers, Quitter +- **Notifications toast** : via winrt-notification (bienvenue, session, replay, connexion) +- **Etat partage** : thread-safe via AtomicBool + Mutex + +### Phase 4 — Chat WebView2 +- **WebView2** : fenetre 520x720, charge http://{server}:5004/chat +- **Positionnement** : bas-droite pres du systray +- **Fallback** : HTML embarque si le serveur est indisponible +- **Toggle** : show/hide via menu systray + +### Phase 5 — Parite complete +- **Enregistrement** : capture evenements souris/clavier via rdev, envoi au serveur +- **Floutage** : detection de champs de saisie + blur gaussien (protection donnees sensibles) +- **Configuration** : BLUR_SENSITIVE, LOG_RETENTION_DAYS, CHAT_PORT +- **Health check** : verification connexion serveur toutes les 30s ## Build ### Linux (pour tests) ```bash -# Pre-requis systeme (Ubuntu/Debian) sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev - -# Build debug -cargo build - -# Build release optimise cargo build --release ``` ### Cross-compilation vers Windows ```bash -# Option A : cargo-xwin (recommande, produit un .exe MSVC) -cargo install cargo-xwin -rustup target add x86_64-pc-windows-msvc -cargo xwin build --target x86_64-pc-windows-msvc --release - -# Option B : MinGW (plus simple) rustup target add x86_64-pc-windows-gnu sudo apt install gcc-mingw-w64-x86-64 cargo build --release --target x86_64-pc-windows-gnu - -# Option C : cross (Docker) -cargo install cross -cross build --release --target x86_64-pc-windows-msvc ``` -Le binaire release se trouve dans `target/release/rpa-agent` (Linux) ou -`target/x86_64-pc-windows-msvc/release/rpa-agent.exe` (Windows). +### Deploiement sur le PC cible + +```bash +sshpass -p 'loli' scp -o StrictHostKeyChecking=no \ + target/x86_64-pc-windows-gnu/release/rpa-agent.exe \ + dom@192.168.1.11:"C:\\rpa_vision\\rpa-agent.exe" +``` ## Configuration @@ -57,74 +63,39 @@ Le binaire release se trouve dans `target/release/rpa-agent` (Linux) ou | `RPA_CAPTURE_PORT` | `5006` | Port du serveur de capture | | `RPA_HEARTBEAT_INTERVAL` | `5` | Intervalle heartbeat (secondes) | | `RPA_JPEG_QUALITY` | `85` | Qualite JPEG (1-100) | - -## Execution - -```bash -# Avec les defauts (serveur local) -./target/release/rpa-agent - -# Vers un serveur distant -RPA_SERVER_URL=http://192.168.1.10:5005/api/v1 ./target/release/rpa-agent - -# Avec un identifiant machine specifique -RPA_MACHINE_ID=pc_bureau_windows ./target/release/rpa-agent -``` - -## API du serveur de capture (port 5006) - -### GET /capture -Retourne un screenshot frais en JSON : -```json -{ - "image": "", - "width": 1920, - "height": 1080, - "format": "jpeg", - "source": "rust_agent", - "capture_ms": 42 -} -``` - -### GET /health -```json -{"status": "ok", "agent": "rust", "version": "0.1.0-rust"} -``` - -### POST /file-action -Actions fichiers sur la machine locale : -```json -{"action": "file_list_dir", "params": {"path": "C:\\Users\\dom\\Documents"}} -{"action": "file_create_dir", "params": {"path": "C:\\Users\\dom\\Documents\\tri"}} -{"action": "file_move", "params": {"source": "...", "destination": "..."}} -{"action": "file_copy", "params": {"source": "...", "destination": "..."}} -{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\Users\\dom\\Downloads"}} -``` +| `RPA_BLUR_SENSITIVE` | `true` | Flouter les zones sensibles | +| `RPA_LOG_RETENTION_DAYS` | `180` | Retention des logs (jours) | +| `RPA_CHAT_PORT` | `5004` | Port du serveur de chat | ## Architecture ``` src/ -├── main.rs — Point d'entree, 3 threads (heartbeat, replay, serveur) -├── config.rs — Configuration (env vars + defauts) -├── capture.rs — Capture ecran (xcap), encodage JPEG, hash perceptuel -├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat) -├── replay.rs — Boucle de polling replay avec backoff -├── executor.rs — Execution actions (click, type, key_combo, scroll, wait) -└── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action) +├── main.rs — Orchestrateur, 7 threads (heartbeat, replay, serveur, health, recorder, chat, tray) +├── config.rs — Configuration (env vars + defauts) +├── state.rs — Etat partage thread-safe (AtomicBool, Mutex) +├── capture.rs — Capture ecran (xcap), JPEG, hash perceptuel +├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat) +├── replay.rs — Boucle de polling replay avec notifications +├── executor.rs — Execution actions (click, type, key_combo, scroll, wait) +├── visual.rs — Resolution visuelle des cibles via le serveur +├── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action) +├── tray.rs — Icone systray + menu contextuel (tray-icon, winit) +├── notifications.rs — Notifications toast Windows (winrt-notification) +├── chat.rs — Fenetre de chat WebView2 (wry) +├── recorder.rs — Capture evenements souris/clavier (rdev) +└── blur.rs — Floutage zones sensibles (detection + box blur) ``` -## Compatibilite serveur +## Taille du binaire -Cet agent est compatible avec le serveur streaming existant (`agent_v0/server_v1/api_stream.py`, port 5005). +| Configuration | Taille | +|---|---| +| Release (LTO + strip + opt-level z) | **2.4 Mo** | +| Python equivalent (venv + packages) | ~200 Mo | -Endpoints utilises : -- `POST /api/v1/traces/stream/image` — envoi heartbeat screenshot -- `GET /api/v1/traces/stream/replay/next` — poll action replay -- `POST /api/v1/traces/stream/replay/result` — rapport resultat replay +## Compatibilite -## Phases suivantes - -- **Phase 2** : Systray + notifications (tray-icon, winrt-notification) -- **Phase 3** : Fenetre de chat (wry/WebView2) -- **Phase 4** : Parite complete (floutage, capture evenements rdev) +- **OS** : Windows 10/11 (systray, notifications, chat WebView2) +- **Fallback Linux** : mode console (heartbeat, replay, serveur) +- **Serveur** : compatible api_stream.py (port 5005) diff --git a/agent_rust/src/blur.rs b/agent_rust/src/blur.rs new file mode 100644 index 000000000..0f9da5a05 --- /dev/null +++ b/agent_rust/src/blur.rs @@ -0,0 +1,340 @@ +//! Floutage des zones sensibles dans les captures d'ecran. +//! +//! Detecte les champs de saisie (zones claires rectangulaires) et applique +//! un flou gaussien pour proteger les donnees sensibles (mots de passe, etc.). +//! Equivalent de agent_v1/vision/blur_sensitive.py. +//! +//! Algorithme : +//! 1. Conversion en niveaux de gris +//! 2. Seuillage binaire (detecter les zones claires = champs de saisie) +//! 3. Detection de contours rectangulaires > 50px de large +//! 4. Application d'un flou gaussien sur les zones detectees +//! +//! Utilise le crate image pour le traitement et imageproc pour le flou. + +use image::{DynamicImage, GrayImage, Rgba, RgbaImage}; + +/// Seuil de luminosite pour detecter les champs de saisie (0-255). +/// Les zones plus claires que ce seuil sont considerees comme des champs. +const BRIGHTNESS_THRESHOLD: u8 = 220; + +/// Largeur minimale d'un champ de saisie detecte (en pixels). +const MIN_FIELD_WIDTH: u32 = 50; + +/// Hauteur minimale d'un champ de saisie detecte (en pixels). +const MIN_FIELD_HEIGHT: u32 = 15; + +/// Hauteur maximale d'un champ de saisie (evite de flouter l'ecran entier). +const MAX_FIELD_HEIGHT: u32 = 80; + +/// Largeur maximale d'un champ (evite les faux positifs sur grandes zones blanches). +const MAX_FIELD_WIDTH: u32 = 800; + +/// Intensite du flou gaussien (sigma). +const BLUR_SIGMA: f32 = 10.0; + +/// Rectangle representant une zone a flouter. +#[derive(Debug, Clone)] +pub struct BlurRegion { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +/// Detecte les champs de saisie dans une image et les floute. +/// +/// Retourne l'image modifiee avec les zones sensibles floutees. +/// Si aucun champ n'est detecte, retourne l'image inchangee. +pub fn blur_sensitive_fields(img: &DynamicImage) -> DynamicImage { + let regions = detect_input_fields(img); + + if regions.is_empty() { + return img.clone(); + } + + println!( + "[BLUR] {} zone(s) sensible(s) detectee(s) — floutage...", + regions.len() + ); + + let mut result = img.to_rgba8(); + + for region in ®ions { + blur_region(&mut result, region); + } + + DynamicImage::ImageRgba8(result) +} + +/// Detecte les champs de saisie (zones claires rectangulaires). +/// +/// Algorithme simplifie : +/// 1. Convertir en niveaux de gris +/// 2. Seuillage binaire +/// 3. Scanner les lignes horizontales pour trouver les series de pixels clairs +/// 4. Regrouper les series adjacentes en rectangles +pub fn detect_input_fields(img: &DynamicImage) -> Vec { + let gray = img.to_luma8(); + let (width, height) = gray.dimensions(); + let mut regions = Vec::new(); + + // Creer une image binaire (seuillage) + let binary = threshold_image(&gray, BRIGHTNESS_THRESHOLD); + + // Scanner par bandes horizontales pour detecter les champs + // On cherche des sequences continues de pixels blancs sur plusieurs lignes + let mut y = 0; + while y < height { + // Pour chaque ligne, trouver les segments horizontaux blancs + let segments = find_white_segments(&binary, y, width); + + for (seg_start, seg_end) in &segments { + let seg_width = seg_end - seg_start; + if seg_width < MIN_FIELD_WIDTH || seg_width > MAX_FIELD_WIDTH { + continue; + } + + // Verifier combien de lignes consecutives partagent ce segment + let field_height = count_vertical_extent( + &binary, + *seg_start, + *seg_end, + y, + height, + ); + + if field_height >= MIN_FIELD_HEIGHT && field_height <= MAX_FIELD_HEIGHT { + // Verifier que cette region ne chevauche pas une region existante + let new_region = BlurRegion { + x: *seg_start, + y, + width: seg_width, + height: field_height, + }; + + if !overlaps_existing(®ions, &new_region) { + regions.push(new_region); + } + } + } + + // Avancer de la hauteur du dernier champ detecte, ou de 1 ligne + y += 1; + } + + // Deduplication : fusionner les regions tres proches + merge_close_regions(&mut regions); + + regions +} + +/// Applique un seuillage binaire simple. +fn threshold_image(gray: &GrayImage, threshold: u8) -> GrayImage { + let (width, height) = gray.dimensions(); + let mut binary = GrayImage::new(width, height); + + for y in 0..height { + for x in 0..width { + let pixel = gray.get_pixel(x, y).0[0]; + if pixel >= threshold { + binary.put_pixel(x, y, image::Luma([255])); + } else { + binary.put_pixel(x, y, image::Luma([0])); + } + } + } + + binary +} + +/// Trouve les segments horizontaux de pixels blancs sur une ligne. +fn find_white_segments(binary: &GrayImage, y: u32, width: u32) -> Vec<(u32, u32)> { + let mut segments = Vec::new(); + let mut in_segment = false; + let mut seg_start = 0u32; + + for x in 0..width { + let is_white = binary.get_pixel(x, y).0[0] > 128; + + if is_white && !in_segment { + seg_start = x; + in_segment = true; + } else if !is_white && in_segment { + segments.push((seg_start, x)); + in_segment = false; + } + } + + if in_segment { + segments.push((seg_start, width)); + } + + segments +} + +/// Compte le nombre de lignes consecutives ou le segment est blanc. +fn count_vertical_extent( + binary: &GrayImage, + seg_start: u32, + seg_end: u32, + start_y: u32, + max_y: u32, +) -> u32 { + let mut count = 0u32; + let check_width = seg_end - seg_start; + let threshold = (check_width as f64 * 0.7) as u32; // 70% doivent etre blancs + + for y in start_y..max_y.min(start_y + MAX_FIELD_HEIGHT + 5) { + let mut white_count = 0u32; + for x in seg_start..seg_end { + if binary.get_pixel(x, y).0[0] > 128 { + white_count += 1; + } + } + + if white_count >= threshold { + count += 1; + } else { + break; + } + } + + count +} + +/// Verifie si une region chevauche une region existante. +fn overlaps_existing(regions: &[BlurRegion], new_region: &BlurRegion) -> bool { + for region in regions { + let x_overlap = new_region.x < region.x + region.width + && new_region.x + new_region.width > region.x; + let y_overlap = new_region.y < region.y + region.height + && new_region.y + new_region.height > region.y; + + if x_overlap && y_overlap { + return true; + } + } + false +} + +/// Fusionne les regions tres proches (< 10px de distance). +fn merge_close_regions(regions: &mut Vec) { + if regions.len() < 2 { + return; + } + + // Tri par position (y, puis x) + regions.sort_by(|a, b| a.y.cmp(&b.y).then(a.x.cmp(&b.x))); + + let mut merged = Vec::new(); + let mut current = regions[0].clone(); + + for region in regions.iter().skip(1) { + let x_close = (current.x + current.width + 10 >= region.x) + && (region.x + region.width + 10 >= current.x); + let y_close = (current.y + current.height + 5 >= region.y) + && (region.y + region.height + 5 >= current.y); + + if x_close && y_close { + // Fusionner + let min_x = current.x.min(region.x); + let min_y = current.y.min(region.y); + let max_x = (current.x + current.width).max(region.x + region.width); + let max_y = (current.y + current.height).max(region.y + region.height); + + current = BlurRegion { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }; + } else { + merged.push(current); + current = region.clone(); + } + } + merged.push(current); + + *regions = merged; +} + +/// Applique un flou gaussien sur une region de l'image. +/// +/// Implementation simplifiee : box blur avec plusieurs passes +/// (approximation du gaussien, plus rapide que le vrai gaussien). +fn blur_region(img: &mut RgbaImage, region: &BlurRegion) { + let (img_w, img_h) = img.dimensions(); + + // Borner la region aux dimensions de l'image + let x_start = region.x.min(img_w); + let y_start = region.y.min(img_h); + let x_end = (region.x + region.width).min(img_w); + let y_end = (region.y + region.height).min(img_h); + + if x_start >= x_end || y_start >= y_end { + return; + } + + let radius = BLUR_SIGMA as u32; + let kernel_size = (radius * 2 + 1) as i32; + let kernel_area = (kernel_size * kernel_size) as u32; + + // Box blur : moyenne des pixels dans un carre de rayon `radius` + // On fait 3 passes pour approximer un flou gaussien + for _pass in 0..3 { + // Copier les pixels de la region dans un buffer temporaire + let reg_w = (x_end - x_start) as usize; + let reg_h = (y_end - y_start) as usize; + let mut buffer: Vec<[u8; 4]> = Vec::with_capacity(reg_w * reg_h); + + for y in y_start..y_end { + for x in x_start..x_end { + buffer.push(img.get_pixel(x, y).0); + } + } + + // Appliquer le box blur + for y in y_start..y_end { + for x in x_start..x_end { + let mut sum_r = 0u32; + let mut sum_g = 0u32; + let mut sum_b = 0u32; + let mut count = 0u32; + + for ky in -(radius as i32)..=(radius as i32) { + for kx in -(radius as i32)..=(radius as i32) { + let sx = x as i32 + kx; + let sy = y as i32 + ky; + + if sx >= x_start as i32 + && sx < x_end as i32 + && sy >= y_start as i32 + && sy < y_end as i32 + { + let bx = (sx - x_start as i32) as usize; + let by = (sy - y_start as i32) as usize; + let pixel = buffer[by * reg_w + bx]; + sum_r += pixel[0] as u32; + sum_g += pixel[1] as u32; + sum_b += pixel[2] as u32; + count += 1; + } + } + } + + if count > 0 { + let pixel = Rgba([ + (sum_r / count) as u8, + (sum_g / count) as u8, + (sum_b / count) as u8, + 255, + ]); + img.put_pixel(x, y, pixel); + } + } + } + } + + let _ = kernel_area; // suppress unused warning +} diff --git a/agent_rust/src/chat.rs b/agent_rust/src/chat.rs new file mode 100644 index 000000000..68a56d209 --- /dev/null +++ b/agent_rust/src/chat.rs @@ -0,0 +1,277 @@ +//! Fenetre de chat WebView2 (wry). +//! +//! Ouvre une fenetre WebView2 qui charge l'interface de chat du serveur +//! (http://{server}:5004/chat). Plus simple et plus riche que l'approche +//! tkinter Python — on reutilise directement le frontend web existant. +//! +//! Equivalent de agent_v1/ui/chat_window.py (mais beaucoup plus simple). +//! +//! Sur Windows : utilise wry (crate Tauri) qui instancie Edge WebView2. +//! Sur les autres OS : pas de fenetre de chat (log en console). + +use crate::config::Config; +use crate::state::AgentState; +use std::sync::Arc; + +/// URL du serveur de chat (port 5004 par defaut). +fn chat_url(config: &Config) -> String { + config.chat_url() +} + +/// HTML de fallback affiche quand le serveur est indisponible. +#[allow(dead_code)] +const FALLBACK_HTML: &str = r#" + + + + + + +
🔌
+

Connexion au serveur requise

+

+ Le serveur de chat n'est pas accessible. + Verifiez que le serveur RPA Vision est demarre. +

+ +

+ Lea Agent v0.2.0 (Rust) - IA +

+ +"#; + +/// Lance la fenetre de chat dans un thread dedie. +/// +/// Sur Windows : ouvre un WebView2 qui charge l'URL du chat. +/// La fenetre peut etre masquee/affichee via l'etat partage. +/// Sur les autres OS : ne fait rien. +pub fn start_chat_thread(config: Arc, state: Arc) { + std::thread::Builder::new() + .name("chat-window".to_string()) + .spawn(move || { + chat_window_loop(&config, &state); + }) + .expect("Impossible de demarrer le thread chat"); +} + +/// Boucle de la fenetre de chat (Windows). +/// +/// Attend que l'etat chat_visible passe a true, puis ouvre la fenetre. +/// Quand la fenetre est fermee, remet chat_visible a false. +#[cfg(windows)] +fn chat_window_loop(config: &Config, state: &AgentState) { + println!("[CHAT] Thread chat demarre — en attente d'activation"); + + loop { + // Attendre que le chat soit demande + while !state.chat_visible.load(std::sync::atomic::Ordering::SeqCst) { + if !state.is_running() { + println!("[CHAT] Arret du thread chat"); + return; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + + println!("[CHAT] Ouverture de la fenetre de chat..."); + + let url = chat_url(config); + println!("[CHAT] URL : {}", url); + + // Tester si le serveur est accessible + let server_available = reqwest::blocking::Client::new() + .get(&url) + .timeout(std::time::Duration::from_secs(3)) + .send() + .map(|r| r.status().is_success() || r.status().is_redirection()) + .unwrap_or(false); + + // Ouvrir le WebView2 dans une fenetre dediee + // On utilise un EventLoop winit separe pour la fenetre de chat + match open_chat_window(&url, server_available) { + Ok(_) => { + println!("[CHAT] Fenetre de chat fermee"); + } + Err(e) => { + eprintln!("[CHAT] Erreur ouverture fenetre : {}", e); + } + } + + // La fenetre a ete fermee, desactiver le flag + state + .chat_visible + .store(false, std::sync::atomic::Ordering::SeqCst); + + // Petit delai avant de pouvoir reouvrir + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + +/// Ouvre la fenetre de chat avec wry WebView2. +/// +/// Cree une fenetre native via la Win32 API et y attache un WebView2. +/// La fenetre fait 520x720 et est positionnee en bas a droite de l'ecran. +/// +/// Note: wry 0.48 attend un objet implementant HasWindowHandle. +/// On utilise un wrapper HWND pour satisfaire ce trait. +#[cfg(windows)] +fn open_chat_window(url: &str, server_available: bool) -> Result<(), String> { + use wry::WebViewBuilder; + use raw_window_handle::{RawWindowHandle, WindowHandle, Win32WindowHandle}; + use windows_sys::Win32::UI::WindowsAndMessaging::*; + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; + + // Obtenir les dimensions de l'ecran + let (screen_w, screen_h) = unsafe { + (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)) + }; + + let win_w = 520; + let win_h = 720; + let win_x = screen_w - win_w - 20; + let win_y = screen_h - win_h - 60; + + // Creer la classe de fenetre + let class_name: Vec = "LeaChatWindow\0".encode_utf16().collect(); + let window_title: Vec = "Lea - Chat IA\0".encode_utf16().collect(); + + unsafe { + let h_instance = GetModuleHandleW(std::ptr::null()); + + let wc = WNDCLASSW { + style: 0, + lpfnWndProc: Some(chat_wnd_proc), + cbClsExtra: 0, + cbWndExtra: 0, + hInstance: h_instance, + hIcon: std::ptr::null_mut(), + hCursor: LoadCursorW(std::ptr::null_mut(), IDC_ARROW), + hbrBackground: 6 as _, // COLOR_WINDOW + 1 + lpszMenuName: std::ptr::null(), + lpszClassName: class_name.as_ptr(), + }; + + RegisterClassW(&wc); + + let hwnd = CreateWindowExW( + WS_EX_TOOLWINDOW, + class_name.as_ptr(), + window_title.as_ptr(), + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + win_x, + win_y, + win_w, + win_h, + std::ptr::null_mut(), + std::ptr::null_mut(), + h_instance, + std::ptr::null(), + ); + + if hwnd.is_null() { + return Err("Impossible de creer la fenetre de chat".to_string()); + } + + // Creer un wrapper HasWindowHandle pour le HWND + let mut win32_handle = Win32WindowHandle::new( + std::num::NonZero::new(hwnd as isize) + .ok_or("HWND invalide")?, + ); + win32_handle.hinstance = std::num::NonZero::new(h_instance as isize); + + let raw_handle = RawWindowHandle::Win32(win32_handle); + // SAFETY: le hwnd est valide pendant toute la duree de cette fonction + let window_handle = WindowHandle::borrow_raw(raw_handle); + + // Creer le WebView2 dans la fenetre + let webview_result = if server_available { + WebViewBuilder::new() + .with_url(url) + .build_as_child(&window_handle) + } else { + WebViewBuilder::new() + .with_html(FALLBACK_HTML) + .build_as_child(&window_handle) + }; + + match webview_result { + Ok(_webview) => { + ShowWindow(hwnd, SW_SHOW); + + // Boucle de messages Windows + let mut msg: MSG = std::mem::zeroed(); + while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + Ok(()) + } + Err(e) => { + DestroyWindow(hwnd); + Err(format!("Erreur creation WebView2 : {}", e)) + } + } + } +} + +/// Procedure de fenetre Win32 pour la fenetre de chat. +#[cfg(windows)] +unsafe extern "system" fn chat_wnd_proc( + hwnd: windows_sys::Win32::Foundation::HWND, + msg: u32, + wparam: windows_sys::Win32::Foundation::WPARAM, + lparam: windows_sys::Win32::Foundation::LPARAM, +) -> windows_sys::Win32::Foundation::LRESULT { + use windows_sys::Win32::UI::WindowsAndMessaging::*; + + match msg { + WM_CLOSE => { + ShowWindow(hwnd, SW_HIDE); + PostQuitMessage(0); + 0 + } + WM_DESTROY => { + PostQuitMessage(0); + 0 + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +/// Version non-Windows : pas de fenetre de chat. +#[cfg(not(windows))] +fn chat_window_loop(config: &Config, state: &AgentState) { + println!("[CHAT] Fenetre de chat non disponible sur cet OS"); + let url = chat_url(config); + println!("[CHAT] Pour acceder au chat, ouvrez : {}", url); + + while state.is_running() { + std::thread::sleep(std::time::Duration::from_millis(1000)); + } +} diff --git a/agent_rust/src/config.rs b/agent_rust/src/config.rs index 512d523fb..5c2c5d21e 100644 --- a/agent_rust/src/config.rs +++ b/agent_rust/src/config.rs @@ -1,23 +1,23 @@ //! Configuration de l'agent RPA. //! -//! Paramètres chargés depuis les variables d'environnement ou valeurs par défaut. +//! Parametres charges depuis les variables d'environnement ou valeurs par defaut. //! Compatible avec la configuration Python (agent_v1/config.py). use std::env; /// Version de l'agent Rust -pub const AGENT_VERSION: &str = "0.1.0-rust"; +pub const AGENT_VERSION: &str = "0.2.0-rust"; -/// Configuration complète de l'agent +/// Configuration complete de l'agent #[derive(Debug, Clone)] pub struct Config { /// URL de base du serveur streaming (ex: http://192.168.1.10:5005/api/v1) pub server_url: String, - /// Identifiant unique de la machine (hostname_os par défaut) + /// Identifiant unique de la machine (hostname_os par defaut) pub machine_id: String, - /// Port du mini-serveur HTTP de capture (défaut: 5006) + /// Port du mini-serveur HTTP de capture (defaut: 5006) pub capture_port: u16, /// Intervalle du heartbeat en secondes @@ -26,19 +26,31 @@ pub struct Config { /// Intervalle de polling replay en secondes pub replay_poll_interval_s: f64, - /// Qualité JPEG pour les screenshots envoyés (1-100) + /// Qualite JPEG pour les screenshots envoyes (1-100) pub jpeg_quality: u8, + + /// Flouter les zones sensibles dans les captures (defaut: true) + pub blur_sensitive: bool, + + /// Retention des logs en jours (Article 12, Reglement IA, defaut: 180) + pub log_retention_days: u32, + + /// Port du serveur de chat (defaut: 5004) + pub chat_port: u16, } impl Config { /// Charge la configuration depuis les variables d'environnement. /// - /// Variables supportées : - /// - `RPA_SERVER_URL` : URL du serveur (défaut: http://localhost:5005/api/v1) - /// - `RPA_MACHINE_ID` : Identifiant machine (défaut: hostname_os) - /// - `RPA_CAPTURE_PORT` : Port du serveur de capture (défaut: 5006) - /// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (défaut: 5) - /// - `RPA_JPEG_QUALITY` : Qualité JPEG (défaut: 85) + /// Variables supportees : + /// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1) + /// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os) + /// - `RPA_CAPTURE_PORT` : Port du serveur de capture (defaut: 5006) + /// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (defaut: 5) + /// - `RPA_JPEG_QUALITY` : Qualite JPEG (defaut: 85) + /// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true) + /// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180) + /// - `RPA_CHAT_PORT` : Port du serveur de chat (defaut: 5004) pub fn from_env() -> Self { let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| { let host = hostname::get() @@ -72,6 +84,20 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(85); + let blur_sensitive = env::var("RPA_BLUR_SENSITIVE") + .map(|v| v != "0" && v.to_lowercase() != "false") + .unwrap_or(true); + + let log_retention_days = env::var("RPA_LOG_RETENTION_DAYS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(180); + + let chat_port = env::var("RPA_CHAT_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5004); + Config { server_url, machine_id, @@ -79,6 +105,9 @@ impl Config { heartbeat_interval_s, replay_poll_interval_s: 1.0, jpeg_quality, + blur_sensitive, + log_retention_days, + chat_port, } } @@ -96,15 +125,36 @@ impl Config { pub fn agent_session_id(&self) -> String { format!("agent_{}", self.machine_id) } + + /// URL du serveur de chat. + pub fn chat_url(&self) -> String { + // Extraire le host du server_url + let base = &self.server_url; + if let Some(host_start) = base.find("://") { + let after_scheme = &base[host_start + 3..]; + if let Some(colon_pos) = after_scheme.find(':') { + let host = &after_scheme[..colon_pos]; + return format!( + "http://{}:{}/chat?machine_id={}", + host, self.chat_port, self.machine_id + ); + } + } + format!( + "http://localhost:{}/chat?machine_id={}", + self.chat_port, self.machine_id + ) + } } impl std::fmt::Display for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, - "Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {} }}", + "Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, blur: {}, log_retention: {}j, chat_port: {} }}", self.server_url, self.machine_id, self.capture_port, - self.heartbeat_interval_s, self.jpeg_quality + self.heartbeat_interval_s, self.jpeg_quality, + self.blur_sensitive, self.log_retention_days, self.chat_port, ) } } diff --git a/agent_rust/src/main.rs b/agent_rust/src/main.rs index 9ec449b77..3970accbf 100644 --- a/agent_rust/src/main.rs +++ b/agent_rust/src/main.rs @@ -1,41 +1,64 @@ -//! Agent RPA Vision — Phase 1 (headless) +//! Agent RPA Vision — Phases 1-5 (parite complete) //! -//! Point d'entree principal. Demarre 3 threads : -//! 1. Heartbeat loop : capture + envoi toutes les 5s (avec dedup par hash) -//! 2. Replay poll loop : poll toutes les 1s, execute les actions -//! 3. Capture HTTP server : port 5006 pour les captures a la demande +//! Point d'entree principal. Architecture multi-threads : +//! +//! - Thread principal : boucle d'evenements systray (Windows) ou attente console (Linux) +//! - Thread heartbeat : capture + envoi toutes les 5s (avec dedup par hash) +//! - Thread replay : poll toutes les 1s, execute les actions +//! - Thread serveur : HTTP port 5006 pour les captures a la demande +//! - Thread recorder : capture evenements souris/clavier (quand enregistrement actif) +//! - Thread chat : fenetre WebView2 (Windows, a la demande) +//! - Thread health : verification connexion serveur (toutes les 30s) +//! +//! Le thread principal gere le systray sur Windows via winit. +//! Sur Linux, le thread principal attend Ctrl+C (mode console). //! //! Configuration via variables d'environnement ou valeurs par defaut. //! Compatible avec le serveur streaming existant (api_stream.py, port 5005). +#[allow(dead_code)] +mod blur; mod capture; +mod chat; mod config; mod executor; mod network; +#[allow(dead_code)] +mod notifications; +mod recorder; mod replay; mod server; +#[allow(dead_code)] +mod state; +mod tray; mod visual; use config::Config; use reqwest::blocking::Client; -use std::sync::atomic::{AtomicBool, Ordering}; +use state::AgentState; +use std::sync::Arc; use std::thread; use std::time::Duration; -/// Flag global pour l'arret propre (Ctrl+C) -static RUNNING: AtomicBool = AtomicBool::new(true); - fn main() { + // Initialiser le logging + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("info"), + ) + .format_timestamp_secs() + .init(); + let config = Config::from_env(); + let config = Arc::new(config); + + // Etat partage thread-safe + let state = AgentState::new(); // Banniere de demarrage print_banner(&config); // Handler Ctrl+C pour arret propre - // On utilise le flag global RUNNING (AtomicBool) — le handler SIGINT - // est installe via un thread qui bloque sur un pipe/signal. - // Approche simple : polling du flag depuis tous les threads. - install_ctrlc_handler(); + install_ctrlc_handler(state.clone()); // Verifier que la capture d'ecran fonctionne print!("[MAIN] Test de capture d'ecran... "); @@ -50,19 +73,21 @@ fn main() { // Thread 1 : Heartbeat loop let hb_config = config.clone(); - let heartbeat_thread = thread::Builder::new() + let hb_state = state.clone(); + let _heartbeat_thread = thread::Builder::new() .name("heartbeat".to_string()) .spawn(move || { - heartbeat_loop(&hb_config); + heartbeat_loop(&hb_config, &hb_state); }) .expect("Impossible de demarrer le thread heartbeat"); // Thread 2 : Replay poll loop let rp_config = config.clone(); + let rp_state = state.clone(); let _replay_thread = thread::Builder::new() .name("replay".to_string()) .spawn(move || { - replay::replay_poll_loop(&rp_config); + replay::replay_poll_loop(&rp_config, &rp_state); }) .expect("Impossible de demarrer le thread replay"); @@ -75,31 +100,46 @@ fn main() { }) .expect("Impossible de demarrer le thread serveur"); - println!("\n[MAIN] Agent operationnel. Appuyez sur Ctrl+C pour quitter.\n"); + // Thread 4 : Health check (verification connexion serveur) + let hc_config = config.clone(); + let hc_state = state.clone(); + let _health_thread = thread::Builder::new() + .name("health-check".to_string()) + .spawn(move || { + health_check_loop(&hc_config, &hc_state); + }) + .expect("Impossible de demarrer le thread health check"); - // Bloquer le thread principal en attendant Ctrl+C - while RUNNING.load(Ordering::SeqCst) { - thread::sleep(Duration::from_millis(500)); - } + // Thread 5 : Recorder (capture evenements — inactif jusqu'a enregistrement) + let rec_config = config.clone(); + let rec_state = state.clone(); + let _recorder_rx = recorder::start_recorder(rec_config, rec_state); + // Thread 6 : Chat window (WebView2, a la demande) + let chat_config = config.clone(); + let chat_state = state.clone(); + chat::start_chat_thread(chat_config, chat_state); + + println!("\n[MAIN] Agent operationnel — tous les threads demarres.\n"); + + // Thread principal : boucle systray (Windows) ou attente console (Linux) + // Le systray bloque le thread principal (necessaire pour la message pump Windows) + tray::run_tray_loop(config.clone(), state.clone()); + + // Si on arrive ici, l'agent doit s'arreter println!("\n[MAIN] Arret en cours..."); + state.request_shutdown(); - // Attendre le thread heartbeat (les autres sont daemon-like) - let _ = heartbeat_thread.join(); + // Laisser le temps aux threads de se terminer + thread::sleep(Duration::from_millis(500)); println!("[MAIN] Agent arrete."); } -/// Installe un handler Ctrl+C qui met RUNNING a false. -/// -/// Sur Unix : intercepte SIGINT via un pipe auto-referent. -/// Sur Windows : sera ameliore en Phase 2 avec le crate windows. -fn install_ctrlc_handler() { - // Approche portable : un thread qui attend sur stdin/signal - // En pratique, on utilise un pipe trick simple +/// Installe un handler Ctrl+C qui met l'etat a "arret demande". +fn install_ctrlc_handler(state: Arc) { #[cfg(unix)] { - // Creer un pipe pour la notification let mut fds = [0i32; 2]; unsafe { if libc::pipe(fds.as_mut_ptr()) != 0 { @@ -107,13 +147,21 @@ fn install_ctrlc_handler() { return; } - // Installer le signal handler qui ecrit dans le pipe static mut WRITE_FD: i32 = -1; WRITE_FD = fds[1]; + // Sauvegarder un pointeur vers l'etat dans une static + // pour pouvoir y acceder depuis le handler + static mut STATE_PTR: *const AgentState = std::ptr::null(); + STATE_PTR = Arc::as_ptr(&state); + extern "C" fn sigint_handler(_sig: i32) { unsafe { - RUNNING.store(false, Ordering::SeqCst); + if !STATE_PTR.is_null() { + (*STATE_PTR) + .running + .store(false, std::sync::atomic::Ordering::SeqCst); + } let buf = [1u8]; let _ = libc::write(WRITE_FD, buf.as_ptr() as *const _, 1); } @@ -125,15 +173,16 @@ fn install_ctrlc_handler() { #[cfg(not(unix))] { - // Sur Windows, on utilise un thread simple qui verifie periodiquement - // Le vrai handler sera SetConsoleCtrlHandler en Phase 2 - // Pour l'instant, Ctrl+C termine le process directement (comportement par defaut) + // Sur Windows, le systray gere l'arret via le menu "Quitter" + // Le handler console est un bonus pour le mode headless + let _ = state; } } /// Boucle de heartbeat : capture un screenshot toutes les N secondes /// et l'envoie au serveur si l'ecran a change. -fn heartbeat_loop(config: &Config) { +/// Applique le floutage des zones sensibles si active dans la config. +fn heartbeat_loop(config: &Config, state: &AgentState) { let client = Client::new(); let session_id = config.bg_session_id(); let mut last_hash: u64 = 0; @@ -144,34 +193,50 @@ fn heartbeat_loop(config: &Config) { session_id, config.heartbeat_interval_s ); - while RUNNING.load(Ordering::SeqCst) { + while state.is_running() { + // Verifier l'arret d'urgence + if state + .emergency_stop + .load(std::sync::atomic::Ordering::SeqCst) + { + thread::sleep(Duration::from_secs(1)); + continue; + } + // Capturer l'ecran match capture::capture_screenshot() { Some(img) => { // Deduplication par hash perceptuel let current_hash = capture::image_hash(&img); if current_hash == last_hash { - // Ecran identique, on skip l'envoi thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); continue; } last_hash = current_hash; + // Appliquer le floutage des zones sensibles si active + let final_img = if config.blur_sensitive { + blur::blur_sensitive_fields(&img) + } else { + img + }; + // Encoder en JPEG - let jpeg_bytes = capture::screenshot_to_jpeg_bytes(&img, config.jpeg_quality); + let jpeg_bytes = + capture::screenshot_to_jpeg_bytes(&final_img, config.jpeg_quality); if jpeg_bytes.is_empty() { thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); continue; } // Envoyer au serveur - let success = network::send_heartbeat(&client, config, &jpeg_bytes, &session_id); + let success = + network::send_heartbeat(&client, config, &jpeg_bytes, &session_id); if success { consecutive_errors = 0; } else { consecutive_errors += 1; if consecutive_errors == 1 || consecutive_errors % 12 == 0 { - // Log seulement la premiere erreur et toutes les minutes eprintln!( "[HEARTBEAT] {} erreur(s) consecutives", consecutive_errors @@ -180,8 +245,6 @@ fn heartbeat_loop(config: &Config) { } } None => { - // Pas de capture possible (pas de display, etc.) - // On attend plus longtemps pour ne pas spammer les logs thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2)); continue; } @@ -193,16 +256,58 @@ fn heartbeat_loop(config: &Config) { println!("[HEARTBEAT] Boucle arretee."); } +/// Boucle de health check : verifie la connexion au serveur toutes les 30s. +/// Met a jour l'etat de connexion dans AgentState. +fn health_check_loop(config: &Config, state: &AgentState) { + let client = Client::new(); + let check_interval = Duration::from_secs(30); + let timeout = Duration::from_secs(5); + + println!("[HEALTH] Boucle health check demarree (intervalle=30s)"); + + while state.is_running() { + let url = format!("{}/stats", config.server_url); + let connected = client + .get(&url) + .timeout(timeout) + .send() + .map(|r| r.status().is_success()) + .unwrap_or(false); + + let was_connected = state.connected.load(std::sync::atomic::Ordering::SeqCst); + state.set_connected(connected); + + // Notifier si le statut a change + if connected != was_connected { + notifications::connection_changed(connected); + } + + thread::sleep(check_interval); + } + + println!("[HEALTH] Boucle arretee."); +} + /// Affiche la banniere de demarrage. fn print_banner(config: &Config) { println!("======================================================"); - println!(" RPA Vision Agent v{} (Rust)", config::AGENT_VERSION); - println!(" Phase 1 -- Headless"); + println!( + " RPA Vision Agent v{} (Rust)", + config::AGENT_VERSION + ); + println!(" Phases 1-5 — Parite complete"); println!("------------------------------------------------------"); - println!(" Machine : {}", config.machine_id); - println!(" Serveur : {}", config.server_url); - println!(" Capture : port {}", config.capture_port); - println!(" Heartbeat: toutes les {}s", config.heartbeat_interval_s); - println!(" JPEG : qualite {}", config.jpeg_quality); + println!(" Machine : {}", config.machine_id); + println!(" Serveur : {}", config.server_url); + println!(" Capture : port {}", config.capture_port); + println!(" Chat : port {}", config.chat_port); + println!(" Heartbeat : toutes les {}s", config.heartbeat_interval_s); + println!(" JPEG : qualite {}", config.jpeg_quality); + println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" }); + println!(" Logs : retention {} jours", config.log_retention_days); println!("======================================================"); + println!(); + println!(" [IA] Cet agent utilise l'intelligence artificielle."); + println!(" Article 50 du Reglement europeen sur l'IA."); + println!(); } diff --git a/agent_rust/src/notifications.rs b/agent_rust/src/notifications.rs new file mode 100644 index 000000000..bd0aae3b1 --- /dev/null +++ b/agent_rust/src/notifications.rs @@ -0,0 +1,135 @@ +//! Notifications toast Windows. +//! +//! Affiche des notifications natives Windows via l'API WinRT (winrt-notification). +//! Equivalent de agent_v1/ui/notifications.py. +//! +//! Sur Linux/macOS : les notifications sont simplement affichees en console (log). +//! Le crate winrt-notification n'est disponible que sur Windows. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Intervalle minimum entre deux notifications identiques (en secondes). +/// Evite le spam de notifications si le meme evenement se repete. +const MIN_INTERVAL_SECS: u64 = 5; + +/// Timestamp de la derniere notification envoyee (rate limiting). +static LAST_NOTIFY_TIME: AtomicU64 = AtomicU64::new(0); + +/// Affiche une notification toast native. +/// +/// Sur Windows : utilise winrt-notification pour les toasts natifs. +/// Sur les autres OS : affiche en console. +/// Rate-limited : pas plus d'une notification toutes les 5 secondes. +pub fn notify(title: &str, message: &str) { + // Rate limiting + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let last = LAST_NOTIFY_TIME.load(Ordering::Relaxed); + if now - last < MIN_INTERVAL_SECS { + return; + } + LAST_NOTIFY_TIME.store(now, Ordering::Relaxed); + + // Log console dans tous les cas + println!("[NOTIFICATION] {} : {}", title, message); + + // Toast natif Windows + #[cfg(windows)] + { + notify_windows(title, message); + } +} + +/// Implementation Windows via winrt-notification. +#[cfg(windows)] +fn notify_windows(title: &str, message: &str) { + use winrt_notification::{Toast, Sound}; + + let result = Toast::new(Toast::POWERSHELL_APP_ID) + .title(title) + .text1(message) + .sound(Some(Sound::Default)) + .show(); + + if let Err(e) = result { + eprintln!("[NOTIFICATION] Erreur toast Windows : {:?}", e); + } +} + +// --- Notifications predefinies (equivalent Python) --- + +/// Notification de bienvenue au demarrage. +pub fn greet() { + notify( + "Lea - Assistant IA", + "Bonjour ! Lea est prete. (IA)\nJe peux observer et automatiser vos taches.", + ); +} + +/// Notification de debut de session d'enregistrement. +pub fn session_started(name: &str) { + notify( + "Enregistrement demarre", + &format!( + "C'est parti ! Je regarde et je memorise.\nSession : {}", + name + ), + ); +} + +/// Notification de fin de session d'enregistrement. +pub fn session_ended(actions_count: u32) { + notify( + "Enregistrement termine", + &format!( + "C'est note ! J'ai compris les {} etapes.", + actions_count + ), + ); +} + +/// Notification de debut de replay. +pub fn replay_started(name: &str) { + notify( + "Replay en cours", + &format!( + "Le systeme d'IA execute la tache...\nWorkflow : {}", + name + ), + ); +} + +/// Notification de fin de replay. +pub fn replay_finished(success: bool) { + if success { + notify("Replay termine", "C'est fait ! La tache a ete executee avec succes."); + } else { + notify( + "Replay echoue", + "Hmm, j'ai eu un souci. Verifiez le resultat.", + ); + } +} + +/// Notification de changement de connexion. +pub fn connection_changed(connected: bool) { + if connected { + notify("Connexion etablie", "Connectee au serveur RPA Vision."); + } else { + notify( + "Connexion perdue", + "Connexion au serveur perdue. Tentative de reconnexion...", + ); + } +} + +/// Notification d'arret d'urgence. +pub fn emergency_stop_activated() { + notify( + "ARRET D'URGENCE", + "Toutes les operations ont ete arretees immediatement.", + ); +} diff --git a/agent_rust/src/recorder.rs b/agent_rust/src/recorder.rs new file mode 100644 index 000000000..dedbe2355 --- /dev/null +++ b/agent_rust/src/recorder.rs @@ -0,0 +1,703 @@ +//! Capture d'evenements souris/clavier pour l'enregistrement de sessions. +//! +//! Utilise rdev pour intercepter les evenements globaux (sans focus). +//! Les evenements sont envoyes au serveur streaming via network.rs. +//! Equivalent de agent_v1/core/captor.py. +//! +//! Le recorder est actif uniquement quand state.recording == true. +//! Il capture : +//! - Clics souris (gauche, droit, double-clic) +//! - Saisie clavier (buffer de texte avec flush apres 500ms d'inactivite) +//! - Combos clavier (Ctrl+C, Alt+Tab, etc.) +//! +//! Sur les OS non-Windows, rdev fonctionne aussi (Linux via X11/evdev) +//! mais les tests doivent etre faits manuellement. + +use crate::capture; +use crate::config::Config; +use crate::state::AgentState; +use crossbeam_channel::{bounded, Receiver, Sender}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +/// Evenement capture et pret a etre envoye au serveur. +#[derive(Debug, Clone)] +pub enum CapturedEvent { + /// Clic souris (x_pct, y_pct, bouton, window_title) + Click { + x_pct: f64, + y_pct: f64, + button: String, + window_title: String, + }, + /// Double-clic (x_pct, y_pct, window_title) + DoubleClick { + x_pct: f64, + y_pct: f64, + window_title: String, + }, + /// Texte saisi (accumule via le buffer de frappe) + Text { + text: String, + x_pct: f64, + y_pct: f64, + }, + /// Combo clavier (ex: ["ctrl", "c"]) + KeyCombo { keys: Vec }, + /// Scroll (delta, x_pct, y_pct) + Scroll { + delta: i32, + x_pct: f64, + y_pct: f64, + }, +} + +/// Etat interne du recorder pour le buffer de frappe. +struct RecorderState { + /// Buffer de texte en cours (flush apres 500ms d'inactivite) + text_buffer: String, + /// Dernier timestamp de frappe (pour le flush timeout) + last_keystroke: Instant, + /// Position du curseur au debut de la saisie + text_start_x: f64, + text_start_y: f64, + /// Derniere position du clic (pour le double-clic) + last_click_time: Instant, + last_click_x: f64, + last_click_y: f64, + /// Modifieurs actuellement enfonces + ctrl_held: bool, + alt_held: bool, + shift_held: bool, + meta_held: bool, + /// Dimensions de l'ecran (pour normaliser les coordonnees) + screen_width: u32, + screen_height: u32, +} + +impl RecorderState { + fn new(screen_width: u32, screen_height: u32) -> Self { + Self { + text_buffer: String::new(), + last_keystroke: Instant::now(), + text_start_x: 0.0, + text_start_y: 0.0, + last_click_time: Instant::now() - Duration::from_secs(10), + last_click_x: 0.0, + last_click_y: 0.0, + ctrl_held: false, + alt_held: false, + shift_held: false, + meta_held: false, + screen_width, + screen_height, + } + } + + /// Normalise les coordonnees absolues en pourcentages (0.0-1.0). + fn normalize(&self, x: f64, y: f64) -> (f64, f64) { + if self.screen_width == 0 || self.screen_height == 0 { + return (0.0, 0.0); + } + ( + x / self.screen_width as f64, + y / self.screen_height as f64, + ) + } + + /// Un modifieur est-il enfonce ? + fn any_modifier_held(&self) -> bool { + self.ctrl_held || self.alt_held || self.meta_held + } +} + +/// Delai de flush du buffer de texte (ms). +const TEXT_FLUSH_DELAY_MS: u64 = 500; + +/// Seuil de distance pour considerer un double-clic (pixels). +const DOUBLE_CLICK_DIST_THRESHOLD: f64 = 10.0; + +/// Seuil de temps pour un double-clic (ms). +const DOUBLE_CLICK_TIME_MS: u64 = 400; + +/// Demarre le thread de capture d'evenements. +/// +/// Cree un canal crossbeam pour envoyer les evenements captures +/// vers le thread d'envoi reseau. Le listener rdev tourne dans +/// un thread dedie car il bloque (callback-based). +pub fn start_recorder( + config: Arc, + state: Arc, +) -> Receiver { + let (tx, rx) = bounded::(100); + + // Thread du listener rdev + let listener_state = state.clone(); + let listener_tx = tx.clone(); + thread::Builder::new() + .name("event-listener".to_string()) + .spawn(move || { + event_listener_loop(listener_tx, listener_state); + }) + .expect("Impossible de demarrer le thread listener"); + + // Thread de flush du buffer de texte + let flush_tx = tx; + let flush_state = state.clone(); + thread::Builder::new() + .name("text-flush".to_string()) + .spawn(move || { + text_flush_loop(flush_tx, flush_state); + }) + .expect("Impossible de demarrer le thread flush"); + + // Thread d'envoi des evenements captures vers le serveur + let send_state = state; + let send_rx = rx.clone(); + let send_config = config; + thread::Builder::new() + .name("event-sender".to_string()) + .spawn(move || { + event_sender_loop(send_rx, send_config, send_state); + }) + .expect("Impossible de demarrer le thread sender"); + + rx +} + +/// Boucle du listener rdev — capture les evenements souris/clavier globaux. +/// +/// rdev::listen est bloquant et appelle le callback pour chaque evenement. +/// On filtre et transforme les evenements pertinents, puis on les envoie +/// via le canal crossbeam. +fn event_listener_loop(tx: Sender, state: Arc) { + let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080)); + let rec_state = std::sync::Mutex::new(RecorderState::new(screen_w, screen_h)); + + println!( + "[RECORDER] Listener demarre (ecran {}x{})", + screen_w, screen_h + ); + + // rdev::listen prend un callback FnMut + let callback = move |event: rdev::Event| { + // Ne capturer que si l'enregistrement est actif + if !state.recording.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + + let mut rs = match rec_state.lock() { + Ok(s) => s, + Err(_) => return, + }; + + match event.event_type { + rdev::EventType::ButtonPress(button) => { + let btn_name = match button { + rdev::Button::Left => "left", + rdev::Button::Right => "right", + rdev::Button::Middle => "middle", + _ => return, + }; + + // Obtenir la position de la souris depuis l'evenement + // rdev ne fournit pas toujours les coordonnees dans ButtonPress, + // on utilise la derniere position connue via MouseMove. + // Pour simplifier, on capture la position courante du curseur. + let (mx, my) = get_cursor_position(); + let (x_pct, y_pct) = rs.normalize(mx, my); + + // Flush le buffer de texte avant le clic + if !rs.text_buffer.is_empty() { + let text_event = CapturedEvent::Text { + text: rs.text_buffer.clone(), + x_pct: rs.text_start_x, + y_pct: rs.text_start_y, + }; + let _ = tx.try_send(text_event); + rs.text_buffer.clear(); + } + + // Detection double-clic + let now = Instant::now(); + let dt = now.duration_since(rs.last_click_time); + let dx = (mx - rs.last_click_x).abs(); + let dy = (my - rs.last_click_y).abs(); + let dist = (dx * dx + dy * dy).sqrt(); + + if btn_name == "left" + && dt < Duration::from_millis(DOUBLE_CLICK_TIME_MS) + && dist < DOUBLE_CLICK_DIST_THRESHOLD + { + // Double-clic detecte + let event = CapturedEvent::DoubleClick { + x_pct, + y_pct, + window_title: get_active_window_title(), + }; + let _ = tx.try_send(event); + } else { + // Clic simple + let event = CapturedEvent::Click { + x_pct, + y_pct, + button: btn_name.to_string(), + window_title: get_active_window_title(), + }; + let _ = tx.try_send(event); + + // Incrementer le compteur d'actions + state.increment_actions(); + } + + rs.last_click_time = now; + rs.last_click_x = mx; + rs.last_click_y = my; + } + + rdev::EventType::KeyPress(key) => { + // Mettre a jour les modifieurs + match key { + rdev::Key::ControlLeft | rdev::Key::ControlRight => { + rs.ctrl_held = true; + return; + } + rdev::Key::Alt | rdev::Key::AltGr => { + rs.alt_held = true; + return; + } + rdev::Key::ShiftLeft | rdev::Key::ShiftRight => { + rs.shift_held = true; + return; + } + rdev::Key::MetaLeft | rdev::Key::MetaRight => { + rs.meta_held = true; + return; + } + _ => {} + } + + // Si un modifieur non-shift est enfonce, c'est un combo + if rs.any_modifier_held() { + let mut keys = Vec::new(); + if rs.ctrl_held { + keys.push("ctrl".to_string()); + } + if rs.alt_held { + keys.push("alt".to_string()); + } + if rs.meta_held { + keys.push("win".to_string()); + } + if rs.shift_held { + keys.push("shift".to_string()); + } + keys.push(rdev_key_to_string(key)); + + // Flush le buffer avant le combo + if !rs.text_buffer.is_empty() { + let text_event = CapturedEvent::Text { + text: rs.text_buffer.clone(), + x_pct: rs.text_start_x, + y_pct: rs.text_start_y, + }; + let _ = tx.try_send(text_event); + rs.text_buffer.clear(); + } + + let event = CapturedEvent::KeyCombo { keys }; + let _ = tx.try_send(event); + state.increment_actions(); + } else { + // Touche de saisie normale — ajouter au buffer + if let Some(c) = rdev_key_to_char(key) { + if rs.text_buffer.is_empty() { + let (mx, my) = get_cursor_position(); + let (x, y) = rs.normalize(mx, my); + rs.text_start_x = x; + rs.text_start_y = y; + } + rs.text_buffer.push(c); + rs.last_keystroke = Instant::now(); + } else { + // Touche speciale non-texte (Enter, Tab, etc.) + // Flush le buffer et envoyer comme combo simple + if !rs.text_buffer.is_empty() { + let text_event = CapturedEvent::Text { + text: rs.text_buffer.clone(), + x_pct: rs.text_start_x, + y_pct: rs.text_start_y, + }; + let _ = tx.try_send(text_event); + rs.text_buffer.clear(); + } + + let key_name = rdev_key_to_string(key); + let event = CapturedEvent::KeyCombo { + keys: vec![key_name], + }; + let _ = tx.try_send(event); + state.increment_actions(); + } + } + } + + rdev::EventType::KeyRelease(key) => { + // Mettre a jour les modifieurs + match key { + rdev::Key::ControlLeft | rdev::Key::ControlRight => rs.ctrl_held = false, + rdev::Key::Alt | rdev::Key::AltGr => rs.alt_held = false, + rdev::Key::ShiftLeft | rdev::Key::ShiftRight => rs.shift_held = false, + rdev::Key::MetaLeft | rdev::Key::MetaRight => rs.meta_held = false, + _ => {} + } + } + + rdev::EventType::Wheel { delta_x: _, delta_y } => { + let (mx, my) = get_cursor_position(); + let (x_pct, y_pct) = rs.normalize(mx, my); + let delta = if delta_y > 0 { 3 } else { -3 }; + + let event = CapturedEvent::Scroll { + delta, + x_pct, + y_pct, + }; + let _ = tx.try_send(event); + state.increment_actions(); + } + + _ => { + // MouseMove et autres evenements ignores + } + } + }; + + // rdev::listen est bloquant — il ne retourne qu'en cas d'erreur + if let Err(e) = rdev::listen(callback) { + eprintln!("[RECORDER] Erreur fatale du listener rdev : {:?}", e); + } +} + +/// Boucle de flush periodique du buffer de texte. +/// +/// Toutes les 100ms, verifie si le buffer de texte est non-vide +/// et si le delai de flush (500ms) est depasse. Si oui, flush le buffer +/// en envoyant un evenement Text. +fn text_flush_loop(_tx: Sender, state: Arc) { + // Note: le flush est gere dans le callback rdev via le Mutex. + // Cette boucle est un filet de securite pour les cas ou le buffer + // resterait non-flush (timeout sans nouveau evenement). + // L'implementation complete necessiterait un acces partage au RecorderState. + // Pour l'instant, le flush est declenche par le prochain evenement (clic, combo). + + while state.is_running() { + thread::sleep(Duration::from_millis(TEXT_FLUSH_DELAY_MS)); + } +} + +/// Boucle d'envoi des evenements captures vers le serveur streaming. +/// +/// Lit les evenements du canal crossbeam et les envoie au serveur +/// via HTTP POST (format compatible avec le Python streamer). +fn event_sender_loop( + rx: Receiver, + config: Arc, + state: Arc, +) { + let client = reqwest::blocking::Client::new(); + + println!("[RECORDER] Thread d'envoi d'evenements demarre"); + + loop { + // Bloquer jusqu'au prochain evenement (ou timeout) + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(event) => { + if !state.recording.load(std::sync::atomic::Ordering::SeqCst) { + continue; // Enregistrement arrete entre-temps + } + + let session_name = state.current_recording_name(); + send_event_to_server(&client, &config, &event, &session_name); + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => { + if !state.is_running() { + break; + } + } + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + println!("[RECORDER] Canal deconnecte — arret du sender"); + break; + } + } + } +} + +/// Envoie un evenement capture au serveur streaming. +fn send_event_to_server( + client: &reqwest::blocking::Client, + config: &Config, + event: &CapturedEvent, + session_name: &str, +) { + let url = format!("{}/traces/stream/event", config.server_url); + let timestamp = chrono::Utc::now().to_rfc3339(); + + let payload = match event { + CapturedEvent::Click { + x_pct, + y_pct, + button, + window_title, + } => { + serde_json::json!({ + "type": "click", + "x_pct": x_pct, + "y_pct": y_pct, + "button": button, + "window_title": window_title, + "session_name": session_name, + "machine_id": config.machine_id, + "timestamp": timestamp, + }) + } + CapturedEvent::DoubleClick { + x_pct, + y_pct, + window_title, + } => { + serde_json::json!({ + "type": "click", + "x_pct": x_pct, + "y_pct": y_pct, + "button": "double", + "window_title": window_title, + "session_name": session_name, + "machine_id": config.machine_id, + "timestamp": timestamp, + }) + } + CapturedEvent::Text { + text, + x_pct, + y_pct, + } => { + serde_json::json!({ + "type": "type", + "text": text, + "x_pct": x_pct, + "y_pct": y_pct, + "session_name": session_name, + "machine_id": config.machine_id, + "timestamp": timestamp, + }) + } + CapturedEvent::KeyCombo { keys } => { + serde_json::json!({ + "type": "key_combo", + "keys": keys, + "session_name": session_name, + "machine_id": config.machine_id, + "timestamp": timestamp, + }) + } + CapturedEvent::Scroll { + delta, + x_pct, + y_pct, + } => { + serde_json::json!({ + "type": "scroll", + "delta": delta, + "x_pct": x_pct, + "y_pct": y_pct, + "session_name": session_name, + "machine_id": config.machine_id, + "timestamp": timestamp, + }) + } + }; + + // Envoi non-bloquant (on ne veut pas ralentir la capture) + match client + .post(&url) + .json(&payload) + .timeout(Duration::from_secs(5)) + .send() + { + Ok(resp) => { + if !resp.status().is_success() { + eprintln!( + "[RECORDER] Envoi evenement echoue : HTTP {}", + resp.status() + ); + } + } + Err(e) => { + eprintln!("[RECORDER] Erreur reseau : {}", e); + } + } + + // Capturer un screenshot pour les clics (dual: full + crop) + if matches!( + event, + CapturedEvent::Click { .. } | CapturedEvent::DoubleClick { .. } + ) { + if let Some(img) = capture::capture_screenshot() { + let jpeg = capture::screenshot_to_jpeg_bytes(&img, 80); + if !jpeg.is_empty() { + let shot_id = format!("rec_{}", chrono::Utc::now().timestamp_millis()); + let _ = crate::network::send_heartbeat( + &reqwest::blocking::Client::new(), + &crate::config::Config::from_env(), + &jpeg, + session_name, + ); + let _ = shot_id; // utilise implicitement via send_heartbeat + } + } + } +} + +// --- Fonctions utilitaires --- + +/// Obtient la position actuelle du curseur souris. +fn get_cursor_position() -> (f64, f64) { + #[cfg(windows)] + { + use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos; + use windows_sys::Win32::Foundation::POINT; + + unsafe { + let mut point: POINT = std::mem::zeroed(); + if GetCursorPos(&mut point) != 0 { + return (point.x as f64, point.y as f64); + } + } + } + + // Fallback : position inconnue + (0.0, 0.0) +} + +/// Obtient le titre de la fenetre active. +fn get_active_window_title() -> String { + #[cfg(windows)] + { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowTextW, + }; + + unsafe { + let hwnd = GetForegroundWindow(); + if !hwnd.is_null() { + let mut buf = [0u16; 256]; + let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32); + if len > 0 { + return String::from_utf16_lossy(&buf[..len as usize]); + } + } + } + } + + "Inconnu".to_string() +} + +/// Convertit une touche rdev en caractere texte (pour le buffer de saisie). +/// Retourne None pour les touches speciales (Enter, Tab, etc.). +fn rdev_key_to_char(key: rdev::Key) -> Option { + match key { + rdev::Key::KeyA => Some('a'), + rdev::Key::KeyB => Some('b'), + rdev::Key::KeyC => Some('c'), + rdev::Key::KeyD => Some('d'), + rdev::Key::KeyE => Some('e'), + rdev::Key::KeyF => Some('f'), + rdev::Key::KeyG => Some('g'), + rdev::Key::KeyH => Some('h'), + rdev::Key::KeyI => Some('i'), + rdev::Key::KeyJ => Some('j'), + rdev::Key::KeyK => Some('k'), + rdev::Key::KeyL => Some('l'), + rdev::Key::KeyM => Some('m'), + rdev::Key::KeyN => Some('n'), + rdev::Key::KeyO => Some('o'), + rdev::Key::KeyP => Some('p'), + rdev::Key::KeyQ => Some('q'), + rdev::Key::KeyR => Some('r'), + rdev::Key::KeyS => Some('s'), + rdev::Key::KeyT => Some('t'), + rdev::Key::KeyU => Some('u'), + rdev::Key::KeyV => Some('v'), + rdev::Key::KeyW => Some('w'), + rdev::Key::KeyX => Some('x'), + rdev::Key::KeyY => Some('y'), + rdev::Key::KeyZ => Some('z'), + rdev::Key::Num0 => Some('0'), + rdev::Key::Num1 => Some('1'), + rdev::Key::Num2 => Some('2'), + rdev::Key::Num3 => Some('3'), + rdev::Key::Num4 => Some('4'), + rdev::Key::Num5 => Some('5'), + rdev::Key::Num6 => Some('6'), + rdev::Key::Num7 => Some('7'), + rdev::Key::Num8 => Some('8'), + rdev::Key::Num9 => Some('9'), + rdev::Key::Space => Some(' '), + rdev::Key::Minus => Some('-'), + rdev::Key::Equal => Some('='), + rdev::Key::LeftBracket => Some('['), + rdev::Key::RightBracket => Some(']'), + rdev::Key::SemiColon => Some(';'), + rdev::Key::Quote => Some('\''), + rdev::Key::Comma => Some(','), + rdev::Key::Dot => Some('.'), + rdev::Key::Slash => Some('/'), + rdev::Key::BackSlash => Some('\\'), + _ => None, + } +} + +/// Convertit une touche rdev en nom de touche (pour les combos). +fn rdev_key_to_string(key: rdev::Key) -> String { + match key { + rdev::Key::Return => "enter".to_string(), + rdev::Key::Tab => "tab".to_string(), + rdev::Key::Escape => "escape".to_string(), + rdev::Key::Backspace => "backspace".to_string(), + rdev::Key::Delete => "delete".to_string(), + rdev::Key::Space => "space".to_string(), + rdev::Key::UpArrow => "up".to_string(), + rdev::Key::DownArrow => "down".to_string(), + rdev::Key::LeftArrow => "left".to_string(), + rdev::Key::RightArrow => "right".to_string(), + rdev::Key::Home => "home".to_string(), + rdev::Key::End => "end".to_string(), + rdev::Key::PageUp => "page_up".to_string(), + rdev::Key::PageDown => "page_down".to_string(), + rdev::Key::F1 => "f1".to_string(), + rdev::Key::F2 => "f2".to_string(), + rdev::Key::F3 => "f3".to_string(), + rdev::Key::F4 => "f4".to_string(), + rdev::Key::F5 => "f5".to_string(), + rdev::Key::F6 => "f6".to_string(), + rdev::Key::F7 => "f7".to_string(), + rdev::Key::F8 => "f8".to_string(), + rdev::Key::F9 => "f9".to_string(), + rdev::Key::F10 => "f10".to_string(), + rdev::Key::F11 => "f11".to_string(), + rdev::Key::F12 => "f12".to_string(), + rdev::Key::CapsLock => "caps_lock".to_string(), + rdev::Key::Insert => "insert".to_string(), + rdev::Key::PrintScreen => "print_screen".to_string(), + // Pour les lettres et chiffres, reutiliser rdev_key_to_char + other => { + if let Some(c) = rdev_key_to_char(other) { + c.to_string() + } else { + format!("{:?}", other).to_lowercase() + } + } + } +} diff --git a/agent_rust/src/replay.rs b/agent_rust/src/replay.rs index fe0d1e8ac..d37fabf24 100644 --- a/agent_rust/src/replay.rs +++ b/agent_rust/src/replay.rs @@ -1,8 +1,8 @@ //! Boucle de polling replay. //! -//! Poll le serveur toutes les secondes pour récupérer les actions à exécuter. -//! Quand une action est reçue, l'exécute via executor et rapporte le résultat. -//! Gère le backoff exponentiel en cas d'indisponibilité du serveur. +//! Poll le serveur toutes les secondes pour recuperer les actions a executer. +//! Quand une action est recue, l'execute via executor et rapporte le resultat. +//! Gere le backoff exponentiel en cas d'indisponibilite du serveur. //! //! Reproduit le comportement de _replay_poll_loop dans agent_v1/main.py. @@ -10,35 +10,50 @@ use crate::capture; use crate::config::Config; use crate::executor; use crate::network; +use crate::notifications; +use crate::state::AgentState; use reqwest::blocking::Client; use std::thread; use std::time::Duration; -/// Boucle de polling replay (tourne dans un thread dédié). +/// Boucle de polling replay (tourne dans un thread dedie). /// /// - Poll GET /replay/next toutes les secondes -/// - Exécute l'action via executor +/// - Execute l'action via executor /// - Capture un screenshot post-action -/// - Rapporte le résultat via POST /replay/result +/// - Rapporte le resultat via POST /replay/result /// - Backoff exponentiel si le serveur est indisponible -pub fn replay_poll_loop(config: &Config) { +pub fn replay_poll_loop(config: &Config, state: &AgentState) { let client = Client::new(); let mut poll_count: u64 = 0; - let mut backoff = config.replay_poll_interval_s; - let backoff_max = 30.0_f64; - let backoff_factor = 1.5_f64; + let backoff = config.replay_poll_interval_s; + let _backoff_max = 30.0_f64; + let _backoff_factor = 1.5_f64; let mut replay_active = false; - let mut last_conn_error_logged = false; println!( "[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}", config.replay_poll_interval_s, config.server_url ); - loop { + while state.is_running() { + // Verifier l'arret d'urgence + if state + .emergency_stop + .load(std::sync::atomic::Ordering::SeqCst) + { + if replay_active { + println!("[REPLAY] ARRET D'URGENCE — replay interrompu"); + replay_active = false; + state.set_replay_active(false); + } + thread::sleep(Duration::from_secs(1)); + continue; + } + poll_count += 1; - // Log périodique toutes les 60s pour confirmer que la boucle tourne + // Log periodique toutes les 60s pour confirmer que la boucle tourne let polls_per_minute = (60.0 / backoff).ceil() as u64; if polls_per_minute > 0 && poll_count % polls_per_minute == 0 { println!( @@ -51,12 +66,10 @@ pub fn replay_poll_loop(config: &Config) { match network::poll_next_action(&client, config) { Some(action) => { - // Reset backoff et flag d'erreur - backoff = config.replay_poll_interval_s; - last_conn_error_logged = false; - if !replay_active { replay_active = true; + state.set_replay_active(true); + notifications::replay_started("workflow"); println!("[REPLAY] Replay demarre"); } @@ -67,10 +80,10 @@ pub fn replay_poll_loop(config: &Config) { action_type, action_id ); - // Obtenir les dimensions de l'écran + // Obtenir les dimensions de l'ecran let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080)); - // Exécuter l'action (avec config pour la résolution visuelle) + // Executer l'action (avec config pour la resolution visuelle) println!(">>> Execution de l'action {}...", action_type); let mut result = executor::execute_action(&action, sw, sh, config); println!( @@ -78,7 +91,7 @@ pub fn replay_poll_loop(config: &Config) { result.success, result.error ); - // Capture screenshot post-action (après 500ms) + // Capture screenshot post-action (apres 500ms) thread::sleep(Duration::from_millis(500)); if let Some(img) = capture::capture_screenshot() { let b64 = capture::screenshot_to_jpeg_base64(&img, 60); @@ -87,35 +100,26 @@ pub fn replay_poll_loop(config: &Config) { } } - // Rapporter le résultat au serveur (TOUJOURS, même en erreur) + // Rapporter le resultat au serveur (TOUJOURS, meme en erreur) network::report_result(&client, config, &result); - // Poll plus rapidement pour enchaîner les actions + // Poll plus rapidement pour enchainer les actions thread::sleep(Duration::from_millis(200)); continue; } None => { - // Pas d'action — soit pas de replay, soit serveur indisponible - if replay_active { println!("[REPLAY] Replay termine — retour en mode capture"); replay_active = false; + state.set_replay_active(false); + notifications::replay_finished(true); } - - // Vérifier si c'est un timeout/erreur réseau (backoff) - // Le poll_next_action retourne None aussi si le serveur refuse - // On ne peut pas distinguer facilement, donc on garde le backoff simple } } - // Si on a eu des erreurs récentes, le backoff est > 1s let sleep_duration = Duration::from_secs_f64(backoff); thread::sleep(sleep_duration); - - // Note: le backoff augmente seulement quand poll_next_action renvoie None - // et qu'on suspecte une erreur réseau. Pour l'instant, on garde le poll - // à intervalles constants (1s). Le backoff sera implémenté plus finement - // quand on aura un meilleur signal d'erreur réseau. - let _ = (backoff_max, backoff_factor, &mut last_conn_error_logged); } + + println!("[REPLAY] Boucle arretee."); } diff --git a/agent_rust/src/state.rs b/agent_rust/src/state.rs new file mode 100644 index 000000000..3a997f0e9 --- /dev/null +++ b/agent_rust/src/state.rs @@ -0,0 +1,175 @@ +//! Etat partage thread-safe de l'agent. +//! +//! Centralise l'etat courant (enregistrement, replay, connexion, etc.) +//! accessible depuis tous les threads (systray, heartbeat, replay, recorder). +//! Equivalent de agent_v1/ui/shared_state.py. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +/// Etats possibles de l'icone systray +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayState { + /// Gris — en attente, pas de session active + Idle, + /// Rouge — enregistrement en cours + Recording, + /// Vert — connecte au serveur, pret + Connected, + /// Bleu — replay en cours + Replay, +} + +/// Etat partage de l'agent, thread-safe via Arc + atomics. +/// +/// Les booleens utilisent AtomicBool pour un acces lock-free. +/// Le nom de session utilise un Mutex car c'est une String. +#[derive(Debug)] +pub struct AgentState { + /// Enregistrement en cours (session de capture) + pub recording: AtomicBool, + + /// Nom de la session d'enregistrement courante + pub recording_name: Mutex, + + /// Replay en cours (execution d'actions) + pub replay_active: AtomicBool, + + /// Connecte au serveur streaming + pub connected: AtomicBool, + + /// Nombre d'actions capturees dans la session courante + pub actions_count: AtomicU32, + + /// L'agent est en cours d'execution (false = arret demande) + pub running: AtomicBool, + + /// Fenetre de chat visible + pub chat_visible: AtomicBool, + + /// Arret d'urgence active + pub emergency_stop: AtomicBool, + + /// Dernier message de notification (pour eviter les doublons) + #[allow(dead_code)] + pub last_notification: Mutex, +} + +impl AgentState { + /// Cree un nouvel etat avec les valeurs par defaut. + pub fn new() -> Arc { + Arc::new(Self { + recording: AtomicBool::new(false), + recording_name: Mutex::new(String::new()), + replay_active: AtomicBool::new(false), + connected: AtomicBool::new(false), + actions_count: AtomicU32::new(0), + running: AtomicBool::new(true), + chat_visible: AtomicBool::new(false), + emergency_stop: AtomicBool::new(false), + last_notification: Mutex::new(String::new()), + }) + } + + /// Demarre un enregistrement avec le nom donne. + pub fn start_recording(&self, name: &str) { + self.recording.store(true, Ordering::SeqCst); + self.actions_count.store(0, Ordering::SeqCst); + if let Ok(mut n) = self.recording_name.lock() { + *n = name.to_string(); + } + println!("[STATE] Enregistrement demarre : '{}'", name); + } + + /// Arrete l'enregistrement en cours. + pub fn stop_recording(&self) -> (String, u32) { + self.recording.store(false, Ordering::SeqCst); + let count = self.actions_count.load(Ordering::SeqCst); + let name = self + .recording_name + .lock() + .map(|n| n.clone()) + .unwrap_or_default(); + println!("[STATE] Enregistrement arrete : '{}' ({} actions)", name, count); + (name, count) + } + + /// Incremente le compteur d'actions capturees. + pub fn increment_actions(&self) -> u32 { + self.actions_count.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Verifie si l'agent est en cours d'execution. + pub fn is_running(&self) -> bool { + self.running.load(Ordering::SeqCst) + } + + /// Demande l'arret de l'agent. + pub fn request_shutdown(&self) { + self.running.store(false, Ordering::SeqCst); + println!("[STATE] Arret demande"); + } + + /// Active/desactive le replay. + pub fn set_replay_active(&self, active: bool) { + self.replay_active.store(active, Ordering::SeqCst); + } + + /// Met a jour le statut de connexion au serveur. + pub fn set_connected(&self, connected: bool) { + let was_connected = self.connected.swap(connected, Ordering::SeqCst); + if was_connected != connected { + println!( + "[STATE] Connexion serveur : {}", + if connected { "CONNECTE" } else { "DECONNECTE" } + ); + } + } + + /// Active l'arret d'urgence — stoppe tout immediatement. + pub fn emergency_stop(&self) { + self.emergency_stop.store(true, Ordering::SeqCst); + self.recording.store(false, Ordering::SeqCst); + self.replay_active.store(false, Ordering::SeqCst); + println!("[STATE] === ARRET D'URGENCE ACTIVE ==="); + } + + /// Retourne l'etat courant du systray. + pub fn tray_state(&self) -> TrayState { + if self.recording.load(Ordering::SeqCst) { + TrayState::Recording + } else if self.replay_active.load(Ordering::SeqCst) { + TrayState::Replay + } else if self.connected.load(Ordering::SeqCst) { + TrayState::Connected + } else { + TrayState::Idle + } + } + + /// Retourne le nom de la session d'enregistrement courante. + pub fn current_recording_name(&self) -> String { + self.recording_name + .lock() + .map(|n| n.clone()) + .unwrap_or_default() + } +} + +impl Default for AgentState { + fn default() -> Self { + // Note: on ne peut pas retourner Arc depuis Default, + // donc on fournit les valeurs brutes. Utiliser new() de preference. + Self { + recording: AtomicBool::new(false), + recording_name: Mutex::new(String::new()), + replay_active: AtomicBool::new(false), + connected: AtomicBool::new(false), + actions_count: AtomicU32::new(0), + running: AtomicBool::new(true), + chat_visible: AtomicBool::new(false), + emergency_stop: AtomicBool::new(false), + last_notification: Mutex::new(String::new()), + } + } +} diff --git a/agent_rust/src/tray.rs b/agent_rust/src/tray.rs new file mode 100644 index 000000000..66b398353 --- /dev/null +++ b/agent_rust/src/tray.rs @@ -0,0 +1,336 @@ +//! Icone systray avec menu contextuel. +//! +//! Affiche une icone dans la barre des taches Windows avec un menu contextuel +//! permettant de controler l'agent (enregistrement, replay, chat, etc.). +//! Equivalent de agent_v1/ui/smart_tray.py. +//! +//! Utilise tray-icon (crate Tauri) pour l'icone et le menu. +//! Necessite une boucle d'evenements Windows (winit ou Win32 message pump). +//! +//! Sur Linux : le systray n'est pas disponible, l'agent tourne en mode console. + +#[allow(unused_imports)] +use crate::config::Config; +#[allow(unused_imports)] +use crate::notifications; +#[allow(unused_imports)] +use crate::state::{AgentState, TrayState}; +use std::sync::Arc; + +/// Identifiants des elements du menu (pour le dispatch des evenements). +#[cfg(windows)] +pub struct TrayMenuIds { + pub machine_info: tray_icon::menu::MenuItem, + pub status_item: tray_icon::menu::MenuItem, + pub start_recording: tray_icon::menu::MenuItem, + pub stop_recording: tray_icon::menu::MenuItem, + pub workflows_submenu: tray_icon::menu::Submenu, + pub emergency_stop: tray_icon::menu::MenuItem, + pub open_chat: tray_icon::menu::MenuItem, + pub open_files: tray_icon::menu::MenuItem, + pub quit: tray_icon::menu::MenuItem, +} + +/// Cree l'icone du systray et la boucle d'evenements associee. +/// +/// Cette fonction bloque le thread appelant (doit etre le thread principal sur Windows). +/// Sur les OS non-Windows, attend Ctrl+C en mode console. +#[cfg(windows)] +pub fn run_tray_loop(config: Arc, state: Arc) { + use tray_icon::{ + menu::MenuEvent, + TrayIconBuilder, + }; + use winit::application::ApplicationHandler; + use winit::event::WindowEvent; + use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; + use winit::window::WindowId; + + // Creer le menu + let menu_ids = create_menu(&config); + let menu = build_tray_menu(&menu_ids); + + // Generer l'icone initiale (gris = idle) + let icon = generate_tray_icon(TrayState::Idle); + + // Creer l'icone systray + let tray = match TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_tooltip("Lea - Agent RPA Vision (IA)") + .with_icon(icon) + .build() + { + Ok(t) => t, + Err(e) => { + eprintln!("[TRAY] Impossible de creer l'icone systray : {}", e); + // Fallback mode console + fallback_console_loop(&state); + return; + } + }; + + println!("[TRAY] Icone systray creee — menu contextuel disponible"); + notifications::greet(); + + // Structure pour l'ApplicationHandler de winit + struct TrayApp { + config: Arc, + state: Arc, + tray: tray_icon::TrayIcon, + menu_ids: TrayMenuIds, + current_tray_state: TrayState, + } + + impl ApplicationHandler for TrayApp { + fn resumed(&mut self, _event_loop: &ActiveEventLoop) { + // Rien a faire — pas de fenetre winit + } + + fn window_event( + &mut self, + _event_loop: &ActiveEventLoop, + _window_id: WindowId, + _event: WindowEvent, + ) { + // Pas de fenetre winit — ignorer + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + // Verifier si l'agent doit s'arreter + if !self.state.is_running() { + event_loop.exit(); + return; + } + + // Traiter les evenements menu + let menu_receiver = MenuEvent::receiver(); + if let Ok(event) = menu_receiver.try_recv() { + handle_menu_event(&event, &self.menu_ids, &self.config, &self.state); + } + + // Mettre a jour l'icone si l'etat a change + let new_state = self.state.tray_state(); + if new_state != self.current_tray_state { + self.current_tray_state = new_state; + let tooltip = match new_state { + TrayState::Idle => "Lea - En attente", + TrayState::Recording => "Lea - ENREGISTREMENT EN COURS", + TrayState::Connected => "Lea - Connectee au serveur", + TrayState::Replay => "Lea - REPLAY EN COURS", + }; + let _ = self.tray.set_tooltip(Some(tooltip)); + let icon = generate_tray_icon(new_state); + let _ = self.tray.set_icon(Some(icon)); + } + + // Attendre un peu avant le prochain cycle + event_loop.set_control_flow(ControlFlow::WaitUntil( + std::time::Instant::now() + std::time::Duration::from_millis(100), + )); + } + } + + // Creer et demarrer la boucle d'evenements winit + let event_loop = match EventLoop::new() { + Ok(el) => el, + Err(e) => { + eprintln!("[TRAY] Impossible de creer la boucle d'evenements : {}", e); + fallback_console_loop(&state); + return; + } + }; + + let mut app = TrayApp { + config, + state, + tray, + menu_ids, + current_tray_state: TrayState::Idle, + }; + + let _ = event_loop.run_app(&mut app); +} + +/// Cree les elements de menu avec leurs labels. +#[cfg(windows)] +fn create_menu(config: &Config) -> TrayMenuIds { + use tray_icon::menu::{MenuItem, Submenu}; + + let machine_info = MenuItem::new( + format!("Machine : {}", config.machine_id), + false, // disabled — info seulement + None, + ); + + let status_item = MenuItem::new("Deconnectee", false, None); + + let start_recording = MenuItem::new("Apprenez-moi une tache", true, None); + + let stop_recording = MenuItem::new("C'est termine", true, None); + + let workflows_submenu = Submenu::new("Mes taches", true); + let _ = workflows_submenu.append(&MenuItem::new("(chargement...)", false, None)); + + let emergency_stop = MenuItem::new("ARRET D'URGENCE", true, None); + + let open_chat = MenuItem::new("Discuter avec Lea", true, None); + + let open_files = MenuItem::new("Mes fichiers", true, None); + + let quit = MenuItem::new("Quitter Lea", true, None); + + TrayMenuIds { + machine_info, + status_item, + start_recording, + stop_recording, + workflows_submenu, + emergency_stop, + open_chat, + open_files, + quit, + } +} + +/// Construit le menu systray a partir des elements. +#[cfg(windows)] +fn build_tray_menu(ids: &TrayMenuIds) -> tray_icon::menu::Menu { + use tray_icon::menu::{Menu, PredefinedMenuItem}; + + let menu = Menu::new(); + + let _ = menu.append(&ids.machine_info); + let _ = menu.append(&ids.status_item); + let _ = menu.append(&PredefinedMenuItem::separator()); + let _ = menu.append(&ids.start_recording); + let _ = menu.append(&ids.stop_recording); + let _ = menu.append(&PredefinedMenuItem::separator()); + let _ = menu.append(&ids.workflows_submenu); + let _ = menu.append(&PredefinedMenuItem::separator()); + let _ = menu.append(&ids.emergency_stop); + let _ = menu.append(&ids.open_chat); + let _ = menu.append(&ids.open_files); + let _ = menu.append(&PredefinedMenuItem::separator()); + let _ = menu.append(&ids.quit); + + menu +} + +/// Gere un evenement de clic sur un element du menu. +#[cfg(windows)] +fn handle_menu_event( + event: &tray_icon::menu::MenuEvent, + ids: &TrayMenuIds, + _config: &Config, + state: &AgentState, +) { + let event_id = event.id(); + + if event_id == ids.start_recording.id() { + if !state.recording.load(std::sync::atomic::Ordering::SeqCst) { + let name = format!( + "session_{}", + chrono::Utc::now().format("%Y%m%d_%H%M%S") + ); + state.start_recording(&name); + notifications::session_started(&name); + println!("[TRAY] Enregistrement demarre : {}", name); + } + } else if event_id == ids.stop_recording.id() { + if state.recording.load(std::sync::atomic::Ordering::SeqCst) { + let (name, count) = state.stop_recording(); + notifications::session_ended(count); + println!( + "[TRAY] Enregistrement arrete : {} ({} actions)", + name, count + ); + } + } else if event_id == ids.emergency_stop.id() { + state.emergency_stop(); + notifications::emergency_stop_activated(); + println!("[TRAY] ARRET D'URGENCE ACTIVE"); + } else if event_id == ids.open_chat.id() { + state + .chat_visible + .store(true, std::sync::atomic::Ordering::SeqCst); + println!("[TRAY] Ouverture du chat demandee"); + } else if event_id == ids.open_files.id() { + let sessions_dir = if cfg!(target_os = "windows") { + "C:\\rpa_vision\\sessions".to_string() + } else { + "/tmp/rpa_vision/sessions".to_string() + }; + println!("[TRAY] Ouverture du dossier : {}", sessions_dir); + #[cfg(windows)] + { + let _ = std::process::Command::new("explorer") + .arg(&sessions_dir) + .spawn(); + } + } else if event_id == ids.quit.id() { + println!("[TRAY] Fermeture demandee par l'utilisateur"); + state.request_shutdown(); + } +} + +/// Genere une icone systray coloree selon l'etat. +/// +/// Cree une image 32x32 RGBA avec un cercle colore : +/// - Gris (#808080) : idle +/// - Rouge (#FF0000) : enregistrement +/// - Vert (#00CC00) : connecte +/// - Bleu (#0066FF) : replay +#[cfg(windows)] +fn generate_tray_icon(tray_state: TrayState) -> tray_icon::Icon { + let size = 32u32; + let mut rgba = vec![0u8; (size * size * 4) as usize]; + + let (r, g, b) = match tray_state { + TrayState::Idle => (128u8, 128u8, 128u8), + TrayState::Recording => (255u8, 0u8, 0u8), + TrayState::Connected => (0u8, 204u8, 0u8), + TrayState::Replay => (0u8, 102u8, 255u8), + }; + + let center = (size / 2) as f64; + let radius = (size / 2 - 2) as f64; + + for y in 0..size { + for x in 0..size { + let dx = x as f64 - center; + let dy = y as f64 - center; + let dist = (dx * dx + dy * dy).sqrt(); + + let offset = ((y * size + x) * 4) as usize; + if dist <= radius { + rgba[offset] = r; + rgba[offset + 1] = g; + rgba[offset + 2] = b; + rgba[offset + 3] = 255; + } else if dist <= radius + 1.0 { + let alpha = ((radius + 1.0 - dist) * 255.0) as u8; + rgba[offset] = r; + rgba[offset + 1] = g; + rgba[offset + 2] = b; + rgba[offset + 3] = alpha; + } + } + } + + tray_icon::Icon::from_rgba(rgba, size, size).expect("Erreur creation icone systray") +} + +/// Mode console (Linux ou fallback si le systray echoue). +fn fallback_console_loop(state: &AgentState) { + println!("[TRAY] Mode console — Appuyez sur Ctrl+C pour quitter"); + while state.is_running() { + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + +/// Version non-Windows : pas de systray, l'agent tourne en mode console. +#[cfg(not(windows))] +pub fn run_tray_loop(_config: Arc, state: Arc) { + println!("[TRAY] Systray non disponible sur cet OS — mode console"); + fallback_console_loop(&state); +}