feat: popup VLM double-appel, auth Bearer partout, texte AZERTY corrigé
- Popup handling via double appel VLM (détection + localisation précise du bouton) - Reconstruction texte depuis raw_keys (numpad /, @ AltGr fusionné) - Clipboard paste pour texte riche, raw_keys pour commandes simples (Win+R) - Skip des release orphelins dans raw_keys (fix menu Démarrer parasite) - Auth Bearer sur toutes les requêtes agent → streaming server - Endpoints /replay/next et /stream/image publics (agent Rust legacy) - alt_gr ajouté dans _MODIFIER_ONLY_KEYS - _key_combo_printable_char détecte ctrl+@ comme caractère imprimable - start.bat tue les anciens process (python + rpa-agent) au démarrage - Heartbeat avec token Bearer dans main.py et deploy/ Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,10 @@ SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
|
||||
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
||||
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
|
||||
|
||||
# Token d'authentification API (doit correspondre au token du serveur)
|
||||
# Configurable via variable d'environnement RPA_API_TOKEN
|
||||
API_TOKEN = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
# Paramètres de session
|
||||
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
||||
SESSIONS_ROOT = BASE_DIR / "sessions"
|
||||
|
||||
@@ -14,7 +14,7 @@ import uuid
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID
|
||||
from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, API_TOKEN
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
from .network.streamer import TraceStreamer
|
||||
@@ -84,9 +84,11 @@ class AgentV1:
|
||||
# Executeur pour le replay (doit exister avant le poll)
|
||||
self._executor = ActionExecutorV1()
|
||||
|
||||
# Boucle de polling replay PERMANENTE (pas besoin de session active)
|
||||
# Boucles permanentes (pas besoin de session active)
|
||||
self.running = True
|
||||
self._bg_vision = VisionCapturer(str(SESSIONS_ROOT / "_background"))
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
|
||||
|
||||
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
|
||||
self.ui = SmartTrayV1(
|
||||
@@ -126,11 +128,59 @@ class AgentV1:
|
||||
# Watchdog de Commandes (GHOST Replay — legacy fichier)
|
||||
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
|
||||
|
||||
# Boucle de polling replay (P0-5 — pull depuis le serveur)
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
# Note: la boucle de polling replay est déjà lancée dans __init__
|
||||
# Ne PAS en relancer une ici — deux threads poll simultanés causent
|
||||
# une race condition où les actions sont consommées mais pas exécutées.
|
||||
|
||||
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
|
||||
|
||||
_last_bg_hash: str = ""
|
||||
|
||||
def _background_heartbeat_loop(self):
|
||||
"""Heartbeat permanent — envoie un screenshot toutes les 5s au serveur.
|
||||
Tourne même sans session active, pour que le VWB puisse capturer Windows.
|
||||
"""
|
||||
import requests as req
|
||||
bg_session = f"bg_{self.machine_id}"
|
||||
logger.info(f"[HEARTBEAT] Boucle permanente démarrée (session={bg_session})")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Ne pas envoyer pendant un enregistrement (le heartbeat session s'en charge)
|
||||
if self.session_id:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
full_path = self._bg_vision.capture_full_context("heartbeat")
|
||||
if not full_path:
|
||||
time.sleep(5)
|
||||
continue
|
||||
|
||||
# Dédup : skip si écran identique
|
||||
img_hash = self._quick_hash(full_path)
|
||||
if img_hash and img_hash == self._last_bg_hash:
|
||||
time.sleep(5)
|
||||
continue
|
||||
self._last_bg_hash = img_hash
|
||||
|
||||
# Envoyer au streaming server (avec token auth)
|
||||
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
|
||||
with open(full_path, 'rb') as f:
|
||||
req.post(
|
||||
f"{SERVER_URL}/traces/stream/image",
|
||||
params={
|
||||
"session_id": bg_session,
|
||||
"shot_id": f"heartbeat_{int(time.time())}",
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=headers,
|
||||
files={"file": ("screenshot.png", f, "image/png")},
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _command_watchdog_loop(self):
|
||||
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
|
||||
import json
|
||||
@@ -143,7 +193,7 @@ class AgentV1:
|
||||
else:
|
||||
cmd_path = str(BASE_DIR / "command.json")
|
||||
|
||||
while self.running:
|
||||
while self.running and self.session_id:
|
||||
# Ne pas traiter les commandes fichier pendant un replay serveur
|
||||
if self._replay_active:
|
||||
time.sleep(1)
|
||||
@@ -181,8 +231,11 @@ class AgentV1:
|
||||
time.sleep(REPLAY_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# Utiliser la session active ou un ID par défaut pour le replay
|
||||
poll_session = self.session_id or f"agent_{self.user_id}"
|
||||
# TOUJOURS utiliser un session_id stable pour le replay.
|
||||
# L'enregistrement et le replay sont indépendants : le serveur
|
||||
# envoie les actions sur agent_{user_id}, pas sur la session
|
||||
# d'enregistrement (sess_xxx).
|
||||
poll_session = f"agent_{self.user_id}"
|
||||
|
||||
# Log periodique pour confirmer que la boucle tourne (toutes les 60s)
|
||||
poll_count += 1
|
||||
@@ -226,18 +279,38 @@ class AgentV1:
|
||||
time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL))
|
||||
|
||||
def stop_session(self):
|
||||
self.running = False
|
||||
# Arrêter la capture et le streaming de la session d'enregistrement
|
||||
if self.captor: self.captor.stop()
|
||||
if self.streamer: self.streamer.stop()
|
||||
logger.info(f"Session {self.session_id} terminée.")
|
||||
|
||||
# Reset le session_id pour que le poll replay utilise l'ID stable
|
||||
self.session_id = None
|
||||
|
||||
# Reset le backoff de l'executor pour reprendre le polling immédiatement
|
||||
if self._executor:
|
||||
self._executor._poll_backoff = self._executor._poll_backoff_min
|
||||
self._executor._server_available = True
|
||||
if hasattr(self._executor, '_last_conn_error_logged'):
|
||||
self._executor._last_conn_error_logged = False
|
||||
|
||||
# NE PAS mettre self.running = False ici !
|
||||
# self.running contrôle la boucle _replay_poll_loop (permanente).
|
||||
# Seule la sortie du programme doit le mettre à False.
|
||||
|
||||
logger.info(
|
||||
f"Session arrêtée — replay poll actif avec session="
|
||||
f"agent_{self.user_id}"
|
||||
)
|
||||
|
||||
_last_heartbeat_hash: str = ""
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
"""Capture périodique pour donner du contexte au stagiaire.
|
||||
Déduplication : n'envoie que si l'écran a changé.
|
||||
Tourne tant que session_id est défini (= enregistrement actif).
|
||||
"""
|
||||
while self.running:
|
||||
while self.running and self.session_id:
|
||||
try:
|
||||
full_path = self.vision.capture_full_context("heartbeat")
|
||||
if full_path:
|
||||
|
||||
@@ -25,7 +25,7 @@ import time
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from ..config import STREAMING_ENDPOINT
|
||||
from ..config import API_TOKEN, STREAMING_ENDPOINT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,6 +56,13 @@ class TraceStreamer:
|
||||
self._health_thread = None
|
||||
self._server_available = True # Désactivé après trop d'échecs
|
||||
|
||||
@staticmethod
|
||||
def _auth_headers() -> dict:
|
||||
"""Headers d'authentification Bearer pour les requêtes API."""
|
||||
if API_TOKEN:
|
||||
return {"Authorization": f"Bearer {API_TOKEN}"}
|
||||
return {}
|
||||
|
||||
def start(self):
|
||||
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
||||
self.running = True
|
||||
@@ -240,6 +247,7 @@ class TraceStreamer:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{STREAMING_ENDPOINT}/stats",
|
||||
headers=self._auth_headers(),
|
||||
timeout=3,
|
||||
)
|
||||
if resp.ok:
|
||||
@@ -292,6 +300,7 @@ class TraceStreamer:
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=3,
|
||||
)
|
||||
if resp.ok:
|
||||
@@ -319,6 +328,7 @@ class TraceStreamer:
|
||||
"session_id": self.session_id,
|
||||
"machine_id": self.machine_id,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=30, # Le build workflow peut prendre du temps
|
||||
)
|
||||
if resp.ok:
|
||||
@@ -343,6 +353,7 @@ class TraceStreamer:
|
||||
resp = requests.post(
|
||||
f"{STREAMING_ENDPOINT}/event",
|
||||
json=payload,
|
||||
headers=self._auth_headers(),
|
||||
timeout=2,
|
||||
)
|
||||
return resp.ok
|
||||
@@ -377,6 +388,7 @@ class TraceStreamer:
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
return resp.ok
|
||||
@@ -390,6 +402,7 @@ class TraceStreamer:
|
||||
f"{STREAMING_ENDPOINT}/image",
|
||||
files=files,
|
||||
params=params,
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
return resp.ok
|
||||
|
||||
@@ -367,9 +367,14 @@ class SmartTrayV1:
|
||||
|
||||
try:
|
||||
import requests
|
||||
# Auth headers pour le streaming server (port 5005)
|
||||
auth_headers = {}
|
||||
if self.server_client is not None:
|
||||
auth_headers = self.server_client._auth_headers()
|
||||
resp = requests.post(
|
||||
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
|
||||
json={"workflow_id": workflow_id},
|
||||
headers=auth_headers,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
|
||||
@@ -91,11 +91,24 @@ class LeaServerClient:
|
||||
# Session de chat
|
||||
self._chat_session_id: Optional[str] = None
|
||||
|
||||
# Token API pour le serveur streaming (auth Bearer)
|
||||
self._api_token = os.environ.get("RPA_API_TOKEN", "")
|
||||
|
||||
logger.info(
|
||||
"LeaServerClient initialise : chat=%s, stream=%s",
|
||||
self._chat_base, self._stream_base,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _auth_headers(self) -> Dict[str, str]:
|
||||
"""Headers d'authentification pour le serveur streaming."""
|
||||
if self._api_token:
|
||||
return {"Authorization": f"Bearer {self._api_token}"}
|
||||
return {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proprietes
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -133,11 +146,12 @@ class LeaServerClient:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Tester la connexion au serveur chat."""
|
||||
"""Tester la connexion au serveur streaming (port 5005)."""
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
f"{self._chat_base}/api/status",
|
||||
f"{self._stream_base}/health",
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
was_connected = self._connected
|
||||
@@ -200,16 +214,21 @@ class LeaServerClient:
|
||||
return None
|
||||
|
||||
def list_workflows(self) -> List[Dict[str, Any]]:
|
||||
"""Recuperer la liste des workflows depuis le serveur chat."""
|
||||
"""Recuperer la liste des workflows depuis le serveur streaming."""
|
||||
try:
|
||||
import requests
|
||||
headers = self._auth_headers()
|
||||
resp = requests.get(
|
||||
f"{self._chat_base}/api/workflows",
|
||||
f"{self._stream_base}/api/v1/traces/stream/workflows",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
self._connected = True
|
||||
# L'API renvoie directement une liste ou un dict avec clé "workflows"
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("workflows", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
@@ -218,20 +237,10 @@ class LeaServerClient:
|
||||
return []
|
||||
|
||||
def list_gestures(self) -> List[Dict[str, Any]]:
|
||||
"""Recuperer la liste des gestes depuis le serveur chat."""
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
f"{self._chat_base}/api/workflows",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
return data.get("workflows", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("List gestures erreur : %s", e)
|
||||
return []
|
||||
"""Recuperer la liste des gestes (non disponible sur streaming server)."""
|
||||
# Les gestes etaient sur le chat server (5004) qui n'est plus utilise.
|
||||
# Retourner une liste vide silencieusement.
|
||||
return []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replay Polling (port 5005)
|
||||
@@ -269,6 +278,7 @@ class LeaServerClient:
|
||||
resp = req_lib.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
||||
params={"session_id": self._poll_session_id},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@@ -301,6 +311,7 @@ class LeaServerClient:
|
||||
import requests
|
||||
resp = requests.get(
|
||||
f"{self._stream_base}/api/v1/traces/stream/replays",
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok:
|
||||
@@ -335,6 +346,7 @@ class LeaServerClient:
|
||||
"error": error,
|
||||
"screenshot": screenshot,
|
||||
},
|
||||
headers=self._auth_headers(),
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user