5 Commits

Author SHA1 Message Date
Dom
786e640de9 Merge dev/ia-tools-improvement: audit phases 1-4 + outils IA
- refactor(audit): suppression code mort, config centralisée, thread-safety, logging
- feat(vwb): outils IA améliorés, validation workflow, suppression fallback statique
- feat(vwb-frontend): sélecteur modèle IA, validation, variables
- fix(vwb): suppression debug /tmp, correction import UIElement
2026-02-17 11:05:23 +01:00
Dom
2cb53901a1 fix(vwb): Supprimer debug /tmp et corriger import UIElement
- Supprimer le bloc debug qui écrivait dans /tmp/vwb_debug.log
- Corriger l'import UIElement (core.models.ui_element au lieu de
  screen_state) — supprime le warning au démarrage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:58:14 +01:00
Dom
75260e3254 feat(vwb-frontend): Sélecteur modèle IA, validation workflow et variables
Nouveaux composants:
- AIModelSelector: sélection du modèle Ollama avec détection auto
- WorkflowValidation: validation des étapes avant exécution
- ollamaService: service de communication avec Ollama (liste modèles)

Améliorations:
- PropertiesPanel: intégration sélecteur IA, champs prompt/température
- VariableManager: support variables runtime et substitution {{var}}
- ConfidenceDashboard: refactoring et simplification
- App.tsx: routing et intégration des nouveaux composants
- api.ts: endpoints validate et export-training
- types.ts: types pour modèles IA et validation
- styles.css: styles pour les nouveaux composants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:56:40 +01:00
Dom
4c9a6d293f feat(vwb): Améliorer outils IA et supprimer fallback statique
Backend:
- analyser_avec_ia.py: centraliser URL Ollama via os.environ.get()
- action_contracts.py: assouplir le contrat ai_analyze_text (mode texte
  sans ancre visuelle, accepter prompt ou analysis_prompt)
- intelligent_executor.py: supprimer le fallback coordonnées statiques
  quand la vision échoue — renvoyer not_found pour self-healing
- workflow.py: ajouter endpoints validate et export-training

run.sh:
- Corriger les ports (3000 → 3002) et le venv (venv_v3 → .venv)
- Lancer run_v4.sh au lieu de l'ancien run.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:56:17 +01:00
Dom
3ff36e3c79 refactor(audit): Nettoyage dette technique phases 1-4
Phase 1 — Code mort et duplication :
- Supprimer catalog_routes.py (-1832 lignes, doublon de v2_vlm)
- Mettre à jour app.py et app_lightweight.py vers catalog_routes_v2_vlm
- Nettoyer 9 imports inutilisés dans catalog_routes_v2_vlm.py
- Supprimer get_required_params inutilisé dans execute.py

Phase 2 — Centraliser la configuration :
- Ollama URL via os.environ.get() dans verify_text_content.py et extraire_tableau.py
- MODEL_PATH relatif au projet + var env UI_DETR_MODEL_PATH dans ui_detection_service.py

Phase 3 — Thread-safety de l'exécution :
- Ajouter _execution_lock (RLock) pour protéger _execution_state
- Remplacer le polling self-healing par threading.Event
- Initialiser 'variables' dans le dict initial (plus de création dynamique)
- Corriger bare except → except Exception as db_err avec message

Phase 4 — Logging minimal :
- Ajouter logger dans execute.py, remplacer print() critiques par logger
- Configurer RotatingFileHandler (5MB, 3 backups) dans app.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:11:45 +01:00
24 changed files with 2533 additions and 2338 deletions

28
run.sh
View File

@@ -36,7 +36,7 @@ show_help() {
echo -e " ${BLUE}--server${NC} 🌐 API Server seul (port 8000)" echo -e " ${BLUE}--server${NC} 🌐 API Server seul (port 8000)"
echo -e " ${PURPLE}--dashboard${NC} 📊 Dashboard Web seul (port 5001)" echo -e " ${PURPLE}--dashboard${NC} 📊 Dashboard Web seul (port 5001)"
echo -e " ${YELLOW}--monitoring${NC} 📈 Interface de monitoring (port 5003)" echo -e " ${YELLOW}--monitoring${NC} 📈 Interface de monitoring (port 5003)"
echo -e " ${CYAN}--workflow${NC} 🔧 Visual Workflow Builder (port 3000)" echo -e " ${CYAN}--workflow${NC} 🔧 Visual Workflow Builder (port 3002)"
echo -e " ${GREEN}--agent${NC} 📹 Agent V0 (capture tool)" echo -e " ${GREEN}--agent${NC} 📹 Agent V0 (capture tool)"
echo -e " ${BLUE}--chat${NC} 💬 Agent Chat (port 5002)" echo -e " ${BLUE}--chat${NC} 💬 Agent Chat (port 5002)"
echo "" echo ""
@@ -64,7 +64,7 @@ show_help() {
echo -e " Dashboard: ${PURPLE}http://localhost:5001${NC}" echo -e " Dashboard: ${PURPLE}http://localhost:5001${NC}"
echo -e " Agent Chat: ${BLUE}http://localhost:5002${NC}" echo -e " Agent Chat: ${BLUE}http://localhost:5002${NC}"
echo -e " Monitoring: ${YELLOW}http://localhost:5003${NC}" echo -e " Monitoring: ${YELLOW}http://localhost:5003${NC}"
echo -e " Workflow Builder: ${CYAN}http://localhost:3000${NC}" echo -e " Workflow Builder: ${CYAN}http://localhost:3002${NC}"
echo "" echo ""
} }
@@ -186,7 +186,7 @@ fi
# Step 3: Check/Create Virtual Environment # Step 3: Check/Create Virtual Environment
echo -e "${BLUE}[3/7]${NC} Setting up Python environment..." echo -e "${BLUE}[3/7]${NC} Setting up Python environment..."
VENV_DIR="venv_v3" VENV_DIR=".venv"
if [ ! -d "$VENV_DIR" ]; then if [ ! -d "$VENV_DIR" ]; then
echo " Creating virtual environment..." echo " Creating virtual environment..."
@@ -349,7 +349,8 @@ cleanup() {
pkill -f "port 5001" 2>/dev/null || true pkill -f "port 5001" 2>/dev/null || true
pkill -f "port 5002" 2>/dev/null || true pkill -f "port 5002" 2>/dev/null || true
pkill -f "port 5003" 2>/dev/null || true pkill -f "port 5003" 2>/dev/null || true
pkill -f "port 3000" 2>/dev/null || true pkill -f "port 3002" 2>/dev/null || true
pkill -f "vite.*3002" 2>/dev/null || true
deactivate 2>/dev/null || true deactivate 2>/dev/null || true
echo -e "${GREEN}${NC} Cleanup complete" echo -e "${GREEN}${NC} Cleanup complete"
@@ -465,12 +466,12 @@ EOF
workflow) workflow)
echo "" echo ""
echo -e "${CYAN}🔧 Launching Visual Workflow Builder on port 3000...${NC}" echo -e "${CYAN}🔧 Launching Visual Workflow Builder v4...${NC}"
echo "" echo ""
echo "Access: http://localhost:3000" echo "Access: http://localhost:3002 (frontend) / http://localhost:5001 (backend)"
echo "" echo ""
cd visual_workflow_builder cd visual_workflow_builder
./run.sh ./run_v4.sh
cd .. cd ..
;; ;;
@@ -581,10 +582,10 @@ if __name__ == '__main__':
EOF EOF
MONITORING_PID=$(start_service "Monitoring" "$VENV_DIR/bin/python3 monitoring_server.py" "5003" "monitoring.log") MONITORING_PID=$(start_service "Monitoring" "$VENV_DIR/bin/python3 monitoring_server.py" "5003" "monitoring.log")
# Start Visual Workflow Builder (in background) # Start Visual Workflow Builder v4 (in background)
echo "Starting Visual Workflow Builder (port 3000)..." echo "Starting Visual Workflow Builder v4 (port 3002)..."
cd visual_workflow_builder cd visual_workflow_builder
./run.sh > ../logs/workflow.log 2>&1 & ./run_v4.sh > ../logs/workflow.log 2>&1 &
WORKFLOW_PID=$! WORKFLOW_PID=$!
cd .. cd ..
sleep 3 sleep 3
@@ -602,7 +603,7 @@ EOF
echo -e " API Server: ${BLUE}http://localhost:8000${NC}" echo -e " API Server: ${BLUE}http://localhost:8000${NC}"
echo -e " Dashboard: ${PURPLE}http://localhost:5001${NC}" echo -e " Dashboard: ${PURPLE}http://localhost:5001${NC}"
echo -e " Monitoring: ${YELLOW}http://localhost:5003${NC}" echo -e " Monitoring: ${YELLOW}http://localhost:5003${NC}"
echo -e " Workflow Builder: ${CYAN}http://localhost:3000${NC}" echo -e " Workflow Builder: ${CYAN}http://localhost:3002${NC}"
echo "" echo ""
echo -e "${BOLD}📊 Logs:${NC}" echo -e "${BOLD}📊 Logs:${NC}"
echo " tail -f logs/api.log" echo " tail -f logs/api.log"
@@ -697,7 +698,7 @@ EOF
check_service_status "Dashboard" "5001" check_service_status "Dashboard" "5001"
check_service_status "Agent Chat" "5002" check_service_status "Agent Chat" "5002"
check_service_status "Monitoring" "5003" check_service_status "Monitoring" "5003"
check_service_status "Workflow Builder" "3000" check_service_status "Workflow Builder" "3002"
echo "" echo ""
;; ;;
@@ -708,7 +709,8 @@ EOF
pkill -f "port 5001" 2>/dev/null || true pkill -f "port 5001" 2>/dev/null || true
pkill -f "port 5002" 2>/dev/null || true pkill -f "port 5002" 2>/dev/null || true
pkill -f "port 5003" 2>/dev/null || true pkill -f "port 5003" 2>/dev/null || true
pkill -f "port 3000" 2>/dev/null || true pkill -f "port 3002" 2>/dev/null || true
pkill -f "vite.*3002" 2>/dev/null || true
echo -e "${GREEN}${NC} All services stopped" echo -e "${GREEN}${NC} All services stopped"
;; ;;
esac esac

View File

@@ -14,6 +14,7 @@ Cas d'usage :
from typing import Dict, Any, List, Optional, Tuple from typing import Dict, Any, List, Optional, Tuple
from datetime import datetime from datetime import datetime
import os
import time import time
import base64 import base64
import io import io
@@ -26,9 +27,9 @@ from ...contracts.error import VWBErrorType, create_vwb_error
from ...contracts.visual_anchor import VWBVisualAnchor from ...contracts.visual_anchor import VWBVisualAnchor
# Configuration par défaut # Configuration par défaut (centralisée via variable d'environnement)
OLLAMA_DEFAULT_URL = "http://localhost:11434" OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = "qwen2.5-vl:7b" OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen2.5-vl:7b")
class VWBExtraireTableauAction(BaseVWBAction): class VWBExtraireTableauAction(BaseVWBAction):

View File

@@ -22,11 +22,12 @@ import requests
from ..base_action import BaseVWBAction, VWBActionResult, VWBActionStatus from ..base_action import BaseVWBAction, VWBActionResult, VWBActionStatus
from ...contracts.error import VWBErrorType, create_vwb_error from ...contracts.error import VWBErrorType, create_vwb_error
from ...contracts.visual_anchor import VWBVisualAnchor from ...contracts.visual_anchor import VWBVisualAnchor
import os
# Configuration Ollama par défaut # Configuration Ollama par défaut (configurable via variables d'environnement)
OLLAMA_DEFAULT_URL = "http://localhost:11434" OLLAMA_DEFAULT_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_DEFAULT_MODEL = "qwen2.5-vl:7b" OLLAMA_DEFAULT_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b")
class VWBAnalyserAvecIAAction(BaseVWBAction): class VWBAnalyserAvecIAAction(BaseVWBAction):

View File

@@ -12,6 +12,7 @@ Modes OCR disponibles:
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from datetime import datetime from datetime import datetime
import os
import time import time
import re import re
import base64 import base64
@@ -36,9 +37,9 @@ class VWBVerifyTextContentAction(BaseVWBAction):
- easyocr: OCR traditionnel (plus rapide, fallback) - easyocr: OCR traditionnel (plus rapide, fallback)
""" """
# Configuration Ollama par défaut # Configuration Ollama par défaut (centralisée via variable d'environnement)
OLLAMA_URL = "http://localhost:11434" OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL = "qwen2.5-vl:7b" # Modèle de vision Qwen - excellent pour OCR OLLAMA_MODEL = os.environ.get("VLM_MODEL", "qwen2.5-vl:7b") # Modèle de vision Qwen - excellent pour OCR
def __init__( def __init__(
self, self,

View File

@@ -16,9 +16,68 @@ import threading
import time import time
import base64 import base64
import os import os
import logging
import subprocess import subprocess
from . import api_v3_bp from . import api_v3_bp
logger = logging.getLogger(__name__)
def safe_type_text(text):
"""Saisie de texte compatible avec tous les claviers (AZERTY/QWERTY).
pyautogui.write() envoie des codes de touches QWERTY qui produisent
des caractères erronés sur AZERTY (* → µ, ( → 5, ) → °, etc.).
Priorité :
1. Presse-papier (xclip) + Ctrl+V → instantané, fiable pour tout texte
2. xdotool type → respecte le layout clavier
3. pyautogui.write() → dernier recours
"""
import shutil
import pyautogui
# Méthode 1 : Presse-papier (le plus fiable, gère UTF-8/accents/CJK)
# xclip reste en vie comme daemon X11 pour servir le clipboard.
# On ne doit PAS attendre sa terminaison — juste envoyer les données
# et coller immédiatement.
xclip = shutil.which('xclip')
if xclip:
try:
p = subprocess.Popen(
['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
p.stdin.write(text.encode('utf-8'))
p.stdin.close()
# Ne PAS attendre xclip — il reste vivant comme owner du clipboard
time.sleep(0.2)
pyautogui.hotkey('ctrl', 'v')
time.sleep(0.3)
print(f" ✅ Saisie via presse-papier ({len(text)} car.)")
return
except Exception as e:
print(f" ⚠️ xclip échoué: {e}")
# Méthode 2 : xdotool type (respecte le layout clavier actif)
if shutil.which('xdotool'):
try:
subprocess.run(
['xdotool', 'type', '--delay', '0', '--clearmodifiers', '--', text],
timeout=max(30, len(text) * 0.05),
check=True
)
print(f" ✅ Saisie via xdotool type ({len(text)} car.)")
return
except Exception as e:
print(f" ⚠️ xdotool type échoué: {e}")
# Méthode 3 : pyautogui (dernier recours — problèmes AZERTY possibles)
print(" ⚠️ Saisie via pyautogui.write() (AZERTY non garanti)")
pyautogui.write(text)
def minimize_active_window(): def minimize_active_window():
"""Minimise la fenêtre active (Linux avec xdotool)""" """Minimise la fenêtre active (Linux avec xdotool)"""
@@ -37,7 +96,7 @@ def minimize_active_window():
print(f"⚠️ [Execute] Erreur minimisation: {e}") print(f"⚠️ [Execute] Erreur minimisation: {e}")
return False return False
from db.models import db, Workflow, Step, Execution, ExecutionStep, VisualAnchor, get_session_state from db.models import db, Workflow, Step, Execution, ExecutionStep, VisualAnchor, get_session_state
from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params from contracts.action_contracts import enforce_action_contract, ContractValidationError
def generate_id(prefix: str) -> str: def generate_id(prefix: str) -> str:
@@ -45,6 +104,14 @@ def generate_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}" return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
# Thread-safety : verrou pour protéger _execution_state (accès R/W depuis le
# thread d'exécution ET les handlers HTTP Flask en parallèle).
_execution_lock = threading.RLock()
# Event pour remplacer le polling actif du self-healing :
# le thread d'exécution attend (.wait), le handler HTTP signale (.set).
_healing_event = threading.Event()
# État de l'exécution en cours (en mémoire) # État de l'exécution en cours (en mémoire)
_execution_state = { _execution_state = {
'is_running': False, 'is_running': False,
@@ -53,6 +120,7 @@ _execution_state = {
'current_execution_id': None, 'current_execution_id': None,
'thread': None, 'thread': None,
'execution_mode': 'basic', # 'basic', 'intelligent', 'debug' 'execution_mode': 'basic', # 'basic', 'intelligent', 'debug'
'variables': {}, # Variables runtime du workflow (initialisé ici, plus de création dynamique)
# Self-healing interactif # Self-healing interactif
'waiting_for_choice': False, 'waiting_for_choice': False,
'pending_action': None, # Action en attente de choix utilisateur 'pending_action': None, # Action en attente de choix utilisateur
@@ -75,7 +143,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
workflow = Workflow.query.get(workflow_id) workflow = Workflow.query.get(workflow_id)
if not execution or not workflow: if not execution or not workflow:
print(f"❌ [Execute] Workflow ou exécution non trouvé") logger.error("Workflow ou exécution non trouvé")
return return
steps = workflow.steps.order_by(Step.order).all() steps = workflow.steps.order_by(Step.order).all()
@@ -84,7 +152,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.started_at = datetime.utcnow() execution.started_at = datetime.utcnow()
db.session.commit() db.session.commit()
print(f"🚀 [Execute] Démarrage workflow {workflow_id}: {len(steps)} étapes") logger.info(f"Démarrage workflow {workflow_id}: {len(steps)} étapes")
for index, step in enumerate(steps): for index, step in enumerate(steps):
# Vérifier si arrêt demandé # Vérifier si arrêt demandé
@@ -155,7 +223,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
try: try:
enforce_action_contract(step.action_type, params) enforce_action_contract(step.action_type, params)
except ContractValidationError as e: except ContractValidationError as e:
print(f"🚫 [Execute] CONTRAT VIOLÉ pour étape {step.id}: {e}") logger.warning(f"CONTRAT VIOLÉ pour étape {step.id}: {e}")
step_result.status = 'error' step_result.status = 'error'
step_result.error_message = f"Contrat violé: {str(e)}" step_result.error_message = f"Contrat violé: {str(e)}"
step_result.ended_at = datetime.utcnow() step_result.ended_at = datetime.utcnow()
@@ -176,6 +244,8 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
print(f"🔄 [Self-Healing] Attente choix utilisateur pour étape {index + 1}") print(f"🔄 [Self-Healing] Attente choix utilisateur pour étape {index + 1}")
# Stocker les informations pour le frontend # Stocker les informations pour le frontend
with _execution_lock:
_healing_event.clear()
_execution_state['waiting_for_choice'] = True _execution_state['waiting_for_choice'] = True
_execution_state['pending_action'] = { _execution_state['pending_action'] = {
'step_id': step.id, 'step_id': step.id,
@@ -196,14 +266,9 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.status = 'waiting_user_choice' execution.status = 'waiting_user_choice'
db.session.commit() db.session.commit()
# Attendre le choix de l'utilisateur # Attendre le choix de l'utilisateur (Event au lieu de polling)
timeout_seconds = 120 # 2 minutes max timeout_seconds = 120 # 2 minutes max
waited = 0 _healing_event.wait(timeout=timeout_seconds)
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 # Vérifier si on doit arrêter
if _execution_state['should_stop']: if _execution_state['should_stop']:
@@ -211,6 +276,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
break break
# Traiter le choix de l'utilisateur # Traiter le choix de l'utilisateur
with _execution_lock:
user_choice = _execution_state['user_choice'] user_choice = _execution_state['user_choice']
_execution_state['waiting_for_choice'] = False _execution_state['waiting_for_choice'] = False
_execution_state['pending_action'] = None _execution_state['pending_action'] = None
@@ -261,7 +327,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
step_result.status = 'error' step_result.status = 'error'
step_result.error_message = result.get('error', 'Erreur inconnue') step_result.error_message = result.get('error', 'Erreur inconnue')
execution.failed_steps += 1 execution.failed_steps += 1
print(f"❌ [Execute] Étape {index + 1} échouée: {step_result.error_message}") logger.warning(f"Étape {index + 1} échouée: {step_result.error_message}")
# Arrêter sur erreur # Arrêter sur erreur
execution.status = 'error' execution.status = 'error'
@@ -272,7 +338,7 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
db.session.commit() db.session.commit()
except Exception as e: except Exception as e:
print(f"❌ [Execute] Exception étape {index + 1}: {e}") logger.error(f"Exception étape {index + 1}: {e}", exc_info=True)
step_result.status = 'error' step_result.status = 'error'
step_result.error_message = str(e) step_result.error_message = str(e)
step_result.ended_at = datetime.utcnow() step_result.ended_at = datetime.utcnow()
@@ -289,11 +355,10 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.ended_at = datetime.utcnow() execution.ended_at = datetime.utcnow()
db.session.commit() db.session.commit()
print(f"🏁 [Execute] Workflow terminé: {execution.status}") logger.info(f"Workflow terminé: {execution.status} (complétées: {execution.completed_steps}, échouées: {execution.failed_steps})")
print(f" Complétées: {execution.completed_steps}, Échouées: {execution.failed_steps}")
except Exception as e: except Exception as e:
print(f"❌ [Execute] Erreur fatale: {e}") logger.error(f"Erreur fatale: {e}", exc_info=True)
try: try:
execution = Execution.query.get(execution_id) execution = Execution.query.get(execution_id)
if execution: if execution:
@@ -301,10 +366,11 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
execution.error_message = f"Erreur fatale: {str(e)}" execution.error_message = f"Erreur fatale: {str(e)}"
execution.ended_at = datetime.utcnow() execution.ended_at = datetime.utcnow()
db.session.commit() db.session.commit()
except: except Exception as db_err:
pass print(f"⚠️ [Execute] DB cleanup error: {db_err}")
finally: finally:
with _execution_lock:
_execution_state['is_running'] = False _execution_state['is_running'] = False
_execution_state['current_execution_id'] = None _execution_state['current_execution_id'] = None
@@ -312,36 +378,84 @@ def execute_workflow_thread(execution_id: str, workflow_id: str, app):
def execute_ai_analyze(params: dict) -> dict: def execute_ai_analyze(params: dict) -> dict:
""" """
Exécute une analyse IA avec Ollama. Exécute une analyse IA avec Ollama.
Capture la zone de l'ancre et envoie à l'IA pour analyse. Deux modes :
- Mode texte : envoie le texte brut directement (meilleure qualité)
- Mode image : capture la zone de l'ancre et envoie le screenshot
""" """
import requests import requests
import re
global _execution_state
try: try:
# Récupérer les paramètres
anchor = params.get('visual_anchor', {})
prompt = params.get('analysis_prompt', params.get('prompt', '')) prompt = params.get('analysis_prompt', params.get('prompt', ''))
model = params.get('model', params.get('ollama_model', 'qwen2.5-vl:7b')) model = params.get('model', params.get('ollama_model', 'qwen3-vl:8b'))
output_variable = params.get('output_variable', 'resultat_analyse') output_variable = params.get('output_variable', 'resultat_analyse')
timeout_ms = params.get('timeout_ms', 60000) timeout_ms = params.get('timeout_ms', 120000) # 2 minutes par défaut
temperature = params.get('temperature', 0.3) temperature = params.get('temperature', 0.7) # Même défaut que CLI Ollama
max_tokens = params.get('max_tokens', -1) # -1 = illimité (défaut Ollama)
input_text = params.get('input_text', '')
# Récupérer l'image de l'ancre # Résoudre les variables {{var}} dans input_text
screenshot_base64 = anchor.get('screenshot') variables = _execution_state.get('variables', {})
if input_text and '{{' in input_text:
def replace_var(match):
var_name = match.group(1)
value = variables.get(var_name, match.group(0))
print(f" 📌 Variable {{{{{var_name}}}}}{str(value)[:50]}...")
return str(value)
input_text = re.sub(r'\{\{(\w+)\}\}', replace_var, input_text)
# Déterminer le mode : texte ou image
use_text_mode = bool(input_text)
anchor = params.get('visual_anchor', {})
print(f"🤖 [IA] Mode: {'TEXTE' if use_text_mode else 'IMAGE'}")
if use_text_mode:
# ═══ MODE TEXTE : envoyer le texte directement (comme en CLI) ═══
print(f"📝 [IA] Texte brut: {len(input_text)} caractères")
# Construire le prompt complet avec le texte en entrée
if prompt:
full_prompt = f"{prompt}\n\nVoici le texte :\n{input_text}"
else:
full_prompt = input_text
# Pour les modèles Qwen, désactiver le thinking étendu
if 'qwen' in model.lower() and not full_prompt.startswith('/no_think'):
full_prompt = f"/no_think\n{full_prompt}"
print(f"🤖 [IA] Analyse texte avec {model}...")
ollama_url = params.get('ollama_url', 'http://localhost:11434')
options = {"temperature": temperature}
if max_tokens > 0:
options["num_predict"] = max_tokens
payload = {
"model": model,
"prompt": full_prompt,
"stream": False,
"options": options
}
else:
# ═══ MODE IMAGE : capturer l'écran et envoyer le screenshot ═══
screenshot_base64 = anchor.get('screenshot') if anchor else None
if not screenshot_base64: if not screenshot_base64:
# Capturer l'écran si pas d'image dans l'ancre
try: try:
from PIL import ImageGrab from PIL import ImageGrab
import io import io
bbox = anchor.get('bounding_box', {}) bbox = anchor.get('bounding_box', {}) if anchor else {}
if bbox: if bbox:
# Capturer la zone spécifique
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0)) x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100)) w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100))
print(f"📸 [IA] Capture zone: ({x}, {y}) -> ({x+w}, {y+h})")
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h)) screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
else: else:
# Capturer tout l'écran print(f"📸 [IA] Capture écran complet")
screenshot = ImageGrab.grab() screenshot = ImageGrab.grab()
buffer = io.BytesIO() buffer = io.BytesIO()
@@ -350,26 +464,33 @@ def execute_ai_analyze(params: dict) -> dict:
except Exception as cap_err: except Exception as cap_err:
return {'success': False, 'error': f"Erreur capture: {cap_err}"} return {'success': False, 'error': f"Erreur capture: {cap_err}"}
if not screenshot_base64:
return {'success': False, 'error': "Pas d'image à analyser (ni ancre, ni capture)"}
if not prompt: if not prompt:
prompt = "Décris ce que tu vois dans cette image." prompt = "Décris ce que tu vois dans cette image."
print(f"🤖 [IA] Analyse avec {model}...") full_prompt = prompt
print(f" Prompt: {prompt[:80]}...") if 'qwen' in model.lower() and not full_prompt.startswith('/no_think'):
full_prompt = f"/no_think\n{full_prompt}"
print(f"🤖 [IA] Analyse image avec {model}...")
# Appeler Ollama
ollama_url = params.get('ollama_url', 'http://localhost:11434') ollama_url = params.get('ollama_url', 'http://localhost:11434')
options = {"temperature": temperature}
if max_tokens > 0:
options["num_predict"] = max_tokens
payload = { payload = {
"model": model, "model": model,
"prompt": prompt, "prompt": full_prompt,
"images": [screenshot_base64], "images": [screenshot_base64],
"stream": False, "stream": False,
"options": { "options": options
"temperature": temperature,
"num_predict": 1000
}
} }
# ═══ APPEL OLLAMA ═══
print(f" Prompt: {full_prompt[:100]}...")
response = requests.post( response = requests.post(
f"{ollama_url}/api/generate", f"{ollama_url}/api/generate",
json=payload, json=payload,
@@ -380,13 +501,15 @@ def execute_ai_analyze(params: dict) -> dict:
result = response.json() result = response.json()
analysis_text = result.get('response', '').strip() analysis_text = result.get('response', '').strip()
# Fallback : extraire du champ thinking si response vide
if not analysis_text and result.get('thinking'):
analysis_text = result.get('thinking', '').strip()
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)") print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
if analysis_text:
print(f" Résultat: {analysis_text[:150]}...") print(f" Résultat: {analysis_text[:150]}...")
# Stocker le résultat dans le contexte d'exécution pour les variables # Stocker dans les variables d'exécution
global _execution_state
if 'variables' not in _execution_state:
_execution_state['variables'] = {}
_execution_state['variables'][output_variable] = analysis_text _execution_state['variables'][output_variable] = analysis_text
return { return {
@@ -394,7 +517,8 @@ def execute_ai_analyze(params: dict) -> dict:
'output': { 'output': {
'analysis': analysis_text, 'analysis': analysis_text,
'variable': output_variable, 'variable': output_variable,
'model': model 'model': model,
'mode': 'text' if use_text_mode else 'image'
} }
} }
else: else:
@@ -582,18 +706,22 @@ def execute_action(action_type: str, params: dict) -> dict:
} }
except Exception as vision_err: except Exception as vision_err:
print(f" [Vision] Erreur: {vision_err}") print(f"⚠️ [Vision] Erreur: {vision_err}")
if execution_mode in ['intelligent', 'debug']:
# En mode visuel, on NE fait PAS de fallback statique
return { return {
'success': False, 'success': False,
'error': f"Erreur vision: {str(vision_err)}" 'error': f"Erreur vision: {vision_err}. Ancre introuvable à l'écran."
} }
else:
print(f"🔄 [Fallback] Mode basic: utilisation des coordonnées statiques...")
# === MODE BASIC (ou fallback) === # === MODE BASIC uniquement ===
# Calculer le centre depuis les coordonnées statiques if execution_mode not in ['intelligent', 'debug']:
x = bbox.get('x', 0) + bbox.get('width', 0) / 2 x = bbox.get('x', 0) + bbox.get('width', 0) / 2
y = bbox.get('y', 0) + bbox.get('height', 0) / 2 y = bbox.get('y', 0) + bbox.get('height', 0) / 2
print(f"🖱️ [Action] Clic {click_type} à ({x}, {y}) [mode: {execution_mode}]") print(f"🖱️ [Action] Clic {click_type} à ({x}, {y}) [mode: basic, coordonnées statiques]")
if click_type == 'double': if click_type == 'double':
pyautogui.doubleClick(x, y) pyautogui.doubleClick(x, y)
@@ -602,7 +730,10 @@ def execute_action(action_type: str, params: dict) -> dict:
else: else:
pyautogui.click(x, y) pyautogui.click(x, y)
return {'success': True, 'output': {'clicked_at': {'x': x, 'y': y}, 'mode': execution_mode}} return {'success': True, 'output': {'clicked_at': {'x': x, 'y': y}, 'mode': 'basic'}}
# En mode intelligent/debug, si on arrive ici c'est que la vision n'a pas été tentée
return {'success': False, 'error': 'Ancre non trouvée (aucune méthode visuelle disponible)'}
elif action_type in ['type_text', 'type']: elif action_type in ['type_text', 'type']:
text = params.get('text', '') text = params.get('text', '')
@@ -631,8 +762,14 @@ def execute_action(action_type: str, params: dict) -> dict:
# Petit délai pour s'assurer que le focus est bon # Petit délai pour s'assurer que le focus est bon
time.sleep(0.2) time.sleep(0.2)
# Utiliser write() pour supporter l'unicode (caractères français, etc.) # Saisie compatible AZERTY/QWERTY (presse-papier > xdotool > pyautogui)
pyautogui.write(text) safe_type_text(text)
# Stocker le texte dans une variable si output_variable est défini
output_variable = params.get('output_variable')
if output_variable:
_execution_state['variables'][output_variable] = text
print(f" 📦 Texte stocké dans variable '{output_variable}' ({len(text)} caractères)")
return {'success': True, 'output': {'typed': text[:100] + '...' if len(text) > 100 else text}} return {'success': True, 'output': {'typed': text[:100] + '...' if len(text) > 100 else text}}
@@ -683,7 +820,7 @@ def start_execution():
data = request.get_json() or {} data = request.get_json() or {}
workflow_id = data.get('workflow_id') workflow_id = data.get('workflow_id')
execution_mode = data.get('execution_mode', 'basic') execution_mode = data.get('execution_mode', 'intelligent')
minimize_browser = data.get('minimize_browser', True) # Activé par défaut minimize_browser = data.get('minimize_browser', True) # Activé par défaut
# Valider le mode # Valider le mode
@@ -727,12 +864,14 @@ def start_execution():
session = get_session_state() session = get_session_state()
session.active_execution_id = execution.id session.active_execution_id = execution.id
# Réinitialiser l'état # Réinitialiser l'état (protégé par lock)
with _execution_lock:
_execution_state['is_running'] = True _execution_state['is_running'] = True
_execution_state['is_paused'] = False _execution_state['is_paused'] = False
_execution_state['should_stop'] = False _execution_state['should_stop'] = False
_execution_state['current_execution_id'] = execution.id _execution_state['current_execution_id'] = execution.id
_execution_state['execution_mode'] = execution_mode _execution_state['execution_mode'] = execution_mode
_execution_state['variables'] = {} # Reset des variables
print(f"🎯 [API v3] Mode d'exécution: {execution_mode}") print(f"🎯 [API v3] Mode d'exécution: {execution_mode}")
@@ -762,6 +901,7 @@ def start_execution():
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
with _execution_lock:
_execution_state['is_running'] = False _execution_state['is_running'] = False
return jsonify({ return jsonify({
'success': False, 'success': False,
@@ -832,6 +972,7 @@ def stop_execution():
"""Arrête l'exécution""" """Arrête l'exécution"""
global _execution_state global _execution_state
with _execution_lock:
if not _execution_state['is_running']: if not _execution_state['is_running']:
return jsonify({ return jsonify({
'success': False, 'success': False,
@@ -840,6 +981,8 @@ def stop_execution():
_execution_state['should_stop'] = True _execution_state['should_stop'] = True
_execution_state['is_paused'] = False _execution_state['is_paused'] = False
# Débloquer le thread s'il attend un choix self-healing
_healing_event.set()
print(f"⛔ [API v3] Arrêt demandé") print(f"⛔ [API v3] Arrêt demandé")
@@ -876,6 +1019,8 @@ def get_execution_status():
'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(),
# Variables runtime du workflow
'variables': _execution_state.get('variables', {}),
# Self-healing interactif # Self-healing interactif
'waiting_for_choice': _execution_state.get('waiting_for_choice', False), 'waiting_for_choice': _execution_state.get('waiting_for_choice', False),
'candidates': _execution_state.get('candidates', []) if _execution_state.get('waiting_for_choice') else [], 'candidates': _execution_state.get('candidates', []) if _execution_state.get('waiting_for_choice') else [],
@@ -975,9 +1120,11 @@ def submit_healing_choice():
'error': "Coordonnées invalides. Format attendu: {x: number, y: number}" 'error': "Coordonnées invalides. Format attendu: {x: number, y: number}"
}), 400 }), 400
# Stocker le choix # Stocker le choix et réveiller le thread d'exécution
with _execution_lock:
_execution_state['user_choice'] = choice _execution_state['user_choice'] = choice
_execution_state['waiting_for_choice'] = False _execution_state['waiting_for_choice'] = False
_healing_event.set()
print(f"✅ [Self-Healing] Choix reçu: {choice}") print(f"✅ [Self-Healing] Choix reçu: {choice}")

View File

@@ -7,14 +7,18 @@ GET /api/v3/workflow/{id}
POST /api/v3/workflow/{id}/step POST /api/v3/workflow/{id}/step
PUT /api/v3/workflow/{id}/step/{step_id} PUT /api/v3/workflow/{id}/step/{step_id}
DELETE /api/v3/workflow/{id}/step/{step_id} DELETE /api/v3/workflow/{id}/step/{step_id}
POST /api/v3/workflow/{id}/validate
POST /api/v3/workflow/{id}/export-training
""" """
from flask import jsonify, request from flask import jsonify, request
from datetime import datetime from datetime import datetime
import uuid import uuid
import json
import os
from . import api_v3_bp from . import api_v3_bp
from db.models import db, Workflow, Step, VisualAnchor, get_session_state from db.models import db, Workflow, Step, VisualAnchor, get_session_state
from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params, validate_action_contract
def generate_id(prefix: str) -> str: def generate_id(prefix: str) -> str:
@@ -461,3 +465,166 @@ def reorder_steps(workflow_id: str):
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}), 500 }), 500
def _validate_workflow_steps(workflow):
"""
Logique de validation partagée entre validate et export-training.
Retourne (errors, warnings, steps).
"""
errors = []
warnings = []
steps = Step.query.filter_by(workflow_id=workflow.id).order_by(Step.order).all()
if len(steps) == 0:
errors.append("Le workflow n'a aucune étape")
return errors, warnings, steps
for i, step in enumerate(steps):
step_label = step.label or step.action_type
prefix = f"Étape {i+1} ({step_label})"
# Valider le contrat d'action
params = step.parameters or {}
violations = validate_action_contract(step.action_type, params)
for v in violations:
errors.append(f"{prefix}: {v.message}")
# Vérifier l'ancre visuelle si requise
required = get_required_params(step.action_type)
if 'visual_anchor' in required and not step.anchor_id:
errors.append(f"{prefix}: ancre visuelle manquante")
# Warnings
if not step.label or step.label == step.action_type:
warnings.append(f"{prefix}: pas de label personnalisé")
return errors, warnings, steps
@api_v3_bp.route('/workflow/<workflow_id>/validate', methods=['POST'])
def validate_workflow(workflow_id: str):
"""
Valide la structure d'un workflow.
Response:
{
"success": true,
"is_valid": true/false,
"errors": [...],
"warnings": [...],
"step_count": 12
}
"""
try:
workflow = Workflow.query.get(workflow_id)
if not workflow:
return jsonify({
'success': False,
'error': f"Workflow '{workflow_id}' non trouvé"
}), 404
errors, warnings, steps = _validate_workflow_steps(workflow)
is_valid = len(errors) == 0
print(f"{'' if is_valid else ''} [API v3] Validation workflow {workflow_id}: "
f"{len(errors)} erreur(s), {len(warnings)} warning(s)")
return jsonify({
'success': True,
'is_valid': is_valid,
'errors': errors,
'warnings': warnings,
'step_count': len(steps)
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_v3_bp.route('/workflow/<workflow_id>/export-training', methods=['POST'])
def export_for_training(workflow_id: str):
"""
Exporte un workflow validé au format JSON d'entraînement.
Response:
{
"success": true,
"export_path": "training_data/workflow_xxx_1234567890.json",
"training_entry": { ... }
}
"""
try:
workflow = Workflow.query.get(workflow_id)
if not workflow:
return jsonify({
'success': False,
'error': f"Workflow '{workflow_id}' non trouvé"
}), 404
# Valider d'abord
errors, warnings, steps = _validate_workflow_steps(workflow)
if len(errors) > 0:
return jsonify({
'success': False,
'error': 'Le workflow contient des erreurs de validation',
'errors': errors
}), 400
# Construire le training entry
training_entry = {
'workflow_id': workflow.id,
'workflow_name': workflow.name,
'description': workflow.description or '',
'tags': workflow.tags if hasattr(workflow, 'tags') and workflow.tags else [],
'steps': [],
'exported_at': datetime.utcnow().isoformat(),
'metadata': {
'step_count': len(steps),
'action_types': list(set(s.action_type for s in steps)),
'has_anchors': any(s.anchor_id for s in steps),
'warnings': warnings
}
}
for step in steps:
step_data = {
'order': step.order,
'action_type': step.action_type,
'label': step.label,
'parameters': step.parameters or {},
'has_anchor': bool(step.anchor_id)
}
training_entry['steps'].append(step_data)
# Sauvegarder dans training_data/
training_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'training_data')
os.makedirs(training_dir, exist_ok=True)
timestamp = int(datetime.now().timestamp())
filename = f"workflow_{workflow_id}_{timestamp}.json"
filepath = os.path.join(training_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(training_entry, f, ensure_ascii=False, indent=2)
export_path = f"training_data/{filename}"
print(f"📦 [API v3] Workflow exporté pour entraînement: {export_path}")
return jsonify({
'success': True,
'export_path': export_path,
'training_entry': training_entry
})
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500

View File

@@ -12,6 +12,8 @@ from flask_socketio import SocketIO
from flask_caching import Cache from flask_caching import Cache
from flask_migrate import Migrate from flask_migrate import Migrate
import os import os
import logging
from logging.handlers import RotatingFileHandler
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables # Load environment variables
@@ -20,6 +22,25 @@ load_dotenv()
# Initialize Flask app # Initialize Flask app
app = Flask(__name__) app = Flask(__name__)
# ============================================================
# Logging — fichier rotatif + console
# ============================================================
_log_dir = os.path.join(os.path.dirname(__file__), 'logs')
os.makedirs(_log_dir, exist_ok=True)
_file_handler = RotatingFileHandler(
os.path.join(_log_dir, 'vwb.log'),
maxBytes=5 * 1024 * 1024, # 5 MB
backupCount=3
)
_file_handler.setLevel(logging.INFO)
_file_handler.setFormatter(logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s'
))
logging.getLogger().addHandler(_file_handler)
logging.getLogger().setLevel(logging.INFO)
# Configuration # Configuration
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///vwb_v3.db') app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///vwb_v3.db')
@@ -134,18 +155,11 @@ except ImportError as e:
# Catalogue VWB - actions VisionOnly # Catalogue VWB - actions VisionOnly
# V2 avec VLM (Vision Language Model) pour détection intelligente # V2 avec VLM (Vision Language Model) pour détection intelligente
try: try:
from catalog_routes_v2_vlm import catalog_bp from catalog_routes_v2_vlm import catalog_bp, VLM_MODEL
app.register_blueprint(catalog_bp) app.register_blueprint(catalog_bp)
print("✅ Blueprint catalog V2 VLM (Ollama qwen2.5vl) enregistré") print(f"✅ Blueprint catalog V2 VLM (Ollama {VLM_MODEL}) enregistré")
except ImportError as e: except ImportError as e:
print(f"⚠️ Blueprint catalog V2 VLM désactivé: {e}") print(f"⚠️ Blueprint catalog V2 VLM désactivé: {e}")
# Fallback sur la version pyautogui
try:
from catalog_routes import catalog_bp
app.register_blueprint(catalog_bp)
print("✅ Blueprint catalog (fallback pyautogui) enregistré")
except ImportError as e2:
print(f"⚠️ Blueprint catalog désactivé: {e2}")
# API Images Ancres Visuelles - stockage serveur # API Images Ancres Visuelles - stockage serveur
try: try:
@@ -224,20 +238,6 @@ def execute_workflow_step():
step_type = data.get('stepType', 'click_anchor') step_type = data.get('stepType', 'click_anchor')
parameters = data.get('parameters', {}) parameters = data.get('parameters', {})
# DEBUG: Écrire les données reçues dans un fichier
import json as json_module
with open('/tmp/vwb_debug.log', 'a') as debug_file:
debug_file.write(f"\n{'='*60}\n")
debug_file.write(f"[execute-step] stepType={step_type}, stepId={step_id}\n")
debug_file.write(f"[execute-step] parameters keys: {list(parameters.keys())}\n")
if 'visual_anchor' in parameters:
va = parameters['visual_anchor']
debug_file.write(f"[execute-step] visual_anchor keys: {list(va.keys()) if va else 'None'}\n")
debug_file.write(f"[execute-step] visual_anchor.id: {va.get('id')}\n")
debug_file.write(f"[execute-step] visual_anchor.thumbnail_url: {va.get('thumbnail_url') or (va.get('metadata', {}) or {}).get('thumbnail_url')}\n")
debug_file.write(f"[execute-step] FULL visual_anchor: {json_module.dumps(va, default=str)[:500]}\n")
debug_file.flush()
# Convert to catalog execute format # Convert to catalog execute format
catalog_request = { catalog_request = {
'type': step_type, 'type': step_type,
@@ -245,13 +245,13 @@ def execute_workflow_step():
'parameters': parameters 'parameters': parameters
} }
# Call the internal catalog execute endpoint # Call the internal catalog execute endpoint (v2 VLM)
from catalog_routes import catalog_bp from catalog_routes_v2_vlm import catalog_bp
# Direct execution via catalog # Direct execution via catalog
try: try:
# Import the execute function directly # Import the execute function directly
from catalog_routes import execute_action as catalog_execute from catalog_routes_v2_vlm import execute_action as catalog_execute
# We need to simulate Flask request context - use internal call # We need to simulate Flask request context - use internal call
from flask import current_app from flask import current_app
with current_app.test_request_context( with current_app.test_request_context(

View File

@@ -50,7 +50,7 @@ except ImportError as e:
# Import des routes du catalogue VWB # Import des routes du catalogue VWB
try: try:
from catalog_routes import register_catalog_routes from catalog_routes_v2_vlm import register_catalog_routes
CATALOG_ROUTES_AVAILABLE = True CATALOG_ROUTES_AVAILABLE = True
print("✅ Routes du catalogue VWB disponibles") print("✅ Routes du catalogue VWB disponibles")
except ImportError as e: except ImportError as e:

File diff suppressed because it is too large Load Diff

View File

@@ -25,14 +25,12 @@ import traceback
# Import des actions et contrats VWB # Import des actions et contrats VWB
try: try:
from visual_workflow_builder.backend.actions import ( from visual_workflow_builder.backend.actions import (
BaseVWBAction, VWBActionResult, VWBActionStatus, BaseVWBAction,
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction, VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction, VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
VWBExtractTextAction VWBExtractTextAction
) )
from visual_workflow_builder.backend.contracts.error import VWBErrorType, VWBErrorSeverity, create_vwb_error from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor
from visual_workflow_builder.backend.contracts.evidence import VWBEvidenceType
from visual_workflow_builder.backend.contracts.visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
ACTIONS_AVAILABLE = True ACTIONS_AVAILABLE = True
print("✅ Actions VWB importées avec succès") print("✅ Actions VWB importées avec succès")
except ImportError as e: except ImportError as e:
@@ -40,14 +38,12 @@ except ImportError as e:
try: try:
# Essayer import relatif # Essayer import relatif
from .actions import ( from .actions import (
BaseVWBAction, VWBActionResult, VWBActionStatus, BaseVWBAction,
VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction, VWBClickAnchorAction, VWBTypeTextAction, VWBWaitForAnchorAction,
VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction, VWBFocusAnchorAction, VWBTypeSecretAction, VWBScrollToAnchorAction,
VWBExtractTextAction VWBExtractTextAction
) )
from .contracts.error import VWBErrorType, VWBErrorSeverity, create_vwb_error from .contracts.visual_anchor import VWBVisualAnchor
from .contracts.evidence import VWBEvidenceType
from .contracts.visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
ACTIONS_AVAILABLE = True ACTIONS_AVAILABLE = True
print("✅ Actions VWB importées avec import relatif") print("✅ Actions VWB importées avec import relatif")
except ImportError as e2: except ImportError as e2:
@@ -97,7 +93,7 @@ try:
sys.path.insert(0, '/home/dom/ai/rpa_vision_v3') sys.path.insert(0, '/home/dom/ai/rpa_vision_v3')
if '/home/dom/ai/OmniParser' not in sys.path: if '/home/dom/ai/OmniParser' not in sys.path:
sys.path.insert(0, '/home/dom/ai/OmniParser') sys.path.insert(0, '/home/dom/ai/OmniParser')
from core.detection.omniparser_adapter import get_omniparser, find_element as omniparser_find from core.detection.omniparser_adapter import get_omniparser
omniparser_adapter = get_omniparser() omniparser_adapter = get_omniparser()
OMNIPARSER_AVAILABLE = omniparser_adapter.available OMNIPARSER_AVAILABLE = omniparser_adapter.available
print(f"✅ OmniParser disponible: {OMNIPARSER_AVAILABLE}") print(f"✅ OmniParser disponible: {OMNIPARSER_AVAILABLE}")
@@ -108,19 +104,18 @@ except Exception as e:
OMNIPARSER_AVAILABLE = False OMNIPARSER_AVAILABLE = False
# ============================================================================ # ============================================================================
# VLM (Vision Language Model) - Ollama qwen2.5vl (fallback si OmniParser échoue) # VLM (Vision Language Model) - Ollama (fallback si OmniParser échoue)
# Configurable via variable d'environnement VLM_MODEL
# ============================================================================ # ============================================================================
OLLAMA_URL = "http://localhost:11434" OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
VLM_MODEL = "qwen2.5vl:7b" # Modèle vision local - bon équilibre précision/vitesse VLM_MODEL = os.environ.get("VLM_MODEL", "qwen3-vl:8b") # qwen3-vl offre une meilleure qualité OCR
# ============================================================================ # ============================================================================
# Pipeline VLM Coarse → Refine → Refine++ (Template Matching) # Pipeline VLM Coarse → Refine → Refine++ (Template Matching)
# ============================================================================ # ============================================================================
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal, Optional, List
from dataclasses import dataclass
# Ollama est optionnel - le template matching fonctionnera sans # Ollama est optionnel - le template matching fonctionnera sans
try: try:

View File

@@ -279,10 +279,9 @@ VWB_ACTION_CONTRACTS: Dict[str, ActionContract] = {
"ai_analyze_text": ActionContract( "ai_analyze_text": ActionContract(
action_type="ai_analyze_text", action_type="ai_analyze_text",
description="Analyser du texte avec IA", description="Analyser du texte ou une image avec IA",
required_params=["visual_anchor", "analysis_prompt"], required_params=[], # prompt est vérifié manuellement (accept prompt ou analysis_prompt)
optional_params=["model", "output_variable"], optional_params=["prompt", "analysis_prompt", "visual_anchor", "input_text", "model", "output_variable", "temperature", "timeout_ms"],
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
), ),
"db_save_data": ActionContract( "db_save_data": ActionContract(

View File

@@ -817,26 +817,10 @@ def find_and_click(
except Exception as seeclick_err: except Exception as seeclick_err:
print(f"⚠️ [Vision] Erreur SeeClick: {seeclick_err}") print(f"⚠️ [Vision] Erreur SeeClick: {seeclick_err}")
# === STRATÉGIE 5: Coordonnées statiques (dernier recours) === # === Toutes les méthodes visuelles ont échoué ===
if anchor_bbox: if anchor_bbox:
best_conf = max(global_result.get('confidence', 0), 0) best_conf = max(global_result.get('confidence', 0), 0)
print(f"❌ [Vision] Ancre non trouvée à l'écran (meilleure confiance: {best_conf:.2f})")
# Utiliser coordonnées statiques seulement si confiance > 0.5
if best_conf >= 0.5:
print(f"⚠️ [Vision] Fallback: coordonnées statiques (confiance: {best_conf:.2f})")
center_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) // 2
center_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) // 2
return {
'found': True,
'coordinates': {'x': int(center_x), 'y': int(center_y)},
'bbox': anchor_bbox,
'confidence': best_conf,
'method': 'static_fallback',
'search_time_ms': (_time.time() - start_time) * 1000,
'candidates': []
}
else:
print(f"❌ [Vision] Ancre non trouvée (confiance: {best_conf:.2f})")
return { return {
'found': False, 'found': False,
'coordinates': None, 'coordinates': None,
@@ -845,7 +829,7 @@ def find_and_click(
'method': 'not_found', 'method': 'not_found',
'search_time_ms': (_time.time() - start_time) * 1000, 'search_time_ms': (_time.time() - start_time) * 1000,
'candidates': [], 'candidates': [],
'reason': 'Ancre non trouvée à l\'écran' 'reason': 'Aucune méthode visuelle n\'a trouvé l\'ancre à l\'écran'
} }
# Pas de bbox, impossible de chercher # Pas de bbox, impossible de chercher

View File

@@ -36,7 +36,8 @@ except ImportError as e:
UIDetector = None UIDetector = None
try: try:
from core.models.screen_state import ScreenState, UIElement from core.models.screen_state import ScreenState
from core.models.ui_element import UIElement
SCREEN_STATE_AVAILABLE = True SCREEN_STATE_AVAILABLE = True
except ImportError as e: except ImportError as e:
print(f"Warning: ScreenState non disponible: {e}") print(f"Warning: ScreenState non disponible: {e}")

View File

@@ -18,8 +18,12 @@ from dataclasses import dataclass
import numpy as np import numpy as np
from PIL import Image from PIL import Image
# Configuration # Configuration — chemin relatif à la racine du projet, ou variable d'environnement
MODEL_PATH = "/home/dom/ai/rpa_vision_v3/models/ui-detr-1/model.pth" _PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..'))
MODEL_PATH = os.environ.get(
"UI_DETR_MODEL_PATH",
os.path.join(_PROJECT_ROOT, "models", "ui-detr-1", "model.pth")
)
CONFIDENCE_THRESHOLD = 0.35 CONFIDENCE_THRESHOLD = 0.35
RESOLUTION = 1600 RESOLUTION = 1600

View File

@@ -1,13 +1,15 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
ReactFlow, ReactFlow,
Controls, Controls,
Background, Background,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
useReactFlow,
addEdge,
ReactFlowProvider, ReactFlowProvider,
} from '@xyflow/react'; } from '@xyflow/react';
import type { Node, Edge, NodeTypes } from '@xyflow/react'; import type { Node, Edge, NodeTypes, Connection } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import * as api from './services/api'; import * as api from './services/api';
@@ -27,6 +29,7 @@ import type { Variable } from './components/VariableManager';
import CaptureLibrary from './components/CaptureLibrary'; import CaptureLibrary from './components/CaptureLibrary';
import SelfHealingDialog from './components/SelfHealingDialog'; import SelfHealingDialog from './components/SelfHealingDialog';
import ConfidenceDashboard from './components/ConfidenceDashboard'; import ConfidenceDashboard from './components/ConfidenceDashboard';
import WorkflowValidation from './components/WorkflowValidation';
const nodeTypes: NodeTypes = { const nodeTypes: NodeTypes = {
step: StepNode, step: StepNode,
@@ -43,9 +46,16 @@ function App() {
const [isExecutionRunning, setIsExecutionRunning] = useState(false); const [isExecutionRunning, setIsExecutionRunning] = useState(false);
const [detectionZone, setDetectionZone] = useState<{x: number; y: number; width: number; height: number} | null>(null); const [detectionZone, setDetectionZone] = useState<{x: number; y: number; width: number; height: number} | null>(null);
const [variables, setVariables] = useState<Variable[]>([]); const [variables, setVariables] = useState<Variable[]>([]);
const [runtimeVariables, setRuntimeVariables] = useState<Record<string, unknown>>({});
const [showWorkflowManager, setShowWorkflowManager] = useState(false); const [showWorkflowManager, setShowWorkflowManager] = useState(false);
const [currentCapture, setCurrentCapture] = useState<Capture | null>(null); const [currentCapture, setCurrentCapture] = useState<Capture | null>(null);
// React Flow instance pour screenToFlowPosition
const reactFlowInstance = useReactFlow();
// Tracker le workflow chargé pour ne pas écraser les edges manuelles
const loadedWorkflowIdRef = useRef<string | null>(null);
// Self-healing interactif // Self-healing interactif
const [showSelfHealing, setShowSelfHealing] = useState(false); const [showSelfHealing, setShowSelfHealing] = useState(false);
const [healingCandidates, setHealingCandidates] = useState<any[]>([]); const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
@@ -56,7 +66,10 @@ function App() {
try { try {
const state = await api.getState(); const state = await api.getState();
setAppState(state); setAppState(state);
updateNodesFromWorkflow(state.workflow?.steps || []); updateNodesFromWorkflow(
state.workflow?.steps || [],
state.workflow?.id
);
} catch (err) { } catch (err) {
setError((err as Error).message); setError((err as Error).message);
} }
@@ -75,6 +88,11 @@ function App() {
const status = await api.getExecutionStatus(); const status = await api.getExecutionStatus();
setIsExecutionRunning(status.is_running); setIsExecutionRunning(status.is_running);
// Extraire les variables runtime du status d'exécution
if (status.variables && typeof status.variables === 'object') {
setRuntimeVariables(status.variables as Record<string, unknown>);
}
// Self-healing interactif: detecter si on attend un choix utilisateur // Self-healing interactif: detecter si on attend un choix utilisateur
if (status.waiting_for_choice && status.candidates) { if (status.waiting_for_choice && status.candidates) {
setHealingCandidates(status.candidates); setHealingCandidates(status.candidates);
@@ -100,7 +118,9 @@ function App() {
}, [isExecutionRunning, loadState]); }, [isExecutionRunning, loadState]);
// Convertir les étapes en nœuds React Flow // Convertir les étapes en nœuds React Flow
const updateNodesFromWorkflow = (steps: Step[]) => { // Les edges ne sont générées automatiquement que lors du premier chargement
// d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées.
const updateNodesFromWorkflow = (steps: Step[], workflowId?: string) => {
const newNodes: Node[] = steps.map((step, index) => ({ const newNodes: Node[] = steps.map((step, index) => ({
id: step.id, id: step.id,
type: 'step', type: 'step',
@@ -108,6 +128,13 @@ function App() {
data: { step }, data: { step },
})); }));
setNodes(newNodes);
// Ne régénérer les edges QUE si on charge un workflow différent
const isNewWorkflow = workflowId && workflowId !== loadedWorkflowIdRef.current;
if (isNewWorkflow) {
loadedWorkflowIdRef.current = workflowId;
const newEdges: Edge[] = []; const newEdges: Edge[] = [];
for (let i = 0; i < steps.length - 1; i++) { for (let i = 0; i < steps.length - 1; i++) {
newEdges.push({ newEdges.push({
@@ -121,9 +148,9 @@ function App() {
style: { strokeWidth: 2 }, style: { strokeWidth: 2 },
}); });
} }
setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
}
// Sinon : les edges existantes sont conservées (connexions manuelles préservées)
}; };
// Actions // Actions
@@ -316,22 +343,48 @@ function App() {
} }
}; };
// Drop d'un outil sur le canvas // Connexion entre deux nœuds (drag d'un handle à un autre)
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) =>
addEdge(
{
...connection,
type: 'smoothstep',
animated: false,
style: { strokeWidth: 2 },
},
eds
)
);
},
[setEdges]
);
// Suppression d'edges (touche Suppr/Backspace)
const onEdgesDelete = useCallback(
(deletedEdges: Edge[]) => {
console.log(`🗑️ ${deletedEdges.length} liaison(s) supprimée(s)`);
},
[]
);
// Drop d'un outil sur le canvas (position corrigée avec zoom/pan)
const onDrop = useCallback( const onDrop = useCallback(
(event: React.DragEvent) => { (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
const actionType = event.dataTransfer.getData('actionType') as ActionType; const actionType = event.dataTransfer.getData('actionType') as ActionType;
if (!actionType) return; if (!actionType) return;
const reactFlowBounds = event.currentTarget.getBoundingClientRect(); // Utiliser screenToFlowPosition pour tenir compte du zoom et du pan
const position = { const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX - reactFlowBounds.left, x: event.clientX,
y: event.clientY - reactFlowBounds.top, y: event.clientY,
}; });
handleAddStep(actionType, position); handleAddStep(actionType, position);
}, },
[appState] [appState, reactFlowInstance]
); );
const onDragOver = useCallback((event: React.DragEvent) => { const onDragOver = useCallback((event: React.DragEvent) => {
@@ -356,6 +409,9 @@ function App() {
onOpenManager={() => setShowWorkflowManager(true)} onOpenManager={() => setShowWorkflowManager(true)}
onRename={handleRenameWorkflow} onRename={handleRenameWorkflow}
/> />
<WorkflowValidation
workflowId={appState?.session.active_workflow_id}
/>
<ExecutionModeToggle <ExecutionModeToggle
mode={executionMode} mode={executionMode}
onChange={setExecutionMode} onChange={setExecutionMode}
@@ -365,6 +421,10 @@ function App() {
onStart={handleStartExecution} onStart={handleStartExecution}
onStop={handleStopExecution} onStop={handleStopExecution}
/> />
<ConfidenceDashboard
isExecutionRunning={isExecutionRunning}
executionMode={executionMode}
/>
</header> </header>
{/* Erreur */} {/* Erreur */}
@@ -389,9 +449,12 @@ function App() {
edges={edges} edges={edges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgesDelete={onEdgesDelete}
onNodeClick={(_, node) => handleSelectStep(node.id)} onNodeClick={(_, node) => handleSelectStep(node.id)}
onNodeDragStop={handleNodeDragStop} onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
deleteKeyCode="Delete"
fitView fitView
> >
<Controls /> <Controls />
@@ -430,6 +493,8 @@ function App() {
onVariableCreate={handleVariableCreate} onVariableCreate={handleVariableCreate}
onVariableUpdate={handleVariableUpdate} onVariableUpdate={handleVariableUpdate}
onVariableDelete={handleVariableDelete} onVariableDelete={handleVariableDelete}
steps={appState?.workflow?.steps || []}
runtimeVariables={runtimeVariables}
/> />
</aside> </aside>
</div> </div>
@@ -473,11 +538,7 @@ function App() {
}} }}
/> />
{/* Confidence Dashboard - scores en temps reel */} {/* ConfidenceDashboard déplacé dans le header */}
<ConfidenceDashboard
isExecutionRunning={isExecutionRunning}
executionMode={executionMode}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,238 @@
/**
* Composant de sélection de modèle IA avec listing Ollama dynamique
* Propose des modèles recommandés selon le type de tâche
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {
listModels,
getRecommendedModels,
checkOllamaStatus,
AI_TASK_TYPES,
MODEL_RECOMMENDATIONS,
type OllamaModelInfo,
type AITaskType,
} from '../services/ollamaService';
interface Props {
taskType: AITaskType;
selectedModel: string;
onModelChange: (model: string) => void;
needsVision?: boolean;
}
export default function AIModelSelector({
taskType,
selectedModel,
onModelChange,
needsVision = false,
}: Props) {
const [models, setModels] = useState<OllamaModelInfo[]>([]);
const [loading, setLoading] = useState(true);
const [ollamaStatus, setOllamaStatus] = useState<{ available: boolean; version?: string }>({ available: false });
const [showAllModels, setShowAllModels] = useState(false);
const [recommendedModels, setRecommendedModels] = useState<{
visionModels: OllamaModelInfo[];
textModels: OllamaModelInfo[];
}>({ visionModels: [], textModels: [] });
// Ref stable pour onModelChange (évite les boucles infinies dans les effets)
const onModelChangeRef = useRef(onModelChange);
useEffect(() => { onModelChangeRef.current = onModelChange; });
// Charger le statut Ollama et les modèles (données uniquement, pas d'auto-sélection)
const loadModels = useCallback(async () => {
setLoading(true);
try {
const status = await checkOllamaStatus();
setOllamaStatus(status);
if (status.available) {
const allModels = await listModels();
setModels(allModels);
const recommended = await getRecommendedModels(taskType);
setRecommendedModels({
visionModels: recommended.visionModels,
textModels: recommended.textModels,
});
}
} catch (err) {
console.error('Erreur chargement modèles:', err);
} finally {
setLoading(false);
}
}, [taskType]);
useEffect(() => {
loadModels();
}, [loadModels]);
// Auto-correction synchrone : quand le modèle sélectionné est incompatible
// avec le mode actuel (vision vs texte), on sélectionne automatiquement
// le premier modèle recommandé compatible
useEffect(() => {
if (loading || models.length === 0) return;
const currentInfo = models.find(m => m.name === selectedModel);
const isIncompatible = currentInfo &&
(needsVision ? !currentInfo.isVision : currentInfo.isVision);
if (!selectedModel || isIncompatible) {
const recs = MODEL_RECOMMENDATIONS[taskType];
const defaultName = needsVision ? recs?.vision?.[0] : recs?.text?.[0];
if (defaultName) {
const found = models.find(m => m.name.includes(defaultName.split(':')[0]));
if (found) { onModelChangeRef.current(found.name); return; }
}
// Fallback : premier modèle compatible
const fallback = models.find(m => needsVision ? m.isVision : !m.isVision);
if (fallback) onModelChangeRef.current(fallback.name);
else if (models.length > 0) onModelChangeRef.current(models[0].name);
}
}, [loading, models, needsVision, taskType, selectedModel]);
const taskInfo = AI_TASK_TYPES.find(t => t.id === taskType);
const recommendations = MODEL_RECOMMENDATIONS[taskType];
// Filtrer les modèles à afficher
const filteredModels = needsVision
? recommendedModels.visionModels
: showAllModels
? models
: recommendedModels.textModels;
// Vérifier si le modèle sélectionné est dans les options visibles du dropdown
const selectedModelInOptions = selectedModel && (
filteredModels.some(m => m.name === selectedModel) ||
(showAllModels && models.some(m => m.name === selectedModel))
);
if (loading) {
return (
<div className="ai-model-selector loading">
<div className="loading-spinner">Chargement des modèles...</div>
</div>
);
}
if (!ollamaStatus.available) {
return (
<div className="ai-model-selector error">
<div className="status-error">
<span className="icon"></span>
<span>Ollama non disponible</span>
</div>
<button className="btn-retry" onClick={loadModels}>
Réessayer
</button>
</div>
);
}
return (
<div className="ai-model-selector">
{/* Info sur le type de tâche */}
<div className="task-info">
<span className="task-icon">{taskInfo?.icon}</span>
<span className="task-description">{recommendations?.description}</span>
</div>
{/* Sélecteur de modèle */}
<div className="model-select-container">
<label>Modèle Ollama</label>
<select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
className="model-select"
>
{filteredModels.length === 0 && !selectedModel ? (
<option value="">Aucun modèle disponible</option>
) : (
<>
{/* Option de secours : si le modèle sélectionné n'apparaît pas
dans les options visibles, on l'ajoute en tant qu'option
désactivée pour que le <select> HTML fonctionne correctement */}
{selectedModel && !selectedModelInOptions && (
<option value={selectedModel} disabled>
{selectedModel} (changement en cours...)
</option>
)}
{/* Groupe des modèles recommandés */}
{recommendedModels.visionModels.length > 0 && needsVision && (
<optgroup label="Recommandés (Vision)">
{recommendedModels.visionModels.slice(0, 3).map(m => (
<option key={m.name} value={m.name}>
{m.displayName} ({m.size}) {recommendations?.vision.some(r => m.name.includes(r.split(':')[0])) ? '★' : ''}
</option>
))}
</optgroup>
)}
{recommendedModels.textModels.length > 0 && !needsVision && (
<optgroup label="Recommandés">
{recommendedModels.textModels.slice(0, 3).map(m => (
<option key={m.name} value={m.name}>
{m.displayName} ({m.size}) {recommendations?.text.some(r => m.name.includes(r.split(':')[0])) ? '★' : ''}
</option>
))}
</optgroup>
)}
{/* Tous les modèles si demandé */}
{showAllModels && (
<optgroup label="Autres modèles">
{models
.filter(m => needsVision ? m.isVision : !m.isVision)
.filter(m => !recommendedModels.textModels.slice(0, 3).some(r => r.name === m.name))
.filter(m => !recommendedModels.visionModels.slice(0, 3).some(r => r.name === m.name))
.map(m => (
<option key={m.name} value={m.name}>
{m.displayName} ({m.size}) {m.isVision ? '👁️' : ''}
</option>
))}
</optgroup>
)}
</>
)}
</select>
</div>
{/* Toggle pour voir tous les modèles */}
<div className="model-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={showAllModels}
onChange={(e) => setShowAllModels(e.target.checked)}
/>
Afficher tous les modèles
</label>
{needsVision && (
<span className="vision-badge">👁 Vision requise</span>
)}
</div>
{/* Modèle sélectionné */}
{selectedModel && (
<div className="selected-model-info">
<span className="label">Sélectionné:</span>
<span className="model-name">{selectedModel}</span>
</div>
)}
{/* Statut Ollama */}
<div className="ollama-status">
<span className="status-dot available" />
<span className="status-text">Ollama v{ollamaStatus.version}</span>
<button className="btn-refresh" onClick={loadModels} title="Rafraîchir">
🔄
</button>
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
/** /**
* Confidence Dashboard Component * Confidence Dashboard Component
* *
* Affiche les scores de confiance en temps réel pendant l'exécution. * Badge compact dans le header avec dropdown pour les scores de confiance.
* Montre CLIP score, template score, distance et méthode utilisée. * S'affiche uniquement en mode intelligent/debug.
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
interface StepScore { interface StepScore {
stepIndex: number; stepIndex: number;
@@ -27,7 +27,19 @@ interface Props {
export default function ConfidenceDashboard({ isExecutionRunning, executionMode }: Props) { export default function ConfidenceDashboard({ isExecutionRunning, executionMode }: Props) {
const [scores, setScores] = useState<StepScore[]>([]); const [scores, setScores] = useState<StepScore[]>([]);
const [currentStep, setCurrentStep] = useState<number>(0); const [currentStep, setCurrentStep] = useState<number>(0);
const [isExpanded, setIsExpanded] = useState(true); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fermer le dropdown au clic extérieur
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Polling pour les scores en temps réel // Polling pour les scores en temps réel
useEffect(() => { useEffect(() => {
@@ -41,7 +53,6 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode
if (data.success && data.execution) { if (data.success && data.execution) {
setCurrentStep(data.execution.current_step_index || 0); setCurrentStep(data.execution.current_step_index || 0);
// Si on a des resultats d'etapes, les ajouter
if (data.execution.step_results) { if (data.execution.step_results) {
const newScores: StepScore[] = data.execution.step_results.map((result: any, index: number) => ({ const newScores: StepScore[] = data.execution.step_results.map((result: any, index: number) => ({
stepIndex: index, stepIndex: index,
@@ -66,27 +77,19 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isExecutionRunning]); }, [isExecutionRunning]);
// Reset quand l'execution s'arrete
useEffect(() => {
if (!isExecutionRunning) {
// Garder les scores pour review
}
}, [isExecutionRunning]);
if (executionMode === 'basic') { if (executionMode === 'basic') {
return null; // Pas de dashboard en mode basic return null;
} }
const getConfidenceColor = (confidence: number): string => { const getConfidenceColor = (confidence: number): string => {
if (confidence >= 0.8) return '#a6e3a1'; // Vert if (confidence >= 0.8) return '#a6e3a1';
if (confidence >= 0.5) return '#f9e2af'; // Jaune if (confidence >= 0.5) return '#f9e2af';
return '#f38ba8'; // Rouge return '#f38ba8';
}; };
const getMethodIcon = (method: string): string => { const getMethodIcon = (method: string): string => {
switch (method) { switch (method) {
case 'clip': return '🧠'; case 'clip': case 'clip_embedding': return '🧠';
case 'clip_embedding': return '🧠';
case 'zoned_template': return '📍'; case 'zoned_template': return '📍';
case 'direct_template': return '🔍'; case 'direct_template': return '🔍';
case 'seeclick_grounding': return '🎯'; case 'seeclick_grounding': return '🎯';
@@ -104,278 +107,238 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode
? (scores.filter(s => s.success).length / scores.length) * 100 ? (scores.filter(s => s.success).length / scores.length) * 100
: 0; : 0;
return ( const avgPct = (averageConfidence * 100).toFixed(0);
<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 && ( return (
<div className="dashboard-content"> <div className="confidence-header-widget" ref={dropdownRef}>
{/* Metriques globales */} {/* Badge compact dans le header */}
<div className="metrics-row"> <button className="confidence-badge" onClick={() => setIsOpen(!isOpen)}>
<div className="metric"> <span className="badge-icon">📊</span>
<span className="metric-label">Etape actuelle</span> {scores.length > 0 ? (
<span className="metric-value">{currentStep + 1}</span> <>
<span className="badge-value" style={{ color: getConfidenceColor(averageConfidence) }}>
{avgPct}%
</span>
{isExecutionRunning && <span className="badge-live">LIVE</span>}
</>
) : (
<span className="badge-label">Confiance</span>
)}
<span className={`badge-arrow ${isOpen ? 'open' : ''}`}></span>
</button>
{/* Dropdown avec les détails */}
{isOpen && (
<div className="confidence-dropdown">
{/* Métriques globales */}
<div className="cd-metrics">
<div className="cd-metric">
<span className="cd-metric-label">Étape</span>
<span className="cd-metric-value">{currentStep + 1}</span>
</div> </div>
<div className="metric"> <div className="cd-metric">
<span className="metric-label">Confiance moy.</span> <span className="cd-metric-label">Confiance</span>
<span <span className="cd-metric-value" style={{ color: getConfidenceColor(averageConfidence) }}>
className="metric-value" {avgPct}%
style={{ color: getConfidenceColor(averageConfidence) }}
>
{(averageConfidence * 100).toFixed(0)}%
</span> </span>
</div> </div>
<div className="metric"> <div className="cd-metric">
<span className="metric-label">Taux succes</span> <span className="cd-metric-label">Succès</span>
<span <span className="cd-metric-value" style={{ color: getConfidenceColor(successRate / 100) }}>
className="metric-value"
style={{ color: getConfidenceColor(successRate / 100) }}
>
{successRate.toFixed(0)}% {successRate.toFixed(0)}%
</span> </span>
</div> </div>
</div> </div>
{/* Liste des scores par etape */} {/* Liste des scores */}
<div className="scores-list"> <div className="cd-scores-list">
{scores.length === 0 ? ( {scores.length === 0 ? (
<div className="no-scores"> <div className="cd-empty">
{isExecutionRunning {isExecutionRunning ? "En attente..." : "Aucune exécution"}
? "En attente de resultats..."
: "Aucune execution en cours"}
</div> </div>
) : ( ) : (
scores.map((score) => ( scores.map((score) => (
<div <div
key={score.stepIndex} key={score.stepIndex}
className={`score-item ${score.success ? 'success' : 'error'} ${score.stepIndex === currentStep ? 'current' : ''}`} className={`cd-score-item ${score.success ? 'success' : 'error'} ${score.stepIndex === currentStep ? 'current' : ''}`}
> >
<div className="score-step"> <span className="cd-step">#{score.stepIndex + 1}</span>
<span className="step-number">#{score.stepIndex + 1}</span> <span className="cd-method-icon">{getMethodIcon(score.method)}</span>
<span className="method-icon">{getMethodIcon(score.method)}</span> <span className="cd-method-name">{score.method}</span>
</div>
<div className="score-details">
<span className="method-name">{score.method}</span>
{score.distance !== undefined && ( {score.distance !== undefined && (
<span className="distance">{score.distance.toFixed(0)}px</span> <span className="cd-distance">{score.distance.toFixed(0)}px</span>
)} )}
</div>
<div <div
className="confidence-bar" className="cd-bar"
style={{ style={{
'--confidence': `${score.confidence * 100}%`, '--confidence': `${score.confidence * 100}%`,
'--confidence-color': getConfidenceColor(score.confidence) '--confidence-color': getConfidenceColor(score.confidence)
} as React.CSSProperties} } as React.CSSProperties}
> >
<span className="confidence-value"> <span className="cd-bar-value">{(score.confidence * 100).toFixed(0)}%</span>
{(score.confidence * 100).toFixed(0)}%
</span>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
{/* Legende */} {/* Légende */}
<div className="legend"> <div className="cd-legend">
<span className="legend-item">🧠 CLIP</span> <span>🧠 CLIP</span>
<span className="legend-item">📍 Template zone</span> <span>📍 Template</span>
<span className="legend-item">🎯 SeeClick</span> <span>🎯 SeeClick</span>
<span className="legend-item">📌 Static</span> <span>📌 Static</span>
</div> </div>
</div> </div>
)} )}
<style>{` <style>{`
.confidence-dashboard { .confidence-header-widget {
position: fixed; position: relative;
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 { .confidence-badge {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #313244;
cursor: pointer;
}
.header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
} padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
.dashboard-icon { border: 1px solid rgba(255, 255, 255, 0.2);
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; border-radius: 6px;
gap: 8px; color: #cdd6f4;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
} }
.score-item.current { .confidence-badge:hover {
border: 1px solid #89b4fa; background: rgba(255, 255, 255, 0.18);
background: rgba(137, 180, 250, 0.1); border-color: rgba(255, 255, 255, 0.35);
} }
.score-item.error { .badge-icon { font-size: 14px; }
border-left: 3px solid #f38ba8;
}
.score-item.success { .badge-value {
border-left: 3px solid #a6e3a1; font-weight: 700;
}
.score-step {
display: flex;
align-items: center;
gap: 4px;
min-width: 50px;
}
.step-number {
color: #89b4fa;
font-weight: bold;
}
.method-icon {
font-size: 14px; font-size: 14px;
} }
.score-details { .badge-label {
flex: 1; font-size: 12px;
opacity: 0.7;
}
.badge-live {
padding: 1px 5px;
background: #f38ba8;
color: #1e1e2e;
border-radius: 3px;
font-size: 9px;
font-weight: bold;
animation: cd-pulse 1.5s infinite;
}
@keyframes cd-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.badge-arrow {
font-size: 9px;
opacity: 0.6;
transition: transform 0.2s;
}
.badge-arrow.open { transform: rotate(180deg); }
.confidence-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 320px;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 1000;
overflow: hidden;
}
.cd-metrics {
display: flex; display: flex;
flex-direction: column; justify-content: space-around;
gap: 2px; padding: 12px;
background: #313244;
} }
.method-name { .cd-metric { text-align: center; }
color: #cdd6f4;
font-size: 11px;
}
.distance { .cd-metric-label {
color: #fab387; display: block;
font-size: 10px; font-size: 10px;
color: #a6adc8;
text-transform: uppercase;
margin-bottom: 3px;
} }
.confidence-bar { .cd-metric-value {
width: 60px; font-size: 18px;
height: 20px; font-weight: 700;
color: #cdd6f4;
}
.cd-scores-list {
max-height: 220px;
overflow-y: auto;
padding: 8px;
}
.cd-empty {
text-align: center;
color: #a6adc8;
padding: 16px;
font-style: italic;
font-size: 12px;
}
.cd-score-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-bottom: 3px;
background: #313244;
border-radius: 5px;
font-size: 12px;
}
.cd-score-item.current { border: 1px solid #89b4fa; background: rgba(137, 180, 250, 0.1); }
.cd-score-item.error { border-left: 3px solid #f38ba8; }
.cd-score-item.success { border-left: 3px solid #a6e3a1; }
.cd-step { color: #89b4fa; font-weight: 700; min-width: 28px; }
.cd-method-icon { font-size: 13px; }
.cd-method-name { flex: 1; color: #cdd6f4; font-size: 11px; }
.cd-distance { color: #fab387; font-size: 10px; }
.cd-bar {
width: 52px;
height: 18px;
background: #45475a; background: #45475a;
border-radius: 4px; border-radius: 4px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.confidence-bar::before { .cd-bar::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 0; left: 0; top: 0; bottom: 0;
top: 0;
bottom: 0;
width: var(--confidence); width: var(--confidence);
background: var(--confidence-color); background: var(--confidence-color);
border-radius: 4px; border-radius: 4px;
transition: width 0.3s;
} }
.confidence-value { .cd-bar-value {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
@@ -383,20 +346,16 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode
justify-content: center; justify-content: center;
height: 100%; height: 100%;
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: 700;
color: #1e1e2e; color: #1e1e2e;
} }
.legend { .cd-legend {
display: flex; display: flex;
gap: 12px; gap: 10px;
justify-content: center; justify-content: center;
margin-top: 12px; padding: 8px;
padding-top: 8px;
border-top: 1px solid #313244; border-top: 1px solid #313244;
}
.legend-item {
font-size: 10px; font-size: 10px;
color: #a6adc8; color: #a6adc8;
} }

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import type { Step, ActionType } from '../types'; import type { Step, ActionType } from '../types';
import { ACTIONS } from '../types'; import { ACTIONS } from '../types';
import { getAnchorThumbnailUrl } from '../services/api'; import { getAnchorThumbnailUrl } from '../services/api';
import AIModelSelector from './AIModelSelector';
import type { AITaskType } from '../services/ollamaService';
interface Props { interface Props {
step: Step | null; step: Step | null;
@@ -147,7 +149,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
value={String(params.text || '')} value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)} onChange={(e) => updateParam('text', e.target.value)}
rows={3} rows={3}
placeholder="Entrez le texte..." placeholder={"Entrez le texte...\nSupporte les variables : {{nom_variable}}"}
/> />
</div> </div>
<div className="prop-field"> <div className="prop-field">
@@ -171,6 +173,16 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
Effacer le champ avant Effacer le champ avant
</label> </label>
</div> </div>
<div className="prop-field">
<label>Stocker dans une variable (optionnel)</label>
<input
type="text"
value={String(params.output_variable || '')}
onChange={(e) => updateParam('output_variable', e.target.value)}
placeholder="nom_variable"
/>
<small className="field-hint">Le texte saisi sera disponible via {'{{nom_variable}}'} dans les étapes suivantes</small>
</div>
</> </>
); );
@@ -442,16 +454,316 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
); );
// === IA === // === IA ===
case 'ai_ocr':
return (
<>
<div className="prop-section-title">
<span className="icon">📝</span> OCR Intelligent
</div>
<AIModelSelector
taskType="ocr"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={true}
/>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="texte_extrait"
/>
</div>
<div className="prop-field">
<label>Langue</label>
<select
value={String(params.language || 'fr')}
onChange={(e) => updateParam('language', e.target.value)}
>
<option value="fr">Français</option>
<option value="en">Anglais</option>
<option value="auto">Auto-détection</option>
</select>
</div>
<div className="prop-field">
<label>Post-traitement</label>
<select
value={String(params.post_process || 'none')}
onChange={(e) => updateParam('post_process', e.target.value)}
>
<option value="none">Aucun</option>
<option value="clean">Nettoyer espaces</option>
<option value="structure">Structurer (JSON)</option>
</select>
</div>
</>
);
case 'ai_summarize':
return (
<>
<div className="prop-section-title">
<span className="icon">📋</span> Résumé IA
</div>
<AIModelSelector
taskType="summarize"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={false}
/>
<div className="prop-field">
<label>Texte source (variable ou direct)</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={3}
placeholder="${variable_texte} ou texte direct"
/>
</div>
<div className="prop-field">
<label>Longueur du résumé</label>
<select
value={String(params.summary_length || 'medium')}
onChange={(e) => updateParam('summary_length', e.target.value)}
>
<option value="short">Court (1-2 phrases)</option>
<option value="medium">Moyen (1 paragraphe)</option>
<option value="long">Long (détaillé)</option>
</select>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="resume_texte"
/>
</div>
</>
);
case 'ai_extract':
return (
<>
<div className="prop-section-title">
<span className="icon">🔍</span> Extraction IA
</div>
<AIModelSelector
taskType="extract"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={Boolean(params.use_vision)}
/>
<div className="prop-field">
<label>Instructions d'extraction</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={3}
placeholder="Ex: Extrais le nom, la date et le montant"
/>
</div>
<div className="prop-field">
<label>Format de sortie</label>
<select
value={String(params.output_format || 'json')}
onChange={(e) => updateParam('output_format', e.target.value)}
>
<option value="text">Texte brut</option>
<option value="json">JSON structuré</option>
<option value="csv">CSV</option>
</select>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="donnees_extraites"
/>
</div>
<div className="prop-field checkbox">
<label>
<input
type="checkbox"
checked={Boolean(params.use_vision)}
onChange={(e) => updateParam('use_vision', e.target.checked)}
/>
Utiliser la vision (pour images/captures)
</label>
</div>
</>
);
case 'ai_classify':
return (
<>
<div className="prop-section-title">
<span className="icon">🏷</span> Classification IA
</div>
<AIModelSelector
taskType="classify"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={false}
/>
<div className="prop-field">
<label>Texte à classifier</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={2}
placeholder="${variable_texte} ou texte direct"
/>
</div>
<div className="prop-field">
<label>Catégories (une par ligne)</label>
<textarea
value={String(params.categories || '')}
onChange={(e) => updateParam('categories', e.target.value)}
rows={4}
placeholder="Facture&#10;Devis&#10;Commande&#10;Autre"
/>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="categorie_detectee"
/>
</div>
</>
);
case 'ai_analyze_text': case 'ai_analyze_text':
return ( return (
<> <>
<div className="prop-section-title">
<span className="icon">🧠</span> Analyse IA
</div>
<AIModelSelector
taskType="analyze"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={!params.input_text}
/>
<div className="prop-field"> <div className="prop-field">
<label>Prompt pour l'IA</label> <label>Mode d'entrée</label>
<div className="mode-toggle-row">
<button
className={`mode-btn ${!params.input_text ? 'active' : ''}`}
onClick={() => updateParam('input_text', '')}
>
📸 Image (screenshot)
</button>
<button
className={`mode-btn ${params.input_text ? 'active' : ''}`}
onClick={() => updateParam('input_text', params.input_text || ' ')}
>
📝 Texte brut
</button>
</div>
</div>
{params.input_text !== undefined && params.input_text !== '' && (
<div className="prop-field">
<label>Texte à analyser</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={6}
placeholder={"Collez le texte ici ou utilisez une variable :\n{{nom_variable}}"}
/>
<small className="field-hint">Supporte les variables : {'{{resultat_ocr}}'}, {'{{texte_extrait}}'}, etc.</small>
</div>
)}
<div className="prop-field">
<label>Prompt d'analyse</label>
<textarea <textarea
value={String(params.prompt || '')} value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)} onChange={(e) => updateParam('prompt', e.target.value)}
rows={4} rows={4}
placeholder="Ex: Extrais le montant total de cette facture" placeholder="Ex: Traduis ce texte en français et fais un résumé..."
/>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.output_variable || 'resultat_analyse')}
onChange={(e) => updateParam('output_variable', e.target.value)}
placeholder="resultat_analyse"
/>
</div>
<div className="prop-field">
<label>Température ({Number(params.temperature || 0.7).toFixed(1)})</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={Number(params.temperature || 0.7)}
onChange={(e) => updateParam('temperature', Number(e.target.value))}
/>
<small className="field-hint">0 = précis/déterministe, 1 = créatif/varié</small>
</div>
<div className="prop-field">
<label>Tokens max</label>
<select
value={String(params.max_tokens || '-1')}
onChange={(e) => updateParam('max_tokens', Number(e.target.value))}
>
<option value="-1">Illimité (recommandé)</option>
<option value="1000">1 000</option>
<option value="2000">2 000</option>
<option value="4000">4 000</option>
<option value="8000">8 000</option>
</select>
</div>
</>
);
case 'ai_custom':
return (
<>
<div className="prop-section-title">
<span className="icon"></span> IA Personnalisée
</div>
<AIModelSelector
taskType="custom"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={Boolean(params.use_vision)}
/>
<div className="prop-field">
<label>Prompt système (contexte)</label>
<textarea
value={String(params.system_prompt || '')}
onChange={(e) => updateParam('system_prompt', e.target.value)}
rows={2}
placeholder="Tu es un assistant expert en..."
/>
</div>
<div className="prop-field">
<label>Prompt utilisateur</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={4}
placeholder="Votre instruction..."
/>
</div>
<div className="prop-field">
<label>Entrée (variable ou texte)</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={2}
placeholder="${variable} ou texte direct"
/> />
</div> </div>
<div className="prop-field"> <div className="prop-field">
@@ -463,17 +775,15 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
placeholder="resultat_ia" placeholder="resultat_ia"
/> />
</div> </div>
<div className="prop-field"> <div className="prop-field checkbox">
<label>Modèle IA</label> <label>
<select <input
value={String(params.model || 'auto')} type="checkbox"
onChange={(e) => updateParam('model', e.target.value)} checked={Boolean(params.use_vision)}
> onChange={(e) => updateParam('use_vision', e.target.checked)}
<option value="auto">Automatique</option> />
<option value="gpt-4">GPT-4</option> Utiliser modèle vision
<option value="claude">Claude</option> </label>
<option value="local">Local (Ollama)</option>
</select>
</div> </div>
</> </>
); );
@@ -615,7 +925,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
{/* Ancre visuelle */} {/* Ancre visuelle */}
{action?.needsAnchor && ( {action?.needsAnchor && (
<div className="prop-anchor"> <div className="prop-anchor">
<label>Ancre visuelle</label> <label>Ancre visuelle (obligatoire)</label>
{step.anchor_id ? ( {step.anchor_id ? (
<div className="anchor-preview"> <div className="anchor-preview">
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" /> <img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
@@ -629,6 +939,17 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</div> </div>
)} )}
{/* Ancre optionnelle (pour les actions qui peuvent fonctionner avec ou sans) */}
{!action?.needsAnchor && step.anchor_id && (
<div className="prop-anchor">
<label>Ancre visuelle (optionnelle)</label>
<div className="anchor-preview">
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
<span className="anchor-ok"> Définie</span>
</div>
</div>
)}
<div className="prop-actions"> <div className="prop-actions">
<button className="btn-save" onClick={handleSave}> <button className="btn-save" onClick={handleSave}>
Enregistrer Enregistrer

View File

@@ -1,9 +1,13 @@
/** /**
* Gestionnaire de Variables simplifié pour VWB v4 * Gestionnaire de Variables simplifié pour VWB v4
* Permet de créer, modifier et supprimer des variables de workflow * Permet de créer, modifier et supprimer des variables de workflow
* + Section "Variables du workflow" : extraction automatique des variables
* produites (output_variable) et consommées ({{var}}) par les étapes.
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback, useMemo } from 'react';
import type { Step } from '../types';
import { ACTIONS } from '../types';
export type VariableType = 'text' | 'number' | 'boolean' | 'list'; export type VariableType = 'text' | 'number' | 'boolean' | 'list';
@@ -15,11 +19,19 @@ export interface Variable {
description?: string; description?: string;
} }
interface WorkflowVariable {
name: string;
producer: Step | null;
consumers: Step[];
}
interface Props { interface Props {
variables: Variable[]; variables: Variable[];
onVariableCreate: (data: Omit<Variable, 'id'>) => void; onVariableCreate: (data: Omit<Variable, 'id'>) => void;
onVariableUpdate: (id: string, data: Partial<Variable>) => void; onVariableUpdate: (id: string, data: Partial<Variable>) => void;
onVariableDelete: (id: string) => void; onVariableDelete: (id: string) => void;
steps?: Step[];
runtimeVariables?: Record<string, unknown>;
} }
const TYPE_LABELS: Record<VariableType, string> = { const TYPE_LABELS: Record<VariableType, string> = {
@@ -29,13 +41,67 @@ const TYPE_LABELS: Record<VariableType, string> = {
list: 'Liste', list: 'Liste',
}; };
function getActionCategory(actionType: string): string {
const def = ACTIONS.find(a => a.type === actionType);
return def?.category || 'other';
}
function getStepLabel(step: Step): string {
const def = ACTIONS.find(a => a.type === step.action_type);
return step.label || def?.label || step.action_type;
}
function extractWorkflowVariables(steps: Step[]): WorkflowVariable[] {
const vars = new Map<string, WorkflowVariable>();
for (const step of steps) {
const p = step.parameters || {};
// Producteurs : output_variable ou variable_name
const outVar = (p.output_variable || p.variable_name) as string | undefined;
if (outVar && typeof outVar === 'string') {
const existing = vars.get(outVar);
if (existing) {
existing.producer = step;
} else {
vars.set(outVar, { name: outVar, producer: step, consumers: [] });
}
}
// Consommateurs : chercher {{var}} dans toutes les valeurs string des params
for (const val of Object.values(p)) {
if (typeof val === 'string') {
for (const match of val.matchAll(/\{\{(\w+)\}\}/g)) {
const varName = match[1];
const existing = vars.get(varName);
if (existing) {
// Eviter les doublons de consommateurs
if (!existing.consumers.some(c => c.id === step.id)) {
existing.consumers.push(step);
}
} else {
vars.set(varName, { name: varName, producer: null, consumers: [step] });
}
}
}
}
}
return Array.from(vars.values());
}
export default function VariableManager({ export default function VariableManager({
variables, variables,
onVariableCreate, onVariableCreate,
onVariableUpdate, onVariableUpdate,
onVariableDelete, onVariableDelete,
steps = [],
runtimeVariables = {},
}: Props) { }: Props) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
// Extraire les variables du workflow depuis les étapes
const workflowVars = useMemo(() => extractWorkflowVariables(steps), [steps]);
const [editingVariable, setEditingVariable] = useState<Variable | null>(null); const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -209,9 +275,57 @@ export default function VariableManager({
</button> </button>
</div> </div>
{/* Section : Variables du workflow (auto-détectées) */}
{workflowVars.length > 0 && (
<div className="workflow-vars-section">
<div className="workflow-vars-header">
<span className="wv-icon">&#x1f517;</span>
<span>Variables du workflow</span>
<span className="wv-count">{workflowVars.length}</span>
</div>
{workflowVars.map((wv) => {
const category = wv.producer ? getActionCategory(wv.producer.action_type) : 'other';
const runtimeVal = runtimeVariables[wv.name];
return (
<div key={wv.name} className="workflow-var-card">
<div className="var-header">
<span className="var-name">{`{{${wv.name}}}`}</span>
<span className={`var-type-badge type-${category}`}>{category}</span>
</div>
{wv.producer ? (
<div className="var-producer">
<span className="label">Produit par:</span>
<span className="step-ref">{getStepLabel(wv.producer)}</span>
</div>
) : (
<div className="var-no-producer">Pas de producteur detect&eacute;</div>
)}
{wv.consumers.length > 0 && (
<div className="var-consumers">
<span className="label">Utilis&eacute; par:</span>
<span className="step-ref">
{wv.consumers.map(c => getStepLabel(c)).join(', ')}
</span>
</div>
)}
{runtimeVal !== undefined && (
<div className="var-runtime-value">
<span className="runtime-label">Valeur:</span>
{String(runtimeVal).length > 120
? String(runtimeVal).slice(0, 120) + '...'
: String(runtimeVal)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Section : Variables manuelles */}
{variables.length === 0 ? ( {variables.length === 0 ? (
<p className="variable-empty-message"> <p className="variable-empty-message">
Aucune variable. Créez-en pour rendre votre workflow flexible. Aucune variable manuelle.
</p> </p>
) : ( ) : (
<div className="variable-list"> <div className="variable-list">

View File

@@ -0,0 +1,171 @@
import { useState } from 'react';
import * as api from '../services/api';
interface WorkflowValidationProps {
workflowId: string | null | undefined;
}
interface ValidationResult {
is_valid: boolean;
errors: string[];
warnings: string[];
step_count: number;
}
export default function WorkflowValidation({ workflowId }: WorkflowValidationProps) {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ValidationResult | null>(null);
const [exportPath, setExportPath] = useState<string | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const handleValidate = async () => {
if (!workflowId) return;
setLoading(true);
setResult(null);
setExportPath(null);
setExportError(null);
setShowModal(true);
try {
const data = await api.validateWorkflow(workflowId);
setResult(data);
} catch (err) {
setResult({
is_valid: false,
errors: [(err as Error).message],
warnings: [],
step_count: 0,
});
} finally {
setLoading(false);
}
};
const handleExport = async () => {
if (!workflowId) return;
setExportError(null);
try {
const data = await api.exportWorkflowForTraining(workflowId);
setExportPath(data.export_path);
} catch (err) {
setExportError((err as Error).message);
}
};
const handleClose = () => {
setShowModal(false);
setResult(null);
setExportPath(null);
setExportError(null);
};
return (
<>
<button
className="validation-btn"
onClick={handleValidate}
disabled={!workflowId}
title={workflowId ? 'Valider le workflow' : 'Aucun workflow actif'}
>
<span className="validation-btn-icon">&#10003;</span>
<span className="validation-btn-text">Valider</span>
</button>
{showModal && (
<div className="modal-overlay" onClick={handleClose}>
<div className="validation-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h4>Validation du workflow</h4>
<button className="btn-close" onClick={handleClose}>&times;</button>
</div>
<div className="modal-body">
{loading && (
<div className="validation-loading">Validation en cours...</div>
)}
{result && !loading && (
<>
{/* Status */}
<div className={`validation-status ${result.is_valid ? 'valid' : 'invalid'}`}>
<span className="validation-status-icon">
{result.is_valid ? '\u2705' : '\u274C'}
</span>
<span>
{result.is_valid
? `Workflow valide (${result.step_count} etape${result.step_count > 1 ? 's' : ''})`
: `${result.errors.length} erreur${result.errors.length > 1 ? 's' : ''} trouvee${result.errors.length > 1 ? 's' : ''}`}
</span>
</div>
{/* Erreurs */}
{result.errors.length > 0 && (
<div className="validation-errors">
<h5>Erreurs</h5>
<ul>
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
{/* Warnings */}
{result.warnings.length > 0 && (
<div className="validation-warnings">
<h5>Warnings</h5>
<ul>
{result.warnings.map((warn, i) => (
<li key={i}>{warn}</li>
))}
</ul>
</div>
)}
{/* Export */}
{result.is_valid && !exportPath && (
<div className="validation-export">
<button className="btn-primary" onClick={handleExport}>
Exporter pour entrainement
</button>
</div>
)}
{/* Export error */}
{exportError && (
<div className="validation-errors">
<h5>Erreur d'export</h5>
<ul>
<li>{exportError}</li>
</ul>
</div>
)}
{/* Export success */}
{exportPath && (
<div className="validation-success">
<span className="validation-success-icon">&#128230;</span>
<div>
<strong>Export reussi</strong>
<code>{exportPath}</code>
</div>
</div>
)}
{/* Message si invalide */}
{!result.is_valid && (
<div className="validation-hint">
Corrigez les erreurs avant de valider.
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -165,6 +165,8 @@ export async function getExecutionStatus(): Promise<{
is_paused: boolean; is_paused: boolean;
execution: Execution | null; execution: Execution | null;
session: AppState['session']; session: AppState['session'];
// Variables runtime du workflow
variables?: Record<string, unknown>;
// Self-healing interactif // Self-healing interactif
waiting_for_choice?: boolean; waiting_for_choice?: boolean;
candidates?: Array<{ candidates?: Array<{
@@ -191,3 +193,20 @@ export async function submitHealingChoice(
): Promise<{ success: boolean; choice: unknown }> { ): Promise<{ success: boolean; choice: unknown }> {
return request('POST', '/execute/healing/choose', { choice }); return request('POST', '/execute/healing/choose', { choice });
} }
// Validation & Export
export async function validateWorkflow(workflowId: string): Promise<{
is_valid: boolean;
errors: string[];
warnings: string[];
step_count: number;
}> {
return request('POST', `/workflow/${workflowId}/validate`);
}
export async function exportWorkflowForTraining(workflowId: string): Promise<{
export_path: string;
training_entry: Record<string, unknown>;
}> {
return request('POST', `/workflow/${workflowId}/export-training`);
}

View File

@@ -0,0 +1,204 @@
/**
* Service Ollama - Gestion des modèles IA locaux
* Liste dynamique des modèles et recommandations par tâche
*/
// Base URL Ollama (localhost car Ollama tourne sur le même serveur)
const OLLAMA_API = 'http://localhost:11434';
export interface OllamaModel {
name: string;
model: string;
modified_at: string;
size: number;
digest: string;
details?: {
parent_model: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
};
}
export interface OllamaModelInfo {
name: string;
displayName: string;
size: string;
isVision: boolean;
family: string;
parameterSize: string;
}
// Modèles connus pour être des modèles vision
const VISION_MODEL_PATTERNS = [
'moondream',
'llava',
'bakllava',
'qwen2.5vl',
'qwen2-vl',
'qwen3-vl',
'granite3.2-vision',
'deepseek-ocr',
'cogvlm',
'minicpm-v',
'internvl',
];
// Recommandations par type de tâche
export const MODEL_RECOMMENDATIONS: Record<string, {
vision: string[];
text: string[];
description: string;
}> = {
ocr: {
vision: ['qwen2.5vl:7b', 'qwen2.5vl:3b', 'deepseek-ocr', 'moondream'],
text: [],
description: 'Extraction de texte depuis images/PDF scannés',
},
summarize: {
vision: [],
text: ['gpt-oss:latest', 'mistral-nemo', 'llama3.1', 'qwen2.5:7b'],
description: 'Résumé et synthèse de texte',
},
extract: {
vision: ['qwen2.5vl:7b'],
text: ['gpt-oss:latest', 'mistral-nemo', 'qwen2.5:7b'],
description: 'Extraction de données structurées',
},
classify: {
vision: ['moondream'],
text: ['mistral-nemo', 'gpt-oss:latest', 'qwen2.5:3b'],
description: 'Classification et catégorisation',
},
analyze: {
vision: ['qwen2.5vl:7b', 'qwen3-vl:8b', 'granite3.2-vision:2b'],
text: ['gpt-oss:latest', 'llama3.1', 'mistral-nemo'],
description: 'Analyse complète de document',
},
custom: {
vision: [],
text: [],
description: 'Tâche personnalisée - choisissez votre modèle',
},
};
/**
* Formate la taille en unité lisible
*/
function formatSize(bytes: number): string {
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) {
return `${gb.toFixed(1)} GB`;
}
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
}
/**
* Détermine si un modèle est un modèle vision
*/
export function isVisionModel(modelName: string): boolean {
const lowerName = modelName.toLowerCase();
return VISION_MODEL_PATTERNS.some(pattern => lowerName.includes(pattern));
}
/**
* Récupère la liste des modèles Ollama disponibles
*/
export async function listModels(): Promise<OllamaModelInfo[]> {
try {
const response = await fetch(`${OLLAMA_API}/api/tags`);
if (!response.ok) {
throw new Error(`Erreur Ollama: ${response.status}`);
}
const data = await response.json();
const models: OllamaModel[] = data.models || [];
return models.map(m => ({
name: m.name,
displayName: m.name.split(':')[0],
size: formatSize(m.size),
isVision: isVisionModel(m.name),
family: m.details?.family || 'unknown',
parameterSize: m.details?.parameter_size || '',
}));
} catch (err) {
console.error('Erreur listModels:', err);
// Retourner une liste vide en cas d'erreur
return [];
}
}
/**
* Récupère les modèles recommandés pour un type de tâche
*/
export async function getRecommendedModels(
taskType: keyof typeof MODEL_RECOMMENDATIONS
): Promise<{
visionModels: OllamaModelInfo[];
textModels: OllamaModelInfo[];
recommendations: typeof MODEL_RECOMMENDATIONS[typeof taskType];
}> {
const allModels = await listModels();
const recommendations = MODEL_RECOMMENDATIONS[taskType] || MODEL_RECOMMENDATIONS.custom;
// Filtrer les modèles disponibles
const visionModels = allModels.filter(m => m.isVision);
const textModels = allModels.filter(m => !m.isVision);
// Trier par recommandation (les recommandés en premier)
const sortByRecommendation = (models: OllamaModelInfo[], recommended: string[]) => {
return [...models].sort((a, b) => {
const aIdx = recommended.findIndex(r => a.name.includes(r.split(':')[0]));
const bIdx = recommended.findIndex(r => b.name.includes(r.split(':')[0]));
if (aIdx === -1 && bIdx === -1) return 0;
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
};
return {
visionModels: sortByRecommendation(visionModels, recommendations.vision),
textModels: sortByRecommendation(textModels, recommendations.text),
recommendations,
};
}
/**
* Vérifie si Ollama est accessible
*/
export async function checkOllamaStatus(): Promise<{
available: boolean;
version?: string;
error?: string;
}> {
try {
const response = await fetch(`${OLLAMA_API}/api/version`);
if (response.ok) {
const data = await response.json();
return { available: true, version: data.version };
}
return { available: false, error: `HTTP ${response.status}` };
} catch (err) {
return { available: false, error: (err as Error).message };
}
}
/**
* Types de tâches IA disponibles
*/
export const AI_TASK_TYPES = [
{ id: 'ocr', label: 'OCR - Extraction texte', icon: '📝', needsVision: true },
{ id: 'summarize', label: 'Résumé', icon: '📋', needsVision: false },
{ id: 'extract', label: 'Extraction données', icon: '🔍', needsVision: false },
{ id: 'classify', label: 'Classification', icon: '🏷️', needsVision: false },
{ id: 'analyze', label: 'Analyse complète', icon: '🧠', needsVision: true },
{ id: 'custom', label: 'Personnalisé', icon: '⚙️', needsVision: false },
] as const;
export type AITaskType = typeof AI_TASK_TYPES[number]['id'];

View File

@@ -43,13 +43,14 @@ body {
.header { .header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 0.75rem;
padding: 0 1rem; padding: 0 1rem;
height: 56px; height: 56px;
background: var(--primary); background: var(--primary);
color: white; color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: visible;
} }
.header h1 { .header h1 {
@@ -596,6 +597,41 @@ body {
resize: vertical; resize: vertical;
} }
.mode-toggle-row {
display: flex;
gap: 4px;
}
.mode-btn {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-paper);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.15s;
}
.mode-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.mode-btn:hover:not(.active) {
background: rgba(99, 102, 241, 0.1);
}
.field-hint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
opacity: 0.7;
}
.prop-anchor { .prop-anchor {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -933,6 +969,8 @@ body {
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.25rem; padding: 0.25rem;
margin-left: auto;
flex-shrink: 0;
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
border-radius: 8px; border-radius: 8px;
} }
@@ -1061,10 +1099,41 @@ body {
.react-flow__edge-path { .react-flow__edge-path {
stroke: var(--border); stroke: var(--border);
stroke-width: 2; stroke-width: 2;
cursor: pointer;
}
.react-flow__edge:hover .react-flow__edge-path {
stroke: var(--secondary);
stroke-width: 3;
} }
.react-flow__edge.selected .react-flow__edge-path { .react-flow__edge.selected .react-flow__edge-path {
stroke: var(--primary); stroke: var(--primary);
stroke-width: 3;
}
/* Handles de connexion visibles au survol des nœuds */
.react-flow__handle {
width: 10px;
height: 10px;
background: var(--border);
border: 2px solid var(--bg-paper);
transition: all 0.15s;
}
.react-flow__handle:hover,
.react-flow__node:hover .react-flow__handle {
background: var(--primary);
width: 12px;
height: 12px;
}
.react-flow__handle-connecting {
background: var(--primary-light);
}
.react-flow__handle-valid {
background: #4caf50;
} }
.react-flow__controls { .react-flow__controls {
@@ -2805,3 +2874,562 @@ body {
background: var(--error); background: var(--error);
color: white; color: white;
} }
/* ===========================================
AI Model Selector - Outils IA avec Ollama
=========================================== */
.ai-model-selector {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-sidebar);
border-radius: 8px;
border: 1px solid var(--border);
}
.ai-model-selector.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
}
.ai-model-selector .loading-spinner {
color: var(--text-secondary);
font-size: 0.85rem;
animation: blink 1s infinite;
}
.ai-model-selector.error {
border-color: var(--error);
background: rgba(244, 67, 54, 0.05);
}
.ai-model-selector .status-error {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--error);
font-weight: 500;
margin-bottom: 0.5rem;
}
.ai-model-selector .btn-retry {
width: 100%;
padding: 0.5rem;
background: var(--error);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.ai-model-selector .task-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.ai-model-selector .task-icon {
font-size: 1.2rem;
}
.ai-model-selector .task-description {
font-size: 0.8rem;
color: var(--text-secondary);
font-style: italic;
}
.ai-model-selector .model-select-container {
margin-bottom: 0.75rem;
}
.ai-model-selector .model-select-container label {
display: block;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--text-primary);
}
.ai-model-selector .model-select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.85rem;
background: var(--bg-paper);
cursor: pointer;
}
.ai-model-selector .model-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.ai-model-selector .model-select optgroup {
font-weight: 600;
color: var(--text-primary);
background: var(--bg-sidebar);
}
.ai-model-selector .model-select option {
padding: 0.5rem;
font-weight: 400;
}
.ai-model-selector .model-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.ai-model-selector .checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
}
.ai-model-selector .checkbox-label input {
cursor: pointer;
}
.ai-model-selector .vision-badge {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: var(--secondary);
color: white;
border-radius: 12px;
font-weight: 500;
}
.ai-model-selector .selected-model-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(25, 118, 210, 0.1);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.ai-model-selector .selected-model-info .label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.ai-model-selector .selected-model-info .model-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
}
.ai-model-selector .ollama-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
}
.ai-model-selector .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ai-model-selector .status-dot.available {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.ai-model-selector .status-dot.unavailable {
background: var(--error);
}
.ai-model-selector .status-text {
color: var(--text-secondary);
flex: 1;
}
.ai-model-selector .btn-refresh {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
opacity: 0.6;
transition: all 0.15s;
}
.ai-model-selector .btn-refresh:hover {
opacity: 1;
transform: rotate(90deg);
}
/* Section title pour les propriétés IA */
.prop-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--primary);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary);
}
.prop-section-title .icon {
font-size: 1.1rem;
}
/* ===========================================
Workflow Variables Section
=========================================== */
.workflow-vars-section {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.workflow-vars-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
.workflow-vars-header .wv-icon {
font-size: 0.9rem;
}
.workflow-vars-header .wv-count {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
background: var(--primary);
color: white;
border-radius: 10px;
}
.workflow-var-card {
padding: 0.5rem 0.6rem;
background: var(--bg-paper);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.4rem;
transition: border-color 0.15s;
}
.workflow-var-card:hover {
border-color: var(--primary-light);
}
.workflow-var-card .var-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.workflow-var-card .var-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--primary-dark);
font-family: 'Consolas', 'Monaco', monospace;
}
.workflow-var-card .var-type-badge {
font-size: 0.6rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
margin-left: auto;
}
.workflow-var-card .var-type-badge.type-ai {
background: var(--secondary);
color: white;
}
.workflow-var-card .var-type-badge.type-data {
background: var(--success);
color: white;
}
.workflow-var-card .var-type-badge.type-keyboard {
background: var(--primary);
color: white;
}
.workflow-var-card .var-type-badge.type-other {
background: var(--text-secondary);
color: white;
}
.var-producer, .var-consumers {
font-size: 0.75rem;
color: var(--text-secondary);
display: flex;
align-items: baseline;
gap: 0.3rem;
margin-bottom: 0.15rem;
}
.var-producer .label, .var-consumers .label {
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.var-producer .step-ref {
color: var(--primary);
font-weight: 500;
}
.var-consumers .step-ref {
color: var(--text-primary);
}
.var-no-producer {
color: var(--warning);
font-style: italic;
font-size: 0.7rem;
}
.var-runtime-value {
margin-top: 0.3rem;
padding: 0.3rem 0.4rem;
background: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.2);
border-radius: 4px;
font-size: 0.7rem;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', monospace;
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: break-all;
}
.var-runtime-value .runtime-label {
font-size: 0.65rem;
color: var(--success);
font-weight: 600;
font-family: inherit;
margin-right: 0.3rem;
}
.workflow-vars-empty {
font-size: 0.75rem;
color: var(--text-disabled);
font-style: italic;
text-align: center;
padding: 0.4rem;
}
.manual-vars-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
/* ===========================================
Workflow Validation
=========================================== */
.validation-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s;
flex-shrink: 0;
}
.validation-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
}
.validation-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.validation-btn-icon {
font-size: 1rem;
}
.validation-modal {
background: var(--bg-paper);
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.validation-loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.95rem;
}
.validation-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.validation-status.valid {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.validation-status.invalid {
background: rgba(244, 67, 54, 0.1);
color: var(--error);
border: 1px solid rgba(244, 67, 54, 0.3);
}
.validation-status-icon {
font-size: 1.2rem;
}
.validation-errors {
margin-bottom: 1rem;
}
.validation-errors h5 {
color: var(--error);
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.validation-errors ul {
list-style: none;
padding: 0;
}
.validation-errors li {
padding: 0.4rem 0.6rem;
margin-bottom: 0.25rem;
background: rgba(244, 67, 54, 0.08);
border-left: 3px solid var(--error);
border-radius: 0 4px 4px 0;
font-size: 0.85rem;
color: var(--text-primary);
}
.validation-warnings {
margin-bottom: 1rem;
}
.validation-warnings h5 {
color: var(--warning);
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.validation-warnings ul {
list-style: none;
padding: 0;
}
.validation-warnings li {
padding: 0.4rem 0.6rem;
margin-bottom: 0.25rem;
background: rgba(255, 152, 0, 0.08);
border-left: 3px solid var(--warning);
border-radius: 0 4px 4px 0;
font-size: 0.85rem;
color: var(--text-primary);
}
.validation-export {
margin-top: 1rem;
text-align: center;
}
.validation-export .btn-primary {
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
}
.validation-success {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 6px;
margin-top: 1rem;
}
.validation-success-icon {
font-size: 1.5rem;
}
.validation-success strong {
display: block;
color: var(--success);
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.validation-success code {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.05);
padding: 0.25rem 0.5rem;
border-radius: 4px;
word-break: break-all;
}
.validation-hint {
margin-top: 1rem;
padding: 0.6rem 0.75rem;
background: var(--bg-sidebar);
border-radius: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
text-align: center;
}

View File

@@ -40,6 +40,11 @@ export type ActionType =
| 'loop_visual' | 'loop_visual'
| 'download_to_folder' | 'download_to_folder'
| 'ai_analyze_text' | 'ai_analyze_text'
| 'ai_ocr'
| 'ai_summarize'
| 'ai_extract'
| 'ai_classify'
| 'ai_custom'
| 'db_save_data' | 'db_save_data'
| 'db_read_data' | 'db_read_data'
| 'verify_element_exists' | 'verify_element_exists'
@@ -83,7 +88,12 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'loop_visual', label: 'Boucle visuelle', icon: '🔁', category: 'logic', needsAnchor: true, params: ['max_iterations'] }, { type: 'loop_visual', label: 'Boucle visuelle', icon: '🔁', category: 'logic', needsAnchor: true, params: ['max_iterations'] },
// === INTELLIGENCE ARTIFICIELLE === // === INTELLIGENCE ARTIFICIELLE ===
{ type: 'ai_analyze_text', label: 'Analyse IA', icon: '🤖', category: 'ai', needsAnchor: true, params: ['prompt', 'variable_name'] }, { type: 'ai_ocr', label: 'OCR Intelligent', icon: '📝', category: 'ai', needsAnchor: true, params: ['variable_name', 'model', 'language'] },
{ type: 'ai_summarize', label: 'Résumé IA', icon: '📋', category: 'ai', needsAnchor: false, params: ['input_text', 'variable_name', 'model', 'max_length'] },
{ type: 'ai_extract', label: 'Extraction IA', icon: '🔍', category: 'ai', needsAnchor: true, params: ['prompt', 'variable_name', 'model', 'output_format'] },
{ type: 'ai_classify', label: 'Classification IA', icon: '🏷️', category: 'ai', needsAnchor: false, params: ['input_text', 'categories', 'variable_name', 'model'] },
{ type: 'ai_analyze_text', label: 'Analyse complète', icon: '🧠', category: 'ai', needsAnchor: false, params: ['prompt', 'variable_name', 'model'] },
{ type: 'ai_custom', label: 'IA Personnalisée', icon: '⚙️', category: 'ai', needsAnchor: false, params: ['prompt', 'input_text', 'variable_name', 'model', 'system_prompt'] },
// === BASE DE DONNÉES === // === BASE DE DONNÉES ===
{ type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] }, { type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] },