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