mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into fix/surface-subscription-deletion-errors
This commit is contained in:
commit
8a7d997a7f
|
|
@ -1,4 +1,8 @@
|
|||
exclude = ["migrations/*"]
|
||||
exclude = [
|
||||
"migrations/*",
|
||||
".git",
|
||||
".git/**",
|
||||
]
|
||||
line-length = 120
|
||||
|
||||
[format]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import re
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
|
|
@ -73,6 +74,48 @@ class AppListQuery(BaseModel):
|
|||
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):
|
||||
name: str = Field(..., min_length=1, description="App name")
|
||||
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_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):
|
||||
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")
|
||||
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):
|
||||
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_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):
|
||||
include_secret: bool = Field(default=False, description="Include secrets in export")
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ class OAuthCallback(Resource):
|
|||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin/invite-settings?invite_token={invite_token}")
|
||||
|
||||
try:
|
||||
account = _generate_account(provider, user_info)
|
||||
account, oauth_new_user = _generate_account(provider, user_info)
|
||||
except AccountNotFoundError:
|
||||
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Account not found.")
|
||||
except (WorkSpaceNotFoundError, WorkSpaceNotAllowedCreateError):
|
||||
|
|
@ -159,7 +159,10 @@ class OAuthCallback(Resource):
|
|||
ip_address=extract_remote_ip(request),
|
||||
)
|
||||
|
||||
response = redirect(f"{dify_config.CONSOLE_WEB_URL}")
|
||||
base_url = dify_config.CONSOLE_WEB_URL
|
||||
query_char = "&" if "?" in base_url else "?"
|
||||
target_url = f"{base_url}{query_char}oauth_new_user={str(oauth_new_user).lower()}"
|
||||
response = redirect(target_url)
|
||||
|
||||
set_access_token_to_cookie(request, response, token_pair.access_token)
|
||||
set_refresh_token_to_cookie(request, response, token_pair.refresh_token)
|
||||
|
|
@ -177,9 +180,10 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
|
|||
return account
|
||||
|
||||
|
||||
def _generate_account(provider: str, user_info: OAuthUserInfo):
|
||||
def _generate_account(provider: str, user_info: OAuthUserInfo) -> tuple[Account, bool]:
|
||||
# Get account by openid or email.
|
||||
account = _get_account_by_openid_or_email(provider, user_info)
|
||||
oauth_new_user = False
|
||||
|
||||
if account:
|
||||
tenants = TenantService.get_join_tenants(account)
|
||||
|
|
@ -193,6 +197,7 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
|
|||
tenant_was_created.send(new_tenant)
|
||||
|
||||
if not account:
|
||||
oauth_new_user = True
|
||||
if not FeatureService.get_system_features().is_allow_register:
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(user_info.email):
|
||||
raise AccountRegisterError(
|
||||
|
|
@ -220,4 +225,4 @@ def _generate_account(provider: str, user_info: OAuthUserInfo):
|
|||
# Link account
|
||||
AccountService.link_account_integrate(provider, user_info.id, account)
|
||||
|
||||
return account
|
||||
return account, oauth_new_user
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import uuid
|
|||
from flask import request
|
||||
from flask_restx import Resource, marshal
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import String, cast, func, or_, select
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import ProviderNotInitializeError
|
||||
|
|
@ -143,7 +145,29 @@ class DatasetDocumentSegmentListApi(Resource):
|
|||
query = query.where(DocumentSegment.hit_count >= hit_count_gte)
|
||||
|
||||
if keyword:
|
||||
query = query.where(DocumentSegment.content.ilike(f"%{keyword}%"))
|
||||
# Search in both content and keywords fields
|
||||
# Use database-specific methods for JSON array search
|
||||
if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql":
|
||||
# PostgreSQL: Use jsonb_array_elements_text to properly handle Unicode/Chinese text
|
||||
keywords_condition = func.array_to_string(
|
||||
func.array(
|
||||
select(func.jsonb_array_elements_text(cast(DocumentSegment.keywords, JSONB)))
|
||||
.correlate(DocumentSegment)
|
||||
.scalar_subquery()
|
||||
),
|
||||
",",
|
||||
).ilike(f"%{keyword}%")
|
||||
else:
|
||||
# MySQL: Cast JSON to string for pattern matching
|
||||
# MySQL stores Chinese text directly in JSON without Unicode escaping
|
||||
keywords_condition = cast(DocumentSegment.keywords, String).ilike(f"%{keyword}%")
|
||||
|
||||
query = query.where(
|
||||
or_(
|
||||
DocumentSegment.content.ilike(f"%{keyword}%"),
|
||||
keywords_condition,
|
||||
)
|
||||
)
|
||||
|
||||
if args.enabled.lower() != "all":
|
||||
if args.enabled.lower() == "true":
|
||||
|
|
|
|||
|
|
@ -27,26 +27,44 @@ class CleanProcessor:
|
|||
pattern = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
||||
text = re.sub(pattern, "", text)
|
||||
|
||||
# Remove URL but keep Markdown image URLs
|
||||
# First, temporarily replace Markdown image URLs with a placeholder
|
||||
markdown_image_pattern = r"!\[.*?\]\((https?://[^\s)]+)\)"
|
||||
placeholders: list[str] = []
|
||||
# Remove URL but keep Markdown image URLs and link URLs
|
||||
# Replace the ENTIRE markdown link/image with a single placeholder to protect
|
||||
# the link text (which might also be a URL) from being removed
|
||||
markdown_link_pattern = r"\[([^\]]*)\]\((https?://[^)]+)\)"
|
||||
markdown_image_pattern = r"!\[.*?\]\((https?://[^)]+)\)"
|
||||
placeholders: list[tuple[str, str, str]] = [] # (type, text, url)
|
||||
|
||||
def replace_with_placeholder(match, placeholders=placeholders):
|
||||
def replace_markdown_with_placeholder(match, placeholders=placeholders):
|
||||
link_type = "link"
|
||||
link_text = match.group(1)
|
||||
url = match.group(2)
|
||||
placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
|
||||
placeholders.append((link_type, link_text, url))
|
||||
return placeholder
|
||||
|
||||
def replace_image_with_placeholder(match, placeholders=placeholders):
|
||||
link_type = "image"
|
||||
url = match.group(1)
|
||||
placeholder = f"__MARKDOWN_IMAGE_URL_{len(placeholders)}__"
|
||||
placeholders.append(url)
|
||||
return f""
|
||||
placeholder = f"__MARKDOWN_PLACEHOLDER_{len(placeholders)}__"
|
||||
placeholders.append((link_type, "image", url))
|
||||
return placeholder
|
||||
|
||||
text = re.sub(markdown_image_pattern, replace_with_placeholder, text)
|
||||
# Protect markdown links first
|
||||
text = re.sub(markdown_link_pattern, replace_markdown_with_placeholder, text)
|
||||
# Then protect markdown images
|
||||
text = re.sub(markdown_image_pattern, replace_image_with_placeholder, text)
|
||||
|
||||
# Now remove all remaining URLs
|
||||
url_pattern = r"https?://[^\s)]+"
|
||||
url_pattern = r"https?://\S+"
|
||||
text = re.sub(url_pattern, "", text)
|
||||
|
||||
# Finally, restore the Markdown image URLs
|
||||
for i, url in enumerate(placeholders):
|
||||
text = text.replace(f"__MARKDOWN_IMAGE_URL_{i}__", url)
|
||||
# Restore the Markdown links and images
|
||||
for i, (link_type, text_or_alt, url) in enumerate(placeholders):
|
||||
placeholder = f"__MARKDOWN_PLACEHOLDER_{i}__"
|
||||
if link_type == "link":
|
||||
text = text.replace(placeholder, f"[{text_or_alt}]({url})")
|
||||
else: # image
|
||||
text = text.replace(placeholder, f"")
|
||||
return text
|
||||
|
||||
def filter_string(self, text):
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ class ApiBasedToolSchemaParser:
|
|||
@staticmethod
|
||||
def auto_parse_to_tool_bundle(
|
||||
content: str, extra_info: dict | None = None, warning: dict | None = None
|
||||
) -> tuple[list[ApiToolBundle], str]:
|
||||
) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]:
|
||||
"""
|
||||
auto parse to tool bundle
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import re
|
|||
def remove_leading_symbols(text: str) -> str:
|
||||
"""
|
||||
Remove leading punctuation or symbols from the given text.
|
||||
Preserves markdown links like [text](url) at the start.
|
||||
|
||||
Args:
|
||||
text (str): The input text to process.
|
||||
|
|
@ -11,6 +12,11 @@ def remove_leading_symbols(text: str) -> str:
|
|||
Returns:
|
||||
str: The text with leading punctuation or symbols removed.
|
||||
"""
|
||||
# Check if text starts with a markdown link - preserve it
|
||||
markdown_link_pattern = r"^\[([^\]]+)\]\((https?://[^)]+)\)"
|
||||
if re.match(markdown_link_pattern, text):
|
||||
return text
|
||||
|
||||
# Match Unicode ranges for punctuation and symbols
|
||||
# FIXME this pattern is confused quick fix for #11868 maybe refactor it later
|
||||
pattern = r'^[\[\]\u2000-\u2025\u2027-\u206F\u2E00-\u2E7F\u3000-\u300F\u3011-\u303F"#$%&\'()*+,./:;<=>?@^_`~]+'
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ class SkipPropagator:
|
|||
if edge_states["has_taken"]:
|
||||
# Enqueue node
|
||||
self._state_manager.enqueue_node(downstream_node_id)
|
||||
self._state_manager.start_execution(downstream_node_id)
|
||||
return
|
||||
|
||||
# All edges are skipped, propagate skip to this node
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ from dify_app import DifyApp
|
|||
|
||||
def _get_celery_ssl_options() -> dict[str, Any] | None:
|
||||
"""Get SSL configuration for Celery broker/backend connections."""
|
||||
# Use REDIS_USE_SSL for consistency with the main Redis client
|
||||
# Only apply SSL if we're using Redis as broker/backend
|
||||
if not dify_config.REDIS_USE_SSL:
|
||||
if not dify_config.BROKER_USE_SSL:
|
||||
return None
|
||||
|
||||
# Check if Celery is actually using Redis
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ celery_redis = Redis(
|
|||
port=redis_config.get("port") or 6379,
|
||||
password=redis_config.get("password") or None,
|
||||
db=int(redis_config.get("virtual_host")) if redis_config.get("virtual_host") else 1,
|
||||
ssl=bool(dify_config.BROKER_USE_SSL),
|
||||
ssl_ca_certs=dify_config.REDIS_SSL_CA_CERTS if dify_config.BROKER_USE_SSL else None,
|
||||
ssl_cert_reqs=getattr(dify_config, "REDIS_SSL_CERT_REQS", None) if dify_config.BROKER_USE_SSL else None,
|
||||
ssl_certfile=getattr(dify_config, "REDIS_SSL_CERTFILE", None) if dify_config.BROKER_USE_SSL else None,
|
||||
ssl_keyfile=getattr(dify_config, "REDIS_SSL_KEYFILE", None) if dify_config.BROKER_USE_SSL else None,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ class ApiToolManageService:
|
|||
raise ValueError(f"invalid schema: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def convert_schema_to_tool_bundles(schema: str, extra_info: dict | None = None) -> tuple[list[ApiToolBundle], str]:
|
||||
def convert_schema_to_tool_bundles(
|
||||
schema: str, extra_info: dict | None = None
|
||||
) -> tuple[list[ApiToolBundle], ApiProviderSchemaType]:
|
||||
"""
|
||||
convert schema to tool bundles
|
||||
|
||||
|
|
@ -103,7 +105,7 @@ class ApiToolManageService:
|
|||
provider_name: str,
|
||||
icon: dict,
|
||||
credentials: dict,
|
||||
schema_type: str,
|
||||
schema_type: ApiProviderSchemaType,
|
||||
schema: str,
|
||||
privacy_policy: str,
|
||||
custom_disclaimer: str,
|
||||
|
|
@ -112,9 +114,6 @@ class ApiToolManageService:
|
|||
"""
|
||||
create api tool provider
|
||||
"""
|
||||
if schema_type not in [member.value for member in ApiProviderSchemaType]:
|
||||
raise ValueError(f"invalid schema type {schema}")
|
||||
|
||||
provider_name = provider_name.strip()
|
||||
|
||||
# check if the provider exists
|
||||
|
|
@ -241,18 +240,15 @@ class ApiToolManageService:
|
|||
original_provider: str,
|
||||
icon: dict,
|
||||
credentials: dict,
|
||||
schema_type: str,
|
||||
_schema_type: ApiProviderSchemaType,
|
||||
schema: str,
|
||||
privacy_policy: str,
|
||||
privacy_policy: str | None,
|
||||
custom_disclaimer: str,
|
||||
labels: list[str],
|
||||
):
|
||||
"""
|
||||
update api tool provider
|
||||
"""
|
||||
if schema_type not in [member.value for member in ApiProviderSchemaType]:
|
||||
raise ValueError(f"invalid schema type {schema}")
|
||||
|
||||
provider_name = provider_name.strip()
|
||||
|
||||
# check if the provider exists
|
||||
|
|
@ -277,7 +273,7 @@ class ApiToolManageService:
|
|||
provider.icon = json.dumps(icon)
|
||||
provider.schema = schema
|
||||
provider.description = extra_info.get("description", "")
|
||||
provider.schema_type_str = ApiProviderSchemaType.OPENAPI
|
||||
provider.schema_type_str = schema_type
|
||||
provider.tools_str = json.dumps(jsonable_encoder(tool_bundles))
|
||||
provider.privacy_policy = privacy_policy
|
||||
provider.custom_disclaimer = custom_disclaimer
|
||||
|
|
@ -356,7 +352,7 @@ class ApiToolManageService:
|
|||
tool_name: str,
|
||||
credentials: dict,
|
||||
parameters: dict,
|
||||
schema_type: str,
|
||||
schema_type: ApiProviderSchemaType,
|
||||
schema: str,
|
||||
):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -171,7 +171,7 @@ class TestOAuthCallback:
|
|||
):
|
||||
mock_config.CONSOLE_WEB_URL = "http://localhost:3000"
|
||||
mock_get_providers.return_value = {"github": oauth_setup["provider"]}
|
||||
mock_generate_account.return_value = oauth_setup["account"]
|
||||
mock_generate_account.return_value = (oauth_setup["account"], True)
|
||||
mock_account_service.login.return_value = oauth_setup["token_pair"]
|
||||
|
||||
with app.test_request_context("/auth/oauth/github/callback?code=test_code"):
|
||||
|
|
@ -179,7 +179,7 @@ class TestOAuthCallback:
|
|||
|
||||
oauth_setup["provider"].get_access_token.assert_called_once_with("test_code")
|
||||
oauth_setup["provider"].get_user_info.assert_called_once_with("access_token")
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000")
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=true")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
|
|
@ -223,7 +223,7 @@ class TestOAuthCallback:
|
|||
# This documents actual behavior. See test_defensive_check_for_closed_account_status for details
|
||||
(
|
||||
AccountStatus.CLOSED.value,
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3000?oauth_new_user=false",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
@ -260,7 +260,7 @@ class TestOAuthCallback:
|
|||
account = MagicMock()
|
||||
account.status = account_status
|
||||
account.id = "123"
|
||||
mock_generate_account.return_value = account
|
||||
mock_generate_account.return_value = (account, False)
|
||||
|
||||
# Mock login for CLOSED status
|
||||
mock_token_pair = MagicMock()
|
||||
|
|
@ -296,7 +296,7 @@ class TestOAuthCallback:
|
|||
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = AccountStatus.PENDING
|
||||
mock_generate_account.return_value = mock_account
|
||||
mock_generate_account.return_value = (mock_account, False)
|
||||
|
||||
mock_token_pair = MagicMock()
|
||||
mock_token_pair.access_token = "jwt_access_token"
|
||||
|
|
@ -360,7 +360,7 @@ class TestOAuthCallback:
|
|||
closed_account.status = AccountStatus.CLOSED
|
||||
closed_account.id = "123"
|
||||
closed_account.name = "Closed Account"
|
||||
mock_generate_account.return_value = closed_account
|
||||
mock_generate_account.return_value = (closed_account, False)
|
||||
|
||||
# Mock successful login (current behavior)
|
||||
mock_token_pair = MagicMock()
|
||||
|
|
@ -374,7 +374,7 @@ class TestOAuthCallback:
|
|||
resource.get("github")
|
||||
|
||||
# Verify current behavior: login succeeds (this is NOT ideal)
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000")
|
||||
mock_redirect.assert_called_once_with("http://localhost:3000?oauth_new_user=false")
|
||||
mock_account_service.login.assert_called_once()
|
||||
|
||||
# Document expected behavior in comments:
|
||||
|
|
@ -458,8 +458,9 @@ class TestAccountGeneration:
|
|||
with pytest.raises(AccountRegisterError):
|
||||
_generate_account("github", user_info)
|
||||
else:
|
||||
result = _generate_account("github", user_info)
|
||||
result, oauth_new_user = _generate_account("github", user_info)
|
||||
assert result == mock_account
|
||||
assert oauth_new_user == should_create
|
||||
|
||||
if should_create:
|
||||
mock_register_service.register.assert_called_once_with(
|
||||
|
|
@ -490,9 +491,10 @@ class TestAccountGeneration:
|
|||
mock_tenant_service.create_tenant.return_value = mock_new_tenant
|
||||
|
||||
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
|
||||
result = _generate_account("github", user_info)
|
||||
result, oauth_new_user = _generate_account("github", user_info)
|
||||
|
||||
assert result == mock_account
|
||||
assert oauth_new_user is False
|
||||
mock_tenant_service.create_tenant.assert_called_once_with("Test User's Workspace")
|
||||
mock_tenant_service.create_tenant_member.assert_called_once_with(
|
||||
mock_new_tenant, mock_account, role="owner"
|
||||
|
|
|
|||
|
|
@ -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: "
|
||||
expected = "Image: "
|
||||
assert CleanProcessor.clean(text, process_rule) == expected
|
||||
|
||||
# Test both link and image preservation
|
||||
text = "[Link](https://link.com) and "
|
||||
expected = "[Link](https://link.com) and "
|
||||
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
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Tests for graph traversal components."""
|
||||
|
|
@ -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")
|
||||
|
|
@ -8,11 +8,12 @@ class TestCelerySSLConfiguration:
|
|||
"""Test suite for Celery SSL configuration."""
|
||||
|
||||
def test_get_celery_ssl_options_when_ssl_disabled(self):
|
||||
"""Test SSL options when REDIS_USE_SSL is False."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = False
|
||||
"""Test SSL options when BROKER_USE_SSL is False."""
|
||||
from configs import DifyConfig
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
dify_config = DifyConfig(CELERY_BROKER_URL="redis://localhost:6379/0")
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", dify_config):
|
||||
from extensions.ext_celery import _get_celery_ssl_options
|
||||
|
||||
result = _get_celery_ssl_options()
|
||||
|
|
@ -21,7 +22,6 @@ class TestCelerySSLConfiguration:
|
|||
def test_get_celery_ssl_options_when_broker_not_redis(self):
|
||||
"""Test SSL options when broker is not Redis."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "amqp://localhost:5672"
|
||||
|
||||
with patch("extensions.ext_celery.dify_config", mock_config):
|
||||
|
|
@ -33,7 +33,6 @@ class TestCelerySSLConfiguration:
|
|||
def test_get_celery_ssl_options_with_cert_none(self):
|
||||
"""Test SSL options with CERT_NONE requirement."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_NONE"
|
||||
mock_config.REDIS_SSL_CA_CERTS = None
|
||||
|
|
@ -53,7 +52,6 @@ class TestCelerySSLConfiguration:
|
|||
def test_get_celery_ssl_options_with_cert_required(self):
|
||||
"""Test SSL options with CERT_REQUIRED and certificates."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "rediss://localhost:6380/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_REQUIRED"
|
||||
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
|
||||
|
|
@ -73,7 +71,6 @@ class TestCelerySSLConfiguration:
|
|||
def test_get_celery_ssl_options_with_cert_optional(self):
|
||||
"""Test SSL options with CERT_OPTIONAL requirement."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "CERT_OPTIONAL"
|
||||
mock_config.REDIS_SSL_CA_CERTS = "/path/to/ca.crt"
|
||||
|
|
@ -91,7 +88,6 @@ class TestCelerySSLConfiguration:
|
|||
def test_get_celery_ssl_options_with_invalid_cert_reqs(self):
|
||||
"""Test SSL options with invalid cert requirement defaults to CERT_NONE."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.REDIS_SSL_CERT_REQS = "INVALID_VALUE"
|
||||
mock_config.REDIS_SSL_CA_CERTS = None
|
||||
|
|
@ -108,7 +104,6 @@ class TestCelerySSLConfiguration:
|
|||
def test_celery_init_applies_ssl_to_broker_and_backend(self):
|
||||
"""Test that SSL options are applied to both broker and backend when using Redis."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.REDIS_USE_SSL = True
|
||||
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
|
||||
mock_config.CELERY_BACKEND = "redis"
|
||||
mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Plan, UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { merge, noop } from 'es-toolkit/compat'
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
|
||||
// Avoid being mocked in tests
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { FC } from 'react'
|
|||
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
|
||||
import { RiCalendarLine } from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
'use client'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import Link from 'next/link'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ResponseError } from '@/service/fetch'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import {
|
|||
RiAddLine,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { ExternalDataTool } from '@/models/common'
|
|||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -7,19 +7,24 @@ export const jsonObjectWrap = {
|
|||
|
||||
export const jsonConfigPlaceHolder = JSON.stringify(
|
||||
{
|
||||
foo: {
|
||||
type: 'string',
|
||||
},
|
||||
bar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: {
|
||||
type: 'number',
|
||||
},
|
||||
type: 'object',
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'string',
|
||||
},
|
||||
bar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInpu
|
|||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import { jsonConfigPlaceHolder, jsonObjectWrap } from './config'
|
||||
import { jsonConfigPlaceHolder } from './config'
|
||||
import Field from './field'
|
||||
import TypeSelector from './type-select'
|
||||
|
||||
|
|
@ -78,13 +78,12 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
|
||||
const isSupportJSON = false
|
||||
const jsonSchemaStr = useMemo(() => {
|
||||
const isJsonObject = type === InputVarType.jsonObject
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2)
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
|
|
@ -129,13 +128,14 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
}, [])
|
||||
|
||||
const handleJSONSchemaChange = useCallback((value: string) => {
|
||||
const isEmpty = value == null || value.trim() === ''
|
||||
if (isEmpty) {
|
||||
handlePayloadChange('json_schema')(undefined)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const v = JSON.parse(value)
|
||||
const res = {
|
||||
...jsonObjectWrap,
|
||||
properties: v,
|
||||
}
|
||||
handlePayloadChange('json_schema')(JSON.stringify(res, null, 2))
|
||||
handlePayloadChange('json_schema')(JSON.stringify(v, null, 2))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
|
|
@ -175,7 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
},
|
||||
]
|
||||
: []),
|
||||
...((!isBasicApp && isSupportJSON)
|
||||
...((!isBasicApp)
|
||||
? [{
|
||||
name: t('variableConfig.json', { ns: 'appDebug' }),
|
||||
value: InputVarType.jsonObject,
|
||||
|
|
@ -233,7 +233,28 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
|
||||
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 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
|
||||
? undefined
|
||||
: {
|
||||
|
|
@ -250,7 +271,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
return
|
||||
}
|
||||
if (isStringInput || type === InputVarType.number) {
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.select) {
|
||||
if (options?.length === 0) {
|
||||
|
|
@ -270,7 +291,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
Toast.notify({ type: 'error', message: t('variableConfig.errorMsg.optionRepeat', { ns: 'appDebug' }) })
|
||||
return
|
||||
}
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
if (tempPayload.allowed_file_types?.length === 0) {
|
||||
|
|
@ -283,10 +304,26 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
|||
Toast.notify({ type: 'error', message: errorMessages })
|
||||
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 {
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
onConfirm(payloadToSave, moreInfo)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { FC } from 'react'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Member } from '@/models/common'
|
|||
import type { DataSet } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import type { ModelAndParameter } from '../types'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
|
||||
export type DebugWithMultipleModelContextType = {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import type {
|
|||
OnSend,
|
||||
TextGenerationConfig,
|
||||
} from '@/app/components/base/text-generation/types'
|
||||
import { cloneDeep, noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { memo } from 'react'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
import { TransferMethod } from '@/app/components/base/chat/types'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
ChatConfig,
|
||||
ChatItem,
|
||||
} from '@/app/components/base/chat/types'
|
||||
import { cloneDeep } from 'es-toolkit/compat'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import {
|
|||
RiSparklingFill,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { cloneDeep, noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { cloneDeep } from 'es-toolkit/object'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ChatPromptConfig, CompletionPromptConfig, ConversationHistoriesRole, PromptItem } from '@/models/debug'
|
||||
import { clone } from 'es-toolkit/compat'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
import { useState } from 'react'
|
||||
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock, PRE_PROMPT_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants'
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ import type {
|
|||
import type { ModelConfig as BackendModelConfig, UserInputFormItem, VisionSettings } from '@/types/app'
|
||||
import { CodeBracketIcon } from '@heroicons/react/20/solid'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone, isEqual } from 'es-toolkit/compat'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import { produce } from 'immer'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type {
|
|||
CodeBasedExtensionItem,
|
||||
ExternalDataTool,
|
||||
} from '@/models/common'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
|
|
|||
|
|
@ -14,13 +14,6 @@ vi.mock('ahooks', () => ({
|
|||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({ isCurrentWorkspaceEditor: true }),
|
||||
}))
|
||||
vi.mock('use-context-selector', async () => {
|
||||
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
|
||||
return {
|
||||
...actual,
|
||||
useContext: () => ({ hasEditPermission: true }),
|
||||
}
|
||||
})
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['Recommended', vi.fn()],
|
||||
}))
|
||||
|
|
@ -119,6 +112,7 @@ describe('Apps', () => {
|
|||
fireEvent.click(screen.getAllByTestId('app-card')[0])
|
||||
expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows no template message when list is empty', () => {
|
||||
mockUseExploreAppList.mockReturnValueOnce({
|
||||
data: { allList: [], categories: [] },
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { useRouter } from 'next/navigation'
|
|||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import AppTypeSelector from '@/app/components/app/type-selector'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
|
|
@ -19,7 +18,6 @@ import CreateAppModal from '@/app/components/explore/create-app-modal'
|
|||
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { DSLImportMode } from '@/models/app'
|
||||
import { importDSL } from '@/service/apps'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
|
|
@ -47,7 +45,6 @@ const Apps = ({
|
|||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { push } = useRouter()
|
||||
const { hasEditPermission } = useContext(ExploreContext)
|
||||
const allCategoriesEn = AppCategories.RECOMMENDED
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
|
@ -214,7 +211,7 @@ const Apps = ({
|
|||
<AppCard
|
||||
key={app.app_id}
|
||||
app={app}
|
||||
canCreate={hasEditPermission}
|
||||
canCreate={isCurrentWorkspaceEditor}
|
||||
onCreate={() => {
|
||||
setCurrApp(app)
|
||||
setIsShowCreateModal(true)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import type { MouseEventHandler } from 'react'
|
||||
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
|||
import type { App } from '@/types/app'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { omit } from 'es-toolkit/compat'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { RiCloseLine, RiEditFill } from '@remixicon/react'
|
|||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { get, noop } from 'es-toolkit/compat'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { RenderOptions } from '@testing-library/react'
|
|||
import type { Mock, MockedFunction } from 'vitest'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { useModalContext as actualUseModalContext } from '@/context/modal-context'
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type { App } from '@/types/app'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useDebounce } from 'ahooks'
|
|||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { omit } from 'es-toolkit/compat'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import type { FC } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
|
||||
import { flatten, uniq } from 'es-toolkit/compat'
|
||||
import { uniq } from 'es-toolkit/array'
|
||||
import { flatten } from 'es-toolkit/compat'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Area } from 'react-easy-crop'
|
|||
import type { OnImageInput } from './ImageInput'
|
||||
import type { AppIconType, ImageFile } from '@/types/app'
|
||||
import { RiImageCircleAiLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
AppMeta,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
|
||||
export type ChatWithHistoryContextValue = {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import type {
|
|||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
useCallback,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import type { InputForm } from './type'
|
|||
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
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 { useParams, usePathname } from 'next/navigation'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
AppMeta,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
|
||||
export type EmbeddedChatbotContextValue = {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
useCallback,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Day } from '../types'
|
|||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { IS_PROD } from '@/config'
|
||||
import tz from '@/utils/timezone.json'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
|
@ -131,7 +132,7 @@ export type ToDayjsOptions = {
|
|||
}
|
||||
|
||||
const warnParseFailure = (value: string) => {
|
||||
if (process.env.NODE_ENV !== 'production')
|
||||
if (!IS_PROD)
|
||||
console.warn('[TimePicker] Failed to parse time value', value)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { RiAlertLine, RiBugLine } from '@remixicon/react'
|
|||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { IS_DEV } from '@/config'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ErrorBoundaryState = {
|
||||
|
|
@ -54,7 +55,7 @@ class ErrorBoundaryInner extends React.Component<
|
|||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (IS_DEV) {
|
||||
console.error('ErrorBoundary caught an error:', error)
|
||||
console.error('Error Info:', errorInfo)
|
||||
}
|
||||
|
|
@ -262,13 +263,13 @@ export function withErrorBoundary<P extends object>(
|
|||
// Simple error fallback component
|
||||
export const ErrorFallback: React.FC<{
|
||||
error: Error
|
||||
resetErrorBoundary: () => void
|
||||
}> = ({ error, resetErrorBoundary }) => {
|
||||
resetErrorBoundaryAction: () => void
|
||||
}> = ({ error, resetErrorBoundaryAction }) => {
|
||||
return (
|
||||
<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>
|
||||
<p className="mb-4 text-center text-red-600">{error.message}</p>
|
||||
<Button onClick={resetErrorBoundary} size="small">
|
||||
<Button onClick={resetErrorBoundaryAction} size="small">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { InputVar } from '@/app/components/workflow/types'
|
|||
import type { PromptVariable } from '@/models/debug'
|
||||
import { RiAddLine, RiAsterisk, RiCloseLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ChangeEvent, FC } from 'react'
|
|||
import type { CodeBasedExtensionItem } from '@/models/common'
|
||||
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ClipboardEvent } from 'react'
|
|||
import type { FileEntity } from './types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FC } from '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 * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type {
|
||||
FileEntity,
|
||||
} from './types'
|
||||
import { isEqual } from 'es-toolkit/compat'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
|
||||
import { RiCloseLargeLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type IModal = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
|||
import { headers } from 'next/headers'
|
||||
import Script from 'next/script'
|
||||
import * as React from 'react'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import { IS_CE_EDITION, IS_PROD } from '@/config'
|
||||
|
||||
export enum GaType {
|
||||
admin = 'admin',
|
||||
|
|
@ -32,7 +32,7 @@ const GA: FC<IGAProps> = ({
|
|||
if (IS_CE_EDITION)
|
||||
return null
|
||||
|
||||
const cspHeader = process.env.NODE_ENV === 'production'
|
||||
const cspHeader = IS_PROD
|
||||
? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy')
|
||||
: null
|
||||
const nonce = extractNonceFromCSP(cspHeader)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FC } from '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 * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { VariantProps } from 'class-variance-authority'
|
|||
import type { ChangeEventHandler, CSSProperties, FocusEventHandler } from 'react'
|
||||
import { RiCloseCircleFill, RiErrorWarningLine, RiSearchLine } from '@remixicon/react'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Fragment } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
// https://headlessui.com/react/dialog
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
IPaginationProps,
|
||||
PageButtonProps,
|
||||
} from './type'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import usePagination from './hook'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ContextBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ContextBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { HistoryBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
useCallback,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { HistoryBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { HtmlContentProps } from '@/app/components/base/popover'
|
|||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import { RiAddLine, RiPriceTag3Line } from '@remixicon/react'
|
||||
import { useUnmount } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import type { Tag } from '@/app/components/base/tag-management/constant'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ReactNode } from '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 Toast, { ToastProvider, useToastContext } from '.'
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
RiErrorWarningFill,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { z } from 'zod'
|
||||
import withValidation from '.'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { headers } from 'next/headers'
|
||||
import Script from 'next/script'
|
||||
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 () => {
|
||||
if (IS_CE_EDITION || !ZENDESK_WIDGET_KEY)
|
||||
return null
|
||||
|
||||
const nonce = process.env.NODE_ENV === 'production' ? (await headers()).get('x-nonce') ?? '' : ''
|
||||
const nonce = IS_PROD ? (await headers()).get('x-nonce') ?? '' : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { IndexingStatusResponse } from '@/models/datasets'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useReducer } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
RiArrowLeftLine,
|
||||
RiSearchEyeLine,
|
||||
} from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { FC } from 'react'
|
||||
import type { ChunkingMode, FileItem } from '@/models/datasets'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Drawer from './drawer'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { FC } from 'react'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useCountDown } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -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 { ChildChunkDetail, SegmentDetailModel, SegmentUpdater } from '@/models/datasets'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { NotionPage } from '@/models/common'
|
||||
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
|
||||
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 { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import {
|
|||
RiGlobalLine,
|
||||
} from '@remixicon/react'
|
||||
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 * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
RiPlayCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import { useBoolean, useDebounceFn } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiArrowLeftLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { MouseEventHandler } from 'react'
|
|||
import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { AppIconType } from '@/types/app'
|
||||
import { RiCloseLine, RiCommandLine, RiCornerDownLeftLine } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FC } from 'react'
|
||||
import type { ApiBasedExtension } from '@/models/common'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
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 { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { FC } from 'react'
|
|||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { RoleKey } from './role-selector'
|
|||
import type { InvitationResult } from '@/models/common'
|
||||
import { RiCloseLine, RiErrorWarningFill } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactMultiEmail } from 'react-multi-email'
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue