diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 211146f016..98fd9bb39d 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -505,22 +505,25 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): sandbox_provider = SandboxProviderService.get_sandbox_provider( application_generate_entity.app_config.tenant_id ) - if workflow.version == Workflow.VERSION_DRAFT: - sandbox = SandboxService.create_draft( - tenant_id=application_generate_entity.app_config.tenant_id, - app_id=application_generate_entity.app_config.app_id, - user_id=application_generate_entity.user_id, - sandbox_provider=sandbox_provider, - ) - else: - sandbox = SandboxService.create( - tenant_id=application_generate_entity.app_config.tenant_id, - app_id=application_generate_entity.app_config.app_id, - user_id=application_generate_entity.user_id, - sandbox_id=conversation.id, - sandbox_provider=sandbox_provider, - ) - graph_layers.append(SandboxLayer(sandbox)) + try: + if workflow.version == Workflow.VERSION_DRAFT: + sandbox = SandboxService.create_draft( + tenant_id=application_generate_entity.app_config.tenant_id, + app_id=application_generate_entity.app_config.app_id, + user_id=application_generate_entity.user_id, + sandbox_provider=sandbox_provider, + ) + else: + sandbox = SandboxService.create( + tenant_id=application_generate_entity.app_config.tenant_id, + app_id=application_generate_entity.app_config.app_id, + user_id=application_generate_entity.user_id, + sandbox_id=conversation.id, + sandbox_provider=sandbox_provider, + ) + graph_layers.append(SandboxLayer(sandbox)) + except ValueError as e: + queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) # new thread with request context and contextvars context = contextvars.copy_context() diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 993a4410a7..a1d4908966 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -321,22 +321,25 @@ class WorkflowAppGenerator(BaseAppGenerator): sandbox_provider = SandboxProviderService.get_sandbox_provider( application_generate_entity.app_config.tenant_id ) - if workflow.version == Workflow.VERSION_DRAFT: - sandbox = SandboxService.create_draft( - tenant_id=application_generate_entity.app_config.tenant_id, - app_id=application_generate_entity.app_config.app_id, - user_id=application_generate_entity.user_id, - sandbox_provider=sandbox_provider, - ) - else: - sandbox = SandboxService.create( - tenant_id=application_generate_entity.app_config.tenant_id, - app_id=application_generate_entity.app_config.app_id, - user_id=application_generate_entity.user_id, - sandbox_id=application_generate_entity.workflow_execution_id, - sandbox_provider=sandbox_provider, - ) - graph_layers.append(SandboxLayer(sandbox=sandbox)) + try: + if workflow.version == Workflow.VERSION_DRAFT: + sandbox = SandboxService.create_draft( + tenant_id=application_generate_entity.app_config.tenant_id, + app_id=application_generate_entity.app_config.app_id, + user_id=application_generate_entity.user_id, + sandbox_provider=sandbox_provider, + ) + else: + sandbox = SandboxService.create( + tenant_id=application_generate_entity.app_config.tenant_id, + app_id=application_generate_entity.app_config.app_id, + user_id=application_generate_entity.user_id, + sandbox_id=application_generate_entity.workflow_execution_id, + sandbox_provider=sandbox_provider, + ) + graph_layers.append(SandboxLayer(sandbox=sandbox)) + except ValueError as e: + queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER) # new thread with request context and contextvars context = contextvars.copy_context() diff --git a/api/core/sandbox/initializer/dify_cli_initializer.py b/api/core/sandbox/initializer/dify_cli_initializer.py index 49f013eca5..e9c98233ad 100644 --- a/api/core/sandbox/initializer/dify_cli_initializer.py +++ b/api/core/sandbox/initializer/dify_cli_initializer.py @@ -38,6 +38,7 @@ class DifyCliInitializer(AsyncSandboxInitializer): def initialize(self, sandbox: Sandbox) -> None: vm = sandbox.vm + # FIXME(Mairuis): should be more robust, effectively. binary = self._locator.resolve(vm.metadata.os, vm.metadata.arch) pipeline(vm).add( diff --git a/api/core/workflow/enums.py b/api/core/workflow/enums.py index b7bbdc3e9d..37bc339f44 100644 --- a/api/core/workflow/enums.py +++ b/api/core/workflow/enums.py @@ -64,6 +64,7 @@ class NodeType(StrEnum): TRIGGER_PLUGIN = "trigger-plugin" HUMAN_INPUT = "human-input" COMMAND = "command" + FILE_UPLOAD = "file-upload" GROUP = "group" @property diff --git a/api/core/workflow/nodes/command/node.py b/api/core/workflow/nodes/command/node.py index bf9b2090e4..e24c003e4e 100644 --- a/api/core/workflow/nodes/command/node.py +++ b/api/core/workflow/nodes/command/node.py @@ -17,7 +17,8 @@ from core.workflow.nodes.command.exc import CommandExecutionError logger = logging.getLogger(__name__) -COMMAND_NODE_TIMEOUT_SECONDS = 60 +# FIXME(Mairuis): The timeout value is currently hardcoded and should be made configurable in the future. +COMMAND_NODE_TIMEOUT_SECONDS = 60 * 10 class CommandNode(Node[CommandNodeData]): @@ -71,8 +72,6 @@ class CommandNode(Node[CommandNodeData]): error_type="CommandNodeError", ) - timeout = COMMAND_NODE_TIMEOUT_SECONDS if COMMAND_NODE_TIMEOUT_SECONDS > 0 else None - try: sandbox.wait_ready(timeout=SANDBOX_READY_TIMEOUT) with with_connection(sandbox.vm) as conn: @@ -81,7 +80,7 @@ class CommandNode(Node[CommandNodeData]): sandbox_debug("command_node", "command", command) future = submit_command(sandbox.vm, conn, command, cwd=working_directory) - result = future.result(timeout=timeout) + result = future.result(timeout=COMMAND_NODE_TIMEOUT_SECONDS) outputs: dict[str, Any] = { "stdout": result.stdout.decode("utf-8", errors="replace"), diff --git a/api/core/workflow/nodes/file_upload/__init__.py b/api/core/workflow/nodes/file_upload/__init__.py new file mode 100644 index 0000000000..89ddd56cea --- /dev/null +++ b/api/core/workflow/nodes/file_upload/__init__.py @@ -0,0 +1,4 @@ +from .entities import FileUploadNodeData +from .node import FileUploadNode + +__all__ = ["FileUploadNode", "FileUploadNodeData"] diff --git a/api/core/workflow/nodes/file_upload/entities.py b/api/core/workflow/nodes/file_upload/entities.py new file mode 100644 index 0000000000..1c23515780 --- /dev/null +++ b/api/core/workflow/nodes/file_upload/entities.py @@ -0,0 +1,7 @@ +from collections.abc import Sequence + +from core.workflow.nodes.base import BaseNodeData + + +class FileUploadNodeData(BaseNodeData): + variable_selector: Sequence[str] diff --git a/api/core/workflow/nodes/file_upload/exc.py b/api/core/workflow/nodes/file_upload/exc.py new file mode 100644 index 0000000000..60bf5f33df --- /dev/null +++ b/api/core/workflow/nodes/file_upload/exc.py @@ -0,0 +1,6 @@ +class FileUploadNodeError(ValueError): + """Base exception for errors related to the FileUploadNode.""" + + +class FileUploadDownloadError(FileUploadNodeError): + """Exception raised when preparing file download in sandbox fails.""" diff --git a/api/core/workflow/nodes/file_upload/node.py b/api/core/workflow/nodes/file_upload/node.py new file mode 100644 index 0000000000..4b10ddc5bf --- /dev/null +++ b/api/core/workflow/nodes/file_upload/node.py @@ -0,0 +1,244 @@ +import logging +import os +import posixpath +from collections.abc import Mapping, Sequence +from pathlib import PurePosixPath +from typing import Any, cast + +from core.file import File, FileTransferMethod +from core.sandbox.bash.session import SANDBOX_READY_TIMEOUT +from core.sandbox.services.asset_download_service import AssetDownloadItem +from core.variables import ArrayFileSegment +from core.variables.segments import ArrayStringSegment, FileSegment +from core.virtual_environment.__base.command_future import CommandCancelledError, CommandTimeoutError +from core.virtual_environment.__base.helpers import pipeline +from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus +from core.workflow.node_events import NodeRunResult +from core.workflow.nodes.base.node import Node + +from .entities import FileUploadNodeData +from .exc import FileUploadDownloadError, FileUploadNodeError + +logger = logging.getLogger(__name__) + + +class FileUploadNode(Node[FileUploadNodeData]): + """Upload workflow file variables into sandbox via presigned URLs. + + The node intentionally avoids streaming file bytes through Dify workers. For local/tool + files, it generates storage-backed presigned URLs and lets sandbox download directly. + """ + + node_type = NodeType.FILE_UPLOAD + + @classmethod + def version(cls) -> str: + return "1" + + @classmethod + def get_default_config(cls, filters: Mapping[str, object] | None = None) -> Mapping[str, object]: + _ = filters + return { + "type": "file-upload", + "config": { + "variable_selector": [], + }, + } + + def _run(self) -> NodeRunResult: + sandbox = self.graph_runtime_state.sandbox + variable_selector = self.node_data.variable_selector + inputs: dict[str, Any] = {"variable_selector": variable_selector} + if sandbox is None: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error="Sandbox not available for FileUploadNode.", + error_type="SandboxNotInitializedError", + inputs=inputs, + ) + + variable = self.graph_runtime_state.variable_pool.get(variable_selector) + if variable is None: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=f"File variable not found for selector: {variable_selector}", + error_type=FileUploadNodeError.__name__, + inputs=inputs, + ) + + if variable.value and not isinstance(variable, ArrayFileSegment | FileSegment): + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=f"Variable {variable_selector} is not a file or file array", + error_type=FileUploadNodeError.__name__, + inputs=inputs, + ) + + files = self._normalize_files(variable.value) + process_data: dict[str, Any] = { + "file_count": len(files), + "files": [file.to_dict() for file in files], + } + if not files: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + error="Selected file variable is empty.", + error_type=FileUploadNodeError.__name__, + inputs=inputs, + process_data=process_data, + ) + + try: + sandbox.wait_ready(timeout=SANDBOX_READY_TIMEOUT) + download_items: list[AssetDownloadItem] = self._build_download_items(files) + sandbox_paths = self._upload(sandbox.vm, download_items) + file_names = [PurePosixPath(path).name for path in sandbox_paths] + process_data = { + **process_data, + "sandbox_paths": sandbox_paths, + "file_names": file_names, + } + + outputs: dict[str, Any] + if len(sandbox_paths) == 1: + outputs = { + "sandbox_path": sandbox_paths[0], + "file_name": file_names[0], + } + else: + outputs = { + "sandbox_path": ArrayStringSegment(value=sandbox_paths), + "file_name": ArrayStringSegment(value=file_names), + } + return NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + inputs=inputs, + process_data=process_data, + outputs=outputs, + ) + + except CommandTimeoutError: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error="File upload timeout", + error_type=CommandTimeoutError.__name__, + inputs=inputs, + process_data=process_data, + ) + except CommandCancelledError: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error="File upload command was cancelled", + error_type=CommandCancelledError.__name__, + inputs=inputs, + process_data=process_data, + ) + except FileUploadNodeError as e: + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=str(e), + error_type=type(e).__name__, + inputs=inputs, + process_data=process_data, + ) + except Exception as e: + logger.exception("File upload node %s failed", self.id) + return NodeRunResult( + status=WorkflowNodeExecutionStatus.FAILED, + error=str(e), + error_type=type(e).__name__, + inputs=inputs, + process_data=process_data, + ) + + @classmethod + def _extract_variable_selector_to_variable_mapping( + cls, + *, + graph_config: Mapping[str, Any], + node_id: str, + node_data: Mapping[str, Any], + ) -> Mapping[str, Sequence[str]]: + _ = graph_config + typed_node_data = FileUploadNodeData.model_validate(node_data) + return {node_id + ".files": typed_node_data.variable_selector} + + @staticmethod + def _normalize_files(value: Any) -> list[File]: + if isinstance(value, File): + return [value] + if isinstance(value, list): + list_value = cast(list[object], value) + files: list[File] = [] + for idx in range(len(list_value)): + candidate = list_value[idx] + if not isinstance(candidate, File): + return [] + files.append(candidate) + return files + return [] + + def _build_download_items(self, files: Sequence[File]) -> list[AssetDownloadItem]: + used_paths: set[str] = set() + items: list[AssetDownloadItem] = [] + for index, file in enumerate(files): + file_url = self._get_download_url(file) + + filename = (file.filename or "").strip() + if not filename or filename in {".", ".."}: + filename = f"file-{index + 1}{file.extension or ''}" + filename = os.path.basename(filename) + + if filename in used_paths: + stem = PurePosixPath(filename).stem or f"file-{index + 1}" + suffix = PurePosixPath(filename).suffix + dedupe = 1 + while filename in used_paths: + filename = f"{stem}_{dedupe}{suffix}" + dedupe += 1 + + used_paths.add(filename) + items.append(AssetDownloadItem(path=filename, url=file_url)) + return items + + @staticmethod + def _normalize_path(path: str) -> str: + normalized = posixpath.normpath(path.strip()) if path else "." + if normalized.startswith("/"): + normalized = normalized.lstrip("/") + return normalized or "." + + def _upload(self, vm: Any, items: list[AssetDownloadItem]) -> list[str]: + p = pipeline(vm) + out_paths: list[str] = [] + for item in items: + out_path = self._normalize_path(item.path) + if out_path in ("", "."): + raise FileUploadDownloadError("Download item path must point to a file") + out_paths.append(out_path) + p.add(["curl", "-fsSL", item.url, "-o", out_path], error_message="Failed to download file") + + try: + p.execute(timeout=None, raise_on_error=True) + except Exception as exc: + raise FileUploadDownloadError(str(exc)) from exc + + return out_paths + + def _get_download_url(self, file: File) -> str: + if file.transfer_method == FileTransferMethod.REMOTE_URL: + if not file.remote_url: + raise FileUploadDownloadError("Remote file URL is missing") + return file.remote_url + + if file.transfer_method in ( + FileTransferMethod.LOCAL_FILE, + FileTransferMethod.TOOL_FILE, + FileTransferMethod.DATASOURCE_FILE, + ): + download_url = file.generate_url(for_external=True) + if not download_url: + raise FileUploadDownloadError("Unable to generate download URL for file") + return download_url + + raise FileUploadDownloadError(f"Unsupported file transfer method: {file.transfer_method}") diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 9ddca65618..76817012ca 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -58,6 +58,7 @@ const DEFAULT_ICON_MAP: Record = { [BlockEnum.LLM]: 'bg-util-colors-indigo-indigo-500', [BlockEnum.Code]: 'bg-util-colors-blue-blue-500', [BlockEnum.Command]: 'bg-util-colors-blue-blue-500', + [BlockEnum.FileUpload]: 'bg-util-colors-green-green-500', [BlockEnum.End]: 'bg-util-colors-warning-warning-500', [BlockEnum.IfElse]: 'bg-util-colors-cyan-cyan-500', [BlockEnum.Iteration]: 'bg-util-colors-cyan-cyan-500', diff --git a/web/app/components/workflow/block-selector/constants.tsx b/web/app/components/workflow/block-selector/constants.tsx index 3c4270fe35..120f6f63a2 100644 --- a/web/app/components/workflow/block-selector/constants.tsx +++ b/web/app/components/workflow/block-selector/constants.tsx @@ -152,6 +152,11 @@ export const BLOCKS = [ type: BlockEnum.Command, title: 'Command', }, + { + classification: BlockClassificationEnum.Utilities, + type: BlockEnum.FileUpload, + title: 'File Upload', + }, { classification: BlockClassificationEnum.Default, type: BlockEnum.Agent, diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts index 523596c663..d49eacc7ac 100644 --- a/web/app/components/workflow/constants.ts +++ b/web/app/components/workflow/constants.ts @@ -118,6 +118,7 @@ export const SUPPORT_OUTPUT_VARS_NODE = [ BlockEnum.Code, BlockEnum.TemplateTransform, BlockEnum.Command, + BlockEnum.FileUpload, BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, @@ -216,6 +217,17 @@ export const COMMAND_OUTPUT_STRUCT: Var[] = [ }, ] +export const FILE_UPLOAD_OUTPUT_STRUCT: Var[] = [ + { + variable: 'sandbox_path', + type: VarType.string, + }, + { + variable: 'file_name', + type: VarType.string, + }, +] + export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [ { variable: 'class_name', diff --git a/web/app/components/workflow/constants/node.ts b/web/app/components/workflow/constants/node.ts index 45e472eb32..24efb40670 100644 --- a/web/app/components/workflow/constants/node.ts +++ b/web/app/components/workflow/constants/node.ts @@ -2,9 +2,10 @@ import agentDefault from '@/app/components/workflow/nodes/agent/default' import assignerDefault from '@/app/components/workflow/nodes/assigner/default' import codeDefault from '@/app/components/workflow/nodes/code/default' import commandDefault from '@/app/components/workflow/nodes/command/default' - import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' +import fileUploadDefault from '@/app/components/workflow/nodes/file-upload/default' + import httpRequestDefault from '@/app/components/workflow/nodes/http/default' import humanInputDefault from '@/app/components/workflow/nodes/human-input/default' import ifElseDefault from '@/app/components/workflow/nodes/if-else/default' @@ -36,6 +37,7 @@ export const WORKFLOW_COMMON_NODES = [ loopEndDefault, codeDefault, commandDefault, + fileUploadDefault, templateTransformDefault, variableAggregatorDefault, documentExtractorDefault, diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts index 138c33ee0b..5b2e2f58f4 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts @@ -4,6 +4,7 @@ import type { CodeNodeType } from '../../../code/types' import type { CommandNodeType } from '../../../command/types' import type { DocExtractorNodeType } from '../../../document-extractor/types' import type { EndNodeType } from '../../../end/types' +import type { FileUploadNodeType } from '../../../file-upload/types' import type { HttpNodeType } from '../../../http/types' import type { IfElseNodeType } from '../../../if-else/types' import type { IterationNodeType } from '../../../iteration/types' @@ -42,6 +43,7 @@ import { AGENT_OUTPUT_STRUCT, COMMAND_OUTPUT_STRUCT, FILE_STRUCT, + FILE_UPLOAD_OUTPUT_STRUCT, getGlobalVars, HTTP_REQUEST_OUTPUT_STRUCT, HUMAN_INPUT_OUTPUT_STRUCT, @@ -471,6 +473,22 @@ const formatItem = ( break } + case BlockEnum.FileUpload: { + res.vars = (data as FileUploadNodeType).is_array_file + ? [ + { + variable: 'sandbox_path', + type: VarType.arrayString, + }, + { + variable: 'file_name', + type: VarType.arrayString, + }, + ] + : FILE_UPLOAD_OUTPUT_STRUCT + break + } + case BlockEnum.QuestionClassifier: { res.vars = QUESTION_CLASSIFIER_OUTPUT_STRUCT break @@ -1538,6 +1556,11 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => { ]) break } + case BlockEnum.FileUpload: { + const payload = data as FileUploadNodeType + res = [payload.variable_selector] + break + } case BlockEnum.QuestionClassifier: { const payload = data as QuestionClassifierNodeType res = [payload.query_variable_selector] @@ -1933,6 +1956,12 @@ export const updateNodeVars = ( ) break } + case BlockEnum.FileUpload: { + const payload = data as FileUploadNodeType + if (payload.variable_selector.join('.') === oldVarSelector.join('.')) + payload.variable_selector = newVarSelector + break + } case BlockEnum.QuestionClassifier: { const payload = data as QuestionClassifierNodeType if ( @@ -2232,6 +2261,17 @@ export const getNodeOutputVars = ( break } + case BlockEnum.FileUpload: { + if ((data as FileUploadNodeType).is_array_file) { + res.push([id, 'sandbox_path']) + res.push([id, 'file_name']) + } + else { + varsToValueSelectorList(FILE_UPLOAD_OUTPUT_STRUCT, [id], res) + } + break + } + case BlockEnum.QuestionClassifier: { varsToValueSelectorList(QUESTION_CLASSIFIER_OUTPUT_STRUCT, [id], res) break diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index 9a2044390e..ecbfe56a7b 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -20,6 +20,7 @@ import useAgentSingleRunFormParams from '@/app/components/workflow/nodes/agent/u import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/nodes/assigner/use-single-run-form-params' import useCodeSingleRunFormParams from '@/app/components/workflow/nodes/code/use-single-run-form-params' import useDocExtractorSingleRunFormParams from '@/app/components/workflow/nodes/document-extractor/use-single-run-form-params' +import useFileUploadSingleRunFormParams from '@/app/components/workflow/nodes/file-upload/use-single-run-form-params' import useHttpRequestSingleRunFormParams from '@/app/components/workflow/nodes/http/use-single-run-form-params' import useHumanInputSingleRunFormParams from '@/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params' import useIfElseSingleRunFormParams from '@/app/components/workflow/nodes/if-else/use-single-run-form-params' @@ -51,6 +52,7 @@ const singleRunFormParamsHooks: Record = { [BlockEnum.KnowledgeRetrieval]: useKnowledgeRetrievalSingleRunFormParams, [BlockEnum.Code]: useCodeSingleRunFormParams, [BlockEnum.Command]: undefined, + [BlockEnum.FileUpload]: useFileUploadSingleRunFormParams, [BlockEnum.TemplateTransform]: useTemplateTransformSingleRunFormParams, [BlockEnum.QuestionClassifier]: useQuestionClassifierSingleRunFormParams, [BlockEnum.HttpRequest]: useHttpRequestSingleRunFormParams, @@ -93,6 +95,7 @@ const getDataForCheckMoreHooks: Record = { [BlockEnum.KnowledgeRetrieval]: undefined, [BlockEnum.Code]: undefined, [BlockEnum.Command]: undefined, + [BlockEnum.FileUpload]: undefined, [BlockEnum.TemplateTransform]: undefined, [BlockEnum.QuestionClassifier]: undefined, [BlockEnum.HttpRequest]: undefined, diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts index f33494ca63..2d85f0ea07 100644 --- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts +++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts @@ -24,6 +24,7 @@ import { getNodeInfoById, isConversationVar, isENV, isSystemVar, toNodeOutputVar import Assigner from '@/app/components/workflow/nodes/assigner/default' import CodeDefault from '@/app/components/workflow/nodes/code/default' import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default' +import FileUploadDefault from '@/app/components/workflow/nodes/file-upload/default' import HTTPDefault from '@/app/components/workflow/nodes/http/default' import HumanInputDefault from '@/app/components/workflow/nodes/human-input/default' import IfElseDefault from '@/app/components/workflow/nodes/if-else/default' @@ -70,6 +71,7 @@ const { checkValid: checkAssignerValid } = Assigner const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault const { checkValid: checkIterationValid } = IterationDefault const { checkValid: checkDocumentExtractorValid } = DocumentExtractorDefault +const { checkValid: checkFileUploadValid } = FileUploadDefault const { checkValid: checkLoopValid } = LoopDefault const { checkValid: checkHumanInputValid } = HumanInputDefault @@ -88,6 +90,7 @@ const checkValidFns: Partial> = { [BlockEnum.ParameterExtractor]: checkParameterExtractorValid, [BlockEnum.Iteration]: checkIterationValid, [BlockEnum.DocExtractor]: checkDocumentExtractorValid, + [BlockEnum.FileUpload]: checkFileUploadValid, [BlockEnum.Loop]: checkLoopValid, [BlockEnum.HumanInput]: checkHumanInputValid, } diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index 96ff3944c6..84d2296ce3 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -16,6 +16,8 @@ import DocExtractorNode from './document-extractor/node' import DocExtractorPanel from './document-extractor/panel' import EndNode from './end/node' import EndPanel from './end/panel' +import FileUploadNode from './file-upload/node' +import FileUploadPanel from './file-upload/panel' import GroupNode from './group/node' import GroupPanel from './group/panel' import HttpNode from './http/node' @@ -83,6 +85,7 @@ export const NodeComponentMap: Record> = { [BlockEnum.TriggerWebhook]: TriggerWebhookNode, [BlockEnum.TriggerPlugin]: TriggerPluginNode, [BlockEnum.Command]: CommandNode, + [BlockEnum.FileUpload]: FileUploadNode, [BlockEnum.Group]: GroupNode, } @@ -114,5 +117,6 @@ export const PanelComponentMap: Record> = { [BlockEnum.TriggerWebhook]: TriggerWebhookPanel, [BlockEnum.TriggerPlugin]: TriggerPluginPanel, [BlockEnum.Command]: CommandPanel, + [BlockEnum.FileUpload]: FileUploadPanel, [BlockEnum.Group]: GroupPanel, } diff --git a/web/app/components/workflow/nodes/file-upload/default.ts b/web/app/components/workflow/nodes/file-upload/default.ts new file mode 100644 index 0000000000..512fafeeb1 --- /dev/null +++ b/web/app/components/workflow/nodes/file-upload/default.ts @@ -0,0 +1,35 @@ +import type { NodeDefault } from '../../types' +import type { FileUploadNodeType } from './types' +import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { genNodeMetaData } from '@/app/components/workflow/utils' + +const i18nPrefix = 'errorMsg' + +const metaData = genNodeMetaData({ + classification: BlockClassificationEnum.Utilities, + sort: 3, + type: BlockEnum.FileUpload, +}) + +const nodeDefault: NodeDefault = { + metaData, + defaultValue: { + variable_selector: [], + is_array_file: false, + }, + checkValid(payload: FileUploadNodeType, t: (key: string, options?: Record) => string) { + let errorMessages = '' + const { variable_selector: variable } = payload + + if (!errorMessages && !variable?.length) + errorMessages = t(`${i18nPrefix}.fieldRequired`, { ns: 'workflow', field: t(`${i18nPrefix}.fields.fileVariable`, { ns: 'workflow' }) }) + + return { + isValid: !errorMessages, + errorMessage: errorMessages, + } + }, +} + +export default nodeDefault diff --git a/web/app/components/workflow/nodes/file-upload/node.tsx b/web/app/components/workflow/nodes/file-upload/node.tsx new file mode 100644 index 0000000000..0f39ac5a50 --- /dev/null +++ b/web/app/components/workflow/nodes/file-upload/node.tsx @@ -0,0 +1,12 @@ +import type { FC } from 'react' +import type { FileUploadNodeType } from './types' +import type { NodeProps } from '@/app/components/workflow/types' +import * as React from 'react' + +const Node: FC> = () => { + return ( +
+ ) +} + +export default React.memo(Node) diff --git a/web/app/components/workflow/nodes/file-upload/panel.tsx b/web/app/components/workflow/nodes/file-upload/panel.tsx new file mode 100644 index 0000000000..fda3effb50 --- /dev/null +++ b/web/app/components/workflow/nodes/file-upload/panel.tsx @@ -0,0 +1,65 @@ +import type { FC } from 'react' +import type { FileUploadNodeType } from './types' +import type { NodePanelProps } from '@/app/components/workflow/types' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import Field from '@/app/components/workflow/nodes/_base/components/field' +import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' +import Split from '@/app/components/workflow/nodes/_base/components/split' +import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker' +import useConfig from './use-config' + +const i18nPrefix = 'nodes.fileUpload' + +const Panel: FC> = ({ + id, + data, +}) => { + const { t } = useTranslation() + const { + readOnly, + inputs, + handleVarChanges, + filterVar, + } = useConfig(id, data) + + return ( +
+
+ + + +
+ +
+ + <> + + + + +
+
+ ) +} + +export default React.memo(Panel) diff --git a/web/app/components/workflow/nodes/file-upload/types.ts b/web/app/components/workflow/nodes/file-upload/types.ts new file mode 100644 index 0000000000..46008b6fe8 --- /dev/null +++ b/web/app/components/workflow/nodes/file-upload/types.ts @@ -0,0 +1,6 @@ +import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types' + +export type FileUploadNodeType = CommonNodeType & { + variable_selector: ValueSelector + is_array_file: boolean +} diff --git a/web/app/components/workflow/nodes/file-upload/use-config.ts b/web/app/components/workflow/nodes/file-upload/use-config.ts new file mode 100644 index 0000000000..4bb755194f --- /dev/null +++ b/web/app/components/workflow/nodes/file-upload/use-config.ts @@ -0,0 +1,65 @@ +import type { ValueSelector, Var } from '../../types' +import type { FileUploadNodeType } from './types' +import { produce } from 'immer' +import { useCallback, useMemo } from 'react' +import { useStoreApi } from 'reactflow' +import { + useIsChatMode, + useNodesReadOnly, + useWorkflow, + useWorkflowVariables, +} from '@/app/components/workflow/hooks' +import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' +import { VarType } from '../../types' + +const useConfig = (id: string, payload: FileUploadNodeType) => { + const { nodesReadOnly: readOnly } = useNodesReadOnly() + const { inputs, setInputs } = useNodeCrud(id, payload) + + const filterVar = useCallback((varPayload: Var) => { + return varPayload.type === VarType.file || varPayload.type === VarType.arrayFile + }, []) + + const isChatMode = useIsChatMode() + const store = useStoreApi() + const { getBeforeNodesInSameBranch } = useWorkflow() + const { getNodes } = store.getState() + const currentNode = getNodes().find(n => n.id === id) + const isInIteration = payload.isInIteration + const iterationNode = isInIteration ? getNodes().find(n => n.id === currentNode!.parentId) : null + const isInLoop = payload.isInLoop + const loopNode = isInLoop ? getNodes().find(n => n.id === currentNode!.parentId) : null + + const availableNodes = useMemo(() => { + return getBeforeNodesInSameBranch(id) + }, [getBeforeNodesInSameBranch, id]) + + const { getCurrentVariableType } = useWorkflowVariables() + const getType = useCallback((variable?: ValueSelector) => { + const varType = getCurrentVariableType({ + parentNode: isInIteration ? iterationNode : loopNode, + valueSelector: variable || [], + availableNodes, + isChatMode, + isConstant: false, + }) + return varType + }, [getCurrentVariableType, isInIteration, iterationNode, loopNode, availableNodes, isChatMode]) + + const handleVarChanges = useCallback((variable: ValueSelector | string) => { + const newInputs = produce(inputs, (draft) => { + draft.variable_selector = variable as ValueSelector + draft.is_array_file = getType(draft.variable_selector) === VarType.arrayFile + }) + setInputs(newInputs) + }, [inputs, setInputs, getType]) + + return { + readOnly, + inputs, + filterVar, + handleVarChanges, + } +} + +export default useConfig diff --git a/web/app/components/workflow/nodes/file-upload/use-single-run-form-params.ts b/web/app/components/workflow/nodes/file-upload/use-single-run-form-params.ts new file mode 100644 index 0000000000..edb21716c5 --- /dev/null +++ b/web/app/components/workflow/nodes/file-upload/use-single-run-form-params.ts @@ -0,0 +1,66 @@ +import type { RefObject } from 'react' +import type { FileUploadNodeType } from './types' +import type { InputVar, Variable } from '@/app/components/workflow/types' +import { useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { InputVarType } from '@/app/components/workflow/types' + +const i18nPrefix = 'nodes.fileUpload' + +type Params = { + id: string + payload: FileUploadNodeType + runInputData: Record + runInputDataRef: RefObject> + getInputVars: (textList: string[]) => InputVar[] + setRunInputData: (data: Record) => void + toVarInputs: (variables: Variable[]) => InputVar[] +} + +const useSingleRunFormParams = ({ + payload, + runInputData, + setRunInputData, +}: Params) => { + const { t } = useTranslation() + const files = runInputData.files + + const setFiles = useCallback((newFiles: []) => { + setRunInputData({ + ...runInputData, + files: newFiles, + }) + }, [runInputData, setRunInputData]) + + const forms = useMemo(() => { + return [ + { + inputs: [{ + label: t(`${i18nPrefix}.inputVar`, { ns: 'workflow' })!, + variable: 'files', + type: payload.is_array_file ? InputVarType.multiFiles : InputVarType.singleFile, + required: true, + }], + values: { files }, + onChange: (keyValue: Record) => setFiles((keyValue.files as []) || []), + }, + ] + }, [files, payload.is_array_file, setFiles, t]) + + const getDependentVars = () => { + return [payload.variable_selector] + } + + const getDependentVar = (variable: string) => { + if (variable === 'files') + return payload.variable_selector + } + + return { + forms, + getDependentVars, + getDependentVar, + } +} + +export default useSingleRunFormParams diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index be0919b16b..d17afcc48d 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -59,6 +59,7 @@ export enum BlockEnum { TriggerWebhook = 'trigger-webhook', TriggerPlugin = 'trigger-plugin', Command = 'command', + FileUpload = 'file-upload', } export enum ControlMode { diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts index afeed71c00..74f22b36be 100644 --- a/web/app/components/workflow/utils/workflow.ts +++ b/web/app/components/workflow/utils/workflow.ts @@ -21,6 +21,7 @@ export const canRunBySingle = (nodeType: BlockEnum, isChildNode: boolean) => { || nodeType === BlockEnum.KnowledgeRetrieval || nodeType === BlockEnum.Code || nodeType === BlockEnum.Command + || nodeType === BlockEnum.FileUpload || nodeType === BlockEnum.TemplateTransform || nodeType === BlockEnum.QuestionClassifier || nodeType === BlockEnum.HttpRequest diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index d5d8ee110a..0b96a28f7b 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -8,6 +8,7 @@ "blocks.datasource-empty": "Empty Data Source", "blocks.document-extractor": "Doc Extractor", "blocks.end": "Output", + "blocks.file-upload": "File Upload", "blocks.group": "Group", "blocks.http-request": "HTTP Request", "blocks.human-input": "Human Input", @@ -41,6 +42,7 @@ "blocksAbout.datasource-empty": "Empty Data Source placeholder", "blocksAbout.document-extractor": "Used to parse uploaded documents into text content that is easily understandable by LLM.", "blocksAbout.end": "Define the output and result type of a workflow", + "blocksAbout.file-upload": "Download selected file variables into sandbox as local paths", "blocksAbout.group": "Group multiple nodes together for better organization", "blocksAbout.http-request": "Allow server requests to be sent over the HTTP protocol", "blocksAbout.human-input": "Ask for human to confirm before generating the next step", @@ -341,6 +343,7 @@ "errorMsg.fieldRequired": "{{field}} is required", "errorMsg.fields.code": "Code", "errorMsg.fields.command": "Command", + "errorMsg.fields.fileVariable": "File Variable", "errorMsg.fields.model": "Model", "errorMsg.fields.rerankModel": "A configured Rerank Model", "errorMsg.fields.variable": "Variable Name", @@ -515,6 +518,9 @@ "nodes.end.type.none": "None", "nodes.end.type.plain-text": "Plain Text", "nodes.end.type.structured": "Structured", + "nodes.fileUpload.inputVar": "File Variable", + "nodes.fileUpload.outputVars.fileName": "File name in sandbox", + "nodes.fileUpload.outputVars.sandboxPath": "Sandbox local file path", "nodes.http.api": "API", "nodes.http.apiPlaceholder": "Enter URL, type ‘/’ insert variable", "nodes.http.authorization.api-key": "API-Key",