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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
976
visual_workflow_builder/frontend/src/services/catalogService.ts
Normal file
976
visual_workflow_builder/frontend/src/services/catalogService.ts
Normal 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;
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
351
visual_workflow_builder/frontend/src/services/evidenceService.ts
Normal file
351
visual_workflow_builder/frontend/src/services/evidenceService.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user