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:
@@ -16,7 +16,7 @@ import logging
|
|||||||
import threading
|
import threading
|
||||||
from .config import (
|
from .config import (
|
||||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
||||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME,
|
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN,
|
||||||
)
|
)
|
||||||
from .core.captor import EventCaptorV1
|
from .core.captor import EventCaptorV1
|
||||||
from .core.executor import ActionExecutorV1
|
from .core.executor import ActionExecutorV1
|
||||||
@@ -77,7 +77,14 @@ class AgentV1:
|
|||||||
# Client serveur pour le chat et les workflows
|
# Client serveur pour le chat et les workflows
|
||||||
self._server_client = None
|
self._server_client = None
|
||||||
if LeaServerClient is not None:
|
if LeaServerClient is not None:
|
||||||
self._server_client = LeaServerClient()
|
# Forcer le token API pour éviter les 401
|
||||||
|
# (le token est set par start.bat dans l'environnement)
|
||||||
|
from .config import API_TOKEN as _token
|
||||||
|
server_host = os.getenv("RPA_SERVER_HOST", "localhost")
|
||||||
|
self._server_client = LeaServerClient(server_host=server_host)
|
||||||
|
if _token and not self._server_client._api_token:
|
||||||
|
self._server_client._api_token = _token
|
||||||
|
logger.info("Token API forcé dans LeaServerClient")
|
||||||
|
|
||||||
# Fenetre de chat Lea (tkinter natif)
|
# Fenetre de chat Lea (tkinter natif)
|
||||||
server_host = (
|
server_host = (
|
||||||
@@ -288,7 +295,8 @@ class AgentV1:
|
|||||||
continue
|
continue
|
||||||
self._last_bg_hash = img_hash
|
self._last_bg_hash = img_hash
|
||||||
|
|
||||||
# Envoyer au streaming server
|
# Envoyer au streaming server (avec token auth)
|
||||||
|
headers = {"Authorization": f"Bearer {API_TOKEN}"} if API_TOKEN else {}
|
||||||
with open(full_path, 'rb') as f:
|
with open(full_path, 'rb') as f:
|
||||||
req.post(
|
req.post(
|
||||||
f"{SERVER_URL}/traces/stream/image",
|
f"{SERVER_URL}/traces/stream/image",
|
||||||
@@ -297,6 +305,7 @@ class AgentV1:
|
|||||||
"shot_id": f"heartbeat_{int(time.time())}",
|
"shot_id": f"heartbeat_{int(time.time())}",
|
||||||
"machine_id": self.machine_id,
|
"machine_id": self.machine_id,
|
||||||
},
|
},
|
||||||
|
headers=headers,
|
||||||
files={"file": ("screenshot.png", f, "image/png")},
|
files={"file": ("screenshot.png", f, "image/png")},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -474,9 +474,14 @@ class SmartTrayV1:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import requests
|
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(
|
resp = requests.post(
|
||||||
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
|
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
|
||||||
json={"workflow_id": workflow_id},
|
json={"workflow_id": workflow_id},
|
||||||
|
headers=auth_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ SERVER_URL = os.getenv("RPA_SERVER_URL", "http://localhost:5005/api/v1")
|
|||||||
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
UPLOAD_ENDPOINT = f"{SERVER_URL}/traces/upload"
|
||||||
STREAMING_ENDPOINT = f"{SERVER_URL}/traces/stream"
|
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
|
# Paramètres de session
|
||||||
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
||||||
SESSIONS_ROOT = BASE_DIR / "sessions"
|
SESSIONS_ROOT = BASE_DIR / "sessions"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import uuid
|
|||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
import threading
|
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.captor import EventCaptorV1
|
||||||
from .core.executor import ActionExecutorV1
|
from .core.executor import ActionExecutorV1
|
||||||
from .network.streamer import TraceStreamer
|
from .network.streamer import TraceStreamer
|
||||||
@@ -84,9 +84,11 @@ class AgentV1:
|
|||||||
# Executeur pour le replay (doit exister avant le poll)
|
# Executeur pour le replay (doit exister avant le poll)
|
||||||
self._executor = ActionExecutorV1()
|
self._executor = ActionExecutorV1()
|
||||||
|
|
||||||
# Boucle de polling replay PERMANENTE (pas besoin de session active)
|
# Boucles permanentes (pas besoin de session active)
|
||||||
self.running = True
|
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._replay_poll_loop, daemon=True).start()
|
||||||
|
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
|
||||||
|
|
||||||
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
|
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
|
||||||
self.ui = SmartTrayV1(
|
self.ui = SmartTrayV1(
|
||||||
@@ -126,11 +128,59 @@ class AgentV1:
|
|||||||
# Watchdog de Commandes (GHOST Replay — legacy fichier)
|
# Watchdog de Commandes (GHOST Replay — legacy fichier)
|
||||||
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
|
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
|
||||||
|
|
||||||
# Boucle de polling replay (P0-5 — pull depuis le serveur)
|
# Note: la boucle de polling replay est déjà lancée dans __init__
|
||||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
# 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...")
|
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):
|
def _command_watchdog_loop(self):
|
||||||
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
|
"""Surveille un fichier de commande pour executer des ordres visuels (legacy)."""
|
||||||
import json
|
import json
|
||||||
@@ -143,7 +193,7 @@ class AgentV1:
|
|||||||
else:
|
else:
|
||||||
cmd_path = str(BASE_DIR / "command.json")
|
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
|
# Ne pas traiter les commandes fichier pendant un replay serveur
|
||||||
if self._replay_active:
|
if self._replay_active:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
@@ -181,8 +231,11 @@ class AgentV1:
|
|||||||
time.sleep(REPLAY_POLL_INTERVAL)
|
time.sleep(REPLAY_POLL_INTERVAL)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Utiliser la session active ou un ID par défaut pour le replay
|
# TOUJOURS utiliser un session_id stable pour le replay.
|
||||||
poll_session = self.session_id or f"agent_{self.user_id}"
|
# 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)
|
# Log periodique pour confirmer que la boucle tourne (toutes les 60s)
|
||||||
poll_count += 1
|
poll_count += 1
|
||||||
@@ -226,18 +279,38 @@ class AgentV1:
|
|||||||
time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL))
|
time.sleep(max(poll_delay, REPLAY_POLL_INTERVAL))
|
||||||
|
|
||||||
def stop_session(self):
|
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.captor: self.captor.stop()
|
||||||
if self.streamer: self.streamer.stop()
|
if self.streamer: self.streamer.stop()
|
||||||
logger.info(f"Session {self.session_id} terminée.")
|
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 = ""
|
_last_heartbeat_hash: str = ""
|
||||||
|
|
||||||
def _heartbeat_loop(self):
|
def _heartbeat_loop(self):
|
||||||
"""Capture périodique pour donner du contexte au stagiaire.
|
"""Capture périodique pour donner du contexte au stagiaire.
|
||||||
Déduplication : n'envoie que si l'écran a changé.
|
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:
|
try:
|
||||||
full_path = self.vision.capture_full_context("heartbeat")
|
full_path = self.vision.capture_full_context("heartbeat")
|
||||||
if full_path:
|
if full_path:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from ..config import STREAMING_ENDPOINT
|
from ..config import API_TOKEN, STREAMING_ENDPOINT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,6 +56,13 @@ class TraceStreamer:
|
|||||||
self._health_thread = None
|
self._health_thread = None
|
||||||
self._server_available = True # Désactivé après trop d'échecs
|
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):
|
def start(self):
|
||||||
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
"""Démarrer le streaming et enregistrer la session côté serveur."""
|
||||||
self.running = True
|
self.running = True
|
||||||
@@ -240,6 +247,7 @@ class TraceStreamer:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{STREAMING_ENDPOINT}/stats",
|
f"{STREAMING_ENDPOINT}/stats",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=3,
|
timeout=3,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -292,6 +300,7 @@ class TraceStreamer:
|
|||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"machine_id": self.machine_id,
|
"machine_id": self.machine_id,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=3,
|
timeout=3,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -319,6 +328,7 @@ class TraceStreamer:
|
|||||||
"session_id": self.session_id,
|
"session_id": self.session_id,
|
||||||
"machine_id": self.machine_id,
|
"machine_id": self.machine_id,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=30, # Le build workflow peut prendre du temps
|
timeout=30, # Le build workflow peut prendre du temps
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -343,6 +353,7 @@ class TraceStreamer:
|
|||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{STREAMING_ENDPOINT}/event",
|
f"{STREAMING_ENDPOINT}/event",
|
||||||
json=payload,
|
json=payload,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=2,
|
timeout=2,
|
||||||
)
|
)
|
||||||
return resp.ok
|
return resp.ok
|
||||||
@@ -377,6 +388,7 @@ class TraceStreamer:
|
|||||||
f"{STREAMING_ENDPOINT}/image",
|
f"{STREAMING_ENDPOINT}/image",
|
||||||
files=files,
|
files=files,
|
||||||
params=params,
|
params=params,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
return resp.ok
|
return resp.ok
|
||||||
@@ -390,6 +402,7 @@ class TraceStreamer:
|
|||||||
f"{STREAMING_ENDPOINT}/image",
|
f"{STREAMING_ENDPOINT}/image",
|
||||||
files=files,
|
files=files,
|
||||||
params=params,
|
params=params,
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
return resp.ok
|
return resp.ok
|
||||||
|
|||||||
@@ -367,9 +367,14 @@ class SmartTrayV1:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import requests
|
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(
|
resp = requests.post(
|
||||||
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
|
f"{self.server_client._stream_base}/api/v1/traces/stream/replay/start",
|
||||||
json={"workflow_id": workflow_id},
|
json={"workflow_id": workflow_id},
|
||||||
|
headers=auth_headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
|
|||||||
@@ -91,11 +91,24 @@ class LeaServerClient:
|
|||||||
# Session de chat
|
# Session de chat
|
||||||
self._chat_session_id: Optional[str] = None
|
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(
|
logger.info(
|
||||||
"LeaServerClient initialise : chat=%s, stream=%s",
|
"LeaServerClient initialise : chat=%s, stream=%s",
|
||||||
self._chat_base, self._stream_base,
|
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
|
# Proprietes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -133,11 +146,12 @@ class LeaServerClient:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def check_connection(self) -> bool:
|
def check_connection(self) -> bool:
|
||||||
"""Tester la connexion au serveur chat."""
|
"""Tester la connexion au serveur streaming (port 5005)."""
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._chat_base}/api/status",
|
f"{self._stream_base}/health",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
was_connected = self._connected
|
was_connected = self._connected
|
||||||
@@ -200,16 +214,21 @@ class LeaServerClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def list_workflows(self) -> List[Dict[str, Any]]:
|
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:
|
try:
|
||||||
import requests
|
import requests
|
||||||
|
headers = self._auth_headers()
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._chat_base}/api/workflows",
|
f"{self._stream_base}/api/v1/traces/stream/workflows",
|
||||||
|
headers=headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
self._connected = True
|
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 data.get("workflows", [])
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -218,20 +237,10 @@ class LeaServerClient:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def list_gestures(self) -> List[Dict[str, Any]]:
|
def list_gestures(self) -> List[Dict[str, Any]]:
|
||||||
"""Recuperer la liste des gestes depuis le serveur chat."""
|
"""Recuperer la liste des gestes (non disponible sur streaming server)."""
|
||||||
try:
|
# Les gestes etaient sur le chat server (5004) qui n'est plus utilise.
|
||||||
import requests
|
# Retourner une liste vide silencieusement.
|
||||||
resp = requests.get(
|
return []
|
||||||
f"{self._chat_base}/api/workflows",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
data = resp.json()
|
|
||||||
return data.get("workflows", [])
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("List gestures erreur : %s", e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Replay Polling (port 5005)
|
# Replay Polling (port 5005)
|
||||||
@@ -269,6 +278,7 @@ class LeaServerClient:
|
|||||||
resp = req_lib.get(
|
resp = req_lib.get(
|
||||||
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
||||||
params={"session_id": self._poll_session_id},
|
params={"session_id": self._poll_session_id},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -301,6 +311,7 @@ class LeaServerClient:
|
|||||||
import requests
|
import requests
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._stream_base}/api/v1/traces/stream/replays",
|
f"{self._stream_base}/api/v1/traces/stream/replays",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -335,6 +346,7 @@ class LeaServerClient:
|
|||||||
"error": error,
|
"error": error,
|
||||||
"screenshot": screenshot,
|
"screenshot": screenshot,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -91,11 +91,24 @@ class LeaServerClient:
|
|||||||
# Session de chat
|
# Session de chat
|
||||||
self._chat_session_id: Optional[str] = None
|
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(
|
logger.info(
|
||||||
"LeaServerClient initialise : chat=%s, stream=%s",
|
"LeaServerClient initialise : chat=%s, stream=%s",
|
||||||
self._chat_base, self._stream_base,
|
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
|
# Proprietes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -133,11 +146,12 @@ class LeaServerClient:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def check_connection(self) -> bool:
|
def check_connection(self) -> bool:
|
||||||
"""Tester la connexion au serveur chat."""
|
"""Tester la connexion au serveur streaming (port 5005)."""
|
||||||
try:
|
try:
|
||||||
import requests
|
import requests
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._chat_base}/api/workflows",
|
f"{self._stream_base}/health",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
was_connected = self._connected
|
was_connected = self._connected
|
||||||
@@ -200,11 +214,13 @@ class LeaServerClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def list_workflows(self) -> List[Dict[str, Any]]:
|
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:
|
try:
|
||||||
import requests
|
import requests
|
||||||
|
headers = self._auth_headers()
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._chat_base}/api/workflows",
|
f"{self._stream_base}/api/v1/traces/stream/workflows",
|
||||||
|
headers=headers,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -221,22 +237,10 @@ class LeaServerClient:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def list_gestures(self) -> List[Dict[str, Any]]:
|
def list_gestures(self) -> List[Dict[str, Any]]:
|
||||||
"""Recuperer la liste des gestes depuis le serveur chat."""
|
"""Recuperer la liste des gestes (non disponible sur streaming server)."""
|
||||||
try:
|
# Les gestes etaient sur le chat server (5004) qui n'est plus utilise.
|
||||||
import requests
|
# Retourner une liste vide silencieusement.
|
||||||
resp = requests.get(
|
return []
|
||||||
f"{self._chat_base}/api/gestures",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.ok:
|
|
||||||
data = resp.json()
|
|
||||||
if isinstance(data, list):
|
|
||||||
return data
|
|
||||||
return data.get("gestures", [])
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("List gestures erreur : %s", e)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Replay Polling (port 5005)
|
# Replay Polling (port 5005)
|
||||||
@@ -274,6 +278,7 @@ class LeaServerClient:
|
|||||||
resp = req_lib.get(
|
resp = req_lib.get(
|
||||||
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
f"{self._stream_base}/api/v1/traces/stream/replay/next",
|
||||||
params={"session_id": self._poll_session_id},
|
params={"session_id": self._poll_session_id},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,6 +311,7 @@ class LeaServerClient:
|
|||||||
import requests
|
import requests
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{self._stream_base}/api/v1/traces/stream/replays",
|
f"{self._stream_base}/api/v1/traces/stream/replays",
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
if resp.ok:
|
if resp.ok:
|
||||||
@@ -340,6 +346,7 @@ class LeaServerClient:
|
|||||||
"error": error,
|
"error": error,
|
||||||
"screenshot": screenshot,
|
"screenshot": screenshot,
|
||||||
},
|
},
|
||||||
|
headers=self._auth_headers(),
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ API_TOKEN = os.environ.get("RPA_API_TOKEN", secrets.token_hex(32))
|
|||||||
|
|
||||||
# Endpoints publics (pas besoin de token)
|
# Endpoints publics (pas besoin de token)
|
||||||
# En production, /docs et /redoc sont désactivés (voir ci-dessous)
|
# En production, /docs et /redoc sont désactivés (voir ci-dessous)
|
||||||
_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"}
|
# Paths publics : pas de token requis
|
||||||
|
# /replay/next est public car l'agent Rust legacy n'envoie pas de token
|
||||||
|
# et c'est un endpoint read-only (polling, pas d'écriture)
|
||||||
|
_PUBLIC_PATHS = {
|
||||||
|
"/health", "/docs", "/openapi.json", "/redoc",
|
||||||
|
"/api/v1/traces/stream/replay/next",
|
||||||
|
"/api/v1/traces/stream/image",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _verify_token(request: Request):
|
async def _verify_token(request: Request):
|
||||||
|
|||||||
@@ -31,12 +31,19 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_MODIFIER_ONLY_KEYS = {
|
_MODIFIER_ONLY_KEYS = {
|
||||||
"ctrl", "ctrl_l", "ctrl_r", "control", "control_l", "control_r",
|
"ctrl", "ctrl_l", "ctrl_r", "control", "control_l", "control_r",
|
||||||
"alt", "alt_l", "alt_r",
|
"alt", "alt_l", "alt_r", "alt_gr",
|
||||||
"shift", "shift_l", "shift_r",
|
"shift", "shift_l", "shift_r",
|
||||||
"win", "win_l", "win_r", "cmd", "cmd_l", "cmd_r",
|
"win", "win_l", "win_r", "cmd", "cmd_l", "cmd_r",
|
||||||
"meta", "meta_l", "meta_r", "super", "super_l", "super_r",
|
"meta", "meta_l", "meta_r", "super", "super_l", "super_r",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Mapping numpad vk codes → caractères (layout-indépendant)
|
||||||
|
_NUMPAD_VK_MAP = {
|
||||||
|
96: '0', 97: '1', 98: '2', 99: '3', 100: '4',
|
||||||
|
101: '5', 102: '6', 103: '7', 104: '8', 105: '9',
|
||||||
|
106: '*', 107: '+', 109: '-', 110: '.', 111: '/',
|
||||||
|
}
|
||||||
|
|
||||||
# Table de conversion des caractères de contrôle vers les touches lisibles
|
# Table de conversion des caractères de contrôle vers les touches lisibles
|
||||||
# (produits par certains agents qui capturent les raw keycodes)
|
# (produits par certains agents qui capturent les raw keycodes)
|
||||||
_CONTROL_CHAR_MAP = {
|
_CONTROL_CHAR_MAP = {
|
||||||
@@ -98,6 +105,72 @@ def _is_parasitic_event(event_data: Dict[str, Any]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _reconstruct_text_from_raw_keys(raw_keys: list) -> str:
|
||||||
|
"""Reconstruire le texte correct à partir des vk codes des raw_keys.
|
||||||
|
|
||||||
|
Corrige les problèmes de capture AZERTY, notamment :
|
||||||
|
- Numpad / (vk=111) capturé comme char='!' → corrigé en '/'
|
||||||
|
- Numpad 0-9 (vk=96-105) capturés comme char=None → corrigés en '0'-'9'
|
||||||
|
"""
|
||||||
|
text_parts = []
|
||||||
|
for event in raw_keys:
|
||||||
|
if event.get("action") != "press":
|
||||||
|
continue
|
||||||
|
vk = event.get("vk", 0)
|
||||||
|
char = event.get("char")
|
||||||
|
kind = event.get("kind", "")
|
||||||
|
name = event.get("name", "")
|
||||||
|
|
||||||
|
# Ignorer les modificateurs (releases qui traînent dans le buffer)
|
||||||
|
if kind == "key" and name in _MODIFIER_ONLY_KEYS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Numpad : mapping fixe (layout-indépendant)
|
||||||
|
if vk in _NUMPAD_VK_MAP:
|
||||||
|
text_parts.append(_NUMPAD_VK_MAP[vk])
|
||||||
|
# Touche normale avec caractère valide
|
||||||
|
elif char and len(char) == 1 and char.isprintable():
|
||||||
|
text_parts.append(char)
|
||||||
|
return "".join(text_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _key_combo_printable_char(keys: list) -> Optional[str]:
|
||||||
|
"""Si le key_combo produit un seul caractère imprimable, le retourner.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- ['ctrl', '@'] → '@' (AltGr+0 sur AZERTY, capturé comme ctrl+@)
|
||||||
|
- ['shift', 'A'] → 'A'
|
||||||
|
- ['ctrl', 'c'] → None (c'est un raccourci, pas un caractère)
|
||||||
|
- ['enter'] → None (pas un caractère imprimable)
|
||||||
|
"""
|
||||||
|
if not keys:
|
||||||
|
return None
|
||||||
|
non_modifiers = [k for k in keys if k.lower() not in _MODIFIER_ONLY_KEYS]
|
||||||
|
if len(non_modifiers) != 1:
|
||||||
|
return None
|
||||||
|
char = non_modifiers[0]
|
||||||
|
# Un seul caractère imprimable (pas un nom de touche spéciale)
|
||||||
|
if len(char) == 1 and char.isprintable():
|
||||||
|
# Vérifier que c'est pas un raccourci courant (ctrl+c, ctrl+v, etc.)
|
||||||
|
modifiers = {k.lower() for k in keys if k.lower() in _MODIFIER_ONLY_KEYS}
|
||||||
|
if modifiers <= {"shift", "shift_l", "shift_r"}:
|
||||||
|
# Shift + char = caractère majuscule/spécial → OK
|
||||||
|
return char
|
||||||
|
if "alt_gr" in modifiers or (
|
||||||
|
"ctrl" in modifiers and ("alt" in modifiers or "alt_r" in modifiers)
|
||||||
|
):
|
||||||
|
# AltGr + char = caractère spécial (@ # € etc.) → OK
|
||||||
|
return char
|
||||||
|
# Ctrl + caractère NON-alphabétique = probablement AltGr résiduel
|
||||||
|
# Sur AZERTY, AltGr+0 produit @, capturé comme ['ctrl', 'alt_gr'] + ['ctrl', '@']
|
||||||
|
# Le premier combo est filtré (modifier-only), le second a juste 'ctrl' + '@'
|
||||||
|
if "ctrl" in modifiers and not char.isalpha():
|
||||||
|
return char
|
||||||
|
# ctrl + lettre seul = raccourci (Ctrl+S, Ctrl+C) → pas un caractère
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _merge_consecutive_text_inputs(steps: list) -> list:
|
def _merge_consecutive_text_inputs(steps: list) -> list:
|
||||||
"""Fusionne les text_input consécutifs en un seul."""
|
"""Fusionne les text_input consécutifs en un seul."""
|
||||||
merged = []
|
merged = []
|
||||||
@@ -577,12 +650,26 @@ def build_replay_from_raw_events(
|
|||||||
# Tous les text_input consécutifs sont fusionnés en un seul, indépendamment
|
# Tous les text_input consécutifs sont fusionnés en un seul, indépendamment
|
||||||
# du gap temporel. L'utilisateur tape lettre par lettre mais on veut un
|
# du gap temporel. L'utilisateur tape lettre par lettre mais on veut un
|
||||||
# seul "type" avec tout le texte dans le replay.
|
# seul "type" avec tout le texte dans le replay.
|
||||||
|
# Les key_combos qui produisent un caractère imprimable (ex: AltGr+0 → @)
|
||||||
|
# sont convertis en text_input pour être fusionnés avec le texte adjacent.
|
||||||
# Seul un changement de fenêtre (window_title différent) coupe la fusion.
|
# Seul un changement de fenêtre (window_title différent) coupe la fusion.
|
||||||
merged_events = []
|
merged_events = []
|
||||||
for evt in actionable_events:
|
for evt in actionable_events:
|
||||||
evt_type = evt.get("type", "")
|
evt_type = evt.get("type", "")
|
||||||
evt_ts = float(evt.get("timestamp", 0))
|
evt_ts = float(evt.get("timestamp", 0))
|
||||||
|
|
||||||
|
# Convertir les key_combos qui produisent un caractère imprimable
|
||||||
|
# en text_input pour qu'ils soient fusionnés avec le texte adjacent.
|
||||||
|
# Ex: AltGr+0 capturé comme ['ctrl', '@'] → text_input '@'
|
||||||
|
if evt_type in ("key_combo", "key_press"):
|
||||||
|
keys = _sanitize_keys(evt.get("keys", []))
|
||||||
|
printable = _key_combo_printable_char(keys)
|
||||||
|
if printable:
|
||||||
|
# Transformer en text_input pour fusion
|
||||||
|
evt = dict(evt, type="text_input", text=printable)
|
||||||
|
evt_type = "text_input"
|
||||||
|
# Pas de raw_keys pour ce caractère (sera collé via clipboard)
|
||||||
|
|
||||||
if evt_type == "text_input":
|
if evt_type == "text_input":
|
||||||
text = evt.get("text", "")
|
text = evt.get("text", "")
|
||||||
if not text:
|
if not text:
|
||||||
@@ -624,6 +711,34 @@ def build_replay_from_raw_events(
|
|||||||
else:
|
else:
|
||||||
merged_events.append(dict(evt))
|
merged_events.append(dict(evt))
|
||||||
|
|
||||||
|
# ── 3b. Reconstruire le texte correct depuis les raw_keys ──
|
||||||
|
# Les raw_keys contiennent les vk codes exacts (layout-indépendant)
|
||||||
|
# qui permettent de corriger les erreurs de capture AZERTY
|
||||||
|
# (ex: numpad / capturé comme '!' → corrigé en '/')
|
||||||
|
# ATTENTION : ne reconstruire QUE si le texte reconstruit a la même
|
||||||
|
# longueur que le texte original. Si des caractères viennent de
|
||||||
|
# key_combos convertis (ex: @ de AltGr), ils n'ont pas de raw_keys
|
||||||
|
# et la reconstruction les perdrait.
|
||||||
|
for evt in merged_events:
|
||||||
|
if evt.get("type") == "text_input" and evt.get("raw_keys"):
|
||||||
|
reconstructed = _reconstruct_text_from_raw_keys(evt["raw_keys"])
|
||||||
|
original = evt.get("text", "")
|
||||||
|
if reconstructed and len(reconstructed) == len(original):
|
||||||
|
# Même longueur → remplacement sûr (corrige les chars numpad)
|
||||||
|
evt["text"] = reconstructed
|
||||||
|
if reconstructed != original:
|
||||||
|
logger.debug(
|
||||||
|
"Texte reconstruit depuis raw_keys : '%s' → '%s'",
|
||||||
|
original[:50], reconstructed[:50],
|
||||||
|
)
|
||||||
|
elif reconstructed and len(reconstructed) < len(original):
|
||||||
|
# Longueur différente → des chars viennent de key_combos convertis
|
||||||
|
# Garder le texte original (qui inclut les chars fusionnés)
|
||||||
|
logger.debug(
|
||||||
|
"Texte non reconstruit (longueur diff) : '%s' (%d) vs '%s' (%d)",
|
||||||
|
original[:50], len(original), reconstructed[:50], len(reconstructed),
|
||||||
|
)
|
||||||
|
|
||||||
# ── 4. Convertir en actions replay normalisées ──
|
# ── 4. Convertir en actions replay normalisées ──
|
||||||
actions = []
|
actions = []
|
||||||
last_ts = 0.0
|
last_ts = 0.0
|
||||||
@@ -729,9 +844,10 @@ def build_replay_from_raw_events(
|
|||||||
"y_relative": y_relative,
|
"y_relative": y_relative,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# Propager les infos textuelles pour compatibilité
|
# NE PAS mettre window_title comme by_text !
|
||||||
if window_title:
|
# by_text doit être le texte de l'ÉLÉMENT cliqué, pas le titre de la fenêtre.
|
||||||
action["target_spec"]["by_text"] = window_title
|
# Sinon le template matching texte cherche "13071967.txt – Bloc-notes"
|
||||||
|
# sur l'écran et clique sur la barre de titre au lieu du bon élément.
|
||||||
|
|
||||||
elif evt_type == "text_input":
|
elif evt_type == "text_input":
|
||||||
text = evt.get("text", "")
|
text = evt.get("text", "")
|
||||||
|
|||||||
Reference in New Issue
Block a user