From a9a53991bc09efedef5e0623d226dcc336d00c79 Mon Sep 17 00:00:00 2001 From: Dom Date: Tue, 20 Jan 2026 21:38:47 +0100 Subject: [PATCH] =?UTF-8?q?fix(vwb):=20Corriger=20auto-save=20et=20coordon?= =?UTF-8?q?n=C3=A9es=20miniature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajouter méthode updateWorkflow (PUT) dans apiClient pour les workflows existants - Utiliser PUT au lieu de POST pour l'auto-sauvegarde des workflows - Ajouter tracking du scale dans VisualSelector pour convertir les coordonnées du canvas vers l'image originale - Corriger le bounding_box pour correspondre aux dimensions réelles de l'image capturée Co-Authored-By: Claude Opus 4.5 --- visual_workflow_builder/frontend/src/App.tsx | 129 +- .../src/components/VisualSelector/index.tsx | 1104 +++++++++++++++++ .../frontend/src/services/apiClient.ts | 756 +++++++++++ 3 files changed, 1987 insertions(+), 2 deletions(-) create mode 100644 visual_workflow_builder/frontend/src/components/VisualSelector/index.tsx create mode 100644 visual_workflow_builder/frontend/src/services/apiClient.ts diff --git a/visual_workflow_builder/frontend/src/App.tsx b/visual_workflow_builder/frontend/src/App.tsx index 7d6cae8cc..244dfd740 100644 --- a/visual_workflow_builder/frontend/src/App.tsx +++ b/visual_workflow_builder/frontend/src/App.tsx @@ -6,7 +6,7 @@ * avec sélection visuelle basée sur la vision et terminologie française. */ -import { useState, useCallback, useMemo, useEffect, Component, ErrorInfo, ReactNode } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef, Component, ErrorInfo, ReactNode } from 'react'; import { Box, CssBaseline, @@ -46,6 +46,9 @@ import VWBIntegrationTest from './components/VWBIntegrationTest'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'; import { useResponsiveLayout } from './hooks/useResponsiveLayout'; +// Service API pour l'auto-sauvegarde +import { apiClient } from './services/apiClient'; + // Import des types partagés import { Step, @@ -184,6 +187,105 @@ function App() { updatedAt: new Date(), }); + // État pour l'auto-sauvegarde + const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const autoSaveTimeoutRef = useRef | null>(null); + const lastSavedWorkflowRef = useRef(''); + const isInitialLoadRef = useRef(true); + + // Auto-sauvegarde vers le backend avec debounce + useEffect(() => { + // Ignorer la sauvegarde initiale (premier render) + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + lastSavedWorkflowRef.current = JSON.stringify({ + steps: workflow.steps, + connections: workflow.connections, + }); + return; + } + + // Ne pas sauvegarder si le workflow n'a pas d'ID valide (pas encore créé sur le backend) + if (!workflow.id || workflow.id === 'workflow_1' || !workflow.id.startsWith('wf_')) { + return; + } + + // Créer une représentation du workflow pour comparer + const currentWorkflowState = JSON.stringify({ + steps: workflow.steps, + connections: workflow.connections, + }); + + // Ne pas sauvegarder si rien n'a changé + if (currentWorkflowState === lastSavedWorkflowRef.current) { + return; + } + + // Annuler le timeout précédent + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + + // Programmer la sauvegarde avec debounce (2 secondes) + autoSaveTimeoutRef.current = setTimeout(async () => { + try { + setAutoSaveStatus('saving'); + console.log('💾 [AutoSave] Sauvegarde automatique en cours...', workflow.id); + + // Préparer les données pour l'API (format compatible avec le backend) + const workflowData = { + id: workflow.id, + name: workflow.name, + description: workflow.description || '', + // Champs requis par WorkflowApiData + steps: workflow.steps, + connections: workflow.connections, + variables: workflow.variables || [], + // Format alternatif pour le backend (nodes/edges) + nodes: workflow.steps.map(step => ({ + id: step.id, + type: step.type, + position: step.position, + data: { + ...step.data, + stepType: step.type, + }, + })), + edges: workflow.connections.map(conn => ({ + id: conn.id, + source: conn.source, + target: conn.target, + sourceHandle: (conn as any).sourceHandle, + targetHandle: (conn as any).targetHandle, + })), + }; + + // Utiliser PUT (updateWorkflow) pour les workflows existants + await apiClient.updateWorkflow(workflow.id, workflowData); + + lastSavedWorkflowRef.current = currentWorkflowState; + setAutoSaveStatus('saved'); + console.log('✅ [AutoSave] Sauvegarde automatique réussie'); + + // Remettre à idle après 3 secondes + setTimeout(() => setAutoSaveStatus('idle'), 3000); + + } catch (error) { + console.error('❌ [AutoSave] Erreur de sauvegarde automatique:', error); + setAutoSaveStatus('error'); + // Remettre à idle après 5 secondes en cas d'erreur + setTimeout(() => setAutoSaveStatus('idle'), 5000); + } + }, 2000); // Debounce de 2 secondes + + // Cleanup + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + }; + }, [workflow.steps, workflow.connections, workflow.id, workflow.name, workflow.description, workflow.variables]); + // Configuration de la navigation au clavier const keyboardNavigation = useKeyboardNavigation({ onStepSelect: (stepId: string) => { @@ -445,7 +547,30 @@ function App() { onWorkflowSave={handleWorkflowSave} /> - + + {/* Indicateur d'auto-sauvegarde */} + {autoSaveStatus !== 'idle' && ( + + {autoSaveStatus === 'saving' && '💾 Sauvegarde...'} + {autoSaveStatus === 'saved' && '✅ Sauvegardé'} + {autoSaveStatus === 'error' && '❌ Erreur sauvegarde'} + + )} + {/* Indicateur de connexion API */} diff --git a/visual_workflow_builder/frontend/src/components/VisualSelector/index.tsx b/visual_workflow_builder/frontend/src/components/VisualSelector/index.tsx new file mode 100644 index 000000000..36c7ee100 --- /dev/null +++ b/visual_workflow_builder/frontend/src/components/VisualSelector/index.tsx @@ -0,0 +1,1104 @@ +/** + * Composant Sélecteur Visuel - Sélection d'éléments basée sur la vision + * Auteur : Dom, Alice, Kiro - 09 janvier 2026 + * + * Ce composant permet la sélection d'éléments à l'écran via capture d'écran + * et création d'embeddings visuels pour la reconnaissance d'éléments. + * + * CONNEXION BACKEND: Utilise l'API Flask avec Option A (ultra stable) + * - Service de capture d'écran centralisé + * - Gestion d'erreurs robuste + * - Interface utilisateur Material-UI + */ + +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + CircularProgress, + Alert, + Stepper, + Step, + StepLabel, + Paper, + IconButton, + FormControl, + InputLabel, + Select, + MenuItem, + Chip, + TextField, + Slider, + Tabs, + Tab, + Grid, + Card, + CardMedia, + CardContent, + CardActions, + Tooltip, +} from '@mui/material'; +import { + CameraAlt as CameraIcon, + Close as CloseIcon, + CheckCircle as CheckIcon, + Visibility as VisibilityIcon, + Monitor as MonitorIcon, + PhotoLibrary as LibraryIcon, + Save as SaveIcon, + Delete as DeleteIcon, + Timer as TimerIcon, +} from '@mui/icons-material'; + +// Import du service de bibliothèque de captures +import { captureLibraryService, SavedCapture } from '../../services/captureLibraryService'; + +// Import des types partagés et du service de capture +import { VisualSelection, BoundingBox } from '../../types'; +import { screenCaptureService } from '../../services/screenCaptureService'; + +interface VisualSelectorProps { + isOpen: boolean; + stepId: string; + onClose: () => void; + onElementSelected: (selection: VisualSelection) => void; +} + +interface CaptureState { + screenshot: string | null; + isCapturing: boolean; + error: string | null; + selectedArea: BoundingBox | null; + isProcessing: boolean; +} + +interface Monitor { + id: number; + width: number; + height: number; + top: number; + left: number; +} + +const steps = [ + 'Préparation', + 'Capture', + 'Sélection', + 'Confirmation', +]; + +/** + * Composant Sélecteur Visuel + */ +const VisualSelector: React.FC = ({ + isOpen, + stepId, + onClose, + onElementSelected, +}) => { + const [activeStep, setActiveStep] = useState(0); + const [captureState, setCaptureState] = useState({ + screenshot: null, + isCapturing: false, + error: null, + selectedArea: null, + isProcessing: false, + }); + + const canvasRef = useRef(null); + const [isSelecting, setIsSelecting] = useState(false); + const [selectionStart, setSelectionStart] = useState<{ x: number; y: number } | null>(null); + const [isCanvasReady, setIsCanvasReady] = useState(false); // Canvas prêt après chargement de l'image + const [canvasSize, setCanvasSize] = useState({ width: 1200, height: 800 }); // Taille du canvas pour le style CSS + const [imageScale, setImageScale] = useState(1); // Scale appliqué à l'image (pour convertir les coordonnées) + + // États pour la sélection de moniteur + const [monitors, setMonitors] = useState([]); + const [selectedMonitor, setSelectedMonitor] = useState(0); + const [isDialogHidden, setIsDialogHidden] = useState(false); // Cacher le dialogue pendant la capture + const [isUserReady, setIsUserReady] = useState(false); // L'utilisateur a confirmé être prêt + + // États pour le délai et la bibliothèque de captures + const [captureDelay, setCaptureDelay] = useState(2.5); // Délai en secondes + const [captureMode, setCaptureMode] = useState<'new' | 'library'>('new'); // Mode de capture + const [savedCaptures, setSavedCaptures] = useState([]); + const [selectedSavedCapture, setSelectedSavedCapture] = useState(null); + const [saveCaptureName, setSaveCaptureName] = useState(''); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + + // Charger la liste des moniteurs au montage + useEffect(() => { + const loadMonitors = async () => { + try { + const response = await fetch('http://localhost:5002/api/real-demo/capture/status'); + const data = await response.json(); + if (data.success && data.monitors) { + setMonitors(data.monitors); + console.log('📺 [VisualSelector] Moniteurs détectés:', data.monitors.length); + } + } catch (error) { + console.warn('⚠️ [VisualSelector] Impossible de charger les moniteurs:', error); + // Moniteur par défaut si le service n'est pas disponible + setMonitors([{ id: 0, width: 1920, height: 1080, top: 0, left: 0 }]); + } + }; + + if (isOpen) { + loadMonitors(); + } + }, [isOpen]); + + // Charger les captures sauvegardées au montage et à chaque ouverture + useEffect(() => { + if (isOpen) { + // Recharger depuis localStorage pour avoir les dernières données + captureLibraryService.reload(); + const captures = captureLibraryService.getAllCaptures(); + setSavedCaptures(captures); + console.log('📚 [VisualSelector] Captures sauvegardées chargées:', captures.length); + } + }, [isOpen]); + + // Sauvegarder la capture actuelle dans la bibliothèque + const handleSaveCapture = useCallback(async () => { + if (!captureState.screenshot || !saveCaptureName.trim()) return; + + try { + const saved = await captureLibraryService.saveCapture( + captureState.screenshot, + saveCaptureName.trim(), + { application: '', description: '' } + ); + setSavedCaptures(captureLibraryService.getAllCaptures()); + setSaveDialogOpen(false); + setSaveCaptureName(''); + console.log('💾 [VisualSelector] Capture sauvegardée:', saved.name); + } catch (error) { + console.error('❌ [VisualSelector] Erreur sauvegarde capture:', error); + } + }, [captureState.screenshot, saveCaptureName]); + + // Utiliser une capture de la bibliothèque + const handleUseSavedCapture = useCallback((capture: SavedCapture) => { + setSelectedSavedCapture(capture); + setCaptureState(prev => ({ + ...prev, + screenshot: capture.screenshot, + error: null, + })); + // Passer directement à l'étape de sélection + setActiveStep(2); + console.log('📷 [VisualSelector] Utilisation capture sauvegardée:', capture.name); + }, []); + + // Supprimer une capture de la bibliothèque + const handleDeleteCapture = useCallback((captureId: string) => { + captureLibraryService.deleteCapture(captureId); + setSavedCaptures(captureLibraryService.getAllCaptures()); + console.log('🗑️ [VisualSelector] Capture supprimée:', captureId); + }, []); + + // Dessiner l'image sur le canvas quand on passe à l'étape de sélection (étape 2) + useEffect(() => { + if (activeStep === 2 && captureState.screenshot) { + // Réinitialiser isCanvasReady avant le chargement + setIsCanvasReady(false); + + // Attendre que le canvas soit rendu dans le DOM + const drawImage = () => { + const canvas = canvasRef.current; + if (!canvas) { + // Réessayer après un court délai si le canvas n'est pas encore prêt + console.log('⏳ [VisualSelector] Canvas pas encore prêt, nouvelle tentative...'); + setTimeout(drawImage, 100); + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = new Image(); + img.onload = () => { + // Ajuster la taille du canvas à l'image (taille plus grande) + const maxWidth = 1200; + const scale = Math.min(1, maxWidth / img.width); + canvas.width = img.width * scale; + canvas.height = img.height * scale; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + console.log('✅ [VisualSelector] Image dessinée sur le canvas:', canvas.width, 'x', canvas.height, 'scale:', scale); + + // IMPORTANT: Stocker le scale pour la conversion des coordonnées + setImageScale(scale); + + // IMPORTANT: Mettre à jour le state canvasSize pour que le CSS corresponde + // Ceci évite le bug de coordonnées quand scaleX != 1 + setCanvasSize({ width: canvas.width, height: canvas.height }); + console.log('📐 [VisualSelector] CSS canvas size mis à jour:', canvas.width, 'x', canvas.height); + + // Canvas prêt pour la sélection + setIsCanvasReady(true); + console.log('🎯 [VisualSelector] Canvas prêt pour sélection'); + }; + img.onerror = (err) => { + console.error('❌ [VisualSelector] Erreur chargement image:', err); + setIsCanvasReady(false); + }; + + // Utiliser directement le screenshot (déjà avec préfixe data:) + if (captureState.screenshot) { + img.src = captureState.screenshot; + } + }; + + // Petit délai pour laisser React rendre le canvas + setTimeout(drawImage, 50); + } else { + setIsCanvasReady(false); + } + }, [activeStep, captureState.screenshot]); + + // Réinitialiser l'état lors de l'ouverture/fermeture + const handleClose = useCallback(() => { + setActiveStep(0); + setCaptureState({ + screenshot: null, + isCapturing: false, + error: null, + selectedArea: null, + isProcessing: false, + }); + setIsSelecting(false); + setSelectionStart(null); + setIsDialogHidden(false); + setIsUserReady(false); + onClose(); + }, [onClose]); + + // Effectuer la capture d'écran réelle (appelé depuis l'étape 1) + const performCapture = useCallback(async () => { + try { + console.log(`🔧 Capture d'écran du moniteur ${selectedMonitor}...`); + + // Cacher le dialogue AVANT la capture + setIsDialogHidden(true); + setCaptureState(prev => ({ ...prev, isCapturing: true, error: null })); + + // Délai personnalisé pour permettre à l'utilisateur de basculer + // vers l'application cible après que le dialogue soit caché + const delayMs = captureDelay * 1000; + console.log(`⏱️ [VisualSelector] Délai avant capture: ${captureDelay}s`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + + // Utiliser l'API de capture réelle avec le moniteur sélectionné + const response = await fetch('http://localhost:5002/api/real-demo/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + monitor_id: selectedMonitor, + detect_elements: false + }) + }); + + const result = await response.json(); + + // Réafficher le dialogue + setIsDialogHidden(false); + + if (!result.success || !result.screenshot) { + throw new Error(result.error || 'Échec de la capture d\'écran'); + } + + console.log(`✅ Capture réussie - Moniteur ${selectedMonitor}`); + + // Garder le screenshot complet avec son préfixe data: original + // Le backend retourne data:image/jpeg;base64,... qu'on garde tel quel + const screenshot = result.screenshot.startsWith('data:') + ? result.screenshot + : `data:image/png;base64,${result.screenshot}`; + + console.log(`📷 Screenshot reçu, longueur: ${screenshot.length}, préfixe: ${screenshot.substring(0, 30)}...`); + + setCaptureState(prev => ({ + ...prev, + screenshot, + isCapturing: false, + })); + + // Passer à l'étape 2 (Sélection) + setActiveStep(2); + } catch (error) { + console.error('❌ Erreur lors de la capture d\'écran:', error); + setIsDialogHidden(false); // Réafficher le dialogue en cas d'erreur + setCaptureState(prev => ({ + ...prev, + isCapturing: false, + error: error instanceof Error ? error.message : 'Erreur inconnue lors de la capture', + })); + } + }, [selectedMonitor, captureDelay]); + + // L'utilisateur confirme être prêt - passer à l'étape Capture + const handleUserReady = useCallback(() => { + setIsUserReady(true); + setActiveStep(1); // Passer à l'étape 1 (Capture) + }, []); + + // Revenir à l'étape de préparation + const handleBackToPreparation = useCallback(() => { + setActiveStep(0); + setIsUserReady(false); + }, []); + + // Gérer le début de sélection sur le canvas + const handleMouseDown = useCallback((event: React.MouseEvent) => { + if (!captureState.screenshot) return; + + // Vérifier que le canvas est prêt (image chargée) + if (!isCanvasReady) { + console.warn('⚠️ [VisualSelector] Canvas pas encore prêt, veuillez patienter...'); + return; + } + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + // Calculer le ratio entre taille CSS et taille interne du canvas + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + // Vérifier que le ratio est valide (canvas dimensionné correctement) + if (scaleX < 0.1 || scaleX > 10 || scaleY < 0.1 || scaleY > 10) { + console.error(`❌ [VisualSelector] Ratio canvas invalide: scaleX=${scaleX.toFixed(3)}, scaleY=${scaleY.toFixed(3)}`); + return; + } + + // Convertir les coordonnées souris en coordonnées canvas + const x = (event.clientX - rect.left) * scaleX; + const y = (event.clientY - rect.top) * scaleY; + + console.log(`🖱️ MouseDown: CSS(${event.clientX - rect.left}, ${event.clientY - rect.top}) -> Canvas(${x.toFixed(0)}, ${y.toFixed(0)}) [scale: ${scaleX.toFixed(2)}, ${scaleY.toFixed(2)}]`); + + setIsSelecting(true); + setSelectionStart({ x, y }); + setCaptureState(prev => ({ ...prev, selectedArea: null })); + }, [captureState.screenshot, isCanvasReady]); + + // Gérer le mouvement de sélection + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (!isSelecting || !selectionStart || !canvasRef.current || !isCanvasReady) return; + + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + // Calculer le ratio entre taille CSS et taille interne du canvas + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + // Convertir les coordonnées souris en coordonnées canvas + const currentX = (event.clientX - rect.left) * scaleX; + const currentY = (event.clientY - rect.top) * scaleY; + + // Dessiner la zone de sélection en temps réel + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Redessiner l'image de base + if (captureState.screenshot) { + const img = new Image(); + img.onload = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + // Dessiner le rectangle de sélection + ctx.strokeStyle = '#1976d2'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.strokeRect( + selectionStart.x, + selectionStart.y, + currentX - selectionStart.x, + currentY - selectionStart.y + ); + }; + // Gérer les deux formats de screenshot (avec ou sans préfixe data:) + img.src = captureState.screenshot.startsWith('data:') + ? captureState.screenshot + : `data:image/png;base64,${captureState.screenshot}`; + } + }, [isSelecting, selectionStart, captureState.screenshot, isCanvasReady]); + + // Finaliser la sélection + const handleMouseUp = useCallback((event: React.MouseEvent) => { + if (!isSelecting || !selectionStart || !canvasRef.current || !isCanvasReady) return; + + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + // Calculer le ratio entre taille CSS et taille interne du canvas + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + // Convertir les coordonnées souris en coordonnées canvas + const endX = (event.clientX - rect.left) * scaleX; + const endY = (event.clientY - rect.top) * scaleY; + + // Coordonnées en canvas (redimensionné) + const canvasX = Math.min(selectionStart.x, endX); + const canvasY = Math.min(selectionStart.y, endY); + const canvasWidth = Math.abs(endX - selectionStart.x); + const canvasHeight = Math.abs(endY - selectionStart.y); + + // IMPORTANT: Convertir les coordonnées du canvas vers les coordonnées de l'image originale + // Le canvas est redimensionné avec imageScale, donc on doit diviser par le scale + const selectedArea: BoundingBox = { + x: canvasX / imageScale, + y: canvasY / imageScale, + width: canvasWidth / imageScale, + height: canvasHeight / imageScale, + }; + + console.log(`🎯 Sélection finale: canvas=(${canvasX.toFixed(0)}, ${canvasY.toFixed(0)}) ${canvasWidth.toFixed(0)}×${canvasHeight.toFixed(0)}px -> original=(${selectedArea.x.toFixed(0)}, ${selectedArea.y.toFixed(0)}) ${selectedArea.width.toFixed(0)}×${selectedArea.height.toFixed(0)}px [scale: ${imageScale}]`); + + // Valider que la zone sélectionnée a une taille minimale + if (selectedArea.width < 10 || selectedArea.height < 10) { + setCaptureState(prev => ({ + ...prev, + error: 'La zone sélectionnée est trop petite. Veuillez sélectionner une zone plus grande.', + })); + setIsSelecting(false); + setSelectionStart(null); + return; + } + + setCaptureState(prev => ({ + ...prev, + selectedArea, + error: null, + })); + + setIsSelecting(false); + setSelectionStart(null); + setActiveStep(3); // Passer à l'étape 3 (Confirmation) + }, [isSelecting, selectionStart, isCanvasReady, imageScale]); + + // Confirmer la sélection et créer l'objet VisualSelection + const handleConfirmSelection = useCallback(async () => { + if (!captureState.screenshot || !captureState.selectedArea) return; + + setCaptureState(prev => ({ ...prev, isProcessing: true, error: null })); + + try { + console.log('🎯 Création de la sélection visuelle...'); + + // Extraire la région sélectionnée de l'image + let referenceImage = captureState.screenshot; + + // Essayer de créer un embedding via le service (optionnel) + let embeddingData: number[] | undefined; + let embeddingId: string | undefined; + + try { + const result = await screenCaptureService.createVisualEmbedding( + captureState.screenshot, + captureState.selectedArea, + stepId + ); + + if (result?.success && result.embedding) { + embeddingData = result.embedding; + embeddingId = result.embedding_id; + referenceImage = result.reference_image || captureState.screenshot; + console.log(`✅ Embedding créé - ID: ${embeddingId}`); + } + } catch (embeddingError) { + // Le service d'embedding n'est pas disponible, on continue sans + console.warn('⚠️ Service d\'embedding non disponible, sélection sans embedding'); + } + + // Créer l'objet VisualSelection (fonctionne avec ou sans embedding) + const visualSelection: VisualSelection = { + id: `visual_${stepId}_${Date.now()}`, + screenshot: captureState.screenshot, + boundingBox: captureState.selectedArea, + embedding: embeddingData, + description: `Élément sélectionné pour l'étape ${stepId}`, + metadata: { + embedding_id: embeddingId, + reference_image: referenceImage, + capture_method: 'screen_capture', + capture_timestamp: new Date().toISOString(), + has_embedding: !!embeddingData, + }, + }; + + console.log('✅ Sélection visuelle créée:', { + id: visualSelection.id, + boundingBox: visualSelection.boundingBox, + hasEmbedding: !!embeddingData + }); + + onElementSelected(visualSelection); + handleClose(); + } catch (error) { + console.error('❌ Erreur lors de la création de la sélection:', error); + setCaptureState(prev => ({ + ...prev, + isProcessing: false, + error: error instanceof Error ? error.message : 'Erreur inconnue', + })); + } + }, [captureState.screenshot, captureState.selectedArea, stepId, onElementSelected, handleClose]); + + // Rendu du contenu selon l'étape active + const renderStepContent = () => { + switch (activeStep) { + // ÉTAPE 0 : Préparation - Nouvelle capture ou Bibliothèque + case 0: + return ( + + {/* Onglets Nouvelle capture / Bibliothèque */} + setCaptureMode(newValue)} + centered + sx={{ mb: 3 }} + > + } + iconPosition="start" + /> + } + iconPosition="start" + /> + + + {/* Mode Nouvelle capture */} + {captureMode === 'new' && ( + <> + + + + + + Préparez votre écran + + + + {/* Instructions détaillées */} + + + Avant de continuer, effectuez ces étapes : + + +
  • + + Minimisez ce navigateur ou déplacez-le sur le côté + +
  • +
  • + + Ouvrez l'application que vous souhaitez capturer + +
  • +
  • + + Mettez-la au premier plan et positionnez l'élément + +
  • +
  • + + Revenez ici et cliquez sur "Je suis prêt" + +
  • +
    +
    + + {/* Sélection du moniteur (si plusieurs) */} + {monitors.length > 1 && ( + + + + + Moniteur à capturer + + + + Moniteur + + + + )} + + {/* Délai avant capture */} + + + + + Délai avant capture + + + + setCaptureDelay(value as number)} + min={0.5} + max={10} + step={0.5} + marks={[ + { value: 0.5, label: '0.5s' }, + { value: 2.5, label: '2.5s' }, + { value: 5, label: '5s' }, + { value: 10, label: '10s' }, + ]} + valueLabelDisplay="auto" + valueLabelFormat={(v) => `${v}s`} + /> + + Temps pour basculer vers l'application cible après avoir cliqué sur "Je suis prêt" + + + + {captureState.error && ( + + {captureState.error} + + )} + + + + + + )} + + {/* Mode Bibliothèque */} + {captureMode === 'library' && ( + <> + {savedCaptures.length === 0 ? ( + + + + Aucune capture sauvegardée + + + Faites une nouvelle capture et sauvegardez-la pour la réutiliser plus tard. + + + + ) : ( + <> + + Sélectionnez une capture existante pour sélectionner un élément dessus. + + + {savedCaptures.map((capture) => ( + + handleUseSavedCapture(capture)} + > + + + + {capture.name} + + + {new Date(capture.createdAt).toLocaleDateString('fr-FR')} + + + + + { + e.stopPropagation(); + handleDeleteCapture(capture.id); + }} + > + + + + + + + ))} + + + )} + + )} +
    + ); + + // ÉTAPE 1 : Capture - Bouton de capture immédiate + case 1: + return ( + + + + {captureState.isCapturing ? ( + + ) : ( + + )} + + + {captureState.isCapturing ? 'Capture en cours...' : 'Prêt à capturer'} + + + {captureState.isCapturing + ? 'Veuillez patienter...' + : 'Cliquez sur le bouton ci-dessous pour capturer votre écran' + } + + + + + + Note : Cette fenêtre va se masquer brièvement pendant la capture. + {monitors.length > 1 && ` La capture sera effectuée sur le moniteur ${selectedMonitor + 1}.`} + + + + {captureState.error && ( + + {captureState.error} + + )} + + + + + + + ); + + // ÉTAPE 2 : Sélection - Dessiner la zone sur la capture + case 2: + return ( + + + Sélection d'élément + + + Cliquez et glissez pour sélectionner l'élément souhaité sur la capture d'écran. + + + {captureState.error && ( + + {captureState.error} + + )} + + + {captureState.screenshot && ( + + )} + + + + + + + + ); + + // ÉTAPE 3 : Confirmation - Vérifier et confirmer la sélection + case 3: + return ( + + + Confirmation de sélection + + + Vérifiez que la zone sélectionnée correspond à l'élément souhaité. + + + {captureState.selectedArea && ( + + Zone sélectionnée : {captureState.selectedArea.width} × {captureState.selectedArea.height} pixels + à la position ({captureState.selectedArea.x}, {captureState.selectedArea.y}) + + )} + + {captureState.error && ( + + {captureState.error} + + )} + + + + + + + ); + + default: + return null; + } + }; + + return ( + + + + + + Sélection visuelle d'élément + + + + + + + + + {/* Stepper pour indiquer la progression */} + + {steps.map((label) => ( + + {label} + + ))} + + + {/* Contenu de l'étape active */} + {renderStepContent()} + + + + + + + {/* Dialog de sauvegarde dans la bibliothèque */} + setSaveDialogOpen(false)} + maxWidth="sm" + fullWidth + > + + Sauvegarder dans la bibliothèque + + + + Donnez un nom à cette capture pour la réutiliser plus tard. + + setSaveCaptureName(e.target.value)} + placeholder="Ex: Bouton Connexion - Appli Compta" + helperText="Un nom descriptif facilite la recherche ultérieure" + /> + + + + + + + + ); +}; + +export default VisualSelector; \ No newline at end of file diff --git a/visual_workflow_builder/frontend/src/services/apiClient.ts b/visual_workflow_builder/frontend/src/services/apiClient.ts new file mode 100644 index 000000000..1be6072ce --- /dev/null +++ b/visual_workflow_builder/frontend/src/services/apiClient.ts @@ -0,0 +1,756 @@ +/** + * Client API - Gestion centralisée des communications avec le Backend_VWB + * Auteur : Dom, Alice, Kiro - 09 janvier 2026 + * + * Ce service centralise toutes les communications avec le backend, + * incluant la gestion d'erreurs, retry automatique, validation des données + * et gestion gracieuse du mode hors ligne. + * + * IMPORTANT: Ce client utilise une initialisation paresseuse (lazy) pour + * éviter les boucles infinies de re-render au chargement de la page. + */ + +import { WorkflowApiData } from '../types'; + +// Configuration du client API +interface ApiClientConfig { + baseUrl: string; + timeout: number; + maxRetries: number; + retryDelay: number; + enableRetry: boolean; + healthCheckInterval: number; +} + +// Types pour les réponses API +interface ApiResponse { + success: boolean; + data?: T; + error?: string; + code?: string; + timestamp?: string; + offline?: boolean; +} + +interface ApiError { + message: string; + code?: string; + status?: number; + details?: any; + offline?: boolean; +} + +// État de connexion - 'offline' par défaut pour éviter les appels au montage +type ConnectionState = 'online' | 'offline' | 'checking'; + +// Callbacks pour les changements d'état +type ConnectionStateCallback = (state: ConnectionState) => void; + +// Détection automatique de l'hôte pour support multi-machines +// Si on accède via une IP (ex: 192.168.1.40), utiliser cette IP pour l'API +// Sinon utiliser localhost +const getApiHost = (): string => { + if (typeof window !== 'undefined') { + const hostname = window.location.hostname; + // Si c'est localhost ou 127.0.0.1, garder localhost + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return 'http://localhost:5002/api'; + } + // Sinon utiliser le même hostname (IP) avec le port 5000 + return `http://${hostname}:5000/api`; + } + return 'http://localhost:5002/api'; +}; + +// Configuration par défaut +const DEFAULT_CONFIG: ApiClientConfig = { + baseUrl: getApiHost(), // Backend Flask - détection auto de l'hôte + timeout: 3000, // 3 secondes (réduit pour éviter les attentes longues) + maxRetries: 1, // Réduit pour éviter les délais + retryDelay: 500, // 500ms + enableRetry: false, // Désactivé par défaut pour éviter les boucles + healthCheckInterval: 60000, // 60 secondes (augmenté pour réduire les appels) +}; + +/** + * Client API centralisé pour les communications avec le Backend_VWB + * Gère automatiquement le mode hors ligne sans provoquer de re-rendus excessifs + * + * ARCHITECTURE: + * - État initial: 'offline' (pas de vérification automatique au démarrage) + * - Initialisation paresseuse: la vérification se fait au premier appel API + * - Pas de timer de health check automatique (évite les re-renders) + */ +class ApiClient { + private config: ApiClientConfig; + private abortController: AbortController | null = null; + // État initial 'online' - on suppose que l'API est disponible et on gère les erreurs au cas par cas + private connectionState: ConnectionState = 'online'; + private stateCallbacks: Set = new Set(); + private healthCheckTimer: ReturnType | null = null; + private lastHealthCheck: number = 0; + private isInitialized: boolean = false; + private initializationPromise: Promise | null = null; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * Initialiser le client et vérifier la connexion + * Appelé une seule fois au premier appel API (initialisation paresseuse) + * Utilise un pattern singleton pour éviter les initialisations multiples + */ + async initialize(): Promise { + // Si déjà initialisé, retourner immédiatement + if (this.isInitialized) return; + + // Si une initialisation est en cours, attendre qu'elle se termine + if (this.initializationPromise) { + return this.initializationPromise; + } + + // Créer la promesse d'initialisation + this.initializationPromise = this.doInitialize(); + + try { + await this.initializationPromise; + } finally { + this.initializationPromise = null; + } + } + + /** + * Effectuer l'initialisation réelle + */ + private async doInitialize(): Promise { + if (this.isInitialized) return; + this.isInitialized = true; + + // Vérification initiale silencieuse (une seule fois) + await this.checkConnectionSilently(); + + // NE PAS démarrer le timer automatique pour éviter les re-renders + // Le timer peut être démarré manuellement si nécessaire + } + + /** + * Vérification silencieuse de la connexion (sans logs excessifs) + * Utilise un debounce pour éviter les vérifications trop fréquentes + */ + private async checkConnectionSilently(): Promise { + const now = Date.now(); + + // TEMPORAIRE: Désactiver le debounce pour debug + // if (now - this.lastHealthCheck < 10000) { + // console.log('[ApiClient] Debounce actif, skip vérification'); + // return this.connectionState === 'online'; + // } + + this.lastHealthCheck = now; + console.log('[ApiClient] checkConnectionSilently appelé'); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 secondes max + + // Utiliser /api/health selon la configuration + const healthUrl = `${this.config.baseUrl}/health`; + console.log('[ApiClient] Vérification de santé:', healthUrl); + + const response = await fetch(healthUrl, { + signal: controller.signal, + headers: { 'Accept': 'application/json' }, + }); + + clearTimeout(timeoutId); + + console.log('[ApiClient] Réponse:', response.status, response.headers.get('content-type')); + + if (response.ok) { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + console.log('[ApiClient] ✅ API en ligne!'); + this.setConnectionState('online'); + return true; + } else { + console.log('[ApiClient] ❌ Content-type invalide:', contentType); + } + } else { + console.log('[ApiClient] ❌ Réponse non-OK:', response.status); + } + + this.setConnectionState('offline'); + return false; + } catch (error) { + console.log('[ApiClient] ❌ Erreur fetch:', error); + this.setConnectionState('offline'); + return false; + } + } + + /** + * Démarrer le timer de vérification de santé (optionnel) + * À appeler manuellement si nécessaire + */ + startHealthCheckTimer(): void { + if (this.healthCheckTimer) return; + + this.healthCheckTimer = setInterval(() => { + this.checkConnectionSilently(); + }, this.config.healthCheckInterval); + } + + /** + * Arrêter le timer de vérification + */ + stopHealthCheck(): void { + if (this.healthCheckTimer) { + clearInterval(this.healthCheckTimer); + this.healthCheckTimer = null; + } + } + + /** + * Mettre à jour l'état de connexion et notifier les listeners + * Utilise un mécanisme de batch pour éviter les notifications multiples + */ + private setConnectionState(state: ConnectionState): void { + if (this.connectionState !== state) { + this.connectionState = state; + // Notifier les callbacks de manière asynchrone pour éviter les boucles + setTimeout(() => { + this.stateCallbacks.forEach(callback => { + try { + callback(state); + } catch (e) { + console.warn('Erreur dans le callback de connexion:', e); + } + }); + }, 0); + } + } + + /** + * S'abonner aux changements d'état de connexion + * NE notifie PAS immédiatement l'état actuel pour éviter les re-renders au montage + */ + onConnectionStateChange(callback: ConnectionStateCallback): () => void { + this.stateCallbacks.add(callback); + + // NE PAS notifier immédiatement - cela évite les re-renders au montage + // L'état sera mis à jour lors du premier appel API ou forceConnectionCheck + + // Retourner une fonction de désabonnement + return () => { + this.stateCallbacks.delete(callback); + }; + } + + /** + * Obtenir l'état de connexion actuel + */ + getConnectionState(): ConnectionState { + return this.connectionState; + } + + /** + * Vérifier si l'API est en ligne + */ + isOnline(): boolean { + return this.connectionState === 'online'; + } + + /** + * Effectuer une requête HTTP avec gestion d'erreurs et retry + * Initialisation paresseuse au premier appel + */ + private async makeRequest( + endpoint: string, + options: RequestInit = {}, + retryCount = 0 + ): Promise> { + // Initialisation paresseuse au premier appel API + if (!this.isInitialized) { + await this.initialize(); + } + + // NOTE: On n'utilise plus le blocage offline - on essaie toujours l'API + // Les erreurs seront gérées par le catch si l'API est vraiment indisponible + + // Créer un nouveau AbortController pour cette requête + this.abortController = new AbortController(); + + const url = `${this.config.baseUrl}${endpoint}`; + const requestOptions: RequestInit = { + ...options, + signal: this.abortController.signal, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...options.headers, + }, + }; + + // Ajouter un timeout + const timeoutId = setTimeout(() => { + if (this.abortController) { + this.abortController.abort(); + } + }, this.config.timeout); + + try { + const response = await fetch(url, requestOptions); + clearTimeout(timeoutId); + + // Vérifier si la réponse est du JSON + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + // Le serveur retourne du HTML (probablement le serveur React) + this.setConnectionState('offline'); + return { + success: false, + error: 'API hors ligne - Le backend n\'est pas démarré', + code: 'OFFLINE', + offline: true, + timestamp: new Date().toISOString(), + }; + } + + // Marquer comme en ligne si la réponse est valide + this.setConnectionState('online'); + + // Vérifier le statut de la réponse + if (!response.ok) { + const errorText = await response.text(); + let errorData: any = {}; + + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { message: errorText }; + } + + const apiError: ApiError = { + message: errorData.message || `Erreur HTTP ${response.status}`, + code: errorData.code || `HTTP_${response.status}`, + status: response.status, + details: errorData, + }; + + // Retry pour certaines erreurs (5xx, timeouts, network errors) + if (this.shouldRetry(response.status) && retryCount < this.config.maxRetries) { + await this.delay(this.config.retryDelay * Math.pow(2, retryCount)); + return this.makeRequest(endpoint, options, retryCount + 1); + } + + throw apiError; + } + + // Parser la réponse JSON + const data = await response.json(); + + return { + success: true, + data, + timestamp: new Date().toISOString(), + }; + + } catch (error) { + clearTimeout(timeoutId); + + // Gestion des erreurs d'abort + if (error instanceof Error && error.name === 'AbortError') { + this.setConnectionState('offline'); + return { + success: false, + error: 'Requête annulée (timeout)', + code: 'TIMEOUT', + offline: true, + timestamp: new Date().toISOString(), + }; + } + + // Gestion des erreurs réseau + if (error instanceof TypeError && (error.message.includes('fetch') || error.message.includes('network'))) { + this.setConnectionState('offline'); + + // Retry pour les erreurs réseau + if (this.config.enableRetry && retryCount < this.config.maxRetries) { + await this.delay(this.config.retryDelay * Math.pow(2, retryCount)); + return this.makeRequest(endpoint, options, retryCount + 1); + } + + return { + success: false, + error: 'Erreur de connexion réseau - API hors ligne', + code: 'NETWORK_ERROR', + offline: true, + timestamp: new Date().toISOString(), + }; + } + + // Re-lancer les autres erreurs + throw error; + } + } + + /** + * Déterminer si une erreur justifie un retry + */ + private shouldRetry(status: number): boolean { + if (!this.config.enableRetry) return false; + return status >= 500 || status === 408 || status === 429; + } + + /** + * Attendre un délai spécifié + */ + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Annuler la requête en cours + */ + public cancelRequest(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + /** + * Valider les données d'un workflow avant envoi + */ + private validateWorkflowData(workflow: WorkflowApiData): void { + if (!workflow.name || workflow.name.trim().length === 0) { + throw new Error('Le nom du workflow est obligatoire'); + } + + if (workflow.name.length > 100) { + throw new Error('Le nom du workflow ne peut pas dépasser 100 caractères'); + } + + if (workflow.description && workflow.description.length > 500) { + throw new Error('La description ne peut pas dépasser 500 caractères'); + } + + if (!Array.isArray(workflow.steps)) { + throw new Error('Les étapes du workflow doivent être un tableau'); + } + + if (!Array.isArray(workflow.connections)) { + throw new Error('Les connexions du workflow doivent être un tableau'); + } + + if (!Array.isArray(workflow.variables)) { + throw new Error('Les variables du workflow doivent être un tableau'); + } + } + + /** + * Valider les données d'une étape avant exécution + */ + private validateStepData(stepData: any): void { + if (!stepData.stepId || typeof stepData.stepId !== 'string') { + throw new Error('L\'ID de l\'étape est obligatoire'); + } + + if (!stepData.stepType || typeof stepData.stepType !== 'string') { + throw new Error('Le type d\'étape est obligatoire'); + } + + if (!stepData.parameters || typeof stepData.parameters !== 'object') { + throw new Error('Les paramètres de l\'étape doivent être un objet'); + } + } + + // === MÉTHODES PUBLIQUES POUR LES WORKFLOWS === + + /** + * Récupérer la liste des workflows + * Retourne un tableau vide si hors ligne + */ + async getWorkflows(): Promise { + try { + const response = await this.makeRequest('/workflows/'); + // Retourner les données même si offline - laisser le composant gérer le fallback + return response.data || []; + } catch (error) { + console.warn('Erreur lors du chargement des workflows:', error); + return []; + } + } + + /** + * Récupérer un workflow par ID + */ + async getWorkflow(workflowId: string): Promise { + if (!workflowId || workflowId.trim().length === 0) { + throw new Error('L\'ID du workflow est obligatoire'); + } + + try { + const response = await this.makeRequest<{ workflow: any }>(`/workflows/${workflowId}`); + // Retourner les données même si offline est true - laisser le composant gérer + return response.data?.workflow || response.data || null; + } catch (error) { + console.warn(`Erreur lors du chargement du workflow ${workflowId}:`, error); + return null; + } + } + + /** + * Sauvegarder un workflow + * Retourne null si hors ligne + */ + async saveWorkflow(workflowData: WorkflowApiData): Promise { + // Validation côté client + this.validateWorkflowData(workflowData); + + try { + const response = await this.makeRequest<{ workflowId: string; id: string }>('/workflows/', { + method: 'POST', + body: JSON.stringify(workflowData), + }); + + if (response.offline) { + console.warn('Sauvegarde impossible - API hors ligne'); + return null; + } + + return response.data?.workflowId || response.data?.id || ''; + } catch (error) { + console.error('Erreur lors de la sauvegarde du workflow:', error); + throw error; + } + } + + /** + * Mettre à jour un workflow existant (auto-save) + * Utilise PUT au lieu de POST + */ + async updateWorkflow(workflowId: string, workflowData: Partial): Promise { + if (!workflowId || workflowId.trim().length === 0) { + throw new Error('L\'ID du workflow est obligatoire'); + } + + try { + const response = await this.makeRequest<{ success: boolean }>(`/workflows/${workflowId}`, { + method: 'PUT', + body: JSON.stringify(workflowData), + }); + + if (response.offline) { + console.warn('Mise à jour impossible - API hors ligne'); + return false; + } + + return response.success; + } catch (error) { + console.error('Erreur lors de la mise à jour du workflow:', error); + throw error; + } + } + + /** + * Supprimer un workflow + */ + async deleteWorkflow(workflowId: string): Promise { + if (!workflowId || workflowId.trim().length === 0) { + throw new Error('L\'ID du workflow est obligatoire'); + } + + try { + const response = await this.makeRequest(`/workflows/${workflowId}`, { + method: 'DELETE', + }); + return !response.offline && response.success; + } catch (error) { + console.error(`Erreur lors de la suppression du workflow ${workflowId}:`, error); + return false; + } + } + + // === MÉTHODES POUR L'EXÉCUTION === + + /** + * Exécuter une étape de workflow + */ + async executeStep(stepData: { + stepId: string; + stepType: string; + parameters: any; + workflowId?: string; + }): Promise<{ success: boolean; output?: any; error?: string; offline?: boolean }> { + // Validation côté client + this.validateStepData(stepData); + + try { + const response = await this.makeRequest<{ + success: boolean; + output?: any; + error?: string; + }>('/workflow/execute-step', { + method: 'POST', + body: JSON.stringify(stepData), + }); + + if (response.offline) { + return { success: false, error: 'API hors ligne', offline: true }; + } + + return response.data || { success: false, error: 'Réponse invalide du serveur' }; + } catch (error) { + console.error('Erreur lors de l\'exécution de l\'étape:', error); + return { success: false, error: (error as ApiError).message || 'Erreur inconnue' }; + } + } + + /** + * Exécuter un workflow complet + */ + async executeWorkflow(workflowId: string, parameters?: any): Promise<{ + success: boolean; + results?: any[]; + error?: string; + offline?: boolean; + }> { + if (!workflowId || workflowId.trim().length === 0) { + throw new Error('L\'ID du workflow est obligatoire'); + } + + try { + const response = await this.makeRequest<{ + success: boolean; + results?: any[]; + error?: string; + }>('/workflow/execute', { + method: 'POST', + body: JSON.stringify({ + workflowId, + parameters: parameters || {}, + }), + }); + + if (response.offline) { + return { success: false, error: 'API hors ligne', offline: true }; + } + + return response.data || { success: false, error: 'Réponse invalide du serveur' }; + } catch (error) { + console.error(`Erreur lors de l'exécution du workflow ${workflowId}:`, error); + return { success: false, error: (error as ApiError).message || 'Erreur inconnue' }; + } + } + + // === MÉTHODES POUR LA VALIDATION === + + /** + * Valider un workflow + */ + async validateWorkflow(workflowData: WorkflowApiData): Promise<{ + isValid: boolean; + errors: string[]; + warnings: string[]; + offline?: boolean; + }> { + // Validation côté client d'abord + try { + this.validateWorkflowData(workflowData); + } catch (error) { + return { + isValid: false, + errors: [(error as ApiError).message], + warnings: [], + }; + } + + try { + const response = await this.makeRequest<{ + isValid: boolean; + errors: string[]; + warnings: string[]; + }>('/workflow/validate', { + method: 'POST', + body: JSON.stringify(workflowData), + }); + + if (response.offline) { + // En mode hors ligne, faire une validation locale basique + return { + isValid: true, + errors: [], + warnings: ['Validation serveur non disponible (mode hors ligne)'], + offline: true, + }; + } + + return response.data || { + isValid: false, + errors: ['Erreur de validation du serveur'], + warnings: [], + }; + } catch (error) { + console.warn('Erreur lors de la validation du workflow:', error); + return { + isValid: true, + errors: [], + warnings: ['Validation serveur non disponible'], + }; + } + } + + // === MÉTHODES UTILITAIRES === + + /** + * Vérifier la santé de l'API + */ + async healthCheck(): Promise<{ status: string; timestamp: string; offline?: boolean }> { + try { + const response = await this.makeRequest<{ status: string; timestamp: string }>('/health'); + if (response.offline) { + return { status: 'offline', timestamp: new Date().toISOString(), offline: true }; + } + return response.data || { status: 'unknown', timestamp: new Date().toISOString() }; + } catch (error) { + return { status: 'offline', timestamp: new Date().toISOString(), offline: true }; + } + } + + /** + * Forcer une vérification de connexion + */ + async forceConnectionCheck(): Promise { + this.lastHealthCheck = 0; // Réinitialiser pour forcer la vérification + return this.checkConnectionSilently(); + } + + /** + * Obtenir les statistiques de l'API + */ + async getApiStats(): Promise { + try { + const response = await this.makeRequest('/stats'); + if (response.offline) { + return { offline: true }; + } + return response.data || {}; + } catch (error) { + console.warn('Erreur lors de la récupération des statistiques:', error); + return { offline: true }; + } + } +} + +// Instance singleton du client API +export const apiClient = new ApiClient(); + +// NOTE: L'initialisation est maintenant paresseuse (lazy) +// Elle se fait automatiquement lors du premier appel API +// Cela évite les boucles infinies au chargement de la page + +// Export des types pour utilisation externe +export type { ApiError, ApiResponse, ApiClientConfig, ConnectionState }; +export default ApiClient;