Compare commits
4 Commits
v1.0-stabl
...
dev/ia-too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb53901a1 | ||
|
|
75260e3254 | ||
|
|
4c9a6d293f | ||
|
|
3ff36e3c79 |
28
run.sh
28
run.sh
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,34 +244,31 @@ 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
|
||||||
_execution_state['waiting_for_choice'] = True
|
with _execution_lock:
|
||||||
_execution_state['pending_action'] = {
|
_healing_event.clear()
|
||||||
'step_id': step.id,
|
_execution_state['waiting_for_choice'] = True
|
||||||
'step_index': index,
|
_execution_state['pending_action'] = {
|
||||||
'action_type': step.action_type,
|
'step_id': step.id,
|
||||||
'params': params
|
'step_index': index,
|
||||||
}
|
'action_type': step.action_type,
|
||||||
_execution_state['candidates'] = result.get('candidates', [])
|
'params': params
|
||||||
_execution_state['current_step_info'] = {
|
}
|
||||||
'index': index,
|
_execution_state['candidates'] = result.get('candidates', [])
|
||||||
'total': len(steps),
|
_execution_state['current_step_info'] = {
|
||||||
'original_bbox': result.get('original_bbox'),
|
'index': index,
|
||||||
'error': result.get('error')
|
'total': len(steps),
|
||||||
}
|
'original_bbox': result.get('original_bbox'),
|
||||||
_execution_state['user_choice'] = None
|
'error': result.get('error')
|
||||||
|
}
|
||||||
|
_execution_state['user_choice'] = None
|
||||||
|
|
||||||
# Mettre à jour le status de l'exécution
|
# Mettre à jour le status de l'exécution
|
||||||
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,10 +276,11 @@ 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
|
||||||
user_choice = _execution_state['user_choice']
|
with _execution_lock:
|
||||||
_execution_state['waiting_for_choice'] = False
|
user_choice = _execution_state['user_choice']
|
||||||
_execution_state['pending_action'] = None
|
_execution_state['waiting_for_choice'] = False
|
||||||
_execution_state['candidates'] = []
|
_execution_state['pending_action'] = None
|
||||||
|
_execution_state['candidates'] = []
|
||||||
|
|
||||||
if user_choice is None:
|
if user_choice is None:
|
||||||
# Timeout - aucun choix
|
# Timeout - aucun choix
|
||||||
@@ -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,74 +366,130 @@ 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:
|
||||||
_execution_state['is_running'] = False
|
with _execution_lock:
|
||||||
_execution_state['current_execution_id'] = None
|
_execution_state['is_running'] = False
|
||||||
|
_execution_state['current_execution_id'] = None
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
if not screenshot_base64:
|
# Déterminer le mode : texte ou image
|
||||||
# Capturer l'écran si pas d'image dans l'ancre
|
use_text_mode = bool(input_text)
|
||||||
try:
|
anchor = params.get('visual_anchor', {})
|
||||||
from PIL import ImageGrab
|
|
||||||
import io
|
|
||||||
|
|
||||||
bbox = anchor.get('bounding_box', {})
|
print(f"🤖 [IA] Mode: {'TEXTE' if use_text_mode else 'IMAGE'}")
|
||||||
if bbox:
|
|
||||||
# Capturer la zone spécifique
|
|
||||||
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
|
|
||||||
w, h = int(bbox.get('width', 100)), int(bbox.get('height', 100))
|
|
||||||
screenshot = ImageGrab.grab(bbox=(x, y, x + w, y + h))
|
|
||||||
else:
|
|
||||||
# Capturer tout l'écran
|
|
||||||
screenshot = ImageGrab.grab()
|
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
if use_text_mode:
|
||||||
screenshot.save(buffer, format='PNG')
|
# ═══ MODE TEXTE : envoyer le texte directement (comme en CLI) ═══
|
||||||
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
print(f"📝 [IA] Texte brut: {len(input_text)} caractères")
|
||||||
except Exception as cap_err:
|
|
||||||
return {'success': False, 'error': f"Erreur capture: {cap_err}"}
|
|
||||||
|
|
||||||
if not prompt:
|
# Construire le prompt complet avec le texte en entrée
|
||||||
prompt = "Décris ce que tu vois dans cette image."
|
if prompt:
|
||||||
|
full_prompt = f"{prompt}\n\nVoici le texte :\n{input_text}"
|
||||||
|
else:
|
||||||
|
full_prompt = input_text
|
||||||
|
|
||||||
print(f"🤖 [IA] Analyse avec {model}...")
|
# Pour les modèles Qwen, désactiver le thinking étendu
|
||||||
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}"
|
||||||
|
|
||||||
# Appeler Ollama
|
print(f"🤖 [IA] Analyse texte avec {model}...")
|
||||||
ollama_url = params.get('ollama_url', 'http://localhost:11434')
|
|
||||||
|
|
||||||
payload = {
|
ollama_url = params.get('ollama_url', 'http://localhost:11434')
|
||||||
"model": model,
|
options = {"temperature": temperature}
|
||||||
"prompt": prompt,
|
if max_tokens > 0:
|
||||||
"images": [screenshot_base64],
|
options["num_predict"] = max_tokens
|
||||||
"stream": False,
|
payload = {
|
||||||
"options": {
|
"model": model,
|
||||||
"temperature": temperature,
|
"prompt": full_prompt,
|
||||||
"num_predict": 1000
|
"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:
|
||||||
|
try:
|
||||||
|
from PIL import ImageGrab
|
||||||
|
import io
|
||||||
|
|
||||||
|
bbox = anchor.get('bounding_box', {}) if anchor else {}
|
||||||
|
|
||||||
|
if bbox:
|
||||||
|
x, y = int(bbox.get('x', 0)), int(bbox.get('y', 0))
|
||||||
|
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))
|
||||||
|
else:
|
||||||
|
print(f"📸 [IA] Capture écran complet")
|
||||||
|
screenshot = ImageGrab.grab()
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
screenshot.save(buffer, format='PNG')
|
||||||
|
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
except Exception as 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:
|
||||||
|
prompt = "Décris ce que tu vois dans cette image."
|
||||||
|
|
||||||
|
full_prompt = prompt
|
||||||
|
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}...")
|
||||||
|
|
||||||
|
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,
|
||||||
|
"images": [screenshot_base64],
|
||||||
|
"stream": False,
|
||||||
|
"options": options
|
||||||
|
}
|
||||||
|
|
||||||
|
# ═══ APPEL OLLAMA ═══
|
||||||
|
print(f" Prompt: {full_prompt[:100]}...")
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{ollama_url}/api/generate",
|
f"{ollama_url}/api/generate",
|
||||||
@@ -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()
|
||||||
|
|
||||||
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
|
# Fallback : extraire du champ thinking si response vide
|
||||||
print(f" Résultat: {analysis_text[:150]}...")
|
if not analysis_text and result.get('thinking'):
|
||||||
|
analysis_text = result.get('thinking', '').strip()
|
||||||
|
|
||||||
# Stocker le résultat dans le contexte d'exécution pour les variables
|
print(f"✅ [IA] Analyse terminée ({len(analysis_text)} caractères)")
|
||||||
global _execution_state
|
if analysis_text:
|
||||||
if 'variables' not in _execution_state:
|
print(f" Résultat: {analysis_text[:150]}...")
|
||||||
_execution_state['variables'] = {}
|
|
||||||
|
# Stocker dans les variables d'exécution
|
||||||
_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,27 +706,34 @@ 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}")
|
||||||
return {
|
if execution_mode in ['intelligent', 'debug']:
|
||||||
'success': False,
|
# En mode visuel, on NE fait PAS de fallback statique
|
||||||
'error': f"Erreur vision: {str(vision_err)}"
|
return {
|
||||||
}
|
'success': False,
|
||||||
|
'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)
|
||||||
elif click_type == 'right':
|
elif click_type == 'right':
|
||||||
pyautogui.rightClick(x, y)
|
pyautogui.rightClick(x, y)
|
||||||
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)
|
||||||
_execution_state['is_running'] = True
|
with _execution_lock:
|
||||||
_execution_state['is_paused'] = False
|
_execution_state['is_running'] = True
|
||||||
_execution_state['should_stop'] = False
|
_execution_state['is_paused'] = False
|
||||||
_execution_state['current_execution_id'] = execution.id
|
_execution_state['should_stop'] = False
|
||||||
_execution_state['execution_mode'] = execution_mode
|
_execution_state['current_execution_id'] = execution.id
|
||||||
|
_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,7 +901,8 @@ def start_execution():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
_execution_state['is_running'] = False
|
with _execution_lock:
|
||||||
|
_execution_state['is_running'] = False
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
@@ -832,14 +972,17 @@ def stop_execution():
|
|||||||
"""Arrête l'exécution"""
|
"""Arrête l'exécution"""
|
||||||
global _execution_state
|
global _execution_state
|
||||||
|
|
||||||
if not _execution_state['is_running']:
|
with _execution_lock:
|
||||||
return jsonify({
|
if not _execution_state['is_running']:
|
||||||
'success': False,
|
return jsonify({
|
||||||
'error': "Aucune exécution en cours"
|
'success': False,
|
||||||
}), 400
|
'error': "Aucune exécution en cours"
|
||||||
|
}), 400
|
||||||
|
|
||||||
_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
|
||||||
_execution_state['user_choice'] = choice
|
with _execution_lock:
|
||||||
_execution_state['waiting_for_choice'] = False
|
_execution_state['user_choice'] = choice
|
||||||
|
_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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -817,36 +817,20 @@ 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
|
return {
|
||||||
if best_conf >= 0.5:
|
'found': False,
|
||||||
print(f"⚠️ [Vision] Fallback: coordonnées statiques (confiance: {best_conf:.2f})")
|
'coordinates': None,
|
||||||
center_x = anchor_bbox.get('x', 0) + anchor_bbox.get('width', 0) // 2
|
'bbox': anchor_bbox,
|
||||||
center_y = anchor_bbox.get('y', 0) + anchor_bbox.get('height', 0) // 2
|
'confidence': best_conf,
|
||||||
return {
|
'method': 'not_found',
|
||||||
'found': True,
|
'search_time_ms': (_time.time() - start_time) * 1000,
|
||||||
'coordinates': {'x': int(center_x), 'y': int(center_y)},
|
'candidates': [],
|
||||||
'bbox': anchor_bbox,
|
'reason': 'Aucune méthode visuelle n\'a trouvé l\'ancre à l\'écran'
|
||||||
'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 {
|
|
||||||
'found': False,
|
|
||||||
'coordinates': None,
|
|
||||||
'bbox': anchor_bbox,
|
|
||||||
'confidence': best_conf,
|
|
||||||
'method': 'not_found',
|
|
||||||
'search_time_ms': (_time.time() - start_time) * 1000,
|
|
||||||
'candidates': [],
|
|
||||||
'reason': 'Ancre non trouvée à l\'écran'
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pas de bbox, impossible de chercher
|
# Pas de bbox, impossible de chercher
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,22 +128,29 @@ function App() {
|
|||||||
data: { step },
|
data: { step },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const newEdges: Edge[] = [];
|
|
||||||
for (let i = 0; i < steps.length - 1; i++) {
|
|
||||||
newEdges.push({
|
|
||||||
id: `e-${steps[i].id}-${steps[i + 1].id}`,
|
|
||||||
source: steps[i].id,
|
|
||||||
sourceHandle: 'bottom',
|
|
||||||
target: steps[i + 1].id,
|
|
||||||
targetHandle: 'top',
|
|
||||||
type: 'smoothstep',
|
|
||||||
animated: false,
|
|
||||||
style: { strokeWidth: 2 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setNodes(newNodes);
|
setNodes(newNodes);
|
||||||
setEdges(newEdges);
|
|
||||||
|
// 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[] = [];
|
||||||
|
for (let i = 0; i < steps.length - 1; i++) {
|
||||||
|
newEdges.push({
|
||||||
|
id: `e-${steps[i].id}-${steps[i + 1].id}`,
|
||||||
|
source: steps[i].id,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
target: steps[i + 1].id,
|
||||||
|
targetHandle: 'top',
|
||||||
|
type: 'smoothstep',
|
||||||
|
animated: false,
|
||||||
|
style: { strokeWidth: 2 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
{score.distance !== undefined && (
|
||||||
<div className="score-details">
|
<span className="cd-distance">{score.distance.toFixed(0)}px</span>
|
||||||
<span className="method-name">{score.method}</span>
|
)}
|
||||||
{score.distance !== undefined && (
|
|
||||||
<span className="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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Devis Commande 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
|
||||||
|
|||||||
@@ -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">🔗</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é</div>
|
||||||
|
)}
|
||||||
|
{wv.consumers.length > 0 && (
|
||||||
|
<div className="var-consumers">
|
||||||
|
<span className="label">Utilisé 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">
|
||||||
|
|||||||
@@ -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">✓</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}>×</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">📦</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
Reference in New Issue
Block a user