-
+
+
Date: Thu, 17 Jul 2025 13:49:41 +0800
Subject: [PATCH 03/10] minor bug fix: wrong default metrics endpoint (#22535)
---
api/extensions/ext_otel.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py
index 0771104fb1..b027a165f9 100644
--- a/api/extensions/ext_otel.py
+++ b/api/extensions/ext_otel.py
@@ -205,7 +205,7 @@ def init_app(app: DifyApp):
metric_endpoint = dify_config.OTLP_METRIC_ENDPOINT
if not metric_endpoint:
- metric_endpoint = dify_config.OTLP_BASE_ENDPOINT + "/v1/traces"
+ metric_endpoint = dify_config.OTLP_BASE_ENDPOINT + "/v1/metrics"
metric_exporter = HTTPMetricExporter(
endpoint=metric_endpoint,
headers=headers,
From 93c27b134da5cbd363be7c2aa1b6aab5b9edcaf9 Mon Sep 17 00:00:00 2001
From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Date: Thu, 17 Jul 2025 13:52:15 +0800
Subject: [PATCH 04/10] minor typo fix: remove debug code and fix typo (#22539)
---
api/controllers/console/auth/error.py | 6 +++---
api/controllers/console/workspace/members.py | 2 --
api/templates/change_mail_confirm_old_template_zh-CN.html | 1 -
.../transfer_workspace_owner_confirm_template_en-US.html | 2 +-
.../change_mail_confirm_old_template_zh-CN.html | 1 -
.../transfer_workspace_owner_confirm_template_en-US.html | 2 +-
6 files changed, 5 insertions(+), 9 deletions(-)
diff --git a/api/controllers/console/auth/error.py b/api/controllers/console/auth/error.py
index f4a8b97483..8c5e23de58 100644
--- a/api/controllers/console/auth/error.py
+++ b/api/controllers/console/auth/error.py
@@ -27,19 +27,19 @@ class InvalidTokenError(BaseHTTPException):
class PasswordResetRateLimitExceededError(BaseHTTPException):
error_code = "password_reset_rate_limit_exceeded"
- description = "Too many password reset emails have been sent. Please try again in 1 minutes."
+ description = "Too many password reset emails have been sent. Please try again in 1 minute."
code = 429
class EmailChangeRateLimitExceededError(BaseHTTPException):
error_code = "email_change_rate_limit_exceeded"
- description = "Too many email change emails have been sent. Please try again in 1 minutes."
+ description = "Too many email change emails have been sent. Please try again in 1 minute."
code = 429
class OwnerTransferRateLimitExceededError(BaseHTTPException):
error_code = "owner_transfer_rate_limit_exceeded"
- description = "Too many owner tansfer emails have been sent. Please try again in 1 minutes."
+ description = "Too many owner transfer emails have been sent. Please try again in 1 minute."
code = 429
diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py
index 30a4148dbb..b1f79ffdec 100644
--- a/api/controllers/console/workspace/members.py
+++ b/api/controllers/console/workspace/members.py
@@ -264,11 +264,9 @@ class OwnerTransfer(Resource):
transfer_token_data = AccountService.get_owner_transfer_data(args["token"])
if not transfer_token_data:
- print(transfer_token_data, "transfer_token_data")
raise InvalidTokenError()
if transfer_token_data.get("email") != current_user.email:
- print(transfer_token_data.get("email"), current_user.email)
raise InvalidEmailError()
AccountService.revoke_owner_transfer_token(args["token"])
diff --git a/api/templates/change_mail_confirm_old_template_zh-CN.html b/api/templates/change_mail_confirm_old_template_zh-CN.html
index 4a3e35cfb6..23c9e46652 100644
--- a/api/templates/change_mail_confirm_old_template_zh-CN.html
+++ b/api/templates/change_mail_confirm_old_template_zh-CN.html
@@ -111,7 +111,6 @@
验证您的邮箱变更请求
我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
-
我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
此验证码仅在接下来的5分钟内有效:
diff --git a/api/templates/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/transfer_workspace_owner_confirm_template_en-US.html
index fb8c107274..101495ea8f 100644
--- a/api/templates/transfer_workspace_owner_confirm_template_en-US.html
+++ b/api/templates/transfer_workspace_owner_confirm_template_en-US.html
@@ -143,7 +143,7 @@
Please note:
- The ownership transfer will take effect immediately once confirmed and cannot be undone.
- - You’ll become a admin member, and the new owner will have full control of the workspace.
+ - You’ll become an admin member, and the new owner will have full control of the workspace.
If you didn’t make this request, please ignore this email or contact support immediately.
diff --git a/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html b/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html
index 66887ccb06..41f0839190 100644
--- a/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html
+++ b/api/templates/without-brand/change_mail_confirm_old_template_zh-CN.html
@@ -108,7 +108,6 @@
验证您的邮箱变更请求
我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
-
我们收到了一个变更您 Dify 账户关联邮箱地址的请求。
此验证码仅在接下来的5分钟内有效:
diff --git a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html
index 0e045a5878..11ce275641 100644
--- a/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html
+++ b/api/templates/without-brand/transfer_workspace_owner_confirm_template_en-US.html
@@ -140,7 +140,7 @@
Please note:
- The ownership transfer will take effect immediately once confirmed and cannot be undone.
- - You’ll become a admin member, and the new owner will have full control of the workspace.
+ - You’ll become an admin member, and the new owner will have full control of the workspace.
If you didn’t make this request, please ignore this email or contact support immediately.
From 4b2baeea655892667a7bde3c246cd6d054172511 Mon Sep 17 00:00:00 2001
From: Stream <1542763342@qq.com>
Date: Thu, 17 Jul 2025 14:19:52 +0800
Subject: [PATCH 05/10] fix: use model provided by user in prompt generator
(#22541) (#22542)
Co-authored-by: stream
---
api/core/llm_generator/llm_generator.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py
index e01896a491..f7fd93be4a 100644
--- a/api/core/llm_generator/llm_generator.py
+++ b/api/core/llm_generator/llm_generator.py
@@ -148,9 +148,11 @@ class LLMGenerator:
model_manager = ModelManager()
- model_instance = model_manager.get_default_model_instance(
+ model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
+ provider=model_config.get("provider", ""),
+ model=model_config.get("name", ""),
)
try:
From fafb1d5fd7f3263aa7edf64e7041faf23384b00b Mon Sep 17 00:00:00 2001
From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Date: Thu, 17 Jul 2025 14:20:44 +0800
Subject: [PATCH 06/10] feat: validate email according to RFC 5322 (#22540)
---
web/app/account/account-page/email-change-modal.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web/app/account/account-page/email-change-modal.tsx b/web/app/account/account-page/email-change-modal.tsx
index 85c7db5945..c3efad104a 100644
--- a/web/app/account/account-page/email-change-modal.tsx
+++ b/web/app/account/account-page/email-change-modal.tsx
@@ -113,8 +113,8 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
const isValidEmail = (email: string): boolean => {
- const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
- return emailRegex.test(email)
+ const rfc5322emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
+ return rfc5322emailRegex.test(email) && email.length <= 254
}
const checkNewEmailExisted = async (email: string) => {
From 74caebac32179bfd3c037d8e430136c350739176 Mon Sep 17 00:00:00 2001
From: Jason Young <44939412+farion1231@users.noreply.github.com>
Date: Thu, 17 Jul 2025 14:20:59 +0800
Subject: [PATCH 07/10] test: add comprehensive OAuth authentication unit tests
(#22528)
---
.../controllers/console/auth/test_oauth.py | 496 ++++++++++++++++++
.../unit_tests/libs/test_oauth_clients.py | 249 +++++++++
2 files changed, 745 insertions(+)
create mode 100644 api/tests/unit_tests/controllers/console/auth/test_oauth.py
create mode 100644 api/tests/unit_tests/libs/test_oauth_clients.py
diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth.py b/api/tests/unit_tests/controllers/console/auth/test_oauth.py
new file mode 100644
index 0000000000..037c9f2745
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/auth/test_oauth.py
@@ -0,0 +1,496 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask
+
+from controllers.console.auth.oauth import (
+ OAuthCallback,
+ OAuthLogin,
+ _generate_account,
+ _get_account_by_openid_or_email,
+ get_oauth_providers,
+)
+from libs.oauth import OAuthUserInfo
+from models.account import AccountStatus
+from services.errors.account import AccountNotFoundError
+
+
+class TestGetOAuthProviders:
+ @pytest.fixture
+ def app(self):
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ return app
+
+ @pytest.mark.parametrize(
+ ("github_config", "google_config", "expected_github", "expected_google"),
+ [
+ # Both providers configured
+ (
+ {"id": "github_id", "secret": "github_secret"},
+ {"id": "google_id", "secret": "google_secret"},
+ True,
+ True,
+ ),
+ # Only GitHub configured
+ ({"id": "github_id", "secret": "github_secret"}, {"id": None, "secret": None}, True, False),
+ # Only Google configured
+ ({"id": None, "secret": None}, {"id": "google_id", "secret": "google_secret"}, False, True),
+ # No providers configured
+ ({"id": None, "secret": None}, {"id": None, "secret": None}, False, False),
+ ],
+ )
+ @patch("controllers.console.auth.oauth.dify_config")
+ def test_should_configure_oauth_providers_correctly(
+ self, mock_config, app, github_config, google_config, expected_github, expected_google
+ ):
+ mock_config.GITHUB_CLIENT_ID = github_config["id"]
+ mock_config.GITHUB_CLIENT_SECRET = github_config["secret"]
+ mock_config.GOOGLE_CLIENT_ID = google_config["id"]
+ mock_config.GOOGLE_CLIENT_SECRET = google_config["secret"]
+ mock_config.CONSOLE_API_URL = "http://localhost"
+
+ with app.app_context():
+ providers = get_oauth_providers()
+
+ assert (providers["github"] is not None) == expected_github
+ assert (providers["google"] is not None) == expected_google
+
+
+class TestOAuthLogin:
+ @pytest.fixture
+ def resource(self):
+ return OAuthLogin()
+
+ @pytest.fixture
+ def app(self):
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ return app
+
+ @pytest.fixture
+ def mock_oauth_provider(self):
+ provider = MagicMock()
+ provider.get_authorization_url.return_value = "https://github.com/login/oauth/authorize?..."
+ return provider
+
+ @pytest.mark.parametrize(
+ ("invite_token", "expected_token"),
+ [
+ (None, None),
+ ("test_invite_token", "test_invite_token"),
+ ("", None),
+ ],
+ )
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ @patch("controllers.console.auth.oauth.redirect")
+ def test_should_handle_oauth_login_with_various_tokens(
+ self,
+ mock_redirect,
+ mock_get_providers,
+ resource,
+ app,
+ mock_oauth_provider,
+ invite_token,
+ expected_token,
+ ):
+ mock_get_providers.return_value = {"github": mock_oauth_provider, "google": None}
+
+ query_string = f"invite_token={invite_token}" if invite_token else ""
+ with app.test_request_context(f"/auth/oauth/github?{query_string}"):
+ resource.get("github")
+
+ mock_oauth_provider.get_authorization_url.assert_called_once_with(invite_token=expected_token)
+ mock_redirect.assert_called_once_with("https://github.com/login/oauth/authorize?...")
+
+ @pytest.mark.parametrize(
+ ("provider", "expected_error"),
+ [
+ ("invalid_provider", "Invalid provider"),
+ ("github", "Invalid provider"), # When GitHub is not configured
+ ("google", "Invalid provider"), # When Google is not configured
+ ],
+ )
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ def test_should_return_error_for_invalid_providers(
+ self, mock_get_providers, resource, app, provider, expected_error
+ ):
+ mock_get_providers.return_value = {"github": None, "google": None}
+
+ with app.test_request_context(f"/auth/oauth/{provider}"):
+ response, status_code = resource.get(provider)
+
+ assert status_code == 400
+ assert response["error"] == expected_error
+
+
+class TestOAuthCallback:
+ @pytest.fixture
+ def resource(self):
+ return OAuthCallback()
+
+ @pytest.fixture
+ def app(self):
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ return app
+
+ @pytest.fixture
+ def oauth_setup(self):
+ """Common OAuth setup for callback tests"""
+ oauth_provider = MagicMock()
+ oauth_provider.get_access_token.return_value = "access_token"
+ oauth_provider.get_user_info.return_value = OAuthUserInfo(id="123", name="Test User", email="test@example.com")
+
+ account = MagicMock()
+ account.status = AccountStatus.ACTIVE.value
+
+ token_pair = MagicMock()
+ token_pair.access_token = "jwt_access_token"
+ token_pair.refresh_token = "jwt_refresh_token"
+
+ return {"provider": oauth_provider, "account": account, "token_pair": token_pair}
+
+ @patch("controllers.console.auth.oauth.dify_config")
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ @patch("controllers.console.auth.oauth._generate_account")
+ @patch("controllers.console.auth.oauth.AccountService")
+ @patch("controllers.console.auth.oauth.TenantService")
+ @patch("controllers.console.auth.oauth.redirect")
+ def test_should_handle_successful_oauth_callback(
+ self,
+ mock_redirect,
+ mock_tenant_service,
+ mock_account_service,
+ mock_generate_account,
+ mock_get_providers,
+ mock_config,
+ resource,
+ app,
+ oauth_setup,
+ ):
+ mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
+ mock_get_providers.return_value = {"github": oauth_setup["provider"]}
+ mock_generate_account.return_value = oauth_setup["account"]
+ mock_account_service.login.return_value = oauth_setup["token_pair"]
+
+ with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
+ resource.get("github")
+
+ oauth_setup["provider"].get_access_token.assert_called_once_with("test_code")
+ oauth_setup["provider"].get_user_info.assert_called_once_with("access_token")
+ mock_redirect.assert_called_once_with(
+ "http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token"
+ )
+
+ @pytest.mark.parametrize(
+ ("exception", "expected_error"),
+ [
+ (Exception("OAuth error"), "OAuth process failed"),
+ (ValueError("Invalid token"), "OAuth process failed"),
+ (KeyError("Missing key"), "OAuth process failed"),
+ ],
+ )
+ @patch("controllers.console.auth.oauth.db")
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ def test_should_handle_oauth_exceptions(
+ self, mock_get_providers, mock_db, resource, app, exception, expected_error
+ ):
+ # Mock database session
+ mock_db.session = MagicMock()
+ mock_db.session.rollback = MagicMock()
+
+ # Import the real requests module to create a proper exception
+ import requests
+
+ request_exception = requests.exceptions.RequestException("OAuth error")
+ request_exception.response = MagicMock()
+ request_exception.response.text = str(exception)
+
+ mock_oauth_provider = MagicMock()
+ mock_oauth_provider.get_access_token.side_effect = request_exception
+ mock_get_providers.return_value = {"github": mock_oauth_provider}
+
+ with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
+ response, status_code = resource.get("github")
+
+ assert status_code == 400
+ assert response["error"] == expected_error
+
+ @pytest.mark.parametrize(
+ ("account_status", "expected_redirect"),
+ [
+ (AccountStatus.BANNED.value, "http://localhost:3000/signin?message=Account is banned."),
+ # CLOSED status: Currently NOT handled, will proceed to login (security issue)
+ # This documents actual behavior. See test_defensive_check_for_closed_account_status for details
+ (
+ AccountStatus.CLOSED.value,
+ "http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token",
+ ),
+ ],
+ )
+ @patch("controllers.console.auth.oauth.AccountService")
+ @patch("controllers.console.auth.oauth.TenantService")
+ @patch("controllers.console.auth.oauth.db")
+ @patch("controllers.console.auth.oauth.dify_config")
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ @patch("controllers.console.auth.oauth._generate_account")
+ @patch("controllers.console.auth.oauth.redirect")
+ def test_should_redirect_based_on_account_status(
+ self,
+ mock_redirect,
+ mock_generate_account,
+ mock_get_providers,
+ mock_config,
+ mock_db,
+ mock_tenant_service,
+ mock_account_service,
+ resource,
+ app,
+ oauth_setup,
+ account_status,
+ expected_redirect,
+ ):
+ # Mock database session
+ mock_db.session = MagicMock()
+ mock_db.session.rollback = MagicMock()
+ mock_db.session.commit = MagicMock()
+
+ mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
+ mock_get_providers.return_value = {"github": oauth_setup["provider"]}
+
+ account = MagicMock()
+ account.status = account_status
+ account.id = "123"
+ mock_generate_account.return_value = account
+
+ # Mock login for CLOSED status
+ mock_token_pair = MagicMock()
+ mock_token_pair.access_token = "jwt_access_token"
+ mock_token_pair.refresh_token = "jwt_refresh_token"
+ mock_account_service.login.return_value = mock_token_pair
+
+ with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
+ resource.get("github")
+
+ mock_redirect.assert_called_once_with(expected_redirect)
+
+ @patch("controllers.console.auth.oauth.dify_config")
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ @patch("controllers.console.auth.oauth._generate_account")
+ @patch("controllers.console.auth.oauth.db")
+ @patch("controllers.console.auth.oauth.TenantService")
+ @patch("controllers.console.auth.oauth.AccountService")
+ def test_should_activate_pending_account(
+ self,
+ mock_account_service,
+ mock_tenant_service,
+ mock_db,
+ mock_generate_account,
+ mock_get_providers,
+ mock_config,
+ resource,
+ app,
+ oauth_setup,
+ ):
+ mock_get_providers.return_value = {"github": oauth_setup["provider"]}
+
+ mock_account = MagicMock()
+ mock_account.status = AccountStatus.PENDING.value
+ mock_generate_account.return_value = mock_account
+
+ with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
+ resource.get("github")
+
+ assert mock_account.status == AccountStatus.ACTIVE.value
+ assert mock_account.initialized_at is not None
+ mock_db.session.commit.assert_called_once()
+
+ @patch("controllers.console.auth.oauth.dify_config")
+ @patch("controllers.console.auth.oauth.get_oauth_providers")
+ @patch("controllers.console.auth.oauth._generate_account")
+ @patch("controllers.console.auth.oauth.db")
+ @patch("controllers.console.auth.oauth.TenantService")
+ @patch("controllers.console.auth.oauth.AccountService")
+ @patch("controllers.console.auth.oauth.redirect")
+ def test_defensive_check_for_closed_account_status(
+ self,
+ mock_redirect,
+ mock_account_service,
+ mock_tenant_service,
+ mock_db,
+ mock_generate_account,
+ mock_get_providers,
+ mock_config,
+ resource,
+ app,
+ oauth_setup,
+ ):
+ """Defensive test for CLOSED account status handling in OAuth callback.
+
+ This is a defensive test documenting expected security behavior for CLOSED accounts.
+
+ Current behavior: CLOSED status is NOT checked, allowing closed accounts to login.
+ Expected behavior: CLOSED accounts should be rejected like BANNED accounts.
+
+ Context:
+ - AccountStatus.CLOSED is defined in the enum but never used in production
+ - The close_account() method exists but is never called
+ - Account deletion uses external service instead of status change
+ - All authentication services (OAuth, password, email) don't check CLOSED status
+
+ TODO: If CLOSED status is implemented in the future:
+ 1. Update OAuth callback to check for CLOSED status
+ 2. Add similar checks to all authentication services for consistency
+ 3. Update this test to verify the rejection behavior
+
+ Security consideration: Until properly implemented, CLOSED status provides no protection.
+ """
+ # Setup
+ mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
+ mock_get_providers.return_value = {"github": oauth_setup["provider"]}
+
+ # Create account with CLOSED status
+ closed_account = MagicMock()
+ closed_account.status = AccountStatus.CLOSED.value
+ closed_account.id = "123"
+ closed_account.name = "Closed Account"
+ mock_generate_account.return_value = closed_account
+
+ # Mock successful login (current behavior)
+ mock_token_pair = MagicMock()
+ mock_token_pair.access_token = "jwt_access_token"
+ mock_token_pair.refresh_token = "jwt_refresh_token"
+ mock_account_service.login.return_value = mock_token_pair
+
+ # Execute OAuth callback
+ with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
+ resource.get("github")
+
+ # Verify current behavior: login succeeds (this is NOT ideal)
+ mock_redirect.assert_called_once_with(
+ "http://localhost:3000?access_token=jwt_access_token&refresh_token=jwt_refresh_token"
+ )
+ mock_account_service.login.assert_called_once()
+
+ # Document expected behavior in comments:
+ # Expected: mock_redirect.assert_called_once_with(
+ # "http://localhost:3000/signin?message=Account is closed."
+ # )
+ # Expected: mock_account_service.login.assert_not_called()
+
+
+class TestAccountGeneration:
+ @pytest.fixture
+ def user_info(self):
+ return OAuthUserInfo(id="123", name="Test User", email="test@example.com")
+
+ @pytest.fixture
+ def mock_account(self):
+ account = MagicMock()
+ account.name = "Test User"
+ return account
+
+ @patch("controllers.console.auth.oauth.db")
+ @patch("controllers.console.auth.oauth.Account")
+ @patch("controllers.console.auth.oauth.Session")
+ @patch("controllers.console.auth.oauth.select")
+ def test_should_get_account_by_openid_or_email(
+ self, mock_select, mock_session, mock_account_model, mock_db, user_info, mock_account
+ ):
+ # Mock db.engine for Session creation
+ mock_db.engine = MagicMock()
+
+ # Test OpenID found
+ mock_account_model.get_by_openid.return_value = mock_account
+ result = _get_account_by_openid_or_email("github", user_info)
+ assert result == mock_account
+ mock_account_model.get_by_openid.assert_called_once_with("github", "123")
+
+ # Test fallback to email
+ mock_account_model.get_by_openid.return_value = None
+ mock_session_instance = MagicMock()
+ mock_session_instance.execute.return_value.scalar_one_or_none.return_value = mock_account
+ mock_session.return_value.__enter__.return_value = mock_session_instance
+
+ result = _get_account_by_openid_or_email("github", user_info)
+ assert result == mock_account
+
+ @pytest.mark.parametrize(
+ ("allow_register", "existing_account", "should_create"),
+ [
+ (True, None, True), # New account creation allowed
+ (True, "existing", False), # Existing account
+ (False, None, False), # Registration not allowed
+ ],
+ )
+ @patch("controllers.console.auth.oauth._get_account_by_openid_or_email")
+ @patch("controllers.console.auth.oauth.FeatureService")
+ @patch("controllers.console.auth.oauth.RegisterService")
+ @patch("controllers.console.auth.oauth.AccountService")
+ @patch("controllers.console.auth.oauth.TenantService")
+ @patch("controllers.console.auth.oauth.db")
+ def test_should_handle_account_generation_scenarios(
+ self,
+ mock_db,
+ mock_tenant_service,
+ mock_account_service,
+ mock_register_service,
+ mock_feature_service,
+ mock_get_account,
+ app,
+ user_info,
+ mock_account,
+ allow_register,
+ existing_account,
+ should_create,
+ ):
+ mock_get_account.return_value = mock_account if existing_account else None
+ mock_feature_service.get_system_features.return_value.is_allow_register = allow_register
+ mock_register_service.register.return_value = mock_account
+
+ with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
+ if not allow_register and not existing_account:
+ with pytest.raises(AccountNotFoundError):
+ _generate_account("github", user_info)
+ else:
+ result = _generate_account("github", user_info)
+ assert result == mock_account
+
+ if should_create:
+ mock_register_service.register.assert_called_once_with(
+ email="test@example.com", name="Test User", password=None, open_id="123", provider="github"
+ )
+
+ @patch("controllers.console.auth.oauth._get_account_by_openid_or_email")
+ @patch("controllers.console.auth.oauth.TenantService")
+ @patch("controllers.console.auth.oauth.FeatureService")
+ @patch("controllers.console.auth.oauth.AccountService")
+ @patch("controllers.console.auth.oauth.tenant_was_created")
+ def test_should_create_workspace_for_account_without_tenant(
+ self,
+ mock_event,
+ mock_account_service,
+ mock_feature_service,
+ mock_tenant_service,
+ mock_get_account,
+ app,
+ user_info,
+ mock_account,
+ ):
+ mock_get_account.return_value = mock_account
+ mock_tenant_service.get_join_tenants.return_value = []
+ mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
+
+ mock_new_tenant = MagicMock()
+ mock_tenant_service.create_tenant.return_value = mock_new_tenant
+
+ with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
+ result = _generate_account("github", user_info)
+
+ assert result == mock_account
+ mock_tenant_service.create_tenant.assert_called_once_with("Test User's Workspace")
+ mock_tenant_service.create_tenant_member.assert_called_once_with(
+ mock_new_tenant, mock_account, role="owner"
+ )
+ mock_event.send.assert_called_once_with(mock_new_tenant)
diff --git a/api/tests/unit_tests/libs/test_oauth_clients.py b/api/tests/unit_tests/libs/test_oauth_clients.py
new file mode 100644
index 0000000000..629d15b81a
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_oauth_clients.py
@@ -0,0 +1,249 @@
+import urllib.parse
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from libs.oauth import GitHubOAuth, GoogleOAuth, OAuthUserInfo
+
+
+class BaseOAuthTest:
+ """Base class for OAuth provider tests with common fixtures"""
+
+ @pytest.fixture
+ def oauth_config(self):
+ return {
+ "client_id": "test_client_id",
+ "client_secret": "test_client_secret",
+ "redirect_uri": "http://localhost/callback",
+ }
+
+ @pytest.fixture
+ def mock_response(self):
+ response = MagicMock()
+ response.json.return_value = {}
+ return response
+
+ def parse_auth_url(self, url):
+ """Helper to parse authorization URL"""
+ parsed = urllib.parse.urlparse(url)
+ params = urllib.parse.parse_qs(parsed.query)
+ return parsed, params
+
+
+class TestGitHubOAuth(BaseOAuthTest):
+ @pytest.fixture
+ def oauth(self, oauth_config):
+ return GitHubOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"])
+
+ @pytest.mark.parametrize(
+ ("invite_token", "expected_state"),
+ [
+ (None, None),
+ ("test_invite_token", "test_invite_token"),
+ ("", None),
+ ],
+ )
+ def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state):
+ url = oauth.get_authorization_url(invite_token)
+ parsed, params = self.parse_auth_url(url)
+
+ assert parsed.scheme == "https"
+ assert parsed.netloc == "github.com"
+ assert parsed.path == "/login/oauth/authorize"
+ assert params["client_id"][0] == oauth_config["client_id"]
+ assert params["redirect_uri"][0] == oauth_config["redirect_uri"]
+ assert params["scope"][0] == "user:email"
+
+ if expected_state:
+ assert params["state"][0] == expected_state
+ else:
+ assert "state" not in params
+
+ @pytest.mark.parametrize(
+ ("response_data", "expected_token", "should_raise"),
+ [
+ ({"access_token": "test_token"}, "test_token", False),
+ ({"error": "invalid_grant"}, None, True),
+ ({}, None, True),
+ ],
+ )
+ @patch("requests.post")
+ def test_should_retrieve_access_token(
+ self, mock_post, oauth, mock_response, response_data, expected_token, should_raise
+ ):
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ if should_raise:
+ with pytest.raises(ValueError) as exc_info:
+ oauth.get_access_token("test_code")
+ assert "Error in GitHub OAuth" in str(exc_info.value)
+ else:
+ token = oauth.get_access_token("test_code")
+ assert token == expected_token
+
+ @pytest.mark.parametrize(
+ ("user_data", "email_data", "expected_email"),
+ [
+ # User with primary email
+ (
+ {"id": 12345, "login": "testuser", "name": "Test User"},
+ [
+ {"email": "secondary@example.com", "primary": False},
+ {"email": "primary@example.com", "primary": True},
+ ],
+ "primary@example.com",
+ ),
+ # User with no emails - fallback to noreply
+ ({"id": 12345, "login": "testuser", "name": "Test User"}, [], "12345+testuser@users.noreply.github.com"),
+ # User with only secondary email - fallback to noreply
+ (
+ {"id": 12345, "login": "testuser", "name": "Test User"},
+ [{"email": "secondary@example.com", "primary": False}],
+ "12345+testuser@users.noreply.github.com",
+ ),
+ ],
+ )
+ @patch("requests.get")
+ def test_should_retrieve_user_info_correctly(self, mock_get, oauth, user_data, email_data, expected_email):
+ user_response = MagicMock()
+ user_response.json.return_value = user_data
+
+ email_response = MagicMock()
+ email_response.json.return_value = email_data
+
+ mock_get.side_effect = [user_response, email_response]
+
+ user_info = oauth.get_user_info("test_token")
+
+ assert user_info.id == str(user_data["id"])
+ assert user_info.name == user_data["name"]
+ assert user_info.email == expected_email
+
+ @patch("requests.get")
+ def test_should_handle_network_errors(self, mock_get, oauth):
+ mock_get.side_effect = requests.exceptions.RequestException("Network error")
+
+ with pytest.raises(requests.exceptions.RequestException):
+ oauth.get_raw_user_info("test_token")
+
+
+class TestGoogleOAuth(BaseOAuthTest):
+ @pytest.fixture
+ def oauth(self, oauth_config):
+ return GoogleOAuth(oauth_config["client_id"], oauth_config["client_secret"], oauth_config["redirect_uri"])
+
+ @pytest.mark.parametrize(
+ ("invite_token", "expected_state"),
+ [
+ (None, None),
+ ("test_invite_token", "test_invite_token"),
+ ("", None),
+ ],
+ )
+ def test_should_generate_authorization_url_correctly(self, oauth, oauth_config, invite_token, expected_state):
+ url = oauth.get_authorization_url(invite_token)
+ parsed, params = self.parse_auth_url(url)
+
+ assert parsed.scheme == "https"
+ assert parsed.netloc == "accounts.google.com"
+ assert parsed.path == "/o/oauth2/v2/auth"
+ assert params["client_id"][0] == oauth_config["client_id"]
+ assert params["redirect_uri"][0] == oauth_config["redirect_uri"]
+ assert params["response_type"][0] == "code"
+ assert params["scope"][0] == "openid email"
+
+ if expected_state:
+ assert params["state"][0] == expected_state
+ else:
+ assert "state" not in params
+
+ @pytest.mark.parametrize(
+ ("response_data", "expected_token", "should_raise"),
+ [
+ ({"access_token": "test_token"}, "test_token", False),
+ ({"error": "invalid_grant"}, None, True),
+ ({}, None, True),
+ ],
+ )
+ @patch("requests.post")
+ def test_should_retrieve_access_token(
+ self, mock_post, oauth, oauth_config, mock_response, response_data, expected_token, should_raise
+ ):
+ mock_response.json.return_value = response_data
+ mock_post.return_value = mock_response
+
+ if should_raise:
+ with pytest.raises(ValueError) as exc_info:
+ oauth.get_access_token("test_code")
+ assert "Error in Google OAuth" in str(exc_info.value)
+ else:
+ token = oauth.get_access_token("test_code")
+ assert token == expected_token
+
+ mock_post.assert_called_once_with(
+ oauth._TOKEN_URL,
+ data={
+ "client_id": oauth_config["client_id"],
+ "client_secret": oauth_config["client_secret"],
+ "code": "test_code",
+ "grant_type": "authorization_code",
+ "redirect_uri": oauth_config["redirect_uri"],
+ },
+ headers={"Accept": "application/json"},
+ )
+
+ @pytest.mark.parametrize(
+ ("user_data", "expected_name"),
+ [
+ ({"sub": "123", "email": "test@example.com", "email_verified": True}, ""),
+ ({"sub": "123", "email": "test@example.com", "name": "Test User"}, ""), # Always returns empty string
+ ],
+ )
+ @patch("requests.get")
+ def test_should_retrieve_user_info_correctly(self, mock_get, oauth, mock_response, user_data, expected_name):
+ mock_response.json.return_value = user_data
+ mock_get.return_value = mock_response
+
+ user_info = oauth.get_user_info("test_token")
+
+ assert user_info.id == user_data["sub"]
+ assert user_info.name == expected_name
+ assert user_info.email == user_data["email"]
+
+ mock_get.assert_called_once_with(oauth._USER_INFO_URL, headers={"Authorization": "Bearer test_token"})
+
+ @pytest.mark.parametrize(
+ "exception_type",
+ [
+ requests.exceptions.HTTPError,
+ requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout,
+ ],
+ )
+ @patch("requests.get")
+ def test_should_handle_http_errors(self, mock_get, oauth, exception_type):
+ mock_response = MagicMock()
+ mock_response.raise_for_status.side_effect = exception_type("Error")
+ mock_get.return_value = mock_response
+
+ with pytest.raises(exception_type):
+ oauth.get_raw_user_info("invalid_token")
+
+
+class TestOAuthUserInfo:
+ @pytest.mark.parametrize(
+ "user_data",
+ [
+ {"id": "123", "name": "Test User", "email": "test@example.com"},
+ {"id": "456", "name": "", "email": "user@domain.com"},
+ {"id": "789", "name": "Another User", "email": "another@test.org"},
+ ],
+ )
+ def test_should_create_user_info_dataclass(self, user_data):
+ user_info = OAuthUserInfo(**user_data)
+
+ assert user_info.id == user_data["id"]
+ assert user_info.name == user_data["name"]
+ assert user_info.email == user_data["email"]
From 4b604bd79af51cc3ca51bc02e8be5bee9b2ddf40 Mon Sep 17 00:00:00 2001
From: Om Kashyap Avashia <147749578+omavashia2005@users.noreply.github.com>
Date: Thu, 17 Jul 2025 12:39:14 +0530
Subject: [PATCH 08/10] fix: Python SDK WorkflowClient and KnowledgeBase client
imports fixed. Added documentation for WorkflowClient. (#22476)
Co-authored-by: crazywoola <427733928@qq.com>
---
sdks/python-client/README.md | 39 ++++++++++++++++++++++
sdks/python-client/dify_client/__init__.py | 2 +-
2 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/sdks/python-client/README.md b/sdks/python-client/README.md
index 8949ef08fa..7401fd2fd4 100644
--- a/sdks/python-client/README.md
+++ b/sdks/python-client/README.md
@@ -183,3 +183,42 @@ rename_conversation_response.raise_for_status()
print('[rename result]')
print(rename_conversation_response.json())
```
+
+* Using the Workflow Client
+```python
+import json
+import requests
+from dify_client import WorkflowClient
+
+api_key = "your_api_key"
+
+# Initialize Workflow Client
+client = WorkflowClient(api_key)
+
+# Prepare parameters for Workflow Client
+user_id = "your_user_id"
+context = "previous user interaction / metadata"
+user_prompt = "What is the capital of France?"
+
+inputs = {
+ "context": context,
+ "user_prompt": user_prompt,
+ # Add other input fields expected by your workflow (e.g., additional context, task parameters)
+
+}
+
+# Set response mode (default: streaming)
+response_mode = "blocking"
+
+# Run the workflow
+response = client.run(inputs=inputs, response_mode=response_mode, user=user_id)
+response.raise_for_status()
+
+# Parse result
+result = json.loads(response.text)
+
+answer = result.get("data").get("outputs")
+
+print(answer["answer"])
+
+```
diff --git a/sdks/python-client/dify_client/__init__.py b/sdks/python-client/dify_client/__init__.py
index 6fa9d190e5..b557a9ce95 100644
--- a/sdks/python-client/dify_client/__init__.py
+++ b/sdks/python-client/dify_client/__init__.py
@@ -1 +1 @@
-from dify_client.client import ChatClient, CompletionClient, DifyClient
+from dify_client.client import ChatClient, CompletionClient, WorkflowClient, KnowledgeBaseClient, DifyClient
From 3cfba9e47bbe6e1378eba73e5c93dea376c20ab8 Mon Sep 17 00:00:00 2001
From: znn
Date: Thu, 17 Jul 2025 12:40:36 +0530
Subject: [PATCH 09/10] updating icon (#22485)
---
.../config-prompt/advanced-prompt-input.tsx | 8 ++---
.../config/agent/prompt-editor.tsx | 8 ++---
web/app/components/base/copy-icon/index.tsx | 8 ++---
.../vender/line/files/clipboard-check.svg | 3 --
.../assets/vender/line/files/clipboard.svg | 3 --
.../assets/vender/line/files/copy-check.svg | 3 ++
.../icons/assets/vender/line/files/copy.svg | 3 ++
.../src/vender/line/files/Clipboard.json | 29 -------------------
.../src/vender/line/files/ClipboardCheck.json | 29 -------------------
.../icons/src/vender/line/files/Copy.json | 29 +++++++++++++++++++
.../line/files/{Clipboard.tsx => Copy.tsx} | 4 +--
.../src/vender/line/files/CopyCheck.json | 29 +++++++++++++++++++
.../{ClipboardCheck.tsx => CopyCheck.tsx} | 4 +--
.../base/icons/src/vender/line/files/index.ts | 4 +--
.../plugins/base/key-value-item.tsx | 4 +--
.../plugin-detail-panel/endpoint-card.tsx | 4 +--
.../nodes/_base/components/editor/base.tsx | 8 ++---
.../nodes/_base/components/prompt/editor.tsx | 8 ++---
.../conversation-variable-modal.tsx | 8 ++---
19 files changed, 98 insertions(+), 98 deletions(-)
delete mode 100644 web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg
delete mode 100644 web/app/components/base/icons/assets/vender/line/files/clipboard.svg
create mode 100644 web/app/components/base/icons/assets/vender/line/files/copy-check.svg
create mode 100644 web/app/components/base/icons/assets/vender/line/files/copy.svg
delete mode 100644 web/app/components/base/icons/src/vender/line/files/Clipboard.json
delete mode 100644 web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json
create mode 100644 web/app/components/base/icons/src/vender/line/files/Copy.json
rename web/app/components/base/icons/src/vender/line/files/{Clipboard.tsx => Copy.tsx} (87%)
create mode 100644 web/app/components/base/icons/src/vender/line/files/CopyCheck.json
rename web/app/components/base/icons/src/vender/line/files/{ClipboardCheck.tsx => CopyCheck.tsx} (85%)
diff --git a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx
index 437e25fde4..e2d37bb9de 100644
--- a/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx
+++ b/web/app/components/app/configuration/config-prompt/advanced-prompt-input.tsx
@@ -17,8 +17,8 @@ import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
import cn from '@/utils/classnames'
import type { PromptRole, PromptVariable } from '@/models/debug'
import {
- Clipboard,
- ClipboardCheck,
+ Copy,
+ CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
@@ -188,13 +188,13 @@ const AdvancedPromptInput: FC = ({
)}
{!isCopied
? (
- {
+ {
copy(value)
setIsCopied(true)
}} />
)
: (
-
+
)}
diff --git a/web/app/components/app/configuration/config/agent/prompt-editor.tsx b/web/app/components/app/configuration/config/agent/prompt-editor.tsx
index 579b7c4d64..98b23e5379 100644
--- a/web/app/components/app/configuration/config/agent/prompt-editor.tsx
+++ b/web/app/components/app/configuration/config/agent/prompt-editor.tsx
@@ -6,8 +6,8 @@ import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import {
- Clipboard,
- ClipboardCheck,
+ Copy,
+ CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import PromptEditor from '@/app/components/base/prompt-editor'
import type { ExternalDataTool } from '@/models/common'
@@ -81,13 +81,13 @@ const Editor: FC
= ({
{!isCopied
? (
- {
+ {
copy(value)
setIsCopied(true)
}} />
)
: (
-
+
)}
diff --git a/web/app/components/base/copy-icon/index.tsx b/web/app/components/base/copy-icon/index.tsx
index c9e8a5ad14..196e256978 100644
--- a/web/app/components/base/copy-icon/index.tsx
+++ b/web/app/components/base/copy-icon/index.tsx
@@ -5,8 +5,8 @@ import { debounce } from 'lodash-es'
import copy from 'copy-to-clipboard'
import Tooltip from '../tooltip'
import {
- Clipboard,
- ClipboardCheck,
+ Copy,
+ CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
type Props = {
@@ -39,10 +39,10 @@ export const CopyIcon = ({ content }: Props) => {
{!isCopied
? (
-
+
)
: (
-
+
)
}
diff --git a/web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg b/web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg
deleted file mode 100644
index 48c70edd74..0000000000
--- a/web/app/components/base/icons/assets/vender/line/files/clipboard-check.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/web/app/components/base/icons/assets/vender/line/files/clipboard.svg b/web/app/components/base/icons/assets/vender/line/files/clipboard.svg
deleted file mode 100644
index 8abaaa9c39..0000000000
--- a/web/app/components/base/icons/assets/vender/line/files/clipboard.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/web/app/components/base/icons/assets/vender/line/files/copy-check.svg b/web/app/components/base/icons/assets/vender/line/files/copy-check.svg
new file mode 100644
index 0000000000..de5f86cc19
--- /dev/null
+++ b/web/app/components/base/icons/assets/vender/line/files/copy-check.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/app/components/base/icons/assets/vender/line/files/copy.svg b/web/app/components/base/icons/assets/vender/line/files/copy.svg
new file mode 100644
index 0000000000..18d2b4e7fc
--- /dev/null
+++ b/web/app/components/base/icons/assets/vender/line/files/copy.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/app/components/base/icons/src/vender/line/files/Clipboard.json b/web/app/components/base/icons/src/vender/line/files/Clipboard.json
deleted file mode 100644
index f256747558..0000000000
--- a/web/app/components/base/icons/src/vender/line/files/Clipboard.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "icon": {
- "type": "element",
- "isRootNode": true,
- "name": "svg",
- "attributes": {
- "width": "24",
- "height": "24",
- "viewBox": "0 0 24 24",
- "fill": "none",
- "xmlns": "http://www.w3.org/2000/svg"
- },
- "children": [
- {
- "type": "element",
- "name": "path",
- "attributes": {
- "d": "M16 4C16.93 4 17.395 4 17.7765 4.10222C18.8117 4.37962 19.6204 5.18827 19.8978 6.22354C20 6.60504 20 7.07003 20 8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V8C4 7.07003 4 6.60504 4.10222 6.22354C4.37962 5.18827 5.18827 4.37962 6.22354 4.10222C6.60504 4 7.07003 4 8 4M9.6 6H14.4C14.9601 6 15.2401 6 15.454 5.89101C15.6422 5.79513 15.7951 5.64215 15.891 5.45399C16 5.24008 16 4.96005 16 4.4V3.6C16 3.03995 16 2.75992 15.891 2.54601C15.7951 2.35785 15.6422 2.20487 15.454 2.10899C15.2401 2 14.9601 2 14.4 2H9.6C9.03995 2 8.75992 2 8.54601 2.10899C8.35785 2.20487 8.20487 2.35785 8.10899 2.54601C8 2.75992 8 3.03995 8 3.6V4.4C8 4.96005 8 5.24008 8.10899 5.45399C8.20487 5.64215 8.35785 5.79513 8.54601 5.89101C8.75992 6 9.03995 6 9.6 6Z",
- "stroke": "currentColor",
- "stroke-width": "2",
- "stroke-linecap": "round",
- "stroke-linejoin": "round"
- },
- "children": []
- }
- ]
- },
- "name": "Clipboard"
-}
\ No newline at end of file
diff --git a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json b/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json
deleted file mode 100644
index 273b115001..0000000000
--- a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "icon": {
- "type": "element",
- "isRootNode": true,
- "name": "svg",
- "attributes": {
- "width": "24",
- "height": "24",
- "viewBox": "0 0 24 24",
- "fill": "none",
- "xmlns": "http://www.w3.org/2000/svg"
- },
- "children": [
- {
- "type": "element",
- "name": "path",
- "attributes": {
- "d": "M16 4C16.93 4 17.395 4 17.7765 4.10222C18.8117 4.37962 19.6204 5.18827 19.8978 6.22354C20 6.60504 20 7.07003 20 8V17.2C20 18.8802 20 19.7202 19.673 20.362C19.3854 20.9265 18.9265 21.3854 18.362 21.673C17.7202 22 16.8802 22 15.2 22H8.8C7.11984 22 6.27976 22 5.63803 21.673C5.07354 21.3854 4.6146 20.9265 4.32698 20.362C4 19.7202 4 18.8802 4 17.2V8C4 7.07003 4 6.60504 4.10222 6.22354C4.37962 5.18827 5.18827 4.37962 6.22354 4.10222C6.60504 4 7.07003 4 8 4M9 15L11 17L15.5 12.5M9.6 6H14.4C14.9601 6 15.2401 6 15.454 5.89101C15.6422 5.79513 15.7951 5.64215 15.891 5.45399C16 5.24008 16 4.96005 16 4.4V3.6C16 3.03995 16 2.75992 15.891 2.54601C15.7951 2.35785 15.6422 2.20487 15.454 2.10899C15.2401 2 14.9601 2 14.4 2H9.6C9.03995 2 8.75992 2 8.54601 2.10899C8.35785 2.20487 8.20487 2.35785 8.10899 2.54601C8 2.75992 8 3.03995 8 3.6V4.4C8 4.96005 8 5.24008 8.10899 5.45399C8.20487 5.64215 8.35785 5.79513 8.54601 5.89101C8.75992 6 9.03995 6 9.6 6Z",
- "stroke": "currentColor",
- "stroke-width": "2",
- "stroke-linecap": "round",
- "stroke-linejoin": "round"
- },
- "children": []
- }
- ]
- },
- "name": "ClipboardCheck"
-}
\ No newline at end of file
diff --git a/web/app/components/base/icons/src/vender/line/files/Copy.json b/web/app/components/base/icons/src/vender/line/files/Copy.json
new file mode 100644
index 0000000000..0aa0935e0f
--- /dev/null
+++ b/web/app/components/base/icons/src/vender/line/files/Copy.json
@@ -0,0 +1,29 @@
+{
+ "icon": {
+ "type": "element",
+ "isRootNode": true,
+ "name": "svg",
+ "attributes": {
+ "width": "16",
+ "height": "16",
+ "viewBox": "0 0 16 16",
+ "fill": "none",
+ "xmlns": "http://www.w3.org/2000/svg"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z",
+ "stroke": "currentColor",
+ "stroke-width": "1.5",
+ "stroke-linecap": "round",
+ "stroke-linejoin": "round"
+ },
+ "children": []
+ }
+ ]
+ },
+ "name": "Copy"
+}
\ No newline at end of file
diff --git a/web/app/components/base/icons/src/vender/line/files/Clipboard.tsx b/web/app/components/base/icons/src/vender/line/files/Copy.tsx
similarity index 87%
rename from web/app/components/base/icons/src/vender/line/files/Clipboard.tsx
rename to web/app/components/base/icons/src/vender/line/files/Copy.tsx
index c49d15d19d..155b825fa1 100644
--- a/web/app/components/base/icons/src/vender/line/files/Clipboard.tsx
+++ b/web/app/components/base/icons/src/vender/line/files/Copy.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import data from './Clipboard.json'
+import data from './Copy.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
@@ -15,6 +15,6 @@ const Icon = (
},
) =>
-Icon.displayName = 'Clipboard'
+Icon.displayName = 'Copy'
export default Icon
diff --git a/web/app/components/base/icons/src/vender/line/files/CopyCheck.json b/web/app/components/base/icons/src/vender/line/files/CopyCheck.json
new file mode 100644
index 0000000000..f1f3a2e1bd
--- /dev/null
+++ b/web/app/components/base/icons/src/vender/line/files/CopyCheck.json
@@ -0,0 +1,29 @@
+{
+ "icon": {
+ "type": "element",
+ "isRootNode": true,
+ "name": "svg",
+ "attributes": {
+ "width": "16",
+ "height": "16",
+ "viewBox": "0 0 16 16",
+ "fill": "none",
+ "xmlns": "http://www.w3.org/2000/svg"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M10.6665 2.66683C11.2865 2.66683 11.5965 2.66683 11.8508 2.73498C12.541 2.91991 13.0801 3.45901 13.265 4.14919C13.3332 4.40352 13.3332 4.71352 13.3332 5.3335V11.4668C13.3332 12.5869 13.3332 13.147 13.1152 13.5748C12.9234 13.9511 12.6175 14.2571 12.2412 14.4488C11.8133 14.6668 11.2533 14.6668 10.1332 14.6668H5.8665C4.7464 14.6668 4.18635 14.6668 3.75852 14.4488C3.3822 14.2571 3.07624 13.9511 2.88449 13.5748C2.6665 13.147 2.6665 12.5869 2.6665 11.4668V5.3335C2.6665 4.71352 2.6665 4.40352 2.73465 4.14919C2.91959 3.45901 3.45868 2.91991 4.14887 2.73498C4.4032 2.66683 4.71319 2.66683 5.33317 2.66683M5.99984 10.0002L7.33317 11.3335L10.3332 8.3335M6.39984 4.00016H9.59984C9.9732 4.00016 10.1599 4.00016 10.3025 3.9275C10.4279 3.86359 10.5299 3.7616 10.5938 3.63616C10.6665 3.49355 10.6665 3.30686 10.6665 2.9335V2.40016C10.6665 2.02679 10.6665 1.84011 10.5938 1.6975C10.5299 1.57206 10.4279 1.47007 10.3025 1.40616C10.1599 1.3335 9.97321 1.3335 9.59984 1.3335H6.39984C6.02647 1.3335 5.83978 1.3335 5.69718 1.40616C5.57174 1.47007 5.46975 1.57206 5.40583 1.6975C5.33317 1.84011 5.33317 2.02679 5.33317 2.40016V2.9335C5.33317 3.30686 5.33317 3.49355 5.40583 3.63616C5.46975 3.7616 5.57174 3.86359 5.69718 3.9275C5.83978 4.00016 6.02647 4.00016 6.39984 4.00016Z",
+ "stroke": "currentColor",
+ "stroke-width": "1.5",
+ "stroke-linecap": "round",
+ "stroke-linejoin": "round"
+ },
+ "children": []
+ }
+ ]
+ },
+ "name": "CopyCheck"
+}
\ No newline at end of file
diff --git a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.tsx b/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx
similarity index 85%
rename from web/app/components/base/icons/src/vender/line/files/ClipboardCheck.tsx
rename to web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx
index 586b55e616..90eca4c04d 100644
--- a/web/app/components/base/icons/src/vender/line/files/ClipboardCheck.tsx
+++ b/web/app/components/base/icons/src/vender/line/files/CopyCheck.tsx
@@ -2,7 +2,7 @@
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
-import data from './ClipboardCheck.json'
+import data from './CopyCheck.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
@@ -15,6 +15,6 @@ const Icon = (
},
) =>
-Icon.displayName = 'ClipboardCheck'
+Icon.displayName = 'CopyCheck'
export default Icon
diff --git a/web/app/components/base/icons/src/vender/line/files/index.ts b/web/app/components/base/icons/src/vender/line/files/index.ts
index 4c0ddc2289..631f7915b0 100644
--- a/web/app/components/base/icons/src/vender/line/files/index.ts
+++ b/web/app/components/base/icons/src/vender/line/files/index.ts
@@ -1,5 +1,5 @@
-export { default as ClipboardCheck } from './ClipboardCheck'
-export { default as Clipboard } from './Clipboard'
+export { default as CopyCheck } from './CopyCheck'
+export { default as Copy } from './Copy'
export { default as File02 } from './File02'
export { default as FileArrow01 } from './FileArrow01'
export { default as FileCheck02 } from './FileCheck02'
diff --git a/web/app/components/plugins/base/key-value-item.tsx b/web/app/components/plugins/base/key-value-item.tsx
index cfc81aa177..b616b5ee18 100644
--- a/web/app/components/plugins/base/key-value-item.tsx
+++ b/web/app/components/plugins/base/key-value-item.tsx
@@ -6,7 +6,7 @@ import {
RiClipboardLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
-import { ClipboardCheck } from '../../base/icons/src/vender/line/files'
+import { CopyCheck } from '../../base/icons/src/vender/line/files'
import Tooltip from '../../base/tooltip'
import cn from '@/utils/classnames'
import ActionButton from '@/app/components/base/action-button'
@@ -44,7 +44,7 @@ const KeyValueItem: FC
= ({
}
}, [isCopied])
- const CopyIcon = isCopied ? ClipboardCheck : RiClipboardLine
+ const CopyIcon = isCopied ? CopyCheck : RiClipboardLine
return (
diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx
index cc3688aebc..00cd1b88ae 100644
--- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx
@@ -7,7 +7,7 @@ import type { EndpointListItem } from '../types'
import EndpointModal from './endpoint-modal'
import { NAME_FIELD } from './utils'
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
-import { ClipboardCheck } from '@/app/components/base/icons/src/vender/line/files'
+import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files'
import ActionButton from '@/app/components/base/action-button'
import Confirm from '@/app/components/base/confirm'
import Indicator from '@/app/components/header/indicator'
@@ -130,7 +130,7 @@ const EndpointCard = ({
}
}, [isCopied])
- const CopyIcon = isCopied ? ClipboardCheck : RiClipboardLine
+ const CopyIcon = isCopied ? CopyCheck : RiClipboardLine
return (
diff --git a/web/app/components/workflow/nodes/_base/components/editor/base.tsx b/web/app/components/workflow/nodes/_base/components/editor/base.tsx
index 284c6c77eb..56917bc447 100644
--- a/web/app/components/workflow/nodes/_base/components/editor/base.tsx
+++ b/web/app/components/workflow/nodes/_base/components/editor/base.tsx
@@ -9,8 +9,8 @@ import Wrap from './wrap'
import cn from '@/utils/classnames'
import PromptEditorHeightResizeWrap from '@/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap'
import {
- Clipboard,
- ClipboardCheck,
+ Copy,
+ CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
@@ -92,10 +92,10 @@ const Base: FC
= ({
{!isCopied
? (
-
+
)
: (
-
+
)
}
diff --git a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx
index b28e8a1ca3..4cf0fa037a 100644
--- a/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx
+++ b/web/app/components/workflow/nodes/_base/components/prompt/editor.tsx
@@ -23,8 +23,8 @@ import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/to
import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
import PromptEditor from '@/app/components/base/prompt-editor'
import {
- Clipboard,
- ClipboardCheck,
+ Copy,
+ CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
@@ -204,12 +204,12 @@ const Editor: FC = ({
{!isCopied
? (
-
+
)
: (
-
+
)
}
diff --git a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx
index 57fe461e03..ee3eded719 100644
--- a/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx
+++ b/web/app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx
@@ -9,8 +9,8 @@ import Modal from '@/app/components/base/modal'
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import {
- Clipboard,
- ClipboardCheck,
+ Copy,
+ CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import { useStore } from '@/app/components/workflow/store'
import type {
@@ -122,10 +122,10 @@ const ConversationVariableModal = ({
{!isCopied
? (
-
+
)
: (
-
+
)
}
From 965e952336efc8abc096bf5218de00de6bf62b3f Mon Sep 17 00:00:00 2001
From: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
Date: Thu, 17 Jul 2025 16:05:33 +0800
Subject: [PATCH 10/10] minor translation fix: fix translation duplicate and
typo, fix date format (#22548)
---
web/i18n/fr-FR/dataset-documents.ts | 2 +-
web/i18n/hi-IN/workflow.ts | 4 ++--
web/i18n/it-IT/dataset-documents.ts | 2 +-
web/i18n/pt-BR/dataset-documents.ts | 2 +-
web/i18n/uk-UA/dataset-documents.ts | 2 +-
web/i18n/vi-VN/dataset-documents.ts | 2 +-
6 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/web/i18n/fr-FR/dataset-documents.ts b/web/i18n/fr-FR/dataset-documents.ts
index 2a46d1cced..debb03a379 100644
--- a/web/i18n/fr-FR/dataset-documents.ts
+++ b/web/i18n/fr-FR/dataset-documents.ts
@@ -374,7 +374,7 @@ const translation = {
expandChunks: 'Développer des blocs',
characters_other: 'caractères',
editedAt: 'Édité le',
- dateTimeFormat: 'MM/DD/YYYY h:mm',
+ dateTimeFormat: 'DD/MM/YYYY HH:mm',
searchResults_other: 'RÉSULTATS',
regenerationSuccessMessage: 'Vous pouvez fermer cette fenêtre.',
parentChunks_one: 'MORCEAU PARENT',
diff --git a/web/i18n/hi-IN/workflow.ts b/web/i18n/hi-IN/workflow.ts
index 9689cbf9c5..68937e5155 100644
--- a/web/i18n/hi-IN/workflow.ts
+++ b/web/i18n/hi-IN/workflow.ts
@@ -381,7 +381,7 @@ const translation = {
},
typeSwitch: {
input: 'इनपुट मान',
- variable: 'चर चर का प्रयोग करें',
+ variable: 'चर का प्रयोग करें',
},
},
start: {
@@ -695,7 +695,7 @@ const translation = {
authorize: 'अधिकृत करें',
insertPlaceholder1: 'टाइप करें या दबाएँ',
settings: 'सेटिंग्स',
- insertPlaceholder2: 'चरित्र डालें',
+ insertPlaceholder2: 'वेरिएबल डालें',
},
questionClassifiers: {
model: 'मॉडल',
diff --git a/web/i18n/it-IT/dataset-documents.ts b/web/i18n/it-IT/dataset-documents.ts
index 327507e406..66eb00aafd 100644
--- a/web/i18n/it-IT/dataset-documents.ts
+++ b/web/i18n/it-IT/dataset-documents.ts
@@ -380,7 +380,7 @@ const translation = {
regenerationConfirmTitle: 'Si desidera rigenerare i blocchi figlio?',
chunks_other: 'BLOCCHI',
editedAt: 'A cura di',
- dateTimeFormat: 'MM/DD/YYYY h:mm',
+ dateTimeFormat: 'DD/MM/YYYY HH:mm',
collapseChunks: 'Comprimi blocchi',
clearFilter: 'Cancella filtro',
chunks_one: 'PEZZO',
diff --git a/web/i18n/pt-BR/dataset-documents.ts b/web/i18n/pt-BR/dataset-documents.ts
index 50ac7ef9f2..30fa87f82f 100644
--- a/web/i18n/pt-BR/dataset-documents.ts
+++ b/web/i18n/pt-BR/dataset-documents.ts
@@ -376,7 +376,7 @@ const translation = {
regeneratingMessage: 'Isso pode demorar um pouco, por favor aguarde...',
edited: 'EDIÇÃO',
editedAt: 'Editado em',
- dateTimeFormat: 'MM/DD/YYYY h:mm',
+ dateTimeFormat: 'DD/MM/YYYY HH:mm',
expandChunks: 'Expandir pedaços',
collapseChunks: 'Recolher partes',
regenerationConfirmMessage: 'A regeneração de partes filhas substituirá as partes filhas atuais, incluindo partes editadas e partes recém-adicionadas. A regeneração não pode ser desfeita.',
diff --git a/web/i18n/uk-UA/dataset-documents.ts b/web/i18n/uk-UA/dataset-documents.ts
index e28e1e4680..903e8a97c4 100644
--- a/web/i18n/uk-UA/dataset-documents.ts
+++ b/web/i18n/uk-UA/dataset-documents.ts
@@ -369,7 +369,7 @@ const translation = {
empty: 'Шматок не знайдено',
chunks_other: 'ШМАТКИ',
editedAt: 'За редакцією',
- dateTimeFormat: 'MM/DD/YYYY h:mm',
+ dateTimeFormat: 'DD.MM.YYYY HH:mm',
searchResults_zero: 'РЕЗУЛЬТАТ',
collapseChunks: 'Згортання шматків',
childChunkAdded: 'Додано 1 дочірній фрагмент',
diff --git a/web/i18n/vi-VN/dataset-documents.ts b/web/i18n/vi-VN/dataset-documents.ts
index c1822b5078..c6fcd4ed45 100644
--- a/web/i18n/vi-VN/dataset-documents.ts
+++ b/web/i18n/vi-VN/dataset-documents.ts
@@ -369,7 +369,7 @@ const translation = {
expandChunks: 'Mở rộng các đoạn',
chunks_other: 'KHỐI',
editedAt: 'Chỉnh sửa tại',
- dateTimeFormat: 'MM/DD/YYYY h:mm',
+ dateTimeFormat: 'DD/MM/YYYY HH:mm',
addAnother: 'Thêm một cái khác',
regenerationConfirmTitle: 'Bạn có muốn tái tạo các chunk con không?',
searchResults_one: 'KẾT QUẢ',