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:
44
core/detection/__init__.py
Normal file
44
core/detection/__init__.py
Normal 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'
|
||||||
|
]
|
||||||
333
core/detection/seeclick_adapter.py
Normal file
333
core/detection/seeclick_adapter.py
Normal 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)
|
||||||
@@ -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*
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user