feat(vwb): Ajouter SeeClick, Self-Healing interactif et Dashboard confiance

## Nouvelles fonctionnalités

### 1. SeeClick Adapter (visual grounding fallback)
- Nouvel adapter pour le modèle SeeClick (HuggingFace)
- Intégré dans la chaîne de fallback: CLIP → Template → SeeClick → Static
- Localise les éléments GUI à partir de descriptions textuelles

### 2. Self-Healing Interactif
- Dialogue qui propose des alternatives quand l'ancre n'est pas trouvée
- L'utilisateur peut choisir: candidat alternatif, coords statiques, ou sauter
- Nouveaux endpoints: /healing/status, /healing/choose, /healing/candidates
- État "waiting_for_choice" pour mettre l'exécution en pause

### 3. Dashboard Confiance (temps réel)
- Affiche les scores de confiance pendant l'exécution
- Montre: méthode utilisée, distance, taux de succès
- Interface pliable en bas à droite
- Visible uniquement en mode intelligent/debug

## Fichiers ajoutés
- core/detection/seeclick_adapter.py
- frontend_v4/src/components/SelfHealingDialog.tsx
- frontend_v4/src/components/ConfidenceDashboard.tsx

## Fichiers modifiés
- core/detection/__init__.py
- backend/services/intelligent_executor.py
- backend/api_v3/execute.py
- frontend_v4/src/App.tsx
- frontend_v4/src/services/api.ts
- docs/VISION_RPA_INTELLIGENT.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-24 02:34:01 +01:00
parent f04f156144
commit 21bfa3b337
9 changed files with 1656 additions and 13 deletions

View File

@@ -0,0 +1,44 @@
"""
Detection Module - Détection Sémantique d'Éléments UI
Ce module gère la détection et classification d'éléments UI
avec Vision-Language Models (VLM).
"""
from .ui_detector import (
UIDetector,
DetectionConfig,
create_detector
)
from .ollama_client import (
OllamaClient,
create_ollama_client,
check_ollama_available
)
# SeeClick pour visual grounding (fallback)
try:
from .seeclick_adapter import (
SeeClickAdapter,
get_seeclick,
ground_element,
GroundingResult
)
_seeclick_available = True
except ImportError:
_seeclick_available = False
__all__ = [
'UIDetector',
'DetectionConfig',
'create_detector',
'OllamaClient',
'create_ollama_client',
'check_ollama_available',
# SeeClick (si disponible)
'SeeClickAdapter',
'get_seeclick',
'ground_element',
'GroundingResult'
]

View File

@@ -0,0 +1,333 @@
"""
SeeClick Adapter pour RPA Vision V3
Intègre le modèle SeeClick pour le visual grounding sur GUI.
SeeClick peut localiser un élément GUI à partir d'une description textuelle
et retourner des coordonnées (x, y) de clic.
Usage:
adapter = SeeClickAdapter()
x, y = adapter.ground(screenshot_pil, "bouton Valider")
# Retourne les coordonnées de clic normalisées ou en pixels
"""
import os
import sys
from typing import Optional, Tuple, Dict, Any
from dataclasses import dataclass
from PIL import Image
import re
# Configuration
SEECLICK_MODEL_ID = "cckevinn/SeeClick"
SEECLICK_LOCAL_PATH = "/home/dom/ai/models/seeclick" # Cache local si téléchargé
@dataclass
class GroundingResult:
"""Résultat de grounding SeeClick"""
found: bool
x: Optional[float] # Coordonnée X normalisée (0-1) ou pixels
y: Optional[float] # Coordonnée Y normalisée (0-1) ou pixels
x_pixel: Optional[int] # Coordonnée X en pixels
y_pixel: Optional[int] # Coordonnée Y en pixels
confidence: float
raw_output: str # Sortie brute du modèle
class SeeClickAdapter:
"""
Adapter pour utiliser SeeClick comme grounding fallback.
SeeClick est un modèle vision-langage basé sur Qwen-VL qui peut
localiser des éléments GUI à partir de descriptions textuelles.
Il retourne des coordonnées (x, y) normalisées entre 0 et 1.
"""
_instance = None
_initialized = False
def __new__(cls):
"""Singleton pour éviter de charger le modèle plusieurs fois"""
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialise SeeClick (lazy loading)"""
if SeeClickAdapter._initialized:
return
self.model = None
self.tokenizer = None
self.available = False
self._device = None
self._check_availability()
def _check_availability(self):
"""Vérifie si SeeClick peut être chargé"""
try:
# Vérifier que transformers est disponible
from transformers import AutoModelForCausalLM, AutoTokenizer
# Vérifier si le modèle est en cache local ou accessible
if os.path.exists(SEECLICK_LOCAL_PATH):
print(f"✅ [SeeClick] Modèle local trouvé: {SEECLICK_LOCAL_PATH}")
self.available = True
else:
# On suppose qu'on peut télécharger depuis HuggingFace
print(f" [SeeClick] Modèle sera téléchargé depuis HuggingFace: {SEECLICK_MODEL_ID}")
self.available = True
print("✅ [SeeClick] Adapter disponible (chargement différé)")
except ImportError as e:
print(f"❌ [SeeClick] Dépendances manquantes: {e}")
self.available = False
except Exception as e:
print(f"❌ [SeeClick] Erreur vérification: {e}")
self.available = False
def _load_model(self):
"""Charge le modèle SeeClick (lazy loading)"""
if self.model is not None:
return True
if not self.available:
return False
try:
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
# Déterminer le device
self._device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f"🔄 [SeeClick] Chargement du modèle sur {self._device}...")
# Charger depuis le cache local ou HuggingFace
model_path = SEECLICK_LOCAL_PATH if os.path.exists(SEECLICK_LOCAL_PATH) else SEECLICK_MODEL_ID
self.tokenizer = AutoTokenizer.from_pretrained(
model_path,
trust_remote_code=True
)
self.model = AutoModelForCausalLM.from_pretrained(
model_path,
device_map=self._device,
trust_remote_code=True,
torch_dtype=torch.float16 if self._device == 'cuda' else torch.float32
)
SeeClickAdapter._initialized = True
print(f"✅ [SeeClick] Modèle chargé avec succès sur {self._device}")
return True
except Exception as e:
print(f"❌ [SeeClick] Erreur chargement modèle: {e}")
import traceback
traceback.print_exc()
self.available = False
return False
def ground(
self,
image: Image.Image,
description: str,
return_pixels: bool = True
) -> GroundingResult:
"""
Localise un élément GUI basé sur une description textuelle.
Args:
image: Image PIL du screenshot
description: Description de l'élément à trouver
Ex: "bouton Valider", "icône OnlyOffice", "champ de recherche"
return_pixels: Si True, retourne les coordonnées en pixels
Returns:
GroundingResult avec les coordonnées trouvées
"""
if not self._load_model():
return GroundingResult(
found=False,
x=None, y=None,
x_pixel=None, y_pixel=None,
confidence=0.0,
raw_output="Model not available"
)
try:
W, H = image.size
# Formatter le prompt pour SeeClick
# Format: "In this UI screenshot, what is the position of the element..."
prompt = f"In this UI screenshot, what is the position of the element corresponding to \"{description}\"?"
# Préparer l'input (dépend de l'implémentation Qwen-VL)
# SeeClick utilise le format chat de Qwen-VL
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": prompt}
]
}
]
# Tokeniser et générer
text = self.tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
inputs = self.tokenizer(text, return_tensors="pt")
if self._device == 'cuda':
inputs = {k: v.cuda() for k, v in inputs.items()}
outputs = self.model.generate(
**inputs,
max_new_tokens=100,
do_sample=False
)
# Décoder la sortie
response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
raw_output = response.split("assistant")[-1].strip() if "assistant" in response else response
print(f"🔍 [SeeClick] Sortie brute: {raw_output}")
# Parser les coordonnées (format: (0.xxx, 0.yyy) ou [0.xxx, 0.yyy])
coords = self._parse_coordinates(raw_output)
if coords:
x_norm, y_norm = coords
x_pixel = int(x_norm * W)
y_pixel = int(y_norm * H)
print(f"✅ [SeeClick] Trouvé: ({x_pixel}, {y_pixel}) = ({x_norm:.3f}, {y_norm:.3f}) normalisé")
return GroundingResult(
found=True,
x=x_pixel if return_pixels else x_norm,
y=y_pixel if return_pixels else y_norm,
x_pixel=x_pixel,
y_pixel=y_pixel,
confidence=0.8, # SeeClick ne donne pas de score explicite
raw_output=raw_output
)
else:
print(f"⚠️ [SeeClick] Pas de coordonnées trouvées dans: {raw_output}")
return GroundingResult(
found=False,
x=None, y=None,
x_pixel=None, y_pixel=None,
confidence=0.0,
raw_output=raw_output
)
except Exception as e:
print(f"❌ [SeeClick] Erreur grounding: {e}")
import traceback
traceback.print_exc()
return GroundingResult(
found=False,
x=None, y=None,
x_pixel=None, y_pixel=None,
confidence=0.0,
raw_output=str(e)
)
def _parse_coordinates(self, text: str) -> Optional[Tuple[float, float]]:
"""
Parse les coordonnées depuis la sortie du modèle.
Formats supportés:
- (0.123, 0.456)
- [0.123, 0.456]
- click(0.123, 0.456)
- x: 0.123, y: 0.456
"""
# Pattern pour (x, y) ou [x, y]
pattern1 = r'[\(\[]?\s*(\d+\.?\d*)\s*[,;]\s*(\d+\.?\d*)\s*[\)\]]?'
# Pattern pour click(x, y)
pattern2 = r'click\s*[\(\[]?\s*(\d+\.?\d*)\s*[,;]\s*(\d+\.?\d*)'
# Pattern pour x: 0.xxx, y: 0.yyy
pattern3 = r'x\s*[:=]\s*(\d+\.?\d*).*?y\s*[:=]\s*(\d+\.?\d*)'
for pattern in [pattern2, pattern1, pattern3]:
matches = re.findall(pattern, text, re.IGNORECASE)
if matches:
x_str, y_str = matches[0]
try:
x = float(x_str)
y = float(y_str)
# Si les valeurs sont > 1, elles sont probablement en pixels
# Dans ce cas, on ne peut pas les normaliser sans connaître la taille
if x > 1 or y > 1:
# Essayer de normaliser si > 1 (supposer pixels)
# Mais on ne sait pas la taille de l'image ici...
# On retourne tel quel et on laisse l'appelant gérer
pass
return (x, y)
except ValueError:
continue
return None
def ground_with_anchor(
self,
image: Image.Image,
anchor_image: Image.Image,
anchor_description: Optional[str] = None
) -> GroundingResult:
"""
Localise un élément similaire à une ancre.
Si une description est fournie, l'utilise.
Sinon, essaie de décrire l'ancre automatiquement.
Args:
image: Screenshot actuel
anchor_image: Image de l'ancre à trouver
anchor_description: Description optionnelle de l'ancre
Returns:
GroundingResult avec les coordonnées trouvées
"""
if anchor_description:
description = anchor_description
else:
# Générer une description générique
# On pourrait utiliser un captioning model ici
description = "the element that looks like the given target"
return self.ground(image, description)
# Instance globale (singleton)
_seeclick_instance: Optional[SeeClickAdapter] = None
def get_seeclick() -> SeeClickAdapter:
"""Retourne l'instance singleton de SeeClick"""
global _seeclick_instance
if _seeclick_instance is None:
_seeclick_instance = SeeClickAdapter()
return _seeclick_instance
def ground_element(
image: Image.Image,
description: str,
return_pixels: bool = True
) -> GroundingResult:
"""Fonction utilitaire pour localiser un élément"""
return get_seeclick().ground(image, description, return_pixels)

View File

@@ -229,14 +229,23 @@ VWB génère des données pour entraîner le moteur principal :
## Prochaines Étapes ## Prochaines Étapes
### Fait ✅
1. [x] Frontend VWB v4 avec React Flow 1. [x] Frontend VWB v4 avec React Flow
2. [ ] Toggle Mode Basique/Intelligent/Debug 2. [x] Toggle Mode Basique/Intelligent/Debug
3. [ ] Intégration UI-DETR-1 pour détection 3. [x] Intégration UI-DETR-1 pour détection
4. [ ] Intégration SeeClick en fallback 4. [x] Overlay Debug (affichage bboxes en temps réel)
5. [ ] Overlay Debug (affichage bboxes) 5. [x] Exécution intelligente (template matching)
6. [ ] Export données d'apprentissage 6. [x] Sélection de zone de détection sur capture fixe
7. [ ] Connexion au moteur principal 7. [x] Intégration SeeClick en fallback (grounding) - 24 janvier 2026
8. [x] Self-healing interactif (proposer alternatives quand échec) - 24 janvier 2026
9. [x] Dashboard confiance (scores en temps réel) - 24 janvier 2026
### À faire
10. [ ] Export données d'apprentissage (format JSON)
11. [ ] Apprentissage des corrections (feedback loop)
12. [ ] Connexion au moteur principal (agents autonomes)
--- ---
*Document créé le 23 janvier 2026* *Document créé le 23 janvier 2026*
*Mis à jour le 24 janvier 2026*

View File

@@ -52,7 +52,13 @@ _execution_state = {
'should_stop': False, 'should_stop': False,
'current_execution_id': None, 'current_execution_id': None,
'thread': None, 'thread': None,
'execution_mode': 'basic' # 'basic', 'intelligent', 'debug' 'execution_mode': 'basic', # 'basic', 'intelligent', 'debug'
# Self-healing interactif
'waiting_for_choice': False,
'pending_action': None, # Action en attente de choix utilisateur
'candidates': [], # Candidats proposés
'user_choice': None, # Choix de l'utilisateur (coordonnées ou 'skip' ou 'static')
'current_step_info': None # Info sur l'étape en cours pour affichage
} }
@@ -164,6 +170,85 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
# === EXÉCUTION DE L'ACTION === # === EXÉCUTION DE L'ACTION ===
result = execute_action(step.action_type, params) result = execute_action(step.action_type, params)
# === SELF-HEALING INTERACTIF ===
# Si l'action nécessite un choix utilisateur, attendre
if result.get('needs_user_choice'):
print(f"🔄 [Self-Healing] Attente choix utilisateur pour étape {index + 1}")
# Stocker les informations pour le frontend
_execution_state['waiting_for_choice'] = True
_execution_state['pending_action'] = {
'step_id': step.id,
'step_index': index,
'action_type': step.action_type,
'params': params
}
_execution_state['candidates'] = result.get('candidates', [])
_execution_state['current_step_info'] = {
'index': index,
'total': len(steps),
'original_bbox': result.get('original_bbox'),
'error': result.get('error')
}
_execution_state['user_choice'] = None
# Mettre à jour le status de l'exécution
execution.status = 'waiting_user_choice'
db.session.commit()
# Attendre le choix de l'utilisateur
timeout_seconds = 120 # 2 minutes max
waited = 0
while _execution_state['waiting_for_choice'] and waited < timeout_seconds:
if _execution_state['should_stop']:
break
time.sleep(0.5)
waited += 0.5
# Vérifier si on doit arrêter
if _execution_state['should_stop']:
execution.status = 'cancelled'
break
# Traiter le choix de l'utilisateur
user_choice = _execution_state['user_choice']
_execution_state['waiting_for_choice'] = False
_execution_state['pending_action'] = None
_execution_state['candidates'] = []
if user_choice is None:
# Timeout - aucun choix
step_result.status = 'error'
step_result.error_message = "Timeout: aucun choix utilisateur"
execution.failed_steps += 1
execution.status = 'error'
execution.error_message = f"Timeout à l'étape {index + 1}: aucun choix utilisateur"
db.session.commit()
break
elif user_choice == 'skip':
# Utilisateur a choisi de sauter l'étape
step_result.status = 'skipped'
step_result.output = {'skipped_by_user': True}
execution.status = 'running'
db.session.commit()
print(f"⏭️ [Self-Healing] Étape {index + 1} sautée par l'utilisateur")
continue
elif user_choice == 'static':
# Utiliser les coordonnées statiques
result = execute_action_with_static_coords(step.action_type, params)
execution.status = 'running'
elif isinstance(user_choice, dict) and 'x' in user_choice:
# Coordonnées choisies par l'utilisateur
result = execute_action_with_coords(step.action_type, params, user_choice)
execution.status = 'running'
else:
step_result.status = 'error'
step_result.error_message = f"Choix invalide: {user_choice}"
execution.failed_steps += 1
execution.status = 'error'
db.session.commit()
break
step_result.ended_at = datetime.utcnow() step_result.ended_at = datetime.utcnow()
step_result.duration_ms = int((step_result.ended_at - step_result.started_at).total_seconds() * 1000) step_result.duration_ms = int((step_result.ended_at - step_result.started_at).total_seconds() * 1000)
@@ -323,6 +408,75 @@ def execute_ai_analyze(params: dict) -> dict:
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
def execute_action_with_coords(action_type: str, params: dict, coords: dict) -> dict:
"""
Exécute une action avec des coordonnées spécifiées par l'utilisateur (self-healing).
"""
import pyautogui
import time
try:
x, y = coords['x'], coords['y']
print(f"🖱️ [Self-Healing] Clic aux coordonnées choisies: ({x}, {y})")
if action_type in ['double_click_anchor']:
pyautogui.doubleClick(x, y)
elif action_type in ['right_click_anchor']:
pyautogui.rightClick(x, y)
else:
pyautogui.click(x, y)
time.sleep(2.0) # Délai après le clic
return {
'success': True,
'output': {
'clicked_at': {'x': x, 'y': y},
'method': 'user_choice',
'self_healed': True
}
}
except Exception as e:
return {'success': False, 'error': str(e)}
def execute_action_with_static_coords(action_type: str, params: dict) -> dict:
"""
Exécute une action avec les coordonnées statiques originales (fallback self-healing).
"""
import pyautogui
import time
try:
anchor = params.get('visual_anchor', {})
bbox = anchor.get('bounding_box', {})
x = bbox.get('x', 0) + bbox.get('width', 0) / 2
y = bbox.get('y', 0) + bbox.get('height', 0) / 2
print(f"🖱️ [Self-Healing] Clic aux coordonnées statiques: ({x}, {y})")
if action_type in ['double_click_anchor']:
pyautogui.doubleClick(x, y)
elif action_type in ['right_click_anchor']:
pyautogui.rightClick(x, y)
else:
pyautogui.click(x, y)
time.sleep(2.0)
return {
'success': True,
'output': {
'clicked_at': {'x': x, 'y': y},
'method': 'static_fallback',
'self_healed': True
}
}
except Exception as e:
return {'success': False, 'error': str(e)}
def execute_action(action_type: str, params: dict) -> dict: def execute_action(action_type: str, params: dict) -> dict:
""" """
Exécute une action RPA. Exécute une action RPA.
@@ -403,11 +557,25 @@ def execute_action(action_type: str, params: dict) -> dict:
} }
else: else:
# En mode intelligent/debug, on refuse d'utiliser les coordonnées statiques # En mode intelligent/debug, on refuse d'utiliser les coordonnées statiques
# si l'ancre n'est pas trouvée - cela évite les clics au mauvais endroit # si l'ancre n'est pas trouvée - MAIS on peut proposer des alternatives
reason = result.get('reason', 'Ancre non trouvée à l\'écran') reason = result.get('reason', 'Ancre non trouvée à l\'écran')
confidence = result.get('confidence', 0) confidence = result.get('confidence', 0)
candidates = result.get('candidates', [])
print(f"❌ [Vision] Ancre NON trouvée (confiance: {confidence:.2f})") print(f"❌ [Vision] Ancre NON trouvée (confiance: {confidence:.2f})")
print(f" Raison: {reason}") print(f" Raison: {reason}")
# Si self-healing interactif activé, proposer des alternatives
if _execution_state.get('execution_mode') == 'intelligent' and candidates:
print(f"🔄 [Self-Healing] {len(candidates)} candidats disponibles - attente choix utilisateur")
return {
'success': False,
'needs_user_choice': True, # Flag pour self-healing
'error': f"Ancre non trouvée - veuillez choisir parmi les alternatives",
'candidates': candidates,
'original_bbox': bbox,
'confidence': confidence
}
return { return {
'success': False, 'success': False,
'error': f"Ancre non trouvée à l'écran (confiance: {confidence:.2f}). {reason}" 'error': f"Ancre non trouvée à l'écran (confiance: {confidence:.2f}). {reason}"
@@ -707,7 +875,11 @@ def get_execution_status():
'is_paused': _execution_state['is_paused'], 'is_paused': _execution_state['is_paused'],
'execution_mode': _execution_state.get('execution_mode', 'basic'), 'execution_mode': _execution_state.get('execution_mode', 'basic'),
'execution': execution.to_dict() if execution else None, 'execution': execution.to_dict() if execution else None,
'session': session.to_dict() 'session': session.to_dict(),
# Self-healing interactif
'waiting_for_choice': _execution_state.get('waiting_for_choice', False),
'candidates': _execution_state.get('candidates', []) if _execution_state.get('waiting_for_choice') else [],
'current_step_info': _execution_state.get('current_step_info') if _execution_state.get('waiting_for_choice') else None
}) })
@@ -734,3 +906,121 @@ def get_execution_history():
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}), 500 }), 500
# ==============================================================================
# SELF-HEALING INTERACTIF - Endpoints
# ==============================================================================
@api_v3_bp.route('/execute/healing/status', methods=['GET'])
def get_healing_status():
"""
Retourne l'état du self-healing interactif.
Si waiting_for_choice est True, retourne les candidats à afficher.
"""
global _execution_state
return jsonify({
'success': True,
'waiting_for_choice': _execution_state.get('waiting_for_choice', False),
'candidates': _execution_state.get('candidates', []),
'current_step_info': _execution_state.get('current_step_info'),
'pending_action': _execution_state.get('pending_action')
})
@api_v3_bp.route('/execute/healing/choose', methods=['POST'])
def submit_healing_choice():
"""
Soumet le choix de l'utilisateur pour le self-healing.
Request:
{
"choice": "skip" | "static" | {"x": 123, "y": 456}
}
- "skip": Sauter cette étape et continuer
- "static": Utiliser les coordonnées statiques originales
- {"x": N, "y": N}: Utiliser ces coordonnées précises
"""
global _execution_state
if not _execution_state.get('waiting_for_choice'):
return jsonify({
'success': False,
'error': "Aucune décision en attente"
}), 400
data = request.get_json() or {}
choice = data.get('choice')
if choice is None:
return jsonify({
'success': False,
'error': "Choix non spécifié"
}), 400
# Valider le choix
valid_choices = ['skip', 'static']
if isinstance(choice, str) and choice not in valid_choices:
return jsonify({
'success': False,
'error': f"Choix invalide. Valeurs acceptées: {valid_choices} ou {{x, y}}"
}), 400
if isinstance(choice, dict):
if 'x' not in choice or 'y' not in choice:
return jsonify({
'success': False,
'error': "Coordonnées invalides. Format attendu: {x: number, y: number}"
}), 400
# Stocker le choix
_execution_state['user_choice'] = choice
_execution_state['waiting_for_choice'] = False
print(f"✅ [Self-Healing] Choix reçu: {choice}")
return jsonify({
'success': True,
'choice': choice
})
@api_v3_bp.route('/execute/healing/candidates', methods=['GET'])
def get_healing_candidates():
"""
Retourne les candidats avec leurs screenshots pour affichage visuel.
Permet à l'utilisateur de voir les alternatives possibles.
"""
global _execution_state
candidates = _execution_state.get('candidates', [])
step_info = _execution_state.get('current_step_info', {})
# Enrichir les candidats avec plus d'informations si disponible
enriched_candidates = []
for i, candidate in enumerate(candidates):
enriched = {
'id': i,
'element_id': candidate.get('element_id'),
'score': candidate.get('score', candidate.get('combined_score', 0)),
'bbox': candidate.get('bbox', {}),
'distance': candidate.get('distance'),
'method': candidate.get('method', 'unknown')
}
# Calculer le centre si bbox disponible
bbox = candidate.get('bbox', {})
if bbox:
enriched['center'] = {
'x': (bbox.get('x1', 0) + bbox.get('x2', 0)) // 2,
'y': (bbox.get('y1', 0) + bbox.get('y2', 0)) // 2
}
enriched_candidates.append(enriched)
return jsonify({
'success': True,
'candidates': enriched_candidates,
'step_info': step_info,
'original_bbox': step_info.get('original_bbox')
})

View File

@@ -6,11 +6,18 @@ Utilise UI-DETR-1 pour la détection et le matching d'ancres visuelles
import time import time
import base64 import base64
import io import io
import sys
import os
from typing import Dict, Any, Optional, List, Tuple from typing import Dict, Any, Optional, List, Tuple
from dataclasses import dataclass from dataclasses import dataclass
from PIL import Image from PIL import Image
import numpy as np import numpy as np
# Ajouter le chemin racine pour les imports de core
RPA_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
if RPA_ROOT not in sys.path:
sys.path.insert(0, RPA_ROOT)
# Import du service de détection UI # Import du service de détection UI
from .ui_detection_service import detect_ui_elements, DetectionResult, UIElement from .ui_detection_service import detect_ui_elements, DetectionResult, UIElement
@@ -764,7 +771,53 @@ def find_and_click(
global_result['search_time_ms'] = (_time.time() - start_time) * 1000 global_result['search_time_ms'] = (_time.time() - start_time) * 1000
return global_result return global_result
# === STRATÉGIE 4: Coordonnées statiques (dernier recours) === # === STRATÉGIE 4: SeeClick (visual grounding) ===
# Essayer SeeClick si les autres méthodes ont échoué
try:
print("🎯 [Vision] Essai SeeClick (visual grounding)...")
from core.detection.seeclick_adapter import get_seeclick
seeclick = get_seeclick()
if seeclick.available:
# Utiliser une description générique basée sur l'ancre
# TODO: Améliorer avec une description plus précise
description = "the clickable element or button"
grounding_result = seeclick.ground(screen_image, description, return_pixels=True)
if grounding_result.found:
found_x = grounding_result.x_pixel
found_y = grounding_result.y_pixel
# Vérifier la distance à la position originale si anchor_bbox existe
accept_seeclick = True
if anchor_bbox:
orig_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) // 2
orig_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) // 2
distance = np.sqrt((found_x - orig_x)**2 + (found_y - orig_y)**2)
MAX_SEECLICK_DISTANCE = 200 # Plus permissif car c'est un fallback
if distance > MAX_SEECLICK_DISTANCE:
print(f"⛔ [Vision] SeeClick rejeté: distance {distance:.0f}px > {MAX_SEECLICK_DISTANCE}px max")
accept_seeclick = False
if accept_seeclick:
print(f"✅ [Vision] SeeClick réussi! Coordonnées: ({found_x}, {found_y})")
return {
'found': True,
'confidence': grounding_result.confidence,
'coordinates': {'x': found_x, 'y': found_y},
'bbox': anchor_bbox,
'method': 'seeclick_grounding',
'search_time_ms': (_time.time() - start_time) * 1000,
'raw_output': grounding_result.raw_output
}
except ImportError:
print(" [Vision] SeeClick non disponible (module non trouvé)")
except Exception as seeclick_err:
print(f"⚠️ [Vision] Erreur SeeClick: {seeclick_err}")
# === STRATÉGIE 5: Coordonnées statiques (dernier recours) ===
if anchor_bbox: if anchor_bbox:
best_conf = max(global_result.get('confidence', 0), 0) best_conf = max(global_result.get('confidence', 0), 0)

View File

@@ -25,6 +25,8 @@ import ExecutionOverlay from './components/ExecutionOverlay';
import VariableManager from './components/VariableManager'; import VariableManager from './components/VariableManager';
import type { Variable } from './components/VariableManager'; import type { Variable } from './components/VariableManager';
import CaptureLibrary from './components/CaptureLibrary'; import CaptureLibrary from './components/CaptureLibrary';
import SelfHealingDialog from './components/SelfHealingDialog';
import ConfidenceDashboard from './components/ConfidenceDashboard';
const nodeTypes: NodeTypes = { const nodeTypes: NodeTypes = {
step: StepNode, step: StepNode,
@@ -44,6 +46,11 @@ function App() {
const [showWorkflowManager, setShowWorkflowManager] = useState(false); const [showWorkflowManager, setShowWorkflowManager] = useState(false);
const [currentCapture, setCurrentCapture] = useState<Capture | null>(null); const [currentCapture, setCurrentCapture] = useState<Capture | null>(null);
// Self-healing interactif
const [showSelfHealing, setShowSelfHealing] = useState(false);
const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
const [healingStepInfo, setHealingStepInfo] = useState<any>(null);
// Charger l'état initial // Charger l'état initial
const loadState = useCallback(async () => { const loadState = useCallback(async () => {
try { try {
@@ -68,11 +75,19 @@ function App() {
const status = await api.getExecutionStatus(); const status = await api.getExecutionStatus();
setIsExecutionRunning(status.is_running); setIsExecutionRunning(status.is_running);
// Self-healing interactif: detecter si on attend un choix utilisateur
if (status.waiting_for_choice && status.candidates) {
setHealingCandidates(status.candidates);
setHealingStepInfo(status.current_step_info);
setShowSelfHealing(true);
}
// Mettre à jour l'état si l'exécution est terminée // Mettre à jour l'état si l'exécution est terminée
// Note: Ne PAS fermer l'overlay automatiquement pour permettre // Note: Ne PAS fermer l'overlay automatiquement pour permettre
// à l'utilisateur de voir les résultats de détection // à l'utilisateur de voir les résultats de détection
if (!status.is_running) { if (!status.is_running) {
await loadState(); await loadState();
setShowSelfHealing(false);
// L'overlay reste visible, l'utilisateur peut le fermer manuellement // L'overlay reste visible, l'utilisateur peut le fermer manuellement
} }
} catch (err) { } catch (err) {
@@ -289,6 +304,18 @@ function App() {
setVariables(prev => prev.filter(v => v.id !== id)); setVariables(prev => prev.filter(v => v.id !== id));
}; };
// Self-healing: soumettre le choix de l'utilisateur
const handleSelfHealingChoice = async (choice: 'skip' | 'static' | { x: number; y: number }) => {
try {
await api.submitHealingChoice(choice);
setShowSelfHealing(false);
setHealingCandidates([]);
setHealingStepInfo(null);
} catch (err) {
setError((err as Error).message);
}
};
// Drop d'un outil sur le canvas // Drop d'un outil sur le canvas
const onDrop = useCallback( const onDrop = useCallback(
(event: React.DragEvent) => { (event: React.DragEvent) => {
@@ -433,6 +460,24 @@ function App() {
onClose={() => setShowWorkflowManager(false)} onClose={() => setShowWorkflowManager(false)}
/> />
)} )}
{/* Self-Healing Dialog */}
<SelfHealingDialog
isOpen={showSelfHealing}
candidates={healingCandidates}
stepInfo={healingStepInfo}
onChoose={handleSelfHealingChoice}
onClose={() => {
setShowSelfHealing(false);
handleStopExecution();
}}
/>
{/* Confidence Dashboard - scores en temps reel */}
<ConfidenceDashboard
isExecutionRunning={isExecutionRunning}
executionMode={executionMode}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,406 @@
/**
* Confidence Dashboard Component
*
* Affiche les scores de confiance en temps réel pendant l'exécution.
* Montre CLIP score, template score, distance et méthode utilisée.
*/
import { useState, useEffect } from 'react';
interface StepScore {
stepIndex: number;
stepType: string;
method: string;
confidence: number;
distance?: number;
clipScore?: number;
templateScore?: number;
timestamp: number;
success: boolean;
}
interface Props {
isExecutionRunning: boolean;
executionMode: 'basic' | 'intelligent' | 'debug';
}
export default function ConfidenceDashboard({ isExecutionRunning, executionMode }: Props) {
const [scores, setScores] = useState<StepScore[]>([]);
const [currentStep, setCurrentStep] = useState<number>(0);
const [isExpanded, setIsExpanded] = useState(true);
// Polling pour les scores en temps réel
useEffect(() => {
if (!isExecutionRunning) return;
const pollScores = async () => {
try {
const response = await fetch('/api/v3/execute/status');
const data = await response.json();
if (data.success && data.execution) {
setCurrentStep(data.execution.current_step_index || 0);
// Si on a des resultats d'etapes, les ajouter
if (data.execution.step_results) {
const newScores: StepScore[] = data.execution.step_results.map((result: any, index: number) => ({
stepIndex: index,
stepType: result.action_type || 'unknown',
method: result.output?.method || 'static',
confidence: result.output?.confidence || 1.0,
distance: result.output?.distance,
clipScore: result.output?.clip_score,
templateScore: result.output?.template_score,
timestamp: new Date(result.ended_at).getTime(),
success: result.status === 'success'
}));
setScores(newScores);
}
}
} catch (err) {
console.error('Erreur polling scores:', err);
}
};
const interval = setInterval(pollScores, 1000);
return () => clearInterval(interval);
}, [isExecutionRunning]);
// Reset quand l'execution s'arrete
useEffect(() => {
if (!isExecutionRunning) {
// Garder les scores pour review
}
}, [isExecutionRunning]);
if (executionMode === 'basic') {
return null; // Pas de dashboard en mode basic
}
const getConfidenceColor = (confidence: number): string => {
if (confidence >= 0.8) return '#a6e3a1'; // Vert
if (confidence >= 0.5) return '#f9e2af'; // Jaune
return '#f38ba8'; // Rouge
};
const getMethodIcon = (method: string): string => {
switch (method) {
case 'clip': return '🧠';
case 'clip_embedding': return '🧠';
case 'zoned_template': return '📍';
case 'direct_template': return '🔍';
case 'seeclick_grounding': return '🎯';
case 'static_fallback': return '📌';
case 'user_choice': return '👆';
default: return '⚡';
}
};
const averageConfidence = scores.length > 0
? scores.reduce((acc, s) => acc + s.confidence, 0) / scores.length
: 0;
const successRate = scores.length > 0
? (scores.filter(s => s.success).length / scores.length) * 100
: 0;
return (
<div className="confidence-dashboard">
<div className="dashboard-header" onClick={() => setIsExpanded(!isExpanded)}>
<div className="header-left">
<span className="dashboard-icon">📊</span>
<span className="dashboard-title">Scores de confiance</span>
{isExecutionRunning && (
<span className="live-indicator">LIVE</span>
)}
</div>
<div className="header-right">
<span className="toggle-icon">{isExpanded ? '▼' : '▶'}</span>
</div>
</div>
{isExpanded && (
<div className="dashboard-content">
{/* Metriques globales */}
<div className="metrics-row">
<div className="metric">
<span className="metric-label">Etape actuelle</span>
<span className="metric-value">{currentStep + 1}</span>
</div>
<div className="metric">
<span className="metric-label">Confiance moy.</span>
<span
className="metric-value"
style={{ color: getConfidenceColor(averageConfidence) }}
>
{(averageConfidence * 100).toFixed(0)}%
</span>
</div>
<div className="metric">
<span className="metric-label">Taux succes</span>
<span
className="metric-value"
style={{ color: getConfidenceColor(successRate / 100) }}
>
{successRate.toFixed(0)}%
</span>
</div>
</div>
{/* Liste des scores par etape */}
<div className="scores-list">
{scores.length === 0 ? (
<div className="no-scores">
{isExecutionRunning
? "En attente de resultats..."
: "Aucune execution en cours"}
</div>
) : (
scores.map((score) => (
<div
key={score.stepIndex}
className={`score-item ${score.success ? 'success' : 'error'} ${score.stepIndex === currentStep ? 'current' : ''}`}
>
<div className="score-step">
<span className="step-number">#{score.stepIndex + 1}</span>
<span className="method-icon">{getMethodIcon(score.method)}</span>
</div>
<div className="score-details">
<span className="method-name">{score.method}</span>
{score.distance !== undefined && (
<span className="distance">{score.distance.toFixed(0)}px</span>
)}
</div>
<div
className="confidence-bar"
style={{
'--confidence': `${score.confidence * 100}%`,
'--confidence-color': getConfidenceColor(score.confidence)
} as React.CSSProperties}
>
<span className="confidence-value">
{(score.confidence * 100).toFixed(0)}%
</span>
</div>
</div>
))
)}
</div>
{/* Legende */}
<div className="legend">
<span className="legend-item">🧠 CLIP</span>
<span className="legend-item">📍 Template zone</span>
<span className="legend-item">🎯 SeeClick</span>
<span className="legend-item">📌 Static</span>
</div>
</div>
)}
<style>{`
.confidence-dashboard {
position: fixed;
bottom: 20px;
right: 20px;
width: 320px;
background: #1e1e2e;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid #313244;
overflow: hidden;
z-index: 1000;
font-size: 13px;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #313244;
cursor: pointer;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.dashboard-icon {
font-size: 16px;
}
.dashboard-title {
font-weight: 600;
color: #cdd6f4;
}
.live-indicator {
padding: 2px 6px;
background: #f38ba8;
color: #1e1e2e;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.toggle-icon {
color: #a6adc8;
font-size: 10px;
}
.dashboard-content {
padding: 12px;
}
.metrics-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #313244;
}
.metric {
text-align: center;
}
.metric-label {
display: block;
font-size: 10px;
color: #a6adc8;
margin-bottom: 4px;
text-transform: uppercase;
}
.metric-value {
font-size: 18px;
font-weight: bold;
color: #cdd6f4;
}
.scores-list {
max-height: 200px;
overflow-y: auto;
}
.no-scores {
text-align: center;
color: #a6adc8;
padding: 20px;
font-style: italic;
}
.score-item {
display: flex;
align-items: center;
padding: 8px;
margin-bottom: 4px;
background: #313244;
border-radius: 6px;
gap: 8px;
}
.score-item.current {
border: 1px solid #89b4fa;
background: rgba(137, 180, 250, 0.1);
}
.score-item.error {
border-left: 3px solid #f38ba8;
}
.score-item.success {
border-left: 3px solid #a6e3a1;
}
.score-step {
display: flex;
align-items: center;
gap: 4px;
min-width: 50px;
}
.step-number {
color: #89b4fa;
font-weight: bold;
}
.method-icon {
font-size: 14px;
}
.score-details {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.method-name {
color: #cdd6f4;
font-size: 11px;
}
.distance {
color: #fab387;
font-size: 10px;
}
.confidence-bar {
width: 60px;
height: 20px;
background: #45475a;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.confidence-bar::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--confidence);
background: var(--confidence-color);
border-radius: 4px;
transition: width 0.3s;
}
.confidence-value {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 10px;
font-weight: bold;
color: #1e1e2e;
}
.legend {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid #313244;
}
.legend-item {
font-size: 10px;
color: #a6adc8;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,422 @@
/**
* Self-Healing Dialog Component
*
* Affiche les candidats alternatifs quand l'ancre n'est pas trouvée
* et permet à l'utilisateur de choisir une action.
*/
import { useState, useEffect } from 'react';
interface Candidate {
id: number;
element_id: number;
score: number;
bbox: {
x1: number;
y1: number;
x2: number;
y2: number;
};
center?: {
x: number;
y: number;
};
distance?: number;
method?: string;
}
interface StepInfo {
index: number;
total: number;
original_bbox?: {
x: number;
y: number;
width: number;
height: number;
};
error?: string;
}
interface Props {
isOpen: boolean;
candidates: Candidate[];
stepInfo: StepInfo | null;
onChoose: (choice: 'skip' | 'static' | { x: number; y: number }) => void;
onClose: () => void;
}
export default function SelfHealingDialog({ isOpen, candidates, stepInfo, onChoose, onClose }: Props) {
const [selectedCandidate, setSelectedCandidate] = useState<number | null>(null);
const [customCoords, setCustomCoords] = useState({ x: '', y: '' });
// Reset quand le dialog s'ouvre
useEffect(() => {
if (isOpen) {
setSelectedCandidate(null);
setCustomCoords({ x: '', y: '' });
}
}, [isOpen]);
if (!isOpen) return null;
const handleCandidateClick = (candidate: Candidate) => {
setSelectedCandidate(candidate.id);
if (candidate.center) {
setCustomCoords({
x: candidate.center.x.toString(),
y: candidate.center.y.toString()
});
}
};
const handleSubmit = () => {
if (customCoords.x && customCoords.y) {
onChoose({
x: parseInt(customCoords.x),
y: parseInt(customCoords.y)
});
}
};
return (
<div className="self-healing-overlay">
<div className="self-healing-dialog">
<div className="dialog-header">
<h2>Self-Healing Required</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="dialog-content">
{stepInfo && (
<div className="step-info">
<span className="step-badge">
Etape {stepInfo.index + 1}/{stepInfo.total}
</span>
<p className="error-message">{stepInfo.error || "L'ancre visuelle n'a pas ete trouvee"}</p>
</div>
)}
<div className="options-section">
<h3>Alternatives possibles</h3>
{candidates.length > 0 ? (
<div className="candidates-list">
{candidates.map((candidate) => (
<div
key={candidate.id}
className={`candidate-item ${selectedCandidate === candidate.id ? 'selected' : ''}`}
onClick={() => handleCandidateClick(candidate)}
>
<div className="candidate-info">
<span className="candidate-id">#{candidate.element_id}</span>
<span className="candidate-score">
Confiance: {(candidate.score * 100).toFixed(0)}%
</span>
{candidate.distance !== undefined && (
<span className="candidate-distance">
Distance: {candidate.distance.toFixed(0)}px
</span>
)}
</div>
{candidate.center && (
<div className="candidate-coords">
({candidate.center.x}, {candidate.center.y})
</div>
)}
</div>
))}
</div>
) : (
<p className="no-candidates">Aucun candidat similaire trouve</p>
)}
</div>
<div className="manual-section">
<h3>Coordonnees manuelles</h3>
<div className="coords-input">
<label>
X:
<input
type="number"
value={customCoords.x}
onChange={(e) => setCustomCoords(prev => ({ ...prev, x: e.target.value }))}
placeholder="X"
/>
</label>
<label>
Y:
<input
type="number"
value={customCoords.y}
onChange={(e) => setCustomCoords(prev => ({ ...prev, y: e.target.value }))}
placeholder="Y"
/>
</label>
</div>
</div>
</div>
<div className="dialog-actions">
<button
className="action-btn primary"
onClick={handleSubmit}
disabled={!customCoords.x || !customCoords.y}
>
Utiliser ces coordonnees
</button>
<button
className="action-btn secondary"
onClick={() => onChoose('static')}
>
Utiliser position originale
</button>
<button
className="action-btn warning"
onClick={() => onChoose('skip')}
>
Sauter cette etape
</button>
</div>
</div>
<style>{`
.self-healing-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.self-healing-dialog {
background: #1e1e2e;
border-radius: 12px;
width: 500px;
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
border: 1px solid #313244;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #313244;
border-bottom: 1px solid #45475a;
}
.dialog-header h2 {
margin: 0;
font-size: 18px;
color: #f5c2e7;
}
.close-btn {
background: none;
border: none;
color: #a6adc8;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: #f38ba8;
}
.dialog-content {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.step-info {
margin-bottom: 20px;
padding: 12px;
background: #313244;
border-radius: 8px;
}
.step-badge {
display: inline-block;
padding: 4px 8px;
background: #89b4fa;
color: #1e1e2e;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
margin-bottom: 8px;
}
.error-message {
margin: 0;
color: #f9e2af;
font-size: 14px;
}
.options-section, .manual-section {
margin-bottom: 20px;
}
h3 {
color: #cdd6f4;
font-size: 14px;
margin: 0 0 12px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.candidates-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.candidate-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #313244;
border-radius: 8px;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.candidate-item:hover {
background: #45475a;
}
.candidate-item.selected {
border-color: #89b4fa;
background: rgba(137, 180, 250, 0.1);
}
.candidate-info {
display: flex;
gap: 12px;
align-items: center;
}
.candidate-id {
color: #89b4fa;
font-weight: bold;
}
.candidate-score {
color: #a6e3a1;
font-size: 13px;
}
.candidate-distance {
color: #fab387;
font-size: 13px;
}
.candidate-coords {
color: #a6adc8;
font-family: monospace;
font-size: 13px;
}
.no-candidates {
color: #a6adc8;
font-style: italic;
text-align: center;
padding: 20px;
}
.coords-input {
display: flex;
gap: 16px;
}
.coords-input label {
display: flex;
align-items: center;
gap: 8px;
color: #a6adc8;
font-size: 14px;
}
.coords-input input {
width: 100px;
padding: 8px 12px;
background: #313244;
border: 1px solid #45475a;
border-radius: 6px;
color: #cdd6f4;
font-size: 14px;
}
.coords-input input:focus {
outline: none;
border-color: #89b4fa;
}
.dialog-actions {
display: flex;
gap: 12px;
padding: 16px 20px;
background: #313244;
border-top: 1px solid #45475a;
}
.action-btn {
flex: 1;
padding: 12px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.primary {
background: #89b4fa;
color: #1e1e2e;
}
.action-btn.primary:hover:not(:disabled) {
background: #b4befe;
}
.action-btn.secondary {
background: #45475a;
color: #cdd6f4;
}
.action-btn.secondary:hover {
background: #585b70;
}
.action-btn.warning {
background: #f38ba8;
color: #1e1e2e;
}
.action-btn.warning:hover {
background: #eba0ac;
}
`}</style>
</div>
);
}

View File

@@ -2,7 +2,7 @@
* API Client - Toutes les interactions avec le backend * API Client - Toutes les interactions avec le backend
*/ */
import type { AppState, Workflow, Step, Execution, Capture, ActionType } from '../types'; import type { AppState, Workflow, Step, Execution, Capture, ActionType, ExecutionMode } from '../types';
const API_BASE = '/api/v3'; const API_BASE = '/api/v3';
@@ -61,6 +61,18 @@ export async function deleteWorkflow(workflowId: string): Promise<{ deleted_id:
return request('DELETE', `/workflow/${workflowId}`); return request('DELETE', `/workflow/${workflowId}`);
} }
export async function updateWorkflow(
workflowId: string,
updates: {
name?: string;
description?: string;
tags?: string[];
triggerExamples?: string[];
}
): Promise<{ workflow: Workflow }> {
return request('PUT', `/workflow/${workflowId}`, updates);
}
// Steps // Steps
export async function addStep( export async function addStep(
workflowId: string, workflowId: string,
@@ -126,8 +138,14 @@ export function getAnchorThumbnailUrl(anchorId: string): string {
} }
// Execution // Execution
export async function startExecution(workflowId?: string): Promise<{ execution: Execution; session: AppState['session'] }> { export async function startExecution(
return request('POST', '/execute/start', workflowId ? { workflow_id: workflowId } : {}); workflowId?: string,
mode?: ExecutionMode
): Promise<{ execution: Execution; session: AppState['session'] }> {
return request('POST', '/execute/start', {
workflow_id: workflowId,
execution_mode: mode || 'basic'
});
} }
export async function pauseExecution(): Promise<{ execution: Execution }> { export async function pauseExecution(): Promise<{ execution: Execution }> {
@@ -147,6 +165,29 @@ export async function getExecutionStatus(): Promise<{
is_paused: boolean; is_paused: boolean;
execution: Execution | null; execution: Execution | null;
session: AppState['session']; session: AppState['session'];
// Self-healing interactif
waiting_for_choice?: boolean;
candidates?: Array<{
id: number;
element_id: number;
score: number;
bbox: { x1: number; y1: number; x2: number; y2: number };
center?: { x: number; y: number };
distance?: number;
}>;
current_step_info?: {
index: number;
total: number;
original_bbox?: { x: number; y: number; width: number; height: number };
error?: string;
};
}> { }> {
return request('GET', '/execute/status'); return request('GET', '/execute/status');
} }
// Self-Healing Interactif
export async function submitHealingChoice(
choice: 'skip' | 'static' | { x: number; y: number }
): Promise<{ success: boolean; choice: unknown }> {
return request('POST', '/execute/healing/choose', { choice });
}