From 40e5fba86c7f9126c1f25e6cf65ec8d08a678e83 Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 18 Mar 2026 16:05:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20outils=20gestion=20fichiers=20dans=20le?= =?UTF-8?q?=20VWB=20(=F0=9F=93=81=20Fichiers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- agent_v0/agent_v1/ui/capture_server.py | 253 ++++++++++++- .../backend/actions/files/__init__.py | 16 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 598 bytes .../__pycache__/file_actions.cpython-312.pyc | Bin 0 -> 13470 bytes .../backend/actions/files/file_actions.py | 353 ++++++++++++++++++ .../backend/api_v3/dag_execute.py | 43 +++ .../backend/contracts/action_contracts.py | 47 +++ .../src/components/PropertiesPanel.tsx | 134 +++++++ .../frontend_v4/src/components/StepNode.tsx | 29 +- .../frontend_v4/src/types.ts | 32 +- 10 files changed, 898 insertions(+), 9 deletions(-) create mode 100644 visual_workflow_builder/backend/actions/files/__init__.py create mode 100644 visual_workflow_builder/backend/actions/files/__pycache__/__init__.cpython-312.pyc create mode 100644 visual_workflow_builder/backend/actions/files/__pycache__/file_actions.cpython-312.pyc create mode 100644 visual_workflow_builder/backend/actions/files/file_actions.py diff --git a/agent_v0/agent_v1/ui/capture_server.py b/agent_v0/agent_v1/ui/capture_server.py index 8220597e9..08c990346 100644 --- a/agent_v0/agent_v1/ui/capture_server.py +++ b/agent_v0/agent_v1/ui/capture_server.py @@ -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).""" diff --git a/visual_workflow_builder/backend/actions/files/__init__.py b/visual_workflow_builder/backend/actions/files/__init__.py new file mode 100644 index 000000000..ebaa74690 --- /dev/null +++ b/visual_workflow_builder/backend/actions/files/__init__.py @@ -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'] diff --git a/visual_workflow_builder/backend/actions/files/__pycache__/__init__.cpython-312.pyc b/visual_workflow_builder/backend/actions/files/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53c84eee3935833293eecb31798019298f9e45be GIT binary patch literal 598 zcmZWnF-sgl6rS0;pscY-C0NAi!okH!6oQBZ^nzGOs$k%n$K1|S2WNMdncXwj#m*lP z$Zxp65U{eeN$XW1snQvaG*)@ztqGVZhHv=3_kHiZ-K)jLl+n4q_+rjEV?W~K{v-{U zE))37H0v|Xbz*s*^b^i>>xlPTqju*}y-ua+c2*fz1ffwJVTd@wLz5jF^g)!a_QIk# zc)zonrrULe^xhP2T;3Hut7_7I|M*F~+z`1U`Pz%MS0rmZH`C|jWD%?h6=)MH_X%RO z4F5v(vzk&}2o!l=l6h1WHbp&uIi{ljRk<5tZ0B-ZT9wTtP49n=Tsb01&z0mJEgtZ} z`3eRjzz1_ZPEJ{F>gEV$sB-o8NlT6 z-S}#@k2^YY%36Rio5nA7`%WIa9Hn--R7Uzz!O+AU7_Lh~wX$&P{K=tpr!c6E)#&9w zWhYo@*%-^15+r~rOa-u7j&A3-a;IyHuYLSeV#i^LuA3I;{8zHT+rO6??|xf&`ZZlS JoAdL;{Q+`Wyj1`I literal 0 HcmV?d00001 diff --git a/visual_workflow_builder/backend/actions/files/__pycache__/file_actions.cpython-312.pyc b/visual_workflow_builder/backend/actions/files/__pycache__/file_actions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..291ac513bbce4b37fc0c5e5840484f9da81526d9 GIT binary patch literal 13470 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 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, + } diff --git a/visual_workflow_builder/backend/api_v3/dag_execute.py b/visual_workflow_builder/backend/api_v3/dag_execute.py index 0c3d95c96..2ee603381 100644 --- a/visual_workflow_builder/backend/api_v3/dag_execute.py +++ b/visual_workflow_builder/backend/api_v3/dag_execute.py @@ -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'): diff --git a/visual_workflow_builder/backend/contracts/action_contracts.py b/visual_workflow_builder/backend/contracts/action_contracts.py index 9c62d9f0f..f20178997 100644 --- a/visual_workflow_builder/backend/contracts/action_contracts.py +++ b/visual_workflow_builder/backend/contracts/action_contracts.py @@ -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())} + ), } diff --git a/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx b/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx index 677ade892..2711f1a94 100644 --- a/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx +++ b/visual_workflow_builder/frontend_v4/src/components/PropertiesPanel.tsx @@ -1155,6 +1155,140 @@ export default function PropertiesPanel({ step, onUpdateParams, onDelete }: Prop ); + // === GESTION DE FICHIERS === + case 'file_list_dir': + return ( + <> +
+ 📂 Lister un dossier +
+
+ + updateParam('path', e.target.value)} + placeholder="C:\Users\dom\Downloads\anonymise" + /> +
+
+ + updateParam('pattern', e.target.value)} + placeholder="*.pdf, *.*, *.docx" + /> +
+ + ); + + case 'file_create_dir': + return ( + <> +
+ 📁 Créer un dossier +
+
+ + updateParam('path', e.target.value)} + placeholder="C:\Users\dom\Downloads\anonymise\pdf" + /> +
+
+ Les dossiers parents seront créés automatiquement si nécessaire. +
+ + ); + + case 'file_move': + return ( + <> +
+ 📎 Déplacer un fichier +
+
+ + updateParam('source', e.target.value)} + placeholder="C:\Users\dom\Downloads\document.pdf" + /> +
+
+ + updateParam('destination', e.target.value)} + placeholder="C:\Users\dom\Downloads\anonymise\pdf\document.pdf" + /> +
+ + ); + + case 'file_copy': + return ( + <> +
+ 📋 Copier un fichier +
+
+ + updateParam('source', e.target.value)} + placeholder="C:\Users\dom\Downloads\document.pdf" + /> +
+
+ + updateParam('destination', e.target.value)} + placeholder="C:\Users\dom\Archives\document.pdf" + /> +
+ + ); + + case 'file_sort_by_ext': + return ( + <> +
+ 🗂️ Classer par extension +
+
+ + updateParam('source_dir', e.target.value)} + placeholder="C:\Users\dom\Downloads\anonymise" + /> +
+
+ +
+
+ Les fichiers seront déplacés dans des sous-dossiers nommés par extension (pdf/, docx/, jpg/, etc.) +
+ + ); + // === VALIDATION === case 'verify_element_exists': return ( diff --git a/visual_workflow_builder/frontend_v4/src/components/StepNode.tsx b/visual_workflow_builder/frontend_v4/src/components/StepNode.tsx index 0ddc0a9fa..e2f339a2a 100644 --- a/visual_workflow_builder/frontend_v4/src/components/StepNode.tsx +++ b/visual_workflow_builder/frontend_v4/src/components/StepNode.tsx @@ -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 ( -
+
{/* Bouton aide (?) */} {action && (
)} + {/* Aperçu actions fichiers */} + {step.action_type === 'file_list_dir' && typeof step.parameters?.path === 'string' && step.parameters.path.length > 0 && ( +
+ {`📂 ${String(step.parameters.path).split(/[\\/]/).pop() || String(step.parameters.path)}`} + {step.parameters.pattern && step.parameters.pattern !== '*' ? ` (${String(step.parameters.pattern)})` : ''} +
+ )} + {step.action_type === 'file_create_dir' && typeof step.parameters?.path === 'string' && step.parameters.path.length > 0 && ( +
+ {`📁 ${String(step.parameters.path).split(/[\\/]/).pop() || String(step.parameters.path)}`} +
+ )} + {(step.action_type === 'file_move' || step.action_type === 'file_copy') && typeof step.parameters?.source === 'string' && step.parameters.source.length > 0 && ( +
+ {`${String(step.parameters.source).split(/[\\/]/).pop()} → ${String(step.parameters.destination || '?').split(/[\\/]/).pop()}`} +
+ )} + {step.action_type === 'file_sort_by_ext' && typeof step.parameters?.source_dir === 'string' && step.parameters.source_dir.length > 0 && ( +
+ {`🗂️ ${String(step.parameters.source_dir).split(/[\\/]/).pop() || String(step.parameters.source_dir)}`} +
+ )} + {!step.anchor_id && action?.needsAnchor && (
Ancre requise diff --git a/visual_workflow_builder/frontend_v4/src/types.ts b/visual_workflow_builder/frontend_v4/src/types.ts index b56228c32..9df8b8d91 100644 --- a/visual_workflow_builder/frontend_v4/src/types.ts +++ b/visual_workflow_builder/frontend_v4/src/types.ts @@ -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: '✅' }, };