added upload folder location for all document creation nodes

This commit is contained in:
Ida 2026-05-06 10:19:20 +02:00
parent eeb9a4a161
commit 6e3da0d0d8
8 changed files with 110 additions and 12 deletions

View file

@ -30,9 +30,6 @@ AI_NODES = [
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder", {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": "", "description": t("Daten aus vorherigen Schritten"), "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}}, "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", {"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Einfacher Modus"), "default": True}, "description": t("Einfacher Modus"), "default": True},
] + _AI_COMMON_PARAMS, ] + _AI_COMMON_PARAMS,
@ -80,9 +77,15 @@ AI_NODES = [
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
"description": t("Dokumente aus vorherigen Schritten"), "description": t("Dokumente aus vorherigen Schritten"),
"graphInherit": {"port": 0, "kind": "documentListWire"}}, "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", {"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["brief", "medium", "detailed"]}, "frontendOptions": {"options": ["brief", "medium", "detailed"]},
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"}, "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, ] + _AI_COMMON_PARAMS,
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -101,8 +104,14 @@ AI_NODES = [
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef", {"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
"description": t("Dokumente aus vorherigen Schritten"), "description": t("Dokumente aus vorherigen Schritten"),
"graphInherit": {"port": 0, "kind": "documentListWire"}}, "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", {"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
"description": t("Zielsprache (z.B. de, en, French)")}, "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, ] + _AI_COMMON_PARAMS,
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -124,6 +133,9 @@ AI_NODES = [
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select", {"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]}, "frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
"description": t("Zielformat")}, "description": t("Zielformat")},
{"name": "folderId", "type": "str", "required": False, "frontendType": "userFileFolder",
"description": t("Zielordner in Meine Dateien"),
"default": ""},
] + _AI_COMMON_PARAMS, ] + _AI_COMMON_PARAMS,
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -149,6 +161,9 @@ AI_NODES = [
{"name": "documentType", "type": "str", "required": False, "frontendType": "select", {"name": "documentType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]}, "frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"}, "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", {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": "", "description": t("Daten aus vorherigen Schritten"), "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}}, "graphInherit": {"port": 0, "kind": "primaryTextRef"}},
@ -177,6 +192,9 @@ AI_NODES = [
{"name": "resultType", "type": "str", "required": False, "frontendType": "select", {"name": "resultType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]}, "frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"}, "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", {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": "", "description": t("Daten aus vorherigen Schritten"), "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}}, "graphInherit": {"port": 0, "kind": "primaryTextRef"}},

View file

@ -15,6 +15,9 @@ FILE_NODES = [
"description": t("Ausgabeformat"), "default": "docx"}, "description": t("Ausgabeformat"), "default": "docx"},
{"name": "title", "type": "str", "required": False, "frontendType": "text", {"name": "title", "type": "str", "required": False, "frontendType": "text",
"description": t("Dokumenttitel")}, "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", {"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": "", "description": t("Daten aus vorherigen Schritten"), "default": "",
"graphInherit": {"port": 0, "kind": "primaryTextRef"}}, "graphInherit": {"port": 0, "kind": "primaryTextRef"}},

View file

@ -1345,16 +1345,34 @@ class ComponentObjects:
return newfileName return newfileName
counter += 1 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. """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, Duplicate check: if a file with the same user + fileHash + fileName already exists,
the existing file is returned instead of creating a new one. the existing file is returned instead of creating a new one.
Same hash with different name is allowed (intentional copy by user). 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"): if not self.checkRbacPermission(FileItem, "create"):
raise PermissionError("No permission to create files") 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 # Compute file size and hash
fileSize = len(content) fileSize = len(content)
fileHash = hashlib.sha256(content).hexdigest() fileHash = hashlib.sha256(content).hexdigest()
@ -1386,6 +1404,7 @@ class ComponentObjects:
mimeType=mimeType, mimeType=mimeType,
fileSize=fileSize, fileSize=fileSize,
fileHash=fileHash, fileHash=fileHash,
folderId=resolved_folder_id,
) )
# Ensure audit user is always stored: workflow/singleton contexts sometimes leave # Ensure audit user is always stored: workflow/singleton contexts sometimes leave
# the connector without _current_user_id, so _saveRecord skips sysCreatedBy → # the connector without _current_user_id, so _saveRecord skips sysCreatedBy →

View file

@ -88,6 +88,9 @@ class FrontendType(str, Enum):
FILTER_EXPRESSION = "filterExpression" FILTER_EXPRESSION = "filterExpression"
"""Filter expression builder for data.filter""" """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 # Mapping of custom types to their API endpoint for dynamic options
CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = { CUSTOM_TYPE_OPTIONS_API: Dict[FrontendType, str] = {

View file

@ -393,6 +393,13 @@ class ActionNodeExecutor:
return _normalizeError(e, outputSchema) return _normalizeError(e, outputSchema)
# 9. Persist generated documents as files and build JSON-safe output # 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 = [] docsList = []
for d in (result.documents or []): for d in (result.documents or []):
dumped = d.model_dump() if hasattr(d, "model_dump") else dict(d) if isinstance(d, dict) else d 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) _mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId)
_docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin"
_mimeType = dumped.get("mimeType") or "application/octet-stream" _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) _mgmt.createFileData(_fileItem.id, rawBytes)
dumped["fileId"] = _fileItem.id dumped["fileId"] = _fileItem.id
dumped["id"] = _fileItem.id dumped["id"] = _fileItem.id

View file

@ -194,7 +194,14 @@ class MethodAi(MethodBase):
required=False, required=False,
default="txt", default="txt",
description="Output file extension" 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__) execute=summarizeDocument.__get__(self, self.__class__)
), ),
@ -239,7 +246,14 @@ class MethodAi(MethodBase):
frontendType=FrontendType.TEXT, frontendType=FrontendType.TEXT,
required=False, required=False,
description="Output file extension. If not specified, uses same format as input" 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__) execute=translateDocument.__get__(self, self.__class__)
), ),
@ -271,7 +285,14 @@ class MethodAi(MethodBase):
required=False, required=False,
default=True, default=True,
description="Whether to preserve document structure (headings, tables, etc.)" 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__) execute=convertDocument.__get__(self, self.__class__)
), ),
@ -335,6 +356,13 @@ class MethodAi(MethodBase):
required=False, required=False,
description="Legacy/API output format extension (e.g. txt, docx). Ignored when outputFormat is set." 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__) execute=generateDocument.__get__(self, self.__class__)
), ),
@ -366,7 +394,14 @@ class MethodAi(MethodBase):
frontendOptions=["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"], frontendOptions=["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"],
required=False, 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)." 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__) execute=generateCode.__get__(self, self.__class__)
), ),

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
from typing import Dict, Any from typing import Dict, Any, Optional
import base64 import base64
import binascii import binascii
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
def _persistDocumentsToUserFiles( def _persistDocumentsToUserFiles(
action_documents: list, action_documents: list,
services, services,
folder_id: Optional[str] = None,
) -> None: ) -> None:
"""Persist file.create output documents to user's file storage (like upload). """Persist file.create output documents to user's file storage (like upload).
Adds fileId to each document's validationMetadata for download links in UI.""" Adds fileId to each document's validationMetadata for download links in UI."""
@ -70,7 +71,7 @@ def _persistDocumentsToUserFiles(
doc_name, doc_name,
len(content), 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) logger.info("file.create persist: createFile returned id=%s", file_item.id)
ok = mgmt.createFileData(file_item.id, content) ok = mgmt.createFileData(file_item.id, content)
logger.info("file.create persist: createFileData returned %s for id=%s", ok, file_item.id) 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", "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: try:
structured_content = markdownToDocumentJson(context, title, language) structured_content = markdownToDocumentJson(context, title, language)
if templateName: 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) return ActionResult.isSuccess(documents=action_documents)
except Exception as e: except Exception as e:

View file

@ -73,6 +73,13 @@ class MethodFile(MethodBase):
default="de", default="de",
description="Language code", 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__), execute=create.__get__(self, self.__class__),
), ),