feat(vwb-frontend): Sélecteur modèle IA, validation workflow et variables

Nouveaux composants:
- AIModelSelector: sélection du modèle Ollama avec détection auto
- WorkflowValidation: validation des étapes avant exécution
- ollamaService: service de communication avec Ollama (liste modèles)

Améliorations:
- PropertiesPanel: intégration sélecteur IA, champs prompt/température
- VariableManager: support variables runtime et substitution {{var}}
- ConfidenceDashboard: refactoring et simplification
- App.tsx: routing et intégration des nouveaux composants
- api.ts: endpoints validate et export-training
- types.ts: types pour modèles IA et validation
- styles.css: styles pour les nouveaux composants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-02-17 10:56:40 +01:00
parent 4c9a6d293f
commit 75260e3254
10 changed files with 2007 additions and 282 deletions

View File

@@ -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<Variable[]>([]);
const [runtimeVariables, setRuntimeVariables] = useState<Record<string, unknown>>({});
const [showWorkflowManager, setShowWorkflowManager] = useState(false);
const [currentCapture, setCurrentCapture] = useState<Capture | null>(null);
// React Flow instance pour screenToFlowPosition
const reactFlowInstance = useReactFlow();
// Tracker le workflow chargé pour ne pas écraser les edges manuelles
const loadedWorkflowIdRef = useRef<string | null>(null);
// Self-healing interactif
const [showSelfHealing, setShowSelfHealing] = useState(false);
const [healingCandidates, setHealingCandidates] = useState<any[]>([]);
@@ -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<string, unknown>);
}
// 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}
/>
<WorkflowValidation
workflowId={appState?.session.active_workflow_id}
/>
<ExecutionModeToggle
mode={executionMode}
onChange={setExecutionMode}
@@ -365,6 +421,10 @@ function App() {
onStart={handleStartExecution}
onStop={handleStopExecution}
/>
<ConfidenceDashboard
isExecutionRunning={isExecutionRunning}
executionMode={executionMode}
/>
</header>
{/* 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
>
<Controls />
@@ -430,6 +493,8 @@ function App() {
onVariableCreate={handleVariableCreate}
onVariableUpdate={handleVariableUpdate}
onVariableDelete={handleVariableDelete}
steps={appState?.workflow?.steps || []}
runtimeVariables={runtimeVariables}
/>
</aside>
</div>
@@ -473,11 +538,7 @@ function App() {
}}
/>
{/* Confidence Dashboard - scores en temps reel */}
<ConfidenceDashboard
isExecutionRunning={isExecutionRunning}
executionMode={executionMode}
/>
{/* ConfidenceDashboard déplacé dans le header */}
</div>
);
}

View File

@@ -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<OllamaModelInfo[]>([]);
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 (
<div className="ai-model-selector loading">
<div className="loading-spinner">Chargement des modèles...</div>
</div>
);
}
if (!ollamaStatus.available) {
return (
<div className="ai-model-selector error">
<div className="status-error">
<span className="icon"></span>
<span>Ollama non disponible</span>
</div>
<button className="btn-retry" onClick={loadModels}>
Réessayer
</button>
</div>
);
}
return (
<div className="ai-model-selector">
{/* Info sur le type de tâche */}
<div className="task-info">
<span className="task-icon">{taskInfo?.icon}</span>
<span className="task-description">{recommendations?.description}</span>
</div>
{/* Sélecteur de modèle */}
<div className="model-select-container">
<label>Modèle Ollama</label>
<select
value={selectedModel}
onChange={(e) => onModelChange(e.target.value)}
className="model-select"
>
{filteredModels.length === 0 && !selectedModel ? (
<option value="">Aucun modèle disponible</option>
) : (
<>
{/* Option de secours : si le modèle sélectionné n'apparaît pas
dans les options visibles, on l'ajoute en tant qu'option
désactivée pour que le <select> HTML fonctionne correctement */}
{selectedModel && !selectedModelInOptions && (
<option value={selectedModel} disabled>
{selectedModel} (changement en cours...)
</option>
)}
{/* Groupe des modèles recommandés */}
{recommendedModels.visionModels.length > 0 && needsVision && (
<optgroup label="Recommandés (Vision)">
{recommendedModels.visionModels.slice(0, 3).map(m => (
<option key={m.name} value={m.name}>
{m.displayName} ({m.size}) {recommendations?.vision.some(r => m.name.includes(r.split(':')[0])) ? '★' : ''}
</option>
))}
</optgroup>
)}
{recommendedModels.textModels.length > 0 && !needsVision && (
<optgroup label="Recommandés">
{recommendedModels.textModels.slice(0, 3).map(m => (
<option key={m.name} value={m.name}>
{m.displayName} ({m.size}) {recommendations?.text.some(r => m.name.includes(r.split(':')[0])) ? '★' : ''}
</option>
))}
</optgroup>
)}
{/* Tous les modèles si demandé */}
{showAllModels && (
<optgroup label="Autres modèles">
{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 => (
<option key={m.name} value={m.name}>
{m.displayName} ({m.size}) {m.isVision ? '👁️' : ''}
</option>
))}
</optgroup>
)}
</>
)}
</select>
</div>
{/* Toggle pour voir tous les modèles */}
<div className="model-options">
<label className="checkbox-label">
<input
type="checkbox"
checked={showAllModels}
onChange={(e) => setShowAllModels(e.target.checked)}
/>
Afficher tous les modèles
</label>
{needsVision && (
<span className="vision-badge">👁 Vision requise</span>
)}
</div>
{/* Modèle sélectionné */}
{selectedModel && (
<div className="selected-model-info">
<span className="label">Sélectionné:</span>
<span className="model-name">{selectedModel}</span>
</div>
)}
{/* Statut Ollama */}
<div className="ollama-status">
<span className="status-dot available" />
<span className="status-text">Ollama v{ollamaStatus.version}</span>
<button className="btn-refresh" onClick={loadModels} title="Rafraîchir">
🔄
</button>
</div>
</div>
);
}

View File

@@ -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<StepScore[]>([]);
const [currentStep, setCurrentStep] = useState<number>(0);
const [isExpanded, setIsExpanded] = useState(true);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="confidence-dashboard">
<div className="dashboard-header" onClick={() => setIsExpanded(!isExpanded)}>
<div className="header-left">
<span className="dashboard-icon">📊</span>
<span className="dashboard-title">Scores de confiance</span>
{isExecutionRunning && (
<span className="live-indicator">LIVE</span>
)}
</div>
<div className="header-right">
<span className="toggle-icon">{isExpanded ? '▼' : '▶'}</span>
</div>
</div>
const avgPct = (averageConfidence * 100).toFixed(0);
{isExpanded && (
<div className="dashboard-content">
{/* Metriques globales */}
<div className="metrics-row">
<div className="metric">
<span className="metric-label">Etape actuelle</span>
<span className="metric-value">{currentStep + 1}</span>
return (
<div className="confidence-header-widget" ref={dropdownRef}>
{/* Badge compact dans le header */}
<button className="confidence-badge" onClick={() => setIsOpen(!isOpen)}>
<span className="badge-icon">📊</span>
{scores.length > 0 ? (
<>
<span className="badge-value" style={{ color: getConfidenceColor(averageConfidence) }}>
{avgPct}%
</span>
{isExecutionRunning && <span className="badge-live">LIVE</span>}
</>
) : (
<span className="badge-label">Confiance</span>
)}
<span className={`badge-arrow ${isOpen ? 'open' : ''}`}></span>
</button>
{/* Dropdown avec les détails */}
{isOpen && (
<div className="confidence-dropdown">
{/* Métriques globales */}
<div className="cd-metrics">
<div className="cd-metric">
<span className="cd-metric-label">Étape</span>
<span className="cd-metric-value">{currentStep + 1}</span>
</div>
<div className="metric">
<span className="metric-label">Confiance moy.</span>
<span
className="metric-value"
style={{ color: getConfidenceColor(averageConfidence) }}
>
{(averageConfidence * 100).toFixed(0)}%
<div className="cd-metric">
<span className="cd-metric-label">Confiance</span>
<span className="cd-metric-value" style={{ color: getConfidenceColor(averageConfidence) }}>
{avgPct}%
</span>
</div>
<div className="metric">
<span className="metric-label">Taux succes</span>
<span
className="metric-value"
style={{ color: getConfidenceColor(successRate / 100) }}
>
<div className="cd-metric">
<span className="cd-metric-label">Succès</span>
<span className="cd-metric-value" style={{ color: getConfidenceColor(successRate / 100) }}>
{successRate.toFixed(0)}%
</span>
</div>
</div>
{/* Liste des scores par etape */}
<div className="scores-list">
{/* Liste des scores */}
<div className="cd-scores-list">
{scores.length === 0 ? (
<div className="no-scores">
{isExecutionRunning
? "En attente de resultats..."
: "Aucune execution en cours"}
<div className="cd-empty">
{isExecutionRunning ? "En attente..." : "Aucune exécution"}
</div>
) : (
scores.map((score) => (
<div
key={score.stepIndex}
className={`score-item ${score.success ? 'success' : 'error'} ${score.stepIndex === currentStep ? 'current' : ''}`}
className={`cd-score-item ${score.success ? 'success' : 'error'} ${score.stepIndex === currentStep ? 'current' : ''}`}
>
<div className="score-step">
<span className="step-number">#{score.stepIndex + 1}</span>
<span className="method-icon">{getMethodIcon(score.method)}</span>
</div>
<div className="score-details">
<span className="method-name">{score.method}</span>
{score.distance !== undefined && (
<span className="distance">{score.distance.toFixed(0)}px</span>
)}
</div>
<span className="cd-step">#{score.stepIndex + 1}</span>
<span className="cd-method-icon">{getMethodIcon(score.method)}</span>
<span className="cd-method-name">{score.method}</span>
{score.distance !== undefined && (
<span className="cd-distance">{score.distance.toFixed(0)}px</span>
)}
<div
className="confidence-bar"
className="cd-bar"
style={{
'--confidence': `${score.confidence * 100}%`,
'--confidence-color': getConfidenceColor(score.confidence)
} as React.CSSProperties}
>
<span className="confidence-value">
{(score.confidence * 100).toFixed(0)}%
</span>
<span className="cd-bar-value">{(score.confidence * 100).toFixed(0)}%</span>
</div>
</div>
))
)}
</div>
{/* Legende */}
<div className="legend">
<span className="legend-item">🧠 CLIP</span>
<span className="legend-item">📍 Template zone</span>
<span className="legend-item">🎯 SeeClick</span>
<span className="legend-item">📌 Static</span>
{/* Légende */}
<div className="cd-legend">
<span>🧠 CLIP</span>
<span>📍 Template</span>
<span>🎯 SeeClick</span>
<span>📌 Static</span>
</div>
</div>
)}
<style>{`
.confidence-dashboard {
position: fixed;
bottom: 20px;
right: 20px;
width: 320px;
background: #1e1e2e;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
border: 1px solid #313244;
overflow: hidden;
z-index: 1000;
font-size: 13px;
.confidence-header-widget {
position: relative;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #313244;
cursor: pointer;
}
.header-left {
.confidence-badge {
display: flex;
align-items: center;
gap: 8px;
}
.dashboard-icon {
font-size: 16px;
}
.dashboard-title {
font-weight: 600;
color: #cdd6f4;
}
.live-indicator {
padding: 2px 6px;
background: #f38ba8;
color: #1e1e2e;
border-radius: 4px;
font-size: 10px;
font-weight: bold;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.toggle-icon {
color: #a6adc8;
font-size: 10px;
}
.dashboard-content {
padding: 12px;
}
.metrics-row {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #313244;
}
.metric {
text-align: center;
}
.metric-label {
display: block;
font-size: 10px;
color: #a6adc8;
margin-bottom: 4px;
text-transform: uppercase;
}
.metric-value {
font-size: 18px;
font-weight: bold;
color: #cdd6f4;
}
.scores-list {
max-height: 200px;
overflow-y: auto;
}
.no-scores {
text-align: center;
color: #a6adc8;
padding: 20px;
font-style: italic;
}
.score-item {
display: flex;
align-items: center;
padding: 8px;
margin-bottom: 4px;
background: #313244;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
gap: 8px;
color: #cdd6f4;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.score-item.current {
border: 1px solid #89b4fa;
background: rgba(137, 180, 250, 0.1);
.confidence-badge:hover {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.35);
}
.score-item.error {
border-left: 3px solid #f38ba8;
}
.badge-icon { font-size: 14px; }
.score-item.success {
border-left: 3px solid #a6e3a1;
}
.score-step {
display: flex;
align-items: center;
gap: 4px;
min-width: 50px;
}
.step-number {
color: #89b4fa;
font-weight: bold;
}
.method-icon {
.badge-value {
font-weight: 700;
font-size: 14px;
}
.score-details {
flex: 1;
.badge-label {
font-size: 12px;
opacity: 0.7;
}
.badge-live {
padding: 1px 5px;
background: #f38ba8;
color: #1e1e2e;
border-radius: 3px;
font-size: 9px;
font-weight: bold;
animation: cd-pulse 1.5s infinite;
}
@keyframes cd-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.badge-arrow {
font-size: 9px;
opacity: 0.6;
transition: transform 0.2s;
}
.badge-arrow.open { transform: rotate(180deg); }
.confidence-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 320px;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 1000;
overflow: hidden;
}
.cd-metrics {
display: flex;
flex-direction: column;
gap: 2px;
justify-content: space-around;
padding: 12px;
background: #313244;
}
.method-name {
color: #cdd6f4;
font-size: 11px;
}
.cd-metric { text-align: center; }
.distance {
color: #fab387;
.cd-metric-label {
display: block;
font-size: 10px;
color: #a6adc8;
text-transform: uppercase;
margin-bottom: 3px;
}
.confidence-bar {
width: 60px;
height: 20px;
.cd-metric-value {
font-size: 18px;
font-weight: 700;
color: #cdd6f4;
}
.cd-scores-list {
max-height: 220px;
overflow-y: auto;
padding: 8px;
}
.cd-empty {
text-align: center;
color: #a6adc8;
padding: 16px;
font-style: italic;
font-size: 12px;
}
.cd-score-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
margin-bottom: 3px;
background: #313244;
border-radius: 5px;
font-size: 12px;
}
.cd-score-item.current { border: 1px solid #89b4fa; background: rgba(137, 180, 250, 0.1); }
.cd-score-item.error { border-left: 3px solid #f38ba8; }
.cd-score-item.success { border-left: 3px solid #a6e3a1; }
.cd-step { color: #89b4fa; font-weight: 700; min-width: 28px; }
.cd-method-icon { font-size: 13px; }
.cd-method-name { flex: 1; color: #cdd6f4; font-size: 11px; }
.cd-distance { color: #fab387; font-size: 10px; }
.cd-bar {
width: 52px;
height: 18px;
background: #45475a;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.confidence-bar::before {
.cd-bar::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
left: 0; top: 0; bottom: 0;
width: var(--confidence);
background: var(--confidence-color);
border-radius: 4px;
transition: width 0.3s;
}
.confidence-value {
.cd-bar-value {
position: relative;
z-index: 1;
display: flex;
@@ -383,20 +346,16 @@ export default function ConfidenceDashboard({ isExecutionRunning, executionMode
justify-content: center;
height: 100%;
font-size: 10px;
font-weight: bold;
font-weight: 700;
color: #1e1e2e;
}
.legend {
.cd-legend {
display: flex;
gap: 12px;
gap: 10px;
justify-content: center;
margin-top: 12px;
padding-top: 8px;
padding: 8px;
border-top: 1px solid #313244;
}
.legend-item {
font-size: 10px;
color: #a6adc8;
}

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import type { Step, ActionType } from '../types';
import { ACTIONS } from '../types';
import { getAnchorThumbnailUrl } from '../services/api';
import AIModelSelector from './AIModelSelector';
import type { AITaskType } from '../services/ollamaService';
interface Props {
step: Step | null;
@@ -147,7 +149,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
value={String(params.text || '')}
onChange={(e) => updateParam('text', e.target.value)}
rows={3}
placeholder="Entrez le texte..."
placeholder={"Entrez le texte...\nSupporte les variables : {{nom_variable}}"}
/>
</div>
<div className="prop-field">
@@ -171,6 +173,16 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
Effacer le champ avant
</label>
</div>
<div className="prop-field">
<label>Stocker dans une variable (optionnel)</label>
<input
type="text"
value={String(params.output_variable || '')}
onChange={(e) => updateParam('output_variable', e.target.value)}
placeholder="nom_variable"
/>
<small className="field-hint">Le texte saisi sera disponible via {'{{nom_variable}}'} dans les étapes suivantes</small>
</div>
</>
);
@@ -442,16 +454,316 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
);
// === IA ===
case 'ai_ocr':
return (
<>
<div className="prop-section-title">
<span className="icon">📝</span> OCR Intelligent
</div>
<AIModelSelector
taskType="ocr"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={true}
/>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="texte_extrait"
/>
</div>
<div className="prop-field">
<label>Langue</label>
<select
value={String(params.language || 'fr')}
onChange={(e) => updateParam('language', e.target.value)}
>
<option value="fr">Français</option>
<option value="en">Anglais</option>
<option value="auto">Auto-détection</option>
</select>
</div>
<div className="prop-field">
<label>Post-traitement</label>
<select
value={String(params.post_process || 'none')}
onChange={(e) => updateParam('post_process', e.target.value)}
>
<option value="none">Aucun</option>
<option value="clean">Nettoyer espaces</option>
<option value="structure">Structurer (JSON)</option>
</select>
</div>
</>
);
case 'ai_summarize':
return (
<>
<div className="prop-section-title">
<span className="icon">📋</span> Résumé IA
</div>
<AIModelSelector
taskType="summarize"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={false}
/>
<div className="prop-field">
<label>Texte source (variable ou direct)</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={3}
placeholder="${variable_texte} ou texte direct"
/>
</div>
<div className="prop-field">
<label>Longueur du résumé</label>
<select
value={String(params.summary_length || 'medium')}
onChange={(e) => updateParam('summary_length', e.target.value)}
>
<option value="short">Court (1-2 phrases)</option>
<option value="medium">Moyen (1 paragraphe)</option>
<option value="long">Long (détaillé)</option>
</select>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="resume_texte"
/>
</div>
</>
);
case 'ai_extract':
return (
<>
<div className="prop-section-title">
<span className="icon">🔍</span> Extraction IA
</div>
<AIModelSelector
taskType="extract"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={Boolean(params.use_vision)}
/>
<div className="prop-field">
<label>Instructions d'extraction</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={3}
placeholder="Ex: Extrais le nom, la date et le montant"
/>
</div>
<div className="prop-field">
<label>Format de sortie</label>
<select
value={String(params.output_format || 'json')}
onChange={(e) => updateParam('output_format', e.target.value)}
>
<option value="text">Texte brut</option>
<option value="json">JSON structuré</option>
<option value="csv">CSV</option>
</select>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="donnees_extraites"
/>
</div>
<div className="prop-field checkbox">
<label>
<input
type="checkbox"
checked={Boolean(params.use_vision)}
onChange={(e) => updateParam('use_vision', e.target.checked)}
/>
Utiliser la vision (pour images/captures)
</label>
</div>
</>
);
case 'ai_classify':
return (
<>
<div className="prop-section-title">
<span className="icon">🏷</span> Classification IA
</div>
<AIModelSelector
taskType="classify"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={false}
/>
<div className="prop-field">
<label>Texte à classifier</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={2}
placeholder="${variable_texte} ou texte direct"
/>
</div>
<div className="prop-field">
<label>Catégories (une par ligne)</label>
<textarea
value={String(params.categories || '')}
onChange={(e) => updateParam('categories', e.target.value)}
rows={4}
placeholder="Facture&#10;Devis&#10;Commande&#10;Autre"
/>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.variable_name || '')}
onChange={(e) => updateParam('variable_name', e.target.value)}
placeholder="categorie_detectee"
/>
</div>
</>
);
case 'ai_analyze_text':
return (
<>
<div className="prop-section-title">
<span className="icon">🧠</span> Analyse IA
</div>
<AIModelSelector
taskType="analyze"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={!params.input_text}
/>
<div className="prop-field">
<label>Prompt pour l'IA</label>
<label>Mode d'entrée</label>
<div className="mode-toggle-row">
<button
className={`mode-btn ${!params.input_text ? 'active' : ''}`}
onClick={() => updateParam('input_text', '')}
>
📸 Image (screenshot)
</button>
<button
className={`mode-btn ${params.input_text ? 'active' : ''}`}
onClick={() => updateParam('input_text', params.input_text || ' ')}
>
📝 Texte brut
</button>
</div>
</div>
{params.input_text !== undefined && params.input_text !== '' && (
<div className="prop-field">
<label>Texte à analyser</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={6}
placeholder={"Collez le texte ici ou utilisez une variable :\n{{nom_variable}}"}
/>
<small className="field-hint">Supporte les variables : {'{{resultat_ocr}}'}, {'{{texte_extrait}}'}, etc.</small>
</div>
)}
<div className="prop-field">
<label>Prompt d'analyse</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={4}
placeholder="Ex: Extrais le montant total de cette facture"
placeholder="Ex: Traduis ce texte en français et fais un résumé..."
/>
</div>
<div className="prop-field">
<label>Variable de sortie</label>
<input
type="text"
value={String(params.output_variable || 'resultat_analyse')}
onChange={(e) => updateParam('output_variable', e.target.value)}
placeholder="resultat_analyse"
/>
</div>
<div className="prop-field">
<label>Température ({Number(params.temperature || 0.7).toFixed(1)})</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={Number(params.temperature || 0.7)}
onChange={(e) => updateParam('temperature', Number(e.target.value))}
/>
<small className="field-hint">0 = précis/déterministe, 1 = créatif/varié</small>
</div>
<div className="prop-field">
<label>Tokens max</label>
<select
value={String(params.max_tokens || '-1')}
onChange={(e) => updateParam('max_tokens', Number(e.target.value))}
>
<option value="-1">Illimité (recommandé)</option>
<option value="1000">1 000</option>
<option value="2000">2 000</option>
<option value="4000">4 000</option>
<option value="8000">8 000</option>
</select>
</div>
</>
);
case 'ai_custom':
return (
<>
<div className="prop-section-title">
<span className="icon"></span> IA Personnalisée
</div>
<AIModelSelector
taskType="custom"
selectedModel={String(params.model || '')}
onModelChange={(model) => updateParam('model', model)}
needsVision={Boolean(params.use_vision)}
/>
<div className="prop-field">
<label>Prompt système (contexte)</label>
<textarea
value={String(params.system_prompt || '')}
onChange={(e) => updateParam('system_prompt', e.target.value)}
rows={2}
placeholder="Tu es un assistant expert en..."
/>
</div>
<div className="prop-field">
<label>Prompt utilisateur</label>
<textarea
value={String(params.prompt || '')}
onChange={(e) => updateParam('prompt', e.target.value)}
rows={4}
placeholder="Votre instruction..."
/>
</div>
<div className="prop-field">
<label>Entrée (variable ou texte)</label>
<textarea
value={String(params.input_text || '')}
onChange={(e) => updateParam('input_text', e.target.value)}
rows={2}
placeholder="${variable} ou texte direct"
/>
</div>
<div className="prop-field">
@@ -463,17 +775,15 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
placeholder="resultat_ia"
/>
</div>
<div className="prop-field">
<label>Modèle IA</label>
<select
value={String(params.model || 'auto')}
onChange={(e) => updateParam('model', e.target.value)}
>
<option value="auto">Automatique</option>
<option value="gpt-4">GPT-4</option>
<option value="claude">Claude</option>
<option value="local">Local (Ollama)</option>
</select>
<div className="prop-field checkbox">
<label>
<input
type="checkbox"
checked={Boolean(params.use_vision)}
onChange={(e) => updateParam('use_vision', e.target.checked)}
/>
Utiliser modèle vision
</label>
</div>
</>
);
@@ -615,7 +925,7 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
{/* Ancre visuelle */}
{action?.needsAnchor && (
<div className="prop-anchor">
<label>Ancre visuelle</label>
<label>Ancre visuelle (obligatoire)</label>
{step.anchor_id ? (
<div className="anchor-preview">
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
@@ -629,6 +939,17 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</div>
)}
{/* Ancre optionnelle (pour les actions qui peuvent fonctionner avec ou sans) */}
{!action?.needsAnchor && step.anchor_id && (
<div className="prop-anchor">
<label>Ancre visuelle (optionnelle)</label>
<div className="anchor-preview">
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
<span className="anchor-ok"> Définie</span>
</div>
</div>
)}
<div className="prop-actions">
<button className="btn-save" onClick={handleSave}>
Enregistrer

View File

@@ -1,9 +1,13 @@
/**
* Gestionnaire de Variables simplifié pour VWB v4
* Permet de créer, modifier et supprimer des variables de workflow
* + Section "Variables du workflow" : extraction automatique des variables
* produites (output_variable) et consommées ({{var}}) par les étapes.
*/
import { useState, useCallback } from 'react';
import { useState, useCallback, useMemo } from 'react';
import type { Step } from '../types';
import { ACTIONS } from '../types';
export type VariableType = 'text' | 'number' | 'boolean' | 'list';
@@ -15,11 +19,19 @@ export interface Variable {
description?: string;
}
interface WorkflowVariable {
name: string;
producer: Step | null;
consumers: Step[];
}
interface Props {
variables: Variable[];
onVariableCreate: (data: Omit<Variable, 'id'>) => void;
onVariableUpdate: (id: string, data: Partial<Variable>) => void;
onVariableDelete: (id: string) => void;
steps?: Step[];
runtimeVariables?: Record<string, unknown>;
}
const TYPE_LABELS: Record<VariableType, string> = {
@@ -29,13 +41,67 @@ const TYPE_LABELS: Record<VariableType, string> = {
list: 'Liste',
};
function getActionCategory(actionType: string): string {
const def = ACTIONS.find(a => a.type === actionType);
return def?.category || 'other';
}
function getStepLabel(step: Step): string {
const def = ACTIONS.find(a => a.type === step.action_type);
return step.label || def?.label || step.action_type;
}
function extractWorkflowVariables(steps: Step[]): WorkflowVariable[] {
const vars = new Map<string, WorkflowVariable>();
for (const step of steps) {
const p = step.parameters || {};
// Producteurs : output_variable ou variable_name
const outVar = (p.output_variable || p.variable_name) as string | undefined;
if (outVar && typeof outVar === 'string') {
const existing = vars.get(outVar);
if (existing) {
existing.producer = step;
} else {
vars.set(outVar, { name: outVar, producer: step, consumers: [] });
}
}
// Consommateurs : chercher {{var}} dans toutes les valeurs string des params
for (const val of Object.values(p)) {
if (typeof val === 'string') {
for (const match of val.matchAll(/\{\{(\w+)\}\}/g)) {
const varName = match[1];
const existing = vars.get(varName);
if (existing) {
// Eviter les doublons de consommateurs
if (!existing.consumers.some(c => c.id === step.id)) {
existing.consumers.push(step);
}
} else {
vars.set(varName, { name: varName, producer: null, consumers: [step] });
}
}
}
}
}
return Array.from(vars.values());
}
export default function VariableManager({
variables,
onVariableCreate,
onVariableUpdate,
onVariableDelete,
steps = [],
runtimeVariables = {},
}: Props) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Extraire les variables du workflow depuis les étapes
const workflowVars = useMemo(() => extractWorkflowVariables(steps), [steps]);
const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
const [formData, setFormData] = useState({
name: '',
@@ -209,9 +275,57 @@ export default function VariableManager({
</button>
</div>
{/* Section : Variables du workflow (auto-détectées) */}
{workflowVars.length > 0 && (
<div className="workflow-vars-section">
<div className="workflow-vars-header">
<span className="wv-icon">&#x1f517;</span>
<span>Variables du workflow</span>
<span className="wv-count">{workflowVars.length}</span>
</div>
{workflowVars.map((wv) => {
const category = wv.producer ? getActionCategory(wv.producer.action_type) : 'other';
const runtimeVal = runtimeVariables[wv.name];
return (
<div key={wv.name} className="workflow-var-card">
<div className="var-header">
<span className="var-name">{`{{${wv.name}}}`}</span>
<span className={`var-type-badge type-${category}`}>{category}</span>
</div>
{wv.producer ? (
<div className="var-producer">
<span className="label">Produit par:</span>
<span className="step-ref">{getStepLabel(wv.producer)}</span>
</div>
) : (
<div className="var-no-producer">Pas de producteur detect&eacute;</div>
)}
{wv.consumers.length > 0 && (
<div className="var-consumers">
<span className="label">Utilis&eacute; par:</span>
<span className="step-ref">
{wv.consumers.map(c => getStepLabel(c)).join(', ')}
</span>
</div>
)}
{runtimeVal !== undefined && (
<div className="var-runtime-value">
<span className="runtime-label">Valeur:</span>
{String(runtimeVal).length > 120
? String(runtimeVal).slice(0, 120) + '...'
: String(runtimeVal)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Section : Variables manuelles */}
{variables.length === 0 ? (
<p className="variable-empty-message">
Aucune variable. Créez-en pour rendre votre workflow flexible.
Aucune variable manuelle.
</p>
) : (
<div className="variable-list">

View File

@@ -0,0 +1,171 @@
import { useState } from 'react';
import * as api from '../services/api';
interface WorkflowValidationProps {
workflowId: string | null | undefined;
}
interface ValidationResult {
is_valid: boolean;
errors: string[];
warnings: string[];
step_count: number;
}
export default function WorkflowValidation({ workflowId }: WorkflowValidationProps) {
const [showModal, setShowModal] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ValidationResult | null>(null);
const [exportPath, setExportPath] = useState<string | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
const handleValidate = async () => {
if (!workflowId) return;
setLoading(true);
setResult(null);
setExportPath(null);
setExportError(null);
setShowModal(true);
try {
const data = await api.validateWorkflow(workflowId);
setResult(data);
} catch (err) {
setResult({
is_valid: false,
errors: [(err as Error).message],
warnings: [],
step_count: 0,
});
} finally {
setLoading(false);
}
};
const handleExport = async () => {
if (!workflowId) return;
setExportError(null);
try {
const data = await api.exportWorkflowForTraining(workflowId);
setExportPath(data.export_path);
} catch (err) {
setExportError((err as Error).message);
}
};
const handleClose = () => {
setShowModal(false);
setResult(null);
setExportPath(null);
setExportError(null);
};
return (
<>
<button
className="validation-btn"
onClick={handleValidate}
disabled={!workflowId}
title={workflowId ? 'Valider le workflow' : 'Aucun workflow actif'}
>
<span className="validation-btn-icon">&#10003;</span>
<span className="validation-btn-text">Valider</span>
</button>
{showModal && (
<div className="modal-overlay" onClick={handleClose}>
<div className="validation-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h4>Validation du workflow</h4>
<button className="btn-close" onClick={handleClose}>&times;</button>
</div>
<div className="modal-body">
{loading && (
<div className="validation-loading">Validation en cours...</div>
)}
{result && !loading && (
<>
{/* Status */}
<div className={`validation-status ${result.is_valid ? 'valid' : 'invalid'}`}>
<span className="validation-status-icon">
{result.is_valid ? '\u2705' : '\u274C'}
</span>
<span>
{result.is_valid
? `Workflow valide (${result.step_count} etape${result.step_count > 1 ? 's' : ''})`
: `${result.errors.length} erreur${result.errors.length > 1 ? 's' : ''} trouvee${result.errors.length > 1 ? 's' : ''}`}
</span>
</div>
{/* Erreurs */}
{result.errors.length > 0 && (
<div className="validation-errors">
<h5>Erreurs</h5>
<ul>
{result.errors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
{/* Warnings */}
{result.warnings.length > 0 && (
<div className="validation-warnings">
<h5>Warnings</h5>
<ul>
{result.warnings.map((warn, i) => (
<li key={i}>{warn}</li>
))}
</ul>
</div>
)}
{/* Export */}
{result.is_valid && !exportPath && (
<div className="validation-export">
<button className="btn-primary" onClick={handleExport}>
Exporter pour entrainement
</button>
</div>
)}
{/* Export error */}
{exportError && (
<div className="validation-errors">
<h5>Erreur d'export</h5>
<ul>
<li>{exportError}</li>
</ul>
</div>
)}
{/* Export success */}
{exportPath && (
<div className="validation-success">
<span className="validation-success-icon">&#128230;</span>
<div>
<strong>Export reussi</strong>
<code>{exportPath}</code>
</div>
</div>
)}
{/* Message si invalide */}
{!result.is_valid && (
<div className="validation-hint">
Corrigez les erreurs avant de valider.
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -165,6 +165,8 @@ export async function getExecutionStatus(): Promise<{
is_paused: boolean;
execution: Execution | null;
session: AppState['session'];
// Variables runtime du workflow
variables?: Record<string, unknown>;
// Self-healing interactif
waiting_for_choice?: boolean;
candidates?: Array<{
@@ -191,3 +193,20 @@ export async function submitHealingChoice(
): Promise<{ success: boolean; choice: unknown }> {
return request('POST', '/execute/healing/choose', { choice });
}
// Validation & Export
export async function validateWorkflow(workflowId: string): Promise<{
is_valid: boolean;
errors: string[];
warnings: string[];
step_count: number;
}> {
return request('POST', `/workflow/${workflowId}/validate`);
}
export async function exportWorkflowForTraining(workflowId: string): Promise<{
export_path: string;
training_entry: Record<string, unknown>;
}> {
return request('POST', `/workflow/${workflowId}/export-training`);
}

View File

@@ -0,0 +1,204 @@
/**
* Service Ollama - Gestion des modèles IA locaux
* Liste dynamique des modèles et recommandations par tâche
*/
// Base URL Ollama (localhost car Ollama tourne sur le même serveur)
const OLLAMA_API = 'http://localhost:11434';
export interface OllamaModel {
name: string;
model: string;
modified_at: string;
size: number;
digest: string;
details?: {
parent_model: string;
format: string;
family: string;
families: string[];
parameter_size: string;
quantization_level: string;
};
}
export interface OllamaModelInfo {
name: string;
displayName: string;
size: string;
isVision: boolean;
family: string;
parameterSize: string;
}
// Modèles connus pour être des modèles vision
const VISION_MODEL_PATTERNS = [
'moondream',
'llava',
'bakllava',
'qwen2.5vl',
'qwen2-vl',
'qwen3-vl',
'granite3.2-vision',
'deepseek-ocr',
'cogvlm',
'minicpm-v',
'internvl',
];
// Recommandations par type de tâche
export const MODEL_RECOMMENDATIONS: Record<string, {
vision: string[];
text: string[];
description: string;
}> = {
ocr: {
vision: ['qwen2.5vl:7b', 'qwen2.5vl:3b', 'deepseek-ocr', 'moondream'],
text: [],
description: 'Extraction de texte depuis images/PDF scannés',
},
summarize: {
vision: [],
text: ['gpt-oss:latest', 'mistral-nemo', 'llama3.1', 'qwen2.5:7b'],
description: 'Résumé et synthèse de texte',
},
extract: {
vision: ['qwen2.5vl:7b'],
text: ['gpt-oss:latest', 'mistral-nemo', 'qwen2.5:7b'],
description: 'Extraction de données structurées',
},
classify: {
vision: ['moondream'],
text: ['mistral-nemo', 'gpt-oss:latest', 'qwen2.5:3b'],
description: 'Classification et catégorisation',
},
analyze: {
vision: ['qwen2.5vl:7b', 'qwen3-vl:8b', 'granite3.2-vision:2b'],
text: ['gpt-oss:latest', 'llama3.1', 'mistral-nemo'],
description: 'Analyse complète de document',
},
custom: {
vision: [],
text: [],
description: 'Tâche personnalisée - choisissez votre modèle',
},
};
/**
* Formate la taille en unité lisible
*/
function formatSize(bytes: number): string {
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) {
return `${gb.toFixed(1)} GB`;
}
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(0)} MB`;
}
/**
* Détermine si un modèle est un modèle vision
*/
export function isVisionModel(modelName: string): boolean {
const lowerName = modelName.toLowerCase();
return VISION_MODEL_PATTERNS.some(pattern => lowerName.includes(pattern));
}
/**
* Récupère la liste des modèles Ollama disponibles
*/
export async function listModels(): Promise<OllamaModelInfo[]> {
try {
const response = await fetch(`${OLLAMA_API}/api/tags`);
if (!response.ok) {
throw new Error(`Erreur Ollama: ${response.status}`);
}
const data = await response.json();
const models: OllamaModel[] = data.models || [];
return models.map(m => ({
name: m.name,
displayName: m.name.split(':')[0],
size: formatSize(m.size),
isVision: isVisionModel(m.name),
family: m.details?.family || 'unknown',
parameterSize: m.details?.parameter_size || '',
}));
} catch (err) {
console.error('Erreur listModels:', err);
// Retourner une liste vide en cas d'erreur
return [];
}
}
/**
* Récupère les modèles recommandés pour un type de tâche
*/
export async function getRecommendedModels(
taskType: keyof typeof MODEL_RECOMMENDATIONS
): Promise<{
visionModels: OllamaModelInfo[];
textModels: OllamaModelInfo[];
recommendations: typeof MODEL_RECOMMENDATIONS[typeof taskType];
}> {
const allModels = await listModels();
const recommendations = MODEL_RECOMMENDATIONS[taskType] || MODEL_RECOMMENDATIONS.custom;
// Filtrer les modèles disponibles
const visionModels = allModels.filter(m => m.isVision);
const textModels = allModels.filter(m => !m.isVision);
// Trier par recommandation (les recommandés en premier)
const sortByRecommendation = (models: OllamaModelInfo[], recommended: string[]) => {
return [...models].sort((a, b) => {
const aIdx = recommended.findIndex(r => a.name.includes(r.split(':')[0]));
const bIdx = recommended.findIndex(r => b.name.includes(r.split(':')[0]));
if (aIdx === -1 && bIdx === -1) return 0;
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
};
return {
visionModels: sortByRecommendation(visionModels, recommendations.vision),
textModels: sortByRecommendation(textModels, recommendations.text),
recommendations,
};
}
/**
* Vérifie si Ollama est accessible
*/
export async function checkOllamaStatus(): Promise<{
available: boolean;
version?: string;
error?: string;
}> {
try {
const response = await fetch(`${OLLAMA_API}/api/version`);
if (response.ok) {
const data = await response.json();
return { available: true, version: data.version };
}
return { available: false, error: `HTTP ${response.status}` };
} catch (err) {
return { available: false, error: (err as Error).message };
}
}
/**
* Types de tâches IA disponibles
*/
export const AI_TASK_TYPES = [
{ id: 'ocr', label: 'OCR - Extraction texte', icon: '📝', needsVision: true },
{ id: 'summarize', label: 'Résumé', icon: '📋', needsVision: false },
{ id: 'extract', label: 'Extraction données', icon: '🔍', needsVision: false },
{ id: 'classify', label: 'Classification', icon: '🏷️', needsVision: false },
{ id: 'analyze', label: 'Analyse complète', icon: '🧠', needsVision: true },
{ id: 'custom', label: 'Personnalisé', icon: '⚙️', needsVision: false },
] as const;
export type AITaskType = typeof AI_TASK_TYPES[number]['id'];

View File

@@ -43,13 +43,14 @@ body {
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0 1rem;
height: 56px;
background: var(--primary);
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: visible;
}
.header h1 {
@@ -596,6 +597,41 @@ body {
resize: vertical;
}
.mode-toggle-row {
display: flex;
gap: 4px;
}
.mode-btn {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-paper);
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
transition: all 0.15s;
}
.mode-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.mode-btn:hover:not(.active) {
background: rgba(99, 102, 241, 0.1);
}
.field-hint {
display: block;
margin-top: 4px;
font-size: 0.75rem;
color: var(--text-secondary);
opacity: 0.7;
}
.prop-anchor {
margin-bottom: 1rem;
}
@@ -933,6 +969,8 @@ body {
align-items: center;
gap: 0.75rem;
padding: 0.25rem;
margin-left: auto;
flex-shrink: 0;
background: rgba(255,255,255,0.1);
border-radius: 8px;
}
@@ -1061,10 +1099,41 @@ body {
.react-flow__edge-path {
stroke: var(--border);
stroke-width: 2;
cursor: pointer;
}
.react-flow__edge:hover .react-flow__edge-path {
stroke: var(--secondary);
stroke-width: 3;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: var(--primary);
stroke-width: 3;
}
/* Handles de connexion visibles au survol des nœuds */
.react-flow__handle {
width: 10px;
height: 10px;
background: var(--border);
border: 2px solid var(--bg-paper);
transition: all 0.15s;
}
.react-flow__handle:hover,
.react-flow__node:hover .react-flow__handle {
background: var(--primary);
width: 12px;
height: 12px;
}
.react-flow__handle-connecting {
background: var(--primary-light);
}
.react-flow__handle-valid {
background: #4caf50;
}
.react-flow__controls {
@@ -2805,3 +2874,562 @@ body {
background: var(--error);
color: white;
}
/* ===========================================
AI Model Selector - Outils IA avec Ollama
=========================================== */
.ai-model-selector {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-sidebar);
border-radius: 8px;
border: 1px solid var(--border);
}
.ai-model-selector.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
}
.ai-model-selector .loading-spinner {
color: var(--text-secondary);
font-size: 0.85rem;
animation: blink 1s infinite;
}
.ai-model-selector.error {
border-color: var(--error);
background: rgba(244, 67, 54, 0.05);
}
.ai-model-selector .status-error {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--error);
font-weight: 500;
margin-bottom: 0.5rem;
}
.ai-model-selector .btn-retry {
width: 100%;
padding: 0.5rem;
background: var(--error);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
}
.ai-model-selector .task-info {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.ai-model-selector .task-icon {
font-size: 1.2rem;
}
.ai-model-selector .task-description {
font-size: 0.8rem;
color: var(--text-secondary);
font-style: italic;
}
.ai-model-selector .model-select-container {
margin-bottom: 0.75rem;
}
.ai-model-selector .model-select-container label {
display: block;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--text-primary);
}
.ai-model-selector .model-select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.85rem;
background: var(--bg-paper);
cursor: pointer;
}
.ai-model-selector .model-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
.ai-model-selector .model-select optgroup {
font-weight: 600;
color: var(--text-primary);
background: var(--bg-sidebar);
}
.ai-model-selector .model-select option {
padding: 0.5rem;
font-weight: 400;
}
.ai-model-selector .model-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.ai-model-selector .checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
cursor: pointer;
}
.ai-model-selector .checkbox-label input {
cursor: pointer;
}
.ai-model-selector .vision-badge {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: var(--secondary);
color: white;
border-radius: 12px;
font-weight: 500;
}
.ai-model-selector .selected-model-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: rgba(25, 118, 210, 0.1);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.ai-model-selector .selected-model-info .label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.ai-model-selector .selected-model-info .model-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--primary);
}
.ai-model-selector .ollama-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
}
.ai-model-selector .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ai-model-selector .status-dot.available {
background: var(--success);
box-shadow: 0 0 6px var(--success);
}
.ai-model-selector .status-dot.unavailable {
background: var(--error);
}
.ai-model-selector .status-text {
color: var(--text-secondary);
flex: 1;
}
.ai-model-selector .btn-refresh {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
opacity: 0.6;
transition: all 0.15s;
}
.ai-model-selector .btn-refresh:hover {
opacity: 1;
transform: rotate(90deg);
}
/* Section title pour les propriétés IA */
.prop-section-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--primary);
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary);
}
.prop-section-title .icon {
font-size: 1.1rem;
}
/* ===========================================
Workflow Variables Section
=========================================== */
.workflow-vars-section {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.workflow-vars-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
.workflow-vars-header .wv-icon {
font-size: 0.9rem;
}
.workflow-vars-header .wv-count {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
background: var(--primary);
color: white;
border-radius: 10px;
}
.workflow-var-card {
padding: 0.5rem 0.6rem;
background: var(--bg-paper);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 0.4rem;
transition: border-color 0.15s;
}
.workflow-var-card:hover {
border-color: var(--primary-light);
}
.workflow-var-card .var-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.3rem;
}
.workflow-var-card .var-name {
font-weight: 600;
font-size: 0.85rem;
color: var(--primary-dark);
font-family: 'Consolas', 'Monaco', monospace;
}
.workflow-var-card .var-type-badge {
font-size: 0.6rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
margin-left: auto;
}
.workflow-var-card .var-type-badge.type-ai {
background: var(--secondary);
color: white;
}
.workflow-var-card .var-type-badge.type-data {
background: var(--success);
color: white;
}
.workflow-var-card .var-type-badge.type-keyboard {
background: var(--primary);
color: white;
}
.workflow-var-card .var-type-badge.type-other {
background: var(--text-secondary);
color: white;
}
.var-producer, .var-consumers {
font-size: 0.75rem;
color: var(--text-secondary);
display: flex;
align-items: baseline;
gap: 0.3rem;
margin-bottom: 0.15rem;
}
.var-producer .label, .var-consumers .label {
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.var-producer .step-ref {
color: var(--primary);
font-weight: 500;
}
.var-consumers .step-ref {
color: var(--text-primary);
}
.var-no-producer {
color: var(--warning);
font-style: italic;
font-size: 0.7rem;
}
.var-runtime-value {
margin-top: 0.3rem;
padding: 0.3rem 0.4rem;
background: rgba(76, 175, 80, 0.08);
border: 1px solid rgba(76, 175, 80, 0.2);
border-radius: 4px;
font-size: 0.7rem;
color: var(--text-primary);
font-family: 'Consolas', 'Monaco', monospace;
max-height: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: break-all;
}
.var-runtime-value .runtime-label {
font-size: 0.65rem;
color: var(--success);
font-weight: 600;
font-family: inherit;
margin-right: 0.3rem;
}
.workflow-vars-empty {
font-size: 0.75rem;
color: var(--text-disabled);
font-style: italic;
text-align: center;
padding: 0.4rem;
}
.manual-vars-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.5rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
}
/* ===========================================
Workflow Validation
=========================================== */
.validation-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 6px;
color: white;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s;
flex-shrink: 0;
}
.validation-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.5);
}
.validation-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.validation-btn-icon {
font-size: 1rem;
}
.validation-modal {
background: var(--bg-paper);
border-radius: 12px;
width: 90%;
max-width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
.validation-loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.95rem;
}
.validation-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.validation-status.valid {
background: rgba(76, 175, 80, 0.1);
color: var(--success);
border: 1px solid rgba(76, 175, 80, 0.3);
}
.validation-status.invalid {
background: rgba(244, 67, 54, 0.1);
color: var(--error);
border: 1px solid rgba(244, 67, 54, 0.3);
}
.validation-status-icon {
font-size: 1.2rem;
}
.validation-errors {
margin-bottom: 1rem;
}
.validation-errors h5 {
color: var(--error);
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.validation-errors ul {
list-style: none;
padding: 0;
}
.validation-errors li {
padding: 0.4rem 0.6rem;
margin-bottom: 0.25rem;
background: rgba(244, 67, 54, 0.08);
border-left: 3px solid var(--error);
border-radius: 0 4px 4px 0;
font-size: 0.85rem;
color: var(--text-primary);
}
.validation-warnings {
margin-bottom: 1rem;
}
.validation-warnings h5 {
color: var(--warning);
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.validation-warnings ul {
list-style: none;
padding: 0;
}
.validation-warnings li {
padding: 0.4rem 0.6rem;
margin-bottom: 0.25rem;
background: rgba(255, 152, 0, 0.08);
border-left: 3px solid var(--warning);
border-radius: 0 4px 4px 0;
font-size: 0.85rem;
color: var(--text-primary);
}
.validation-export {
margin-top: 1rem;
text-align: center;
}
.validation-export .btn-primary {
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
}
.validation-success {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 6px;
margin-top: 1rem;
}
.validation-success-icon {
font-size: 1.5rem;
}
.validation-success strong {
display: block;
color: var(--success);
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.validation-success code {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.05);
padding: 0.25rem 0.5rem;
border-radius: 4px;
word-break: break-all;
}
.validation-hint {
margin-top: 1rem;
padding: 0.6rem 0.75rem;
background: var(--bg-sidebar);
border-radius: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
text-align: center;
}

View File

@@ -40,6 +40,11 @@ export type ActionType =
| 'loop_visual'
| 'download_to_folder'
| 'ai_analyze_text'
| 'ai_ocr'
| 'ai_summarize'
| 'ai_extract'
| 'ai_classify'
| 'ai_custom'
| 'db_save_data'
| 'db_read_data'
| 'verify_element_exists'
@@ -83,7 +88,12 @@ export const ACTIONS: ActionDefinition[] = [
{ type: 'loop_visual', label: 'Boucle visuelle', icon: '🔁', category: 'logic', needsAnchor: true, params: ['max_iterations'] },
// === INTELLIGENCE ARTIFICIELLE ===
{ type: 'ai_analyze_text', label: 'Analyse IA', icon: '🤖', category: 'ai', needsAnchor: true, params: ['prompt', 'variable_name'] },
{ type: 'ai_ocr', label: 'OCR Intelligent', icon: '📝', category: 'ai', needsAnchor: true, params: ['variable_name', 'model', 'language'] },
{ type: 'ai_summarize', label: 'Résumé IA', icon: '📋', category: 'ai', needsAnchor: false, params: ['input_text', 'variable_name', 'model', 'max_length'] },
{ type: 'ai_extract', label: 'Extraction IA', icon: '🔍', category: 'ai', needsAnchor: true, params: ['prompt', 'variable_name', 'model', 'output_format'] },
{ type: 'ai_classify', label: 'Classification IA', icon: '🏷️', category: 'ai', needsAnchor: false, params: ['input_text', 'categories', 'variable_name', 'model'] },
{ type: 'ai_analyze_text', label: 'Analyse complète', icon: '🧠', category: 'ai', needsAnchor: false, params: ['prompt', 'variable_name', 'model'] },
{ type: 'ai_custom', label: 'IA Personnalisée', icon: '⚙️', category: 'ai', needsAnchor: false, params: ['prompt', 'input_text', 'variable_name', 'model', 'system_prompt'] },
// === BASE DE DONNÉES ===
{ type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] },