feat: plugin auto upgrade strategy (#19758)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: crazywoola <427733928@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Novice <novice12185727@gmail.com>
This commit is contained in:
Junyan Qin (Chin) 2025-07-23 15:33:39 +08:00 committed by GitHub
parent e6913744ae
commit eaae79a581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
305 changed files with 2524 additions and 581 deletions

View File

@ -471,6 +471,16 @@ APP_MAX_ACTIVE_REQUESTS=0
# Celery beat configuration
CELERY_BEAT_SCHEDULER_TIME=1
# Celery schedule tasks configuration
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false
ENABLE_CLEAN_UNUSED_DATASETS_TASK=false
ENABLE_CREATE_TIDB_SERVERLESS_TASK=false
ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false
ENABLE_CLEAN_MESSAGES=false
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false
ENABLE_DATASETS_QUEUE_MONITOR=false
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true
# Position configuration
POSITION_TOOL_PINS=
POSITION_TOOL_INCLUDES=

View File

@ -74,7 +74,12 @@
10. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
```bash
uv run celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion
uv run celery -A app.celery worker -P gevent -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion,plugin
```
Addition, if you want to debug the celery scheduled tasks, you can use the following command in another terminal:
```bash
uv run celery -A app.celery beat
```
## Testing

View File

@ -832,6 +832,41 @@ class CeleryBeatConfig(BaseSettings):
)
class CeleryScheduleTasksConfig(BaseSettings):
ENABLE_CLEAN_EMBEDDING_CACHE_TASK: bool = Field(
description="Enable clean embedding cache task",
default=False,
)
ENABLE_CLEAN_UNUSED_DATASETS_TASK: bool = Field(
description="Enable clean unused datasets task",
default=False,
)
ENABLE_CREATE_TIDB_SERVERLESS_TASK: bool = Field(
description="Enable create tidb service job task",
default=False,
)
ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK: bool = Field(
description="Enable update tidb service job status task",
default=False,
)
ENABLE_CLEAN_MESSAGES: bool = Field(
description="Enable clean messages task",
default=False,
)
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field(
description="Enable mail clean document notify task",
default=False,
)
ENABLE_DATASETS_QUEUE_MONITOR: bool = Field(
description="Enable queue monitor task",
default=False,
)
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK: bool = Field(
description="Enable check upgradable plugin task",
default=True,
)
class PositionConfig(BaseSettings):
POSITION_PROVIDER_PINS: str = Field(
description="Comma-separated list of pinned model providers",
@ -961,5 +996,6 @@ class FeatureConfig(
# hosted services config
HostedServiceConfig,
CeleryBeatConfig,
CeleryScheduleTasksConfig,
):
pass

View File

@ -12,7 +12,8 @@ from controllers.console.wraps import account_initialization_required, setup_req
from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.impl.exc import PluginDaemonClientSideError
from libs.login import login_required
from models.account import TenantPluginPermission
from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.plugin.plugin_service import PluginService
@ -534,6 +535,114 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
return jsonable_encoder({"options": options})
class PluginChangePreferencesApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
user = current_user
if not user.is_admin_or_owner:
raise Forbidden()
req = reqparse.RequestParser()
req.add_argument("permission", type=dict, required=True, location="json")
req.add_argument("auto_upgrade", type=dict, required=True, location="json")
args = req.parse_args()
tenant_id = user.current_tenant_id
permission = args["permission"]
install_permission = TenantPluginPermission.InstallPermission(permission.get("install_permission", "everyone"))
debug_permission = TenantPluginPermission.DebugPermission(permission.get("debug_permission", "everyone"))
auto_upgrade = args["auto_upgrade"]
strategy_setting = TenantPluginAutoUpgradeStrategy.StrategySetting(
auto_upgrade.get("strategy_setting", "fix_only")
)
upgrade_time_of_day = auto_upgrade.get("upgrade_time_of_day", 0)
upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode(auto_upgrade.get("upgrade_mode", "exclude"))
exclude_plugins = auto_upgrade.get("exclude_plugins", [])
include_plugins = auto_upgrade.get("include_plugins", [])
# set permission
set_permission_result = PluginPermissionService.change_permission(
tenant_id,
install_permission,
debug_permission,
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
# set auto upgrade strategy
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
)
if not set_auto_upgrade_strategy_result:
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
return jsonable_encoder({"success": True})
class PluginFetchPreferencesApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
tenant_id = current_user.current_tenant_id
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
auto_upgrade_dict = {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
if auto_upgrade:
auto_upgrade_dict = {
"strategy_setting": auto_upgrade.strategy_setting,
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
"upgrade_mode": auto_upgrade.upgrade_mode,
"exclude_plugins": auto_upgrade.exclude_plugins,
"include_plugins": auto_upgrade.include_plugins,
}
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
class PluginAutoUpgradeExcludePluginApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
# exclude one single plugin
tenant_id = current_user.current_tenant_id
req = reqparse.RequestParser()
req.add_argument("plugin_id", type=str, required=True, location="json")
args = req.parse_args()
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args["plugin_id"])})
api.add_resource(PluginDebuggingKeyApi, "/workspaces/current/plugin/debugging-key")
api.add_resource(PluginListApi, "/workspaces/current/plugin/list")
api.add_resource(PluginListLatestVersionsApi, "/workspaces/current/plugin/list/latest-versions")
@ -560,3 +669,7 @@ api.add_resource(PluginChangePermissionApi, "/workspaces/current/plugin/permissi
api.add_resource(PluginFetchPermissionApi, "/workspaces/current/plugin/permission/fetch")
api.add_resource(PluginFetchDynamicSelectOptionsApi, "/workspaces/current/plugin/parameters/dynamic-options")
api.add_resource(PluginFetchPreferencesApi, "/workspaces/current/plugin/preferences/fetch")
api.add_resource(PluginChangePreferencesApi, "/workspaces/current/plugin/preferences/change")
api.add_resource(PluginAutoUpgradeExcludePluginApi, "/workspaces/current/plugin/preferences/autoupgrade/exclude")

View File

@ -25,9 +25,29 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP
url = str(marketplace_api_url / "api/v1/plugins/batch")
response = requests.post(url, json={"plugin_ids": plugin_ids})
response.raise_for_status()
return [MarketplacePluginDeclaration(**plugin) for plugin in response.json()["data"]["plugins"]]
def batch_fetch_plugin_manifests_ignore_deserialization_error(
plugin_ids: list[str],
) -> Sequence[MarketplacePluginDeclaration]:
if len(plugin_ids) == 0:
return []
url = str(marketplace_api_url / "api/v1/plugins/batch")
response = requests.post(url, json={"plugin_ids": plugin_ids})
response.raise_for_status()
result: list[MarketplacePluginDeclaration] = []
for plugin in response.json()["data"]["plugins"]:
try:
result.append(MarketplacePluginDeclaration(**plugin))
except Exception as e:
pass
return result
def record_install_plugin_event(plugin_unique_identifier: str):
url = str(marketplace_api_url / "api/v1/stats/plugins/install_count")
response = requests.post(url, json={"unique_identifier": plugin_unique_identifier})

View File

@ -22,7 +22,7 @@ if [[ "${MODE}" == "worker" ]]; then
exec celery -A app.celery worker -P ${CELERY_WORKER_CLASS:-gevent} $CONCURRENCY_OPTION \
--max-tasks-per-child ${MAX_TASK_PRE_CHILD:-50} --loglevel ${LOG_LEVEL:-INFO} \
-Q ${CELERY_QUEUES:-dataset,mail,ops_trace,app_deletion}
-Q ${CELERY_QUEUES:-dataset,mail,ops_trace,app_deletion,plugin}
elif [[ "${MODE}" == "beat" ]]; then
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}

View File

@ -64,49 +64,62 @@ def init_app(app: DifyApp) -> Celery:
celery_app.set_default()
app.extensions["celery"] = celery_app
imports = [
"schedule.clean_embedding_cache_task",
"schedule.clean_unused_datasets_task",
"schedule.create_tidb_serverless_task",
"schedule.update_tidb_serverless_status_task",
"schedule.clean_messages",
"schedule.mail_clean_document_notify_task",
"schedule.queue_monitor_task",
]
imports = []
day = dify_config.CELERY_BEAT_SCHEDULER_TIME
beat_schedule = {
"clean_embedding_cache_task": {
# if you add a new task, please add the switch to CeleryScheduleTasksConfig
beat_schedule = {}
if dify_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK:
imports.append("schedule.clean_embedding_cache_task")
beat_schedule["clean_embedding_cache_task"] = {
"task": "schedule.clean_embedding_cache_task.clean_embedding_cache_task",
"schedule": timedelta(days=day),
},
"clean_unused_datasets_task": {
}
if dify_config.ENABLE_CLEAN_UNUSED_DATASETS_TASK:
imports.append("schedule.clean_unused_datasets_task")
beat_schedule["clean_unused_datasets_task"] = {
"task": "schedule.clean_unused_datasets_task.clean_unused_datasets_task",
"schedule": timedelta(days=day),
},
"create_tidb_serverless_task": {
}
if dify_config.ENABLE_CREATE_TIDB_SERVERLESS_TASK:
imports.append("schedule.create_tidb_serverless_task")
beat_schedule["create_tidb_serverless_task"] = {
"task": "schedule.create_tidb_serverless_task.create_tidb_serverless_task",
"schedule": crontab(minute="0", hour="*"),
},
"update_tidb_serverless_status_task": {
}
if dify_config.ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK:
imports.append("schedule.update_tidb_serverless_status_task")
beat_schedule["update_tidb_serverless_status_task"] = {
"task": "schedule.update_tidb_serverless_status_task.update_tidb_serverless_status_task",
"schedule": timedelta(minutes=10),
},
"clean_messages": {
}
if dify_config.ENABLE_CLEAN_MESSAGES:
imports.append("schedule.clean_messages")
beat_schedule["clean_messages"] = {
"task": "schedule.clean_messages.clean_messages",
"schedule": timedelta(days=day),
},
# every Monday
"mail_clean_document_notify_task": {
}
if dify_config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK:
imports.append("schedule.mail_clean_document_notify_task")
beat_schedule["mail_clean_document_notify_task"] = {
"task": "schedule.mail_clean_document_notify_task.mail_clean_document_notify_task",
"schedule": crontab(minute="0", hour="10", day_of_week="1"),
},
"datasets-queue-monitor": {
}
if dify_config.ENABLE_DATASETS_QUEUE_MONITOR:
imports.append("schedule.queue_monitor_task")
beat_schedule["datasets-queue-monitor"] = {
"task": "schedule.queue_monitor_task.queue_monitor_task",
"schedule": timedelta(
minutes=dify_config.QUEUE_MONITOR_INTERVAL if dify_config.QUEUE_MONITOR_INTERVAL else 30
),
},
}
if dify_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK:
imports.append("schedule.check_upgradable_plugin_task")
beat_schedule["check_upgradable_plugin_task"] = {
"task": "schedule.check_upgradable_plugin_task.check_upgradable_plugin_task",
"schedule": crontab(minute="*/15"),
}
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
return celery_app

View File

@ -0,0 +1,42 @@
"""add_tenant_plugin_autoupgrade_table
Revision ID: 8bcc02c9bd07
Revises: 375fe79ead14
Create Date: 2025-07-23 15:08:50.161441
"""
from alembic import op
import models as models
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '8bcc02c9bd07'
down_revision = '375fe79ead14'
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

@ -297,6 +297,40 @@ class TenantPluginPermission(Base):
)
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID)
install_permission: Mapped[InstallPermission] = mapped_column(db.String(16), server_default="everyone")
debug_permission: Mapped[DebugPermission] = mapped_column(db.String(16), server_default="noone")
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
install_permission: Mapped[InstallPermission] = mapped_column(
db.String(16), nullable=False, server_default="everyone"
)
debug_permission: Mapped[DebugPermission] = mapped_column(db.String(16), nullable=False, server_default="noone")
class TenantPluginAutoUpgradeStrategy(Base):
class StrategySetting(enum.StrEnum):
DISABLED = "disabled"
FIX_ONLY = "fix_only"
LATEST = "latest"
class UpgradeMode(enum.StrEnum):
ALL = "all"
PARTIAL = "partial"
EXCLUDE = "exclude"
__tablename__ = "tenant_plugin_auto_upgrade_strategies"
__table_args__ = (
db.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"),
db.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
strategy_setting: Mapped[StrategySetting] = mapped_column(db.String(16), nullable=False, server_default="fix_only")
upgrade_time_of_day: Mapped[int] = mapped_column(db.Integer, nullable=False, default=0) # seconds of the day
upgrade_mode: Mapped[UpgradeMode] = mapped_column(db.String(16), nullable=False, server_default="exclude")
exclude_plugins: Mapped[list[str]] = mapped_column(
db.ARRAY(db.String(255)), nullable=False
) # plugin_id (author/name)
include_plugins: Mapped[list[str]] = mapped_column(
db.ARRAY(db.String(255)), nullable=False
) # plugin_id (author/name)
created_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp())

View File

@ -0,0 +1,49 @@
import time
import click
import app
from extensions.ext_database import db
from models.account import TenantPluginAutoUpgradeStrategy
from tasks.process_tenant_plugin_autoupgrade_check_task import process_tenant_plugin_autoupgrade_check_task
AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL = 15 * 60 # 15 minutes
@app.celery.task(queue="plugin")
def check_upgradable_plugin_task():
click.echo(click.style("Start check upgradable plugin.", fg="green"))
start_at = time.perf_counter()
now_seconds_of_day = time.time() % 86400 - 30 # we assume the tz is UTC
click.echo(click.style("Now seconds of day: {}".format(now_seconds_of_day), fg="green"))
strategies = (
db.session.query(TenantPluginAutoUpgradeStrategy)
.filter(
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day >= now_seconds_of_day,
TenantPluginAutoUpgradeStrategy.upgrade_time_of_day
< now_seconds_of_day + AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL,
TenantPluginAutoUpgradeStrategy.strategy_setting
!= TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
)
.all()
)
for strategy in strategies:
process_tenant_plugin_autoupgrade_check_task.delay(
strategy.tenant_id,
strategy.strategy_setting,
strategy.upgrade_time_of_day,
strategy.upgrade_mode,
strategy.exclude_plugins,
strategy.include_plugins,
)
end_at = time.perf_counter()
click.echo(
click.style(
"Checked upgradable plugin success latency: {}".format(end_at - start_at),
fg="green",
)
)

View File

@ -29,6 +29,7 @@ from models.account import (
Tenant,
TenantAccountJoin,
TenantAccountRole,
TenantPluginAutoUpgradeStrategy,
TenantStatus,
)
from models.model import DifySetup
@ -828,6 +829,17 @@ class TenantService:
db.session.add(tenant)
db.session.commit()
plugin_upgrade_strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant.id,
strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
upgrade_time_of_day=0,
upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
exclude_plugins=[],
include_plugins=[],
)
db.session.add(plugin_upgrade_strategy)
db.session.commit()
tenant.encrypt_public_key = generate_key_pair(tenant.id)
db.session.commit()
return tenant

View File

@ -0,0 +1,87 @@
from sqlalchemy.orm import Session
from extensions.ext_database import db
from models.account import TenantPluginAutoUpgradeStrategy
class PluginAutoUpgradeService:
@staticmethod
def get_strategy(tenant_id: str) -> TenantPluginAutoUpgradeStrategy | None:
with Session(db.engine) as session:
return (
session.query(TenantPluginAutoUpgradeStrategy)
.filter(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.first()
)
@staticmethod
def change_strategy(
tenant_id: str,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
) -> bool:
with Session(db.engine) as session:
exist_strategy = (
session.query(TenantPluginAutoUpgradeStrategy)
.filter(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.first()
)
if not exist_strategy:
strategy = TenantPluginAutoUpgradeStrategy(
tenant_id=tenant_id,
strategy_setting=strategy_setting,
upgrade_time_of_day=upgrade_time_of_day,
upgrade_mode=upgrade_mode,
exclude_plugins=exclude_plugins,
include_plugins=include_plugins,
)
session.add(strategy)
else:
exist_strategy.strategy_setting = strategy_setting
exist_strategy.upgrade_time_of_day = upgrade_time_of_day
exist_strategy.upgrade_mode = upgrade_mode
exist_strategy.exclude_plugins = exclude_plugins
exist_strategy.include_plugins = include_plugins
session.commit()
return True
@staticmethod
def exclude_plugin(tenant_id: str, plugin_id: str) -> bool:
with Session(db.engine) as session:
exist_strategy = (
session.query(TenantPluginAutoUpgradeStrategy)
.filter(TenantPluginAutoUpgradeStrategy.tenant_id == tenant_id)
.first()
)
if not exist_strategy:
# create for this tenant
PluginAutoUpgradeService.change_strategy(
tenant_id,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY,
0,
TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
[plugin_id],
[],
)
return True
else:
if exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
if plugin_id not in exist_strategy.exclude_plugins:
new_exclude_plugins = exist_strategy.exclude_plugins.copy()
new_exclude_plugins.append(plugin_id)
exist_strategy.exclude_plugins = new_exclude_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL:
if plugin_id in exist_strategy.include_plugins:
new_include_plugins = exist_strategy.include_plugins.copy()
new_include_plugins.remove(plugin_id)
exist_strategy.include_plugins = new_include_plugins
elif exist_strategy.upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
exist_strategy.upgrade_mode = TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE
exist_strategy.exclude_plugins = [plugin_id]
session.commit()
return True

View File

@ -0,0 +1,166 @@
import traceback
import typing
import click
from celery import shared_task # type: ignore
from core.helper import marketplace
from core.helper.marketplace import MarketplacePluginDeclaration
from core.plugin.entities.plugin import PluginInstallationSource
from core.plugin.impl.plugin import PluginInstaller
from models.account import TenantPluginAutoUpgradeStrategy
RETRY_TIMES_OF_ONE_PLUGIN_IN_ONE_TENANT = 3
cached_plugin_manifests: dict[str, typing.Union[MarketplacePluginDeclaration, None]] = {}
def marketplace_batch_fetch_plugin_manifests(
plugin_ids_plain_list: list[str],
) -> list[MarketplacePluginDeclaration]:
global cached_plugin_manifests
# return marketplace.batch_fetch_plugin_manifests(plugin_ids_plain_list)
not_included_plugin_ids = [
plugin_id for plugin_id in plugin_ids_plain_list if plugin_id not in cached_plugin_manifests
]
if not_included_plugin_ids:
manifests = marketplace.batch_fetch_plugin_manifests_ignore_deserialization_error(not_included_plugin_ids)
for manifest in manifests:
cached_plugin_manifests[manifest.plugin_id] = manifest
if (
len(manifests) == 0
): # this indicates that the plugin not found in marketplace, should set None in cache to prevent future check
for plugin_id in not_included_plugin_ids:
cached_plugin_manifests[plugin_id] = None
result: list[MarketplacePluginDeclaration] = []
for plugin_id in plugin_ids_plain_list:
final_manifest = cached_plugin_manifests.get(plugin_id)
if final_manifest is not None:
result.append(final_manifest)
return result
@shared_task(queue="plugin")
def process_tenant_plugin_autoupgrade_check_task(
tenant_id: str,
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting,
upgrade_time_of_day: int,
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode,
exclude_plugins: list[str],
include_plugins: list[str],
):
try:
manager = PluginInstaller()
click.echo(
click.style(
"Checking upgradable plugin for tenant: {}".format(tenant_id),
fg="green",
)
)
if strategy_setting == TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED:
return
# get plugin_ids to check
plugin_ids: list[tuple[str, str, str]] = [] # plugin_id, version, unique_identifier
click.echo(click.style("Upgrade mode: {}".format(upgrade_mode), fg="green"))
if upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL and include_plugins:
all_plugins = manager.list_plugins(tenant_id)
for plugin in all_plugins:
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id in include_plugins:
plugin_ids.append(
(
plugin.plugin_id,
plugin.version,
plugin.plugin_unique_identifier,
)
)
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE:
# get all plugins and remove excluded plugins
all_plugins = manager.list_plugins(tenant_id)
plugin_ids = [
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
for plugin in all_plugins
if plugin.source == PluginInstallationSource.Marketplace and plugin.plugin_id not in exclude_plugins
]
elif upgrade_mode == TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL:
all_plugins = manager.list_plugins(tenant_id)
plugin_ids = [
(plugin.plugin_id, plugin.version, plugin.plugin_unique_identifier)
for plugin in all_plugins
if plugin.source == PluginInstallationSource.Marketplace
]
if not plugin_ids:
return
plugin_ids_plain_list = [plugin_id for plugin_id, _, _ in plugin_ids]
manifests = marketplace_batch_fetch_plugin_manifests(plugin_ids_plain_list)
if not manifests:
return
for manifest in manifests:
for plugin_id, version, original_unique_identifier in plugin_ids:
if manifest.plugin_id != plugin_id:
continue
try:
current_version = version
latest_version = manifest.latest_version
def fix_only_checker(latest_version, current_version):
latest_version_tuple = tuple(int(val) for val in latest_version.split("."))
current_version_tuple = tuple(int(val) for val in current_version.split("."))
if (
latest_version_tuple[0] == current_version_tuple[0]
and latest_version_tuple[1] == current_version_tuple[1]
):
return latest_version_tuple[2] != current_version_tuple[2]
return False
version_checker = {
TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST: lambda latest_version,
current_version: latest_version != current_version,
TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY: fix_only_checker,
}
if version_checker[strategy_setting](latest_version, current_version):
# execute upgrade
new_unique_identifier = manifest.latest_package_identifier
marketplace.record_install_plugin_event(new_unique_identifier)
click.echo(
click.style(
"Upgrade plugin: {} -> {}".format(original_unique_identifier, new_unique_identifier),
fg="green",
)
)
task_start_resp = manager.upgrade_plugin(
tenant_id,
original_unique_identifier,
new_unique_identifier,
PluginInstallationSource.Marketplace,
{
"plugin_unique_identifier": new_unique_identifier,
},
)
except Exception as e:
click.echo(click.style("Error when upgrading plugin: {}".format(e), fg="red"))
traceback.print_exc()
break
except Exception as e:
click.echo(click.style("Error when checking upgradable plugin: {}".format(e), fg="red"))
traceback.print_exc()
return

View File

@ -1168,3 +1168,13 @@ QUEUE_MONITOR_THRESHOLD=200
QUEUE_MONITOR_ALERT_EMAILS=
# Monitor interval in minutes, default is 30 minutes
QUEUE_MONITOR_INTERVAL=30
# Celery schedule tasks configuration
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false
ENABLE_CLEAN_UNUSED_DATASETS_TASK=false
ENABLE_CREATE_TIDB_SERVERLESS_TASK=false
ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK=false
ENABLE_CLEAN_MESSAGES=false
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK=false
ENABLE_DATASETS_QUEUE_MONITOR=false
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK=true

View File

@ -55,6 +55,25 @@ services:
- ssrf_proxy_network
- default
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.5.0
restart: always
environment:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks.
MODE: beat
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- ssrf_proxy_network
- default
# Frontend web application.
web:
image: langgenius/dify-web:1.6.0

View File

@ -527,6 +527,14 @@ x-shared-env: &shared-api-worker-env
QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200}
QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-}
QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30}
ENABLE_CLEAN_EMBEDDING_CACHE_TASK: ${ENABLE_CLEAN_EMBEDDING_CACHE_TASK:-false}
ENABLE_CLEAN_UNUSED_DATASETS_TASK: ${ENABLE_CLEAN_UNUSED_DATASETS_TASK:-false}
ENABLE_CREATE_TIDB_SERVERLESS_TASK: ${ENABLE_CREATE_TIDB_SERVERLESS_TASK:-false}
ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK: ${ENABLE_UPDATE_TIDB_SERVERLESS_STATUS_TASK:-false}
ENABLE_CLEAN_MESSAGES: ${ENABLE_CLEAN_MESSAGES:-false}
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: ${ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK:-false}
ENABLE_DATASETS_QUEUE_MONITOR: ${ENABLE_DATASETS_QUEUE_MONITOR:-false}
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK: ${ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK:-true}
services:
# API service
@ -584,6 +592,25 @@ services:
- ssrf_proxy_network
- default
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.5.0
restart: always
environment:
# Use the shared environment variables.
<<: *shared-api-worker-env
# Startup mode, 'worker_beat' starts the Celery beat for scheduling periodic tasks.
MODE: beat
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- ssrf_proxy_network
- default
# Frontend web application.
web:
image: langgenius/dify-web:1.6.0

View File

@ -4,18 +4,21 @@ import cn from '@/utils/classnames'
type OptionListItemProps = {
isSelected: boolean
onClick: () => void
noAutoScroll?: boolean
} & React.LiHTMLAttributes<HTMLLIElement>
const OptionListItem: FC<OptionListItemProps> = ({
isSelected,
onClick,
noAutoScroll,
children,
}) => {
const listItemRef = useRef<HTMLLIElement>(null)
useEffect(() => {
if (isSelected)
if (isSelected && !noAutoScroll)
listItemRef.current?.scrollIntoView({ behavior: 'instant' })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (

View File

@ -1,13 +1,18 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
const Header = () => {
type Props = {
title?: string
}
const Header = ({
title,
}: Props) => {
const { t } = useTranslation()
return (
<div className='flex flex-col border-b-[0.5px] border-divider-regular'>
<div className='system-md-semibold flex items-center px-2 py-1.5 text-text-primary'>
{t('time.title.pickTime')}
{title || t('time.title.pickTime')}
</div>
</div>
)

View File

@ -20,6 +20,9 @@ const TimePicker = ({
onChange,
onClear,
renderTrigger,
title,
minuteFilter,
popupClassName,
}: TimePickerProps) => {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
@ -108,18 +111,7 @@ const TimePicker = ({
const displayValue = value?.format(timeFormat) || ''
const placeholderDate = isOpen && selectedTime ? selectedTime.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
return (
<PortalToFollowElem
open={isOpen}
onOpenChange={setIsOpen}
placement='bottom-end'
>
<PortalToFollowElemTrigger>
{renderTrigger ? (renderTrigger()) : (
<div
className='group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt'
onClick={handleClickTrigger}
>
const inputElem = (
<input
className='system-xs-regular flex-1 cursor-pointer appearance-none truncate bg-transparent p-1
text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder'
@ -127,6 +119,24 @@ const TimePicker = ({
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
/>
)
return (
<PortalToFollowElem
open={isOpen}
onOpenChange={setIsOpen}
placement='bottom-end'
>
<PortalToFollowElemTrigger>
{renderTrigger ? (renderTrigger({
inputElem,
onClick: handleClickTrigger,
isOpen,
})) : (
<div
className='group flex w-[252px] cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt'
onClick={handleClickTrigger}
>
{inputElem}
<RiTimeLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
@ -142,14 +152,15 @@ const TimePicker = ({
</div>
)}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
<div className='mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'>
{/* Header */}
<Header />
<Header title={title} />
{/* Time Options */}
<Options
selectedTime={selectedTime}
minuteFilter={minuteFilter}
handleSelectHour={handleSelectHour}
handleSelectMinute={handleSelectMinute}
handleSelectPeriod={handleSelectPeriod}

View File

@ -5,6 +5,7 @@ import OptionListItem from '../common/option-list-item'
const Options: FC<TimeOptionsProps> = ({
selectedTime,
minuteFilter,
handleSelectHour,
handleSelectMinute,
handleSelectPeriod,
@ -33,7 +34,7 @@ const Options: FC<TimeOptionsProps> = ({
{/* Minute */}
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'>
{
minuteOptions.map((minute) => {
(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
const isSelected = selectedTime?.format('mm') === minute
return (
<OptionListItem
@ -57,6 +58,7 @@ const Options: FC<TimeOptionsProps> = ({
key={period}
isSelected={isSelected}
onClick={handleSelectPeriod.bind(null, period)}
noAutoScroll // if choose PM which would hide(scrolled) AM that may make user confused that there's no am.
>
{period}
</OptionListItem>

View File

@ -28,6 +28,7 @@ export type DatePickerProps = {
onClear: () => void
triggerWrapClassName?: string
renderTrigger?: (props: TriggerProps) => React.ReactNode
minuteFilter?: (minutes: string[]) => string[]
popupZIndexClassname?: string
}
@ -47,13 +48,21 @@ export type DatePickerFooterProps = {
handleConfirmDate: () => void
}
export type TriggerParams = {
isOpen: boolean
inputElem: React.ReactNode
onClick: (e: React.MouseEvent) => void
}
export type TimePickerProps = {
value: Dayjs | undefined
timezone?: string
placeholder?: string
onChange: (date: Dayjs | undefined) => void
onClear: () => void
renderTrigger?: () => React.ReactNode
renderTrigger?: (props: TriggerParams) => React.ReactNode
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string
}
export type TimePickerFooterProps = {
@ -81,6 +90,7 @@ export type CalendarItemProps = {
export type TimeOptionsProps = {
selectedTime: Dayjs | undefined
minuteFilter?: (minutes: string[]) => string[]
handleSelectHour: (hour: string) => void
handleSelectMinute: (minute: string) => void
handleSelectPeriod: (period: Period) => void

View File

@ -2,6 +2,7 @@ import dayjs, { type Dayjs } from 'dayjs'
import type { Day } from '../types'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import tz from '@/utils/timezone.json'
dayjs.extend(utc)
dayjs.extend(timezone)
@ -78,3 +79,14 @@ export const getHourIn12Hour = (date: Dayjs) => {
export const getDateWithTimezone = (props: { date?: Dayjs, timezone?: string }) => {
return props.date ? dayjs.tz(props.date, props.timezone) : dayjs().tz(props.timezone)
}
// Asia/Shanghai -> UTC+8
const DEFAULT_OFFSET_STR = 'UTC+0'
export const convertTimezoneToOffsetStr = (timezone?: string) => {
if (!timezone)
return DEFAULT_OFFSET_STR
const tzItem = tz.find(item => item.value === timezone)
if(!tzItem)
return DEFAULT_OFFSET_STR
return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}`
}

View File

@ -0,0 +1,7 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00488 16H6.67155" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00488 9.33334H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.00488 22.6667H8.00488" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26 22L29.3333 25.3333" stroke="#676F83" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 823 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3308 16H14.2915L13.6249 13.9476H10.3761L9.70846 16H7.66918L10.7759 7H13.2281L16.3308 16ZM10.8595 12.4622H13.1435L12.0378 9.05639H11.9673L10.8595 12.4622Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 772 B

View File

@ -75,7 +75,7 @@ Icon.displayName = '<%= svgName %>'
export default Icon
`.trim())
await writeFile(path.resolve(currentPath, `${fileName}.json`), JSON.stringify(svgData, '', '\t'))
await writeFile(path.resolve(currentPath, `${fileName}.json`), `${JSON.stringify(svgData, '', '\t')}\n`)
await writeFile(path.resolve(currentPath, `${fileName}.tsx`), `${componentRender({ svgName: fileName })}\n`)
const indexingRender = template(`

File diff suppressed because one or more lines are too long

View File

@ -4,12 +4,16 @@
import * as React from 'react'
import data from './AliyunIcon.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
const Icon = (
{
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AliyunIcon'

File diff suppressed because one or more lines are too long

View File

@ -4,12 +4,16 @@
import * as React from 'react'
import data from './AliyunIconBig.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
const Icon = (
{
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AliyunIconBig'

View File

@ -4,12 +4,16 @@
import * as React from 'react'
import data from './WeaveIcon.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
const Icon = (
{
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'WeaveIcon'

View File

@ -4,12 +4,16 @@
import * as React from 'react'
import data from './WeaveIconBig.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
const Icon = (
{
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'WeaveIconBig'

View File

@ -1,3 +1,5 @@
export { default as AliyunIconBig } from './AliyunIconBig'
export { default as AliyunIcon } from './AliyunIcon'
export { default as ArizeIconBig } from './ArizeIconBig'
export { default as ArizeIcon } from './ArizeIcon'
export { default as LangfuseIconBig } from './LangfuseIconBig'
@ -11,5 +13,3 @@ export { default as PhoenixIcon } from './PhoenixIcon'
export { default as TracingIcon } from './TracingIcon'
export { default as WeaveIconBig } from './WeaveIconBig'
export { default as WeaveIcon } from './WeaveIcon'
export { default as AliyunIconBig } from './AliyunIconBig'
export { default as AliyunIcon } from './AliyunIcon'

View File

@ -0,0 +1,77 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "32",
"height": "32",
"viewBox": "0 0 32 32",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M28.0049 16C28.0049 20.4183 24.4231 24 20.0049 24C15.5866 24 12.0049 20.4183 12.0049 16C12.0049 11.5817 15.5866 8 20.0049 8C24.4231 8 28.0049 11.5817 28.0049 16Z",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M4.00488 16H6.67155",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M4.00488 9.33334H8.00488",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M4.00488 22.6667H8.00488",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M26 22L29.3333 25.3333",
"stroke": "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round"
},
"children": []
}
]
},
"name": "SearchMenu"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './SearchMenu.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'SearchMenu'
export default Icon

View File

@ -19,6 +19,7 @@ export { default as Pin01 } from './Pin01'
export { default as Pin02 } from './Pin02'
export { default as Plus02 } from './Plus02'
export { default as Refresh } from './Refresh'
export { default as SearchMenu } from './SearchMenu'
export { default as Settings01 } from './Settings01'
export { default as Settings04 } from './Settings04'
export { default as Target04 } from './Target04'

View File

@ -0,0 +1,37 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "24",
"height": "24",
"viewBox": "0 0 24 24",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M16.3308 16H14.2915L13.6249 13.9476H10.3761L9.70846 16H7.66918L10.7759 7H13.2281L16.3308 16ZM10.8595 12.4622H13.1435L12.0378 9.05639H11.9673L10.8595 12.4622Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "AutoUpdateLine"
}

View File

@ -0,0 +1,20 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './AutoUpdateLine.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'AutoUpdateLine'
export default Icon

View File

@ -0,0 +1 @@
export { default as AutoUpdateLine } from './AutoUpdateLine'

View File

@ -10,7 +10,7 @@ import type { ExposeRefs } from './install-multi'
import InstallMulti from './install-multi'
import { useInstallOrUpdate } from '@/service/use-plugins'
import useRefreshPluginList from '../../hooks/use-refresh-plugin-list'
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-permission'
import { useCanInstallPluginFromMarketplace } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { useMittContextSelector } from '@/context/mitt-context'
import Checkbox from '@/app/components/base/checkbox'
const i18nPrefix = 'plugin.installModal'

View File

@ -40,6 +40,11 @@ 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'
import { AutoUpdateLine } from '../../base/icons/src/vender/system'
import { convertUTCDaySecondsToLocalSeconds, timeOfDayToDayjs } from '../reference-setting-modal/auto-update-setting/utils'
import useReferenceSetting from '../plugin-page/use-reference-setting'
import { AUTO_UPDATE_MODE } from '../reference-setting-modal/auto-update-setting/types'
import { useAppContext } from '@/context/app-context'
const i18nPrefix = 'plugin.action'
@ -55,6 +60,8 @@ const DetailHeader = ({
onUpdate,
}: Props) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const { theme } = useTheme()
const locale = useGetLanguage()
const { locale: currentLocale } = useI18N()
@ -112,8 +119,24 @@ const DetailHeader = ({
setFalse: hideUpdateModal,
}] = useBoolean(false)
const handleUpdate = async () => {
const { referenceSetting } = useReferenceSetting()
const { auto_upgrade: autoUpgradeInfo } = referenceSetting || {}
const isAutoUpgradeEnabled = useMemo(() => {
if (!autoUpgradeInfo || !isFromMarketplace)
return false
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.update_all)
return true
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.partial && autoUpgradeInfo.include_plugins.includes(plugin_id))
return true
if(autoUpgradeInfo.upgrade_mode === AUTO_UPDATE_MODE.exclude && !autoUpgradeInfo.exclude_plugins.includes(plugin_id))
return true
return false
}, [autoUpgradeInfo, plugin_id, isFromMarketplace])
const [isDowngrade, setIsDowngrade] = useState(false)
const handleUpdate = async (isDowngrade?: boolean) => {
if (isFromMarketplace) {
setIsDowngrade(!!isDowngrade)
showUpdateModal()
return
}
@ -180,9 +203,6 @@ const DetailHeader = ({
}
}, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
// #plugin TODO# used in apps
// const usedInApps = 3
return (
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}>
<div className="flex">
@ -201,7 +221,7 @@ const DetailHeader = ({
currentVersion={version}
onSelect={(state) => {
setTargetVersion(state)
handleUpdate()
handleUpdate(state.isDowngrade)
}}
trigger={
<Badge
@ -221,6 +241,18 @@ const DetailHeader = ({
/>
}
/>
{/* Auto update info */}
{isAutoUpgradeEnabled && (
<Tooltip popupContent={t('plugin.autoUpdate.nextUpdateTime', { time: timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(autoUpgradeInfo?.upgrade_time_of_day || 0, timezone!)).format('hh:mm A') })}>
{/* add a a div to fix tooltip hover not show problem */}
<div>
<Badge className='mr-1 cursor-pointer px-1'>
<AutoUpdateLine className='size-3' />
</Badge>
</div>
</Tooltip>
)}
{(hasNewVersion || isFromGitHub) && (
<Button variant='secondary-accent' size='small' className='!h-5' onClick={() => {
if (isFromMarketplace) {
@ -324,6 +356,7 @@ const DetailHeader = ({
{
isShowUpdateModal && (
<UpdateFromMarketplace
pluginId={plugin_id}
payload={{
category: detail.declaration.category,
originalPackageInfo: {
@ -337,6 +370,7 @@ const DetailHeader = ({
}}
onCancel={hideUpdateModal}
onSave={handleUpdatedFromMarketplace}
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
/>
)
}

View File

@ -17,14 +17,14 @@ import {
} from './context'
import InstallPluginDropdown from './install-plugin-dropdown'
import { useUploader } from './use-uploader'
import usePermission from './use-permission'
import useReferenceSetting from './use-reference-setting'
import DebugInfo from './debug-info'
import PluginTasks from './plugin-tasks'
import Button from '@/app/components/base/button'
import TabSlider from '@/app/components/base/tab-slider'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames'
import PermissionSetModal from '@/app/components/plugins/permission-setting-modal/modal'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal/modal'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import {
useRouter,
@ -121,16 +121,16 @@ const PluginPage = ({
}, [packageId, bundleInfo])
const {
referenceSetting,
canManagement,
canDebugger,
canSetPermissions,
permissions,
setPermissions,
} = usePermission()
setReferenceSettings,
} = useReferenceSetting()
const [showPluginSettingModal, {
setTrue: setShowPluginSettingModal,
setFalse: setHidePluginSettingModal,
}] = useBoolean()
}] = useBoolean(false)
const [currentFile, setCurrentFile] = useState<File | null>(null)
const containerRef = usePluginPageContext(v => v.containerRef)
const options = usePluginPageContext(v => v.options)
@ -276,10 +276,10 @@ const PluginPage = ({
}
{showPluginSettingModal && (
<PermissionSetModal
payload={permissions!}
<ReferenceSettingModal
payload={referenceSetting!}
onHide={setHidePluginSettingModal}
onSave={setPermissions}
onSave={setReferenceSettings}
/>
)}

View File

@ -2,7 +2,7 @@ import { PermissionType } from '../types'
import { useAppContext } from '@/context/app-context'
import Toast from '../../base/toast'
import { useTranslation } from 'react-i18next'
import { useInvalidatePermissions, useMutationPermissions, usePermissions } from '@/service/use-plugins'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { useMemo } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
@ -19,14 +19,16 @@ const hasPermission = (permission: PermissionType | undefined, isAdmin: boolean)
return isAdmin
}
const usePermission = () => {
const useReferenceSetting = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner } = useAppContext()
const { data: permissions } = usePermissions()
const invalidatePermissions = useInvalidatePermissions()
const { mutate: updatePermission, isPending: isUpdatePending } = useMutationPermissions({
const { data } = useReferenceSettings()
// console.log(data)
const { permission: permissions } = data || {}
const invalidateReferenceSettings = useInvalidateReferenceSettings()
const { mutate: updateReferenceSetting, isPending: isUpdatePending } = useMutationReferenceSettings({
onSuccess: () => {
invalidatePermissions()
invalidateReferenceSettings()
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
@ -36,18 +38,18 @@ const usePermission = () => {
const isAdmin = isCurrentWorkspaceManager || isCurrentWorkspaceOwner
return {
referenceSetting: data,
setReferenceSettings: updateReferenceSetting,
canManagement: hasPermission(permissions?.install_permission, isAdmin),
canDebugger: hasPermission(permissions?.debug_permission, isAdmin),
canSetPermissions: isAdmin,
permissions,
setPermissions: updatePermission,
isUpdatePending,
}
}
export const useCanInstallPluginFromMarketplace = () => {
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { canManagement } = usePermission()
const { canManagement } = useReferenceSetting()
const canInstallPluginFromMarketplace = useMemo(() => {
return enable_marketplace && canManagement
@ -58,4 +60,4 @@ export const useCanInstallPluginFromMarketplace = () => {
}
}
export default usePermission
export default useReferenceSetting

View File

@ -0,0 +1,9 @@
import type { AutoUpdateConfig } from './types'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from './types'
export const defaultValue: AutoUpdateConfig = {
strategy_setting: AUTO_UPDATE_STRATEGY.disabled,
upgrade_time_of_day: 0,
upgrade_mode: AUTO_UPDATE_MODE.update_all,
exclude_plugins: [],
include_plugins: [],
}

View File

@ -0,0 +1,185 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY, type AutoUpdateConfig } from './types'
import Label from '../label'
import StrategyPicker from './strategy-picker'
import { Trans, useTranslation } from 'react-i18next'
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import PluginsPicker from './plugins-picker'
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from './utils'
import { useAppContext } from '@/context/app-context'
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
import { RiTimeLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import { useModalContextSelector } from '@/context/modal-context'
const i18nPrefix = 'plugin.autoUpdate'
type Props = {
payload: AutoUpdateConfig
onChange: (payload: AutoUpdateConfig) => void
}
const SettingTimeZone: FC<{
children?: React.ReactNode
}> = ({
children,
}) => {
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
return (
<span className='body-xs-regular cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: 'language' })} >{children}</span>
)
}
const AutoUpdateSetting: FC<Props> = ({
payload,
onChange,
}) => {
const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const {
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
} = payload
const minuteFilter = useCallback((minutes: string[]) => {
return minutes.filter((m) => {
const time = Number.parseInt(m, 10)
return time % 15 === 0
})
}, [])
const strategyDescription = useMemo(() => {
switch (strategy_setting) {
case AUTO_UPDATE_STRATEGY.fixOnly:
return t(`${i18nPrefix}.strategy.fixOnly.selectedDescription`)
case AUTO_UPDATE_STRATEGY.latest:
return t(`${i18nPrefix}.strategy.latest.selectedDescription`)
default:
return ''
}
}, [strategy_setting, t])
const plugins = useMemo(() => {
switch (upgrade_mode) {
case AUTO_UPDATE_MODE.partial:
return include_plugins
case AUTO_UPDATE_MODE.exclude:
return exclude_plugins
default:
return []
}
}, [upgrade_mode, exclude_plugins, include_plugins])
const handlePluginsChange = useCallback((newPlugins: string[]) => {
if (upgrade_mode === AUTO_UPDATE_MODE.partial) {
onChange({
...payload,
include_plugins: newPlugins,
})
}
else if (upgrade_mode === AUTO_UPDATE_MODE.exclude) {
onChange({
...payload,
exclude_plugins: newPlugins,
})
}
}, [payload, upgrade_mode, onChange])
const handleChange = useCallback((key: keyof AutoUpdateConfig) => {
return (value: AutoUpdateConfig[keyof AutoUpdateConfig]) => {
onChange({
...payload,
[key]: value,
})
}
}, [payload, onChange])
const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => {
return (
<div
className='group float-right flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt'
onClick={onClick}
>
<div className='flex w-0 grow items-center gap-x-1'>
<RiTimeLine className={cn(
'h-4 w-4 shrink-0 text-text-tertiary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
)} />
{inputElem}
</div>
<div className='system-sm-regular text-text-tertiary'>{convertTimezoneToOffsetStr(timezone)}</div>
</div>
)
}, [timezone])
return (
<div className='self-stretch px-6'>
<div className='my-3 flex items-center'>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${i18nPrefix}.updateSettings`)}</div>
<div className='ml-2 h-px grow bg-divider-subtle'></div>
</div>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<Label label={t(`${i18nPrefix}.automaticUpdates`)} description={strategyDescription} />
<StrategyPicker value={strategy_setting} onChange={handleChange('strategy_setting')} />
</div>
{strategy_setting !== AUTO_UPDATE_STRATEGY.disabled && (
<>
<div className='flex items-center justify-between'>
<Label label={t(`${i18nPrefix}.updateTime`)} />
<div className='flex flex-col justify-start'>
<TimePicker
value={timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!))}
timezone={timezone}
onChange={v => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(dayjsToTimeOfDay(v), timezone!))}
onClear={() => handleChange('upgrade_time_of_day')(convertLocalSecondsToUTCDaySeconds(0, timezone!))}
popupClassName='z-[99]'
title={t(`${i18nPrefix}.updateTime`)}
minuteFilter={minuteFilter}
renderTrigger={renderTimePickerTrigger}
/>
<div className='body-xs-regular mt-1 text-right text-text-tertiary'>
<Trans
i18nKey={`${i18nPrefix}.changeTimezone`}
components={{
setTimezone: <SettingTimeZone />,
}}
/>
</div>
</div>
</div>
<div>
<Label label={t(`${i18nPrefix}.specifyPluginsToUpdate`)} />
<div className='mt-1 flex w-full items-start justify-between gap-2'>
{[AUTO_UPDATE_MODE.update_all, AUTO_UPDATE_MODE.exclude, AUTO_UPDATE_MODE.partial].map(option => (
<OptionCard
key={option}
title={t(`${i18nPrefix}.upgradeMode.${option}`)}
onSelect={() => handleChange('upgrade_mode')(option)}
selected={upgrade_mode === option}
className="flex-1"
/>
))}
</div>
{upgrade_mode !== AUTO_UPDATE_MODE.update_all && (
<PluginsPicker
value={plugins}
onChange={handlePluginsChange}
updateMode={upgrade_mode}
/>
)}
</div>
</>
)}
</div>
</div>
)
}
export default React.memo(AutoUpdateSetting)

View File

@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { Group } from '@/app/components/base/icons/src/vender/other'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { useTranslation } from 'react-i18next'
type Props = {
className: string
noPlugins?: boolean
}
const NoDataPlaceholder: FC<Props> = ({
className,
noPlugins,
}) => {
const { t } = useTranslation()
const icon = noPlugins ? (<Group className='size-6 text-text-quaternary' />) : (<SearchMenu className='size-8 text-text-tertiary' />)
const text = t(`plugin.autoUpdate.noPluginPlaceholder.${noPlugins ? 'noInstalled' : 'noFound'}`)
return (
<div className={cn('flex items-center justify-center', className)}>
<div className='flex flex-col items-center'>
{icon}
<div className='system-sm-regular mt-2 text-text-tertiary'>{text}</div>
</div>
</div>
)
}
export default React.memo(NoDataPlaceholder)

View File

@ -0,0 +1,22 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { AUTO_UPDATE_MODE } from './types'
import { useTranslation } from 'react-i18next'
type Props = {
updateMode: AUTO_UPDATE_MODE
}
const NoPluginSelected: FC<Props> = ({
updateMode,
}) => {
const { t } = useTranslation()
const text = `${t(`plugin.autoUpdate.upgradeModePlaceholder.${updateMode === AUTO_UPDATE_MODE.partial ? 'partial' : 'exclude'}`)}`
return (
<div className='system-xs-regular rounded-[10px] border border-[divider-subtle] bg-background-section p-3 text-center text-text-tertiary'>
{text}
</div>
)
}
export default React.memo(NoPluginSelected)

View File

@ -0,0 +1,69 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import NoPluginSelected from './no-plugin-selected'
import { AUTO_UPDATE_MODE } from './types'
import PluginsSelected from './plugins-selected'
import Button from '@/app/components/base/button'
import { RiAddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import ToolPicker from './tool-picker'
const i18nPrefix = 'plugin.autoUpdate'
type Props = {
updateMode: AUTO_UPDATE_MODE
value: string[] // plugin ids
onChange: (value: string[]) => void
}
const PluginsPicker: FC<Props> = ({
updateMode,
value,
onChange,
}) => {
const { t } = useTranslation()
const hasSelected = value.length > 0
const isExcludeMode = updateMode === AUTO_UPDATE_MODE.exclude
const handleClear = () => {
onChange([])
}
const [isShowToolPicker, {
set: setToolPicker,
}] = useBoolean(false)
return (
<div className='mt-2 rounded-[10px] bg-background-section-burn p-2.5'>
{hasSelected ? (
<div className='flex justify-between text-text-tertiary'>
<div className='system-xs-medium'>{t(`${i18nPrefix}.${isExcludeMode ? 'excludeUpdate' : 'partialUPdate'}`, { num: value.length })}</div>
<div className='system-xs-medium cursor-pointer' onClick={handleClear}>{t(`${i18nPrefix}.operation.clearAll`)}</div>
</div>
) : (
<NoPluginSelected updateMode={updateMode} />
)}
{hasSelected && (
<PluginsSelected
className='mt-2'
plugins={value}
/>
)}
<ToolPicker
trigger={
<Button className='mt-2 w-[412px]' size='small' variant='secondary-accent'>
<RiAddLine className='size-3.5' />
{t(`${i18nPrefix}.operation.select`)}
</Button>
}
value={value}
onChange={onChange}
isShow={isShowToolPicker}
onShowChange={setToolPicker}
/>
</div>
)
}
export default React.memo(PluginsPicker)

View File

@ -0,0 +1,29 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Icon from '@/app/components/plugins/card/base/card-icon'
const MAX_DISPLAY_COUNT = 14
type Props = {
className?: string
plugins: string[]
}
const PluginsSelected: FC<Props> = ({
className,
plugins,
}) => {
const isShowAll = plugins.length < MAX_DISPLAY_COUNT
const displayPlugins = plugins.slice(0, MAX_DISPLAY_COUNT)
return (
<div className={cn('flex items-center space-x-1', className)}>
{displayPlugins.map(plugin => (
<Icon key={plugin} size='tiny' src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin}/icon`} />
))}
{!isShowAll && <div className='system-xs-medium text-text-tertiary'>+{plugins.length - MAX_DISPLAY_COUNT}</div>}
</div>
)
}
export default React.memo(PluginsSelected)

View File

@ -0,0 +1,98 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
import { AUTO_UPDATE_STRATEGY } from './types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Button from '@/app/components/base/button'
const i18nPrefix = 'plugin.autoUpdate.strategy'
type Props = {
value: AUTO_UPDATE_STRATEGY
onChange: (value: AUTO_UPDATE_STRATEGY) => void
}
const StrategyPicker = ({
value,
onChange,
}: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const options = [
{
value: AUTO_UPDATE_STRATEGY.disabled,
label: t(`${i18nPrefix}.disabled.name`),
description: t(`${i18nPrefix}.disabled.description`),
},
{
value: AUTO_UPDATE_STRATEGY.fixOnly,
label: t(`${i18nPrefix}.fixOnly.name`),
description: t(`${i18nPrefix}.fixOnly.description`),
},
{
value: AUTO_UPDATE_STRATEGY.latest,
label: t(`${i18nPrefix}.latest.name`),
description: t(`${i18nPrefix}.latest.description`),
},
]
const selectedOption = options.find(option => option.value === value)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-end'
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}>
<Button
size='small'
>
{selectedOption?.label}
<RiArrowDownSLine className='h-3.5 w-3.5' />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[99]'>
<div className='w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
{
options.map(option => (
<div
key={option.value}
className='flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover'
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onChange(option.value)
setOpen(false)
}}
>
<div className='mr-1 w-4 shrink-0'>
{
value === option.value && (
<RiCheckLine className='h-4 w-4 text-text-accent' />
)
}
</div>
<div className='grow'>
<div className='system-sm-semibold mb-0.5 text-text-secondary'>{option.label}</div>
<div className='system-xs-regular text-text-tertiary'>{option.description}</div>
</div>
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default StrategyPicker

View File

@ -0,0 +1,45 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { renderI18nObject } from '@/i18n'
import { useGetLanguage } from '@/context/i18n'
import { MARKETPLACE_API_PREFIX } from '@/config'
import Checkbox from '@/app/components/base/checkbox'
type Props = {
payload: PluginDetail
isChecked?: boolean
onCheckChange: () => void
}
const ToolItem: FC<Props> = ({
payload,
isChecked,
onCheckChange,
}) => {
const language = useGetLanguage()
const { plugin_id, declaration } = payload
const { label, author: org } = declaration
return (
<div className='p-1'>
<div
className='flex w-full select-none items-center rounded-lg pr-2 hover:bg-state-base-hover'
>
<div className='flex h-8 grow items-center space-x-2 pl-3 pr-2'>
<Icon size='tiny' src={`${MARKETPLACE_API_PREFIX}/plugins/${plugin_id}/icon`} />
<div className='system-sm-medium max-w-[150px] shrink-0 truncate text-text-primary'>{renderI18nObject(label, language)}</div>
<div className='system-xs-regular max-w-[150px] shrink-0 truncate text-text-quaternary'>{org}</div>
</div>
<Checkbox
checked={isChecked}
onCheck={onCheckChange}
className='shrink-0'
/>
</div>
</div>
)
}
export default React.memo(ToolItem)

View File

@ -0,0 +1,167 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useInstalledPluginList } from '@/service/use-plugins'
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch'
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import ToolItem from './tool-item'
import Loading from '@/app/components/base/loading'
import NoDataPlaceholder from './no-data-placeholder'
import { PluginSource } from '../../types'
type Props = {
trigger: React.ReactNode
value: string[]
onChange: (value: string[]) => void
isShow: boolean
onShowChange: (isShow: boolean) => void
}
const ToolPicker: FC<Props> = ({
trigger,
value,
onChange,
isShow,
onShowChange,
}) => {
const { t } = useTranslation()
const toggleShowPopup = useCallback(() => {
onShowChange(!isShow)
}, [onShowChange, isShow])
const tabs = [
{
key: PLUGIN_TYPE_SEARCH_MAP.all,
name: t('plugin.category.all'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.model,
name: t('plugin.category.models'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.tool,
name: t('plugin.category.tools'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.agent,
name: t('plugin.category.agents'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.extension,
name: t('plugin.category.extensions'),
},
{
key: PLUGIN_TYPE_SEARCH_MAP.bundle,
name: t('plugin.category.bundles'),
},
]
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
const [query, setQuery] = useState('')
const [tags, setTags] = useState<string[]>([])
const { data, isLoading } = useInstalledPluginList()
const filteredList = useMemo(() => {
const list = data ? data.plugins : []
return list.filter((plugin) => {
const isFromMarketPlace = plugin.source === PluginSource.marketplace
return (
isFromMarketPlace && (pluginType === PLUGIN_TYPE_SEARCH_MAP.all || plugin.declaration.category === pluginType)
&& (tags.length === 0 || tags.some(tag => plugin.declaration.tags.includes(tag)))
&& (query === '' || plugin.plugin_id.toLowerCase().includes(query.toLowerCase()))
)
})
}, [data, pluginType, query, tags])
const handleCheckChange = useCallback((pluginId: string) => {
return () => {
const newValue = value.includes(pluginId)
? value.filter(id => id !== pluginId)
: [...value, pluginId]
onChange(newValue)
}
}, [onChange, value])
const listContent = (
<div className='max-h-[396px] overflow-y-auto'>
{filteredList.map(item => (
<ToolItem
key={item.plugin_id}
payload={item}
isChecked={value.includes(item.plugin_id)}
onCheckChange={handleCheckChange(item.plugin_id)}
/>
))}
</div>
)
const loadingContent = (
<div className='flex h-[396px] items-center justify-center'>
<Loading />
</div>
)
const noData = (
<NoDataPlaceholder className='h-[396px]' noPlugins={!query} />
)
return (
<PortalToFollowElem
placement='top'
offset={0}
open={isShow}
onOpenChange={onShowChange}
>
<PortalToFollowElemTrigger
onClick={toggleShowPopup}
>
{trigger}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn('relative min-h-20 w-[436px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur pb-2 shadow-lg backdrop-blur-sm')}>
<div className='p-2 pb-1'>
<SearchBox
search={query}
onSearchChange={setQuery}
tags={tags}
onTagsChange={setTags}
size='small'
placeholder={t('plugin.searchTools')!}
inputClassName='w-full'
/>
</div>
<div className='flex items-center justify-between border-b-[0.5px] border-divider-subtle bg-background-default-hover px-3 shadow-xs'>
<div className='flex h-8 items-center space-x-1'>
{
tabs.map(tab => (
<div
className={cn(
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
'text-xs font-medium text-text-secondary',
pluginType === tab.key && 'bg-state-base-hover-alt',
)}
key={tab.key}
onClick={() => setPluginType(tab.key)}
>
{tab.name}
</div>
))
}
</div>
</div>
{!isLoading && filteredList.length > 0 && listContent}
{!isLoading && filteredList.length === 0 && noData}
{isLoading && loadingContent}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(ToolPicker)

View File

@ -0,0 +1,19 @@
export enum AUTO_UPDATE_STRATEGY {
fixOnly = 'fix_only',
disabled = 'disabled',
latest = 'latest',
}
export enum AUTO_UPDATE_MODE {
partial = 'partial',
exclude = 'exclude',
update_all = 'all',
}
export type AutoUpdateConfig = {
strategy_setting: AUTO_UPDATE_STRATEGY
upgrade_time_of_day: number
upgrade_mode: AUTO_UPDATE_MODE
exclude_plugins: string[]
include_plugins: string[]
}

View File

@ -0,0 +1,14 @@
import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds } from './utils'
describe('convertLocalSecondsToUTCDaySeconds', () => {
it('should convert local seconds to UTC day seconds correctly', () => {
const localTimezone = 'Asia/Shanghai'
const utcSeconds = convertLocalSecondsToUTCDaySeconds(0, localTimezone)
expect(utcSeconds).toBe((24 - 8) * 3600)
})
it('should convert local seconds to UTC day seconds for a specific time', () => {
const localTimezone = 'Asia/Shanghai'
expect(convertUTCDaySecondsToLocalSeconds(convertLocalSecondsToUTCDaySeconds(0, localTimezone), localTimezone)).toBe(0)
})
})

View File

@ -0,0 +1,37 @@
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export const timeOfDayToDayjs = (timeOfDay: number): Dayjs => {
const hours = Math.floor(timeOfDay / 3600)
const minutes = (timeOfDay - hours * 3600) / 60
const res = dayjs().startOf('day').hour(hours).minute(minutes)
return res
}
export const convertLocalSecondsToUTCDaySeconds = (secondsInDay: number, localTimezone: string): number => {
const localDayStart = dayjs().tz(localTimezone).startOf('day')
const localTargetTime = localDayStart.add(secondsInDay, 'second')
const utcTargetTime = localTargetTime.utc()
const utcDayStart = utcTargetTime.startOf('day')
const secondsFromUTCMidnight = utcTargetTime.diff(utcDayStart, 'second')
return secondsFromUTCMidnight
}
export const dayjsToTimeOfDay = (date?: Dayjs): number => {
if (!date) return 0
return date.hour() * 3600 + date.minute() * 60
}
export const convertUTCDaySecondsToLocalSeconds = (utcDaySeconds: number, localTimezone: string): number => {
const utcDayStart = dayjs().utc().startOf('day')
const utcTargetTime = utcDayStart.add(utcDaySeconds, 'second')
const localTargetTime = utcTargetTime.tz(localTimezone)
const localDayStart = localTargetTime.startOf('day')
const secondsInLocalDay = localTargetTime.diff(localDayStart, 'second')
return secondsInLocalDay
}

View File

@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
type Props = {
label: string
description?: string
}
const Label: FC<Props> = ({
label,
description,
}) => {
return (
<div>
<div className={cn('flex h-6 items-center', description && 'h-4')}>
<span className='system-sm-semibold text-text-secondary'>{label}</span>
</div>
{description && (
<div className='body-xs-regular mt-1 text-text-tertiary'>
{description}
</div>
)}
</div>
)
}
export default React.memo(Label)

View File

@ -5,14 +5,18 @@ import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import Button from '@/app/components/base/button'
import type { Permissions } from '@/app/components/plugins/types'
import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types'
import { PermissionType } from '@/app/components/plugins/types'
import type { AutoUpdateConfig } from './auto-update-setting/types'
import AutoUpdateSetting from './auto-update-setting'
import { defaultValue as autoUpdateDefaultValue } from './auto-update-setting/config'
import Label from './label'
const i18nPrefix = 'plugin.privilege'
type Props = {
payload: Permissions
payload: ReferenceSetting
onHide: () => void
onSave: (payload: Permissions) => void
onSave: (payload: ReferenceSetting) => void
}
const PluginSettingModal: FC<Props> = ({
@ -21,7 +25,9 @@ const PluginSettingModal: FC<Props> = ({
onSave,
}) => {
const { t } = useTranslation()
const [tempPrivilege, setTempPrivilege] = useState<Permissions>(payload)
const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {}
const [tempPrivilege, setTempPrivilege] = useState<Permissions>(privilege)
const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>(autoUpdateConfig || autoUpdateDefaultValue)
const handlePrivilegeChange = useCallback((key: string) => {
return (value: PermissionType) => {
setTempPrivilege({
@ -32,18 +38,21 @@ const PluginSettingModal: FC<Props> = ({
}, [tempPrivilege])
const handleSave = useCallback(async () => {
await onSave(tempPrivilege)
await onSave({
permission: tempPrivilege,
auto_upgrade: tempAutoUpdateConfig,
})
onHide()
}, [onHide, onSave, tempPrivilege])
}, [onHide, onSave, tempAutoUpdateConfig, tempPrivilege])
return (
<Modal
isShow
onClose={onHide}
closable
className='w-[420px] !p-0'
className='w-[480px] !p-0'
>
<div className='shadows-shadow-xl flex w-[420px] flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg'>
<div className='shadows-shadow-xl flex w-[480px] flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg'>
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
<span className='title-2xl-semi-bold self-stretch text-text-primary'>{t(`${i18nPrefix}.title`)}</span>
</div>
@ -53,9 +62,7 @@ const PluginSettingModal: FC<Props> = ({
{ title: t(`${i18nPrefix}.whoCanDebug`), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne },
].map(({ title, key, value }) => (
<div key={key} className='flex flex-col items-start gap-1 self-stretch'>
<div className='flex h-6 items-center gap-0.5'>
<span className='system-sm-semibold text-text-secondary'>{title}</span>
</div>
<Label label={title} />
<div className='flex w-full items-start justify-between gap-2'>
{[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => (
<OptionCard
@ -70,6 +77,8 @@ const PluginSettingModal: FC<Props> = ({
</div>
))}
</div>
<AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} />
<div className='flex h-[76px] items-center justify-end gap-2 self-stretch p-6 pt-5'>
<Button
className='min-w-[72px]'

View File

@ -2,6 +2,7 @@ import type { CredentialFormSchemaBase } from '../header/account-setting/model-p
import type { ToolCredential } from '@/app/components/tools/types'
import type { Locale } from '@/i18n'
import type { AgentFeature } from '@/app/components/workflow/nodes/agent/types'
import type { AutoUpdateConfig } from './reference-setting-modal/auto-update-setting/types'
export enum PluginType {
tool = 'tool',
model = 'model',
@ -170,6 +171,11 @@ export type Permissions = {
debug_permission: PermissionType
}
export type ReferenceSetting = {
permission: Permissions
auto_upgrade: AutoUpdateConfig
}
export type UpdateFromMarketPlacePayload = {
category: PluginType
originalPackageInfo: {

View File

@ -0,0 +1,35 @@
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
const i18nPrefix = 'plugin.autoUpdate.pluginDowngradeWarning'
type Props = {
onCancel: () => void
onJustDowngrade: () => void
onExcludeAndDowngrade: () => void
}
const DowngradeWarningModal = ({
onCancel,
onJustDowngrade,
onExcludeAndDowngrade,
}: Props) => {
const { t } = useTranslation()
return (
<>
<div className='flex flex-col items-start gap-2 self-stretch'>
<div className='title-2xl-semi-bold text-text-primary'>{t(`${i18nPrefix}.title`)}</div>
<div className='system-md-regular text-text-secondary'>
{t(`${i18nPrefix}.description`)}
</div>
</div>
<div className='mt-9 flex items-start justify-end space-x-2 self-stretch'>
<Button variant='secondary' onClick={() => onCancel()}>{t('app.newApp.Cancel')}</Button>
<Button variant='secondary' destructive onClick={onJustDowngrade}>{t(`${i18nPrefix}.downgrade`)}</Button>
<Button variant='primary' onClick={onExcludeAndDowngrade}>{t(`${i18nPrefix}.exclude`)}</Button>
</div>
</>
)
}
export default DowngradeWarningModal

View File

@ -13,13 +13,18 @@ import { updateFromMarketPlace } from '@/service/plugins'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import { usePluginTaskList } from '@/service/use-plugins'
import Toast from '../../base/toast'
import DowngradeWarningModal from './downgrade-warning'
import { useInvalidateReferenceSettings, useRemoveAutoUpgrade } from '@/service/use-plugins'
import cn from '@/utils/classnames'
const i18nPrefix = 'plugin.upgrade'
type Props = {
payload: UpdateFromMarketPlacePayload
pluginId: string
onSave: () => void
onCancel: () => void
isShowDowngradeWarningModal?: boolean
}
enum UploadStep {
@ -30,8 +35,10 @@ enum UploadStep {
const UpdatePluginModal: FC<Props> = ({
payload,
pluginId,
onSave,
onCancel,
isShowDowngradeWarningModal,
}) => {
const {
originalPackageInfo,
@ -103,14 +110,34 @@ const UpdatePluginModal: FC<Props> = ({
onSave()
}, [onSave, uploadStep, check, originalPackageInfo.id, handleRefetch, targetPackageInfo.id])
const { mutateAsync } = useRemoveAutoUpgrade()
const invalidateReferenceSettings = useInvalidateReferenceSettings()
const handleExcludeAndDownload = async () => {
await mutateAsync({
plugin_id: pluginId,
})
invalidateReferenceSettings()
handleConfirm()
}
const doShowDowngradeWarningModal = isShowDowngradeWarningModal && uploadStep === UploadStep.notStarted
return (
<Modal
isShow={true}
onClose={onCancel}
className='min-w-[560px]'
className={cn('min-w-[560px]', doShowDowngradeWarningModal && 'min-w-[640px]')}
closable
title={t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`)}
title={!doShowDowngradeWarningModal && t(`${i18nPrefix}.${uploadStep === UploadStep.installed ? 'successfulTitle' : 'title'}`)}
>
{doShowDowngradeWarningModal && (
<DowngradeWarningModal
onCancel={onCancel}
onJustDowngrade={handleConfirm}
onExcludeAndDowngrade={handleExcludeAndDownload}
/>
)}
{!doShowDowngradeWarningModal && (
<>
<div className='system-md-regular mb-2 mt-3 text-text-secondary'>
{t(`${i18nPrefix}.description`)}
</div>
@ -148,6 +175,9 @@ const UpdatePluginModal: FC<Props> = ({
{configBtnText}
</Button>
</div>
</>
)}
</Modal>
)
}

View File

@ -15,6 +15,7 @@ import type {
import { useVersionListOfPlugin } from '@/service/use-plugins'
import useTimestamp from '@/hooks/use-timestamp'
import cn from '@/utils/classnames'
import { lt } from 'semver'
type Props = {
disabled?: boolean
@ -28,9 +29,11 @@ type Props = {
onSelect: ({
version,
unique_identifier,
isDowngrade,
}: {
version: string
unique_identifier: string
isDowngrade: boolean
}) => void
}
@ -59,13 +62,14 @@ const PluginVersionPicker: FC<Props> = ({
const { data: res } = useVersionListOfPlugin(pluginID)
const handleSelect = useCallback(({ version, unique_identifier }: {
const handleSelect = useCallback(({ version, unique_identifier, isDowngrade }: {
version: string
unique_identifier: string
isDowngrade: boolean
}) => {
if (currentVersion === version)
return
onSelect({ version, unique_identifier })
onSelect({ version, unique_identifier, isDowngrade })
onShowChange(false)
}, [currentVersion, onSelect, onShowChange])
@ -99,6 +103,7 @@ const PluginVersionPicker: FC<Props> = ({
onClick={() => handleSelect({
version: version.version,
unique_identifier: version.unique_identifier,
isDowngrade: lt(version.version, currentVersion),
})}
>
<div className='flex grow items-center'>

View File

@ -125,6 +125,56 @@ const translation = {
admins: 'Admins',
noone: 'No one',
},
autoUpdate: {
automaticUpdates: 'Automatic updates',
updateTime: 'Update time',
specifyPluginsToUpdate: 'Specify plugins to update',
strategy: {
disabled: {
name: 'Disabled',
description: 'Plugins will not auto-update',
},
fixOnly: {
name: 'Fix Only',
description: 'Auto-update for patch versions only (e.g., 1.0.1 → 1.0.2). Minor version changes won\'t trigger updates.',
selectedDescription: 'Auto-update for patch versions only',
},
latest: {
name: 'Latest',
description: 'Always update to latest version',
selectedDescription: 'Always update to latest version',
},
},
updateTimeTitle: 'Update time',
upgradeMode: {
all: 'Update all',
exclude: 'Exclude selected',
partial: 'Only selected',
},
upgradeModePlaceholder: {
exclude: 'Selected plugins will not auto-update',
partial: 'Only selected plugins will auto-update. No plugins are currently selected, so no plugins will auto-update.',
},
excludeUpdate: 'The following {{num}} plugins will not auto-update',
partialUPdate: 'Only the following {{num}} plugins will auto-update',
operation: {
clearAll: 'Clear all',
select: 'Select plugins',
},
nextUpdateTime: 'Next auto-update: {{time}}',
pluginDowngradeWarning: {
title: 'Plugin Downgrade',
description: 'Auto-update is currently enabled for this plugin. Downgrading the version may cause your changes to be overwritten during the next automatic update.',
downgrade: 'Downgrade anyway',
exclude: 'Exclude from auto-update',
},
noPluginPlaceholder: {
noFound: 'No plugins were found',
noInstalled: 'No plugins installed',
},
updateSettings: 'Update Settings',
changeTimezone: 'To change time zone, go to <setTimezone>Settings</setTimezone>',
},
pluginInfoModal: {
title: 'Plugin info',
repository: 'Repository',

View File

@ -125,6 +125,56 @@ const translation = {
admins: '管理员',
noone: '无人',
},
autoUpdate: {
automaticUpdates: '自动更新',
updateTime: '更新时间',
specifyPluginsToUpdate: '指定要更新的插件',
strategy: {
disabled: {
name: '禁用',
description: '插件将不会自动更新',
},
fixOnly: {
name: '仅修复',
description: '仅自动更新补丁版本例如1.0.1 → 1.0.2)。次要版本更改不会触发更新。',
selectedDescription: '仅自动更新补丁版本',
},
latest: {
name: '最新',
description: '始终更新到最新版本',
selectedDescription: '始终更新到最新版本',
},
},
updateTimeTitle: '更新时间',
upgradeMode: {
all: '更新全部',
exclude: '排除选定',
partial: '仅选定',
},
upgradeModePlaceholder: {
exclude: '选定的插件将不会自动更新',
partial: '仅选定的插件将自动更新。目前未选择任何插件,因此不会自动更新任何插件。',
},
excludeUpdate: '以下 {{num}} 个插件将不会自动更新',
partialUPdate: '仅以下 {{num}} 个插件将自动更新',
operation: {
clearAll: '清除所有',
select: '选择插件',
},
nextUpdateTime: '下次自动更新时间: {{time}}',
pluginDowngradeWarning: {
title: '插件降级',
description: '此插件目前已启用自动更新。降级版本可能会导致您的更改在下次自动更新时被覆盖。',
downgrade: '仍然降级',
exclude: '从自动更新中排除',
},
noPluginPlaceholder: {
noFound: '未找到插件',
noInstalled: '未安装插件',
},
updateSettings: '更新设置',
changeTimezone: '要更改时区,请前往<setTimezone>设置</setTimezone>',
},
pluginInfoModal: {
title: '插件信息',
repository: '仓库',

View File

@ -13,7 +13,6 @@ import type {
InstalledLatestVersionResponse,
InstalledPluginListWithTotalResponse,
PackageDependency,
Permissions,
Plugin,
PluginDeclaration,
PluginDetail,
@ -22,6 +21,7 @@ import type {
PluginType,
PluginsFromMarketplaceByInfoResponse,
PluginsFromMarketplaceResponse,
ReferenceSetting,
VersionInfo,
VersionListResponse,
uploadGitHubResponse,
@ -40,7 +40,7 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import { useInvalidateAllBuiltInTools } from './use-tools'
import usePermission from '@/app/components/plugins/plugin-page/use-permission'
import useReferenceSetting from '@/app/components/plugins/plugin-page/use-reference-setting'
import { uninstallPlugin } from '@/service/plugins'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { cloneDeep } from 'lodash-es'
@ -350,37 +350,45 @@ export const useDebugKey = () => {
})
}
const usePermissionsKey = [NAME_SPACE, 'permissions']
export const usePermissions = () => {
const useReferenceSettingKey = [NAME_SPACE, 'referenceSettings']
export const useReferenceSettings = () => {
return useQuery({
queryKey: usePermissionsKey,
queryFn: () => get<Permissions>('/workspaces/current/plugin/permission/fetch'),
queryKey: useReferenceSettingKey,
queryFn: () => get<ReferenceSetting>('/workspaces/current/plugin/preferences/fetch'),
})
}
export const useInvalidatePermissions = () => {
export const useInvalidateReferenceSettings = () => {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries(
{
queryKey: usePermissionsKey,
queryKey: useReferenceSettingKey,
})
}
}
export const useMutationPermissions = ({
export const useMutationReferenceSettings = ({
onSuccess,
}: {
onSuccess?: () => void
}) => {
return useMutation({
mutationFn: (payload: Permissions) => {
return post('/workspaces/current/plugin/permission/change', { body: payload })
mutationFn: (payload: ReferenceSetting) => {
return post('/workspaces/current/plugin/preferences/change', { body: payload })
},
onSuccess,
})
}
export const useRemoveAutoUpgrade = () => {
return useMutation({
mutationFn: (payload: { plugin_id: string }) => {
return post('/workspaces/current/plugin/preferences/autoupgrade/exclude', { body: payload })
},
})
}
export const useMutationPluginsFromMarketplace = () => {
return useMutation({
mutationFn: (pluginsSearchParams: PluginsSearchParams) => {
@ -427,6 +435,39 @@ export const useFetchPluginsInMarketPlaceByIds = (unique_identifiers: string[],
})
}
export const useFetchPluginListOrBundleList = (pluginsSearchParams: PluginsSearchParams) => {
return useQuery({
queryKey: [NAME_SPACE, 'fetchPluginListOrBundleList', pluginsSearchParams],
queryFn: () => {
const {
query,
sortBy,
sortOrder,
category,
tags,
exclude,
type,
page = 1,
pageSize = 40,
} = pluginsSearchParams
const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins'
return postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, {
body: {
page,
page_size: pageSize,
query,
sort_by: sortBy,
sort_order: sortOrder,
category: category !== 'all' ? category : '',
tags,
exclude,
type,
},
})
},
})
}
export const useFetchPluginsInMarketPlaceByInfo = (infos: Record<string, any>[]) => {
return useQuery({
queryKey: [NAME_SPACE, 'fetchPluginsInMarketPlaceByInfo', infos],
@ -448,7 +489,7 @@ const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList']
export const usePluginTaskList = (category?: PluginType) => {
const {
canManagement,
} = usePermission()
} = useReferenceSetting()
const { refreshPluginList } = useRefreshPluginList()
const {
data,