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_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
MULTIMODAL_SEND_FORMAT=base64
PROMPT_GENERATION_MAX_TOKENS=512

View File

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

View File

@ -331,6 +331,31 @@ class FileUploadConfig(BaseSettings):
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):
"""

View File

@ -22,6 +22,11 @@ class WeaviateConfig(BaseSettings):
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(
description="Number of objects to be processed in a single batch operation (default is 100)",
default=100,

View File

@ -25,6 +25,12 @@ class UnsupportedFileTypeError(BaseHTTPException):
code = 415
class BlockedFileExtensionError(BaseHTTPException):
error_code = "file_extension_blocked"
description = "The file extension is blocked for security reasons."
code = 400
class TooManyFilesError(BaseHTTPException):
error_code = "too_many_files"
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(403, "Permission denied")
@edit_permission_required

View File

@ -8,6 +8,7 @@ import services
from configs import dify_config
from constants import DOCUMENT_EXTENSIONS
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
@ -83,6 +84,8 @@ class FileApi(Resource):
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
raise BlockedFileExtensionError(blocked_extension_error.description)
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
# If caller needs end-user context, attach EndUser to current_user
if fetch_user_arg:
if fetch_user_arg.fetch_from == WhereisUserArg.QUERY:
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:
user_id = request.form.get("user")
else:
# use default-user
user_id = None
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
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
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)

View File

@ -140,7 +140,27 @@ class MessageCycleManager:
if not self._application_generate_entity.app_config.additional_features:
raise ValueError("Additional features not found")
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:
"""

View File

@ -6,10 +6,7 @@ from core.helper.code_executor.template_transformer import TemplateTransformer
class NodeJsTemplateTransformer(TemplateTransformer):
@classmethod
def get_runner_script(cls) -> str:
runner_script = dedent(
f"""
// declare main function
{cls._code_placeholder}
runner_script = dedent(f""" {cls._code_placeholder}
// decode and prepare input object
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 result = `<<RESULT>>${{output_json}}<<RESULT>>`
console.log(result)
"""
)
""")
return runner_script

View File

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

View File

@ -39,11 +39,13 @@ class WeaviateConfig(BaseModel):
Attributes:
endpoint: Weaviate server endpoint URL
grpc_endpoint: Optional Weaviate gRPC server endpoint URL
api_key: Optional API key for authentication
batch_size: Number of objects to batch per insert operation
"""
endpoint: str
grpc_endpoint: str | None = None
api_key: str | None = None
batch_size: int = 100
@ -88,9 +90,22 @@ class WeaviateVector(BaseVector):
http_secure = p.scheme == "https"
http_port = p.port or (443 if http_secure else 80)
grpc_host = host
grpc_secure = http_secure
grpc_port = 443 if grpc_secure else 50051
# Parse gRPC configuration
if config.grpc_endpoint:
# 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(
http_host=host,
@ -432,6 +447,7 @@ class WeaviateVectorFactory(AbstractVectorFactory):
collection_name=collection_name,
config=WeaviateConfig(
endpoint=dify_config.WEAVIATE_ENDPOINT or "",
grpc_endpoint=dify_config.WEAVIATE_GRPC_ENDPOINT or "",
api_key=dify_config.WEAVIATE_API_KEY,
batch_size=dify_config.WEAVIATE_BATCH_SIZE,
),

View File

@ -210,12 +210,13 @@ class Tool(ABC):
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
"""
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(

View File

@ -129,6 +129,7 @@ class ToolInvokeMessage(BaseModel):
class JsonMessage(BaseModel):
json_object: dict
suppress_output: bool = Field(default=False, description="Whether to suppress JSON output in result string")
class BlobMessage(BaseModel):
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."
)
elif response.type == ToolInvokeMessage.MessageType.JSON:
json_message = cast(ToolInvokeMessage.JsonMessage, response.message)
if json_message.suppress_output:
continue
json_parts.append(
json.dumps(
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)
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
def latest_usage(self) -> LLMUsage:

View File

@ -11,3 +11,7 @@ class FileTooLargeError(BaseServiceError):
class UnsupportedFileTypeError(BaseServiceError):
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.model import EndUser, UploadFile
from .errors.file import FileTooLargeError, UnsupportedFileTypeError
from .errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError
PREVIEW_WORDS_LIMIT = 3000
@ -59,6 +59,10 @@ class FileService:
if len(filename) > 200:
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:
raise UnsupportedFileTypeError()

View File

@ -11,7 +11,7 @@ from configs import dify_config
from models import Account, Tenant
from models.enums import CreatorUserRole
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
@ -943,3 +943,150 @@ class TestFileService:
# Should have the signed URL when source_url is empty
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`.
WEAVIATE_ENDPOINT=http://weaviate:8080
WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
WEAVIATE_GRPC_ENDPOINT=grpc://weaviate:50051
# The Qdrant endpoint URL. Only available when VECTOR_STORE is `qdrant`.
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.
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`
# `dify` Dify's proprietary 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}
WEAVIATE_ENDPOINT: ${WEAVIATE_ENDPOINT:-http://weaviate:8080}
WEAVIATE_API_KEY: ${WEAVIATE_API_KEY:-WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih}
WEAVIATE_GRPC_ENDPOINT: ${WEAVIATE_GRPC_ENDPOINT:-grpc://weaviate:50051}
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
QDRANT_API_KEY: ${QDRANT_API_KEY:-difyai123456}
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}
UPLOAD_FILE_SIZE_LIMIT: ${UPLOAD_FILE_SIZE_LIMIT:-15}
UPLOAD_FILE_BATCH_LIMIT: ${UPLOAD_FILE_BATCH_LIMIT:-5}
UPLOAD_FILE_EXTENSION_BLACKLIST: ${UPLOAD_FILE_EXTENSION_BLACKLIST:-}
ETL_TYPE: ${ETL_TYPE:-dify}
UNSTRUCTURED_API_URL: ${UNSTRUCTURED_API_URL:-}
UNSTRUCTURED_API_KEY: ${UNSTRUCTURED_API_KEY:-}

View File

@ -759,4 +759,104 @@ export default translation`
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')
})
})
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>
</Button>
</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]'>
{moreOperations.map(item => <div
key={item.id}

View File

@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
<div className='flex h-6 items-center gap-1'>
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
{!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>
)}

View File

@ -49,7 +49,7 @@ const InputsFormContent = ({ showTip }: Props) => {
<div className='flex h-6 items-center gap-1'>
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
{!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>
)}

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

View File

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

View File

@ -7,11 +7,30 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import type { FileResponse } from '@/types/workflow'
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 = {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void
onErrorCallback: () => void
onErrorCallback: (error?: any) => void
}
type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void
export const fileUpload: FileUpload = ({
@ -37,8 +56,8 @@ export const fileUpload: FileUpload = ({
.then((res: { id: string }) => {
onSuccessCallback(res)
})
.catch(() => {
onErrorCallback()
.catch((error) => {
onErrorCallback(error)
})
}

View File

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

View File

@ -1,10 +1,29 @@
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 = {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void
onErrorCallback: () => void
onErrorCallback: (error?: any) => void
}
type ImageUpload = (v: ImageUploadParams, isPublic?: boolean, url?: string) => void
export const imageUpload: ImageUpload = ({
@ -30,7 +49,7 @@ export const imageUpload: ImageUpload = ({
.then((res: { id: string }) => {
onSuccessCallback(res)
})
.catch(() => {
onErrorCallback()
.catch((error) => {
onErrorCallback(error)
})
}

View File

@ -16,7 +16,7 @@ import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { useProviderContext } from '@/context/provider-context'
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 { BubbleTextMod } from '@/app/components/base/icons/src/vender/solid/communication'
import {
@ -67,8 +67,9 @@ const CustomWebAppBrand = () => {
setUploadProgress(100)
setFileId(res.id)
},
onErrorCallback: () => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
onErrorCallback: (error?: any) => {
const errorMessage = getImageUploadErrorMessage(error, t('common.imageUploader.uploadFromComputerUploadError'), t)
notify({ type: 'error', message: errorMessage })
setUploadProgress(-1)
},
}, 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 { Theme } from '@/types/app'
import useTheme from '@/hooks/use-theme'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
type IFileUploaderProps = {
fileList: FileItem[]
@ -134,7 +135,8 @@ const FileUploader = ({
return Promise.resolve({ ...completeFile })
})
.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)
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 { ToastContext } from '@/app/components/base/toast'
import { upload } from '@/service/base'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { IS_CE_EDITION } from '@/config'
@ -154,7 +155,8 @@ const LocalFile = ({
return Promise.resolve({ ...completeFile })
})
.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)
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 type { FileItem } from '@/models/datasets'
import { upload } from '@/service/base'
import { getFileUploadErrorMessage } from '@/app/components/base/file-uploader/utils'
import useSWR from 'swr'
import { fetchFileUploadConfig } from '@/service/common'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
@ -74,7 +75,8 @@ const CSVUploader: FC<Props> = ({
return Promise.resolve({ ...completeFile })
})
.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 = {
...fileItem,
progress: -2,

View File

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

View File

@ -7,7 +7,7 @@ import { useTheme } from 'next-themes'
import { setLocaleOnClient } from '@/i18n-config'
import { themeCommand } from './theme'
import { languageCommand } from './language'
import { feedbackCommand } from './feedback'
import { forumCommand } from './forum'
import { docsCommand } from './docs'
import { communityCommand } from './community'
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
slashCommandRegistry.register(themeCommand, { setTheme: deps.setTheme })
slashCommandRegistry.register(languageCommand, { setLocale: deps.setLocale })
slashCommandRegistry.register(feedbackCommand, {})
slashCommandRegistry.register(forumCommand, {})
slashCommandRegistry.register(docsCommand, {})
slashCommandRegistry.register(communityCommand, {})
slashCommandRegistry.register(accountCommand, {})
@ -44,7 +44,7 @@ export const unregisterSlashCommands = () => {
// Remove command handlers from registry system (automatically calls each command's unregister method)
slashCommandRegistry.unregister('theme')
slashCommandRegistry.unregister('language')
slashCommandRegistry.unregister('feedback')
slashCommandRegistry.unregister('forum')
slashCommandRegistry.unregister('docs')
slashCommandRegistry.unregister('community')
slashCommandRegistry.unregister('account')

View File

@ -1,5 +1,5 @@
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 Link from 'next/link'
import { useTranslation } from 'react-i18next'
@ -86,10 +86,10 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
className={cn(itemClassName, 'group justify-between',
'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'>
<RiFeedbackLine className='size-4 shrink-0 text-text-tertiary' />
<div className='system-md-regular grow px-1 text-text-secondary'>{t('common.userProfile.communityFeedback')}</div>
<RiDiscussLine className='size-4 shrink-0 text-text-tertiary' />
<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' />
</Link>
</MenuItem>

View File

@ -73,7 +73,7 @@ const DetailHeader = ({
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const {
installation_id,
id,
source,
tenant_id,
version,
@ -197,7 +197,7 @@ const DetailHeader = ({
const handleDelete = useCallback(async () => {
showDeleting()
const res = await uninstallPlugin(installation_id)
const res = await uninstallPlugin(id)
hideDeleting()
if (res.success) {
hideDeleteConfirm()
@ -207,7 +207,7 @@ const DetailHeader = ({
if (PluginType.tool.includes(category))
invalidateAllToolProviders()
}
}, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
}, [showDeleting, id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
return (
<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={
<div>
{t(`${i18nPrefix}.deleteContentLeft`)}<span className='system-md-semibold'>{label[locale]}</span>{t(`${i18nPrefix}.deleteContentRight`)}<br />
{/* {usedInApps > 0 && t(`${i18nPrefix}.usedInApps`, { num: usedInApps })} */}
</div>
}
onCancel={hideDeleteConfirm}

View File

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

View File

@ -100,7 +100,10 @@ const RunOnce: FC<IRunOnceProps> = ({
: promptConfig.prompt_variables.map(item => (
<div className='mt-4 w-full' key={item.key}>
{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'>
{item.type === 'select' && (
@ -115,7 +118,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'string' && (
<Input
type="text"
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
placeholder={item.name}
value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
@ -124,7 +127,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'paragraph' && (
<Textarea
className='h-[104px] sm:text-xs'
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
placeholder={item.name}
value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
@ -132,7 +135,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'number' && (
<Input
type="number"
placeholder={`${item.name}${!item.required ? `(${t('appDebug.variableTable.optional')})` : ''}`}
placeholder={item.name}
value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>

View File

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

View File

@ -106,7 +106,7 @@ const StatusPanel: FC<ResultProps> = ({
{status === 'failed' && error && (
<>
<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 && (
<>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Espace de travail',
createWorkspace: 'Créer un Espace de Travail',
helpCenter: 'Aide',
communityFeedback: 'Retour d\'information',
roadmap: 'Feuille de route',
community: 'Communauté',
about: 'À propos',
@ -170,6 +169,7 @@ const translation = {
github: 'GitHub',
compliance: 'Conformité',
contactUs: 'Contactez-nous',
forum: 'Forum',
},
settings: {
accountGroup: 'COMPTE',
@ -727,6 +727,7 @@ const translation = {
fileExtensionNotSupport: 'Extension de fichier non prise en charge',
pasteFileLinkInvalid: 'Lien de fichier non valide',
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: {
expiring: 'Expirant dans un jour',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Espaço de trabalho',
createWorkspace: 'Criar Espaço de Trabalho',
helpCenter: 'Ajuda',
communityFeedback: 'Feedback',
roadmap: 'Roteiro',
community: 'Comunidade',
about: 'Sobre',
@ -170,6 +169,7 @@ const translation = {
support: 'Suporte',
compliance: 'Conformidade',
contactUs: 'Contate-Nos',
forum: 'Fórum',
},
settings: {
accountGroup: 'CONTA',
@ -726,6 +726,7 @@ const translation = {
uploadFromComputerReadError: 'Falha na leitura do arquivo, tente novamente.',
uploadFromComputerLimit: 'Carregar arquivo não pode exceder {{size}}',
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: {
expiring: 'Expirando em um dia',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -255,7 +255,6 @@ const translation = {
variableTable: {
key: 'Khóa biến',
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',
action: 'Hành động',
typeString: 'Chuỗi',

View File

@ -161,7 +161,6 @@ const translation = {
workspace: 'Không gian làm việc',
createWorkspace: 'Tạo Không gian làm việc',
helpCenter: 'Trung tâm trợ giúp',
communityFeedback: 'Phản hồi',
roadmap: 'Lộ trình',
community: 'Cộng đồng',
about: 'Về chúng tôi',
@ -170,6 +169,7 @@ const translation = {
github: 'GitHub',
support: 'Hỗ trợ',
contactUs: 'Liên hệ với chúng tôi',
forum: 'Diễn đàn',
},
settings: {
accountGroup: 'TÀI KHOẢN',
@ -726,6 +726,7 @@ const translation = {
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.',
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: {
expiring_plural: 'Hết hạn sau {{count}} ngày',

View File

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

View File

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

View File

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

View File

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

View File

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

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')
})
})
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'
describe('classnames', () => {
/**
* Tests basic classnames library features:
* - String concatenation
* - Array handling
* - Falsy value filtering
* - Object-based conditional classes
*/
test('classnames libs feature', () => {
expect(cn('foo')).toBe('foo')
expect(cn('foo', 'bar')).toBe('foo bar')
@ -17,6 +29,14 @@ describe('classnames', () => {
})).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', () => {
/* eslint-disable tailwindcss/classnames-order */
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')
})
/**
* Tests the integration of classnames and tailwind-merge:
* - Object-based conditional classes with Tailwind conflict resolution
*/
test('classnames combined with tailwind-merge', () => {
expect(cn('text-right', {
'text-center': true,
@ -53,4 +77,81 @@ describe('classnames', () => {
'text-center': false,
})).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', () => {
test('should correctly format integers', () => {
@ -102,3 +102,95 @@ describe('downloadFile', () => {
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