diff --git a/api/controllers/common/errors.py b/api/controllers/common/errors.py index 9f762b3135..6e2ea952fc 100644 --- a/api/controllers/common/errors.py +++ b/api/controllers/common/errors.py @@ -1,5 +1,7 @@ from werkzeug.exceptions import HTTPException +from libs.exception import BaseHTTPException + class FilenameNotExistsError(HTTPException): code = 400 @@ -9,3 +11,27 @@ class FilenameNotExistsError(HTTPException): class RemoteFileUploadError(HTTPException): code = 400 description = "Error uploading remote file." + + +class FileTooLargeError(BaseHTTPException): + error_code = "file_too_large" + description = "File size exceeded. {message}" + code = 413 + + +class UnsupportedFileTypeError(BaseHTTPException): + error_code = "unsupported_file_type" + description = "File type not allowed." + code = 415 + + +class TooManyFilesError(BaseHTTPException): + error_code = "too_many_files" + description = "Only one file is allowed." + code = 400 + + +class NoFileUploadedError(BaseHTTPException): + error_code = "no_file_uploaded" + description = "Please upload your file." + code = 400 diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py index ee6011cd65..493a9a52e2 100644 --- a/api/controllers/console/app/annotation.py +++ b/api/controllers/console/app/annotation.py @@ -3,9 +3,8 @@ from flask_login import current_user from flask_restful import Resource, marshal, marshal_with, reqparse from werkzeug.exceptions import Forbidden +from controllers.common.errors import NoFileUploadedError, TooManyFilesError from controllers.console import api -from controllers.console.app.error import NoFileUploadedError -from controllers.console.datasets.error import TooManyFilesError from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, diff --git a/api/controllers/console/app/error.py b/api/controllers/console/app/error.py index 1559f82d6e..fbd7901646 100644 --- a/api/controllers/console/app/error.py +++ b/api/controllers/console/app/error.py @@ -79,18 +79,6 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException): code = 400 -class NoFileUploadedError(BaseHTTPException): - error_code = "no_file_uploaded" - description = "Please upload your file." - code = 400 - - -class TooManyFilesError(BaseHTTPException): - error_code = "too_many_files" - description = "Only one file is allowed." - code = 400 - - class DraftWorkflowNotExist(BaseHTTPException): error_code = "draft_workflow_not_exist" description = "Draft workflow need to be initialized." diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index d4ce5921c2..680ac4a64c 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -27,7 +27,7 @@ from fields.conversation_fields import annotation_fields, message_detail_fields from libs.helper import uuid_value from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.login import login_required -from models.model import AppMode, Conversation, Message, MessageAnnotation +from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback from services.annotation_service import AppAnnotationService from services.errors.conversation import ConversationNotExistsError from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError @@ -124,17 +124,34 @@ class MessageFeedbackApi(Resource): parser.add_argument("rating", type=str, choices=["like", "dislike", None], location="json") args = parser.parse_args() - try: - MessageService.create_feedback( - app_model=app_model, - message_id=str(args["message_id"]), - user=current_user, - rating=args.get("rating"), - content=None, - ) - except MessageNotExistsError: + message_id = str(args["message_id"]) + + message = db.session.query(Message).filter(Message.id == message_id, Message.app_id == app_model.id).first() + + if not message: raise NotFound("Message Not Exists.") + feedback = message.admin_feedback + + if not args["rating"] and feedback: + db.session.delete(feedback) + elif args["rating"] and feedback: + feedback.rating = args["rating"] + elif not args["rating"] and not feedback: + raise ValueError("rating cannot be None when feedback not exists") + else: + feedback = MessageFeedback( + app_id=app_model.id, + conversation_id=message.conversation_id, + message_id=message.id, + rating=args["rating"], + from_source="admin", + from_account_id=current_user.id, + ) + db.session.add(feedback) + + db.session.commit() + return {"result": "success"} diff --git a/api/controllers/console/datasets/error.py b/api/controllers/console/datasets/error.py index 1fa6057a39..ac09ec16b2 100644 --- a/api/controllers/console/datasets/error.py +++ b/api/controllers/console/datasets/error.py @@ -1,30 +1,6 @@ from libs.exception import BaseHTTPException -class NoFileUploadedError(BaseHTTPException): - error_code = "no_file_uploaded" - description = "Please upload your file." - code = 400 - - -class TooManyFilesError(BaseHTTPException): - error_code = "too_many_files" - description = "Only one file is allowed." - code = 400 - - -class FileTooLargeError(BaseHTTPException): - error_code = "file_too_large" - description = "File size exceeded. {message}" - code = 413 - - -class UnsupportedFileTypeError(BaseHTTPException): - error_code = "unsupported_file_type" - description = "File type not allowed." - code = 415 - - class DatasetNotInitializedError(BaseHTTPException): error_code = "dataset_not_initialized" description = "The dataset is still being initialized or indexing. Please wait a moment." diff --git a/api/controllers/console/error.py b/api/controllers/console/error.py index 0a4dfe1c10..0645d63be5 100644 --- a/api/controllers/console/error.py +++ b/api/controllers/console/error.py @@ -76,30 +76,6 @@ class EmailSendIpLimitError(BaseHTTPException): code = 429 -class FileTooLargeError(BaseHTTPException): - error_code = "file_too_large" - description = "File size exceeded. {message}" - code = 413 - - -class UnsupportedFileTypeError(BaseHTTPException): - error_code = "unsupported_file_type" - description = "File type not allowed." - code = 415 - - -class TooManyFilesError(BaseHTTPException): - error_code = "too_many_files" - description = "Only one file is allowed." - code = 400 - - -class NoFileUploadedError(BaseHTTPException): - error_code = "no_file_uploaded" - description = "Please upload your file." - code = 400 - - class UnauthorizedAndForceLogout(BaseHTTPException): error_code = "unauthorized_and_force_logout" description = "Unauthorized and force logout." diff --git a/api/controllers/console/files.py b/api/controllers/console/files.py index 256ff24b3b..a87d270e9c 100644 --- a/api/controllers/console/files.py +++ b/api/controllers/console/files.py @@ -8,7 +8,13 @@ from werkzeug.exceptions import Forbidden import services from configs import dify_config from constants import DOCUMENT_EXTENSIONS -from controllers.common.errors import FilenameNotExistsError +from controllers.common.errors import ( + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, + UnsupportedFileTypeError, +) from controllers.console.wraps import ( account_initialization_required, cloud_edition_billing_resource_check, @@ -18,13 +24,6 @@ from fields.file_fields import file_fields, upload_config_fields from libs.login import login_required from services.file_service import FileService -from .error import ( - FileTooLargeError, - NoFileUploadedError, - TooManyFilesError, - UnsupportedFileTypeError, -) - PREVIEW_WORDS_LIMIT = 3000 diff --git a/api/controllers/console/remote_files.py b/api/controllers/console/remote_files.py index b8cf019e4f..c356113c40 100644 --- a/api/controllers/console/remote_files.py +++ b/api/controllers/console/remote_files.py @@ -7,18 +7,17 @@ from flask_restful import Resource, marshal_with, reqparse import services from controllers.common import helpers -from controllers.common.errors import RemoteFileUploadError +from controllers.common.errors import ( + FileTooLargeError, + RemoteFileUploadError, + UnsupportedFileTypeError, +) from core.file import helpers as file_helpers from core.helper import ssrf_proxy from fields.file_fields import file_fields_with_signed_url, remote_file_info_fields from models.account import Account from services.file_service import FileService -from .error import ( - FileTooLargeError, - UnsupportedFileTypeError, -) - class RemoteFileInfoApi(Resource): @marshal_with(remote_file_info_fields) diff --git a/api/controllers/console/workspace/workspace.py b/api/controllers/console/workspace/workspace.py index 6012c9ecc8..f4f0078da7 100644 --- a/api/controllers/console/workspace/workspace.py +++ b/api/controllers/console/workspace/workspace.py @@ -7,15 +7,15 @@ from sqlalchemy import select from werkzeug.exceptions import Unauthorized import services -from controllers.common.errors import FilenameNotExistsError -from controllers.console import api -from controllers.console.admin import admin_required -from controllers.console.datasets.error import ( +from controllers.common.errors import ( + FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.console import api +from controllers.console.admin import admin_required from controllers.console.error import AccountNotLinkTenantError from controllers.console.wraps import ( account_initialization_required, diff --git a/api/controllers/files/error.py b/api/controllers/files/error.py deleted file mode 100644 index a7ce4cd6f7..0000000000 --- a/api/controllers/files/error.py +++ /dev/null @@ -1,7 +0,0 @@ -from libs.exception import BaseHTTPException - - -class UnsupportedFileTypeError(BaseHTTPException): - error_code = "unsupported_file_type" - description = "File type not allowed." - code = 415 diff --git a/api/controllers/files/image_preview.py b/api/controllers/files/image_preview.py index 46c19e1fbb..91f7b27d1d 100644 --- a/api/controllers/files/image_preview.py +++ b/api/controllers/files/image_preview.py @@ -5,8 +5,8 @@ from flask_restful import Resource, reqparse from werkzeug.exceptions import NotFound import services +from controllers.common.errors import UnsupportedFileTypeError from controllers.files import api -from controllers.files.error import UnsupportedFileTypeError from services.account_service import TenantService from services.file_service import FileService diff --git a/api/controllers/files/tool_files.py b/api/controllers/files/tool_files.py index 1c3430ef4f..d9c4e50511 100644 --- a/api/controllers/files/tool_files.py +++ b/api/controllers/files/tool_files.py @@ -4,8 +4,8 @@ from flask import Response from flask_restful import Resource, reqparse from werkzeug.exceptions import Forbidden, NotFound +from controllers.common.errors import UnsupportedFileTypeError from controllers.files import api -from controllers.files.error import UnsupportedFileTypeError from core.tools.signature import verify_tool_file_signature from core.tools.tool_file_manager import ToolFileManager from models import db as global_db diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py index 15f93d2774..bcc72d131c 100644 --- a/api/controllers/files/upload.py +++ b/api/controllers/files/upload.py @@ -5,11 +5,13 @@ from flask_restful import Resource, marshal_with from werkzeug.exceptions import Forbidden import services +from controllers.common.errors import ( + FileTooLargeError, + UnsupportedFileTypeError, +) from controllers.console.wraps import setup_required from controllers.files import api -from controllers.files.error import UnsupportedFileTypeError from controllers.inner_api.plugin.wraps import get_user -from controllers.service_api.app.error import FileTooLargeError from core.file.helpers import verify_plugin_file_signature from core.tools.tool_file_manager import ToolFileManager from fields.file_fields import file_fields diff --git a/api/controllers/service_api/app/error.py b/api/controllers/service_api/app/error.py index ba705f71e2..0e04a04cb2 100644 --- a/api/controllers/service_api/app/error.py +++ b/api/controllers/service_api/app/error.py @@ -85,30 +85,6 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException): code = 400 -class NoFileUploadedError(BaseHTTPException): - error_code = "no_file_uploaded" - description = "Please upload your file." - code = 400 - - -class TooManyFilesError(BaseHTTPException): - error_code = "too_many_files" - description = "Only one file is allowed." - code = 400 - - -class FileTooLargeError(BaseHTTPException): - error_code = "file_too_large" - description = "File size exceeded. {message}" - code = 413 - - -class UnsupportedFileTypeError(BaseHTTPException): - error_code = "unsupported_file_type" - description = "File type not allowed." - code = 415 - - class FileNotFoundError(BaseHTTPException): error_code = "file_not_found" description = "The requested file was not found." diff --git a/api/controllers/service_api/app/file.py b/api/controllers/service_api/app/file.py index f09d07bcb6..37153ca5db 100644 --- a/api/controllers/service_api/app/file.py +++ b/api/controllers/service_api/app/file.py @@ -2,14 +2,14 @@ from flask import request from flask_restful import Resource, marshal_with import services -from controllers.common.errors import FilenameNotExistsError -from controllers.service_api import api -from controllers.service_api.app.error import ( +from controllers.common.errors import ( + FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.service_api import api from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token from fields.file_fields import file_fields from models.model import App, EndUser diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py index 2955d5d20d..d0354f7851 100644 --- a/api/controllers/service_api/dataset/document.py +++ b/api/controllers/service_api/dataset/document.py @@ -6,15 +6,15 @@ from sqlalchemy import desc, select from werkzeug.exceptions import Forbidden, NotFound import services -from controllers.common.errors import FilenameNotExistsError -from controllers.service_api import api -from controllers.service_api.app.error import ( +from controllers.common.errors import ( + FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, - ProviderNotInitializeError, TooManyFilesError, UnsupportedFileTypeError, ) +from controllers.service_api import api +from controllers.service_api.app.error import ProviderNotInitializeError from controllers.service_api.dataset.error import ( ArchivedDocumentImmutableError, DocumentIndexingError, diff --git a/api/controllers/service_api/dataset/error.py b/api/controllers/service_api/dataset/error.py index ecc47b40a1..e4214a16ad 100644 --- a/api/controllers/service_api/dataset/error.py +++ b/api/controllers/service_api/dataset/error.py @@ -1,30 +1,6 @@ from libs.exception import BaseHTTPException -class NoFileUploadedError(BaseHTTPException): - error_code = "no_file_uploaded" - description = "Please upload your file." - code = 400 - - -class TooManyFilesError(BaseHTTPException): - error_code = "too_many_files" - description = "Only one file is allowed." - code = 400 - - -class FileTooLargeError(BaseHTTPException): - error_code = "file_too_large" - description = "File size exceeded. {message}" - code = 413 - - -class UnsupportedFileTypeError(BaseHTTPException): - error_code = "unsupported_file_type" - description = "File type not allowed." - code = 415 - - class DatasetNotInitializedError(BaseHTTPException): error_code = "dataset_not_initialized" description = "The dataset is still being initialized or indexing. Please wait a moment." diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py index 036e11d5c5..196a27e348 100644 --- a/api/controllers/web/error.py +++ b/api/controllers/web/error.py @@ -97,30 +97,6 @@ class ProviderNotSupportSpeechToTextError(BaseHTTPException): code = 400 -class NoFileUploadedError(BaseHTTPException): - error_code = "no_file_uploaded" - description = "Please upload your file." - code = 400 - - -class TooManyFilesError(BaseHTTPException): - error_code = "too_many_files" - description = "Only one file is allowed." - code = 400 - - -class FileTooLargeError(BaseHTTPException): - error_code = "file_too_large" - description = "File size exceeded. {message}" - code = 413 - - -class UnsupportedFileTypeError(BaseHTTPException): - error_code = "unsupported_file_type" - description = "File type not allowed." - code = 415 - - class WebAppAuthRequiredError(BaseHTTPException): error_code = "web_sso_auth_required" description = "Web app authentication required." diff --git a/api/controllers/web/files.py b/api/controllers/web/files.py index 8e9317606e..0c30435825 100644 --- a/api/controllers/web/files.py +++ b/api/controllers/web/files.py @@ -2,8 +2,13 @@ from flask import request from flask_restful import marshal_with import services -from controllers.common.errors import FilenameNotExistsError -from controllers.web.error import FileTooLargeError, NoFileUploadedError, TooManyFilesError, UnsupportedFileTypeError +from controllers.common.errors import ( + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, + UnsupportedFileTypeError, +) from controllers.web.wraps import WebApiResource from fields.file_fields import file_fields from services.file_service import FileService diff --git a/api/controllers/web/remote_files.py b/api/controllers/web/remote_files.py index ae68df6bdc..4e19716c3d 100644 --- a/api/controllers/web/remote_files.py +++ b/api/controllers/web/remote_files.py @@ -5,15 +5,17 @@ from flask_restful import marshal_with, reqparse import services from controllers.common import helpers -from controllers.common.errors import RemoteFileUploadError +from controllers.common.errors import ( + FileTooLargeError, + RemoteFileUploadError, + UnsupportedFileTypeError, +) from controllers.web.wraps import WebApiResource from core.file import helpers as file_helpers from core.helper import ssrf_proxy from fields.file_fields import file_fields_with_signed_url, remote_file_info_fields from services.file_service import FileService -from .error import FileTooLargeError, UnsupportedFileTypeError - class RemoteFileInfoApi(WebApiResource): @marshal_with(remote_file_info_fields) diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py index 5db7539926..347fed4a17 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -74,6 +74,7 @@ from core.workflow.system_variable import SystemVariable from core.workflow.workflow_cycle_manager import CycleManagerWorkflowInfo, WorkflowCycleManager from events.message_event import message_was_created from extensions.ext_database import db +from libs.datetime_utils import naive_utc_now from models import Conversation, EndUser, Message, MessageFile from models.account import Account from models.enums import CreatorUserRole @@ -896,6 +897,7 @@ class AdvancedChatAppGenerateTaskPipeline: def _save_message(self, *, session: Session, graph_runtime_state: Optional[GraphRuntimeState] = None) -> None: message = self._get_message(session=session) message.answer = self._task_state.answer + message.updated_at = naive_utc_now() message.provider_response_latency = time.perf_counter() - self._base_task_pipeline._start_at message.message_metadata = self._task_state.metadata.model_dump_json() message_files = [ diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py index 56131d99c9..471118c8cb 100644 --- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py +++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py @@ -57,6 +57,7 @@ from core.prompt.utils.prompt_message_util import PromptMessageUtil from core.prompt.utils.prompt_template_parser import PromptTemplateParser from events.message_event import message_was_created from extensions.ext_database import db +from libs.datetime_utils import naive_utc_now from models.model import AppMode, Conversation, Message, MessageAgentThought logger = logging.getLogger(__name__) @@ -389,6 +390,7 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline): if llm_result.message.content else "" ) + message.updated_at = naive_utc_now() message.answer_tokens = usage.completion_tokens message.answer_unit_price = usage.completion_unit_price message.answer_price_unit = usage.completion_price_unit diff --git a/api/core/workflow/nodes/http_request/executor.py b/api/core/workflow/nodes/http_request/executor.py index e45f63bbec..c9f7fa1221 100644 --- a/api/core/workflow/nodes/http_request/executor.py +++ b/api/core/workflow/nodes/http_request/executor.py @@ -276,17 +276,26 @@ class Executor: encoded_credentials = credentials headers[authorization.config.header] = f"Basic {encoded_credentials}" elif self.auth.config.type == "custom": - headers[authorization.config.header] = authorization.config.api_key or "" + if authorization.config.header and authorization.config.api_key: + headers[authorization.config.header] = authorization.config.api_key # Handle Content-Type for multipart/form-data requests - # Fix for issue #22880: Missing boundary when using multipart/form-data + # Fix for issue #23829: Missing boundary when using multipart/form-data body = self.node_data.body if body and body.type == "form-data": - # For multipart/form-data with files, let httpx handle the boundary automatically - # by not setting Content-Type header when files are present - if not self.files or all(f[0] == "__multipart_placeholder__" for f in self.files): - # Only set Content-Type when there are no actual files - # This ensures httpx generates the correct boundary + # For multipart/form-data with files (including placeholder files), + # remove any manually set Content-Type header to let httpx handle + # For multipart/form-data, if any files are present (including placeholder files), + # we must remove any manually set Content-Type header. This is because httpx needs to + # automatically set the Content-Type and boundary for multipart encoding whenever files + # are included, even if they are placeholders, to avoid boundary issues and ensure correct + # file upload behaviour. Manually setting Content-Type can cause httpx to fail to set the + # boundary, resulting in invalid requests. + if self.files: + # Remove Content-Type if it was manually set to avoid boundary issues + headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} + else: + # No files at all, set Content-Type manually if "content-type" not in (k.lower() for k in headers): headers["Content-Type"] = "multipart/form-data" elif body and body.type in BODY_TYPE_TO_CONTENT_TYPE: diff --git a/api/factories/file_factory.py b/api/factories/file_factory.py index a4f65a9c9b..5b13901dd3 100644 --- a/api/factories/file_factory.py +++ b/api/factories/file_factory.py @@ -249,6 +249,8 @@ def _get_remote_file_info(url: str): # Initialize mime_type from filename as fallback mime_type, _ = mimetypes.guess_type(filename) + if mime_type is None: + mime_type = "" resp = ssrf_proxy.head(url, follow_redirects=True) resp = cast(httpx.Response, resp) @@ -257,7 +259,12 @@ def _get_remote_file_info(url: str): filename = str(content_disposition.split("filename=")[-1].strip('"')) # Re-guess mime_type from updated filename mime_type, _ = mimetypes.guess_type(filename) + if mime_type is None: + mime_type = "" file_size = int(resp.headers.get("Content-Length", file_size)) + # Fallback to Content-Type header if mime_type is still empty + if not mime_type: + mime_type = resp.headers.get("Content-Type", "").split(";")[0].strip() return mime_type, filename, file_size diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index 344539d51a..f7bb7c4600 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -160,6 +160,177 @@ def test_custom_authorization_header(setup_http_mock): assert "?A=b" in data assert "X-Header: 123" in data + # Custom authorization header should be set (may be masked) + assert "X-Auth:" in data + + +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) +def test_custom_auth_with_empty_api_key_does_not_set_header(setup_http_mock): + """Test: In custom authentication mode, when the api_key is empty, no header should be set.""" + from core.workflow.entities.variable_pool import VariablePool + from core.workflow.nodes.http_request.entities import ( + HttpRequestNodeAuthorization, + HttpRequestNodeData, + HttpRequestNodeTimeout, + ) + from core.workflow.nodes.http_request.executor import Executor + from core.workflow.system_variable import SystemVariable + + # Create variable pool + variable_pool = VariablePool( + system_variables=SystemVariable(user_id="test", files=[]), + user_inputs={}, + environment_variables=[], + conversation_variables=[], + ) + + # Create node data with custom auth and empty api_key + node_data = HttpRequestNodeData( + title="http", + desc="", + url="http://example.com", + method="get", + authorization=HttpRequestNodeAuthorization( + type="api-key", + config={ + "type": "custom", + "api_key": "", # Empty api_key + "header": "X-Custom-Auth", + }, + ), + headers="", + params="", + body=None, + ssl_verify=True, + ) + + # Create executor + executor = Executor( + node_data=node_data, timeout=HttpRequestNodeTimeout(connect=10, read=30, write=10), variable_pool=variable_pool + ) + + # Get assembled headers + headers = executor._assembling_headers() + + # When api_key is empty, the custom header should NOT be set + assert "X-Custom-Auth" not in headers + + +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) +def test_bearer_authorization_with_custom_header_ignored(setup_http_mock): + """ + Test that when switching from custom to bearer authorization, + the custom header settings don't interfere with bearer token. + This test verifies the fix for issue #23554. + """ + node = init_http_node( + config={ + "id": "1", + "data": { + "title": "http", + "desc": "", + "method": "get", + "url": "http://example.com", + "authorization": { + "type": "api-key", + "config": { + "type": "bearer", + "api_key": "test-token", + "header": "", # Empty header - should default to Authorization + }, + }, + "headers": "", + "params": "", + "body": None, + }, + } + ) + + result = node._run() + assert result.process_data is not None + data = result.process_data.get("request", "") + + # In bearer mode, should use Authorization header (value is masked with *) + assert "Authorization: " in data + # Should contain masked Bearer token + assert "*" in data + + +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) +def test_basic_authorization_with_custom_header_ignored(setup_http_mock): + """ + Test that when switching from custom to basic authorization, + the custom header settings don't interfere with basic auth. + This test verifies the fix for issue #23554. + """ + node = init_http_node( + config={ + "id": "1", + "data": { + "title": "http", + "desc": "", + "method": "get", + "url": "http://example.com", + "authorization": { + "type": "api-key", + "config": { + "type": "basic", + "api_key": "user:pass", + "header": "", # Empty header - should default to Authorization + }, + }, + "headers": "", + "params": "", + "body": None, + }, + } + ) + + result = node._run() + assert result.process_data is not None + data = result.process_data.get("request", "") + + # In basic mode, should use Authorization header (value is masked with *) + assert "Authorization: " in data + # Should contain masked Basic credentials + assert "*" in data + + +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) +def test_custom_authorization_with_empty_api_key(setup_http_mock): + """ + Test that custom authorization doesn't set header when api_key is empty. + This test verifies the fix for issue #23554. + """ + node = init_http_node( + config={ + "id": "1", + "data": { + "title": "http", + "desc": "", + "method": "get", + "url": "http://example.com", + "authorization": { + "type": "api-key", + "config": { + "type": "custom", + "api_key": "", # Empty api_key + "header": "X-Custom-Auth", + }, + }, + "headers": "", + "params": "", + "body": None, + }, + } + ) + + result = node._run() + assert result.process_data is not None + data = result.process_data.get("request", "") + + # Custom header should NOT be set when api_key is empty + assert "X-Custom-Auth:" not in data @pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) @@ -239,6 +410,7 @@ def test_json(setup_http_mock): assert "X-Header: 123" in data +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) def test_x_www_form_urlencoded(setup_http_mock): node = init_http_node( config={ @@ -285,6 +457,7 @@ def test_x_www_form_urlencoded(setup_http_mock): assert "X-Header: 123" in data +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) def test_form_data(setup_http_mock): node = init_http_node( config={ @@ -334,6 +507,7 @@ def test_form_data(setup_http_mock): assert "X-Header: 123" in data +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) def test_none_data(setup_http_mock): node = init_http_node( config={ @@ -366,6 +540,7 @@ def test_none_data(setup_http_mock): assert "123123123" not in data +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) def test_mock_404(setup_http_mock): node = init_http_node( config={ @@ -394,6 +569,7 @@ def test_mock_404(setup_http_mock): assert "Not Found" in resp.get("body", "") +@pytest.mark.parametrize("setup_http_mock", [["none"]], indirect=True) def test_multi_colons_parse(setup_http_mock): node = init_http_node( config={ diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py new file mode 100644 index 0000000000..ca0f309fd4 --- /dev/null +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -0,0 +1,1048 @@ +import uuid +from unittest.mock import MagicMock, patch + +import pytest +from faker import Faker +from openai._exceptions import RateLimitError + +from core.app.entities.app_invoke_entities import InvokeFrom +from models.model import EndUser +from models.workflow import Workflow +from services.app_generate_service import AppGenerateService +from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError +from services.errors.llm import InvokeRateLimitError + + +class TestAppGenerateService: + """Integration tests for AppGenerateService using testcontainers.""" + + @pytest.fixture + def mock_external_service_dependencies(self): + """Mock setup for external service dependencies.""" + with ( + patch("services.app_generate_service.BillingService") as mock_billing_service, + patch("services.app_generate_service.WorkflowService") as mock_workflow_service, + patch("services.app_generate_service.RateLimit") as mock_rate_limit, + patch("services.app_generate_service.RateLimiter") as mock_rate_limiter, + patch("services.app_generate_service.CompletionAppGenerator") as mock_completion_generator, + patch("services.app_generate_service.ChatAppGenerator") as mock_chat_generator, + patch("services.app_generate_service.AgentChatAppGenerator") as mock_agent_chat_generator, + patch("services.app_generate_service.AdvancedChatAppGenerator") as mock_advanced_chat_generator, + patch("services.app_generate_service.WorkflowAppGenerator") as mock_workflow_generator, + patch("services.account_service.FeatureService") as mock_account_feature_service, + patch("services.app_generate_service.dify_config") as mock_dify_config, + ): + # Setup default mock returns for billing service + mock_billing_service.get_info.return_value = {"subscription": {"plan": "sandbox"}} + + # Setup default mock returns for workflow service + mock_workflow_service_instance = mock_workflow_service.return_value + mock_workflow_service_instance.get_published_workflow.return_value = MagicMock(spec=Workflow) + mock_workflow_service_instance.get_draft_workflow.return_value = MagicMock(spec=Workflow) + mock_workflow_service_instance.get_published_workflow_by_id.return_value = MagicMock(spec=Workflow) + + # Setup default mock returns for rate limiting + mock_rate_limit_instance = mock_rate_limit.return_value + mock_rate_limit_instance.enter.return_value = "test_request_id" + mock_rate_limit_instance.generate.return_value = ["test_response"] + mock_rate_limit_instance.exit.return_value = None + + mock_rate_limiter_instance = mock_rate_limiter.return_value + mock_rate_limiter_instance.is_rate_limited.return_value = False + mock_rate_limiter_instance.increment_rate_limit.return_value = None + + # Setup default mock returns for app generators + mock_completion_generator_instance = mock_completion_generator.return_value + mock_completion_generator_instance.generate.return_value = ["completion_response"] + mock_completion_generator_instance.generate_more_like_this.return_value = ["more_like_this_response"] + mock_completion_generator.convert_to_event_stream.return_value = ["completion_stream"] + + mock_chat_generator_instance = mock_chat_generator.return_value + mock_chat_generator_instance.generate.return_value = ["chat_response"] + mock_chat_generator.convert_to_event_stream.return_value = ["chat_stream"] + + mock_agent_chat_generator_instance = mock_agent_chat_generator.return_value + mock_agent_chat_generator_instance.generate.return_value = ["agent_chat_response"] + mock_agent_chat_generator.convert_to_event_stream.return_value = ["agent_chat_stream"] + + mock_advanced_chat_generator_instance = mock_advanced_chat_generator.return_value + mock_advanced_chat_generator_instance.generate.return_value = ["advanced_chat_response"] + mock_advanced_chat_generator_instance.single_iteration_generate.return_value = ["single_iteration_response"] + mock_advanced_chat_generator_instance.single_loop_generate.return_value = ["single_loop_response"] + mock_advanced_chat_generator.convert_to_event_stream.return_value = ["advanced_chat_stream"] + + mock_workflow_generator_instance = mock_workflow_generator.return_value + mock_workflow_generator_instance.generate.return_value = ["workflow_response"] + mock_workflow_generator_instance.single_iteration_generate.return_value = [ + "workflow_single_iteration_response" + ] + mock_workflow_generator_instance.single_loop_generate.return_value = ["workflow_single_loop_response"] + mock_workflow_generator.convert_to_event_stream.return_value = ["workflow_stream"] + + # Setup default mock returns for account service + mock_account_feature_service.get_system_features.return_value.is_allow_register = True + + # Setup dify_config mock returns + mock_dify_config.BILLING_ENABLED = False + mock_dify_config.APP_MAX_ACTIVE_REQUESTS = 100 + mock_dify_config.APP_DAILY_RATE_LIMIT = 1000 + + yield { + "billing_service": mock_billing_service, + "workflow_service": mock_workflow_service, + "rate_limit": mock_rate_limit, + "rate_limiter": mock_rate_limiter, + "completion_generator": mock_completion_generator, + "chat_generator": mock_chat_generator, + "agent_chat_generator": mock_agent_chat_generator, + "advanced_chat_generator": mock_advanced_chat_generator, + "workflow_generator": mock_workflow_generator, + "account_feature_service": mock_account_feature_service, + "dify_config": mock_dify_config, + } + + def _create_test_app_and_account(self, db_session_with_containers, mock_external_service_dependencies, mode="chat"): + """ + Helper method to create a test app and account for testing. + + Args: + db_session_with_containers: Database session from testcontainers infrastructure + mock_external_service_dependencies: Mock dependencies + mode: App mode to create + + Returns: + tuple: (app, account) - Created app and account instances + """ + fake = Faker() + + # Setup mocks for account creation + mock_external_service_dependencies[ + "account_feature_service" + ].get_system_features.return_value.is_allow_register = True + + # Create account and tenant + from services.account_service import AccountService, TenantService + + account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=fake.password(length=12), + ) + TenantService.create_owner_tenant_if_not_exist(account, name=fake.company()) + tenant = account.current_tenant + + # Create app with realistic data + app_args = { + "name": fake.company(), + "description": fake.text(max_nb_chars=100), + "mode": mode, + "icon_type": "emoji", + "icon": "🤖", + "icon_background": "#FF6B6B", + "api_rph": 100, + "api_rpm": 10, + "max_active_requests": 5, + } + + from services.app_service import AppService + + app_service = AppService() + app = app_service.create_app(tenant.id, app_args, account) + + return app, account + + def _create_test_workflow(self, db_session_with_containers, app): + """ + Helper method to create a test workflow for testing. + + Args: + db_session_with_containers: Database session from testcontainers infrastructure + app: App instance + + Returns: + Workflow: Created workflow instance + """ + fake = Faker() + + workflow = Workflow( + id=str(uuid.uuid4()), + app_id=app.id, + name=fake.company(), + description=fake.text(max_nb_chars=100), + type="workflow", + status="published", + ) + + from extensions.ext_database import db + + db.session.add(workflow) + db.session.commit() + + return workflow + + def test_generate_completion_mode_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful generation for completion mode app. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify rate limiting was called + mock_external_service_dependencies["rate_limit"].return_value.enter.assert_called_once() + mock_external_service_dependencies["rate_limit"].return_value.generate.assert_called_once() + + # Verify completion generator was called + mock_external_service_dependencies["completion_generator"].return_value.generate.assert_called_once() + mock_external_service_dependencies["completion_generator"].convert_to_event_stream.assert_called_once() + + def test_generate_chat_mode_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful generation for chat mode app. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="chat" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify chat generator was called + mock_external_service_dependencies["chat_generator"].return_value.generate.assert_called_once() + mock_external_service_dependencies["chat_generator"].convert_to_event_stream.assert_called_once() + + def test_generate_agent_chat_mode_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful generation for agent chat mode app. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="agent-chat" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify agent chat generator was called + mock_external_service_dependencies["agent_chat_generator"].return_value.generate.assert_called_once() + mock_external_service_dependencies["agent_chat_generator"].convert_to_event_stream.assert_called_once() + + def test_generate_advanced_chat_mode_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful generation for advanced chat mode app. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify advanced chat generator was called + mock_external_service_dependencies["advanced_chat_generator"].return_value.generate.assert_called_once() + mock_external_service_dependencies["advanced_chat_generator"].convert_to_event_stream.assert_called_once() + + def test_generate_workflow_mode_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful generation for workflow mode app. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="workflow" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify workflow generator was called + mock_external_service_dependencies["workflow_generator"].return_value.generate.assert_called_once() + mock_external_service_dependencies["workflow_generator"].convert_to_event_stream.assert_called_once() + + def test_generate_with_specific_workflow_id(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with a specific workflow ID. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + workflow_id = str(uuid.uuid4()) + + # Setup test arguments + args = { + "inputs": {"query": fake.text(max_nb_chars=50)}, + "workflow_id": workflow_id, + "response_mode": "streaming", + } + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify workflow service was called with specific workflow ID + mock_external_service_dependencies[ + "workflow_service" + ].return_value.get_published_workflow_by_id.assert_called_once() + + def test_generate_with_debugger_invoke_from(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with debugger invoke from. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify draft workflow was fetched for debugger + mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once() + + def test_generate_with_non_streaming_mode(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with non-streaming mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "blocking"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=False + ) + + # Verify the result + assert result == ["test_response"] + + # Verify rate limit exit was called for non-streaming mode + mock_external_service_dependencies["rate_limit"].return_value.exit.assert_called_once() + + def test_generate_with_end_user(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with EndUser instead of Account. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Create end user + end_user = EndUser( + tenant_id=account.current_tenant.id, + app_id=app.id, + type="normal", + external_user_id=fake.uuid4(), + name=fake.name(), + is_anonymous=False, + session_id=fake.uuid4(), + ) + + from extensions.ext_database import db + + db.session.add(end_user) + db.session.commit() + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + def test_generate_with_billing_enabled_sandbox_plan( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation with billing enabled and sandbox plan. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Setup billing service mock for sandbox plan + mock_external_service_dependencies["billing_service"].get_info.return_value = { + "subscription": {"plan": "sandbox"} + } + + # Set BILLING_ENABLED to True for this test + mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify billing service was called + mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(app.tenant_id) + + def test_generate_with_rate_limit_exceeded(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation when rate limit is exceeded. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Setup billing service mock for sandbox plan + mock_external_service_dependencies["billing_service"].get_info.return_value = { + "subscription": {"plan": "sandbox"} + } + + # Set BILLING_ENABLED to True for this test + mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True + + # Setup system rate limiter to return rate limited + with patch("services.app_generate_service.AppGenerateService.system_rate_limiter") as mock_system_rate_limiter: + mock_system_rate_limiter.is_rate_limited.return_value = True + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test and expect rate limit error + with pytest.raises(InvokeRateLimitError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify error message + assert "Rate limit exceeded" in str(exc_info.value) + + def test_generate_with_rate_limit_error_from_openai( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation when OpenAI rate limit error occurs. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Setup completion generator to raise RateLimitError + mock_response = MagicMock() + mock_response.request = MagicMock() + mock_external_service_dependencies["completion_generator"].return_value.generate.side_effect = RateLimitError( + "Rate limit exceeded", response=mock_response, body=None + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test and expect rate limit error + with pytest.raises(InvokeRateLimitError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify error message + assert "Rate limit exceeded" in str(exc_info.value) + + def test_generate_with_invalid_app_mode(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with invalid app mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="chat" + ) + + # Manually set invalid mode after creation + app.mode = "invalid_mode" + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test and expect ValueError + with pytest.raises(ValueError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify error message + assert "Invalid app mode" in str(exc_info.value) + + def test_generate_with_workflow_id_format_error( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation with invalid workflow ID format. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + # Setup test arguments with invalid workflow ID + args = { + "inputs": {"query": fake.text(max_nb_chars=50)}, + "workflow_id": "invalid_uuid", + "response_mode": "streaming", + } + + # Execute the method under test and expect WorkflowIdFormatError + with pytest.raises(WorkflowIdFormatError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify error message + assert "Invalid workflow_id format" in str(exc_info.value) + + def test_generate_with_workflow_not_found_error( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation when workflow is not found. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + workflow_id = str(uuid.uuid4()) + + # Setup workflow service to return None (workflow not found) + mock_external_service_dependencies[ + "workflow_service" + ].return_value.get_published_workflow_by_id.return_value = None + + # Setup test arguments + args = { + "inputs": {"query": fake.text(max_nb_chars=50)}, + "workflow_id": workflow_id, + "response_mode": "streaming", + } + + # Execute the method under test and expect WorkflowNotFoundError + with pytest.raises(WorkflowNotFoundError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify error message + assert f"Workflow not found with id: {workflow_id}" in str(exc_info.value) + + def test_generate_with_workflow_not_initialized_error( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation when workflow is not initialized for debugger. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + # Setup workflow service to return None (workflow not initialized) + mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test and expect ValueError + with pytest.raises(ValueError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=True + ) + + # Verify error message + assert "Workflow not initialized" in str(exc_info.value) + + def test_generate_with_workflow_not_published_error( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation when workflow is not published for non-debugger. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + # Setup workflow service to return None (workflow not published) + mock_external_service_dependencies["workflow_service"].return_value.get_published_workflow.return_value = None + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test and expect ValueError + with pytest.raises(ValueError) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify error message + assert "Workflow not published" in str(exc_info.value) + + def test_generate_single_iteration_advanced_chat_success( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test successful single iteration generation for advanced chat mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + node_id = fake.uuid4() + args = {"inputs": {"query": fake.text(max_nb_chars=50)}} + + # Execute the method under test + result = AppGenerateService.generate_single_iteration( + app_model=app, user=account, node_id=node_id, args=args, streaming=True + ) + + # Verify the result + assert result == ["advanced_chat_stream"] + + # Verify advanced chat generator was called + mock_external_service_dependencies[ + "advanced_chat_generator" + ].return_value.single_iteration_generate.assert_called_once() + + def test_generate_single_iteration_workflow_success( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test successful single iteration generation for workflow mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="workflow" + ) + + node_id = fake.uuid4() + args = {"inputs": {"query": fake.text(max_nb_chars=50)}} + + # Execute the method under test + result = AppGenerateService.generate_single_iteration( + app_model=app, user=account, node_id=node_id, args=args, streaming=True + ) + + # Verify the result + assert result == ["advanced_chat_stream"] + + # Verify workflow generator was called + mock_external_service_dependencies[ + "workflow_generator" + ].return_value.single_iteration_generate.assert_called_once() + + def test_generate_single_iteration_invalid_mode( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test single iteration generation with invalid app mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + node_id = fake.uuid4() + args = {"inputs": {"query": fake.text(max_nb_chars=50)}} + + # Execute the method under test and expect ValueError + with pytest.raises(ValueError) as exc_info: + AppGenerateService.generate_single_iteration( + app_model=app, user=account, node_id=node_id, args=args, streaming=True + ) + + # Verify error message + assert "Invalid app mode" in str(exc_info.value) + + def test_generate_single_loop_advanced_chat_success( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test successful single loop generation for advanced chat mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + node_id = fake.uuid4() + args = {"inputs": {"query": fake.text(max_nb_chars=50)}} + + # Execute the method under test + result = AppGenerateService.generate_single_loop( + app_model=app, user=account, node_id=node_id, args=args, streaming=True + ) + + # Verify the result + assert result == ["advanced_chat_stream"] + + # Verify advanced chat generator was called + mock_external_service_dependencies[ + "advanced_chat_generator" + ].return_value.single_loop_generate.assert_called_once() + + def test_generate_single_loop_workflow_success( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test successful single loop generation for workflow mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="workflow" + ) + + node_id = fake.uuid4() + args = {"inputs": {"query": fake.text(max_nb_chars=50)}} + + # Execute the method under test + result = AppGenerateService.generate_single_loop( + app_model=app, user=account, node_id=node_id, args=args, streaming=True + ) + + # Verify the result + assert result == ["advanced_chat_stream"] + + # Verify workflow generator was called + mock_external_service_dependencies["workflow_generator"].return_value.single_loop_generate.assert_called_once() + + def test_generate_single_loop_invalid_mode(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test single loop generation with invalid app mode. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + node_id = fake.uuid4() + args = {"inputs": {"query": fake.text(max_nb_chars=50)}} + + # Execute the method under test and expect ValueError + with pytest.raises(ValueError) as exc_info: + AppGenerateService.generate_single_loop( + app_model=app, user=account, node_id=node_id, args=args, streaming=True + ) + + # Verify error message + assert "Invalid app mode" in str(exc_info.value) + + def test_generate_more_like_this_success(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test successful more like this generation. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + message_id = fake.uuid4() + + # Execute the method under test + result = AppGenerateService.generate_more_like_this( + app_model=app, user=account, message_id=message_id, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["more_like_this_response"] + + # Verify completion generator was called + mock_external_service_dependencies[ + "completion_generator" + ].return_value.generate_more_like_this.assert_called_once() + + def test_generate_more_like_this_with_end_user( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test more like this generation with EndUser. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Create end user + end_user = EndUser( + tenant_id=account.current_tenant.id, + app_id=app.id, + type="normal", + external_user_id=fake.uuid4(), + name=fake.name(), + is_anonymous=False, + session_id=fake.uuid4(), + ) + + from extensions.ext_database import db + + db.session.add(end_user) + db.session.commit() + + message_id = fake.uuid4() + + # Execute the method under test + result = AppGenerateService.generate_more_like_this( + app_model=app, user=end_user, message_id=message_id, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["more_like_this_response"] + + def test_get_max_active_requests_with_app_limit( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test getting max active requests with app-specific limit. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Set app-specific limit + app.max_active_requests = 10 + + # Execute the method under test + result = AppGenerateService._get_max_active_requests(app) + + # Verify the result (should return the smaller value between app limit and config limit) + assert result == 10 + + def test_get_max_active_requests_with_config_limit( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test getting max active requests with config limit being smaller. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Set app-specific limit higher than config + app.max_active_requests = 100 + + # Execute the method under test + result = AppGenerateService._get_max_active_requests(app) + + # Verify the result (should return the smaller value) + # Assuming config limit is smaller than 100 + assert result <= 100 + + def test_get_max_active_requests_with_zero_limits( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test getting max active requests with zero limits (infinite). + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Set app-specific limit to 0 (infinite) + app.max_active_requests = 0 + + # Execute the method under test + result = AppGenerateService._get_max_active_requests(app) + + # Verify the result (should return config limit when app limit is 0) + assert result == 100 # dify_config.APP_MAX_ACTIVE_REQUESTS + + def test_generate_with_exception_cleanup(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test that rate limit exit is called when an exception occurs. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="completion" + ) + + # Setup completion generator to raise an exception + mock_external_service_dependencies["completion_generator"].return_value.generate.side_effect = Exception( + "Test exception" + ) + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test and expect exception + with pytest.raises(Exception) as exc_info: + AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify exception message + assert "Test exception" in str(exc_info.value) + + # Verify rate limit exit was called for cleanup + mock_external_service_dependencies["rate_limit"].return_value.exit.assert_called_once() + + def test_generate_with_agent_mode_detection(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with agent mode detection based on app configuration. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="chat" + ) + + # Mock app to have agent mode enabled by setting the mode directly + app.mode = "agent-chat" + + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify agent chat generator was called instead of regular chat generator + mock_external_service_dependencies["agent_chat_generator"].return_value.generate.assert_called_once() + mock_external_service_dependencies["agent_chat_generator"].convert_to_event_stream.assert_called_once() + + def test_generate_with_different_invoke_from_values( + self, db_session_with_containers, mock_external_service_dependencies + ): + """ + Test generation with different invoke from values. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="advanced-chat" + ) + + # Test different invoke from values + invoke_from_values = [ + InvokeFrom.SERVICE_API, + InvokeFrom.WEB_APP, + InvokeFrom.EXPLORE, + InvokeFrom.DEBUGGER, + ] + + for invoke_from in invoke_from_values: + # Setup test arguments + args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=invoke_from, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + def test_generate_with_complex_args(self, db_session_with_containers, mock_external_service_dependencies): + """ + Test generation with complex arguments including files and external trace ID. + """ + fake = Faker() + app, account = self._create_test_app_and_account( + db_session_with_containers, mock_external_service_dependencies, mode="workflow" + ) + + # Setup complex test arguments + args = { + "inputs": { + "query": fake.text(max_nb_chars=50), + "context": fake.text(max_nb_chars=100), + "parameters": {"temperature": 0.7, "max_tokens": 1000}, + }, + "files": [ + {"id": fake.uuid4(), "name": "test_file.txt", "size": 1024}, + {"id": fake.uuid4(), "name": "test_image.jpg", "size": 2048}, + ], + "external_trace_id": fake.uuid4(), + "response_mode": "streaming", + } + + # Execute the method under test + result = AppGenerateService.generate( + app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True + ) + + # Verify the result + assert result == ["test_response"] + + # Verify workflow generator was called with complex args + mock_external_service_dependencies["workflow_generator"].return_value.generate.assert_called_once() + call_args = mock_external_service_dependencies["workflow_generator"].return_value.generate.call_args + assert call_args[1]["args"] == args diff --git a/api/tests/unit_tests/controllers/console/test_files_security.py b/api/tests/unit_tests/controllers/console/test_files_security.py index cb5562d345..2630fbcfd0 100644 --- a/api/tests/unit_tests/controllers/console/test_files_security.py +++ b/api/tests/unit_tests/controllers/console/test_files_security.py @@ -4,8 +4,8 @@ from unittest.mock import patch import pytest from werkzeug.exceptions import Forbidden -from controllers.common.errors import FilenameNotExistsError -from controllers.console.error import ( +from controllers.common.errors import ( + FilenameNotExistsError, FileTooLargeError, NoFileUploadedError, TooManyFilesError, diff --git a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py index 3101f7dd34..8b5a82fcbb 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py +++ b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_executor.py @@ -243,8 +243,6 @@ def test_executor_with_form_data(): # Check the executor's data assert executor.method == "post" assert executor.url == "https://api.example.com/upload" - assert "Content-Type" in executor.headers - assert "multipart/form-data" in executor.headers["Content-Type"] assert executor.params is None assert executor.json is None # '__multipart_placeholder__' is expected when no file inputs exist, @@ -252,6 +250,11 @@ def test_executor_with_form_data(): assert executor.files == [("__multipart_placeholder__", ("", b"", "application/octet-stream"))] assert executor.content is None + # After fix for #23829: When placeholder files exist, Content-Type is removed + # to let httpx handle Content-Type and boundary automatically + headers = executor._assembling_headers() + assert "Content-Type" not in headers or "multipart/form-data" not in headers.get("Content-Type", "") + # Check that the form data is correctly loaded in executor.data assert isinstance(executor.data, dict) assert "text_field" in executor.data diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx index 3d572b926a..e58e79918f 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx @@ -3,7 +3,7 @@ import type { FC } from 'react' import React from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' -import AppCard from '@/app/components/app/overview/appCard' +import AppCard from '@/app/components/app/overview/app-card' import Loading from '@/app/components/base/loading' import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' import { ToastContext } from '@/app/components/base/toast' @@ -17,7 +17,7 @@ import type { App } from '@/types/app' import type { UpdateAppSiteCodeResponse } from '@/models/app' import { asyncRunSafe } from '@/utils' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' -import type { IAppCardProps } from '@/app/components/app/overview/appCard' +import type { IAppCardProps } from '@/app/components/app/overview/app-card' import { useStore as useAppStore } from '@/app/components/app/store' export type ICardViewProps = { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx index 09d3e4317c..847de19165 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/chart-view.tsx @@ -3,8 +3,8 @@ import React, { useState } from 'react' import dayjs from 'dayjs' import quarterOfYear from 'dayjs/plugin/quarterOfYear' import { useTranslation } from 'react-i18next' -import type { PeriodParams } from '@/app/components/app/overview/appChart' -import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/appChart' +import type { PeriodParams } from '@/app/components/app/overview/app-chart' +import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart' import type { Item } from '@/app/components/base/select' import { SimpleSelect } from '@/app/components/base/select' import { TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter' diff --git a/web/app/components/app/overview/appCard.tsx b/web/app/components/app/overview/app-card.tsx similarity index 98% rename from web/app/components/app/overview/appCard.tsx rename to web/app/components/app/overview/app-card.tsx index f11e111cb0..02fd779df6 100644 --- a/web/app/components/app/overview/appCard.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -35,7 +35,7 @@ import type { AppDetailResponse } from '@/models/app' import { useAppContext } from '@/context/app-context' import type { AppSSO } from '@/types/app' import Indicator from '@/app/components/header/indicator' -import { fetchAppDetail } from '@/service/apps' +import { fetchAppDetailDirect } from '@/service/apps' import { AccessMode } from '@/models/access-control' import AccessControl from '../app-access-control' import { useAppWhiteListSubjects } from '@/service/access-control' @@ -161,11 +161,15 @@ function AppCard({ return setShowAccessControl(true) }, [appDetail]) - const handleAccessControlUpdate = useCallback(() => { - fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => { + const handleAccessControlUpdate = useCallback(async () => { + try { + const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id }) setAppDetail(res) setShowAccessControl(false) - }) + } + catch (error) { + console.error('Failed to fetch app detail:', error) + } }, [appDetail, setAppDetail]) return ( diff --git a/web/app/components/app/overview/appChart.tsx b/web/app/components/app/overview/app-chart.tsx similarity index 96% rename from web/app/components/app/overview/appChart.tsx rename to web/app/components/app/overview/app-chart.tsx index 4e74eda600..9d9b27f230 100644 --- a/web/app/components/app/overview/appChart.tsx +++ b/web/app/components/app/overview/app-chart.tsx @@ -123,7 +123,7 @@ const Chart: React.FC = ({ dimensions: ['date', yField], source: statistics, }, - grid: { top: 8, right: 36, bottom: 0, left: 0, containLabel: true }, + grid: { top: 8, right: 36, bottom: 10, left: 25, containLabel: true }, tooltip: { trigger: 'item', position: 'top', @@ -165,7 +165,7 @@ const Chart: React.FC = ({ lineStyle: { color: COMMON_COLOR_MAP.splitLineDark, }, - interval(index, value) { + interval(_index, value) { return !!value }, }, @@ -242,7 +242,7 @@ const Chart: React.FC = ({ ? '' : {t('appOverview.analysis.tokenUsage.consumed')} Tokens ( - ~{sum(statistics.map(item => Number.parseFloat(get(item, 'total_price', '0')))).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4 })} + ~{sum(statistics.map(item => Number.parseFloat(String(get(item, 'total_price', '0'))))).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 4 })} ) } textStyle={{ main: `!text-3xl !font-normal ${sumData === 0 ? '!text-text-quaternary' : ''}` }} /> @@ -268,7 +268,7 @@ export const MessagesChart: FC = ({ id, period }) => { const noDataFlag = !response.data || response.data.length === 0 return @@ -282,7 +282,7 @@ export const ConversationsChart: FC = ({ id, period }) => { const noDataFlag = !response.data || response.data.length === 0 return @@ -297,7 +297,7 @@ export const EndUsersChart: FC = ({ id, period }) => { const noDataFlag = !response.data || response.data.length === 0 return @@ -380,7 +380,7 @@ export const CostChart: FC = ({ id, period }) => { const noDataFlag = !response.data || response.data.length === 0 return @@ -394,7 +394,7 @@ export const WorkflowMessagesChart: FC = ({ id, period }) => { const noDataFlag = !response.data || response.data.length === 0 return = ({ id, period }) const noDataFlag = !response.data || response.data.length === 0 return @@ -425,7 +425,7 @@ export const WorkflowCostChart: FC = ({ id, period }) => { const noDataFlag = !response.data || response.data.length === 0 return diff --git a/web/app/components/app/overview/embedded/style.module.css b/web/app/components/app/overview/embedded/style.module.css index f2a4d2d0f4..84ce8f5d3c 100644 --- a/web/app/components/app/overview/embedded/style.module.css +++ b/web/app/components/app/overview/embedded/style.module.css @@ -18,3 +18,13 @@ .pluginInstallIcon { background-image: url(../assets/chromeplugin-install.svg); } + +:global(html[data-theme="dark"]) .iframeIcon, +:global(html[data-theme="dark"]) .scriptsIcon, +:global(html[data-theme="dark"]) .chromePluginIcon { + filter: invert(0.86) hue-rotate(180deg) saturate(0.5) brightness(0.95); +} + +:global(html[data-theme="dark"]) .pluginInstallIcon { + filter: invert(0.9); +} diff --git a/web/app/components/base/mermaid/index.tsx b/web/app/components/base/mermaid/index.tsx index 80271bb29b..44df3e394f 100644 --- a/web/app/components/base/mermaid/index.tsx +++ b/web/app/components/base/mermaid/index.tsx @@ -117,7 +117,7 @@ const Flowchart = React.forwardRef((props: { const [isInitialized, setIsInitialized] = useState(false) const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light') const containerRef = useRef(null) - const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current + const chartId = useRef(`mermaid-chart-${Math.random().toString(36).slice(2, 11)}`).current const [isLoading, setIsLoading] = useState(true) const renderTimeoutRef = useRef() const [errMsg, setErrMsg] = useState('') diff --git a/web/app/components/base/prompt-editor/utils.ts b/web/app/components/base/prompt-editor/utils.ts index 4b2570e697..e665172049 100644 --- a/web/app/components/base/prompt-editor/utils.ts +++ b/web/app/components/base/prompt-editor/utils.ts @@ -259,7 +259,7 @@ function getFullMatchOffset( ): number { let triggerOffset = offset for (let i = triggerOffset; i <= entryText.length; i++) { - if (documentText.substr(-i) === entryText.substr(0, i)) + if (documentText.slice(-i) === entryText.slice(0, i)) triggerOffset = i } return triggerOffset diff --git a/web/app/components/goto-anything/index.tsx b/web/app/components/goto-anything/index.tsx index bff0773a46..22e6661546 100644 --- a/web/app/components/goto-anything/index.tsx +++ b/web/app/components/goto-anything/index.tsx @@ -32,7 +32,7 @@ const GotoAnything: FC = ({ const { t } = useTranslation() const [show, setShow] = useState(false) const [searchQuery, setSearchQuery] = useState('') - const [cmdVal, setCmdVal] = useState('') + const [cmdVal, setCmdVal] = useState('_') const inputRef = useRef(null) const handleNavSearch = useCallback((q: string) => { setShow(true) @@ -120,9 +120,14 @@ const GotoAnything: FC = ({ }, ) + // Prevent automatic selection of the first option when cmdVal is not set + const clearSelection = () => { + setCmdVal('_') + } + const handleCommandSelect = useCallback((commandKey: string) => { setSearchQuery(`${commandKey} `) - setCmdVal('') + clearSelection() setTimeout(() => { inputRef.current?.focus() }, 0) @@ -233,9 +238,6 @@ const GotoAnything: FC = ({ inputRef.current?.focus() }) } - return () => { - setCmdVal('') - } }, [show]) return ( @@ -245,6 +247,7 @@ const GotoAnything: FC = ({ onClose={() => { setShow(false) setSearchQuery('') + clearSelection() onHide?.() }} closable={false} @@ -268,7 +271,7 @@ const GotoAnything: FC = ({ onChange={(e) => { setSearchQuery(e.target.value) if (!e.target.value.startsWith('@')) - setCmdVal('') + clearSelection() }} className='flex-1 !border-0 !bg-transparent !shadow-none' wrapperClassName='flex-1 !border-0 !bg-transparent' @@ -321,40 +324,40 @@ const GotoAnything: FC = ({ /> ) : ( Object.entries(groupedResults).map(([type, results], groupIndex) => ( - { - const typeMap: Record = { - 'app': 'app.gotoAnything.groups.apps', - 'plugin': 'app.gotoAnything.groups.plugins', - 'knowledge': 'app.gotoAnything.groups.knowledgeBases', - 'workflow-node': 'app.gotoAnything.groups.workflowNodes', - } - return t(typeMap[type] || `${type}s`) - })()} className='p-2 capitalize text-text-secondary'> - {results.map(result => ( - handleNavigate(result)} - > - {result.icon} -
-
- {result.title} -
- {result.description && ( -
- {result.description} + { + const typeMap: Record = { + 'app': 'app.gotoAnything.groups.apps', + 'plugin': 'app.gotoAnything.groups.plugins', + 'knowledge': 'app.gotoAnything.groups.knowledgeBases', + 'workflow-node': 'app.gotoAnything.groups.workflowNodes', + } + return t(typeMap[type] || `${type}s`) + })()} className='p-2 capitalize text-text-secondary'> + {results.map(result => ( + handleNavigate(result)} + > + {result.icon} +
+
+ {result.title}
- )} -
-
- {result.type} -
-
- ))} -
- )) + {result.description && ( +
+ {result.description} +
+ )} +
+
+ {result.type} +
+ + ))} + + )) )} {!isCommandsMode && emptyResult} {!isCommandsMode && defaultUI} @@ -373,7 +376,7 @@ const GotoAnything: FC = ({ {t('app.gotoAnything.resultCount', { count: searchResults.length })} {searchMode !== 'general' && ( -{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} + {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} )} diff --git a/web/app/routePrefixHandle.tsx b/web/app/routePrefixHandle.tsx index 58b861b014..ee4ef722fc 100644 --- a/web/app/routePrefixHandle.tsx +++ b/web/app/routePrefixHandle.tsx @@ -9,7 +9,7 @@ export default function RoutePrefixHandle() { const handleRouteChange = () => { const addPrefixToImg = (e: HTMLImageElement) => { const url = new URL(e.src) - const prefix = url.pathname.substr(0, basePath.length) + const prefix = url.pathname.slice(0, basePath.length) if (prefix !== basePath && !url.href.startsWith('blob:') && !url.href.startsWith('data:')) { url.pathname = basePath + url.pathname e.src = url.toString() diff --git a/web/hooks/use-document-title.ts b/web/hooks/use-document-title.ts index 2c848a1f56..23789129d0 100644 --- a/web/hooks/use-document-title.ts +++ b/web/hooks/use-document-title.ts @@ -1,6 +1,7 @@ 'use client' import { useGlobalPublicStore } from '@/context/global-public-context' import { useFavicon, useTitle } from 'ahooks' +import { basePath } from '@/utils/var' export default function useDocumentTitle(title: string) { const isPending = useGlobalPublicStore(s => s.isGlobalPending) @@ -15,7 +16,7 @@ export default function useDocumentTitle(title: string) { } else { titleStr = `${prefix}Dify` - favicon = '/favicon.ico' + favicon = `${basePath}/favicon.ico` } } useTitle(titleStr) diff --git a/web/i18n/de-DE/app.ts b/web/i18n/de-DE/app.ts index 30e94a3c42..d55842a042 100644 --- a/web/i18n/de-DE/app.ts +++ b/web/i18n/de-DE/app.ts @@ -256,11 +256,11 @@ const translation = { maxActiveRequestsTip: 'Maximale Anzahl gleichzeitiger aktiver Anfragen pro App (0 für unbegrenzt)', gotoAnything: { actions: { - searchPlugins: 'Such-Plugins', + searchPlugins: 'Plugins durchsuchen', searchKnowledgeBases: 'Wissensdatenbanken durchsuchen', searchWorkflowNodes: 'Workflow-Knoten durchsuchen', searchKnowledgeBasesDesc: 'Suchen und navigieren Sie zu Ihren Wissensdatenbanken', - searchApplications: 'Anwendungen suchen', + searchApplications: 'Anwendungen durchsuchen', searchWorkflowNodesHelp: 'Diese Funktion funktioniert nur, wenn ein Workflow angezeigt wird. Navigieren Sie zuerst zu einem Workflow.', searchApplicationsDesc: 'Suchen und navigieren Sie zu Ihren Anwendungen', searchPluginsDesc: 'Suchen und navigieren Sie zu Ihren Plugins', diff --git a/web/i18n/es-ES/app.ts b/web/i18n/es-ES/app.ts index cf88462d34..05797eaca1 100644 --- a/web/i18n/es-ES/app.ts +++ b/web/i18n/es-ES/app.ts @@ -254,10 +254,10 @@ const translation = { maxActiveRequestsTip: 'Número máximo de solicitudes activas concurrentes por aplicación (0 para ilimitado)', gotoAnything: { actions: { - searchApplications: 'Aplicaciones de búsqueda', + searchApplications: 'Buscar aplicaciones', searchKnowledgeBasesDesc: 'Busque y navegue por sus bases de conocimiento', searchWorkflowNodes: 'Buscar nodos de flujo de trabajo', - searchPlugins: 'Complementos de búsqueda', + searchPlugins: 'Buscar complementos', searchWorkflowNodesDesc: 'Buscar y saltar a nodos en el flujo de trabajo actual por nombre o tipo', searchKnowledgeBases: 'Buscar en las bases de conocimiento', searchApplicationsDesc: 'Buscar y navegar a sus aplicaciones', diff --git a/web/i18n/fa-IR/app.ts b/web/i18n/fa-IR/app.ts index dd9bda3223..fe4b4d8a4b 100644 --- a/web/i18n/fa-IR/app.ts +++ b/web/i18n/fa-IR/app.ts @@ -254,8 +254,8 @@ const translation = { maxActiveRequestsTip: 'حداکثر تعداد درخواست‌های فعال همزمان در هر برنامه (0 برای نامحدود)', gotoAnything: { actions: { - searchPlugins: 'افزونه های جستجو', - searchWorkflowNodes: 'گره های گردش کار جستجو', + searchPlugins: 'جستجوی افزونه ها', + searchWorkflowNodes: 'جستجوی گره های گردش کار', searchApplications: 'جستجوی برنامه ها', searchKnowledgeBases: 'جستجو در پایگاه های دانش', searchWorkflowNodesHelp: 'این ویژگی فقط هنگام مشاهده گردش کار کار می کند. ابتدا به گردش کار بروید.', diff --git a/web/i18n/fr-FR/app.ts b/web/i18n/fr-FR/app.ts index e4817a6721..2597e3e730 100644 --- a/web/i18n/fr-FR/app.ts +++ b/web/i18n/fr-FR/app.ts @@ -58,7 +58,7 @@ const translation = { appCreateDSLErrorTitle: 'Incompatibilité de version', appCreateDSLErrorPart3: 'Version actuelle de l’application DSL :', appCreateDSLErrorPart2: 'Voulez-vous continuer ?', - foundResults: '{{compte}} Résultats', + foundResults: '{{count}} Résultats', workflowShortDescription: 'Flux agentique pour automatisations intelligentes', agentShortDescription: 'Agent intelligent avec raisonnement et utilisation autonome de l’outil', learnMore: 'Pour en savoir plus', @@ -75,7 +75,7 @@ const translation = { completionUserDescription: 'Créez rapidement un assistant IA pour les tâches de génération de texte avec une configuration simple.', agentUserDescription: 'Un agent intelligent capable d’un raisonnement itératif et d’une utilisation autonome d’outils pour atteindre les objectifs de la tâche.', forBeginners: 'Types d’applications plus basiques', - foundResult: '{{compte}} Résultat', + foundResult: '{{count}} Résultat', noIdeaTip: 'Pas d’idées ? Consultez nos modèles', optional: 'Optionnel', advancedShortDescription: 'Workflow amélioré pour conversations multi-tours', @@ -258,7 +258,7 @@ const translation = { searchKnowledgeBasesDesc: 'Recherchez et accédez à vos bases de connaissances', searchWorkflowNodesDesc: 'Recherchez et accédez aux nœuds du flux de travail actuel par nom ou type', searchApplicationsDesc: 'Recherchez et accédez à vos applications', - searchPlugins: 'Plugins de recherche', + searchPlugins: 'Rechercher des plugins', searchWorkflowNodes: 'Rechercher des nœuds de workflow', searchKnowledgeBases: 'Rechercher dans les bases de connaissances', searchApplications: 'Rechercher des applications', diff --git a/web/i18n/fr-FR/dataset-creation.ts b/web/i18n/fr-FR/dataset-creation.ts index e306589989..457b83d342 100644 --- a/web/i18n/fr-FR/dataset-creation.ts +++ b/web/i18n/fr-FR/dataset-creation.ts @@ -162,7 +162,7 @@ const translation = { general: 'Généralités', fullDocTip: 'L’intégralité du document est utilisée comme morceau parent et récupérée directement. Veuillez noter que pour des raisons de performance, le texte dépassant 10000 jetons sera automatiquement tronqué.', fullDoc: 'Doc complet', - previewChunkCount: '{{compte}} Tronçons estimés', + previewChunkCount: '{{count}} Tronçons estimés', childChunkForRetrieval: 'Child-chunk pour l’extraction', parentChildDelimiterTip: 'Un délimiteur est le caractère utilisé pour séparer le texte. \\n\\n est recommandé pour diviser le document d’origine en gros morceaux parents. Vous pouvez également utiliser des délimiteurs spéciaux définis par vous-même.', qaSwitchHighQualityTipTitle: 'Le format Q&R nécessite une méthode d’indexation de haute qualité', diff --git a/web/i18n/fr-FR/workflow.ts b/web/i18n/fr-FR/workflow.ts index 1fc655c921..36aac55977 100644 --- a/web/i18n/fr-FR/workflow.ts +++ b/web/i18n/fr-FR/workflow.ts @@ -749,8 +749,8 @@ const translation = { continueOnError: 'continuer sur l’erreur', }, comma: ',', - error_one: '{{compte}} Erreur', - error_other: '{{compte}} Erreurs', + error_one: '{{count}} Erreur', + error_other: '{{count}} Erreurs', parallelModeEnableDesc: 'En mode parallèle, les tâches au sein des itérations prennent en charge l’exécution parallèle. Vous pouvez le configurer dans le panneau des propriétés à droite.', parallelModeUpper: 'MODE PARALLÈLE', parallelPanelDesc: 'En mode parallèle, les tâches de l’itération prennent en charge l’exécution parallèle.', diff --git a/web/i18n/hi-IN/app.ts b/web/i18n/hi-IN/app.ts index bcdaa6002a..1b60ce889e 100644 --- a/web/i18n/hi-IN/app.ts +++ b/web/i18n/hi-IN/app.ts @@ -254,28 +254,28 @@ const translation = { maxActiveRequestsTip: 'प्रति ऐप सक्रिय अनुरोधों की अधिकतम संख्या (असीमित के लिए 0)', gotoAnything: { actions: { - searchPlugins: 'खोज प्लगइन्स', - searchWorkflowNodes: 'खोज कार्यप्रवाह नोड्स', + searchPlugins: 'प्लगइन्स खोजें', + searchWorkflowNodes: 'कार्यप्रवाह नोड्स खोजें', searchKnowledgeBases: 'ज्ञान आधार खोजें', - searchApplications: 'अनुसंधान एप्लिकेशन', + searchApplications: 'एप्लिकेशन खोजें', searchPluginsDesc: 'अपने प्लगइन्स को खोजें और नेविगेट करें', searchWorkflowNodesDesc: 'वर्तमान कार्यप्रवाह में नाम या प्रकार द्वारा नोड्स को खोजें और उन पर कूदें', searchKnowledgeBasesDesc: 'अपने ज्ञान आधारों की खोज करें और उन्हें नेविगेट करें', searchApplicationsDesc: 'अपने अनुप्रयोगों की खोज करें और उन्हें नेविगेट करें', searchWorkflowNodesHelp: 'यह सुविधा केवल तब काम करती है जब आप एक कार्यप्रवाह देख रहे हों। पहले एक कार्यप्रवाह पर जाएं।', themeCategoryTitle: 'थीम', - runTitle: 'आदेश', + runTitle: 'कमांड', languageCategoryTitle: 'भाषा', languageCategoryDesc: 'इंटरफेस भाषा बदलें', themeSystem: 'सिस्टम थीम', themeLight: 'लाइट थीम', - themeDarkDesc: 'अंधेरे रूप का उपयोग करें', + themeDarkDesc: 'डार्क उपस्थिति का प्रयोग करें', themeDark: 'डार्क थीम', themeLightDesc: 'हल्की उपस्थिति का प्रयोग करें', - languageChangeDesc: 'यूआई भाषा बदलें', - themeCategoryDesc: 'ऐप्लिकेशन थीम बदलें', + languageChangeDesc: 'इंटरफेस भाषा बदलें', + themeCategoryDesc: 'ऐप की थीम बदलें', themeSystemDesc: 'अपने ऑपरेटिंग सिस्टम की उपस्थिति का पालन करें', - runDesc: 'त्वरित आदेश चलाएँ (थीम, भाषा, ...)', + runDesc: 'त्वरित कमांड चलाएँ (थीम, भाषा, ...)', }, emptyState: { noPluginsFound: 'कोई प्लगइन नहीं मिले', diff --git a/web/i18n/it-IT/app.ts b/web/i18n/it-IT/app.ts index e7ed57c4a4..01d9c25d2d 100644 --- a/web/i18n/it-IT/app.ts +++ b/web/i18n/it-IT/app.ts @@ -266,7 +266,7 @@ const translation = { searchApplications: 'Cerca applicazioni', searchPluginsDesc: 'Cerca e naviga verso i tuoi plugin', searchKnowledgeBasesDesc: 'Cerca e naviga nelle tue knowledge base', - searchPlugins: 'Plugin di ricerca', + searchPlugins: 'Cerca plugin', searchWorkflowNodesDesc: 'Trovare e passare ai nodi nel flusso di lavoro corrente in base al nome o al tipo', searchKnowledgeBases: 'Cerca nelle Basi di Conoscenza', themeCategoryTitle: 'Tema', diff --git a/web/i18n/pt-BR/app.ts b/web/i18n/pt-BR/app.ts index 9276f58129..32e8c18983 100644 --- a/web/i18n/pt-BR/app.ts +++ b/web/i18n/pt-BR/app.ts @@ -258,11 +258,11 @@ const translation = { searchApplicationsDesc: 'Pesquise e navegue até seus aplicativos', searchPluginsDesc: 'Pesquise e navegue até seus plug-ins', searchKnowledgeBases: 'Pesquisar bases de conhecimento', - searchApplications: 'Aplicativos de pesquisa', + searchApplications: 'Pesquisar aplicativos', searchWorkflowNodesDesc: 'Localizar e ir para nós no fluxo de trabalho atual por nome ou tipo', searchWorkflowNodesHelp: 'Esse recurso só funciona ao visualizar um fluxo de trabalho. Navegue até um fluxo de trabalho primeiro.', searchKnowledgeBasesDesc: 'Pesquise e navegue até suas bases de conhecimento', - searchWorkflowNodes: 'Nós de fluxo de trabalho de pesquisa', + searchWorkflowNodes: 'Pesquisar nós de fluxo de trabalho', themeDarkDesc: 'Use aparência escura', themeCategoryDesc: 'Mudar o tema do aplicativo', themeLight: 'Tema Claro', diff --git a/web/i18n/ru-RU/app.ts b/web/i18n/ru-RU/app.ts index ed98b94f03..d1bbee791a 100644 --- a/web/i18n/ru-RU/app.ts +++ b/web/i18n/ru-RU/app.ts @@ -254,7 +254,7 @@ const translation = { maxActiveRequestsTip: 'Максимальное количество одновременно активных запросов на одно приложение (0 для неограниченного количества)', gotoAnything: { actions: { - searchPlugins: 'Поисковые плагины', + searchPlugins: 'Поиск плагинов', searchKnowledgeBases: 'Поиск в базах знаний', searchApplications: 'Поиск приложений', searchKnowledgeBasesDesc: 'Поиск и переход к базам знаний', @@ -269,11 +269,11 @@ const translation = { themeCategoryTitle: 'Тема', languageCategoryTitle: 'Язык', themeSystem: 'Системная тема', - runDesc: 'Запустите быстрые команды (тема, язык, ...)', + runDesc: 'Запустите быстрые команды (тема, язык, …)', themeLight: 'Светлая тема', themeDarkDesc: 'Используйте темный внешний вид', - languageChangeDesc: 'Изменить язык интерфейса', - languageCategoryDesc: 'Переключить язык интерфейса', + languageChangeDesc: 'Измените язык интерфейса', + languageCategoryDesc: 'Переключите язык интерфейса', themeLightDesc: 'Используйте светлый внешний вид', themeSystemDesc: 'Следуйте внешнему виду вашей операционной системы', }, diff --git a/web/i18n/sl-SI/app.ts b/web/i18n/sl-SI/app.ts index c4a275999d..518a0bd862 100644 --- a/web/i18n/sl-SI/app.ts +++ b/web/i18n/sl-SI/app.ts @@ -258,7 +258,7 @@ const translation = { searchKnowledgeBasesDesc: 'Iskanje in krmarjenje do zbirk znanja', searchWorkflowNodesHelp: 'Ta funkcija deluje le pri ogledu poteka dela. Najprej se pomaknite do poteka dela.', searchApplicationsDesc: 'Iskanje in krmarjenje do aplikacij', - searchPlugins: 'Iskalni vtičniki', + searchPlugins: 'Iskanje vtičnikov', searchApplications: 'Iskanje aplikacij', searchWorkflowNodesDesc: 'Iskanje vozlišč in skok nanje v trenutnem poteku dela po imenu ali vrsti', searchKnowledgeBases: 'Iskanje po zbirkah znanja', diff --git a/web/i18n/th-TH/app.ts b/web/i18n/th-TH/app.ts index ee0e53895e..c7eeda213f 100644 --- a/web/i18n/th-TH/app.ts +++ b/web/i18n/th-TH/app.ts @@ -37,11 +37,11 @@ const translation = { captionName: 'ไอคอนและชื่อโปรเจกต์', appNamePlaceholder: 'ตั้งชื่อโปรเจกต์ของคุณ', captionDescription: 'คำอธิบาย', - appDescriptionPlaceholder: 'ป้อนคําอธิบายของโปรเจกต์', + appDescriptionPlaceholder: 'ป้อนคำอธิบายของโปรเจกต์', useTemplate: 'ใช้เทมเพลตนี้', previewDemo: 'ตัวอย่างการใช้งาน', chatApp: 'ผู้ช่วย', - chatAppIntro: 'ฉันต้องการสร้างโปรเจกต์ ที่เป็นแอปพลิเคชันที่ใช้การแชท โปรเจกต์นี้ใช้รูปแบบคําถามและคําตอบ ทําให้สามารถสนทนาต่อเนื่องได้หลายรอบ(Multi-turn)', + chatAppIntro: 'ฉันต้องการสร้างโปรเจกต์ ที่เป็นแอปพลิเคชันที่ใช้การแชท โปรเจกต์นี้ใช้รูปแบบคำถามและคำตอบ ทําให้สามารถสนทนาต่อเนื่องได้หลายรอบ(Multi-turn)', agentAssistant: 'ผู้ช่วยใหม่', completeApp: 'เครื่องมือสร้างข้อความ', completeAppIntro: 'ฉันต้องการสร้างโปรเจกต์ที่ ที่สามารถสร้างข้อความคุณภาพสูงตามข้อความแจ้ง เช่น การสร้างบทความ สรุป การแปล และอื่นๆ', @@ -294,7 +294,7 @@ const translation = { searchTemporarilyUnavailable: 'การค้นหาไม่พร้อมใช้งานชั่วคราว', someServicesUnavailable: 'บริการค้นหาบางบริการไม่พร้อมใช้งาน', clearToSearchAll: 'ล้าง @ เพื่อค้นหาทั้งหมด', - searchPlaceholder: 'ค้นหาหรือพิมพ์ @ สําหรับคําสั่ง...', + searchPlaceholder: 'ค้นหาหรือพิมพ์ @ สำหรับคำสั่ง...', servicesUnavailableMessage: 'บริการค้นหาบางบริการอาจประสบปัญหา ลองอีกครั้งในอีกสักครู่', searching: 'กำลังค้นหา...', searchHint: 'เริ่มพิมพ์เพื่อค้นหาทุกอย่างได้ทันที', @@ -303,7 +303,7 @@ const translation = { resultCount: '{{count}} ผลลัพธ์', resultCount_other: '{{count}} ผลลัพธ์', inScope: 'ใน {{scope}}s', - noMatchingCommands: 'ไม่พบคําสั่งที่ตรงกัน', + noMatchingCommands: 'ไม่พบคำสั่งที่ตรงกัน', tryDifferentSearch: 'ลองใช้ข้อความค้นหาอื่น', }, } diff --git a/web/i18n/tr-TR/app.ts b/web/i18n/tr-TR/app.ts index d0ac18d3cd..5c165030a4 100644 --- a/web/i18n/tr-TR/app.ts +++ b/web/i18n/tr-TR/app.ts @@ -252,11 +252,10 @@ const translation = { actions: { searchKnowledgeBasesDesc: 'Bilgi bankalarınızda arama yapın ve bu forumlara gidin', searchWorkflowNodesDesc: 'Geçerli iş akışındaki düğümleri ada veya türe göre bulun ve atlayın', - searchApplications: 'Arama Uygulamaları', + searchApplications: 'Uygulamaları Ara', searchKnowledgeBases: 'Bilgi Bankalarında Ara', - searchWorkflowNodes: 'Arama İş Akışı Düğümleri', - searchPluginsDesc: 'Eklentilerinizi arayın ve eklentilerinize gidin', - searchPlugins: 'Arama Eklentileri', + searchWorkflowNodes: 'İş Akışı Düğümlerini Ara', + searchPlugins: 'Eklentileri Ara', searchWorkflowNodesHelp: 'Bu özellik yalnızca bir iş akışını görüntülerken çalışır. Önce bir iş akışına gidin.', searchApplicationsDesc: 'Uygulamalarınızı arayın ve uygulamalarınıza gidin', languageChangeDesc: 'UI dilini değiştir', diff --git a/web/i18n/uk-UA/app.ts b/web/i18n/uk-UA/app.ts index aea7bf525e..973ad8b2a5 100644 --- a/web/i18n/uk-UA/app.ts +++ b/web/i18n/uk-UA/app.ts @@ -256,22 +256,22 @@ const translation = { actions: { searchApplications: 'Пошук додатків', searchKnowledgeBases: 'Пошук по базах знань', - searchWorkflowNodes: 'Вузли документообігу пошуку', + searchWorkflowNodes: 'Пошук вузлів робочого процесу', searchApplicationsDesc: 'Шукайте та переходьте до своїх програм', searchPluginsDesc: 'Пошук і навігація до ваших плагінів', searchWorkflowNodesHelp: 'Ця функція працює лише під час перегляду робочого процесу. Спочатку перейдіть до робочого процесу.', - searchPlugins: 'Пошукові плагіни', + searchPlugins: 'Пошук плагінів', searchKnowledgeBasesDesc: 'Шукайте та переходьте до своїх баз знань', searchWorkflowNodesDesc: 'Знаходьте вузли в поточному робочому процесі та переходьте до них за іменем або типом', - themeSystem: 'Тема системи', + themeSystem: 'Системна тема', languageCategoryTitle: 'Мова', themeCategoryTitle: 'Тема', themeLight: 'Світла тема', runTitle: 'Команди', languageChangeDesc: 'Змінити мову інтерфейсу', - themeDark: 'Темний режим', + themeDark: 'Темна тема', themeDarkDesc: 'Використовуйте темний режим', - runDesc: 'Run quick commands (theme, language, ...)', + runDesc: 'Запустіть швидкі команди (тема, мова, ...)', themeCategoryDesc: 'Переключити тему застосунку', themeLightDesc: 'Використовуйте світлий вигляд', themeSystemDesc: 'Дотримуйтесь зовнішнього вигляду вашої операційної системи', diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 713b34cdb9..a05c4cdfce 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -162,7 +162,7 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: ...baseHooks.beforeRequest || [], isPublicAPI && beforeRequestPublicAuthorization, !isPublicAPI && !isMarketplaceAPI && beforeRequestAuthorization, - ].filter(Boolean), + ].filter((h): h is BeforeRequestHook => Boolean(h)), afterResponse: [ ...baseHooks.afterResponse || [], afterResponseErrorCode(otherOptions), diff --git a/web/utils/completion-params.ts b/web/utils/completion-params.ts index b46c3ab720..fb339423c8 100644 --- a/web/utils/completion-params.ts +++ b/web/utils/completion-params.ts @@ -7,7 +7,6 @@ export const mergeValidCompletionParams = ( if (!oldParams || Object.keys(oldParams).length === 0) return { params: {}, removedDetails: {} } - const acceptedKeys = new Set(rules.map(r => r.name)) const ruleMap: Record = {} rules.forEach((r) => { ruleMap[r.name] = r @@ -17,11 +16,6 @@ export const mergeValidCompletionParams = ( const removedDetails: Record = {} Object.entries(oldParams).forEach(([key, value]) => { - if (!acceptedKeys.has(key)) { - removedDetails[key] = 'unsupported' - return - } - const rule = ruleMap[key] if (!rule) { removedDetails[key] = 'unsupported'