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:
Dom
2026-01-14 21:30:23 +01:00
parent e7657ee1e5
commit c636f7f163
5 changed files with 3484 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

View 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

View File

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

View File

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