merge main

This commit is contained in:
zxhlyh
2025-07-22 13:52:24 +08:00
40 changed files with 1066 additions and 158 deletions

View File

@@ -47,6 +47,8 @@ RUN \
curl nodejs libgmp-dev libmpfr-dev libmpc-dev \
# For Security
expat libldap-2.5-0 perl libsqlite3-0 zlib1g \
# install fonts to support the use of tools like pypdfium2
fonts-noto-cjk \
# install a package to improve the accuracy of guessing mime type and file extension
media-types \
# install libmagic to support the use of python-magic guess MIMETYPE

View File

@@ -69,7 +69,7 @@ class MCPClient:
parsed_url = urlparse(self.server_url)
path = parsed_url.path or ""
method_name = path.removesuffix("/").lower()
method_name = path.rstrip("/").split("/")[-1] if path else ""
if method_name in connection_methods:
client_factory = connection_methods[method_name]
self.connect_server(client_factory, method_name)

View File

@@ -32,6 +32,13 @@ class MarketplacePluginDeclaration(BaseModel):
latest_package_identifier: str = Field(
..., description="Unique identifier for the latest package release of the plugin"
)
status: str = Field(..., description="Indicate the status of marketplace plugin, enum from `active` `deleted`")
deprecated_reason: str = Field(
..., description="Not empty when status='deleted', indicates the reason why this plugin is deleted(deprecated)"
)
alternative_plugin_id: str = Field(
..., description="Optional, indicates the alternative plugin for user to switch to"
)
@model_validator(mode="before")
@classmethod

View File

@@ -206,9 +206,19 @@ class TencentVector(BaseVector):
def delete_by_ids(self, ids: list[str]) -> None:
if not ids:
return
self._client.delete(
database_name=self._client_config.database, collection_name=self.collection_name, document_ids=ids
)
total_count = len(ids)
batch_size = self._client_config.max_upsert_batch_size
batch = math.ceil(total_count / batch_size)
for j in range(batch):
start_idx = j * batch_size
end_idx = min(total_count, (j + 1) * batch_size)
batch_ids = ids[start_idx:end_idx]
self._client.delete(
database_name=self._client_config.database, collection_name=self.collection_name, document_ids=batch_ids
)
def delete_by_metadata_field(self, key: str, value: str) -> None:
self._client.delete(

View File

@@ -486,7 +486,7 @@ class AgentNode(BaseNode):
text = ""
files: list[File] = []
json: list[dict] = []
json_list: list[dict] = []
agent_logs: list[AgentLogEvent] = []
agent_execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] = {}
@@ -564,7 +564,7 @@ class AgentNode(BaseNode):
if key in WorkflowNodeExecutionMetadataKey.__members__.values()
}
if message.message.json_object is not None:
json.append(message.message.json_object)
json_list.append(message.message.json_object)
elif message.type == ToolInvokeMessage.MessageType.LINK:
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
stream_text = f"Link: {message.message.text}\n"
@@ -676,8 +676,8 @@ class AgentNode(BaseNode):
}
)
# Step 2: normalize JSON into {"data": [...]}.change json to list[dict]
if json:
json_output.extend(json)
if json_list:
json_output.extend(json_list)
else:
json_output.append({"data": []})

View File

@@ -163,6 +163,7 @@ class WorkflowEntry:
graph=graph,
graph_runtime_state=GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()),
)
node.init_node_data(node_config_data)
try:
# variable selector to variable mapping
@@ -273,6 +274,7 @@ class WorkflowEntry:
graph=graph,
graph_runtime_state=GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()),
)
node.init_node_data(node_data)
try:
# variable selector to variable mapping

View File

@@ -1,41 +0,0 @@
"""empty message
Revision ID: 16081485540c
Revises: d28f2004b072
Create Date: 2025-05-15 16:35:39.113777
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '16081485540c'
down_revision = '2adcbe1f5dfb'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tenant_plugin_auto_upgrade_strategies',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('strategy_setting', sa.String(length=16), server_default='fix_only', nullable=False),
sa.Column('upgrade_time_of_day', sa.Integer(), nullable=False),
sa.Column('upgrade_mode', sa.String(length=16), server_default='exclude', nullable=False),
sa.Column('exclude_plugins', sa.ARRAY(sa.String(length=255)), nullable=False),
sa.Column('include_plugins', sa.ARRAY(sa.String(length=255)), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='tenant_plugin_auto_upgrade_strategy_pkey'),
sa.UniqueConstraint('tenant_id', name='unique_tenant_plugin_auto_upgrade_strategy')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tenant_plugin_auto_upgrade_strategies')
# ### end Alembic commands ###

View File

@@ -12,7 +12,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4474872b0ee6'
down_revision = '16081485540c'
down_revision = '2adcbe1f5dfb'
branch_labels = None
depends_on = None

View File

@@ -38,6 +38,9 @@ class PluginService:
plugin_id: str
version: str
unique_identifier: str
status: str
deprecated_reason: str
alternative_plugin_id: str
REDIS_KEY_PREFIX = "plugin_service:latest_plugin:"
REDIS_TTL = 60 * 5 # 5 minutes
@@ -71,6 +74,9 @@ class PluginService:
plugin_id=plugin_id,
version=manifest.latest_version,
unique_identifier=manifest.latest_package_identifier,
status=manifest.status,
deprecated_reason=manifest.deprecated_reason,
alternative_plugin_id=manifest.alternative_plugin_id,
)
# Store in Redis

View File

@@ -0,0 +1,150 @@
import pytest
from services.auth.auth_type import AuthType
class TestAuthType:
"""Test cases for AuthType enum"""
def test_auth_type_is_str_enum(self):
"""Test that AuthType is properly a StrEnum"""
assert issubclass(AuthType, str)
assert hasattr(AuthType, "__members__")
def test_auth_type_has_expected_values(self):
"""Test that all expected auth types exist with correct values"""
expected_values = {
"FIRECRAWL": "firecrawl",
"WATERCRAWL": "watercrawl",
"JINA": "jinareader",
}
# Verify all expected members exist
for member_name, expected_value in expected_values.items():
assert hasattr(AuthType, member_name)
assert getattr(AuthType, member_name).value == expected_value
# Verify no extra members exist
assert len(AuthType) == len(expected_values)
@pytest.mark.parametrize(
("auth_type", "expected_string"),
[
(AuthType.FIRECRAWL, "firecrawl"),
(AuthType.WATERCRAWL, "watercrawl"),
(AuthType.JINA, "jinareader"),
],
)
def test_auth_type_string_representation(self, auth_type, expected_string):
"""Test string representation of auth types"""
assert str(auth_type) == expected_string
assert auth_type.value == expected_string
@pytest.mark.parametrize(
("auth_type", "compare_value", "expected_result"),
[
(AuthType.FIRECRAWL, "firecrawl", True),
(AuthType.WATERCRAWL, "watercrawl", True),
(AuthType.JINA, "jinareader", True),
(AuthType.FIRECRAWL, "FIRECRAWL", False), # Case sensitive
(AuthType.FIRECRAWL, "watercrawl", False),
(AuthType.JINA, "jina", False), # Full value mismatch
],
)
def test_auth_type_comparison(self, auth_type, compare_value, expected_result):
"""Test auth type comparison with strings"""
assert (auth_type == compare_value) is expected_result
def test_auth_type_iteration(self):
"""Test that AuthType can be iterated over"""
auth_types = list(AuthType)
assert len(auth_types) == 3
assert AuthType.FIRECRAWL in auth_types
assert AuthType.WATERCRAWL in auth_types
assert AuthType.JINA in auth_types
def test_auth_type_membership(self):
"""Test membership checking for AuthType"""
assert "firecrawl" in [auth.value for auth in AuthType]
assert "watercrawl" in [auth.value for auth in AuthType]
assert "jinareader" in [auth.value for auth in AuthType]
assert "invalid" not in [auth.value for auth in AuthType]
def test_auth_type_invalid_attribute_access(self):
"""Test accessing non-existent auth type raises AttributeError"""
with pytest.raises(AttributeError):
_ = AuthType.INVALID_TYPE
def test_auth_type_immutability(self):
"""Test that enum values cannot be modified"""
# In Python 3.11+, enum members are read-only
with pytest.raises(AttributeError):
AuthType.FIRECRAWL = "modified"
def test_auth_type_from_value(self):
"""Test creating AuthType from string value"""
assert AuthType("firecrawl") == AuthType.FIRECRAWL
assert AuthType("watercrawl") == AuthType.WATERCRAWL
assert AuthType("jinareader") == AuthType.JINA
# Test invalid value
with pytest.raises(ValueError) as exc_info:
AuthType("invalid_auth_type")
assert "invalid_auth_type" in str(exc_info.value)
def test_auth_type_name_property(self):
"""Test the name property of enum members"""
assert AuthType.FIRECRAWL.name == "FIRECRAWL"
assert AuthType.WATERCRAWL.name == "WATERCRAWL"
assert AuthType.JINA.name == "JINA"
@pytest.mark.parametrize(
"auth_type",
[AuthType.FIRECRAWL, AuthType.WATERCRAWL, AuthType.JINA],
)
def test_auth_type_isinstance_checks(self, auth_type):
"""Test isinstance checks for auth types"""
assert isinstance(auth_type, AuthType)
assert isinstance(auth_type, str)
assert isinstance(auth_type.value, str)
def test_auth_type_hash(self):
"""Test that auth types are hashable and can be used in sets/dicts"""
auth_set = {AuthType.FIRECRAWL, AuthType.WATERCRAWL, AuthType.JINA}
assert len(auth_set) == 3
auth_dict = {
AuthType.FIRECRAWL: "firecrawl_handler",
AuthType.WATERCRAWL: "watercrawl_handler",
AuthType.JINA: "jina_handler",
}
assert auth_dict[AuthType.FIRECRAWL] == "firecrawl_handler"
def test_auth_type_json_serializable(self):
"""Test that auth types can be JSON serialized"""
import json
auth_data = {
"provider": AuthType.FIRECRAWL,
"enabled": True,
}
# Should serialize to string value
json_str = json.dumps(auth_data, default=str)
assert '"provider": "firecrawl"' in json_str
def test_auth_type_matches_factory_usage(self):
"""Test that all AuthType values are handled by ApiKeyAuthFactory"""
# This test verifies that the enum values match what's expected
# by the factory implementation
from services.auth.api_key_auth_factory import ApiKeyAuthFactory
for auth_type in AuthType:
# Should not raise ValueError for valid auth types
try:
auth_class = ApiKeyAuthFactory.get_apikey_auth_factory(auth_type)
assert auth_class is not None
except ImportError:
# It's OK if the actual auth implementation doesn't exist
# We're just testing that the enum value is recognized
pass

View File

@@ -47,7 +47,7 @@ const AccessControlDialog = ({
>
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
<div onClick={() => close()} className="absolute right-5 top-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
<RiCloseLine className='h-5 w-5' />
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
{children}
</Dialog.Panel>

View File

@@ -72,7 +72,7 @@ export default function AccessControl(props: AccessControlProps) {
</div>
<div className='flex flex-col gap-y-1 px-6 pb-3'>
<div className='leading-6'>
<p className='system-sm-medium'>{t('app.accessControlDialog.accessLabel')}</p>
<p className='system-sm-medium text-text-tertiary'>{t('app.accessControlDialog.accessLabel')}</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className='flex items-center p-3'>

View File

@@ -35,6 +35,22 @@ export type FileUpload = {
number_limits?: number
transfer_methods?: TransferMethod[]
}
document?: EnabledOrDisabled & {
number_limits?: number
transfer_methods?: TransferMethod[]
}
audio?: EnabledOrDisabled & {
number_limits?: number
transfer_methods?: TransferMethod[]
}
video?: EnabledOrDisabled & {
number_limits?: number
transfer_methods?: TransferMethod[]
}
custom?: EnabledOrDisabled & {
number_limits?: number
transfer_methods?: TransferMethod[]
}
allowed_file_types?: string[]
allowed_file_extensions?: string[]
allowed_file_upload_methods?: TransferMethod[]

View File

@@ -580,11 +580,30 @@ The text generation application offers non-session support and is ideal for tran
- `default` (string) Default value
- `options` (array[string]) Option values
- `file_upload` (object) File upload configuration
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods, remote_url, local_file, must choose one
- `document` (object) Document settings
Currently only supports document types: `txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Document number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `audio` (object) Audio settings
Currently only supports audio types: `mp3`, `m4a`, `wav`, `webm`, `amr`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Audio number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `video` (object) Video settings
Currently only supports video types: `mp4`, `mov`, `mpeg`, `mpga`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Video number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `custom` (object) Custom settings
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Custom number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `system_parameters` (object) System parameters
- `file_size_limit` (int) Document upload size limit (MB)
- `image_file_size_limit` (int) Image file upload size limit (MB)

View File

@@ -578,11 +578,30 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
- `default` (string) デフォルト値
- `options` (array[string]) オプション値
- `file_upload` (object) ファイルアップロード設定
- `image` (object) 画像設定
現在は画像タイプのみ対応:`png`、`jpg`、`jpeg`、`webp`、`gif`
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数制限、デフォルトは3
- `transfer_methods` (array[string]) 転送方法リストremote_urllocal_fileいずれかを選択
- `document` (object) ドキュメント設定
現在サポートされているドキュメントタイプ:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ドキュメント数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト`remote_url`, `local_file`。いずれかを選択する必要があります。
- `image` (object) 画像設定
現在サポートされている画像タイプ:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `audio` (object) オーディオ設定
現在サポートされているオーディオタイプ:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) オーディオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `video` (object) ビデオ設定
現在サポートされているビデオタイプ:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ビデオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `custom` (object) カスタム設定
- `enabled` (bool) 有効かどうか
- `number_limits` (int) カスタム数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `system_parameters` (object) システムパラメータ
- `file_size_limit` (int) ドキュメントアップロードサイズ制限MB
- `image_file_size_limit` (int) 画像ファイルアップロードサイズ制限MB

View File

@@ -552,11 +552,30 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `default` (string) 默认值
- `options` (array[string]) 选项值
- `file_upload` (object) 文件上传配置
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 是否
- `number_limits` (int) 图片数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表remote_url , local_file选一个
- `document` (object) 文档设置
当前仅支持文档类型:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 是否启
- `number_limits` (int) 文档数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表`remote_url`, `local_file`,必须选择一个。
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 图片数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `audio` (object) 音频设置
当前仅支持音频类型:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 音频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `video` (object) 视频设置
当前仅支持视频类型:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 视频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `custom` (object) 自定义设置
- `enabled` (bool) 是否启用
- `number_limits` (int) 自定义数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `system_parameters` (object) 系统参数
- `file_size_limit` (int) 文档上传大小限制 (MB)
- `image_file_size_limit` (int) 图片文件上传大小限制MB

View File

@@ -1197,11 +1197,30 @@ Chat applications support session persistence, allowing previous chat history to
- `default` (string) Default value
- `options` (array[string]) Option values
- `file_upload` (object) File upload configuration
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods, remote_url, local_file, must choose one
- `document` (object) Document settings
Currently only supports document types: `txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Document number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `audio` (object) Audio settings
Currently only supports audio types: `mp3`, `m4a`, `wav`, `webm`, `amr`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Audio number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `video` (object) Video settings
Currently only supports video types: `mp4`, `mov`, `mpeg`, `mpga`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Video number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `custom` (object) Custom settings
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Custom number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `system_parameters` (object) System parameters
- `file_size_limit` (int) Document upload size limit (MB)
- `image_file_size_limit` (int) Image file upload size limit (MB)

View File

@@ -1197,11 +1197,30 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
- `default` (string) デフォルト値
- `options` (array[string]) オプション値
- `file_upload` (object) ファイルアップロード設定
- `image` (object) 画像設定
現在サポートされている画像タイプ:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の制限、デフォルトは3
- `transfer_methods` (array[string]) 転送方法リストremote_url, local_fileいずれかを選択する必要があります
- `document` (object) ドキュメント設定
現在サポートされているドキュメントタイプ:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ドキュメント数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト`remote_url`, `local_file`。いずれかを選択する必要があります
- `image` (object) 画像設定
現在サポートされている画像タイプ:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `audio` (object) オーディオ設定
現在サポートされているオーディオタイプ:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) オーディオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `video` (object) ビデオ設定
現在サポートされているビデオタイプ:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ビデオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `custom` (object) カスタム設定
- `enabled` (bool) 有効かどうか
- `number_limits` (int) カスタム数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `system_parameters` (object) システムパラメータ
- `file_size_limit` (int) ドキュメントアップロードサイズ制限MB
- `image_file_size_limit` (int) 画像ファイルアップロードサイズ制限MB

View File

@@ -1229,11 +1229,30 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `default` (string) 默认值
- `options` (array[string]) 选项值
- `file_upload` (object) 文件上传配置
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 是否
- `number_limits` (int) 图片数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表remote_url , local_file选一个
- `document` (object) 文档设置
当前仅支持文档类型:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 是否启
- `number_limits` (int) 文档数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表`remote_url`, `local_file`,必须选择一个。
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 图片数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `audio` (object) 音频设置
当前仅支持音频类型:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 音频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `video` (object) 视频设置
当前仅支持视频类型:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 视频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `custom` (object) 自定义设置
- `enabled` (bool) 是否启用
- `number_limits` (int) 自定义数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `system_parameters` (object) 系统参数
- `file_size_limit` (int) Document upload size limit (MB)
- `image_file_size_limit` (int) Image file upload size limit (MB)

View File

@@ -1234,11 +1234,30 @@ Chat applications support session persistence, allowing previous chat history to
- `default` (string) Default value
- `options` (array[string]) Option values
- `file_upload` (object) File upload configuration
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods, remote_url, local_file, must choose one
- `document` (object) Document settings
Currently only supports document types: `txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Document number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `audio` (object) Audio settings
Currently only supports audio types: `mp3`, `m4a`, `wav`, `webm`, `amr`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Audio number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `video` (object) Video settings
Currently only supports video types: `mp4`, `mov`, `mpeg`, `mpga`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Video number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `custom` (object) Custom settings
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Custom number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `system_parameters` (object) System parameters
- `file_size_limit` (int) Document upload size limit (MB)
- `image_file_size_limit` (int) Image file upload size limit (MB)

View File

@@ -1224,12 +1224,31 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
- `required` (bool) 必須かどうか
- `default` (string) デフォルト値
- `options` (array[string]) オプション値
- `file_upload` (object) ファイルアップロード構成
- `image` (object) 画像設定
現在サポートされている画像タイプ:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の制限、デフォルトは3
- `transfer_methods` (array[string]) 転送方法リストremote_url, local_fileいずれかを選択する必要があります
- `file_upload` (object) ファイルアップロード設定
- `document` (object) ドキュメント設定
現在サポートされているドキュメントタイプ:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ドキュメント数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト`remote_url`, `local_file`。いずれかを選択する必要があります
- `image` (object) 画像設定
現在サポートされている画像タイプ:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `audio` (object) オーディオ設定
現在サポートされているオーディオタイプ:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) オーディオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `video` (object) ビデオ設定
現在サポートされているビデオタイプ:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ビデオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `custom` (object) カスタム設定
- `enabled` (bool) 有効かどうか
- `number_limits` (int) カスタム数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `system_parameters` (object) システムパラメータ
- `file_size_limit` (int) ドキュメントアップロードサイズ制限MB
- `image_file_size_limit` (int) 画像ファイルアップロードサイズ制限MB

View File

@@ -1237,11 +1237,30 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
- `default` (string) 默认值
- `options` (array[string]) 选项值
- `file_upload` (object) 文件上传配置
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 是否
- `number_limits` (int) 图片数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表remote_url , local_file选一个
- `document` (object) 文档设置
当前仅支持文档类型:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 是否启
- `number_limits` (int) 文档数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表`remote_url`, `local_file`,必须选择一个。
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 图片数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `audio` (object) 音频设置
当前仅支持音频类型:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 音频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `video` (object) 视频设置
当前仅支持视频类型:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 视频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `custom` (object) 自定义设置
- `enabled` (bool) 是否启用
- `number_limits` (int) 自定义数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `system_parameters` (object) 系统参数
- `file_size_limit` (int) 文档上传大小限制 (MB)
- `image_file_size_limit` (int) 图片文件上传大小限制MB

View File

@@ -690,11 +690,30 @@ Workflow applications offers non-session support and is ideal for translation, a
- `default` (string) Default value
- `options` (array[string]) Option values
- `file_upload` (object) File upload configuration
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods, remote_url, local_file, must choose one
- `document` (object) Document settings
Currently only supports document types: `txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Document number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `image` (object) Image settings
Currently only supports image types: `png`, `jpg`, `jpeg`, `webp`, `gif`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Image number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `audio` (object) Audio settings
Currently only supports audio types: `mp3`, `m4a`, `wav`, `webm`, `amr`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Audio number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `video` (object) Video settings
Currently only supports video types: `mp4`, `mov`, `mpeg`, `mpga`.
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Video number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `custom` (object) Custom settings
- `enabled` (bool) Whether it is enabled
- `number_limits` (int) Custom number limit, default is 3
- `transfer_methods` (array[string]) List of transfer methods: `remote_url`, `local_file`. Must choose one.
- `system_parameters` (object) System parameters
- `file_size_limit` (int) Document upload size limit (MB)
- `image_file_size_limit` (int) Image file upload size limit (MB)

View File

@@ -691,11 +691,30 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
- `default` (string) デフォルト値
- `options` (array[string]) オプション値
- `file_upload` (object) ファイルアップロード設定
- `image` (object) 画像設定
現在サポートされている画像タイプのみ:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の制限、デフォルトは3
- `transfer_methods` (array[string]) 転送方法リストremote_url, local_fileいずれかを選択する必要があります
- `document` (object) ドキュメント設定
現在サポートされているドキュメントタイプ:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ドキュメント数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト`remote_url`, `local_file`。いずれかを選択する必要があります
- `image` (object) 画像設定
現在サポートされている画像タイプ:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) 画像数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `audio` (object) オーディオ設定
現在サポートされているオーディオタイプ:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) オーディオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `video` (object) ビデオ設定
現在サポートされているビデオタイプ:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 有効かどうか
- `number_limits` (int) ビデオ数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `custom` (object) カスタム設定
- `enabled` (bool) 有効かどうか
- `number_limits` (int) カスタム数の上限。デフォルトは 3
- `transfer_methods` (array[string]) 転送方法リスト:`remote_url`, `local_file`。いずれかを選択する必要があります。
- `system_parameters` (object) システムパラメータ
- `file_size_limit` (int) ドキュメントアップロードサイズ制限MB
- `image_file_size_limit` (int) 画像ファイルアップロードサイズ制限MB

View File

@@ -678,11 +678,30 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
- `default` (string) 默认值
- `options` (array[string]) 选项值
- `file_upload` (object) 文件上传配置
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`
- `enabled` (bool) 是否
- `number_limits` (int) 图片数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表remote_url , local_file选一个
- `document` (object) 文档设置
当前仅支持文档类型:`txt`, `md`, `markdown`, `pdf`, `html`, `xlsx`, `xls`, `docx`, `csv`, `eml`, `msg`, `pptx`, `ppt`, `xml`, `epub`。
- `enabled` (bool) 是否启
- `number_limits` (int) 文档数量限制,默认 3
- `transfer_methods` (array[string]) 传方式列表`remote_url`, `local_file`,必须选择一个。
- `image` (object) 图片设置
当前仅支持图片类型:`png`, `jpg`, `jpeg`, `webp`, `gif`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 图片数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `audio` (object) 音频设置
当前仅支持音频类型:`mp3`, `m4a`, `wav`, `webm`, `amr`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 音频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `video` (object) 视频设置
当前仅支持视频类型:`mp4`, `mov`, `mpeg`, `mpga`。
- `enabled` (bool) 是否启用
- `number_limits` (int) 视频数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `custom` (object) 自定义设置
- `enabled` (bool) 是否启用
- `number_limits` (int) 自定义数量限制,默认为 3
- `transfer_methods` (array[string]) 传输方式列表:`remote_url`, `local_file`,必须选择一个。
- `system_parameters` (object) 系统参数
- `file_size_limit` (int) 文档上传大小限制 (MB)
- `image_file_size_limit` (int) 图片文件上传大小限制MB

View File

@@ -0,0 +1,105 @@
import React, { useMemo } from 'react'
import type { FC } from 'react'
import Link from 'next/link'
import cn from '@/utils/classnames'
import { RiAlertFill } from '@remixicon/react'
import { Trans } from 'react-i18next'
import { snakeCase2CamelCase } from '@/utils/format'
import { useMixedTranslation } from '../marketplace/hooks'
type DeprecationNoticeProps = {
status: 'deleted' | 'active'
deprecatedReason: string
alternativePluginId: string
alternativePluginURL: string
locale?: string
className?: string
innerWrapperClassName?: string
iconWrapperClassName?: string
textClassName?: string
}
const i18nPrefix = 'plugin.detailPanel.deprecation'
const DeprecationNotice: FC<DeprecationNoticeProps> = ({
status,
deprecatedReason,
alternativePluginId,
alternativePluginURL,
locale,
className,
innerWrapperClassName,
iconWrapperClassName,
textClassName,
}) => {
const { t } = useMixedTranslation(locale)
const deprecatedReasonKey = useMemo(() => {
if (!deprecatedReason) return ''
return snakeCase2CamelCase(deprecatedReason)
}, [deprecatedReason])
// Check if the deprecatedReasonKey exists in i18n
const hasValidDeprecatedReason = useMemo(() => {
if (!deprecatedReason || !deprecatedReasonKey) return false
// Define valid reason keys that exist in i18n
const validReasonKeys = ['businessAdjustments', 'ownershipTransferred', 'noMaintainer']
return validReasonKeys.includes(deprecatedReasonKey)
}, [deprecatedReason, deprecatedReasonKey])
if (status !== 'deleted')
return null
return (
<div className={cn('w-full', className)}>
<div className={cn(
'relative flex items-start gap-x-0.5 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-2 shadow-xs shadow-shadow-shadow-3 backdrop-blur-[5px]',
innerWrapperClassName,
)}>
<div className='absolute left-0 top-0 -z-10 h-full w-full bg-toast-warning-bg opacity-40' />
<div className={cn('flex size-6 shrink-0 items-center justify-center', iconWrapperClassName)}>
<RiAlertFill className='size-4 text-text-warning-secondary' />
</div>
<div className={cn('system-xs-regular grow py-1 text-text-primary', textClassName)}>
{
hasValidDeprecatedReason && alternativePluginId && (
<Trans
t={t}
i18nKey={`${i18nPrefix}.fullMessage`}
components={{
CustomLink: (
<Link
href={alternativePluginURL}
target='_blank'
rel='noopener noreferrer'
className='underline'
/>
),
}}
values={{
deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`),
alternativePluginId,
}}
/>
)
}
{
hasValidDeprecatedReason && !alternativePluginId && (
<span>
{t(`${i18nPrefix}.onlyReason`, { deprecatedReason: t(`${i18nPrefix}.reason.${deprecatedReasonKey}`) })}
</span>
)
}
{
!hasValidDeprecatedReason && (
<span>{t(`${i18nPrefix}.noReason`)}</span>
)
}
</div>
</div>
</div>
)
}
export default React.memo(DeprecationNotice)

View File

@@ -6,7 +6,7 @@ import PluginTypeSwitch from './plugin-type-switch'
import ListWrapper from './list/list-wrapper'
import type { SearchParams } from './types'
import { getMarketplaceCollectionsAndPlugins } from './utils'
import { TanstackQueryIniter } from '@/context/query-client'
import { TanstackQueryInitializer } from '@/context/query-client'
type MarketplaceProps = {
locale: string
@@ -39,7 +39,7 @@ const Marketplace = async ({
}
return (
<TanstackQueryIniter>
<TanstackQueryInitializer>
<MarketplaceContextProvider
searchParams={searchParams}
shouldExclude={shouldExclude}
@@ -65,7 +65,7 @@ const Marketplace = async ({
showInstallButton={showInstallButton}
/>
</MarketplaceContextProvider>
</TanstackQueryIniter>
</TanstackQueryInitializer>
)
}

View File

@@ -29,7 +29,7 @@ import Toast from '@/app/components/base/toast'
import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin'
import { Github } from '@/app/components/base/icons/src/public/common'
import { uninstallPlugin } from '@/service/plugins'
import { useGetLanguage } from '@/context/i18n'
import { useGetLanguage, useI18N } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useInvalidateAllToolProviders } from '@/service/use-tools'
@@ -39,6 +39,7 @@ import { getMarketplaceUrl } from '@/utils/var'
import { PluginAuth } from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { useAllToolProviders } from '@/service/use-tools'
import DeprecationNotice from '../base/deprecation-notice'
const i18nPrefix = 'plugin.action'
@@ -56,6 +57,7 @@ const DetailHeader = ({
const { t } = useTranslation()
const { theme } = useTheme()
const locale = useGetLanguage()
const { locale: currentLocale } = useI18N()
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
@@ -70,6 +72,9 @@ const DetailHeader = ({
latest_version,
meta,
plugin_id,
status,
deprecated_reason,
alternative_plugin_id,
} = detail
const { author, category, name, label, description, icon, verified, tool } = detail.declaration
const isTool = category === PluginType.tool
@@ -98,7 +103,7 @@ const DetailHeader = ({
if (isFromGitHub)
return `https://github.com/${meta!.repo}`
if (isFromMarketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: currentLocale, theme })
return ''
}, [author, isFromGitHub, isFromMarketplace, meta, name, theme])
@@ -272,6 +277,15 @@ const DetailHeader = ({
</ActionButton>
</div>
</div>
{isFromMarketplace && (
<DeprecationNotice
status={status}
deprecatedReason={deprecated_reason}
alternativePluginId={alternative_plugin_id}
alternativePluginURL={getMarketplaceUrl(`/plugins/${alternative_plugin_id}`, { language: currentLocale, theme })}
className='mt-3'
/>
)}
<Description className='mb-2 mt-3 h-auto' text={description[locale]} descriptionLineRows={2}></Description>
{
category === PluginType.tool && (

View File

@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTheme } from 'next-themes'
import {
RiArrowRightUpLine,
@@ -55,6 +55,8 @@ const PluginItem: FC<Props> = ({
endpoints_active,
meta,
plugin_id,
status,
deprecated_reason,
} = plugin
const { category, author, name, label, description, icon, verified, meta: declarationMeta } = plugin.declaration
@@ -70,9 +72,14 @@ const PluginItem: FC<Props> = ({
return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0')
}, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version])
const handleDelete = () => {
const isDeprecated = useMemo(() => {
return status === 'deleted' && !!deprecated_reason
}, [status, deprecated_reason])
const handleDelete = useCallback(() => {
refreshPluginList({ category } as any)
}
}, [category, refreshPluginList])
const getValueFromI18nObject = useRenderI18nObject()
const title = getValueFromI18nObject(label)
const descriptionText = getValueFromI18nObject(description)
@@ -81,7 +88,7 @@ const PluginItem: FC<Props> = ({
return (
<div
className={cn(
'rounded-xl border-[1.5px] border-background-section-burn p-1',
'relative overflow-hidden rounded-xl border-[1.5px] border-background-section-burn p-1',
currentPluginID === plugin_id && 'border-components-option-card-option-selected-border',
source === PluginSource.debugging
? 'bg-[repeating-linear-gradient(-45deg,rgba(16,24,40,0.04),rgba(16,24,40,0.04)_5px,rgba(0,0,0,0.02)_5px,rgba(0,0,0,0.02)_10px)]'
@@ -91,10 +98,10 @@ const PluginItem: FC<Props> = ({
setCurrentPluginID(plugin.plugin_id)
}}
>
<div className={cn('hover-bg-components-panel-on-panel-item-bg relative rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}>
<div className={cn('hover-bg-components-panel-on-panel-item-bg relative z-10 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 pb-3 shadow-xs', className)}>
<CornerMark text={categoriesMap[category].label} />
{/* Header */}
<div className="flex">
<div className='flex'>
<div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'>
<img
className='h-full w-full'
@@ -102,13 +109,13 @@ const PluginItem: FC<Props> = ({
alt={`plugin-${plugin_unique_identifier}-logo`}
/>
</div>
<div className="ml-3 w-0 grow">
<div className="flex h-5 items-center">
<div className='ml-3 w-0 grow'>
<div className='flex h-5 items-center'>
<Title title={title} />
{verified && <RiVerifiedBadgeLine className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" />}
{verified && <RiVerifiedBadgeLine className='ml-0.5 h-4 w-4 shrink-0 text-text-accent' />}
{!isDifyVersionCompatible && <Tooltip popupContent={
t('plugin.difyVersionNotCompatible', { minimalDifyVersion: declarationMeta.minimum_dify_version })
}><RiErrorWarningLine color='red' className="ml-0.5 h-4 w-4 shrink-0 text-text-accent" /></Tooltip>}
}><RiErrorWarningLine color='red' className='ml-0.5 h-4 w-4 shrink-0 text-text-accent' /></Tooltip>}
<Badge className='ml-1 shrink-0'
text={source === PluginSource.github ? plugin.meta!.version : plugin.version}
hasRedCornerMark={(source === PluginSource.marketplace) && !!plugin.latest_version && plugin.latest_version !== plugin.version}
@@ -135,10 +142,11 @@ const PluginItem: FC<Props> = ({
</div>
</div>
</div>
<div className='mb-1 mt-1.5 flex h-4 items-center justify-between px-4'>
<div className='flex items-center'>
<div className='mb-1 mt-1.5 flex h-4 items-center gap-x-2 px-4'>
{/* Organization & Name */}
<div className='flex grow items-center overflow-hidden'>
<OrgInfo
className="mt-0.5"
className='mt-0.5'
orgName={orgName}
packageName={name}
packageNameClassName='w-auto max-w-[150px]'
@@ -146,15 +154,20 @@ const PluginItem: FC<Props> = ({
{category === PluginType.extension && (
<>
<div className='system-xs-regular mx-2 text-text-quaternary'>·</div>
<div className='system-xs-regular flex space-x-1 text-text-tertiary'>
<RiLoginCircleLine className='h-4 w-4' />
<span>{t('plugin.endpointsEnabled', { num: endpoints_active })}</span>
<div className='system-xs-regular flex space-x-1 overflow-hidden text-text-tertiary'>
<RiLoginCircleLine className='h-4 w-4 shrink-0' />
<span
className='truncate'
title={t('plugin.endpointsEnabled', { num: endpoints_active })}
>
{t('plugin.endpointsEnabled', { num: endpoints_active })}
</span>
</div>
</>
)}
</div>
<div className='flex items-center'>
{/* Source */}
<div className='flex shrink-0 items-center'>
{source === PluginSource.github
&& <>
<a href={`https://github.com/${meta!.repo}`} target='_blank' className='flex items-center gap-1'>
@@ -192,7 +205,20 @@ const PluginItem: FC<Props> = ({
</>
}
</div>
{/* Deprecated */}
{source === PluginSource.marketplace && enable_marketplace && isDeprecated && (
<div className='system-2xs-medium-uppercase flex shrink-0 items-center gap-x-2'>
<span className='text-text-tertiary'>·</span>
<span className='text-text-warning'>
{t('plugin.deprecated')}
</span>
</div>
)}
</div>
{/* BG Effect for Deprecated Plugin */}
{source === PluginSource.marketplace && enable_marketplace && isDeprecated && (
<div className='absolute bottom-[-71px] right-[-45px] z-0 size-40 bg-components-badge-status-light-warning-halo opacity-60 blur-[120px]' />
)}
</div>
)
}

View File

@@ -36,6 +36,9 @@ const PluginsPanel = () => {
...plugin,
latest_version: installedLatestVersion?.versions[plugin.plugin_id]?.version ?? '',
latest_unique_identifier: installedLatestVersion?.versions[plugin.plugin_id]?.unique_identifier ?? '',
status: installedLatestVersion?.versions[plugin.plugin_id]?.status ?? 'active',
deprecated_reason: installedLatestVersion?.versions[plugin.plugin_id]?.deprecated_reason ?? '',
alternative_plugin_id: installedLatestVersion?.versions[plugin.plugin_id]?.alternative_plugin_id ?? '',
})) || []
}, [pluginList, installedLatestVersion])
@@ -66,20 +69,25 @@ const PluginsPanel = () => {
onFilterChange={handleFilterChange}
/>
</div>
{isPluginListLoading ? <Loading type='app' /> : (filteredList?.length ?? 0) > 0 ? (
<div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'>
<div className='w-full'>
<List pluginList={filteredList || []} />
</div>
{!isLastPage && !isFetching && (
<Button onClick={loadNextPage}>
{t('workflow.common.loadMore')}
</Button>
{isPluginListLoading && <Loading type='app' />}
{!isPluginListLoading && (
<>
{(filteredList?.length ?? 0) > 0 ? (
<div className='flex grow flex-wrap content-start items-start justify-center gap-2 self-stretch px-12'>
<div className='w-full'>
<List pluginList={filteredList || []} />
</div>
{!isLastPage && !isFetching && (
<Button onClick={loadNextPage}>
{t('workflow.common.loadMore')}
</Button>
)}
{isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>}
</div>
) : (
<Empty />
)}
{isFetching && <div className='system-md-semibold text-text-secondary'>{t('appLog.detail.loading')}</div>}
</div>
) : (
<Empty />
</>
)}
<PluginDetailPanel
detail={currentPluginDetail}

View File

@@ -120,6 +120,9 @@ export type PluginDetail = {
latest_unique_identifier: string
source: PluginSource
meta?: MetaData
status: 'active' | 'deleted'
deprecated_reason: string
alternative_plugin_id: string
}
export type PluginInfoFromMarketPlace = {
@@ -345,6 +348,9 @@ export type InstalledLatestVersionResponse = {
[plugin_id: string]: {
unique_identifier: string
version: string
status: 'active' | 'deleted'
deprecated_reason: string
alternative_plugin_id: string
} | null
}
}

View File

@@ -41,7 +41,7 @@ const Field: FC<Props> = ({
<Tooltip popupContent={t('app.structOutput.moreFillTip')} disabled={depth !== MAX_DEPTH + 1}>
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onClick={() => !readonly && onSelect?.([...valueSelector, name])}
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className='flex grow items-stretch'>
<TreeIndentLine depth={depth} />

View File

@@ -0,0 +1,178 @@
/**
* Workflow Panel Width Persistence Tests
* Tests for GitHub issue #22745: Panel width persistence bug fix
*/
import '@testing-library/jest-dom'
type PanelWidthSource = 'user' | 'system'
// Mock localStorage for testing
const createMockLocalStorage = () => {
const storage: Record<string, string> = {}
return {
getItem: jest.fn((key: string) => storage[key] || null),
setItem: jest.fn((key: string, value: string) => {
storage[key] = value
}),
removeItem: jest.fn((key: string) => {
delete storage[key]
}),
clear: jest.fn(() => {
Object.keys(storage).forEach(key => delete storage[key])
}),
get storage() { return { ...storage } },
}
}
// Core panel width logic extracted from the component
const createPanelWidthManager = (storageKey: string) => {
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
const newValue = Math.max(400, Math.min(width, 800))
if (source === 'user')
localStorage.setItem(storageKey, `${newValue}`)
return newValue
},
getStoredWidth: () => {
const stored = localStorage.getItem(storageKey)
return stored ? Number.parseFloat(stored) : 400
},
}
}
describe('Workflow Panel Width Persistence', () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => {
mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('Node Panel Width Management', () => {
const storageKey = 'workflow-node-panel-width'
it('should save user resize to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const result = manager.updateWidth(500, 'user')
expect(result).toBe(500)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '500')
})
it('should not save system compression to localStorage', () => {
const manager = createPanelWidthManager(storageKey)
const result = manager.updateWidth(200, 'system')
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should enforce minimum width of 400px', () => {
const manager = createPanelWidthManager(storageKey)
// User tries to set below minimum
const userResult = manager.updateWidth(300, 'user')
expect(userResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, '400')
// System compression below minimum
const systemResult = manager.updateWidth(150, 'system')
expect(systemResult).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
it('should preserve user preferences during system compression', () => {
localStorage.setItem(storageKey, '600')
const manager = createPanelWidthManager(storageKey)
// System compresses panel
manager.updateWidth(200, 'system')
// User preference should remain unchanged
expect(localStorage.getItem(storageKey)).toBe('600')
})
})
describe('Bug Scenario Reproduction', () => {
it('should reproduce original bug behavior (for comparison)', () => {
const storageKey = 'workflow-node-panel-width'
// Original buggy behavior - always saves regardless of source
const buggyUpdate = (width: number) => {
localStorage.setItem(storageKey, `${width}`)
return Math.max(400, width)
}
localStorage.setItem(storageKey, '500') // User preference
buggyUpdate(200) // System compression pollutes localStorage
expect(localStorage.getItem(storageKey)).toBe('200') // Bug: corrupted state
})
it('should verify fix prevents localStorage pollution', () => {
const storageKey = 'workflow-node-panel-width'
const manager = createPanelWidthManager(storageKey)
localStorage.setItem(storageKey, '500') // User preference
manager.updateWidth(200, 'system') // System compression
expect(localStorage.getItem(storageKey)).toBe('500') // Fix: preserved state
})
})
describe('Edge Cases', () => {
it('should handle multiple rapid operations correctly', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
// Rapid system adjustments
manager.updateWidth(300, 'system')
manager.updateWidth(250, 'system')
manager.updateWidth(180, 'system')
// Single user adjustment
manager.updateWidth(550, 'user')
expect(localStorage.setItem).toHaveBeenCalledTimes(1)
expect(localStorage.setItem).toHaveBeenCalledWith('workflow-node-panel-width', '550')
})
it('should handle corrupted localStorage gracefully', () => {
localStorage.setItem('workflow-node-panel-width', '150') // Below minimum
const manager = createPanelWidthManager('workflow-node-panel-width')
const storedWidth = manager.getStoredWidth()
expect(storedWidth).toBe(150) // Returns raw value
// User can correct the preference
const correctedWidth = manager.updateWidth(500, 'user')
expect(correctedWidth).toBe(500)
expect(localStorage.getItem('workflow-node-panel-width')).toBe('500')
})
})
describe('TypeScript Type Safety', () => {
it('should enforce source parameter type', () => {
const manager = createPanelWidthManager('workflow-node-panel-width')
// Valid source values
manager.updateWidth(500, 'user')
manager.updateWidth(500, 'system')
// Default to 'user'
manager.updateWidth(500)
expect(localStorage.setItem).toHaveBeenCalledTimes(2) // user + default
})
})
})

View File

@@ -105,15 +105,18 @@ const BasePanel: FC<BasePanelProps> = ({
return Math.max(available, 400)
}, [workflowCanvasWidth, otherPanelWidth])
const updateNodePanelWidth = useCallback((width: number) => {
const updateNodePanelWidth = useCallback((width: number, source: 'user' | 'system' = 'user') => {
// Ensure the width is within the min and max range
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
if (source === 'user')
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
setNodePanelWidth(newValue)
}, [maxNodePanelWidth, setNodePanelWidth])
const handleResize = useCallback((width: number) => {
updateNodePanelWidth(width)
updateNodePanelWidth(width, 'user')
}, [updateNodePanelWidth])
const {
@@ -127,7 +130,10 @@ const BasePanel: FC<BasePanelProps> = ({
onResize: debounce(handleResize),
})
const debounceUpdate = debounce(updateNodePanelWidth)
const debounceUpdate = debounce((width: number) => {
updateNodePanelWidth(width, 'system')
})
useEffect(() => {
if (!workflowCanvasWidth)
return
@@ -138,7 +144,7 @@ const BasePanel: FC<BasePanelProps> = ({
const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400)
debounceUpdate(target)
}
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, updateNodePanelWidth])
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, debounceUpdate])
const { handleNodeSelect } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly()

View File

@@ -0,0 +1,145 @@
/**
* Debug and Preview Panel Width Persistence Tests
* Tests for GitHub issue #22745: Panel width persistence bug fix
*/
import '@testing-library/jest-dom'
type PanelWidthSource = 'user' | 'system'
// Mock localStorage for testing
const createMockLocalStorage = () => {
const storage: Record<string, string> = {}
return {
getItem: jest.fn((key: string) => storage[key] || null),
setItem: jest.fn((key: string, value: string) => {
storage[key] = value
}),
removeItem: jest.fn((key: string) => {
delete storage[key]
}),
clear: jest.fn(() => {
Object.keys(storage).forEach(key => delete storage[key])
}),
get storage() { return { ...storage } },
}
}
// Preview panel width logic
const createPreviewPanelManager = () => {
const storageKey = 'debug-and-preview-panel-width'
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
const newValue = Math.max(400, Math.min(width, 800))
if (source === 'user')
localStorage.setItem(storageKey, `${newValue}`)
return newValue
},
getStoredWidth: () => {
const stored = localStorage.getItem(storageKey)
return stored ? Number.parseFloat(stored) : 400
},
}
}
describe('Debug and Preview Panel Width Persistence', () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => {
mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('Preview Panel Width Management', () => {
it('should save user resize to localStorage', () => {
const manager = createPreviewPanelManager()
const result = manager.updateWidth(450, 'user')
expect(result).toBe(450)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '450')
})
it('should not save system compression to localStorage', () => {
const manager = createPreviewPanelManager()
const result = manager.updateWidth(300, 'system')
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should behave identically to Node Panel', () => {
const manager = createPreviewPanelManager()
// Both user and system operations should behave consistently
manager.updateWidth(500, 'user')
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
manager.updateWidth(200, 'system')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
})
})
describe('Dual Panel Scenario', () => {
it('should maintain independence from Node Panel', () => {
localStorage.setItem('workflow-node-panel-width', '600')
localStorage.setItem('debug-and-preview-panel-width', '450')
const manager = createPreviewPanelManager()
// System compresses preview panel
manager.updateWidth(200, 'system')
// Only preview panel storage key should be unaffected
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450')
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600')
})
it('should handle F12 scenario consistently', () => {
const manager = createPreviewPanelManager()
// User sets preference
manager.updateWidth(500, 'user')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
// F12 opens causing viewport compression
manager.updateWidth(180, 'system')
// User preference preserved
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
})
})
describe('Consistency with Node Panel', () => {
it('should enforce same minimum width rules', () => {
const manager = createPreviewPanelManager()
// Same 400px minimum as Node Panel
const result = manager.updateWidth(300, 'user')
expect(result).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '400')
})
it('should use same source parameter pattern', () => {
const manager = createPreviewPanelManager()
// Default to 'user' when source not specified
manager.updateWidth(500)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
// Explicit 'system' source
manager.updateWidth(300, 'system')
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
})
})

View File

@@ -53,8 +53,9 @@ const DebugAndPreview = () => {
const nodePanelWidth = useStore(s => s.nodePanelWidth)
const panelWidth = useStore(s => s.previewPanelWidth)
const setPanelWidth = useStore(s => s.setPreviewPanelWidth)
const handleResize = useCallback((width: number) => {
localStorage.setItem('debug-and-preview-panel-width', `${width}`)
const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => {
if (source === 'user')
localStorage.setItem('debug-and-preview-panel-width', `${width}`)
setPanelWidth(width)
}, [setPanelWidth])
const maxPanelWidth = useMemo(() => {
@@ -74,7 +75,9 @@ const DebugAndPreview = () => {
triggerDirection: 'left',
minWidth: 400,
maxWidth: maxPanelWidth,
onResize: debounce(handleResize),
onResize: debounce((width: number) => {
handleResize(width, 'user')
}),
})
return (

View File

@@ -31,6 +31,7 @@ const translation = {
searchTools: 'Search tools...',
installPlugin: 'Install plugin',
installFrom: 'INSTALL FROM',
deprecated: 'Deprecated',
list: {
noInstalled: 'No plugins installed',
notFound: 'No plugins found',
@@ -101,6 +102,16 @@ const translation = {
configureApp: 'Configure App',
configureModel: 'Configure model',
configureTool: 'Configure tool',
deprecation: {
fullMessage: 'This plugin has been deprecated due to {{deprecatedReason}}, and will no longer be updated. Please use <CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink> instead.',
onlyReason: 'This plugin has been deprecated due to {{deprecatedReason}} and will no longer be updated.',
noReason: 'This plugin has been deprecated and will no longer be updated.',
reason: {
businessAdjustments: 'business adjustments',
ownershipTransferred: 'ownership transferred',
noMaintainer: 'no maintainer',
},
},
},
install: '{{num}} installs',
installAction: 'Install',

View File

@@ -84,6 +84,16 @@ const translation = {
actionNum: '{{num}} {{action}} が含まれています',
endpointsDocLink: 'ドキュメントを表示する',
switchVersion: 'バージョンの切り替え',
deprecation: {
fullMessage: 'このプラグインは{{deprecatedReason}}のため非推奨となり、新しいバージョンはリリースされません。代わりに<CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink>をご利用ください。',
onlyReason: 'このプラグインは{{deprecatedReason}}のため非推奨となり、新しいバージョンはリリースされません。',
noReason: 'このプラグインは廃止されており、今後更新されることはありません。',
reason: {
businessAdjustments: '事業調整',
ownershipTransferred: '所有権移転',
noMaintainer: 'メンテナーの不足',
},
},
},
debugInfo: {
title: 'デバッグ',
@@ -198,6 +208,7 @@ const translation = {
install: '{{num}} インストール',
installAction: 'インストール',
installFrom: 'インストール元',
deprecated: '非推奨',
searchPlugins: '検索プラグイン',
search: '検索',
endpointsEnabled: '{{num}} セットのエンドポイントが有効になりました',

View File

@@ -31,6 +31,7 @@ const translation = {
searchTools: '搜索工具...',
installPlugin: '安装插件',
installFrom: '安装源',
deprecated: '已弃用',
list: {
noInstalled: '无已安装的插件',
notFound: '未找到插件',
@@ -101,6 +102,16 @@ const translation = {
configureApp: '应用设置',
configureModel: '模型设置',
configureTool: '工具设置',
deprecation: {
fullMessage: '由于{{deprecatedReason}},此插件已被弃用,将不再发布新版本。请使用<CustomLink href=\'https://example.com/\'>{{-alternativePluginId}}</CustomLink>替代。',
onlyReason: '由于{{deprecatedReason}},此插件已被弃用,将不再发布新版本。',
noReason: '此插件已被弃用,将不再发布新版本。',
reason: {
businessAdjustments: '业务调整',
ownershipTransferred: '所有权转移',
noMaintainer: '无人维护',
},
},
},
install: '{{num}} 次安装',
installAction: '安装',

View File

@@ -90,3 +90,7 @@ export const formatNumberAbbreviated = (num: number) => {
}
}
}
export const snakeCase2CamelCase = (input: string): string => {
return input.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}