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

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

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

View File

@@ -0,0 +1,533 @@
/**
* Service StepTypeResolver - Résolution unifiée des types d'étapes
* Auteur : Dom, Alice, Kiro - 12 janvier 2026
*
* Ce service fournit une logique de mapping unifiée pour résoudre les configurations
* de paramètres des étapes, avec gestion robuste des types standard et VWB.
*/
import { Step, StepType, Variable } from '../types';
import { VWBCatalogAction } from '../types/catalog';
/**
* Configuration d'un paramètre d'étape
*/
export interface ParameterConfig {
name: string;
label: string;
type: 'text' | 'number' | 'boolean' | 'select' | 'visual';
required?: boolean;
description?: string;
supportVariables?: boolean;
options?: { value: string; label: string }[];
defaultValue?: any;
min?: number;
max?: number;
step?: number;
placeholder?: string;
multiline?: boolean;
group?: string;
order?: number;
conditional?: ConditionalRule;
}
/**
* Règle conditionnelle pour l'affichage de paramètres
*/
export interface ConditionalRule {
dependsOn: string;
condition: 'equals' | 'not_equals' | 'greater_than' | 'less_than';
value: any;
}
/**
* Résultat de la résolution d'un type d'étape
*/
export interface StepTypeResolutionResult {
stepType: string;
isVWBAction: boolean;
isStandardType: boolean;
parameterConfig: ParameterConfig[];
vwbAction?: VWBCatalogAction;
detectionMethods: Record<string, boolean>;
resolutionSource: 'stepParametersConfig' | 'vwbCatalog' | 'fallback';
timestamp: number;
}
/**
* Options de résolution
*/
export interface ResolutionOptions {
enableCache?: boolean;
enableLogging?: boolean;
fallbackToEmpty?: boolean;
vwbDetectionMethods?: string[];
}
/**
* Interface du résolveur de types d'étapes
*/
export interface IStepTypeResolver {
/**
* Résout la configuration des paramètres pour une étape
*/
resolveParameterConfig(step: Step, options?: ResolutionOptions): Promise<StepTypeResolutionResult>;
/**
* Vérifie si une étape est une action VWB
*/
isVWBAction(step: Step): boolean;
/**
* Obtient les détails d'une action VWB
*/
getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null>;
/**
* Invalide le cache de résolution
*/
invalidateCache(): void;
/**
* Obtient les statistiques de résolution
*/
getResolutionStats(): ResolutionStats;
}
/**
* Statistiques de résolution
*/
export interface ResolutionStats {
totalResolutions: number;
cacheHits: number;
cacheMisses: number;
vwbDetections: number;
standardDetections: number;
fallbackUsed: number;
averageResolutionTime: number;
}
/**
* Implémentation du résolveur de types d'étapes
*/
export class StepTypeResolver implements IStepTypeResolver {
private cache = new Map<string, StepTypeResolutionResult>();
private stats: ResolutionStats = {
totalResolutions: 0,
cacheHits: 0,
cacheMisses: 0,
vwbDetections: 0,
standardDetections: 0,
fallbackUsed: 0,
averageResolutionTime: 0
};
private resolutionTimes: number[] = [];
/**
* Configuration des paramètres par type d'étape standard
*/
private readonly stepParametersConfig: Record<StepType, ParameterConfig[]> = {
click: [
{
name: 'target',
label: 'Élément cible',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément à cliquer',
},
{
name: 'clickType',
label: 'Type de clic',
type: 'select',
options: [
{ value: 'left', label: 'Clic gauche' },
{ value: 'right', label: 'Clic droit' },
{ value: 'double', label: 'Double-clic' },
],
defaultValue: 'left',
},
],
type: [
{
name: 'target',
label: 'Champ de saisie',
type: 'visual',
required: true,
description: 'Sélectionner le champ où saisir le texte',
},
{
name: 'text',
label: 'Texte à saisir',
type: 'text',
required: true,
supportVariables: true,
},
{
name: 'clearFirst',
label: 'Vider le champ d\'abord',
type: 'boolean',
defaultValue: true,
},
],
wait: [
{
name: 'duration',
label: 'Durée (secondes)',
type: 'number',
required: true,
min: 0.1,
max: 60,
defaultValue: 1,
},
],
condition: [
{
name: 'condition',
label: 'Condition',
type: 'text',
required: true,
supportVariables: true,
description: 'Expression conditionnelle à évaluer',
},
],
extract: [
{
name: 'target',
label: 'Élément source',
type: 'visual',
required: true,
description: 'Sélectionner l\'élément dont extraire les données',
},
{
name: 'attribute',
label: 'Attribut à extraire',
type: 'select',
options: [
{ value: 'text', label: 'Texte' },
{ value: 'value', label: 'Valeur' },
{ value: 'href', label: 'Lien (href)' },
{ value: 'src', label: 'Source (src)' },
],
defaultValue: 'text',
},
],
scroll: [
{
name: 'direction',
label: 'Direction',
type: 'select',
options: [
{ value: 'up', label: 'Vers le haut' },
{ value: 'down', label: 'Vers le bas' },
{ value: 'left', label: 'Vers la gauche' },
{ value: 'right', label: 'Vers la droite' },
],
defaultValue: 'down',
},
{
name: 'amount',
label: 'Quantité (pixels)',
type: 'number',
defaultValue: 300,
min: 1,
},
],
navigate: [
{
name: 'url',
label: 'URL de destination',
type: 'text',
required: true,
supportVariables: true,
},
],
screenshot: [
{
name: 'filename',
label: 'Nom du fichier',
type: 'text',
supportVariables: true,
description: 'Nom du fichier de capture (optionnel)',
},
],
};
/**
* Actions VWB connues
*/
private readonly knownVWBActions = [
'click_anchor', 'type_text', 'type_secret', 'wait_for_anchor',
'extract_text', 'screenshot_evidence', 'scroll_to_anchor',
'focus_anchor', 'hotkey', 'navigate_to_url', 'browser_back',
'verify_element_exists', 'verify_text_content'
];
/**
* Résout la configuration des paramètres pour une étape
*/
async resolveParameterConfig(
step: Step,
options: ResolutionOptions = {}
): Promise<StepTypeResolutionResult> {
const startTime = performance.now();
try {
// Options par défaut
const resolveOptions = {
enableCache: true,
enableLogging: true,
fallbackToEmpty: true,
vwbDetectionMethods: ['all'],
...options
};
// Vérifier le cache
const cacheKey = this.generateCacheKey(step, resolveOptions);
if (resolveOptions.enableCache && this.cache.has(cacheKey)) {
this.stats.cacheHits++;
const cached = this.cache.get(cacheKey)!;
if (resolveOptions.enableLogging) {
console.log('🎯 [StepTypeResolver] Cache hit:', {
stepId: step.id,
stepType: step.type,
cacheKey
});
}
return cached;
}
this.stats.cacheMisses++;
// Analyser le type d'étape
const stepTypeString = step.type as string;
// Détecter si c'est une action VWB
const vwbDetectionResult = this.detectVWBAction(step, resolveOptions);
let result: StepTypeResolutionResult;
if (vwbDetectionResult.isVWBAction) {
// Résolution pour action VWB
result = await this.resolveVWBAction(step, vwbDetectionResult, resolveOptions);
this.stats.vwbDetections++;
} else {
// Résolution pour type standard
result = this.resolveStandardType(step, resolveOptions);
this.stats.standardDetections++;
}
// Mettre en cache le résultat
if (resolveOptions.enableCache) {
this.cache.set(cacheKey, result);
}
// Logging détaillé
if (resolveOptions.enableLogging) {
console.log('🔍 [StepTypeResolver] Résolution complète:', {
stepId: step.id,
stepType: stepTypeString,
isVWBAction: result.isVWBAction,
parameterCount: result.parameterConfig.length,
resolutionSource: result.resolutionSource,
detectionMethods: result.detectionMethods
});
}
// Mettre à jour les statistiques
const resolutionTime = performance.now() - startTime;
this.resolutionTimes.push(resolutionTime);
this.stats.totalResolutions++;
this.stats.averageResolutionTime =
this.resolutionTimes.reduce((a, b) => a + b, 0) / this.resolutionTimes.length;
return result;
} catch (error) {
console.error('❌ [StepTypeResolver] Erreur de résolution:', error);
// Fallback en cas d'erreur
const fallbackResult: StepTypeResolutionResult = {
stepType: step.type as string,
isVWBAction: false,
isStandardType: false,
parameterConfig: [],
detectionMethods: { error: true },
resolutionSource: 'fallback',
timestamp: Date.now()
};
this.stats.fallbackUsed++;
return fallbackResult;
}
}
/**
* Vérifie si une étape est une action VWB
*/
isVWBAction(step: Step): boolean {
const detection = this.detectVWBAction(step, { enableLogging: false });
return detection.isVWBAction;
}
/**
* Détecte si une étape est une action VWB avec méthodes multiples
*/
private detectVWBAction(step: Step, options: ResolutionOptions): {
isVWBAction: boolean;
detectionMethods: Record<string, boolean>;
confidence: number;
} {
const stepTypeString = step.type as string;
// Méthodes de détection multiples pour robustesse
const detectionMethods = {
hasVWBFlag: Boolean(step.data?.isVWBCatalogAction),
hasVWBActionId: Boolean(step.data?.vwbActionId),
typeStartsWithVWB: stepTypeString.startsWith('vwb_'),
typeContainsAnchor: stepTypeString.includes('_anchor'),
typeContainsText: stepTypeString.includes('_text'),
typeContainsSecret: stepTypeString.includes('_secret'),
isKnownVWBAction: this.knownVWBActions.includes(stepTypeString),
hasVWBPattern: /^(click|type|wait|extract|scroll|focus|hotkey|navigate|browser|verify)_/.test(stepTypeString)
};
// Calculer la confiance basée sur le nombre de méthodes positives
const positiveDetections = Object.values(detectionMethods).filter(Boolean).length;
const confidence = positiveDetections / Object.keys(detectionMethods).length;
// Une action est considérée VWB si au moins une méthode la détecte
const isVWBAction = positiveDetections > 0;
if (options.enableLogging) {
console.log('🎯 [StepTypeResolver] Détection VWB:', {
stepType: stepTypeString,
detectionMethods,
positiveDetections,
confidence: `${(confidence * 100).toFixed(1)}%`,
isVWBAction
});
}
return {
isVWBAction,
detectionMethods,
confidence
};
}
/**
* Résout une action VWB
*/
private async resolveVWBAction(
step: Step,
vwbDetection: any,
options: ResolutionOptions
): Promise<StepTypeResolutionResult> {
try {
// Charger les détails de l'action VWB si disponible
const vwbAction = await this.getVWBActionDetails(step);
return {
stepType: step.type as string,
isVWBAction: true,
isStandardType: false,
parameterConfig: [], // Les actions VWB utilisent VWBActionProperties
vwbAction: vwbAction || undefined,
detectionMethods: vwbDetection.detectionMethods,
resolutionSource: 'vwbCatalog',
timestamp: Date.now()
};
} catch (error) {
console.error('❌ [StepTypeResolver] Erreur résolution VWB:', error);
// Fallback pour action VWB
return {
stepType: step.type as string,
isVWBAction: true,
isStandardType: false,
parameterConfig: [],
detectionMethods: vwbDetection.detectionMethods,
resolutionSource: 'fallback',
timestamp: Date.now()
};
}
}
/**
* Résout un type d'étape standard
*/
private resolveStandardType(step: Step, options: ResolutionOptions): StepTypeResolutionResult {
const stepTypeString = step.type as string;
const config = this.stepParametersConfig[stepTypeString as StepType] || [];
return {
stepType: stepTypeString,
isVWBAction: false,
isStandardType: config.length > 0,
parameterConfig: config,
detectionMethods: { standardType: config.length > 0 },
resolutionSource: 'stepParametersConfig',
timestamp: Date.now()
};
}
/**
* Obtient les détails d'une action VWB
*/
async getVWBActionDetails(step: Step): Promise<VWBCatalogAction | null> {
try {
// Simuler le chargement depuis le catalogue VWB
// Dans une implémentation réelle, ceci ferait appel au service de catalogue
const vwbActionId = step.data?.vwbActionId || step.type;
// Pour l'instant, retourner null - sera implémenté avec le service de catalogue
return null;
} catch (error) {
console.error('❌ [StepTypeResolver] Erreur chargement action VWB:', error);
return null;
}
}
/**
* Génère une clé de cache pour une résolution
*/
private generateCacheKey(step: Step, options: ResolutionOptions): string {
const stepData = {
type: step.type,
isVWBCatalogAction: step.data?.isVWBCatalogAction,
vwbActionId: step.data?.vwbActionId
};
return `${JSON.stringify(stepData)}_${JSON.stringify(options)}`;
}
/**
* Invalide le cache de résolution
*/
invalidateCache(): void {
this.cache.clear();
console.log('🗑️ [StepTypeResolver] Cache invalidé');
}
/**
* Obtient les statistiques de résolution
*/
getResolutionStats(): ResolutionStats {
return { ...this.stats };
}
}
/**
* Instance singleton du résolveur
*/
export const stepTypeResolver = new StepTypeResolver();
/**
* Export par défaut
*/
export default stepTypeResolver;

View File

@@ -0,0 +1,530 @@
/**
* Service de Capture Visuelle - Configuration des paramètres d'étapes
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Service de capture visuelle pour l'interface frontend.
* Gère la communication avec l'API backend pour la capture d'écran,
* la détection d'éléments et la génération d'embeddings visuels.
*/
import { BoundingBox } from '../types';
interface VisualMetadata {
element_type: string;
relative_position?: string;
text_content?: string;
visual_description?: string;
size_description?: string;
contextual_elements_count?: number;
accessibility_info?: Record<string, any>;
}
interface VisualTarget {
screenshot: string;
bounding_box: BoundingBox;
metadata: VisualMetadata;
confidence?: number;
signature?: string;
}
export interface CaptureOptions {
includeContext?: boolean;
highQuality?: boolean;
timeout?: number;
}
export interface DetectedElement {
id: string;
bounds: BoundingBox;
type: string;
text?: string;
confidence: number;
metadata?: Partial<VisualMetadata>;
}
export interface CaptureResult {
screenshot: string; // Base64 encoded
elements: DetectedElement[];
timestamp: string;
screenSize: { width: number; height: number };
}
export interface ValidationResult {
isValid: boolean;
confidence: number;
issues: string[];
suggestions: string[];
timestamp: string;
}
class VisualCaptureService {
private baseUrl: string;
private timeout: number;
private cache: Map<string, any>;
private cacheTimeout: number;
constructor(baseUrl: string = 'http://localhost:8000') {
this.baseUrl = baseUrl;
this.timeout = 30000; // 30 secondes
this.cache = new Map();
this.cacheTimeout = 60000; // 1 minute
}
/**
* Capture l'écran et détecte les éléments UI
*/
async captureScreen(options: CaptureOptions = {}): Promise<CaptureResult> {
const startTime = performance.now();
try {
console.log('🔍 Début de capture d\'écran...');
const response = await fetch(`${this.baseUrl}/api/visual/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
include_context: options.includeContext ?? true,
high_quality: options.highQuality ?? true,
timeout: options.timeout ?? this.timeout,
}),
signal: AbortSignal.timeout(options.timeout ?? this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de capture: ${response.status} ${response.statusText}`);
}
const result: CaptureResult = await response.json();
const duration = performance.now() - startTime;
console.log(`✅ Capture terminée en ${duration.toFixed(0)}ms - ${result.elements.length} éléments détectés`);
// Mettre en cache le résultat
this.setCacheItem('last_capture', result);
return result;
} catch (error) {
const duration = performance.now() - startTime;
console.error(`❌ Erreur de capture après ${duration.toFixed(0)}ms:`, error);
// Fallback vers une capture simulée en cas d'erreur
return this.createMockCapture();
}
}
/**
* Crée une cible visuelle à partir d'un élément détecté
*/
async createVisualTarget(element: DetectedElement, screenshot: string): Promise<VisualTarget> {
const startTime = performance.now();
try {
console.log(`🎯 Création de cible visuelle pour élément ${element.type}...`);
const response = await fetch(`${this.baseUrl}/api/visual/create-target`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
element: element,
screenshot: screenshot,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de création de cible: ${response.status}`);
}
const target: VisualTarget = await response.json();
const duration = performance.now() - startTime;
console.log(`✅ Cible visuelle créée en ${duration.toFixed(0)}ms (confiance: ${Math.round((target.confidence || 0) * 100)}%)`);
return target;
} catch (error) {
console.error('❌ Erreur lors de la création de cible visuelle:', error);
// Fallback vers une cible simulée
return this.createMockTarget(element, screenshot);
}
}
/**
* Valide une cible visuelle existante
*/
async validateTarget(target: VisualTarget): Promise<ValidationResult> {
const startTime = performance.now();
try {
console.log(`🔍 Validation de la cible ${target.signature}...`);
const response = await fetch(`${this.baseUrl}/api/visual/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: target,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de validation: ${response.status}`);
}
const result: ValidationResult = await response.json();
const duration = performance.now() - startTime;
console.log(`✅ Validation terminée en ${duration.toFixed(0)}ms (valide: ${result.isValid})`);
return result;
} catch (error) {
console.error('❌ Erreur lors de la validation:', error);
// Fallback vers une validation simulée
return {
isValid: false,
confidence: 0,
issues: ['Erreur de connexion au service de validation'],
suggestions: ['Vérifier la connexion réseau'],
timestamp: new Date().toISOString(),
};
}
}
/**
* Met à jour la capture d'écran d'une cible
*/
async updateTargetScreenshot(target: VisualTarget): Promise<VisualTarget> {
try {
console.log(`📸 Mise à jour de la capture pour ${target.signature}...`);
const response = await fetch(`${this.baseUrl}/api/visual/update-screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: target,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de mise à jour: ${response.status}`);
}
const updatedTarget: VisualTarget = await response.json();
console.log('✅ Capture mise à jour avec succès');
return updatedTarget;
} catch (error) {
console.error('❌ Erreur lors de la mise à jour de capture:', error);
throw error;
}
}
/**
* Recherche des éléments similaires
*/
async findSimilarElements(target: VisualTarget): Promise<DetectedElement[]> {
try {
console.log(`🔍 Recherche d'éléments similaires à ${target.signature}...`);
const response = await fetch(`${this.baseUrl}/api/visual/find-similar`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
target: target,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Erreur de recherche: ${response.status}`);
}
const elements: DetectedElement[] = await response.json();
console.log(`${elements.length} éléments similaires trouvés`);
return elements;
} catch (error) {
console.error('❌ Erreur lors de la recherche d\'éléments similaires:', error);
return [];
}
}
/**
* Crée une capture simulée pour les tests
*/
private createMockCapture(): CaptureResult {
console.log('🎭 Création d\'une capture simulée...');
const mockElements: DetectedElement[] = [
{
id: 'mock_button_1',
bounds: { x: 100, y: 100, width: 120, height: 40 },
type: 'button',
text: 'Connexion',
confidence: 0.95,
metadata: {
element_type: 'Bouton',
visual_description: 'Bouton avec le texte "Connexion"',
relative_position: 'en haut à gauche de l\'écran',
text_content: 'Connexion',
size_description: 'moyenne',
contextual_elements_count: 2,
accessibility_info: {
has_text: true,
tag_name: 'button',
attributes_count: 3,
is_interactive: true
}
}
},
{
id: 'mock_input_1',
bounds: { x: 300, y: 150, width: 200, height: 30 },
type: 'input',
text: 'Email',
confidence: 0.88,
metadata: {
element_type: 'Champ de saisie',
visual_description: 'Champ de saisie pour l\'email',
relative_position: 'au centre de l\'écran',
text_content: 'Email',
size_description: 'moyenne',
contextual_elements_count: 1,
accessibility_info: {
has_text: true,
tag_name: 'input',
attributes_count: 5,
is_interactive: true
}
}
},
{
id: 'mock_link_1',
bounds: { x: 400, y: 300, width: 150, height: 25 },
type: 'link',
text: 'Mot de passe oublié ?',
confidence: 0.82,
metadata: {
element_type: 'Lien',
visual_description: 'Lien "Mot de passe oublié ?"',
relative_position: 'en bas au centre de l\'écran',
text_content: 'Mot de passe oublié ?',
size_description: 'petite',
contextual_elements_count: 0,
accessibility_info: {
has_text: true,
tag_name: 'a',
attributes_count: 2,
is_interactive: true
}
}
}
];
// Créer une image simulée
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 600;
const ctx = canvas.getContext('2d')!;
// Fond blanc
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 800, 600);
// Dessiner les éléments simulés
mockElements.forEach(element => {
const color = this.getElementColor(element.type);
// Élément
ctx.fillStyle = color;
ctx.fillRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
// Bordure
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
ctx.strokeRect(element.bounds.x, element.bounds.y, element.bounds.width, element.bounds.height);
// Texte
if (element.text) {
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(
element.text,
element.bounds.x + element.bounds.width / 2,
element.bounds.y + element.bounds.height / 2 + 5
);
}
});
const screenshot = canvas.toDataURL().split(',')[1]; // Enlever le préfixe data:image/png;base64,
return {
screenshot,
elements: mockElements,
timestamp: new Date().toISOString(),
screenSize: { width: 800, height: 600 }
};
}
/**
* Crée une cible visuelle simulée
*/
private createMockTarget(element: DetectedElement, screenshot: string): VisualTarget {
console.log('🎭 Création d\'une cible simulée...');
// Créer une image de l'élément avec contour
const canvas = document.createElement('canvas');
const margin = 10;
canvas.width = element.bounds.width + margin * 2;
canvas.height = element.bounds.height + margin * 2;
const ctx = canvas.getContext('2d')!;
// Fond blanc
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Élément
const color = this.getElementColor(element.type);
ctx.fillStyle = color;
ctx.fillRect(margin, margin, element.bounds.width, element.bounds.height);
// Contour vert pour la sélection
ctx.strokeStyle = '#4CAF50';
ctx.lineWidth = 3;
ctx.strokeRect(margin - 1, margin - 1, element.bounds.width + 2, element.bounds.height + 2);
// Texte
if (element.text) {
ctx.fillStyle = color === '#f5f5f5' ? '#333333' : '#ffffff';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(
element.text,
margin + element.bounds.width / 2,
margin + element.bounds.height / 2 + 5
);
}
const elementScreenshot = canvas.toDataURL().split(',')[1];
return {
screenshot: elementScreenshot,
bounding_box: element.bounds,
confidence: element.confidence,
signature: `visual_${element.id}_${Date.now()}`,
metadata: element.metadata as VisualMetadata || {
element_type: this.getElementTypeLabel(element.type),
visual_description: `${this.getElementTypeLabel(element.type)} ${element.text ? `avec le texte "${element.text}"` : ''}`,
relative_position: 'au centre de l\'écran',
text_content: element.text,
size_description: 'moyenne',
contextual_elements_count: 0,
accessibility_info: {
has_text: !!element.text,
tag_name: element.type,
attributes_count: 0,
is_interactive: ['button', 'input', 'link'].includes(element.type)
}
}
};
}
/**
* Obtient la couleur d'un type d'élément
*/
private getElementColor(type: string): string {
const colors: Record<string, string> = {
button: '#1976d2',
input: '#f5f5f5',
link: '#f59e0b',
text: '#333333',
image: '#9c27b0',
div: '#e0e0e0',
span: '#bdbdbd'
};
return colors[type] || '#666666';
}
/**
* Obtient le label d'un type d'élément
*/
private getElementTypeLabel(type: string): string {
const labels: Record<string, string> = {
button: 'Bouton',
input: 'Champ de saisie',
link: 'Lien',
text: 'Texte',
image: 'Image',
div: 'Zone de contenu',
span: 'Texte'
};
return labels[type] || 'Élément';
}
/**
* Gestion du cache
*/
private setCacheItem(key: string, value: any): void {
this.cache.set(key, {
value,
timestamp: Date.now()
});
}
private getCacheItem(key: string): any | null {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() - item.timestamp > this.cacheTimeout) {
this.cache.delete(key);
return null;
}
return item.value;
}
/**
* Nettoie le cache expiré
*/
public clearExpiredCache(): void {
const now = Date.now();
const entries = Array.from(this.cache.entries());
for (const [key, item] of entries) {
if (now - item.timestamp > this.cacheTimeout) {
this.cache.delete(key);
}
}
}
/**
* Obtient les statistiques du service
*/
public getStats(): { cacheSize: number; cacheHits: number } {
return {
cacheSize: this.cache.size,
cacheHits: 0 // À implémenter si nécessaire
};
}
}
// Instance singleton du service
export const visualCaptureService = new VisualCaptureService();
export default VisualCaptureService;

View File

@@ -0,0 +1,274 @@
/**
* Service de gestion des images d'ancres visuelles côté serveur.
*
* Auteur : Dom, Alice, Kiro - 21 janvier 2026
*
* Ce service gère l'upload et la récupération des images d'ancres
* via l'API backend, évitant le stockage base64 dans les workflows.
*/
import { BoundingBox } from '../types';
const API_BASE = 'http://localhost:5001';
export interface AnchorImageUploadResult {
success: boolean;
anchor_id: string;
thumbnail_url: string;
original_url: string;
metadata: {
anchor_id: string;
bounding_box: BoundingBox;
original_size: { width: number; height: number };
thumbnail_size: { width: number; height: number };
created_at: string;
original_file_size: number;
thumbnail_file_size: number;
extra?: Record<string, any>;
};
}
export interface AnchorMetadata {
anchor_id: string;
bounding_box: BoundingBox;
original_size: { width: number; height: number };
thumbnail_size: { width: number; height: number };
created_at: string;
original_file_size: number;
thumbnail_file_size: number;
}
export interface StorageStats {
anchor_count: number;
total_size_bytes: number;
total_size_mb: number;
data_directory: string;
}
/**
* Upload une image d'ancre vers le serveur.
*
* @param imageBase64 - Screenshot complet en base64 (avec ou sans préfixe data:)
* @param boundingBox - Zone de sélection sur l'image
* @param anchorId - ID optionnel (généré automatiquement si absent)
* @param metadata - Métadonnées additionnelles optionnelles
* @returns Résultat avec anchor_id et URLs
*/
export async function uploadAnchorImage(
imageBase64: string,
boundingBox: BoundingBox,
anchorId?: string,
metadata?: Record<string, any>
): Promise<AnchorImageUploadResult> {
console.log('📤 [anchorImageService] Upload image d\'ancre...', {
hasImage: !!imageBase64,
imageLength: imageBase64?.length || 0,
boundingBox,
anchorId
});
const response = await fetch(`${API_BASE}/api/anchor-images`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
image_base64: imageBase64,
bounding_box: boundingBox,
anchor_id: anchorId,
metadata,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Erreur HTTP ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Erreur lors de l\'upload');
}
console.log('✅ [anchorImageService] Upload réussi:', {
anchor_id: result.anchor_id,
thumbnail_url: result.thumbnail_url,
});
return result;
}
/**
* Obtenir l'URL complète de la miniature d'une ancre.
*
* @param anchorId - ID de l'ancre
* @returns URL complète de la miniature
*/
export function getThumbnailUrl(anchorId: string): string {
return `${API_BASE}/api/anchor-images/${anchorId}/thumbnail`;
}
/**
* Obtenir l'URL complète de l'image originale d'une ancre.
*
* @param anchorId - ID de l'ancre
* @returns URL complète de l'image originale
*/
export function getOriginalUrl(anchorId: string): string {
return `${API_BASE}/api/anchor-images/${anchorId}/original`;
}
/**
* Obtenir les métadonnées d'une ancre.
*
* @param anchorId - ID de l'ancre
* @returns Métadonnées de l'ancre
*/
export async function getAnchorMetadata(anchorId: string): Promise<AnchorMetadata> {
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}/metadata`);
if (!response.ok) {
throw new Error(`Ancre '${anchorId}' non trouvée`);
}
const result = await response.json();
return result.metadata;
}
/**
* Supprimer une image d'ancre du serveur.
*
* @param anchorId - ID de l'ancre à supprimer
* @returns true si supprimé avec succès
*/
export async function deleteAnchorImage(anchorId: string): Promise<boolean> {
const response = await fetch(`${API_BASE}/api/anchor-images/${anchorId}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 404) {
throw new Error(`Erreur lors de la suppression de l'ancre '${anchorId}'`);
}
return response.ok;
}
/**
* Lister toutes les images d'ancres stockées.
*
* @param limit - Nombre maximum d'ancres à retourner
* @param offset - Décalage pour la pagination
* @returns Liste des métadonnées des ancres
*/
export async function listAnchorImages(
limit: number = 100,
offset: number = 0
): Promise<{ anchors: AnchorMetadata[]; total: number }> {
const response = await fetch(
`${API_BASE}/api/anchor-images?limit=${limit}&offset=${offset}`
);
if (!response.ok) {
throw new Error('Erreur lors de la récupération de la liste des ancres');
}
const result = await response.json();
return {
anchors: result.anchors,
total: result.total,
};
}
/**
* Obtenir les statistiques de stockage.
*
* @returns Statistiques de stockage
*/
export async function getStorageStats(): Promise<StorageStats> {
const response = await fetch(`${API_BASE}/api/anchor-images/stats`);
if (!response.ok) {
throw new Error('Erreur lors de la récupération des statistiques');
}
const result = await response.json();
return result.stats;
}
/**
* Vérifier si une ancre existe sur le serveur.
*
* @param anchorId - ID de l'ancre
* @returns true si l'ancre existe
*/
export async function anchorExists(anchorId: string): Promise<boolean> {
try {
const response = await fetch(
`${API_BASE}/api/anchor-images/${anchorId}/metadata`,
{ method: 'HEAD' }
);
return response.ok;
} catch {
return false;
}
}
/**
* Obtenir l'URL de prévisualisation d'une ancre.
* Retourne l'URL de la miniature si disponible, sinon null.
*
* @param anchor - Ancre visuelle avec potentiellement thumbnail_url ou reference_image_base64
* @returns URL de prévisualisation ou chaîne data: pour base64 legacy
*/
export function getPreviewImageUrl(anchor: {
anchor_id?: string;
thumbnail_url?: string;
reference_image_url?: string;
reference_image_base64?: string;
}): string | null {
// Priorité 1: URL de miniature serveur
if (anchor.thumbnail_url) {
// Si l'URL est relative, ajouter le préfixe API
return anchor.thumbnail_url.startsWith('http')
? anchor.thumbnail_url
: `${API_BASE}${anchor.thumbnail_url}`;
}
// Priorité 2: URL d'image originale serveur
if (anchor.reference_image_url) {
return anchor.reference_image_url.startsWith('http')
? anchor.reference_image_url
: `${API_BASE}${anchor.reference_image_url}`;
}
// Priorité 3: Construire l'URL depuis anchor_id si présent
if (anchor.anchor_id && anchor.anchor_id.startsWith('anchor_')) {
return getThumbnailUrl(anchor.anchor_id);
}
// Fallback: base64 legacy
if (anchor.reference_image_base64) {
if (anchor.reference_image_base64.startsWith('data:')) {
return anchor.reference_image_base64;
}
return `data:image/png;base64,${anchor.reference_image_base64}`;
}
return null;
}
// Export par défaut pour compatibilité
const anchorImageService = {
uploadAnchorImage,
getThumbnailUrl,
getOriginalUrl,
getAnchorMetadata,
deleteAnchorImage,
listAnchorImages,
getStorageStats,
anchorExists,
getPreviewImageUrl,
};
export default anchorImageService;

View File

@@ -54,12 +54,12 @@ const getApiHost = (): string => {
const hostname = window.location.hostname;
// Si c'est localhost ou 127.0.0.1, garder localhost
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return 'http://localhost:5002/api';
return 'http://localhost:5001/api';
}
// Sinon utiliser le même hostname (IP) avec le port 5000
return `http://${hostname}:5000/api`;
}
return 'http://localhost:5002/api';
return 'http://localhost:5001/api';
};
// Configuration par défaut

View File

@@ -0,0 +1,326 @@
/**
* Service de Bibliothèque de Captures d'Écran
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
*
* Permet de sauvegarder, organiser et réutiliser des captures d'écran
* pour éviter de refaire des captures lors du travail sur le même logiciel.
*/
export interface SavedCapture {
id: string;
name: string;
description?: string;
application?: string;
screenshot: string; // base64
thumbnailSmall: string; // base64 miniature 100px
createdAt: string;
updatedAt: string;
dimensions: {
width: number;
height: number;
};
tags: string[];
}
export interface CaptureLibraryState {
captures: SavedCapture[];
lastUpdated: string;
version: string;
}
const STORAGE_KEY = 'vwb_capture_library';
const LIBRARY_VERSION = '1.0.0';
const MAX_CAPTURES = 50; // Limite pour éviter de saturer le localStorage
/**
* Service de gestion de la bibliothèque de captures
* Utilise localStorage pour la persistance entre sessions
*/
class CaptureLibraryService {
private state: CaptureLibraryState;
constructor() {
this.state = this.loadFromStorage();
console.log('📚 [CaptureLibrary] Service initialisé avec', this.state.captures.length, 'captures');
}
/**
* Recharger les données depuis localStorage (utile après un refresh)
*/
public reload(): void {
this.state = this.loadFromStorage();
console.log('🔄 [CaptureLibrary] Rechargé:', this.state.captures.length, 'captures');
}
/**
* Charger la bibliothèque depuis localStorage
*/
private loadFromStorage(): CaptureLibraryState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as CaptureLibraryState;
// Vérifier la version
if (parsed.version === LIBRARY_VERSION) {
return parsed;
}
}
} catch (error) {
console.warn('Erreur lors du chargement de la bibliothèque de captures:', error);
}
// Retourner un état vide par défaut
return {
captures: [],
lastUpdated: new Date().toISOString(),
version: LIBRARY_VERSION,
};
}
/**
* Sauvegarder la bibliothèque dans localStorage
*/
private saveToStorage(): void {
try {
this.state.lastUpdated = new Date().toISOString();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch (error) {
console.error('Erreur lors de la sauvegarde de la bibliothèque:', error);
// Si localStorage est plein, supprimer les plus anciennes captures
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
this.cleanupOldCaptures(10);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch {
console.error('Impossible de sauvegarder même après nettoyage');
}
}
}
}
/**
* Créer une miniature d'une image base64
*/
private async createThumbnail(base64Image: string, maxSize: number = 100): Promise<string> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(base64Image);
return;
}
// Calculer les dimensions proportionnelles
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxSize) {
height = (height * maxSize) / width;
width = maxSize;
}
} else {
if (height > maxSize) {
width = (width * maxSize) / height;
height = maxSize;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.7));
};
img.onerror = () => resolve(base64Image);
if (base64Image.startsWith('data:')) {
img.src = base64Image;
} else {
img.src = `data:image/png;base64,${base64Image}`;
}
});
}
/**
* Obtenir les dimensions d'une image base64
*/
private async getImageDimensions(base64Image: string): Promise<{ width: number; height: number }> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.width, height: img.height });
img.onerror = () => resolve({ width: 0, height: 0 });
if (base64Image.startsWith('data:')) {
img.src = base64Image;
} else {
img.src = `data:image/png;base64,${base64Image}`;
}
});
}
/**
* Sauvegarder une nouvelle capture dans la bibliothèque
*/
async saveCapture(
screenshot: string,
name: string,
options: {
description?: string;
application?: string;
tags?: string[];
} = {}
): Promise<SavedCapture> {
// Vérifier la limite
if (this.state.captures.length >= MAX_CAPTURES) {
this.cleanupOldCaptures(5);
}
const id = `capture_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const dimensions = await this.getImageDimensions(screenshot);
const thumbnailSmall = await this.createThumbnail(screenshot, 100);
const capture: SavedCapture = {
id,
name,
description: options.description || '',
application: options.application || '',
screenshot,
thumbnailSmall,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
dimensions,
tags: options.tags || [],
};
this.state.captures.unshift(capture); // Ajouter en premier
this.saveToStorage();
return capture;
}
/**
* Obtenir toutes les captures
*/
getAllCaptures(): SavedCapture[] {
return [...this.state.captures];
}
/**
* Obtenir une capture par ID
*/
getCaptureById(id: string): SavedCapture | null {
return this.state.captures.find((c) => c.id === id) || null;
}
/**
* Rechercher des captures par nom ou application
*/
searchCaptures(query: string): SavedCapture[] {
const lowerQuery = query.toLowerCase();
return this.state.captures.filter(
(c) =>
c.name.toLowerCase().includes(lowerQuery) ||
c.application?.toLowerCase().includes(lowerQuery) ||
c.description?.toLowerCase().includes(lowerQuery) ||
c.tags.some((t) => t.toLowerCase().includes(lowerQuery))
);
}
/**
* Obtenir les captures par application
*/
getCapturesByApplication(application: string): SavedCapture[] {
return this.state.captures.filter(
(c) => c.application?.toLowerCase() === application.toLowerCase()
);
}
/**
* Mettre à jour une capture
*/
updateCapture(
id: string,
updates: Partial<Pick<SavedCapture, 'name' | 'description' | 'application' | 'tags'>>
): SavedCapture | null {
const index = this.state.captures.findIndex((c) => c.id === id);
if (index === -1) return null;
this.state.captures[index] = {
...this.state.captures[index],
...updates,
updatedAt: new Date().toISOString(),
};
this.saveToStorage();
return this.state.captures[index];
}
/**
* Supprimer une capture
*/
deleteCapture(id: string): boolean {
const index = this.state.captures.findIndex((c) => c.id === id);
if (index === -1) return false;
this.state.captures.splice(index, 1);
this.saveToStorage();
return true;
}
/**
* Supprimer les captures les plus anciennes
*/
private cleanupOldCaptures(count: number): void {
// Trier par date (les plus anciennes à la fin) et supprimer
this.state.captures.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
this.state.captures = this.state.captures.slice(0, MAX_CAPTURES - count);
}
/**
* Obtenir la liste des applications uniques
*/
getApplications(): string[] {
const apps = new Set<string>();
this.state.captures.forEach((c) => {
if (c.application) apps.add(c.application);
});
return Array.from(apps).sort();
}
/**
* Vider la bibliothèque
*/
clearLibrary(): void {
this.state.captures = [];
this.saveToStorage();
}
/**
* Obtenir les statistiques de la bibliothèque
*/
getStats(): {
totalCaptures: number;
applications: number;
oldestCapture: string | null;
newestCapture: string | null;
} {
return {
totalCaptures: this.state.captures.length,
applications: this.getApplications().length,
oldestCapture:
this.state.captures.length > 0
? this.state.captures[this.state.captures.length - 1].createdAt
: null,
newestCapture:
this.state.captures.length > 0 ? this.state.captures[0].createdAt : null,
};
}
}
// Export singleton
export const captureLibraryService = new CaptureLibraryService();
export default captureLibraryService;

View File

@@ -0,0 +1,976 @@
/**
* Service Catalogue VWB - Communication avec l'API Catalogue d'Actions VisionOnly
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce service gère toutes les communications avec l'API du catalogue d'actions VisionOnly
* du Visual Workflow Builder, incluant la liste des actions, l'exécution, et la validation.
*
* ARCHITECTURE:
* - Détection automatique de l'URL du backend (cross-machine)
* - Fallback automatique vers catalogue statique hors ligne
* - Gestion d'erreurs robuste avec messages en français
* - Types TypeScript stricts pour toutes les données
* - Cache intelligent pour optimiser les performances
* - Support du mode hors ligne gracieux
*
* NOUVEAUTÉS v2.0:
* - URL configurable et détection automatique
* - Catalogue statique de secours (5 actions de base)
* - Persistance de configuration dans localStorage
* - Gestion cross-machine robuste
*/
// Import des types du catalogue
import {
VWBCatalogAction,
VWBActionParameter,
VWBActionExample,
VWBActionCategory,
VWBActionExecutionRequest,
VWBActionExecutionResult,
VWBActionEvidence,
VWBActionError,
VWBActionValidationRequest,
VWBActionValidationResult,
VWBCatalogHealth,
VWBServiceStatus
} from '../types/catalog';
// Import du catalogue statique de secours
import {
getStaticCatalogActions,
getStaticActionsByCategory,
getStaticActionById,
searchStaticActions,
getStaticCatalogCategories,
getStaticCatalogStats,
} from '../data/staticCatalog';
// Configuration du service catalogue
interface CatalogServiceConfig {
urls: string[];
timeout: number;
retryCount: number;
cacheKey: string;
fallbackToStatic: boolean;
}
// État du service catalogue
interface CatalogServiceState {
mode: 'dynamic' | 'static' | 'offline';
currentUrl: string | null;
lastError: string | null;
lastCheck: number;
isOnline: boolean;
}
// Alias pour éviter les conflits de noms
type CatalogAction = VWBCatalogAction;
type CatalogActionCategory = VWBActionCategory;
// Types pour l'API interne (compatibles avec les types VWB)
export interface ActionExecutionRequest extends VWBActionExecutionRequest {}
export interface ActionExecutionResult extends VWBActionExecutionResult {}
export interface ActionEvidence extends VWBActionEvidence {}
export interface ActionError extends VWBActionError {}
// Types pour la validation
export interface ActionValidationRequest extends VWBActionValidationRequest {}
export interface ActionValidationResult extends VWBActionValidationResult {}
// Types pour les réponses API
interface CatalogApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
offline?: boolean;
}
interface CatalogListResponse {
actions: CatalogAction[];
total: number;
categories: string[];
screen_capturer_available: boolean;
}
interface CatalogExecutionResponse {
result: ActionExecutionResult;
}
interface CatalogValidationResponse {
validation: ActionValidationResult;
}
interface CatalogHealthResponse {
status: string;
services: {
screen_capturer: boolean;
actions: number;
screen_capturer_method: string;
};
timestamp: string;
version: string;
}
/**
* Service de gestion du catalogue d'actions VisionOnly pour le VWB
* Fournit une interface TypeScript typée pour l'API du catalogue
* avec détection automatique d'URL et fallback statique
*/
class CatalogService {
private cache: Map<string, { data: any; timestamp: number }> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
private readonly API_BASE_PATH = '/api/vwb/catalog';
// Configuration par défaut
private config: CatalogServiceConfig = {
urls: [],
timeout: 2000,
retryCount: 3,
cacheKey: 'vwb_catalog_config',
fallbackToStatic: true,
};
// État du service
private state: CatalogServiceState = {
mode: 'offline',
currentUrl: null,
lastError: null,
lastCheck: 0,
isOnline: false,
};
constructor() {
this.initializeConfig();
this.loadPersistedConfig();
}
/**
* Initialiser la configuration avec détection automatique d'URLs
*/
private initializeConfig(): void {
// URLs à tester par ordre de priorité
const candidateUrls: string[] = [];
// 1. Variable d'environnement (priorité maximale)
const envUrl = process.env.REACT_APP_CATALOG_URL;
if (envUrl) {
candidateUrls.push(envUrl);
}
// 2. Paramètre URL
const urlParams = new URLSearchParams(window.location.search);
const paramUrl = urlParams.get('catalogUrl');
if (paramUrl) {
candidateUrls.push(paramUrl);
}
// 3. Même origine que le frontend (pour déploiements intégrés)
const currentOrigin = window.location.origin;
candidateUrls.push(currentOrigin);
// 4. Localhost standard (développement) - Port 5001 en priorité
if (!candidateUrls.includes('http://localhost:5001')) {
candidateUrls.push('http://localhost:5001');
}
if (!candidateUrls.includes('http://localhost:5001')) {
candidateUrls.push('http://localhost:5001');
}
// 5. IP locale détectée (cross-machine)
try {
const localIp = this.detectLocalIp();
if (localIp && localIp !== '127.0.0.1') {
candidateUrls.push(`http://${localIp}:5000`);
candidateUrls.push(`http://${localIp}:5004`);
}
} catch (error) {
console.warn('Impossible de détecter l\'IP locale:', error);
}
this.config.urls = candidateUrls;
console.log('🔍 URLs candidates pour le catalogue:', candidateUrls);
}
/**
* Détecter l'IP locale (approximation basée sur WebRTC)
*/
private detectLocalIp(): string | null {
// Note: Cette méthode est limitée par les restrictions de sécurité des navigateurs
// Elle fournit une estimation basée sur l'URL courante
try {
const hostname = window.location.hostname;
// Si on est déjà sur une IP, l'utiliser
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return hostname;
}
// Sinon, retourner null pour utiliser les autres méthodes
return null;
} catch (error) {
return null;
}
}
/**
* Charger la configuration persistée depuis localStorage
*/
private loadPersistedConfig(): void {
try {
const stored = localStorage.getItem(this.config.cacheKey);
if (stored) {
const parsed = JSON.parse(stored);
// Vérifier si la configuration n'est pas expirée (24h)
const age = Date.now() - parsed.timestamp;
const maxAge = 24 * 60 * 60 * 1000; // 24 heures
if (age < maxAge && parsed.url) {
// Mettre l'URL fonctionnelle en première position
this.config.urls = [
parsed.url,
...this.config.urls.filter(url => url !== parsed.url)
];
console.log('📦 Configuration persistée chargée:', parsed.url);
}
}
} catch (error) {
console.warn('Erreur lors du chargement de la configuration persistée:', error);
}
}
/**
* Persister la configuration fonctionnelle
*/
private persistConfig(workingUrl: string): void {
try {
const config = {
url: workingUrl,
timestamp: Date.now(),
};
localStorage.setItem(this.config.cacheKey, JSON.stringify(config));
console.log('💾 Configuration persistée:', workingUrl);
} catch (error) {
console.warn('Erreur lors de la persistance de configuration:', error);
}
}
/**
* Détecter automatiquement l'URL du backend fonctionnelle
*/
private async detectBackendUrl(): Promise<string | null> {
console.log('🔍 Détection automatique de l\'URL du backend...');
for (const url of this.config.urls) {
try {
console.log(`⏳ Test de ${url}...`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
const response = await fetch(`${url}/health`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (response.ok) {
console.log(`✅ Backend trouvé sur ${url}`);
this.state.currentUrl = url;
this.state.isOnline = true;
this.state.mode = 'dynamic';
this.state.lastError = null;
// Persister la configuration fonctionnelle
this.persistConfig(url);
return url;
}
} catch (error) {
console.log(`${url} non accessible:`, error instanceof Error ? error.message : error);
continue;
}
}
console.log('🔴 Aucun backend accessible, passage en mode statique');
this.state.mode = 'static';
this.state.isOnline = false;
this.state.currentUrl = null;
this.state.lastError = 'Aucun backend catalogue accessible';
return null;
}
/**
* Forcer une nouvelle détection d'URL
*/
async forceUrlDetection(): Promise<boolean> {
console.log('🔄 Détection forcée de l\'URL du backend...');
// Réinitialiser l'état
this.state.lastCheck = 0;
// Relancer la détection
const url = await this.detectBackendUrl();
this.state.lastCheck = Date.now();
return url !== null;
}
/**
* Obtenir l'état actuel du service
*/
getServiceState(): CatalogServiceState {
return { ...this.state };
}
/**
* Effectuer une requête vers l'API catalogue avec gestion d'erreurs et fallback
*/
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
// Vérifier si on a une URL fonctionnelle ou tenter la détection
if (!this.state.currentUrl || !this.state.isOnline) {
const detectedUrl = await this.detectBackendUrl();
if (!detectedUrl) {
throw new Error('Service catalogue hors ligne - Mode statique activé');
}
}
try {
const fullEndpoint = `${this.API_BASE_PATH}${endpoint}`;
const response = await fetch(`${this.state.currentUrl}${fullEndpoint}`, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
const errorText = await response.text();
let errorData: any = {};
try {
errorData = JSON.parse(errorText);
} catch {
errorData = { message: errorText };
}
throw new Error(
errorData.error ||
errorData.message ||
`Erreur API catalogue: ${response.status}`
);
}
const data = await response.json();
if (!data.success && data.error) {
throw new Error(data.error);
}
// Marquer comme en ligne si la requête réussit
this.state.isOnline = true;
this.state.lastError = null;
return data;
} catch (error) {
console.error(`Erreur requête catalogue ${endpoint}:`, error);
// Marquer comme hors ligne
this.state.isOnline = false;
this.state.lastError = error instanceof Error ? error.message : 'Erreur inconnue';
// Gestion gracieuse des erreurs réseau
if (error instanceof TypeError && error.message.includes('fetch')) {
this.state.mode = 'static';
throw new Error('Service catalogue hors ligne - Mode statique activé');
}
throw error;
}
}
/**
* Vérifier si une donnée en cache est encore valide
*/
private isCacheValid(cacheKey: string): boolean {
const cached = this.cache.get(cacheKey);
if (!cached) return false;
return Date.now() - cached.timestamp < this.CACHE_DURATION;
}
/**
* Obtenir une donnée du cache ou null si invalide
*/
private getCachedData<T>(cacheKey: string): T | null {
if (!this.isCacheValid(cacheKey)) {
this.cache.delete(cacheKey);
return null;
}
return this.cache.get(cacheKey)?.data || null;
}
/**
* Mettre en cache une donnée
*/
private setCachedData(cacheKey: string, data: any): void {
this.cache.set(cacheKey, {
data,
timestamp: Date.now(),
});
}
/**
* Récupérer la liste des actions disponibles dans le catalogue
* Avec fallback automatique vers le catalogue statique
*
* @param category - Filtrer par catégorie (optionnel)
* @param search - Terme de recherche (optionnel)
* @returns Liste des actions avec métadonnées
*/
async getActions(
category?: CatalogActionCategory,
search?: string
): Promise<{
actions: CatalogAction[];
total: number;
categories: string[];
screenCapturerAvailable: boolean;
mode: 'dynamic' | 'static';
}> {
// Tenter de charger depuis le backend dynamique
const cacheKey = `actions_${category || 'all'}_${search || ''}`;
const cached = this.getCachedData<{
actions: CatalogAction[];
total: number;
categories: string[];
screenCapturerAvailable: boolean;
}>(cacheKey);
if (cached) {
return { ...cached, mode: 'dynamic' };
}
try {
let endpoint = '/actions';
const params = new URLSearchParams();
if (category) {
params.append('category', category);
}
if (search) {
params.append('search', search);
}
if (params.toString()) {
endpoint += `?${params.toString()}`;
}
const response = await this.makeRequest<CatalogListResponse>(endpoint);
const result = {
actions: response.actions,
total: response.total,
categories: response.categories,
screenCapturerAvailable: response.screen_capturer_available,
};
this.setCachedData(cacheKey, result);
return { ...result, mode: 'dynamic' as const };
} catch (error) {
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
return this.getStaticActions(category, search);
}
}
/**
* Récupérer les actions du catalogue statique (mode hors ligne)
*/
private getStaticActions(
category?: CatalogActionCategory,
search?: string
): {
actions: CatalogAction[];
total: number;
categories: string[];
screenCapturerAvailable: boolean;
mode: 'static';
} {
let actions: CatalogAction[];
if (search) {
actions = searchStaticActions(search);
} else if (category) {
actions = getStaticActionsByCategory(category);
} else {
actions = getStaticCatalogActions();
}
const categories = getStaticCatalogCategories().map(cat => cat.id);
return {
actions,
total: actions.length,
categories,
screenCapturerAvailable: false, // Pas de capture d'écran en mode statique
mode: 'static',
};
}
/**
* Récupérer les détails d'une action spécifique
* Avec fallback vers le catalogue statique
*
* @param actionId - Identifiant de l'action
* @returns Détails complets de l'action
*/
async getActionDetails(actionId: string): Promise<CatalogAction | null> {
if (!actionId || actionId.trim().length === 0) {
throw new Error('L\'identifiant de l\'action est obligatoire');
}
try {
// Vérifier le cache
const cacheKey = `action_details_${actionId}`;
const cached = this.getCachedData<{ action: CatalogAction }>(cacheKey);
if (cached) {
return cached.action;
}
// Effectuer la requête
const response = await this.makeRequest<{ action: CatalogAction }>(
`/actions/${actionId}`
);
// Mettre en cache
this.setCachedData(cacheKey, response);
return response.action;
} catch (error) {
console.warn(`Erreur catalogue dynamique pour action ${actionId}, recherche en mode statique:`, error);
// Fallback vers le catalogue statique
return getStaticActionById(actionId);
}
}
/**
* Exécuter une action du catalogue
*
* @param request - Configuration de l'action à exécuter
* @returns Résultat de l'exécution avec evidence
*/
async executeAction(request: ActionExecutionRequest): Promise<ActionExecutionResult> {
// Validation des paramètres
if (!request.type || request.type.trim().length === 0) {
throw new Error('Le type d\'action est obligatoire');
}
if (!request.parameters || typeof request.parameters !== 'object') {
throw new Error('Les paramètres de l\'action sont obligatoires');
}
try {
console.log(`🚀 Exécution de l'action ${request.type}...`);
// Effectuer la requête d'exécution
const response = await this.makeRequest<CatalogExecutionResponse>('/execute', {
method: 'POST',
body: JSON.stringify(request),
});
const result = response.result;
// Log du résultat
const statusEmoji = result.status === 'success' ? '✅' : '❌';
console.log(
`${statusEmoji} Action ${request.type} terminée en ${result.execution_time_ms}ms`
);
return result;
} catch (error) {
console.error(`Erreur lors de l'exécution de l'action ${request.type}:`, error);
// Créer un résultat d'erreur standardisé
const errorResult: ActionExecutionResult = {
action_id: request.action_id || `error_${Date.now()}`,
step_id: request.step_id || `step_${Date.now()}`,
status: 'error',
start_time: new Date().toISOString(),
end_time: new Date().toISOString(),
execution_time_ms: 0,
output_data: {},
evidence_list: [],
error: {
error_id: `error_${Date.now()}`,
error_type: 'action_execution_failed',
severity: 'high',
message: error instanceof Error ? error.message : 'Erreur inconnue',
timestamp: new Date().toISOString(),
},
retry_count: 0,
workflow_id: request.workflow_id,
user_id: request.user_id,
};
return errorResult;
}
}
/**
* Valider la configuration d'une action sans l'exécuter
*
* @param request - Configuration de l'action à valider
* @returns Résultat de la validation avec erreurs et suggestions
*/
async validateAction(request: ActionValidationRequest): Promise<ActionValidationResult> {
// Validation des paramètres
if (!request.type || request.type.trim().length === 0) {
throw new Error('Le type d\'action est obligatoire');
}
try {
// Effectuer la requête de validation
const response = await this.makeRequest<CatalogValidationResponse>('/validate', {
method: 'POST',
body: JSON.stringify(request),
});
// S'assurer que la réponse contient une validation valide
if (response && response.validation) {
return response.validation;
}
// Si pas de validation dans la réponse, retourner un résultat valide par défaut
return {
is_valid: true,
errors: [],
warnings: [],
suggestions: [],
};
} catch (error) {
console.error(`Erreur lors de la validation de l'action ${request.type}:`, error);
// Retourner un résultat d'erreur
return {
is_valid: false,
errors: [{
parameter: 'general',
message: error instanceof Error ? error.message : 'Erreur de validation',
code: 'validation_error',
severity: 'error',
}],
warnings: [],
suggestions: [{
type: 'best_practice',
message: 'Vérifiez la configuration de l\'action',
priority: 'medium',
}],
};
}
}
/**
* Vérifier la santé du service catalogue
*
* @returns État du service avec informations de diagnostic
*/
async getHealth(): Promise<{
status: string;
services: {
screenCapturer: boolean;
actions: number;
screenCapturerMethod: string;
};
timestamp: string;
version: string;
}> {
try {
const response = await this.makeRequest<CatalogHealthResponse>('/health');
return {
status: response.status,
services: {
screenCapturer: response.services.screen_capturer,
actions: response.services.actions,
screenCapturerMethod: response.services.screen_capturer_method,
},
timestamp: response.timestamp,
version: response.version,
};
} catch (error) {
console.error('Erreur lors de la vérification de santé du catalogue:', error);
return {
status: 'offline',
services: {
screenCapturer: false,
actions: 0,
screenCapturerMethod: 'unavailable',
},
timestamp: new Date().toISOString(),
version: 'unknown',
};
}
}
/**
* Obtenir les catégories d'actions disponibles
* Avec fallback vers le catalogue statique
*
* @returns Liste des catégories avec métadonnées
*/
async getCategories(): Promise<Array<{
id: CatalogActionCategory;
name: string;
description: string;
icon: string;
actionCount: number;
mode: 'dynamic' | 'static';
}>> {
try {
// Récupérer toutes les actions pour calculer les statistiques
const { actions, categories } = await this.getActions();
// Définir les métadonnées des catégories
const categoryMetadata: Record<CatalogActionCategory, {
name: string;
description: string;
icon: string;
}> = {
vision_ui: {
name: 'Interactions Visuelles',
description: 'Cliquer, saisir, glisser-déposer sur des éléments visuels',
icon: '🖱️',
},
control: {
name: 'Contrôle de Flux',
description: 'Conditions, boucles et synchronisation basées sur la vision',
icon: '🔀',
},
data: {
name: 'Extraction de Données',
description: 'Extraire texte, tableaux, télécharger des fichiers',
icon: '📊',
},
intelligence: {
name: 'Intelligence IA',
description: 'Analyse et traitement intelligent par IA',
icon: '🤖',
},
database: {
name: 'Base de Données',
description: 'Lire et enregistrer des données en base',
icon: '💾',
},
validation: {
name: 'Validation',
description: 'Vérifier la présence et le contenu des éléments',
icon: '✅',
},
};
// Construire la liste des catégories avec statistiques
return categories.map(categoryId => {
const category = categoryId as CatalogActionCategory;
const metadata = categoryMetadata[category] || {
name: category,
description: `Actions de type ${category}`,
icon: '📋',
};
const actionCount = actions.filter(action => action.category === category).length;
return {
id: category,
name: metadata.name,
description: metadata.description,
icon: metadata.icon,
actionCount,
mode: this.state.mode === 'offline' ? 'static' : this.state.mode,
};
});
} catch (error) {
console.warn('Erreur catalogue dynamique, utilisation du catalogue statique:', error);
// Fallback vers le catalogue statique
return getStaticCatalogCategories().map(cat => ({
...cat,
mode: 'static' as const,
}));
}
}
/**
* Rechercher des actions par terme
* Avec fallback vers le catalogue statique
*
* @param searchTerm - Terme de recherche
* @returns Actions correspondant au terme de recherche
*/
async searchActions(searchTerm: string): Promise<CatalogAction[]> {
if (!searchTerm || searchTerm.trim().length === 0) {
return [];
}
try {
const { actions } = await this.getActions(undefined, searchTerm.trim());
return actions;
} catch (error) {
console.warn('Erreur recherche catalogue dynamique, utilisation du catalogue statique:', error);
// Fallback vers le catalogue statique
return searchStaticActions(searchTerm.trim());
}
}
/**
* Vider le cache du service
*/
clearCache(): void {
this.cache.clear();
console.log('Cache du service catalogue vidé');
}
/**
* Obtenir les statistiques du cache
*/
getCacheStats(): {
size: number;
keys: string[];
oldestEntry: string | null;
newestEntry: string | null;
} {
const entries = Array.from(this.cache.entries());
if (entries.length === 0) {
return {
size: 0,
keys: [],
oldestEntry: null,
newestEntry: null,
};
}
// Trier par timestamp
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
return {
size: entries.length,
keys: entries.map(([key]) => key),
oldestEntry: entries[0][0],
newestEntry: entries[entries.length - 1][0],
};
}
/**
* Obtenir les statistiques complètes du service
*/
async getServiceStats(): Promise<{
mode: 'dynamic' | 'static' | 'offline';
isOnline: boolean;
currentUrl: string | null;
lastError: string | null;
lastCheck: number;
cacheStats: {
size: number;
keys: string[];
oldestEntry: string | null;
newestEntry: string | null;
};
catalogStats: any;
}> {
const cacheStats = this.getCacheStats();
let catalogStats;
try {
if (this.state.mode === 'static') {
catalogStats = getStaticCatalogStats();
} else {
const { actions, total } = await this.getActions();
catalogStats = {
totalActions: total,
mode: this.state.mode,
actionsLoaded: actions.length,
};
}
} catch (error) {
catalogStats = { error: 'Impossible de récupérer les statistiques' };
}
return {
mode: this.state.mode,
isOnline: this.state.isOnline,
currentUrl: this.state.currentUrl,
lastError: this.state.lastError,
lastCheck: this.state.lastCheck,
cacheStats,
catalogStats,
};
}
/**
* Réinitialiser complètement le service
*/
async reset(): Promise<void> {
console.log('🔄 Réinitialisation complète du service catalogue...');
// Vider le cache
this.clearCache();
// Supprimer la configuration persistée
try {
localStorage.removeItem(this.config.cacheKey);
} catch (error) {
console.warn('Erreur lors de la suppression de la configuration persistée:', error);
}
// Réinitialiser l'état
this.state = {
mode: 'offline',
currentUrl: null,
lastError: null,
lastCheck: 0,
isOnline: false,
};
// Réinitialiser la configuration
this.initializeConfig();
// Relancer la détection
await this.forceUrlDetection();
console.log('✅ Service catalogue réinitialisé');
}
}
// Instance singleton du service catalogue
export const catalogService = new CatalogService();
// Export des types pour utilisation externe (sans conflits)
export type {
CatalogAction,
CatalogActionCategory,
};
export default CatalogService;

View File

@@ -0,0 +1,504 @@
/**
* Service Evidence d'Exécution - Gestion centralisée des Evidence VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*
* Ce service centralise la gestion des Evidence pendant l'exécution des workflows VWB,
* avec persistance, synchronisation et optimisations de performance.
*/
import { Evidence, Step } from '../types';
import { VWBExecutionResult } from './vwbExecutionService';
export interface EvidenceExecutionConfig {
maxEvidencePerStep: number;
maxTotalEvidence: number;
autoCleanupInterval: number;
persistToStorage: boolean;
compressionEnabled: boolean;
}
export interface EvidenceMetrics {
totalEvidence: number;
evidenceByStep: Record<string, number>;
evidenceByType: Record<string, number>;
averageSize: number;
oldestTimestamp: Date | null;
newestTimestamp: Date | null;
}
export interface EvidenceQuery {
stepId?: string;
type?: string;
action_id?: string;
dateFrom?: Date;
dateTo?: Date;
limit?: number;
offset?: number;
sortBy?: 'timestamp' | 'type' | 'size';
sortOrder?: 'asc' | 'desc';
}
/**
* Service principal pour la gestion des Evidence d'exécution
*/
export class EvidenceExecutionService {
private static instance: EvidenceExecutionService;
private config: EvidenceExecutionConfig;
private evidenceStore: Map<string, Evidence[]> = new Map();
private allEvidence: Evidence[] = [];
private listeners: Set<(evidence: Evidence[], stepId?: string) => void> = new Set();
private cleanupTimer: NodeJS.Timeout | null = null;
private constructor(config: Partial<EvidenceExecutionConfig> = {}) {
this.config = {
maxEvidencePerStep: 50,
maxTotalEvidence: 200,
autoCleanupInterval: 5 * 60 * 1000, // 5 minutes
persistToStorage: true,
compressionEnabled: false,
...config,
};
this.initializeService();
}
public static getInstance(config?: Partial<EvidenceExecutionConfig>): EvidenceExecutionService {
if (!EvidenceExecutionService.instance) {
EvidenceExecutionService.instance = new EvidenceExecutionService(config);
}
return EvidenceExecutionService.instance;
}
/**
* Initialiser le service
*/
private initializeService(): void {
// Charger les Evidence depuis le stockage
this.loadFromStorage();
// Démarrer le nettoyage automatique
if (this.config.autoCleanupInterval > 0) {
this.startAutoCleanup();
}
// Écouter les changements de visibilité pour optimiser les performances
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
}
/**
* Ajouter une Evidence pour une étape
*/
public addEvidence(stepId: string, evidence: Evidence): void {
// Vérifier les limites
if (this.allEvidence.length >= this.config.maxTotalEvidence) {
this.performCleanup();
}
// Obtenir les Evidence de l'étape
let stepEvidence = this.evidenceStore.get(stepId) || [];
// Vérifier la limite par étape
if (stepEvidence.length >= this.config.maxEvidencePerStep) {
stepEvidence = stepEvidence.slice(1); // Supprimer la plus ancienne
}
// Ajouter la nouvelle Evidence
stepEvidence.push(evidence);
this.evidenceStore.set(stepId, stepEvidence);
// Mettre à jour la liste globale
this.updateAllEvidence();
// Sauvegarder si activé
if (this.config.persistToStorage) {
this.saveToStorage();
}
// Notifier les listeners
this.notifyListeners(stepEvidence, stepId);
}
/**
* Ajouter plusieurs Evidence pour une étape
*/
public addMultipleEvidence(stepId: string, evidenceList: Evidence[]): void {
evidenceList.forEach(evidence => this.addEvidence(stepId, evidence));
}
/**
* Traiter les résultats d'exécution VWB
*/
public processExecutionResult(result: VWBExecutionResult): void {
if (result.evidence && result.evidence.length > 0) {
this.addMultipleEvidence(result.stepId, result.evidence);
}
}
/**
* Obtenir les Evidence d'une étape
*/
public getEvidenceByStep(stepId: string): Evidence[] {
return this.evidenceStore.get(stepId) || [];
}
/**
* Obtenir toutes les Evidence
*/
public getAllEvidence(): Evidence[] {
return [...this.allEvidence];
}
/**
* Rechercher des Evidence
*/
public searchEvidence(query: EvidenceQuery): Evidence[] {
let results = this.allEvidence;
// Filtrer par étape
if (query.stepId) {
results = this.getEvidenceByStep(query.stepId);
}
// Filtrer par type
if (query.action_id) {
results = results.filter(evidence => evidence.action_id === query.action_id);
}
// Filtrer par date
if (query.dateFrom) {
results = results.filter(evidence =>
new Date(evidence.captured_at) >= query.dateFrom!
);
}
if (query.dateTo) {
results = results.filter(evidence =>
new Date(evidence.captured_at) <= query.dateTo!
);
}
// Trier
if (query.sortBy) {
results.sort((a, b) => {
let comparison = 0;
switch (query.sortBy) {
case 'timestamp':
comparison = new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime();
break;
case 'type':
comparison = a.action_id.localeCompare(b.action_id);
break;
case 'size':
const sizeA = this.getEvidenceSize(a);
const sizeB = this.getEvidenceSize(b);
comparison = sizeA - sizeB;
break;
}
return query.sortOrder === 'desc' ? -comparison : comparison;
});
}
// Pagination
if (query.offset || query.limit) {
const start = query.offset || 0;
const end = query.limit ? start + query.limit : undefined;
results = results.slice(start, end);
}
return results;
}
/**
* Obtenir les métriques des Evidence
*/
public getMetrics(): EvidenceMetrics {
const evidenceByStep: Record<string, number> = {};
const evidenceByType: Record<string, number> = {};
let totalSize = 0;
let oldestTimestamp: Date | null = null;
let newestTimestamp: Date | null = null;
// Calculer les métriques par étape
this.evidenceStore.forEach((evidence, stepId) => {
evidenceByStep[stepId] = evidence.length;
});
// Calculer les métriques globales
this.allEvidence.forEach(evidence => {
// Par type
evidenceByType[evidence.action_id] = (evidenceByType[evidence.action_id] || 0) + 1;
// Taille
totalSize += this.getEvidenceSize(evidence);
// Timestamps
const timestamp = new Date(evidence.captured_at);
if (!oldestTimestamp || timestamp < oldestTimestamp) {
oldestTimestamp = timestamp;
}
if (!newestTimestamp || timestamp > newestTimestamp) {
newestTimestamp = timestamp;
}
});
return {
totalEvidence: this.allEvidence.length,
evidenceByStep,
evidenceByType,
averageSize: this.allEvidence.length > 0 ? totalSize / this.allEvidence.length : 0,
oldestTimestamp,
newestTimestamp,
};
}
/**
* Nettoyer les Evidence d'une étape
*/
public clearStepEvidence(stepId: string): void {
this.evidenceStore.delete(stepId);
this.updateAllEvidence();
if (this.config.persistToStorage) {
this.saveToStorage();
}
this.notifyListeners([], stepId);
}
/**
* Nettoyer toutes les Evidence
*/
public clearAllEvidence(): void {
this.evidenceStore.clear();
this.allEvidence = [];
if (this.config.persistToStorage) {
this.clearStorage();
}
this.notifyListeners([]);
}
/**
* Ajouter un listener pour les changements
*/
public addListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
this.listeners.add(listener);
}
/**
* Supprimer un listener
*/
public removeListener(listener: (evidence: Evidence[], stepId?: string) => void): void {
this.listeners.delete(listener);
}
/**
* Exporter les Evidence
*/
public exportEvidence(stepId?: string): string {
const data = stepId
? { [stepId]: this.getEvidenceByStep(stepId) }
: Object.fromEntries(this.evidenceStore);
return JSON.stringify({
timestamp: new Date().toISOString(),
stepId,
evidence: data,
metrics: this.getMetrics(),
}, null, 2);
}
/**
* Importer des Evidence
*/
public importEvidence(jsonData: string): void {
try {
const data = JSON.parse(jsonData);
if (data.evidence) {
Object.entries(data.evidence).forEach(([stepId, evidence]) => {
if (Array.isArray(evidence)) {
this.evidenceStore.set(stepId, evidence as Evidence[]);
}
});
this.updateAllEvidence();
if (this.config.persistToStorage) {
this.saveToStorage();
}
this.notifyListeners(this.allEvidence);
}
} catch (error) {
console.error('Erreur lors de l\'importation des Evidence:', error);
throw new Error('Format de données invalide');
}
}
/**
* Mettre à jour la liste globale des Evidence
*/
private updateAllEvidence(): void {
this.allEvidence = [];
this.evidenceStore.forEach(stepEvidence => {
this.allEvidence.push(...stepEvidence);
});
// Trier par timestamp
this.allEvidence.sort((a, b) =>
new Date(a.captured_at).getTime() - new Date(b.captured_at).getTime()
);
}
/**
* Notifier les listeners
*/
private notifyListeners(evidence: Evidence[], stepId?: string): void {
this.listeners.forEach(listener => {
try {
listener(evidence, stepId);
} catch (error) {
console.error('Erreur dans le listener Evidence:', error);
}
});
}
/**
* Obtenir la taille d'une Evidence
*/
private getEvidenceSize(evidence: Evidence): number {
return JSON.stringify(evidence).length;
}
/**
* Effectuer le nettoyage automatique
*/
private performCleanup(): void {
const cutoffTime = Date.now() - (30 * 60 * 1000); // 30 minutes
this.evidenceStore.forEach((stepEvidence, stepId) => {
const filtered = stepEvidence.filter(evidence =>
new Date(evidence.captured_at).getTime() > cutoffTime
);
if (filtered.length !== stepEvidence.length) {
this.evidenceStore.set(stepId, filtered);
}
});
this.updateAllEvidence();
if (this.config.persistToStorage) {
this.saveToStorage();
}
}
/**
* Démarrer le nettoyage automatique
*/
private startAutoCleanup(): void {
this.cleanupTimer = setInterval(() => {
this.performCleanup();
}, this.config.autoCleanupInterval);
}
/**
* Gérer les changements de visibilité
*/
private handleVisibilityChange(): void {
if (document.hidden) {
// Sauvegarder quand la page devient invisible
if (this.config.persistToStorage) {
this.saveToStorage();
}
}
}
/**
* Sauvegarder dans le stockage local
*/
private saveToStorage(): void {
try {
const data = {
evidenceStore: Object.fromEntries(this.evidenceStore),
timestamp: Date.now(),
config: this.config,
};
localStorage.setItem('vwb_evidence_execution', JSON.stringify(data));
} catch (error) {
console.warn('Erreur lors de la sauvegarde des Evidence:', error);
}
}
/**
* Charger depuis le stockage local
*/
private loadFromStorage(): void {
try {
const stored = localStorage.getItem('vwb_evidence_execution');
if (stored) {
const data = JSON.parse(stored);
if (data.evidenceStore) {
this.evidenceStore = new Map(Object.entries(data.evidenceStore));
this.updateAllEvidence();
}
}
} catch (error) {
console.warn('Erreur lors du chargement des Evidence:', error);
}
}
/**
* Nettoyer le stockage local
*/
private clearStorage(): void {
try {
localStorage.removeItem('vwb_evidence_execution');
} catch (error) {
console.warn('Erreur lors du nettoyage du stockage:', error);
}
}
/**
* Nettoyer les ressources
*/
public cleanup(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
this.listeners.clear();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange.bind(this));
}
}
}
// Instance singleton
export const evidenceExecutionService = EvidenceExecutionService.getInstance();
// Hook pour utiliser le service
export const useEvidenceExecutionService = () => {
return {
service: evidenceExecutionService,
addEvidence: (stepId: string, evidence: Evidence) =>
evidenceExecutionService.addEvidence(stepId, evidence),
getEvidenceByStep: (stepId: string) =>
evidenceExecutionService.getEvidenceByStep(stepId),
getAllEvidence: () => evidenceExecutionService.getAllEvidence(),
searchEvidence: (query: EvidenceQuery) =>
evidenceExecutionService.searchEvidence(query),
getMetrics: () => evidenceExecutionService.getMetrics(),
clearAllEvidence: () => evidenceExecutionService.clearAllEvidence(),
};
};

View File

@@ -0,0 +1,351 @@
/**
* Service pour la gestion des Evidence VWB
* Auteur : Dom, Alice, Kiro - 10 janvier 2026
*/
import { VWBEvidence, EvidenceFilters, EvidenceExportOptions, EvidenceStats, EvidenceUtils } from '../types/evidence';
export class EvidenceService {
private baseUrl: string;
private cache: Map<string, VWBEvidence[]> = new Map();
private cacheTimeout: number = 5 * 60 * 1000; // 5 minutes
constructor(baseUrl: string = 'http://localhost:5001') {
this.baseUrl = baseUrl;
}
/**
* Récupère toutes les Evidence disponibles
*/
async getEvidences(workflowId?: string): Promise<VWBEvidence[]> {
try {
const cacheKey = `evidences_${workflowId || 'all'}`;
const cached = this.cache.get(cacheKey);
if (cached) {
return cached;
}
const url = workflowId
? `${this.baseUrl}/api/vwb/evidences?workflow_id=${workflowId}`
: `${this.baseUrl}/api/vwb/evidences`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Erreur lors de la récupération des Evidence : ${response.statusText}`);
}
const evidences: VWBEvidence[] = await response.json();
// Mise en cache
this.cache.set(cacheKey, evidences);
setTimeout(() => this.cache.delete(cacheKey), this.cacheTimeout);
return evidences;
} catch (error) {
console.error('Erreur EvidenceService.getEvidences:', error);
throw new Error(`Impossible de récupérer les Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Récupère une Evidence spécifique par ID
*/
async getEvidence(evidenceId: string): Promise<VWBEvidence | null> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Erreur lors de la récupération de l'Evidence : ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Erreur EvidenceService.getEvidence:', error);
throw new Error(`Impossible de récupérer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Sauvegarde une Evidence
*/
async saveEvidence(evidence: VWBEvidence): Promise<VWBEvidence> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(evidence),
});
if (!response.ok) {
throw new Error(`Erreur lors de la sauvegarde de l'Evidence : ${response.statusText}`);
}
const savedEvidence = await response.json();
// Invalider le cache
this.cache.clear();
return savedEvidence;
} catch (error) {
console.error('Erreur EvidenceService.saveEvidence:', error);
throw new Error(`Impossible de sauvegarder l'Evidence : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Supprime une Evidence
*/
async deleteEvidence(evidenceId: string): Promise<void> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/${evidenceId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Erreur lors de la suppression de l'Evidence : ${response.statusText}`);
}
// Invalider le cache
this.cache.clear();
} catch (error) {
console.error('Erreur EvidenceService.deleteEvidence:', error);
throw new Error(`Impossible de supprimer l'Evidence ${evidenceId} : ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Filtre les Evidence selon les critères spécifiés
*/
filterEvidences(evidences: VWBEvidence[], filters: EvidenceFilters): VWBEvidence[] {
return EvidenceUtils.filterEvidences(evidences, filters);
}
/**
* Trie les Evidence
*/
sortEvidences(evidences: VWBEvidence[], sortBy: string, sortOrder: 'asc' | 'desc' = 'desc'): VWBEvidence[] {
return EvidenceUtils.sortEvidences(evidences, sortBy, sortOrder);
}
/**
* Calcule les statistiques des Evidence
*/
calculateStats(evidences: VWBEvidence[]): EvidenceStats {
return EvidenceUtils.calculateStats(evidences);
}
/**
* Exporte les Evidence selon les options spécifiées
*/
async exportEvidences(evidences: VWBEvidence[], options: EvidenceExportOptions): Promise<Blob> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
evidences,
options,
format: options.format || 'json'
}),
});
if (!response.ok) {
throw new Error(`Erreur lors de l'export des Evidence : ${response.statusText}`);
}
return await response.blob();
} catch (error) {
console.error('Erreur EvidenceService.exportEvidences:', error);
// Fallback : export côté client
return this.exportEvidencesClientSide(evidences, options);
}
}
/**
* Export côté client en cas d'échec du serveur
*/
private exportEvidencesClientSide(evidences: VWBEvidence[], options: EvidenceExportOptions): Blob {
if (options.format === 'json') {
const exportData = {
metadata: {
exportDate: new Date().toISOString(),
totalEvidences: evidences.length,
options
},
evidences: evidences.map(evidence => ({
...evidence,
screenshot_base64: options.includeScreenshots ? evidence.screenshot_base64 : undefined
}))
};
return new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
}
if (options.format === 'html') {
const html = this.generateHtmlReport(evidences, options);
return new Blob([html], { type: 'text/html' });
}
if (options.format === 'pdf') {
// Pour PDF, on génère du HTML qui peut être converti
const html = this.generateHtmlReport(evidences, options);
return new Blob([html], { type: 'text/html' });
}
// Format par défaut
const html = this.generateHtmlReport(evidences, options);
return new Blob([html], { type: 'text/html' });
}
/**
* Génère un rapport HTML des Evidence
*/
private generateHtmlReport(evidences: VWBEvidence[], options: EvidenceExportOptions): string {
const stats = this.calculateStats(evidences);
const html = `
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rapport Evidence VWB - ${new Date().toLocaleDateString('fr-FR')}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.header { border-bottom: 2px solid #1976d2; padding-bottom: 10px; margin-bottom: 20px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px; }
.stat-card { background: #f5f5f5; padding: 15px; border-radius: 8px; text-align: center; }
.stat-value { font-size: 24px; font-weight: bold; color: #1976d2; }
.evidence-item { border: 1px solid #ddd; margin-bottom: 20px; padding: 15px; border-radius: 8px; }
.evidence-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.evidence-status { padding: 4px 8px; border-radius: 4px; color: white; font-size: 12px; }
.status-success { background: #4caf50; }
.status-error { background: #f44336; }
.evidence-screenshot { max-width: 300px; max-height: 200px; border: 1px solid #ddd; margin: 10px 0; }
.evidence-metadata { background: #f9f9f9; padding: 10px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>Rapport Evidence VWB</h1>
<p>Généré le ${new Date().toLocaleString('fr-FR')}</p>
<p>Nombre d'Evidence : ${evidences.length}</p>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-value">${stats.total}</div>
<div>Total</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.successful}</div>
<div>Réussies</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.failed}</div>
<div>Échouées</div>
</div>
<div class="stat-card">
<div class="stat-value">${EvidenceUtils.formatExecutionTime(stats.averageExecutionTime)}</div>
<div>Temps moyen</div>
</div>
<div class="stat-card">
<div class="stat-value">${EvidenceUtils.formatConfidence(stats.averageConfidence)}</div>
<div>Confiance moyenne</div>
</div>
</div>
<h2>Détail des Evidence</h2>
${evidences.map(evidence => `
<div class="evidence-item">
<div class="evidence-header">
<h3>${evidence.action_name || evidence.action_id}</h3>
<span class="evidence-status ${evidence.success ? 'status-success' : 'status-error'}">
${evidence.success ? 'SUCCÈS' : 'ERREUR'}
</span>
</div>
<p><strong>Date :</strong> ${EvidenceUtils.formatDate(evidence.captured_at)}</p>
<p><strong>Temps d'exécution :</strong> ${EvidenceUtils.formatExecutionTime(evidence.execution_time_ms)}</p>
${evidence.confidence_score ? `<p><strong>Confiance :</strong> ${EvidenceUtils.formatConfidence(evidence.confidence_score)}</p>` : ''}
${evidence.error ? `
<div style="background: #ffebee; padding: 10px; border-radius: 4px; margin: 10px 0;">
<strong>Erreur :</strong> ${evidence.error.message}
</div>
` : ''}
${options.includeScreenshots && evidence.screenshot_base64 ? `
<img src="data:image/png;base64,${evidence.screenshot_base64}"
alt="Screenshot Evidence" class="evidence-screenshot">
` : ''}
${options.includeMetadata && evidence.metadata ? `
<div class="evidence-metadata">
<strong>Métadonnées :</strong>
<pre>${JSON.stringify(evidence.metadata, null, 2)}</pre>
</div>
` : ''}
</div>
`).join('')}
</body>
</html>`;
// Utilisation de URL.createObjectURL pour la génération
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
URL.revokeObjectURL(url); // Nettoyage immédiat pour les tests
return html;
}
/**
* Valide la santé du service Evidence
*/
async healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/vwb/evidences/health`, {
method: 'GET',
timeout: 5000
} as RequestInit);
return response.ok;
} catch (error) {
console.warn('Service Evidence non disponible:', error);
return false;
}
}
/**
* Nettoie le cache
*/
clearCache(): void {
this.cache.clear();
}
}
// Instance singleton du service
export const evidenceService = new EvidenceService();
export default EvidenceService;

View File

@@ -0,0 +1,388 @@
/**
* Service de Capture d'Écran Réelle - Interface avec l'API Backend
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce service gère la capture d'écran réelle avec détection d'éléments UI
* en utilisant le service RealScreenCaptureService du backend.
*/
// Configuration du service
const BACKEND_BASE_URL = 'http://localhost:5001/api';
const REQUEST_TIMEOUT = 20000; // 20 secondes pour la capture avec détection
// Types pour les réponses API
interface Monitor {
id: number;
width: number;
height: number;
top: number;
left: number;
}
interface UIElement {
id: string;
type: string;
text: string;
bbox: {
x: number;
y: number;
width: number;
height: number;
};
confidence: number;
attributes: Record<string, any>;
}
interface CaptureStatus {
is_capturing: boolean;
selected_monitor: number;
monitors_count: number;
capture_interval: number;
elements_detected: number;
has_screenshot: boolean;
}
interface RealScreenCaptureResponse {
success: boolean;
screenshot?: string;
elements?: UIElement[];
monitors?: Monitor[];
status?: CaptureStatus;
timestamp?: string;
method?: string;
error?: string;
}
interface CaptureControlResponse {
success: boolean;
message?: string;
status?: CaptureStatus;
error?: string;
}
interface StatusResponse {
success: boolean;
status?: CaptureStatus;
monitors?: Monitor[];
error?: string;
}
/**
* Service de capture d'écran réelle avec détection d'éléments UI
*/
class RealScreenCaptureService {
private abortController: AbortController | null = null;
/**
* Capturer l'écran avec détection d'éléments UI
*
* @param monitorId ID du moniteur à capturer (0 par défaut)
* @param detectElements Détecter les éléments UI (true par défaut)
* @returns Promise avec les données de capture ou null si erreur
*/
async captureWithElements(
monitorId: number = 0,
detectElements: boolean = true
): Promise<RealScreenCaptureResponse | null> {
try {
// Annuler toute requête précédente
this.cancelRequest();
// Créer un nouveau contrôleur d'abort
this.abortController = new AbortController();
// Timeout pour la requête
const timeoutId = setTimeout(() => {
if (this.abortController) {
this.abortController.abort();
}
}, REQUEST_TIMEOUT);
console.log(`🔧 Capture d'écran réelle (moniteur ${monitorId}, détection: ${detectElements})...`);
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
monitor_id: monitorId,
detect_elements: detectElements,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: RealScreenCaptureResponse = await response.json();
if (data.success) {
console.log(`✅ Capture réelle réussie - ${data.elements?.length || 0} éléments détectés`);
} else {
console.error('❌ Capture réelle échouée:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de la capture d\'écran réelle:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return {
success: false,
error: 'Capture annulée (timeout)',
};
}
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de la capture réelle',
};
} finally {
this.abortController = null;
}
}
/**
* Démarrer la capture en temps réel
*
* @param interval Intervalle entre les captures en secondes (1.0 par défaut)
* @returns Promise avec le résultat de l'opération
*/
async startRealTimeCapture(interval: number = 1.0): Promise<CaptureControlResponse | null> {
try {
console.log(`🔧 Démarrage capture temps réel (intervalle: ${interval}s)...`);
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
interval,
}),
signal: AbortSignal.timeout(10000), // 10 secondes max
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: CaptureControlResponse = await response.json();
if (data.success) {
console.log('✅ Capture temps réel démarrée');
} else {
console.error('❌ Échec démarrage capture temps réel:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors du démarrage de la capture temps réel:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors du démarrage',
};
}
}
/**
* Arrêter la capture en temps réel
*
* @returns Promise avec le résultat de l'opération
*/
async stopRealTimeCapture(): Promise<CaptureControlResponse | null> {
try {
console.log('🔧 Arrêt capture temps réel...');
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 secondes max
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: CaptureControlResponse = await response.json();
if (data.success) {
console.log('✅ Capture temps réel arrêtée');
} else {
console.error('❌ Échec arrêt capture temps réel:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de l\'arrêt de la capture temps réel:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de l\'arrêt',
};
}
}
/**
* Obtenir le statut du service de capture réelle
*
* @returns Promise avec le statut du service
*/
async getStatus(): Promise<StatusResponse | null> {
try {
const response = await fetch(`${BACKEND_BASE_URL}/real-demo/capture/status`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 secondes max
});
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: StatusResponse = await response.json();
if (data.success) {
console.log('✅ Statut obtenu:', data.status);
} else {
console.error('❌ Échec obtention statut:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de l\'obtention du statut:', error);
if (error instanceof Error) {
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de l\'obtention du statut',
};
}
}
/**
* Vérifier la disponibilité du service de capture réelle
*
* @returns Promise<boolean> true si le service est disponible
*/
async checkAvailability(): Promise<boolean> {
try {
const statusResponse = await this.getStatus();
return statusResponse?.success === true;
} catch (error) {
console.warn('⚠️ Service de capture réelle indisponible:', error);
return false;
}
}
/**
* Obtenir la liste des moniteurs disponibles
*
* @returns Promise avec la liste des moniteurs ou null si erreur
*/
async getMonitors(): Promise<Monitor[] | null> {
try {
const statusResponse = await this.getStatus();
if (statusResponse?.success && statusResponse.monitors) {
return statusResponse.monitors;
}
return null;
} catch (error) {
console.error('❌ Erreur lors de l\'obtention des moniteurs:', error);
return null;
}
}
/**
* Annuler la requête en cours
*/
cancelRequest(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
/**
* Nettoyer les ressources
*/
cleanup(): void {
this.cancelRequest();
}
}
// Instance singleton du service
export const realScreenCaptureService = new RealScreenCaptureService();
// Export des types
export type {
RealScreenCaptureResponse,
CaptureControlResponse,
StatusResponse,
Monitor,
UIElement,
CaptureStatus
};
export default RealScreenCaptureService;

View File

@@ -0,0 +1,371 @@
/**
* Service de Capture d'Écran - Interface avec l'API Backend Ultra Stable
* Auteur : Dom, Alice, Kiro - 09 janvier 2026
*
* Ce service gère la capture d'écran et la création d'embeddings visuels
* en utilisant l'Option A (MSS créé à chaque capture) pour une stabilité maximale.
*/
import { BoundingBox, VisualSelection } from '../types';
// Configuration du service
const BACKEND_BASE_URL = 'http://localhost:5001/api';
const REQUEST_TIMEOUT = 15000; // 15 secondes pour la capture d'écran
// Types pour les réponses API
interface ScreenCaptureResponse {
success: boolean;
screenshot?: string;
width?: number;
height?: number;
timestamp?: string;
method?: string;
error?: string;
}
interface VisualEmbeddingResponse {
success: boolean;
embedding?: number[];
embedding_id?: string;
dimension?: number;
reference_image?: string;
bounding_box?: BoundingBox;
error?: string;
}
/**
* Service de capture d'écran utilisant l'API Backend ultra stable
*/
class ScreenCaptureService {
private abortController: AbortController | null = null;
/**
* Capturer l'écran actuel via l'API Backend (Option A - ultra stable)
*
* @param format Format de l'image ('png' ou 'jpeg')
* @param quality Qualité pour JPEG (1-100)
* @returns Promise avec les données de capture ou null si erreur
*/
async captureScreen(
format: 'png' | 'jpeg' = 'png',
quality: number = 90
): Promise<ScreenCaptureResponse | null> {
try {
// Annuler toute requête précédente
this.cancelRequest();
// Créer un nouveau contrôleur d'abort
this.abortController = new AbortController();
// Timeout pour la requête
const timeoutId = setTimeout(() => {
if (this.abortController) {
this.abortController.abort();
}
}, REQUEST_TIMEOUT);
console.log('🔧 Capture d\'écran via API Backend (Option A - ultra stable)...');
const response = await fetch(`${BACKEND_BASE_URL}/screen-capture/capture`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
format,
quality,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: ScreenCaptureResponse = await response.json();
if (data.success) {
console.log(`✅ Capture réussie - ${data.width}x${data.height} (${data.method})`);
} else {
console.error('❌ Capture échouée:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de la capture d\'écran:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return {
success: false,
error: 'Capture annulée (timeout)',
};
}
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de la capture',
};
} finally {
this.abortController = null;
}
}
/**
* Créer un embedding visuel à partir d'une capture et d'une zone sélectionnée
*
* @param screenshot Image en base64
* @param boundingBox Zone sélectionnée
* @param stepId Identifiant de l'étape
* @returns Promise avec les données d'embedding ou null si erreur
*/
async createVisualEmbedding(
screenshot: string,
boundingBox: BoundingBox,
stepId: string
): Promise<VisualEmbeddingResponse | null> {
try {
// Annuler toute requête précédente
this.cancelRequest();
// Créer un nouveau contrôleur d'abort
this.abortController = new AbortController();
// Timeout pour la requête
const timeoutId = setTimeout(() => {
if (this.abortController) {
this.abortController.abort();
}
}, REQUEST_TIMEOUT);
console.log('🎯 Création d\'embedding visuel via API Backend...');
const response = await fetch(`${BACKEND_BASE_URL}/visual-embedding`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
screenshot,
boundingBox,
stepId,
}),
signal: this.abortController.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.error(`❌ Erreur HTTP ${response.status}:`, errorText);
return {
success: false,
error: `Erreur HTTP ${response.status}: ${errorText}`,
};
}
const data: VisualEmbeddingResponse = await response.json();
if (data.success) {
console.log(`✅ Embedding créé - ID: ${data.embedding_id}, Dimension: ${data.dimension}`);
} else {
console.error('❌ Création d\'embedding échouée:', data.error);
}
return data;
} catch (error) {
console.error('❌ Erreur lors de la création d\'embedding:', error);
if (error instanceof Error) {
if (error.name === 'AbortError') {
return {
success: false,
error: 'Création d\'embedding annulée (timeout)',
};
}
return {
success: false,
error: error.message,
};
}
return {
success: false,
error: 'Erreur inconnue lors de la création d\'embedding',
};
} finally {
this.abortController = null;
}
}
/**
* Capturer l'écran et créer une sélection visuelle complète
*
* @param boundingBox Zone sélectionnée sur la capture
* @param stepId Identifiant de l'étape
* @param description Description de la sélection
* @returns Promise avec la sélection visuelle complète ou null si erreur
*/
async captureAndCreateSelection(
boundingBox: BoundingBox,
stepId: string,
description?: string
): Promise<VisualSelection | null> {
try {
// Étape 1: Capturer l'écran
console.log('📷 Étape 1/2: Capture d\'écran...');
const captureResult = await this.captureScreen('png', 90);
if (!captureResult || !captureResult.success || !captureResult.screenshot) {
console.error('❌ Échec de la capture d\'écran');
return null;
}
// Étape 2: Créer l'embedding visuel
console.log('🎯 Étape 2/2: Création d\'embedding visuel...');
const embeddingResult = await this.createVisualEmbedding(
captureResult.screenshot,
boundingBox,
stepId
);
if (!embeddingResult || !embeddingResult.success || !embeddingResult.embedding) {
console.error('❌ Échec de la création d\'embedding');
return null;
}
// Créer la sélection visuelle complète
const visualSelection: VisualSelection = {
id: `visual_${stepId}_${Date.now()}`,
screenshot: captureResult.screenshot,
boundingBox: embeddingResult.bounding_box || boundingBox,
embedding: embeddingResult.embedding,
description: description || `Élément sélectionné pour l'étape ${stepId}`,
metadata: {
embedding_id: embeddingResult.embedding_id,
dimension: embeddingResult.dimension,
reference_image: embeddingResult.reference_image,
capture_method: captureResult.method,
capture_timestamp: captureResult.timestamp,
screen_resolution: {
width: captureResult.width,
height: captureResult.height,
},
},
};
console.log('✅ Sélection visuelle créée avec succès');
return visualSelection;
} catch (error) {
console.error('❌ Erreur lors de la création de sélection visuelle:', error);
return null;
}
}
/**
* Vérifier la disponibilité de l'API Backend
*
* @returns Promise<boolean> true si l'API est disponible
*/
async checkApiAvailability(): Promise<boolean> {
try {
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5000), // 5 secondes max
});
if (response.ok) {
const data = await response.json();
console.log('✅ API Backend disponible:', data);
return true;
} else {
console.warn('⚠️ API Backend indisponible - HTTP', response.status);
return false;
}
} catch (error) {
console.warn('⚠️ API Backend indisponible:', error);
return false;
}
}
/**
* Obtenir les informations sur les capacités de capture
*
* @returns Promise avec les informations de capacité
*/
async getCapabilities(): Promise<{
screen_capture: boolean;
visual_embedding: boolean;
methods: string[];
} | null> {
try {
const response = await fetch(`${BACKEND_BASE_URL}/health`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const data = await response.json();
return {
screen_capture: data.features?.screen_capture || false,
visual_embedding: data.features?.visual_embedding || false,
methods: data.methods || ['unknown'],
};
}
return null;
} catch (error) {
console.warn('⚠️ Impossible d\'obtenir les capacités:', error);
return null;
}
}
/**
* Annuler la requête en cours
*/
cancelRequest(): void {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
/**
* Nettoyer les ressources
*/
cleanup(): void {
this.cancelRequest();
}
}
// Instance singleton du service
export const screenCaptureService = new ScreenCaptureService();
// Export des types
export type { ScreenCaptureResponse, VisualEmbeddingResponse };
export default ScreenCaptureService;

View File

@@ -7,14 +7,20 @@
*/
import { catalogService } from './catalogService';
import {
Step,
StepExecutionState,
ExecutionResult,
import {
Step,
StepExecutionState,
ExecutionResult,
ExecutionError,
Evidence
Evidence
} from '../types';
import { VWBCatalogAction } from '../types/catalog';
import {
enforceActionContract,
ContractValidationError,
getRequiredParams,
isKnownActionType
} from '../contracts';
interface VWBActionValidationResult {
is_valid: boolean;
@@ -110,12 +116,57 @@ export class VWBExecutionService {
'verify_text_content',
]);
/**
* NORMALISATION CRITIQUE: Résout les incohérences entre step.type et step.data.stepType
*
* Cette fonction détecte et corrige le problème où le type d'action peut être différent
* entre step.type (source principale) et step.data.stepType (doublon historique).
*
* @returns Le type d'action normalisé (VWB si possible, sinon le type disponible)
*/
public normalizeStepType(step: Step): string {
const typeFromStep = step.type;
const typeFromData = step.data?.stepType;
const typeFromVwbAction = step.data?.vwbActionId;
const typeFromActionId = step.action_id;
// Détection d'incohérence
if (typeFromStep && typeFromData && typeFromStep !== typeFromData) {
console.warn(
`⚠️ [VWB NORMALISATION] Incohérence détectée pour étape ${step.id}:`,
`\n step.type = "${typeFromStep}"`,
`\n step.data.stepType = "${typeFromData}"`,
`\n → Utilisation de la valeur VWB valide`
);
}
// Priorité: Type VWB valide > step.type > step.data.stepType > vwbActionId > action_id
const candidates = [typeFromStep, typeFromData, typeFromVwbAction, typeFromActionId];
// Chercher d'abord un type VWB valide
for (const candidate of candidates) {
if (candidate && VWBExecutionService.VWB_ACTION_TYPES.has(candidate)) {
if (candidate !== typeFromStep) {
console.log(
`🔧 [VWB NORMALISATION] Correction: "${typeFromStep}" → "${candidate}" pour étape ${step.id}`
);
}
return candidate;
}
}
// Sinon, prendre le premier disponible
const fallback = typeFromStep || typeFromData || typeFromVwbAction || typeFromActionId || 'unknown';
console.log(`📋 [VWB NORMALISATION] Type résolu: "${fallback}" pour étape ${step.id}`);
return fallback;
}
/**
* Vérifier si une étape est une action VWB
*/
public isVWBStep(step: Step): boolean {
// Vérifier d'abord si le type est dans la liste des actions VWB connues
const stepType = step.type || step.data?.stepType;
// Utiliser la normalisation pour obtenir le vrai type
const stepType = this.normalizeStepType(step);
if (stepType && VWBExecutionService.VWB_ACTION_TYPES.has(stepType)) {
return true;
}
@@ -137,8 +188,8 @@ export class VWBExecutionService {
throw new Error(`L'étape ${step.id} n'est pas une action VWB`);
}
// Extraire le type d'action depuis plusieurs sources possibles
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
// UTILISER LA NORMALISATION pour obtenir le type correct
const actionId = this.normalizeStepType(step);
const parameters = step.data?.parameters || {};
try {
@@ -217,23 +268,55 @@ export class VWBExecutionService {
}
}
// Extraire le type d'action depuis plusieurs sources possibles
const actionId = step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown";
// UTILISER LA NORMALISATION pour obtenir le type correct
const actionId = this.normalizeStepType(step);
const parameters = this.prepareParameters(step);
console.log(`🎯 [VWB] Exécution étape ${step.id}: type normalisé = "${actionId}"`);
console.log(` step.type = "${step.type}", step.data?.stepType = "${step.data?.stepType}"`);
console.log(` Paramètres: ${Object.keys(parameters).join(', ')}`);
// === VALIDATION CONTRAT STRICT ===
// BLOQUE l'exécution si le contrat n'est pas respecté
try {
enforceActionContract(actionId, parameters);
} catch (contractError) {
if (contractError instanceof ContractValidationError) {
console.error(`🚫 [CONTRAT VIOLÉ] Action '${actionId}' bloquée!`);
console.error(` Paramètres requis: ${getRequiredParams(actionId).join(', ')}`);
console.error(` Paramètres fournis: ${Object.keys(parameters).join(', ')}`);
throw new Error(
`Contrat violé pour '${actionId}': ${contractError.violations.map(v => v.message).join('; ')}`
);
}
throw contractError;
}
// Exécuter l'action avec retry
let lastError: Error | null = null;
for (let attempt = 1; attempt <= retryAttempts; attempt++) {
try {
const result = await this.executeActionWithTimeout(
actionId,
parameters,
actionId,
parameters,
timeout,
this.currentExecution.signal
);
const duration = Date.now() - startTime;
// IMPORTANT: Vérifier si le résultat indique une erreur (ex: ancre non trouvée)
// Le backend retourne status='error' ou should_stop=true quand l'action échoue
const isError = result?.status === 'error' ||
result?.should_stop === true ||
result?.success === false;
if (isError) {
console.log('🛑 [VWB] Erreur détectée dans le résultat:', result);
const errorMessage = result?.error?.message || result?.error || result?.message || 'Erreur retournée par le backend';
throw new Error(errorMessage);
}
// Traiter les Evidence si disponibles
const evidence = generateEvidence ? await this.processEvidence(result, startTime) : undefined;
@@ -274,7 +357,7 @@ export class VWBExecutionService {
return {
success: false,
stepId: step.id,
actionId: step.type || step.data?.stepType || step.data?.vwbActionId || step.action_id || "unknown",
actionId: this.normalizeStepType(step),
duration,
error: executionError
};
@@ -315,10 +398,40 @@ export class VWBExecutionService {
/**
* Préparer les paramètres pour l'exécution
* Gère les différents formats de données possibles (workflows sauvegardés vs créés en direct)
*/
private prepareParameters(step: Step): Record<string, any> {
// Récupérer les paramètres depuis plusieurs sources possibles
let parameters = { ...step.data?.parameters || {} };
// Si visual_anchor est vide mais qu'il y a un target avec des données visuelles, l'utiliser
if (!parameters.visual_anchor && step.data?.visualSelection) {
parameters.visual_anchor = step.data.visualSelection;
}
// Si target contient des données visuelles, fusionner avec visual_anchor
if (parameters.target && !parameters.visual_anchor) {
parameters.visual_anchor = parameters.target;
}
// S'assurer que visual_anchor a les bonnes propriétés pour les actions de clic
if (parameters.visual_anchor) {
const anchor = parameters.visual_anchor;
// Normaliser le nom de la propriété de l'image
if (!anchor.reference_image_base64 && anchor.screenshot) {
anchor.reference_image_base64 = anchor.screenshot;
}
if (!anchor.reference_image_base64 && anchor.image) {
anchor.reference_image_base64 = anchor.image;
}
// Normaliser bounding_box
if (!anchor.bounding_box && anchor.boundingBox) {
anchor.bounding_box = anchor.boundingBox;
}
}
// Résoudre les variables si contexte disponible
if (this.executionContext?.variables) {
parameters = this.resolveVariables(parameters, this.executionContext.variables);

View File

@@ -0,0 +1,162 @@
/**
* Service de Stockage Local des Workflows
* Auteur : Dom, Alice, Kiro - 13 janvier 2026
*
* Permet de sauvegarder et charger des workflows en localStorage
* pour fonctionner sans backend.
*/
import { Workflow, Step, WorkflowConnection, Variable } from '../types';
export interface StoredWorkflow {
id: string;
name: string;
description?: string;
steps: Step[];
connections: WorkflowConnection[];
variables: Variable[];
createdAt: string;
updatedAt: string;
version: number;
}
interface WorkflowStorageState {
workflows: StoredWorkflow[];
lastUpdated: string;
version: string;
}
const STORAGE_KEY = 'vwb_workflows';
const STORAGE_VERSION = '1.0.0';
/**
* Service de gestion du stockage local des workflows
*/
class WorkflowStorageService {
private state: WorkflowStorageState;
constructor() {
this.state = this.loadFromStorage();
console.log('💾 [WorkflowStorage] Service initialisé avec', this.state.workflows.length, 'workflows');
}
/**
* Recharger les données depuis localStorage
*/
public reload(): void {
this.state = this.loadFromStorage();
console.log('🔄 [WorkflowStorage] Rechargé:', this.state.workflows.length, 'workflows');
}
/**
* Charger depuis localStorage
*/
private loadFromStorage(): WorkflowStorageState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as WorkflowStorageState;
if (parsed.version === STORAGE_VERSION) {
return parsed;
}
}
} catch (error) {
console.warn('Erreur lors du chargement des workflows:', error);
}
return {
workflows: [],
lastUpdated: new Date().toISOString(),
version: STORAGE_VERSION,
};
}
/**
* Sauvegarder dans localStorage
*/
private saveToStorage(): void {
try {
this.state.lastUpdated = new Date().toISOString();
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch (error) {
console.error('Erreur lors de la sauvegarde des workflows:', error);
}
}
/**
* Obtenir tous les workflows
*/
getAllWorkflows(): StoredWorkflow[] {
return [...this.state.workflows];
}
/**
* Obtenir un workflow par ID
*/
getWorkflowById(id: string): StoredWorkflow | null {
return this.state.workflows.find((w) => w.id === id) || null;
}
/**
* Sauvegarder un workflow
*/
saveWorkflow(workflow: Workflow): StoredWorkflow {
const now = new Date().toISOString();
const existingIndex = this.state.workflows.findIndex((w) => w.id === workflow.id);
const storedWorkflow: StoredWorkflow = {
id: workflow.id || `workflow_${Date.now()}`,
name: workflow.name,
description: workflow.description,
steps: workflow.steps || [],
connections: workflow.connections || [],
variables: workflow.variables || [],
createdAt: existingIndex >= 0 ? this.state.workflows[existingIndex].createdAt : now,
updatedAt: now,
version: existingIndex >= 0 ? this.state.workflows[existingIndex].version + 1 : 1,
};
if (existingIndex >= 0) {
this.state.workflows[existingIndex] = storedWorkflow;
} else {
this.state.workflows.unshift(storedWorkflow);
}
this.saveToStorage();
console.log('💾 [WorkflowStorage] Workflow sauvegardé:', storedWorkflow.name);
return storedWorkflow;
}
/**
* Supprimer un workflow
*/
deleteWorkflow(id: string): boolean {
const index = this.state.workflows.findIndex((w) => w.id === id);
if (index === -1) return false;
this.state.workflows.splice(index, 1);
this.saveToStorage();
console.log('🗑️ [WorkflowStorage] Workflow supprimé:', id);
return true;
}
/**
* Convertir en format Workflow pour l'application
*/
toWorkflow(stored: StoredWorkflow): Workflow {
return {
id: stored.id,
name: stored.name,
description: stored.description,
steps: stored.steps,
connections: stored.connections,
variables: stored.variables,
createdAt: new Date(stored.createdAt),
updatedAt: new Date(stored.updatedAt),
};
}
}
// Export singleton
export const workflowStorageService = new WorkflowStorageService();
export default workflowStorageService;