feat(vwb-v4): Frontend React Flow avec palette d'outils complète
- Interface style n8n avec React Flow pour le canvas - 22 actions organisées en 7 catégories (souris, clavier, attente, données, logique, IA, validation) - 4 points d'accroche par nœud (haut, bas, droite, gauche) pour workflows complexes - Panel de propriétés complet avec tous les paramètres pour chaque type d'action - Capture d'écran plein écran avec sélection d'ancre - Thème sombre professionnel Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
308
visual_workflow_builder/frontend_v4/src/App.tsx
Normal file
308
visual_workflow_builder/frontend_v4/src/App.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react';
|
||||
import type { Node, Edge, NodeTypes } from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import * as api from './services/api';
|
||||
import type { AppState, Step, ActionType, Capture } from './types';
|
||||
import { ACTIONS } from './types';
|
||||
import StepNode from './components/StepNode';
|
||||
import ToolPalette from './components/ToolPalette';
|
||||
import PropertiesPanel from './components/PropertiesPanel';
|
||||
import CapturePanel from './components/CapturePanel';
|
||||
import WorkflowList from './components/WorkflowList';
|
||||
import ExecutionControls from './components/ExecutionControls';
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
step: StepNode,
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [appState, setAppState] = useState<AppState | null>(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [capture, setCapture] = useState<Capture | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Charger l'état initial
|
||||
const loadState = useCallback(async () => {
|
||||
try {
|
||||
const state = await api.getState();
|
||||
setAppState(state);
|
||||
updateNodesFromWorkflow(state.workflow?.steps || []);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadState();
|
||||
}, [loadState]);
|
||||
|
||||
// Convertir les étapes en nœuds React Flow
|
||||
const updateNodesFromWorkflow = (steps: Step[]) => {
|
||||
const newNodes: Node[] = steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
type: 'step',
|
||||
position: step.position || { x: 100, y: 100 + index * 120 },
|
||||
data: { step },
|
||||
}));
|
||||
|
||||
const newEdges: Edge[] = [];
|
||||
for (let i = 0; i < steps.length - 1; i++) {
|
||||
newEdges.push({
|
||||
id: `e-${steps[i].id}-${steps[i + 1].id}`,
|
||||
source: steps[i].id,
|
||||
sourceHandle: 'bottom',
|
||||
target: steps[i + 1].id,
|
||||
targetHandle: 'top',
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
};
|
||||
|
||||
// Actions
|
||||
const handleCreateWorkflow = async () => {
|
||||
const name = prompt('Nom du workflow:');
|
||||
if (!name) return;
|
||||
try {
|
||||
await api.createWorkflow(name);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectWorkflow = async (id: string) => {
|
||||
try {
|
||||
await api.selectWorkflow(id);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWorkflow = async (id: string) => {
|
||||
if (!confirm('Supprimer ce workflow ?')) return;
|
||||
try {
|
||||
await api.deleteWorkflow(id);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddStep = async (actionType: ActionType, position?: { x: number; y: number }) => {
|
||||
if (!appState?.session.active_workflow_id) {
|
||||
setError('Sélectionnez un workflow d\'abord');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.addStep(appState.session.active_workflow_id, actionType, { position });
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectStep = async (id: string) => {
|
||||
try {
|
||||
await api.selectStep(id);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteStep = async (id: string) => {
|
||||
if (!appState?.session.active_workflow_id) return;
|
||||
try {
|
||||
await api.deleteStep(appState.session.active_workflow_id, id);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStepParams = async (id: string, params: Record<string, unknown>) => {
|
||||
if (!appState?.session.active_workflow_id) return;
|
||||
try {
|
||||
await api.updateStep(appState.session.active_workflow_id, id, { parameters: params });
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeDragStop = async (_: unknown, node: Node) => {
|
||||
if (!appState?.session.active_workflow_id) return;
|
||||
try {
|
||||
await api.updateStep(appState.session.active_workflow_id, node.id, {
|
||||
position: { x: Math.round(node.position.x), y: Math.round(node.position.y) }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Erreur mise à jour position:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCapture = async () => {
|
||||
try {
|
||||
const result = await api.captureScreen();
|
||||
setCapture(result.capture);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAnchor = async (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => {
|
||||
if (!appState?.session.selected_step_id) {
|
||||
setError('Sélectionnez une étape d\'abord');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.selectAnchor(appState.session.selected_step_id, bbox, undefined, screenshotBase64);
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartExecution = async () => {
|
||||
try {
|
||||
await api.startExecution();
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopExecution = async () => {
|
||||
try {
|
||||
await api.stopExecution();
|
||||
await loadState();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// Drop d'un outil sur le canvas
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
const actionType = event.dataTransfer.getData('actionType') as ActionType;
|
||||
if (!actionType) return;
|
||||
|
||||
const reactFlowBounds = event.currentTarget.getBoundingClientRect();
|
||||
const position = {
|
||||
x: event.clientX - reactFlowBounds.left,
|
||||
y: event.clientY - reactFlowBounds.top,
|
||||
};
|
||||
|
||||
handleAddStep(actionType, position);
|
||||
},
|
||||
[appState]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
const selectedStep = appState?.workflow?.steps.find(
|
||||
s => s.id === appState?.session.selected_step_id
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Header */}
|
||||
<header className="header">
|
||||
<h1>VWB - Visual Workflow Builder</h1>
|
||||
<ExecutionControls
|
||||
execution={appState?.execution || null}
|
||||
onStart={handleStartExecution}
|
||||
onStop={handleStopExecution}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{/* Erreur */}
|
||||
{error && (
|
||||
<div className="error-bar">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="main-layout">
|
||||
{/* Sidebar gauche: Workflows + Outils */}
|
||||
<aside className="sidebar left">
|
||||
<WorkflowList
|
||||
workflows={appState?.workflows_list || []}
|
||||
activeId={appState?.session.active_workflow_id || null}
|
||||
onSelect={handleSelectWorkflow}
|
||||
onCreate={handleCreateWorkflow}
|
||||
onDelete={handleDeleteWorkflow}
|
||||
/>
|
||||
<ToolPalette />
|
||||
</aside>
|
||||
|
||||
{/* Canvas central */}
|
||||
<main className="canvas-container" onDrop={onDrop} onDragOver={onDragOver}>
|
||||
{appState?.workflow ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, node) => handleSelectStep(node.id)}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
>
|
||||
<Controls />
|
||||
<Background />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div className="empty-canvas">
|
||||
<p>Sélectionnez ou créez un workflow</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Sidebar droite: Propriétés + Capture */}
|
||||
<aside className="sidebar right">
|
||||
<PropertiesPanel
|
||||
step={selectedStep || null}
|
||||
onUpdateParams={handleUpdateStepParams}
|
||||
onDelete={handleDeleteStep}
|
||||
/>
|
||||
<CapturePanel
|
||||
capture={capture}
|
||||
onCapture={handleCapture}
|
||||
onSelectAnchor={handleSelectAnchor}
|
||||
hasSelectedStep={!!appState?.session.selected_step_id}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<App />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import type { Capture } from '../types';
|
||||
|
||||
interface Props {
|
||||
capture: Capture | null;
|
||||
onCapture: () => void;
|
||||
onSelectAnchor: (bbox: { x: number; y: number; width: number; height: number }, screenshotBase64?: string) => void;
|
||||
hasSelectedStep: boolean;
|
||||
}
|
||||
|
||||
interface LibraryItem {
|
||||
id: string;
|
||||
capture: Capture;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export default function CapturePanel({ capture, onCapture, onSelectAnchor, hasSelectedStep }: 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);
|
||||
|
||||
// Charger la bibliothèque depuis sessionStorage
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem('captureLibrary');
|
||||
if (stored) {
|
||||
setLibrary(JSON.parse(stored));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Sauvegarder la bibliothèque
|
||||
useEffect(() => {
|
||||
sessionStorage.setItem('captureLibrary', JSON.stringify(library));
|
||||
}, [library]);
|
||||
|
||||
// Ajouter capture à la bibliothèque
|
||||
useEffect(() => {
|
||||
if (capture) {
|
||||
setCurrentCapture(capture);
|
||||
const newItem: LibraryItem = {
|
||||
id: `cap_${Date.now()}`,
|
||||
capture,
|
||||
timestamp: new Date()
|
||||
};
|
||||
setLibrary(prev => [newItem, ...prev.slice(0, 19)]);
|
||||
}
|
||||
}, [capture]);
|
||||
|
||||
const handleTimerCapture = () => {
|
||||
if (timerSeconds === 0) {
|
||||
onCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining = timerSeconds;
|
||||
setCountdown(remaining);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
remaining--;
|
||||
if (remaining > 0) {
|
||||
setCountdown(remaining);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setCountdown(null);
|
||||
onCapture();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleLibrarySelect = (item: LibraryItem) => {
|
||||
setCurrentCapture(item.capture);
|
||||
};
|
||||
|
||||
const handleDeleteLibraryItem = (id: string) => {
|
||||
setLibrary(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="capture-panel">
|
||||
<h3>Capture</h3>
|
||||
|
||||
{/* Contrôles de capture */}
|
||||
<div className="capture-controls">
|
||||
<button onClick={onCapture} disabled={countdown !== null}>
|
||||
Capturer
|
||||
</button>
|
||||
<select value={timerSeconds} onChange={(e) => setTimerSeconds(Number(e.target.value))}>
|
||||
<option value="0">Immédiat</option>
|
||||
<option value="3">3 sec</option>
|
||||
<option value="5">5 sec</option>
|
||||
<option value="10">10 sec</option>
|
||||
</select>
|
||||
<button onClick={handleTimerCapture} disabled={countdown !== null}>
|
||||
{countdown !== null ? countdown : 'Timer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Aperçu de la capture */}
|
||||
{currentCapture && (
|
||||
<div className="capture-preview">
|
||||
<img
|
||||
src={`data:image/png;base64,${currentCapture.screenshot_base64}`}
|
||||
alt="Capture"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
/>
|
||||
<p className="capture-info">
|
||||
{currentCapture.width}x{currentCapture.height}
|
||||
<button onClick={() => setIsFullscreen(true)}>Plein écran</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSelectedStep && currentCapture && (
|
||||
<p className="capture-hint">Sélectionnez une étape pour définir l'ancre</p>
|
||||
)}
|
||||
|
||||
{/* Bibliothèque */}
|
||||
<div className="capture-library">
|
||||
<h4>Bibliothèque ({library.length})</h4>
|
||||
<div className="library-grid">
|
||||
{library.map(item => (
|
||||
<div key={item.id} className="library-item">
|
||||
<img
|
||||
src={`data:image/png;base64,${item.capture.screenshot_base64}`}
|
||||
alt="Capture"
|
||||
onClick={() => handleLibrarySelect(item)}
|
||||
/>
|
||||
<button
|
||||
className="delete-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteLibraryItem(item.id); }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal plein écran */}
|
||||
{isFullscreen && currentCapture && (
|
||||
<FullscreenSelector
|
||||
capture={currentCapture}
|
||||
onClose={() => setIsFullscreen(false)}
|
||||
onSelect={(bbox) => {
|
||||
onSelectAnchor(bbox, currentCapture.screenshot_base64);
|
||||
setIsFullscreen(false);
|
||||
}}
|
||||
enabled={hasSelectedStep}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant sélecteur plein écran
|
||||
function FullscreenSelector({
|
||||
capture,
|
||||
onClose,
|
||||
onSelect,
|
||||
enabled
|
||||
}: {
|
||||
capture: Capture;
|
||||
onClose: () => void;
|
||||
onSelect: (bbox: { x: number; y: number; width: number; height: number }) => void;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
|
||||
const [selection, setSelection] = useState({ x: 0, y: 0, width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!enabled || !imgRef.current) return;
|
||||
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
setIsSelecting(true);
|
||||
setStartPos({ x, y });
|
||||
setSelection({ x, y, width: 0, height: 0 });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isSelecting || !imgRef.current) return;
|
||||
|
||||
const rect = imgRef.current.getBoundingClientRect();
|
||||
const currentX = e.clientX - rect.left;
|
||||
const currentY = e.clientY - rect.top;
|
||||
|
||||
const width = currentX - startPos.x;
|
||||
const height = currentY - startPos.y;
|
||||
|
||||
setSelection({
|
||||
x: width < 0 ? currentX : startPos.x,
|
||||
y: height < 0 ? currentY : startPos.y,
|
||||
width: Math.abs(width),
|
||||
height: Math.abs(height)
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isSelecting || !imgRef.current) return;
|
||||
setIsSelecting(false);
|
||||
|
||||
if (selection.width < 10 || selection.height < 10) return;
|
||||
|
||||
// Convertir en coordonnées réelles de l'image
|
||||
const scaleX = imgRef.current.naturalWidth / imgRef.current.width;
|
||||
const scaleY = imgRef.current.naturalHeight / imgRef.current.height;
|
||||
|
||||
const realBbox = {
|
||||
x: Math.round(selection.x * scaleX),
|
||||
y: Math.round(selection.y * scaleY),
|
||||
width: Math.round(selection.width * scaleX),
|
||||
height: Math.round(selection.height * scaleY)
|
||||
};
|
||||
|
||||
onSelect(realBbox);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fullscreen-modal">
|
||||
<div className="fullscreen-header">
|
||||
<span>{enabled ? 'Dessinez un rectangle pour sélectionner l\'ancre' : 'Sélectionnez d\'abord une étape'}</span>
|
||||
<button onClick={onClose}>Fermer (Échap)</button>
|
||||
</div>
|
||||
<div
|
||||
className="fullscreen-content"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={`data:image/png;base64,${capture.screenshot_base64}`}
|
||||
alt="Capture plein écran"
|
||||
draggable={false}
|
||||
/>
|
||||
{(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Execution } from '../types';
|
||||
|
||||
interface Props {
|
||||
execution: Execution | null;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export default function ExecutionControls({ execution, onStart, onStop }: Props) {
|
||||
const isRunning = execution?.status === 'running' || execution?.status === 'paused';
|
||||
|
||||
return (
|
||||
<div className="execution-controls">
|
||||
{!isRunning ? (
|
||||
<button className="btn-start" onClick={onStart}>
|
||||
▶️ Exécuter
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="exec-status">
|
||||
<span className={`status-badge ${execution?.status}`}>
|
||||
{execution?.status === 'running' ? 'En cours' : 'En pause'}
|
||||
</span>
|
||||
<span className="exec-progress">
|
||||
{execution?.completed_steps}/{execution?.total_steps}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn-stop" onClick={onStop}>
|
||||
⏹️ Arrêter
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{execution?.status === 'completed' && (
|
||||
<span className="status-badge completed">Terminé</span>
|
||||
)}
|
||||
|
||||
{execution?.status === 'error' && (
|
||||
<span className="status-badge error" title={execution.error_message}>
|
||||
Erreur
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Step, ActionType } from '../types';
|
||||
import { ACTIONS } from '../types';
|
||||
import { getAnchorThumbnailUrl } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
step: Step | null;
|
||||
onUpdateParams: (id: string, params: Record<string, unknown>) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Props) {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (step) {
|
||||
setParams(step.parameters || {});
|
||||
}
|
||||
}, [step?.id, step?.parameters]);
|
||||
|
||||
if (!step) {
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<h3>Propriétés</h3>
|
||||
<p className="empty">Sélectionnez une étape</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const action = ACTIONS.find(a => a.type === step.action_type);
|
||||
|
||||
const updateParam = (key: string, value: unknown) => {
|
||||
setParams(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdateParams(step.id, params);
|
||||
};
|
||||
|
||||
const renderParamsForAction = (actionType: ActionType) => {
|
||||
switch (actionType) {
|
||||
// === SOURIS ===
|
||||
case 'click_anchor':
|
||||
case 'double_click_anchor':
|
||||
case 'right_click_anchor':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Délai avant clic (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.delay_before || 0)}
|
||||
onChange={(e) => updateParam('delay_before', Number(e.target.value))}
|
||||
min="0"
|
||||
step="100"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Délai après clic (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.delay_after || 0)}
|
||||
onChange={(e) => updateParam('delay_after', Number(e.target.value))}
|
||||
min="0"
|
||||
step="100"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'hover_anchor':
|
||||
return (
|
||||
<div className="prop-field">
|
||||
<label>Durée du survol (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.hover_duration || 500)}
|
||||
onChange={(e) => updateParam('hover_duration', Number(e.target.value))}
|
||||
min="0"
|
||||
step="100"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'drag_drop_anchor':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Ancre de destination</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.target_anchor_id || '')}
|
||||
onChange={(e) => updateParam('target_anchor_id', e.target.value)}
|
||||
placeholder="ID de l'ancre cible"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Vitesse du glissement</label>
|
||||
<select
|
||||
value={String(params.drag_speed || 'normal')}
|
||||
onChange={(e) => updateParam('drag_speed', e.target.value)}
|
||||
>
|
||||
<option value="slow">Lent</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="fast">Rapide</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'scroll_to_anchor':
|
||||
return (
|
||||
<div className="prop-field">
|
||||
<label>Direction</label>
|
||||
<select
|
||||
value={String(params.direction || 'auto')}
|
||||
onChange={(e) => updateParam('direction', e.target.value)}
|
||||
>
|
||||
<option value="auto">Automatique</option>
|
||||
<option value="up">Vers le haut</option>
|
||||
<option value="down">Vers le bas</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'focus_anchor':
|
||||
return (
|
||||
<div className="prop-field">
|
||||
<label>Méthode de focus</label>
|
||||
<select
|
||||
value={String(params.focus_method || 'click')}
|
||||
onChange={(e) => updateParam('focus_method', e.target.value)}
|
||||
>
|
||||
<option value="click">Clic</option>
|
||||
<option value="tab">Tabulation</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// === CLAVIER ===
|
||||
case 'type_text':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Texte à saisir</label>
|
||||
<textarea
|
||||
value={String(params.text || '')}
|
||||
onChange={(e) => updateParam('text', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Entrez le texte..."
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Vitesse de frappe (ms entre touches)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.typing_speed || 50)}
|
||||
onChange={(e) => updateParam('typing_speed', Number(e.target.value))}
|
||||
min="0"
|
||||
max="500"
|
||||
step="10"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.clear_before)}
|
||||
onChange={(e) => updateParam('clear_before', e.target.checked)}
|
||||
/>
|
||||
Effacer le champ avant
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'type_secret':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Clé du secret (variable d'env)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.secret_key || '')}
|
||||
onChange={(e) => updateParam('secret_key', e.target.value)}
|
||||
placeholder="MON_MOT_DE_PASSE"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-info">
|
||||
Le secret sera lu depuis les variables d'environnement
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'keyboard_shortcut':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Combinaison de touches</label>
|
||||
<input
|
||||
type="text"
|
||||
value={Array.isArray(params.keys) ? params.keys.join('+') : String(params.keys || '')}
|
||||
onChange={(e) => updateParam('keys', e.target.value.split('+').map(k => k.trim()))}
|
||||
placeholder="ctrl+c, alt+tab, enter..."
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-info">
|
||||
Touches disponibles: ctrl, alt, shift, cmd, tab, enter, escape, space, up, down, left, right, f1-f12
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === ATTENTE ===
|
||||
case 'wait_for_anchor':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Timeout (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.timeout_ms || 5000)}
|
||||
onChange={(e) => updateParam('timeout_ms', Number(e.target.value))}
|
||||
min="1000"
|
||||
step="1000"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Intervalle de vérification (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.check_interval || 500)}
|
||||
onChange={(e) => updateParam('check_interval', Number(e.target.value))}
|
||||
min="100"
|
||||
step="100"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.fail_on_timeout)}
|
||||
onChange={(e) => updateParam('fail_on_timeout', e.target.checked)}
|
||||
/>
|
||||
Échouer si timeout
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === DONNÉES ===
|
||||
case 'extract_text':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Nom de la variable</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.variable_name || '')}
|
||||
onChange={(e) => updateParam('variable_name', e.target.value)}
|
||||
placeholder="texte_extrait"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Méthode d'extraction</label>
|
||||
<select
|
||||
value={String(params.extraction_method || 'ocr')}
|
||||
onChange={(e) => updateParam('extraction_method', e.target.value)}
|
||||
>
|
||||
<option value="ocr">OCR (image)</option>
|
||||
<option value="clipboard">Copier-coller</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Regex de filtrage (optionnel)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.regex_filter || '')}
|
||||
onChange={(e) => updateParam('regex_filter', e.target.value)}
|
||||
placeholder="Ex: \d{4}-\d{2}-\d{2}"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'extract_table':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Nom de la variable</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.variable_name || '')}
|
||||
onChange={(e) => updateParam('variable_name', e.target.value)}
|
||||
placeholder="tableau_extrait"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Format de sortie</label>
|
||||
<select
|
||||
value={String(params.output_format || 'json')}
|
||||
onChange={(e) => updateParam('output_format', e.target.value)}
|
||||
>
|
||||
<option value="json">JSON</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="array">Array</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.include_headers)}
|
||||
onChange={(e) => updateParam('include_headers', e.target.checked)}
|
||||
/>
|
||||
Inclure les en-têtes
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'screenshot_evidence':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Nom du fichier</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.filename || '')}
|
||||
onChange={(e) => updateParam('filename', e.target.value)}
|
||||
placeholder="capture_{timestamp}"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Zone à capturer</label>
|
||||
<select
|
||||
value={String(params.capture_area || 'full')}
|
||||
onChange={(e) => updateParam('capture_area', e.target.value)}
|
||||
>
|
||||
<option value="full">Écran complet</option>
|
||||
<option value="anchor">Zone de l'ancre</option>
|
||||
<option value="window">Fenêtre active</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'download_to_folder':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Dossier de destination</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.folder_path || '')}
|
||||
onChange={(e) => updateParam('folder_path', e.target.value)}
|
||||
placeholder="/chemin/vers/dossier"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Timeout téléchargement (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.download_timeout || 30000)}
|
||||
onChange={(e) => updateParam('download_timeout', Number(e.target.value))}
|
||||
min="5000"
|
||||
step="5000"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === LOGIQUE ===
|
||||
case 'visual_condition':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Si trouvé, aller à l'étape</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.on_found || '')}
|
||||
onChange={(e) => updateParam('on_found', e.target.value)}
|
||||
placeholder="ID de l'étape"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Si non trouvé, aller à l'étape</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.on_not_found || '')}
|
||||
onChange={(e) => updateParam('on_not_found', e.target.value)}
|
||||
placeholder="ID de l'étape"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Timeout de recherche (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.search_timeout || 3000)}
|
||||
onChange={(e) => updateParam('search_timeout', Number(e.target.value))}
|
||||
min="1000"
|
||||
step="1000"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'loop_visual':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Nombre max d'itérations</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.max_iterations || 10)}
|
||||
onChange={(e) => updateParam('max_iterations', Number(e.target.value))}
|
||||
min="1"
|
||||
max="1000"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Condition d'arrêt</label>
|
||||
<select
|
||||
value={String(params.stop_condition || 'not_found')}
|
||||
onChange={(e) => updateParam('stop_condition', e.target.value)}
|
||||
>
|
||||
<option value="not_found">Élément non trouvé</option>
|
||||
<option value="found">Élément trouvé</option>
|
||||
<option value="max_reached">Max itérations atteint</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Délai entre itérations (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.iteration_delay || 500)}
|
||||
onChange={(e) => updateParam('iteration_delay', Number(e.target.value))}
|
||||
min="100"
|
||||
step="100"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === IA ===
|
||||
case 'ai_analyze_text':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Prompt pour l'IA</label>
|
||||
<textarea
|
||||
value={String(params.prompt || '')}
|
||||
onChange={(e) => updateParam('prompt', e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Ex: Extrais le montant total de cette facture"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Variable de sortie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.variable_name || '')}
|
||||
onChange={(e) => updateParam('variable_name', e.target.value)}
|
||||
placeholder="resultat_ia"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Modèle IA</label>
|
||||
<select
|
||||
value={String(params.model || 'auto')}
|
||||
onChange={(e) => updateParam('model', e.target.value)}
|
||||
>
|
||||
<option value="auto">Automatique</option>
|
||||
<option value="gpt-4">GPT-4</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="local">Local (Ollama)</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === BDD ===
|
||||
case 'db_save_data':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Nom de la table</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.table || '')}
|
||||
onChange={(e) => updateParam('table', e.target.value)}
|
||||
placeholder="ma_table"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Données (JSON)</label>
|
||||
<textarea
|
||||
value={String(params.data || '')}
|
||||
onChange={(e) => updateParam('data', e.target.value)}
|
||||
rows={3}
|
||||
placeholder='{"champ": "valeur"}'
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'db_read_data':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Requête SQL</label>
|
||||
<textarea
|
||||
value={String(params.query || '')}
|
||||
onChange={(e) => updateParam('query', e.target.value)}
|
||||
rows={3}
|
||||
placeholder="SELECT * FROM ma_table WHERE..."
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Variable de sortie</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.variable_name || '')}
|
||||
onChange={(e) => updateParam('variable_name', e.target.value)}
|
||||
placeholder="resultat_requete"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// === VALIDATION ===
|
||||
case 'verify_element_exists':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Timeout (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(params.timeout_ms || 5000)}
|
||||
onChange={(e) => updateParam('timeout_ms', Number(e.target.value))}
|
||||
min="1000"
|
||||
step="1000"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.should_exist !== false)}
|
||||
onChange={(e) => updateParam('should_exist', e.target.checked)}
|
||||
/>
|
||||
L'élément doit exister
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case 'verify_text_content':
|
||||
return (
|
||||
<>
|
||||
<div className="prop-field">
|
||||
<label>Texte attendu</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(params.expected_text || '')}
|
||||
onChange={(e) => updateParam('expected_text', e.target.value)}
|
||||
placeholder="Texte à vérifier"
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-field">
|
||||
<label>Mode de comparaison</label>
|
||||
<select
|
||||
value={String(params.match_mode || 'contains')}
|
||||
onChange={(e) => updateParam('match_mode', e.target.value)}
|
||||
>
|
||||
<option value="exact">Exact</option>
|
||||
<option value="contains">Contient</option>
|
||||
<option value="starts_with">Commence par</option>
|
||||
<option value="regex">Regex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="prop-field checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(params.case_sensitive)}
|
||||
onChange={(e) => updateParam('case_sensitive', e.target.checked)}
|
||||
/>
|
||||
Sensible à la casse
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div className="prop-info">Pas de paramètres supplémentaires</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<h3>Propriétés</h3>
|
||||
|
||||
<div className="prop-header">
|
||||
<span className="prop-icon">{action?.icon}</span>
|
||||
<span className="prop-type">{action?.label || step.action_type}</span>
|
||||
</div>
|
||||
|
||||
<div className="prop-id">ID: {step.id.slice(0, 25)}...</div>
|
||||
|
||||
{/* Paramètres spécifiques à l'action */}
|
||||
<div className="prop-params">
|
||||
{renderParamsForAction(step.action_type)}
|
||||
</div>
|
||||
|
||||
{/* Ancre visuelle */}
|
||||
{action?.needsAnchor && (
|
||||
<div className="prop-anchor">
|
||||
<label>Ancre visuelle</label>
|
||||
{step.anchor_id ? (
|
||||
<div className="anchor-preview">
|
||||
<img src={getAnchorThumbnailUrl(step.anchor_id)} alt="Ancre" />
|
||||
<span className="anchor-ok">✓ Définie</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="anchor-missing">
|
||||
⚠ Non définie - Utilisez la capture
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prop-actions">
|
||||
<button className="btn-save" onClick={handleSave}>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button className="btn-delete" onClick={() => onDelete(step.id)}>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import type { Step } from '../types';
|
||||
import { ACTIONS } from '../types';
|
||||
import { getAnchorThumbnailUrl } from '../services/api';
|
||||
|
||||
interface StepNodeProps {
|
||||
data: { step: Step };
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
function StepNode({ data, selected }: StepNodeProps) {
|
||||
const step = data.step;
|
||||
const action = ACTIONS.find(a => a.type === step.action_type);
|
||||
const isConditional = step.action_type === 'visual_condition' || step.action_type === 'loop_visual';
|
||||
|
||||
return (
|
||||
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''}`}>
|
||||
{/* Entrée: haut */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="top"
|
||||
className="handle-top"
|
||||
/>
|
||||
|
||||
<div className="step-node-header">
|
||||
<span className="step-icon">{action?.icon || '?'}</span>
|
||||
<span className="step-label">{action?.label || step.action_type}</span>
|
||||
</div>
|
||||
|
||||
{step.anchor_id && (
|
||||
<div className="step-node-anchor">
|
||||
<img
|
||||
src={getAnchorThumbnailUrl(step.anchor_id)}
|
||||
alt="Ancre"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.action_type === 'type_text' && typeof step.parameters?.text === 'string' && step.parameters.text.length > 0 && (
|
||||
<div className="step-node-params">
|
||||
{`"${step.parameters.text.slice(0, 20)}${step.parameters.text.length > 20 ? '...' : ''}"`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!step.anchor_id && action?.needsAnchor && (
|
||||
<div className="step-node-warning">
|
||||
Ancre requise
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sortie principale: bas */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
className="handle-bottom"
|
||||
/>
|
||||
|
||||
{/* Sortie alternative: droite (pour conditions/boucles) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="right"
|
||||
className="handle-right"
|
||||
style={{ top: '50%' }}
|
||||
/>
|
||||
|
||||
{/* Entrée latérale: gauche (pour retours de boucle) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="left"
|
||||
className="handle-left"
|
||||
style={{ top: '50%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(StepNode);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ACTIONS, ACTION_CATEGORIES } from '../types';
|
||||
|
||||
export default function ToolPalette() {
|
||||
const onDragStart = (event: React.DragEvent, actionType: string) => {
|
||||
event.dataTransfer.setData('actionType', actionType);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// Grouper par catégorie
|
||||
const categories = Object.keys(ACTION_CATEGORIES) as Array<keyof typeof ACTION_CATEGORIES>;
|
||||
|
||||
return (
|
||||
<div className="tool-palette">
|
||||
<h3>Outils</h3>
|
||||
<div className="tool-categories">
|
||||
{categories.map((catKey) => {
|
||||
const cat = ACTION_CATEGORIES[catKey];
|
||||
const tools = ACTIONS.filter(a => a.category === catKey);
|
||||
|
||||
if (tools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={catKey} className="tool-category">
|
||||
<div className="category-header">
|
||||
<span className="category-icon">{cat.icon}</span>
|
||||
<span className="category-label">{cat.label}</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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { WorkflowSummary } from '../types';
|
||||
|
||||
interface Props {
|
||||
workflows: WorkflowSummary[];
|
||||
activeId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function WorkflowList({ workflows, activeId, onSelect, onCreate, onDelete }: Props) {
|
||||
return (
|
||||
<div className="workflow-list">
|
||||
<div className="workflow-list-header">
|
||||
<h3>Workflows</h3>
|
||||
<button onClick={onCreate} title="Nouveau workflow">+</button>
|
||||
</div>
|
||||
|
||||
{workflows.length === 0 ? (
|
||||
<p className="empty">Aucun workflow</p>
|
||||
) : (
|
||||
<ul>
|
||||
{workflows.map(wf => (
|
||||
<li
|
||||
key={wf.id}
|
||||
className={wf.id === activeId ? 'active' : ''}
|
||||
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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
visual_workflow_builder/frontend_v4/src/main.tsx
Normal file
10
visual_workflow_builder/frontend_v4/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
152
visual_workflow_builder/frontend_v4/src/services/api.ts
Normal file
152
visual_workflow_builder/frontend_v4/src/services/api.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* API Client - Toutes les interactions avec le backend
|
||||
*/
|
||||
|
||||
import type { AppState, Workflow, Step, Execution, Capture, ActionType } from '../types';
|
||||
|
||||
const API_BASE = '/api/v3';
|
||||
|
||||
async function request<T>(method: string, endpoint: string, body?: unknown): Promise<T> {
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
};
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Erreur API');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Session
|
||||
export async function getState(): Promise<AppState> {
|
||||
const data = await request<{
|
||||
success: boolean;
|
||||
session: AppState['session'];
|
||||
workflow: AppState['workflow'];
|
||||
execution: AppState['execution'];
|
||||
workflows_list: AppState['workflows_list'];
|
||||
}>('GET', '/session/state');
|
||||
|
||||
return {
|
||||
session: data.session,
|
||||
workflow: data.workflow,
|
||||
execution: data.execution,
|
||||
workflows_list: data.workflows_list
|
||||
};
|
||||
}
|
||||
|
||||
export async function selectWorkflow(workflowId: string): Promise<{ session: AppState['session']; workflow: Workflow }> {
|
||||
return request('POST', `/session/select-workflow/${workflowId}`);
|
||||
}
|
||||
|
||||
export async function selectStep(stepId: string): Promise<{ session: AppState['session']; step: Step }> {
|
||||
return request('POST', `/session/select-step/${stepId}`);
|
||||
}
|
||||
|
||||
// Workflow CRUD
|
||||
export async function createWorkflow(name: string, description?: string): Promise<{ workflow: Workflow; session: AppState['session'] }> {
|
||||
return request('POST', '/workflow/create', { name, description });
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(workflowId: string): Promise<{ deleted_id: string; session: AppState['session'] }> {
|
||||
return request('DELETE', `/workflow/${workflowId}`);
|
||||
}
|
||||
|
||||
// Steps
|
||||
export async function addStep(
|
||||
workflowId: string,
|
||||
actionType: ActionType,
|
||||
options?: {
|
||||
position?: { x: number; y: number };
|
||||
parameters?: Record<string, unknown>;
|
||||
label?: string;
|
||||
insertAfter?: string;
|
||||
}
|
||||
): Promise<{ workflow: Workflow; step: Step; needs_anchor: boolean; session: AppState['session'] }> {
|
||||
return request('POST', `/workflow/${workflowId}/step`, {
|
||||
action_type: actionType,
|
||||
position: options?.position,
|
||||
parameters: options?.parameters,
|
||||
label: options?.label,
|
||||
insert_after: options?.insertAfter
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateStep(
|
||||
workflowId: string,
|
||||
stepId: string,
|
||||
updates: {
|
||||
action_type?: string;
|
||||
position?: { x: number; y: number };
|
||||
parameters?: Record<string, unknown>;
|
||||
label?: string;
|
||||
}
|
||||
): Promise<{ workflow: Workflow; step: Step }> {
|
||||
return request('PUT', `/workflow/${workflowId}/step/${stepId}`, updates);
|
||||
}
|
||||
|
||||
export async function deleteStep(workflowId: string, stepId: string): Promise<{ workflow: Workflow; session: AppState['session'] }> {
|
||||
return request('DELETE', `/workflow/${workflowId}/step/${stepId}`);
|
||||
}
|
||||
|
||||
export async function reorderSteps(workflowId: string, stepIds: string[]): Promise<{ workflow: Workflow }> {
|
||||
return request('POST', `/workflow/${workflowId}/reorder`, { step_ids: stepIds });
|
||||
}
|
||||
|
||||
// Capture
|
||||
export async function captureScreen(): Promise<{ capture: Capture }> {
|
||||
return request('POST', '/capture/screen');
|
||||
}
|
||||
|
||||
export async function selectAnchor(
|
||||
stepId: string,
|
||||
bbox: { x: number; y: number; width: number; height: number },
|
||||
description?: string,
|
||||
screenshotBase64?: string
|
||||
): Promise<{ workflow: Workflow; step: Step; anchor: unknown }> {
|
||||
return request('POST', '/capture/select', {
|
||||
step_id: stepId,
|
||||
bbox,
|
||||
description,
|
||||
screenshot_base64: screenshotBase64
|
||||
});
|
||||
}
|
||||
|
||||
export function getAnchorThumbnailUrl(anchorId: string): string {
|
||||
return `${API_BASE}/anchor/${anchorId}/thumbnail`;
|
||||
}
|
||||
|
||||
// Execution
|
||||
export async function startExecution(workflowId?: string): Promise<{ execution: Execution; session: AppState['session'] }> {
|
||||
return request('POST', '/execute/start', workflowId ? { workflow_id: workflowId } : {});
|
||||
}
|
||||
|
||||
export async function pauseExecution(): Promise<{ execution: Execution }> {
|
||||
return request('POST', '/execute/pause');
|
||||
}
|
||||
|
||||
export async function resumeExecution(): Promise<{ execution: Execution }> {
|
||||
return request('POST', '/execute/resume');
|
||||
}
|
||||
|
||||
export async function stopExecution(): Promise<{ execution: Execution; session: AppState['session'] }> {
|
||||
return request('POST', '/execute/stop');
|
||||
}
|
||||
|
||||
export async function getExecutionStatus(): Promise<{
|
||||
is_running: boolean;
|
||||
is_paused: boolean;
|
||||
execution: Execution | null;
|
||||
session: AppState['session'];
|
||||
}> {
|
||||
return request('GET', '/execute/status');
|
||||
}
|
||||
742
visual_workflow_builder/frontend_v4/src/styles.css
Normal file
742
visual_workflow_builder/frontend_v4/src/styles.css
Normal file
@@ -0,0 +1,742 @@
|
||||
/* Reset et base */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
/* Layout principal */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.25rem;
|
||||
color: #e94560;
|
||||
}
|
||||
|
||||
.error-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-bar button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebars */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: #16213e;
|
||||
border-right: 1px solid #0f3460;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar.right {
|
||||
border-right: none;
|
||||
border-left: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.sidebar h3, .sidebar h4 {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0f3460;
|
||||
color: #e94560;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Canvas central */
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.empty-canvas {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Workflow List */
|
||||
.workflow-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.workflow-list-header button {
|
||||
background: #e94560;
|
||||
border: none;
|
||||
color: white;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.workflow-list ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.workflow-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.workflow-list li:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.workflow-list li.active {
|
||||
background: #e94560;
|
||||
}
|
||||
|
||||
.workflow-list .wf-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workflow-list .wf-count {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.workflow-list .delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e94560;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.workflow-list li.active .delete-btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Tool Palette */
|
||||
.tool-palette {
|
||||
border-top: 1px solid #0f3460;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-categories {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-category {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #e94560;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.category-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tool-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #0f3460;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
transition: all 0.15s;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tool-item:hover {
|
||||
background: #e94560;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.tool-item:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 1rem;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tool-anchor-badge {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Step Node (React Flow) */
|
||||
.step-node {
|
||||
background: #16213e;
|
||||
border: 2px solid #0f3460;
|
||||
border-radius: 8px;
|
||||
width: 160px;
|
||||
padding: 0.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.step-node.selected {
|
||||
border-color: #e94560;
|
||||
box-shadow: 0 0 12px rgba(233, 69, 96, 0.5);
|
||||
}
|
||||
|
||||
.step-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.step-node-anchor {
|
||||
margin-top: 0.4rem;
|
||||
background: #0f3460;
|
||||
border-radius: 4px;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.step-node-anchor img {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
max-width: 140px;
|
||||
object-fit: contain;
|
||||
border-radius: 3px;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.step-node-params {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.3rem;
|
||||
background: #0f3460;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
color: #aaa;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.step-node-warning {
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #e94560;
|
||||
text-align: center;
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Nœud conditionnel */
|
||||
.step-node.conditional {
|
||||
border-color: #ff9800;
|
||||
}
|
||||
|
||||
.step-node.conditional.selected {
|
||||
border-color: #ffb74d;
|
||||
box-shadow: 0 0 12px rgba(255, 152, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Handles (points d'accroche) */
|
||||
.step-node .react-flow__handle {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #0f3460;
|
||||
background: #16213e;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.step-node .react-flow__handle:hover {
|
||||
background: #e94560;
|
||||
border-color: #e94560;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
/* Handle haut - entrée principale */
|
||||
.step-node .handle-top {
|
||||
background: #4caf50;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
/* Handle bas - sortie principale */
|
||||
.step-node .handle-bottom {
|
||||
background: #2196f3;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
|
||||
/* Handle droite - sortie alternative (conditions) */
|
||||
.step-node .handle-right {
|
||||
background: #ff9800;
|
||||
border-color: #ff9800;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Handle gauche - entrée secondaire (retour boucle) */
|
||||
.step-node .handle-left {
|
||||
background: #9c27b0;
|
||||
border-color: #9c27b0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
/* Properties Panel */
|
||||
.properties-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.properties-panel .empty {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.prop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prop-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.prop-type {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prop-id {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prop-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prop-field label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.prop-field input,
|
||||
.prop-field textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background: #0f3460;
|
||||
border: 1px solid #1a1a2e;
|
||||
color: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.prop-field textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.prop-anchor {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prop-anchor label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.anchor-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.anchor-preview img {
|
||||
width: 80px;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.anchor-ok {
|
||||
color: #4caf50;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.anchor-missing {
|
||||
color: #e94560;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prop-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.prop-actions button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Capture Panel */
|
||||
.capture-panel {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.capture-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.capture-controls button,
|
||||
.capture-controls select {
|
||||
padding: 0.5rem;
|
||||
background: #0f3460;
|
||||
border: none;
|
||||
color: #eee;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-controls button:hover {
|
||||
background: #e94560;
|
||||
}
|
||||
|
||||
.capture-preview {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.capture-preview img {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.capture-info button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #0f3460;
|
||||
border: none;
|
||||
color: #eee;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #e94560;
|
||||
font-style: italic;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Library */
|
||||
.capture-library h4 {
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.library-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.library-item img {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.library-item img:hover {
|
||||
border-color: #e94560;
|
||||
}
|
||||
|
||||
.library-item .delete-btn {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
background: rgba(233, 69, 96, 0.8);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.library-item:hover .delete-btn {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Fullscreen Modal */
|
||||
.fullscreen-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fullscreen-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
}
|
||||
|
||||
.fullscreen-header button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #e94560;
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fullscreen-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fullscreen-content img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selection-overlay {
|
||||
position: absolute;
|
||||
border: 2px solid #e94560;
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Execution Controls */
|
||||
.execution-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-start, .btn-stop {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-start {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.exec-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.paused {
|
||||
background: #ff9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #e94560;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.exec-progress {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* React Flow overrides */
|
||||
.react-flow__node {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: #0f3460;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__edge.selected .react-flow__edge-path {
|
||||
stroke: #e94560;
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
background: #16213e;
|
||||
border: 1px solid #0f3460;
|
||||
}
|
||||
|
||||
.react-flow__controls button {
|
||||
background: #16213e;
|
||||
border-bottom: 1px solid #0f3460;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.react-flow__controls button:hover {
|
||||
background: #0f3460;
|
||||
}
|
||||
|
||||
.react-flow__background {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
149
visual_workflow_builder/frontend_v4/src/types.ts
Normal file
149
visual_workflow_builder/frontend_v4/src/types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// Types pour l'API v3
|
||||
|
||||
export type ActionType =
|
||||
| 'click_anchor'
|
||||
| 'double_click_anchor'
|
||||
| 'right_click_anchor'
|
||||
| 'hover_anchor'
|
||||
| 'type_text'
|
||||
| 'type_secret'
|
||||
| 'focus_anchor'
|
||||
| 'wait_for_anchor'
|
||||
| 'scroll_to_anchor'
|
||||
| 'drag_drop_anchor'
|
||||
| 'keyboard_shortcut'
|
||||
| 'extract_text'
|
||||
| 'extract_table'
|
||||
| 'screenshot_evidence'
|
||||
| 'visual_condition'
|
||||
| 'loop_visual'
|
||||
| 'download_to_folder'
|
||||
| 'ai_analyze_text'
|
||||
| 'db_save_data'
|
||||
| 'db_read_data'
|
||||
| 'verify_element_exists'
|
||||
| 'verify_text_content';
|
||||
|
||||
export interface ActionDefinition {
|
||||
type: ActionType;
|
||||
label: string;
|
||||
icon: string;
|
||||
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'validation';
|
||||
needsAnchor: boolean;
|
||||
params: string[];
|
||||
}
|
||||
|
||||
export const ACTIONS: ActionDefinition[] = [
|
||||
// === SOURIS ===
|
||||
{ type: 'click_anchor', label: 'Clic', icon: '🖱️', category: 'mouse', needsAnchor: true, params: [] },
|
||||
{ type: 'double_click_anchor', label: 'Double-clic', icon: '🖱️', category: 'mouse', needsAnchor: true, params: [] },
|
||||
{ type: 'right_click_anchor', label: 'Clic droit', icon: '🖱️', category: 'mouse', needsAnchor: true, params: [] },
|
||||
{ type: 'hover_anchor', label: 'Survol', icon: '👆', category: 'mouse', needsAnchor: true, params: [] },
|
||||
{ type: 'drag_drop_anchor', label: 'Glisser-déposer', icon: '↔️', category: 'mouse', needsAnchor: true, params: ['target_anchor'] },
|
||||
{ type: 'scroll_to_anchor', label: 'Défiler vers', icon: '📜', category: 'mouse', needsAnchor: true, params: [] },
|
||||
{ type: 'focus_anchor', label: 'Focus', icon: '🎯', category: 'mouse', needsAnchor: true, params: [] },
|
||||
|
||||
// === CLAVIER ===
|
||||
{ type: 'type_text', label: 'Saisir texte', icon: '⌨️', category: 'keyboard', needsAnchor: false, params: ['text'] },
|
||||
{ type: 'type_secret', label: 'Saisir secret', icon: '🔐', category: 'keyboard', needsAnchor: false, params: ['secret_key'] },
|
||||
{ type: 'keyboard_shortcut', label: 'Raccourci clavier', icon: '⌘', category: 'keyboard', needsAnchor: false, params: ['keys'] },
|
||||
|
||||
// === ATTENTE ===
|
||||
{ type: 'wait_for_anchor', label: 'Attendre élément', icon: '⏳', category: 'wait', needsAnchor: true, params: ['timeout_ms'] },
|
||||
|
||||
// === EXTRACTION DE DONNÉES ===
|
||||
{ type: 'extract_text', label: 'Extraire texte', icon: '📋', category: 'data', needsAnchor: true, params: ['variable_name'] },
|
||||
{ type: 'extract_table', label: 'Extraire tableau', icon: '📊', category: 'data', needsAnchor: true, params: ['variable_name'] },
|
||||
{ type: 'screenshot_evidence', label: 'Capture preuve', icon: '📸', category: 'data', needsAnchor: false, params: ['filename'] },
|
||||
{ type: 'download_to_folder', label: 'Télécharger', icon: '💾', category: 'data', needsAnchor: false, params: ['folder_path'] },
|
||||
|
||||
// === LOGIQUE ===
|
||||
{ type: 'visual_condition', label: 'Condition visuelle', icon: '🔀', category: 'logic', needsAnchor: true, params: ['on_found', 'on_not_found'] },
|
||||
{ type: 'loop_visual', label: 'Boucle visuelle', icon: '🔁', category: 'logic', needsAnchor: true, params: ['max_iterations'] },
|
||||
|
||||
// === INTELLIGENCE ARTIFICIELLE ===
|
||||
{ type: 'ai_analyze_text', label: 'Analyse IA', icon: '🤖', category: 'ai', needsAnchor: true, params: ['prompt', 'variable_name'] },
|
||||
|
||||
// === BASE DE DONNÉES ===
|
||||
{ type: 'db_save_data', label: 'Sauvegarder en BDD', icon: '💿', category: 'data', needsAnchor: false, params: ['table', 'data'] },
|
||||
{ type: 'db_read_data', label: 'Lire depuis BDD', icon: '📖', category: 'data', needsAnchor: false, params: ['query', 'variable_name'] },
|
||||
|
||||
// === VALIDATION ===
|
||||
{ type: 'verify_element_exists', label: 'Vérifier présence', icon: '✅', category: 'validation', needsAnchor: true, params: ['timeout_ms'] },
|
||||
{ type: 'verify_text_content', label: 'Vérifier texte', icon: '🔍', category: 'validation', needsAnchor: true, params: ['expected_text'] },
|
||||
];
|
||||
|
||||
export const ACTION_CATEGORIES = {
|
||||
mouse: { label: 'Souris', icon: '🖱️' },
|
||||
keyboard: { label: 'Clavier', icon: '⌨️' },
|
||||
wait: { label: 'Attente', icon: '⏳' },
|
||||
data: { label: 'Données', icon: '📊' },
|
||||
logic: { label: 'Logique', icon: '🔀' },
|
||||
ai: { label: 'IA', icon: '🤖' },
|
||||
validation: { label: 'Validation', icon: '✅' },
|
||||
};
|
||||
|
||||
export interface VisualAnchor {
|
||||
id: string;
|
||||
bounding_box: { x: number; y: number; width: number; height: number };
|
||||
thumbnail_url?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
id: string;
|
||||
action_type: ActionType;
|
||||
order: number;
|
||||
label: string;
|
||||
position: { x: number; y: number };
|
||||
parameters: Record<string, unknown>;
|
||||
anchor_id?: string;
|
||||
anchor?: VisualAnchor;
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: Step[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkflowSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
step_count: number;
|
||||
}
|
||||
|
||||
export interface Execution {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: 'pending' | 'running' | 'paused' | 'completed' | 'error' | 'cancelled';
|
||||
progress: number;
|
||||
current_step_index: number;
|
||||
completed_steps: number;
|
||||
failed_steps: number;
|
||||
total_steps: number;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
active_workflow_id: string | null;
|
||||
selected_step_id: string | null;
|
||||
active_execution_id: string | null;
|
||||
}
|
||||
|
||||
export interface Capture {
|
||||
screenshot_base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
session: Session;
|
||||
workflow: Workflow | null;
|
||||
execution: Execution | null;
|
||||
workflows_list: WorkflowSummary[];
|
||||
}
|
||||
Reference in New Issue
Block a user