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:
@@ -26,7 +26,7 @@ class OllamaClient:
|
||||
def __init__(self,
|
||||
endpoint: str = "http://localhost:11434",
|
||||
model: str = "qwen3-vl:8b",
|
||||
timeout: int = 60):
|
||||
timeout: int = 180):
|
||||
"""
|
||||
Initialiser le client Ollama
|
||||
|
||||
@@ -63,14 +63,21 @@ class OllamaClient:
|
||||
system_prompt: Optional[str] = None,
|
||||
temperature: float = 0.1,
|
||||
max_tokens: int = 500,
|
||||
force_json: bool = False) -> Dict[str, Any]:
|
||||
force_json: bool = False,
|
||||
assistant_prefill: Optional[str] = None,
|
||||
num_ctx: Optional[int] = None,
|
||||
extra_images_b64: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Générer une réponse du VLM via l'API chat d'Ollama.
|
||||
|
||||
Note: On utilise /api/chat au lieu de /api/generate car qwen3-vl
|
||||
avec /api/generate consomme tous les tokens en thinking interne
|
||||
et retourne une réponse vide. L'API chat gère correctement
|
||||
le mode /no_think et sépare thinking/réponse.
|
||||
Pour les modèles thinking (qwen3-vl), on utilise la technique du
|
||||
"assistant prefill" : un message assistant pré-rempli est ajouté
|
||||
après le message user, forçant le modèle à continuer directement
|
||||
sans phase de thinking. Cela résout le bug Ollama 0.18.x où
|
||||
think=false est ignoré par le renderer qwen3-vl-thinking.
|
||||
|
||||
Sans prefill : le modèle pense 500+ tokens puis répond (~180s)
|
||||
Avec prefill : le modèle répond directement (~1-5s)
|
||||
|
||||
Args:
|
||||
prompt: Prompt textuel
|
||||
@@ -80,6 +87,11 @@ class OllamaClient:
|
||||
temperature: Température de génération
|
||||
max_tokens: Nombre max de tokens
|
||||
force_json: Forcer la sortie JSON (non recommandé pour qwen3-vl)
|
||||
assistant_prefill: Début de réponse pré-rempli (auto-détecté si None)
|
||||
num_ctx: Context window (défaut 2048, augmenter pour batch)
|
||||
extra_images_b64: Images supplémentaires en base64 à envoyer avec le prompt.
|
||||
Ajoutées après l'image principale. Utile pour le VLM multi-image
|
||||
(ex: screenshot + crop de référence).
|
||||
|
||||
Returns:
|
||||
Dict avec 'response', 'success', 'error'
|
||||
@@ -93,17 +105,19 @@ class OllamaClient:
|
||||
image_data = self._encode_image_from_pil(image)
|
||||
|
||||
# Nettoyer le prompt — retirer /no_think et /nothink du texte
|
||||
# car le mode thinking est contrôlé via le paramètre think=false
|
||||
# de l'API chat. Les préfixes /no_think dans le prompt causent
|
||||
# paradoxalement PLUS de thinking interne chez qwen3-vl.
|
||||
effective_prompt = prompt.replace("/no_think\n", "").replace("/no_think", "")
|
||||
effective_prompt = effective_prompt.replace("/nothink ", "").replace("/nothink", "")
|
||||
effective_prompt = effective_prompt.strip()
|
||||
|
||||
# Construire le message utilisateur
|
||||
user_message = {"role": "user", "content": effective_prompt}
|
||||
all_images = []
|
||||
if image_data:
|
||||
user_message["images"] = [image_data]
|
||||
all_images.append(image_data)
|
||||
if extra_images_b64:
|
||||
all_images.extend(extra_images_b64)
|
||||
if all_images:
|
||||
user_message["images"] = all_images
|
||||
|
||||
# Construire les messages
|
||||
messages = []
|
||||
@@ -111,9 +125,37 @@ class OllamaClient:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append(user_message)
|
||||
|
||||
# Déterminer si le modèle supporte le thinking
|
||||
# Déterminer si le modèle est un modèle thinking (qwen3)
|
||||
is_thinking_model = "qwen3" in self.model.lower()
|
||||
|
||||
# WORKAROUND Ollama 0.18.x : think=false est ignoré par le
|
||||
# renderer qwen3-vl-thinking. On utilise un assistant prefill
|
||||
# pour forcer le modèle à skip le thinking et répondre directement.
|
||||
# Le prefill est choisi en fonction du format attendu.
|
||||
# IMPORTANT : avec image, sans prefill le thinking dépasse 180s.
|
||||
prefill_used = None
|
||||
if is_thinking_model:
|
||||
if assistant_prefill is not None:
|
||||
prefill_used = assistant_prefill
|
||||
elif force_json:
|
||||
prefill_used = "{"
|
||||
elif all_images:
|
||||
# Avec image(s), le thinking est catastrophique (>180s).
|
||||
# Prefill générique pour forcer une réponse directe.
|
||||
prefill_used = "Based on the image,"
|
||||
|
||||
if prefill_used is not None:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": prefill_used
|
||||
})
|
||||
|
||||
# num_ctx par défaut à 2048 (correspondant au default du modèle
|
||||
# chargé en mémoire). Changer num_ctx force un rechargement du
|
||||
# KV cache (~30s de pénalité), donc ne l'augmenter que pour les
|
||||
# requêtes batch qui dépassent la limite (image + prompt long).
|
||||
effective_num_ctx = num_ctx or 2048
|
||||
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
@@ -121,13 +163,13 @@ class OllamaClient:
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
"num_ctx": 2048,
|
||||
"num_ctx": effective_num_ctx,
|
||||
"top_k": 1
|
||||
}
|
||||
}
|
||||
|
||||
# Désactiver le thinking pour les modèles qui le supportent
|
||||
# Cela réduit drastiquement la consommation de tokens et le temps
|
||||
# Garder think=false au cas où une future version d'Ollama le
|
||||
# corrige — le prefill reste le mécanisme principal
|
||||
if is_thinking_model:
|
||||
payload["think"] = False
|
||||
|
||||
@@ -144,6 +186,11 @@ class OllamaClient:
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Reconstituer la réponse complète en ajoutant le prefill
|
||||
if prefill_used and content:
|
||||
content = prefill_used + content
|
||||
|
||||
return {
|
||||
"response": content,
|
||||
"success": True,
|
||||
@@ -181,8 +228,11 @@ For each element, provide:
|
||||
- Semantic role (primary_action, cancel, submit, form_input, search_field, navigation, settings, close)
|
||||
|
||||
Format your response as JSON."""
|
||||
|
||||
result = self.generate(prompt, image_path=image_path, temperature=0.1)
|
||||
|
||||
result = self.generate(
|
||||
prompt, image_path=image_path, temperature=0.1,
|
||||
assistant_prefill="[",
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
try:
|
||||
@@ -214,14 +264,21 @@ Format your response as JSON."""
|
||||
Choose ONLY ONE from: {types_list}
|
||||
|
||||
Respond with just the type name, nothing else."""
|
||||
|
||||
|
||||
if context:
|
||||
prompt += f"\n\nContext: {context}"
|
||||
|
||||
result = self.generate(prompt, image=element_image, temperature=0.1)
|
||||
|
||||
result = self.generate(
|
||||
prompt, image=element_image, temperature=0.1,
|
||||
assistant_prefill="The type is:",
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
element_type = result["response"].strip().lower()
|
||||
# Retirer le prefill du début pour extraire le type
|
||||
raw = result["response"]
|
||||
if raw.startswith("The type is:"):
|
||||
raw = raw[len("The type is:"):]
|
||||
element_type = raw.strip().lower()
|
||||
# Valider que c'est un type connu
|
||||
valid_types = types_list.split(", ")
|
||||
if element_type in valid_types:
|
||||
@@ -255,14 +312,21 @@ Respond with just the type name, nothing else."""
|
||||
Choose ONLY ONE from: {roles_list}
|
||||
|
||||
Respond with just the role name, nothing else."""
|
||||
|
||||
|
||||
if context:
|
||||
prompt += f"\n\nContext: {context}"
|
||||
|
||||
result = self.generate(prompt, image=element_image, temperature=0.1)
|
||||
|
||||
result = self.generate(
|
||||
prompt, image=element_image, temperature=0.1,
|
||||
assistant_prefill="The role is:",
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
role = result["response"].strip().lower()
|
||||
# Retirer le prefill du début pour extraire le rôle
|
||||
raw = result["response"]
|
||||
if raw.startswith("The role is:"):
|
||||
raw = raw[len("The role is:"):]
|
||||
role = raw.strip().lower()
|
||||
# Valider que c'est un rôle connu
|
||||
valid_roles = roles_list.split(", ")
|
||||
if role in valid_roles:
|
||||
@@ -286,12 +350,19 @@ Respond with just the role name, nothing else."""
|
||||
Dict avec 'text' extrait
|
||||
"""
|
||||
prompt = "Extract all visible text from this image. Return only the text, nothing else."
|
||||
|
||||
result = self.generate(prompt, image=image, temperature=0.1)
|
||||
|
||||
result = self.generate(
|
||||
prompt, image=image, temperature=0.1,
|
||||
assistant_prefill="Text:",
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
return {"text": result["response"].strip(), "success": True}
|
||||
|
||||
# Retirer le prefill du début pour extraire le texte
|
||||
raw = result["response"]
|
||||
if raw.startswith("Text:"):
|
||||
raw = raw[len("Text:"):]
|
||||
return {"text": raw.strip(), "success": True}
|
||||
|
||||
return {"text": "", "success": False, "error": result["error"]}
|
||||
|
||||
# Taille minimum pour une classification fiable par le VLM
|
||||
@@ -346,7 +417,8 @@ Your answer:"""
|
||||
system_prompt=system_prompt,
|
||||
temperature=0.1,
|
||||
max_tokens=300,
|
||||
force_json=False
|
||||
force_json=False,
|
||||
assistant_prefill="{"
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
|
||||
Reference in New Issue
Block a user