Merge branch 'main' into feat/memory-orchestration-fed

This commit is contained in:
zxhlyh 2025-11-06 15:50:38 +08:00
commit 2f0076166a
109 changed files with 2939 additions and 711 deletions

View File

@ -371,6 +371,12 @@ UPLOAD_IMAGE_FILE_SIZE_LIMIT=10
UPLOAD_VIDEO_FILE_SIZE_LIMIT=100 UPLOAD_VIDEO_FILE_SIZE_LIMIT=100
UPLOAD_AUDIO_FILE_SIZE_LIMIT=50 UPLOAD_AUDIO_FILE_SIZE_LIMIT=50
# Comma-separated list of file extensions blocked from upload for security reasons.
# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll).
# Empty by default to allow all file types.
# Recommended: exe,bat,cmd,com,scr,vbs,ps1,msi,dll
UPLOAD_FILE_EXTENSION_BLACKLIST=
# Model configuration # Model configuration
MULTIMODAL_SEND_FORMAT=base64 MULTIMODAL_SEND_FORMAT=base64
PROMPT_GENERATION_MAX_TOKENS=512 PROMPT_GENERATION_MAX_TOKENS=512

View File

@ -1601,7 +1601,7 @@ def transform_datasource_credentials():
"integration_secret": api_key, "integration_secret": api_key,
} }
datasource_provider = DatasourceProvider( datasource_provider = DatasourceProvider(
provider="jina", provider="jinareader",
tenant_id=tenant_id, tenant_id=tenant_id,
plugin_id=jina_plugin_id, plugin_id=jina_plugin_id,
auth_type=api_key_credential_type.value, auth_type=api_key_credential_type.value,

View File

@ -331,6 +331,31 @@ class FileUploadConfig(BaseSettings):
default=10, default=10,
) )
inner_UPLOAD_FILE_EXTENSION_BLACKLIST: str = Field(
description=(
"Comma-separated list of file extensions that are blocked from upload. "
"Extensions should be lowercase without dots (e.g., 'exe,bat,sh,dll'). "
"Empty by default to allow all file types."
),
validation_alias=AliasChoices("UPLOAD_FILE_EXTENSION_BLACKLIST"),
default="",
)
@computed_field # type: ignore[misc]
@property
def UPLOAD_FILE_EXTENSION_BLACKLIST(self) -> set[str]:
"""
Parse and return the blacklist as a set of lowercase extensions.
Returns an empty set if no blacklist is configured.
"""
if not self.inner_UPLOAD_FILE_EXTENSION_BLACKLIST:
return set()
return {
ext.strip().lower().strip(".")
for ext in self.inner_UPLOAD_FILE_EXTENSION_BLACKLIST.split(",")
if ext.strip()
}
class HttpConfig(BaseSettings): class HttpConfig(BaseSettings):
""" """

View File

@ -22,6 +22,11 @@ class WeaviateConfig(BaseSettings):
default=True, default=True,
) )
WEAVIATE_GRPC_ENDPOINT: str | None = Field(
description="URL of the Weaviate gRPC server (e.g., 'grpc://localhost:50051' or 'grpcs://weaviate.example.com:443')",
default=None,
)
WEAVIATE_BATCH_SIZE: PositiveInt = Field( WEAVIATE_BATCH_SIZE: PositiveInt = Field(
description="Number of objects to be processed in a single batch operation (default is 100)", description="Number of objects to be processed in a single batch operation (default is 100)",
default=100, default=100,

View File

@ -25,6 +25,12 @@ class UnsupportedFileTypeError(BaseHTTPException):
code = 415 code = 415
class BlockedFileExtensionError(BaseHTTPException):
error_code = "file_extension_blocked"
description = "The file extension is blocked for security reasons."
code = 400
class TooManyFilesError(BaseHTTPException): class TooManyFilesError(BaseHTTPException):
error_code = "too_many_files" error_code = "too_many_files"
description = "Only one file is allowed." description = "Only one file is allowed."

View File

@ -102,7 +102,18 @@ class DraftWorkflowApi(Resource):
}, },
) )
) )
@api.response(200, "Draft workflow synced successfully", workflow_fields) @api.response(
200,
"Draft workflow synced successfully",
api.model(
"SyncDraftWorkflowResponse",
{
"result": fields.String,
"hash": fields.String,
"updated_at": fields.String,
},
),
)
@api.response(400, "Invalid workflow configuration") @api.response(400, "Invalid workflow configuration")
@api.response(403, "Permission denied") @api.response(403, "Permission denied")
@edit_permission_required @edit_permission_required

View File

@ -8,6 +8,7 @@ import services
from configs import dify_config from configs import dify_config
from constants import DOCUMENT_EXTENSIONS from constants import DOCUMENT_EXTENSIONS
from controllers.common.errors import ( from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError, FilenameNotExistsError,
FileTooLargeError, FileTooLargeError,
NoFileUploadedError, NoFileUploadedError,
@ -83,6 +84,8 @@ class FileApi(Resource):
raise FileTooLargeError(file_too_large_error.description) raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError: except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError() raise UnsupportedFileTypeError()
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
raise BlockedFileExtensionError(blocked_extension_error.description)
return upload_file, 201 return upload_file, 201

View File

@ -67,6 +67,7 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe
kwargs["app_model"] = app_model kwargs["app_model"] = app_model
# If caller needs end-user context, attach EndUser to current_user
if fetch_user_arg: if fetch_user_arg:
if fetch_user_arg.fetch_from == WhereisUserArg.QUERY: if fetch_user_arg.fetch_from == WhereisUserArg.QUERY:
user_id = request.args.get("user") user_id = request.args.get("user")
@ -75,7 +76,6 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe
elif fetch_user_arg.fetch_from == WhereisUserArg.FORM: elif fetch_user_arg.fetch_from == WhereisUserArg.FORM:
user_id = request.form.get("user") user_id = request.form.get("user")
else: else:
# use default-user
user_id = None user_id = None
if not user_id and fetch_user_arg.required: if not user_id and fetch_user_arg.required:
@ -90,6 +90,28 @@ def validate_app_token(view: Callable[P, R] | None = None, *, fetch_user_arg: Fe
# Set EndUser as current logged-in user for flask_login.current_user # Set EndUser as current logged-in user for flask_login.current_user
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore
else:
# For service API without end-user context, ensure an Account is logged in
# so services relying on current_account_with_tenant() work correctly.
tenant_owner_info = (
db.session.query(Tenant, Account)
.join(TenantAccountJoin, Tenant.id == TenantAccountJoin.tenant_id)
.join(Account, TenantAccountJoin.account_id == Account.id)
.where(
Tenant.id == app_model.tenant_id,
TenantAccountJoin.role == "owner",
Tenant.status == TenantStatus.NORMAL,
)
.one_or_none()
)
if tenant_owner_info:
tenant_model, account = tenant_owner_info
account.current_tenant = tenant_model
current_app.login_manager._update_request_context_with_user(account) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore
else:
raise Unauthorized("Tenant owner account not found or tenant is not active.")
return view_func(*args, **kwargs) return view_func(*args, **kwargs)

View File

@ -140,7 +140,27 @@ class MessageCycleManager:
if not self._application_generate_entity.app_config.additional_features: if not self._application_generate_entity.app_config.additional_features:
raise ValueError("Additional features not found") raise ValueError("Additional features not found")
if self._application_generate_entity.app_config.additional_features.show_retrieve_source: if self._application_generate_entity.app_config.additional_features.show_retrieve_source:
self._task_state.metadata.retriever_resources = event.retriever_resources merged_resources = [r for r in self._task_state.metadata.retriever_resources or [] if r]
existing_ids = {(r.dataset_id, r.document_id) for r in merged_resources if r.dataset_id and r.document_id}
# Add new unique resources from the event
for resource in event.retriever_resources or []:
if not resource:
continue
is_duplicate = (
resource.dataset_id
and resource.document_id
and (resource.dataset_id, resource.document_id) in existing_ids
)
if not is_duplicate:
merged_resources.append(resource)
for i, resource in enumerate(merged_resources, 1):
resource.position = i
self._task_state.metadata.retriever_resources = merged_resources
def message_file_to_stream_response(self, event: QueueMessageFileEvent) -> MessageFileStreamResponse | None: def message_file_to_stream_response(self, event: QueueMessageFileEvent) -> MessageFileStreamResponse | None:
""" """

View File

@ -6,10 +6,7 @@ from core.helper.code_executor.template_transformer import TemplateTransformer
class NodeJsTemplateTransformer(TemplateTransformer): class NodeJsTemplateTransformer(TemplateTransformer):
@classmethod @classmethod
def get_runner_script(cls) -> str: def get_runner_script(cls) -> str:
runner_script = dedent( runner_script = dedent(f""" {cls._code_placeholder}
f"""
// declare main function
{cls._code_placeholder}
// decode and prepare input object // decode and prepare input object
var inputs_obj = JSON.parse(Buffer.from('{cls._inputs_placeholder}', 'base64').toString('utf-8')) var inputs_obj = JSON.parse(Buffer.from('{cls._inputs_placeholder}', 'base64').toString('utf-8'))
@ -21,6 +18,5 @@ class NodeJsTemplateTransformer(TemplateTransformer):
var output_json = JSON.stringify(output_obj) var output_json = JSON.stringify(output_obj)
var result = `<<RESULT>>${{output_json}}<<RESULT>>` var result = `<<RESULT>>${{output_json}}<<RESULT>>`
console.log(result) console.log(result)
""" """)
)
return runner_script return runner_script

View File

@ -6,9 +6,7 @@ from core.helper.code_executor.template_transformer import TemplateTransformer
class Python3TemplateTransformer(TemplateTransformer): class Python3TemplateTransformer(TemplateTransformer):
@classmethod @classmethod
def get_runner_script(cls) -> str: def get_runner_script(cls) -> str:
runner_script = dedent(f""" runner_script = dedent(f""" {cls._code_placeholder}
# declare main function
{cls._code_placeholder}
import json import json
from base64 import b64decode from base64 import b64decode

View File

@ -39,11 +39,13 @@ class WeaviateConfig(BaseModel):
Attributes: Attributes:
endpoint: Weaviate server endpoint URL endpoint: Weaviate server endpoint URL
grpc_endpoint: Optional Weaviate gRPC server endpoint URL
api_key: Optional API key for authentication api_key: Optional API key for authentication
batch_size: Number of objects to batch per insert operation batch_size: Number of objects to batch per insert operation
""" """
endpoint: str endpoint: str
grpc_endpoint: str | None = None
api_key: str | None = None api_key: str | None = None
batch_size: int = 100 batch_size: int = 100
@ -88,9 +90,22 @@ class WeaviateVector(BaseVector):
http_secure = p.scheme == "https" http_secure = p.scheme == "https"
http_port = p.port or (443 if http_secure else 80) http_port = p.port or (443 if http_secure else 80)
grpc_host = host # Parse gRPC configuration
grpc_secure = http_secure if config.grpc_endpoint:
grpc_port = 443 if grpc_secure else 50051 # Urls without scheme won't be parsed correctly in some python verions,
# see https://bugs.python.org/issue27657
grpc_endpoint_with_scheme = (
config.grpc_endpoint if "://" in config.grpc_endpoint else f"grpc://{config.grpc_endpoint}"
)
grpc_p = urlparse(grpc_endpoint_with_scheme)
grpc_host = grpc_p.hostname or "localhost"
grpc_port = grpc_p.port or (443 if grpc_p.scheme == "grpcs" else 50051)
grpc_secure = grpc_p.scheme == "grpcs"
else:
# Infer from HTTP endpoint as fallback
grpc_host = host
grpc_secure = http_secure
grpc_port = 443 if grpc_secure else 50051
client = weaviate.connect_to_custom( client = weaviate.connect_to_custom(
http_host=host, http_host=host,
@ -432,6 +447,7 @@ class WeaviateVectorFactory(AbstractVectorFactory):
collection_name=collection_name, collection_name=collection_name,
config=WeaviateConfig( config=WeaviateConfig(
endpoint=dify_config.WEAVIATE_ENDPOINT or "", endpoint=dify_config.WEAVIATE_ENDPOINT or "",
grpc_endpoint=dify_config.WEAVIATE_GRPC_ENDPOINT or "",
api_key=dify_config.WEAVIATE_API_KEY, api_key=dify_config.WEAVIATE_API_KEY,
batch_size=dify_config.WEAVIATE_BATCH_SIZE, batch_size=dify_config.WEAVIATE_BATCH_SIZE,
), ),

View File

@ -210,12 +210,13 @@ class Tool(ABC):
meta=meta, meta=meta,
) )
def create_json_message(self, object: dict) -> ToolInvokeMessage: def create_json_message(self, object: dict, suppress_output: bool = False) -> ToolInvokeMessage:
""" """
create a json message create a json message
""" """
return ToolInvokeMessage( return ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.JSON, message=ToolInvokeMessage.JsonMessage(json_object=object) type=ToolInvokeMessage.MessageType.JSON,
message=ToolInvokeMessage.JsonMessage(json_object=object, suppress_output=suppress_output),
) )
def create_variable_message( def create_variable_message(

View File

@ -129,6 +129,7 @@ class ToolInvokeMessage(BaseModel):
class JsonMessage(BaseModel): class JsonMessage(BaseModel):
json_object: dict json_object: dict
suppress_output: bool = Field(default=False, description="Whether to suppress JSON output in result string")
class BlobMessage(BaseModel): class BlobMessage(BaseModel):
blob: bytes blob: bytes

View File

@ -245,6 +245,9 @@ class ToolEngine:
+ "you do not need to create it, just tell the user to check it now." + "you do not need to create it, just tell the user to check it now."
) )
elif response.type == ToolInvokeMessage.MessageType.JSON: elif response.type == ToolInvokeMessage.MessageType.JSON:
json_message = cast(ToolInvokeMessage.JsonMessage, response.message)
if json_message.suppress_output:
continue
json_parts.append( json_parts.append(
json.dumps( json.dumps(
safe_json_value(cast(ToolInvokeMessage.JsonMessage, response.message).json_object), safe_json_value(cast(ToolInvokeMessage.JsonMessage, response.message).json_object),

View File

@ -117,7 +117,7 @@ class WorkflowTool(Tool):
self._latest_usage = self._derive_usage_from_result(data) self._latest_usage = self._derive_usage_from_result(data)
yield self.create_text_message(json.dumps(outputs, ensure_ascii=False)) yield self.create_text_message(json.dumps(outputs, ensure_ascii=False))
yield self.create_json_message(outputs) yield self.create_json_message(outputs, suppress_output=True)
@property @property
def latest_usage(self) -> LLMUsage: def latest_usage(self) -> LLMUsage:

View File

@ -11,3 +11,7 @@ class FileTooLargeError(BaseServiceError):
class UnsupportedFileTypeError(BaseServiceError): class UnsupportedFileTypeError(BaseServiceError):
pass pass
class BlockedFileExtensionError(BaseServiceError):
description = "File extension '{extension}' is not allowed for security reasons"

View File

@ -23,7 +23,7 @@ from models import Account
from models.enums import CreatorUserRole from models.enums import CreatorUserRole
from models.model import EndUser, UploadFile from models.model import EndUser, UploadFile
from .errors.file import FileTooLargeError, UnsupportedFileTypeError from .errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
PREVIEW_WORDS_LIMIT = 3000 PREVIEW_WORDS_LIMIT = 3000
@ -59,6 +59,10 @@ class FileService:
if len(filename) > 200: if len(filename) > 200:
filename = filename.split(".")[0][:200] + "." + extension filename = filename.split(".")[0][:200] + "." + extension
# check if extension is in blacklist
if extension and extension in dify_config.UPLOAD_FILE_EXTENSION_BLACKLIST:
raise BlockedFileExtensionError(f"File extension '.{extension}' is not allowed for security reasons")
if source == "datasets" and extension not in DOCUMENT_EXTENSIONS: if source == "datasets" and extension not in DOCUMENT_EXTENSIONS:
raise UnsupportedFileTypeError() raise UnsupportedFileTypeError()

View File

@ -11,7 +11,7 @@ from configs import dify_config
from models import Account, Tenant from models import Account, Tenant
from models.enums import CreatorUserRole from models.enums import CreatorUserRole
from models.model import EndUser, UploadFile from models.model import EndUser, UploadFile
from services.errors.file import FileTooLargeError, UnsupportedFileTypeError from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
from services.file_service import FileService from services.file_service import FileService
@ -943,3 +943,150 @@ class TestFileService:
# Should have the signed URL when source_url is empty # Should have the signed URL when source_url is empty
assert upload_file2.source_url == "https://example.com/signed-url" assert upload_file2.source_url == "https://example.com/signed-url"
# Test file extension blacklist
def test_upload_file_blocked_extension(
self, db_session_with_containers, engine, mock_external_service_dependencies
):
"""
Test file upload with blocked extension.
"""
fake = Faker()
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
# Mock blacklist configuration by patching the inner field
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat,sh"):
filename = "malware.exe"
content = b"test content"
mimetype = "application/x-msdownload"
with pytest.raises(BlockedFileExtensionError):
FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
def test_upload_file_blocked_extension_case_insensitive(
self, db_session_with_containers, engine, mock_external_service_dependencies
):
"""
Test file upload with blocked extension (case insensitive).
"""
fake = Faker()
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
# Mock blacklist configuration by patching the inner field
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat"):
# Test with uppercase extension
filename = "malware.EXE"
content = b"test content"
mimetype = "application/x-msdownload"
with pytest.raises(BlockedFileExtensionError):
FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
def test_upload_file_not_in_blacklist(self, db_session_with_containers, engine, mock_external_service_dependencies):
"""
Test file upload with extension not in blacklist.
"""
fake = Faker()
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
# Mock blacklist configuration by patching the inner field
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat,sh"):
filename = "document.pdf"
content = b"test content"
mimetype = "application/pdf"
upload_file = FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
assert upload_file is not None
assert upload_file.name == filename
assert upload_file.extension == "pdf"
def test_upload_file_empty_blacklist(self, db_session_with_containers, engine, mock_external_service_dependencies):
"""
Test file upload with empty blacklist (default behavior).
"""
fake = Faker()
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
# Mock empty blacklist configuration by patching the inner field
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", ""):
# Should allow all file types when blacklist is empty
filename = "script.sh"
content = b"#!/bin/bash\necho test"
mimetype = "application/x-sh"
upload_file = FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
assert upload_file is not None
assert upload_file.extension == "sh"
def test_upload_file_multiple_blocked_extensions(
self, db_session_with_containers, engine, mock_external_service_dependencies
):
"""
Test file upload with multiple blocked extensions.
"""
fake = Faker()
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
# Mock blacklist with multiple extensions by patching the inner field
blacklist_str = "exe,bat,cmd,com,scr,vbs,ps1,msi,dll"
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", blacklist_str):
for ext in blacklist_str.split(","):
filename = f"malware.{ext}"
content = b"test content"
mimetype = "application/octet-stream"
with pytest.raises(BlockedFileExtensionError):
FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
def test_upload_file_no_extension_with_blacklist(
self, db_session_with_containers, engine, mock_external_service_dependencies
):
"""
Test file upload with no extension when blacklist is configured.
"""
fake = Faker()
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
# Mock blacklist configuration by patching the inner field
with patch.object(dify_config, "inner_UPLOAD_FILE_EXTENSION_BLACKLIST", "exe,bat"):
# Files with no extension should not be blocked
filename = "README"
content = b"test content"
mimetype = "text/plain"
upload_file = FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
assert upload_file is not None
assert upload_file.extension == ""

View File

@ -0,0 +1,12 @@
from core.helper.code_executor.javascript.javascript_code_provider import JavascriptCodeProvider
from core.helper.code_executor.javascript.javascript_transformer import NodeJsTemplateTransformer
def test_get_runner_script():
code = JavascriptCodeProvider.get_default_code()
inputs = {"arg1": "hello, ", "arg2": "world!"}
script = NodeJsTemplateTransformer.assemble_runner_script(code, inputs)
script_lines = script.splitlines()
code_lines = code.splitlines()
# Check that the first lines of script are exactly the same as code
assert script_lines[: len(code_lines)] == code_lines

View File

@ -0,0 +1,12 @@
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
from core.helper.code_executor.python3.python3_transformer import Python3TemplateTransformer
def test_get_runner_script():
code = Python3CodeProvider.get_default_code()
inputs = {"arg1": "hello, ", "arg2": "world!"}
script = Python3TemplateTransformer.assemble_runner_script(code, inputs)
script_lines = script.splitlines()
code_lines = code.splitlines()
# Check that the first lines of script are exactly the same as code
assert script_lines[: len(code_lines)] == code_lines

View File

@ -492,6 +492,7 @@ VECTOR_INDEX_NAME_PREFIX=Vector_index
# The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`. # The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`.
WEAVIATE_ENDPOINT=http://weaviate:8080 WEAVIATE_ENDPOINT=http://weaviate:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051
# The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`. # The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`.
QDRANT_URL=http://qdrant:6333 QDRANT_URL=http://qdrant:6333
@ -762,6 +763,12 @@ UPLOAD_FILE_SIZE_LIMIT=15
# The maximum number of files that can be uploaded at a time, default 5. # The maximum number of files that can be uploaded at a time, default 5.
UPLOAD_FILE_BATCH_LIMIT=5 UPLOAD_FILE_BATCH_LIMIT=5
# Comma-separated list of file extensions blocked from upload for security reasons.
# Extensions should be lowercase without dots (e.g., exe,bat,sh,dll).
# Empty by default to allow all file types.
# Recommended: exe,bat,cmd,com,scr,vbs,ps1,msi,dll
UPLOAD_FILE_EXTENSION_BLACKLIST=
# ETL type, support: `dify`, `Unstructured` # ETL type, support: `dify`, `Unstructured`
# `dify` Dify's proprietary file extraction scheme # `dify` Dify's proprietary file extraction scheme
# `Unstructured` Unstructured.io file extraction scheme # `Unstructured` Unstructured.io file extraction scheme

View File

@ -157,6 +157,7 @@ x-shared-env: &shared-api-worker-env
VECTOR_INDEX_NAME_PREFIX: ${VECTOR_INDEX_NAME_PREFIX:-Vector_index} VECTOR_INDEX_NAME_PREFIX: ${VECTOR_INDEX_NAME_PREFIX:-Vector_index}
WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080} WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080}
WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih} WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih}
WEAVIATE_GRPC_ENDPOINT: ${WEAVIATE_GRPC_ENDPOINT:-grpc://weaviate:50051}
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333} QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456} QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456}
QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20} QDRANT_CLIENT_TIMEOUT: ${QDRANT_CLIENT_TIMEOUT:-20}
@ -353,6 +354,7 @@ x-shared-env: &shared-api-worker-env
CLICKZETTA_VECTOR_DISTANCE_FUNCTION: ${CLICKZETTA_VECTOR_DISTANCE_FUNCTION:-cosine_distance} CLICKZETTA_VECTOR_DISTANCE_FUNCTION: ${CLICKZETTA_VECTOR_DISTANCE_FUNCTION:-cosine_distance}
UPLOAD_FILE_SIZE_LIMIT: ${UPLOAD_FILE_SIZE_LIMIT:-15} UPLOAD_FILE_SIZE_LIMIT: ${UPLOAD_FILE_SIZE_LIMIT:-15}
UPLOAD_FILE_BATCH_LIMIT: ${UPLOAD_FILE_BATCH_LIMIT:-5} UPLOAD_FILE_BATCH_LIMIT: ${UPLOAD_FILE_BATCH_LIMIT:-5}
UPLOAD_FILE_EXTENSION_BLACKLIST: ${UPLOAD_FILE_EXTENSION_BLACKLIST:-}
ETL_TYPE: ${ETL_TYPE:-dify} ETL_TYPE: ${ETL_TYPE:-dify}
UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-} UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-}
UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-} UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-}

View File

@ -759,4 +759,104 @@ export default translation`
expect(result).not.toContain('Zbuduj inteligentnego agenta') expect(result).not.toContain('Zbuduj inteligentnego agenta')
}) })
}) })
describe('Performance and Scalability', () => {
it('should handle large translation files efficiently', async () => {
// Create a large translation file with 1000 keys
const largeContent = `const translation = {
${Array.from({ length: 1000 }, (_, i) => ` key${i}: 'value${i}',`).join('\n')}
}
export default translation`
fs.writeFileSync(path.join(testEnDir, 'large.ts'), largeContent)
const startTime = Date.now()
const keys = await getKeysFromLanguage('en-US')
const endTime = Date.now()
expect(keys.length).toBe(1000)
expect(endTime - startTime).toBeLessThan(1000) // Should complete in under 1 second
})
it('should handle multiple translation files concurrently', async () => {
// Create multiple files
for (let i = 0; i < 10; i++) {
const content = `const translation = {
key${i}: 'value${i}',
nested${i}: {
subkey: 'subvalue'
}
}
export default translation`
fs.writeFileSync(path.join(testEnDir, `file${i}.ts`), content)
}
const startTime = Date.now()
const keys = await getKeysFromLanguage('en-US')
const endTime = Date.now()
expect(keys.length).toBe(20) // 10 files * 2 keys each
expect(endTime - startTime).toBeLessThan(500)
})
})
describe('Unicode and Internationalization', () => {
it('should handle Unicode characters in keys and values', async () => {
const unicodeContent = `const translation = {
'中文键': '中文值',
'العربية': 'قيمة',
'emoji_😀': 'value with emoji 🎉',
'mixed_中文_English': 'mixed value'
}
export default translation`
fs.writeFileSync(path.join(testEnDir, 'unicode.ts'), unicodeContent)
const keys = await getKeysFromLanguage('en-US')
expect(keys).toContain('unicode.中文键')
expect(keys).toContain('unicode.العربية')
expect(keys).toContain('unicode.emoji_😀')
expect(keys).toContain('unicode.mixed_中文_English')
})
it('should handle RTL language files', async () => {
const rtlContent = `const translation = {
مرحبا: 'Hello',
العالم: 'World',
nested: {
مفتاح: 'key'
}
}
export default translation`
fs.writeFileSync(path.join(testEnDir, 'rtl.ts'), rtlContent)
const keys = await getKeysFromLanguage('en-US')
expect(keys).toContain('rtl.مرحبا')
expect(keys).toContain('rtl.العالم')
expect(keys).toContain('rtl.nested.مفتاح')
})
})
describe('Error Recovery', () => {
it('should handle syntax errors in translation files gracefully', async () => {
const invalidContent = `const translation = {
validKey: 'valid value',
invalidKey: 'missing quote,
anotherKey: 'another value'
}
export default translation`
fs.writeFileSync(path.join(testEnDir, 'invalid.ts'), invalidContent)
await expect(getKeysFromLanguage('en-US')).rejects.toThrow()
})
})
}) })

View File

@ -286,4 +286,116 @@ describe('Navigation Utilities', () => {
expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc') expect(mockPush).toHaveBeenCalledWith('/datasets/filtered-set/documents?page=1&limit=50&status=active&type=pdf&sort=created_at&order=desc')
}) })
}) })
describe('Edge Cases and Error Handling', () => {
test('handles special characters in query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?keyword=hello%20world&filter=type%3Apdf&tag=%E4%B8%AD%E6%96%87' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
expect(path).toContain('hello+world')
expect(path).toContain('type%3Apdf')
expect(path).toContain('%E4%B8%AD%E6%96%87')
})
test('handles duplicate query parameters', () => {
Object.defineProperty(window, 'location', {
value: { search: '?tag=tag1&tag=tag2&tag=tag3' },
writable: true,
})
const params = extractQueryParams(['tag'])
// URLSearchParams.get() returns the first value
expect(params.tag).toBe('tag1')
})
test('handles very long query strings', () => {
const longValue = 'a'.repeat(1000)
Object.defineProperty(window, 'location', {
value: { search: `?data=${longValue}` },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
expect(path).toContain(longValue)
expect(path.length).toBeGreaterThan(1000)
})
test('handles empty string values in query parameters', () => {
const path = createNavigationPathWithParams('/datasets/123/documents', {
page: 1,
keyword: '',
filter: '',
sort: 'name',
})
expect(path).toBe('/datasets/123/documents?page=1&sort=name')
expect(path).not.toContain('keyword=')
expect(path).not.toContain('filter=')
})
test('handles null and undefined values in mergeQueryParams', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1&limit=10&keyword=test' },
writable: true,
})
const merged = mergeQueryParams({
keyword: null,
filter: undefined,
sort: 'name',
})
const result = merged.toString()
expect(result).toContain('page=1')
expect(result).toContain('limit=10')
expect(result).not.toContain('keyword')
expect(result).toContain('sort=name')
})
test('handles navigation with hash fragments', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1', hash: '#section-2' },
writable: true,
})
const path = createNavigationPath('/datasets/123/documents')
// Should preserve query params but not hash
expect(path).toBe('/datasets/123/documents?page=1')
})
test('handles malformed query strings gracefully', () => {
Object.defineProperty(window, 'location', {
value: { search: '?page=1&invalid&limit=10&=value&key=' },
writable: true,
})
const params = extractQueryParams(['page', 'limit', 'invalid', 'key'])
expect(params.page).toBe('1')
expect(params.limit).toBe('10')
// Malformed params should be handled by URLSearchParams
expect(params.invalid).toBe('') // for `&invalid`
expect(params.key).toBe('') // for `&key=`
})
})
describe('Performance Tests', () => {
test('handles large number of query parameters efficiently', () => {
const manyParams = Array.from({ length: 50 }, (_, i) => `param${i}=value${i}`).join('&')
Object.defineProperty(window, 'location', {
value: { search: `?${manyParams}` },
writable: true,
})
const startTime = Date.now()
const path = createNavigationPath('/datasets/123/documents')
const endTime = Date.now()
expect(endTime - startTime).toBeLessThan(50) // Should be fast
expect(path).toContain('param0=value0')
expect(path).toContain('param49=value49')
})
})
}) })

View File

@ -124,7 +124,7 @@ const AppOperations = ({ operations, gap }: {
<span className='system-xs-medium text-components-button-secondary-text'>{t('common.operation.more')}</span> <span className='system-xs-medium text-components-button-secondary-text'>{t('common.operation.more')}</span>
</Button> </Button>
</PortalToFollowElemTrigger> </PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[21]'> <PortalToFollowElemContent className='z-[30]'>
<div className='flex min-w-[264px] flex-col rounded-[12px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]'> <div className='flex min-w-[264px] flex-col rounded-[12px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]'>
{moreOperations.map(item => <div {moreOperations.map(item => <div
key={item.id} key={item.id}

View File

@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
<div className='flex h-6 items-center gap-1'> <div className='flex h-6 items-center gap-1'>
<div className='system-md-semibold text-text-secondary'>{form.label}</div> <div className='system-md-semibold text-text-secondary'>{form.label}</div>
{!form.required && ( {!form.required && (
<div className='system-xs-regular text-text-tertiary'>{t('appDebug.variableTable.optional')}</div> <div className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</div>
)} )}
</div> </div>
)} )}

View File

@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
<div className='flex h-6 items-center gap-1'> <div className='flex h-6 items-center gap-1'>
<div className='system-md-semibold text-text-secondary'>{form.label}</div> <div className='system-md-semibold text-text-secondary'>{form.label}</div>
{!form.required && ( {!form.required && (
<div className='system-xs-regular text-text-tertiary'>{t('appDebug.variableTable.optional')}</div> <div className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</div>
)} )}
</div> </div>
)} )}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { produce } from 'immer' import { produce } from 'immer'
@ -45,7 +45,13 @@ const OpeningSettingModal = ({
const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false) const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false)
const [notIncludeKeys, setNotIncludeKeys] = useState<string[]>([]) const [notIncludeKeys, setNotIncludeKeys] = useState<string[]>([])
const isSaveDisabled = useMemo(() => !tempValue.trim(), [tempValue])
const handleSave = useCallback((ignoreVariablesCheck?: boolean) => { const handleSave = useCallback((ignoreVariablesCheck?: boolean) => {
// Prevent saving if opening statement is empty
if (isSaveDisabled)
return
if (!ignoreVariablesCheck) { if (!ignoreVariablesCheck) {
const keys = getInputKeys(tempValue) const keys = getInputKeys(tempValue)
const promptKeys = promptVariables.map(item => item.key) const promptKeys = promptVariables.map(item => item.key)
@ -75,7 +81,7 @@ const OpeningSettingModal = ({
} }
}) })
onSave(newOpening) onSave(newOpening)
}, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue]) }, [data, onSave, promptVariables, workflowVariables, showConfirmAddVar, tempSuggestedQuestions, tempValue, isSaveDisabled])
const cancelAutoAddVar = useCallback(() => { const cancelAutoAddVar = useCallback(() => {
hideConfirmAddVar() hideConfirmAddVar()
@ -217,6 +223,7 @@ const OpeningSettingModal = ({
<Button <Button
variant='primary' variant='primary'
onClick={() => handleSave()} onClick={() => handleSave()}
disabled={isSaveDisabled}
> >
{t('common.operation.save')} {t('common.operation.save')}
</Button> </Button>

View File

@ -11,6 +11,7 @@ import type { FileEntity } from './types'
import { useFileStore } from './store' import { useFileStore } from './store'
import { import {
fileUpload, fileUpload,
getFileUploadErrorMessage,
getSupportFileType, getSupportFileType,
isAllowedFileExtension, isAllowedFileExtension,
} from './utils' } from './utils'
@ -172,8 +173,9 @@ export const useFile = (fileConfig: FileUpload) => {
onSuccessCallback: (res) => { onSuccessCallback: (res) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
}, },
onErrorCallback: () => { onErrorCallback: (error?: any) => {
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') }) const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
handleUpdateFile({ ...uploadingFile, progress: -1 }) handleUpdateFile({ ...uploadingFile, progress: -1 })
}, },
}, !!params.token) }, !!params.token)
@ -279,8 +281,9 @@ export const useFile = (fileConfig: FileUpload) => {
onSuccessCallback: (res) => { onSuccessCallback: (res) => {
handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 }) handleUpdateFile({ ...uploadingFile, uploadedId: res.id, progress: 100 })
}, },
onErrorCallback: () => { onErrorCallback: (error?: any) => {
notify({ type: 'error', message: t('common.fileUploader.uploadFromComputerUploadError') }) const errorMessage = getFileUploadErrorMessage(error, t('common.fileUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
handleUpdateFile({ ...uploadingFile, progress: -1 }) handleUpdateFile({ ...uploadingFile, progress: -1 })
}, },
}, !!params.token) }, !!params.token)

View File

@ -7,11 +7,30 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import type { FileResponse } from '@/types/workflow' import type { FileResponse } from '@/types/workflow'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
/**
* Get appropriate error message for file upload errors
* @param error - The error object from upload failure
* @param defaultMessage - Default error message to use if no specific error is matched
* @param t - Translation function
* @returns Localized error message
*/
export const getFileUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => {
const errorCode = error?.response?.code
if (errorCode === 'forbidden')
return error?.response?.message
if (errorCode === 'file_extension_blocked')
return t('common.fileUploader.fileExtensionBlocked')
return defaultMessage
}
type FileUploadParams = { type FileUploadParams = {
file: File file: File
onProgressCallback: (progress: number) => void onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void onSuccessCallback: (res: { id: string }) => void
onErrorCallback: () => void onErrorCallback: (error?: any) => void
} }
type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void
export const fileUpload: FileUpload = ({ export const fileUpload: FileUpload = ({
@ -37,8 +56,8 @@ export const fileUpload: FileUpload = ({
.then((res: { id: string }) => { .then((res: { id: string }) => {
onSuccessCallback(res) onSuccessCallback(res)
}) })
.catch(() => { .catch((error) => {
onErrorCallback() onErrorCallback(error)
}) })
} }

View File

@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import type { ClipboardEvent } from 'react' import type { ClipboardEvent } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { imageUpload } from './utils' import { getImageUploadErrorMessage, imageUpload } from './utils'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app' import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app'
import type { ImageFile, VisionSettings } from '@/types/app' import type { ImageFile, VisionSettings } from '@/types/app'
@ -81,8 +81,9 @@ export const useImageFiles = () => {
filesRef.current = newFiles filesRef.current = newFiles
setFiles(newFiles) setFiles(newFiles)
}, },
onErrorCallback: () => { onErrorCallback: (error?: any) => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)] const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles filesRef.current = newFiles
setFiles(newFiles) setFiles(newFiles)
@ -158,8 +159,9 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
onSuccessCallback: (res) => { onSuccessCallback: (res) => {
onUpload({ ...imageFile, fileId: res.id, progress: 100 }) onUpload({ ...imageFile, fileId: res.id, progress: 100 })
}, },
onErrorCallback: () => { onErrorCallback: (error?: any) => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
onUpload({ ...imageFile, progress: -1 }) onUpload({ ...imageFile, progress: -1 })
}, },
}, !!params.token) }, !!params.token)

View File

@ -1,10 +1,29 @@
import { upload } from '@/service/base' import { upload } from '@/service/base'
/**
* Get appropriate error message for image upload errors
* @param error - The error object from upload failure
* @param defaultMessage - Default error message to use if no specific error is matched
* @param t - Translation function
* @returns Localized error message
*/
export const getImageUploadErrorMessage = (error: any, defaultMessage: string, t: (key: string) => string): string => {
const errorCode = error?.response?.code
if (errorCode === 'forbidden')
return error?.response?.message
if (errorCode === 'file_extension_blocked')
return t('common.fileUploader.fileExtensionBlocked')
return defaultMessage
}
type ImageUploadParams = { type ImageUploadParams = {
file: File file: File
onProgressCallback: (progress: number) => void onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void onSuccessCallback: (res: { id: string }) => void
onErrorCallback: () => void onErrorCallback: (error?: any) => void
} }
type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void
export const imageUpload: ImageUpload = ({ export const imageUpload: ImageUpload = ({
@ -30,7 +49,7 @@ export const imageUpload: ImageUpload = ({
.then((res: { id: string }) => { .then((res: { id: string }) => {
onSuccessCallback(res) onSuccessCallback(res)
}) })
.catch(() => { .catch((error) => {
onErrorCallback() onErrorCallback(error)
}) })
} }

View File

@ -16,7 +16,7 @@ import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type' import { Plan } from '@/app/components/billing/type'
import { imageUpload } from '@/app/components/base/image-uploader/utils' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils'
import { useToastContext } from '@/app/components/base/toast' import { useToastContext } from '@/app/components/base/toast'
import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication' import { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication'
import { import {
@ -67,8 +67,9 @@ const CustomWebAppBrand = () => {
setUploadProgress(100) setUploadProgress(100)
setFileId(res.id) setFileId(res.id)
}, },
onErrorCallback: () => { onErrorCallback: (error?: any) => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
setUploadProgress(-1) setUploadProgress(-1)
}, },
}, false, '/workspaces/custom-config/webapp-logo/upload') }, false, '/workspaces/custom-config/webapp-logo/upload')

View File

@ -18,6 +18,7 @@ import { LanguagesSupported } from '@/i18n-config/language'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
import { Theme } from '@/types/app' import { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
type IFileUploaderProps = { type IFileUploaderProps = {
fileList: FileItem[] fileList: FileItem[]
@ -134,7 +135,8 @@ const FileUploader = ({
return Promise.resolve({ ...completeFile }) return Promise.resolve({ ...completeFile })
}) })
.catch((e) => { .catch((e) => {
notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') }) const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
notify({ type: 'error', message: errorMessage })
onFileUpdate(fileItem, -2, fileListRef.current) onFileUpdate(fileItem, -2, fileListRef.current)
return Promise.resolve({ ...fileItem }) return Promise.resolve({ ...fileItem })
}) })

View File

@ -8,6 +8,7 @@ import cn from '@/utils/classnames'
import type { CustomFile as File, FileItem } from '@/models/datasets' import type { CustomFile as File, FileItem } from '@/models/datasets'
import { ToastContext } from '@/app/components/base/toast' import { ToastContext } from '@/app/components/base/toast'
import { upload } from '@/service/base' import { upload } from '@/service/base'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import I18n from '@/context/i18n' import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language' import { LanguagesSupported } from '@/i18n-config/language'
import { IS_CE_EDITION } from '@/config' import { IS_CE_EDITION } from '@/config'
@ -154,7 +155,8 @@ const LocalFile = ({
return Promise.resolve({ ...completeFile }) return Promise.resolve({ ...completeFile })
}) })
.catch((e) => { .catch((e) => {
notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') }) const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
notify({ type: 'error', message: errorMessage })
updateFile(fileItem, -2, fileListRef.current) updateFile(fileItem, -2, fileListRef.current)
return Promise.resolve({ ...fileItem }) return Promise.resolve({ ...fileItem })
}) })

View File

@ -12,6 +12,7 @@ import { ToastContext } from '@/app/components/base/toast'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
import type { FileItem } from '@/models/datasets' import type { FileItem } from '@/models/datasets'
import { upload } from '@/service/base' import { upload } from '@/service/base'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import useSWR from 'swr' import useSWR from 'swr'
import { fetchFileUploadConfig } from '@/service/common' import { fetchFileUploadConfig } from '@/service/common'
import SimplePieChart from '@/app/components/base/simple-pie-chart' import SimplePieChart from '@/app/components/base/simple-pie-chart'
@ -74,7 +75,8 @@ const CSVUploader: FC<Props> = ({
return Promise.resolve({ ...completeFile }) return Promise.resolve({ ...completeFile })
}) })
.catch((e) => { .catch((e) => {
notify({ type: 'error', message: e?.response?.code === 'forbidden' ? e?.response?.message : t('datasetCreation.stepOne.uploader.failed') }) const errorMessage = getFileUploadErrorMessage(e, t('datasetCreation.stepOne.uploader.failed'), t)
notify({ type: 'error', message: errorMessage })
const errorFile = { const errorFile = {
...fileItem, ...fileItem,
progress: -2, progress: -2,

View File

@ -4,27 +4,27 @@ import { RiFeedbackLine } from '@remixicon/react'
import i18n from '@/i18n-config/i18next-config' import i18n from '@/i18n-config/i18next-config'
import { registerCommands, unregisterCommands } from './command-bus' import { registerCommands, unregisterCommands } from './command-bus'
// Feedback command dependency types // Forum command dependency types
type FeedbackDeps = Record<string, never> type ForumDeps = Record<string, never>
/** /**
* Feedback command - Opens GitHub feedback discussions * Forum command - Opens Dify community forum
*/ */
export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = { export const forumCommand: SlashCommandHandler<ForumDeps> = {
name: 'feedback', name: 'forum',
description: 'Open feedback discussions', description: 'Open Dify community forum',
mode: 'direct', mode: 'direct',
// Direct execution function // Direct execution function
execute: () => { execute: () => {
const url = 'https://github.com/langgenius/dify/discussions/categories/feedbacks' const url = 'https://forum.dify.ai'
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
}, },
async search(args: string, locale: string = 'en') { async search(args: string, locale: string = 'en') {
return [{ return [{
id: 'feedback', id: 'forum',
title: i18n.t('common.userProfile.communityFeedback', { lng: locale }), title: i18n.t('common.userProfile.forum', { lng: locale }),
description: i18n.t('app.gotoAnything.actions.feedbackDesc', { lng: locale }) || 'Open community feedback discussions', description: i18n.t('app.gotoAnything.actions.feedbackDesc', { lng: locale }) || 'Open community feedback discussions',
type: 'command' as const, type: 'command' as const,
icon: ( icon: (
@ -32,20 +32,20 @@ export const feedbackCommand: SlashCommandHandler<FeedbackDeps> = {
<RiFeedbackLine className='h-4 w-4 text-text-tertiary' /> <RiFeedbackLine className='h-4 w-4 text-text-tertiary' />
</div> </div>
), ),
data: { command: 'navigation.feedback', args: { url: 'https://github.com/langgenius/dify/discussions/categories/feedbacks' } }, data: { command: 'navigation.forum', args: { url: 'https://forum.dify.ai' } },
}] }]
}, },
register(_deps: FeedbackDeps) { register(_deps: ForumDeps) {
registerCommands({ registerCommands({
'navigation.feedback': async (args) => { 'navigation.forum': async (args) => {
const url = args?.url || 'https://github.com/langgenius/dify/discussions/categories/feedbacks' const url = args?.url || 'https://forum.dify.ai'
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
}, },
}) })
}, },
unregister() { unregister() {
unregisterCommands(['navigation.feedback']) unregisterCommands(['navigation.forum'])
}, },
} }

View File

@ -7,7 +7,7 @@ import { useTheme } from 'next-themes'
import { setLocaleOnClient } from '@/i18n-config' import { setLocaleOnClient } from '@/i18n-config'
import { themeCommand } from './theme' import { themeCommand } from './theme'
import { languageCommand } from './language' import { languageCommand } from './language'
import { feedbackCommand } from './feedback' import { forumCommand } from './forum'
import { docsCommand } from './docs' import { docsCommand } from './docs'
import { communityCommand } from './community' import { communityCommand } from './community'
import { accountCommand } from './account' import { accountCommand } from './account'
@ -34,7 +34,7 @@ export const registerSlashCommands = (deps: Record<string, any>) => {
// Register command handlers to the registry system with their respective dependencies // Register command handlers to the registry system with their respective dependencies
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme }) slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale }) slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
slashCommandRegistry.register(feedbackCommand, {}) slashCommandRegistry.register(forumCommand, {})
slashCommandRegistry.register(docsCommand, {}) slashCommandRegistry.register(docsCommand, {})
slashCommandRegistry.register(communityCommand, {}) slashCommandRegistry.register(communityCommand, {})
slashCommandRegistry.register(accountCommand, {}) slashCommandRegistry.register(accountCommand, {})
@ -44,7 +44,7 @@ export const unregisterSlashCommands = () => {
// Remove command handlers from registry system (automatically calls each command's unregister method) // Remove command handlers from registry system (automatically calls each command's unregister method)
slashCommandRegistry.unregister('theme') slashCommandRegistry.unregister('theme')
slashCommandRegistry.unregister('language') slashCommandRegistry.unregister('language')
slashCommandRegistry.unregister('feedback') slashCommandRegistry.unregister('forum')
slashCommandRegistry.unregister('docs') slashCommandRegistry.unregister('docs')
slashCommandRegistry.unregister('community') slashCommandRegistry.unregister('community')
slashCommandRegistry.unregister('account') slashCommandRegistry.unregister('account')

View File

@ -1,5 +1,5 @@
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiFeedbackLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react' import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
import { Fragment } from 'react' import { Fragment } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -86,10 +86,10 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
className={cn(itemClassName, 'group justify-between', className={cn(itemClassName, 'group justify-between',
'data-[active]:bg-state-base-hover', 'data-[active]:bg-state-base-hover',
)} )}
href='https://github.com/langgenius/dify/discussions/categories/feedbacks' href='https://forum.dify.ai/'
target='_blank' rel='noopener noreferrer'> target='_blank' rel='noopener noreferrer'>
<RiFeedbackLine className='size-4 shrink-0 text-text-tertiary' /> <RiDiscussLine className='size-4 shrink-0 text-text-tertiary' />
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.communityFeedback')}</div> <div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.forum')}</div>
<RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' /> <RiArrowRightUpLine className='size-[14px] shrink-0 text-text-tertiary' />
</Link> </Link>
</MenuItem> </MenuItem>

View File

@ -73,7 +73,7 @@ const DetailHeader = ({
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { const {
installation_id, id,
source, source,
tenant_id, tenant_id,
version, version,
@ -197,7 +197,7 @@ const DetailHeader = ({
const handleDelete = useCallback(async () => { const handleDelete = useCallback(async () => {
showDeleting() showDeleting()
const res = await uninstallPlugin(installation_id) const res = await uninstallPlugin(id)
hideDeleting() hideDeleting()
if (res.success) { if (res.success) {
hideDeleteConfirm() hideDeleteConfirm()
@ -207,7 +207,7 @@ const DetailHeader = ({
if (PluginType.tool.includes(category)) if (PluginType.tool.includes(category))
invalidateAllToolProviders() invalidateAllToolProviders()
} }
}, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) }, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
return ( return (
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}> <div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}>
@ -351,7 +351,6 @@ const DetailHeader = ({
content={ content={
<div> <div>
{t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{label[locale]}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br /> {t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{label[locale]}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br />
{/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
</div> </div>
} }
onCancel={hideDeleteConfirm} onCancel={hideDeleteConfirm}

View File

@ -72,6 +72,8 @@ const PluginPage = ({
} }
}, [searchParams]) }, [searchParams])
const [uniqueIdentifier, setUniqueIdentifier] = useState<string | null>(null)
const [dependencies, setDependencies] = useState<Dependency[]>([]) const [dependencies, setDependencies] = useState<Dependency[]>([])
const bundleInfo = useMemo(() => { const bundleInfo = useMemo(() => {
const info = searchParams.get(BUNDLE_INFO_KEY) const info = searchParams.get(BUNDLE_INFO_KEY)
@ -99,6 +101,7 @@ const PluginPage = ({
useEffect(() => { useEffect(() => {
(async () => { (async () => {
setUniqueIdentifier(null)
await sleep(100) await sleep(100)
if (packageId) { if (packageId) {
const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId)) const { data } = await fetchManifestFromMarketPlace(encodeURIComponent(packageId))
@ -108,6 +111,7 @@ const PluginPage = ({
version: version.version, version: version.version,
icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`, icon: `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`,
}) })
setUniqueIdentifier(packageId)
showInstallFromMarketplace() showInstallFromMarketplace()
return return
} }
@ -283,10 +287,10 @@ const PluginPage = ({
)} )}
{ {
isShowInstallFromMarketplace && ( isShowInstallFromMarketplace && uniqueIdentifier && (
<InstallFromMarketplace <InstallFromMarketplace
manifest={manifest! as PluginManifestInMarket} manifest={manifest! as PluginManifestInMarket}
uniqueIdentifier={packageId} uniqueIdentifier={uniqueIdentifier}
isBundle={!!bundleInfo} isBundle={!!bundleInfo}
dependencies={dependencies} dependencies={dependencies}
onClose={hideInstallFromMarketplace} onClose={hideInstallFromMarketplace}

View File

@ -100,7 +100,10 @@ const RunOnce: FC<IRunOnceProps> = ({
: promptConfig.prompt_variables.map(item => ( : promptConfig.prompt_variables.map(item => (
<div className='mt-4 w-full' key={item.key}> <div className='mt-4 w-full' key={item.key}>
{item.type !== 'checkbox' && ( {item.type !== 'checkbox' && (
<label className='system-md-semibold flex h-6 items-center text-text-secondary'>{item.name}</label> <div className='system-md-semibold flex h-6 items-center gap-1 text-text-secondary'>
<div className='truncate'>{item.name}</div>
{!item.required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>}
</div>
)} )}
<div className='mt-1'> <div className='mt-1'>
{item.type === 'select' && ( {item.type === 'select' && (
@ -115,7 +118,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'string' && ( {item.type === 'string' && (
<Input <Input
type="text" type="text"
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} placeholder={item.name}
value={inputs[item.key]} value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN} maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
@ -124,7 +127,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'paragraph' && ( {item.type === 'paragraph' && (
<Textarea <Textarea
className='h-[104px] sm:text-xs' className='h-[104px] sm:text-xs'
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} placeholder={item.name}
value={inputs[item.key]} value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/> />
@ -132,7 +135,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'number' && ( {item.type === 'number' && (
<Input <Input
type="number" type="number"
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`} placeholder={item.name}
value={inputs[item.key]} value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/> />

View File

@ -140,7 +140,7 @@ const FormItem: FC<Props> = ({
<Input <Input
value={value || ''} value={value || ''}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
placeholder={t('appDebug.variableConfig.inputPlaceholder')!} placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
) )
@ -152,7 +152,7 @@ const FormItem: FC<Props> = ({
type="number" type="number"
value={value || ''} value={value || ''}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
placeholder={t('appDebug.variableConfig.inputPlaceholder')!} placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
) )
@ -163,7 +163,7 @@ const FormItem: FC<Props> = ({
<Textarea <Textarea
value={value || ''} value={value || ''}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}
placeholder={t('appDebug.variableConfig.inputPlaceholder')!} placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
autoFocus={autoFocus} autoFocus={autoFocus}
/> />
) )

View File

@ -106,7 +106,7 @@ const StatusPanel: FC<ResultProps> = ({
{status === 'failed' && error && ( {status === 'failed' && error && (
<> <>
<div className='my-2 h-[0.5px] bg-divider-subtle'/> <div className='my-2 h-[0.5px] bg-divider-subtle'/>
<div className='system-xs-regular text-text-destructive'>{error}</div> <div className='system-xs-regular whitespace-pre-wrap text-text-destructive'>{error}</div>
{ {
!!exceptionCounts && ( !!exceptionCounts && (
<> <>

View File

@ -259,7 +259,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Variablenschlüssel', key: 'Variablenschlüssel',
name: 'Name des Benutzereingabefelds', name: 'Name des Benutzereingabefelds',
optional: 'Optional',
type: 'Eingabetyp', type: 'Eingabetyp',
action: 'Aktionen', action: 'Aktionen',
typeString: 'String', typeString: 'String',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Arbeitsbereich', workspace: 'Arbeitsbereich',
createWorkspace: 'Arbeitsbereich erstellen', createWorkspace: 'Arbeitsbereich erstellen',
helpCenter: 'Hilfe', helpCenter: 'Hilfe',
communityFeedback: 'Rückmeldung',
roadmap: 'Fahrplan', roadmap: 'Fahrplan',
community: 'Gemeinschaft', community: 'Gemeinschaft',
about: 'Über', about: 'Über',
@ -170,6 +169,7 @@ const translation = {
support: 'Unterstützung', support: 'Unterstützung',
github: 'GitHub', github: 'GitHub',
contactUs: 'Kontaktieren Sie uns', contactUs: 'Kontaktieren Sie uns',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'KONTO', accountGroup: 'KONTO',
@ -726,6 +726,7 @@ const translation = {
uploadFromComputerLimit: 'Datei hochladen darf {{size}} nicht überschreiten', uploadFromComputerLimit: 'Datei hochladen darf {{size}} nicht überschreiten',
uploadFromComputerReadError: 'Lesen der Datei fehlgeschlagen, bitte versuchen Sie es erneut.', uploadFromComputerReadError: 'Lesen der Datei fehlgeschlagen, bitte versuchen Sie es erneut.',
fileExtensionNotSupport: 'Dateiendung nicht bedient', fileExtensionNotSupport: 'Dateiendung nicht bedient',
fileExtensionBlocked: 'Dieser Dateityp ist aus Sicherheitsgründen gesperrt',
}, },
license: { license: {
expiring: 'Läuft an einem Tag ab', expiring: 'Läuft an einem Tag ab',

View File

@ -346,7 +346,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Variable Key', key: 'Variable Key',
name: 'User Input Field Name', name: 'User Input Field Name',
optional: 'Optional',
type: 'Input Type', type: 'Input Type',
action: 'Actions', action: 'Actions',
typeString: 'String', typeString: 'String',

View File

@ -177,7 +177,7 @@ const translation = {
helpCenter: 'Docs', helpCenter: 'Docs',
support: 'Support', support: 'Support',
compliance: 'Compliance', compliance: 'Compliance',
communityFeedback: 'Feedback', forum: 'Forum',
roadmap: 'Roadmap', roadmap: 'Roadmap',
github: 'GitHub', github: 'GitHub',
community: 'Community', community: 'Community',
@ -734,6 +734,7 @@ const translation = {
uploadFromComputerLimit: 'Upload {{type}} cannot exceed {{size}}', uploadFromComputerLimit: 'Upload {{type}} cannot exceed {{size}}',
pasteFileLinkInvalid: 'Invalid file link', pasteFileLinkInvalid: 'Invalid file link',
fileExtensionNotSupport: 'File extension not supported', fileExtensionNotSupport: 'File extension not supported',
fileExtensionBlocked: 'This file type is blocked for security reasons',
}, },
tag: { tag: {
placeholder: 'All Tags', placeholder: 'All Tags',

View File

@ -255,7 +255,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Clave de Variable', key: 'Clave de Variable',
name: 'Nombre del Campo de Entrada del Usuario', name: 'Nombre del Campo de Entrada del Usuario',
optional: 'Opcional',
type: 'Tipo de Entrada', type: 'Tipo de Entrada',
action: 'Acciones', action: 'Acciones',
typeString: 'Cadena', typeString: 'Cadena',

View File

@ -165,7 +165,6 @@ const translation = {
workspace: 'Espacio de trabajo', workspace: 'Espacio de trabajo',
createWorkspace: 'Crear espacio de trabajo', createWorkspace: 'Crear espacio de trabajo',
helpCenter: 'Ayuda', helpCenter: 'Ayuda',
communityFeedback: 'Comentarios',
roadmap: 'Hoja de ruta', roadmap: 'Hoja de ruta',
community: 'Comunidad', community: 'Comunidad',
about: 'Acerca de', about: 'Acerca de',
@ -174,6 +173,7 @@ const translation = {
compliance: 'Cumplimiento', compliance: 'Cumplimiento',
github: 'GitHub', github: 'GitHub',
contactUs: 'Contáctenos', contactUs: 'Contáctenos',
forum: 'Foro',
}, },
settings: { settings: {
accountGroup: 'CUENTA', accountGroup: 'CUENTA',
@ -726,6 +726,7 @@ const translation = {
fileExtensionNotSupport: 'Extensión de archivo no compatible', fileExtensionNotSupport: 'Extensión de archivo no compatible',
pasteFileLinkInputPlaceholder: 'Introduzca la URL...', pasteFileLinkInputPlaceholder: 'Introduzca la URL...',
uploadFromComputerLimit: 'El archivo de carga no puede exceder {{size}}', uploadFromComputerLimit: 'El archivo de carga no puede exceder {{size}}',
fileExtensionBlocked: 'Este tipo de archivo está bloqueado por motivos de seguridad',
}, },
license: { license: {
expiring: 'Caduca en un día', expiring: 'Caduca en un día',

View File

@ -588,7 +588,6 @@ const translation = {
typeString: 'رشته', typeString: 'رشته',
name: 'نام فیلد ورودی کاربر', name: 'نام فیلد ورودی کاربر',
type: 'نوع ورودی', type: 'نوع ورودی',
optional: 'اختیاری',
}, },
varKeyError: {}, varKeyError: {},
otherError: { otherError: {

View File

@ -165,7 +165,6 @@ const translation = {
workspace: 'فضای کاری', workspace: 'فضای کاری',
createWorkspace: 'ایجاد فضای کاری', createWorkspace: 'ایجاد فضای کاری',
helpCenter: 'راهنما', helpCenter: 'راهنما',
communityFeedback: 'بازخورد',
roadmap: 'نقشه راه', roadmap: 'نقشه راه',
community: 'انجمن', community: 'انجمن',
about: 'درباره', about: 'درباره',
@ -174,6 +173,7 @@ const translation = {
compliance: 'انطباق', compliance: 'انطباق',
support: 'پشتیبانی', support: 'پشتیبانی',
contactUs: 'با ما تماس بگیرید', contactUs: 'با ما تماس بگیرید',
forum: 'انجمن',
}, },
settings: { settings: {
accountGroup: 'حساب کاربری', accountGroup: 'حساب کاربری',
@ -726,6 +726,7 @@ const translation = {
uploadFromComputerUploadError: 'آپلود فایل انجام نشد، لطفا دوباره آپلود کنید.', uploadFromComputerUploadError: 'آپلود فایل انجام نشد، لطفا دوباره آپلود کنید.',
pasteFileLink: 'پیوند فایل را جایگذاری کنید', pasteFileLink: 'پیوند فایل را جایگذاری کنید',
uploadFromComputerLimit: 'آپلود فایل نمی تواند از {{size}} تجاوز کند', uploadFromComputerLimit: 'آپلود فایل نمی تواند از {{size}} تجاوز کند',
fileExtensionBlocked: 'این نوع فایل به دلایل امنیتی مسدود شده است',
}, },
license: { license: {
expiring_plural: 'انقضا در {{count}} روز', expiring_plural: 'انقضا در {{count}} روز',

View File

@ -259,7 +259,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Clé Variable', key: 'Clé Variable',
name: 'Nom du champ d\'entrée de l\'utilisateur', name: 'Nom du champ d\'entrée de l\'utilisateur',
optional: 'Facultatif',
type: 'Type d\'Entrée', type: 'Type d\'Entrée',
action: 'Actions', action: 'Actions',
typeString: 'Chaîne', typeString: 'Chaîne',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Espace de travail', workspace: 'Espace de travail',
createWorkspace: 'Créer un Espace de Travail', createWorkspace: 'Créer un Espace de Travail',
helpCenter: 'Aide', helpCenter: 'Aide',
communityFeedback: 'Retour d\'information',
roadmap: 'Feuille de route', roadmap: 'Feuille de route',
community: 'Communauté', community: 'Communauté',
about: 'À propos', about: 'À propos',
@ -170,6 +169,7 @@ const translation = {
github: 'GitHub', github: 'GitHub',
compliance: 'Conformité', compliance: 'Conformité',
contactUs: 'Contactez-nous', contactUs: 'Contactez-nous',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'COMPTE', accountGroup: 'COMPTE',
@ -727,6 +727,7 @@ const translation = {
fileExtensionNotSupport: 'Extension de fichier non prise en charge', fileExtensionNotSupport: 'Extension de fichier non prise en charge',
pasteFileLinkInvalid: 'Lien de fichier non valide', pasteFileLinkInvalid: 'Lien de fichier non valide',
uploadFromComputerLimit: 'Le fichier de téléchargement ne peut pas dépasser {{size}}', uploadFromComputerLimit: 'Le fichier de téléchargement ne peut pas dépasser {{size}}',
fileExtensionBlocked: 'Ce type de fichier est bloqué pour des raisons de sécurité',
}, },
license: { license: {
expiring: 'Expirant dans un jour', expiring: 'Expirant dans un jour',

View File

@ -279,7 +279,6 @@ const translation = {
variableTable: { variableTable: {
key: 'वेरिएबल कुंजी', key: 'वेरिएबल कुंजी',
name: 'उपयोगकर्ता इनपुट फ़ील्ड नाम', name: 'उपयोगकर्ता इनपुट फ़ील्ड नाम',
optional: 'वैकल्पिक',
type: 'इनपुट प्रकार', type: 'इनपुट प्रकार',
action: 'क्रियाएँ', action: 'क्रियाएँ',
typeString: 'स्ट्रिंग', typeString: 'स्ट्रिंग',

View File

@ -170,7 +170,6 @@ const translation = {
workspace: 'वर्कस्पेस', workspace: 'वर्कस्पेस',
createWorkspace: 'वर्कस्पेस बनाएं', createWorkspace: 'वर्कस्पेस बनाएं',
helpCenter: 'सहायता', helpCenter: 'सहायता',
communityFeedback: 'प्रतिक्रिया',
roadmap: 'रोडमैप', roadmap: 'रोडमैप',
community: 'समुदाय', community: 'समुदाय',
about: 'के बारे में', about: 'के बारे में',
@ -179,6 +178,7 @@ const translation = {
github: 'गिटहब', github: 'गिटहब',
support: 'समर्थन', support: 'समर्थन',
contactUs: 'संपर्क करें', contactUs: 'संपर्क करें',
forum: 'फोरम',
}, },
settings: { settings: {
accountGroup: 'खाता', accountGroup: 'खाता',
@ -748,6 +748,7 @@ const translation = {
pasteFileLink: 'फ़ाइल लिंक पेस्ट करें', pasteFileLink: 'फ़ाइल लिंक पेस्ट करें',
fileExtensionNotSupport: 'फ़ाइल एक्सटेंशन समर्थित नहीं है', fileExtensionNotSupport: 'फ़ाइल एक्सटेंशन समर्थित नहीं है',
uploadFromComputer: 'स्थानीय अपलोड', uploadFromComputer: 'स्थानीय अपलोड',
fileExtensionBlocked: 'सुरक्षा कारणों से इस फ़ाइल प्रकार को अवरुद्ध कर दिया गया है',
}, },
license: { license: {
expiring: 'एक दिन में समाप्त हो रहा है', expiring: 'एक दिन में समाप्त हो रहा है',

View File

@ -325,7 +325,6 @@ const translation = {
variableTable: { variableTable: {
action: 'Tindakan', action: 'Tindakan',
typeString: 'String', typeString: 'String',
optional: 'Fakultatif',
typeSelect: 'Pilih', typeSelect: 'Pilih',
type: 'Jenis Masukan', type: 'Jenis Masukan',
key: 'Kunci Variabel', key: 'Kunci Variabel',

View File

@ -163,7 +163,6 @@ const translation = {
helpCenter: 'Docs', helpCenter: 'Docs',
compliance: 'Kepatuhan', compliance: 'Kepatuhan',
community: 'Masyarakat', community: 'Masyarakat',
communityFeedback: 'Umpan balik',
roadmap: 'Peta jalan', roadmap: 'Peta jalan',
logout: 'Keluar', logout: 'Keluar',
settings: 'Pengaturan', settings: 'Pengaturan',
@ -173,6 +172,7 @@ const translation = {
workspace: 'Workspace', workspace: 'Workspace',
createWorkspace: 'Membuat Ruang Kerja', createWorkspace: 'Membuat Ruang Kerja',
contactUs: 'Hubungi Kami', contactUs: 'Hubungi Kami',
forum: 'Forum',
}, },
compliance: { compliance: {
soc2Type2: 'Laporan SOC 2 Tipe II', soc2Type2: 'Laporan SOC 2 Tipe II',
@ -701,6 +701,7 @@ const translation = {
pasteFileLinkInvalid: 'Tautan file tidak valid', pasteFileLinkInvalid: 'Tautan file tidak valid',
pasteFileLinkInputPlaceholder: 'Masukkan URL...', pasteFileLinkInputPlaceholder: 'Masukkan URL...',
uploadFromComputerReadError: 'Pembacaan file gagal, silakan coba lagi.', uploadFromComputerReadError: 'Pembacaan file gagal, silakan coba lagi.',
fileExtensionBlocked: 'Tipe file ini diblokir karena alasan keamanan',
}, },
tag: { tag: {
noTag: 'Tidak ada tag', noTag: 'Tidak ada tag',

View File

@ -281,7 +281,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Chiave Variabile', key: 'Chiave Variabile',
name: 'Nome Campo Input Utente', name: 'Nome Campo Input Utente',
optional: 'Opzionale',
type: 'Tipo di Input', type: 'Tipo di Input',
action: 'Azioni', action: 'Azioni',
typeString: 'Stringa', typeString: 'Stringa',

View File

@ -170,7 +170,6 @@ const translation = {
workspace: 'Workspace', workspace: 'Workspace',
createWorkspace: 'Crea Workspace', createWorkspace: 'Crea Workspace',
helpCenter: 'Aiuto', helpCenter: 'Aiuto',
communityFeedback: 'Feedback',
roadmap: 'Tabella di marcia', roadmap: 'Tabella di marcia',
community: 'Comunità', community: 'Comunità',
about: 'Informazioni', about: 'Informazioni',
@ -179,6 +178,7 @@ const translation = {
compliance: 'Conformità', compliance: 'Conformità',
github: 'GitHub', github: 'GitHub',
contactUs: 'Contattaci', contactUs: 'Contattaci',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'ACCOUNT', accountGroup: 'ACCOUNT',
@ -756,6 +756,7 @@ const translation = {
uploadFromComputerUploadError: 'Caricamento del file non riuscito, carica di nuovo.', uploadFromComputerUploadError: 'Caricamento del file non riuscito, carica di nuovo.',
pasteFileLink: 'Incolla il collegamento del file', pasteFileLink: 'Incolla il collegamento del file',
uploadFromComputerReadError: 'Lettura del file non riuscita, riprovare.', uploadFromComputerReadError: 'Lettura del file non riuscita, riprovare.',
fileExtensionBlocked: 'Questo tipo di file è bloccato per motivi di sicurezza',
}, },
license: { license: {
expiring_plural: 'Scadenza tra {{count}} giorni', expiring_plural: 'Scadenza tra {{count}} giorni',

View File

@ -340,7 +340,6 @@ const translation = {
variableTable: { variableTable: {
key: '変数キー', key: '変数キー',
name: 'ユーザー入力フィールド名', name: 'ユーザー入力フィールド名',
optional: 'オプション',
type: '入力タイプ', type: '入力タイプ',
action: 'アクション', action: 'アクション',
typeString: '文字列', typeString: '文字列',

View File

@ -173,13 +173,13 @@ const translation = {
helpCenter: 'ヘルプ', helpCenter: 'ヘルプ',
support: 'サポート', support: 'サポート',
compliance: 'コンプライアンス', compliance: 'コンプライアンス',
communityFeedback: 'フィードバック',
roadmap: 'ロードマップ', roadmap: 'ロードマップ',
community: 'コミュニティ', community: 'コミュニティ',
about: 'Dify について', about: 'Dify について',
logout: 'ログアウト', logout: 'ログアウト',
github: 'GitHub', github: 'GitHub',
contactUs: 'お問い合わせ', contactUs: 'お問い合わせ',
forum: 'フォーラム',
}, },
compliance: { compliance: {
soc2Type1: 'SOC 2 Type I 報告書', soc2Type1: 'SOC 2 Type I 報告書',
@ -740,6 +740,7 @@ const translation = {
uploadFromComputerReadError: 'ファイルの読み取りに失敗しました。もう一度やり直してください。', uploadFromComputerReadError: 'ファイルの読み取りに失敗しました。もう一度やり直してください。',
fileExtensionNotSupport: 'ファイル拡張子はサポートされていません', fileExtensionNotSupport: 'ファイル拡張子はサポートされていません',
pasteFileLinkInvalid: '無効なファイルリンク', pasteFileLinkInvalid: '無効なファイルリンク',
fileExtensionBlocked: 'このファイルタイプは、セキュリティ上の理由でブロックされています',
}, },
license: { license: {
expiring_plural: '有効期限 {{count}} 日', expiring_plural: '有効期限 {{count}} 日',

View File

@ -255,7 +255,6 @@ const translation = {
variableTable: { variableTable: {
key: '변수 키', key: '변수 키',
name: '사용자 입력 필드명', name: '사용자 입력 필드명',
optional: '옵션',
type: '입력 타입', type: '입력 타입',
action: '액션', action: '액션',
typeString: '문자열', typeString: '문자열',

View File

@ -157,7 +157,6 @@ const translation = {
workspace: '작업 공간', workspace: '작업 공간',
createWorkspace: '작업 공간 만들기', createWorkspace: '작업 공간 만들기',
helpCenter: '도움말 센터', helpCenter: '도움말 센터',
communityFeedback: '로드맵 및 피드백',
roadmap: '로드맵', roadmap: '로드맵',
community: '커뮤니티', community: '커뮤니티',
about: 'Dify 소개', about: 'Dify 소개',
@ -166,6 +165,7 @@ const translation = {
compliance: '컴플라이언스', compliance: '컴플라이언스',
support: '지원', support: '지원',
contactUs: '문의하기', contactUs: '문의하기',
forum: '포럼',
}, },
settings: { settings: {
accountGroup: '계정', accountGroup: '계정',
@ -722,6 +722,7 @@ const translation = {
fileExtensionNotSupport: '지원되지 않는 파일 확장자', fileExtensionNotSupport: '지원되지 않는 파일 확장자',
uploadFromComputerLimit: '업로드 파일은 {{size}}를 초과할 수 없습니다.', uploadFromComputerLimit: '업로드 파일은 {{size}}를 초과할 수 없습니다.',
uploadFromComputerUploadError: '파일 업로드에 실패했습니다. 다시 업로드하십시오.', uploadFromComputerUploadError: '파일 업로드에 실패했습니다. 다시 업로드하십시오.',
fileExtensionBlocked: '보안상의 이유로 이 파일 형식은 차단되었습니다',
}, },
license: { license: {
expiring_plural: '{{count}}일 후에 만료', expiring_plural: '{{count}}일 후에 만료',

View File

@ -277,7 +277,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Klucz Zmiennej', key: 'Klucz Zmiennej',
name: 'Nazwa Pola Wejściowego Użytkownika', name: 'Nazwa Pola Wejściowego Użytkownika',
optional: 'Opcjonalnie',
type: 'Typ Wejścia', type: 'Typ Wejścia',
action: 'Akcje', action: 'Akcje',
typeString: 'String', typeString: 'String',

View File

@ -166,7 +166,6 @@ const translation = {
workspace: 'Przestrzeń robocza', workspace: 'Przestrzeń robocza',
createWorkspace: 'Utwórz przestrzeń roboczą', createWorkspace: 'Utwórz przestrzeń roboczą',
helpCenter: 'Pomoc', helpCenter: 'Pomoc',
communityFeedback: 'Opinie',
roadmap: 'Plan działania', roadmap: 'Plan działania',
community: 'Społeczność', community: 'Społeczność',
about: 'O', about: 'O',
@ -175,6 +174,7 @@ const translation = {
github: 'GitHub', github: 'GitHub',
compliance: 'Zgodność', compliance: 'Zgodność',
contactUs: 'Skontaktuj się z nami', contactUs: 'Skontaktuj się z nami',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'KONTO', accountGroup: 'KONTO',
@ -744,6 +744,7 @@ const translation = {
uploadFromComputerReadError: 'Odczyt pliku nie powiódł się, spróbuj ponownie.', uploadFromComputerReadError: 'Odczyt pliku nie powiódł się, spróbuj ponownie.',
fileExtensionNotSupport: 'Rozszerzenie pliku nie jest obsługiwane', fileExtensionNotSupport: 'Rozszerzenie pliku nie jest obsługiwane',
uploadFromComputer: 'Przesyłanie lokalne', uploadFromComputer: 'Przesyłanie lokalne',
fileExtensionBlocked: 'Ten typ pliku jest zablokowany ze względów bezpieczeństwa',
}, },
license: { license: {
expiring_plural: 'Wygasa za {{count}} dni', expiring_plural: 'Wygasa za {{count}} dni',

View File

@ -261,7 +261,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Chave da Variável', key: 'Chave da Variável',
name: 'Nome do Campo de Entrada do Usuário', name: 'Nome do Campo de Entrada do Usuário',
optional: 'Opcional',
type: 'Tipo de Entrada', type: 'Tipo de Entrada',
action: 'Ações', action: 'Ações',
typeString: 'Texto', typeString: 'Texto',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Espaço de trabalho', workspace: 'Espaço de trabalho',
createWorkspace: 'Criar Espaço de Trabalho', createWorkspace: 'Criar Espaço de Trabalho',
helpCenter: 'Ajuda', helpCenter: 'Ajuda',
communityFeedback: 'Feedback',
roadmap: 'Roteiro', roadmap: 'Roteiro',
community: 'Comunidade', community: 'Comunidade',
about: 'Sobre', about: 'Sobre',
@ -170,6 +169,7 @@ const translation = {
support: 'Suporte', support: 'Suporte',
compliance: 'Conformidade', compliance: 'Conformidade',
contactUs: 'Contate-Nos', contactUs: 'Contate-Nos',
forum: 'Fórum',
}, },
settings: { settings: {
accountGroup: 'CONTA', accountGroup: 'CONTA',
@ -726,6 +726,7 @@ const translation = {
uploadFromComputerReadError: 'Falha na leitura do arquivo, tente novamente.', uploadFromComputerReadError: 'Falha na leitura do arquivo, tente novamente.',
uploadFromComputerLimit: 'Carregar arquivo não pode exceder {{size}}', uploadFromComputerLimit: 'Carregar arquivo não pode exceder {{size}}',
uploadFromComputerUploadError: 'Falha no upload do arquivo, faça o upload novamente.', uploadFromComputerUploadError: 'Falha no upload do arquivo, faça o upload novamente.',
fileExtensionBlocked: 'Este tipo de arquivo está bloqueado por razões de segurança',
}, },
license: { license: {
expiring: 'Expirando em um dia', expiring: 'Expirando em um dia',

View File

@ -261,7 +261,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Cheie variabilă', key: 'Cheie variabilă',
name: 'Nume câmp de intrare utilizator', name: 'Nume câmp de intrare utilizator',
optional: 'Opțional',
type: 'Tip intrare', type: 'Tip intrare',
action: 'Acțiuni', action: 'Acțiuni',
typeString: 'Șir', typeString: 'Șir',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Spațiu de lucru', workspace: 'Spațiu de lucru',
createWorkspace: 'Creează Spațiu de lucru', createWorkspace: 'Creează Spațiu de lucru',
helpCenter: 'Ajutor', helpCenter: 'Ajutor',
communityFeedback: 'Feedback',
roadmap: 'Plan de acțiune', roadmap: 'Plan de acțiune',
community: 'Comunitate', community: 'Comunitate',
about: 'Despre', about: 'Despre',
@ -170,6 +169,7 @@ const translation = {
support: 'Suport', support: 'Suport',
compliance: 'Conformitate', compliance: 'Conformitate',
contactUs: 'Contactați-ne', contactUs: 'Contactați-ne',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'CONT', accountGroup: 'CONT',
@ -726,6 +726,7 @@ const translation = {
pasteFileLinkInvalid: 'Link fișier nevalid', pasteFileLinkInvalid: 'Link fișier nevalid',
uploadFromComputerLimit: 'Încărcarea fișierului nu poate depăși {{size}}', uploadFromComputerLimit: 'Încărcarea fișierului nu poate depăși {{size}}',
pasteFileLink: 'Lipiți linkul fișierului', pasteFileLink: 'Lipiți linkul fișierului',
fileExtensionBlocked: 'Acest tip de fișier este blocat din motive de securitate',
}, },
license: { license: {
expiring: 'Expiră într-o zi', expiring: 'Expiră într-o zi',

View File

@ -327,7 +327,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Ключ переменной', key: 'Ключ переменной',
name: 'Имя поля пользовательского ввода', name: 'Имя поля пользовательского ввода',
optional: 'Необязательно',
type: 'Тип ввода', type: 'Тип ввода',
action: 'Действия', action: 'Действия',
typeString: 'Строка', typeString: 'Строка',

View File

@ -165,7 +165,6 @@ const translation = {
workspace: 'Рабочее пространство', workspace: 'Рабочее пространство',
createWorkspace: 'Создать рабочее пространство', createWorkspace: 'Создать рабочее пространство',
helpCenter: 'Помощь', helpCenter: 'Помощь',
communityFeedback: 'Обратная связь',
roadmap: 'План развития', roadmap: 'План развития',
community: 'Сообщество', community: 'Сообщество',
about: 'О нас', about: 'О нас',
@ -174,6 +173,7 @@ const translation = {
compliance: 'Соблюдение', compliance: 'Соблюдение',
support: 'Поддержка', support: 'Поддержка',
contactUs: 'Свяжитесь с нами', contactUs: 'Свяжитесь с нами',
forum: 'Форум',
}, },
settings: { settings: {
accountGroup: 'АККАУНТ', accountGroup: 'АККАУНТ',
@ -726,6 +726,7 @@ const translation = {
pasteFileLinkInvalid: 'Неверная ссылка на файл', pasteFileLinkInvalid: 'Неверная ссылка на файл',
uploadFromComputerLimit: 'Файл загрузки не может превышать {{size}}', uploadFromComputerLimit: 'Файл загрузки не может превышать {{size}}',
uploadFromComputerUploadError: 'Загрузка файла не удалась, пожалуйста, загрузите еще раз.', uploadFromComputerUploadError: 'Загрузка файла не удалась, пожалуйста, загрузите еще раз.',
fileExtensionBlocked: 'Этот тип файла заблокирован по соображениям безопасности',
}, },
license: { license: {
expiring: 'Срок действия истекает за один день', expiring: 'Срок действия истекает за один день',

View File

@ -350,7 +350,6 @@ const translation = {
}, },
variableTable: { variableTable: {
action: 'Dejanja', action: 'Dejanja',
optional: 'Neobvezno',
typeString: 'Niz', typeString: 'Niz',
typeSelect: 'Izbrati', typeSelect: 'Izbrati',
type: 'Vrsta vnosa', type: 'Vrsta vnosa',

View File

@ -165,7 +165,6 @@ const translation = {
workspace: 'Delovni prostor', workspace: 'Delovni prostor',
createWorkspace: 'Ustvari delovni prostor', createWorkspace: 'Ustvari delovni prostor',
helpCenter: 'Pomoč', helpCenter: 'Pomoč',
communityFeedback: 'Povratne informacije',
roadmap: 'Načrt razvoja', roadmap: 'Načrt razvoja',
community: 'Skupnost', community: 'Skupnost',
about: 'O nas', about: 'O nas',
@ -174,6 +173,7 @@ const translation = {
github: 'GitHub', github: 'GitHub',
compliance: 'Skladnost', compliance: 'Skladnost',
contactUs: 'Kontaktirajte nas', contactUs: 'Kontaktirajte nas',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'SPLOŠNO', accountGroup: 'SPLOŠNO',
@ -792,6 +792,7 @@ const translation = {
uploadFromComputer: 'Lokalno nalaganje', uploadFromComputer: 'Lokalno nalaganje',
uploadFromComputerLimit: 'Nalaganje {{type}} ne sme presegati {{size}}', uploadFromComputerLimit: 'Nalaganje {{type}} ne sme presegati {{size}}',
uploadFromComputerReadError: 'Branje datoteke ni uspelo, poskusite znova.', uploadFromComputerReadError: 'Branje datoteke ni uspelo, poskusite znova.',
fileExtensionBlocked: 'Ta vrsta datoteke je zaradi varnostnih razlogov blokirana',
}, },
tag: { tag: {
addTag: 'Dodajanje oznak', addTag: 'Dodajanje oznak',

View File

@ -323,7 +323,6 @@ const translation = {
timeoutExceeded: 'ผลลัพธ์จะไม่แสดงเนื่องจากหมดเวลา โปรดดูบันทึกเพื่อรวบรวมผลลัพธ์ที่สมบูรณ์', timeoutExceeded: 'ผลลัพธ์จะไม่แสดงเนื่องจากหมดเวลา โปรดดูบันทึกเพื่อรวบรวมผลลัพธ์ที่สมบูรณ์',
}, },
variableTable: { variableTable: {
optional: 'เสริม',
key: 'ปุ่มตัวแปร', key: 'ปุ่มตัวแปร',
typeString: 'เชือก', typeString: 'เชือก',
typeSelect: 'เลือก', typeSelect: 'เลือก',

View File

@ -160,7 +160,6 @@ const translation = {
workspace: 'พื้นที่', workspace: 'พื้นที่',
createWorkspace: 'สร้างพื้นที่ทํางาน', createWorkspace: 'สร้างพื้นที่ทํางาน',
helpCenter: 'วิธีใช้', helpCenter: 'วิธีใช้',
communityFeedback: 'การตอบสนอง',
roadmap: 'แผนงาน', roadmap: 'แผนงาน',
community: 'ชุมชน', community: 'ชุมชน',
about: 'ประมาณ', about: 'ประมาณ',
@ -169,6 +168,7 @@ const translation = {
compliance: 'การปฏิบัติตามข้อกำหนด', compliance: 'การปฏิบัติตามข้อกำหนด',
support: 'การสนับสนุน', support: 'การสนับสนุน',
contactUs: 'ติดต่อเรา', contactUs: 'ติดต่อเรา',
forum: 'ฟอรั่ม',
}, },
settings: { settings: {
accountGroup: 'ทั่วไป', accountGroup: 'ทั่วไป',
@ -706,6 +706,7 @@ const translation = {
uploadFromComputerLimit: 'อัปโหลด {{type}} ต้องไม่เกิน {{size}}', uploadFromComputerLimit: 'อัปโหลด {{type}} ต้องไม่เกิน {{size}}',
pasteFileLinkInvalid: 'ลิงก์ไฟล์ไม่ถูกต้อง', pasteFileLinkInvalid: 'ลิงก์ไฟล์ไม่ถูกต้อง',
fileExtensionNotSupport: 'ไม่รองรับนามสกุลไฟล์', fileExtensionNotSupport: 'ไม่รองรับนามสกุลไฟล์',
fileExtensionBlocked: 'ประเภทไฟล์นี้ถูกบล็อกด้วยเหตุผลด้านความปลอดภัย',
}, },
tag: { tag: {
placeholder: 'แท็กทั้งหมด', placeholder: 'แท็กทั้งหมด',

View File

@ -327,7 +327,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Değişken Anahtarı', key: 'Değişken Anahtarı',
name: 'Kullanıcı Giriş Alanı Adı', name: 'Kullanıcı Giriş Alanı Adı',
optional: 'İsteğe Bağlı',
type: 'Giriş Tipi', type: 'Giriş Tipi',
action: 'Aksiyonlar', action: 'Aksiyonlar',
typeString: 'Metin', typeString: 'Metin',

View File

@ -165,7 +165,6 @@ const translation = {
workspace: 'Çalışma Alanı', workspace: 'Çalışma Alanı',
createWorkspace: 'Çalışma Alanı Oluştur', createWorkspace: 'Çalışma Alanı Oluştur',
helpCenter: 'Yardım', helpCenter: 'Yardım',
communityFeedback: 'Geri Bildirim',
roadmap: 'Yol haritası', roadmap: 'Yol haritası',
community: 'Topluluk', community: 'Topluluk',
about: 'Hakkında', about: 'Hakkında',
@ -174,6 +173,7 @@ const translation = {
compliance: 'Uygunluk', compliance: 'Uygunluk',
github: 'GitHub', github: 'GitHub',
contactUs: 'Bize Ulaşın', contactUs: 'Bize Ulaşın',
forum: 'Forum',
}, },
settings: { settings: {
accountGroup: 'HESAP', accountGroup: 'HESAP',
@ -726,6 +726,7 @@ const translation = {
pasteFileLinkInputPlaceholder: 'URL\'yi giriniz...', pasteFileLinkInputPlaceholder: 'URL\'yi giriniz...',
pasteFileLinkInvalid: 'Geçersiz dosya bağlantısı', pasteFileLinkInvalid: 'Geçersiz dosya bağlantısı',
fileExtensionNotSupport: 'Dosya uzantısı desteklenmiyor', fileExtensionNotSupport: 'Dosya uzantısı desteklenmiyor',
fileExtensionBlocked: 'Bu dosya türü güvenlik nedenleriyle engellenmiştir',
}, },
license: { license: {
expiring_plural: '{{count}} gün içinde sona eriyor', expiring_plural: '{{count}} gün içinde sona eriyor',

View File

@ -273,7 +273,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Ключ змінної', // Variable Key key: 'Ключ змінної', // Variable Key
name: 'Назва поля для введення користувача', // User Input Field Name name: 'Назва поля для введення користувача', // User Input Field Name
optional: 'Додатково', // Optional
type: 'Тип введення', // Input Type type: 'Тип введення', // Input Type
action: 'Дії', // Actions action: 'Дії', // Actions
typeString: 'Рядок', // String typeString: 'Рядок', // String

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Робочий простір', workspace: 'Робочий простір',
createWorkspace: 'Створити робочий простір', createWorkspace: 'Створити робочий простір',
helpCenter: 'Довідковий центр', helpCenter: 'Довідковий центр',
communityFeedback: 'відгуки',
roadmap: 'Дорожня карта', roadmap: 'Дорожня карта',
community: 'Спільнота', community: 'Спільнота',
about: 'Про нас', about: 'Про нас',
@ -170,6 +169,7 @@ const translation = {
support: 'Підтримка', support: 'Підтримка',
github: 'Гітхаб', github: 'Гітхаб',
contactUs: 'Зв’яжіться з нами', contactUs: 'Зв’яжіться з нами',
forum: 'Форум',
}, },
settings: { settings: {
accountGroup: 'ОБЛІКОВИЙ ЗАПИС', accountGroup: 'ОБЛІКОВИЙ ЗАПИС',
@ -727,6 +727,7 @@ const translation = {
fileExtensionNotSupport: 'Розширення файлу не підтримується', fileExtensionNotSupport: 'Розширення файлу не підтримується',
uploadFromComputerReadError: 'Не вдалося прочитати файл, будь ласка, спробуйте ще раз.', uploadFromComputerReadError: 'Не вдалося прочитати файл, будь ласка, спробуйте ще раз.',
uploadFromComputerUploadError: 'Не вдалося завантажити файл, будь ласка, завантажте ще раз.', uploadFromComputerUploadError: 'Не вдалося завантажити файл, будь ласка, завантажте ще раз.',
fileExtensionBlocked: 'Цей тип файлу заблоковано з міркувань безпеки',
}, },
license: { license: {
expiring: 'Термін дії закінчується за один день', expiring: 'Термін дії закінчується за один день',

View File

@ -255,7 +255,6 @@ const translation = {
variableTable: { variableTable: {
key: 'Khóa biến', key: 'Khóa biến',
name: 'Tên trường nhập liệu người dùng', name: 'Tên trường nhập liệu người dùng',
optional: 'Tùy chọn',
type: 'Loại nhập liệu', type: 'Loại nhập liệu',
action: 'Hành động', action: 'Hành động',
typeString: 'Chuỗi', typeString: 'Chuỗi',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Không gian làm việc', workspace: 'Không gian làm việc',
createWorkspace: 'Tạo Không gian làm việc', createWorkspace: 'Tạo Không gian làm việc',
helpCenter: 'Trung tâm trợ giúp', helpCenter: 'Trung tâm trợ giúp',
communityFeedback: 'Phản hồi',
roadmap: 'Lộ trình', roadmap: 'Lộ trình',
community: 'Cộng đồng', community: 'Cộng đồng',
about: 'Về chúng tôi', about: 'Về chúng tôi',
@ -170,6 +169,7 @@ const translation = {
github: 'GitHub', github: 'GitHub',
support: 'Hỗ trợ', support: 'Hỗ trợ',
contactUs: 'Liên hệ với chúng tôi', contactUs: 'Liên hệ với chúng tôi',
forum: 'Diễn đàn',
}, },
settings: { settings: {
accountGroup: 'TÀI KHOẢN', accountGroup: 'TÀI KHOẢN',
@ -726,6 +726,7 @@ const translation = {
pasteFileLinkInvalid: 'Liên kết tệp không hợp lệ', pasteFileLinkInvalid: 'Liên kết tệp không hợp lệ',
uploadFromComputerUploadError: 'Tải lên tệp không thành công, vui lòng tải lên lại.', uploadFromComputerUploadError: 'Tải lên tệp không thành công, vui lòng tải lên lại.',
uploadFromComputerReadError: 'Đọc tệp không thành công, vui lòng thử lại.', uploadFromComputerReadError: 'Đọc tệp không thành công, vui lòng thử lại.',
fileExtensionBlocked: 'Loại tệp này bị chặn vì lý do bảo mật',
}, },
license: { license: {
expiring_plural: 'Hết hạn sau {{count}} ngày', expiring_plural: 'Hết hạn sau {{count}} ngày',

View File

@ -342,7 +342,6 @@ const translation = {
variableTable: { variableTable: {
key: '变量 Key', key: '变量 Key',
name: '字段名称', name: '字段名称',
optional: '可选',
type: '类型', type: '类型',
action: '操作', action: '操作',
typeString: '文本', typeString: '文本',

View File

@ -176,7 +176,7 @@ const translation = {
helpCenter: '帮助文档', helpCenter: '帮助文档',
support: '支持', support: '支持',
compliance: '合规', compliance: '合规',
communityFeedback: '用户反馈', forum: '论坛',
roadmap: '路线图', roadmap: '路线图',
github: 'GitHub', github: 'GitHub',
community: '社区', community: '社区',
@ -728,6 +728,7 @@ const translation = {
uploadFromComputerLimit: '上传 {{type}} 不能超过 {{size}}', uploadFromComputerLimit: '上传 {{type}} 不能超过 {{size}}',
pasteFileLinkInvalid: '文件链接无效', pasteFileLinkInvalid: '文件链接无效',
fileExtensionNotSupport: '文件类型不支持', fileExtensionNotSupport: '文件类型不支持',
fileExtensionBlocked: '出于安全考虑,该文件类型已被禁止上传',
}, },
tag: { tag: {
placeholder: '全部标签', placeholder: '全部标签',

View File

@ -255,7 +255,6 @@ const translation = {
variableTable: { variableTable: {
key: '變數 Key', key: '變數 Key',
name: '欄位名稱', name: '欄位名稱',
optional: '可選',
type: '型別', type: '型別',
action: '操作', action: '操作',
typeString: '文字', typeString: '文字',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: '工作空間', workspace: '工作空間',
createWorkspace: '建立工作空間', createWorkspace: '建立工作空間',
helpCenter: '幫助文件', helpCenter: '幫助文件',
communityFeedback: '使用者反饋',
roadmap: '路線圖', roadmap: '路線圖',
community: '社群', community: '社群',
about: '關於', about: '關於',
@ -170,6 +169,7 @@ const translation = {
github: 'GitHub', github: 'GitHub',
compliance: '合規', compliance: '合規',
contactUs: '聯絡我們', contactUs: '聯絡我們',
forum: '論壇',
}, },
settings: { settings: {
accountGroup: '賬戶', accountGroup: '賬戶',
@ -726,6 +726,7 @@ const translation = {
uploadFromComputer: '本地上傳', uploadFromComputer: '本地上傳',
fileExtensionNotSupport: '不支援檔擴展名', fileExtensionNotSupport: '不支援檔擴展名',
uploadFromComputerLimit: '上傳文件不能超過 {{size}}', uploadFromComputerLimit: '上傳文件不能超過 {{size}}',
fileExtensionBlocked: '出於安全原因,此檔案類型被阻止',
}, },
license: { license: {
expiring: '將在 1 天內過期', expiring: '將在 1 天內過期',

View File

@ -208,7 +208,7 @@
"canvas": "^3.2.0", "canvas": "^3.2.0",
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"pbkdf2": "~3.1.3", "pbkdf2": "~3.1.3",
"vite": "~6.2", "vite": "~6.4.1",
"prismjs": "~1.30", "prismjs": "~1.30",
"brace-expansion": "~2.0" "brace-expansion": "~2.0"
}, },
@ -231,7 +231,7 @@
"esbuild@<0.25.0": "0.25.0", "esbuild@<0.25.0": "0.25.0",
"pbkdf2@<3.1.3": "3.1.3", "pbkdf2@<3.1.3": "3.1.3",
"prismjs@<1.30.0": "1.30.0", "prismjs@<1.30.0": "1.30.0",
"vite@<6.2.7": "6.2.7", "vite@<6.4.1": "6.4.1",
"array-includes": "npm:@nolyfill/array-includes@^1", "array-includes": "npm:@nolyfill/array-includes@^1",
"array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1", "array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1",
"array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@^1", "array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@^1",

633
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -14,3 +14,39 @@ describe('makeProviderQuery', () => {
expect(buildProviderQuery('ABC?DEF')).toBe('provider=ABC%3FDEF') expect(buildProviderQuery('ABC?DEF')).toBe('provider=ABC%3FDEF')
}) })
}) })
describe('Tools Utilities', () => {
describe('buildProviderQuery', () => {
it('should build query string with provider parameter', () => {
const result = buildProviderQuery('openai')
expect(result).toBe('provider=openai')
})
it('should handle provider names with special characters', () => {
const result = buildProviderQuery('provider-name')
expect(result).toBe('provider=provider-name')
})
it('should handle empty string', () => {
const result = buildProviderQuery('')
expect(result).toBe('provider=')
})
it('should URL encode special characters', () => {
const result = buildProviderQuery('provider name')
expect(result).toBe('provider=provider+name')
})
it('should handle Unicode characters', () => {
const result = buildProviderQuery('提供者')
expect(result).toContain('provider=')
expect(decodeURIComponent(result)).toBe('provider=提供者')
})
it('should handle provider names with slashes', () => {
const result = buildProviderQuery('langgenius/openai/gpt-4')
expect(result).toContain('provider=')
expect(decodeURIComponent(result)).toBe('provider=langgenius/openai/gpt-4')
})
})
})

View File

@ -0,0 +1,106 @@
/**
* Test suite for app redirection utility functions
* Tests navigation path generation based on user permissions and app modes
*/
import { getRedirection, getRedirectionPath } from './app-redirection'
describe('app-redirection', () => {
/**
* Tests getRedirectionPath which determines the correct path based on:
* - User's editor permissions
* - App mode (workflow, advanced-chat, chat, completion, agent-chat)
*/
describe('getRedirectionPath', () => {
test('returns overview path when user is not editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const result = getRedirectionPath(false, app)
expect(result).toBe('/app/app-123/overview')
})
test('returns workflow path for workflow mode when user is editor', () => {
const app = { id: 'app-123', mode: 'workflow' as const }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/workflow')
})
test('returns workflow path for advanced-chat mode when user is editor', () => {
const app = { id: 'app-123', mode: 'advanced-chat' as const }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/workflow')
})
test('returns configuration path for chat mode when user is editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/configuration')
})
test('returns configuration path for completion mode when user is editor', () => {
const app = { id: 'app-123', mode: 'completion' as const }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-123/configuration')
})
test('returns configuration path for agent-chat mode when user is editor', () => {
const app = { id: 'app-456', mode: 'agent-chat' as const }
const result = getRedirectionPath(true, app)
expect(result).toBe('/app/app-456/configuration')
})
test('handles different app IDs', () => {
const app1 = { id: 'abc-123', mode: 'chat' as const }
const app2 = { id: 'xyz-789', mode: 'workflow' as const }
expect(getRedirectionPath(false, app1)).toBe('/app/abc-123/overview')
expect(getRedirectionPath(true, app2)).toBe('/app/xyz-789/workflow')
})
})
/**
* Tests getRedirection which combines path generation with a redirect callback
*/
describe('getRedirection', () => {
/**
* Tests that the redirection function is called with the correct path
*/
test('calls redirection function with correct path for non-editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const mockRedirect = jest.fn()
getRedirection(false, app, mockRedirect)
expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/overview')
expect(mockRedirect).toHaveBeenCalledTimes(1)
})
test('calls redirection function with workflow path for editor', () => {
const app = { id: 'app-123', mode: 'workflow' as const }
const mockRedirect = jest.fn()
getRedirection(true, app, mockRedirect)
expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/workflow')
expect(mockRedirect).toHaveBeenCalledTimes(1)
})
test('calls redirection function with configuration path for chat mode editor', () => {
const app = { id: 'app-123', mode: 'chat' as const }
const mockRedirect = jest.fn()
getRedirection(true, app, mockRedirect)
expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/configuration')
expect(mockRedirect).toHaveBeenCalledTimes(1)
})
test('works with different redirection functions', () => {
const app = { id: 'app-123', mode: 'workflow' as const }
const paths: string[] = []
const customRedirect = (path: string) => paths.push(path)
getRedirection(true, app, customRedirect)
expect(paths).toEqual(['/app/app-123/workflow'])
})
})
})

View File

@ -1,6 +1,18 @@
/**
* Test suite for the classnames utility function
* This utility combines the classnames library with tailwind-merge
* to handle conditional CSS classes and merge conflicting Tailwind classes
*/
import cn from './classnames' import cn from './classnames'
describe('classnames', () => { describe('classnames', () => {
/**
* Tests basic classnames library features:
* - String concatenation
* - Array handling
* - Falsy value filtering
* - Object-based conditional classes
*/
test('classnames libs feature', () => { test('classnames libs feature', () => {
expect(cn('foo')).toBe('foo') expect(cn('foo')).toBe('foo')
expect(cn('foo', 'bar')).toBe('foo bar') expect(cn('foo', 'bar')).toBe('foo bar')
@ -17,6 +29,14 @@ describe('classnames', () => {
})).toBe('foo baz') })).toBe('foo baz')
}) })
/**
* Tests tailwind-merge functionality:
* - Conflicting class resolution (last one wins)
* - Modifier handling (hover, focus, etc.)
* - Important prefix (!)
* - Custom color classes
* - Arbitrary values
*/
test('tailwind-merge', () => { test('tailwind-merge', () => {
/* eslint-disable tailwindcss/classnames-order */ /* eslint-disable tailwindcss/classnames-order */
expect(cn('p-0')).toBe('p-0') expect(cn('p-0')).toBe('p-0')
@ -44,6 +64,10 @@ describe('classnames', () => {
expect(cn('text-3.5xl text-black')).toBe('text-3.5xl text-black') expect(cn('text-3.5xl text-black')).toBe('text-3.5xl text-black')
}) })
/**
* Tests the integration of classnames and tailwind-merge:
* - Object-based conditional classes with Tailwind conflict resolution
*/
test('classnames combined with tailwind-merge', () => { test('classnames combined with tailwind-merge', () => {
expect(cn('text-right', { expect(cn('text-right', {
'text-center': true, 'text-center': true,
@ -53,4 +77,81 @@ describe('classnames', () => {
'text-center': false, 'text-center': false,
})).toBe('text-right') })).toBe('text-right')
}) })
/**
* Tests handling of multiple mixed argument types:
* - Strings, arrays, and objects in a single call
* - Tailwind merge working across different argument types
*/
test('multiple mixed argument types', () => {
expect(cn('foo', ['bar', 'baz'], { qux: true, quux: false })).toBe('foo bar baz qux')
expect(cn('p-4', ['p-2', 'm-4'], { 'text-left': true, 'text-right': true })).toBe('p-2 m-4 text-right')
})
/**
* Tests nested array handling:
* - Deep array flattening
* - Tailwind merge with nested structures
*/
test('nested arrays', () => {
expect(cn(['foo', ['bar', 'baz']])).toBe('foo bar baz')
expect(cn(['p-4', ['p-2', 'text-center']])).toBe('p-2 text-center')
})
/**
* Tests empty input handling:
* - Empty strings, arrays, and objects
* - Mixed empty and non-empty values
*/
test('empty inputs', () => {
expect(cn('')).toBe('')
expect(cn([])).toBe('')
expect(cn({})).toBe('')
expect(cn('', [], {})).toBe('')
expect(cn('foo', '', 'bar')).toBe('foo bar')
})
/**
* Tests number input handling:
* - Truthy numbers converted to strings
* - Zero treated as falsy
*/
test('numbers as inputs', () => {
expect(cn(1)).toBe('1')
expect(cn(0)).toBe('')
expect(cn('foo', 1, 'bar')).toBe('foo 1 bar')
})
/**
* Tests multiple object arguments:
* - Object merging
* - Tailwind conflict resolution across objects
*/
test('multiple objects', () => {
expect(cn({ foo: true }, { bar: true })).toBe('foo bar')
expect(cn({ foo: true, bar: false }, { bar: true, baz: true })).toBe('foo bar baz')
expect(cn({ 'p-4': true }, { 'p-2': true })).toBe('p-2')
})
/**
* Tests complex edge cases:
* - Mixed falsy values
* - Nested arrays with falsy values
* - Multiple conflicting Tailwind classes
*/
test('complex edge cases', () => {
expect(cn('foo', null, undefined, false, 'bar', 0, 1, '')).toBe('foo bar 1')
expect(cn(['foo', null, ['bar', undefined, 'baz']])).toBe('foo bar baz')
expect(cn('text-sm', { 'text-lg': false, 'text-xl': true }, 'text-2xl')).toBe('text-2xl')
})
/**
* Tests important (!) modifier behavior:
* - Important modifiers in objects
* - Conflict resolution with important prefix
*/
test('important modifier with objects', () => {
expect(cn({ '!font-medium': true }, { '!font-bold': true })).toBe('!font-bold')
expect(cn('font-normal', { '!font-bold': true })).toBe('font-normal !font-bold')
})
}) })

109
web/utils/clipboard.spec.ts Normal file
View File

@ -0,0 +1,109 @@
import { writeTextToClipboard } from './clipboard'
describe('Clipboard Utilities', () => {
describe('writeTextToClipboard', () => {
afterEach(() => {
jest.restoreAllMocks()
})
it('should use navigator.clipboard.writeText when available', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
})
await writeTextToClipboard('test text')
expect(mockWriteText).toHaveBeenCalledWith('test text')
})
it('should fallback to execCommand when clipboard API not available', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
writable: true,
configurable: true,
})
const mockExecCommand = jest.fn().mockReturnValue(true)
document.execCommand = mockExecCommand
const appendChildSpy = jest.spyOn(document.body, 'appendChild')
const removeChildSpy = jest.spyOn(document.body, 'removeChild')
await writeTextToClipboard('fallback text')
expect(appendChildSpy).toHaveBeenCalled()
expect(mockExecCommand).toHaveBeenCalledWith('copy')
expect(removeChildSpy).toHaveBeenCalled()
})
it('should handle execCommand failure', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
writable: true,
configurable: true,
})
const mockExecCommand = jest.fn().mockReturnValue(false)
document.execCommand = mockExecCommand
await expect(writeTextToClipboard('fail text')).rejects.toThrow()
})
it('should handle execCommand exception', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
writable: true,
configurable: true,
})
const mockExecCommand = jest.fn().mockImplementation(() => {
throw new Error('execCommand error')
})
document.execCommand = mockExecCommand
await expect(writeTextToClipboard('error text')).rejects.toThrow('execCommand error')
})
it('should clean up textarea after fallback', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
writable: true,
configurable: true,
})
document.execCommand = jest.fn().mockReturnValue(true)
const removeChildSpy = jest.spyOn(document.body, 'removeChild')
await writeTextToClipboard('cleanup test')
expect(removeChildSpy).toHaveBeenCalled()
})
it('should handle empty string', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
})
await writeTextToClipboard('')
expect(mockWriteText).toHaveBeenCalledWith('')
})
it('should handle special characters', async () => {
const mockWriteText = jest.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
writable: true,
configurable: true,
})
const specialText = 'Test\n\t"quotes"\n中文\n😀'
await writeTextToClipboard(specialText)
expect(mockWriteText).toHaveBeenCalledWith(specialText)
})
})
})

View File

@ -0,0 +1,230 @@
import { mergeValidCompletionParams } from './completion-params'
import type { FormValue, ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations'
describe('completion-params', () => {
describe('mergeValidCompletionParams', () => {
test('returns empty params and removedDetails for undefined oldParams', () => {
const rules: ModelParameterRule[] = []
const result = mergeValidCompletionParams(undefined, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({})
})
test('returns empty params and removedDetails for empty oldParams', () => {
const rules: ModelParameterRule[] = []
const result = mergeValidCompletionParams({}, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({})
})
test('validates int type parameter within range', () => {
const rules: ModelParameterRule[] = [
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
]
const oldParams: FormValue = { max_tokens: 100 }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ max_tokens: 100 })
expect(result.removedDetails).toEqual({})
})
test('removes int parameter below minimum', () => {
const rules: ModelParameterRule[] = [
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
]
const oldParams: FormValue = { max_tokens: 0 }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({ max_tokens: 'out of range (1-4096)' })
})
test('removes int parameter above maximum', () => {
const rules: ModelParameterRule[] = [
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
]
const oldParams: FormValue = { max_tokens: 5000 }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({ max_tokens: 'out of range (1-4096)' })
})
test('removes int parameter with invalid type', () => {
const rules: ModelParameterRule[] = [
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
]
const oldParams: FormValue = { max_tokens: 'not a number' as any }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({ max_tokens: 'invalid type' })
})
test('validates float type parameter', () => {
const rules: ModelParameterRule[] = [
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
]
const oldParams: FormValue = { temperature: 0.7 }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ temperature: 0.7 })
expect(result.removedDetails).toEqual({})
})
test('validates float at boundary values', () => {
const rules: ModelParameterRule[] = [
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
]
const result1 = mergeValidCompletionParams({ temperature: 0 }, rules)
expect(result1.params).toEqual({ temperature: 0 })
const result2 = mergeValidCompletionParams({ temperature: 2 }, rules)
expect(result2.params).toEqual({ temperature: 2 })
})
test('validates boolean type parameter', () => {
const rules: ModelParameterRule[] = [
{ name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false },
]
const oldParams: FormValue = { stream: true }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ stream: true })
expect(result.removedDetails).toEqual({})
})
test('removes boolean parameter with invalid type', () => {
const rules: ModelParameterRule[] = [
{ name: 'stream', type: 'boolean', label: { en_US: 'Stream', zh_Hans: '流' }, required: false },
]
const oldParams: FormValue = { stream: 'yes' as any }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({ stream: 'invalid type' })
})
test('validates string type parameter', () => {
const rules: ModelParameterRule[] = [
{ name: 'model', type: 'string', label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
]
const oldParams: FormValue = { model: 'gpt-4' }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ model: 'gpt-4' })
expect(result.removedDetails).toEqual({})
})
test('validates string parameter with options', () => {
const rules: ModelParameterRule[] = [
{ name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
]
const oldParams: FormValue = { model: 'gpt-4' }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ model: 'gpt-4' })
expect(result.removedDetails).toEqual({})
})
test('removes string parameter with invalid option', () => {
const rules: ModelParameterRule[] = [
{ name: 'model', type: 'string', options: ['gpt-3.5-turbo', 'gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
]
const oldParams: FormValue = { model: 'invalid-model' }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({ model: 'unsupported option' })
})
test('validates text type parameter', () => {
const rules: ModelParameterRule[] = [
{ name: 'prompt', type: 'text', label: { en_US: 'Prompt', zh_Hans: '提示' }, required: false },
]
const oldParams: FormValue = { prompt: 'Hello world' }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ prompt: 'Hello world' })
expect(result.removedDetails).toEqual({})
})
test('removes unsupported parameters', () => {
const rules: ModelParameterRule[] = [
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
]
const oldParams: FormValue = { temperature: 0.7, unsupported_param: 'value' }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ temperature: 0.7 })
expect(result.removedDetails).toEqual({ unsupported_param: 'unsupported' })
})
test('keeps stop parameter in advanced mode even without rule', () => {
const rules: ModelParameterRule[] = []
const oldParams: FormValue = { stop: ['END'] }
const result = mergeValidCompletionParams(oldParams, rules, true)
expect(result.params).toEqual({ stop: ['END'] })
expect(result.removedDetails).toEqual({})
})
test('removes stop parameter in normal mode without rule', () => {
const rules: ModelParameterRule[] = []
const oldParams: FormValue = { stop: ['END'] }
const result = mergeValidCompletionParams(oldParams, rules, false)
expect(result.params).toEqual({})
expect(result.removedDetails).toEqual({ stop: 'unsupported' })
})
test('handles multiple parameters with mixed validity', () => {
const rules: ModelParameterRule[] = [
{ name: 'temperature', type: 'float', min: 0, max: 2, label: { en_US: 'Temperature', zh_Hans: '温度' }, required: false },
{ name: 'max_tokens', type: 'int', min: 1, max: 4096, label: { en_US: 'Max Tokens', zh_Hans: '最大标记' }, required: false },
{ name: 'model', type: 'string', options: ['gpt-4'], label: { en_US: 'Model', zh_Hans: '模型' }, required: false },
]
const oldParams: FormValue = {
temperature: 0.7,
max_tokens: 5000,
model: 'gpt-4',
unsupported: 'value',
}
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({
temperature: 0.7,
model: 'gpt-4',
})
expect(result.removedDetails).toEqual({
max_tokens: 'out of range (1-4096)',
unsupported: 'unsupported',
})
})
test('handles parameters without min/max constraints', () => {
const rules: ModelParameterRule[] = [
{ name: 'value', type: 'int', label: { en_US: 'Value', zh_Hans: '值' }, required: false },
]
const oldParams: FormValue = { value: 999999 }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({ value: 999999 })
expect(result.removedDetails).toEqual({})
})
test('removes parameter with unsupported rule type', () => {
const rules: ModelParameterRule[] = [
{ name: 'custom', type: 'unknown_type', label: { en_US: 'Custom', zh_Hans: '自定义' }, required: false } as any,
]
const oldParams: FormValue = { custom: 'value' }
const result = mergeValidCompletionParams(oldParams, rules)
expect(result.params).toEqual({})
expect(result.removedDetails.custom).toContain('unsupported rule type')
})
})
})

77
web/utils/emoji.spec.ts Normal file
View File

@ -0,0 +1,77 @@
import { searchEmoji } from './emoji'
import { SearchIndex } from 'emoji-mart'
jest.mock('emoji-mart', () => ({
SearchIndex: {
search: jest.fn(),
},
}))
describe('Emoji Utilities', () => {
describe('searchEmoji', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should return emoji natives for search results', async () => {
const mockEmojis = [
{ skins: [{ native: '😀' }] },
{ skins: [{ native: '😃' }] },
{ skins: [{ native: '😄' }] },
]
;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis)
const result = await searchEmoji('smile')
expect(result).toEqual(['😀', '😃', '😄'])
})
it('should return empty array when no results', async () => {
;(SearchIndex.search as jest.Mock).mockResolvedValue([])
const result = await searchEmoji('nonexistent')
expect(result).toEqual([])
})
it('should return empty array when search returns null', async () => {
;(SearchIndex.search as jest.Mock).mockResolvedValue(null)
const result = await searchEmoji('test')
expect(result).toEqual([])
})
it('should handle search with empty string', async () => {
;(SearchIndex.search as jest.Mock).mockResolvedValue([])
const result = await searchEmoji('')
expect(result).toEqual([])
expect(SearchIndex.search).toHaveBeenCalledWith('')
})
it('should extract native from first skin', async () => {
const mockEmojis = [
{
skins: [
{ native: '👍' },
{ native: '👍🏻' },
{ native: '👍🏼' },
],
},
]
;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis)
const result = await searchEmoji('thumbs')
expect(result).toEqual(['👍'])
})
it('should handle multiple search terms', async () => {
const mockEmojis = [
{ skins: [{ native: '❤️' }] },
{ skins: [{ native: '💙' }] },
]
;(SearchIndex.search as jest.Mock).mockResolvedValue(mockEmojis)
const result = await searchEmoji('heart love')
expect(result).toEqual(['❤️', '💙'])
})
})
})

View File

@ -1,4 +1,4 @@
import { downloadFile, formatFileSize, formatNumber, formatTime } from './format' import { downloadFile, formatFileSize, formatNumber, formatNumberAbbreviated, formatTime } from './format'
describe('formatNumber', () => { describe('formatNumber', () => {
test('should correctly format integers', () => { test('should correctly format integers', () => {
@ -102,3 +102,95 @@ describe('downloadFile', () => {
jest.restoreAllMocks() jest.restoreAllMocks()
}) })
}) })
describe('formatNumberAbbreviated', () => {
it('should return number as string when less than 1000', () => {
expect(formatNumberAbbreviated(0)).toBe('0')
expect(formatNumberAbbreviated(1)).toBe('1')
expect(formatNumberAbbreviated(999)).toBe('999')
})
it('should format thousands with k suffix', () => {
expect(formatNumberAbbreviated(1000)).toBe('1k')
expect(formatNumberAbbreviated(1200)).toBe('1.2k')
expect(formatNumberAbbreviated(1500)).toBe('1.5k')
expect(formatNumberAbbreviated(9999)).toBe('10k')
})
it('should format millions with M suffix', () => {
expect(formatNumberAbbreviated(1000000)).toBe('1M')
expect(formatNumberAbbreviated(1500000)).toBe('1.5M')
expect(formatNumberAbbreviated(2300000)).toBe('2.3M')
expect(formatNumberAbbreviated(999999999)).toBe('1000M')
})
it('should format billions with B suffix', () => {
expect(formatNumberAbbreviated(1000000000)).toBe('1B')
expect(formatNumberAbbreviated(1500000000)).toBe('1.5B')
expect(formatNumberAbbreviated(2300000000)).toBe('2.3B')
})
it('should remove .0 from whole numbers', () => {
expect(formatNumberAbbreviated(1000)).toBe('1k')
expect(formatNumberAbbreviated(2000000)).toBe('2M')
expect(formatNumberAbbreviated(3000000000)).toBe('3B')
})
it('should keep decimal for non-whole numbers', () => {
expect(formatNumberAbbreviated(1100)).toBe('1.1k')
expect(formatNumberAbbreviated(1500000)).toBe('1.5M')
expect(formatNumberAbbreviated(2700000000)).toBe('2.7B')
})
it('should handle edge cases', () => {
expect(formatNumberAbbreviated(950)).toBe('950')
expect(formatNumberAbbreviated(1001)).toBe('1k')
expect(formatNumberAbbreviated(999999)).toBe('1000k')
})
})
describe('formatNumber edge cases', () => {
it('should handle very large numbers', () => {
expect(formatNumber(1234567890123)).toBe('1,234,567,890,123')
})
it('should handle numbers with many decimal places', () => {
expect(formatNumber(1234.56789)).toBe('1,234.56789')
})
it('should handle negative decimals', () => {
expect(formatNumber(-1234.56)).toBe('-1,234.56')
})
it('should handle string with decimals', () => {
expect(formatNumber('9876543.21')).toBe('9,876,543.21')
})
})
describe('formatFileSize edge cases', () => {
it('should handle exactly 1024 bytes', () => {
expect(formatFileSize(1024)).toBe('1.00 KB')
})
it('should handle fractional bytes', () => {
expect(formatFileSize(512.5)).toBe('512.50 bytes')
})
})
describe('formatTime edge cases', () => {
it('should handle exactly 60 seconds', () => {
expect(formatTime(60)).toBe('1.00 min')
})
it('should handle exactly 3600 seconds', () => {
expect(formatTime(3600)).toBe('1.00 h')
})
it('should handle fractional seconds', () => {
expect(formatTime(45.5)).toBe('45.50 sec')
})
it('should handle very large durations', () => {
expect(formatTime(86400)).toBe('24.00 h') // 24 hours
})
})

View File

@ -0,0 +1,49 @@
/**
* Test suite for icon utility functions
* Tests the generation of marketplace plugin icon URLs
*/
import { getIconFromMarketPlace } from './get-icon'
import { MARKETPLACE_API_PREFIX } from '@/config'
describe('get-icon', () => {
describe('getIconFromMarketPlace', () => {
/**
* Tests basic URL generation for marketplace plugin icons
*/
test('returns correct marketplace icon URL', () => {
const pluginId = 'test-plugin-123'
const result = getIconFromMarketPlace(pluginId)
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
})
/**
* Tests URL generation with plugin IDs containing special characters
* like dashes and underscores
*/
test('handles plugin ID with special characters', () => {
const pluginId = 'plugin-with-dashes_and_underscores'
const result = getIconFromMarketPlace(pluginId)
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
})
/**
* Tests behavior with empty plugin ID
* Note: This creates a malformed URL but doesn't throw an error
*/
test('handles empty plugin ID', () => {
const pluginId = ''
const result = getIconFromMarketPlace(pluginId)
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins//icon`)
})
/**
* Tests URL generation with plugin IDs containing spaces
* Spaces will be URL-encoded when actually used
*/
test('handles plugin ID with spaces', () => {
const pluginId = 'plugin with spaces'
const result = getIconFromMarketPlace(pluginId)
expect(result).toBe(`${MARKETPLACE_API_PREFIX}/plugins/${pluginId}/icon`)
})
})
})

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