diff --git a/visual_workflow_builder/frontend_v4/src/App.tsx b/visual_workflow_builder/frontend_v4/src/App.tsx index c39e92d32..e74d9678b 100644 --- a/visual_workflow_builder/frontend_v4/src/App.tsx +++ b/visual_workflow_builder/frontend_v4/src/App.tsx @@ -1,13 +1,15 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { ReactFlow, Controls, Background, useNodesState, useEdgesState, + useReactFlow, + addEdge, ReactFlowProvider, } from '@xyflow/react'; -import type { Node, Edge, NodeTypes } from '@xyflow/react'; +import type { Node, Edge, NodeTypes, Connection } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import * as api from './services/api'; @@ -27,6 +29,7 @@ import type { Variable } from './components/VariableManager'; import CaptureLibrary from './components/CaptureLibrary'; import SelfHealingDialog from './components/SelfHealingDialog'; import ConfidenceDashboard from './components/ConfidenceDashboard'; +import WorkflowValidation from './components/WorkflowValidation'; const nodeTypes: NodeTypes = { step: StepNode, @@ -43,9 +46,16 @@ function App() { const [isExecutionRunning, setIsExecutionRunning] = useState(false); const [detectionZone, setDetectionZone] = useState<{x: number; y: number; width: number; height: number} | null>(null); const [variables, setVariables] = useState([]); + const [runtimeVariables, setRuntimeVariables] = useState>({}); const [showWorkflowManager, setShowWorkflowManager] = useState(false); const [currentCapture, setCurrentCapture] = useState(null); + // React Flow instance pour screenToFlowPosition + const reactFlowInstance = useReactFlow(); + + // Tracker le workflow chargé pour ne pas écraser les edges manuelles + const loadedWorkflowIdRef = useRef(null); + // Self-healing interactif const [showSelfHealing, setShowSelfHealing] = useState(false); const [healingCandidates, setHealingCandidates] = useState([]); @@ -56,7 +66,10 @@ function App() { try { const state = await api.getState(); setAppState(state); - updateNodesFromWorkflow(state.workflow?.steps || []); + updateNodesFromWorkflow( + state.workflow?.steps || [], + state.workflow?.id + ); } catch (err) { setError((err as Error).message); } @@ -75,6 +88,11 @@ function App() { const status = await api.getExecutionStatus(); setIsExecutionRunning(status.is_running); + // Extraire les variables runtime du status d'exécution + if (status.variables && typeof status.variables === 'object') { + setRuntimeVariables(status.variables as Record); + } + // Self-healing interactif: detecter si on attend un choix utilisateur if (status.waiting_for_choice && status.candidates) { setHealingCandidates(status.candidates); @@ -100,7 +118,9 @@ function App() { }, [isExecutionRunning, loadState]); // Convertir les étapes en nœuds React Flow - const updateNodesFromWorkflow = (steps: Step[]) => { + // Les edges ne sont générées automatiquement que lors du premier chargement + // d'un workflow. Ensuite, les connexions manuelles de l'utilisateur sont préservées. + const updateNodesFromWorkflow = (steps: Step[], workflowId?: string) => { const newNodes: Node[] = steps.map((step, index) => ({ id: step.id, type: 'step', @@ -108,22 +128,29 @@ function App() { data: { step }, })); - const newEdges: Edge[] = []; - for (let i = 0; i < steps.length - 1; i++) { - newEdges.push({ - id: `e-${steps[i].id}-${steps[i + 1].id}`, - source: steps[i].id, - sourceHandle: 'bottom', - target: steps[i + 1].id, - targetHandle: 'top', - type: 'smoothstep', - animated: false, - style: { strokeWidth: 2 }, - }); - } - setNodes(newNodes); - setEdges(newEdges); + + // Ne régénérer les edges QUE si on charge un workflow différent + const isNewWorkflow = workflowId && workflowId !== loadedWorkflowIdRef.current; + if (isNewWorkflow) { + loadedWorkflowIdRef.current = workflowId; + + const newEdges: Edge[] = []; + for (let i = 0; i < steps.length - 1; i++) { + newEdges.push({ + id: `e-${steps[i].id}-${steps[i + 1].id}`, + source: steps[i].id, + sourceHandle: 'bottom', + target: steps[i + 1].id, + targetHandle: 'top', + type: 'smoothstep', + animated: false, + style: { strokeWidth: 2 }, + }); + } + setEdges(newEdges); + } + // Sinon : les edges existantes sont conservées (connexions manuelles préservées) }; // Actions @@ -316,22 +343,48 @@ function App() { } }; - // Drop d'un outil sur le canvas + // Connexion entre deux nœuds (drag d'un handle à un autre) + const onConnect = useCallback( + (connection: Connection) => { + setEdges((eds) => + addEdge( + { + ...connection, + type: 'smoothstep', + animated: false, + style: { strokeWidth: 2 }, + }, + eds + ) + ); + }, + [setEdges] + ); + + // Suppression d'edges (touche Suppr/Backspace) + const onEdgesDelete = useCallback( + (deletedEdges: Edge[]) => { + console.log(`🗑️ ${deletedEdges.length} liaison(s) supprimée(s)`); + }, + [] + ); + + // Drop d'un outil sur le canvas (position corrigée avec zoom/pan) const onDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); const actionType = event.dataTransfer.getData('actionType') as ActionType; if (!actionType) return; - const reactFlowBounds = event.currentTarget.getBoundingClientRect(); - const position = { - x: event.clientX - reactFlowBounds.left, - y: event.clientY - reactFlowBounds.top, - }; + // Utiliser screenToFlowPosition pour tenir compte du zoom et du pan + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); handleAddStep(actionType, position); }, - [appState] + [appState, reactFlowInstance] ); const onDragOver = useCallback((event: React.DragEvent) => { @@ -356,6 +409,9 @@ function App() { onOpenManager={() => setShowWorkflowManager(true)} onRename={handleRenameWorkflow} /> + + {/* Erreur */} @@ -389,9 +449,12 @@ function App() { edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} + onConnect={onConnect} + onEdgesDelete={onEdgesDelete} onNodeClick={(_, node) => handleSelectStep(node.id)} onNodeDragStop={handleNodeDragStop} nodeTypes={nodeTypes} + deleteKeyCode="Delete" fitView > @@ -430,6 +493,8 @@ function App() { onVariableCreate={handleVariableCreate} onVariableUpdate={handleVariableUpdate} onVariableDelete={handleVariableDelete} + steps={appState?.workflow?.steps || []} + runtimeVariables={runtimeVariables} /> @@ -473,11 +538,7 @@ function App() { }} /> - {/* Confidence Dashboard - scores en temps reel */} - + {/* ConfidenceDashboard déplacé dans le header */} ); } diff --git a/visual_workflow_builder/frontend_v4/src/components/AIModelSelector.tsx b/visual_workflow_builder/frontend_v4/src/components/AIModelSelector.tsx new file mode 100644 index 000000000..c4fb3552d --- /dev/null +++ b/visual_workflow_builder/frontend_v4/src/components/AIModelSelector.tsx @@ -0,0 +1,238 @@ +/** + * Composant de sélection de modèle IA avec listing Ollama dynamique + * Propose des modèles recommandés selon le type de tâche + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + listModels, + getRecommendedModels, + checkOllamaStatus, + AI_TASK_TYPES, + MODEL_RECOMMENDATIONS, + type OllamaModelInfo, + type AITaskType, +} from '../services/ollamaService'; + +interface Props { + taskType: AITaskType; + selectedModel: string; + onModelChange: (model: string) => void; + needsVision?: boolean; +} + +export default function AIModelSelector({ + taskType, + selectedModel, + onModelChange, + needsVision = false, +}: Props) { + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [ollamaStatus, setOllamaStatus] = useState<{ available: boolean; version?: string }>({ available: false }); + const [showAllModels, setShowAllModels] = useState(false); + const [recommendedModels, setRecommendedModels] = useState<{ + visionModels: OllamaModelInfo[]; + textModels: OllamaModelInfo[]; + }>({ visionModels: [], textModels: [] }); + + // Ref stable pour onModelChange (évite les boucles infinies dans les effets) + const onModelChangeRef = useRef(onModelChange); + useEffect(() => { onModelChangeRef.current = onModelChange; }); + + // Charger le statut Ollama et les modèles (données uniquement, pas d'auto-sélection) + const loadModels = useCallback(async () => { + setLoading(true); + try { + const status = await checkOllamaStatus(); + setOllamaStatus(status); + + if (status.available) { + const allModels = await listModels(); + setModels(allModels); + + const recommended = await getRecommendedModels(taskType); + setRecommendedModels({ + visionModels: recommended.visionModels, + textModels: recommended.textModels, + }); + } + } catch (err) { + console.error('Erreur chargement modèles:', err); + } finally { + setLoading(false); + } + }, [taskType]); + + useEffect(() => { + loadModels(); + }, [loadModels]); + + // Auto-correction synchrone : quand le modèle sélectionné est incompatible + // avec le mode actuel (vision vs texte), on sélectionne automatiquement + // le premier modèle recommandé compatible + useEffect(() => { + if (loading || models.length === 0) return; + + const currentInfo = models.find(m => m.name === selectedModel); + const isIncompatible = currentInfo && + (needsVision ? !currentInfo.isVision : currentInfo.isVision); + + if (!selectedModel || isIncompatible) { + const recs = MODEL_RECOMMENDATIONS[taskType]; + const defaultName = needsVision ? recs?.vision?.[0] : recs?.text?.[0]; + + if (defaultName) { + const found = models.find(m => m.name.includes(defaultName.split(':')[0])); + if (found) { onModelChangeRef.current(found.name); return; } + } + + // Fallback : premier modèle compatible + const fallback = models.find(m => needsVision ? m.isVision : !m.isVision); + if (fallback) onModelChangeRef.current(fallback.name); + else if (models.length > 0) onModelChangeRef.current(models[0].name); + } + }, [loading, models, needsVision, taskType, selectedModel]); + + const taskInfo = AI_TASK_TYPES.find(t => t.id === taskType); + const recommendations = MODEL_RECOMMENDATIONS[taskType]; + + // Filtrer les modèles à afficher + const filteredModels = needsVision + ? recommendedModels.visionModels + : showAllModels + ? models + : recommendedModels.textModels; + + // Vérifier si le modèle sélectionné est dans les options visibles du dropdown + const selectedModelInOptions = selectedModel && ( + filteredModels.some(m => m.name === selectedModel) || + (showAllModels && models.some(m => m.name === selectedModel)) + ); + + if (loading) { + return ( +
+
Chargement des modèles...
+
+ ); + } + + if (!ollamaStatus.available) { + return ( +
+
+ ⚠️ + Ollama non disponible +
+ +
+ ); + } + + return ( +
+ {/* Info sur le type de tâche */} +
+ {taskInfo?.icon} + {recommendations?.description} +
+ + {/* Sélecteur de modèle */} +
+ + HTML fonctionne correctement */} + {selectedModel && !selectedModelInOptions && ( + + )} + + {/* Groupe des modèles recommandés */} + {recommendedModels.visionModels.length > 0 && needsVision && ( + + {recommendedModels.visionModels.slice(0, 3).map(m => ( + + ))} + + )} + + {recommendedModels.textModels.length > 0 && !needsVision && ( + + {recommendedModels.textModels.slice(0, 3).map(m => ( + + ))} + + )} + + {/* Tous les modèles si demandé */} + {showAllModels && ( + + {models + .filter(m => needsVision ? m.isVision : !m.isVision) + .filter(m => !recommendedModels.textModels.slice(0, 3).some(r => r.name === m.name)) + .filter(m => !recommendedModels.visionModels.slice(0, 3).some(r => r.name === m.name)) + .map(m => ( + + ))} + + )} + + )} + +
+ + {/* Toggle pour voir tous les modèles */} +
+ + + {needsVision && ( + 👁️ Vision requise + )} +
+ + {/* Modèle sélectionné */} + {selectedModel && ( +
+ Sélectionné: + {selectedModel} +
+ )} + + {/* Statut Ollama */} +
+ + Ollama v{ollamaStatus.version} + +
+
+ ); +} diff --git a/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx b/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx index c2bc95056..7bab752fc 100644 --- a/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx +++ b/visual_workflow_builder/frontend_v4/src/components/ConfidenceDashboard.tsx @@ -1,11 +1,11 @@ /** * Confidence Dashboard Component * - * Affiche les scores de confiance en temps réel pendant l'exécution. - * Montre CLIP score, template score, distance et méthode utilisée. + * Badge compact dans le header avec dropdown pour les scores de confiance. + * S'affiche uniquement en mode intelligent/debug. */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; interface StepScore { stepIndex: number; @@ -27,7 +27,19 @@ interface Props { export default function ConfidenceDashboard({ isExecutionRunning, executionMode }: Props) { const [scores, setScores] = useState([]); const [currentStep, setCurrentStep] = useState(0); - const [isExpanded, setIsExpanded] = useState(true); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Fermer le dropdown au clic extérieur + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); // Polling pour les scores en temps réel useEffect(() => { @@ -41,7 +53,6 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode if (data.success && data.execution) { setCurrentStep(data.execution.current_step_index || 0); - // Si on a des resultats d'etapes, les ajouter if (data.execution.step_results) { const newScores: StepScore[] = data.execution.step_results.map((result: any, index: number) => ({ stepIndex: index, @@ -66,27 +77,19 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode return () => clearInterval(interval); }, [isExecutionRunning]); - // Reset quand l'execution s'arrete - useEffect(() => { - if (!isExecutionRunning) { - // Garder les scores pour review - } - }, [isExecutionRunning]); - if (executionMode === 'basic') { - return null; // Pas de dashboard en mode basic + return null; } const getConfidenceColor = (confidence: number): string => { - if (confidence >= 0.8) return '#a6e3a1'; // Vert - if (confidence >= 0.5) return '#f9e2af'; // Jaune - return '#f38ba8'; // Rouge + if (confidence >= 0.8) return '#a6e3a1'; + if (confidence >= 0.5) return '#f9e2af'; + return '#f38ba8'; }; const getMethodIcon = (method: string): string => { switch (method) { - case 'clip': return '🧠'; - case 'clip_embedding': return '🧠'; + case 'clip': case 'clip_embedding': return '🧠'; case 'zoned_template': return '📍'; case 'direct_template': return '🔍'; case 'seeclick_grounding': return '🎯'; @@ -104,278 +107,238 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode ? (scores.filter(s => s.success).length / scores.length) * 100 : 0; - return ( -
-
setIsExpanded(!isExpanded)}> -
- 📊 - Scores de confiance - {isExecutionRunning && ( - LIVE - )} -
-
- {isExpanded ? '▼' : '▶'} -
-
+ const avgPct = (averageConfidence * 100).toFixed(0); - {isExpanded && ( -
- {/* Metriques globales */} -
-
- Etape actuelle - {currentStep + 1} + return ( +
+ {/* Badge compact dans le header */} + + + {/* Dropdown avec les détails */} + {isOpen && ( +
+ {/* Métriques globales */} +
+
+ Étape + {currentStep + 1}
-
- Confiance moy. - - {(averageConfidence * 100).toFixed(0)}% +
+ Confiance + + {avgPct}%
-
- Taux succes - +
+ Succès + {successRate.toFixed(0)}%
- {/* Liste des scores par etape */} -
+ {/* Liste des scores */} +
{scores.length === 0 ? ( -
- {isExecutionRunning - ? "En attente de resultats..." - : "Aucune execution en cours"} +
+ {isExecutionRunning ? "En attente..." : "Aucune exécution"}
) : ( scores.map((score) => (
-
- #{score.stepIndex + 1} - {getMethodIcon(score.method)} -
-
- {score.method} - {score.distance !== undefined && ( - {score.distance.toFixed(0)}px - )} -
+ #{score.stepIndex + 1} + {getMethodIcon(score.method)} + {score.method} + {score.distance !== undefined && ( + {score.distance.toFixed(0)}px + )}
- - {(score.confidence * 100).toFixed(0)}% - + {(score.confidence * 100).toFixed(0)}%
)) )}
- {/* Legende */} -
- 🧠 CLIP - 📍 Template zone - 🎯 SeeClick - 📌 Static + {/* Légende */} +
+ 🧠 CLIP + 📍 Template + 🎯 SeeClick + 📌 Static
)}