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