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