diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index d1df7b1d..ec15d30f 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -30,9 +30,6 @@ AI_NODES = [ {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", "description": t("Daten aus vorherigen Schritten"), "default": "", "graphInherit": {"port": 0, "kind": "primaryTextRef"}}, - {"name": "documentTheme", "type": "str", "required": False, "frontendType": "select", - "frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]}, - "description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"}, {"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Einfacher Modus"), "default": True}, ] + _AI_COMMON_PARAMS, @@ -80,9 +77,15 @@ AI_NODES = [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", "description": t("Dokumente aus vorherigen Schritten"), "graphInherit": {"port": 0, "kind": "documentListWire"}}, + {"name": "resultType", "type": "str", "required": False, "frontendType": "select", + "frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]}, + "description": t("Ausgabeformat"), "default": "txt"}, {"name": "summaryLength", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["brief", "medium", "detailed"]}, "description": t("Kurz, mittel oder ausführlich"), "default": "medium"}, + {"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder", + "description": t("Zielordner in Meine Dateien"), + "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, @@ -101,8 +104,14 @@ AI_NODES = [ {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", "description": t("Dokumente aus vorherigen Schritten"), "graphInherit": {"port": 0, "kind": "documentListWire"}}, + {"name": "resultType", "type": "str", "required": False, "frontendType": "select", + "frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]}, + "description": t("Ausgabeformat"), "default": "txt"}, {"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text", "description": t("Zielsprache (z.B. de, en, French)")}, + {"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder", + "description": t("Zielordner in Meine Dateien"), + "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, @@ -124,6 +133,9 @@ AI_NODES = [ {"name": "targetFormat", "type": "str", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]}, "description": t("Zielformat")}, + {"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder", + "description": t("Zielordner in Meine Dateien"), + "default": ""}, ] + _AI_COMMON_PARAMS, "inputs": 1, "outputs": 1, @@ -149,6 +161,9 @@ AI_NODES = [ {"name": "documentType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]}, "description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"}, + {"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder", + "description": t("Zielordner in Meine Dateien"), + "default": ""}, {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", "description": t("Daten aus vorherigen Schritten"), "default": "", "graphInherit": {"port": 0, "kind": "primaryTextRef"}}, @@ -177,6 +192,9 @@ AI_NODES = [ {"name": "resultType", "type": "str", "required": False, "frontendType": "select", "frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]}, "description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"}, + {"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder", + "description": t("Zielordner in Meine Dateien"), + "default": ""}, {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", "description": t("Daten aus vorherigen Schritten"), "default": "", "graphInherit": {"port": 0, "kind": "primaryTextRef"}}, diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 9cc8d5f4..6526fc9c 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -15,6 +15,9 @@ FILE_NODES = [ "description": t("Ausgabeformat"), "default": "docx"}, {"name": "title", "type": "str", "required": False, "frontendType": "text", "description": t("Dokumenttitel")}, + {"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder", + "description": t("Zielordner in Meine Dateien"), + "default": ""}, {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", "description": t("Daten aus vorherigen Schritten"), "default": "", "graphInherit": {"port": 0, "kind": "primaryTextRef"}}, diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index e212d502..6a22f9af 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -1345,16 +1345,34 @@ class ComponentObjects: return newfileName counter += 1 - def createFile(self, name: str, mimeType: str, content: bytes) -> FileItem: + def createFile( + self, + name: str, + mimeType: str, + content: bytes, + folderId: Optional[str] = None, + ) -> FileItem: """Creates a new file entry if user has permission. Computes fileHash and fileSize from content. Duplicate check: if a file with the same user + fileHash + fileName already exists, the existing file is returned instead of creating a new one. Same hash with different name is allowed (intentional copy by user). + + When ``folderId`` is set, the folder must exist and the user must be allowed to modify it. """ if not self.checkRbacPermission(FileItem, "create"): raise PermissionError("No permission to create files") + resolved_folder_id: Optional[str] = None + if folderId is not None: + raw = str(folderId).strip() + if raw: + folder = self.getFolder(raw) + if not folder: + raise FileNotFoundError(f"Folder {raw} not found") + self._requireFolderWriteAccess(folder, raw, "update") + resolved_folder_id = raw + # Compute file size and hash fileSize = len(content) fileHash = hashlib.sha256(content).hexdigest() @@ -1386,6 +1404,7 @@ class ComponentObjects: mimeType=mimeType, fileSize=fileSize, fileHash=fileHash, + folderId=resolved_folder_id, ) # Ensure audit user is always stored: workflow/singleton contexts sometimes leave # the connector without _current_user_id, so _saveRecord skips sysCreatedBy → diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py index 9d73ee03..29db7ba6 100644 --- a/modules/shared/frontendTypes.py +++ b/modules/shared/frontendTypes.py @@ -88,6 +88,9 @@ class FrontendType(str, Enum): FILTER_EXPRESSION = "filterExpression" """Filter expression builder for data.filter""" + USER_FILE_FOLDER = "userFileFolder" + """User file storage folder (graph editor): browse My Files tree or create folders.""" + # Mapping of custom types to their API endpoint for dynamic options CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = { diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index d11c31f3..dd98dd6b 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -393,6 +393,13 @@ class ActionNodeExecutor: return _normalizeError(e, outputSchema) # 9. Persist generated documents as files and build JSON-safe output + _raw_folder_id = resolvedParams.get("folderId") + persist_folder_id: Optional[str] = None + if _raw_folder_id is not None: + _s = str(_raw_folder_id).strip() + if _s: + persist_folder_id = _s + docsList = [] for d in (result.documents or []): dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d @@ -432,7 +439,7 @@ class ActionNodeExecutor: _mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId) _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _mimeType = dumped.get("mimeType") or "application/octet-stream" - _fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes) + _fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id) _mgmt.createFileData(_fileItem.id, rawBytes) dumped["fileId"] = _fileItem.id dumped["id"] = _fileItem.id diff --git a/modules/workflows/methods/methodAi/methodAi.py b/modules/workflows/methods/methodAi/methodAi.py index 8afd6001..9fb50eaa 100644 --- a/modules/workflows/methods/methodAi/methodAi.py +++ b/modules/workflows/methods/methodAi/methodAi.py @@ -194,7 +194,14 @@ class MethodAi(MethodBase): required=False, default="txt", description="Output file extension" - ) + ), + "folderId": WorkflowActionParameter( + name="folderId", + type="str", + frontendType=FrontendType.USER_FILE_FOLDER, + required=False, + description="Target folder in My Files when persisting workflow output", + ), }, execute=summarizeDocument.__get__(self, self.__class__) ), @@ -239,7 +246,14 @@ class MethodAi(MethodBase): frontendType=FrontendType.TEXT, required=False, description="Output file extension. If not specified, uses same format as input" - ) + ), + "folderId": WorkflowActionParameter( + name="folderId", + type="str", + frontendType=FrontendType.USER_FILE_FOLDER, + required=False, + description="Target folder in My Files when persisting workflow output", + ), }, execute=translateDocument.__get__(self, self.__class__) ), @@ -271,7 +285,14 @@ class MethodAi(MethodBase): required=False, default=True, description="Whether to preserve document structure (headings, tables, etc.)" - ) + ), + "folderId": WorkflowActionParameter( + name="folderId", + type="str", + frontendType=FrontendType.USER_FILE_FOLDER, + required=False, + description="Target folder in My Files when persisting workflow output", + ), }, execute=convertDocument.__get__(self, self.__class__) ), @@ -335,6 +356,13 @@ class MethodAi(MethodBase): required=False, description="Legacy/API output format extension (e.g. txt, docx). Ignored when outputFormat is set." ), + "folderId": WorkflowActionParameter( + name="folderId", + type="str", + frontendType=FrontendType.USER_FILE_FOLDER, + required=False, + description="Target folder in My Files when persisting workflow output", + ), }, execute=generateDocument.__get__(self, self.__class__) ), @@ -366,7 +394,14 @@ class MethodAi(MethodBase): frontendOptions=["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"], required=False, description="Output format (html, js, py, json, csv, xml, etc.). Optional: if omitted, formats are determined from prompt by AI. This action can return MULTIPLE files in a single call when the prompt requests multiple files. With per-document format determination, AI can determine different formats for different files based on prompt. When multiple files are requested, the action will return multiple documents (one per file)." - ) + ), + "folderId": WorkflowActionParameter( + name="folderId", + type="str", + frontendType=FrontendType.USER_FILE_FOLDER, + required=False, + description="Target folder in My Files when persisting workflow output", + ), }, execute=generateCode.__get__(self, self.__class__) ), diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 285b970d..c0c59dfa 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -from typing import Dict, Any +from typing import Dict, Any, Optional import base64 import binascii @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) def _persistDocumentsToUserFiles( action_documents: list, services, + folder_id: Optional[str] = None, ) -> None: """Persist file.create output documents to user's file storage (like upload). Adds fileId to each document's validationMetadata for download links in UI.""" @@ -70,7 +71,7 @@ def _persistDocumentsToUserFiles( doc_name, len(content), ) - file_item = mgmt.createFile(doc_name, mime, content) + file_item = mgmt.createFile(doc_name, mime, content, folderId=folder_id) logger.info("file.create persist: createFile returned id=%s", file_item.id) ok = mgmt.createFileData(file_item.id, content) logger.info("file.create persist: createFileData returned %s for id=%s", ok, file_item.id) @@ -111,6 +112,11 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult: "de", ) + folder_id: Optional[str] = None + raw_folder = parameters.get("folderId") + if raw_folder is not None and str(raw_folder).strip(): + folder_id = str(raw_folder).strip() + try: structured_content = markdownToDocumentJson(context, title, language) if templateName: @@ -164,7 +170,7 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult: }, )) - _persistDocumentsToUserFiles(action_documents, self.services) + _persistDocumentsToUserFiles(action_documents, self.services, folder_id=folder_id) return ActionResult.isSuccess(documents=action_documents) except Exception as e: diff --git a/modules/workflows/methods/methodFile/methodFile.py b/modules/workflows/methods/methodFile/methodFile.py index 8724ab11..3f9dbd02 100644 --- a/modules/workflows/methods/methodFile/methodFile.py +++ b/modules/workflows/methods/methodFile/methodFile.py @@ -73,6 +73,13 @@ class MethodFile(MethodBase): default="de", description="Language code", ), + "folderId": WorkflowActionParameter( + name="folderId", + type="str", + frontendType=FrontendType.USER_FILE_FOLDER, + required=False, + description="Optional My Files folder to store created documents", + ), }, execute=create.__get__(self, self.__class__), ),