feat: replay visuel VLM-first, worker séparé, package Léa, AZERTY, sécurité HTTPS

Pipeline replay visuel :
- VLM-first : l'agent appelle Ollama directement pour trouver les éléments
- Template matching en fallback (seuil strict 0.90)
- Stop immédiat si élément non trouvé (pas de clic blind)
- Replay depuis session brute (/replay-session) sans attendre le VLM
- Vérification post-action (screenshot hash avant/après)
- Gestion des popups (Enter/Escape/Tab+Enter)

Worker VLM séparé :
- run_worker.py : process distinct du serveur HTTP
- Communication par fichiers (_worker_queue.txt + _replay_active.lock)
- Le serveur HTTP ne fait plus jamais de VLM → toujours réactif
- Service systemd rpa-worker.service

Capture clavier :
- raw_keys (vk + press/release) pour replay exact indépendant du layout
- Fix AZERTY : ToUnicodeEx + AltGr detection
- Enter capturé comme \n, Tab comme \t
- Filtrage modificateurs seuls (Ctrl/Alt/Shift parasites)
- Fusion text_input consécutifs, dédup key_combo

Sécurité & Internet :
- HTTPS Let's Encrypt (lea.labs + vwb.labs.laurinebazin.design)
- Token API fixe dans .env.local
- HTTP Basic Auth sur VWB
- Security headers (HSTS, CSP, nosniff)
- CORS domaines publics, plus de wildcard

Infrastructure :
- DPI awareness (SetProcessDpiAwareness) Python + Rust
- Métadonnées système (dpi_scale, window_bounds, monitors, os_theme)
- Template matching multi-scale [0.5, 2.0]
- Résolution dynamique (plus de hardcode 1920x1080)
- VLM prefill fix (47x speedup, 3.5s au lieu de 180s)

Modules :
- core/auth/ : credential vault (Fernet AES), TOTP (RFC 6238), auth handler
- core/federation/ : LearningPack export/import anonymisé, FAISS global
- deploy/ : package Léa (config.txt, Lea.bat, install.bat, LISEZMOI.txt)

UX :
- Filtrage OS (VWB + Chat montrent que les workflows de l'OS courant)
- Bibliothèque persistante (cache local + SQLite)
- Clustering hybride (titre fenêtre + DBSCAN)
- EdgeConstraints + PostConditions peuplés
- GraphBuilder compound actions (toutes les frappes)

Agent Rust :
- Token Bearer auth (network.rs)
- sysinfo.rs (DPI, résolution, window bounds via Win32 API)
- config.txt lu automatiquement
- Support Chrome/Brave/Firefox (pas que Edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-26 10:19:18 +01:00
parent fe5e0ba83d
commit d5deac3029
162 changed files with 25669 additions and 557 deletions

View File

@@ -5,9 +5,21 @@
//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005).
use crate::config::Config;
use reqwest::blocking::Client;
use crate::sysinfo;
use reqwest::blocking::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
/// Ajoute le header Authorization Bearer si un token est configure.
///
/// Si `config.api_token` est vide, la requete est retournee telle quelle.
pub fn with_auth(request: RequestBuilder, config: &Config) -> RequestBuilder {
if config.api_token.is_empty() {
request
} else {
request.header("Authorization", format!("Bearer {}", config.api_token))
}
}
/// Action de replay reçue du serveur.
///
/// Format identique à celui du Python executor (agent_v1/core/executor.py).
@@ -102,6 +114,8 @@ impl ActionResult {
/// Envoie un heartbeat (screenshot) au serveur streaming.
///
/// POST /traces/stream/image avec le screenshot en multipart.
/// Inclut les métadonnées système (DPI, résolution, fenêtre, moniteur)
/// dans les query params pour que le serveur puisse les exploiter.
/// Retourne true si l'envoi a réussi.
pub fn send_heartbeat(
client: &Client,
@@ -112,6 +126,19 @@ pub fn send_heartbeat(
let url = format!("{}/image", config.streaming_url());
let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp());
// Collecter les métadonnées système
let meta = sysinfo::get_screen_metadata();
let dpi_str = meta.dpi_scale.to_string();
let screen_w_str = meta.screen_resolution[0].to_string();
let screen_h_str = meta.screen_resolution[1].to_string();
let monitor_str = meta.monitor_index.to_string();
// Sérialiser window_bounds en JSON compact (ou "null")
let wb_str = match meta.window_bounds {
Some(wb) => format!("[{},{},{},{}]", wb[0], wb[1], wb[2], wb[3]),
None => "null".to_string(),
};
let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
.file_name("screenshot.jpg")
.mime_str("image/jpeg")
@@ -122,17 +149,22 @@ pub fn send_heartbeat(
let form = reqwest::blocking::multipart::Form::new().part("file", part);
match client
let request = client
.post(&url)
.query(&[
("session_id", session_id),
("shot_id", &shot_id),
("machine_id", &config.machine_id),
("dpi_scale", &dpi_str),
("screen_w", &screen_w_str),
("screen_h", &screen_h_str),
("monitor_index", &monitor_str),
("window_bounds", &wb_str),
])
.multipart(form)
.timeout(std::time::Duration::from_secs(10))
.send()
{
.timeout(std::time::Duration::from_secs(10));
match with_auth(request, config).send() {
Ok(resp) => {
if resp.status().is_success() {
true
@@ -166,15 +198,15 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
let url = format!("{}/replay/next", config.streaming_url());
let session_id = config.agent_session_id();
let resp = client
let request = client
.get(&url)
.query(&[
("session_id", session_id.as_str()),
("machine_id", config.machine_id.as_str()),
])
.timeout(std::time::Duration::from_secs(5))
.send()
.ok()?;
.timeout(std::time::Duration::from_secs(5));
let resp = with_auth(request, config).send().ok()?;
if !resp.status().is_success() {
return None;
@@ -184,6 +216,120 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
data.action
}
/// Informations résumées d'un workflow disponible.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInfo {
/// Identifiant unique du workflow
pub workflow_id: String,
/// Nom lisible du workflow
#[serde(default)]
pub name: String,
/// Identifiant machine associé
#[serde(default)]
pub machine_id: String,
/// Nombre de nœuds
#[serde(default)]
pub nodes: u32,
/// Nombre de transitions
#[serde(default)]
pub edges: u32,
}
/// Réponse du serveur pour GET /traces/stream/workflows
#[derive(Debug, Deserialize)]
struct WorkflowsResponse {
#[serde(default)]
workflows: Vec<WorkflowInfo>,
}
/// Récupère la liste des workflows disponibles pour cette machine.
///
/// GET /traces/stream/workflows?machine_id=<machine_id>
/// Sauvegarde le résultat dans workflows.json à côté de l'exécutable.
/// Retourne la liste (éventuellement depuis le cache local si le serveur est indisponible).
pub fn fetch_workflows(client: &Client, config: &Config) -> Vec<WorkflowInfo> {
let url = format!("{}/workflows", config.streaming_url());
let request = client
.get(&url)
.query(&[("machine_id", config.machine_id.as_str())])
.timeout(std::time::Duration::from_secs(5));
let workflows = match with_auth(request, config).send() {
Ok(resp) if resp.status().is_success() => {
match resp.json::<WorkflowsResponse>() {
Ok(data) => data.workflows,
Err(e) => {
eprintln!("[WORKFLOWS] Erreur parsing reponse : {}", e);
Vec::new()
}
}
}
Ok(resp) => {
eprintln!("[WORKFLOWS] Serveur HTTP {} — chargement cache local", resp.status());
return load_workflows_cache();
}
Err(e) => {
eprintln!("[WORKFLOWS] Serveur injoignable ({}) — chargement cache local", e);
return load_workflows_cache();
}
};
// Sauvegarder dans le cache local
save_workflows_cache(&workflows);
workflows
}
/// Chemin du fichier cache workflows.json (à côté de l'exécutable ou dans le dossier courant).
fn workflows_cache_path() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
return dir.join("workflows.json");
}
}
std::path::PathBuf::from("workflows.json")
}
/// Sauvegarde les workflows dans le cache local.
fn save_workflows_cache(workflows: &[WorkflowInfo]) {
let path = workflows_cache_path();
match serde_json::to_string_pretty(workflows) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
eprintln!("[WORKFLOWS] Erreur ecriture cache {} : {}", path.display(), e);
}
}
Err(e) => {
eprintln!("[WORKFLOWS] Erreur serialisation cache : {}", e);
}
}
}
/// Charge les workflows depuis le cache local.
fn load_workflows_cache() -> Vec<WorkflowInfo> {
let path = workflows_cache_path();
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<Vec<WorkflowInfo>>(&content) {
Ok(workflows) => {
println!("[WORKFLOWS] {} workflow(s) charges depuis le cache local", workflows.len());
workflows
}
Err(e) => {
eprintln!("[WORKFLOWS] Erreur parsing cache : {}", e);
Vec::new()
}
}
}
Err(_) => Vec::new(), // Pas de cache, pas d'erreur
}
}
/// Rapporte le résultat d'une action au serveur.
///
/// POST /traces/stream/replay/result avec le résultat en JSON.
@@ -208,12 +354,12 @@ pub fn report_result(client: &Client, config: &Config, result: &ActionResult) ->
screenshot: &result.screenshot,
};
match client
let request = client
.post(&url)
.json(&report)
.timeout(std::time::Duration::from_secs(10))
.send()
{
.timeout(std::time::Duration::from_secs(10));
match with_auth(request, config).send() {
Ok(resp) => {
if resp.status().is_success() {
if let Ok(data) = resp.json::<serde_json::Value>() {