Files
rpa_vision_v3/.kiro/specs/resolution-palette-vide-cross-machine/design.md
Dom a7de6a488b feat: replay E2E fonctionnel — 25/25 actions, 0 retries, SomEngine via serveur
Validé sur PC Windows (DESKTOP-58D5CAC, 2560x1600) :
- 8 clics résolus visuellement (1 anchor_template, 1 som_text_match, 6 som_vlm)
- Score moyen 0.75, temps moyen 1.6s
- Texte tapé correctement (bonjour, test word, date, email)
- 0 retries, 2 actions non vérifiées (OK)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:04:41 +02:00

15 KiB

Design Technique : Résolution du Problème de Palette Vide Cross-Machine

Vue d'Ensemble Architecturale

Cette spécification définit l'architecture technique pour résoudre le problème de palette vide dans le Visual Workflow Builder lorsqu'utilisé sur une machine distante.

Auteur : Dom, Alice, Kiro - 10 janvier 2026

Architecture Actuelle vs Cible

Architecture Actuelle (Problématique)

Frontend (Machine A) → catalogService.ts → http://localhost:5004 (Machine A)
                                         ↓
                                    ❌ ÉCHEC (Backend sur Machine B)
                                         ↓
                                    Palette Vide

Architecture Cible (Solution)

Frontend (Machine A) → catalogService.ts → Détection Auto URL
                                         ↓
                    ┌─ http://current-origin/api/vwb/catalog (Machine A)
                    ├─ http://localhost:5004 (Machine A)  
                    ├─ http://192.168.x.x:5004 (Machine B)
                    └─ Catalogue Statique (Fallback)
                                         ↓
                                    ✅ Palette Complète

Composants Modifiés

1. Service Catalogue Étendu

Interface de Configuration

interface CatalogServiceConfig {
  // URLs à tester dans l'ordre
  candidateUrls: string[];
  
  // Configuration de retry
  timeout: number;
  maxRetries: number;
  retryDelay: number;
  
  // Cache de configuration
  cacheKey: string;
  cacheDuration: number;
  
  // Mode fallback
  enableStaticFallback: boolean;
  staticCatalogPath: string;
}

Méthode de Détection d'URL

class CatalogService {
  private async detectBackendUrl(): Promise<string | null> {
    const candidates = [
      // 1. URL courante (même origine)
      `${window.location.origin}/api/vwb/catalog`,
      
      // 2. Configuration explicite
      process.env.REACT_APP_CATALOG_URL,
      new URLSearchParams(window.location.search).get('catalogUrl'),
      
      // 3. URLs par défaut
      'http://localhost:5004/api/vwb/catalog',
      
      // 4. IP locale détectée
      await this.detectLocalIP() + ':5004/api/vwb/catalog'
    ].filter(Boolean);

    for (const url of candidates) {
      if (await this.testUrl(url)) {
        this.cacheWorkingUrl(url);
        return url;
      }
    }
    
    return null; // Déclenche fallback statique
  }

  private async testUrl(url: string): Promise<boolean> {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 2000);
      
      const response = await fetch(`${url}/health`, {
        signal: controller.signal,
        method: 'GET',
        headers: { 'Accept': 'application/json' }
      });
      
      clearTimeout(timeoutId);
      return response.ok;
    } catch {
      return false;
    }
  }
}

2. Catalogue Statique de Secours

Structure du Catalogue Statique

// visual_workflow_builder/frontend/src/data/staticCatalog.ts

export const STATIC_CATALOG_ACTIONS: VWBCatalogAction[] = [
  {
    id: 'click_anchor',
    name: 'Cliquer sur Élément',
    description: 'Cliquer sur un élément identifié visuellement',
    category: 'vision_ui',
    icon: '🖱️',
    parameters: {
      visual_anchor: {
        type: 'VWBVisualAnchor',
        required: true,
        description: 'Élément à cliquer'
      },
      click_type: {
        type: 'string',
        required: false,
        default: 'left',
        options: ['left', 'right', 'double'],
        description: 'Type de clic'
      }
    },
    examples: [
      {
        name: 'Clic simple sur bouton',
        description: 'Cliquer sur un bouton "Valider"',
        parameters: {
          visual_anchor: { /* anchor data */ },
          click_type: 'left'
        }
      }
    ],
    metadata: {
      version: '1.0.0',
      author: 'Dom, Alice, Kiro',
      complexity: 'simple',
      estimatedDuration: 1000
    }
  },
  // ... autres actions
];

export const STATIC_CATALOG_CATEGORIES: VWBActionCategoryInfo[] = [
  {
    id: 'vision_ui',
    name: 'Interface Utilisateur',
    description: 'Actions d\'interaction visuelle',
    icon: '🖱️',
    color: '#2196f3',
    actionCount: 5,
    isEnabled: true
  }
];

3. Hook useCatalogActions Étendu

Logique de Fallback

export const useCatalogActions = (options: UseCatalogActionsOptions = {}) => {
  const [mode, setMode] = useState<'dynamic' | 'static' | 'detecting'>('detecting');
  
  const loadCatalogData = useCallback(async () => {
    setState(prev => ({ ...prev, isLoading: true, error: null }));
    
    try {
      // Tentative de chargement dynamique
      const dynamicData = await catalogService.getActions();
      
      setState({
        actions: dynamicData.actions,
        categories: await catalogService.getCategories(),
        isLoading: false,
        isOnline: true,
        error: null,
        lastUpdate: new Date()
      });
      
      setMode('dynamic');
      
    } catch (error) {
      console.warn('Catalogue dynamique indisponible, basculement vers statique:', error);
      
      // Fallback vers catalogue statique
      setState({
        actions: STATIC_CATALOG_ACTIONS,
        categories: STATIC_CATALOG_CATEGORIES,
        isLoading: false,
        isOnline: false,
        error: 'Service catalogue indisponible - Mode hors ligne',
        lastUpdate: new Date()
      });
      
      setMode('static');
    }
  }, []);
  
  // ... reste de l'implémentation
};

4. Interface Utilisateur Améliorée

Indicateurs de Statut dans la Palette

// Composant StatusIndicator
const CatalogStatusIndicator: React.FC<{
  mode: 'dynamic' | 'static' | 'detecting';
  actionCount: number;
  error?: string;
  onRetry: () => void;
}> = ({ mode, actionCount, error, onRetry }) => {
  const getStatusConfig = () => {
    switch (mode) {
      case 'dynamic':
        return {
          icon: <OnlineIcon color="success" />,
          label: 'En ligne',
          color: 'success',
          tooltip: `Catalogue en ligne - ${actionCount} actions disponibles`
        };
      case 'static':
        return {
          icon: <OfflineIcon color="warning" />,
          label: 'Hors ligne',
          color: 'warning',
          tooltip: `Mode hors ligne - ${actionCount} actions de base disponibles`
        };
      case 'detecting':
        return {
          icon: <CircularProgress size={16} />,
          label: 'Détection...',
          color: 'info',
          tooltip: 'Détection du service catalogue en cours...'
        };
    }
  };

  const config = getStatusConfig();

  return (
    <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
      <Tooltip title={config.tooltip} placement="left">
        <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
          {config.icon}
          <Typography variant="caption" color={config.color}>
            {config.label}
          </Typography>
          <Badge badgeContent={actionCount} color="primary" max={99} />
        </Box>
      </Tooltip>
      
      {error && (
        <Tooltip title="Cliquer pour réessayer">
          <IconButton size="small" onClick={onRetry}>
            <RefreshIcon fontSize="small" />
          </IconButton>
        </Tooltip>
      )}
    </Box>
  );
};

Flux de Données

1. Séquence de Démarrage

sequenceDiagram
    participant U as Utilisateur
    participant P as Palette
    participant H as useCatalogActions
    participant S as CatalogService
    participant C as Cache
    participant SC as StaticCatalog

    U->>P: Ouvre VWB
    P->>H: useEffect (autoLoad)
    H->>S: loadCatalogData()
    S->>C: getCachedUrl()
    
    alt URL en cache valide
        C-->>S: URL fonctionnelle
        S->>S: testUrl(cachedUrl)
        alt URL fonctionne
            S-->>H: Actions dynamiques
            H-->>P: État: dynamic, actions
        else URL échoue
            S->>S: detectBackendUrl()
            S-->>H: Nouvelle URL ou null
        end
    else Pas de cache
        S->>S: detectBackendUrl()
        alt URL trouvée
            S->>C: cacheWorkingUrl()
            S-->>H: Actions dynamiques
            H-->>P: État: dynamic, actions
        else Aucune URL
            H->>SC: STATIC_CATALOG_ACTIONS
            H-->>P: État: static, actions statiques
        end
    end

2. Gestion des Erreurs Réseau

flowchart TD
    A[Requête Catalogue] --> B{Succès?}
    B -->|Oui| C[Mode Dynamique]
    B -->|Non| D[Erreur Réseau]
    D --> E{Type d'erreur}
    E -->|Timeout| F[Retry avec délai]
    E -->|404/500| G[Essayer URL suivante]
    E -->|Network Error| H[Mode Statique]
    F --> I{Retry réussi?}
    I -->|Oui| C
    I -->|Non| H
    G --> J{Autres URLs?}
    J -->|Oui| A
    J -->|Non| H
    H --> K[Catalogue Statique]

Patterns de Design

1. Strategy Pattern pour Détection d'URL

interface UrlDetectionStrategy {
  detect(): Promise<string | null>;
  priority: number;
}

class OriginUrlStrategy implements UrlDetectionStrategy {
  priority = 1;
  async detect(): Promise<string | null> {
    const url = `${window.location.origin}/api/vwb/catalog`;
    return await catalogService.testUrl(url) ? url : null;
  }
}

class LocalhostStrategy implements UrlDetectionStrategy {
  priority = 2;
  async detect(): Promise<string | null> {
    const url = 'http://localhost:5004/api/vwb/catalog';
    return await catalogService.testUrl(url) ? url : null;
  }
}

class LocalIPStrategy implements UrlDetectionStrategy {
  priority = 3;
  async detect(): Promise<string | null> {
    const ip = await this.detectLocalIP();
    const url = `http://${ip}:5004/api/vwb/catalog`;
    return await catalogService.testUrl(url) ? url : null;
  }
}

2. Observer Pattern pour Statut

interface CatalogStatusObserver {
  onStatusChange(status: CatalogStatus): void;
}

class CatalogService {
  private observers: CatalogStatusObserver[] = [];
  
  addObserver(observer: CatalogStatusObserver) {
    this.observers.push(observer);
  }
  
  private notifyStatusChange(status: CatalogStatus) {
    this.observers.forEach(observer => observer.onStatusChange(status));
  }
}

3. Cache Pattern avec Expiration

interface CacheEntry<T> {
  data: T;
  timestamp: number;
  ttl: number;
}

class CatalogCache {
  private cache = new Map<string, CacheEntry<any>>();
  
  set<T>(key: string, data: T, ttl: number = 24 * 60 * 60 * 1000) {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      ttl
    });
  }
  
  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    if (Date.now() - entry.timestamp > entry.ttl) {
      this.cache.delete(key);
      return null;
    }
    
    return entry.data;
  }
}

Configuration et Déploiement

Variables d'Environnement

# .env.example
REACT_APP_CATALOG_URL=http://localhost:5004/api/vwb/catalog
REACT_APP_CATALOG_TIMEOUT=5000
REACT_APP_CATALOG_RETRY_ATTEMPTS=3
REACT_APP_ENABLE_STATIC_FALLBACK=true

Configuration Runtime

// Configuration via URL parameters
const urlParams = new URLSearchParams(window.location.search);
const catalogConfig = {
  url: urlParams.get('catalogUrl'),
  timeout: parseInt(urlParams.get('catalogTimeout') || '5000'),
  enableFallback: urlParams.get('enableFallback') !== 'false'
};

Tests et Validation

Tests Unitaires

describe('CatalogService URL Detection', () => {
  it('should try origin URL first', async () => {
    const service = new CatalogService();
    const spy = jest.spyOn(service, 'testUrl');
    
    await service.detectBackendUrl();
    
    expect(spy).toHaveBeenCalledWith(
      `${window.location.origin}/api/vwb/catalog`
    );
  });
  
  it('should fallback to static catalog when all URLs fail', async () => {
    const service = new CatalogService();
    jest.spyOn(service, 'testUrl').mockResolvedValue(false);
    
    const result = await service.detectBackendUrl();
    
    expect(result).toBeNull();
  });
});

Tests d'Intégration Cross-Machine

# tests/integration/test_palette_cross_machine_resolution_10jan2026.py

def test_palette_cross_machine_detection():
    """Test détection automatique d'URL cross-machine"""
    # Démarrer backend sur IP différente
    backend_ip = get_local_ip()
    start_backend_on_ip(backend_ip, 5004)
    
    # Ouvrir frontend sur localhost
    driver = webdriver.Chrome()
    driver.get("http://localhost:3000")
    
    # Vérifier que la palette se charge
    palette = wait_for_element(driver, "[data-testid='palette']")
    actions = palette.find_elements(By.CSS_SELECTOR, "[draggable='true']")
    
    # Doit avoir plus que les actions web par défaut
    assert len(actions) > 4, "Catalogue VisionOnly non chargé"
    
    # Vérifier indicateur de statut
    status = driver.find_element(By.CSS_SELECTOR, "[data-testid='catalog-status']")
    assert "En ligne" in status.text

Métriques et Monitoring

Métriques de Performance

interface CatalogMetrics {
  detectionTime: number;
  successRate: number;
  fallbackRate: number;
  cacheHitRate: number;
  averageResponseTime: number;
}

class CatalogMetricsCollector {
  private metrics: CatalogMetrics = {
    detectionTime: 0,
    successRate: 0,
    fallbackRate: 0,
    cacheHitRate: 0,
    averageResponseTime: 0
  };
  
  recordDetection(startTime: number, success: boolean) {
    const duration = Date.now() - startTime;
    this.metrics.detectionTime = duration;
    // ... mise à jour des métriques
  }
}

Logging Structuré

interface CatalogLogEntry {
  timestamp: string;
  level: 'info' | 'warn' | 'error';
  event: string;
  data: Record<string, any>;
}

class CatalogLogger {
  log(level: string, event: string, data: any = {}) {
    const entry: CatalogLogEntry = {
      timestamp: new Date().toISOString(),
      level: level as any,
      event,
      data
    };
    
    console.log(`[Catalog] ${entry.timestamp} ${level.toUpperCase()}: ${event}`, data);
  }
}

Sécurité et Robustesse

Validation des URLs

function isValidCatalogUrl(url: string): boolean {
  try {
    const parsed = new URL(url);
    
    // Protocoles autorisés
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      return false;
    }
    
    // Ports autorisés (éviter ports système)
    const port = parseInt(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
    if (port < 1024 && ![80, 443].includes(port)) {
      return false;
    }
    
    return true;
  } catch {
    return false;
  }
}

Protection contre les Attaques

class CatalogService {
  private rateLimiter = new Map<string, number>();
  
  private async makeRequest<T>(endpoint: string): Promise<T> {
    // Rate limiting par URL
    const now = Date.now();
    const lastRequest = this.rateLimiter.get(endpoint) || 0;
    
    if (now - lastRequest < 1000) { // Max 1 req/sec par endpoint
      throw new Error('Rate limit exceeded');
    }
    
    this.rateLimiter.set(endpoint, now);
    
    // ... reste de l'implémentation
  }
}

Conclusion

Cette architecture technique fournit une solution robuste et évolutive pour résoudre le problème de palette vide cross-machine. Elle combine détection automatique intelligente, fallback gracieux, et amélioration de l'expérience utilisateur tout en maintenant la compatibilité avec l'architecture existante du VWB.