Merge branch 'main' into fix/surface-subscription-deletion-errors

This commit is contained in:
Maries 2025-12-31 14:57:55 +08:00 committed by GitHub
commit 8a7d997a7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
214 changed files with 1321 additions and 286 deletions

View File

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

View File

@ -1,3 +1,4 @@
import re
import uuid import uuid
from typing import Literal from typing import Literal
@ -73,6 +74,48 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc raise ValueError("Invalid UUID format in tag_ids.") from exc
# XSS prevention: patterns that could lead to XSS attacks
# Includes: script tags, iframe tags, javascript: protocol, SVG with onload, etc.
_XSS_PATTERNS = [
r"<script[^>]*>.*?</script>", # Script tags
r"<iframe\b[^>]*?(?:/>|>.*?</iframe>)", # Iframe tags (including self-closing)
r"javascript:", # JavaScript protocol
r"<svg[^>]*?\s+onload\s*=[^>]*>", # SVG with onload handler (attribute-aware, flexible whitespace)
r"<.*?on\s*\w+\s*=", # Event handlers like onclick, onerror, etc.
r"<object\b[^>]*(?:\s*/>|>.*?</object\s*>)", # Object tags (opening tag)
r"<embed[^>]*>", # Embed tags (self-closing)
r"<link[^>]*>", # Link tags with javascript
]
def _validate_xss_safe(value: str | None, field_name: str = "Field") -> str | None:
"""
Validate that a string value doesn't contain potential XSS payloads.
Args:
value: The string value to validate
field_name: Name of the field for error messages
Returns:
The original value if safe
Raises:
ValueError: If the value contains XSS patterns
"""
if value is None:
return None
value_lower = value.lower()
for pattern in _XSS_PATTERNS:
if re.search(pattern, value_lower, re.DOTALL | re.IGNORECASE):
raise ValueError(
f"{field_name} contains invalid characters or patterns. "
"HTML tags, JavaScript, and other potentially dangerous content are not allowed."
)
return value
class CreateAppPayload(BaseModel): class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name") name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@ -81,6 +124,11 @@ class CreateAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon") icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color") icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class UpdateAppPayload(BaseModel): class UpdateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name") name: str = Field(..., min_length=1, description="App name")
@ -91,6 +139,11 @@ class UpdateAppPayload(BaseModel):
use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon") use_icon_as_answer_icon: bool | None = Field(default=None, description="Use icon as answer icon")
max_active_requests: int | None = Field(default=None, description="Maximum active requests") max_active_requests: int | None = Field(default=None, description="Maximum active requests")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class CopyAppPayload(BaseModel): class CopyAppPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied app") name: str | None = Field(default=None, description="Name for the copied app")
@ -99,6 +152,11 @@ class CopyAppPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon") icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color") icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("name", "description", mode="before")
@classmethod
def validate_xss_safe(cls, value: str | None, info) -> str | None:
return _validate_xss_safe(value, info.field_name)
class AppExportQuery(BaseModel): class AppExportQuery(BaseModel):
include_secret: bool = Field(default=False, description="Include secrets in export") include_secret: bool = Field(default=False, description="Include secrets in export")

View File

@ -124,7 +124,7 @@ class OAuthCallback(Resource):
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}") return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
try: try:
account = _generate_account(provider, user_info) account, oauth_new_user = _generate_account(provider, user_info)
except AccountNotFoundError: except AccountNotFoundError:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.") return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError): except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
@ -159,7 +159,10 @@ class OAuthCallback(Resource):
ip_address=extract_remote_ip(request), 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_access_token_to_cookie(request, response, token_pair.access_token)
set_refresh_token_to_cookie(request, response, token_pair.refresh_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 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. # Get account by openid or email.
account = _get_account_by_openid_or_email(provider, user_info) account = _get_account_by_openid_or_email(provider, user_info)
oauth_new_user = False
if account: if account:
tenants = TenantService.get_join_tenants(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) tenant_was_created.send(new_tenant)
if not account: if not account:
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register: if not FeatureService.get_system_features().is_allow_register:
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email): if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email):
raise AccountRegisterError( raise AccountRegisterError(
@ -220,4 +225,4 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
# Link account # Link account
AccountService.link_account_integrate(provider, user_info.id, 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 import request
from flask_restx import Resource, marshal from flask_restx import Resource, marshal
from pydantic import BaseModel, Field 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 from werkzeug.exceptions import Forbidden, NotFound
import services import services
from configs import dify_config
from controllers.common.schema import register_schema_models from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ProviderNotInitializeError from controllers.console.app.error import ProviderNotInitializeError
@ -143,7 +145,29 @@ class DatasetDocumentSegmentListApi(Resource):
query = query.where(DocumentSegment.hit_count >= hit_count_gte) query = query.where(DocumentSegment.hit_count >= hit_count_gte)
if keyword: 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() != "all":
if args.enabled.lower() == "true": 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-.]+)" pattern = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
text = re.sub(pattern, "", text) text = re.sub(pattern, "", text)
# Remove URL but keep Markdown image URLs # Remove URL but keep Markdown image URLs and link URLs
# First, temporarily replace Markdown image URLs with a placeholder # Replace the ENTIRE markdown link/image with a single placeholder to protect
markdown_image_pattern = r"!\[.*?\]\((https?://[^\s)]+)\)" # the link text (which might also be a URL) from being removed
placeholders: list[str] = [] 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) url = match.group(1)
placeholder = f"__MARKDOWN_IMAGE_URL_{len(placeholders)}__" placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
placeholders.append(url) placeholders.append((link_type, "image", url))
return f"![image]({placeholder})" 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 # Now remove all remaining URLs
url_pattern = r"https?://[^\s)]+" url_pattern = r"https?://\S+"
text = re.sub(url_pattern, "", text) text = re.sub(url_pattern, "", text)
# Finally, restore the Markdown image URLs # Restore the Markdown links and images
for i, url in enumerate(placeholders): for i, (link_type, text_or_alt, url) in enumerate(placeholders):
text = text.replace(f"__MARKDOWN_IMAGE_URL_{i}__", url) 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 return text
def filter_string(self, text): def filter_string(self, text):

View File

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

View File

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

View File

@ -60,6 +60,7 @@ class SkipPropagator:
if edge_states["has_taken"]: if edge_states["has_taken"]:
# Enqueue node # Enqueue node
self._state_manager.enqueue_node(downstream_node_id) self._state_manager.enqueue_node(downstream_node_id)
self._state_manager.start_execution(downstream_node_id)
return return
# All edges are skipped, propagate skip to this node # 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: def _get_celery_ssl_options() -> dict[str, Any] | None:
"""Get SSL configuration for Celery broker/backend connections.""" """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 # 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 return None
# Check if Celery is actually using Redis # Check if Celery is actually using Redis

View File

@ -16,6 +16,11 @@ celery_redis = Redis(
port=redis_config.get("port") or 6379, port=redis_config.get("port") or 6379,
password=redis_config.get("password") or None, password=redis_config.get("password") or None,
db=int(redis_config.get("virtual_host")) if redis_config.get("virtual_host") else 1, 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__) logger = logging.getLogger(__name__)

View File

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

View File

@ -0,0 +1,254 @@
"""
Unit tests for XSS prevention in App payloads.
This test module validates that HTML tags, JavaScript, and other potentially
dangerous content are rejected in App names and descriptions.
"""
import pytest
from controllers.console.app.app import CopyAppPayload, CreateAppPayload, UpdateAppPayload
class TestXSSPreventionUnit:
"""Unit tests for XSS prevention in App payloads."""
def test_create_app_valid_names(self):
"""Test CreateAppPayload with valid app names."""
# Normal app names should be valid
valid_names = [
"My App",
"Test App 123",
"App with - dash",
"App with _ underscore",
"App with + plus",
"App with () parentheses",
"App with [] brackets",
"App with {} braces",
"App with ! exclamation",
"App with @ at",
"App with # hash",
"App with $ dollar",
"App with % percent",
"App with ^ caret",
"App with & ampersand",
"App with * asterisk",
"Unicode: 测试应用",
"Emoji: 🤖",
"Mixed: Test 测试 123",
]
for name in valid_names:
payload = CreateAppPayload(
name=name,
mode="chat",
)
assert payload.name == name
def test_create_app_xss_script_tags(self):
"""Test CreateAppPayload rejects script tags."""
xss_payloads = [
"<script>alert(document.cookie)</script>",
"<Script>alert(1)</Script>",
"<SCRIPT>alert('XSS')</SCRIPT>",
"<script>alert(String.fromCharCode(88,83,83))</script>",
"<script src='evil.js'></script>",
"<script>document.location='http://evil.com'</script>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_iframe_tags(self):
"""Test CreateAppPayload rejects iframe tags."""
xss_payloads = [
"<iframe src='evil.com'></iframe>",
"<Iframe srcdoc='<script>alert(1)</script>'></iframe>",
"<IFRAME src='javascript:alert(1)'></iframe>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_javascript_protocol(self):
"""Test CreateAppPayload rejects javascript: protocol."""
xss_payloads = [
"javascript:alert(1)",
"JAVASCRIPT:alert(1)",
"JavaScript:alert(document.cookie)",
"javascript:void(0)",
"javascript://comment%0Aalert(1)",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_svg_onload(self):
"""Test CreateAppPayload rejects SVG with onload."""
xss_payloads = [
"<svg onload=alert(1)>",
"<SVG ONLOAD=alert(1)>",
"<svg/x/onload=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_event_handlers(self):
"""Test CreateAppPayload rejects HTML event handlers."""
xss_payloads = [
"<div onclick=alert(1)>",
"<img onerror=alert(1)>",
"<body onload=alert(1)>",
"<input onfocus=alert(1)>",
"<a onmouseover=alert(1)>",
"<DIV ONCLICK=alert(1)>",
"<img src=x onerror=alert(1)>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_object_embed(self):
"""Test CreateAppPayload rejects object and embed tags."""
xss_payloads = [
"<object data='evil.swf'></object>",
"<embed src='evil.swf'>",
"<OBJECT data='javascript:alert(1)'></OBJECT>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_link_javascript(self):
"""Test CreateAppPayload rejects link tags with javascript."""
xss_payloads = [
"<link href='javascript:alert(1)'>",
"<LINK HREF='javascript:alert(1)'>",
]
for name in xss_payloads:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_xss_in_description(self):
"""Test CreateAppPayload rejects XSS in description."""
xss_descriptions = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for description in xss_descriptions:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(
name="Valid Name",
mode="chat",
description=description,
)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_create_app_valid_descriptions(self):
"""Test CreateAppPayload with valid descriptions."""
valid_descriptions = [
"A simple description",
"Description with < and > symbols",
"Description with & ampersand",
"Description with 'quotes' and \"double quotes\"",
"Description with / slashes",
"Description with \\ backslashes",
"Description with ; semicolons",
"Unicode: 这是一个描述",
"Emoji: 🎉🚀",
]
for description in valid_descriptions:
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=description,
)
assert payload.description == description
def test_create_app_none_description(self):
"""Test CreateAppPayload with None description."""
payload = CreateAppPayload(
name="Valid App Name",
mode="chat",
description=None,
)
assert payload.description is None
def test_update_app_xss_prevention(self):
"""Test UpdateAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
UpdateAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_update_app_valid_names(self):
"""Test UpdateAppPayload with valid names."""
payload = UpdateAppPayload(name="Valid Updated Name")
assert payload.name == "Valid Updated Name"
def test_copy_app_xss_prevention(self):
"""Test CopyAppPayload also prevents XSS."""
xss_names = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"<img onerror=alert(1)>",
]
for name in xss_names:
with pytest.raises(ValueError) as exc_info:
CopyAppPayload(name=name)
assert "invalid characters or patterns" in str(exc_info.value).lower()
def test_copy_app_valid_names(self):
"""Test CopyAppPayload with valid names."""
payload = CopyAppPayload(name="Valid Copy Name")
assert payload.name == "Valid Copy Name"
def test_copy_app_none_name(self):
"""Test CopyAppPayload with None name (should be allowed)."""
payload = CopyAppPayload(name=None)
assert payload.name is None
def test_edge_case_angle_brackets_content(self):
"""Test that angle brackets with actual content are rejected."""
# Angle brackets without valid HTML-like patterns should be checked
# The regex pattern <.*?on\w+\s*= should catch event handlers
# But let's verify other patterns too
# Valid: angle brackets used as symbols (not matched by our patterns)
# Our patterns specifically look for dangerous constructs
# Invalid: actual HTML tags with event handlers
invalid_names = [
"<div onclick=xss>",
"<img src=x onerror=alert(1)>",
]
for name in invalid_names:
with pytest.raises(ValueError) as exc_info:
CreateAppPayload(name=name, mode="chat")
assert "invalid characters or patterns" in str(exc_info.value).lower()

View File

@ -171,7 +171,7 @@ class TestOAuthCallback:
): ):
mock_config.CONSOLE_WEB_URL = "http://localhost:3000" mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
mock_get_providers.return_value = {"github": oauth_setup["provider"]} 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"] mock_account_service.login.return_value = oauth_setup["token_pair"]
with app.test_request_context("/auth/oauth/github/callback?code=test_code"): 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_access_token.assert_called_once_with("test_code")
oauth_setup["provider"].get_user_info.assert_called_once_with("access_token") 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( @pytest.mark.parametrize(
("exception", "expected_error"), ("exception", "expected_error"),
@ -223,7 +223,7 @@ class TestOAuthCallback:
# This documents actual behavior. See test_defensive_check_for_closed_account_status for details # This documents actual behavior. See test_defensive_check_for_closed_account_status for details
( (
AccountStatus.CLOSED.value, AccountStatus.CLOSED.value,
"http://localhost:3000", "http://localhost:3000?oauth_new_user=false",
), ),
], ],
) )
@ -260,7 +260,7 @@ class TestOAuthCallback:
account = MagicMock() account = MagicMock()
account.status = account_status account.status = account_status
account.id = "123" account.id = "123"
mock_generate_account.return_value = account mock_generate_account.return_value = (account, False)
# Mock login for CLOSED status # Mock login for CLOSED status
mock_token_pair = MagicMock() mock_token_pair = MagicMock()
@ -296,7 +296,7 @@ class TestOAuthCallback:
mock_account = MagicMock() mock_account = MagicMock()
mock_account.status = AccountStatus.PENDING 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 = MagicMock()
mock_token_pair.access_token = "jwt_access_token" mock_token_pair.access_token = "jwt_access_token"
@ -360,7 +360,7 @@ class TestOAuthCallback:
closed_account.status = AccountStatus.CLOSED closed_account.status = AccountStatus.CLOSED
closed_account.id = "123" closed_account.id = "123"
closed_account.name = "Closed Account" 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 successful login (current behavior)
mock_token_pair = MagicMock() mock_token_pair = MagicMock()
@ -374,7 +374,7 @@ class TestOAuthCallback:
resource.get("github") resource.get("github")
# Verify current behavior: login succeeds (this is NOT ideal) # 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() mock_account_service.login.assert_called_once()
# Document expected behavior in comments: # Document expected behavior in comments:
@ -458,8 +458,9 @@ class TestAccountGeneration:
with pytest.raises(AccountRegisterError): with pytest.raises(AccountRegisterError):
_generate_account("github", user_info) _generate_account("github", user_info)
else: else:
result = _generate_account("github", user_info) result, oauth_new_user = _generate_account("github", user_info)
assert result == mock_account assert result == mock_account
assert oauth_new_user == should_create
if should_create: if should_create:
mock_register_service.register.assert_called_once_with( mock_register_service.register.assert_called_once_with(
@ -490,9 +491,10 @@ class TestAccountGeneration:
mock_tenant_service.create_tenant.return_value = mock_new_tenant mock_tenant_service.create_tenant.return_value = mock_new_tenant
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): 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 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.assert_called_once_with("Test User's Workspace")
mock_tenant_service.create_tenant_member.assert_called_once_with( mock_tenant_service.create_tenant_member.assert_called_once_with(
mock_new_tenant, mock_account, role="owner" 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.""" """Test suite for Celery SSL configuration."""
def test_get_celery_ssl_options_when_ssl_disabled(self): def test_get_celery_ssl_options_when_ssl_disabled(self):
"""Test SSL options when REDIS_USE_SSL is False.""" """Test SSL options when BROKER_USE_SSL is False."""
mock_config = MagicMock() from configs import DifyConfig
mock_config.REDIS_USE_SSL = False
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 from extensions.ext_celery import _get_celery_ssl_options
result = _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): def test_get_celery_ssl_options_when_broker_not_redis(self):
"""Test SSL options when broker is not Redis.""" """Test SSL options when broker is not Redis."""
mock_config = MagicMock() mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "amqp://localhost:5672" mock_config.CELERY_BROKER_URL = "amqp://localhost:5672"
with patch("extensions.ext_celery.dify_config", mock_config): 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): def test_get_celery_ssl_options_with_cert_none(self):
"""Test SSL options with CERT_NONE requirement.""" """Test SSL options with CERT_NONE requirement."""
mock_config = MagicMock() mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE" mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE"
mock_config.REDIS_SSL_CA_CERTS = None mock_config.REDIS_SSL_CA_CERTS = None
@ -53,7 +52,6 @@ class TestCelerySSLConfiguration:
def test_get_celery_ssl_options_with_cert_required(self): def test_get_celery_ssl_options_with_cert_required(self):
"""Test SSL options with CERT_REQUIRED and certificates.""" """Test SSL options with CERT_REQUIRED and certificates."""
mock_config = MagicMock() mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0" mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED" mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED"
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt" 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): def test_get_celery_ssl_options_with_cert_optional(self):
"""Test SSL options with CERT_OPTIONAL requirement.""" """Test SSL options with CERT_OPTIONAL requirement."""
mock_config = MagicMock() mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL" mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL"
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt" 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): def test_get_celery_ssl_options_with_invalid_cert_reqs(self):
"""Test SSL options with invalid cert requirement defaults to CERT_NONE.""" """Test SSL options with invalid cert requirement defaults to CERT_NONE."""
mock_config = MagicMock() mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE" mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE"
mock_config.REDIS_SSL_CA_CERTS = None mock_config.REDIS_SSL_CA_CERTS = None
@ -108,7 +104,6 @@ class TestCelerySSLConfiguration:
def test_celery_init_applies_ssl_to_broker_and_backend(self): 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.""" """Test that SSL options are applied to both broker and backend when using Redis."""
mock_config = MagicMock() mock_config = MagicMock()
mock_config.REDIS_USE_SSL = True
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0" mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.CELERY_BACKEND = "redis" mock_config.CELERY_BACKEND = "redis"
mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0" 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): 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 { Plan, UsagePlanInfo } from '@/app/components/billing/type'
import type { ProviderContextState } from '@/context/provider-context' 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' import { defaultPlan } from '@/app/components/billing/config'
// Avoid being mocked in tests // Avoid being mocked in tests

View File

@ -4,7 +4,7 @@ import type { FC } from 'react'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types' import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { RiCalendarLine } from '@remixicon/react' import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useCallback } from 'react' import { useCallback } from 'react'
import Picker from '@/app/components/base/date-and-time-picker/date-picker' import Picker from '@/app/components/base/date-and-time-picker/date-picker'

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'

View File

@ -1,4 +1,4 @@
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -1,5 +1,5 @@
'use client' 'use client'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import Link from 'next/link' import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'

View File

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

View File

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

View File

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

View File

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

View File

@ -7,19 +7,24 @@ export const jsonObjectWrap = {
export const jsonConfigPlaceHolder = JSON.stringify( export const jsonConfigPlaceHolder = JSON.stringify(
{ {
foo: { type: 'object',
type: 'string', properties: {
}, foo: {
bar: { type: 'string',
type: 'object', },
properties: { bar: {
sub: { type: 'object',
type: 'number', properties: {
}, sub: {
type: 'number',
},
},
required: [],
additionalProperties: true,
}, },
required: [],
additionalProperties: true,
}, },
required: [],
additionalProperties: true,
}, },
null, null,
2, 2,

View File

@ -28,7 +28,7 @@ import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInpu
import ConfigSelect from '../config-select' import ConfigSelect from '../config-select'
import ConfigString from '../config-string' import ConfigString from '../config-string'
import ModalFoot from '../modal-foot' import ModalFoot from '../modal-foot'
import { jsonConfigPlaceHolder, jsonObjectWrap } from './config' import { jsonConfigPlaceHolder } from './config'
import Field from './field' import Field from './field'
import TypeSelector from './type-select' import TypeSelector from './type-select'
@ -78,13 +78,12 @@ const ConfigModal: FC<IConfigModalProps> = ({
const modalRef = useRef<HTMLDivElement>(null) const modalRef = useRef<HTMLDivElement>(null)
const appDetail = useAppStore(state => state.appDetail) const appDetail = useAppStore(state => state.appDetail)
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
const isSupportJSON = false
const jsonSchemaStr = useMemo(() => { const jsonSchemaStr = useMemo(() => {
const isJsonObject = type === InputVarType.jsonObject const isJsonObject = type === InputVarType.jsonObject
if (!isJsonObject || !tempPayload.json_schema) if (!isJsonObject || !tempPayload.json_schema)
return '' return ''
try { try {
return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2) return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
} }
catch { catch {
return '' return ''
@ -129,13 +128,14 @@ const ConfigModal: FC<IConfigModalProps> = ({
}, []) }, [])
const handleJSONSchemaChange = useCallback((value: string) => { const handleJSONSchemaChange = useCallback((value: string) => {
const isEmpty = value == null || value.trim() === ''
if (isEmpty) {
handlePayloadChange('json_schema')(undefined)
return null
}
try { try {
const v = JSON.parse(value) const v = JSON.parse(value)
const res = { handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
...jsonObjectWrap,
properties: v,
}
handlePayloadChange('json_schema')(JSON.stringify(res, null, 2))
} }
catch { catch {
return null return null
@ -175,7 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
}, },
] ]
: []), : []),
...((!isBasicApp && isSupportJSON) ...((!isBasicApp)
? [{ ? [{
name: t('variableConfig.json', { ns: 'appDebug' }), name: t('variableConfig.json', { ns: 'appDebug' }),
value: InputVarType.jsonObject, value: InputVarType.jsonObject,
@ -233,7 +233,28 @@ const ConfigModal: FC<IConfigModalProps> = ({
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default]) const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
const isJsonSchemaEmpty = (value: InputVar['json_schema']) => {
if (value === null || value === undefined) {
return true
}
if (typeof value !== 'string') {
return false
}
const trimmed = value.trim()
return trimmed === ''
}
const handleConfirm = () => { const handleConfirm = () => {
const jsonSchemaValue = tempPayload.json_schema
const isSchemaEmpty = isJsonSchemaEmpty(jsonSchemaValue)
const normalizedJsonSchema = isSchemaEmpty ? undefined : jsonSchemaValue
// if the input type is jsonObject and the schema is empty as determined by `isJsonSchemaEmpty`,
// remove the `json_schema` field from the payload by setting its value to `undefined`.
const payloadToSave = tempPayload.type === InputVarType.jsonObject && isSchemaEmpty
? { ...tempPayload, json_schema: undefined }
: tempPayload
const moreInfo = tempPayload.variable === payload?.variable const moreInfo = tempPayload.variable === payload?.variable
? undefined ? undefined
: { : {
@ -250,7 +271,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
return return
} }
if (isStringInput || type === InputVarType.number) { if (isStringInput || type === InputVarType.number) {
onConfirm(tempPayload, moreInfo) onConfirm(payloadToSave, moreInfo)
} }
else if (type === InputVarType.select) { else if (type === InputVarType.select) {
if (options?.length === 0) { if (options?.length === 0) {
@ -270,7 +291,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) }) Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) })
return return
} }
onConfirm(tempPayload, moreInfo) onConfirm(payloadToSave, moreInfo)
} }
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) { else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
if (tempPayload.allowed_file_types?.length === 0) { if (tempPayload.allowed_file_types?.length === 0) {
@ -283,10 +304,26 @@ const ConfigModal: FC<IConfigModalProps> = ({
Toast.notify({ type: 'error', message: errorMessages }) Toast.notify({ type: 'error', message: errorMessages })
return return
} }
onConfirm(tempPayload, moreInfo) onConfirm(payloadToSave, moreInfo)
}
else if (type === InputVarType.jsonObject) {
if (!isSchemaEmpty && typeof normalizedJsonSchema === 'string') {
try {
const schema = JSON.parse(normalizedJsonSchema)
if (schema?.type !== 'object') {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaMustBeObject', { ns: 'appDebug' }) })
return
}
}
catch {
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.jsonSchemaInvalid', { ns: 'appDebug' }) })
return
}
}
onConfirm(payloadToSave, moreInfo)
} }
else { else {
onConfirm(tempPayload, moreInfo) onConfirm(payloadToSave, moreInfo)
} }
} }

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
import type { DataSet } from '@/models/datasets' import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app' import type { RetrievalConfig } from '@/types/app'
import { RiCloseLine } from '@remixicon/react' 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 { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'

View File

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

View File

@ -4,7 +4,8 @@ import type {
OnSend, OnSend,
TextGenerationConfig, TextGenerationConfig,
} from '@/app/components/base/text-generation/types' } 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 { memo } from 'react'
import TextGeneration from '@/app/components/app/text-generate/item' import TextGeneration from '@/app/components/app/text-generate/item'
import { TransferMethod } from '@/app/components/base/chat/types' import { TransferMethod } from '@/app/components/base/chat/types'

View File

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

View File

@ -11,7 +11,8 @@ import {
RiSparklingFill, RiSparklingFill,
} from '@remixicon/react' } from '@remixicon/react'
import { useBoolean } from 'ahooks' 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 { produce, setAutoFreeze } from 'immer'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useRef, useState } 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 { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug' 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 { produce } from 'immer'
import { useState } from 'react' import { useState } from 'react'
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants' 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 type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
import { CodeBracketIcon } from '@heroicons/react/20/solid' import { CodeBracketIcon } from '@heroicons/react/20/solid'
import { useBoolean, useGetState } from 'ahooks' 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 { produce } from 'immer'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import * as React from 'react' import * as React from 'react'

View File

@ -3,7 +3,7 @@ import type {
CodeBasedExtensionItem, CodeBasedExtensionItem,
ExternalDataTool, ExternalDataTool,
} from '@/models/common' } from '@/models/common'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'

View File

@ -14,13 +14,6 @@ vi.mock('ahooks', () => ({
vi.mock('@/context/app-context', () => ({ vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceEditor: true }), 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', () => ({ vi.mock('nuqs', () => ({
useQueryState: () => ['Recommended', vi.fn()], useQueryState: () => ['Recommended', vi.fn()],
})) }))
@ -119,6 +112,7 @@ describe('Apps', () => {
fireEvent.click(screen.getAllByTestId('app-card')[0]) fireEvent.click(screen.getAllByTestId('app-card')[0])
expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument() expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument()
}) })
it('shows no template message when list is empty', () => { it('shows no template message when list is empty', () => {
mockUseExploreAppList.mockReturnValueOnce({ mockUseExploreAppList.mockReturnValueOnce({
data: { allList: [], categories: [] }, data: { allList: [], categories: [] },

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import { useDebounce } from 'ahooks' import { useDebounce } from 'ahooks'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { omit } from 'es-toolkit/compat' import { omit } from 'es-toolkit/object'
import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'

View File

@ -12,7 +12,8 @@ import { RiCloseLine, RiEditFill } from '@remixicon/react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc' 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 { usePathname, useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useRef, useState } 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 { Mock, MockedFunction } from 'vitest'
import type { ModalContextState } from '@/context/modal-context' import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render } from '@testing-library/react' 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 { defaultPlan } from '@/app/components/billing/config'
import { useModalContext as actualUseModalContext } from '@/context/modal-context' import { useModalContext as actualUseModalContext } from '@/context/modal-context'

View File

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

View File

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

View File

@ -2,7 +2,8 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { IChatItem } from '@/app/components/base/chat/chat/type'
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log' 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 * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

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

View File

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

View File

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

View File

@ -8,7 +8,8 @@ import type { InputForm } from './type'
import type AudioPlayer from '@/app/components/base/audio-btn/audio' import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Annotation } from '@/models/log' import type { Annotation } from '@/models/log'
import { noop, uniqBy } from 'es-toolkit/compat' import { uniqBy } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { produce, setAutoFreeze } from 'immer' import { produce, setAutoFreeze } from 'immer'
import { useParams, usePathname } from 'next/navigation' import { useParams, usePathname } from 'next/navigation'
import { import {

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import type { Day } from '../types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
import { IS_PROD } from '@/config'
import tz from '@/utils/timezone.json' import tz from '@/utils/timezone.json'
dayjs.extend(utc) dayjs.extend(utc)
@ -131,7 +132,7 @@ export type ToDayjsOptions = {
} }
const warnParseFailure = (value: string) => { const warnParseFailure = (value: string) => {
if (process.env.NODE_ENV !== 'production') if (!IS_PROD)
console.warn('[TimePicker] Failed to parse time value', value) console.warn('[TimePicker] Failed to parse time value', value)
} }

View File

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

View File

@ -4,6 +4,7 @@ import { RiAlertLine, RiBugLine } from '@remixicon/react'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { IS_DEV } from '@/config'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
type ErrorBoundaryState = { type ErrorBoundaryState = {
@ -54,7 +55,7 @@ class ErrorBoundaryInner extends React.Component<
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo) { componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (process.env.NODE_ENV === 'development') { if (IS_DEV) {
console.error('ErrorBoundary caught an error:', error) console.error('ErrorBoundary caught an error:', error)
console.error('Error Info:', errorInfo) console.error('Error Info:', errorInfo)
} }
@ -262,13 +263,13 @@ export function withErrorBoundary<P extends object>(
// Simple error fallback component // Simple error fallback component
export const ErrorFallback: React.FC<{ export const ErrorFallback: React.FC<{
error: Error error: Error
resetErrorBoundary: () => void resetErrorBoundaryAction: () => void
}> = ({ error, resetErrorBoundary }) => { }> = ({ error, resetErrorBoundaryAction }) => {
return ( return (
<div className="flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-8"> <div className="flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-8">
<h2 className="mb-2 text-lg font-semibold text-red-800">Oops! Something went wrong</h2> <h2 className="mb-2 text-lg font-semibold text-red-800">Oops! Something went wrong</h2>
<p className="mb-4 text-center text-red-600">{error.message}</p> <p className="mb-4 text-center text-red-600">{error.message}</p>
<Button onClick={resetErrorBoundary} size="small"> <Button onClick={resetErrorBoundaryAction} size="small">
Try again Try again
</Button> </Button>
</div> </div>

View File

@ -3,7 +3,7 @@ import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug' import type { PromptVariable } from '@/models/debug'
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react' import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { produce } from 'immer' import { produce } from 'immer'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'

View File

@ -2,7 +2,7 @@ import type { ChangeEvent, FC } from 'react'
import type { CodeBasedExtensionItem } from '@/models/common' import type { CodeBasedExtensionItem } from '@/models/common'
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug' import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'

View File

@ -2,7 +2,7 @@ import type { ClipboardEvent } from 'react'
import type { FileEntity } from './types' import type { FileEntity } from './types'
import type { FileUpload } from '@/app/components/base/features/types' import type { FileUpload } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common' import type { FileUploadConfigResponse } from '@/models/common'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { produce } from 'immer' import { produce } from 'immer'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { import {

View File

@ -1,6 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' import { RiCloseLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { t } from 'i18next' import { t } from 'i18next'
import * as React from 'react' import * as React from 'react'
import { useState } from 'react' import { useState } from 'react'

View File

@ -1,7 +1,7 @@
import type { import type {
FileEntity, FileEntity,
} from './types' } from './types'
import { isEqual } from 'es-toolkit/compat' import { isEqual } from 'es-toolkit/predicate'
import { import {
createContext, createContext,
useContext, useContext,

View File

@ -1,6 +1,6 @@
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react' import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLargeLine } from '@remixicon/react' import { RiCloseLargeLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
type IModal = { type IModal = {

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Script from 'next/script' import Script from 'next/script'
import * as React from 'react' import * as React from 'react'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION, IS_PROD } from '@/config'
export enum GaType { export enum GaType {
admin = 'admin', admin = 'admin',
@ -32,7 +32,7 @@ const GA: FC<IGAProps> = ({
if (IS_CE_EDITION) if (IS_CE_EDITION)
return null return null
const cspHeader = process.env.NODE_ENV === 'production' const cspHeader = IS_PROD
? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy') ? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy')
: null : null
const nonce = extractNonceFromCSP(cspHeader) const nonce = extractNonceFromCSP(cspHeader)

View File

@ -1,6 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { t } from 'i18next' import { t } from 'i18next'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'

View File

@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority'
import type { ChangeEventHandler, CSSProperties, FocusEventHandler } from 'react' import type { ChangeEventHandler, CSSProperties, FocusEventHandler } from 'react'
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react' import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
import { cva } from 'class-variance-authority' import { cva } from 'class-variance-authority'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'

View File

@ -1,6 +1,6 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react' import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { Fragment } from 'react' import { Fragment } from 'react'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
// https://headlessui.com/react/dialog // https://headlessui.com/react/dialog

View File

@ -1,6 +1,6 @@
import type { ButtonProps } from '@/app/components/base/button' import type { ButtonProps } from '@/app/components/base/button'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'

View File

@ -4,7 +4,7 @@ import type {
IPaginationProps, IPaginationProps,
PageButtonProps, PageButtonProps,
} from './type' } from './type'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import usePagination from './hook' import usePagination from './hook'

View File

@ -1,7 +1,7 @@
import type { ContextBlockType } from '../../types' import type { ContextBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { $applyNodeReplacement } from 'lexical' import { $applyNodeReplacement } from 'lexical'
import { import {
memo, memo,

View File

@ -1,7 +1,7 @@
import type { ContextBlockType } from '../../types' import type { ContextBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { import {
$insertNodes, $insertNodes,
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,

View File

@ -1,7 +1,7 @@
import type { HistoryBlockType } from '../../types' import type { HistoryBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { $applyNodeReplacement } from 'lexical' import { $applyNodeReplacement } from 'lexical'
import { import {
useCallback, useCallback,

View File

@ -1,7 +1,7 @@
import type { HistoryBlockType } from '../../types' import type { HistoryBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { import {
$insertNodes, $insertNodes,
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'

View File

@ -3,7 +3,7 @@ import type { HtmlContentProps } from '@/app/components/base/popover'
import type { Tag } from '@/app/components/base/tag-management/constant' import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiAddLine, RiPriceTag3Line } from '@remixicon/react' import { RiAddLine, RiPriceTag3Line } from '@remixicon/react'
import { useUnmount } from 'ahooks' import { useUnmount } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -2,7 +2,7 @@
import type { Tag } from '@/app/components/base/tag-management/constant' import type { Tag } from '@/app/components/base/tag-management/constant'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback' import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'

View File

@ -1,6 +1,6 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { act, render, screen, waitFor } from '@testing-library/react' import { act, render, screen, waitFor } from '@testing-library/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import Toast, { ToastProvider, useToastContext } from '.' import Toast, { ToastProvider, useToastContext } from '.'

View File

@ -7,7 +7,7 @@ import {
RiErrorWarningFill, RiErrorWarningFill,
RiInformation2Fill, RiInformation2Fill,
} from '@remixicon/react' } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { z } from 'zod' import { z } from 'zod'
import withValidation from '.' import withValidation from '.'

View File

@ -1,13 +1,13 @@
import { headers } from 'next/headers' import { headers } from 'next/headers'
import Script from 'next/script' import Script from 'next/script'
import { memo } from 'react' import { memo } from 'react'
import { IS_CE_EDITION, ZENDESK_WIDGET_KEY } from '@/config' import { IS_CE_EDITION, IS_PROD, ZENDESK_WIDGET_KEY } from '@/config'
const Zendesk = async () => { const Zendesk = async () => {
if (IS_CE_EDITION || !ZENDESK_WIDGET_KEY) if (IS_CE_EDITION || !ZENDESK_WIDGET_KEY)
return null return null
const nonce = process.env.NODE_ENV === 'production' ? (await headers()).get('x-nonce') ?? '' : '' const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : ''
return ( return (
<> <>

View File

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

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useDebounceFn, useKeyPress } from 'ahooks' import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useMemo, useRef, useState } from 'react' import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -9,7 +9,7 @@ import {
RiArrowLeftLine, RiArrowLeftLine,
RiSearchEyeLine, RiSearchEyeLine,
} from '@remixicon/react' } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'

View File

@ -2,7 +2,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ChunkingMode, FileItem } from '@/models/datasets' import type { ChunkingMode, FileItem } from '@/models/datasets'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -1,4 +1,4 @@
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Drawer from './drawer' import Drawer from './drawer'

View File

@ -1,7 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import { RiLoader2Line } from '@remixicon/react' import { RiLoader2Line } from '@remixicon/react'
import { useCountDown } from 'ahooks' import { useCountDown } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -4,7 +4,7 @@ import type { Item } from '@/app/components/base/select'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets' import type { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

View File

@ -1,7 +1,7 @@
import type { NotionPage } from '@/models/common' import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets' import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline' import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -9,7 +9,8 @@ import {
RiGlobalLine, RiGlobalLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { pick, uniq } from 'es-toolkit/compat' import { uniq } from 'es-toolkit/array'
import { pick } from 'es-toolkit/object'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'

View File

@ -11,7 +11,7 @@ import {
RiPlayCircleLine, RiPlayCircleLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useBoolean, useDebounceFn } from 'ahooks' import { useBoolean, useDebounceFn } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'

View File

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

View File

@ -4,7 +4,7 @@ import type { MouseEventHandler } from 'react'
import type { AppIconSelection } from '../../base/app-icon-picker' import type { AppIconSelection } from '../../base/app-icon-picker'
import type { DataSet } from '@/models/datasets' import type { DataSet } from '@/models/datasets'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'

View File

@ -2,7 +2,7 @@
import type { AppIconType } from '@/types/app' import type { AppIconType } from '@/types/app'
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react' import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
import { useDebounceFn, useKeyPress } from 'ahooks' import { useDebounceFn, useKeyPress } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

View File

@ -1,6 +1,6 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ApiBasedExtension } from '@/models/common' import type { ApiBasedExtension } from '@/models/common'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'

View File

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

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import { import {
RiDeleteBinLine, RiDeleteBinLine,
} from '@remixicon/react' } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import * as React from 'react' import * as React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { RiCloseLine } from '@remixicon/react' import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector' import { useContext } from 'use-context-selector'

View File

@ -3,7 +3,7 @@ import type { RoleKey } from './role-selector'
import type { InvitationResult } from '@/models/common' import type { InvitationResult } from '@/models/common'
import { RiCloseLine, RiErrorWarningFill } from '@remixicon/react' import { RiCloseLine, RiErrorWarningFill } from '@remixicon/react'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ReactMultiEmail } from 'react-multi-email' import { ReactMultiEmail } from 'react-multi-email'

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