diff --git a/visual_workflow_builder/backend/api/workflows.py b/visual_workflow_builder/backend/api/workflows.py new file mode 100644 index 000000000..aeef17da8 --- /dev/null +++ b/visual_workflow_builder/backend/api/workflows.py @@ -0,0 +1,716 @@ +""" +Workflows API Blueprint + +Provides REST endpoints for workflow CRUD operations. +""" + +from flask import Blueprint, request, jsonify +from datetime import datetime +from typing import Dict, List, Any +import traceback + +from models import ( + VisualWorkflow, + VisualNode, + VisualEdge, + Variable, + WorkflowSettings, + generate_id +) +from services.serialization import ( + WorkflowSerializer, + WorkflowDatabase, + SerializationError, + ValidationError as SerializationValidationError, + create_empty_workflow +) +from services.converter import ( + VisualToGraphConverter, + ConversionError, + convert_visual_to_graph +) +from services.execution_integration import ( + get_executor, + ExecutionStatus +) +from services.learning_integration import ( + register_workflow_for_learning, + record_workflow_execution, + get_workflow_learning_state, + get_workflow_stats +) +from .errors import ( + ValidationError, + NotFoundError, + BadRequestError, + error_response +) +from .validation import validate_workflow_data, validate_update_data + +workflows_bp = Blueprint('workflows', __name__) + +# Database instance for persistent storage +db = WorkflowDatabase("data/workflows") + +# Keep in-memory store for backward compatibility (will be removed later) +workflows_store: Dict[str, VisualWorkflow] = {} + +def _refresh_store_from_db() -> None: + """Synchronise le cache mémoire depuis le stockage persistant.""" + try: + workflows = db.list() + workflows_store.clear() + for w in workflows: + workflows_store[w.id] = w + except Exception: + # Ne jamais empêcher le serveur de démarrer si le disque est indisponible. + pass + + +def _get_workflow_cached(workflow_id: str) -> VisualWorkflow | None: + """Retourne le workflow depuis le cache, sinon tente de le charger depuis la DB.""" + if workflow_id in workflows_store: + return workflows_store[workflow_id] + try: + w = db.load(workflow_id) + except Exception: + w = None + if w is not None: + workflows_store[workflow_id] = w + return w + + +# Warmup cache on import (best-effort) +_refresh_store_from_db() + + +@workflows_bp.route('/', methods=['GET']) +def list_workflows(): + """ + List all workflows + + Query parameters: + - category: Filter by category + - is_template: Filter templates (true/false) + - tags: Comma-separated list of tags + """ + try: + # Get query parameters + category = request.args.get('category') + is_template = request.args.get('is_template') + tags = request.args.get('tags') + + # Refresh cache from DB (source of truth) + _refresh_store_from_db() + # Filter workflows + workflows = list(workflows_store.values()) + + if category: + workflows = [w for w in workflows if w.category == category] + + if is_template is not None: + is_template_bool = is_template.lower() == 'true' + workflows = [w for w in workflows if w.is_template == is_template_bool] + + if tags: + tag_list = [t.strip() for t in tags.split(',')] + workflows = [w for w in workflows if any(tag in w.tags for tag in tag_list)] + + # Serialize workflows + result = [w.to_dict() for w in workflows] + + return jsonify(result), 200 + + except Exception as e: + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/', methods=['POST']) +def create_workflow(): + """ + Create a new workflow + + Request body: + { + "name": "Workflow Name", + "description": "Optional description", + "created_by": "user_id", + "nodes": [], + "edges": [], + "variables": [], + "settings": {}, + "tags": [], + "category": "optional" + } + """ + try: + data = request.get_json(force=True, silent=True) + + if data is None: + raise BadRequestError("Request body is required") + + # Validate required fields + if 'name' not in data: + raise ValidationError("Field 'name' is required") + if 'created_by' not in data: + raise ValidationError("Field 'created_by' is required") + + # Validate workflow data + validate_workflow_data(data) + + # Create workflow with auto-generated ID + workflow = create_empty_workflow( + name=data['name'], + description=data.get('description', ''), + created_by=data['created_by'] + ) + + # Add optional data + if 'nodes' in data: + workflow.nodes = [VisualNode.from_dict(n) for n in data['nodes']] + if 'edges' in data: + workflow.edges = [VisualEdge.from_dict(e) for e in data['edges']] + if 'variables' in data: + workflow.variables = [Variable.from_dict(v) for v in data['variables']] + if 'settings' in data: + workflow.settings = WorkflowSettings.from_dict(data['settings']) + if 'tags' in data: + workflow.tags = data['tags'] + if 'category' in data: + workflow.category = data['category'] + if 'is_template' in data: + workflow.is_template = data['is_template'] + + # Validate workflow structure + errors = workflow.validate() + if errors: + raise ValidationError(f"Workflow validation failed: {', '.join(errors)}") + + # Save to database + db.save(workflow) + + # Also store in memory for backward compatibility + workflows_store[workflow.id] = workflow + + # Enregistrer dans le système d'apprentissage + register_workflow_for_learning(workflow) + + return jsonify(workflow.to_dict()), 201 + + except ValidationError as e: + return error_response(400, str(e)) + except BadRequestError as e: + return error_response(400, str(e)) + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/', methods=['GET']) +def get_workflow(workflow_id: str): + """Get a specific workflow by ID""" + try: + workflow = _get_workflow_cached(workflow_id) + if workflow is None: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + return jsonify(workflow.to_dict()), 200 + + except NotFoundError as e: + return error_response(404, str(e)) + except Exception as e: + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/', methods=['PUT']) +def update_workflow(workflow_id: str): + """ + Update a workflow + + Request body can include any of: + - name + - description + - nodes + - edges + - variables + - settings + - tags + - category + """ + try: + workflow = _get_workflow_cached(workflow_id) + if workflow is None: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + + data = request.get_json() + if not data: + raise BadRequestError("Request body is required") + + # Validate update data + validate_update_data(data) + + # Get existing workflow + workflow_dict = workflow.to_dict() + + # Update fields + if 'name' in data: + workflow_dict['name'] = data['name'] + if 'description' in data: + workflow_dict['description'] = data['description'] + if 'nodes' in data: + workflow_dict['nodes'] = data['nodes'] + if 'edges' in data: + workflow_dict['edges'] = data['edges'] + if 'variables' in data: + workflow_dict['variables'] = data['variables'] + if 'settings' in data: + workflow_dict['settings'] = data['settings'] + if 'tags' in data: + workflow_dict['tags'] = data['tags'] + if 'category' in data: + workflow_dict['category'] = data['category'] + + # Update timestamp + workflow_dict['updated_at'] = datetime.now().isoformat() + + # Create updated workflow + updated_workflow = VisualWorkflow.from_dict(workflow_dict) + + # Validate + errors = updated_workflow.validate() + if errors: + raise ValidationError(f"Workflow validation failed: {', '.join(errors)}") + + # Persist + cache + db.save(updated_workflow) + workflows_store[workflow_id] = updated_workflow + + # Mettre à jour le système d'apprentissage + register_workflow_for_learning(updated_workflow) + + return jsonify(updated_workflow.to_dict()), 200 + + except NotFoundError as e: + return error_response(404, str(e)) + except ValidationError as e: + return error_response(400, str(e)) + except BadRequestError as e: + return error_response(400, str(e)) + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/', methods=['DELETE']) +def delete_workflow(workflow_id: str): + """Delete a workflow""" + try: + if not db.exists(workflow_id) and workflow_id not in workflows_store: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + + # Delete from persistent storage first + if db.exists(workflow_id): + db.delete(workflow_id) + + workflows_store.pop(workflow_id, None) + return '', 204 + + except NotFoundError as e: + return error_response(404, str(e)) + except Exception as e: + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('//validate', methods=['POST']) +def validate_workflow(workflow_id: str): + """ + Validate a workflow structure + + Returns validation errors and warnings + """ + try: + workflow = _get_workflow_cached(workflow_id) + if workflow is None: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + errors = workflow.validate() + + # Check for disconnected nodes (warnings) + warnings = [] + if len(workflow.nodes) > 1: + connected_nodes = set() + for edge in workflow.edges: + connected_nodes.add(edge.source) + connected_nodes.add(edge.target) + + for node in workflow.nodes: + if node.id not in connected_nodes: + warnings.append(f"Node '{node.id}' is not connected to any other nodes") + + return jsonify({ + 'is_valid': len(errors) == 0, + 'errors': errors, + 'warnings': warnings + }), 200 + + except NotFoundError as e: + return error_response(404, str(e)) + except Exception as e: + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('//convert', methods=['POST']) +def convert_workflow(workflow_id: str): + """ + Convert a visual workflow to WorkflowGraph format + + Returns the converted workflow in WorkflowGraph format + """ + try: + # Load workflow + workflow = db.load(workflow_id) + if workflow is None: + if workflow_id not in workflows_store: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + workflow = workflows_store[workflow_id] + + # Convert to WorkflowGraph + converter = VisualToGraphConverter() + workflow_graph = converter.convert(workflow) + + # Get warnings if any + warnings = converter.get_warnings() + + return jsonify({ + 'workflow_graph': workflow_graph.to_dict(), + 'warnings': warnings, + 'message': 'Conversion successful' + }), 200 + + except NotFoundError as e: + return error_response(404, str(e)) + except ConversionError as e: + return error_response(400, f"Conversion error: {str(e)}") + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('//execute', methods=['POST']) +def execute_workflow(workflow_id: str): + """ + Execute a visual workflow via ExecutionLoop + + Body parameters: + - variables: Dict of input variables (optional) + + Exigence: 20.1 + """ + try: + # Check if workflow exists + if not db.exists(workflow_id) and workflow_id not in workflows_store: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + + # Get request data + data = request.get_json() or {} + variables = data.get('variables', {}) + + # Execute workflow + executor = get_executor() + execution_id = executor.execute_workflow( + workflow_id=workflow_id, + variables=variables + ) + + return jsonify({ + 'execution_id': execution_id, + 'status': ExecutionStatus.PENDING, + 'message': 'Workflow execution started', + 'workflow_id': workflow_id + }), 202 + + except NotFoundError as e: + return error_response(404, str(e)) + except ValueError as e: + return error_response(400, str(e)) + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/executions/', methods=['GET']) +def get_execution_status(execution_id: str): + """ + Get the status of a workflow execution + + Exigence: 20.1 + """ + try: + executor = get_executor() + result = executor.get_execution_status(execution_id) + + if result is None: + return error_response(404, f"Execution '{execution_id}' not found") + + return jsonify(result.to_dict()), 200 + + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/executions/', methods=['DELETE']) +def cancel_execution(execution_id: str): + """ + Cancel a running workflow execution + + Exigence: 20.1 + """ + try: + executor = get_executor() + cancelled = executor.cancel_execution(execution_id) + + if not cancelled: + return error_response(400, "Execution cannot be cancelled (not running or not found)") + + return jsonify({ + 'message': 'Execution cancelled successfully', + 'execution_id': execution_id + }), 200 + + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/executions', methods=['GET']) +def list_executions(): + """ + List workflow executions + + Query parameters: + - workflow_id: Filter by workflow ID (optional) + - limit: Maximum number of results (default: 50) + + Exigence: 20.1 + """ + try: + workflow_id = request.args.get('workflow_id') + limit = int(request.args.get('limit', 50)) + + executor = get_executor() + executions = executor.list_executions(workflow_id=workflow_id) + + # Apply limit + if limit > 0: + executions = executions[:limit] + + return jsonify({ + 'executions': executions, + 'total': len(executions) + }), 200 + + except ValueError as e: + return error_response(400, f"Invalid parameter: {str(e)}") + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('//export', methods=['GET']) +def export_workflow(workflow_id: str): + """ + Export a workflow in JSON or YAML format + + Query parameters: + - format: 'json' or 'yaml' (default: 'json') + """ + try: + # Load from database + workflow = db.load(workflow_id) + if workflow is None: + # Fallback to memory store + if workflow_id not in workflows_store: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + workflow = workflows_store[workflow_id] + + # Get format from query params + export_format = request.args.get('format', 'json').lower() + if export_format not in ['json', 'yaml']: + raise BadRequestError(f"Invalid format: {export_format}. Use 'json' or 'yaml'") + + # Serialize + serialized = WorkflowSerializer.serialize(workflow, format=export_format) + + # Return with appropriate content type + if export_format == 'json': + return serialized, 200, {'Content-Type': 'application/json'} + else: + return serialized, 200, {'Content-Type': 'application/x-yaml'} + + except NotFoundError as e: + return error_response(404, str(e)) + except BadRequestError as e: + return error_response(400, str(e)) + except SerializationError as e: + return error_response(500, f"Serialization error: {str(e)}") + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('/import', methods=['POST']) +def import_workflow(): + """ + Import a workflow from JSON or YAML + + Query parameters: + - format: 'json' or 'yaml' (default: 'json') + - generate_new_id: 'true' or 'false' (default: 'false') + """ + try: + # Get format from query params + import_format = request.args.get('format', 'json').lower() + generate_new_id = request.args.get('generate_new_id', 'false').lower() == 'true' + + if import_format not in ['json', 'yaml']: + raise BadRequestError(f"Invalid format: {import_format}. Use 'json' or 'yaml'") + + # Get raw data + if import_format == 'json': + data = request.get_data(as_text=True) + else: + data = request.get_data(as_text=True) + + if not data: + raise BadRequestError("Request body is required") + + # Deserialize + workflow = WorkflowSerializer.deserialize(data, format=import_format) + + # Generate new ID if requested + if generate_new_id: + old_id = workflow.id + workflow.id = WorkflowSerializer.generate_workflow_id() + workflow.created_at = datetime.now() + workflow.updated_at = datetime.now() + print(f"Generated new ID: {old_id} -> {workflow.id}") + + # Check if workflow already exists + if db.exists(workflow.id): + raise BadRequestError(f"Workflow with ID '{workflow.id}' already exists. Use generate_new_id=true to create a copy.") + + # Save to database + db.save(workflow) + workflows_store[workflow.id] = workflow + + return jsonify({ + 'message': 'Workflow imported successfully', + 'workflow': workflow.to_dict() + }), 201 + + except BadRequestError as e: + return error_response(400, str(e)) + except SerializationValidationError as e: + return error_response(400, f"Validation error: {', '.join(e.errors)}") + except SerializationError as e: + return error_response(400, f"Import error: {str(e)}") + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('//feedback', methods=['POST']) +def submit_workflow_feedback(workflow_id: str): + """ + Submit user feedback for a workflow execution. + + Body: + { + "execution_id": "exec_xxx", + "success": true/false, + "confidence": 0.0-1.0 (optional), + "comment": "optional comment" + } + """ + try: + # Vérifier que le workflow existe + workflow = _get_workflow_cached(workflow_id) + if workflow is None: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + + data = request.get_json() + if not data: + raise BadRequestError("Request body is required") + + if 'success' not in data: + raise ValidationError("Field 'success' is required") + + success = bool(data['success']) + # Confiance élevée si l'utilisateur confirme, plus basse s'il corrige + confidence = data.get('confidence', 0.95 if success else 0.3) + + # Enregistrer le feedback dans le système d'apprentissage + recorded = record_workflow_execution( + workflow_id=workflow_id, + success=success, + confidence=confidence + ) + + # Récupérer l'état mis à jour + new_state = get_workflow_learning_state(workflow_id) + stats = get_workflow_stats(workflow_id) + + return jsonify({ + 'message': 'Feedback enregistré', + 'workflow_id': workflow_id, + 'feedback_recorded': recorded, + 'learning_state': new_state, + 'stats': stats + }), 200 + + except NotFoundError as e: + return error_response(404, str(e)) + except (ValidationError, BadRequestError) as e: + return error_response(400, str(e)) + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") + + +@workflows_bp.route('//learning', methods=['GET']) +def get_workflow_learning_info(workflow_id: str): + """ + Get learning state and statistics for a workflow + + Returns learning state, execution count, success rate, etc. + """ + try: + # Vérifier que le workflow existe + workflow = _get_workflow_cached(workflow_id) + if workflow is None: + raise NotFoundError(f"Workflow '{workflow_id}' not found") + + # Récupérer les stats d'apprentissage + stats = get_workflow_stats(workflow_id) + state = get_workflow_learning_state(workflow_id) + + if stats is None: + # Workflow pas encore dans le système d'apprentissage + return jsonify({ + 'workflow_id': workflow_id, + 'learning_state': 'NOT_REGISTERED', + 'message': 'Ce workflow n\'a pas encore été enregistré dans le système d\'apprentissage', + 'stats': None + }), 200 + + return jsonify({ + 'workflow_id': workflow_id, + 'learning_state': state, + 'stats': stats, + 'can_auto_execute': state in ['AUTO_CANDIDATE', 'AUTO_CONFIRMED'] + }), 200 + + except NotFoundError as e: + return error_response(404, str(e)) + except Exception as e: + traceback.print_exc() + return error_response(500, f"Internal server error: {str(e)}") diff --git a/visual_workflow_builder/backend/app_lightweight.py b/visual_workflow_builder/backend/app_lightweight.py new file mode 100644 index 000000000..fcd6e8c38 --- /dev/null +++ b/visual_workflow_builder/backend/app_lightweight.py @@ -0,0 +1,1452 @@ +#!/usr/bin/env python3 +""" +Visual Workflow Builder - Backend Flask Application (Version Allégée) + +Auteur : Dom, Alice, Kiro - 09 janvier 2026 + +Version optimisée pour un démarrage rapide avec uniquement les fonctionnalités essentielles. +Cette version évite les imports lourds et les dépendances optionnelles. + +Fonctionnalités : +- API REST pour la gestion des workflows +- Capture d'écran via ScreenCapturer (core/capture) +- Création d'embeddings visuels via CLIPEmbedder (core/embedding) +""" + +import json +import os +import sys +import base64 +import io +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional + +# Ajouter le répertoire racine au path pour les imports core +ROOT_DIR = Path(__file__).parent.parent.parent +sys.path.insert(0, str(ROOT_DIR)) +sys.path.insert(0, str(Path(__file__).parent)) + +# Import minimal sans dépendances lourdes +try: + from http.server import HTTPServer, BaseHTTPRequestHandler + from urllib.parse import urlparse, parse_qs + import socketserver + USE_FLASK = False + print("⚡ Mode serveur HTTP natif (sans Flask)") +except ImportError: + USE_FLASK = True + print("🔄 Tentative d'utilisation de Flask...") + +# Import du service de capture d'écran réelle +try: + from services.real_screen_capture import real_capture_service + REAL_CAPTURE_AVAILABLE = True + print("✅ Service de capture d'écran réelle disponible") +except ImportError as e: + REAL_CAPTURE_AVAILABLE = False + real_capture_service = None + print(f"⚠️ Service de capture d'écran réelle non disponible: {e}") + +# Import des routes du catalogue VWB +try: + from catalog_routes import register_catalog_routes + CATALOG_ROUTES_AVAILABLE = True + print("✅ Routes du catalogue VWB disponibles") +except ImportError as e: + CATALOG_ROUTES_AVAILABLE = False + register_catalog_routes = None + print(f"⚠️ Routes du catalogue VWB non disponibles: {e}") + +# ============================================================================ +# Services de capture d'écran et d'embedding +# ============================================================================ + +# Instance globale du capturer (initialisée à la demande) +_screen_capturer = None +_clip_embedder = None + + +def get_screen_capturer(): + """ + Obtenir l'instance du ScreenCapturer (initialisation paresseuse). + + Returns: + ScreenCapturer ou None si non disponible + """ + global _screen_capturer + if _screen_capturer is None: + try: + # Vérifier les dépendances de capture d'écran + try: + import mss + print("✅ mss disponible") + except ImportError: + print("❌ mss non disponible") + + try: + import pyautogui + print("✅ pyautogui disponible") + except ImportError: + print("❌ pyautogui non disponible") + + from core.capture import ScreenCapturer + _screen_capturer = ScreenCapturer(buffer_size=5, detect_changes=False) + print(f"✅ ScreenCapturer initialisé avec succès - méthode: {_screen_capturer.method}") + except ImportError as e: + print(f"⚠️ ScreenCapturer non disponible: {e}") + return None + except Exception as e: + print(f"❌ Erreur initialisation ScreenCapturer: {e}") + return None + return _screen_capturer + + +def get_clip_embedder(): + """ + Obtenir l'instance du CLIPEmbedder (initialisation paresseuse). + + Returns: + CLIPEmbedder ou None si non disponible + """ + global _clip_embedder + if _clip_embedder is None: + try: + from core.embedding import create_clip_embedder + _clip_embedder = create_clip_embedder(device="cpu") + print("✅ CLIPEmbedder initialisé avec succès") + except ImportError as e: + print(f"⚠️ CLIPEmbedder non disponible: {e}") + return None + except Exception as e: + print(f"❌ Erreur initialisation CLIPEmbedder: {e}") + return None + return _clip_embedder + + +def capture_screen_to_base64() -> Dict[str, Any]: + """ + Capture l'écran et retourne l'image en base64 (version ultra stable - Option A). + + Returns: + Dict avec 'success', 'screenshot' (base64), 'width', 'height', ou 'error' + """ + try: + # Utiliser directement le ScreenCapturer corrigé (Option A - ultra stable) + capturer = get_screen_capturer() + if capturer is None: + return { + 'success': False, + 'error': 'Service de capture d\'écran non disponible' + } + + from PIL import Image + import numpy as np + + # Capturer l'écran avec la méthode ultra stable + img_array = capturer.capture() + if img_array is None: + return { + 'success': False, + 'error': 'Échec de la capture d\'écran' + } + + # Convertir en PIL Image + pil_image = Image.fromarray(img_array) + + # Convertir en base64 + buffer = io.BytesIO() + pil_image.save(buffer, format='PNG', optimize=True) + buffer.seek(0) + screenshot_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return { + 'success': True, + 'screenshot': screenshot_base64, + 'width': pil_image.width, + 'height': pil_image.height, + 'timestamp': datetime.now().isoformat(), + 'method': 'ultra_stable_mss' + } + + except Exception as e: + return { + 'success': False, + 'error': f'Erreur lors de la capture: {str(e)}' + } + + +def create_visual_embedding(screenshot_base64: str, bounding_box: Dict[str, int], step_id: str) -> Dict[str, Any]: + """ + Crée un embedding visuel à partir d'une capture d'écran et d'une zone sélectionnée. + + Args: + screenshot_base64: Image en base64 + bounding_box: Zone sélectionnée {'x', 'y', 'width', 'height'} + step_id: Identifiant de l'étape + + Returns: + Dict avec 'success', 'embedding', 'embedding_id', ou 'error' + """ + embedder = get_clip_embedder() + if embedder is None: + return { + 'success': False, + 'error': 'Service d\'embedding non disponible' + } + + try: + from PIL import Image + import numpy as np + + # Décoder l'image base64 + image_data = base64.b64decode(screenshot_base64) + pil_image = Image.open(io.BytesIO(image_data)) + + # Extraire la zone sélectionnée + x = bounding_box.get('x', 0) + y = bounding_box.get('y', 0) + width = bounding_box.get('width', 100) + height = bounding_box.get('height', 100) + + # Valider les coordonnées + x = max(0, min(x, pil_image.width - 1)) + y = max(0, min(y, pil_image.height - 1)) + width = max(10, min(width, pil_image.width - x)) + height = max(10, min(height, pil_image.height - y)) + + # Découper la zone + cropped_image = pil_image.crop((x, y, x + width, y + height)) + + # Créer l'embedding + embedding = embedder.embed_image(cropped_image) + + # Générer un ID unique pour l'embedding + embedding_id = f"emb_{step_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + # Sauvegarder l'embedding et l'image de référence + embeddings_dir = ROOT_DIR / "data" / "visual_embeddings" + embeddings_dir.mkdir(parents=True, exist_ok=True) + + # Sauvegarder l'embedding en numpy + embedding_path = embeddings_dir / f"{embedding_id}.npy" + np.save(str(embedding_path), embedding) + + # Sauvegarder l'image de référence + reference_path = embeddings_dir / f"{embedding_id}_ref.png" + cropped_image.save(str(reference_path)) + + return { + 'success': True, + 'embedding': embedding.tolist(), + 'embedding_id': embedding_id, + 'dimension': len(embedding), + 'reference_image': f"{embedding_id}_ref.png", + 'bounding_box': { + 'x': x, + 'y': y, + 'width': width, + 'height': height + } + } + + except Exception as e: + return { + 'success': False, + 'error': f'Erreur lors de la création de l\'embedding: {str(e)}' + } + +class WorkflowHandler(BaseHTTPRequestHandler): + """Gestionnaire HTTP simple pour les workflows.""" + + def __init__(self, *args, **kwargs): + self.workflows_db = WorkflowDatabase() + super().__init__(*args, **kwargs) + + def do_GET(self): + """Gère les requêtes GET.""" + parsed_path = urlparse(self.path) + path = parsed_path.path + + # Headers CORS + self.send_cors_headers() + + if path == '/health': + self.send_health_check() + elif path == '/': + self.send_index() + elif path.startswith('/api/workflows'): + self.handle_workflows_get(path) + else: + self.send_error(404, "Not Found") + + def do_POST(self): + """Gère les requêtes POST.""" + parsed_path = urlparse(self.path) + path = parsed_path.path + + self.send_cors_headers() + + if path.startswith('/api/workflows'): + self.handle_workflows_post(path) + else: + self.send_error(404, "Not Found") + + def do_OPTIONS(self): + """Gère les requêtes OPTIONS pour CORS.""" + self.send_cors_headers() + self.send_response(200) + self.end_headers() + + def send_cors_headers(self): + """Envoie les headers CORS.""" + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization') + + def send_json_response(self, data: Any, status_code: int = 200): + """Envoie une réponse JSON.""" + self.send_response(status_code) + self.send_header('Content-Type', 'application/json') + self.send_cors_headers() + self.end_headers() + + json_data = json.dumps(data, ensure_ascii=False, indent=2) + self.wfile.write(json_data.encode('utf-8')) + + def send_health_check(self): + """Endpoint de santé.""" + self.send_json_response({ + 'status': 'healthy', + 'version': '1.0.0-lightweight', + 'mode': 'native-http' + }) + + def send_index(self): + """Page d'accueil.""" + self.send_json_response({ + 'message': 'Visual Workflow Builder Backend (Version Allégée)', + 'version': '1.0.0-lightweight', + 'mode': 'native-http', + 'endpoints': ['/health', '/api/workflows'] + }) + + def handle_workflows_get(self, path: str): + """Gère les GET sur /api/workflows.""" + if path == '/api/workflows' or path == '/api/workflows/': + # Liste des workflows + try: + workflows = self.workflows_db.list_workflows() + self.send_json_response([w.to_dict() for w in workflows]) + except Exception as e: + self.send_json_response({'error': str(e)}, 500) + else: + # Workflow spécifique + workflow_id = path.split('/')[-1] + try: + workflow = self.workflows_db.get_workflow(workflow_id) + if workflow: + self.send_json_response(workflow.to_dict()) + else: + self.send_json_response({'error': 'Workflow not found'}, 404) + except Exception as e: + self.send_json_response({'error': str(e)}, 500) + + def handle_workflows_post(self, path: str): + """Gère les POST sur /api/workflows.""" + try: + content_length = int(self.headers.get('Content-Length', 0)) + if content_length > 0: + post_data = self.rfile.read(content_length) + data = json.loads(post_data.decode('utf-8')) + else: + data = {} + + if path == '/api/workflows' or path == '/api/workflows/': + # Créer un nouveau workflow + workflow = self.workflows_db.create_workflow(data) + self.send_json_response(workflow.to_dict(), 201) + else: + self.send_json_response({'error': 'Method not allowed'}, 405) + + except json.JSONDecodeError: + self.send_json_response({'error': 'Invalid JSON'}, 400) + except Exception as e: + self.send_json_response({'error': str(e)}, 500) + +class SimpleWorkflow: + """Modèle de workflow simplifié.""" + + def __init__(self, id: str, name: str, description: str = "", created_by: str = "unknown"): + self.id = id + self.name = name + self.description = description + self.created_by = created_by + self.created_at = datetime.now().isoformat() + self.updated_at = self.created_at + self.nodes = [] + self.edges = [] + self.variables = [] + self.settings = {} + self.tags = [] + self.category = "default" + self.is_template = False + + def to_dict(self) -> Dict[str, Any]: + """Convertit en dictionnaire.""" + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'created_by': self.created_by, + 'created_at': self.created_at, + 'updated_at': self.updated_at, + 'nodes': self.nodes, + 'edges': self.edges, + 'variables': self.variables, + 'settings': self.settings, + 'tags': self.tags, + 'category': self.category, + 'is_template': self.is_template + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'SimpleWorkflow': + """Crée depuis un dictionnaire.""" + workflow = cls( + id=data.get('id', f"wf_{datetime.now().strftime('%Y%m%d_%H%M%S')}"), + name=data.get('name', 'Sans titre'), + description=data.get('description', ''), + created_by=data.get('created_by', 'unknown') + ) + + workflow.nodes = data.get('nodes', []) + workflow.edges = data.get('edges', []) + workflow.variables = data.get('variables', []) + workflow.settings = data.get('settings', {}) + workflow.tags = data.get('tags', []) + workflow.category = data.get('category', 'default') + workflow.is_template = data.get('is_template', False) + + return workflow + +class WorkflowDatabase: + """Base de données simple pour les workflows.""" + + def __init__(self): + self.data_dir = Path("../../data/workflows") + self.data_dir.mkdir(parents=True, exist_ok=True) + print(f"📁 Base de données: {self.data_dir.absolute()}") + + def _get_file_path(self, workflow_id: str) -> Path: + """Retourne le chemin du fichier pour un workflow.""" + safe_id = "".join(c for c in workflow_id if c.isalnum() or c in ("_", "-")) + return self.data_dir / f"{safe_id}.json" + + def create_workflow(self, data: Dict[str, Any]) -> SimpleWorkflow: + """Crée un nouveau workflow.""" + if 'name' not in data: + raise ValueError("Le nom est requis") + + workflow = SimpleWorkflow.from_dict(data) + self.save_workflow(workflow) + return workflow + + def save_workflow(self, workflow: SimpleWorkflow): + """Sauvegarde un workflow.""" + file_path = self._get_file_path(workflow.id) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(workflow.to_dict(), f, ensure_ascii=False, indent=2) + + def get_workflow(self, workflow_id: str) -> Optional[SimpleWorkflow]: + """Récupère un workflow par ID.""" + file_path = self._get_file_path(workflow_id) + if not file_path.exists(): + return None + + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + return SimpleWorkflow.from_dict(data) + except Exception as e: + print(f"Erreur lecture workflow {workflow_id}: {e}") + return None + + def list_workflows(self) -> List[SimpleWorkflow]: + """Liste tous les workflows.""" + workflows = [] + for file_path in self.data_dir.glob("*.json"): + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + workflows.append(SimpleWorkflow.from_dict(data)) + except Exception as e: + print(f"Erreur lecture {file_path}: {e}") + + return workflows + +def start_native_server(port: int = 5002): + """Démarre le serveur HTTP natif.""" + print(f"🚀 Démarrage du serveur natif sur le port {port}") + print(f"🌐 URL: http://localhost:{port}") + print(f"❤️ Health check: http://localhost:{port}/health") + print(f"📋 API Workflows: http://localhost:{port}/api/workflows") + print("") + print("Appuyez sur Ctrl+C pour arrêter") + + try: + with socketserver.TCPServer(("", port), WorkflowHandler) as httpd: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n🛑 Arrêt du serveur") + except Exception as e: + print(f"❌ Erreur serveur: {e}") + +def start_flask_server(port: int = 5002): + """Démarre le serveur Flask si disponible.""" + try: + from flask import Flask, jsonify, request + from flask_cors import CORS + + app = Flask(__name__) + CORS(app, + origins="*", + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "Accept", "X-Requested-With"], + supports_credentials=False) + + # Enregistrer les routes du catalogue VWB si disponibles + if CATALOG_ROUTES_AVAILABLE and register_catalog_routes: + try: + register_catalog_routes(app) + print("✅ Routes du catalogue VWB enregistrées avec succès") + except Exception as e: + print(f"⚠️ Erreur lors de l'enregistrement des routes catalogue: {e}") + + # Gestionnaire global pour les requêtes OPTIONS (CORS preflight) + @app.before_request + def handle_preflight(): + if request.method == "OPTIONS": + response = jsonify({'status': 'OK'}) + response.headers.add("Access-Control-Allow-Origin", "*") + response.headers.add('Access-Control-Allow-Headers', "Content-Type,Authorization,Accept,X-Requested-With") + response.headers.add('Access-Control-Allow-Methods', "GET,PUT,POST,DELETE,OPTIONS") + return response + + db = WorkflowDatabase() + + @app.route('/health') + @app.route('/api/health') + def health_check(): + return jsonify({ + 'status': 'healthy', + 'version': '1.0.0-lightweight', + 'mode': 'flask', + 'features': { + 'screen_capture': get_screen_capturer() is not None, + 'visual_embedding': get_clip_embedder() is not None + } + }) + + @app.route('/') + def index(): + endpoints = [ + '/health', + '/api/workflows', + '/api/screen-capture', + '/api/visual-embedding', + '/api/real-screen-capture', + '/api/real-screen-capture/start', + '/api/real-screen-capture/stop', + '/api/real-screen-capture/status' + ] + + # Ajouter les endpoints du catalogue si disponibles + if CATALOG_ROUTES_AVAILABLE: + endpoints.extend([ + '/api/vwb/catalog/actions', + '/api/vwb/catalog/execute', + '/api/vwb/catalog/validate', + '/api/vwb/catalog/health' + ]) + + return jsonify({ + 'message': 'Visual Workflow Builder Backend (Version Allégée)', + 'version': '1.0.0-lightweight', + 'mode': 'flask', + 'endpoints': endpoints, + 'features': { + 'catalog_routes': CATALOG_ROUTES_AVAILABLE, + 'real_capture': REAL_CAPTURE_AVAILABLE + } + }) + + @app.route('/api/workflows', methods=['GET']) + def list_workflows(): + try: + workflows = db.list_workflows() + return jsonify([w.to_dict() for w in workflows]) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/workflows', methods=['POST']) + def create_workflow(): + try: + data = request.get_json() or {} + workflow = db.create_workflow(data) + + # Enregistrer dans le système d'apprentissage + try: + from services.learning_integration import register_workflow_for_learning + register_workflow_for_learning(workflow) + except ImportError: + pass # Module non disponible, on continue sans + + return jsonify(workflow.to_dict()), 201 + except Exception as e: + return jsonify({'error': str(e)}), 400 + + @app.route('/api/workflows/', methods=['GET']) + def get_workflow(workflow_id): + """ + Récupère un workflow spécifique par son ID. + """ + try: + workflow = db.get_workflow(workflow_id) + if workflow: + return jsonify(workflow.to_dict()) + else: + return jsonify({'error': 'Workflow not found'}), 404 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/workflows/', methods=['PUT']) + def update_workflow(workflow_id): + """ + Met à jour un workflow existant. + """ + try: + data = request.get_json() or {} + workflow = db.get_workflow(workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found'}), 404 + + # Mettre à jour les champs + workflow.name = data.get('name', workflow.name) + workflow.description = data.get('description', workflow.description) + workflow.nodes = data.get('nodes', workflow.nodes) + workflow.edges = data.get('edges', workflow.edges) + workflow.variables = data.get('variables', workflow.variables) + workflow.settings = data.get('settings', workflow.settings) + workflow.tags = data.get('tags', workflow.tags) + workflow.category = data.get('category', workflow.category) + workflow.is_template = data.get('is_template', workflow.is_template) + workflow.updated_at = datetime.now().isoformat() + + db.save_workflow(workflow) + return jsonify(workflow.to_dict()) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/workflows/', methods=['DELETE']) + def delete_workflow(workflow_id): + """ + Supprime un workflow. + """ + try: + workflow = db.get_workflow(workflow_id) + if not workflow: + return jsonify({'error': 'Workflow not found'}), 404 + + # Supprimer le fichier + file_path = db._get_file_path(workflow_id) + if file_path.exists(): + file_path.unlink() + + return jsonify({'success': True, 'message': 'Workflow supprimé'}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/workflow/execute', methods=['POST']) + def execute_workflow(): + """ + Exécute un workflow complet. + + Request Body: + { + "workflowId": "workflow_id", + "parameters": {...} + } + """ + try: + data = request.get_json() or {} + workflow_id = data.get('workflowId') + parameters = data.get('parameters', {}) + + if not workflow_id: + return jsonify({ + 'success': False, + 'error': 'workflowId requis' + }), 400 + + workflow = db.get_workflow(workflow_id) + if not workflow: + return jsonify({ + 'success': False, + 'error': 'Workflow non trouvé' + }), 404 + + # Simulation d'exécution (à implémenter selon les besoins) + results = [] + for i, node in enumerate(workflow.nodes): + results.append({ + 'stepId': node.get('id', f'step_{i}'), + 'success': True, + 'output': f'Simulation - étape {i+1} exécutée', + 'timestamp': datetime.now().isoformat() + }) + + return jsonify({ + 'success': True, + 'results': results, + 'workflowId': workflow_id, + 'executionTime': datetime.now().isoformat() + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur exécution: {str(e)}' + }), 500 + + @app.route('/api/workflow/execute-step', methods=['POST']) + def execute_step(): + """ + Exécute une étape individuelle avec pyautogui pour les actions réelles. + + Request Body: + { + "stepId": "step_id", + "stepType": "click|type|wait|...", + "parameters": {...}, + "workflowId": "workflow_id" (optionnel) + } + """ + try: + import pyautogui + import time + + data = request.get_json() or {} + step_id = data.get('stepId') + step_type = data.get('stepType') + parameters = data.get('parameters', {}) + workflow_id = data.get('workflowId') + + if not step_id or not step_type: + return jsonify({ + 'success': False, + 'error': 'stepId et stepType requis' + }), 400 + + result_message = '' + execution_success = True + + # Exécution réelle basée sur le type d'étape + if step_type in ['click', 'click_anchor']: + # Récupérer les coordonnées depuis plusieurs sources possibles + # 1. visual_anchor.bounding_box (snake_case) + # 2. visual_anchor.boundingBox (camelCase) + # 3. target.boundingBox (format frontend) + # 4. target.bounding_box + visual_anchor = parameters.get('visual_anchor', {}) + target = parameters.get('target', {}) + + bounding_box = ( + visual_anchor.get('bounding_box') or + visual_anchor.get('boundingBox') or + target.get('boundingBox') or + target.get('bounding_box') or + {} + ) + + # Récupérer la résolution de l'écran pour conversion des coordonnées normalisées + screen_width, screen_height = pyautogui.size() + print(f"📐 Résolution écran: {screen_width}x{screen_height}") + + if bounding_box: + # Calculer le centre de la bounding box + bbox_x = bounding_box.get('x', 0) + bbox_y = bounding_box.get('y', 0) + bbox_w = bounding_box.get('width', 0) + bbox_h = bounding_box.get('height', 0) + + print(f"📦 BoundingBox brute: x={bbox_x}, y={bbox_y}, w={bbox_w}, h={bbox_h}") + + # Détecter si les coordonnées sont normalisées (0-1) + # Si x ou y est < 2, on considère que c'est normalisé + if 0 < bbox_x < 2: + bbox_x = bbox_x * screen_width + print(f"↔️ x normalisé converti: {bbox_x}") + if 0 < bbox_y < 2: + bbox_y = bbox_y * screen_height + print(f"↕️ y normalisé converti: {bbox_y}") + if 0 < bbox_w < 2: + bbox_w = bbox_w * screen_width + if 0 < bbox_h < 2: + bbox_h = bbox_h * screen_height + + # Calculer le centre (utiliser / au lieu de // pour les floats) + x = int(bbox_x + bbox_w / 2) + y = int(bbox_y + bbox_h / 2) + print(f"🎯 Centre calculé: ({x}, {y})") + else: + # Utiliser les coordonnées directes si disponibles + x = parameters.get('x', parameters.get('click_x', 0)) + y = parameters.get('y', parameters.get('click_y', 0)) + print(f"📍 Coordonnées directes: ({x}, {y})") + + if x > 0 and y > 0: + click_type = parameters.get('click_type', 'left') + print(f"🖱️ Clic {click_type} à ({x}, {y})") + + if click_type == 'right': + pyautogui.rightClick(x, y) + elif click_type == 'double': + pyautogui.doubleClick(x, y) + else: + pyautogui.click(x, y) + + result_message = f'Clic effectué à ({x}, {y})' + else: + result_message = 'Coordonnées de clic non définies - simulation' + + elif step_type in ['type', 'type_text', 'saisir_texte']: + text = parameters.get('text', parameters.get('texte', '')) + if text: + print(f"⌨️ Saisie de texte: {text[:30]}...") + pyautogui.typewrite(text, interval=0.05) if text.isascii() else pyautogui.write(text) + result_message = f'Texte saisi: {text[:50]}...' + else: + result_message = 'Aucun texte à saisir' + + elif step_type in ['wait', 'attendre', 'wait_for_anchor']: + wait_time = parameters.get('duration', parameters.get('timeout_ms', 1000)) / 1000.0 + print(f"⏳ Attente de {wait_time}s") + time.sleep(wait_time) + result_message = f'Attente de {wait_time}s terminée' + + elif step_type in ['hotkey', 'raccourci']: + keys = parameters.get('keys', parameters.get('touches', [])) + if keys: + print(f"⌨️ Raccourci: {'+'.join(keys)}") + pyautogui.hotkey(*keys) + result_message = f'Raccourci {"+".join(keys)} exécuté' + else: + result_message = 'Aucune touche définie' + + elif step_type in ['scroll', 'defiler']: + direction = parameters.get('direction', 'down') + amount = parameters.get('amount', 3) + clicks = amount if direction == 'down' else -amount + print(f"📜 Scroll {direction} de {amount}") + pyautogui.scroll(clicks) + result_message = f'Scroll {direction} effectué' + + else: + result_message = f'Type d\'étape {step_type} - simulation' + + output = { + 'stepId': step_id, + 'stepType': step_type, + 'result': result_message, + 'real_execution': True, + 'parameters': parameters, + 'timestamp': datetime.now().isoformat() + } + + return jsonify({ + 'success': execution_success, + 'output': output + }) + + except Exception as e: + import traceback + print(f"❌ Erreur exécution: {traceback.format_exc()}") + return jsonify({ + 'success': False, + 'error': f'Erreur exécution étape: {str(e)}' + }), 500 + + @app.route('/api/workflow/validate', methods=['POST']) + def validate_workflow(): + """ + Valide un workflow. + + Request Body: WorkflowData + + Response: + { + "isValid": true, + "errors": [], + "warnings": [] + } + """ + try: + data = request.get_json() or {} + + errors = [] + warnings = [] + + # Validation basique + if not data.get('name'): + errors.append('Le nom du workflow est obligatoire') + + if not isinstance(data.get('steps', []), list): + errors.append('Les étapes doivent être un tableau') + + if not isinstance(data.get('connections', []), list): + errors.append('Les connexions doivent être un tableau') + + # Vérifications avancées + steps = data.get('steps', []) + connections = data.get('connections', []) + + if len(steps) == 0: + warnings.append('Le workflow ne contient aucune étape') + + if len(steps) > 1 and len(connections) == 0: + warnings.append('Le workflow contient plusieurs étapes mais aucune connexion') + + # Vérifier les IDs uniques + step_ids = [step.get('id') for step in steps if step.get('id')] + if len(step_ids) != len(set(step_ids)): + errors.append('Les IDs des étapes doivent être uniques') + + return jsonify({ + 'isValid': len(errors) == 0, + 'errors': errors, + 'warnings': warnings + }) + + except Exception as e: + return jsonify({ + 'isValid': False, + 'errors': [f'Erreur validation: {str(e)}'], + 'warnings': [] + }), 500 + + # ==================================================================== + # Endpoints d'apprentissage (Learning Integration) + # ==================================================================== + + @app.route('/api/workflows//feedback', methods=['POST']) + def submit_feedback(workflow_id): + """ + Soumet un feedback utilisateur pour l'apprentissage. + + Request Body: + { + "success": true/false, + "confidence": 0.0-1.0 (optionnel) + } + """ + try: + from services.learning_integration import ( + record_workflow_execution, + get_workflow_learning_state, + get_workflow_stats + ) + + data = request.get_json() or {} + if 'success' not in data: + return jsonify({'error': 'Le champ "success" est requis'}), 400 + + success = bool(data['success']) + confidence = data.get('confidence', 0.95 if success else 0.3) + + # Enregistrer le feedback + recorded = record_workflow_execution(workflow_id, success, confidence) + + # Récupérer l'état mis à jour + new_state = get_workflow_learning_state(workflow_id) + stats = get_workflow_stats(workflow_id) + + return jsonify({ + 'message': 'Feedback enregistré', + 'workflow_id': workflow_id, + 'feedback_recorded': recorded, + 'learning_state': new_state, + 'stats': stats + }) + + except ImportError as e: + return jsonify({ + 'message': 'Feedback simulé (learning_integration non disponible)', + 'workflow_id': workflow_id, + 'learning_state': 'OBSERVATION', + 'note': str(e) + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/workflows//learning', methods=['GET']) + def get_learning_info(workflow_id): + """ + Récupère les informations d'apprentissage d'un workflow. + """ + try: + from services.learning_integration import ( + get_workflow_learning_state, + get_workflow_stats + ) + + state = get_workflow_learning_state(workflow_id) + stats = get_workflow_stats(workflow_id) + + if stats is None: + return jsonify({ + 'workflow_id': workflow_id, + 'learning_state': 'NOT_REGISTERED', + 'message': 'Workflow non enregistré dans le système d\'apprentissage', + 'stats': None + }) + + return jsonify({ + 'workflow_id': workflow_id, + 'learning_state': state, + 'stats': stats, + 'can_auto_execute': state in ['AUTO_CANDIDATE', 'AUTO_CONFIRMED'] + }) + + except ImportError: + return jsonify({ + 'workflow_id': workflow_id, + 'learning_state': 'NOT_AVAILABLE', + 'message': 'Module learning_integration non disponible', + 'stats': None + }) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + @app.route('/api/stats', methods=['GET']) + def get_stats(): + """ + Retourne les statistiques de l'API. + """ + try: + workflows = db.list_workflows() + + stats = { + 'workflows': { + 'total': len(workflows), + 'templates': len([w for w in workflows if w.is_template]), + 'categories': list(set(w.category for w in workflows)) + }, + 'features': { + 'screen_capture': get_screen_capturer() is not None, + 'visual_embedding': get_clip_embedder() is not None + }, + 'server': { + 'version': '1.0.0-lightweight', + 'mode': 'flask', + 'uptime': datetime.now().isoformat() + } + } + + return jsonify(stats) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + # ==================================================================== + # Endpoints de capture d'écran et d'embedding visuel + # ==================================================================== + + @app.route('/api/screen-capture', methods=['POST']) + def screen_capture(): + """ + Capture l'écran actuel et retourne l'image en base64 (Option A - ultra stable). + + Request Body (optionnel): + { + "format": "png", // Format de l'image (png par défaut) + "quality": 90 // Qualité (non utilisé pour PNG) + } + + Response: + { + "success": true, + "screenshot": "base64_encoded_image", + "width": 1920, + "height": 1080, + "timestamp": "2026-01-09T...", + "method": "ultra_stable_mss" + } + """ + try: + print("🔧 Capture d'écran demandée via API Flask...") + + # Utiliser directement la méthode ultra stable (Option A) + result = capture_screen_to_base64() + + if result['success']: + print(f"✅ Capture réussie - {result.get('width')}x{result.get('height')} ({result.get('method')})") + return jsonify(result) + else: + print(f"❌ Capture échouée: {result.get('error')}") + return jsonify(result), 500 + + except Exception as e: + print(f"❌ Erreur lors de la capture d'écran: {e}") + return jsonify({ + 'success': False, + 'error': f'Erreur capture d\'écran: {str(e)}', + 'method': 'error_fallback' + }), 500 + + @app.route('/api/real-screen-capture', methods=['POST']) + def real_screen_capture(): + """ + Service de capture d'écran réelle avec détection d'éléments UI. + + Request Body (optionnel): + { + "monitor_id": 0, // ID du moniteur (0 par défaut) + "detect_elements": true // Détecter les éléments UI + } + + Response: + { + "success": true, + "screenshot": "data:image/jpeg;base64,...", + "elements": [...], // Éléments UI détectés + "monitors": [...], // Liste des moniteurs + "status": {...} // Statut du service + } + """ + if not REAL_CAPTURE_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Service de capture d\'écran réelle non disponible' + }), 503 + + try: + data = request.get_json() or {} + monitor_id = data.get('monitor_id', 0) + detect_elements = data.get('detect_elements', True) + + print(f"🔧 Capture d'écran réelle demandée (moniteur {monitor_id})...") + + # Sélectionner le moniteur si spécifié + if monitor_id != real_capture_service.selected_monitor: + if not real_capture_service.select_monitor(monitor_id): + return jsonify({ + 'success': False, + 'error': f'Moniteur {monitor_id} non valide' + }), 400 + + # TOUJOURS faire une nouvelle capture (pas de cache) + # Cela garantit que l'utilisateur obtient l'écran actuel + print(f"📸 Exécution d'une nouvelle capture d'écran...") + screenshot_array = real_capture_service._capture_screen() + if screenshot_array is not None: + real_capture_service.current_screenshot = screenshot_array + screenshot_b64 = real_capture_service.get_current_screenshot_base64() + else: + screenshot_b64 = None + + if screenshot_b64 is None: + return jsonify({ + 'success': False, + 'error': 'Impossible de capturer l\'écran' + }), 500 + + # Obtenir les éléments détectés si demandé + elements = [] + if detect_elements: + elements = real_capture_service.get_detected_elements() + + result = { + 'success': True, + 'screenshot': screenshot_b64, + 'elements': elements, + 'monitors': real_capture_service.get_monitors(), + 'status': real_capture_service.get_status(), + 'timestamp': datetime.now().isoformat(), + 'method': 'real_screen_capture_service' + } + + print(f"✅ Capture réelle réussie - {len(elements)} éléments détectés") + return jsonify(result) + + except Exception as e: + print(f"❌ Erreur capture d'écran réelle: {e}") + return jsonify({ + 'success': False, + 'error': f'Erreur capture d\'écran réelle: {str(e)}' + }), 500 + + @app.route('/api/real-screen-capture/start', methods=['POST']) + def start_real_capture(): + """ + Démarre la capture d'écran en temps réel. + + Request Body (optionnel): + { + "interval": 1.0 // Intervalle en secondes + } + """ + if not REAL_CAPTURE_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Service de capture d\'écran réelle non disponible' + }), 503 + + try: + data = request.get_json() or {} + interval = data.get('interval', 1.0) + + success = real_capture_service.start_capture(interval) + + if success: + return jsonify({ + 'success': True, + 'message': f'Capture temps réel démarrée (intervalle: {interval}s)', + 'status': real_capture_service.get_status() + }) + else: + return jsonify({ + 'success': False, + 'error': 'Impossible de démarrer la capture' + }), 500 + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur: {str(e)}' + }), 500 + + @app.route('/api/real-screen-capture/stop', methods=['POST']) + def stop_real_capture(): + """Arrête la capture d'écran en temps réel.""" + if not REAL_CAPTURE_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Service de capture d\'écran réelle non disponible' + }), 503 + + try: + success = real_capture_service.stop_capture() + + return jsonify({ + 'success': success, + 'message': 'Capture temps réel arrêtée' if success else 'Aucune capture en cours', + 'status': real_capture_service.get_status() + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur: {str(e)}' + }), 500 + + @app.route('/api/real-screen-capture/status', methods=['GET']) + def real_capture_status(): + """Obtient le statut du service de capture réelle.""" + if not REAL_CAPTURE_AVAILABLE: + return jsonify({ + 'success': False, + 'error': 'Service de capture d\'écran réelle non disponible' + }), 503 + + try: + return jsonify({ + 'success': True, + 'status': real_capture_service.get_status(), + 'monitors': real_capture_service.get_monitors() + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur: {str(e)}' + }), 500 + + @app.route('/api/visual-embedding', methods=['POST']) + def visual_embedding(): + """ + Crée un embedding visuel à partir d'une capture d'écran et d'une zone sélectionnée. + + Request Body: + { + "screenshot": "base64_encoded_image", + "boundingBox": { + "x": 100, + "y": 200, + "width": 150, + "height": 50 + }, + "stepId": "step_123" + } + + Response: + { + "success": true, + "embedding": [0.1, 0.2, ...], + "embedding_id": "emb_step_123_20260109_...", + "dimension": 512, + "reference_image": "emb_step_123_..._ref.png", + "bounding_box": {...} + } + """ + try: + data = request.get_json() + if not data: + return jsonify({ + 'success': False, + 'error': 'Corps de requête JSON requis' + }), 400 + + # Valider les paramètres requis + screenshot = data.get('screenshot') + bounding_box = data.get('boundingBox') + step_id = data.get('stepId', 'unknown') + + if not screenshot: + return jsonify({ + 'success': False, + 'error': 'Paramètre "screenshot" requis' + }), 400 + + if not bounding_box: + return jsonify({ + 'success': False, + 'error': 'Paramètre "boundingBox" requis' + }), 400 + + # Créer l'embedding + result = create_visual_embedding(screenshot, bounding_box, step_id) + + if result['success']: + return jsonify(result) + else: + return jsonify(result), 500 + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur serveur: {str(e)}' + }), 500 + + @app.route('/api/visual-embedding/', methods=['GET']) + def get_visual_embedding(embedding_id): + """ + Récupère un embedding visuel existant par son ID. + + Response: + { + "success": true, + "embedding_id": "emb_...", + "embedding": [0.1, 0.2, ...], + "reference_image_url": "/api/visual-embedding/emb_.../image" + } + """ + try: + import numpy as np + + embeddings_dir = ROOT_DIR / "data" / "visual_embeddings" + embedding_path = embeddings_dir / f"{embedding_id}.npy" + + if not embedding_path.exists(): + return jsonify({ + 'success': False, + 'error': f'Embedding "{embedding_id}" non trouvé' + }), 404 + + embedding = np.load(str(embedding_path)) + + return jsonify({ + 'success': True, + 'embedding_id': embedding_id, + 'embedding': embedding.tolist(), + 'dimension': len(embedding), + 'reference_image_url': f'/api/visual-embedding/{embedding_id}/image' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur: {str(e)}' + }), 500 + + @app.route('/api/visual-embedding//image', methods=['GET']) + def get_embedding_reference_image(embedding_id): + """ + Récupère l'image de référence d'un embedding. + """ + try: + from flask import send_file + + embeddings_dir = ROOT_DIR / "data" / "visual_embeddings" + image_path = embeddings_dir / f"{embedding_id}_ref.png" + + if not image_path.exists(): + return jsonify({ + 'success': False, + 'error': f'Image de référence non trouvée' + }), 404 + + return send_file(str(image_path), mimetype='image/png') + + except Exception as e: + return jsonify({ + 'success': False, + 'error': f'Erreur: {str(e)}' + }), 500 + + print(f"🚀 Démarrage du serveur Flask sur le port {port}") + print(f"🌐 URL: http://localhost:{port}") + print(f"❤️ Health check: http://localhost:{port}/health") + print(f"📋 API Workflows: http://localhost:{port}/api/workflows") + print(f"📷 API Capture: http://localhost:{port}/api/screen-capture") + print(f"🎯 API Embedding: http://localhost:{port}/api/visual-embedding") + print(f"🔍 API Capture Réelle: http://localhost:{port}/api/real-screen-capture") + print(f"📊 API Statut Capture: http://localhost:{port}/api/real-screen-capture/status") + + # Afficher les routes du catalogue si disponibles + if CATALOG_ROUTES_AVAILABLE: + print(f"📋 API Catalogue VWB: http://localhost:{port}/api/vwb/catalog/actions") + print(f"⚡ API Exécution VWB: http://localhost:{port}/api/vwb/catalog/execute") + print(f"✅ API Validation VWB: http://localhost:{port}/api/vwb/catalog/validate") + print(f"❤️ Health Catalogue: http://localhost:{port}/api/vwb/catalog/health") + + app.run(host='0.0.0.0', port=port, debug=False) + + except ImportError as e: + print(f"❌ Flask non disponible: {e}") + print("🔄 Basculement vers le serveur natif...") + start_native_server(port) + +def main(): + """Fonction principale.""" + print("=" * 60) + print(" VISUAL WORKFLOW BUILDER - BACKEND ALLÉGÉ") + print("=" * 60) + print("Auteur : Dom, Alice, Kiro - 09 janvier 2026") + print("") + + # Déterminer le port - utiliser 5003 par défaut (compatible frontend) + port = int(os.getenv('PORT', 5003)) + + # Vérifier les dépendances + try: + import flask + import flask_cors + print("✅ Flask disponible - utilisation du mode Flask") + start_flask_server(port) + except ImportError: + print("⚡ Flask non disponible - utilisation du serveur natif") + start_native_server(port) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/visual_workflow_builder/backend/services/learning_integration.py b/visual_workflow_builder/backend/services/learning_integration.py new file mode 100644 index 000000000..365f6fe27 --- /dev/null +++ b/visual_workflow_builder/backend/services/learning_integration.py @@ -0,0 +1,227 @@ +""" +Learning Integration Service - Connecte VWB au système d'apprentissage +Auteur: Dom, Claude - 14 janvier 2026 + +Ce service fait le pont entre le Visual Workflow Builder et le système +d'apprentissage du core RPA Vision. +""" + +import logging +import sys +from pathlib import Path +from typing import Optional, Dict, Any + +# Ajouter le chemin du core au path +CORE_PATH = Path(__file__).parent.parent.parent.parent +if str(CORE_PATH) not in sys.path: + sys.path.insert(0, str(CORE_PATH)) + +logger = logging.getLogger(__name__) + +# Singleton pour le LearningManager +_learning_manager = None +_initialized = False + + +def _get_learning_manager(): + """Obtient l'instance singleton du LearningManager.""" + global _learning_manager, _initialized + + if _initialized: + return _learning_manager + + _initialized = True + + try: + from core.learning.learning_manager import LearningManager + from core.models.workflow_graph import LearningState + _learning_manager = LearningManager() + logger.info("✓ LearningManager initialisé avec succès") + except ImportError as e: + logger.warning(f"⚠ Impossible d'importer LearningManager: {e}") + _learning_manager = None + except Exception as e: + logger.error(f"✗ Erreur initialisation LearningManager: {e}") + _learning_manager = None + + return _learning_manager + + +def _create_minimal_workflow(visual_workflow) -> Optional[Any]: + """ + Crée un objet Workflow minimal pour le LearningManager + à partir d'un VisualWorkflow ou SimpleWorkflow. + """ + try: + from core.models.workflow_graph import ( + Workflow, + LearningState, + WorkflowStats as CoreWorkflowStats, + SafetyRules, + LearningConfig + ) + from datetime import datetime + + # Récupérer les attributs (compatible VisualWorkflow et SimpleWorkflow) + wf_id = getattr(visual_workflow, 'id', str(visual_workflow)) + wf_name = getattr(visual_workflow, 'name', 'Unknown') + wf_desc = getattr(visual_workflow, 'description', '') or '' + wf_created_by = getattr(visual_workflow, 'created_by', 'unknown') + + # Créer un workflow minimal avec tous les paramètres requis + workflow = Workflow( + workflow_id=wf_id, + name=wf_name, + description=wf_desc, + version=1, + learning_state=LearningState.OBSERVATION, + created_at=datetime.now(), + updated_at=datetime.now(), + entry_nodes=[], + end_nodes=[], + nodes=[], + edges=[], + safety_rules=SafetyRules(), + stats=CoreWorkflowStats(), + learning=LearningConfig(), + metadata={'created_by': wf_created_by, 'source': 'VWB'} + ) + + return workflow + + except Exception as e: + logger.error(f"Erreur création workflow minimal: {e}") + import traceback + traceback.print_exc() + return None + + +def register_workflow_for_learning(visual_workflow) -> bool: + """ + Enregistre un workflow VWB dans le système d'apprentissage. + + Args: + visual_workflow: Instance de VisualWorkflow + + Returns: + True si enregistré avec succès, False sinon + """ + manager = _get_learning_manager() + + if manager is None: + logger.debug("LearningManager non disponible, enregistrement ignoré") + return False + + try: + workflow = _create_minimal_workflow(visual_workflow) + if workflow is None: + return False + + manager.register_workflow(workflow) + logger.info(f"✓ Workflow '{visual_workflow.name}' (ID: {visual_workflow.id}) enregistré pour apprentissage") + return True + + except Exception as e: + logger.error(f"✗ Erreur enregistrement workflow: {e}") + return False + + +def record_workflow_execution(workflow_id: str, success: bool, confidence: float = 0.8) -> bool: + """ + Enregistre une exécution de workflow pour l'apprentissage. + + Args: + workflow_id: ID du workflow + success: True si l'exécution a réussi + confidence: Score de confiance (0.0 à 1.0) + + Returns: + True si enregistré avec succès, False sinon + """ + manager = _get_learning_manager() + + if manager is None: + logger.debug("LearningManager non disponible, exécution non enregistrée") + return False + + try: + manager.record_execution(workflow_id, success, confidence) + logger.info(f"✓ Exécution enregistrée: workflow={workflow_id}, success={success}, confidence={confidence:.2f}") + return True + + except Exception as e: + logger.error(f"✗ Erreur enregistrement exécution: {e}") + return False + + +def get_workflow_learning_state(workflow_id: str) -> Optional[str]: + """ + Obtient l'état d'apprentissage d'un workflow. + + Returns: + Nom de l'état (OBSERVATION, COACHING, AUTO_CANDIDATE, AUTO_CONFIRMED) + ou None si non trouvé + """ + manager = _get_learning_manager() + + if manager is None: + return None + + try: + state = manager.get_workflow_state(workflow_id) + if state: + return state.value + return None + except Exception as e: + logger.error(f"Erreur récupération état: {e}") + return None + + +def get_workflow_stats(workflow_id: str) -> Optional[Dict[str, Any]]: + """ + Obtient les statistiques d'apprentissage d'un workflow. + + Returns: + Dict avec les stats ou None si non trouvé + """ + manager = _get_learning_manager() + + if manager is None: + return None + + try: + stats = manager.get_workflow_stats(workflow_id) + if stats: + return { + "workflow_id": stats.workflow_id, + "learning_state": stats.learning_state.value, + "observation_count": stats.observation_count, + "execution_count": stats.execution_count, + "success_count": stats.success_count, + "failure_count": stats.failure_count, + "success_rate": stats.success_rate, + "avg_confidence": stats.avg_confidence, + "last_execution": stats.last_execution.isoformat() if stats.last_execution else None + } + return None + except Exception as e: + logger.error(f"Erreur récupération stats: {e}") + return None + + +def should_auto_execute(workflow_id: str) -> bool: + """ + Vérifie si un workflow peut s'exécuter automatiquement. + + Returns: + True si le workflow est en AUTO_CANDIDATE ou AUTO_CONFIRMED + """ + manager = _get_learning_manager() + + if manager is None: + return False + + try: + return manager.should_execute_automatically(workflow_id) + except Exception: + return False diff --git a/visual_workflow_builder/frontend/src/components/Executor/VWBExecutorExtension.tsx b/visual_workflow_builder/frontend/src/components/Executor/VWBExecutorExtension.tsx new file mode 100644 index 000000000..77a408170 --- /dev/null +++ b/visual_workflow_builder/frontend/src/components/Executor/VWBExecutorExtension.tsx @@ -0,0 +1,681 @@ +/** + * Extension VWB pour le Composant Exécuteur - Support des actions VisionOnly + * Auteur : Dom, Alice, Kiro - 10 janvier 2026 + * + * Cette extension ajoute le support des actions VWB au composant Executor existant, + * avec gestion des Evidence, états visuels et feedback en temps réel. + */ + +import React, { useState, useCallback } from 'react'; +import { + Box, + Button, + ButtonGroup, + Typography, + LinearProgress, + Alert, + AlertTitle, + Chip, + Card, + CardContent, + List, + ListItem, + ListItemIcon, + ListItemText, + Collapse, + IconButton, + Tooltip, + Badge, + Tabs, + Tab, + CircularProgress, +} from '@mui/material'; +import { + PlayArrow as PlayIcon, + Stop as StopIcon, + Pause as PauseIcon, + CheckCircle as SuccessIcon, + Error as ErrorIcon, + Schedule as PendingIcon, + Visibility as EvidenceIcon, + ExpandMore as ExpandMoreIcon, + ExpandLess as ExpandLessIcon, + Speed as PerformanceIcon, + BugReport as DebugIcon, + Settings as SettingsIcon, + ThumbUp as ThumbUpIcon, + ThumbDown as ThumbDownIcon, + School as LearningIcon, +} from '@mui/icons-material'; + +// Import des hooks et services VWB +import { useVWBExecution, VWBExecutionSummary } from '../../hooks/useVWBExecution'; + +// Import des contrôles d'exécution +import { ExecutionControls } from '../ExecutionControls'; + +// Import des types +import { + Workflow, + Step, + StepExecutionState, + Variable, +} from '../../types'; +import { VWBEvidence } from '../../types/evidence'; + +interface VWBExecutorExtensionProps { + workflow: Workflow; + variables: Variable[]; + canExecute: boolean; + onStepStateChange?: (stepId: string, state: StepExecutionState) => void; + onExecutionComplete?: (success: boolean, summary: VWBExecutionSummary) => void; + onEvidenceGenerated?: (stepId: string, evidence: VWBEvidence[]) => void; + showEvidencePanel?: boolean; + showExecutionControls?: boolean; + debugMode?: boolean; + onDebugModeChange?: (enabled: boolean) => void; +} + +/** + * Extension VWB pour l'Exécuteur + */ +const VWBExecutorExtension: React.FC = ({ + workflow, + variables, + canExecute, + onStepStateChange, + onExecutionComplete, + onEvidenceGenerated, + showEvidencePanel = true, + showExecutionControls = true, + debugMode = false, + onDebugModeChange, +}) => { + // États locaux + const [showDetails, setShowDetails] = useState(false); + const [activeTab, setActiveTab] = useState(0); + // État pour replier/déplier toute la section des contrôles + const [isControlsExpanded, setIsControlsExpanded] = useState(false); + // États pour le feedback d'apprentissage + const [feedbackGiven, setFeedbackGiven] = useState(false); + const [feedbackLoading, setFeedbackLoading] = useState(false); + const [learningState, setLearningState] = useState(null); + + // Hook d'exécution VWB + const { + executionState, + isRunning, + canStart, + canPause, + canResume, + canStop, + startExecution, + pauseExecution, + resumeExecution, + stopExecution, + resetExecution, + isVWBStep, + } = useVWBExecution( + workflow, + variables, + { + onStepStart: (step, index) => { + console.log(`Début étape VWB: ${step.name} (${index + 1}/${workflow.steps.length})`); + onStepStateChange?.(step.id, StepExecutionState.RUNNING); + }, + onStepComplete: (step, result) => { + console.log(`Étape VWB terminée: ${step.name}`, result); + onStepStateChange?.(step.id, result.success ? StepExecutionState.SUCCESS : StepExecutionState.ERROR); + + if (result.evidence && result.evidence.length > 0) { + onEvidenceGenerated?.(step.id, result.evidence); + } + }, + onStepError: (step, error) => { + console.error(`Erreur étape VWB: ${step.name}`, error); + onStepStateChange?.(step.id, StepExecutionState.ERROR); + }, + onExecutionComplete: (success, summary) => { + console.log('Exécution VWB terminée:', { success, summary }); + onExecutionComplete?.(success, summary); + }, + onEvidenceGenerated: (stepId, evidence) => { + onEvidenceGenerated?.(stepId, evidence); + }, + onProgressUpdate: (progress, currentStep) => { + // Mise à jour du progrès en temps réel + console.log(`Progrès: ${progress}%, Étape: ${currentStep?.name}`); + }, + }, + { + autoValidate: true, + generateEvidence: true, + retryAttempts: 3, + timeout: 30000, + pauseOnError: debugMode, + skipNonVWBSteps: false, + } + ); + + // Service d'exécution VWB disponible si nécessaire + // const vwbService = useVWBExecutionService(); + + // Calculer les statistiques VWB + const vwbStats = React.useMemo(() => { + const vwbSteps = workflow.steps.filter(step => isVWBStep(step)); + const totalVWBSteps = vwbSteps.length; + const completedVWBSteps = executionState.results.filter(r => + vwbSteps.some(s => s.id === r.stepId) && r.success + ).length; + const failedVWBSteps = executionState.results.filter(r => + vwbSteps.some(s => s.id === r.stepId) && !r.success + ).length; + + return { + totalVWBSteps, + completedVWBSteps, + failedVWBSteps, + vwbSuccessRate: totalVWBSteps > 0 ? (completedVWBSteps / totalVWBSteps) * 100 : 0, + totalEvidence: executionState.evidence.length, + }; + }, [workflow.steps, executionState.results, executionState.evidence, isVWBStep]); + + // Gestionnaire de clic sur Evidence + const handleEvidenceClick = useCallback((stepId: string) => { + const stepEvidence = executionState.evidence.filter(e => + e.action_id === stepId || e.id.includes(stepId) + ); + // Logique pour afficher les Evidence (peut être étendue plus tard) + console.log('Evidence pour l\'étape:', stepId, stepEvidence); + }, [executionState.evidence]); + + // Formater la durée + const formatDuration = useCallback((ms: number): string => { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + }, []); + + // Obtenir l'icône d'état pour une étape + const getStepStateIcon = useCallback((step: Step) => { + const result = executionState.results.find(r => r.stepId === step.id); + + if (executionState.currentStep?.id === step.id && isRunning) { + return ; + } + + if (!result) return null; + + return result.success ? ( + + ) : ( + + ); + }, [executionState.results, executionState.currentStep, isRunning]); + + // Gestionnaire de changement d'onglet + const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: number) => { + setActiveTab(newValue); + }, []); + + // Gestionnaire de feedback pour l'apprentissage + const handleFeedback = useCallback(async (success: boolean) => { + if (!workflow.id || feedbackLoading) return; + + setFeedbackLoading(true); + try { + // Déterminer l'URL de l'API + const hostname = window.location.hostname; + const apiBase = (hostname === 'localhost' || hostname === '127.0.0.1') + ? 'http://localhost:5003/api' + : `http://${hostname}:5003/api`; + + const response = await fetch(`${apiBase}/workflows/${workflow.id}/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + success, + confidence: success ? 0.95 : 0.3, + }), + }); + + if (response.ok) { + const data = await response.json(); + setFeedbackGiven(true); + setLearningState(data.learning_state); + console.log('Feedback enregistré:', data); + } else { + console.error('Erreur feedback:', response.status); + } + } catch (error) { + console.error('Erreur envoi feedback:', error); + } finally { + setFeedbackLoading(false); + } + }, [workflow.id, feedbackLoading]); + + // Réinitialiser le feedback quand une nouvelle exécution démarre + React.useEffect(() => { + if (executionState.status === 'running') { + setFeedbackGiven(false); + setLearningState(null); + } + }, [executionState.status]); + + return ( + + {/* Barre repliable des Contrôles d'Exécution */} + {showExecutionControls && ( + + {/* En-tête cliquable pour replier/déplier */} + setIsControlsExpanded(!isControlsExpanded)} + > + + + + Contrôles d'Exécution + + {/* Indicateur d'état */} + {executionState.status === 'running' && ( + + )} + {executionState.status === 'completed' && ( + + )} + {executionState.status === 'error' && ( + + )} + + { e.stopPropagation(); setIsControlsExpanded(!isControlsExpanded); }}> + {isControlsExpanded ? : } + + + + {/* Contenu repliable */} + + + + } + iconPosition="start" + /> + } + iconPosition="start" + /> + + + {/* Contenu de l'onglet Contrôles */} + {activeTab === 0 && ( + + )} + + + + )} + + {/* Contenu de l'exécuteur VWB (affiché dans l'onglet 1 quand déplié) */} + {showExecutionControls && isControlsExpanded && activeTab === 1 && ( + <> + {/* En-tête avec statistiques VWB */} + + + + + Exécuteur VWB + + + } + /> + {vwbStats.totalEvidence > 0 && ( + } + /> + )} + {debugMode && ( + } + /> + )} + + + + {/* Contrôles d'exécution */} + + {canStart && ( + + )} + + {canPause && ( + + )} + + {canResume && ( + + )} + + {canStop && ( + + )} + + {executionState.status === 'completed' || executionState.status === 'error' ? ( + + ) : null} + + + {/* Barre de progression */} + {isRunning && ( + + + + Étape {executionState.currentStepIndex + 1} sur {executionState.totalSteps} + + + {Math.round(executionState.progress)}% + + + + {executionState.currentStep && ( + + En cours : {executionState.currentStep.name} + {isVWBStep(executionState.currentStep) && ( + + )} + + )} + + )} + + {/* Résumé d'exécution */} + {(executionState.status === 'completed' || executionState.status === 'error') && ( + + + Exécution {executionState.status === 'completed' ? 'Terminée' : 'Échouée'} + + + + {executionState.failedSteps > 0 && ( + + )} + } + size="small" + /> + {vwbStats.vwbSuccessRate > 0 && ( + = 90 ? 'success' : 'warning'} + size="small" + /> + )} + + + {/* Boutons de feedback pour l'apprentissage */} + + + + + Feedback pour l'apprentissage + + + + {!feedbackGiven ? ( + + + Le résultat était-il correct ? + + + + + + + ) : ( + + + + Feedback enregistré + + {learningState && ( + } + /> + )} + + )} + + + )} + + + + {/* Détails des étapes */} + + + + + Détails des Étapes ({workflow.steps.length}) + + setShowDetails(!showDetails)} + aria-label={showDetails ? 'Masquer les détails' : 'Afficher les détails'} + > + {showDetails ? : } + + + + + + {workflow.steps.map((step, index) => { + const result = executionState.results.find(r => r.stepId === step.id); + const stepEvidence = executionState.evidence.filter(e => + e.data?.stepId === step.id || e.id.includes(step.id) + ); + const isVWB = isVWBStep(step); + + return ( + + + {getStepStateIcon(step)} + + + + {index + 1}. {step.name} + + {isVWB && ( + + )} + {stepEvidence.length > 0 && ( + + + handleEvidenceClick(step.id)} + > + + + + + )} + + } + secondary={ + result ? ( + + + + {result.error && ( + + } + /> + + )} + + ) : ( + + {step.type} - En attente + + ) + } + /> + + ); + })} + + + + + + {/* Panneau Evidence (si activé) */} + {showEvidencePanel && executionState.evidence.length > 0 && ( + + + + Evidence d'Exécution ({executionState.evidence.length}) + + + Les Evidence générées pendant l'exécution des actions VWB sont disponibles + pour analyse et débogage. + + + + + )} + + )} + + ); +}; + +export default VWBExecutorExtension; \ No newline at end of file diff --git a/visual_workflow_builder/frontend/src/components/VariableAutocomplete/index.tsx b/visual_workflow_builder/frontend/src/components/VariableAutocomplete/index.tsx new file mode 100644 index 000000000..6dedc451f --- /dev/null +++ b/visual_workflow_builder/frontend/src/components/VariableAutocomplete/index.tsx @@ -0,0 +1,408 @@ +/** + * Composant Autocomplétion Variables - Saisie intelligente avec ${variable_name} + * Auteur : Dom, Alice, Kiro - 08 janvier 2026 + * + * Ce composant fournit une autocomplétion intelligente pour les références de variables + * dans les champs de texte, avec prévisualisation des valeurs et validation. + */ + +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { + TextField, + Popper, + Paper, + List, + ListItem, + ListItemText, + ListItemIcon, + Typography, + Box, + Chip, + ClickAwayListener, +} from '@mui/material'; +import { + Code as CodeIcon, + Numbers as NumberIcon, + ToggleOn as BooleanIcon, + List as ListIcon, +} from '@mui/icons-material'; + +// Import des types partagés +import { Variable, VariableType } from '../../types'; + +interface VariableAutocompleteProps { + label: string; + value: string; + onChange: (value: string) => void; + variables: Variable[]; + placeholder?: string; + helperText?: string; + error?: boolean; + required?: boolean; + multiline?: boolean; + rows?: number; + disabled?: boolean; +} + +interface AutocompleteState { + isOpen: boolean; + anchorEl: HTMLElement | null; + filteredVariables: Variable[]; + currentQuery: string; + cursorPosition: number; + insertPosition: { start: number; end: number } | null; +} + +/** + * Composant Autocomplétion Variables + */ +const VariableAutocomplete: React.FC = ({ + label, + value, + onChange, + variables, + placeholder, + helperText, + error = false, + required = false, + multiline = false, + rows = 1, + disabled = false, +}) => { + const textFieldRef = useRef(null); + const inputRef = useRef(null); + const [autocompleteState, setAutocompleteState] = useState({ + isOpen: false, + anchorEl: null, + filteredVariables: [], + currentQuery: '', + cursorPosition: 0, + insertPosition: null, + }); + + // Icônes pour les types de variables + const getVariableIcon = (type: VariableType) => { + switch (type) { + case 'text': + return ; + case 'number': + return ; + case 'boolean': + return ; + case 'list': + return ; + default: + return ; + } + }; + + // Formater la valeur d'une variable pour l'aperçu + const formatVariableValue = (variable: Variable): string => { + if (variable.value !== undefined) { + return String(variable.value); + } + if (variable.defaultValue !== undefined) { + switch (variable.type) { + case 'boolean': + return variable.defaultValue ? 'true' : 'false'; + case 'list': + return Array.isArray(variable.defaultValue) + ? `[${variable.defaultValue.length} éléments]` + : JSON.stringify(variable.defaultValue); + default: + return String(variable.defaultValue); + } + } + return 'Non définie'; + }; + + // Détecter si le curseur est dans une position pour l'autocomplétion + const detectAutocompleteContext = useCallback((text: string, cursorPos: number) => { + // Chercher le début d'une référence de variable ${ + let searchStart = cursorPos - 1; + let dollarPos = -1; + let bracePos = -1; + + // Chercher vers l'arrière pour trouver ${ + while (searchStart >= 0) { + if (text[searchStart] === '{' && searchStart > 0 && text[searchStart - 1] === '$') { + dollarPos = searchStart - 1; + bracePos = searchStart; + break; + } + if (text[searchStart] === '}' || text[searchStart] === ' ' || text[searchStart] === '\n') { + break; + } + searchStart--; + } + + if (dollarPos >= 0 && bracePos >= 0) { + // Chercher la fin de la référence (} ou fin de texte) + let searchEnd = cursorPos; + while (searchEnd < text.length && text[searchEnd] !== '}' && text[searchEnd] !== ' ') { + searchEnd++; + } + + const query = text.substring(bracePos + 1, cursorPos); + return { + isInVariable: true, + query, + insertPosition: { start: dollarPos, end: searchEnd }, + }; + } + + return { isInVariable: false, query: '', insertPosition: null }; + }, []); + + // Filtrer les variables selon la requête + const filterVariables = useCallback((query: string): Variable[] => { + if (!query) return variables; + + const lowerQuery = query.toLowerCase(); + return variables.filter(variable => + variable.name.toLowerCase().includes(lowerQuery) || + (variable.description && variable.description.toLowerCase().includes(lowerQuery)) + ); + }, [variables]); + + // Gestionnaire de changement de texte + const handleTextChange = (event: React.ChangeEvent) => { + const newValue = event.target.value; + const cursorPos = event.target.selectionStart || 0; + + onChange(newValue); + + // Détecter le contexte d'autocomplétion + const context = detectAutocompleteContext(newValue, cursorPos); + + if (context.isInVariable) { + const filtered = filterVariables(context.query); + setAutocompleteState({ + isOpen: filtered.length > 0, + anchorEl: textFieldRef.current, + filteredVariables: filtered, + currentQuery: context.query, + cursorPosition: cursorPos, + insertPosition: context.insertPosition, + }); + } else { + setAutocompleteState(prev => ({ ...prev, isOpen: false })); + } + }; + + // Gestionnaire de sélection de variable + const handleVariableSelect = (variable: Variable) => { + if (!autocompleteState.insertPosition) return; + + const { start, end } = autocompleteState.insertPosition; + const newValue = + value.substring(0, start) + + `\${${variable.name}}` + + value.substring(end); + + onChange(newValue); + setAutocompleteState(prev => ({ ...prev, isOpen: false })); + + // Repositionner le curseur après l'insertion + setTimeout(() => { + if (inputRef.current) { + const newCursorPos = start + `\${${variable.name}}`.length; + inputRef.current.setSelectionRange(newCursorPos, newCursorPos); + inputRef.current.focus(); + } + }, 0); + }; + + // Gestionnaire de touches clavier + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!autocompleteState.isOpen) return; + + switch (event.key) { + case 'Escape': + setAutocompleteState(prev => ({ ...prev, isOpen: false })); + event.preventDefault(); + break; + case 'ArrowDown': + case 'ArrowUp': + // TODO: Implémenter la navigation au clavier dans la liste + event.preventDefault(); + break; + case 'Enter': + case 'Tab': + if (autocompleteState.filteredVariables.length > 0) { + handleVariableSelect(autocompleteState.filteredVariables[0]); + event.preventDefault(); + } + break; + } + }; + + // Fermer l'autocomplétion en cliquant ailleurs + const handleClickAway = () => { + setAutocompleteState(prev => ({ ...prev, isOpen: false })); + }; + + // Extraire les variables utilisées dans le texte + const extractUsedVariables = (): Variable[] => { + const variablePattern = /\$\{([^}]+)\}/g; + const usedVariableNames: string[] = []; + let match; + while ((match = variablePattern.exec(value)) !== null) { + usedVariableNames.push(match[1]); + } + + return variables.filter(variable => + usedVariableNames.includes(variable.name) + ); + }; + + // Insérer une variable via clic sur chip + const handleChipInsert = useCallback((variable: Variable) => { + const cursorPos = inputRef.current?.selectionStart ?? value.length; + const newValue = + value.substring(0, cursorPos) + + `\${${variable.name}}` + + value.substring(cursorPos); + + onChange(newValue); + + // Repositionner le curseur après l'insertion + setTimeout(() => { + if (inputRef.current) { + const newCursorPos = cursorPos + `\${${variable.name}}`.length; + inputRef.current.setSelectionRange(newCursorPos, newCursorPos); + inputRef.current.focus(); + } + }, 0); + }, [value, onChange]); + + const usedVariables = extractUsedVariables(); + const availableVariables = variables.filter(v => !usedVariables.some(uv => uv.id === v.id)); + + return ( + + + {/* Champ de texte principal */} + + + {/* Variables disponibles - Chips cliquables */} + {variables.length > 0 && ( + + + Insérer: + + {variables.map((variable) => ( + uv.id === variable.id) ? "filled" : "outlined"} + color="primary" + icon={getVariableIcon(variable.type)} + onClick={() => handleChipInsert(variable)} + disabled={disabled} + sx={{ + cursor: 'pointer', + '&:hover': { + backgroundColor: 'primary.light', + color: 'primary.contrastText' + } + }} + /> + ))} + + )} + + {/* Popper d'autocomplétion */} + + + + {autocompleteState.filteredVariables.map((variable) => ( + handleVariableSelect(variable)} + sx={{ + cursor: 'pointer', + '&:hover': { + backgroundColor: 'action.hover', + }, + }} + > + + {getVariableIcon(variable.type)} + + + + ${variable.name} + + + + } + secondary={ + + + Valeur: {formatVariableValue(variable)} + + {variable.description && ( + + {variable.description} + + )} + + } + /> + + ))} + {autocompleteState.filteredVariables.length === 0 && ( + + + Aucune variable trouvée pour "{autocompleteState.currentQuery}" + + } + /> + + )} + + + + + + ); +}; + +export default VariableAutocomplete; \ No newline at end of file