mirror of
https://github.com/langgenius/dify.git
synced 2026-02-22 19:15:47 +08:00
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:
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -64,6 +64,7 @@ class NodeType(StrEnum):
|
||||
TRIGGER_PLUGIN = "trigger-plugin"
|
||||
HUMAN_INPUT = "human-input"
|
||||
COMMAND = "command"
|
||||
FILE_UPLOAD = "file-upload"
|
||||
GROUP = "group"
|
||||
|
||||
@property
|
||||
|
||||
@ -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"),
|
||||
|
||||
4
api/core/workflow/nodes/file_upload/__init__.py
Normal file
4
api/core/workflow/nodes/file_upload/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .entities import FileUploadNodeData
|
||||
from .node import FileUploadNode
|
||||
|
||||
__all__ = ["FileUploadNode", "FileUploadNodeData"]
|
||||
7
api/core/workflow/nodes/file_upload/entities.py
Normal file
7
api/core/workflow/nodes/file_upload/entities.py
Normal file
@ -0,0 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from core.workflow.nodes.base import BaseNodeData
|
||||
|
||||
|
||||
class FileUploadNodeData(BaseNodeData):
|
||||
variable_selector: Sequence[str]
|
||||
6
api/core/workflow/nodes/file_upload/exc.py
Normal file
6
api/core/workflow/nodes/file_upload/exc.py
Normal 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."""
|
||||
244
api/core/workflow/nodes/file_upload/node.py
Normal file
244
api/core/workflow/nodes/file_upload/node.py
Normal 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}")
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
35
web/app/components/workflow/nodes/file-upload/default.ts
Normal file
35
web/app/components/workflow/nodes/file-upload/default.ts
Normal 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
|
||||
12
web/app/components/workflow/nodes/file-upload/node.tsx
Normal file
12
web/app/components/workflow/nodes/file-upload/node.tsx
Normal 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)
|
||||
65
web/app/components/workflow/nodes/file-upload/panel.tsx
Normal file
65
web/app/components/workflow/nodes/file-upload/panel.tsx
Normal 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)
|
||||
6
web/app/components/workflow/nodes/file-upload/types.ts
Normal file
6
web/app/components/workflow/nodes/file-upload/types.ts
Normal 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
|
||||
}
|
||||
65
web/app/components/workflow/nodes/file-upload/use-config.ts
Normal file
65
web/app/components/workflow/nodes/file-upload/use-config.ts
Normal 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
|
||||
@ -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
|
||||
@ -59,6 +59,7 @@ export enum BlockEnum {
|
||||
TriggerWebhook = 'trigger-webhook',
|
||||
TriggerPlugin = 'trigger-plugin',
|
||||
Command = 'command',
|
||||
FileUpload = 'file-upload',
|
||||
}
|
||||
|
||||
export enum ControlMode {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
Reference in New Issue
Block a user