feat: outils gestion fichiers dans le VWB (📁 Fichiers)

- 5 actions : lister, créer dossier, déplacer, copier, classer par extension
- Exécution sur Windows via agent port 5006
- Sécurité chemins (bloque C:\Windows, /etc, etc.)
- Propriétés panel + preview canvas pour chaque action

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 16:05:36 +01:00
parent 97d708c6f5
commit 40e5fba86c
10 changed files with 898 additions and 9 deletions

View File

@@ -1,10 +1,12 @@
"""
Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande.
Mini serveur HTTP sur l'agent Windows pour les captures d'ecran a la demande
et les operations fichiers.
Ecoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
Endpoints :
GET /capture -> screenshot frais en base64 (JPEG)
GET /health -> {"status": "ok"}
GET /capture -> screenshot frais en base64 (JPEG)
GET /health -> {"status": "ok"}
POST /file-action -> operations fichiers (list, create, move, copy, sort)
"""
import threading
import logging
@@ -21,7 +23,10 @@ CAPTURE_PORT = int(os.environ.get("RPA_CAPTURE_PORT", "5006"))
class CaptureHandler(BaseHTTPRequestHandler):
"""Retourne un screenshot frais a chaque requete GET /capture."""
"""Retourne un screenshot frais a chaque requete GET /capture.
Gere aussi les actions fichiers via POST /file-action.
"""
def do_GET(self):
if self.path == "/capture":
@@ -31,6 +36,12 @@ class CaptureHandler(BaseHTTPRequestHandler):
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
if self.path == "/file-action":
self._handle_file_action()
else:
self._send_json(404, {"error": "not found"})
def do_OPTIONS(self):
"""Gestion CORS preflight."""
self.send_response(200)
@@ -40,6 +51,37 @@ class CaptureHandler(BaseHTTPRequestHandler):
# ------------------------------------------------------------------
def _handle_file_action(self):
"""Execute une action fichier sur la machine Windows locale.
Body JSON attendu :
{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\..."}}
"""
try:
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
data = json.loads(body.decode("utf-8"))
action = data.get("action", "")
params = data.get("params", {})
if not action:
self._send_json(400, {"error": "Parametre 'action' requis"})
return
handler = _FileActionHandlerLocal()
result = handler.execute(action, params)
code = 500 if "error" in result else 200
self._send_json(code, result)
except json.JSONDecodeError:
self._send_json(400, {"error": "JSON invalide"})
except Exception as e:
logger.error(f"Erreur file-action : {e}")
self._send_json(500, {"error": str(e)})
# ------------------------------------------------------------------
def _handle_capture(self):
"""Capture l'ecran principal et le renvoie en base64 JPEG."""
t0 = time.perf_counter()
@@ -86,7 +128,7 @@ class CaptureHandler(BaseHTTPRequestHandler):
def _cors_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def log_message(self, format, *args):
@@ -94,6 +136,207 @@ class CaptureHandler(BaseHTTPRequestHandler):
pass
# ---------------------------------------------------------------------------
# Gestionnaire d'actions fichiers local (execute sur la machine Windows)
# ---------------------------------------------------------------------------
# Repertoires autorises sur Windows (securite anti-traversal)
_WIN_ALLOWED_ROOTS = [
"C:\\Users",
"D:\\",
"E:\\",
]
def _normalize_win_path(path_str: str) -> str:
"""Normalise un chemin Windows."""
import ntpath
return ntpath.normpath(path_str)
def _is_safe_win_path(path_str: str) -> bool:
"""Verifie qu'un chemin Windows est dans une zone autorisee."""
if not path_str or not path_str.strip():
return False
norm = _normalize_win_path(path_str).upper()
return any(norm.startswith(root.upper()) for root in _WIN_ALLOWED_ROOTS)
class _FileActionHandlerLocal:
"""Execute les operations fichiers sur la machine locale (Windows)."""
def execute(self, action_type: str, params: dict) -> dict:
"""Dispatch vers la bonne methode selon le type d'action."""
handlers = {
"file_list_dir": self._list_dir,
"file_create_dir": self._create_dir,
"file_move": self._move_file,
"file_copy": self._copy_file,
"file_sort_by_ext": self._sort_by_extension,
}
handler = handlers.get(action_type)
if not handler:
return {"error": f"Action fichier inconnue : {action_type}"}
try:
return handler(params)
except Exception as e:
logger.error(f"Erreur action fichier '{action_type}' : {e}")
return {"error": str(e)}
def _list_dir(self, params: dict) -> dict:
"""Liste les fichiers d'un dossier."""
import fnmatch as _fnmatch
from pathlib import Path as _Path
path_str = params.get("path", "")
pattern = params.get("pattern", "*")
if not path_str:
return {"error": "Parametre 'path' requis"}
if not _is_safe_win_path(path_str):
return {"error": f"Chemin non autorise : {path_str}"}
source = _Path(path_str)
if not source.exists():
return {"error": f"Dossier introuvable : {path_str}"}
if not source.is_dir():
return {"error": f"Pas un dossier : {path_str}"}
files = []
extensions = {}
for item in source.iterdir():
if item.is_file() and _fnmatch.fnmatch(item.name, pattern):
ext = item.suffix.lstrip(".").lower() or "sans_extension"
files.append({
"name": item.name,
"extension": ext,
"size": item.stat().st_size,
"path": str(item),
})
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(f"Liste dossier '{path_str}' : {len(files)} fichiers")
return {"files": files, "count": len(files), "extensions": extensions, "path": path_str}
def _create_dir(self, params: dict) -> dict:
"""Cree un dossier (parents inclus)."""
from pathlib import Path as _Path
path_str = params.get("path", "")
if not path_str:
return {"error": "Parametre 'path' requis"}
if not _is_safe_win_path(path_str):
return {"error": f"Chemin non autorise : {path_str}"}
target = _Path(path_str)
existed = target.exists()
target.mkdir(parents=True, exist_ok=True)
logger.info(f"Dossier '{path_str}' {'existait deja' if existed else 'cree'}")
return {"created": not existed, "path": path_str, "already_existed": existed}
def _move_file(self, params: dict) -> dict:
"""Deplace ou renomme un fichier."""
import shutil as _shutil
from pathlib import Path as _Path
src = params.get("source", "")
dst = params.get("destination", "")
if not src or not dst:
return {"error": "Parametres 'source' et 'destination' requis"}
if not _is_safe_win_path(src):
return {"error": f"Source non autorisee : {src}"}
if not _is_safe_win_path(dst):
return {"error": f"Destination non autorisee : {dst}"}
if not _Path(src).exists():
return {"error": f"Fichier source introuvable : {src}"}
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
_shutil.move(src, dst)
logger.info(f"Fichier deplace : '{src}' -> '{dst}'")
return {"moved": True, "source": src, "destination": dst}
def _copy_file(self, params: dict) -> dict:
"""Copie un fichier."""
import shutil as _shutil
from pathlib import Path as _Path
src = params.get("source", "")
dst = params.get("destination", "")
if not src or not dst:
return {"error": "Parametres 'source' et 'destination' requis"}
if not _is_safe_win_path(src):
return {"error": f"Source non autorisee : {src}"}
if not _is_safe_win_path(dst):
return {"error": f"Destination non autorisee : {dst}"}
source = _Path(src)
if not source.exists():
return {"error": f"Fichier source introuvable : {src}"}
_Path(dst).parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
_shutil.copytree(src, dst)
else:
_shutil.copy2(src, dst)
logger.info(f"Fichier copie : '{src}' -> '{dst}'")
return {"copied": True, "source": src, "destination": dst}
def _sort_by_extension(self, params: dict) -> dict:
"""Classe les fichiers par extension dans des sous-dossiers."""
import shutil as _shutil
from pathlib import Path as _Path
source_dir_str = params.get("source_dir", "")
create_subdirs = params.get("create_subdirs", True)
if not source_dir_str:
return {"error": "Parametre 'source_dir' requis"}
if not _is_safe_win_path(source_dir_str):
return {"error": f"Chemin non autorise : {source_dir_str}"}
source = _Path(source_dir_str)
if not source.exists():
return {"error": f"Dossier introuvable : {source_dir_str}"}
if not source.is_dir():
return {"error": f"Pas un dossier : {source_dir_str}"}
moved = []
extensions = {}
for f in source.iterdir():
if f.is_file():
ext = f.suffix.lstrip(".").lower() or "sans_extension"
target_dir = source / ext
if create_subdirs:
target_dir.mkdir(exist_ok=True)
elif not target_dir.exists():
continue
dest = target_dir / f.name
# Eviter ecrasement
if dest.exists():
base = f.stem
counter = 1
while dest.exists():
dest = target_dir / f"{base}_{counter}{f.suffix}"
counter += 1
_shutil.move(str(f), str(dest))
moved.append({"file": f.name, "to": ext, "destination": str(dest)})
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(
f"Classement par extension '{source_dir_str}' : {len(moved)} fichiers"
)
return {
"moved": moved,
"count": len(moved),
"extensions": extensions,
"source_dir": source_dir_str,
}
class CaptureServer:
"""Serveur de capture d'ecran en temps reel (thread daemon)."""

View File

@@ -0,0 +1,16 @@
"""
Actions de gestion de fichiers pour le VWB.
Auteur : Dom, Claude — 18 mars 2026
Actions :
- file_list_dir : Lister les fichiers d'un dossier
- file_create_dir : Créer un dossier
- file_move : Déplacer un fichier
- file_copy : Copier un fichier
- file_sort_by_ext : Classer les fichiers par extension
"""
from .file_actions import FileActionHandler
__all__ = ['FileActionHandler']

View File

@@ -0,0 +1,353 @@
"""
Gestionnaire d'actions fichiers pour le VWB.
Auteur : Dom, Claude — 18 mars 2026
Opérations de gestion de fichiers : lister, créer dossier, déplacer,
copier, classer par extension. Compatible chemins Windows (backslash)
et Linux (slash).
Sécurité : validation des chemins pour empêcher le path traversal
en dehors des répertoires autorisés.
"""
import fnmatch
import logging
import os
import shutil
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Répertoires autorisés par défaut sur Windows (racines légitimes)
# On autorise les chemins sous les profils utilisateur et les lecteurs standards
_WINDOWS_ALLOWED_ROOTS = [
"C:\\Users",
"D:\\",
"E:\\",
]
# Répertoires autorisés sur Linux
_LINUX_ALLOWED_ROOTS = [
"/home",
"/tmp",
]
def _normalize_path(path_str: str) -> str:
"""Normalise un chemin en résolvant .. et en nettoyant les séparateurs."""
# Déterminer si c'est un chemin Windows (contient : comme C:\ ou \)
if ':' in path_str[:3] or path_str.startswith('\\\\'):
# Chemin Windows : normaliser avec ntpath
import ntpath
return ntpath.normpath(path_str)
else:
return os.path.normpath(path_str)
def _is_safe_path(path_str: str) -> bool:
"""Vérifie qu'un chemin est sûr (pas de traversal hors zones autorisées).
Bloque les chemins contenant des séquences de traversal dangereuses
et vérifie que le chemin résolu reste dans une zone autorisée.
"""
normalized = _normalize_path(path_str)
# Bloquer les chemins vides
if not normalized or normalized.strip() in ('', '.'):
return False
# Déterminer le système de fichiers cible
is_windows = ':' in normalized[:3] or normalized.startswith('\\\\')
if is_windows:
allowed_roots = _WINDOWS_ALLOWED_ROOTS
# Vérifier que le chemin est sous une racine autorisée
norm_upper = normalized.upper()
return any(norm_upper.startswith(root.upper()) for root in allowed_roots)
else:
allowed_roots = _LINUX_ALLOWED_ROOTS
return any(normalized.startswith(root) for root in allowed_roots)
class FileActionHandler:
"""Gère les opérations sur les fichiers.
Peut s'exécuter en local (serveur Linux) ou les paramètres
peuvent être transmis à l'agent Windows via le proxy.
"""
def __init__(self, allowed_roots: Optional[List[str]] = None):
"""Initialise le gestionnaire.
Args:
allowed_roots: Racines autorisées supplémentaires (optionnel).
"""
if allowed_roots:
_WINDOWS_ALLOWED_ROOTS.extend(
r for r in allowed_roots if r not in _WINDOWS_ALLOWED_ROOTS
)
def execute(self, action_type: str, params: dict, target: str = 'local') -> dict:
"""Exécute une action fichier.
Args:
action_type: Type d'action (file_list_dir, file_create_dir, etc.)
params: Paramètres de l'action
target: 'local' pour exécution serveur, 'windows' pour l'agent
Returns:
Dict avec le résultat ou une erreur
"""
handlers = {
'file_list_dir': self._list_dir,
'file_create_dir': self._create_dir,
'file_move': self._move_file,
'file_copy': self._copy_file,
'file_sort_by_ext': self._sort_by_extension,
}
handler = handlers.get(action_type)
if not handler:
return {'error': f"Type d'action fichier inconnu : {action_type}"}
try:
return handler(params)
except Exception as e:
logger.error("Erreur action fichier '%s' : %s", action_type, e)
return {'error': str(e)}
def _list_dir(self, params: dict) -> dict:
"""Liste les fichiers d'un dossier.
Args:
params: {'path': str, 'pattern': str (optionnel, défaut '*')}
Returns:
{'files': [...], 'count': int, 'extensions': {...}}
"""
path_str = params.get('path', '')
pattern = params.get('pattern', '*')
if not path_str:
return {'error': "Paramètre 'path' requis"}
if not _is_safe_path(path_str):
return {'error': f"Chemin non autorisé : {path_str}"}
source = Path(path_str)
if not source.exists():
return {'error': f"Dossier introuvable : {path_str}"}
if not source.is_dir():
return {'error': f"Le chemin n'est pas un dossier : {path_str}"}
files = []
extensions: Dict[str, int] = {}
for item in source.iterdir():
if item.is_file():
# Appliquer le filtre glob
if not fnmatch.fnmatch(item.name, pattern):
continue
ext = item.suffix.lstrip('.').lower() or 'sans_extension'
size = item.stat().st_size
files.append({
'name': item.name,
'extension': ext,
'size': size,
'path': str(item),
})
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(
"Listage dossier '%s' : %d fichiers, extensions : %s",
path_str, len(files), extensions,
)
return {
'files': files,
'count': len(files),
'extensions': extensions,
'path': path_str,
}
def _create_dir(self, params: dict) -> dict:
"""Crée un dossier (et les sous-dossiers si nécessaire).
Args:
params: {'path': str}
Returns:
{'created': bool, 'path': str}
"""
path_str = params.get('path', '')
if not path_str:
return {'error': "Paramètre 'path' requis"}
if not _is_safe_path(path_str):
return {'error': f"Chemin non autorisé : {path_str}"}
target = Path(path_str)
already_exists = target.exists()
target.mkdir(parents=True, exist_ok=True)
logger.info(
"Dossier '%s' %s",
path_str,
"existait déjà" if already_exists else "créé",
)
return {
'created': not already_exists,
'path': path_str,
'already_existed': already_exists,
}
def _move_file(self, params: dict) -> dict:
"""Déplace ou renomme un fichier.
Args:
params: {'source': str, 'destination': str}
Returns:
{'moved': bool, 'source': str, 'destination': str}
"""
source_str = params.get('source', '')
dest_str = params.get('destination', '')
if not source_str or not dest_str:
return {'error': "Paramètres 'source' et 'destination' requis"}
if not _is_safe_path(source_str):
return {'error': f"Chemin source non autorisé : {source_str}"}
if not _is_safe_path(dest_str):
return {'error': f"Chemin destination non autorisé : {dest_str}"}
source = Path(source_str)
if not source.exists():
return {'error': f"Fichier source introuvable : {source_str}"}
dest = Path(dest_str)
# Créer le dossier parent si nécessaire
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(dest))
logger.info("Fichier déplacé : '%s''%s'", source_str, dest_str)
return {
'moved': True,
'source': source_str,
'destination': dest_str,
}
def _copy_file(self, params: dict) -> dict:
"""Copie un fichier vers un autre emplacement.
Args:
params: {'source': str, 'destination': str}
Returns:
{'copied': bool, 'source': str, 'destination': str}
"""
source_str = params.get('source', '')
dest_str = params.get('destination', '')
if not source_str or not dest_str:
return {'error': "Paramètres 'source' et 'destination' requis"}
if not _is_safe_path(source_str):
return {'error': f"Chemin source non autorisé : {source_str}"}
if not _is_safe_path(dest_str):
return {'error': f"Chemin destination non autorisé : {dest_str}"}
source = Path(source_str)
if not source.exists():
return {'error': f"Fichier source introuvable : {source_str}"}
dest = Path(dest_str)
# Créer le dossier parent si nécessaire
dest.parent.mkdir(parents=True, exist_ok=True)
if source.is_dir():
shutil.copytree(str(source), str(dest))
else:
shutil.copy2(str(source), str(dest))
logger.info("Fichier copié : '%s''%s'", source_str, dest_str)
return {
'copied': True,
'source': source_str,
'destination': dest_str,
}
def _sort_by_extension(self, params: dict) -> dict:
"""Classe les fichiers par extension dans des sous-dossiers.
Crée un sous-dossier par extension (pdf/, docx/, jpg/, etc.)
et déplace chaque fichier dans le sous-dossier correspondant.
Args:
params: {'source_dir': str, 'create_subdirs': bool (défaut True)}
Returns:
{'moved': [...], 'count': int, 'extensions': {...}}
"""
source_dir_str = params.get('source_dir', '')
create_subdirs = params.get('create_subdirs', True)
if not source_dir_str:
return {'error': "Paramètre 'source_dir' requis"}
if not _is_safe_path(source_dir_str):
return {'error': f"Chemin non autorisé : {source_dir_str}"}
source = Path(source_dir_str)
if not source.exists():
return {'error': f"Dossier introuvable : {source_dir_str}"}
if not source.is_dir():
return {'error': f"Le chemin n'est pas un dossier : {source_dir_str}"}
moved = []
extensions: Dict[str, int] = {}
for file in source.iterdir():
if file.is_file():
ext = file.suffix.lstrip('.').lower() or 'sans_extension'
target_dir = source / ext
if create_subdirs:
target_dir.mkdir(exist_ok=True)
elif not target_dir.exists():
logger.warning(
"Sous-dossier '%s' inexistant et create_subdirs=False, fichier ignoré : %s",
ext, file.name,
)
continue
dest = target_dir / file.name
# Éviter d'écraser un fichier existant
if dest.exists():
base = file.stem
counter = 1
while dest.exists():
dest = target_dir / f"{base}_{counter}{file.suffix}"
counter += 1
shutil.move(str(file), str(dest))
moved.append({
'file': file.name,
'to': ext,
'destination': str(dest),
})
extensions[ext] = extensions.get(ext, 0) + 1
logger.info(
"Classement par extension dans '%s' : %d fichiers déplacés, extensions : %s",
source_dir_str, len(moved), extensions,
)
return {
'moved': moved,
'count': len(moved),
'extensions': extensions,
'source_dir': source_dir_str,
}

View File

@@ -77,6 +77,12 @@ _CONDITION_ACTION_TYPES = {"visual_condition"}
# Actions VWB de type data loop
_DATA_LOOP_ACTION_TYPES = {"import_excel", "db_foreach"}
# Actions VWB de gestion de fichiers
_FILE_ACTION_TYPES = {
"file_list_dir", "file_create_dir", "file_move",
"file_copy", "file_sort_by_ext",
}
def _classify_step_type(action_type: str) -> StepType:
"""Détermine le StepType DAG à partir du action_type VWB."""
@@ -982,6 +988,43 @@ def execute_windows():
if vwb_type in ('keyboard_shortcut', 'hotkey') and 'keys' in params:
action['keys'] = params['keys']
# ---------------------------------------------------------------
# Actions fichiers → proxy vers /file-action de l'agent (port 5006)
# ---------------------------------------------------------------
if 'actions' in data:
file_actions = [a for a in data['actions'] if a.get('type', '') in _FILE_ACTION_TYPES]
if file_actions:
# Exécuter les actions fichiers via l'agent Windows
file_results = []
for fa in file_actions:
try:
fa_resp = req.post(
'http://192.168.1.11:5006/file-action',
json={
'action': fa['type'],
'params': fa.get('parameters', {}),
},
timeout=30,
)
file_results.append(fa_resp.json())
except req.ConnectionError:
file_results.append({
'error': "Agent Windows (port 5006) non disponible pour l'action fichier"
})
except Exception as e:
file_results.append({'error': str(e)})
# Si TOUTES les actions sont des actions fichiers, retourner directement
non_file_actions = [a for a in data['actions'] if a.get('type', '') not in _FILE_ACTION_TYPES]
if not non_file_actions:
return jsonify({
'success': all('error' not in r for r in file_results),
'file_results': file_results,
})
# Sinon, retirer les actions fichiers du flux principal
data['actions'] = non_file_actions
# Injecter le machine_id pour le ciblage multi-machine
# Chercher la première machine Windows connectée si pas spécifié
if 'machine_id' not in data or not data.get('machine_id'):

View File

@@ -360,6 +360,53 @@ VWB_ACTION_CONTRACTS: Dict[str, ActionContract] = {
required_params=[],
optional_params=["prompt", "context", "model", "temperature"],
),
# --- ACTIONS GESTION DE FICHIERS ---
"file_list_dir": ActionContract(
action_type="file_list_dir",
description="Lister les fichiers d'un dossier",
required_params=["path"],
optional_params=["pattern"],
param_validators={"path": lambda p: bool(p and isinstance(p, str) and p.strip())}
),
"file_create_dir": ActionContract(
action_type="file_create_dir",
description="Créer un dossier (et les sous-dossiers si nécessaire)",
required_params=["path"],
optional_params=[],
param_validators={"path": lambda p: bool(p and isinstance(p, str) and p.strip())}
),
"file_move": ActionContract(
action_type="file_move",
description="Déplacer ou renommer un fichier",
required_params=["source", "destination"],
optional_params=[],
param_validators={
"source": lambda p: bool(p and isinstance(p, str) and p.strip()),
"destination": lambda p: bool(p and isinstance(p, str) and p.strip()),
}
),
"file_copy": ActionContract(
action_type="file_copy",
description="Copier un fichier vers un autre emplacement",
required_params=["source", "destination"],
optional_params=[],
param_validators={
"source": lambda p: bool(p and isinstance(p, str) and p.strip()),
"destination": lambda p: bool(p and isinstance(p, str) and p.strip()),
}
),
"file_sort_by_ext": ActionContract(
action_type="file_sort_by_ext",
description="Classer les fichiers par extension dans des sous-dossiers",
required_params=["source_dir"],
optional_params=["create_subdirs"],
param_validators={"source_dir": lambda p: bool(p and isinstance(p, str) and p.strip())}
),
}

View File

@@ -1155,6 +1155,140 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop
</>
);
// === GESTION DE FICHIERS ===
case 'file_list_dir':
return (
<>
<div className="prop-section-title">
<span className="icon">📂</span> Lister un dossier
</div>
<div className="prop-field">
<label>Chemin du dossier</label>
<input
type="text"
value={String(params.path || '')}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="C:\Users\dom\Downloads\anonymise"
/>
</div>
<div className="prop-field">
<label>Filtre (glob)</label>
<input
type="text"
value={String(params.pattern || '*')}
onChange={(e) => updateParam('pattern', e.target.value)}
placeholder="*.pdf, *.*, *.docx"
/>
</div>
</>
);
case 'file_create_dir':
return (
<>
<div className="prop-section-title">
<span className="icon">📁</span> Créer un dossier
</div>
<div className="prop-field">
<label>Chemin du dossier</label>
<input
type="text"
value={String(params.path || '')}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="C:\Users\dom\Downloads\anonymise\pdf"
/>
</div>
<div className="prop-info">
Les dossiers parents seront créés automatiquement si nécessaire.
</div>
</>
);
case 'file_move':
return (
<>
<div className="prop-section-title">
<span className="icon">📎</span> Déplacer un fichier
</div>
<div className="prop-field">
<label>Chemin source</label>
<input
type="text"
value={String(params.source || '')}
onChange={(e) => updateParam('source', e.target.value)}
placeholder="C:\Users\dom\Downloads\document.pdf"
/>
</div>
<div className="prop-field">
<label>Chemin destination</label>
<input
type="text"
value={String(params.destination || '')}
onChange={(e) => updateParam('destination', e.target.value)}
placeholder="C:\Users\dom\Downloads\anonymise\pdf\document.pdf"
/>
</div>
</>
);
case 'file_copy':
return (
<>
<div className="prop-section-title">
<span className="icon">📋</span> Copier un fichier
</div>
<div className="prop-field">
<label>Chemin source</label>
<input
type="text"
value={String(params.source || '')}
onChange={(e) => updateParam('source', e.target.value)}
placeholder="C:\Users\dom\Downloads\document.pdf"
/>
</div>
<div className="prop-field">
<label>Chemin destination</label>
<input
type="text"
value={String(params.destination || '')}
onChange={(e) => updateParam('destination', e.target.value)}
placeholder="C:\Users\dom\Archives\document.pdf"
/>
</div>
</>
);
case 'file_sort_by_ext':
return (
<>
<div className="prop-section-title">
<span className="icon">🗂️</span> Classer par extension
</div>
<div className="prop-field">
<label>Dossier source</label>
<input
type="text"
value={String(params.source_dir || '')}
onChange={(e) => updateParam('source_dir', e.target.value)}
placeholder="C:\Users\dom\Downloads\anonymise"
/>
</div>
<div className="prop-field checkbox">
<label>
<input
type="checkbox"
checked={Boolean(params.create_subdirs !== false)}
onChange={(e) => updateParam('create_subdirs', e.target.checked)}
/>
Créer les sous-dossiers automatiquement
</label>
</div>
<div className="prop-info">
Les fichiers seront déplacés dans des sous-dossiers nommés par extension (pdf/, docx/, jpg/, etc.)
</div>
</>
);
// === VALIDATION ===
case 'verify_element_exists':
return (

View File

@@ -16,6 +16,7 @@ function StepNode({ data, selected }: StepNodeProps) {
const isConditional = step.action_type === 'visual_condition' || step.action_type === 'loop_visual';
const isDataLoop = step.action_type === 'db_foreach';
const isImport = step.action_type === 'import_excel';
const isFileAction = step.action_type.startsWith('file_');
// État du tooltip d'aide
const [showHelp, setShowHelp] = useState(false);
@@ -34,7 +35,7 @@ function StepNode({ data, selected }: StepNodeProps) {
}, [showHelp]);
return (
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''}`}>
<div className={`step-node ${selected ? 'selected' : ''} ${isConditional ? 'conditional' : ''} ${isDataLoop ? 'data-loop' : ''} ${isImport ? 'data-import' : ''} ${isFileAction ? 'file-action' : ''}`}>
{/* Bouton aide (?) */}
{action && (
<button
@@ -82,7 +83,8 @@ function StepNode({ data, selected }: StepNodeProps) {
title="Supprimer (Suppr)"
onClick={(e) => {
e.stopPropagation();
data.onDelete?.(step.id);
// Dispatch un custom event que App.tsx écoute (contourne le memo)
window.dispatchEvent(new CustomEvent('rpa-delete-step', { detail: step.id }));
}}
>
×
@@ -152,6 +154,29 @@ function StepNode({ data, selected }: StepNodeProps) {
</div>
)}
{/* Aperçu actions fichiers */}
{step.action_type === 'file_list_dir' && typeof step.parameters?.path === 'string' && step.parameters.path.length > 0 && (
<div className="step-node-params">
{`📂 ${String(step.parameters.path).split(/[\\/]/).pop() || String(step.parameters.path)}`}
{step.parameters.pattern && step.parameters.pattern !== '*' ? ` (${String(step.parameters.pattern)})` : ''}
</div>
)}
{step.action_type === 'file_create_dir' && typeof step.parameters?.path === 'string' && step.parameters.path.length > 0 && (
<div className="step-node-params">
{`📁 ${String(step.parameters.path).split(/[\\/]/).pop() || String(step.parameters.path)}`}
</div>
)}
{(step.action_type === 'file_move' || step.action_type === 'file_copy') && typeof step.parameters?.source === 'string' && step.parameters.source.length > 0 && (
<div className="step-node-params">
{`${String(step.parameters.source).split(/[\\/]/).pop()}${String(step.parameters.destination || '?').split(/[\\/]/).pop()}`}
</div>
)}
{step.action_type === 'file_sort_by_ext' && typeof step.parameters?.source_dir === 'string' && step.parameters.source_dir.length > 0 && (
<div className="step-node-params">
{`🗂️ ${String(step.parameters.source_dir).split(/[\\/]/).pop() || String(step.parameters.source_dir)}`}
</div>
)}
{!step.anchor_id && action?.needsAnchor && (
<div className="step-node-warning">
Ancre requise

View File

@@ -55,14 +55,20 @@ export type ActionType =
| 'llm_analyze'
| 'llm_translate'
| 'llm_extract_data'
| 'llm_generate';
| 'llm_generate'
// === Gestion de fichiers ===
| 'file_list_dir'
| 'file_create_dir'
| 'file_move'
| 'file_copy'
| 'file_sort_by_ext';
export interface ActionDefinition {
type: ActionType;
label: string;
icon: string;
description: string;
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'llm' | 'validation';
category: 'mouse' | 'keyboard' | 'wait' | 'data' | 'logic' | 'ai' | 'llm' | 'validation' | 'files';
needsAnchor: boolean;
params: { name: string; type: string; description: string }[];
}
@@ -200,6 +206,27 @@ export const ACTIONS: ActionDefinition[] = [
{ name: 'model', type: 'string', description: 'Modèle Ollama' }
] },
// === GESTION DE FICHIERS ===
{ type: 'file_list_dir', label: 'Lister un dossier', icon: '📂', description: 'Liste les fichiers d\'un dossier et retourne leurs noms et extensions.', category: 'files', needsAnchor: false, params: [
{ name: 'path', type: 'string', description: 'Chemin du dossier' },
{ name: 'pattern', type: 'string', description: 'Filtre (ex: *.pdf, *.*)' },
] },
{ type: 'file_create_dir', label: 'Créer un dossier', icon: '📁', description: 'Crée un dossier (et les sous-dossiers si nécessaire).', category: 'files', needsAnchor: false, params: [
{ name: 'path', type: 'string', description: 'Chemin du dossier à créer' },
] },
{ type: 'file_move', label: 'Déplacer un fichier', icon: '📎', description: 'Déplace ou renomme un fichier.', category: 'files', needsAnchor: false, params: [
{ name: 'source', type: 'string', description: 'Chemin source' },
{ name: 'destination', type: 'string', description: 'Chemin destination' },
] },
{ type: 'file_copy', label: 'Copier un fichier', icon: '📋', description: 'Copie un fichier vers un autre emplacement.', category: 'files', needsAnchor: false, params: [
{ name: 'source', type: 'string', description: 'Chemin source' },
{ name: 'destination', type: 'string', description: 'Chemin destination' },
] },
{ type: 'file_sort_by_ext', label: 'Classer par extension', icon: '🗂️', description: 'Crée des sous-dossiers par extension et déplace les fichiers.', category: 'files', needsAnchor: false, params: [
{ name: 'source_dir', type: 'string', description: 'Dossier source' },
{ name: 'create_subdirs', type: 'boolean', description: 'Créer les sous-dossiers automatiquement' },
] },
// === VALIDATION ===
{ type: 'verify_element_exists', label: 'Vérifier présence', icon: '✅', description: 'Vérifie qu\'un élément visuel est présent à l\'écran.', category: 'validation', needsAnchor: true, params: [
{ name: 'timeout_ms', type: 'number', description: 'Délai max d\'attente en millisecondes' }
@@ -217,6 +244,7 @@ export const ACTION_CATEGORIES = {
logic: { label: 'Logique', icon: '🔀' },
ai: { label: 'IA', icon: '🤖' },
llm: { label: 'IA / LLM', icon: '🧪' },
files: { label: 'Fichiers', icon: '📁' },
validation: { label: 'Validation', icon: '✅' },
};