Merge branch 'main' into refactor/query-params-nuqs

This commit is contained in:
yyh 2025-12-31 13:25:58 +08:00 committed by GitHub
commit 9ff4d2bbf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
322 changed files with 1255 additions and 767 deletions

View File

@ -1,4 +1,8 @@
exclude = ["migrations/*"]
exclude = [
"migrations/*",
".git",
".git/**",
]
line-length = 120
[format]

View File

@ -124,7 +124,7 @@ class OAuthCallback(Resource):
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
try:
account = _generate_account(provider, user_info)
account, oauth_new_user = _generate_account(provider, user_info)
except AccountNotFoundError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
@ -159,7 +159,10 @@ class OAuthCallback(Resource):
ip_address=extract_remote_ip(request),
)
response = redirect(f"{dify_config.CONSOLE_WEB_URL}")
base_url = dify_config.CONSOLE_WEB_URL
query_char = "&" if "?" in base_url else "?"
target_url = f"{base_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}"
response = redirect(target_url)
set_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
@ -177,9 +180,10 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
return account
def _generate_account(provider: str, user_info: OAuthUserInfo):
def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, bool]:
# Get account by openid or email.
account = _get_account_by_openid_or_email(provider, user_info)
oauth_new_user = False
if account:
tenants = TenantService.get_join_tenants(account)
@ -193,6 +197,7 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
tenant_was_created.send(new_tenant)
if not account:
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email):
raise AccountRegisterError(
@ -220,4 +225,4 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
# Link account
AccountService.link_account_integrate(provider, user_info.id, account)
return account
return account, oauth_new_user

View File

@ -3,10 +3,12 @@ import uuid
from flask import request
from flask_restx import Resource, marshal
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.dialects.postgresql import JSONB
from werkzeug.exceptions import Forbidden, NotFound
import services
from configs import dify_config
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import ProviderNotInitializeError
@ -143,7 +145,29 @@ class DatasetDocumentSegmentListApi(Resource):
query = query.where(DocumentSegment.hit_count >= hit_count_gte)
if keyword:
query = query.where(DocumentSegment.content.ilike(f"%{keyword}%"))
# Search in both content and keywords fields
# Use database-specific methods for JSON array search
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
keywords_condition = func.array_to_string(
func.array(
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
.correlate(DocumentSegment)
.scalar_subquery()
),
",",
).ilike(f"%{keyword}%")
else:
# MySQL: Cast JSON to string for pattern matching
# MySQL stores Chinese text directly in JSON without Unicode escaping
keywords_condition = cast(DocumentSegment.keywords, String).ilike(f"%{keyword}%")
query = query.where(
or_(
DocumentSegment.content.ilike(f"%{keyword}%"),
keywords_condition,
)
)
if args.enabled.lower() != "all":
if args.enabled.lower() == "true":

View File

@ -27,26 +27,44 @@ class CleanProcessor:
pattern = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
text = re.sub(pattern, "", text)
# Remove URL but keep Markdown image URLs
# First, temporarily replace Markdown image URLs with a placeholder
markdown_image_pattern = r"!\[.*?\]\((https?://[^\s)]+)\)"
placeholders: list[str] = []
# Remove URL but keep Markdown image URLs and link URLs
# Replace the ENTIRE markdown link/image with a single placeholder to protect
# the link text (which might also be a URL) from being removed
markdown_link_pattern = r"\[([^\]]*)\]\((https?://[^)]+)\)"
markdown_image_pattern = r"!\[.*?\]\((https?://[^)]+)\)"
placeholders: list[tuple[str, str, str]] = [] # (type, text, url)
def replace_with_placeholder(match, placeholders=placeholders):
def replace_markdown_with_placeholder(match, placeholders=placeholders):
link_type = "link"
link_text = match.group(1)
url = match.group(2)
placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
placeholders.append((link_type, link_text, url))
return placeholder
def replace_image_with_placeholder(match, placeholders=placeholders):
link_type = "image"
url = match.group(1)
placeholder = f"__MARKDOWN_IMAGE_URL_{len(placeholders)}__"
placeholders.append(url)
return f"![image]({placeholder})"
placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
placeholders.append((link_type, "image", url))
return placeholder
text = re.sub(markdown_image_pattern, replace_with_placeholder, text)
# Protect markdown links first
text = re.sub(markdown_link_pattern, replace_markdown_with_placeholder, text)
# Then protect markdown images
text = re.sub(markdown_image_pattern, replace_image_with_placeholder, text)
# Now remove all remaining URLs
url_pattern = r"https?://[^\s)]+"
url_pattern = r"https?://\S+"
text = re.sub(url_pattern, "", text)
# Finally, restore the Markdown image URLs
for i, url in enumerate(placeholders):
text = text.replace(f"__MARKDOWN_IMAGE_URL_{i}__", url)
# Restore the Markdown links and images
for i, (link_type, text_or_alt, url) in enumerate(placeholders):
placeholder = f"__MARKDOWN_PLACEHOLDER_{i}__"
if link_type == "link":
text = text.replace(placeholder, f"[{text_or_alt}]({url})")
else: # image
text = text.replace(placeholder, f"![{text_or_alt}]({url})")
return text
def filter_string(self, text):

View File

@ -378,7 +378,7 @@ class ApiBasedToolSchemaParser:
@staticmethod
def auto_parse_to_tool_bundle(
content: str, extra_info: dict | None = None, warning: dict | None = None
) -> tuple[list[ApiToolBundle], str]:
) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]:
"""
auto parse to tool bundle

View File

@ -4,6 +4,7 @@ import re
def remove_leading_symbols(text: str) -> str:
"""
Remove leading punctuation or symbols from the given text.
Preserves markdown links like [text](url) at the start.
Args:
text (str): The input text to process.
@ -11,6 +12,11 @@ def remove_leading_symbols(text: str) -> str:
Returns:
str: The text with leading punctuation or symbols removed.
"""
# Check if text starts with a markdown link - preserve it
markdown_link_pattern = r"^\[([^\]]+)\]\((https?://[^)]+)\)"
if re.match(markdown_link_pattern, text):
return text
# Match Unicode ranges for punctuation and symbols
# FIXME this pattern is confused quick fix for #11868 maybe refactor it later
pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+'

View File

@ -54,7 +54,6 @@ class WorkflowToolProviderController(ToolProviderController):
raise ValueError("app not found")
user = session.get(Account, db_provider.user_id) if db_provider.user_id else None
controller = WorkflowToolProviderController(
entity=ToolProviderEntity(
identity=ToolProviderIdentity(
@ -67,7 +66,7 @@ class WorkflowToolProviderController(ToolProviderController):
credentials_schema=[],
plugin_id=None,
),
provider_id="",
provider_id=db_provider.id,
)
controller.tools = [

View File

@ -60,6 +60,7 @@ class SkipPropagator:
if edge_states["has_taken"]:
# Enqueue node
self._state_manager.enqueue_node(downstream_node_id)
self._state_manager.start_execution(downstream_node_id)
return
# All edges are skipped, propagate skip to this node

View File

@ -12,9 +12,8 @@ from dify_app import DifyApp
def _get_celery_ssl_options() -> dict[str, Any] | None:
"""Get SSL configuration for Celery broker/backend connections."""
# Use REDIS_USE_SSL for consistency with the main Redis client
# Only apply SSL if we're using Redis as broker/backend
if not dify_config.REDIS_USE_SSL:
if not dify_config.BROKER_USE_SSL:
return None
# Check if Celery is actually using Redis

View File

@ -16,6 +16,11 @@ celery_redis = Redis(
port=redis_config.get("port") or 6379,
password=redis_config.get("password") or None,
db=int(redis_config.get("virtual_host")) if redis_config.get("virtual_host") else 1,
ssl=bool(dify_config.BROKER_USE_SSL),
ssl_ca_certs=dify_config.REDIS_SSL_CA_CERTS if dify_config.BROKER_USE_SSL else None,
ssl_cert_reqs=getattr(dify_config, "REDIS_SSL_CERT_REQS", None) if dify_config.BROKER_USE_SSL else None,
ssl_certfile=getattr(dify_config, "REDIS_SSL_CERTFILE", None) if dify_config.BROKER_USE_SSL else None,
ssl_keyfile=getattr(dify_config, "REDIS_SSL_KEYFILE", None) if dify_config.BROKER_USE_SSL else None,
)
logger = logging.getLogger(__name__)

View File

@ -85,7 +85,9 @@ class ApiToolManageService:
raise ValueError(f"invalid schema: {str(e)}")
@staticmethod
def convert_schema_to_tool_bundles(schema: str, extra_info: dict | None = None) -> tuple[list[ApiToolBundle], str]:
def convert_schema_to_tool_bundles(
schema: str, extra_info: dict | None = None
) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]:
"""
convert schema to tool bundles
@ -103,7 +105,7 @@ class ApiToolManageService:
provider_name: str,
icon: dict,
credentials: dict,
schema_type: str,
schema_type: ApiProviderSchemaType,
schema: str,
privacy_policy: str,
custom_disclaimer: str,
@ -112,9 +114,6 @@ class ApiToolManageService:
"""
create api tool provider
"""
if schema_type not in [member.value for member in ApiProviderSchemaType]:
raise ValueError(f"invalid schema type {schema}")
provider_name = provider_name.strip()
# check if the provider exists
@ -241,18 +240,15 @@ class ApiToolManageService:
original_provider: str,
icon: dict,
credentials: dict,
schema_type: str,
_schema_type: ApiProviderSchemaType,
schema: str,
privacy_policy: str,
privacy_policy: str | None,
custom_disclaimer: str,
labels: list[str],
):
"""
update api tool provider
"""
if schema_type not in [member.value for member in ApiProviderSchemaType]:
raise ValueError(f"invalid schema type {schema}")
provider_name = provider_name.strip()
# check if the provider exists
@ -277,7 +273,7 @@ class ApiToolManageService:
provider.icon = json.dumps(icon)
provider.schema = schema
provider.description = extra_info.get("description", "")
provider.schema_type_str = ApiProviderSchemaType.OPENAPI
provider.schema_type_str = schema_type
provider.tools_str = json.dumps(jsonable_encoder(tool_bundles))
provider.privacy_policy = privacy_policy
provider.custom_disclaimer = custom_disclaimer
@ -356,7 +352,7 @@ class ApiToolManageService:
tool_name: str,
credentials: dict,
parameters: dict,
schema_type: str,
schema_type: ApiProviderSchemaType,
schema: str,
):
"""

View File

@ -171,7 +171,7 @@ class TestOAuthCallback:
):
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_generate_account.return_value = (oauth_setup["account"], True)
mock_account_service.login.return_value = oauth_setup["token_pair"]
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
@ -179,7 +179,7 @@ class TestOAuthCallback:
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")
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true")
@pytest.mark.parametrize(
("exception", "expected_error"),
@ -223,7 +223,7 @@ class TestOAuthCallback:
# This documents actual behavior. See test_defensive_check_for_closed_account_status for details
(
AccountStatus.CLOSED.value,
"http://localhost:3000",
"http://localhost:3000?oauth_new_user=false",
),
],
)
@ -260,7 +260,7 @@ class TestOAuthCallback:
account = MagicMock()
account.status = account_status
account.id = "123"
mock_generate_account.return_value = account
mock_generate_account.return_value = (account, False)
# Mock login for CLOSED status
mock_token_pair = MagicMock()
@ -296,7 +296,7 @@ class TestOAuthCallback:
mock_account = MagicMock()
mock_account.status = AccountStatus.PENDING
mock_generate_account.return_value = mock_account
mock_generate_account.return_value = (mock_account, False)
mock_token_pair = MagicMock()
mock_token_pair.access_token = "jwt_access_token"
@ -360,7 +360,7 @@ class TestOAuthCallback:
closed_account.status = AccountStatus.CLOSED
closed_account.id = "123"
closed_account.name = "Closed Account"
mock_generate_account.return_value = closed_account
mock_generate_account.return_value = (closed_account, False)
# Mock successful login (current behavior)
mock_token_pair = MagicMock()
@ -374,7 +374,7 @@ class TestOAuthCallback:
resource.get("github")
# Verify current behavior: login succeeds (this is NOT ideal)
mock_redirect.assert_called_once_with("http://localhost:3000")
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=false")
mock_account_service.login.assert_called_once()
# Document expected behavior in comments:
@ -458,8 +458,9 @@ class TestAccountGeneration:
with pytest.raises(AccountRegisterError):
_generate_account("github", user_info)
else:
result = _generate_account("github", user_info)
result, oauth_new_user = _generate_account("github", user_info)
assert result == mock_account
assert oauth_new_user == should_create
if should_create:
mock_register_service.register.assert_called_once_with(
@ -490,9 +491,10 @@ class TestAccountGeneration:
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)
result, oauth_new_user = _generate_account("github", user_info)
assert result == mock_account
assert oauth_new_user is False
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"

View File

@ -0,0 +1,213 @@
from core.rag.cleaner.clean_processor import CleanProcessor
class TestCleanProcessor:
"""Test cases for CleanProcessor.clean method."""
def test_clean_default_removal_of_invalid_symbols(self):
"""Test default cleaning removes invalid symbols."""
# Test <| replacement
assert CleanProcessor.clean("text<|with<|invalid", None) == "text<with<invalid"
# Test |> replacement
assert CleanProcessor.clean("text|>with|>invalid", None) == "text>with>invalid"
# Test removal of control characters
text_with_control = "normal\x00text\x1fwith\x07control\x7fchars"
expected = "normaltextwithcontrolchars"
assert CleanProcessor.clean(text_with_control, None) == expected
# Test U+FFFE removal
text_with_ufffe = "normal\ufffepadding"
expected = "normalpadding"
assert CleanProcessor.clean(text_with_ufffe, None) == expected
def test_clean_with_none_process_rule(self):
"""Test cleaning with None process_rule - only default cleaning applied."""
text = "Hello<|World\x00"
expected = "Hello<World"
assert CleanProcessor.clean(text, None) == expected
def test_clean_with_empty_process_rule(self):
"""Test cleaning with empty process_rule dict - only default cleaning applied."""
text = "Hello<|World\x00"
expected = "Hello<World"
assert CleanProcessor.clean(text, {}) == expected
def test_clean_with_empty_rules(self):
"""Test cleaning with empty rules - only default cleaning applied."""
text = "Hello<|World\x00"
expected = "Hello<World"
assert CleanProcessor.clean(text, {"rules": {}}) == expected
def test_clean_remove_extra_spaces_enabled(self):
"""Test remove_extra_spaces rule when enabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": True}]}}
# Test multiple newlines reduced to two
text = "Line1\n\n\n\n\nLine2"
expected = "Line1\n\nLine2"
assert CleanProcessor.clean(text, process_rule) == expected
# Test various whitespace characters reduced to single space
text = "word1\u2000\u2001\t\t \u3000word2"
expected = "word1 word2"
assert CleanProcessor.clean(text, process_rule) == expected
# Test combination of newlines and spaces
text = "Line1\n\n\n\n \t Line2"
expected = "Line1\n\n Line2"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_remove_extra_spaces_disabled(self):
"""Test remove_extra_spaces rule when disabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_extra_spaces", "enabled": False}]}}
text = "Line1\n\n\n\n\nLine2 with spaces"
# Should only apply default cleaning (no invalid symbols here)
assert CleanProcessor.clean(text, process_rule) == text
def test_clean_remove_urls_emails_enabled(self):
"""Test remove_urls_emails rule when enabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Test email removal
text = "Contact us at test@example.com for more info"
expected = "Contact us at for more info"
assert CleanProcessor.clean(text, process_rule) == expected
# Test URL removal
text = "Visit https://example.com or http://test.org"
expected = "Visit or "
assert CleanProcessor.clean(text, process_rule) == expected
# Test both email and URL
text = "Email me@test.com and visit https://site.com"
expected = "Email and visit "
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_preserve_markdown_links_and_images(self):
"""Test that markdown links and images are preserved when removing URLs."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Test markdown link preservation
text = "Check [Google](https://google.com) for info"
expected = "Check [Google](https://google.com) for info"
assert CleanProcessor.clean(text, process_rule) == expected
# Test markdown image preservation
text = "Image: ![alt](https://example.com/image.png)"
expected = "Image: ![alt](https://example.com/image.png)"
assert CleanProcessor.clean(text, process_rule) == expected
# Test both link and image preservation
text = "[Link](https://link.com) and ![Image](https://image.com/img.jpg)"
expected = "[Link](https://link.com) and ![Image](https://image.com/img.jpg)"
assert CleanProcessor.clean(text, process_rule) == expected
# Test that non-markdown URLs are still removed
text = "Check [Link](https://keep.com) but remove https://remove.com"
expected = "Check [Link](https://keep.com) but remove "
assert CleanProcessor.clean(text, process_rule) == expected
# Test email removal alongside markdown preservation
text = "Email: test@test.com, link: [Click](https://site.com)"
expected = "Email: , link: [Click](https://site.com)"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_remove_urls_emails_disabled(self):
"""Test remove_urls_emails rule when disabled."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": False}]}}
text = "Email test@example.com visit https://example.com"
# Should only apply default cleaning
assert CleanProcessor.clean(text, process_rule) == text
def test_clean_both_rules_enabled(self):
"""Test both pre-processing rules enabled together."""
process_rule = {
"rules": {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": True},
]
}
}
text = "Hello\n\n\n\n World test@example.com \n\n\nhttps://example.com"
expected = "Hello\n\n World \n\n"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_with_markdown_link_and_extra_spaces(self):
"""Test markdown link preservation with extra spaces removal."""
process_rule = {
"rules": {
"pre_processing_rules": [
{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": True},
]
}
}
text = "[Link](https://example.com)\n\n\n\n Text https://remove.com"
expected = "[Link](https://example.com)\n\n Text "
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_unknown_rule_id_ignored(self):
"""Test that unknown rule IDs are silently ignored."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "unknown_rule", "enabled": True}]}}
text = "Hello<|World\x00"
expected = "Hello<World"
# Only default cleaning should be applied
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_empty_text(self):
"""Test cleaning empty text."""
assert CleanProcessor.clean("", None) == ""
assert CleanProcessor.clean("", {}) == ""
assert CleanProcessor.clean("", {"rules": {}}) == ""
def test_clean_text_with_only_invalid_symbols(self):
"""Test text containing only invalid symbols."""
text = "<|<|\x00\x01\x02\ufffe|>|>"
# <| becomes <, |> becomes >, control chars and U+FFFE are removed
assert CleanProcessor.clean(text, None) == "<<>>"
def test_clean_multiple_markdown_links_preserved(self):
"""Test multiple markdown links are all preserved."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
text = "[One](https://one.com) [Two](http://two.org) [Three](https://three.net)"
expected = "[One](https://one.com) [Two](http://two.org) [Three](https://three.net)"
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_markdown_link_text_as_url(self):
"""Test markdown link where the link text itself is a URL."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Link text that looks like URL should be preserved
text = "[https://text-url.com](https://actual-url.com)"
expected = "[https://text-url.com](https://actual-url.com)"
assert CleanProcessor.clean(text, process_rule) == expected
# Text URL without markdown should be removed
text = "https://text-url.com https://actual-url.com"
expected = " "
assert CleanProcessor.clean(text, process_rule) == expected
def test_clean_complex_markdown_link_content(self):
"""Test markdown links with complex content - known limitation with brackets in link text."""
process_rule = {"rules": {"pre_processing_rules": [{"id": "remove_urls_emails", "enabled": True}]}}
# Note: The regex pattern [^\]]* cannot handle ] within link text
# This is a known limitation - the pattern stops at the first ]
text = "[Text with [brackets] and (parens)](https://example.com)"
# Actual behavior: only matches up to first ], URL gets removed
expected = "[Text with [brackets] and (parens)]("
assert CleanProcessor.clean(text, process_rule) == expected
# Test that properly formatted markdown links work
text = "[Text with (parens) and symbols](https://example.com)"
expected = "[Text with (parens) and symbols](https://example.com)"
assert CleanProcessor.clean(text, process_rule) == expected

View File

@ -0,0 +1 @@
"""Tests for graph traversal components."""

View File

@ -0,0 +1,307 @@
"""Unit tests for skip propagator."""
from unittest.mock import MagicMock, create_autospec
from core.workflow.graph import Edge, Graph
from core.workflow.graph_engine.graph_state_manager import GraphStateManager
from core.workflow.graph_engine.graph_traversal.skip_propagator import SkipPropagator
class TestSkipPropagator:
"""Test suite for SkipPropagator."""
def test_propagate_skip_from_edge_with_unknown_edges_stops_processing(self) -> None:
"""When there are unknown incoming edges, propagation should stop."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create a mock edge
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
# Setup graph edges dict
mock_graph.edges = {"edge_1": mock_edge}
# Setup incoming edges
incoming_edges = [MagicMock(spec=Edge), MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Setup state manager to return has_unknown=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": True,
"has_taken": False,
"all_skipped": False,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
mock_graph.get_incoming_edges.assert_called_once_with("node_2")
mock_state_manager.analyze_edge_states.assert_called_once_with(incoming_edges)
# Should not call any other state manager methods
mock_state_manager.enqueue_node.assert_not_called()
mock_state_manager.start_execution.assert_not_called()
mock_state_manager.mark_node_skipped.assert_not_called()
def test_propagate_skip_from_edge_with_taken_edge_enqueues_node(self) -> None:
"""When there is at least one taken edge, node should be enqueued."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create a mock edge
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
mock_graph.edges = {"edge_1": mock_edge}
incoming_edges = [MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Setup state manager to return has_taken=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": True,
"all_skipped": False,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
mock_state_manager.enqueue_node.assert_called_once_with("node_2")
mock_state_manager.start_execution.assert_called_once_with("node_2")
mock_state_manager.mark_node_skipped.assert_not_called()
def test_propagate_skip_from_edge_with_all_skipped_propagates_to_node(self) -> None:
"""When all incoming edges are skipped, should propagate skip to node."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create a mock edge
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
mock_graph.edges = {"edge_1": mock_edge}
incoming_edges = [MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Setup state manager to return all_skipped=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": False,
"all_skipped": True,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
mock_state_manager.mark_node_skipped.assert_called_once_with("node_2")
mock_state_manager.enqueue_node.assert_not_called()
mock_state_manager.start_execution.assert_not_called()
def test_propagate_skip_to_node_marks_node_and_outgoing_edges_skipped(self) -> None:
"""_propagate_skip_to_node should mark node and all outgoing edges as skipped."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create outgoing edges
edge1 = MagicMock(spec=Edge)
edge1.id = "edge_2"
edge1.head = "node_downstream_1" # Set head for propagate_skip_from_edge
edge2 = MagicMock(spec=Edge)
edge2.id = "edge_3"
edge2.head = "node_downstream_2"
# Setup graph edges dict for propagate_skip_from_edge
mock_graph.edges = {"edge_2": edge1, "edge_3": edge2}
mock_graph.get_outgoing_edges.return_value = [edge1, edge2]
# Setup get_incoming_edges to return empty list to stop recursion
mock_graph.get_incoming_edges.return_value = []
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Use mock to call private method
# Act
propagator._propagate_skip_to_node("node_1")
# Assert
mock_state_manager.mark_node_skipped.assert_called_once_with("node_1")
mock_state_manager.mark_edge_skipped.assert_any_call("edge_2")
mock_state_manager.mark_edge_skipped.assert_any_call("edge_3")
assert mock_state_manager.mark_edge_skipped.call_count == 2
# Should recursively propagate from each edge
# Since propagate_skip_from_edge is called, we need to verify it was called
# But we can't directly verify due to recursion. We'll trust the logic.
def test_skip_branch_paths_marks_unselected_edges_and_propagates(self) -> None:
"""skip_branch_paths should mark all unselected edges as skipped and propagate."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create unselected edges
edge1 = MagicMock(spec=Edge)
edge1.id = "edge_1"
edge1.head = "node_downstream_1"
edge2 = MagicMock(spec=Edge)
edge2.id = "edge_2"
edge2.head = "node_downstream_2"
unselected_edges = [edge1, edge2]
# Setup graph edges dict
mock_graph.edges = {"edge_1": edge1, "edge_2": edge2}
# Setup get_incoming_edges to return empty list to stop recursion
mock_graph.get_incoming_edges.return_value = []
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.skip_branch_paths(unselected_edges)
# Assert
mock_state_manager.mark_edge_skipped.assert_any_call("edge_1")
mock_state_manager.mark_edge_skipped.assert_any_call("edge_2")
assert mock_state_manager.mark_edge_skipped.call_count == 2
# propagate_skip_from_edge should be called for each edge
# We can't directly verify due to the mock, but the logic is covered
def test_propagate_skip_from_edge_recursively_propagates_through_graph(self) -> None:
"""Skip propagation should recursively propagate through the graph."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
# Create edge chain: edge_1 -> node_2 -> edge_3 -> node_4
edge1 = MagicMock(spec=Edge)
edge1.id = "edge_1"
edge1.head = "node_2"
edge3 = MagicMock(spec=Edge)
edge3.id = "edge_3"
edge3.head = "node_4"
mock_graph.edges = {"edge_1": edge1, "edge_3": edge3}
# Setup get_incoming_edges to return different values based on node
def get_incoming_edges_side_effect(node_id):
if node_id == "node_2":
return [edge1]
elif node_id == "node_4":
return [edge3]
return []
mock_graph.get_incoming_edges.side_effect = get_incoming_edges_side_effect
# Setup get_outgoing_edges to return different values based on node
def get_outgoing_edges_side_effect(node_id):
if node_id == "node_2":
return [edge3]
elif node_id == "node_4":
return [] # No outgoing edges, stops recursion
return []
mock_graph.get_outgoing_edges.side_effect = get_outgoing_edges_side_effect
# Setup state manager to return all_skipped for both nodes
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": False,
"all_skipped": True,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert
# Should mark node_2 as skipped
mock_state_manager.mark_node_skipped.assert_any_call("node_2")
# Should mark edge_3 as skipped
mock_state_manager.mark_edge_skipped.assert_any_call("edge_3")
# Should propagate to node_4
mock_state_manager.mark_node_skipped.assert_any_call("node_4")
assert mock_state_manager.mark_node_skipped.call_count == 2
def test_propagate_skip_from_edge_with_mixed_edge_states_handles_correctly(self) -> None:
"""Test with mixed edge states (some unknown, some taken, some skipped)."""
# Arrange
mock_graph = create_autospec(Graph)
mock_state_manager = create_autospec(GraphStateManager)
mock_edge = MagicMock(spec=Edge)
mock_edge.id = "edge_1"
mock_edge.head = "node_2"
mock_graph.edges = {"edge_1": mock_edge}
incoming_edges = [MagicMock(spec=Edge), MagicMock(spec=Edge), MagicMock(spec=Edge)]
mock_graph.get_incoming_edges.return_value = incoming_edges
# Test 1: has_unknown=True, has_taken=False, all_skipped=False
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": True,
"has_taken": False,
"all_skipped": False,
}
propagator = SkipPropagator(mock_graph, mock_state_manager)
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert - should stop processing
mock_state_manager.enqueue_node.assert_not_called()
mock_state_manager.mark_node_skipped.assert_not_called()
# Reset mocks for next test
mock_state_manager.reset_mock()
mock_graph.reset_mock()
# Test 2: has_unknown=False, has_taken=True, all_skipped=False
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": True,
"all_skipped": False,
}
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert - should enqueue node
mock_state_manager.enqueue_node.assert_called_once_with("node_2")
mock_state_manager.start_execution.assert_called_once_with("node_2")
# Reset mocks for next test
mock_state_manager.reset_mock()
mock_graph.reset_mock()
# Test 3: has_unknown=False, has_taken=False, all_skipped=True
mock_state_manager.analyze_edge_states.return_value = {
"has_unknown": False,
"has_taken": False,
"all_skipped": True,
}
# Act
propagator.propagate_skip_from_edge("edge_1")
# Assert - should propagate skip
mock_state_manager.mark_node_skipped.assert_called_once_with("node_2")

View File

@ -8,11 +8,12 @@ class TestCelerySSLConfiguration:
"""Test suite for Celery SSL configuration."""
def test_get_celery_ssl_options_when_ssl_disabled(self):
"""Test SSL options when REDIS_USE_SSL is False."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = False
"""Test SSL options when BROKER_USE_SSL is False."""
from configs import DifyConfig
with patch("extensions.ext_celery.dify_config", mock_config):
dify_config = DifyConfig(CELERY_BROKER_URL="redis://localhost:6379/0")
with patch("extensions.ext_celery.dify_config", dify_config):
from extensions.ext_celery import _get_celery_ssl_options
result = _get_celery_ssl_options()
@ -21,7 +22,6 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_when_broker_not_redis(self):
"""Test SSL options when broker is not Redis."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "amqp://localhost:5672"
with patch("extensions.ext_celery.dify_config", mock_config):
@ -33,7 +33,6 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_none(self):
"""Test SSL options with CERT_NONE requirement."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE"
mock_config.REDIS_SSL_CA_CERTS = None
@ -53,7 +52,6 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_required(self):
"""Test SSL options with CERT_REQUIRED and certificates."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED"
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
@ -73,7 +71,6 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_optional(self):
"""Test SSL options with CERT_OPTIONAL requirement."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL"
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
@ -91,7 +88,6 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_invalid_cert_reqs(self):
"""Test SSL options with invalid cert requirement defaults to CERT_NONE."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE"
mock_config.REDIS_SSL_CA_CERTS = None
@ -108,7 +104,6 @@ class TestCelerySSLConfiguration:
def test_celery_init_applies_ssl_to_broker_and_backend(self):
"""Test that SSL options are applied to both broker and backend when using Redis."""
mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.CELERY_BACKEND = "redis"
mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0"

View File

@ -15,6 +15,11 @@ from core.tools.utils.text_processing_utils import remove_leading_symbols
("", ""),
(" ", " "),
("【测试】", "【测试】"),
# Markdown link preservation - should be preserved if text starts with a markdown link
("[Google](https://google.com) is a search engine", "[Google](https://google.com) is a search engine"),
("[Example](http://example.com) some text", "[Example](http://example.com) some text"),
# Leading symbols before markdown link are removed, including the opening bracket [
("@[Test](https://example.com)", "Test](https://example.com)"),
],
)
def test_remove_leading_symbols(input_text, expected_output):

View File

@ -1,6 +1,7 @@
import type { Plan, UsagePlanInfo } from '@/app/components/billing/type'
import type { ProviderContextState } from '@/context/provider-context'
import { merge, noop } from 'es-toolkit/compat'
import { merge } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests

View File

@ -64,7 +64,6 @@ vi.mock('i18next', () => ({
// Mock the useConfig hook
vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
__esModule: true,
default: () => ({
inputs: {
is_parallel: true,

View File

@ -4,11 +4,11 @@ import type { FC } from 'react'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import { useI18N } from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
@ -26,7 +26,7 @@ const DatePicker: FC<Props> = ({
onStartChange,
onEndChange,
}) => {
const { locale } = useI18N()
const locale = useLocale()
const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => {
return (

View File

@ -7,7 +7,7 @@ import dayjs from 'dayjs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import { useI18N } from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format'
import DatePicker from './date-picker'
import RangeSelector from './range-selector'
@ -27,7 +27,7 @@ const TimeRangePicker: FC<Props> = ({
onSelect,
queryDateFormat,
}) => {
const { locale } = useI18N()
const locale = useLocale()
const [isCustomRange, setIsCustomRange] = useState(false)
const [start, setStart] = useState<Dayjs>(today)

View File

@ -3,12 +3,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
export default function CheckCode() {
@ -19,7 +19,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const locale = useLocale()
const verify = async () => {
try {

View File

@ -1,17 +1,17 @@
'use client'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common'
@ -22,7 +22,7 @@ export default function CheckCode() {
const router = useRouter()
const [email, setEmail] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const locale = useLocale()
const handleGetEMailVerificationCode = async () => {
try {

View File

@ -4,12 +4,12 @@ import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
@ -23,7 +23,7 @@ export default function CheckCode() {
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const locale = useLocale()
const codeInputRef = useRef<HTMLInputElement>(null)
const redirectUrl = searchParams.get('redirect_url')
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)

View File

@ -1,14 +1,13 @@
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { sendWebAppEMailLoginCode } from '@/service/common'
export default function MailAndCodeAuth() {
@ -18,7 +17,7 @@ export default function MailAndCodeAuth() {
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const locale = useLocale()
const handleGetEMailVerificationCode = async () => {
try {

View File

@ -1,15 +1,14 @@
'use client'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { webAppLogin } from '@/service/common'
import { fetchAccessToken } from '@/service/share'
@ -21,7 +20,7 @@ type MailAndPasswordAuthProps = {
export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) {
const { t } = useTranslation()
const { locale } = useContext(I18NContext)
const locale = useLocale()
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)

View File

@ -1,6 +1,6 @@
import type { ResponseError } from '@/service/fetch'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'

View File

@ -1,14 +1,18 @@
'use client'
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { fetchSetupStatus } from '@/service/common'
import { sendGAEvent } from '@/utils/gtag'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
type AppInitializerProps = {
children: ReactNode
@ -22,6 +26,10 @@ export const AppInitializer = ({
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser, setOauthNewUser] = useQueryState(
'oauth_new_user',
parseAsString.withOptions({ history: 'replace' }),
)
const isSetupFinished = useCallback(async () => {
try {
@ -45,6 +53,34 @@ export const AppInitializer = ({
(async () => {
const action = searchParams.get('action')
if (oauthNewUser === 'true') {
let utmInfo = null
const utmInfoStr = Cookies.get('utm_info')
if (utmInfoStr) {
try {
utmInfo = JSON.parse(utmInfoStr)
}
catch (e) {
console.error('Failed to parse utm_info cookie:', e)
}
}
// Track registration event with UTM params
trackEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
sendGAEvent(utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success', {
method: 'oauth',
...utmInfo,
})
// Clean up: remove utm_info cookie and URL params
Cookies.remove('utm_info')
setOauthNewUser(null)
}
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
@ -67,7 +103,7 @@ export const AppInitializer = ({
router.replace('/signin')
}
})()
}, [isSetupFinished, router, pathname, searchParams])
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser, setOauthNewUser])
return init ? children : null
}

View File

@ -132,7 +132,6 @@ vi.mock('@/hooks/use-knowledge', () => ({
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
__esModule: true,
default: ({
show,
onClose,

View File

@ -13,7 +13,6 @@ vi.mock('next/navigation', () => ({
// Mock classnames utility
vi.mock('@/utils/classnames', () => ({
__esModule: true,
default: (...classes: any[]) => classes.filter(Boolean).join(' '),
}))

View File

@ -10,7 +10,6 @@ vi.mock('@/context/provider-context', () => ({
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: vi.fn(args => mockToastNotify(args)),
},

View File

@ -1,7 +1,8 @@
import type { Mock } from 'vitest'
import type { Locale } from '@/i18n-config'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import I18nContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import CSVDownload from './csv-downloader'
@ -17,17 +18,13 @@ vi.mock('react-papaparse', () => ({
})),
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const renderWithLocale = (locale: Locale) => {
return render(
<I18nContext.Provider value={{
locale,
i18n: {},
setLocaleOnClient: vi.fn().mockResolvedValue(undefined),
}}
>
<CSVDownload />
</I18nContext.Provider>,
)
;(useLocale as Mock).mockReturnValue(locale)
return render(<CSVDownload />)
}
describe('CSVDownload', () => {

View File

@ -5,9 +5,9 @@ import { useTranslation } from 'react-i18next'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
const CSV_TEMPLATE_QA_EN = [
@ -24,7 +24,7 @@ const CSV_TEMPLATE_QA_CN = [
const CSVDownload: FC = () => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const getTemplate = () => {

View File

@ -8,7 +8,6 @@ import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/ser
import BatchModal, { ProcessStatus } from './index'
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: vi.fn(),
},
@ -24,14 +23,12 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('./csv-downloader', () => ({
__esModule: true,
default: () => <div data-testid="csv-downloader-stub" />,
}))
let lastUploadedFile: File | undefined
vi.mock('./csv-uploader', () => ({
__esModule: true,
default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
<div>
<button
@ -49,7 +46,6 @@ vi.mock('./csv-uploader', () => ({
}))
vi.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -26,7 +26,6 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: () => '2023-12-01 10:30:00',
}),
@ -35,7 +34,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
// Note: i18n is automatically mocked by Vitest via web/vitest.setup.ts
vi.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))

View File

@ -1,10 +1,11 @@
import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
import type { AnnotationItemBasic } from '../type'
import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import I18NContext from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import HeaderOptions from './index'
@ -159,16 +160,21 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/billing/annotation-full', () => ({
__esModule: true,
default: () => <div data-testid="annotation-full" />,
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => LanguagesSupported[0]),
}))
type HeaderOptionsProps = ComponentProps<typeof HeaderOptions>
const renderComponent = (
props: Partial<HeaderOptionsProps> = {},
locale: Locale = LanguagesSupported[0],
) => {
;(useLocale as Mock).mockReturnValue(locale)
const defaultProps: HeaderOptionsProps = {
appId: 'test-app-id',
onAdd: vi.fn(),
@ -177,17 +183,7 @@ const renderComponent = (
...props,
}
return render(
<I18NContext.Provider
value={{
locale,
i18n: {},
setLocaleOnClient: vi.fn(),
}}
>
<HeaderOptions {...defaultProps} />
</I18NContext.Provider>,
)
return render(<HeaderOptions {...defaultProps} />)
}
const openOperationsPopover = async (user: ReturnType<typeof userEvent.setup>) => {
@ -440,20 +436,12 @@ describe('HeaderOptions', () => {
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(1))
view.rerender(
<I18NContext.Provider
value={{
locale: LanguagesSupported[0],
i18n: {},
setLocaleOnClient: vi.fn(),
}}
>
<HeaderOptions
appId="test-app-id"
onAdd={vi.fn()}
onAdded={vi.fn()}
controlUpdateList={1}
/>
</I18NContext.Provider>,
<HeaderOptions
appId="test-app-id"
onAdd={vi.fn()}
onAdded={vi.fn()}
controlUpdateList={1}
/>,
)
await waitFor(() => expect(mockedFetchAnnotations).toHaveBeenCalledTimes(2))

View File

@ -13,15 +13,14 @@ import { useTranslation } from 'react-i18next'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useContext } from 'use-context-selector'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import CustomPopover from '@/app/components/base/popover'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import { cn } from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal'
import BatchAddModal from '../batch-add-annotation-modal'
@ -44,7 +43,7 @@ const HeaderOptions: FC<Props> = ({
controlUpdateList,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const locale = useLocale()
const { CSVDownloader, Type } = useCSVDownloader()
const [list, setList] = useState<AnnotationItemBasic[]>([])
const annotationUnavailable = list.length === 0

View File

@ -18,7 +18,6 @@ import Annotation from './index'
import { JobStatus } from './type'
vi.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: { notify: vi.fn() },
}))

View File

@ -6,7 +6,6 @@ import List from './list'
const mockFormatTime = vi.fn(() => 'formatted-time')
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: mockFormatTime,
}),

View File

@ -8,7 +8,6 @@ import ViewAnnotationModal from './index'
const mockFormatTime = vi.fn(() => 'formatted-time')
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: mockFormatTime,
}),
@ -24,7 +23,6 @@ vi.mock('../edit-annotation-modal/edit-item', () => {
Answer: 'answer',
}
return {
__esModule: true,
default: ({ type, content, onSave }: { type: string, content: string, onSave: (value: string) => void }) => (
<div>
<div data-testid={`content-${type}`}>{content}</div>

View File

@ -4,7 +4,7 @@ import {
RiAddLine,
RiEditLine,
} from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'

View File

@ -3,7 +3,6 @@ import * as React from 'react'
import ConfirmAddVar from './index'
vi.mock('../../base/var-highlight', () => ({
__esModule: true,
default: ({ name }: { name: string }) => <span data-testid="var-highlight">{name}</span>,
}))

View File

@ -4,7 +4,6 @@ import * as React from 'react'
import EditModal from './edit-modal'
vi.mock('@/app/components/base/modal', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

View File

@ -8,7 +8,6 @@ vi.mock('@/context/i18n', () => ({
}))
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
__esModule: true,
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" data-testid="edit-button" onClick={onClick}>
edit
@ -17,7 +16,6 @@ vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
}))
vi.mock('@/app/components/app/configuration/base/feature-panel', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))

View File

@ -31,7 +31,6 @@ const defaultPromptVariables: PromptVariable[] = [
let mockSimplePromptInputProps: IPromptProps | null = null
vi.mock('./simple-prompt-input', () => ({
__esModule: true,
default: (props: IPromptProps) => {
mockSimplePromptInputProps = props
return (
@ -67,7 +66,6 @@ type AdvancedMessageInputProps = {
}
vi.mock('./advanced-prompt-input', () => ({
__esModule: true,
default: (props: AdvancedMessageInputProps) => {
return (
<div

View File

@ -4,7 +4,7 @@ import type { ExternalDataTool } from '@/models/common'
import type { PromptVariable } from '@/models/debug'
import type { GenRes } from '@/service/debug'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useState } from 'react'

View File

@ -16,7 +16,6 @@ vi.mock('react-i18next', () => ({
let latestAgentSettingProps: any
vi.mock('./agent/agent-setting', () => ({
__esModule: true,
default: (props: any) => {
latestAgentSettingProps = props
return (

View File

@ -76,7 +76,6 @@ const ToolPickerMock = (props: ToolPickerProps) => (
</div>
)
vi.mock('@/app/components/workflow/block-selector/tool-picker', () => ({
__esModule: true,
default: (props: ToolPickerProps) => <ToolPickerMock {...props} />,
}))
@ -96,7 +95,6 @@ const SettingBuiltInToolMock = (props: SettingBuiltInToolProps) => {
)
}
vi.mock('./setting-built-in-tool', () => ({
__esModule: true,
default: (props: SettingBuiltInToolProps) => <SettingBuiltInToolMock {...props} />,
}))

View File

@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { CollectionType } from '@/app/components/tools/types'
import I18n from '@/context/i18n'
import SettingBuiltInTool from './setting-built-in-tool'
const fetchModelToolList = vi.fn()
@ -36,7 +35,6 @@ const FormMock = ({ value, onChange }: MockFormProps) => {
)
}
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
__esModule: true,
default: (props: MockFormProps) => <FormMock {...props} />,
}))
@ -56,6 +54,10 @@ vi.mock('@/app/components/plugins/readme-panel/entrance', () => ({
ReadmeEntrance: ({ className }: { className?: string }) => <div className={className}>readme</div>,
}))
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(() => 'en-US'),
}))
const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => ({
name: 'settingParam',
label: {
@ -129,18 +131,16 @@ const renderComponent = (props?: Partial<React.ComponentProps<typeof SettingBuil
const onSave = vi.fn()
const onAuthorizationItemClick = vi.fn()
const utils = render(
<I18n.Provider value={{ locale: 'en-US', i18n: {}, setLocaleOnClient: vi.fn() as any }}>
<SettingBuiltInTool
collection={baseCollection as any}
toolName="search"
isModel
setting={{ settingParam: 'value' }}
onHide={onHide}
onSave={onSave}
onAuthorizationItemClick={onAuthorizationItemClick}
{...props}
/>
</I18n.Provider>,
<SettingBuiltInTool
collection={baseCollection as any}
toolName="search"
isModel
setting={{ settingParam: 'value' }}
onHide={onHide}
onSave={onSave}
onAuthorizationItemClick={onAuthorizationItemClick}
{...props}
/>,
)
return {
...utils,

View File

@ -9,7 +9,6 @@ import {
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import Drawer from '@/app/components/base/drawer'
@ -26,7 +25,7 @@ import {
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
import { cn } from '@/utils/classnames'
@ -58,7 +57,7 @@ const SettingBuiltInTool: FC<Props> = ({
credentialId,
onAuthorizationItemClick,
}) => {
const { locale } = useContext(I18n)
const locale = useLocale()
const language = getLanguage(locale)
const { t } = useTranslation()
const passedTools = (collection as ToolWithProvider).tools

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { ExternalDataTool } from '@/models/common'
import copy from 'copy-to-clipboard'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'

View File

@ -17,13 +17,11 @@ vi.mock('use-context-selector', async (importOriginal) => {
const mockFormattingDispatcher = vi.fn()
vi.mock('../debug/hooks', () => ({
__esModule: true,
useFormattingChangedDispatcher: () => mockFormattingDispatcher,
}))
let latestConfigPromptProps: any
vi.mock('@/app/components/app/configuration/config-prompt', () => ({
__esModule: true,
default: (props: any) => {
latestConfigPromptProps = props
return <div data-testid="config-prompt" />
@ -32,7 +30,6 @@ vi.mock('@/app/components/app/configuration/config-prompt', () => ({
let latestConfigVarProps: any
vi.mock('@/app/components/app/configuration/config-var', () => ({
__esModule: true,
default: (props: any) => {
latestConfigVarProps = props
return <div data-testid="config-var" />
@ -40,33 +37,27 @@ vi.mock('@/app/components/app/configuration/config-var', () => ({
}))
vi.mock('../dataset-config', () => ({
__esModule: true,
default: () => <div data-testid="dataset-config" />,
}))
vi.mock('./agent/agent-tools', () => ({
__esModule: true,
default: () => <div data-testid="agent-tools" />,
}))
vi.mock('../config-vision', () => ({
__esModule: true,
default: () => <div data-testid="config-vision" />,
}))
vi.mock('./config-document', () => ({
__esModule: true,
default: () => <div data-testid="config-document" />,
}))
vi.mock('./config-audio', () => ({
__esModule: true,
default: () => <div data-testid="config-audio" />,
}))
let latestHistoryPanelProps: any
vi.mock('../config-prompt/conversation-history/history-panel', () => ({
__esModule: true,
default: (props: any) => {
latestHistoryPanelProps = props
return <div data-testid="history-panel" />

View File

@ -11,7 +11,6 @@ import { RETRIEVE_METHOD } from '@/types/app'
import Item from './index'
vi.mock('../settings-modal', () => ({
__esModule: true,
default: ({ onSave, onCancel, currentDataset }: any) => (
<div>
<div>Mock settings modal</div>
@ -24,7 +23,6 @@ vi.mock('../settings-modal', () => ({
vi.mock('@/hooks/use-breakpoints', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/hooks/use-breakpoints')>()
return {
__esModule: true,
...actual,
default: vi.fn(() => actual.MediaType.pc),
}

View File

@ -80,7 +80,6 @@ vi.mock('uuid', () => ({
// Mock child components
vi.mock('./card-item', () => ({
__esModule: true,
default: ({ config, onRemove, onSave, editable }: any) => (
<div data-testid={`card-item-${config.id}`}>
<span>{config.name}</span>
@ -91,7 +90,6 @@ vi.mock('./card-item', () => ({
}))
vi.mock('./params-config', () => ({
__esModule: true,
default: ({ disabled, selectedDatasets }: any) => (
<button data-testid="params-config" disabled={disabled}>
Params (
@ -102,7 +100,6 @@ vi.mock('./params-config', () => ({
}))
vi.mock('./context-var', () => ({
__esModule: true,
default: ({ value, options, onChange }: any) => (
<select data-testid="context-var" value={value} onChange={e => onChange(e.target.value)}>
<option value="">Select context variable</option>
@ -114,7 +111,6 @@ vi.mock('./context-var', () => ({
}))
vi.mock('@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter', () => ({
__esModule: true,
default: ({
metadataList,
metadataFilterMode,
@ -198,7 +194,6 @@ const mockConfigContext: any = {
}
vi.mock('@/context/debug-configuration', () => ({
__esModule: true,
default: ({ children }: any) => (
<div data-testid="config-context-provider">
{children}

View File

@ -30,13 +30,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
)
return {
__esModule: true,
default: MockModelSelector,
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))

View File

@ -65,13 +65,11 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
)
return {
__esModule: true,
default: MockModelSelector,
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
__esModule: true,
default: () => <div data-testid="model-parameter-modal" />,
}))

View File

@ -1,4 +1,4 @@
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Slider from '@/app/components/base/slider'

View File

@ -9,7 +9,6 @@ import { RETRIEVE_METHOD } from '@/types/app'
import SelectDataSet from './index'
vi.mock('@/i18n-config/i18next-config', () => ({
__esModule: true,
default: {
changeLanguage: vi.fn(),
addResourceBundle: vi.fn(),

View File

@ -33,7 +33,6 @@ vi.mock('ky', () => {
})
vi.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',
@ -45,7 +44,6 @@ vi.mock('@/service/datasets', () => ({
}))
vi.mock('@/service/use-common', async () => ({
__esModule: true,
...(await vi.importActual('@/service/use-common')),
useMembers: vi.fn(),
}))
@ -86,7 +84,6 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
__esModule: true,
useModelList: (...args: unknown[]) => mockUseModelList(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
@ -95,7 +92,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="model-selector">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}

View File

@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { isEqual } from 'es-toolkit/compat'
import { isEqual } from 'es-toolkit/predicate'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'

View File

@ -34,7 +34,6 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
__esModule: true,
useModelListAndDefaultModelAndCurrentProviderAndModel: (...args: unknown[]) =>
mockUseModelListAndDefaultModelAndCurrentProviderAndModel(...args),
useModelListAndDefaultModel: (...args: unknown[]) => mockUseModelListAndDefaultModel(...args),
@ -43,7 +42,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
__esModule: true,
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="model-selector">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-model'}
@ -52,7 +50,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
}))
vi.mock('@/app/components/datasets/create/step-two', () => ({
__esModule: true,
IndexingType: {
QUALIFIED: 'high_quality',
ECONOMICAL: 'economy',

View File

@ -1,7 +1,7 @@
'use client'
import type { ModelAndParameter } from '../types'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { createContext, useContext } from 'use-context-selector'
export type DebugWithMultipleModelContextType = {

View File

@ -52,27 +52,22 @@ const mockFiles: FileEntity[] = [
]
vi.mock('@/context/debug-configuration', () => ({
__esModule: true,
useDebugConfigurationContext: () => mockUseDebugConfigurationContext(),
}))
vi.mock('@/app/components/base/features/hooks', () => ({
__esModule: true,
useFeatures: (selector: (state: FeatureStoreState) => unknown) => mockUseFeaturesSelector(selector),
}))
vi.mock('@/context/event-emitter', () => ({
__esModule: true,
useEventEmitterContextContext: () => mockUseEventEmitterContext(),
}))
vi.mock('@/app/components/app/store', () => ({
__esModule: true,
useStore: (selector: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => mockUseAppStoreSelector(selector),
}))
vi.mock('./debug-item', () => ({
__esModule: true,
default: ({
modelAndParameter,
className,
@ -95,7 +90,6 @@ vi.mock('./debug-item', () => ({
}))
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
__esModule: true,
default: (props: MockChatInputAreaProps) => {
capturedChatInputProps = props
return (

View File

@ -4,7 +4,8 @@ import type {
OnSend,
TextGenerationConfig,
} from '@/app/components/base/text-generation/types'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import { memo } from 'react'
import TextGeneration from '@/app/components/app/text-generate/item'
import { TransferMethod } from '@/app/components/base/chat/types'

View File

@ -403,7 +403,6 @@ vi.mock('@/app/components/base/toast', () => ({
// Mock hooks/use-timestamp
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: vi.fn(() => ({
formatTime: vi.fn((timestamp: number) => new Date(timestamp).toLocaleString()),
})),

View File

@ -6,7 +6,7 @@ import type {
ChatConfig,
ChatItem,
} from '@/app/components/base/chat/types'
import { cloneDeep } from 'es-toolkit/compat'
import { cloneDeep } from 'es-toolkit/object'
import {
useCallback,
useRef,

View File

@ -11,7 +11,8 @@ import {
RiSparklingFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { cloneDeep, noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { cloneDeep } from 'es-toolkit/object'
import { produce, setAutoFreeze } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

View File

@ -1,6 +1,6 @@
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug'
import { clone } from 'es-toolkit/compat'
import { clone } from 'es-toolkit/object'
import { produce } from 'immer'
import { useState } from 'react'
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'

View File

@ -20,7 +20,8 @@ import type {
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks'
import { clone, isEqual } from 'es-toolkit/compat'
import { clone } from 'es-toolkit/object'
import { isEqual } from 'es-toolkit/predicate'
import { produce } from 'immer'
import { usePathname } from 'next/navigation'
import * as React from 'react'

View File

@ -11,7 +11,6 @@ vi.mock('@/app/components/app/store', () => ({
useStore: vi.fn(),
}))
vi.mock('@/app/components/base/features/new-feature-panel/feature-bar', () => ({
__esModule: true,
default: ({ onFeatureBarClick }: { onFeatureBarClick: () => void }) => (
<button type="button" onClick={onFeatureBarClick}>
feature bar

View File

@ -3,10 +3,9 @@ import type {
CodeBasedExtensionItem,
ExternalDataTool,
} from '@/models/common'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import EmojiPicker from '@/app/components/base/emoji-picker'
@ -16,7 +15,7 @@ import Modal from '@/app/components/base/modal'
import { SimpleSelect } from '@/app/components/base/select'
import { useToastContext } from '@/app/components/base/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import I18n, { useDocLink } from '@/context/i18n'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions } from '@/service/use-common'
@ -41,7 +40,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useToastContext()
const { locale } = useContext(I18n)
const locale = useLocale()
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')

View File

@ -14,13 +14,6 @@ vi.mock('ahooks', () => ({
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceEditor: true }),
}))
vi.mock('use-context-selector', async () => {
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
return {
...actual,
useContext: () => ({ hasEditPermission: true }),
}
})
vi.mock('nuqs', () => ({
useQueryState: () => ['Recommended', vi.fn()],
}))
@ -28,13 +21,11 @@ vi.mock('@/service/use-explore', () => ({
useExploreAppList: () => mockUseExploreAppList(),
}))
vi.mock('@/app/components/app/type-selector', () => ({
__esModule: true,
default: ({ value, onChange }: { value: AppModeEnum[], onChange: (value: AppModeEnum[]) => void }) => (
<button data-testid="type-selector" onClick={() => onChange([...value, 'chat' as AppModeEnum])}>{value.join(',')}</button>
),
}))
vi.mock('../app-card', () => ({
__esModule: true,
default: ({ app, onCreate }: { app: any, onCreate: () => void }) => (
<div
data-testid="app-card"
@ -46,7 +37,6 @@ vi.mock('../app-card', () => ({
),
}))
vi.mock('@/app/components/explore/create-app-modal', () => ({
__esModule: true,
default: () => <div data-testid="create-from-template-modal" />,
}))
vi.mock('@/app/components/base/toast', () => ({
@ -122,6 +112,7 @@ describe('Apps', () => {
fireEvent.click(screen.getAllByTestId('app-card')[0])
expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument()
})
it('shows no template message when list is empty', () => {
mockUseExploreAppList.mockReturnValueOnce({
data: { allList: [], categories: [] },

View File

@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppTypeSelector from '@/app/components/app/type-selector'
import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
@ -19,7 +18,6 @@ import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import ExploreContext from '@/context/explore-context'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
import { fetchAppDetail } from '@/service/explore'
@ -47,7 +45,6 @@ const Apps = ({
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const { push } = useRouter()
const { hasEditPermission } = useContext(ExploreContext)
const allCategoriesEn = AppCategories.RECOMMENDED
const [keywords, setKeywords] = useState('')
@ -214,7 +211,7 @@ const Apps = ({
<AppCard
key={app.app_id}
app={app}
canCreate={hasEditPermission}
canCreate={isCurrentWorkspaceEditor}
onCreate={() => {
setCurrApp(app)
setIsShowCreateModal(true)

View File

@ -44,7 +44,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => () => '/guides',
}))
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => ({ theme: 'light' }),
}))

View File

@ -3,7 +3,7 @@
import type { MouseEventHandler } from 'react'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -9,7 +9,6 @@ import DuplicateAppModal from './index'
const appsFullRenderSpy = vi.fn()
vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
__esModule: true,
default: ({ loc }: { loc: string }) => {
appsFullRenderSpy(loc)
return <div data-testid="apps-full">AppsFull</div>

View File

@ -1,7 +1,7 @@
'use client'
import type { AppIconType } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -14,21 +14,18 @@ vi.mock('next/navigation', () => ({
}))
vi.mock('@/app/components/app/annotation', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="annotation" data-app-id={appDetail.id} />
),
}))
vi.mock('@/app/components/app/log', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="log" data-app-id={appDetail.id} />
),
}))
vi.mock('@/app/components/app/workflow-log', () => ({
__esModule: true,
default: ({ appDetail }: { appDetail: App }) => (
<div data-testid="workflow-log" data-app-id={appDetail.id} />
),

View File

@ -12,7 +12,8 @@ import { RiCloseLine, RiEditFill } from '@remixicon/react'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { get, noop } from 'es-toolkit/compat'
import { get } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

View File

@ -2,7 +2,7 @@ import type { RenderOptions } from '@testing-library/react'
import type { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render } from '@testing-library/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { defaultPlan } from '@/app/components/billing/config'
import { useModalContext as actualUseModalContext } from '@/context/modal-context'

View File

@ -8,7 +8,6 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import Embedded from './index'
vi.mock('./style.module.css', () => ({
__esModule: true,
default: {
option: 'option',
active: 'active',
@ -37,7 +36,6 @@ const mockUseAppContext = vi.fn(() => ({
}))
vi.mock('copy-to-clipboard', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({

View File

@ -72,7 +72,6 @@ vi.mock('@/context/provider-context', () => ({
}))
vi.mock('@/app/components/billing/apps-full-in-dialog', () => ({
__esModule: true,
default: ({ loc }: { loc: string }) => (
<div data-testid="apps-full">
AppsFull

View File

@ -2,7 +2,7 @@
import type { App } from '@/types/app'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -8,7 +8,6 @@ import Toast from '@/app/components/base/toast'
import SavedItems from './index'
vi.mock('copy-to-clipboard', () => ({
__esModule: true,
default: vi.fn(),
}))
vi.mock('next/navigation', () => ({

View File

@ -27,7 +27,6 @@ vi.mock('next/navigation', () => ({
// Mock the Run component as it has complex dependencies
vi.mock('@/app/components/workflow/run', () => ({
__esModule: true,
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string, tracingListUrl: string }) => (
<div data-testid="workflow-run">
<span data-testid="run-detail-url">{runDetailUrl}</span>

View File

@ -54,13 +54,11 @@ vi.mock('next/navigation', () => ({
}))
vi.mock('next/link', () => ({
__esModule: true,
default: ({ children, href }: { children: React.ReactNode, href: string }) => <a href={href}>{children}</a>,
}))
// Mock the Run component to avoid complex dependencies
vi.mock('@/app/components/workflow/run', () => ({
__esModule: true,
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string, tracingListUrl: string }) => (
<div data-testid="workflow-run">
<span data-testid="run-detail-url">{runDetailUrl}</span>
@ -75,7 +73,6 @@ vi.mock('@/app/components/base/amplitude/utils', () => ({
}))
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => {
return { theme: 'light' }
},

View File

@ -5,7 +5,7 @@ import { useDebounce } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { omit } from 'es-toolkit/compat'
import { omit } from 'es-toolkit/object'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -31,7 +31,6 @@ vi.mock('next/navigation', () => ({
// Mock useTimestamp hook
vi.mock('@/hooks/use-timestamp', () => ({
__esModule: true,
default: () => ({
formatTime: (timestamp: number, _format: string) => `formatted-${timestamp}`,
}),
@ -39,7 +38,6 @@ vi.mock('@/hooks/use-timestamp', () => ({
// Mock useBreakpoints hook
vi.mock('@/hooks/use-breakpoints', () => ({
__esModule: true,
default: () => 'pc', // Return desktop by default
MediaType: {
mobile: 'mobile',
@ -49,7 +47,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
// Mock the Run component
vi.mock('@/app/components/workflow/run', () => ({
__esModule: true,
default: ({ runDetailUrl, tracingListUrl }: { runDetailUrl: string, tracingListUrl: string }) => (
<div data-testid="workflow-run">
<span data-testid="run-detail-url">{runDetailUrl}</span>
@ -67,13 +64,11 @@ vi.mock('@/app/components/workflow/context', () => ({
// Mock BlockIcon
vi.mock('@/app/components/workflow/block-icon', () => ({
__esModule: true,
default: () => <div data-testid="block-icon">BlockIcon</div>,
}))
// Mock useTheme
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => {
return { theme: 'light' }
},

View File

@ -17,13 +17,11 @@ import TriggerByDisplay from './trigger-by-display'
let mockTheme = Theme.light
vi.mock('@/hooks/use-theme', () => ({
__esModule: true,
default: () => ({ theme: mockTheme }),
}))
// Mock BlockIcon as it has complex dependencies
vi.mock('@/app/components/workflow/block-icon', () => ({
__esModule: true,
default: ({ type, toolIcon }: { type: string, toolIcon?: string }) => (
<div data-testid="block-icon" data-type={type} data-tool-icon={toolIcon || ''}>
BlockIcon

View File

@ -188,13 +188,11 @@ vi.mock('@/app/components/base/popover', () => {
// Tooltip uses portals - minimal mock preserving popup content as title attribute
vi.mock('@/app/components/base/tooltip', () => ({
__esModule: true,
default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children),
}))
// TagSelector has API dependency (service/tag) - mock for isolated testing
vi.mock('@/app/components/base/tag-management/selector', () => ({
__esModule: true,
default: ({ tags }: any) => {
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: any) => React.createElement('span', { key: tag.id }, tag.name)))
},

View File

@ -10,7 +10,6 @@ let educationInitCalls: number = 0
// Mock useDocumentTitle hook
vi.mock('@/hooks/use-document-title', () => ({
__esModule: true,
default: (title: string) => {
documentTitleCalls.push(title)
},
@ -25,7 +24,6 @@ vi.mock('@/app/education-apply/hooks', () => ({
// Mock List component
vi.mock('./list', () => ({
__esModule: true,
default: () => {
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
},

View File

@ -39,7 +39,6 @@ const mockQueryState = {
isCreatedByMe: false,
}
vi.mock('./hooks/use-apps-query-state', () => ({
__esModule: true,
default: () => ({
query: mockQueryState,
setQuery: mockSetQuery,
@ -144,7 +143,6 @@ vi.mock('@/service/tag', () => ({
// Store TagFilter onChange callback for testing
let mockTagFilterOnChange: ((value: string[]) => void) | null = null
vi.mock('@/app/components/base/tag-management/filter', () => ({
__esModule: true,
default: ({ onChange }: { onChange: (value: string[]) => void }) => {
mockTagFilterOnChange = onChange
return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder')
@ -200,7 +198,6 @@ vi.mock('next/dynamic', () => ({
* Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests.
*/
vi.mock('./app-card', () => ({
__esModule: true,
default: ({ app }: any) => {
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
},
@ -213,14 +210,12 @@ vi.mock('./new-app-card', () => ({
}))
vi.mock('./empty', () => ({
__esModule: true,
default: () => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
},
}))
vi.mock('./footer', () => ({
__esModule: true,
default: () => {
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
},

View File

@ -2,7 +2,8 @@
import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
import { flatten, uniq } from 'es-toolkit/compat'
import { uniq } from 'es-toolkit/array'
import { flatten } from 'es-toolkit/compat'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -6,13 +6,13 @@ import {
RiErrorWarningLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useContext } from 'use-context-selector'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import BlockIcon from '@/app/components/workflow/block-icon'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { BlockEnum } from '@/app/components/workflow/types'
import I18n from '@/context/i18n'
import { useLocale } from '@/context/i18n'
import { cn } from '@/utils/classnames'
type Props = {
@ -26,7 +26,7 @@ type Props = {
const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => {
const [collapseState, setCollapseState] = useState<boolean>(true)
const { locale } = useContext(I18n)
const locale = useLocale()
const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')])
const getTime = (time: number) => {

View File

@ -68,6 +68,7 @@ const AmplitudeProvider: FC<IAmplitudeProps> = ({
pageViews: true,
formInteractions: true,
fileDownloads: true,
attribution: true,
},
})

View File

@ -3,7 +3,7 @@ import type { Area } from 'react-easy-crop'
import type { OnImageInput } from './ImageInput'
import type { AppIconType, ImageFile } from '@/types/app'
import { RiImageCircleAiLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'

View File

@ -14,7 +14,7 @@ import type {
AppMeta,
ConversationItem,
} from '@/models/share'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { createContext, useContext } from 'use-context-selector'
export type ChatWithHistoryContextValue = {

View File

@ -10,7 +10,7 @@ import type {
ConversationItem,
} from '@/models/share'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import {
useCallback,

Some files were not shown because too many files have changed in this diff Show More