feat: add File Upload node functionality and related components

- Implemented File Upload node with support for uploading files to the sandbox.
- Added necessary UI components including node panel and default configurations.
- Enhanced workflow constants and enums to include File Upload.
- Updated error handling for file upload operations.
- Integrated File Upload into existing workflow structure, ensuring compatibility with variable handling and output management.
- Added translations for new File Upload features in workflow.json.
This commit is contained in:
Harry
2026-02-10 20:46:38 +08:00
parent a5271baea0
commit 2da770cdbd
26 changed files with 633 additions and 37 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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(

View File

@ -64,6 +64,7 @@ class NodeType(StrEnum):
TRIGGER_PLUGIN = "trigger-plugin"
HUMAN_INPUT = "human-input"
COMMAND = "command"
FILE_UPLOAD = "file-upload"
GROUP = "group"
@property

View File

@ -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"),

View File

@ -0,0 +1,4 @@
from .entities import FileUploadNodeData
from .node import FileUploadNode
__all__ = ["FileUploadNode", "FileUploadNodeData"]

View File

@ -0,0 +1,7 @@
from collections.abc import Sequence
from core.workflow.nodes.base import BaseNodeData
class FileUploadNodeData(BaseNodeData):
variable_selector: Sequence[str]

View File

@ -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."""

View File

@ -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}")

View File

@ -58,6 +58,7 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
[BlockEnum.LLM]: Llm,
[BlockEnum.Code]: Code,
[BlockEnum.Command]: WindowCursor,
[BlockEnum.FileUpload]: DocsExtractor,
[BlockEnum.End]: End,
[BlockEnum.IfElse]: IfElse,
[BlockEnum.HttpRequest]: Http,
@ -100,6 +101,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[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',

View File

@ -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,

View File

@ -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',

View File

@ -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,

View File

@ -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

View File

@ -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, any> = {
[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, any> = {
[BlockEnum.KnowledgeRetrieval]: undefined,
[BlockEnum.Code]: undefined,
[BlockEnum.Command]: undefined,
[BlockEnum.FileUpload]: undefined,
[BlockEnum.TemplateTransform]: undefined,
[BlockEnum.QuestionClassifier]: undefined,
[BlockEnum.HttpRequest]: undefined,

View File

@ -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<Record<BlockEnum, Function>> = {
[BlockEnum.ParameterExtractor]: checkParameterExtractorValid,
[BlockEnum.Iteration]: checkIterationValid,
[BlockEnum.DocExtractor]: checkDocumentExtractorValid,
[BlockEnum.FileUpload]: checkFileUploadValid,
[BlockEnum.Loop]: checkLoopValid,
[BlockEnum.HumanInput]: checkHumanInputValid,
}

View File

@ -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<string, ComponentType<any>> = {
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
[BlockEnum.Command]: CommandNode,
[BlockEnum.FileUpload]: FileUploadNode,
[BlockEnum.Group]: GroupNode,
}
@ -114,5 +117,6 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
[BlockEnum.Command]: CommandPanel,
[BlockEnum.FileUpload]: FileUploadPanel,
[BlockEnum.Group]: GroupPanel,
}

View File

@ -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<FileUploadNodeType> = {
metaData,
defaultValue: {
variable_selector: [],
is_array_file: false,
},
checkValid(payload: FileUploadNodeType, t: (key: string, options?: Record<string, unknown>) => 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

View File

@ -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<NodeProps<FileUploadNodeType>> = () => {
return (
<div></div>
)
}
export default React.memo(Node)

View File

@ -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<NodePanelProps<FileUploadNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const {
readOnly,
inputs,
handleVarChanges,
filterVar,
} = useConfig(id, data)
return (
<div className="mt-2">
<div className="space-y-4 px-4 pb-4">
<Field
title={t(`${i18nPrefix}.inputVar`, { ns: 'workflow' })}
required
>
<VarReferencePicker
readonly={readOnly}
nodeId={id}
isShowNodeName
value={inputs.variable_selector || []}
onChange={handleVarChanges}
filterVar={filterVar}
typePlaceHolder="File | Array[File]"
/>
</Field>
</div>
<Split />
<div>
<OutputVars>
<>
<VarItem
name="sandbox_path"
type={inputs.is_array_file ? 'array[string]' : 'string'}
description={t(`${i18nPrefix}.outputVars.sandboxPath`, { ns: 'workflow' })}
/>
<VarItem
name="file_name"
type={inputs.is_array_file ? 'array[string]' : 'string'}
description={t(`${i18nPrefix}.outputVars.fileName`, { ns: 'workflow' })}
/>
</>
</OutputVars>
</div>
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,6 @@
import type { CommonNodeType, ValueSelector } from '@/app/components/workflow/types'
export type FileUploadNodeType = CommonNodeType & {
variable_selector: ValueSelector
is_array_file: boolean
}

View File

@ -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<FileUploadNodeType>(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

View File

@ -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<string, unknown>
runInputDataRef: RefObject<Record<string, unknown>>
getInputVars: (textList: string[]) => InputVar[]
setRunInputData: (data: Record<string, unknown>) => 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<string, unknown>) => 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

View File

@ -59,6 +59,7 @@ export enum BlockEnum {
TriggerWebhook = 'trigger-webhook',
TriggerPlugin = 'trigger-plugin',
Command = 'command',
FileUpload = 'file-upload',
}
export enum ControlMode {

View File

@ -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

View File

@ -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",