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:
Dom
2026-01-23 12:07:13 +01:00
parent a9a53991bc
commit 858e6007f9
23 changed files with 6813 additions and 6 deletions

View 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']

View 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

View 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

View 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

View 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

View File

@@ -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(

View 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'

View 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())

View 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')
)

View 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')
)

View 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')
)

View 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'
]

View 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

View 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>

View 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
}
}
}
}
}

View 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"
}
}

View 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}`);
}

View 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);

View 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'
};

View 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}`);
}

View 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;
}

View 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/**/*"]
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:5001',
changeOrigin: true
}
}
}
})