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:
1822
visual_workflow_builder/frontend_v4/package-lock.json
generated
Normal file
1822
visual_workflow_builder/frontend_v4/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user