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