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:
Dom
2026-01-23 12:54:08 +01:00
parent 858e6007f9
commit 483653a0b4
16 changed files with 2569 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VWB - Visual Workflow Builder</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,23 @@
{
"name": "vwb-v4",
"version": "4.0.0",
"description": "Visual Workflow Builder - React + API v3",
"type": "module",
"scripts": {
"dev": "vite --port 3002",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@xyflow/react": "^12.0.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>,
)

View 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');
}

View 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;
}

View 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[];
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5001',
changeOrigin: true
}
}
}
})