feat(vwb-v3): Architecture Thin Client fonctionnelle
API = Source de vérité unique (SQLite + Flask) - Backend: API v3 avec session, workflow, capture, execute - Frontend: Vanilla TypeScript, pas de state local - Contrats stricts pour les actions RPA - Drag & drop pour réorganiser les étapes - Insertion d'étapes entre deux existantes - Bibliothèque de captures (sessionStorage) - Exécution avec coordonnées statiques (pyautogui) Fonctionne mais fragile (coordonnées fixes, pas de détection visuelle) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
18
visual_workflow_builder/backend/api_v3/__init__.py
Normal file
18
visual_workflow_builder/backend/api_v3/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
API v3 - RPA Vision Thin Client
|
||||
Source de vérité unique, pas de logique frontend
|
||||
|
||||
Auteur: Dom, Alice, Kiro - 23 janvier 2026
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
api_v3_bp = Blueprint('api_v3', __name__, url_prefix='/api/v3')
|
||||
|
||||
# Import des routes après la création du blueprint pour éviter les imports circulaires
|
||||
from . import session
|
||||
from . import workflow
|
||||
from . import capture
|
||||
from . import execute
|
||||
|
||||
__all__ = ['api_v3_bp']
|
||||
318
visual_workflow_builder/backend/api_v3/capture.py
Normal file
318
visual_workflow_builder/backend/api_v3/capture.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
API v3 - Capture et Sélection Visuelle
|
||||
Gestion des captures d'écran et création d'ancres visuelles
|
||||
|
||||
POST /api/v3/capture/screen → Capture écran
|
||||
POST /api/v3/capture/select → Crée ancre depuis sélection
|
||||
GET /api/v3/anchor/{id}/image → Image de l'ancre
|
||||
"""
|
||||
|
||||
from flask import jsonify, request, send_file
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import os
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Step, VisualAnchor, get_session_state
|
||||
|
||||
|
||||
# Dossier pour stocker les images d'ancres
|
||||
ANCHORS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'anchors')
|
||||
os.makedirs(ANCHORS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/screen', methods=['POST'])
|
||||
def capture_screen():
|
||||
"""
|
||||
Capture l'écran actuel.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"capture": {
|
||||
"screenshot_base64": "...",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"timestamp": "..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
import pyautogui
|
||||
|
||||
# Capture écran
|
||||
screenshot = pyautogui.screenshot()
|
||||
width, height = screenshot.size
|
||||
|
||||
# Convertir en base64
|
||||
buffer = BytesIO()
|
||||
screenshot.save(buffer, format='PNG')
|
||||
screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
# Stocker dans la session
|
||||
session = get_session_state()
|
||||
session.last_capture = {
|
||||
'screenshot_base64': screenshot_base64,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
print(f"📸 [API v3] Capture écran: {width}x{height}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'capture': session.last_capture
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/capture/select', methods=['POST'])
|
||||
def select_anchor():
|
||||
"""
|
||||
Crée une ancre visuelle à partir d'une sélection utilisateur.
|
||||
|
||||
Request:
|
||||
{
|
||||
"step_id": "step_123", // Étape à laquelle associer l'ancre
|
||||
"bbox": {
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"width": 50,
|
||||
"height": 30
|
||||
},
|
||||
"description": "Bouton Valider", // Optionnel
|
||||
"screenshot_base64": "..." // Optionnel - si fourni, utilise cette capture au lieu de session.last_capture
|
||||
}
|
||||
|
||||
L'image est extraite de la capture fournie ou de la dernière capture (session.last_capture).
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... },
|
||||
"step": { ... },
|
||||
"anchor": { ... }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
step_id = data.get('step_id')
|
||||
bbox = data.get('bbox')
|
||||
description = data.get('description', '')
|
||||
screenshot_base64 = data.get('screenshot_base64') # Capture optionnelle directe
|
||||
|
||||
if not step_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "step_id requis"
|
||||
}), 400
|
||||
|
||||
if not bbox:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "bbox requis"
|
||||
}), 400
|
||||
|
||||
# Vérifier que l'étape existe
|
||||
step = Step.query.get(step_id)
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
# Récupérer la capture (fournie ou depuis la session)
|
||||
session = get_session_state()
|
||||
|
||||
if screenshot_base64:
|
||||
# Utiliser la capture fournie directement
|
||||
print(f"📸 [API v3] Utilisation de la capture fournie dans la requête")
|
||||
elif session.last_capture:
|
||||
screenshot_base64 = session.last_capture['screenshot_base64']
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune capture disponible. Fournissez screenshot_base64 ou appelez /capture/screen d'abord."
|
||||
}), 400
|
||||
|
||||
# Décoder l'image
|
||||
img_data = base64.b64decode(screenshot_base64)
|
||||
img = Image.open(BytesIO(img_data))
|
||||
|
||||
# Extraire la zone sélectionnée
|
||||
x = int(bbox.get('x', 0))
|
||||
y = int(bbox.get('y', 0))
|
||||
w = int(bbox.get('width', 100))
|
||||
h = int(bbox.get('height', 100))
|
||||
|
||||
# Valider les coordonnées
|
||||
x = max(0, min(x, img.width - 1))
|
||||
y = max(0, min(y, img.height - 1))
|
||||
w = max(10, min(w, img.width - x))
|
||||
h = max(10, min(h, img.height - y))
|
||||
|
||||
# Créer l'ancre
|
||||
anchor_id = generate_id('anchor')
|
||||
|
||||
# Sauvegarder l'image complète de référence
|
||||
image_path = os.path.join(ANCHORS_DIR, f"{anchor_id}_full.png")
|
||||
img.save(image_path, 'PNG')
|
||||
|
||||
# Créer et sauvegarder la miniature (zone sélectionnée)
|
||||
thumbnail = img.crop((x, y, x + w, y + h))
|
||||
thumbnail_path = os.path.join(ANCHORS_DIR, f"{anchor_id}_thumb.png")
|
||||
thumbnail.save(thumbnail_path, 'PNG')
|
||||
|
||||
# Créer l'enregistrement en base
|
||||
# Utiliser les dimensions de l'image décodée (pas de session.last_capture qui peut être None)
|
||||
anchor = VisualAnchor(
|
||||
id=anchor_id,
|
||||
image_path=image_path,
|
||||
thumbnail_path=thumbnail_path,
|
||||
bbox_x=x,
|
||||
bbox_y=y,
|
||||
bbox_width=w,
|
||||
bbox_height=h,
|
||||
screen_width=img.width,
|
||||
screen_height=img.height,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.session.add(anchor)
|
||||
|
||||
# Associer l'ancre à l'étape
|
||||
step.anchor_id = anchor_id
|
||||
|
||||
# Mettre à jour les paramètres de l'étape avec l'ancre
|
||||
params = step.parameters or {}
|
||||
params['visual_anchor'] = {
|
||||
'anchor_id': anchor_id,
|
||||
'bounding_box': {'x': x, 'y': y, 'width': w, 'height': h}
|
||||
}
|
||||
step.parameters = params
|
||||
|
||||
db.session.commit()
|
||||
|
||||
from db.models import Workflow
|
||||
workflow = Workflow.query.get(step.workflow_id)
|
||||
|
||||
print(f"✅ [API v3] Ancre créée: {anchor_id} pour étape {step_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'step': step.to_dict(),
|
||||
'anchor': anchor.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/anchor/<anchor_id>/image', methods=['GET'])
|
||||
def get_anchor_image(anchor_id: str):
|
||||
"""Retourne l'image complète de l'ancre"""
|
||||
try:
|
||||
anchor = VisualAnchor.query.get(anchor_id)
|
||||
if not anchor or not anchor.image_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{anchor_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(anchor.image_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Fichier image non trouvé"
|
||||
}), 404
|
||||
|
||||
return send_file(anchor.image_path, mimetype='image/png')
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/anchor/<anchor_id>/thumbnail', methods=['GET'])
|
||||
def get_anchor_thumbnail(anchor_id: str):
|
||||
"""Retourne la miniature de l'ancre (zone sélectionnée)"""
|
||||
try:
|
||||
anchor = VisualAnchor.query.get(anchor_id)
|
||||
if not anchor or not anchor.thumbnail_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{anchor_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(anchor.thumbnail_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Fichier miniature non trouvé"
|
||||
}), 404
|
||||
|
||||
return send_file(anchor.thumbnail_path, mimetype='image/png')
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/anchor/<anchor_id>/base64', methods=['GET'])
|
||||
def get_anchor_base64(anchor_id: str):
|
||||
"""Retourne l'image de l'ancre en base64 (pour exécution)"""
|
||||
try:
|
||||
anchor = VisualAnchor.query.get(anchor_id)
|
||||
if not anchor or not anchor.image_path:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{anchor_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
if not os.path.exists(anchor.image_path):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Fichier image non trouvé"
|
||||
}), 404
|
||||
|
||||
with open(anchor.image_path, 'rb') as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'anchor_id': anchor_id,
|
||||
'image_base64': image_base64,
|
||||
'bounding_box': {
|
||||
'x': anchor.bbox_x,
|
||||
'y': anchor.bbox_y,
|
||||
'width': anchor.bbox_width,
|
||||
'height': anchor.bbox_height
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
504
visual_workflow_builder/backend/api_v3/execute.py
Normal file
504
visual_workflow_builder/backend/api_v3/execute.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
API v3 - Exécution de Workflows
|
||||
Gestion de l'exécution avec validation de contrats
|
||||
|
||||
POST /api/v3/execute/start → Lance l'exécution
|
||||
POST /api/v3/execute/pause → Met en pause
|
||||
POST /api/v3/execute/resume → Reprend
|
||||
POST /api/v3/execute/stop → Arrête
|
||||
GET /api/v3/execute/status → État actuel
|
||||
"""
|
||||
|
||||
from flask import jsonify, request
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import threading
|
||||
import time
|
||||
import base64
|
||||
import os
|
||||
from . import api_v3_bp
|
||||
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
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
|
||||
|
||||
|
||||
# État de l'exécution en cours (en mémoire)
|
||||
_execution_state = {
|
||||
'is_running': False,
|
||||
'is_paused': False,
|
||||
'should_stop': False,
|
||||
'current_execution_id': None,
|
||||
'thread': None
|
||||
}
|
||||
|
||||
|
||||
def execute_workflow_thread(execution_id: str, workflow_id: str, app):
|
||||
"""
|
||||
Thread d'exécution du workflow.
|
||||
Exécute chaque étape séquentiellement avec validation de contrat.
|
||||
"""
|
||||
global _execution_state
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
execution = Execution.query.get(execution_id)
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
|
||||
if not execution or not workflow:
|
||||
print(f"❌ [Execute] Workflow ou exécution non trouvé")
|
||||
return
|
||||
|
||||
steps = workflow.steps.order_by(Step.order).all()
|
||||
execution.total_steps = len(steps)
|
||||
execution.status = 'running'
|
||||
execution.started_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
print(f"🚀 [Execute] Démarrage workflow {workflow_id}: {len(steps)} étapes")
|
||||
|
||||
for index, step in enumerate(steps):
|
||||
# Vérifier si arrêt demandé
|
||||
if _execution_state['should_stop']:
|
||||
print(f"⛔ [Execute] Arrêt demandé")
|
||||
execution.status = 'cancelled'
|
||||
break
|
||||
|
||||
# Attendre si en pause
|
||||
while _execution_state['is_paused'] and not _execution_state['should_stop']:
|
||||
time.sleep(0.1)
|
||||
|
||||
if _execution_state['should_stop']:
|
||||
execution.status = 'cancelled'
|
||||
break
|
||||
|
||||
# Mettre à jour la progression
|
||||
execution.current_step_index = index
|
||||
db.session.commit()
|
||||
|
||||
# Créer l'enregistrement de résultat
|
||||
step_result = ExecutionStep(
|
||||
execution_id=execution_id,
|
||||
step_id=step.id,
|
||||
status='running',
|
||||
started_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(step_result)
|
||||
db.session.commit()
|
||||
|
||||
print(f"📋 [Execute] Étape {index + 1}/{len(steps)}: {step.action_type} (id={step.id})")
|
||||
|
||||
try:
|
||||
# === VALIDATION CONTRAT STRICT ===
|
||||
params = step.parameters or {}
|
||||
|
||||
# Si l'étape a une ancre, charger ses données
|
||||
if step.anchor_id:
|
||||
anchor = VisualAnchor.query.get(step.anchor_id)
|
||||
if anchor:
|
||||
# Charger l'image base64 depuis le fichier
|
||||
if anchor.image_path and os.path.exists(anchor.image_path):
|
||||
with open(anchor.image_path, 'rb') as f:
|
||||
image_base64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
else:
|
||||
image_base64 = None
|
||||
|
||||
params['visual_anchor'] = {
|
||||
'anchor_id': anchor.id,
|
||||
'screenshot': image_base64,
|
||||
'bounding_box': {
|
||||
'x': anchor.bbox_x,
|
||||
'y': anchor.bbox_y,
|
||||
'width': anchor.bbox_width,
|
||||
'height': anchor.bbox_height
|
||||
},
|
||||
'metadata': {
|
||||
'screen_resolution': {
|
||||
'width': anchor.screen_width,
|
||||
'height': anchor.screen_height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Valider le contrat
|
||||
try:
|
||||
enforce_action_contract(step.action_type, params)
|
||||
except ContractValidationError as e:
|
||||
print(f"🚫 [Execute] CONTRAT VIOLÉ pour étape {step.id}: {e}")
|
||||
step_result.status = 'error'
|
||||
step_result.error_message = f"Contrat violé: {str(e)}"
|
||||
step_result.ended_at = datetime.utcnow()
|
||||
execution.failed_steps += 1
|
||||
db.session.commit()
|
||||
|
||||
# Arrêter sur violation de contrat
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Contrat violé à l'étape {index + 1}: {str(e)}"
|
||||
break
|
||||
|
||||
# === EXÉCUTION DE L'ACTION ===
|
||||
result = execute_action(step.action_type, params)
|
||||
|
||||
step_result.ended_at = datetime.utcnow()
|
||||
step_result.duration_ms = int((step_result.ended_at - step_result.started_at).total_seconds() * 1000)
|
||||
|
||||
if result.get('success'):
|
||||
step_result.status = 'success'
|
||||
step_result.output = result.get('output', {})
|
||||
execution.completed_steps += 1
|
||||
print(f"✅ [Execute] Étape {index + 1} réussie")
|
||||
else:
|
||||
step_result.status = 'error'
|
||||
step_result.error_message = result.get('error', 'Erreur inconnue')
|
||||
execution.failed_steps += 1
|
||||
print(f"❌ [Execute] Étape {index + 1} échouée: {step_result.error_message}")
|
||||
|
||||
# Arrêter sur erreur
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Erreur à l'étape {index + 1}: {step_result.error_message}"
|
||||
db.session.commit()
|
||||
break
|
||||
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Execute] Exception étape {index + 1}: {e}")
|
||||
step_result.status = 'error'
|
||||
step_result.error_message = str(e)
|
||||
step_result.ended_at = datetime.utcnow()
|
||||
execution.failed_steps += 1
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Exception à l'étape {index + 1}: {str(e)}"
|
||||
db.session.commit()
|
||||
break
|
||||
|
||||
# Finaliser l'exécution
|
||||
if execution.status == 'running':
|
||||
execution.status = 'completed'
|
||||
|
||||
execution.ended_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
print(f"🏁 [Execute] Workflow terminé: {execution.status}")
|
||||
print(f" Complétées: {execution.completed_steps}, Échouées: {execution.failed_steps}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [Execute] Erreur fatale: {e}")
|
||||
try:
|
||||
execution = Execution.query.get(execution_id)
|
||||
if execution:
|
||||
execution.status = 'error'
|
||||
execution.error_message = f"Erreur fatale: {str(e)}"
|
||||
execution.ended_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
finally:
|
||||
_execution_state['is_running'] = False
|
||||
_execution_state['current_execution_id'] = None
|
||||
|
||||
|
||||
def execute_action(action_type: str, params: dict) -> dict:
|
||||
"""
|
||||
Exécute une action RPA.
|
||||
Utilise pyautogui pour les interactions.
|
||||
"""
|
||||
import pyautogui
|
||||
import time
|
||||
|
||||
try:
|
||||
if action_type in ['click_anchor', 'click', 'double_click_anchor', 'right_click_anchor']:
|
||||
# Récupérer les coordonnées depuis l'ancre
|
||||
anchor = params.get('visual_anchor', {})
|
||||
bbox = anchor.get('bounding_box', {})
|
||||
|
||||
if not bbox:
|
||||
return {'success': False, 'error': 'Pas de bounding_box dans visual_anchor'}
|
||||
|
||||
# Calculer le centre
|
||||
x = bbox.get('x', 0) + bbox.get('width', 0) / 2
|
||||
y = bbox.get('y', 0) + bbox.get('height', 0) / 2
|
||||
|
||||
# TODO: Utiliser la détection visuelle (OmniParser/VLM) ici
|
||||
# Pour l'instant, on utilise les coordonnées statiques
|
||||
|
||||
print(f"🖱️ [Action] Clic à ({x}, {y})")
|
||||
|
||||
if action_type == 'double_click_anchor':
|
||||
pyautogui.doubleClick(x, y)
|
||||
elif action_type == 'right_click_anchor':
|
||||
pyautogui.rightClick(x, y)
|
||||
else:
|
||||
pyautogui.click(x, y)
|
||||
|
||||
return {'success': True, 'output': {'clicked_at': {'x': x, 'y': y}}}
|
||||
|
||||
elif action_type in ['type_text', 'type']:
|
||||
text = params.get('text', '')
|
||||
if not text:
|
||||
return {'success': False, 'error': 'Pas de texte à saisir'}
|
||||
|
||||
print(f"⌨️ [Action] Saisie: {text[:30]}...")
|
||||
|
||||
# Petit délai pour s'assurer que le focus est bon
|
||||
time.sleep(0.2)
|
||||
|
||||
if text.isascii():
|
||||
pyautogui.typewrite(text, interval=0.05)
|
||||
else:
|
||||
pyautogui.write(text)
|
||||
|
||||
return {'success': True, 'output': {'typed': text}}
|
||||
|
||||
elif action_type in ['wait_for_anchor', 'wait']:
|
||||
timeout_ms = params.get('timeout_ms', params.get('timeout', 5000))
|
||||
print(f"⏳ [Action] Attente {timeout_ms}ms")
|
||||
time.sleep(timeout_ms / 1000)
|
||||
return {'success': True, 'output': {'waited_ms': timeout_ms}}
|
||||
|
||||
elif action_type == 'keyboard_shortcut':
|
||||
keys = params.get('keys', [])
|
||||
if not keys:
|
||||
return {'success': False, 'error': 'Pas de touches définies'}
|
||||
|
||||
print(f"⌨️ [Action] Raccourci: {'+'.join(keys)}")
|
||||
pyautogui.hotkey(*keys)
|
||||
return {'success': True, 'output': {'hotkey': keys}}
|
||||
|
||||
else:
|
||||
return {'success': False, 'error': f"Type d'action non supporté: {action_type}"}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/start', methods=['POST'])
|
||||
def start_execution():
|
||||
"""
|
||||
Lance l'exécution d'un workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"workflow_id": "wf_123" // Optionnel, utilise le workflow actif sinon
|
||||
}
|
||||
"""
|
||||
global _execution_state
|
||||
|
||||
try:
|
||||
if _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Une exécution est déjà en cours"
|
||||
}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
workflow_id = data.get('workflow_id')
|
||||
|
||||
# Utiliser le workflow actif si non spécifié
|
||||
if not workflow_id:
|
||||
session = get_session_state()
|
||||
workflow_id = session.active_workflow_id
|
||||
|
||||
if not workflow_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucun workflow spécifié ou actif"
|
||||
}), 400
|
||||
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
if workflow.steps.count() == 0:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Le workflow n'a aucune étape"
|
||||
}), 400
|
||||
|
||||
# Créer l'exécution
|
||||
execution = Execution(
|
||||
id=generate_id('exec'),
|
||||
workflow_id=workflow_id,
|
||||
status='pending'
|
||||
)
|
||||
db.session.add(execution)
|
||||
db.session.commit()
|
||||
|
||||
# Mettre à jour la session
|
||||
session = get_session_state()
|
||||
session.active_execution_id = execution.id
|
||||
|
||||
# Réinitialiser l'état
|
||||
_execution_state['is_running'] = True
|
||||
_execution_state['is_paused'] = False
|
||||
_execution_state['should_stop'] = False
|
||||
_execution_state['current_execution_id'] = execution.id
|
||||
|
||||
# Lancer le thread d'exécution
|
||||
from flask import current_app
|
||||
app = current_app._get_current_object()
|
||||
|
||||
thread = threading.Thread(
|
||||
target=execute_workflow_thread,
|
||||
args=(execution.id, workflow_id, app)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
_execution_state['thread'] = thread
|
||||
|
||||
print(f"🚀 [API v3] Exécution lancée: {execution.id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict(),
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
_execution_state['is_running'] = False
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/pause', methods=['POST'])
|
||||
def pause_execution():
|
||||
"""Met en pause l'exécution"""
|
||||
global _execution_state
|
||||
|
||||
if not _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune exécution en cours"
|
||||
}), 400
|
||||
|
||||
_execution_state['is_paused'] = True
|
||||
|
||||
execution = Execution.query.get(_execution_state['current_execution_id'])
|
||||
if execution:
|
||||
execution.status = 'paused'
|
||||
db.session.commit()
|
||||
|
||||
print(f"⏸️ [API v3] Exécution en pause")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict() if execution else None
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/resume', methods=['POST'])
|
||||
def resume_execution():
|
||||
"""Reprend l'exécution"""
|
||||
global _execution_state
|
||||
|
||||
if not _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune exécution en cours"
|
||||
}), 400
|
||||
|
||||
if not _execution_state['is_paused']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "L'exécution n'est pas en pause"
|
||||
}), 400
|
||||
|
||||
_execution_state['is_paused'] = False
|
||||
|
||||
execution = Execution.query.get(_execution_state['current_execution_id'])
|
||||
if execution:
|
||||
execution.status = 'running'
|
||||
db.session.commit()
|
||||
|
||||
print(f"▶️ [API v3] Exécution reprise")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict() if execution else None
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/stop', methods=['POST'])
|
||||
def stop_execution():
|
||||
"""Arrête l'exécution"""
|
||||
global _execution_state
|
||||
|
||||
if not _execution_state['is_running']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "Aucune exécution en cours"
|
||||
}), 400
|
||||
|
||||
_execution_state['should_stop'] = True
|
||||
_execution_state['is_paused'] = False
|
||||
|
||||
print(f"⛔ [API v3] Arrêt demandé")
|
||||
|
||||
# Attendre un peu que le thread réagisse
|
||||
time.sleep(0.5)
|
||||
|
||||
execution = Execution.query.get(_execution_state['current_execution_id'])
|
||||
|
||||
session = get_session_state()
|
||||
session.active_execution_id = None
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'execution': execution.to_dict() if execution else None,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/status', methods=['GET'])
|
||||
def get_execution_status():
|
||||
"""Retourne l'état de l'exécution en cours"""
|
||||
global _execution_state
|
||||
|
||||
session = get_session_state()
|
||||
|
||||
execution = None
|
||||
if session.active_execution_id:
|
||||
execution = Execution.query.get(session.active_execution_id)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'is_running': _execution_state['is_running'],
|
||||
'is_paused': _execution_state['is_paused'],
|
||||
'execution': execution.to_dict() if execution else None,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@api_v3_bp.route('/execute/history', methods=['GET'])
|
||||
def get_execution_history():
|
||||
"""Retourne l'historique des exécutions"""
|
||||
try:
|
||||
workflow_id = request.args.get('workflow_id')
|
||||
|
||||
query = Execution.query.order_by(Execution.started_at.desc())
|
||||
|
||||
if workflow_id:
|
||||
query = query.filter_by(workflow_id=workflow_id)
|
||||
|
||||
executions = query.limit(50).all()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'executions': [e.to_dict() for e in executions]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
157
visual_workflow_builder/backend/api_v3/session.py
Normal file
157
visual_workflow_builder/backend/api_v3/session.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
API v3 - Session State
|
||||
Endpoint principal qui retourne l'état complet de l'UI
|
||||
|
||||
GET /api/v3/session/state → Tout ce que le frontend doit afficher
|
||||
"""
|
||||
|
||||
from flask import jsonify
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Workflow, Step, Execution, get_session_state
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/state', methods=['GET'])
|
||||
def get_state():
|
||||
"""
|
||||
Retourne l'état complet de l'UI.
|
||||
|
||||
Le frontend thin client appelle cet endpoint et affiche ce qu'il reçoit.
|
||||
C'est LA source de vérité.
|
||||
|
||||
Response:
|
||||
{
|
||||
"session": {
|
||||
"active_workflow_id": "...",
|
||||
"selected_step_id": "...",
|
||||
"active_execution_id": "..."
|
||||
},
|
||||
"workflow": { ... } ou null,
|
||||
"execution": { ... } ou null,
|
||||
"workflows_list": [ {id, name, step_count, updated_at}, ... ]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
|
||||
# Workflow actif
|
||||
active_workflow = None
|
||||
if session.active_workflow_id:
|
||||
wf = Workflow.query.get(session.active_workflow_id)
|
||||
if wf:
|
||||
active_workflow = wf.to_dict()
|
||||
|
||||
# Exécution active
|
||||
active_execution = None
|
||||
if session.active_execution_id:
|
||||
exe = Execution.query.get(session.active_execution_id)
|
||||
if exe:
|
||||
active_execution = exe.to_dict()
|
||||
|
||||
# Liste des workflows (résumé)
|
||||
workflows_list = []
|
||||
for wf in Workflow.query.filter_by(is_active=True).order_by(Workflow.updated_at.desc()).all():
|
||||
workflows_list.append({
|
||||
'id': wf.id,
|
||||
'name': wf.name,
|
||||
'step_count': wf.steps.count(),
|
||||
'updated_at': wf.updated_at.isoformat() if wf.updated_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict(),
|
||||
'workflow': active_workflow,
|
||||
'execution': active_execution,
|
||||
'workflows_list': workflows_list
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/select-workflow/<workflow_id>', methods=['POST'])
|
||||
def select_workflow(workflow_id: str):
|
||||
"""Sélectionne un workflow comme actif"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
|
||||
# Vérifier que le workflow existe
|
||||
wf = Workflow.query.get(workflow_id)
|
||||
if not wf:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
session.active_workflow_id = workflow_id
|
||||
session.selected_step_id = None
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict(),
|
||||
'workflow': wf.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/select-step/<step_id>', methods=['POST'])
|
||||
def select_step(step_id: str):
|
||||
"""Sélectionne une étape"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
|
||||
# Vérifier que l'étape existe
|
||||
step = Step.query.get(step_id)
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
session.selected_step_id = step_id
|
||||
|
||||
# S'assurer que le workflow parent est actif
|
||||
if session.active_workflow_id != step.workflow_id:
|
||||
session.active_workflow_id = step.workflow_id
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict(),
|
||||
'step': step.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/session/clear', methods=['POST'])
|
||||
def clear_session():
|
||||
"""Réinitialise la session"""
|
||||
try:
|
||||
session = get_session_state()
|
||||
session.active_workflow_id = None
|
||||
session.selected_step_id = None
|
||||
session.active_execution_id = None
|
||||
session.last_capture = None
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
403
visual_workflow_builder/backend/api_v3/workflow.py
Normal file
403
visual_workflow_builder/backend/api_v3/workflow.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
API v3 - Workflow CRUD
|
||||
Gestion des workflows et étapes
|
||||
|
||||
POST /api/v3/workflow/create
|
||||
GET /api/v3/workflow/{id}
|
||||
POST /api/v3/workflow/{id}/step
|
||||
PUT /api/v3/workflow/{id}/step/{step_id}
|
||||
DELETE /api/v3/workflow/{id}/step/{step_id}
|
||||
"""
|
||||
|
||||
from flask import jsonify, request
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from . import api_v3_bp
|
||||
from db.models import db, Workflow, Step, VisualAnchor, get_session_state
|
||||
from contracts.action_contracts import enforce_action_contract, ContractValidationError, get_required_params
|
||||
|
||||
|
||||
def generate_id(prefix: str) -> str:
|
||||
"""Génère un ID unique"""
|
||||
return f"{prefix}_{uuid.uuid4().hex[:12]}_{int(datetime.now().timestamp())}"
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/create', methods=['POST'])
|
||||
def create_workflow():
|
||||
"""
|
||||
Crée un nouveau workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"name": "Mon workflow",
|
||||
"description": "Description optionnelle"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... },
|
||||
"session": { ... }
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
name = data.get('name', f"Workflow {datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||
description = data.get('description', '')
|
||||
|
||||
workflow = Workflow(
|
||||
id=generate_id('wf'),
|
||||
name=name,
|
||||
description=description
|
||||
)
|
||||
|
||||
db.session.add(workflow)
|
||||
db.session.commit()
|
||||
|
||||
# Activer ce workflow dans la session
|
||||
session = get_session_state()
|
||||
session.active_workflow_id = workflow.id
|
||||
session.selected_step_id = None
|
||||
|
||||
print(f"✅ [API v3] Workflow créé: {workflow.id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>', methods=['GET'])
|
||||
def get_workflow(workflow_id: str):
|
||||
"""Récupère un workflow complet"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>', methods=['DELETE'])
|
||||
def delete_workflow(workflow_id: str):
|
||||
"""Supprime un workflow"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
# Désactiver si c'est le workflow actif
|
||||
session = get_session_state()
|
||||
if session.active_workflow_id == workflow_id:
|
||||
session.active_workflow_id = None
|
||||
session.selected_step_id = None
|
||||
|
||||
db.session.delete(workflow)
|
||||
db.session.commit()
|
||||
|
||||
print(f"🗑️ [API v3] Workflow supprimé: {workflow_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_id': workflow_id,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/step', methods=['POST'])
|
||||
def add_step(workflow_id: str):
|
||||
"""
|
||||
Ajoute une étape au workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"action_type": "click_anchor",
|
||||
"position": {"x": 100, "y": 200},
|
||||
"parameters": {},
|
||||
"label": "Clic sur bouton",
|
||||
"insert_after": "step_123" // Optionnel: insérer après cette étape
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"workflow": { ... }, // Workflow complet mis à jour
|
||||
"step": { ... }, // Nouvelle étape
|
||||
"needs_anchor": true // Si l'action requiert une ancre visuelle
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
action_type = data.get('action_type')
|
||||
if not action_type:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': "action_type requis"
|
||||
}), 400
|
||||
|
||||
position = data.get('position', {'x': 100, 'y': 100})
|
||||
parameters = data.get('parameters', {})
|
||||
label = data.get('label', action_type)
|
||||
insert_after = data.get('insert_after') # ID de l'étape après laquelle insérer
|
||||
|
||||
# Calculer l'ordre
|
||||
if insert_after:
|
||||
# Insérer après une étape spécifique
|
||||
prev_step = Step.query.filter_by(id=insert_after, workflow_id=workflow_id).first()
|
||||
if not prev_step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{insert_after}' non trouvée"
|
||||
}), 404
|
||||
|
||||
new_order = prev_step.order + 1
|
||||
|
||||
# Décaler toutes les étapes suivantes
|
||||
Step.query.filter(
|
||||
Step.workflow_id == workflow_id,
|
||||
Step.order >= new_order
|
||||
).update({Step.order: Step.order + 1})
|
||||
else:
|
||||
# Ajouter à la fin
|
||||
max_order = db.session.query(db.func.max(Step.order)).filter(
|
||||
Step.workflow_id == workflow_id
|
||||
).scalar() or -1
|
||||
new_order = max_order + 1
|
||||
|
||||
step = Step(
|
||||
id=generate_id('step'),
|
||||
workflow_id=workflow_id,
|
||||
action_type=action_type,
|
||||
order=new_order,
|
||||
position_x=position.get('x', 100),
|
||||
position_y=position.get('y', 100),
|
||||
label=label
|
||||
)
|
||||
step.parameters = parameters
|
||||
|
||||
db.session.add(step)
|
||||
db.session.commit()
|
||||
|
||||
# Sélectionner cette étape
|
||||
session = get_session_state()
|
||||
session.selected_step_id = step.id
|
||||
|
||||
# Vérifier si l'action requiert une ancre visuelle
|
||||
required_params = get_required_params(action_type)
|
||||
needs_anchor = 'visual_anchor' in required_params
|
||||
|
||||
print(f"✅ [API v3] Étape ajoutée: {step.id} ({action_type}) au workflow {workflow_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'step': step.to_dict(),
|
||||
'needs_anchor': needs_anchor,
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/step/<step_id>', methods=['PUT'])
|
||||
def update_step(workflow_id: str, step_id: str):
|
||||
"""
|
||||
Met à jour une étape.
|
||||
|
||||
Request:
|
||||
{
|
||||
"action_type": "...", // Optionnel
|
||||
"position": {"x": ..., "y": ...}, // Optionnel
|
||||
"parameters": {...}, // Optionnel
|
||||
"label": "...", // Optionnel
|
||||
"anchor_id": "..." // Optionnel
|
||||
}
|
||||
"""
|
||||
try:
|
||||
step = Step.query.filter_by(id=step_id, workflow_id=workflow_id).first()
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée dans le workflow '{workflow_id}'"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Mettre à jour les champs fournis
|
||||
if 'action_type' in data:
|
||||
step.action_type = data['action_type']
|
||||
|
||||
if 'position' in data:
|
||||
step.position_x = data['position'].get('x', step.position_x)
|
||||
step.position_y = data['position'].get('y', step.position_y)
|
||||
|
||||
if 'parameters' in data:
|
||||
step.parameters = data['parameters']
|
||||
|
||||
if 'label' in data:
|
||||
step.label = data['label']
|
||||
|
||||
if 'anchor_id' in data:
|
||||
# Vérifier que l'ancre existe
|
||||
if data['anchor_id']:
|
||||
anchor = VisualAnchor.query.get(data['anchor_id'])
|
||||
if not anchor:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Ancre '{data['anchor_id']}' non trouvée"
|
||||
}), 404
|
||||
step.anchor_id = data['anchor_id']
|
||||
|
||||
step.updated_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
|
||||
print(f"✅ [API v3] Étape mise à jour: {step_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'step': step.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/step/<step_id>', methods=['DELETE'])
|
||||
def delete_step(workflow_id: str, step_id: str):
|
||||
"""Supprime une étape"""
|
||||
try:
|
||||
step = Step.query.filter_by(id=step_id, workflow_id=workflow_id).first()
|
||||
if not step:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Étape '{step_id}' non trouvée"
|
||||
}), 404
|
||||
|
||||
deleted_order = step.order
|
||||
|
||||
db.session.delete(step)
|
||||
|
||||
# Réordonner les étapes suivantes
|
||||
Step.query.filter(
|
||||
Step.workflow_id == workflow_id,
|
||||
Step.order > deleted_order
|
||||
).update({Step.order: Step.order - 1})
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Désélectionner si c'était l'étape sélectionnée
|
||||
session = get_session_state()
|
||||
if session.selected_step_id == step_id:
|
||||
session.selected_step_id = None
|
||||
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
|
||||
print(f"🗑️ [API v3] Étape supprimée: {step_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict(),
|
||||
'session': session.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@api_v3_bp.route('/workflow/<workflow_id>/reorder', methods=['POST'])
|
||||
def reorder_steps(workflow_id: str):
|
||||
"""
|
||||
Réordonne les étapes d'un workflow.
|
||||
|
||||
Request:
|
||||
{
|
||||
"step_ids": ["step_1", "step_2", "step_3"] // Nouvel ordre
|
||||
}
|
||||
"""
|
||||
try:
|
||||
workflow = Workflow.query.get(workflow_id)
|
||||
if not workflow:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f"Workflow '{workflow_id}' non trouvé"
|
||||
}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
step_ids = data.get('step_ids', [])
|
||||
|
||||
# Mettre à jour l'ordre de chaque étape
|
||||
for index, step_id in enumerate(step_ids):
|
||||
step = Step.query.filter_by(id=step_id, workflow_id=workflow_id).first()
|
||||
if step:
|
||||
step.order = index
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print(f"🔄 [API v3] Étapes réordonnées pour workflow {workflow_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'workflow': workflow.to_dict()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
@@ -9,7 +9,6 @@ for real-time execution updates.
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_caching import Cache
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
@@ -22,14 +21,15 @@ app = Flask(__name__)
|
||||
|
||||
# Configuration
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///workflows.db')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///vwb_v3.db')
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB max upload
|
||||
app.config['CACHE_TYPE'] = 'redis' if os.getenv('REDIS_URL') else 'simple'
|
||||
app.config['CACHE_REDIS_URL'] = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy(app)
|
||||
# Initialize extensions - Use db from v3 models (source of truth)
|
||||
from db.models import db
|
||||
db.init_app(app)
|
||||
cache = Cache(app)
|
||||
socketio = SocketIO(
|
||||
app,
|
||||
@@ -126,6 +126,40 @@ try:
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Blueprint coaching_sessions désactivé: {e}")
|
||||
|
||||
# Catalogue VWB - actions VisionOnly
|
||||
# V2 avec VLM (Vision Language Model) pour détection intelligente
|
||||
try:
|
||||
from catalog_routes_v2_vlm import catalog_bp
|
||||
app.register_blueprint(catalog_bp)
|
||||
print("✅ Blueprint catalog V2 VLM (Ollama qwen2.5vl) enregistré")
|
||||
except ImportError as 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
|
||||
try:
|
||||
from api.anchor_images import anchor_images_bp
|
||||
app.register_blueprint(anchor_images_bp)
|
||||
print("✅ Blueprint anchor_images enregistré")
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Blueprint anchor_images désactivé: {e}")
|
||||
|
||||
# ============================================================
|
||||
# API V3 - Thin Client Architecture (Source de Vérité Unique)
|
||||
# ============================================================
|
||||
try:
|
||||
from api_v3 import api_v3_bp
|
||||
app.register_blueprint(api_v3_bp)
|
||||
print("✅ Blueprint API v3 (Thin Client) enregistré - /api/v3/*")
|
||||
except ImportError as e:
|
||||
print(f"⚠️ Blueprint API v3 désactivé: {e}")
|
||||
|
||||
|
||||
# Import WebSocket handlers (optional)
|
||||
try:
|
||||
@@ -158,9 +192,92 @@ def handle_exception(error):
|
||||
|
||||
# Health check endpoint
|
||||
@app.route('/health')
|
||||
@app.route('/api/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring"""
|
||||
return {'status': 'healthy', 'version': '1.0.0'}
|
||||
from flask import jsonify
|
||||
return jsonify({'status': 'healthy', 'version': '1.0.0'})
|
||||
|
||||
# Workflow execution endpoint (proxy to catalog execute)
|
||||
@app.route('/api/workflow/execute-step', methods=['POST'])
|
||||
def execute_workflow_step():
|
||||
"""Execute a workflow step via the catalog execute endpoint"""
|
||||
from flask import jsonify, request
|
||||
import requests
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
step_id = data.get('stepId', f'step_{int(__import__("time").time() * 1000)}')
|
||||
step_type = data.get('stepType', 'click_anchor')
|
||||
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
|
||||
catalog_request = {
|
||||
'type': step_type,
|
||||
'step_id': step_id,
|
||||
'parameters': parameters
|
||||
}
|
||||
|
||||
# Call the internal catalog execute endpoint
|
||||
from catalog_routes import catalog_bp
|
||||
|
||||
# Direct execution via catalog
|
||||
try:
|
||||
# Import the execute function directly
|
||||
from catalog_routes import execute_action as catalog_execute
|
||||
# We need to simulate Flask request context - use internal call
|
||||
from flask import current_app
|
||||
with current_app.test_request_context(
|
||||
'/api/vwb/catalog/execute',
|
||||
method='POST',
|
||||
data=__import__('json').dumps(catalog_request),
|
||||
content_type='application/json'
|
||||
):
|
||||
response = catalog_execute()
|
||||
if hasattr(response, 'get_json'):
|
||||
result = response.get_json()
|
||||
else:
|
||||
result = __import__('json').loads(response[0].get_data(as_text=True))
|
||||
|
||||
# Convert to expected format
|
||||
if result.get('success') and result.get('result'):
|
||||
return jsonify({
|
||||
'success': result['result'].get('status') == 'success',
|
||||
'output': result['result'].get('output_data', {}),
|
||||
'error': result['result'].get('error', {}).get('message') if result['result'].get('error') else None
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Échec de l\'exécution')
|
||||
})
|
||||
except Exception as inner_e:
|
||||
print(f"❌ Erreur exécution interne: {inner_e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(inner_e)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Erreur execute-step: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
# Create database tables
|
||||
with app.app_context():
|
||||
@@ -195,7 +312,7 @@ except Exception as e:
|
||||
print(f"❌ Erreur lors de l'initialisation des services visuels: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.getenv('PORT', 5002))
|
||||
port = int(os.getenv('PORT', 5000))
|
||||
debug = os.getenv('FLASK_ENV') == 'development'
|
||||
|
||||
socketio.run(
|
||||
|
||||
54
visual_workflow_builder/backend/contracts/__init__.py
Normal file
54
visual_workflow_builder/backend/contracts/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Contrats de Données VWB - Module d'initialisation
|
||||
|
||||
Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
|
||||
Ce module contient les contrats de données spécifiques au Visual Workflow Builder
|
||||
pour les actions VisionOnly RPA.
|
||||
|
||||
Contrats disponibles :
|
||||
- VWBActionError : Gestion des erreurs d'actions
|
||||
- VWBEvidence : Preuves d'exécution avec screenshots
|
||||
- VWBVisualAnchor : Ancres visuelles pour sélection d'éléments UI
|
||||
"""
|
||||
|
||||
from .error import VWBActionError, VWBErrorType, VWBErrorSeverity
|
||||
from .evidence import VWBEvidence, VWBEvidenceType
|
||||
from .visual_anchor import VWBVisualAnchor, VWBVisualAnchorType
|
||||
from .action_contracts import (
|
||||
ActionContract,
|
||||
ContractViolation,
|
||||
ContractViolationType,
|
||||
ContractValidationError,
|
||||
VWB_ACTION_CONTRACTS,
|
||||
validate_action_contract,
|
||||
enforce_action_contract,
|
||||
get_action_contract,
|
||||
get_required_params,
|
||||
list_all_action_types
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'VWBActionError',
|
||||
'VWBErrorType',
|
||||
'VWBErrorSeverity',
|
||||
'VWBEvidence',
|
||||
'VWBEvidenceType',
|
||||
'VWBVisualAnchor',
|
||||
'VWBVisualAnchorType',
|
||||
# Contrats d'actions
|
||||
'ActionContract',
|
||||
'ContractViolation',
|
||||
'ContractViolationType',
|
||||
'ContractValidationError',
|
||||
'VWB_ACTION_CONTRACTS',
|
||||
'validate_action_contract',
|
||||
'enforce_action_contract',
|
||||
'get_action_contract',
|
||||
'get_required_params',
|
||||
'list_all_action_types'
|
||||
]
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__author__ = 'Dom, Alice, Kiro'
|
||||
__date__ = '09 janvier 2026'
|
||||
403
visual_workflow_builder/backend/contracts/action_contracts.py
Normal file
403
visual_workflow_builder/backend/contracts/action_contracts.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Contrats Stricts des Actions VWB - Définition et Validation
|
||||
|
||||
Auteur : Dom, Alice, Kiro - 23 janvier 2026
|
||||
|
||||
Ce module définit les contrats stricts pour chaque action VWB.
|
||||
Chaque action a des paramètres OBLIGATOIRES qui doivent être présents
|
||||
pour que l'exécution soit autorisée.
|
||||
|
||||
PRINCIPE CLÉ: Si le contrat n'est pas respecté → BLOQUER l'exécution
|
||||
avec un message d'erreur clair.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional, Set, Callable
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ContractViolationType(Enum):
|
||||
"""Types de violation de contrat."""
|
||||
MISSING_REQUIRED = "missing_required" # Paramètre obligatoire manquant
|
||||
INVALID_TYPE = "invalid_type" # Mauvais type de valeur
|
||||
INVALID_VALUE = "invalid_value" # Valeur invalide
|
||||
FORBIDDEN_PARAM = "forbidden_param" # Paramètre interdit présent
|
||||
INCOMPATIBLE_ACTION = "incompatible_action" # Type d'action incompatible avec params
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContractViolation:
|
||||
"""Représente une violation de contrat."""
|
||||
violation_type: ContractViolationType
|
||||
parameter: str
|
||||
message: str
|
||||
expected: Optional[str] = None
|
||||
received: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": self.violation_type.value,
|
||||
"parameter": self.parameter,
|
||||
"message": self.message,
|
||||
"expected": self.expected,
|
||||
"received": self.received
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionContract:
|
||||
"""Définition du contrat d'une action VWB."""
|
||||
action_type: str
|
||||
description: str
|
||||
required_params: List[str]
|
||||
optional_params: List[str] = field(default_factory=list)
|
||||
param_validators: Dict[str, Callable[[Any], bool]] = field(default_factory=dict)
|
||||
# Actions qui ne peuvent PAS avoir certains paramètres
|
||||
forbidden_if_missing: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def validate(self, parameters: Dict[str, Any]) -> List[ContractViolation]:
|
||||
"""
|
||||
Valide les paramètres contre le contrat.
|
||||
|
||||
Returns:
|
||||
Liste de violations (vide si tout est OK)
|
||||
"""
|
||||
violations = []
|
||||
|
||||
# 1. Vérifier les paramètres obligatoires
|
||||
for param in self.required_params:
|
||||
if param not in parameters or parameters[param] is None:
|
||||
violations.append(ContractViolation(
|
||||
violation_type=ContractViolationType.MISSING_REQUIRED,
|
||||
parameter=param,
|
||||
message=f"Paramètre obligatoire '{param}' manquant pour l'action '{self.action_type}'",
|
||||
expected=f"'{param}' doit être fourni",
|
||||
received="absent ou None"
|
||||
))
|
||||
elif param in self.param_validators:
|
||||
# Valider le contenu du paramètre
|
||||
if not self.param_validators[param](parameters[param]):
|
||||
violations.append(ContractViolation(
|
||||
violation_type=ContractViolationType.INVALID_VALUE,
|
||||
parameter=param,
|
||||
message=f"Valeur invalide pour '{param}' dans l'action '{self.action_type}'",
|
||||
expected="valeur valide selon les règles du contrat",
|
||||
received=str(type(parameters[param]).__name__)
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def has_visual_anchor(params: Dict[str, Any]) -> bool:
|
||||
"""Vérifie si visual_anchor est présent et valide."""
|
||||
anchor = params.get('visual_anchor') or params.get('target')
|
||||
if not anchor:
|
||||
return False
|
||||
if not isinstance(anchor, dict):
|
||||
return False
|
||||
# Doit avoir soit une image, soit des coordonnées
|
||||
has_image = bool(
|
||||
anchor.get('screenshot') or
|
||||
anchor.get('image') or
|
||||
anchor.get('reference_image_base64') or
|
||||
anchor.get('id') # ID d'ancre stockée sur le serveur
|
||||
)
|
||||
has_coords = bool(
|
||||
anchor.get('bounding_box') or
|
||||
anchor.get('boundingBox')
|
||||
)
|
||||
return has_image or has_coords
|
||||
|
||||
|
||||
def has_text(params: Dict[str, Any]) -> bool:
|
||||
"""Vérifie si text est présent et non vide."""
|
||||
text = params.get('text') or params.get('text_to_type') or params.get('texte')
|
||||
return bool(text and isinstance(text, str) and len(text.strip()) > 0)
|
||||
|
||||
|
||||
def has_timeout(params: Dict[str, Any]) -> bool:
|
||||
"""Vérifie si timeout est présent et valide."""
|
||||
timeout = params.get('timeout') or params.get('timeout_ms') or params.get('max_wait_time_ms')
|
||||
if timeout is None:
|
||||
return True # Optionnel, une valeur par défaut sera utilisée
|
||||
try:
|
||||
return int(timeout) > 0
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DÉFINITION DES CONTRATS POUR CHAQUE ACTION VWB
|
||||
# =============================================================================
|
||||
|
||||
VWB_ACTION_CONTRACTS: Dict[str, ActionContract] = {
|
||||
# --- ACTIONS DE CLIC ---
|
||||
"click_anchor": ActionContract(
|
||||
action_type="click_anchor",
|
||||
description="Clic sur un élément identifié par ancre visuelle",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["click_type", "click_offset_x", "click_offset_y", "confidence_threshold"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"double_click_anchor": ActionContract(
|
||||
action_type="double_click_anchor",
|
||||
description="Double-clic sur un élément identifié par ancre visuelle",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["click_offset_x", "click_offset_y", "confidence_threshold"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"right_click_anchor": ActionContract(
|
||||
action_type="right_click_anchor",
|
||||
description="Clic droit sur un élément identifié par ancre visuelle",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["click_offset_x", "click_offset_y", "confidence_threshold"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"hover_anchor": ActionContract(
|
||||
action_type="hover_anchor",
|
||||
description="Survol d'un élément identifié par ancre visuelle",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["hover_duration_ms", "confidence_threshold"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
# --- ACTIONS DE SAISIE ---
|
||||
"type_text": ActionContract(
|
||||
action_type="type_text",
|
||||
description="Saisie de texte (PAS de clic automatique, le focus doit être déjà fait)",
|
||||
required_params=["text"],
|
||||
optional_params=["typing_speed_ms", "clear_field_first", "press_enter_after"],
|
||||
param_validators={"text": lambda p: bool(p and isinstance(p, str))}
|
||||
),
|
||||
|
||||
"type_secret": ActionContract(
|
||||
action_type="type_secret",
|
||||
description="Saisie sécurisée de texte sensible (mot de passe)",
|
||||
required_params=["secret_text"],
|
||||
optional_params=["typing_speed_ms", "clear_field_first", "mask_in_evidence"],
|
||||
param_validators={"secret_text": lambda p: bool(p and isinstance(p, str))}
|
||||
),
|
||||
|
||||
# --- ACTIONS DE FOCUS ---
|
||||
"focus_anchor": ActionContract(
|
||||
action_type="focus_anchor",
|
||||
description="Donne le focus à un élément (clic pour activer)",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["focus_method", "verify_focus", "confidence_threshold"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
# --- ACTIONS D'ATTENTE ---
|
||||
"wait_for_anchor": ActionContract(
|
||||
action_type="wait_for_anchor",
|
||||
description="Attendre qu'un élément apparaisse ou disparaisse",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["wait_mode", "max_wait_time_ms", "check_interval_ms"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
# --- ACTIONS DE SCROLL ---
|
||||
"scroll_to_anchor": ActionContract(
|
||||
action_type="scroll_to_anchor",
|
||||
description="Défiler jusqu'à ce qu'un élément soit visible",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["scroll_direction", "scroll_speed", "max_scroll_attempts", "target_position"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"drag_drop_anchor": ActionContract(
|
||||
action_type="drag_drop_anchor",
|
||||
description="Glisser-déposer d'un élément vers un autre",
|
||||
required_params=["source_anchor", "target_anchor"],
|
||||
optional_params=["drag_speed", "hold_duration_ms"],
|
||||
param_validators={
|
||||
"source_anchor": lambda p: has_visual_anchor({"visual_anchor": p}),
|
||||
"target_anchor": lambda p: has_visual_anchor({"visual_anchor": p})
|
||||
}
|
||||
),
|
||||
|
||||
# --- ACTIONS CLAVIER ---
|
||||
"keyboard_shortcut": ActionContract(
|
||||
action_type="keyboard_shortcut",
|
||||
description="Exécuter un raccourci clavier",
|
||||
required_params=["keys"],
|
||||
optional_params=["hold_duration_ms"],
|
||||
param_validators={"keys": lambda p: isinstance(p, list) and len(p) > 0}
|
||||
),
|
||||
|
||||
# --- ACTIONS D'EXTRACTION ---
|
||||
"extract_text": ActionContract(
|
||||
action_type="extract_text",
|
||||
description="Extraire du texte d'une zone identifiée par ancre",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["extraction_mode", "text_filters", "output_format", "output_variable"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"extract_table": ActionContract(
|
||||
action_type="extract_table",
|
||||
description="Extraire un tableau d'une zone identifiée par ancre",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["table_format", "output_variable"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"screenshot_evidence": ActionContract(
|
||||
action_type="screenshot_evidence",
|
||||
description="Capturer une preuve visuelle (screenshot)",
|
||||
required_params=[], # Aucun paramètre obligatoire
|
||||
optional_params=["region", "label", "include_timestamp"]
|
||||
),
|
||||
|
||||
# --- ACTIONS CONDITIONNELLES ---
|
||||
"visual_condition": ActionContract(
|
||||
action_type="visual_condition",
|
||||
description="Condition basée sur présence/absence d'élément visuel",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["condition_type", "timeout_ms"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"loop_visual": ActionContract(
|
||||
action_type="loop_visual",
|
||||
description="Boucle tant qu'un élément est visible",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["max_iterations", "timeout_ms"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
# --- ACTIONS DONNÉES ---
|
||||
"download_to_folder": ActionContract(
|
||||
action_type="download_to_folder",
|
||||
description="Télécharger un fichier vers un dossier",
|
||||
required_params=["target_folder"],
|
||||
optional_params=["filename_pattern", "timeout_ms"]
|
||||
),
|
||||
|
||||
"ai_analyze_text": ActionContract(
|
||||
action_type="ai_analyze_text",
|
||||
description="Analyser du texte avec IA",
|
||||
required_params=["visual_anchor", "analysis_prompt"],
|
||||
optional_params=["model", "output_variable"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"db_save_data": ActionContract(
|
||||
action_type="db_save_data",
|
||||
description="Sauvegarder des données en base",
|
||||
required_params=["data", "table_name"],
|
||||
optional_params=["connection_id"]
|
||||
),
|
||||
|
||||
"db_read_data": ActionContract(
|
||||
action_type="db_read_data",
|
||||
description="Lire des données depuis la base",
|
||||
required_params=["query"],
|
||||
optional_params=["connection_id", "output_variable"]
|
||||
),
|
||||
|
||||
# --- ACTIONS DE VÉRIFICATION ---
|
||||
"verify_element_exists": ActionContract(
|
||||
action_type="verify_element_exists",
|
||||
description="Vérifier qu'un élément existe à l'écran",
|
||||
required_params=["visual_anchor"],
|
||||
optional_params=["timeout_ms", "should_exist"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
|
||||
"verify_text_content": ActionContract(
|
||||
action_type="verify_text_content",
|
||||
description="Vérifier le contenu textuel d'un élément",
|
||||
required_params=["visual_anchor", "expected_text"],
|
||||
optional_params=["match_mode", "case_sensitive"],
|
||||
param_validators={"visual_anchor": lambda p: has_visual_anchor({"visual_anchor": p})}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ContractValidationError(Exception):
|
||||
"""Exception levée quand un contrat n'est pas respecté."""
|
||||
|
||||
def __init__(self, violations: List[ContractViolation], action_type: str):
|
||||
self.violations = violations
|
||||
self.action_type = action_type
|
||||
messages = [v.message for v in violations]
|
||||
super().__init__(f"Contrat violé pour '{action_type}': {'; '.join(messages)}")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"error": "contract_violation",
|
||||
"action_type": self.action_type,
|
||||
"violations": [v.to_dict() for v in self.violations],
|
||||
"message": str(self)
|
||||
}
|
||||
|
||||
|
||||
def validate_action_contract(action_type: str, parameters: Dict[str, Any]) -> List[ContractViolation]:
|
||||
"""
|
||||
Valide les paramètres d'une action contre son contrat.
|
||||
|
||||
Args:
|
||||
action_type: Type de l'action (ex: "click_anchor", "type_text")
|
||||
parameters: Paramètres fournis pour l'action
|
||||
|
||||
Returns:
|
||||
Liste de violations (vide si tout est OK)
|
||||
|
||||
Raises:
|
||||
ContractValidationError si le contrat n'est pas respecté
|
||||
"""
|
||||
# Normaliser le type d'action
|
||||
action_type_normalized = action_type.lower().strip()
|
||||
|
||||
# Vérifier si l'action existe
|
||||
if action_type_normalized not in VWB_ACTION_CONTRACTS:
|
||||
# Action non reconnue - on laisse passer avec un warning
|
||||
print(f"⚠️ [Contract] Action '{action_type}' non reconnue dans les contrats")
|
||||
return []
|
||||
|
||||
contract = VWB_ACTION_CONTRACTS[action_type_normalized]
|
||||
violations = contract.validate(parameters)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def enforce_action_contract(action_type: str, parameters: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Valide et BLOQUE si le contrat n'est pas respecté.
|
||||
|
||||
Args:
|
||||
action_type: Type de l'action
|
||||
parameters: Paramètres fournis
|
||||
|
||||
Raises:
|
||||
ContractValidationError si le contrat n'est pas respecté
|
||||
"""
|
||||
violations = validate_action_contract(action_type, parameters)
|
||||
|
||||
if violations:
|
||||
print(f"🚫 [Contract] VIOLATION DÉTECTÉE pour '{action_type}':")
|
||||
for v in violations:
|
||||
print(f" - {v.parameter}: {v.message}")
|
||||
raise ContractValidationError(violations, action_type)
|
||||
|
||||
print(f"✅ [Contract] Contrat respecté pour '{action_type}'")
|
||||
|
||||
|
||||
def get_action_contract(action_type: str) -> Optional[ActionContract]:
|
||||
"""Retourne le contrat d'une action."""
|
||||
return VWB_ACTION_CONTRACTS.get(action_type.lower().strip())
|
||||
|
||||
|
||||
def get_required_params(action_type: str) -> List[str]:
|
||||
"""Retourne la liste des paramètres obligatoires pour une action."""
|
||||
contract = get_action_contract(action_type)
|
||||
return contract.required_params if contract else []
|
||||
|
||||
|
||||
def list_all_action_types() -> List[str]:
|
||||
"""Retourne la liste de tous les types d'actions avec contrat."""
|
||||
return list(VWB_ACTION_CONTRACTS.keys())
|
||||
291
visual_workflow_builder/backend/contracts/error.py
Normal file
291
visual_workflow_builder/backend/contracts/error.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Contrat de Données VWB - Gestion des Erreurs d'Actions
|
||||
|
||||
Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
|
||||
Ce module définit les contrats pour la gestion des erreurs dans les actions
|
||||
VisionOnly du Visual Workflow Builder.
|
||||
|
||||
Classes :
|
||||
- VWBErrorType : Types d'erreurs possibles
|
||||
- VWBErrorSeverity : Niveaux de gravité
|
||||
- VWBActionError : Contrat principal pour les erreurs d'actions
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
|
||||
class VWBErrorType(Enum):
|
||||
"""Types d'erreurs possibles dans les actions VWB."""
|
||||
|
||||
# Erreurs de capture d'écran
|
||||
SCREEN_CAPTURE_FAILED = "screen_capture_failed"
|
||||
SCREEN_CAPTURE_TIMEOUT = "screen_capture_timeout"
|
||||
|
||||
# Erreurs de détection d'éléments
|
||||
ELEMENT_NOT_FOUND = "element_not_found"
|
||||
ELEMENT_NOT_VISIBLE = "element_not_visible"
|
||||
ELEMENT_NOT_CLICKABLE = "element_not_clickable"
|
||||
MULTIPLE_ELEMENTS_FOUND = "multiple_elements_found"
|
||||
|
||||
# Erreurs d'interaction
|
||||
CLICK_FAILED = "click_failed"
|
||||
TYPE_TEXT_FAILED = "type_text_failed"
|
||||
WAIT_TIMEOUT = "wait_timeout"
|
||||
|
||||
# Erreurs de validation
|
||||
VALIDATION_FAILED = "validation_failed"
|
||||
PARAMETER_INVALID = "parameter_invalid"
|
||||
ANCHOR_INVALID = "anchor_invalid"
|
||||
|
||||
# Erreurs système
|
||||
SYSTEM_ERROR = "system_error"
|
||||
NETWORK_ERROR = "network_error"
|
||||
PERMISSION_DENIED = "permission_denied"
|
||||
|
||||
# Erreurs de configuration
|
||||
CONFIG_ERROR = "config_error"
|
||||
DEPENDENCY_MISSING = "dependency_missing"
|
||||
|
||||
|
||||
class VWBErrorSeverity(Enum):
|
||||
"""Niveaux de gravité des erreurs VWB."""
|
||||
|
||||
INFO = "info" # Information, pas d'impact
|
||||
WARNING = "warning" # Avertissement, impact mineur
|
||||
ERROR = "error" # Erreur, impact modéré
|
||||
CRITICAL = "critical" # Erreur critique, arrêt nécessaire
|
||||
FATAL = "fatal" # Erreur fatale, système compromis
|
||||
|
||||
|
||||
@dataclass
|
||||
class VWBActionError:
|
||||
"""
|
||||
Contrat de données pour les erreurs d'actions VWB.
|
||||
|
||||
Cette classe encapsule toutes les informations nécessaires pour
|
||||
diagnostiquer et résoudre les erreurs survenant lors de l'exécution
|
||||
des actions VisionOnly dans le Visual Workflow Builder.
|
||||
"""
|
||||
|
||||
# Identification de l'erreur
|
||||
error_id: str
|
||||
error_type: VWBErrorType
|
||||
severity: VWBErrorSeverity
|
||||
|
||||
# Message et description
|
||||
message: str
|
||||
description: str
|
||||
|
||||
# Contexte d'exécution
|
||||
action_id: str
|
||||
step_id: str
|
||||
|
||||
# Informations temporelles
|
||||
timestamp: datetime
|
||||
|
||||
# Détails techniques
|
||||
technical_details: Dict[str, Any]
|
||||
|
||||
# Suggestions de résolution
|
||||
suggestions: List[str]
|
||||
|
||||
# Paramètres optionnels avec valeurs par défaut
|
||||
workflow_id: Optional[str] = None
|
||||
execution_time_ms: Optional[float] = None
|
||||
stack_trace: Optional[str] = None
|
||||
retry_possible: bool = True
|
||||
user_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
environment: str = "development"
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validation et initialisation post-création."""
|
||||
if not self.error_id:
|
||||
self.error_id = f"err_{self.action_id}_{int(self.timestamp.timestamp())}"
|
||||
|
||||
if not self.technical_details:
|
||||
self.technical_details = {}
|
||||
|
||||
if not self.suggestions:
|
||||
self.suggestions = self._generate_default_suggestions()
|
||||
|
||||
def _generate_default_suggestions(self) -> List[str]:
|
||||
"""Génère des suggestions par défaut selon le type d'erreur."""
|
||||
suggestions_map = {
|
||||
VWBErrorType.SCREEN_CAPTURE_FAILED: [
|
||||
"Vérifiez que l'écran est accessible",
|
||||
"Redémarrez le service de capture d'écran",
|
||||
"Vérifiez les permissions d'accès à l'écran"
|
||||
],
|
||||
VWBErrorType.ELEMENT_NOT_FOUND: [
|
||||
"Vérifiez que l'élément est visible à l'écran",
|
||||
"Ajustez les paramètres de détection",
|
||||
"Attendez que la page soit complètement chargée"
|
||||
],
|
||||
VWBErrorType.CLICK_FAILED: [
|
||||
"Vérifiez que l'élément est cliquable",
|
||||
"Attendez que l'interface soit stable",
|
||||
"Réessayez avec un délai plus long"
|
||||
],
|
||||
VWBErrorType.VALIDATION_FAILED: [
|
||||
"Vérifiez les paramètres de l'action",
|
||||
"Consultez la documentation de l'action",
|
||||
"Contactez l'administrateur si le problème persiste"
|
||||
]
|
||||
}
|
||||
|
||||
return suggestions_map.get(self.error_type, [
|
||||
"Consultez les logs pour plus de détails",
|
||||
"Réessayez l'opération",
|
||||
"Contactez le support technique si nécessaire"
|
||||
])
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convertit l'erreur en dictionnaire pour sérialisation JSON."""
|
||||
data = asdict(self)
|
||||
|
||||
# Convertir les enums en strings
|
||||
data['error_type'] = self.error_type.value
|
||||
data['severity'] = self.severity.value
|
||||
|
||||
# Convertir le timestamp en ISO string
|
||||
data['timestamp'] = self.timestamp.isoformat()
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'VWBActionError':
|
||||
"""Crée une instance depuis un dictionnaire."""
|
||||
# Convertir les strings en enums
|
||||
data['error_type'] = VWBErrorType(data['error_type'])
|
||||
data['severity'] = VWBErrorSeverity(data['severity'])
|
||||
|
||||
# Convertir le timestamp
|
||||
if isinstance(data['timestamp'], str):
|
||||
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
|
||||
|
||||
return cls(**data)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Sérialise l'erreur en JSON."""
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'VWBActionError':
|
||||
"""Désérialise depuis JSON."""
|
||||
data = json.loads(json_str)
|
||||
return cls.from_dict(data)
|
||||
|
||||
def is_retryable(self) -> bool:
|
||||
"""Détermine si l'erreur permet un retry."""
|
||||
non_retryable_types = {
|
||||
VWBErrorType.PARAMETER_INVALID,
|
||||
VWBErrorType.ANCHOR_INVALID,
|
||||
VWBErrorType.PERMISSION_DENIED,
|
||||
VWBErrorType.CONFIG_ERROR,
|
||||
VWBErrorType.DEPENDENCY_MISSING
|
||||
}
|
||||
|
||||
return (
|
||||
self.retry_possible and
|
||||
self.error_type not in non_retryable_types and
|
||||
self.severity not in {VWBErrorSeverity.FATAL}
|
||||
)
|
||||
|
||||
def get_user_friendly_message(self) -> str:
|
||||
"""Retourne un message convivial pour l'utilisateur."""
|
||||
friendly_messages = {
|
||||
VWBErrorType.SCREEN_CAPTURE_FAILED: "Impossible de capturer l'écran",
|
||||
VWBErrorType.ELEMENT_NOT_FOUND: "Élément non trouvé sur l'écran",
|
||||
VWBErrorType.CLICK_FAILED: "Impossible de cliquer sur l'élément",
|
||||
VWBErrorType.TYPE_TEXT_FAILED: "Impossible de saisir le texte",
|
||||
VWBErrorType.WAIT_TIMEOUT: "Délai d'attente dépassé",
|
||||
VWBErrorType.VALIDATION_FAILED: "Validation échouée",
|
||||
VWBErrorType.SYSTEM_ERROR: "Erreur système",
|
||||
VWBErrorType.NETWORK_ERROR: "Erreur réseau",
|
||||
VWBErrorType.PERMISSION_DENIED: "Permissions insuffisantes"
|
||||
}
|
||||
|
||||
base_message = friendly_messages.get(self.error_type, self.message)
|
||||
|
||||
if self.severity == VWBErrorSeverity.CRITICAL:
|
||||
return f"🚨 {base_message}"
|
||||
elif self.severity == VWBErrorSeverity.ERROR:
|
||||
return f"❌ {base_message}"
|
||||
elif self.severity == VWBErrorSeverity.WARNING:
|
||||
return f"⚠️ {base_message}"
|
||||
else:
|
||||
return f"ℹ️ {base_message}"
|
||||
|
||||
def add_technical_detail(self, key: str, value: Any) -> None:
|
||||
"""Ajoute un détail technique."""
|
||||
self.technical_details[key] = value
|
||||
|
||||
def add_suggestion(self, suggestion: str) -> None:
|
||||
"""Ajoute une suggestion de résolution."""
|
||||
if suggestion not in self.suggestions:
|
||||
self.suggestions.append(suggestion)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Représentation string de l'erreur."""
|
||||
return f"VWBActionError({self.error_type.value}, {self.severity.value}): {self.message}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation détaillée de l'erreur."""
|
||||
return (
|
||||
f"VWBActionError("
|
||||
f"error_id='{self.error_id}', "
|
||||
f"error_type={self.error_type.value}, "
|
||||
f"severity={self.severity.value}, "
|
||||
f"action_id='{self.action_id}', "
|
||||
f"message='{self.message}'"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
def create_vwb_error(
|
||||
error_type: VWBErrorType,
|
||||
message: str,
|
||||
action_id: str,
|
||||
step_id: str,
|
||||
severity: VWBErrorSeverity = VWBErrorSeverity.ERROR,
|
||||
**kwargs
|
||||
) -> VWBActionError:
|
||||
"""
|
||||
Fonction utilitaire pour créer rapidement une erreur VWB.
|
||||
|
||||
Args:
|
||||
error_type: Type d'erreur
|
||||
message: Message d'erreur
|
||||
action_id: ID de l'action
|
||||
step_id: ID de l'étape
|
||||
severity: Gravité (ERROR par défaut)
|
||||
**kwargs: Paramètres additionnels
|
||||
|
||||
Returns:
|
||||
Instance de VWBActionError
|
||||
"""
|
||||
return VWBActionError(
|
||||
error_id=kwargs.get('error_id', ''),
|
||||
error_type=error_type,
|
||||
severity=severity,
|
||||
message=message,
|
||||
description=kwargs.get('description', message),
|
||||
action_id=action_id,
|
||||
step_id=step_id,
|
||||
workflow_id=kwargs.get('workflow_id'),
|
||||
timestamp=kwargs.get('timestamp', datetime.now()),
|
||||
execution_time_ms=kwargs.get('execution_time_ms'),
|
||||
technical_details=kwargs.get('technical_details', {}),
|
||||
stack_trace=kwargs.get('stack_trace'),
|
||||
suggestions=kwargs.get('suggestions', []),
|
||||
retry_possible=kwargs.get('retry_possible', True),
|
||||
user_id=kwargs.get('user_id'),
|
||||
session_id=kwargs.get('session_id'),
|
||||
environment=kwargs.get('environment', 'development')
|
||||
)
|
||||
375
visual_workflow_builder/backend/contracts/evidence.py
Normal file
375
visual_workflow_builder/backend/contracts/evidence.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Contrat de Données VWB - Evidence d'Exécution
|
||||
|
||||
Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
|
||||
Ce module définit les contrats pour les preuves d'exécution (Evidence) des actions
|
||||
VisionOnly dans le Visual Workflow Builder.
|
||||
|
||||
Classes :
|
||||
- VWBEvidenceType : Types d'evidence possibles
|
||||
- VWBEvidence : Contrat principal pour les preuves d'exécution
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class VWBEvidenceType(Enum):
|
||||
"""Types d'evidence possibles dans le VWB."""
|
||||
|
||||
# Evidence visuelles
|
||||
SCREENSHOT_BEFORE = "screenshot_before" # Capture avant action
|
||||
SCREENSHOT_AFTER = "screenshot_after" # Capture après action
|
||||
SCREENSHOT_ERROR = "screenshot_error" # Capture lors d'erreur
|
||||
ELEMENT_HIGHLIGHT = "element_highlight" # Élément surligné
|
||||
|
||||
# Evidence d'interaction
|
||||
CLICK_EVIDENCE = "click_evidence" # Preuve de clic
|
||||
TYPE_EVIDENCE = "type_evidence" # Preuve de saisie
|
||||
WAIT_EVIDENCE = "wait_evidence" # Preuve d'attente
|
||||
|
||||
# Evidence de validation
|
||||
VALIDATION_SUCCESS = "validation_success" # Validation réussie
|
||||
VALIDATION_FAILURE = "validation_failure" # Validation échouée
|
||||
|
||||
# Evidence système
|
||||
SYSTEM_STATE = "system_state" # État système
|
||||
PERFORMANCE_METRICS = "performance_metrics" # Métriques de performance
|
||||
|
||||
# Evidence de débogage
|
||||
DEBUG_INFO = "debug_info" # Informations de débogage
|
||||
LOG_ENTRY = "log_entry" # Entrée de log
|
||||
|
||||
|
||||
@dataclass
|
||||
class VWBEvidence:
|
||||
"""
|
||||
Contrat de données pour les preuves d'exécution VWB.
|
||||
|
||||
Cette classe encapsule toutes les informations nécessaires pour
|
||||
documenter et tracer l'exécution des actions VisionOnly dans le
|
||||
Visual Workflow Builder.
|
||||
"""
|
||||
|
||||
# Identification de l'evidence
|
||||
evidence_id: str
|
||||
evidence_type: VWBEvidenceType
|
||||
|
||||
# Contexte d'exécution
|
||||
action_id: str
|
||||
step_id: str
|
||||
|
||||
# Informations temporelles
|
||||
timestamp: datetime
|
||||
|
||||
# Contenu de l'evidence
|
||||
title: str
|
||||
description: str
|
||||
|
||||
# Données structurées
|
||||
data: Dict[str, Any]
|
||||
|
||||
# Liens vers autres evidence
|
||||
related_evidence_ids: List[str]
|
||||
|
||||
# Paramètres optionnels avec valeurs par défaut
|
||||
workflow_id: Optional[str] = None
|
||||
execution_time_ms: Optional[float] = None
|
||||
screenshot_base64: Optional[str] = None
|
||||
screenshot_width: Optional[int] = None
|
||||
screenshot_height: Optional[int] = None
|
||||
highlight_box: Optional[Dict[str, int]] = None # {x, y, width, height}
|
||||
success: bool = True
|
||||
confidence_score: Optional[float] = None
|
||||
user_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
parent_evidence_id: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validation et initialisation post-création."""
|
||||
if not self.evidence_id:
|
||||
self.evidence_id = f"ev_{self.action_id}_{int(self.timestamp.timestamp())}"
|
||||
|
||||
if not self.data:
|
||||
self.data = {}
|
||||
|
||||
if not self.related_evidence_ids:
|
||||
self.related_evidence_ids = []
|
||||
|
||||
# Validation des dimensions screenshot
|
||||
if self.screenshot_base64:
|
||||
if not self.screenshot_width or not self.screenshot_height:
|
||||
self._extract_screenshot_dimensions()
|
||||
|
||||
def _extract_screenshot_dimensions(self):
|
||||
"""Extrait les dimensions du screenshot depuis les données base64."""
|
||||
try:
|
||||
if self.screenshot_base64:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Décoder le base64
|
||||
image_data = base64.b64decode(self.screenshot_base64)
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
self.screenshot_width = image.width
|
||||
self.screenshot_height = image.height
|
||||
except Exception:
|
||||
# En cas d'erreur, utiliser des valeurs par défaut
|
||||
self.screenshot_width = 1920
|
||||
self.screenshot_height = 1080
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convertit l'evidence en dictionnaire pour sérialisation JSON."""
|
||||
data = asdict(self)
|
||||
|
||||
# Convertir l'enum en string
|
||||
data['evidence_type'] = self.evidence_type.value
|
||||
|
||||
# Convertir le timestamp en ISO string
|
||||
data['timestamp'] = self.timestamp.isoformat()
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'VWBEvidence':
|
||||
"""Crée une instance depuis un dictionnaire."""
|
||||
# Convertir le string en enum
|
||||
data['evidence_type'] = VWBEvidenceType(data['evidence_type'])
|
||||
|
||||
# Convertir le timestamp
|
||||
if isinstance(data['timestamp'], str):
|
||||
data['timestamp'] = datetime.fromisoformat(data['timestamp'])
|
||||
|
||||
return cls(**data)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Sérialise l'evidence en JSON."""
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'VWBEvidence':
|
||||
"""Désérialise depuis JSON."""
|
||||
data = json.loads(json_str)
|
||||
return cls.from_dict(data)
|
||||
|
||||
def has_screenshot(self) -> bool:
|
||||
"""Vérifie si l'evidence contient un screenshot."""
|
||||
return self.screenshot_base64 is not None and len(self.screenshot_base64) > 0
|
||||
|
||||
def has_highlight(self) -> bool:
|
||||
"""Vérifie si l'evidence contient une zone surlignée."""
|
||||
return (
|
||||
self.highlight_box is not None and
|
||||
all(key in self.highlight_box for key in ['x', 'y', 'width', 'height'])
|
||||
)
|
||||
|
||||
def get_screenshot_data_url(self) -> Optional[str]:
|
||||
"""Retourne l'URL data du screenshot pour affichage web."""
|
||||
if not self.has_screenshot():
|
||||
return None
|
||||
|
||||
# Déterminer le format (PNG par défaut)
|
||||
format_prefix = "data:image/png;base64,"
|
||||
if self.screenshot_base64.startswith('/9j/'): # JPEG magic bytes en base64
|
||||
format_prefix = "data:image/jpeg;base64,"
|
||||
|
||||
return f"{format_prefix}{self.screenshot_base64}"
|
||||
|
||||
def save_screenshot(self, file_path: str) -> bool:
|
||||
"""Sauvegarde le screenshot sur disque."""
|
||||
if not self.has_screenshot():
|
||||
return False
|
||||
|
||||
try:
|
||||
image_data = base64.b64decode(self.screenshot_base64)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def add_data(self, key: str, value: Any) -> None:
|
||||
"""Ajoute une donnée à l'evidence."""
|
||||
self.data[key] = value
|
||||
|
||||
def get_data(self, key: str, default: Any = None) -> Any:
|
||||
"""Récupère une donnée de l'evidence."""
|
||||
return self.data.get(key, default)
|
||||
|
||||
def set_highlight_box(self, x: int, y: int, width: int, height: int) -> None:
|
||||
"""Définit la zone de surbrillance."""
|
||||
self.highlight_box = {
|
||||
'x': max(0, x),
|
||||
'y': max(0, y),
|
||||
'width': max(1, width),
|
||||
'height': max(1, height)
|
||||
}
|
||||
|
||||
def add_related_evidence(self, evidence_id: str) -> None:
|
||||
"""Ajoute une evidence liée."""
|
||||
if evidence_id not in self.related_evidence_ids:
|
||||
self.related_evidence_ids.append(evidence_id)
|
||||
|
||||
def get_file_size_mb(self) -> float:
|
||||
"""Calcule la taille approximative en MB."""
|
||||
size_bytes = 0
|
||||
|
||||
# Taille du screenshot
|
||||
if self.screenshot_base64:
|
||||
size_bytes += len(self.screenshot_base64.encode('utf-8'))
|
||||
|
||||
# Taille des données JSON
|
||||
size_bytes += len(json.dumps(self.data).encode('utf-8'))
|
||||
|
||||
return size_bytes / (1024 * 1024)
|
||||
|
||||
def is_visual_evidence(self) -> bool:
|
||||
"""Vérifie si c'est une evidence visuelle."""
|
||||
visual_types = {
|
||||
VWBEvidenceType.SCREENSHOT_BEFORE,
|
||||
VWBEvidenceType.SCREENSHOT_AFTER,
|
||||
VWBEvidenceType.SCREENSHOT_ERROR,
|
||||
VWBEvidenceType.ELEMENT_HIGHLIGHT
|
||||
}
|
||||
return self.evidence_type in visual_types
|
||||
|
||||
def is_interaction_evidence(self) -> bool:
|
||||
"""Vérifie si c'est une evidence d'interaction."""
|
||||
interaction_types = {
|
||||
VWBEvidenceType.CLICK_EVIDENCE,
|
||||
VWBEvidenceType.TYPE_EVIDENCE,
|
||||
VWBEvidenceType.WAIT_EVIDENCE
|
||||
}
|
||||
return self.evidence_type in interaction_types
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Retourne un résumé de l'evidence pour affichage."""
|
||||
return {
|
||||
'evidence_id': self.evidence_id,
|
||||
'type': self.evidence_type.value,
|
||||
'title': self.title,
|
||||
'timestamp': self.timestamp.isoformat(),
|
||||
'success': self.success,
|
||||
'has_screenshot': self.has_screenshot(),
|
||||
'has_highlight': self.has_highlight(),
|
||||
'file_size_mb': round(self.get_file_size_mb(), 2),
|
||||
'confidence_score': self.confidence_score
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Représentation string de l'evidence."""
|
||||
status = "✅" if self.success else "❌"
|
||||
return f"{status} VWBEvidence({self.evidence_type.value}): {self.title}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation détaillée de l'evidence."""
|
||||
return (
|
||||
f"VWBEvidence("
|
||||
f"evidence_id='{self.evidence_id}', "
|
||||
f"evidence_type={self.evidence_type.value}, "
|
||||
f"action_id='{self.action_id}', "
|
||||
f"success={self.success}, "
|
||||
f"has_screenshot={self.has_screenshot()}"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
def create_screenshot_evidence(
|
||||
action_id: str,
|
||||
step_id: str,
|
||||
screenshot_base64: str,
|
||||
evidence_type: VWBEvidenceType = VWBEvidenceType.SCREENSHOT_BEFORE,
|
||||
title: str = "Capture d'écran",
|
||||
**kwargs
|
||||
) -> VWBEvidence:
|
||||
"""
|
||||
Fonction utilitaire pour créer rapidement une evidence de screenshot.
|
||||
|
||||
Args:
|
||||
action_id: ID de l'action
|
||||
step_id: ID de l'étape
|
||||
screenshot_base64: Screenshot en base64
|
||||
evidence_type: Type d'evidence
|
||||
title: Titre de l'evidence
|
||||
**kwargs: Paramètres additionnels
|
||||
|
||||
Returns:
|
||||
Instance de VWBEvidence
|
||||
"""
|
||||
return VWBEvidence(
|
||||
evidence_id=kwargs.get('evidence_id', ''),
|
||||
evidence_type=evidence_type,
|
||||
action_id=action_id,
|
||||
step_id=step_id,
|
||||
workflow_id=kwargs.get('workflow_id'),
|
||||
timestamp=kwargs.get('timestamp', datetime.now()),
|
||||
execution_time_ms=kwargs.get('execution_time_ms'),
|
||||
title=title,
|
||||
description=kwargs.get('description', title),
|
||||
screenshot_base64=screenshot_base64,
|
||||
screenshot_width=kwargs.get('screenshot_width'),
|
||||
screenshot_height=kwargs.get('screenshot_height'),
|
||||
highlight_box=kwargs.get('highlight_box'),
|
||||
data=kwargs.get('data', {}),
|
||||
success=kwargs.get('success', True),
|
||||
confidence_score=kwargs.get('confidence_score'),
|
||||
user_id=kwargs.get('user_id'),
|
||||
session_id=kwargs.get('session_id'),
|
||||
related_evidence_ids=kwargs.get('related_evidence_ids', []),
|
||||
parent_evidence_id=kwargs.get('parent_evidence_id')
|
||||
)
|
||||
|
||||
|
||||
def create_interaction_evidence(
|
||||
action_id: str,
|
||||
step_id: str,
|
||||
evidence_type: VWBEvidenceType,
|
||||
title: str,
|
||||
interaction_data: Dict[str, Any],
|
||||
**kwargs
|
||||
) -> VWBEvidence:
|
||||
"""
|
||||
Fonction utilitaire pour créer une evidence d'interaction.
|
||||
|
||||
Args:
|
||||
action_id: ID de l'action
|
||||
step_id: ID de l'étape
|
||||
evidence_type: Type d'evidence d'interaction
|
||||
title: Titre de l'evidence
|
||||
interaction_data: Données de l'interaction
|
||||
**kwargs: Paramètres additionnels
|
||||
|
||||
Returns:
|
||||
Instance de VWBEvidence
|
||||
"""
|
||||
return VWBEvidence(
|
||||
evidence_id=kwargs.get('evidence_id', ''),
|
||||
evidence_type=evidence_type,
|
||||
action_id=action_id,
|
||||
step_id=step_id,
|
||||
workflow_id=kwargs.get('workflow_id'),
|
||||
timestamp=kwargs.get('timestamp', datetime.now()),
|
||||
execution_time_ms=kwargs.get('execution_time_ms'),
|
||||
title=title,
|
||||
description=kwargs.get('description', title),
|
||||
screenshot_base64=kwargs.get('screenshot_base64'),
|
||||
screenshot_width=kwargs.get('screenshot_width'),
|
||||
screenshot_height=kwargs.get('screenshot_height'),
|
||||
highlight_box=kwargs.get('highlight_box'),
|
||||
data=interaction_data,
|
||||
success=kwargs.get('success', True),
|
||||
confidence_score=kwargs.get('confidence_score'),
|
||||
user_id=kwargs.get('user_id'),
|
||||
session_id=kwargs.get('session_id'),
|
||||
related_evidence_ids=kwargs.get('related_evidence_ids', []),
|
||||
parent_evidence_id=kwargs.get('parent_evidence_id')
|
||||
)
|
||||
474
visual_workflow_builder/backend/contracts/visual_anchor.py
Normal file
474
visual_workflow_builder/backend/contracts/visual_anchor.py
Normal file
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Contrat de Données VWB - Ancres Visuelles
|
||||
|
||||
Auteur : Dom, Alice, Kiro - 09 janvier 2026
|
||||
|
||||
Ce module définit les contrats pour les ancres visuelles utilisées dans les actions
|
||||
VisionOnly du Visual Workflow Builder pour la sélection et l'identification
|
||||
d'éléments UI.
|
||||
|
||||
Classes :
|
||||
- VWBVisualAnchorType : Types d'ancres visuelles
|
||||
- VWBVisualAnchor : Contrat principal pour les ancres visuelles
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
|
||||
class VWBVisualAnchorType(Enum):
|
||||
"""Types d'ancres visuelles possibles dans le VWB."""
|
||||
|
||||
# Type générique (par défaut pour les captures VWB)
|
||||
GENERIC = "generic" # Type par défaut, utilise template matching
|
||||
|
||||
# Ancres basées sur l'image
|
||||
IMAGE_TEMPLATE = "image_template" # Template d'image exact
|
||||
IMAGE_FUZZY = "image_fuzzy" # Template d'image avec tolérance
|
||||
|
||||
# Ancres basées sur le texte
|
||||
TEXT_EXACT = "text_exact" # Texte exact
|
||||
TEXT_PARTIAL = "text_partial" # Texte partiel
|
||||
TEXT_REGEX = "text_regex" # Expression régulière
|
||||
|
||||
# Ancres basées sur la position
|
||||
COORDINATES = "coordinates" # Coordonnées absolues
|
||||
RELATIVE_POSITION = "relative_position" # Position relative à un autre élément
|
||||
|
||||
# Ancres basées sur les propriétés UI
|
||||
UI_ELEMENT = "ui_element" # Élément UI spécifique
|
||||
BUTTON = "button" # Bouton
|
||||
INPUT_FIELD = "input_field" # Champ de saisie
|
||||
DROPDOWN = "dropdown" # Liste déroulante
|
||||
CHECKBOX = "checkbox" # Case à cocher
|
||||
RADIO_BUTTON = "radio_button" # Bouton radio
|
||||
|
||||
# Ancres composites
|
||||
MULTI_CRITERIA = "multi_criteria" # Plusieurs critères combinés
|
||||
CONTEXTUAL = "contextual" # Basée sur le contexte
|
||||
|
||||
|
||||
@dataclass
|
||||
class VWBVisualAnchor:
|
||||
"""
|
||||
Contrat de données pour les ancres visuelles VWB.
|
||||
|
||||
Cette classe encapsule toutes les informations nécessaires pour
|
||||
identifier et localiser des éléments UI dans les actions VisionOnly
|
||||
du Visual Workflow Builder.
|
||||
"""
|
||||
|
||||
# Identification de l'ancre
|
||||
anchor_id: str
|
||||
anchor_type: VWBVisualAnchorType
|
||||
|
||||
# Métadonnées de base
|
||||
name: str
|
||||
description: str
|
||||
|
||||
# Critères de recherche
|
||||
search_criteria: Dict[str, Any]
|
||||
|
||||
# Contexte d'utilisation
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
||||
# Paramètres optionnels avec valeurs par défaut
|
||||
reference_image_base64: Optional[str] = None
|
||||
reference_width: Optional[int] = None
|
||||
reference_height: Optional[int] = None
|
||||
bounding_box: Optional[Dict[str, int]] = None # {x, y, width, height}
|
||||
confidence_threshold: float = 0.8
|
||||
max_search_time_ms: int = 5000
|
||||
retry_count: int = 3
|
||||
visual_embedding: Optional[List[float]] = None
|
||||
embedding_model: Optional[str] = None
|
||||
last_used_at: Optional[datetime] = None
|
||||
usage_count: int = 0
|
||||
success_rate: float = 0.0
|
||||
average_match_time_ms: float = 0.0
|
||||
screen_resolution: Optional[Tuple[int, int]] = None
|
||||
application_context: Optional[str] = None
|
||||
is_active: bool = True
|
||||
validation_hash: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validation et initialisation post-création."""
|
||||
if not self.anchor_id:
|
||||
self.anchor_id = f"anchor_{self.name.lower().replace(' ', '_')}_{int(self.created_at.timestamp())}"
|
||||
|
||||
if not self.search_criteria:
|
||||
self.search_criteria = {}
|
||||
|
||||
# Générer le hash de validation
|
||||
self._update_validation_hash()
|
||||
|
||||
# Valider les dimensions de l'image de référence
|
||||
if self.reference_image_base64:
|
||||
if not self.reference_width or not self.reference_height:
|
||||
self._extract_reference_dimensions()
|
||||
|
||||
def _extract_reference_dimensions(self):
|
||||
"""Extrait les dimensions de l'image de référence."""
|
||||
try:
|
||||
if self.reference_image_base64:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
# Décoder le base64
|
||||
image_data = base64.b64decode(self.reference_image_base64)
|
||||
image = Image.open(io.BytesIO(image_data))
|
||||
|
||||
self.reference_width = image.width
|
||||
self.reference_height = image.height
|
||||
except Exception:
|
||||
# En cas d'erreur, utiliser des valeurs par défaut
|
||||
self.reference_width = 100
|
||||
self.reference_height = 50
|
||||
|
||||
def _update_validation_hash(self):
|
||||
"""Met à jour le hash de validation basé sur les critères principaux."""
|
||||
hash_data = {
|
||||
'anchor_type': self.anchor_type.value,
|
||||
'search_criteria': self.search_criteria,
|
||||
'bounding_box': self.bounding_box,
|
||||
'confidence_threshold': self.confidence_threshold
|
||||
}
|
||||
|
||||
hash_string = json.dumps(hash_data, sort_keys=True)
|
||||
self.validation_hash = hashlib.md5(hash_string.encode()).hexdigest()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convertit l'ancre en dictionnaire pour sérialisation JSON."""
|
||||
data = asdict(self)
|
||||
|
||||
# Convertir l'enum en string
|
||||
data['anchor_type'] = self.anchor_type.value
|
||||
|
||||
# Convertir les timestamps en ISO strings
|
||||
data['created_at'] = self.created_at.isoformat()
|
||||
if self.last_used_at:
|
||||
data['last_used_at'] = self.last_used_at.isoformat()
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'VWBVisualAnchor':
|
||||
"""Crée une instance depuis un dictionnaire.
|
||||
|
||||
Gère les données partielles du frontend en fournissant des valeurs par défaut
|
||||
pour les champs obligatoires manquants.
|
||||
"""
|
||||
# Copier pour ne pas modifier l'original
|
||||
data = data.copy()
|
||||
|
||||
# Convertir le string en enum (avec fallback sur GENERIC)
|
||||
anchor_type_val = data.get('anchor_type', 'generic')
|
||||
if isinstance(anchor_type_val, str):
|
||||
try:
|
||||
data['anchor_type'] = VWBVisualAnchorType(anchor_type_val)
|
||||
except ValueError:
|
||||
data['anchor_type'] = VWBVisualAnchorType.GENERIC
|
||||
elif not isinstance(anchor_type_val, VWBVisualAnchorType):
|
||||
data['anchor_type'] = VWBVisualAnchorType.GENERIC
|
||||
|
||||
# Fournir des valeurs par défaut pour les champs obligatoires manquants
|
||||
if 'anchor_id' not in data:
|
||||
data['anchor_id'] = f"anchor_{datetime.now().timestamp()}"
|
||||
if 'name' not in data:
|
||||
data['name'] = data.get('description', 'Ancre visuelle')[:50]
|
||||
if 'description' not in data:
|
||||
data['description'] = 'Ancre visuelle VWB'
|
||||
if 'search_criteria' not in data:
|
||||
data['search_criteria'] = {}
|
||||
if 'created_by' not in data:
|
||||
data['created_by'] = 'vwb_frontend'
|
||||
if 'created_at' not in data:
|
||||
data['created_at'] = datetime.now()
|
||||
|
||||
# Convertir les timestamps string en datetime
|
||||
if isinstance(data.get('created_at'), str):
|
||||
try:
|
||||
data['created_at'] = datetime.fromisoformat(data['created_at'])
|
||||
except ValueError:
|
||||
data['created_at'] = datetime.now()
|
||||
|
||||
if data.get('last_used_at') and isinstance(data['last_used_at'], str):
|
||||
try:
|
||||
data['last_used_at'] = datetime.fromisoformat(data['last_used_at'])
|
||||
except ValueError:
|
||||
data['last_used_at'] = None
|
||||
|
||||
# Filtrer les clés non reconnues par le dataclass
|
||||
valid_fields = {
|
||||
'anchor_id', 'anchor_type', 'name', 'description', 'search_criteria',
|
||||
'created_by', 'created_at', 'reference_image_base64', 'reference_width',
|
||||
'reference_height', 'bounding_box', 'confidence_threshold', 'max_search_time_ms',
|
||||
'retry_count', 'visual_embedding', 'embedding_model', 'last_used_at',
|
||||
'usage_count', 'success_rate', 'average_match_time_ms', 'screen_resolution',
|
||||
'application_context', 'is_active', 'validation_hash'
|
||||
}
|
||||
filtered_data = {k: v for k, v in data.items() if k in valid_fields}
|
||||
|
||||
return cls(**filtered_data)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Sérialise l'ancre en JSON."""
|
||||
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'VWBVisualAnchor':
|
||||
"""Désérialise depuis JSON."""
|
||||
data = json.loads(json_str)
|
||||
return cls.from_dict(data)
|
||||
|
||||
def has_reference_image(self) -> bool:
|
||||
"""Vérifie si l'ancre a une image de référence."""
|
||||
return self.reference_image_base64 is not None and len(self.reference_image_base64) > 0
|
||||
|
||||
def has_bounding_box(self) -> bool:
|
||||
"""Vérifie si l'ancre a une bounding box définie."""
|
||||
return (
|
||||
self.bounding_box is not None and
|
||||
all(key in self.bounding_box for key in ['x', 'y', 'width', 'height'])
|
||||
)
|
||||
|
||||
def has_visual_embedding(self) -> bool:
|
||||
"""Vérifie si l'ancre a un embedding visuel."""
|
||||
return self.visual_embedding is not None and len(self.visual_embedding) > 0
|
||||
|
||||
def get_reference_data_url(self) -> Optional[str]:
|
||||
"""Retourne l'URL data de l'image de référence pour affichage web."""
|
||||
if not self.has_reference_image():
|
||||
return None
|
||||
|
||||
# Déterminer le format (PNG par défaut)
|
||||
format_prefix = "data:image/png;base64,"
|
||||
if self.reference_image_base64.startswith('/9j/'): # JPEG magic bytes en base64
|
||||
format_prefix = "data:image/jpeg;base64,"
|
||||
|
||||
return f"{format_prefix}{self.reference_image_base64}"
|
||||
|
||||
def update_usage_stats(self, match_time_ms: float, success: bool):
|
||||
"""Met à jour les statistiques d'utilisation."""
|
||||
self.usage_count += 1
|
||||
self.last_used_at = datetime.now()
|
||||
|
||||
# Mettre à jour le temps moyen de matching
|
||||
if self.average_match_time_ms == 0:
|
||||
self.average_match_time_ms = match_time_ms
|
||||
else:
|
||||
# Moyenne mobile
|
||||
self.average_match_time_ms = (
|
||||
(self.average_match_time_ms * (self.usage_count - 1) + match_time_ms) /
|
||||
self.usage_count
|
||||
)
|
||||
|
||||
# Mettre à jour le taux de succès
|
||||
if success:
|
||||
success_count = int(self.success_rate * (self.usage_count - 1)) + 1
|
||||
else:
|
||||
success_count = int(self.success_rate * (self.usage_count - 1))
|
||||
|
||||
self.success_rate = success_count / self.usage_count
|
||||
|
||||
def is_reliable(self) -> bool:
|
||||
"""Détermine si l'ancre est fiable basé sur les statistiques."""
|
||||
return (
|
||||
self.usage_count >= 3 and
|
||||
self.success_rate >= 0.8 and
|
||||
self.average_match_time_ms <= self.max_search_time_ms
|
||||
)
|
||||
|
||||
def needs_optimization(self) -> bool:
|
||||
"""Détermine si l'ancre a besoin d'optimisation."""
|
||||
return (
|
||||
self.usage_count >= 5 and
|
||||
(self.success_rate < 0.7 or self.average_match_time_ms > self.max_search_time_ms * 0.8)
|
||||
)
|
||||
|
||||
def add_search_criterion(self, key: str, value: Any):
|
||||
"""Ajoute un critère de recherche."""
|
||||
self.search_criteria[key] = value
|
||||
self._update_validation_hash()
|
||||
|
||||
def remove_search_criterion(self, key: str):
|
||||
"""Supprime un critère de recherche."""
|
||||
if key in self.search_criteria:
|
||||
del self.search_criteria[key]
|
||||
self._update_validation_hash()
|
||||
|
||||
def set_bounding_box(self, x: int, y: int, width: int, height: int):
|
||||
"""Définit la bounding box."""
|
||||
self.bounding_box = {
|
||||
'x': max(0, x),
|
||||
'y': max(0, y),
|
||||
'width': max(1, width),
|
||||
'height': max(1, height)
|
||||
}
|
||||
self._update_validation_hash()
|
||||
|
||||
def set_visual_embedding(self, embedding: List[float], model: str):
|
||||
"""Définit l'embedding visuel."""
|
||||
self.visual_embedding = embedding
|
||||
self.embedding_model = model
|
||||
|
||||
def is_compatible_with_resolution(self, width: int, height: int) -> bool:
|
||||
"""Vérifie si l'ancre est compatible avec une résolution donnée."""
|
||||
if not self.screen_resolution:
|
||||
return True # Pas de contrainte de résolution
|
||||
|
||||
# Tolérance de 10% sur les dimensions
|
||||
tolerance = 0.1
|
||||
ref_width, ref_height = self.screen_resolution
|
||||
|
||||
width_ok = abs(width - ref_width) / ref_width <= tolerance
|
||||
height_ok = abs(height - ref_height) / ref_height <= tolerance
|
||||
|
||||
return width_ok and height_ok
|
||||
|
||||
def get_search_area(self, screen_width: int, screen_height: int) -> Optional[Dict[str, int]]:
|
||||
"""Calcule la zone de recherche sur l'écran actuel."""
|
||||
if not self.has_bounding_box():
|
||||
return None
|
||||
|
||||
# Si pas de résolution de référence, utiliser les coordonnées telles quelles
|
||||
if not self.screen_resolution:
|
||||
return self.bounding_box.copy()
|
||||
|
||||
# Adapter les coordonnées à la résolution actuelle
|
||||
ref_width, ref_height = self.screen_resolution
|
||||
scale_x = screen_width / ref_width
|
||||
scale_y = screen_height / ref_height
|
||||
|
||||
return {
|
||||
'x': int(self.bounding_box['x'] * scale_x),
|
||||
'y': int(self.bounding_box['y'] * scale_y),
|
||||
'width': int(self.bounding_box['width'] * scale_x),
|
||||
'height': int(self.bounding_box['height'] * scale_y)
|
||||
}
|
||||
|
||||
def validate_integrity(self) -> bool:
|
||||
"""Valide l'intégrité de l'ancre."""
|
||||
current_hash = self.validation_hash
|
||||
self._update_validation_hash()
|
||||
return current_hash == self.validation_hash
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Retourne un résumé de l'ancre pour affichage."""
|
||||
return {
|
||||
'anchor_id': self.anchor_id,
|
||||
'name': self.name,
|
||||
'type': self.anchor_type.value,
|
||||
'confidence_threshold': self.confidence_threshold,
|
||||
'usage_count': self.usage_count,
|
||||
'success_rate': round(self.success_rate, 2),
|
||||
'average_match_time_ms': round(self.average_match_time_ms, 1),
|
||||
'is_reliable': self.is_reliable(),
|
||||
'needs_optimization': self.needs_optimization(),
|
||||
'has_reference_image': self.has_reference_image(),
|
||||
'has_embedding': self.has_visual_embedding(),
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Représentation string de l'ancre."""
|
||||
status = "🟢" if self.is_reliable() else "🟡" if self.is_active else "🔴"
|
||||
return f"{status} VWBVisualAnchor({self.anchor_type.value}): {self.name}"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Représentation détaillée de l'ancre."""
|
||||
return (
|
||||
f"VWBVisualAnchor("
|
||||
f"anchor_id='{self.anchor_id}', "
|
||||
f"anchor_type={self.anchor_type.value}, "
|
||||
f"name='{self.name}', "
|
||||
f"success_rate={self.success_rate:.2f}, "
|
||||
f"usage_count={self.usage_count}"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
def create_image_anchor(
|
||||
name: str,
|
||||
reference_image_base64: str,
|
||||
created_by: str,
|
||||
bounding_box: Optional[Dict[str, int]] = None,
|
||||
confidence_threshold: float = 0.8,
|
||||
**kwargs
|
||||
) -> VWBVisualAnchor:
|
||||
"""
|
||||
Fonction utilitaire pour créer une ancre basée sur une image.
|
||||
|
||||
Args:
|
||||
name: Nom de l'ancre
|
||||
reference_image_base64: Image de référence en base64
|
||||
created_by: Créateur de l'ancre
|
||||
bounding_box: Zone de capture
|
||||
confidence_threshold: Seuil de confiance
|
||||
**kwargs: Paramètres additionnels
|
||||
|
||||
Returns:
|
||||
Instance de VWBVisualAnchor
|
||||
"""
|
||||
return VWBVisualAnchor(
|
||||
anchor_id=kwargs.get('anchor_id', ''),
|
||||
anchor_type=VWBVisualAnchorType.IMAGE_TEMPLATE,
|
||||
name=name,
|
||||
description=kwargs.get('description', f"Ancre image: {name}"),
|
||||
reference_image_base64=reference_image_base64,
|
||||
bounding_box=bounding_box,
|
||||
search_criteria=kwargs.get('search_criteria', {}),
|
||||
confidence_threshold=confidence_threshold,
|
||||
max_search_time_ms=kwargs.get('max_search_time_ms', 5000),
|
||||
retry_count=kwargs.get('retry_count', 3),
|
||||
visual_embedding=kwargs.get('visual_embedding'),
|
||||
embedding_model=kwargs.get('embedding_model'),
|
||||
created_by=created_by,
|
||||
created_at=kwargs.get('created_at', datetime.now()),
|
||||
screen_resolution=kwargs.get('screen_resolution'),
|
||||
application_context=kwargs.get('application_context')
|
||||
)
|
||||
|
||||
|
||||
def create_text_anchor(
|
||||
name: str,
|
||||
text_pattern: str,
|
||||
created_by: str,
|
||||
anchor_type: VWBVisualAnchorType = VWBVisualAnchorType.TEXT_EXACT,
|
||||
**kwargs
|
||||
) -> VWBVisualAnchor:
|
||||
"""
|
||||
Fonction utilitaire pour créer une ancre basée sur du texte.
|
||||
|
||||
Args:
|
||||
name: Nom de l'ancre
|
||||
text_pattern: Motif de texte à rechercher
|
||||
created_by: Créateur de l'ancre
|
||||
anchor_type: Type d'ancre texte
|
||||
**kwargs: Paramètres additionnels
|
||||
|
||||
Returns:
|
||||
Instance de VWBVisualAnchor
|
||||
"""
|
||||
search_criteria = {'text_pattern': text_pattern}
|
||||
search_criteria.update(kwargs.get('search_criteria', {}))
|
||||
|
||||
return VWBVisualAnchor(
|
||||
anchor_id=kwargs.get('anchor_id', ''),
|
||||
anchor_type=anchor_type,
|
||||
name=name,
|
||||
description=kwargs.get('description', f"Ancre texte: {name}"),
|
||||
search_criteria=search_criteria,
|
||||
confidence_threshold=kwargs.get('confidence_threshold', 0.9),
|
||||
max_search_time_ms=kwargs.get('max_search_time_ms', 3000),
|
||||
retry_count=kwargs.get('retry_count', 2),
|
||||
created_by=created_by,
|
||||
created_at=kwargs.get('created_at', datetime.now()),
|
||||
application_context=kwargs.get('application_context')
|
||||
)
|
||||
18
visual_workflow_builder/backend/db/__init__.py
Normal file
18
visual_workflow_builder/backend/db/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Database Module - RPA Vision v3
|
||||
Source de vérité unique pour les workflows VWB
|
||||
"""
|
||||
|
||||
from .models import db, Workflow, Step, VisualAnchor, Execution, ExecutionStep
|
||||
from .models import init_db, get_db_session
|
||||
|
||||
__all__ = [
|
||||
'db',
|
||||
'Workflow',
|
||||
'Step',
|
||||
'VisualAnchor',
|
||||
'Execution',
|
||||
'ExecutionStep',
|
||||
'init_db',
|
||||
'get_db_session'
|
||||
]
|
||||
306
visual_workflow_builder/backend/db/models.py
Normal file
306
visual_workflow_builder/backend/db/models.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Modèles SQLAlchemy - RPA Vision v3
|
||||
Source de vérité unique pour les workflows VWB
|
||||
|
||||
Auteur: Dom, Alice, Kiro - 23 janvier 2026
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
import json
|
||||
|
||||
# Instance SQLAlchemy partagée
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
class Workflow(db.Model):
|
||||
"""Workflow VWB - Conteneur d'étapes ordonnées"""
|
||||
__tablename__ = 'workflows'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Relations
|
||||
steps = db.relationship('Step', backref='workflow', lazy='dynamic',
|
||||
order_by='Step.order', cascade='all, delete-orphan')
|
||||
executions = db.relationship('Execution', backref='workflow', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise le workflow complet"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'steps': [step.to_dict() for step in self.steps.order_by(Step.order).all()],
|
||||
'step_count': self.steps.count()
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Workflow {self.id}: {self.name}>'
|
||||
|
||||
|
||||
class Step(db.Model):
|
||||
"""Étape d'un workflow - Action VWB avec type et paramètres"""
|
||||
__tablename__ = 'steps'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
workflow_id = db.Column(db.String(64), db.ForeignKey('workflows.id'), nullable=False)
|
||||
|
||||
# Type d'action - SOURCE DE VÉRITÉ UNIQUE
|
||||
action_type = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Ordre dans le workflow (0-indexed)
|
||||
order = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
# Position sur le canvas (pour l'affichage)
|
||||
position_x = db.Column(db.Float, default=0)
|
||||
position_y = db.Column(db.Float, default=0)
|
||||
|
||||
# Paramètres de l'action (JSON)
|
||||
parameters_json = db.Column(db.Text, default='{}')
|
||||
|
||||
# Référence vers l'ancre visuelle (si applicable)
|
||||
anchor_id = db.Column(db.String(64), db.ForeignKey('visual_anchors.id'), nullable=True)
|
||||
|
||||
# Métadonnées
|
||||
label = db.Column(db.String(255), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relations
|
||||
anchor = db.relationship('VisualAnchor', backref='steps')
|
||||
|
||||
@property
|
||||
def parameters(self) -> Dict[str, Any]:
|
||||
"""Retourne les paramètres comme dict"""
|
||||
try:
|
||||
return json.loads(self.parameters_json) if self.parameters_json else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
@parameters.setter
|
||||
def parameters(self, value: Dict[str, Any]):
|
||||
"""Stocke les paramètres comme JSON"""
|
||||
self.parameters_json = json.dumps(value) if value else '{}'
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise l'étape"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'workflow_id': self.workflow_id,
|
||||
'action_type': self.action_type, # SOURCE DE VÉRITÉ
|
||||
'order': self.order,
|
||||
'position': {'x': self.position_x, 'y': self.position_y},
|
||||
'parameters': self.parameters,
|
||||
'anchor_id': self.anchor_id,
|
||||
'anchor': self.anchor.to_dict() if self.anchor else None,
|
||||
'label': self.label or self.action_type,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Step {self.id}: {self.action_type} (order={self.order})>'
|
||||
|
||||
|
||||
class VisualAnchor(db.Model):
|
||||
"""Ancre visuelle - Image de référence pour localiser un élément UI"""
|
||||
__tablename__ = 'visual_anchors'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
|
||||
# Image de référence (chemin fichier, pas base64 en DB)
|
||||
image_path = db.Column(db.String(512), nullable=True)
|
||||
thumbnail_path = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# Bounding box de la sélection
|
||||
bbox_x = db.Column(db.Float, nullable=True)
|
||||
bbox_y = db.Column(db.Float, nullable=True)
|
||||
bbox_width = db.Column(db.Float, nullable=True)
|
||||
bbox_height = db.Column(db.Float, nullable=True)
|
||||
|
||||
# Résolution de l'écran lors de la capture
|
||||
screen_width = db.Column(db.Integer, nullable=True)
|
||||
screen_height = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Description pour l'utilisateur
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Seuil de confiance pour la détection
|
||||
confidence_threshold = db.Column(db.Float, default=0.8)
|
||||
|
||||
# Métadonnées
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
capture_method = db.Column(db.String(64), default='screen_capture')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise l'ancre visuelle"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'image_url': f'/api/v3/anchor/{self.id}/image' if self.image_path else None,
|
||||
'thumbnail_url': f'/api/v3/anchor/{self.id}/thumbnail' if self.thumbnail_path else None,
|
||||
'bounding_box': {
|
||||
'x': self.bbox_x,
|
||||
'y': self.bbox_y,
|
||||
'width': self.bbox_width,
|
||||
'height': self.bbox_height
|
||||
} if self.bbox_x is not None else None,
|
||||
'screen_resolution': {
|
||||
'width': self.screen_width,
|
||||
'height': self.screen_height
|
||||
} if self.screen_width else None,
|
||||
'description': self.description,
|
||||
'confidence_threshold': self.confidence_threshold,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<VisualAnchor {self.id}>'
|
||||
|
||||
|
||||
class Execution(db.Model):
|
||||
"""Exécution d'un workflow - Historique et état"""
|
||||
__tablename__ = 'executions'
|
||||
|
||||
id = db.Column(db.String(64), primary_key=True)
|
||||
workflow_id = db.Column(db.String(64), db.ForeignKey('workflows.id'), nullable=False)
|
||||
|
||||
# État de l'exécution
|
||||
status = db.Column(db.String(32), default='pending') # pending, running, paused, completed, error, cancelled
|
||||
|
||||
# Timestamps
|
||||
started_at = db.Column(db.DateTime, nullable=True)
|
||||
ended_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Progression
|
||||
current_step_index = db.Column(db.Integer, default=0)
|
||||
total_steps = db.Column(db.Integer, default=0)
|
||||
|
||||
# Résumé
|
||||
completed_steps = db.Column(db.Integer, default=0)
|
||||
failed_steps = db.Column(db.Integer, default=0)
|
||||
|
||||
# Erreur globale (si applicable)
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relations
|
||||
step_results = db.relationship('ExecutionStep', backref='execution', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Sérialise l'exécution"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'workflow_id': self.workflow_id,
|
||||
'status': self.status,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
|
||||
'current_step_index': self.current_step_index,
|
||||
'total_steps': self.total_steps,
|
||||
'completed_steps': self.completed_steps,
|
||||
'failed_steps': self.failed_steps,
|
||||
'error_message': self.error_message,
|
||||
'progress': (self.current_step_index / self.total_steps * 100) if self.total_steps > 0 else 0,
|
||||
'step_results': [sr.to_dict() for sr in self.step_results.all()]
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Execution {self.id}: {self.status}>'
|
||||
|
||||
|
||||
class ExecutionStep(db.Model):
|
||||
"""Résultat d'exécution d'une étape"""
|
||||
__tablename__ = 'execution_steps'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
execution_id = db.Column(db.String(64), db.ForeignKey('executions.id'), nullable=False)
|
||||
step_id = db.Column(db.String(64), nullable=False)
|
||||
|
||||
# Résultat
|
||||
status = db.Column(db.String(32), default='pending') # pending, running, success, error, skipped
|
||||
|
||||
# Timestamps
|
||||
started_at = db.Column(db.DateTime, nullable=True)
|
||||
ended_at = db.Column(db.DateTime, nullable=True)
|
||||
duration_ms = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Erreur (si applicable)
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Evidence (chemin vers screenshot)
|
||||
evidence_path = db.Column(db.String(512), nullable=True)
|
||||
|
||||
# Output data (JSON)
|
||||
output_json = db.Column(db.Text, default='{}')
|
||||
|
||||
@property
|
||||
def output(self) -> Dict[str, Any]:
|
||||
try:
|
||||
return json.loads(self.output_json) if self.output_json else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
@output.setter
|
||||
def output(self, value: Dict[str, Any]):
|
||||
self.output_json = json.dumps(value) if value else '{}'
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'step_id': self.step_id,
|
||||
'status': self.status,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'ended_at': self.ended_at.isoformat() if self.ended_at else None,
|
||||
'duration_ms': self.duration_ms,
|
||||
'error_message': self.error_message,
|
||||
'evidence_url': f'/api/v3/evidence/{self.id}' if self.evidence_path else None,
|
||||
'output': self.output
|
||||
}
|
||||
|
||||
|
||||
# Session active (en mémoire, pas en DB)
|
||||
class SessionState:
|
||||
"""État de la session utilisateur (en mémoire)"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_workflow_id: Optional[str] = None
|
||||
self.selected_step_id: Optional[str] = None
|
||||
self.active_execution_id: Optional[str] = None
|
||||
self.last_capture: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'active_workflow_id': self.active_workflow_id,
|
||||
'selected_step_id': self.selected_step_id,
|
||||
'active_execution_id': self.active_execution_id,
|
||||
'has_capture': self.last_capture is not None
|
||||
}
|
||||
|
||||
|
||||
# Instance globale de session
|
||||
_session_state = SessionState()
|
||||
|
||||
|
||||
def get_session_state() -> SessionState:
|
||||
"""Retourne l'état de session global"""
|
||||
return _session_state
|
||||
|
||||
|
||||
def init_db(app):
|
||||
"""Initialise la base de données avec l'application Flask"""
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print("✅ [DB] Base de données SQLite initialisée")
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Retourne la session DB actuelle"""
|
||||
return db.session
|
||||
96
visual_workflow_builder/frontend_v3/index.html
Normal file
96
visual_workflow_builder/frontend_v3/index.html
Normal file
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>VWB v3 - Thin Client</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Visual Workflow Builder v3</h1>
|
||||
<span class="badge">Thin Client - API = Verite</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Colonne gauche: Workflows -->
|
||||
<aside class="sidebar">
|
||||
<section class="panel">
|
||||
<h2>Workflows</h2>
|
||||
<form id="create-workflow-form">
|
||||
<input type="text" placeholder="Nouveau workflow..." required />
|
||||
<button type="submit">+</button>
|
||||
</form>
|
||||
<div id="workflow-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<!-- Zone centrale: Canvas/Etapes -->
|
||||
<section class="main-content">
|
||||
<div class="toolbar">
|
||||
<h3 id="workflow-name">Aucun workflow</h3>
|
||||
<div id="action-buttons"></div>
|
||||
</div>
|
||||
|
||||
<!-- Zone scrollable pour les étapes -->
|
||||
<div class="steps-container">
|
||||
<h4>Étapes du workflow</h4>
|
||||
<div id="steps-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Zone de capture (colonne séparée) -->
|
||||
<section class="capture-section">
|
||||
<div class="capture-zone">
|
||||
<div class="capture-header">
|
||||
<h4>Capture d'écran</h4>
|
||||
<button id="btn-toggle-capture" class="toggle-btn">Réduire</button>
|
||||
</div>
|
||||
<div id="capture-content">
|
||||
<div class="capture-controls">
|
||||
<button id="btn-capture" class="capture-btn">Capturer</button>
|
||||
<div class="timer-control">
|
||||
<select id="capture-delay">
|
||||
<option value="0">Immédiat</option>
|
||||
<option value="3">3s</option>
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
</select>
|
||||
<button id="btn-capture-timer" class="capture-btn secondary">Avec délai</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="timer-countdown" class="timer-countdown hidden"></div>
|
||||
<div id="capture-preview"></div>
|
||||
<button id="btn-fullscreen" class="fullscreen-btn hidden">Ouvrir en plein écran</button>
|
||||
|
||||
<!-- Bibliothèque de captures -->
|
||||
<div class="capture-library">
|
||||
<h5>Bibliothèque <span id="library-count">(0)</span></h5>
|
||||
<div id="capture-library-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Colonne droite: Propriétés et Execution -->
|
||||
<aside class="sidebar right">
|
||||
<section class="panel">
|
||||
<h2>Étape sélectionnée</h2>
|
||||
<div id="selected-step">
|
||||
<p class="empty">Sélectionnez une étape</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Exécution</h2>
|
||||
<div id="execution-status">
|
||||
<p>Prêt à exécuter</p>
|
||||
<button id="btn-start" class="primary">Démarrer</button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
944
visual_workflow_builder/frontend_v3/package-lock.json
generated
Normal file
944
visual_workflow_builder/frontend_v3/package-lock.json
generated
Normal file
@@ -0,0 +1,944 @@
|
||||
{
|
||||
"name": "vwb-thin-client",
|
||||
"version": "3.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vwb-thin-client",
|
||||
"version": "3.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
|
||||
"integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
|
||||
"integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
|
||||
"integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
|
||||
"integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
|
||||
"integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
|
||||
"integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
|
||||
"integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
|
||||
"integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
|
||||
"integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.56.0",
|
||||
"@rollup/rollup-android-arm64": "4.56.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.56.0",
|
||||
"@rollup/rollup-darwin-x64": "4.56.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.56.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.56.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.56.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.56.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.56.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.56.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.56.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.56.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.56.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.56.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.56.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.56.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
"rollup": "^4.20.0"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
visual_workflow_builder/frontend_v3/package.json
Normal file
15
visual_workflow_builder/frontend_v3/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "vwb-thin-client",
|
||||
"version": "3.0.0",
|
||||
"description": "Visual Workflow Builder - Thin Client (API = Verite)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port 3001",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
228
visual_workflow_builder/frontend_v3/src/api.ts
Normal file
228
visual_workflow_builder/frontend_v3/src/api.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* API Client VWB v3 - Thin Client
|
||||
* Toutes les interactions avec le backend passent par ce module.
|
||||
* L'API est la SOURCE DE VERITE UNIQUE.
|
||||
*/
|
||||
|
||||
import type { AppState, Workflow, Step, Execution, Capture } from './types';
|
||||
|
||||
const API_BASE = '/api/v3';
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Erreur API');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session
|
||||
// ============================================================
|
||||
|
||||
export async function getState(): Promise<AppState> {
|
||||
const data = await request<{
|
||||
success: boolean;
|
||||
session: AppState['session'];
|
||||
workflow: AppState['workflow'];
|
||||
execution: AppState['execution'];
|
||||
workflows_list: AppState['workflows_list'];
|
||||
}>('GET', '/session/state');
|
||||
|
||||
return {
|
||||
session: data.session,
|
||||
workflow: data.workflow,
|
||||
execution: data.execution,
|
||||
workflows_list: data.workflows_list
|
||||
};
|
||||
}
|
||||
|
||||
export async function selectWorkflow(workflowId: string): Promise<{
|
||||
session: AppState['session'];
|
||||
workflow: Workflow;
|
||||
}> {
|
||||
return request('POST', `/session/select-workflow/${workflowId}`);
|
||||
}
|
||||
|
||||
export async function selectStep(stepId: string): Promise<{
|
||||
session: AppState['session'];
|
||||
step: Step;
|
||||
}> {
|
||||
return request('POST', `/session/select-step/${stepId}`);
|
||||
}
|
||||
|
||||
export async function clearSession(): Promise<{ session: AppState['session'] }> {
|
||||
return request('POST', '/session/clear');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Workflow CRUD
|
||||
// ============================================================
|
||||
|
||||
export async function createWorkflow(
|
||||
name: string,
|
||||
description?: string
|
||||
): Promise<{ workflow: Workflow; session: AppState['session'] }> {
|
||||
return request('POST', '/workflow/create', { name, description });
|
||||
}
|
||||
|
||||
export async function getWorkflow(workflowId: string): Promise<{ workflow: Workflow }> {
|
||||
return request('GET', `/workflow/${workflowId}`);
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(workflowId: string): Promise<{
|
||||
deleted_id: string;
|
||||
session: AppState['session'];
|
||||
}> {
|
||||
return request('DELETE', `/workflow/${workflowId}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Steps
|
||||
// ============================================================
|
||||
|
||||
export async function addStep(
|
||||
workflowId: string,
|
||||
actionType: string,
|
||||
options?: {
|
||||
position?: { x: number; y: number };
|
||||
parameters?: Record<string, unknown>;
|
||||
label?: string;
|
||||
insertAfter?: string; // ID de l'étape après laquelle insérer
|
||||
}
|
||||
): Promise<{
|
||||
workflow: Workflow;
|
||||
step: Step;
|
||||
needs_anchor: boolean;
|
||||
session: AppState['session'];
|
||||
}> {
|
||||
return request('POST', `/workflow/${workflowId}/step`, {
|
||||
action_type: actionType,
|
||||
position: options?.position,
|
||||
parameters: options?.parameters,
|
||||
label: options?.label,
|
||||
insert_after: options?.insertAfter
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStep(
|
||||
workflowId: string,
|
||||
stepId: string,
|
||||
updates: {
|
||||
action_type?: string;
|
||||
position?: { x: number; y: number };
|
||||
parameters?: Record<string, unknown>;
|
||||
label?: string;
|
||||
anchor_id?: string | null;
|
||||
}
|
||||
): Promise<{ workflow: Workflow; step: Step }> {
|
||||
return request('PUT', `/workflow/${workflowId}/step/${stepId}`, updates);
|
||||
}
|
||||
|
||||
export async function deleteStep(
|
||||
workflowId: string,
|
||||
stepId: string
|
||||
): Promise<{ workflow: Workflow; session: AppState['session'] }> {
|
||||
return request('DELETE', `/workflow/${workflowId}/step/${stepId}`);
|
||||
}
|
||||
|
||||
export async function reorderSteps(
|
||||
workflowId: string,
|
||||
stepIds: string[]
|
||||
): Promise<{ workflow: Workflow }> {
|
||||
return request('POST', `/workflow/${workflowId}/reorder`, { step_ids: stepIds });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Capture
|
||||
// ============================================================
|
||||
|
||||
export async function captureScreen(): Promise<{ capture: Capture }> {
|
||||
return request('POST', '/capture/screen');
|
||||
}
|
||||
|
||||
export async function selectAnchor(
|
||||
stepId: string,
|
||||
bbox: { x: number; y: number; width: number; height: number },
|
||||
description?: string,
|
||||
screenshotBase64?: string // Capture optionnelle
|
||||
): Promise<{
|
||||
workflow: Workflow;
|
||||
step: Step;
|
||||
anchor: import('./types').VisualAnchor;
|
||||
}> {
|
||||
return request('POST', '/capture/select', {
|
||||
step_id: stepId,
|
||||
bbox,
|
||||
description,
|
||||
screenshot_base64: screenshotBase64
|
||||
});
|
||||
}
|
||||
|
||||
export function getAnchorImageUrl(anchorId: string): string {
|
||||
return `${API_BASE}/anchor/${anchorId}/image`;
|
||||
}
|
||||
|
||||
export function getAnchorThumbnailUrl(anchorId: string): string {
|
||||
return `${API_BASE}/anchor/${anchorId}/thumbnail`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Execution
|
||||
// ============================================================
|
||||
|
||||
export async function startExecution(workflowId?: string): Promise<{
|
||||
execution: Execution;
|
||||
session: AppState['session'];
|
||||
}> {
|
||||
return request('POST', '/execute/start', workflowId ? { workflow_id: workflowId } : {});
|
||||
}
|
||||
|
||||
export async function pauseExecution(): Promise<{ execution: Execution }> {
|
||||
return request('POST', '/execute/pause');
|
||||
}
|
||||
|
||||
export async function resumeExecution(): Promise<{ execution: Execution }> {
|
||||
return request('POST', '/execute/resume');
|
||||
}
|
||||
|
||||
export async function stopExecution(): Promise<{
|
||||
execution: Execution;
|
||||
session: AppState['session'];
|
||||
}> {
|
||||
return request('POST', '/execute/stop');
|
||||
}
|
||||
|
||||
export async function getExecutionStatus(): Promise<{
|
||||
is_running: boolean;
|
||||
is_paused: boolean;
|
||||
execution: Execution | null;
|
||||
session: AppState['session'];
|
||||
}> {
|
||||
return request('GET', '/execute/status');
|
||||
}
|
||||
|
||||
export async function getExecutionHistory(workflowId?: string): Promise<{
|
||||
executions: Execution[];
|
||||
}> {
|
||||
const query = workflowId ? `?workflow_id=${workflowId}` : '';
|
||||
return request('GET', `/execute/history${query}`);
|
||||
}
|
||||
232
visual_workflow_builder/frontend_v3/src/main.ts
Normal file
232
visual_workflow_builder/frontend_v3/src/main.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* VWB v3 - Thin Client
|
||||
* Point d'entrée principal
|
||||
*
|
||||
* PRINCIPE: L'API est la SOURCE DE VERITE UNIQUE
|
||||
* Ce frontend NE FAIT QU'AFFICHER ce que l'API retourne.
|
||||
*/
|
||||
|
||||
import * as api from './api';
|
||||
import * as ui from './ui';
|
||||
import type { AppState, ActionType, Capture } from './types';
|
||||
|
||||
// État local minimal (juste pour le polling et la capture en cours)
|
||||
let pollingInterval: number | null = null;
|
||||
let currentCapture: Capture | null = null;
|
||||
|
||||
async function loadState(): Promise<void> {
|
||||
try {
|
||||
const state = await api.getState();
|
||||
ui.render(state);
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function init(): Promise<void> {
|
||||
// Initialiser l'UI avec les callbacks
|
||||
ui.initUI({
|
||||
// Workflows
|
||||
onCreateWorkflow: async (name) => {
|
||||
try {
|
||||
await api.createWorkflow(name);
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onSelectWorkflow: async (id) => {
|
||||
try {
|
||||
await api.selectWorkflow(id);
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onDeleteWorkflow: async (id) => {
|
||||
try {
|
||||
await api.deleteWorkflow(id);
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
// Steps
|
||||
onAddStep: async (actionType: ActionType, insertAfter?: string) => {
|
||||
try {
|
||||
const state = await api.getState();
|
||||
if (!state.session.active_workflow_id) {
|
||||
ui.showError('Aucun workflow actif');
|
||||
return;
|
||||
}
|
||||
await api.addStep(state.session.active_workflow_id, actionType, {
|
||||
insertAfter
|
||||
});
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onSelectStep: async (id) => {
|
||||
try {
|
||||
await api.selectStep(id);
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onDeleteStep: async (id) => {
|
||||
try {
|
||||
const state = await api.getState();
|
||||
if (!state.session.active_workflow_id) return;
|
||||
await api.deleteStep(state.session.active_workflow_id, id);
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onReorderSteps: async (stepIds) => {
|
||||
console.log('📝 onReorderSteps appelé avec:', stepIds);
|
||||
try {
|
||||
const state = await api.getState();
|
||||
console.log('État récupéré, active_workflow_id:', state.session.active_workflow_id);
|
||||
if (!state.session.active_workflow_id) {
|
||||
console.log('Pas de workflow actif, abandon');
|
||||
return;
|
||||
}
|
||||
console.log('Appel API reorderSteps...');
|
||||
await api.reorderSteps(state.session.active_workflow_id, stepIds);
|
||||
console.log('API reorderSteps terminé, rechargement état...');
|
||||
await loadState();
|
||||
console.log('État rechargé');
|
||||
} catch (error) {
|
||||
console.error('Erreur dans onReorderSteps:', error);
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onUpdateStepParams: async (id, params) => {
|
||||
try {
|
||||
const state = await api.getState();
|
||||
if (!state.session.active_workflow_id) return;
|
||||
|
||||
// Traiter le cas des touches clavier
|
||||
if ('keys' in params && typeof params.keys === 'string') {
|
||||
params.keys = (params.keys as string).split('+').map(k => k.trim());
|
||||
}
|
||||
|
||||
await api.updateStep(state.session.active_workflow_id, id, { parameters: params });
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
// Capture
|
||||
onCaptureScreen: async () => {
|
||||
try {
|
||||
const result = await api.captureScreen();
|
||||
currentCapture = result.capture;
|
||||
ui.renderCapture(currentCapture);
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onSelectAnchor: async (bbox, screenshotBase64) => {
|
||||
try {
|
||||
const state = await api.getState();
|
||||
if (!state.session.selected_step_id) {
|
||||
ui.showError('Sélectionnez une étape d\'abord');
|
||||
return;
|
||||
}
|
||||
await api.selectAnchor(state.session.selected_step_id, bbox, undefined, screenshotBase64);
|
||||
await loadState();
|
||||
ui.showInfo('Ancre créée avec succès');
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
// Execution
|
||||
onStartExecution: async () => {
|
||||
try {
|
||||
await api.startExecution();
|
||||
startPolling();
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onPauseExecution: async () => {
|
||||
try {
|
||||
await api.pauseExecution();
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onResumeExecution: async () => {
|
||||
try {
|
||||
await api.resumeExecution();
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
},
|
||||
|
||||
onStopExecution: async () => {
|
||||
try {
|
||||
await api.stopExecution();
|
||||
stopPolling();
|
||||
await loadState();
|
||||
} catch (error) {
|
||||
ui.showError((error as Error).message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Charger l'état initial
|
||||
await loadState();
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (pollingInterval) return;
|
||||
pollingInterval = window.setInterval(async () => {
|
||||
try {
|
||||
const status = await api.getExecutionStatus();
|
||||
ui.render({
|
||||
session: status.session,
|
||||
workflow: null, // On garde le workflow actuel
|
||||
execution: status.execution,
|
||||
workflows_list: []
|
||||
} as AppState);
|
||||
|
||||
// Arrêter le polling si l'exécution est terminée
|
||||
if (!status.is_running) {
|
||||
stopPolling();
|
||||
await loadState(); // Recharger l'état complet
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Polling error:', error);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval);
|
||||
pollingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Démarrer l'application
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
125
visual_workflow_builder/frontend_v3/src/types.ts
Normal file
125
visual_workflow_builder/frontend_v3/src/types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Types VWB v3 - Thin Client
|
||||
* Ces types reflètent EXACTEMENT ce que l'API retourne.
|
||||
* L'API est la source de vérité unique.
|
||||
*/
|
||||
|
||||
export interface Session {
|
||||
active_workflow_id: string | null;
|
||||
selected_step_id: string | null;
|
||||
active_execution_id: string | null;
|
||||
has_capture: boolean;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface VisualAnchor {
|
||||
id: string;
|
||||
image_url: string | null;
|
||||
thumbnail_url: string | null;
|
||||
bounding_box: BoundingBox | null;
|
||||
screen_resolution: { width: number; height: number } | null;
|
||||
description: string | null;
|
||||
confidence_threshold: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
action_type: string; // SOURCE DE VERITE UNIQUE
|
||||
order: number;
|
||||
position: { x: number; y: number };
|
||||
parameters: Record<string, unknown>;
|
||||
anchor_id: string | null;
|
||||
anchor: VisualAnchor | null;
|
||||
label: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
steps: Step[];
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
step_count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionStepResult {
|
||||
step_id: string;
|
||||
status: 'pending' | 'running' | 'success' | 'error' | 'skipped';
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
evidence_url: string | null;
|
||||
output: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Execution {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled';
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
current_step_index: number;
|
||||
total_steps: number;
|
||||
completed_steps: number;
|
||||
failed_steps: number;
|
||||
error_message: string | null;
|
||||
progress: number;
|
||||
step_results: ExecutionStepResult[];
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
session: Session;
|
||||
workflow: Workflow | null;
|
||||
execution: Execution | null;
|
||||
workflows_list: WorkflowSummary[];
|
||||
}
|
||||
|
||||
export interface Capture {
|
||||
screenshot_base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Types d'actions VWB supportés
|
||||
export type ActionType =
|
||||
| 'click_anchor'
|
||||
| 'double_click_anchor'
|
||||
| 'right_click_anchor'
|
||||
| 'type_text'
|
||||
| 'wait_for_anchor'
|
||||
| 'keyboard_shortcut'
|
||||
| 'scroll_to_anchor'
|
||||
| 'extract_text'
|
||||
| 'screenshot_evidence';
|
||||
|
||||
export const ACTION_LABELS: Record<ActionType, string> = {
|
||||
'click_anchor': 'Clic sur ancre',
|
||||
'double_click_anchor': 'Double-clic sur ancre',
|
||||
'right_click_anchor': 'Clic droit sur ancre',
|
||||
'type_text': 'Saisir texte',
|
||||
'wait_for_anchor': 'Attendre ancre',
|
||||
'keyboard_shortcut': 'Raccourci clavier',
|
||||
'scroll_to_anchor': 'Défiler vers ancre',
|
||||
'extract_text': 'Extraire texte',
|
||||
'screenshot_evidence': 'Capture preuve'
|
||||
};
|
||||
872
visual_workflow_builder/frontend_v3/src/ui.ts
Normal file
872
visual_workflow_builder/frontend_v3/src/ui.ts
Normal file
@@ -0,0 +1,872 @@
|
||||
/**
|
||||
* UI VWB v3 - Thin Client
|
||||
* Ce module AFFICHE ce que l'API retourne.
|
||||
* Pas de logique complexe, pas d'état local.
|
||||
*/
|
||||
|
||||
import type { AppState, Step, Workflow, Execution, Capture, ActionType } from './types';
|
||||
import { ACTION_LABELS } from './types';
|
||||
|
||||
// Éléments DOM
|
||||
let $workflowList: HTMLElement;
|
||||
let $workflowName: HTMLElement;
|
||||
let $stepsList: HTMLElement;
|
||||
let $selectedStep: HTMLElement;
|
||||
let $executionStatus: HTMLElement;
|
||||
let $capturePreview: HTMLElement;
|
||||
let $actionButtons: HTMLElement;
|
||||
let $timerCountdown: HTMLElement;
|
||||
let $libraryList: HTMLElement;
|
||||
let $libraryCount: HTMLElement;
|
||||
|
||||
// Bibliothèque de captures (en mémoire session)
|
||||
let captureLibrary: Array<{ id: string; capture: import('./types').Capture; timestamp: Date }> = [];
|
||||
|
||||
// Callbacks pour les actions utilisateur
|
||||
export type UICallbacks = {
|
||||
onCreateWorkflow: (name: string) => void;
|
||||
onSelectWorkflow: (id: string) => void;
|
||||
onDeleteWorkflow: (id: string) => void;
|
||||
onAddStep: (actionType: ActionType, insertAfter?: string) => void;
|
||||
onSelectStep: (id: string) => void;
|
||||
onDeleteStep: (id: string) => void;
|
||||
onUpdateStepParams: (id: string, params: Record<string, unknown>) => void;
|
||||
onReorderSteps: (stepIds: string[]) => void;
|
||||
onCaptureScreen: () => void;
|
||||
onSelectAnchor: (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => void;
|
||||
onStartExecution: () => void;
|
||||
onPauseExecution: () => void;
|
||||
onResumeExecution: () => void;
|
||||
onStopExecution: () => void;
|
||||
};
|
||||
|
||||
// Variable pour stocker l'ID de l'étape après laquelle insérer
|
||||
let insertAfterStepId: string | null = null;
|
||||
|
||||
let callbacks: UICallbacks;
|
||||
|
||||
export function initUI(cb: UICallbacks): void {
|
||||
callbacks = cb;
|
||||
|
||||
// Récupérer les éléments DOM
|
||||
$workflowList = document.getElementById('workflow-list')!;
|
||||
$workflowName = document.getElementById('workflow-name')!;
|
||||
$stepsList = document.getElementById('steps-list')!;
|
||||
$selectedStep = document.getElementById('selected-step')!;
|
||||
$executionStatus = document.getElementById('execution-status')!;
|
||||
$capturePreview = document.getElementById('capture-preview')!;
|
||||
$actionButtons = document.getElementById('action-buttons')!;
|
||||
$timerCountdown = document.getElementById('timer-countdown')!;
|
||||
$libraryList = document.getElementById('capture-library-list')!;
|
||||
$libraryCount = document.getElementById('library-count')!;
|
||||
|
||||
// Boutons d'action
|
||||
setupActionButtons();
|
||||
|
||||
// Formulaire nouveau workflow
|
||||
const createForm = document.getElementById('create-workflow-form') as HTMLFormElement;
|
||||
createForm?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const input = createForm.querySelector('input') as HTMLInputElement;
|
||||
if (input.value.trim()) {
|
||||
callbacks.onCreateWorkflow(input.value.trim());
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Boutons execution
|
||||
document.getElementById('btn-start')?.addEventListener('click', () => callbacks.onStartExecution());
|
||||
document.getElementById('btn-pause')?.addEventListener('click', () => callbacks.onPauseExecution());
|
||||
document.getElementById('btn-resume')?.addEventListener('click', () => callbacks.onResumeExecution());
|
||||
document.getElementById('btn-stop')?.addEventListener('click', () => callbacks.onStopExecution());
|
||||
|
||||
// Capture immédiate
|
||||
document.getElementById('btn-capture')?.addEventListener('click', () => callbacks.onCaptureScreen());
|
||||
|
||||
// Capture avec délai
|
||||
document.getElementById('btn-capture-timer')?.addEventListener('click', () => {
|
||||
const delay = parseInt((document.getElementById('capture-delay') as HTMLSelectElement).value);
|
||||
startCaptureTimer(delay);
|
||||
});
|
||||
|
||||
// Bouton plein écran
|
||||
document.getElementById('btn-fullscreen')?.addEventListener('click', openFullscreenModal);
|
||||
|
||||
// Bouton toggle capture
|
||||
const $toggleBtn = document.getElementById('btn-toggle-capture');
|
||||
const $captureContent = document.getElementById('capture-content');
|
||||
$toggleBtn?.addEventListener('click', () => {
|
||||
const isCollapsed = $captureContent?.classList.toggle('collapsed');
|
||||
if ($toggleBtn) {
|
||||
$toggleBtn.textContent = isCollapsed ? 'Afficher' : 'Réduire';
|
||||
}
|
||||
});
|
||||
|
||||
// Créer la modal plein écran
|
||||
createFullscreenModal();
|
||||
|
||||
// Charger la bibliothèque depuis sessionStorage
|
||||
loadLibraryFromStorage();
|
||||
}
|
||||
|
||||
function startCaptureTimer(seconds: number): void {
|
||||
if (seconds === 0) {
|
||||
callbacks.onCaptureScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = seconds;
|
||||
$timerCountdown.classList.remove('hidden');
|
||||
$timerCountdown.textContent = String(remaining);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
remaining--;
|
||||
if (remaining > 0) {
|
||||
$timerCountdown.textContent = String(remaining);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
$timerCountdown.classList.add('hidden');
|
||||
callbacks.onCaptureScreen();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function loadLibraryFromStorage(): void {
|
||||
try {
|
||||
const stored = sessionStorage.getItem('captureLibrary');
|
||||
if (stored) {
|
||||
captureLibrary = JSON.parse(stored);
|
||||
renderLibrary();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement bibliothèque:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveLibraryToStorage(): void {
|
||||
try {
|
||||
sessionStorage.setItem('captureLibrary', JSON.stringify(captureLibrary));
|
||||
} catch (e) {
|
||||
console.error('Erreur sauvegarde bibliothèque:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function addToLibrary(capture: import('./types').Capture): void {
|
||||
const id = `cap_${Date.now()}`;
|
||||
captureLibrary.unshift({ id, capture, timestamp: new Date() });
|
||||
// Garder max 20 captures
|
||||
if (captureLibrary.length > 20) {
|
||||
captureLibrary = captureLibrary.slice(0, 20);
|
||||
}
|
||||
saveLibraryToStorage();
|
||||
renderLibrary();
|
||||
}
|
||||
|
||||
function renderLibrary(): void {
|
||||
$libraryCount.textContent = `(${captureLibrary.length})`;
|
||||
|
||||
if (captureLibrary.length === 0) {
|
||||
$libraryList.innerHTML = '<p class="empty">Aucune capture</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$libraryList.innerHTML = captureLibrary.map(item => `
|
||||
<div class="library-item" data-id="${item.id}">
|
||||
<img src="data:image/png;base64,${item.capture.screenshot_base64}" alt="Capture" />
|
||||
<button class="delete-lib" data-delete="${item.id}">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Événements
|
||||
$libraryList.querySelectorAll('.library-item').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('delete-lib')) {
|
||||
const id = target.dataset.delete!;
|
||||
captureLibrary = captureLibrary.filter(c => c.id !== id);
|
||||
saveLibraryToStorage();
|
||||
renderLibrary();
|
||||
} else {
|
||||
const id = (el as HTMLElement).dataset.id!;
|
||||
const item = captureLibrary.find(c => c.id === id);
|
||||
if (item) {
|
||||
renderCapture(item.capture, false); // false = ne pas ré-ajouter à la bibliothèque
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Modal plein écran
|
||||
let $fullscreenModal: HTMLElement;
|
||||
let currentFullscreenCapture: import('./types').Capture | null = null;
|
||||
|
||||
function createFullscreenModal(): void {
|
||||
$fullscreenModal = document.createElement('div');
|
||||
$fullscreenModal.className = 'fullscreen-modal hidden';
|
||||
$fullscreenModal.innerHTML = `
|
||||
<button class="close-btn">Fermer (Echap)</button>
|
||||
<div class="modal-content">
|
||||
<img id="fullscreen-image" src="" alt="Capture plein écran" />
|
||||
<div id="fullscreen-overlay"></div>
|
||||
</div>
|
||||
<p class="instructions">Dessinez un rectangle pour sélectionner l'ancre visuelle</p>
|
||||
`;
|
||||
document.body.appendChild($fullscreenModal);
|
||||
|
||||
// Fermer avec le bouton ou Echap
|
||||
$fullscreenModal.querySelector('.close-btn')?.addEventListener('click', closeFullscreenModal);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !$fullscreenModal.classList.contains('hidden')) {
|
||||
closeFullscreenModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openFullscreenModal(): void {
|
||||
if (!currentFullscreenCapture) return;
|
||||
|
||||
const img = $fullscreenModal.querySelector('#fullscreen-image') as HTMLImageElement;
|
||||
img.src = `data:image/png;base64,${currentFullscreenCapture.screenshot_base64}`;
|
||||
|
||||
$fullscreenModal.classList.remove('hidden');
|
||||
|
||||
// Setup selection tool pour le mode plein écran
|
||||
setTimeout(() => setupFullscreenSelectionTool(), 100);
|
||||
}
|
||||
|
||||
function closeFullscreenModal(): void {
|
||||
$fullscreenModal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function setupFullscreenSelectionTool(): void {
|
||||
const img = $fullscreenModal.querySelector('#fullscreen-image') as HTMLImageElement;
|
||||
const overlay = $fullscreenModal.querySelector('#fullscreen-overlay') as HTMLElement;
|
||||
|
||||
if (!img || !overlay) return;
|
||||
|
||||
let isSelecting = false;
|
||||
let startX = 0, startY = 0;
|
||||
let imgRect: DOMRect;
|
||||
|
||||
// Supprimer les anciens listeners
|
||||
const newImg = img.cloneNode(true) as HTMLImageElement;
|
||||
img.parentNode?.replaceChild(newImg, img);
|
||||
|
||||
newImg.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
imgRect = newImg.getBoundingClientRect();
|
||||
startX = e.clientX - imgRect.left;
|
||||
startY = e.clientY - imgRect.top;
|
||||
isSelecting = true;
|
||||
|
||||
overlay.style.display = 'block';
|
||||
overlay.style.left = startX + 'px';
|
||||
overlay.style.top = startY + 'px';
|
||||
overlay.style.width = '0';
|
||||
overlay.style.height = '0';
|
||||
});
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isSelecting) return;
|
||||
|
||||
const currentX = e.clientX - imgRect.left;
|
||||
const currentY = e.clientY - imgRect.top;
|
||||
|
||||
const width = currentX - startX;
|
||||
const height = currentY - startY;
|
||||
|
||||
overlay.style.left = (width < 0 ? currentX : startX) + 'px';
|
||||
overlay.style.top = (height < 0 ? currentY : startY) + 'px';
|
||||
overlay.style.width = Math.abs(width) + 'px';
|
||||
overlay.style.height = Math.abs(height) + 'px';
|
||||
};
|
||||
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
if (!isSelecting) return;
|
||||
isSelecting = false;
|
||||
|
||||
const endX = e.clientX - imgRect.left;
|
||||
const endY = e.clientY - imgRect.top;
|
||||
|
||||
const scaleX = newImg.naturalWidth / newImg.width;
|
||||
const scaleY = newImg.naturalHeight / newImg.height;
|
||||
|
||||
const bbox = {
|
||||
x: Math.round(Math.min(startX, endX) * scaleX),
|
||||
y: Math.round(Math.min(startY, endY) * scaleY),
|
||||
width: Math.round(Math.abs(endX - startX) * scaleX),
|
||||
height: Math.round(Math.abs(endY - startY) * scaleY)
|
||||
};
|
||||
|
||||
if (bbox.width > 10 && bbox.height > 10) {
|
||||
closeFullscreenModal();
|
||||
// Envoyer la capture courante avec la sélection
|
||||
const screenshotBase64 = currentFullscreenCapture?.screenshot_base64;
|
||||
callbacks.onSelectAnchor(bbox, screenshotBase64);
|
||||
} else {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
const AVAILABLE_ACTIONS: ActionType[] = [
|
||||
'click_anchor',
|
||||
'double_click_anchor',
|
||||
'type_text',
|
||||
'wait_for_anchor',
|
||||
'keyboard_shortcut'
|
||||
];
|
||||
|
||||
function setupActionButtons(): void {
|
||||
$actionButtons.innerHTML = AVAILABLE_ACTIONS.map(action => `
|
||||
<button class="action-btn" data-action="${action}">
|
||||
${ACTION_LABELS[action]}
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
$actionButtons.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const action = target.dataset.action as ActionType;
|
||||
if (action) {
|
||||
callbacks.onAddStep(action);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Menu contextuel pour insérer une étape
|
||||
let $insertMenu: HTMLElement | null = null;
|
||||
|
||||
function showInsertStepMenu(anchorEl: HTMLElement, insertAfterId: string): void {
|
||||
// Supprimer le menu existant
|
||||
hideInsertStepMenu();
|
||||
|
||||
// Créer le menu
|
||||
$insertMenu = document.createElement('div');
|
||||
$insertMenu.className = 'insert-menu';
|
||||
$insertMenu.innerHTML = `
|
||||
<div class="insert-menu-header">Insérer après l'étape:</div>
|
||||
${AVAILABLE_ACTIONS.map(action => `
|
||||
<button class="insert-menu-item" data-action="${action}">
|
||||
${ACTION_LABELS[action]}
|
||||
</button>
|
||||
`).join('')}
|
||||
`;
|
||||
|
||||
// Positionner le menu
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
$insertMenu.style.position = 'fixed';
|
||||
$insertMenu.style.left = `${rect.left}px`;
|
||||
$insertMenu.style.top = `${rect.bottom + 5}px`;
|
||||
|
||||
document.body.appendChild($insertMenu);
|
||||
|
||||
// Event listeners
|
||||
$insertMenu.querySelectorAll('.insert-menu-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const action = (btn as HTMLElement).dataset.action as ActionType;
|
||||
callbacks.onAddStep(action, insertAfterId);
|
||||
hideInsertStepMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// Fermer si clic ailleurs
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent): void {
|
||||
if ($insertMenu && !$insertMenu.contains(e.target as Node)) {
|
||||
hideInsertStepMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function hideInsertStepMenu(): void {
|
||||
if ($insertMenu) {
|
||||
$insertMenu.remove();
|
||||
$insertMenu = null;
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
}
|
||||
}
|
||||
|
||||
export function render(state: AppState): void {
|
||||
renderWorkflowList(state);
|
||||
renderWorkflow(state.workflow);
|
||||
renderSelectedStep(state);
|
||||
renderExecution(state.execution);
|
||||
}
|
||||
|
||||
function renderWorkflowList(state: AppState): void {
|
||||
if (state.workflows_list.length === 0) {
|
||||
$workflowList.innerHTML = '<p class="empty">Aucun workflow</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$workflowList.innerHTML = state.workflows_list.map(wf => `
|
||||
<div class="workflow-item ${state.session.active_workflow_id === wf.id ? 'active' : ''}"
|
||||
data-id="${wf.id}">
|
||||
<span class="name">${escapeHtml(wf.name)}</span>
|
||||
<span class="count">${wf.step_count} étapes</span>
|
||||
<button class="delete-btn" data-delete="${wf.id}" title="Supprimer">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Event listeners
|
||||
$workflowList.querySelectorAll('.workflow-item').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('delete-btn')) {
|
||||
const id = target.dataset.delete!;
|
||||
if (confirm('Supprimer ce workflow ?')) {
|
||||
callbacks.onDeleteWorkflow(id);
|
||||
}
|
||||
} else {
|
||||
const id = (el as HTMLElement).dataset.id!;
|
||||
callbacks.onSelectWorkflow(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Variables pour le drag & drop
|
||||
let draggedStepId: string | null = null;
|
||||
let currentWorkflowSteps: string[] = [];
|
||||
|
||||
function renderWorkflow(workflow: Workflow | null): void {
|
||||
if (!workflow) {
|
||||
$workflowName.textContent = 'Aucun workflow sélectionné';
|
||||
$stepsList.innerHTML = '<p class="empty">Sélectionnez ou créez un workflow</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$workflowName.textContent = workflow.name;
|
||||
|
||||
if (workflow.steps.length === 0) {
|
||||
$stepsList.innerHTML = '<p class="empty">Ajoutez des étapes avec les boutons ci-dessus</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Stocker l'ordre actuel des étapes
|
||||
currentWorkflowSteps = workflow.steps.map(s => s.id);
|
||||
|
||||
// Générer HTML avec drag & drop
|
||||
let html = '';
|
||||
workflow.steps.forEach((step, index) => {
|
||||
html += `
|
||||
<div class="step-item" data-id="${step.id}" draggable="true">
|
||||
<span class="drag-handle" title="Glisser pour réorganiser">⋮⋮</span>
|
||||
<span class="order">${index + 1}</span>
|
||||
<span class="type">${ACTION_LABELS[step.action_type as ActionType] || step.action_type}</span>
|
||||
<span class="label">${escapeHtml(step.label)}</span>
|
||||
${step.anchor ? '<span class="anchor-badge">Ancre</span>' : ''}
|
||||
<button class="delete-btn" data-delete="${step.id}" title="Supprimer">×</button>
|
||||
</div>
|
||||
<div class="insert-step-btn" data-insert-after="${step.id}" title="Insérer une étape ici">
|
||||
<span class="insert-icon">+</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
$stepsList.innerHTML = html;
|
||||
|
||||
// Event listeners pour les étapes (clic et drag & drop)
|
||||
$stepsList.querySelectorAll('.step-item').forEach(el => {
|
||||
const stepEl = el as HTMLElement;
|
||||
|
||||
// Clic pour sélectionner ou supprimer
|
||||
stepEl.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('delete-btn')) {
|
||||
const id = target.dataset.delete!;
|
||||
callbacks.onDeleteStep(id);
|
||||
} else if (!target.classList.contains('drag-handle')) {
|
||||
const id = stepEl.dataset.id!;
|
||||
callbacks.onSelectStep(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Drag & Drop
|
||||
stepEl.addEventListener('dragstart', (e) => {
|
||||
draggedStepId = stepEl.dataset.id!;
|
||||
stepEl.classList.add('dragging');
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
});
|
||||
|
||||
stepEl.addEventListener('dragend', () => {
|
||||
stepEl.classList.remove('dragging');
|
||||
draggedStepId = null;
|
||||
// Supprimer tous les indicateurs
|
||||
$stepsList.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
});
|
||||
|
||||
stepEl.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!draggedStepId || draggedStepId === stepEl.dataset.id) return;
|
||||
|
||||
// Supprimer les autres indicateurs
|
||||
$stepsList.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
|
||||
// Ajouter l'indicateur sur cet élément
|
||||
stepEl.classList.add('drag-over');
|
||||
});
|
||||
|
||||
stepEl.addEventListener('dragleave', () => {
|
||||
stepEl.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
stepEl.addEventListener('drop', (e) => {
|
||||
console.log('🎯 DROP EVENT TRIGGERED on', stepEl.dataset.id);
|
||||
e.preventDefault();
|
||||
stepEl.classList.remove('drag-over');
|
||||
|
||||
if (!draggedStepId || draggedStepId === stepEl.dataset.id) {
|
||||
console.log('Drop ignoré: draggedStepId=', draggedStepId, 'targetId=', stepEl.dataset.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = stepEl.dataset.id!;
|
||||
|
||||
console.log('Drop event: draggedStepId=', draggedStepId, 'targetId=', targetId);
|
||||
console.log('currentWorkflowSteps avant:', [...currentWorkflowSteps]);
|
||||
|
||||
// Calculer le nouvel ordre
|
||||
const newOrder = [...currentWorkflowSteps];
|
||||
const draggedIndex = newOrder.indexOf(draggedStepId);
|
||||
const targetIndex = newOrder.indexOf(targetId);
|
||||
|
||||
console.log('draggedIndex=', draggedIndex, 'targetIndex=', targetIndex);
|
||||
|
||||
if (draggedIndex !== -1 && targetIndex !== -1 && draggedIndex !== targetIndex) {
|
||||
// Retirer l'élément glissé
|
||||
newOrder.splice(draggedIndex, 1);
|
||||
// L'insérer à la position cible
|
||||
// Quand on déplace vers le bas: on veut aller APRÈS l'élément cible
|
||||
// Quand on déplace vers le haut: on veut prendre la place de l'élément cible
|
||||
// Dans les deux cas après suppression, targetIndex est la bonne position
|
||||
newOrder.splice(targetIndex, 0, draggedStepId);
|
||||
|
||||
console.log('newOrder après réorganisation:', newOrder);
|
||||
|
||||
// Appeler le callback pour réordonner
|
||||
try {
|
||||
console.log('Appel de callbacks.onReorderSteps...');
|
||||
callbacks.onReorderSteps(newOrder);
|
||||
console.log('Callback appelé avec succès');
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du callback onReorderSteps:', err);
|
||||
}
|
||||
} else {
|
||||
console.log('Pas de changement nécessaire');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Event listeners pour les boutons d'insertion
|
||||
$stepsList.querySelectorAll('.insert-step-btn').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const insertAfter = (el as HTMLElement).dataset.insertAfter!;
|
||||
showInsertStepMenu(el as HTMLElement, insertAfter);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSelectedStep(state: AppState): void {
|
||||
const selectedId = state.session.selected_step_id;
|
||||
|
||||
// Highlight selected step
|
||||
$stepsList.querySelectorAll('.step-item').forEach(el => {
|
||||
el.classList.toggle('selected', (el as HTMLElement).dataset.id === selectedId);
|
||||
});
|
||||
|
||||
if (!selectedId || !state.workflow) {
|
||||
$selectedStep.innerHTML = '<p class="empty">Sélectionnez une étape</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const step = state.workflow.steps.find(s => s.id === selectedId);
|
||||
if (!step) {
|
||||
$selectedStep.innerHTML = '<p class="empty">Étape non trouvée</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$selectedStep.innerHTML = `
|
||||
<div class="step-details">
|
||||
<h4>${ACTION_LABELS[step.action_type as ActionType] || step.action_type}</h4>
|
||||
<p><strong>ID:</strong> ${step.id}</p>
|
||||
<p><strong>Type:</strong> ${step.action_type}</p>
|
||||
${renderStepParams(step)}
|
||||
${step.anchor ? renderAnchor(step) : renderNeedAnchor(step)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listener pour les paramètres
|
||||
const paramsForm = $selectedStep.querySelector('.params-form');
|
||||
paramsForm?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const params: Record<string, unknown> = {};
|
||||
formData.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
callbacks.onUpdateStepParams(step.id, params);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStepParams(step: Step): string {
|
||||
const actionType = step.action_type as ActionType;
|
||||
|
||||
if (actionType === 'type_text') {
|
||||
return `
|
||||
<form class="params-form">
|
||||
<label>
|
||||
Texte à saisir:
|
||||
<input type="text" name="text" value="${escapeHtml(String(step.parameters.text || ''))}" />
|
||||
</label>
|
||||
<button type="submit">Mettre à jour</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
if (actionType === 'wait_for_anchor') {
|
||||
return `
|
||||
<form class="params-form">
|
||||
<label>
|
||||
Timeout (ms):
|
||||
<input type="number" name="timeout_ms" value="${step.parameters.timeout_ms || 5000}" />
|
||||
</label>
|
||||
<button type="submit">Mettre à jour</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
if (actionType === 'keyboard_shortcut') {
|
||||
return `
|
||||
<form class="params-form">
|
||||
<label>
|
||||
Touches (séparées par +):
|
||||
<input type="text" name="keys" value="${escapeHtml((step.parameters.keys as string[] || []).join('+'))}" />
|
||||
</label>
|
||||
<button type="submit">Mettre à jour</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
return '<p class="no-params">Pas de paramètres supplémentaires</p>';
|
||||
}
|
||||
|
||||
function renderAnchor(step: Step): string {
|
||||
const anchor = step.anchor!;
|
||||
return `
|
||||
<div class="anchor-info">
|
||||
<h5>Ancre visuelle</h5>
|
||||
${anchor.thumbnail_url ? `<img src="${anchor.thumbnail_url}" alt="Ancre" class="anchor-thumb" />` : ''}
|
||||
<p>${anchor.description || 'Aucune description'}</p>
|
||||
<p class="coords">Position: (${anchor.bounding_box?.x}, ${anchor.bounding_box?.y})</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNeedAnchor(step: Step): string {
|
||||
const needsAnchor = ['click_anchor', 'double_click_anchor', 'right_click_anchor', 'wait_for_anchor', 'scroll_to_anchor']
|
||||
.includes(step.action_type);
|
||||
|
||||
if (!needsAnchor) return '';
|
||||
|
||||
return `
|
||||
<div class="need-anchor">
|
||||
<p class="warning">Cette étape nécessite une ancre visuelle</p>
|
||||
<button id="btn-select-anchor">Capturer et sélectionner</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderExecution(execution: Execution | null): void {
|
||||
if (!execution) {
|
||||
$executionStatus.innerHTML = `
|
||||
<p>Prêt à exécuter</p>
|
||||
<div class="exec-buttons">
|
||||
<button id="btn-start" class="primary">Démarrer</button>
|
||||
</div>
|
||||
`;
|
||||
// Attacher l'événement au nouveau bouton
|
||||
document.getElementById('btn-start')?.addEventListener('click', () => {
|
||||
console.log('Démarrer cliqué');
|
||||
callbacks.onStartExecution();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
'pending': 'En attente',
|
||||
'running': 'En cours',
|
||||
'paused': 'En pause',
|
||||
'completed': 'Terminé',
|
||||
'error': 'Erreur',
|
||||
'cancelled': 'Annulé'
|
||||
};
|
||||
|
||||
$executionStatus.innerHTML = `
|
||||
<div class="exec-info">
|
||||
<p class="status status-${execution.status}">${statusLabels[execution.status]}</p>
|
||||
<div class="progress-bar">
|
||||
<div class="progress" style="width: ${execution.progress}%"></div>
|
||||
</div>
|
||||
<p class="progress-text">${execution.completed_steps}/${execution.total_steps} étapes</p>
|
||||
${execution.error_message ? `<p class="error">${escapeHtml(execution.error_message)}</p>` : ''}
|
||||
</div>
|
||||
<div class="exec-buttons">
|
||||
${execution.status === 'running' ? '<button id="btn-pause">Pause</button>' : ''}
|
||||
${execution.status === 'paused' ? '<button id="btn-resume">Reprendre</button>' : ''}
|
||||
${['running', 'paused'].includes(execution.status) ? '<button id="btn-stop" class="danger">Arrêter</button>' : ''}
|
||||
${['completed', 'error', 'cancelled'].includes(execution.status) ? '<button id="btn-start" class="primary">Relancer</button>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Re-attacher les événements
|
||||
document.getElementById('btn-start')?.addEventListener('click', () => callbacks.onStartExecution());
|
||||
document.getElementById('btn-pause')?.addEventListener('click', () => callbacks.onPauseExecution());
|
||||
document.getElementById('btn-resume')?.addEventListener('click', () => callbacks.onResumeExecution());
|
||||
document.getElementById('btn-stop')?.addEventListener('click', () => callbacks.onStopExecution());
|
||||
}
|
||||
|
||||
export function renderCapture(capture: Capture, addToLib: boolean = true): void {
|
||||
currentFullscreenCapture = capture;
|
||||
|
||||
$capturePreview.innerHTML = `
|
||||
<div class="capture-container">
|
||||
<img src="data:image/png;base64,${capture.screenshot_base64}"
|
||||
alt="Capture d'écran"
|
||||
id="capture-image" />
|
||||
<div id="selection-overlay"></div>
|
||||
</div>
|
||||
<p class="capture-info">${capture.width}x${capture.height} - Dessinez un rectangle ou utilisez le mode plein écran</p>
|
||||
`;
|
||||
|
||||
// Afficher le bouton plein écran
|
||||
const $btnFullscreen = document.getElementById('btn-fullscreen');
|
||||
if ($btnFullscreen) {
|
||||
$btnFullscreen.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Ajouter à la bibliothèque (sauf si on charge depuis la bibliothèque)
|
||||
if (addToLib) {
|
||||
addToLibrary(capture);
|
||||
}
|
||||
|
||||
// Attendre que l'image soit chargée
|
||||
const img = document.getElementById('capture-image') as HTMLImageElement;
|
||||
if (img) {
|
||||
img.onload = () => {
|
||||
console.log('Image chargée, setup selection tool');
|
||||
setupSelectionTool();
|
||||
};
|
||||
// Si déjà chargée (cache)
|
||||
if (img.complete) {
|
||||
console.log('Image déjà en cache');
|
||||
setupSelectionTool();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setupSelectionTool(): void {
|
||||
const img = document.getElementById('capture-image') as HTMLImageElement;
|
||||
const overlay = document.getElementById('selection-overlay')!;
|
||||
const container = img?.parentElement;
|
||||
|
||||
if (!img || !overlay || !container) {
|
||||
console.error('Selection tool: éléments manquants', { img: !!img, overlay: !!overlay, container: !!container });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Selection tool initialisé');
|
||||
|
||||
let isSelecting = false;
|
||||
let startX = 0, startY = 0;
|
||||
let imgRect: DOMRect;
|
||||
|
||||
// Mousedown sur l'image pour démarrer la sélection
|
||||
img.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
imgRect = img.getBoundingClientRect();
|
||||
startX = e.clientX - imgRect.left;
|
||||
startY = e.clientY - imgRect.top;
|
||||
isSelecting = true;
|
||||
|
||||
overlay.style.display = 'block';
|
||||
overlay.style.left = startX + 'px';
|
||||
overlay.style.top = startY + 'px';
|
||||
overlay.style.width = '0';
|
||||
overlay.style.height = '0';
|
||||
|
||||
console.log('Sélection démarrée à', startX, startY);
|
||||
});
|
||||
|
||||
// Mousemove sur le document pour suivre même en dehors de l'image
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isSelecting) return;
|
||||
|
||||
const currentX = e.clientX - imgRect.left;
|
||||
const currentY = e.clientY - imgRect.top;
|
||||
|
||||
const width = currentX - startX;
|
||||
const height = currentY - startY;
|
||||
|
||||
overlay.style.left = (width < 0 ? currentX : startX) + 'px';
|
||||
overlay.style.top = (height < 0 ? currentY : startY) + 'px';
|
||||
overlay.style.width = Math.abs(width) + 'px';
|
||||
overlay.style.height = Math.abs(height) + 'px';
|
||||
});
|
||||
|
||||
// Mouseup sur le document pour terminer
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (!isSelecting) return;
|
||||
isSelecting = false;
|
||||
|
||||
const endX = e.clientX - imgRect.left;
|
||||
const endY = e.clientY - imgRect.top;
|
||||
|
||||
// Calculer les coordonnées réelles (ratio image affichée / taille réelle)
|
||||
const scaleX = img.naturalWidth / img.width;
|
||||
const scaleY = img.naturalHeight / img.height;
|
||||
|
||||
const bbox = {
|
||||
x: Math.round(Math.min(startX, endX) * scaleX),
|
||||
y: Math.round(Math.min(startY, endY) * scaleY),
|
||||
width: Math.round(Math.abs(endX - startX) * scaleX),
|
||||
height: Math.round(Math.abs(endY - startY) * scaleY)
|
||||
};
|
||||
|
||||
console.log('Sélection terminée, bbox:', bbox);
|
||||
|
||||
if (bbox.width > 10 && bbox.height > 10) {
|
||||
console.log('Envoi de la sélection au backend...');
|
||||
// Envoyer la capture courante avec la sélection
|
||||
const screenshotBase64 = currentFullscreenCapture?.screenshot_base64;
|
||||
callbacks.onSelectAnchor(bbox, screenshotBase64);
|
||||
} else {
|
||||
console.log('Sélection trop petite, ignorée');
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export function showError(message: string): void {
|
||||
alert(`Erreur: ${message}`);
|
||||
}
|
||||
|
||||
export function showInfo(message: string): void {
|
||||
console.log(`Info: ${message}`);
|
||||
}
|
||||
830
visual_workflow_builder/frontend_v3/styles.css
Normal file
830
visual_workflow_builder/frontend_v3/styles.css
Normal file
@@ -0,0 +1,830 @@
|
||||
/* VWB v3 - Thin Client Styles */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #16213e;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr 320px 300px;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Formulaire création workflow */
|
||||
#create-workflow-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#create-workflow-form input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
#create-workflow-form button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Liste workflows */
|
||||
#workflow-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.workflow-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.workflow-item:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.workflow-item.active {
|
||||
background: #0f3460;
|
||||
border-left: 3px solid #e94560;
|
||||
}
|
||||
|
||||
.workflow-item .name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.workflow-item .count {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.workflow-item .delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.workflow-item .delete-btn:hover {
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
/* Zone centrale */
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Section capture */
|
||||
.capture-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capture-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.capture-header h4 {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #0f3460;
|
||||
color: #94a3b8;
|
||||
border: 1px solid #1a5276;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: #1a5276;
|
||||
}
|
||||
|
||||
#capture-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#capture-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: #16213e;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.toolbar h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #0f3460;
|
||||
color: white;
|
||||
border: 1px solid #1a5276;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #1a5276;
|
||||
border-color: #2471a3;
|
||||
}
|
||||
|
||||
/* Liste des étapes */
|
||||
.steps-container {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid #0f3460;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.steps-container h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: #94a3b8;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #16213e;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.step-item:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.step-item.selected {
|
||||
border-color: #e94560;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.step-item .order {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #0f3460;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-item .type {
|
||||
font-weight: 500;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.step-item .label {
|
||||
flex: 1;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.step-item .anchor-badge {
|
||||
font-size: 0.625rem;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Drag & Drop */
|
||||
.step-item .drag-handle {
|
||||
cursor: grab;
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.step-item .drag-handle:hover {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.step-item .drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.step-item.dragging {
|
||||
opacity: 0.5;
|
||||
border: 2px dashed #3498db;
|
||||
}
|
||||
|
||||
.step-item.drag-over {
|
||||
border-top: 3px solid #e94560;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.step-item[draggable="true"] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Bouton d'insertion entre les étapes */
|
||||
.insert-step-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
margin: 2px 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.insert-step-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.insert-step-btn .insert-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.insert-step-btn:hover .insert-icon {
|
||||
background: #2980b9;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Menu contextuel d'insertion */
|
||||
.insert-menu {
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.insert-menu-header {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.insert-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #eee;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.insert-menu-item:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
/* Zone capture */
|
||||
.capture-zone {
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid #0f3460;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.capture-btn:hover {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
#capture-preview {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#capture-preview .capture-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#capture-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 250px;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
cursor: crosshair;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
#selection-overlay {
|
||||
position: absolute;
|
||||
border: 2px dashed #e94560;
|
||||
background: rgba(233, 69, 96, 0.3);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.capture-info {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.capture-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.capture-zone h4 {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.capture-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timer-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timer-control label {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.timer-control select {
|
||||
padding: 0.5rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.capture-btn.secondary {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.capture-btn.secondary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.timer-countdown {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: #e94560;
|
||||
padding: 2rem;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.timer-countdown.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.fullscreen-btn {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #0f3460;
|
||||
color: white;
|
||||
border: 1px solid #1a5276;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fullscreen-btn:hover {
|
||||
background: #1a5276;
|
||||
}
|
||||
|
||||
.fullscreen-btn.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Bibliothèque de captures */
|
||||
.capture-library {
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.capture-library h5 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#library-count {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
#capture-library-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.library-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.library-item:hover {
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.library-item.selected {
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.library-item img {
|
||||
width: 60px;
|
||||
height: 45px;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.library-item .delete-lib {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-item:hover .delete-lib {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Modal plein écran */
|
||||
.fullscreen-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fullscreen-modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fullscreen-modal .close-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.fullscreen-modal .modal-content {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.fullscreen-modal img {
|
||||
max-width: 100%;
|
||||
max-height: 85vh;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.fullscreen-modal #fullscreen-overlay {
|
||||
position: absolute;
|
||||
border: 3px dashed #e94560;
|
||||
background: rgba(233, 69, 96, 0.3);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fullscreen-modal .instructions {
|
||||
color: #94a3b8;
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Panneau étape sélectionnée */
|
||||
#selected-step {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.step-details h4 {
|
||||
margin-bottom: 0.75rem;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.step-details p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.params-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.params-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.params-form input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 4px;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.params-form button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anchor-info {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.anchor-info h5 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.anchor-thumb {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.need-anchor {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #e74c3c;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Panneau exécution */
|
||||
#execution-status {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.exec-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-running { color: #3498db; }
|
||||
.status-paused { color: #f39c12; }
|
||||
.status-completed { color: #27ae60; }
|
||||
.status-error { color: #e74c3c; }
|
||||
.status-cancelled { color: #95a5a6; }
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
background: #3498db;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.exec-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exec-buttons button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.exec-buttons .primary {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.exec-buttons .danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Utilitaires */
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
14
visual_workflow_builder/frontend_v3/tsconfig.json
Normal file
14
visual_workflow_builder/frontend_v3/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
13
visual_workflow_builder/frontend_v3/vite.config.ts
Normal file
13
visual_workflow_builder/frontend_v3/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user