diff --git a/api/.env.example b/api/.env.example index 22dd7600ed..f5bfa72254 100644 --- a/api/.env.example +++ b/api/.env.example @@ -371,6 +371,12 @@ UPLOAD_IMAGE_FILE_SIZE_LIMIT=10 UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 +# Comma-separated list of file extensions blocked from upload for security reasons. +# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll). +# Empty by default to allow all file types. +# Recommended: exe,bat,cmd,com,scr,vbs,ps1,msi,dll +UPLOAD_FILE_EXTENSION_BLACKLIST= + # Model configuration MULTIMODAL_SEND_FORMAT=base64 PROMPT_GENERATION_MAX_TOKENS=512 diff --git a/api/commands.py b/api/commands.py index 084fd576a1..8698ec3f97 100644 --- a/api/commands.py +++ b/api/commands.py @@ -1601,7 +1601,7 @@ def transform_datasource_credentials(): "integration_secret": api_key, } datasource_provider = DatasourceProvider( - provider="jina", + provider="jinareader", tenant_id=tenant_id, plugin_id=jina_plugin_id, auth_type=api_key_credential_type.value, diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 59fc0b9661..843e6b6f70 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -331,6 +331,31 @@ class FileUploadConfig(BaseSettings): default=10, ) + inner_UPLOAD_FILE_EXTENSION_BLACKLIST: str = Field( + description=( + "Comma-separated list of file extensions that are blocked from upload. " + "Extensions should be lowercase without dots (e.g., 'exe,bat,sh,dll'). " + "Empty by default to allow all file types." + ), + validation_alias=AliasChoices("UPLOAD_FILE_EXTENSION_BLACKLIST"), + default="", + ) + + @computed_field # type: ignore[misc] + @property + def UPLOAD_FILE_EXTENSION_BLACKLIST(self) -> set[str]: + """ + Parse and return the blacklist as a set of lowercase extensions. + Returns an empty set if no blacklist is configured. + """ + if not self.inner_UPLOAD_FILE_EXTENSION_BLACKLIST: + return set() + return { + ext.strip().lower().strip(".") + for ext in self.inner_UPLOAD_FILE_EXTENSION_BLACKLIST.split(",") + if ext.strip() + } + class HttpConfig(BaseSettings): """ diff --git a/api/configs/middleware/vdb/weaviate_config.py b/api/configs/middleware/vdb/weaviate_config.py index 6a79412ab8..aa81c870f6 100644 --- a/api/configs/middleware/vdb/weaviate_config.py +++ b/api/configs/middleware/vdb/weaviate_config.py @@ -22,6 +22,11 @@ class WeaviateConfig(BaseSettings): default=True, ) + WEAVIATE_GRPC_ENDPOINT: str | None = Field( + description="URL of the Weaviate gRPC server (e.g., 'grpc://localhost:50051' or 'grpcs://weaviate.example.com:443')", + default=None, + ) + WEAVIATE_BATCH_SIZE: PositiveInt = Field( description="Number of objects to be processed in a single batch operation (default is 100)", default=100, diff --git a/api/controllers/common/errors.py b/api/controllers/common/errors.py index 6e2ea952fc..252cf3549a 100644 --- a/api/controllers/common/errors.py +++ b/api/controllers/common/errors.py @@ -25,6 +25,12 @@ class UnsupportedFileTypeError(BaseHTTPException): code = 415 +class BlockedFileExtensionError(BaseHTTPException): + error_code = "file_extension_blocked" + description = "The file extension is blocked for security reasons." + code = 400 + + class TooManyFilesError(BaseHTTPException): error_code = "too_many_files" description = "Only one file is allowed." diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 56771ed420..5f41b65e88 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -102,7 +102,18 @@ class DraftWorkflowApi(Resource): }, ) ) - @api.response(200, "Draft workflow synced successfully", workflow_fields) + @api.response( + 200, + "Draft workflow synced successfully", + api.model( + "SyncDraftWorkflowResponse", + { + "result": fields.String, + "hash": fields.String, + "updated_at": fields.String, + }, + ), + ) @api.response(400, "Invalid workflow configuration") @api.response(403, "Permission denied") @edit_permission_required diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 36fcd460bb..fdd7c2f479 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -8,6 +8,7 @@ import services from configs import dify_config from constants import DOCUMENT_EXTENSIONS from controllers.common.errors import ( + BlockedFileExtensionError, FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, @@ -83,6 +84,8 @@ class FileApi(Resource): raise FileTooLargeError(file_too_large_error.description) except services.errors.file.UnsupportedFileTypeError: raise UnsupportedFileTypeError() + except services.errors.file.BlockedFileExtensionError as blocked_extension_error: + raise BlockedFileExtensionError(blocked_extension_error.description) return upload_file, 201 diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index fe1e2c419b..319b7bd780 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -67,6 +67,7 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe kwargs["app_model"] = app_model + # If caller needs end-user context, attach EndUser to current_user if fetch_user_arg: if fetch_user_arg.fetch_from == WhereisUserArg.QUERY: user_id = request.args.get("user") @@ -75,7 +76,6 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe elif fetch_user_arg.fetch_from == WhereisUserArg.FORM: user_id = request.form.get("user") else: - # use default-user user_id = None if not user_id and fetch_user_arg.required: @@ -90,6 +90,28 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe # Set EndUser as current logged-in user for flask_login.current_user current_app.login_manager._update_request_context_with_user(end_user) # type: ignore user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore + else: + # For service API without end-user context, ensure an Account is logged in + # so services relying on current_account_with_tenant() work correctly. + tenant_owner_info = ( + db.session.query(Tenant, Account) + .join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id) + .join(Account, TenantAccountJoin.account_id == Account.id) + .where( + Tenant.id == app_model.tenant_id, + TenantAccountJoin.role == "owner", + Tenant.status == TenantStatus.NORMAL, + ) + .one_or_none() + ) + + if tenant_owner_info: + tenant_model, account = tenant_owner_info + account.current_tenant = tenant_model + current_app.login_manager._update_request_context_with_user(account) # type: ignore + user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore + else: + raise Unauthorized("Tenant owner account not found or tenant is not active.") return view_func(*args, **kwargs) diff --git a/api/core/app/task_pipeline/message_cycle_manager.py b/api/core/app/task_pipeline/message_cycle_manager.py index 7a384e5c92..e7daeb4a32 100644 --- a/api/core/app/task_pipeline/message_cycle_manager.py +++ b/api/core/app/task_pipeline/message_cycle_manager.py @@ -140,7 +140,27 @@ class MessageCycleManager: if not self._application_generate_entity.app_config.additional_features: raise ValueError("Additional features not found") if self._application_generate_entity.app_config.additional_features.show_retrieve_source: - self._task_state.metadata.retriever_resources = event.retriever_resources + merged_resources = [r for r in self._task_state.metadata.retriever_resources or [] if r] + existing_ids = {(r.dataset_id, r.document_id) for r in merged_resources if r.dataset_id and r.document_id} + + # Add new unique resources from the event + for resource in event.retriever_resources or []: + if not resource: + continue + + is_duplicate = ( + resource.dataset_id + and resource.document_id + and (resource.dataset_id, resource.document_id) in existing_ids + ) + + if not is_duplicate: + merged_resources.append(resource) + + for i, resource in enumerate(merged_resources, 1): + resource.position = i + + self._task_state.metadata.retriever_resources = merged_resources def message_file_to_stream_response(self, event: QueueMessageFileEvent) -> MessageFileStreamResponse | None: """ diff --git a/api/core/helper/code_executor/javascript/javascript_transformer.py b/api/core/helper/code_executor/javascript/javascript_transformer.py index 62489cdf29..e28f027a3a 100644 --- a/api/core/helper/code_executor/javascript/javascript_transformer.py +++ b/api/core/helper/code_executor/javascript/javascript_transformer.py @@ -6,10 +6,7 @@ from core.helper.code_executor.template_transformer import TemplateTransformer class NodeJsTemplateTransformer(TemplateTransformer): @classmethod def get_runner_script(cls) -> str: - runner_script = dedent( - f""" - // declare main function - {cls._code_placeholder} + runner_script = dedent(f""" {cls._code_placeholder} // decode and prepare input object var inputs_obj = JSON.parse(Buffer.from('{cls._inputs_placeholder}', 'base64').toString('utf-8')) @@ -21,6 +18,5 @@ class NodeJsTemplateTransformer(TemplateTransformer): var output_json = JSON.stringify(output_obj) var result = `<>${{output_json}}<>` console.log(result) - """ - ) + """) return runner_script diff --git a/api/core/helper/code_executor/python3/python3_transformer.py b/api/core/helper/code_executor/python3/python3_transformer.py index 836fd273ae..ee866eeb81 100644 --- a/api/core/helper/code_executor/python3/python3_transformer.py +++ b/api/core/helper/code_executor/python3/python3_transformer.py @@ -6,9 +6,7 @@ from core.helper.code_executor.template_transformer import TemplateTransformer class Python3TemplateTransformer(TemplateTransformer): @classmethod def get_runner_script(cls) -> str: - runner_script = dedent(f""" - # declare main function - {cls._code_placeholder} + runner_script = dedent(f""" {cls._code_placeholder} import json from base64 import b64decode diff --git a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py index dceade0af9..39eb91e50e 100644 --- a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py +++ b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py @@ -39,11 +39,13 @@ class WeaviateConfig(BaseModel): Attributes: endpoint: Weaviate server endpoint URL + grpc_endpoint: Optional Weaviate gRPC server endpoint URL api_key: Optional API key for authentication batch_size: Number of objects to batch per insert operation """ endpoint: str + grpc_endpoint: str | None = None api_key: str | None = None batch_size: int = 100 @@ -88,9 +90,22 @@ class WeaviateVector(BaseVector): http_secure = p.scheme == "https" http_port = p.port or (443 if http_secure else 80) - grpc_host = host - grpc_secure = http_secure - grpc_port = 443 if grpc_secure else 50051 + # Parse gRPC configuration + if config.grpc_endpoint: + # Urls without scheme won't be parsed correctly in some python verions, + # see https://bugs.python.org/issue27657 + grpc_endpoint_with_scheme = ( + config.grpc_endpoint if "://" in config.grpc_endpoint else f"grpc://{config.grpc_endpoint}" + ) + grpc_p = urlparse(grpc_endpoint_with_scheme) + grpc_host = grpc_p.hostname or "localhost" + grpc_port = grpc_p.port or (443 if grpc_p.scheme == "grpcs" else 50051) + grpc_secure = grpc_p.scheme == "grpcs" + else: + # Infer from HTTP endpoint as fallback + grpc_host = host + grpc_secure = http_secure + grpc_port = 443 if grpc_secure else 50051 client = weaviate.connect_to_custom( http_host=host, @@ -432,6 +447,7 @@ class WeaviateVectorFactory(AbstractVectorFactory): collection_name=collection_name, config=WeaviateConfig( endpoint=dify_config.WEAVIATE_ENDPOINT or "", + grpc_endpoint=dify_config.WEAVIATE_GRPC_ENDPOINT or "", api_key=dify_config.WEAVIATE_API_KEY, batch_size=dify_config.WEAVIATE_BATCH_SIZE, ), diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index 82616596f8..8ca4eabb7a 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -210,12 +210,13 @@ class Tool(ABC): meta=meta, ) - def create_json_message(self, object: dict) -> ToolInvokeMessage: + def create_json_message(self, object: dict, suppress_output: bool = False) -> ToolInvokeMessage: """ create a json message """ return ToolInvokeMessage( - type=ToolInvokeMessage.MessageType.JSON, message=ToolInvokeMessage.JsonMessage(json_object=object) + type=ToolInvokeMessage.MessageType.JSON, + message=ToolInvokeMessage.JsonMessage(json_object=object, suppress_output=suppress_output), ) def create_variable_message( diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py index 15a4f0aafd..5b385f1bb2 100644 --- a/api/core/tools/entities/tool_entities.py +++ b/api/core/tools/entities/tool_entities.py @@ -129,6 +129,7 @@ class ToolInvokeMessage(BaseModel): class JsonMessage(BaseModel): json_object: dict + suppress_output: bool = Field(default=False, description="Whether to suppress JSON output in result string") class BlobMessage(BaseModel): blob: bytes diff --git a/api/core/tools/tool_engine.py b/api/core/tools/tool_engine.py index 92d441b5ac..13fd579e20 100644 --- a/api/core/tools/tool_engine.py +++ b/api/core/tools/tool_engine.py @@ -245,6 +245,9 @@ class ToolEngine: + "you do not need to create it, just tell the user to check it now." ) elif response.type == ToolInvokeMessage.MessageType.JSON: + json_message = cast(ToolInvokeMessage.JsonMessage, response.message) + if json_message.suppress_output: + continue json_parts.append( json.dumps( safe_json_value(cast(ToolInvokeMessage.JsonMessage, response.message).json_object), diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py index 2cd46647a0..5703c19c88 100644 --- a/api/core/tools/workflow_as_tool/tool.py +++ b/api/core/tools/workflow_as_tool/tool.py @@ -117,7 +117,7 @@ class WorkflowTool(Tool): self._latest_usage = self._derive_usage_from_result(data) yield self.create_text_message(json.dumps(outputs, ensure_ascii=False)) - yield self.create_json_message(outputs) + yield self.create_json_message(outputs, suppress_output=True) @property def latest_usage(self) -> LLMUsage: diff --git a/api/services/errors/file.py b/api/services/errors/file.py index 29f3f44eec..bf9d65a25b 100644 --- a/api/services/errors/file.py +++ b/api/services/errors/file.py @@ -11,3 +11,7 @@ class FileTooLargeError(BaseServiceError): class UnsupportedFileTypeError(BaseServiceError): pass + + +class BlockedFileExtensionError(BaseServiceError): + description = "File extension '{extension}' is not allowed for security reasons" diff --git a/api/services/file_service.py b/api/services/file_service.py index dd6a829ea2..b0c5a32c9f 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -23,7 +23,7 @@ from models import Account from models.enums import CreatorUserRole from models.model import EndUser, UploadFile -from .errors.file import FileTooLargeError, UnsupportedFileTypeError +from .errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError PREVIEW_WORDS_LIMIT = 3000 @@ -59,6 +59,10 @@ class FileService: if len(filename) > 200: filename = filename.split(".")[0][:200] + "." + extension + # check if extension is in blacklist + if extension and extension in dify_config.UPLOAD_FILE_EXTENSION_BLACKLIST: + raise BlockedFileExtensionError(f"File extension '.{extension}' is not allowed for security reasons") + if source == "datasets" and extension not in DOCUMENT_EXTENSIONS: raise UnsupportedFileTypeError() diff --git a/api/tests/test_containers_integration_tests/services/test_file_service.py b/api/tests/test_containers_integration_tests/services/test_file_service.py index 4c94e42f3e..93516a0030 100644 --- a/api/tests/test_containers_integration_tests/services/test_file_service.py +++ b/api/tests/test_containers_integration_tests/services/test_file_service.py @@ -11,7 +11,7 @@ from configs import dify_config from models import Account, Tenant from models.enums import CreatorUserRole from models.model import EndUser, UploadFile -from services.errors.file import FileTooLargeError, UnsupportedFileTypeError +from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError from services.file_service import FileService @@ -943,3 +943,150 @@ class TestFileService: # Should have the signed URL when source_url is empty assert upload_file2.source_url == "https://example.com/signed-url" + + # Test file extension blacklist + def test_upload_file_blocked_extension( + self, db_session_with_containers, engine, mock_external_service_dependencies + ): + """ + Test file upload with blocked extension. + """ + fake = Faker() + account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies) + + # Mock blacklist configuration by patching the inner field + with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat,sh"): + filename = "malware.exe" + content = b"test content" + mimetype = "application/x-msdownload" + + with pytest.raises(BlockedFileExtensionError): + FileService(engine).upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=account, + ) + + def test_upload_file_blocked_extension_case_insensitive( + self, db_session_with_containers, engine, mock_external_service_dependencies + ): + """ + Test file upload with blocked extension (case insensitive). + """ + fake = Faker() + account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies) + + # Mock blacklist configuration by patching the inner field + with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat"): + # Test with uppercase extension + filename = "malware.EXE" + content = b"test content" + mimetype = "application/x-msdownload" + + with pytest.raises(BlockedFileExtensionError): + FileService(engine).upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=account, + ) + + def test_upload_file_not_in_blacklist(self, db_session_with_containers, engine, mock_external_service_dependencies): + """ + Test file upload with extension not in blacklist. + """ + fake = Faker() + account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies) + + # Mock blacklist configuration by patching the inner field + with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat,sh"): + filename = "document.pdf" + content = b"test content" + mimetype = "application/pdf" + + upload_file = FileService(engine).upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=account, + ) + + assert upload_file is not None + assert upload_file.name == filename + assert upload_file.extension == "pdf" + + def test_upload_file_empty_blacklist(self, db_session_with_containers, engine, mock_external_service_dependencies): + """ + Test file upload with empty blacklist (default behavior). + """ + fake = Faker() + account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies) + + # Mock empty blacklist configuration by patching the inner field + with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", ""): + # Should allow all file types when blacklist is empty + filename = "script.sh" + content = b"#!/bin/bash\necho test" + mimetype = "application/x-sh" + + upload_file = FileService(engine).upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=account, + ) + + assert upload_file is not None + assert upload_file.extension == "sh" + + def test_upload_file_multiple_blocked_extensions( + self, db_session_with_containers, engine, mock_external_service_dependencies + ): + """ + Test file upload with multiple blocked extensions. + """ + fake = Faker() + account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies) + + # Mock blacklist with multiple extensions by patching the inner field + blacklist_str = "exe,bat,cmd,com,scr,vbs,ps1,msi,dll" + with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", blacklist_str): + for ext in blacklist_str.split(","): + filename = f"malware.{ext}" + content = b"test content" + mimetype = "application/octet-stream" + + with pytest.raises(BlockedFileExtensionError): + FileService(engine).upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=account, + ) + + def test_upload_file_no_extension_with_blacklist( + self, db_session_with_containers, engine, mock_external_service_dependencies + ): + """ + Test file upload with no extension when blacklist is configured. + """ + fake = Faker() + account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies) + + # Mock blacklist configuration by patching the inner field + with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat"): + # Files with no extension should not be blocked + filename = "README" + content = b"test content" + mimetype = "text/plain" + + upload_file = FileService(engine).upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=account, + ) + + assert upload_file is not None + assert upload_file.extension == "" diff --git a/api/tests/unit_tests/core/helper/code_executor/__init__.py b/api/tests/unit_tests/core/helper/code_executor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/helper/code_executor/javascript/__init__.py b/api/tests/unit_tests/core/helper/code_executor/javascript/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/helper/code_executor/javascript/test_javascript_transformer.py b/api/tests/unit_tests/core/helper/code_executor/javascript/test_javascript_transformer.py new file mode 100644 index 0000000000..03f37756d7 --- /dev/null +++ b/api/tests/unit_tests/core/helper/code_executor/javascript/test_javascript_transformer.py @@ -0,0 +1,12 @@ +from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider +from core.helper.code_executor.javascript.javascript_transformer import NodeJsTemplateTransformer + + +def test_get_runner_script(): + code = JavascriptCodeProvider.get_default_code() + inputs = {"arg1": "hello, ", "arg2": "world!"} + script = NodeJsTemplateTransformer.assemble_runner_script(code, inputs) + script_lines = script.splitlines() + code_lines = code.splitlines() + # Check that the first lines of script are exactly the same as code + assert script_lines[: len(code_lines)] == code_lines diff --git a/api/tests/unit_tests/core/helper/code_executor/python3/__init__.py b/api/tests/unit_tests/core/helper/code_executor/python3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/core/helper/code_executor/python3/test_python3_transformer.py b/api/tests/unit_tests/core/helper/code_executor/python3/test_python3_transformer.py new file mode 100644 index 0000000000..1166cb8892 --- /dev/null +++ b/api/tests/unit_tests/core/helper/code_executor/python3/test_python3_transformer.py @@ -0,0 +1,12 @@ +from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider +from core.helper.code_executor.python3.python3_transformer import Python3TemplateTransformer + + +def test_get_runner_script(): + code = Python3CodeProvider.get_default_code() + inputs = {"arg1": "hello, ", "arg2": "world!"} + script = Python3TemplateTransformer.assemble_runner_script(code, inputs) + script_lines = script.splitlines() + code_lines = code.splitlines() + # Check that the first lines of script are exactly the same as code + assert script_lines[: len(code_lines)] == code_lines diff --git a/docker/.env.example b/docker/.env.example index 386e328d99..c19084ebbf 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -492,6 +492,7 @@ VECTOR_INDEX_NAME_PREFIX=Vector_index # The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`. WEAVIATE_ENDPOINT=http://weaviate:8080 WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih +WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051 # The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`. QDRANT_URL=http://qdrant:6333 @@ -762,6 +763,12 @@ UPLOAD_FILE_SIZE_LIMIT=15 # The maximum number of files that can be uploaded at a time, default 5. UPLOAD_FILE_BATCH_LIMIT=5 +# Comma-separated list of file extensions blocked from upload for security reasons. +# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll). +# Empty by default to allow all file types. +# Recommended: exe,bat,cmd,com,scr,vbs,ps1,msi,dll +UPLOAD_FILE_EXTENSION_BLACKLIST= + # ETL type, support: `dify`, `Unstructured` # `dify` Dify's proprietary file extraction scheme # `Unstructured` Unstructured.io file extraction scheme diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index cc69c13ce2..1ff33a94b5 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -157,6 +157,7 @@ x-shared-env: &shared-api-worker-env VECTOR_INDEX_NAME_PREFIX: ${VECTOR_INDEX_NAME_PREFIX:-Vector_index} WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080} WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} + WEAVIATE_GRPC_ENDPOINT: ${WEAVIATE_GRPC_ENDPOINT:-grpc://weaviate:50051} QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456} QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20} @@ -353,6 +354,7 @@ x-shared-env: &shared-api-worker-env CLICKZETTA_VECTOR_DISTANCE_FUNCTION: ${CLICKZETTA_VECTOR_DISTANCE_FUNCTION:-cosine_distance} UPLOAD_FILE_SIZE_LIMIT: ${UPLOAD_FILE_SIZE_LIMIT:-15} UPLOAD_FILE_BATCH_LIMIT: ${UPLOAD_FILE_BATCH_LIMIT:-5} + UPLOAD_FILE_EXTENSION_BLACKLIST: ${UPLOAD_FILE_EXTENSION_BLACKLIST:-} ETL_TYPE: ${ETL_TYPE:-dify} UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-} UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-} diff --git a/web/__tests__/check-i18n.test.ts b/web/__tests__/check-i18n.test.ts index b579f22d4b..7773edcdbb 100644 --- a/web/__tests__/check-i18n.test.ts +++ b/web/__tests__/check-i18n.test.ts @@ -759,4 +759,104 @@ export default translation` expect(result).not.toContain('Zbuduj inteligentnego agenta') }) }) + + describe('Performance and Scalability', () => { + it('should handle large translation files efficiently', async () => { + // Create a large translation file with 1000 keys + const largeContent = `const translation = { +${Array.from({ length: 1000 }, (_, i) => ` key${i}: 'value${i}',`).join('\n')} +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'large.ts'), largeContent) + + const startTime = Date.now() + const keys = await getKeysFromLanguage('en-US') + const endTime = Date.now() + + expect(keys.length).toBe(1000) + expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second + }) + + it('should handle multiple translation files concurrently', async () => { + // Create multiple files + for (let i = 0; i < 10; i++) { + const content = `const translation = { + key${i}: 'value${i}', + nested${i}: { + subkey: 'subvalue' + } +} + +export default translation` + fs.writeFileSync(path.join(testEnDir, `file${i}.ts`), content) + } + + const startTime = Date.now() + const keys = await getKeysFromLanguage('en-US') + const endTime = Date.now() + + expect(keys.length).toBe(20) // 10 files * 2 keys each + expect(endTime - startTime).toBeLessThan(500) + }) + }) + + describe('Unicode and Internationalization', () => { + it('should handle Unicode characters in keys and values', async () => { + const unicodeContent = `const translation = { + '中文键': '中文值', + 'العربية': 'قيمة', + 'emoji_😀': 'value with emoji 🎉', + 'mixed_中文_English': 'mixed value' +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'unicode.ts'), unicodeContent) + + const keys = await getKeysFromLanguage('en-US') + + expect(keys).toContain('unicode.中文键') + expect(keys).toContain('unicode.العربية') + expect(keys).toContain('unicode.emoji_😀') + expect(keys).toContain('unicode.mixed_中文_English') + }) + + it('should handle RTL language files', async () => { + const rtlContent = `const translation = { + مرحبا: 'Hello', + العالم: 'World', + nested: { + مفتاح: 'key' + } +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'rtl.ts'), rtlContent) + + const keys = await getKeysFromLanguage('en-US') + + expect(keys).toContain('rtl.مرحبا') + expect(keys).toContain('rtl.العالم') + expect(keys).toContain('rtl.nested.مفتاح') + }) + }) + + describe('Error Recovery', () => { + it('should handle syntax errors in translation files gracefully', async () => { + const invalidContent = `const translation = { + validKey: 'valid value', + invalidKey: 'missing quote, + anotherKey: 'another value' +} + +export default translation` + + fs.writeFileSync(path.join(testEnDir, 'invalid.ts'), invalidContent) + + await expect(getKeysFromLanguage('en-US')).rejects.toThrow() + }) + }) }) diff --git a/web/__tests__/navigation-utils.test.ts b/web/__tests__/navigation-utils.test.ts index fa4986e63d..3eeba52943 100644 --- a/web/__tests__/navigation-utils.test.ts +++ b/web/__tests__/navigation-utils.test.ts @@ -286,4 +286,116 @@ describe('Navigation Utilities', () => { expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc') }) }) + + describe('Edge Cases and Error Handling', () => { + test('handles special characters in query parameters', () => { + Object.defineProperty(window, 'location', { + value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' }, + writable: true, + }) + + const path = createNavigationPath('/datasets/123/documents') + expect(path).toContain('hello+world') + expect(path).toContain('type%3Apdf') + expect(path).toContain('%E4%B8%AD%E6%96%87') + }) + + test('handles duplicate query parameters', () => { + Object.defineProperty(window, 'location', { + value: { search: '?tag=tag1&tag=tag2&tag=tag3' }, + writable: true, + }) + + const params = extractQueryParams(['tag']) + // URLSearchParams.get() returns the first value + expect(params.tag).toBe('tag1') + }) + + test('handles very long query strings', () => { + const longValue = 'a'.repeat(1000) + Object.defineProperty(window, 'location', { + value: { search: `?data=${longValue}` }, + writable: true, + }) + + const path = createNavigationPath('/datasets/123/documents') + expect(path).toContain(longValue) + expect(path.length).toBeGreaterThan(1000) + }) + + test('handles empty string values in query parameters', () => { + const path = createNavigationPathWithParams('/datasets/123/documents', { + page: 1, + keyword: '', + filter: '', + sort: 'name', + }) + + expect(path).toBe('/datasets/123/documents?page=1&sort=name') + expect(path).not.toContain('keyword=') + expect(path).not.toContain('filter=') + }) + + test('handles null and undefined values in mergeQueryParams', () => { + Object.defineProperty(window, 'location', { + value: { search: '?page=1&limit=10&keyword=test' }, + writable: true, + }) + + const merged = mergeQueryParams({ + keyword: null, + filter: undefined, + sort: 'name', + }) + const result = merged.toString() + + expect(result).toContain('page=1') + expect(result).toContain('limit=10') + expect(result).not.toContain('keyword') + expect(result).toContain('sort=name') + }) + + test('handles navigation with hash fragments', () => { + Object.defineProperty(window, 'location', { + value: { search: '?page=1', hash: '#section-2' }, + writable: true, + }) + + const path = createNavigationPath('/datasets/123/documents') + // Should preserve query params but not hash + expect(path).toBe('/datasets/123/documents?page=1') + }) + + test('handles malformed query strings gracefully', () => { + Object.defineProperty(window, 'location', { + value: { search: '?page=1&invalid&limit=10&=value&key=' }, + writable: true, + }) + + const params = extractQueryParams(['page', 'limit', 'invalid', 'key']) + expect(params.page).toBe('1') + expect(params.limit).toBe('10') + // Malformed params should be handled by URLSearchParams + expect(params.invalid).toBe('') // for `&invalid` + expect(params.key).toBe('') // for `&key=` + }) + }) + + describe('Performance Tests', () => { + test('handles large number of query parameters efficiently', () => { + const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&') + Object.defineProperty(window, 'location', { + value: { search: `?${manyParams}` }, + writable: true, + }) + + const startTime = Date.now() + const path = createNavigationPath('/datasets/123/documents') + const endTime = Date.now() + + expect(endTime - startTime).toBeLessThan(50) // Should be fast + expect(path).toContain('param0=value0') + expect(path).toContain('param49=value49') + }) + }) }) diff --git a/web/app/components/app-sidebar/app-operations.tsx b/web/app/components/app-sidebar/app-operations.tsx index 79c460419d..ab1069a29f 100644 --- a/web/app/components/app-sidebar/app-operations.tsx +++ b/web/app/components/app-sidebar/app-operations.tsx @@ -124,7 +124,7 @@ const AppOperations = ({ operations, gap }: { {t('common.operation.more')} - +
{moreOperations.map(item =>
{
{form.label}
{!form.required && ( -
{t('appDebug.variableTable.optional')}
+
{t('workflow.panel.optional')}
)}
)} diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx index dd65f0ce72..caf4e363ff 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/content.tsx @@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
{form.label}
{!form.required && ( -
{t('appDebug.variableTable.optional')}
+
{t('workflow.panel.optional')}
)}
)} diff --git a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx index f0af893f0d..8ab007e66b 100644 --- a/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx +++ b/web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useBoolean } from 'ahooks' import { produce } from 'immer' @@ -45,7 +45,13 @@ const OpeningSettingModal = ({ const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false) const [notIncludeKeys, setNotIncludeKeys] = useState([]) + const isSaveDisabled = useMemo(() => !tempValue.trim(), [tempValue]) + const handleSave = useCallback((ignoreVariablesCheck?: boolean) => { + // Prevent saving if opening statement is empty + if (isSaveDisabled) + return + if (!ignoreVariablesCheck) { const keys = getInputKeys(tempValue) const promptKeys = promptVariables.map(item => item.key) @@ -75,7 +81,7 @@ const OpeningSettingModal = ({ } }) onSave(newOpening) - }, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue]) + }, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue, isSaveDisabled]) const cancelAutoAddVar = useCallback(() => { hideConfirmAddVar() @@ -217,6 +223,7 @@ const OpeningSettingModal = ({ diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 3f4d4a6b06..9675123fe7 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -11,6 +11,7 @@ import type { FileEntity } from './types' import { useFileStore } from './store' import { fileUpload, + getFileUploadErrorMessage, getSupportFileType, isAllowedFileExtension, } from './utils' @@ -172,8 +173,9 @@ export const useFile = (fileConfig: FileUpload) => { onSuccessCallback: (res) => { handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, - onErrorCallback: () => { - notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') }) + onErrorCallback: (error?: any) => { + const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t) + notify({ type: 'error', message: errorMessage }) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, }, !!params.token) @@ -279,8 +281,9 @@ export const useFile = (fileConfig: FileUpload) => { onSuccessCallback: (res) => { handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) }, - onErrorCallback: () => { - notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') }) + onErrorCallback: (error?: any) => { + const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t) + notify({ type: 'error', message: errorMessage }) handleUpdateFile({ ...uploadingFile, progress: -1 }) }, }, !!params.token) diff --git a/web/app/components/base/file-uploader/utils.ts b/web/app/components/base/file-uploader/utils.ts index 9c217646ca..e0a1a0250f 100644 --- a/web/app/components/base/file-uploader/utils.ts +++ b/web/app/components/base/file-uploader/utils.ts @@ -7,11 +7,30 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types' import type { FileResponse } from '@/types/workflow' import { TransferMethod } from '@/types/app' +/** + * Get appropriate error message for file upload errors + * @param error - The error object from upload failure + * @param defaultMessage - Default error message to use if no specific error is matched + * @param t - Translation function + * @returns Localized error message + */ +export const getFileUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => { + const errorCode = error?.response?.code + + if (errorCode === 'forbidden') + return error?.response?.message + + if (errorCode === 'file_extension_blocked') + return t('common.fileUploader.fileExtensionBlocked') + + return defaultMessage +} + type FileUploadParams = { file: File onProgressCallback: (progress: number) => void onSuccessCallback: (res: { id: string }) => void - onErrorCallback: () => void + onErrorCallback: (error?: any) => void } type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void export const fileUpload: FileUpload = ({ @@ -37,8 +56,8 @@ export const fileUpload: FileUpload = ({ .then((res: { id: string }) => { onSuccessCallback(res) }) - .catch(() => { - onErrorCallback() + .catch((error) => { + onErrorCallback(error) }) } diff --git a/web/app/components/base/image-uploader/hooks.ts b/web/app/components/base/image-uploader/hooks.ts index 41074000a2..524e86cc1b 100644 --- a/web/app/components/base/image-uploader/hooks.ts +++ b/web/app/components/base/image-uploader/hooks.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react' import type { ClipboardEvent } from 'react' import { useParams } from 'next/navigation' import { useTranslation } from 'react-i18next' -import { imageUpload } from './utils' +import { getImageUploadErrorMessage, imageUpload } from './utils' import { useToastContext } from '@/app/components/base/toast' import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app' import type { ImageFile, VisionSettings } from '@/types/app' @@ -81,8 +81,9 @@ export const useImageFiles = () => { filesRef.current = newFiles setFiles(newFiles) }, - onErrorCallback: () => { - notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) + onErrorCallback: (error?: any) => { + const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t) + notify({ type: 'error', message: errorMessage }) const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)] filesRef.current = newFiles setFiles(newFiles) @@ -158,8 +159,9 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL onSuccessCallback: (res) => { onUpload({ ...imageFile, fileId: res.id, progress: 100 }) }, - onErrorCallback: () => { - notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) + onErrorCallback: (error?: any) => { + const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t) + notify({ type: 'error', message: errorMessage }) onUpload({ ...imageFile, progress: -1 }) }, }, !!params.token) diff --git a/web/app/components/base/image-uploader/utils.ts b/web/app/components/base/image-uploader/utils.ts index 0c1ada747d..3579d0541e 100644 --- a/web/app/components/base/image-uploader/utils.ts +++ b/web/app/components/base/image-uploader/utils.ts @@ -1,10 +1,29 @@ import { upload } from '@/service/base' +/** + * Get appropriate error message for image upload errors + * @param error - The error object from upload failure + * @param defaultMessage - Default error message to use if no specific error is matched + * @param t - Translation function + * @returns Localized error message + */ +export const getImageUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => { + const errorCode = error?.response?.code + + if (errorCode === 'forbidden') + return error?.response?.message + + if (errorCode === 'file_extension_blocked') + return t('common.fileUploader.fileExtensionBlocked') + + return defaultMessage +} + type ImageUploadParams = { file: File onProgressCallback: (progress: number) => void onSuccessCallback: (res: { id: string }) => void - onErrorCallback: () => void + onErrorCallback: (error?: any) => void } type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void export const imageUpload: ImageUpload = ({ @@ -30,7 +49,7 @@ export const imageUpload: ImageUpload = ({ .then((res: { id: string }) => { onSuccessCallback(res) }) - .catch(() => { - onErrorCallback() + .catch((error) => { + onErrorCallback(error) }) } diff --git a/web/app/components/custom/custom-web-app-brand/index.tsx b/web/app/components/custom/custom-web-app-brand/index.tsx index eb06265042..fee0bf75f7 100644 --- a/web/app/components/custom/custom-web-app-brand/index.tsx +++ b/web/app/components/custom/custom-web-app-brand/index.tsx @@ -16,7 +16,7 @@ import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { useProviderContext } from '@/context/provider-context' import { Plan } from '@/app/components/billing/type' -import { imageUpload } from '@/app/components/base/image-uploader/utils' +import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { useToastContext } from '@/app/components/base/toast' import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication' import { @@ -67,8 +67,9 @@ const CustomWebAppBrand = () => { setUploadProgress(100) setFileId(res.id) }, - onErrorCallback: () => { - notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) + onErrorCallback: (error?: any) => { + const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t) + notify({ type: 'error', message: errorMessage }) setUploadProgress(-1) }, }, false, '/workspaces/custom-config/webapp-logo/upload') diff --git a/web/app/components/datasets/create/file-uploader/index.tsx b/web/app/components/datasets/create/file-uploader/index.tsx index 75557b37c9..aee2192b6c 100644 --- a/web/app/components/datasets/create/file-uploader/index.tsx +++ b/web/app/components/datasets/create/file-uploader/index.tsx @@ -18,6 +18,7 @@ import { LanguagesSupported } from '@/i18n-config/language' import { IS_CE_EDITION } from '@/config' import { Theme } from '@/types/app' import useTheme from '@/hooks/use-theme' +import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils' type IFileUploaderProps = { fileList: FileItem[] @@ -134,7 +135,8 @@ const FileUploader = ({ return Promise.resolve({ ...completeFile }) }) .catch((e) => { - notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') }) + const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t) + notify({ type: 'error', message: errorMessage }) onFileUpdate(fileItem, -2, fileListRef.current) return Promise.resolve({ ...fileItem }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx index 361378362e..868621e1a3 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/index.tsx @@ -8,6 +8,7 @@ import cn from '@/utils/classnames' import type { CustomFile as File, FileItem } from '@/models/datasets' import { ToastContext } from '@/app/components/base/toast' import { upload } from '@/service/base' +import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils' import I18n from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' import { IS_CE_EDITION } from '@/config' @@ -154,7 +155,8 @@ const LocalFile = ({ return Promise.resolve({ ...completeFile }) }) .catch((e) => { - notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') }) + const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t) + notify({ type: 'error', message: errorMessage }) updateFile(fileItem, -2, fileListRef.current) return Promise.resolve({ ...fileItem }) }) diff --git a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx index 96cab11c9c..317db84c43 100644 --- a/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx +++ b/web/app/components/datasets/documents/detail/batch-modal/csv-uploader.tsx @@ -12,6 +12,7 @@ import { ToastContext } from '@/app/components/base/toast' import Button from '@/app/components/base/button' import type { FileItem } from '@/models/datasets' import { upload } from '@/service/base' +import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils' import useSWR from 'swr' import { fetchFileUploadConfig } from '@/service/common' import SimplePieChart from '@/app/components/base/simple-pie-chart' @@ -74,7 +75,8 @@ const CSVUploader: FC = ({ return Promise.resolve({ ...completeFile }) }) .catch((e) => { - notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') }) + const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t) + notify({ type: 'error', message: errorMessage }) const errorFile = { ...fileItem, progress: -2, diff --git a/web/app/components/goto-anything/actions/commands/feedback.tsx b/web/app/components/goto-anything/actions/commands/forum.tsx similarity index 54% rename from web/app/components/goto-anything/actions/commands/feedback.tsx rename to web/app/components/goto-anything/actions/commands/forum.tsx index cce0aeb5f4..66237cb348 100644 --- a/web/app/components/goto-anything/actions/commands/feedback.tsx +++ b/web/app/components/goto-anything/actions/commands/forum.tsx @@ -4,27 +4,27 @@ import { RiFeedbackLine } from '@remixicon/react' import i18n from '@/i18n-config/i18next-config' import { registerCommands, unregisterCommands } from './command-bus' -// Feedback command dependency types -type FeedbackDeps = Record +// Forum command dependency types +type ForumDeps = Record /** - * Feedback command - Opens GitHub feedback discussions + * Forum command - Opens Dify community forum */ -export const feedbackCommand: SlashCommandHandler = { - name: 'feedback', - description: 'Open feedback discussions', +export const forumCommand: SlashCommandHandler = { + name: 'forum', + description: 'Open Dify community forum', mode: 'direct', // Direct execution function execute: () => { - const url = 'https://github.com/langgenius/dify/discussions/categories/feedbacks' + const url = 'https://forum.dify.ai' window.open(url, '_blank', 'noopener,noreferrer') }, async search(args: string, locale: string = 'en') { return [{ - id: 'feedback', - title: i18n.t('common.userProfile.communityFeedback', { lng: locale }), + id: 'forum', + title: i18n.t('common.userProfile.forum', { lng: locale }), description: i18n.t('app.gotoAnything.actions.feedbackDesc', { lng: locale }) || 'Open community feedback discussions', type: 'command' as const, icon: ( @@ -32,20 +32,20 @@ export const feedbackCommand: SlashCommandHandler = {
), - data: { command: 'navigation.feedback', args: { url: 'https://github.com/langgenius/dify/discussions/categories/feedbacks' } }, + data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } }, }] }, - register(_deps: FeedbackDeps) { + register(_deps: ForumDeps) { registerCommands({ - 'navigation.feedback': async (args) => { - const url = args?.url || 'https://github.com/langgenius/dify/discussions/categories/feedbacks' + 'navigation.forum': async (args) => { + const url = args?.url || 'https://forum.dify.ai' window.open(url, '_blank', 'noopener,noreferrer') }, }) }, unregister() { - unregisterCommands(['navigation.feedback']) + unregisterCommands(['navigation.forum']) }, } diff --git a/web/app/components/goto-anything/actions/commands/slash.tsx b/web/app/components/goto-anything/actions/commands/slash.tsx index e0d03d5019..b99215255f 100644 --- a/web/app/components/goto-anything/actions/commands/slash.tsx +++ b/web/app/components/goto-anything/actions/commands/slash.tsx @@ -7,7 +7,7 @@ import { useTheme } from 'next-themes' import { setLocaleOnClient } from '@/i18n-config' import { themeCommand } from './theme' import { languageCommand } from './language' -import { feedbackCommand } from './feedback' +import { forumCommand } from './forum' import { docsCommand } from './docs' import { communityCommand } from './community' import { accountCommand } from './account' @@ -34,7 +34,7 @@ export const registerSlashCommands = (deps: Record) => { // Register command handlers to the registry system with their respective dependencies slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) - slashCommandRegistry.register(feedbackCommand, {}) + slashCommandRegistry.register(forumCommand, {}) slashCommandRegistry.register(docsCommand, {}) slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(accountCommand, {}) @@ -44,7 +44,7 @@ export const unregisterSlashCommands = () => { // Remove command handlers from registry system (automatically calls each command's unregister method) slashCommandRegistry.unregister('theme') slashCommandRegistry.unregister('language') - slashCommandRegistry.unregister('feedback') + slashCommandRegistry.unregister('forum') slashCommandRegistry.unregister('docs') slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('account') diff --git a/web/app/components/header/account-dropdown/support.tsx b/web/app/components/header/account-dropdown/support.tsx index b165c5fcca..f354cc4ab0 100644 --- a/web/app/components/header/account-dropdown/support.tsx +++ b/web/app/components/header/account-dropdown/support.tsx @@ -1,5 +1,5 @@ import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' -import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiFeedbackLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react' +import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react' import { Fragment } from 'react' import Link from 'next/link' import { useTranslation } from 'react-i18next' @@ -86,10 +86,10 @@ export default function Support({ closeAccountDropdown }: SupportProps) { className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover', )} - href='https://github.com/langgenius/dify/discussions/categories/feedbacks' + href='https://forum.dify.ai/' target='_blank' rel='noopener noreferrer'> - -
{t('common.userProfile.communityFeedback')}
+ +
{t('common.userProfile.forum')}
diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx index ccd0d8be1b..9f326fa198 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header.tsx @@ -73,7 +73,7 @@ const DetailHeader = ({ const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { - installation_id, + id, source, tenant_id, version, @@ -197,7 +197,7 @@ const DetailHeader = ({ const handleDelete = useCallback(async () => { showDeleting() - const res = await uninstallPlugin(installation_id) + const res = await uninstallPlugin(id) hideDeleting() if (res.success) { hideDeleteConfirm() @@ -207,7 +207,7 @@ const DetailHeader = ({ if (PluginType.tool.includes(category)) invalidateAllToolProviders() } - }, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) + }, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) return (
@@ -351,7 +351,6 @@ const DetailHeader = ({ content={
{t(`${i18nPrefix}.deleteContentLeft`)}{label[locale]}{t(`${i18nPrefix}.deleteContentRight`)}
- {/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
} onCancel={hideDeleteConfirm} diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index d326fdf6e4..4b8444ab34 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -72,6 +72,8 @@ const PluginPage = ({ } }, [searchParams]) + const [uniqueIdentifier, setUniqueIdentifier] = useState(null) + const [dependencies, setDependencies] = useState([]) const bundleInfo = useMemo(() => { const info = searchParams.get(BUNDLE_INFO_KEY) @@ -99,6 +101,7 @@ const PluginPage = ({ useEffect(() => { (async () => { + setUniqueIdentifier(null) await sleep(100) if (packageId) { const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId)) @@ -108,6 +111,7 @@ const PluginPage = ({ version: version.version, icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`, }) + setUniqueIdentifier(packageId) showInstallFromMarketplace() return } @@ -283,10 +287,10 @@ const PluginPage = ({ )} { - isShowInstallFromMarketplace && ( + isShowInstallFromMarketplace && uniqueIdentifier && ( = ({ : promptConfig.prompt_variables.map(item => (
{item.type !== 'checkbox' && ( - +
+
{item.name}
+ {!item.required && {t('workflow.panel.optional')}} +
)}
{item.type === 'select' && ( @@ -115,7 +118,7 @@ const RunOnce: FC = ({ {item.type === 'string' && ( ) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN} @@ -124,7 +127,7 @@ const RunOnce: FC = ({ {item.type === 'paragraph' && (