Feat: Intégration système d'apprentissage VWB
- Création service learning_integration.py (pont VWB <-> LearningManager) - Enregistrement automatique des workflows à la création - Enregistrement des résultats d'exécution (succès/échec + confiance) - Endpoints API: /workflows/<id>/feedback et /workflows/<id>/learning - Boutons feedback (pouce vert/rouge) dans VWBExecutorExtension - Fix: VariableAutocomplete inputRef pour setSelectionRange - Amélioration: Chips cliquables pour insérer les variables Le système apprend maintenant des exécutions et feedbacks utilisateur. États: OBSERVATION -> COACHING -> AUTO_CANDIDATE -> AUTO_CONFIRMED Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
716
visual_workflow_builder/backend/api/workflows.py
Normal file
716
visual_workflow_builder/backend/api/workflows.py
Normal file
@@ -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('/<workflow_id>', 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('/<workflow_id>', 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('/<workflow_id>', 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('/<workflow_id>/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('/<workflow_id>/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('/<workflow_id>/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/<execution_id>', 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/<execution_id>', 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('/<workflow_id>/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('/<workflow_id>/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('/<workflow_id>/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)}")
|
||||||
1452
visual_workflow_builder/backend/app_lightweight.py
Normal file
1452
visual_workflow_builder/backend/app_lightweight.py
Normal file
File diff suppressed because it is too large
Load Diff
227
visual_workflow_builder/backend/services/learning_integration.py
Normal file
227
visual_workflow_builder/backend/services/learning_integration.py
Normal file
@@ -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
|
||||||
@@ -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<VWBExecutorExtensionProps> = ({
|
||||||
|
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<string | null>(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 <PendingIcon color="primary" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return result.success ? (
|
||||||
|
<SuccessIcon color="success" />
|
||||||
|
) : (
|
||||||
|
<ErrorIcon color="error" />
|
||||||
|
);
|
||||||
|
}, [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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{/* Barre repliable des Contrôles d'Exécution */}
|
||||||
|
{showExecutionControls && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
{/* En-tête cliquable pour replier/déplier */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
p: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: isControlsExpanded ? 'action.selected' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
borderBottom: isControlsExpanded ? '1px solid' : 'none',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsControlsExpanded(!isControlsExpanded)}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SettingsIcon color="primary" fontSize="small" />
|
||||||
|
<Typography variant="subtitle2" fontWeight="medium">
|
||||||
|
Contrôles d'Exécution
|
||||||
|
</Typography>
|
||||||
|
{/* Indicateur d'état */}
|
||||||
|
{executionState.status === 'running' && (
|
||||||
|
<Chip label="En cours" color="primary" size="small" />
|
||||||
|
)}
|
||||||
|
{executionState.status === 'completed' && (
|
||||||
|
<Chip label="Terminé" color="success" size="small" />
|
||||||
|
)}
|
||||||
|
{executionState.status === 'error' && (
|
||||||
|
<Chip label="Erreur" color="error" size="small" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setIsControlsExpanded(!isControlsExpanded); }}>
|
||||||
|
{isControlsExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Contenu repliable */}
|
||||||
|
<Collapse in={isControlsExpanded}>
|
||||||
|
<CardContent sx={{ pt: 1 }}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
label="Contrôles"
|
||||||
|
icon={<SettingsIcon />}
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
label="Exécuteur VWB"
|
||||||
|
icon={<PlayIcon />}
|
||||||
|
iconPosition="start"
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Contenu de l'onglet Contrôles */}
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<ExecutionControls
|
||||||
|
workflow={workflow}
|
||||||
|
variables={variables}
|
||||||
|
executionState={executionState}
|
||||||
|
onStepStateChange={onStepStateChange}
|
||||||
|
onExecutionComplete={onExecutionComplete}
|
||||||
|
onEvidenceGenerated={onEvidenceGenerated}
|
||||||
|
debugMode={debugMode}
|
||||||
|
onDebugModeChange={onDebugModeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contenu de l'exécuteur VWB (affiché dans l'onglet 1 quand déplié) */}
|
||||||
|
{showExecutionControls && isControlsExpanded && activeTab === 1 && (
|
||||||
|
<>
|
||||||
|
{/* En-tête avec statistiques VWB */}
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6" component="h3">
|
||||||
|
Exécuteur VWB
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${vwbStats.totalVWBSteps} actions VWB`}
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlayIcon />}
|
||||||
|
/>
|
||||||
|
{vwbStats.totalEvidence > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`${vwbStats.totalEvidence} Evidence`}
|
||||||
|
color="info"
|
||||||
|
size="small"
|
||||||
|
icon={<EvidenceIcon />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{debugMode && (
|
||||||
|
<Chip
|
||||||
|
label="Mode Debug"
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
icon={<DebugIcon />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Contrôles d'exécution */}
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||||
|
{canStart && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PlayIcon />}
|
||||||
|
onClick={startExecution}
|
||||||
|
disabled={!canExecute || workflow.steps.length === 0}
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
Exécuter VWB
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canPause && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<PauseIcon />}
|
||||||
|
onClick={pauseExecution}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canResume && (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<PlayIcon />}
|
||||||
|
onClick={resumeExecution}
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
Reprendre
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canStop && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<StopIcon />}
|
||||||
|
onClick={stopExecution}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
Arrêter
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{executionState.status === 'completed' || executionState.status === 'error' ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={resetExecution}
|
||||||
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Barre de progression */}
|
||||||
|
{isRunning && (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Étape {executionState.currentStepIndex + 1} sur {executionState.totalSteps}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{Math.round(executionState.progress)}%
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={executionState.progress}
|
||||||
|
sx={{ height: 8, borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
{executionState.currentStep && (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
En cours : {executionState.currentStep.name}
|
||||||
|
{isVWBStep(executionState.currentStep) && (
|
||||||
|
<Chip label="VWB" size="small" color="primary" sx={{ ml: 1 }} />
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Résumé d'exécution */}
|
||||||
|
{(executionState.status === 'completed' || executionState.status === 'error') && (
|
||||||
|
<Alert
|
||||||
|
severity={executionState.status === 'completed' ? 'success' : 'error'}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<AlertTitle>
|
||||||
|
Exécution {executionState.status === 'completed' ? 'Terminée' : 'Échouée'}
|
||||||
|
</AlertTitle>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mt: 1 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${executionState.completedSteps} réussies`}
|
||||||
|
color="success"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{executionState.failedSteps > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`${executionState.failedSteps} échouées`}
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
label={`${formatDuration(executionState.duration)}`}
|
||||||
|
icon={<PerformanceIcon />}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{vwbStats.vwbSuccessRate > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={`${Math.round(vwbStats.vwbSuccessRate)}% VWB`}
|
||||||
|
color={vwbStats.vwbSuccessRate >= 90 ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Boutons de feedback pour l'apprentissage */}
|
||||||
|
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<LearningIcon fontSize="small" color="primary" />
|
||||||
|
<Typography variant="body2" fontWeight="medium">
|
||||||
|
Feedback pour l'apprentissage
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!feedbackGiven ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Le résultat était-il correct ?
|
||||||
|
</Typography>
|
||||||
|
<ButtonGroup size="small" disabled={feedbackLoading}>
|
||||||
|
<Button
|
||||||
|
startIcon={feedbackLoading ? <CircularProgress size={16} /> : <ThumbUpIcon />}
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleFeedback(true)}
|
||||||
|
>
|
||||||
|
Oui
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={feedbackLoading ? <CircularProgress size={16} /> : <ThumbDownIcon />}
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => handleFeedback(false)}
|
||||||
|
>
|
||||||
|
Non
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SuccessIcon color="success" fontSize="small" />
|
||||||
|
<Typography variant="body2" color="success.main">
|
||||||
|
Feedback enregistré
|
||||||
|
</Typography>
|
||||||
|
{learningState && (
|
||||||
|
<Chip
|
||||||
|
label={learningState}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
icon={<LearningIcon />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Détails des étapes */}
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Détails des Étapes ({workflow.steps.length})
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
aria-label={showDetails ? 'Masquer les détails' : 'Afficher les détails'}
|
||||||
|
>
|
||||||
|
{showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Collapse in={showDetails}>
|
||||||
|
<List dense>
|
||||||
|
{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 (
|
||||||
|
<ListItem key={step.id} divider>
|
||||||
|
<ListItemIcon>
|
||||||
|
{getStepStateIcon(step)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{index + 1}. {step.name}
|
||||||
|
</Typography>
|
||||||
|
{isVWB && (
|
||||||
|
<Chip label="VWB" size="small" color="primary" />
|
||||||
|
)}
|
||||||
|
{stepEvidence.length > 0 && (
|
||||||
|
<Badge badgeContent={stepEvidence.length} color="info">
|
||||||
|
<Tooltip title="Voir les Evidence">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEvidenceClick(step.id)}
|
||||||
|
>
|
||||||
|
<EvidenceIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
result ? (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={result.success ? 'Succès' : 'Échec'}
|
||||||
|
color={result.success ? 'success' : 'error'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<Chip
|
||||||
|
label={formatDuration(result.duration)}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{result.error && (
|
||||||
|
<Tooltip title={result.error.message}>
|
||||||
|
<Chip
|
||||||
|
label="Erreur"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
icon={<ErrorIcon />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{step.type} - En attente
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Collapse>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Panneau Evidence (si activé) */}
|
||||||
|
{showEvidencePanel && executionState.evidence.length > 0 && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Evidence d'Exécution ({executionState.evidence.length})
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Les Evidence générées pendant l'exécution des actions VWB sont disponibles
|
||||||
|
pour analyse et débogage.
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<EvidenceIcon />}
|
||||||
|
onClick={() => console.log('Affichage de toutes les Evidence:', executionState.evidence)}
|
||||||
|
disabled={executionState.evidence.length === 0}
|
||||||
|
>
|
||||||
|
Voir toutes les Evidence
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VWBExecutorExtension;
|
||||||
@@ -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<VariableAutocompleteProps> = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
variables,
|
||||||
|
placeholder,
|
||||||
|
helperText,
|
||||||
|
error = false,
|
||||||
|
required = false,
|
||||||
|
multiline = false,
|
||||||
|
rows = 1,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const textFieldRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||||
|
const [autocompleteState, setAutocompleteState] = useState<AutocompleteState>({
|
||||||
|
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 <CodeIcon fontSize="small" />;
|
||||||
|
case 'number':
|
||||||
|
return <NumberIcon fontSize="small" />;
|
||||||
|
case 'boolean':
|
||||||
|
return <BooleanIcon fontSize="small" />;
|
||||||
|
case 'list':
|
||||||
|
return <ListIcon fontSize="small" />;
|
||||||
|
default:
|
||||||
|
return <CodeIcon fontSize="small" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<ClickAwayListener onClickAway={handleClickAway}>
|
||||||
|
<Box>
|
||||||
|
{/* Champ de texte principal */}
|
||||||
|
<TextField
|
||||||
|
ref={textFieldRef}
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
helperText={helperText}
|
||||||
|
error={error}
|
||||||
|
required={required}
|
||||||
|
multiline={multiline}
|
||||||
|
rows={rows}
|
||||||
|
disabled={disabled}
|
||||||
|
inputRef={inputRef}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
autoComplete: 'off',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Variables disponibles - Chips cliquables */}
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1, display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mr: 0.5 }}>
|
||||||
|
Insérer:
|
||||||
|
</Typography>
|
||||||
|
{variables.map((variable) => (
|
||||||
|
<Chip
|
||||||
|
key={variable.id}
|
||||||
|
label={variable.name}
|
||||||
|
size="small"
|
||||||
|
variant={usedVariables.some(uv => 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'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Popper d'autocomplétion */}
|
||||||
|
<Popper
|
||||||
|
open={autocompleteState.isOpen}
|
||||||
|
anchorEl={autocompleteState.anchorEl}
|
||||||
|
placement="bottom-start"
|
||||||
|
style={{ zIndex: 1300 }}
|
||||||
|
>
|
||||||
|
<Paper elevation={8} sx={{ maxWidth: 400, maxHeight: 300, overflow: 'auto' }}>
|
||||||
|
<List dense>
|
||||||
|
{autocompleteState.filteredVariables.map((variable) => (
|
||||||
|
<ListItem
|
||||||
|
key={variable.id}
|
||||||
|
component="div"
|
||||||
|
onClick={() => handleVariableSelect(variable)}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{getVariableIcon(variable.type)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="body2" fontWeight="medium">
|
||||||
|
${variable.name}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={variable.type}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Valeur: {formatVariableValue(variable)}
|
||||||
|
</Typography>
|
||||||
|
{variable.description && (
|
||||||
|
<Typography variant="caption" display="block" color="text.secondary">
|
||||||
|
{variable.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
{autocompleteState.filteredVariables.length === 0 && (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Aucune variable trouvée pour "{autocompleteState.currentQuery}"
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
</Popper>
|
||||||
|
</Box>
|
||||||
|
</ClickAwayListener>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VariableAutocomplete;
|
||||||
Reference in New Issue
Block a user