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:
12
visual_workflow_builder/frontend_v4/index.html
Normal file
12
visual_workflow_builder/frontend_v4/index.html
Normal 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>
|
||||||
23
visual_workflow_builder/frontend_v4/package.json
Normal file
23
visual_workflow_builder/frontend_v4/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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[];
|
||||||
|
}
|
||||||
21
visual_workflow_builder/frontend_v4/tsconfig.json
Normal file
21
visual_workflow_builder/frontend_v4/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
visual_workflow_builder/frontend_v4/tsconfig.node.json
Normal file
10
visual_workflow_builder/frontend_v4/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
visual_workflow_builder/frontend_v4/vite.config.ts
Normal file
14
visual_workflow_builder/frontend_v4/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user