v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution

- Frontend v4 accessible sur réseau local (192.168.1.40)
- Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard)
- Ollama GPU fonctionnel
- Self-healing interactif
- Dashboard confiance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dom
2026-01-29 11:23:51 +01:00
parent 21bfa3b337
commit a27b74cf22
1595 changed files with 412691 additions and 400 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "Visual Workflow Builder - React + API v3",
"type": "module",
"scripts": {
"dev": "vite --port 3002",
"dev": "vite --port 3002 --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview"
},

View File

@@ -0,0 +1,264 @@
import { useState, useEffect } from 'react';
import type { Capture } from '../types';
interface LibraryItem {
id: string;
capture: Capture;
timestamp: Date;
sessionId?: string;
favorite?: boolean;
}
interface Props {
currentCapture: Capture | null;
onSelectCapture: (capture: Capture) => void;
onCapture: () => void;
}
export default function CaptureLibrary({ currentCapture, onSelectCapture, onCapture }: Props) {
const [isExpanded, setIsExpanded] = useState(false);
const [library, setLibrary] = useState<LibraryItem[]>([]);
const [currentSessionId] = useState(() => `session_${Date.now()}`);
const [viewMode, setViewMode] = useState<'all' | 'session' | 'favorites'>('session');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// Charger la bibliothèque depuis sessionStorage (avec migration de l'ancienne clé)
useEffect(() => {
// Essayer la nouvelle clé d'abord
let stored = sessionStorage.getItem('captureLibrary_v2');
// Migration depuis l'ancienne clé si nécessaire
if (!stored) {
const oldStored = sessionStorage.getItem('captureLibrary');
if (oldStored) {
try {
const oldData = JSON.parse(oldStored);
// Migrer les anciennes données vers le nouveau format
const migrated = oldData.map((item: any) => ({
...item,
sessionId: currentSessionId,
favorite: false
}));
sessionStorage.setItem('captureLibrary_v2', JSON.stringify(migrated));
stored = JSON.stringify(migrated);
console.log(`✅ Migration de ${oldData.length} captures vers le nouveau format`);
} catch (e) {
console.error('Erreur migration captures:', e);
}
}
}
if (stored) {
const parsed = JSON.parse(stored);
setLibrary(parsed.map((item: any) => ({
...item,
timestamp: new Date(item.timestamp)
})));
}
}, [currentSessionId]);
// Sauvegarder la bibliothèque
useEffect(() => {
sessionStorage.setItem('captureLibrary_v2', JSON.stringify(library));
}, [library]);
// Ajouter capture à la bibliothèque
useEffect(() => {
if (currentCapture) {
const newItem: LibraryItem = {
id: `cap_${Date.now()}`,
capture: currentCapture,
timestamp: new Date(),
sessionId: currentSessionId,
favorite: false
};
setLibrary(prev => [newItem, ...prev.slice(0, 49)]); // Max 50 captures
}
}, [currentCapture, currentSessionId]);
// Filtrer selon le mode de vue
const filteredLibrary = library.filter(item => {
if (viewMode === 'session') return item.sessionId === currentSessionId;
if (viewMode === 'favorites') return item.favorite;
return true;
});
// Grouper par session/date
const groupedByDate = filteredLibrary.reduce((acc, item) => {
const dateKey = item.timestamp.toLocaleDateString('fr-FR');
if (!acc[dateKey]) acc[dateKey] = [];
acc[dateKey].push(item);
return acc;
}, {} as Record<string, LibraryItem[]>);
const toggleFavorite = (id: string) => {
setLibrary(prev => prev.map(item =>
item.id === id ? { ...item, favorite: !item.favorite } : item
));
};
const deleteItem = (id: string) => {
setLibrary(prev => prev.filter(item => item.id !== id));
setSelectedItems(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const deleteSelected = () => {
if (selectedItems.size === 0) return;
if (confirm(`Supprimer ${selectedItems.size} capture(s) ?`)) {
setLibrary(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
}
};
const toggleSelect = (id: string) => {
setSelectedItems(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const sessionCount = library.filter(item => item.sessionId === currentSessionId).length;
const totalCount = library.length;
return (
<div className={`capture-library ${isExpanded ? 'expanded' : 'collapsed'}`}>
{/* Header compact */}
<div className="library-header" onClick={() => setIsExpanded(!isExpanded)}>
<div className="header-left">
<span className="library-icon">📸</span>
<span className="library-title">Captures</span>
<span className="library-count">
{sessionCount} cette session
{totalCount > sessionCount && ` (+${totalCount - sessionCount})`}
</span>
</div>
<div className="header-right">
<button
className="capture-btn"
onClick={(e) => { e.stopPropagation(); onCapture(); }}
title="Nouvelle capture"
>
📷
</button>
<span className={`expand-arrow ${isExpanded ? 'open' : ''}`}>
{isExpanded ? '▼' : '▶'}
</span>
</div>
</div>
{/* Aperçu compact (quand replié) */}
{!isExpanded && currentCapture && (
<div className="library-preview">
<img
src={`data:image/png;base64,${currentCapture.screenshot_base64}`}
alt="Dernière capture"
onClick={() => setIsExpanded(true)}
/>
</div>
)}
{/* Contenu étendu */}
{isExpanded && (
<div className="library-content">
{/* Filtres */}
<div className="library-filters">
<button
className={viewMode === 'session' ? 'active' : ''}
onClick={() => setViewMode('session')}
>
Session ({sessionCount})
</button>
<button
className={viewMode === 'all' ? 'active' : ''}
onClick={() => setViewMode('all')}
>
Toutes ({totalCount})
</button>
<button
className={viewMode === 'favorites' ? 'active' : ''}
onClick={() => setViewMode('favorites')}
>
Favoris ({library.filter(i => i.favorite).length})
</button>
</div>
{/* Actions en masse */}
{selectedItems.size > 0 && (
<div className="library-bulk-actions">
<span>{selectedItems.size} sélectionné(s)</span>
<button onClick={deleteSelected}>🗑 Supprimer</button>
<button onClick={() => setSelectedItems(new Set())}>Désélectionner</button>
</div>
)}
{/* Grille de captures */}
<div className="library-grid">
{filteredLibrary.length === 0 ? (
<p className="empty-library">
{viewMode === 'favorites' ? 'Aucun favori' : 'Aucune capture'}
</p>
) : (
Object.entries(groupedByDate).map(([date, items]) => (
<div key={date} className="library-group">
{viewMode === 'all' && (
<div className="group-header">{date}</div>
)}
<div className="group-items">
{items.map(item => (
<div
key={item.id}
className={`library-item ${selectedItems.has(item.id) ? 'selected' : ''}`}
>
<img
src={`data:image/png;base64,${item.capture.screenshot_base64}`}
alt="Capture"
onClick={() => onSelectCapture(item.capture)}
/>
<div className="item-overlay">
<span className="item-time">
{item.timestamp.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}
</span>
<div className="item-actions">
<button
className={`fav-btn ${item.favorite ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleFavorite(item.id); }}
title={item.favorite ? 'Retirer des favoris' : 'Ajouter aux favoris'}
>
{item.favorite ? '⭐' : '☆'}
</button>
<button
className="select-btn"
onClick={(e) => { e.stopPropagation(); toggleSelect(item.id); }}
>
{selectedItems.has(item.id) ? '☑' : '☐'}
</button>
<button
className="delete-btn"
onClick={(e) => { e.stopPropagation(); deleteItem(item.id); }}
title="Supprimer"
>
×
</button>
</div>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,13 @@
import { useState, useRef, useEffect } from 'react';
import type { Capture, ExecutionMode } from '../types';
import DetectionOverlay from './DetectionOverlay';
import type { UIElement, DetectionResult } from '../services/uiDetection';
import type { UIElement } from '../services/uiDetection';
interface DetectionZone {
x: number;
y: number;
width: number;
height: number;
}
interface Props {
capture: Capture | null;
@@ -9,7 +15,8 @@ interface Props {
onSelectAnchor: (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => void;
hasSelectedStep: boolean;
executionMode?: ExecutionMode;
onDetectionComplete?: (result: DetectionResult) => void;
detectionZone?: DetectionZone | null;
onSetDetectionZone?: (zone: DetectionZone | null) => void;
}
interface LibraryItem {
@@ -24,37 +31,17 @@ export default function CapturePanel({
onSelectAnchor,
hasSelectedStep,
executionMode = 'basic',
onDetectionComplete
detectionZone,
onSetDetectionZone
}: Props) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [library, setLibrary] = useState<LibraryItem[]>([]);
const [currentCapture, setCurrentCapture] = useState<Capture | null>(null);
const [timerSeconds, setTimerSeconds] = useState(0);
const [countdown, setCountdown] = useState<number | null>(null);
const [lastDetection, setLastDetection] = useState<DetectionResult | null>(null);
const isDebugMode = executionMode === 'debug';
const handleDetectionComplete = (result: DetectionResult) => {
setLastDetection(result);
if (onDetectionComplete) {
onDetectionComplete(result);
}
};
const handleElementClick = (element: UIElement) => {
// En mode debug, cliquer sur un élément détecté le sélectionne comme ancre
if (hasSelectedStep && currentCapture) {
const bbox = {
x: element.bbox.x1,
y: element.bbox.y1,
width: element.bbox.x2 - element.bbox.x1,
height: element.bbox.y2 - element.bbox.y1,
};
onSelectAnchor(bbox, currentCapture.screenshot_base64);
}
};
// Charger la bibliothèque depuis sessionStorage
useEffect(() => {
const stored = sessionStorage.getItem('captureLibrary');
@@ -133,26 +120,13 @@ export default function CapturePanel({
{/* Aperçu de la capture */}
{currentCapture && (
<div className="capture-preview">
{isDebugMode ? (
<DetectionOverlay
imageBase64={`data:image/png;base64,${currentCapture.screenshot_base64}`}
enabled={true}
threshold={0.35}
onDetectionComplete={handleDetectionComplete}
onElementClick={handleElementClick}
/>
) : (
<img
src={`data:image/png;base64,${currentCapture.screenshot_base64}`}
alt="Capture"
onClick={() => setIsFullscreen(true)}
/>
)}
<img
src={`data:image/png;base64,${currentCapture.screenshot_base64}`}
alt="Capture"
onClick={() => setIsFullscreen(true)}
/>
<p className="capture-info">
{currentCapture.width}x{currentCapture.height}
{isDebugMode && lastDetection && (
<span className="detection-summary"> | {lastDetection.count} éléments détectés</span>
)}
<button onClick={() => setIsFullscreen(true)}>Plein écran</button>
</p>
</div>
@@ -162,6 +136,19 @@ export default function CapturePanel({
<p className="capture-hint">Sélectionnez une étape pour définir l'ancre</p>
)}
{/* Zone de détection */}
{detectionZone && (
<div className="detection-zone-info">
<span>📍 Zone de détection: {detectionZone.width}x{detectionZone.height}</span>
<button onClick={() => onSetDetectionZone?.(null)}>❌</button>
</div>
)}
{!detectionZone && currentCapture && (executionMode === 'intelligent' || executionMode === 'debug') && (
<p className="capture-hint zone-hint">
💡 Cliquez sur "Plein écran" puis "Zone de détection" pour cibler une zone
</p>
)}
{/* Bibliothèque */}
<div className="capture-library">
<h4>Bibliothèque ({library.length})</h4>
@@ -195,6 +182,8 @@ export default function CapturePanel({
}}
enabled={hasSelectedStep}
debugMode={isDebugMode}
detectionZone={detectionZone}
onSetDetectionZone={onSetDetectionZone}
/>
)}
</div>
@@ -207,13 +196,17 @@ function FullscreenSelector({
onClose,
onSelect,
enabled,
debugMode = false
debugMode = false,
detectionZone,
onSetDetectionZone
}: {
capture: Capture;
onClose: () => void;
onSelect: (bbox: { x: number; y: number; width: number; height: number }) => void;
enabled: boolean;
debugMode?: boolean;
detectionZone?: DetectionZone | null;
onSetDetectionZone?: (zone: DetectionZone | null) => void;
}) {
const imgRef = useRef<HTMLImageElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -223,6 +216,7 @@ function FullscreenSelector({
const [detectedElements, setDetectedElements] = useState<UIElement[]>([]);
const [isDetecting, setIsDetecting] = useState(false);
const [imageScale, setImageScale] = useState({ x: 1, y: 1 });
const [selectionMode, setSelectionMode] = useState<'anchor' | 'zone'>('anchor');
// Lancer la détection en mode Debug
useEffect(() => {
@@ -278,7 +272,9 @@ function FullscreenSelector({
}, [onClose]);
const handleMouseDown = (e: React.MouseEvent) => {
if (!enabled || !imgRef.current) return;
// Permettre le dessin en mode zone même sans étape sélectionnée
const canDraw = selectionMode === 'zone' || enabled;
if (!canDraw || !imgRef.current) return;
const rect = imgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
@@ -311,7 +307,10 @@ function FullscreenSelector({
if (!isSelecting || !imgRef.current) return;
setIsSelecting(false);
if (selection.width < 10 || selection.height < 10) return;
if (selection.width < 10 || selection.height < 10) {
setSelection({ x: 0, y: 0, width: 0, height: 0 });
return;
}
// Convertir en coordonnées réelles de l'image
const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
@@ -324,17 +323,49 @@ function FullscreenSelector({
height: Math.round(selection.height * scaleY)
};
onSelect(realBbox);
if (selectionMode === 'zone' && onSetDetectionZone) {
// Mode zone de détection - fonctionne même sans étape sélectionnée
onSetDetectionZone(realBbox);
setSelectionMode('anchor'); // Revenir au mode ancre
setSelection({ x: 0, y: 0, width: 0, height: 0 });
} else if (enabled) {
// Mode ancre normal - nécessite une étape sélectionnée
onSelect(realBbox);
}
};
return (
<div className="fullscreen-modal">
<div className="fullscreen-header">
<span>
{debugMode && isDetecting && '🔍 Détection en cours... '}
{debugMode && !isDetecting && `🎯 ${detectedElements.length} éléments détectés - `}
{enabled ? 'Dessinez un rectangle ou cliquez sur un élément détecté' : 'Sélectionnez d\'abord une étape'}
</span>
<div className="header-left">
<span>
{debugMode && isDetecting && '🔍 Détection en cours... '}
{debugMode && !isDetecting && `🎯 ${detectedElements.length} éléments - `}
{selectionMode === 'zone'
? '✂️ Dessinez la zone de détection'
: (enabled ? 'Dessinez un rectangle pour l\'ancre' : 'Sélectionnez d\'abord une étape')}
</span>
</div>
<div className="header-center">
{onSetDetectionZone && (
<>
<button
className={`zone-select-btn ${selectionMode === 'zone' ? 'active' : ''}`}
onClick={() => setSelectionMode(selectionMode === 'zone' ? 'anchor' : 'zone')}
>
{selectionMode === 'zone' ? '✋ Annuler' : '✂️ Zone de détection'}
</button>
{detectionZone && (
<button
className="zone-clear-btn"
onClick={() => onSetDetectionZone(null)}
>
Effacer zone
</button>
)}
</>
)}
</div>
<button onClick={onClose}>Fermer (Échap)</button>
</div>
<div
@@ -343,7 +374,7 @@ function FullscreenSelector({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Conteneur relatif pour positionner les bboxes par rapport à l'image */}
{/* Conteneur relatif pour positionner les bboxes et la sélection par rapport à l'image */}
<div style={{ position: 'relative', display: 'inline-block' }}>
<img
ref={imgRef}
@@ -391,19 +422,54 @@ function FullscreenSelector({
</span>
</div>
))}
{/* Zone de détection existante */}
{detectionZone && imgRef.current && (
<div
className="detection-zone-overlay"
style={{
position: 'absolute',
left: detectionZone.x * imageScale.x,
top: detectionZone.y * imageScale.y,
width: detectionZone.width * imageScale.x,
height: detectionZone.height * imageScale.y,
border: '3px solid #4caf50',
background: 'rgba(76, 175, 80, 0.15)',
pointerEvents: 'none',
zIndex: 5
}}
>
<span style={{
position: 'absolute',
top: -25,
left: 0,
background: '#4caf50',
color: 'white',
padding: '2px 8px',
borderRadius: '3px',
fontSize: '11px',
fontWeight: 'bold'
}}>
Zone de détection
</span>
</div>
)}
{/* Rectangle de sélection manuelle */}
{(isSelecting || selection.width > 0) && (
<div
ref={overlayRef}
className={`selection-overlay ${selectionMode === 'zone' ? 'zone-mode' : ''}`}
style={{
position: 'absolute',
left: selection.x,
top: selection.y,
width: selection.width,
height: selection.height
}}
/>
)}
</div>
{(isSelecting || selection.width > 0) && (
<div
ref={overlayRef}
className="selection-overlay"
style={{
left: selection.x,
top: selection.y,
width: selection.width,
height: selection.height
}}
/>
)}
</div>
</div>
);

View File

@@ -85,7 +85,7 @@ export default function ExecutionOverlay({ isVisible, isRunning, onClose, initia
setIsDetecting(true);
try {
// Appeler l'API de capture sur le backend (port 5001)
const API_BASE = 'http://localhost:5001';
const API_BASE = `http://${window.location.hostname}:5001`;
const response = await fetch(`${API_BASE}/api/v3/capture/screen`, { method: 'POST' });
const data = await response.json();
@@ -157,7 +157,7 @@ export default function ExecutionOverlay({ isVisible, isRunning, onClose, initia
const pollStatus = async () => {
try {
const API_BASE = 'http://localhost:5001';
const API_BASE = `http://${window.location.hostname}:5001`;
const response = await fetch(`${API_BASE}/api/v3/execute/status`);
const data = await response.json();

View File

@@ -1,11 +1,23 @@
import { useState } from 'react';
import { ACTIONS, ACTION_CATEGORIES } from '../types';
export default function ToolPalette() {
// État des catégories dépliées (toutes fermées par défaut sauf 'mouse')
const [expandedCategories, setExpandedCategories] = useState<string[]>(['mouse']);
const onDragStart = (event: React.DragEvent, actionType: string) => {
event.dataTransfer.setData('actionType', actionType);
event.dataTransfer.effectAllowed = 'move';
};
const toggleCategory = (catKey: string) => {
setExpandedCategories(prev =>
prev.includes(catKey)
? prev.filter(k => k !== catKey)
: [...prev, catKey]
);
};
// Grouper par catégorie
const categories = Object.keys(ACTION_CATEGORIES) as Array<keyof typeof ACTION_CATEGORIES>;
@@ -16,30 +28,41 @@ export default function ToolPalette() {
{categories.map((catKey) => {
const cat = ACTION_CATEGORIES[catKey];
const tools = ACTIONS.filter(a => a.category === catKey);
const isExpanded = expandedCategories.includes(catKey);
if (tools.length === 0) return null;
return (
<div key={catKey} className="tool-category">
<div className="category-header">
<div key={catKey} className={`tool-category ${isExpanded ? 'expanded' : 'collapsed'}`}>
<div
className="category-header"
onClick={() => toggleCategory(catKey)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleCategory(catKey)}
>
<span className="category-chevron">{isExpanded ? '▼' : '▶'}</span>
<span className="category-icon">{cat.icon}</span>
<span className="category-label">{cat.label}</span>
<span className="category-count">({tools.length})</span>
</div>
<div className="tool-list">
{tools.map((action) => (
<div
key={action.type}
className="tool-item"
draggable
onDragStart={(e) => onDragStart(e, action.type)}
title={action.label}
>
<span className="tool-icon">{action.icon}</span>
<span className="tool-label">{action.label}</span>
{action.needsAnchor && <span className="tool-anchor-badge">🎯</span>}
</div>
))}
</div>
{isExpanded && (
<div className="tool-list">
{tools.map((action) => (
<div
key={action.type}
className="tool-item"
draggable
onDragStart={(e) => onDragStart(e, action.type)}
title={action.label}
>
<span className="tool-icon">{action.icon}</span>
<span className="tool-label">{action.label}</span>
{action.needsAnchor && <span className="tool-anchor-badge">🎯</span>}
</div>
))}
</div>
)}
</div>
);
})}

View File

@@ -0,0 +1,332 @@
/**
* Gestionnaire de Variables simplifié pour VWB v4
* Permet de créer, modifier et supprimer des variables de workflow
*/
import { useState, useCallback } from 'react';
export type VariableType = 'text' | 'number' | 'boolean' | 'list';
export interface Variable {
id: string;
name: string;
type: VariableType;
defaultValue?: string | number | boolean | unknown[];
description?: string;
}
interface Props {
variables: Variable[];
onVariableCreate: (data: Omit<Variable, 'id'>) => void;
onVariableUpdate: (id: string, data: Partial<Variable>) => void;
onVariableDelete: (id: string) => void;
}
const TYPE_LABELS: Record<VariableType, string> = {
text: 'Texte',
number: 'Nombre',
boolean: 'Booléen',
list: 'Liste',
};
export default function VariableManager({
variables,
onVariableCreate,
onVariableUpdate,
onVariableDelete,
}: Props) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingVariable, setEditingVariable] = useState<Variable | null>(null);
const [formData, setFormData] = useState({
name: '',
type: 'text' as VariableType,
defaultValue: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Ouvrir le dialogue pour créer une nouvelle variable
const handleCreateNew = () => {
setEditingVariable(null);
setFormData({
name: '',
type: 'text',
defaultValue: '',
description: '',
});
setErrors({});
setIsDialogOpen(true);
};
// Ouvrir le dialogue pour modifier une variable existante
const handleEdit = (variable: Variable) => {
setEditingVariable(variable);
setFormData({
name: variable.name,
type: variable.type,
defaultValue: variable.defaultValue !== undefined ? String(variable.defaultValue) : '',
description: variable.description || '',
});
setErrors({});
setIsDialogOpen(true);
};
// Fermer le dialogue
const handleCloseDialog = () => {
setIsDialogOpen(false);
setEditingVariable(null);
setFormData({
name: '',
type: 'text',
defaultValue: '',
description: '',
});
setErrors({});
};
// Valider le formulaire
const validateForm = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
// Validation du nom
if (!formData.name.trim()) {
newErrors.name = 'Le nom est obligatoire';
} else if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.name)) {
newErrors.name = 'Nom invalide (lettres, chiffres, _ uniquement)';
} else {
// Vérifier l'unicité du nom
const existingVariable = variables.find(
v => v.name === formData.name && v.id !== editingVariable?.id
);
if (existingVariable) {
newErrors.name = 'Ce nom existe déjà';
}
}
// Validation de la valeur par défaut selon le type
if (formData.defaultValue) {
switch (formData.type) {
case 'number':
if (isNaN(Number(formData.defaultValue))) {
newErrors.defaultValue = 'Doit être un nombre';
}
break;
case 'boolean':
if (!['true', 'false'].includes(formData.defaultValue.toLowerCase())) {
newErrors.defaultValue = 'Doit être "true" ou "false"';
}
break;
case 'list':
try {
JSON.parse(formData.defaultValue);
} catch {
newErrors.defaultValue = 'JSON invalide pour une liste';
}
break;
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData, variables, editingVariable]);
// Sauvegarder la variable
const handleSave = () => {
if (!validateForm()) return;
let processedDefaultValue: string | number | boolean | unknown[] | undefined = undefined;
// Traiter la valeur par défaut selon le type
if (formData.defaultValue) {
switch (formData.type) {
case 'number':
processedDefaultValue = Number(formData.defaultValue);
break;
case 'boolean':
processedDefaultValue = formData.defaultValue.toLowerCase() === 'true';
break;
case 'list':
try {
processedDefaultValue = JSON.parse(formData.defaultValue) as unknown[];
} catch {
processedDefaultValue = [];
}
break;
case 'text':
default:
processedDefaultValue = formData.defaultValue;
break;
}
}
const variableData: Omit<Variable, 'id'> = {
name: formData.name,
type: formData.type,
defaultValue: processedDefaultValue,
description: formData.description || undefined,
};
if (editingVariable) {
onVariableUpdate(editingVariable.id, variableData);
} else {
onVariableCreate(variableData);
}
handleCloseDialog();
};
// Supprimer une variable
const handleDelete = (variable: Variable) => {
if (window.confirm(`Supprimer la variable "${variable.name}" ?`)) {
onVariableDelete(variable.id);
}
};
// Formater la valeur par défaut pour l'affichage
const formatDefaultValue = (variable: Variable): string => {
if (variable.defaultValue === undefined || variable.defaultValue === null) {
return '-';
}
switch (variable.type) {
case 'boolean':
return variable.defaultValue ? 'Vrai' : 'Faux';
case 'list':
return Array.isArray(variable.defaultValue)
? `[${(variable.defaultValue as unknown[]).length} éléments]`
: JSON.stringify(variable.defaultValue);
default:
return String(variable.defaultValue);
}
};
return (
<div className="variable-manager">
<div className="variable-manager-header">
<h3>Variables</h3>
<button className="btn-primary btn-small" onClick={handleCreateNew}>
+ Nouvelle
</button>
</div>
{variables.length === 0 ? (
<p className="variable-empty-message">
Aucune variable. Créez-en pour rendre votre workflow flexible.
</p>
) : (
<div className="variable-list">
{variables.map((variable) => (
<div key={variable.id} className="variable-item">
<div className="variable-info">
<span className="variable-name">{variable.name}</span>
<span className={`variable-type type-${variable.type}`}>
{TYPE_LABELS[variable.type]}
</span>
</div>
<div className="variable-value">
Défaut: {formatDefaultValue(variable)}
</div>
{variable.description && (
<div className="variable-description">{variable.description}</div>
)}
<div className="variable-actions">
<button className="btn-icon" onClick={() => handleEdit(variable)} title="Modifier">
</button>
<button className="btn-icon btn-danger" onClick={() => handleDelete(variable)} title="Supprimer">
🗑
</button>
</div>
</div>
))}
</div>
)}
{/* Modal de création/modification */}
{isDialogOpen && (
<div className="modal-overlay" onClick={handleCloseDialog}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h4>{editingVariable ? 'Modifier la variable' : 'Nouvelle variable'}</h4>
<button className="btn-close" onClick={handleCloseDialog}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label htmlFor="var-name">Nom *</label>
<input
id="var-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="ma_variable"
className={errors.name ? 'input-error' : ''}
/>
{errors.name && <span className="error-message">{errors.name}</span>}
</div>
<div className="form-group">
<label htmlFor="var-type">Type</label>
<select
id="var-type"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as VariableType })}
>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="var-default">Valeur par défaut</label>
{formData.type === 'list' ? (
<textarea
id="var-default"
value={formData.defaultValue}
onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
placeholder='["item1", "item2"]'
rows={3}
className={errors.defaultValue ? 'input-error' : ''}
/>
) : (
<input
id="var-default"
type="text"
value={formData.defaultValue}
onChange={(e) => setFormData({ ...formData, defaultValue: e.target.value })}
placeholder={
formData.type === 'number' ? '0' :
formData.type === 'boolean' ? 'true ou false' :
'valeur'
}
className={errors.defaultValue ? 'input-error' : ''}
/>
)}
{errors.defaultValue && <span className="error-message">{errors.defaultValue}</span>}
</div>
<div className="form-group">
<label htmlFor="var-desc">Description</label>
<textarea
id="var-desc"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Description optionnelle"
rows={2}
/>
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={handleCloseDialog}>Annuler</button>
<button className="btn-primary" onClick={handleSave}>
{editingVariable ? 'Modifier' : 'Créer'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import type { WorkflowSummary } from '../types';
interface Props {
@@ -9,6 +10,19 @@ interface Props {
}
export default function WorkflowList({ workflows, activeId, onSelect, onCreate, onDelete }: Props) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const toggleExpand = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setExpandedId(expandedId === id ? null : id);
};
const hasMetadata = (wf: WorkflowSummary) => {
return (wf.tags && wf.tags.length > 0) ||
wf.description ||
(wf.trigger_examples && wf.trigger_examples.length > 0);
};
return (
<div className="workflow-list">
<div className="workflow-list-header">
@@ -23,18 +37,86 @@ export default function WorkflowList({ workflows, activeId, onSelect, onCreate,
{workflows.map(wf => (
<li
key={wf.id}
className={wf.id === activeId ? 'active' : ''}
className={`${wf.id === activeId ? 'active' : ''} ${expandedId === wf.id ? 'expanded' : ''}`}
onClick={() => onSelect(wf.id)}
>
<span className="wf-name">{wf.name}</span>
<span className="wf-count">{wf.step_count} étapes</span>
<button
className="delete-btn"
onClick={(e) => { e.stopPropagation(); onDelete(wf.id); }}
title="Supprimer"
>
×
</button>
<div className="wf-main-row">
<span className="wf-name">{wf.name}</span>
{/* Indicateurs compacts des métadonnées */}
<span className="wf-meta-indicators">
{wf.tags && wf.tags.length > 0 && (
<span className="meta-badge tags" title={`Tags: ${wf.tags.join(', ')}`}>
🏷{wf.tags.length}
</span>
)}
{wf.trigger_examples && wf.trigger_examples.length > 0 && (
<span className="meta-badge triggers" title={`Triggers: ${wf.trigger_examples.length}`}>
💬{wf.trigger_examples.length}
</span>
)}
{wf.description && (
<span className="meta-badge desc" title={wf.description}>
📝
</span>
)}
</span>
<span className="wf-count">{wf.step_count}</span>
{/* Bouton expand si métadonnées */}
{hasMetadata(wf) && (
<button
className="expand-btn"
onClick={(e) => toggleExpand(e, wf.id)}
title="Voir les détails"
>
{expandedId === wf.id ? '▼' : '▶'}
</button>
)}
<button
className="delete-btn"
onClick={(e) => { e.stopPropagation(); onDelete(wf.id); }}
title="Supprimer"
>
×
</button>
</div>
{/* Détails expandés */}
{expandedId === wf.id && hasMetadata(wf) && (
<div className="wf-details" onClick={(e) => e.stopPropagation()}>
{wf.description && (
<div className="wf-detail-row">
<span className="detail-label">📝</span>
<span className="detail-value desc">{wf.description}</span>
</div>
)}
{wf.tags && wf.tags.length > 0 && (
<div className="wf-detail-row">
<span className="detail-label">🏷</span>
<div className="tags-list">
{wf.tags.map((tag, i) => (
<span key={i} className="tag">{tag}</span>
))}
</div>
</div>
)}
{wf.trigger_examples && wf.trigger_examples.length > 0 && (
<div className="wf-detail-row">
<span className="detail-label">💬</span>
<div className="triggers-list">
{wf.trigger_examples.map((ex, i) => (
<span key={i} className="trigger">"{ex}"</span>
))}
</div>
</div>
)}
</div>
)}
</li>
))}
</ul>

View File

@@ -0,0 +1,297 @@
import { useState } from 'react';
import type { WorkflowSummary } from '../types';
interface Props {
workflows: WorkflowSummary[];
activeWorkflowId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onRename: (id: string, newName: string) => void;
onUpdateMetadata: (id: string, metadata: { description?: string; tags?: string[]; trigger_examples?: string[] }) => void;
onClose: () => void;
}
export default function WorkflowManagerModal({
workflows,
activeWorkflowId,
onSelect,
onDelete,
onRename,
onUpdateMetadata,
onClose
}: Props) {
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(activeWorkflowId);
const [editingField, setEditingField] = useState<string | null>(null);
// État local pour l'édition
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editTags, setEditTags] = useState('');
const [editTriggers, setEditTriggers] = useState('');
const filteredWorkflows = workflows.filter(wf =>
wf.name.toLowerCase().includes(search.toLowerCase()) ||
(wf.description || '').toLowerCase().includes(search.toLowerCase()) ||
(wf.tags || []).some(tag => tag.toLowerCase().includes(search.toLowerCase()))
);
const selectedWorkflow = workflows.find(wf => wf.id === selectedId);
const handleSelectWorkflow = (wf: WorkflowSummary) => {
setSelectedId(wf.id);
setEditingField(null);
// Charger les valeurs actuelles
setEditName(wf.name);
setEditDescription(wf.description || '');
setEditTags((wf.tags || []).join(', '));
setEditTriggers((wf.trigger_examples || []).join('\n'));
};
const handleSaveName = () => {
if (selectedId && editName.trim()) {
onRename(selectedId, editName.trim());
}
setEditingField(null);
};
const handleSaveDescription = () => {
if (selectedId) {
onUpdateMetadata(selectedId, { description: editDescription.trim() });
}
setEditingField(null);
};
const handleSaveTags = () => {
if (selectedId) {
const tags = editTags.split(',').map(t => t.trim()).filter(Boolean);
onUpdateMetadata(selectedId, { tags });
}
setEditingField(null);
};
const handleSaveTriggers = () => {
if (selectedId) {
const triggers = editTriggers.split('\n').map(t => t.trim()).filter(Boolean);
onUpdateMetadata(selectedId, { trigger_examples: triggers });
}
setEditingField(null);
};
const handleDelete = (id: string) => {
if (confirm('Supprimer ce workflow ?')) {
onDelete(id);
if (selectedId === id) {
setSelectedId(null);
}
}
};
const handleOpenWorkflow = () => {
if (selectedId) {
onSelect(selectedId);
onClose();
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="workflow-manager-modal" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="modal-header">
<h2>Gestion des workflows</h2>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{/* Panneau gauche : liste */}
<div className="manager-list-panel">
<div className="list-search">
<input
type="text"
placeholder="Rechercher un workflow..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="list-content">
{filteredWorkflows.length === 0 ? (
<p className="empty-list">Aucun workflow trouvé</p>
) : (
filteredWorkflows.map(wf => (
<div
key={wf.id}
className={`list-item ${wf.id === selectedId ? 'selected' : ''} ${wf.id === activeWorkflowId ? 'active' : ''}`}
onClick={() => handleSelectWorkflow(wf)}
>
<div className="item-main">
<span className="item-name">{wf.name}</span>
{wf.id === activeWorkflowId && <span className="active-badge">actif</span>}
</div>
<div className="item-info">
<span>{wf.step_count} étapes</span>
{wf.tags && wf.tags.length > 0 && (
<span className="tag-count">{wf.tags.length} tags</span>
)}
</div>
</div>
))
)}
</div>
<div className="list-footer">
<span>{workflows.length} workflow{workflows.length > 1 ? 's' : ''}</span>
</div>
</div>
{/* Panneau droit : détails */}
<div className="manager-detail-panel">
{selectedWorkflow ? (
<>
{/* Nom */}
<div className="detail-section">
<label>Nom</label>
{editingField === 'name' ? (
<div className="edit-field">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSaveName()}
autoFocus
/>
<button onClick={handleSaveName}></button>
<button onClick={() => setEditingField(null)}></button>
</div>
) : (
<div className="display-field" onClick={() => { setEditName(selectedWorkflow.name); setEditingField('name'); }}>
<span>{selectedWorkflow.name}</span>
<button className="edit-btn"></button>
</div>
)}
</div>
{/* Description */}
<div className="detail-section">
<label>Description</label>
{editingField === 'description' ? (
<div className="edit-field">
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
rows={3}
autoFocus
/>
<div className="edit-actions">
<button onClick={handleSaveDescription}> Sauvegarder</button>
<button onClick={() => setEditingField(null)}> Annuler</button>
</div>
</div>
) : (
<div className="display-field" onClick={() => { setEditDescription(selectedWorkflow.description || ''); setEditingField('description'); }}>
<span className={!selectedWorkflow.description ? 'placeholder' : ''}>
{selectedWorkflow.description || 'Cliquez pour ajouter une description...'}
</span>
<button className="edit-btn"></button>
</div>
)}
</div>
{/* Tags */}
<div className="detail-section">
<label>Tags</label>
{editingField === 'tags' ? (
<div className="edit-field">
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="tag1, tag2, tag3"
onKeyDown={(e) => e.key === 'Enter' && handleSaveTags()}
autoFocus
/>
<button onClick={handleSaveTags}></button>
<button onClick={() => setEditingField(null)}></button>
</div>
) : (
<div className="display-field tags-display" onClick={() => { setEditTags((selectedWorkflow.tags || []).join(', ')); setEditingField('tags'); }}>
{selectedWorkflow.tags && selectedWorkflow.tags.length > 0 ? (
<div className="tags-list">
{selectedWorkflow.tags.map((tag, i) => (
<span key={i} className="tag">{tag}</span>
))}
</div>
) : (
<span className="placeholder">Cliquez pour ajouter des tags...</span>
)}
<button className="edit-btn"></button>
</div>
)}
</div>
{/* Trigger Examples */}
<div className="detail-section">
<label>Phrases de déclenchement</label>
{editingField === 'triggers' ? (
<div className="edit-field">
<textarea
value={editTriggers}
onChange={(e) => setEditTriggers(e.target.value)}
rows={4}
placeholder="Une phrase par ligne..."
autoFocus
/>
<div className="edit-actions">
<button onClick={handleSaveTriggers}> Sauvegarder</button>
<button onClick={() => setEditingField(null)}> Annuler</button>
</div>
</div>
) : (
<div className="display-field triggers-display" onClick={() => { setEditTriggers((selectedWorkflow.trigger_examples || []).join('\n')); setEditingField('triggers'); }}>
{selectedWorkflow.trigger_examples && selectedWorkflow.trigger_examples.length > 0 ? (
<ul className="triggers-list">
{selectedWorkflow.trigger_examples.map((ex, i) => (
<li key={i}>"{ex}"</li>
))}
</ul>
) : (
<span className="placeholder">Cliquez pour ajouter des phrases...</span>
)}
<button className="edit-btn"></button>
</div>
)}
</div>
{/* Statistiques */}
<div className="detail-section stats">
<label>Informations</label>
<div className="stats-grid">
<div className="stat">
<span className="stat-value">{selectedWorkflow.step_count}</span>
<span className="stat-label">étapes</span>
</div>
</div>
</div>
{/* Actions */}
<div className="detail-actions">
<button className="btn-primary" onClick={handleOpenWorkflow}>
Ouvrir ce workflow
</button>
<button className="btn-danger" onClick={() => handleDelete(selectedWorkflow.id)}>
Supprimer
</button>
</div>
</>
) : (
<div className="no-selection">
<p>Sélectionnez un workflow pour voir ses détails</p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
/**
* Composant pour éditer les métadonnées d'un workflow
* - Description : ce que fait le workflow
* - Tags : catégories pour le matching
* - Exemples de déclenchement : phrases qui activent ce workflow
*/
import { useState, useEffect } from 'react';
import type { Workflow } from '../types';
interface Props {
workflow: Workflow | null;
onUpdate: (data: { description?: string; tags?: string[]; triggerExamples?: string[] }) => void;
}
export default function WorkflowMetadata({ workflow, onUpdate }: Props) {
const [isExpanded, setIsExpanded] = useState(false);
const [description, setDescription] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [examplesInput, setExamplesInput] = useState('');
const [hasChanges, setHasChanges] = useState(false);
// Synchroniser avec le workflow
useEffect(() => {
if (workflow) {
setDescription(workflow.description || '');
setTagsInput((workflow.tags || []).join(', '));
setExamplesInput((workflow.triggerExamples || []).join('\n'));
setHasChanges(false);
}
}, [workflow?.id]);
// Détecter les changements
useEffect(() => {
if (!workflow) return;
const currentTags = tagsInput.split(',').map(t => t.trim()).filter(Boolean);
const currentExamples = examplesInput.split('\n').map(e => e.trim()).filter(Boolean);
const changed =
description !== (workflow.description || '') ||
JSON.stringify(currentTags) !== JSON.stringify(workflow.tags || []) ||
JSON.stringify(currentExamples) !== JSON.stringify(workflow.triggerExamples || []);
setHasChanges(changed);
}, [description, tagsInput, examplesInput, workflow]);
const handleSave = () => {
const tags = tagsInput.split(',').map(t => t.trim()).filter(Boolean);
const triggerExamples = examplesInput.split('\n').map(e => e.trim()).filter(Boolean);
onUpdate({
description: description || undefined,
tags: tags.length > 0 ? tags : undefined,
triggerExamples: triggerExamples.length > 0 ? triggerExamples : undefined,
});
setHasChanges(false);
};
if (!workflow) {
return (
<div className="workflow-metadata">
<div className="metadata-header" onClick={() => setIsExpanded(!isExpanded)}>
<span className="metadata-chevron">{isExpanded ? '▼' : '▶'}</span>
<span className="metadata-icon">📋</span>
<span className="metadata-title">Métadonnées</span>
</div>
{isExpanded && (
<div className="metadata-empty">
Sélectionnez un workflow pour éditer ses métadonnées
</div>
)}
</div>
);
}
return (
<div className="workflow-metadata">
<div className="metadata-header" onClick={() => setIsExpanded(!isExpanded)}>
<span className="metadata-chevron">{isExpanded ? '▼' : '▶'}</span>
<span className="metadata-icon">📋</span>
<span className="metadata-title">Métadonnées</span>
{hasChanges && <span className="metadata-unsaved"></span>}
{(workflow.tags?.length || 0) > 0 && (
<span className="metadata-count">{workflow.tags?.length} tags</span>
)}
</div>
{isExpanded && (
<div className="metadata-content">
{/* Description */}
<div className="metadata-field">
<label htmlFor="wf-description">
Description
<span className="field-hint">Ce que fait ce workflow</span>
</label>
<textarea
id="wf-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Ex: Ce workflow permet de créer une facture pour un client dans le logiciel de comptabilité"
rows={3}
/>
</div>
{/* Tags */}
<div className="metadata-field">
<label htmlFor="wf-tags">
Tags
<span className="field-hint">Catégories séparées par des virgules</span>
</label>
<input
id="wf-tags"
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="Ex: facturation, comptabilité, client"
/>
{tagsInput && (
<div className="tags-preview">
{tagsInput.split(',').map(t => t.trim()).filter(Boolean).map((tag, i) => (
<span key={i} className="tag-chip">{tag}</span>
))}
</div>
)}
</div>
{/* Exemples de déclenchement */}
<div className="metadata-field">
<label htmlFor="wf-examples">
Exemples de déclenchement
<span className="field-hint">Phrases qui activent ce workflow (une par ligne)</span>
</label>
<textarea
id="wf-examples"
value={examplesInput}
onChange={(e) => setExamplesInput(e.target.value)}
placeholder={"Ex:\nCréer une facture pour le client Dupont\nNouvelle facture\nFacturer la commande 123"}
rows={4}
/>
{examplesInput && (
<div className="examples-count">
{examplesInput.split('\n').filter(e => e.trim()).length} exemple(s)
</div>
)}
</div>
{/* Bouton sauvegarder */}
<div className="metadata-actions">
<button
className={`btn-save-metadata ${hasChanges ? 'has-changes' : ''}`}
onClick={handleSave}
disabled={!hasChanges}
>
{hasChanges ? '💾 Sauvegarder' : '✓ Sauvegardé'}
</button>
</div>
{/* Info sur le matching */}
<div className="metadata-info">
<span className="info-icon">💡</span>
<span>Ces métadonnées permettent au système de trouver automatiquement ce workflow quand vous donnez une instruction en langage naturel.</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useState, useRef, useEffect } from 'react';
import type { WorkflowSummary } from '../types';
interface Props {
workflows: WorkflowSummary[];
activeWorkflow: { id: string; name: string } | null;
onSelect: (id: string) => void;
onCreate: () => void;
onOpenManager: () => void;
onRename: (id: string, newName: string) => void;
}
export default function WorkflowSelector({
workflows,
activeWorkflow,
onSelect,
onCreate,
onOpenManager,
onRename
}: Props) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Fermer le dropdown si clic extérieur
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
setEditingId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus sur l'input d'édition
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
// Filtrer les workflows
const filteredWorkflows = workflows.filter(wf =>
wf.name.toLowerCase().includes(search.toLowerCase()) ||
(wf.tags || []).some(tag => tag.toLowerCase().includes(search.toLowerCase()))
);
// Workflows récents (les 8 premiers)
const recentWorkflows = filteredWorkflows.slice(0, 8);
const hasMore = filteredWorkflows.length > 8;
const handleSelect = (id: string) => {
onSelect(id);
setIsOpen(false);
setSearch('');
};
const handleDoubleClick = (wf: WorkflowSummary) => {
setEditingId(wf.id);
setEditName(wf.name);
};
const handleRenameSubmit = (id: string) => {
if (editName.trim() && editName !== workflows.find(w => w.id === id)?.name) {
onRename(id, editName.trim());
}
setEditingId(null);
};
const handleKeyDown = (e: React.KeyboardEvent, id: string) => {
if (e.key === 'Enter') {
handleRenameSubmit(id);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
return (
<div className="workflow-selector" ref={dropdownRef}>
{/* Bouton principal */}
<button
className="workflow-selector-btn"
onClick={() => setIsOpen(!isOpen)}
>
<span className="workflow-icon">📂</span>
<span className="workflow-name">
{activeWorkflow ? activeWorkflow.name : 'Sélectionner un workflow'}
</span>
<span className="workflow-count">({workflows.length})</span>
<span className={`dropdown-arrow ${isOpen ? 'open' : ''}`}></span>
</button>
{/* Dropdown */}
{isOpen && (
<div className="workflow-dropdown">
{/* Barre de recherche */}
<div className="dropdown-search">
<input
type="text"
placeholder="Rechercher..."
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
/>
{search && (
<button className="clear-search" onClick={() => setSearch('')}>×</button>
)}
</div>
{/* Actions rapides */}
<div className="dropdown-actions">
<button onClick={() => { onCreate(); setIsOpen(false); }}>
+ Nouveau workflow
</button>
</div>
{/* Liste des workflows */}
<div className="dropdown-list">
{recentWorkflows.length === 0 ? (
<p className="no-results">
{search ? 'Aucun résultat' : 'Aucun workflow'}
</p>
) : (
recentWorkflows.map(wf => (
<div
key={wf.id}
className={`dropdown-item ${wf.id === activeWorkflow?.id ? 'active' : ''}`}
onClick={() => editingId !== wf.id && handleSelect(wf.id)}
onDoubleClick={() => handleDoubleClick(wf)}
>
{editingId === wf.id ? (
<input
ref={inputRef}
type="text"
className="rename-input"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => handleRenameSubmit(wf.id)}
onKeyDown={(e) => handleKeyDown(e, wf.id)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<span className="item-name">{wf.name}</span>
<span className="item-meta">
{wf.step_count} étapes
{wf.tags && wf.tags.length > 0 && (
<span className="item-tags">
{wf.tags.slice(0, 2).map((tag, i) => (
<span key={i} className="mini-tag">{tag}</span>
))}
</span>
)}
</span>
</>
)}
</div>
))
)}
</div>
{/* Lien vers le gestionnaire */}
{(hasMore || workflows.length > 0) && (
<div className="dropdown-footer">
<button onClick={() => { onOpenManager(); setIsOpen(false); }}>
Gérer tous les workflows...
</button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,8 @@
* Service de détection UI (UI-DETR-1)
*/
const API_BASE = 'http://localhost:5001';
// Utilise l'hostname actuel pour permettre l'accès réseau
const API_BASE = `http://${window.location.hostname}:5001`;
export interface UIElement {
id: number;
@@ -136,3 +137,55 @@ export async function findElement(
return data.result;
}
/**
* Résultat d'un clic intelligent
*/
export interface IntelligentClickResult {
found: boolean;
coordinates: { x: number; y: number } | null;
bbox: { x1: number; y1: number; x2: number; y2: number } | null;
confidence: number;
clicked: boolean;
method: string;
search_time_ms: number;
candidates: Array<{ element_id: number; score: number; bbox: any }>;
}
/**
* Effectue un clic intelligent en utilisant la vision
* Trouve l'ancre dans l'écran actuel et clique dessus
*/
export async function intelligentClick(
anchorImageBase64: string,
options: {
anchorBbox?: { x: number; y: number; width: number; height: number };
method?: 'template' | 'clip' | 'hybrid';
clickType?: 'left' | 'right' | 'double';
executeClick?: boolean;
threshold?: number;
} = {}
): Promise<IntelligentClickResult> {
const response = await fetch(`${API_BASE}/api/ui-detection/intelligent-click`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
anchor_image_base64: anchorImageBase64,
anchor_bbox: options.anchorBbox,
method: options.method ?? 'template',
click_type: options.clickType ?? 'left',
execute_click: options.executeClick ?? true,
threshold: options.threshold ?? 0.35,
}),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Erreur de clic intelligent');
}
return data.result;
}

File diff suppressed because it is too large Load Diff

View File

@@ -126,6 +126,8 @@ export interface Workflow {
id: string;
name: string;
description?: string;
tags?: string[];
triggerExamples?: string[];
steps: Step[];
created_at: string;
updated_at: string;
@@ -135,6 +137,9 @@ export interface WorkflowSummary {
id: string;
name: string;
step_count: number;
tags?: string[];
description?: string;
trigger_examples?: string[];
}
export interface Execution {