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>
pull/22824/head
Junyan Qin (Chin) 10 months ago committed by GitHub
parent e6913744ae
commit eaae79a581
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -471,6 +471,16 @@ APP_MAX_ACTIVE_REQUESTS=0
# Celery beat configuration # Celery beat configuration
CELERY_BEAT_SCHEDULER_TIME=1 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 configuration
POSITION_TOOL_PINS= POSITION_TOOL_PINS=
POSITION_TOOL_INCLUDES= POSITION_TOOL_INCLUDES=

@ -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. 10. If you need to handle and debug the async tasks (e.g. dataset importing and documents indexing), please start the worker service.
```bash ```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 ## Testing

@ -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): class PositionConfig(BaseSettings):
POSITION_PROVIDER_PINS: str = Field( POSITION_PROVIDER_PINS: str = Field(
description="Comma-separated list of pinned model providers", description="Comma-separated list of pinned model providers",
@ -961,5 +996,6 @@ class FeatureConfig(
# hosted services config # hosted services config
HostedServiceConfig, HostedServiceConfig,
CeleryBeatConfig, CeleryBeatConfig,
CeleryScheduleTasksConfig,
): ):
pass pass

@ -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.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.impl.exc import PluginDaemonClientSideError
from libs.login import login_required 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_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService from services.plugin.plugin_permission_service import PluginPermissionService
from services.plugin.plugin_service import PluginService from services.plugin.plugin_service import PluginService
@ -534,6 +535,114 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
return jsonable_encoder({"options": options}) 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(PluginDebuggingKeyApi, "/workspaces/current/plugin/debugging-key")
api.add_resource(PluginListApi, "/workspaces/current/plugin/list") api.add_resource(PluginListApi, "/workspaces/current/plugin/list")
api.add_resource(PluginListLatestVersionsApi, "/workspaces/current/plugin/list/latest-versions") 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(PluginFetchPermissionApi, "/workspaces/current/plugin/permission/fetch")
api.add_resource(PluginFetchDynamicSelectOptionsApi, "/workspaces/current/plugin/parameters/dynamic-options") 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")

@ -25,9 +25,29 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP
url = str(marketplace_api_url / "api/v1/plugins/batch") url = str(marketplace_api_url / "api/v1/plugins/batch")
response = requests.post(url, json={"plugin_ids": plugin_ids}) response = requests.post(url, json={"plugin_ids": plugin_ids})
response.raise_for_status() response.raise_for_status()
return [MarketplacePluginDeclaration(**plugin) for plugin in response.json()["data"]["plugins"]] 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): def record_install_plugin_event(plugin_unique_identifier: str):
url = str(marketplace_api_url / "api/v1/stats/plugins/install_count") url = str(marketplace_api_url / "api/v1/stats/plugins/install_count")
response = requests.post(url, json={"unique_identifier": plugin_unique_identifier}) response = requests.post(url, json={"unique_identifier": plugin_unique_identifier})

@ -22,7 +22,7 @@ if [[ "${MODE}" == "worker" ]]; then
exec celery -A app.celery worker -P ${CELERY_WORKER_CLASS:-gevent} $CONCURRENCY_OPTION \ 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} \ --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 elif [[ "${MODE}" == "beat" ]]; then
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO} exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}

@ -64,49 +64,62 @@ def init_app(app: DifyApp) -> Celery:
celery_app.set_default() celery_app.set_default()
app.extensions["celery"] = celery_app app.extensions["celery"] = celery_app
imports = [ 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",
]
day = dify_config.CELERY_BEAT_SCHEDULER_TIME 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", "task": "schedule.clean_embedding_cache_task.clean_embedding_cache_task",
"schedule": timedelta(days=day), "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", "task": "schedule.clean_unused_datasets_task.clean_unused_datasets_task",
"schedule": timedelta(days=day), "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", "task": "schedule.create_tidb_serverless_task.create_tidb_serverless_task",
"schedule": crontab(minute="0", hour="*"), "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", "task": "schedule.update_tidb_serverless_status_task.update_tidb_serverless_status_task",
"schedule": timedelta(minutes=10), "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", "task": "schedule.clean_messages.clean_messages",
"schedule": timedelta(days=day), "schedule": timedelta(days=day),
}, }
# every Monday if dify_config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK:
"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", "task": "schedule.mail_clean_document_notify_task.mail_clean_document_notify_task",
"schedule": crontab(minute="0", hour="10", day_of_week="1"), "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", "task": "schedule.queue_monitor_task.queue_monitor_task",
"schedule": timedelta( "schedule": timedelta(
minutes=dify_config.QUEUE_MONITOR_INTERVAL if dify_config.QUEUE_MONITOR_INTERVAL else 30 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) celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
return celery_app return celery_app

@ -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 ###

@ -297,6 +297,40 @@ class TenantPluginPermission(Base):
) )
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
install_permission: Mapped[InstallPermission] = mapped_column(db.String(16), server_default="everyone") install_permission: Mapped[InstallPermission] = mapped_column(
debug_permission: Mapped[DebugPermission] = mapped_column(db.String(16), server_default="noone") 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())

@ -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",
)
)

@ -29,6 +29,7 @@ from models.account import (
Tenant, Tenant,
TenantAccountJoin, TenantAccountJoin,
TenantAccountRole, TenantAccountRole,
TenantPluginAutoUpgradeStrategy,
TenantStatus, TenantStatus,
) )
from models.model import DifySetup from models.model import DifySetup
@ -828,6 +829,17 @@ class TenantService:
db.session.add(tenant) db.session.add(tenant)
db.session.commit() 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) tenant.encrypt_public_key = generate_key_pair(tenant.id)
db.session.commit() db.session.commit()
return tenant return tenant

@ -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

@ -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

@ -1168,3 +1168,13 @@ QUEUE_MONITOR_THRESHOLD=200
QUEUE_MONITOR_ALERT_EMAILS= QUEUE_MONITOR_ALERT_EMAILS=
# Monitor interval in minutes, default is 30 minutes # Monitor interval in minutes, default is 30 minutes
QUEUE_MONITOR_INTERVAL=30 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

@ -55,6 +55,25 @@ services:
- ssrf_proxy_network - ssrf_proxy_network
- default - 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. # Frontend web application.
web: web:
image: langgenius/dify-web:1.6.0 image: langgenius/dify-web:1.6.0

@ -527,6 +527,14 @@ x-shared-env: &shared-api-worker-env
QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200} QUEUE_MONITOR_THRESHOLD: ${QUEUE_MONITOR_THRESHOLD:-200}
QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-} QUEUE_MONITOR_ALERT_EMAILS: ${QUEUE_MONITOR_ALERT_EMAILS:-}
QUEUE_MONITOR_INTERVAL: ${QUEUE_MONITOR_INTERVAL:-30} 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: services:
# API service # API service
@ -584,6 +592,25 @@ services:
- ssrf_proxy_network - ssrf_proxy_network
- default - 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. # Frontend web application.
web: web:
image: langgenius/dify-web:1.6.0 image: langgenius/dify-web:1.6.0

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

@ -1,13 +1,18 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const Header = () => { type Props = {
title?: string
}
const Header = ({
title,
}: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div className='flex flex-col border-b-[0.5px] border-divider-regular'> <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'> <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>
</div> </div>
) )

@ -20,6 +20,9 @@ const TimePicker = ({
onChange, onChange,
onClear, onClear,
renderTrigger, renderTrigger,
title,
minuteFilter,
popupClassName,
}: TimePickerProps) => { }: TimePickerProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@ -108,6 +111,15 @@ const TimePicker = ({
const displayValue = value?.format(timeFormat) || '' const displayValue = value?.format(timeFormat) || ''
const placeholderDate = isOpen && selectedTime ? selectedTime.format(timeFormat) : (placeholder || t('time.defaultPlaceholder')) const placeholderDate = isOpen && selectedTime ? selectedTime.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
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'
readOnly
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
/>
)
return ( return (
<PortalToFollowElem <PortalToFollowElem
open={isOpen} open={isOpen}
@ -115,18 +127,16 @@ const TimePicker = ({
placement='bottom-end' placement='bottom-end'
> >
<PortalToFollowElemTrigger> <PortalToFollowElemTrigger>
{renderTrigger ? (renderTrigger()) : ( {renderTrigger ? (renderTrigger({
inputElem,
onClick: handleClickTrigger,
isOpen,
})) : (
<div <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' 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} onClick={handleClickTrigger}
> >
<input {inputElem}
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'
readOnly
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
/>
<RiTimeLine className={cn( <RiTimeLine className={cn(
'h-4 w-4 shrink-0 text-text-quaternary', 'h-4 w-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary', isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
@ -142,14 +152,15 @@ const TimePicker = ({
</div> </div>
)} )}
</PortalToFollowElemTrigger> </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'> <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 /> <Header title={title} />
{/* Time Options */} {/* Time Options */}
<Options <Options
selectedTime={selectedTime} selectedTime={selectedTime}
minuteFilter={minuteFilter}
handleSelectHour={handleSelectHour} handleSelectHour={handleSelectHour}
handleSelectMinute={handleSelectMinute} handleSelectMinute={handleSelectMinute}
handleSelectPeriod={handleSelectPeriod} handleSelectPeriod={handleSelectPeriod}

@ -5,6 +5,7 @@ import OptionListItem from '../common/option-list-item'
const Options: FC<TimeOptionsProps> = ({ const Options: FC<TimeOptionsProps> = ({
selectedTime, selectedTime,
minuteFilter,
handleSelectHour, handleSelectHour,
handleSelectMinute, handleSelectMinute,
handleSelectPeriod, handleSelectPeriod,
@ -33,7 +34,7 @@ const Options: FC<TimeOptionsProps> = ({
{/* Minute */} {/* Minute */}
<ul className='no-scrollbar flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]'> <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 const isSelected = selectedTime?.format('mm') === minute
return ( return (
<OptionListItem <OptionListItem
@ -57,6 +58,7 @@ const Options: FC<TimeOptionsProps> = ({
key={period} key={period}
isSelected={isSelected} isSelected={isSelected}
onClick={handleSelectPeriod.bind(null, period)} 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} {period}
</OptionListItem> </OptionListItem>

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

@ -2,6 +2,7 @@ import dayjs, { type Dayjs } from 'dayjs'
import type { Day } from '../types' import type { Day } from '../types'
import utc from 'dayjs/plugin/utc' import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone' import timezone from 'dayjs/plugin/timezone'
import tz from '@/utils/timezone.json'
dayjs.extend(utc) dayjs.extend(utc)
dayjs.extend(timezone) dayjs.extend(timezone)
@ -78,3 +79,14 @@ export const getHourIn12Hour = (date: Dayjs) => {
export const getDateWithTimezone = (props: { date?: Dayjs, timezone?: string }) => { export const getDateWithTimezone = (props: { date?: Dayjs, timezone?: string }) => {
return props.date ? dayjs.tz(props.date, props.timezone) : dayjs().tz(props.timezone) 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)}`
}

@ -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

@ -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

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

File diff suppressed because one or more lines are too long

@ -4,12 +4,16 @@
import * as React from 'react' import * as React from 'react'
import data from './AliyunIcon.json' import data from './AliyunIcon.json'
import IconBase from '@/app/components/base/icons/IconBase' 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'>>(( const Icon = (
props, {
ref, 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' Icon.displayName = 'AliyunIcon'

File diff suppressed because one or more lines are too long

@ -4,12 +4,16 @@
import * as React from 'react' import * as React from 'react'
import data from './AliyunIconBig.json' import data from './AliyunIconBig.json'
import IconBase from '@/app/components/base/icons/IconBase' 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'>>(( const Icon = (
props, {
ref, 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' Icon.displayName = 'AliyunIconBig'

@ -4,12 +4,16 @@
import * as React from 'react' import * as React from 'react'
import data from './WeaveIcon.json' import data from './WeaveIcon.json'
import IconBase from '@/app/components/base/icons/IconBase' 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'>>(( const Icon = (
props, {
ref, 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' Icon.displayName = 'WeaveIcon'

@ -4,12 +4,16 @@
import * as React from 'react' import * as React from 'react'
import data from './WeaveIconBig.json' import data from './WeaveIconBig.json'
import IconBase from '@/app/components/base/icons/IconBase' 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'>>(( const Icon = (
props, {
ref, 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' Icon.displayName = 'WeaveIconBig'

@ -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 ArizeIconBig } from './ArizeIconBig'
export { default as ArizeIcon } from './ArizeIcon' export { default as ArizeIcon } from './ArizeIcon'
export { default as LangfuseIconBig } from './LangfuseIconBig' 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 TracingIcon } from './TracingIcon'
export { default as WeaveIconBig } from './WeaveIconBig' export { default as WeaveIconBig } from './WeaveIconBig'
export { default as WeaveIcon } from './WeaveIcon' export { default as WeaveIcon } from './WeaveIcon'
export { default as AliyunIconBig } from './AliyunIconBig'
export { default as AliyunIcon } from './AliyunIcon'

@ -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"
}

@ -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

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

@ -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"
}

@ -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

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

@ -10,7 +10,7 @@ import type { ExposeRefs } from './install-multi'
import InstallMulti from './install-multi' import InstallMulti from './install-multi'
import { useInstallOrUpdate } from '@/service/use-plugins' import { useInstallOrUpdate } from '@/service/use-plugins'
import useRefreshPluginList from '../../hooks/use-refresh-plugin-list' 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 { useMittContextSelector } from '@/context/mitt-context'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
const i18nPrefix = 'plugin.installModal' const i18nPrefix = 'plugin.installModal'

@ -40,6 +40,11 @@ import { PluginAuth } from '@/app/components/plugins/plugin-auth'
import { AuthCategory } from '@/app/components/plugins/plugin-auth' import { AuthCategory } from '@/app/components/plugins/plugin-auth'
import { useAllToolProviders } from '@/service/use-tools' import { useAllToolProviders } from '@/service/use-tools'
import DeprecationNotice from '../base/deprecation-notice' 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' const i18nPrefix = 'plugin.action'
@ -55,6 +60,8 @@ const DetailHeader = ({
onUpdate, onUpdate,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { userProfile: { timezone } } = useAppContext()
const { theme } = useTheme() const { theme } = useTheme()
const locale = useGetLanguage() const locale = useGetLanguage()
const { locale: currentLocale } = useI18N() const { locale: currentLocale } = useI18N()
@ -112,8 +119,24 @@ const DetailHeader = ({
setFalse: hideUpdateModal, setFalse: hideUpdateModal,
}] = useBoolean(false) }] = 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) { if (isFromMarketplace) {
setIsDowngrade(!!isDowngrade)
showUpdateModal() showUpdateModal()
return return
} }
@ -180,9 +203,6 @@ const DetailHeader = ({
} }
}, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders]) }, [showDeleting, installation_id, hideDeleting, hideDeleteConfirm, onUpdate, category, refreshModelProviders, invalidateAllToolProviders])
// #plugin TODO# used in apps
// const usedInApps = 3
return ( return (
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}> <div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3')}>
<div className="flex"> <div className="flex">
@ -201,7 +221,7 @@ const DetailHeader = ({
currentVersion={version} currentVersion={version}
onSelect={(state) => { onSelect={(state) => {
setTargetVersion(state) setTargetVersion(state)
handleUpdate() handleUpdate(state.isDowngrade)
}} }}
trigger={ trigger={
<Badge <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) && ( {(hasNewVersion || isFromGitHub) && (
<Button variant='secondary-accent' size='small' className='!h-5' onClick={() => { <Button variant='secondary-accent' size='small' className='!h-5' onClick={() => {
if (isFromMarketplace) { if (isFromMarketplace) {
@ -324,6 +356,7 @@ const DetailHeader = ({
{ {
isShowUpdateModal && ( isShowUpdateModal && (
<UpdateFromMarketplace <UpdateFromMarketplace
pluginId={plugin_id}
payload={{ payload={{
category: detail.declaration.category, category: detail.declaration.category,
originalPackageInfo: { originalPackageInfo: {
@ -337,6 +370,7 @@ const DetailHeader = ({
}} }}
onCancel={hideUpdateModal} onCancel={hideUpdateModal}
onSave={handleUpdatedFromMarketplace} onSave={handleUpdatedFromMarketplace}
isShowDowngradeWarningModal={isDowngrade && isAutoUpgradeEnabled}
/> />
) )
} }

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

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

@ -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: [],
}

@ -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)

@ -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)

@ -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)

@ -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)

@ -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)

@ -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

@ -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)

@ -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)

@ -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[]
}

@ -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)
})
})

@ -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
}

@ -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)

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

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

@ -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

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

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

@ -125,6 +125,56 @@ const translation = {
admins: 'Admins', admins: 'Admins',
noone: 'No one', 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: { pluginInfoModal: {
title: 'Plugin info', title: 'Plugin info',
repository: 'Repository', repository: 'Repository',

@ -125,6 +125,56 @@ const translation = {
admins: '管理员', admins: '管理员',
noone: '无人', 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: { pluginInfoModal: {
title: '插件信息', title: '插件信息',
repository: '仓库', repository: '仓库',

@ -13,7 +13,6 @@ import type {
InstalledLatestVersionResponse, InstalledLatestVersionResponse,
InstalledPluginListWithTotalResponse, InstalledPluginListWithTotalResponse,
PackageDependency, PackageDependency,
Permissions,
Plugin, Plugin,
PluginDeclaration, PluginDeclaration,
PluginDetail, PluginDetail,
@ -22,6 +21,7 @@ import type {
PluginType, PluginType,
PluginsFromMarketplaceByInfoResponse, PluginsFromMarketplaceByInfoResponse,
PluginsFromMarketplaceResponse, PluginsFromMarketplaceResponse,
ReferenceSetting,
VersionInfo, VersionInfo,
VersionListResponse, VersionListResponse,
uploadGitHubResponse, uploadGitHubResponse,
@ -40,7 +40,7 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { useInvalidateAllBuiltInTools } from './use-tools' 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 { uninstallPlugin } from '@/service/plugins'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { cloneDeep } from 'lodash-es' import { cloneDeep } from 'lodash-es'
@ -350,37 +350,45 @@ export const useDebugKey = () => {
}) })
} }
const usePermissionsKey = [NAME_SPACE, 'permissions'] const useReferenceSettingKey = [NAME_SPACE, 'referenceSettings']
export const usePermissions = () => { export const useReferenceSettings = () => {
return useQuery({ return useQuery({
queryKey: usePermissionsKey, queryKey: useReferenceSettingKey,
queryFn: () => get<Permissions>('/workspaces/current/plugin/permission/fetch'), queryFn: () => get<ReferenceSetting>('/workspaces/current/plugin/preferences/fetch'),
}) })
} }
export const useInvalidatePermissions = () => { export const useInvalidateReferenceSettings = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return () => { return () => {
queryClient.invalidateQueries( queryClient.invalidateQueries(
{ {
queryKey: usePermissionsKey, queryKey: useReferenceSettingKey,
}) })
} }
} }
export const useMutationPermissions = ({ export const useMutationReferenceSettings = ({
onSuccess, onSuccess,
}: { }: {
onSuccess?: () => void onSuccess?: () => void
}) => { }) => {
return useMutation({ return useMutation({
mutationFn: (payload: Permissions) => { mutationFn: (payload: ReferenceSetting) => {
return post('/workspaces/current/plugin/permission/change', { body: payload }) return post('/workspaces/current/plugin/preferences/change', { body: payload })
}, },
onSuccess, onSuccess,
}) })
} }
export const useRemoveAutoUpgrade = () => {
return useMutation({
mutationFn: (payload: { plugin_id: string }) => {
return post('/workspaces/current/plugin/preferences/autoupgrade/exclude', { body: payload })
},
})
}
export const useMutationPluginsFromMarketplace = () => { export const useMutationPluginsFromMarketplace = () => {
return useMutation({ return useMutation({
mutationFn: (pluginsSearchParams: PluginsSearchParams) => { 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>[]) => { export const useFetchPluginsInMarketPlaceByInfo = (infos: Record<string, any>[]) => {
return useQuery({ return useQuery({
queryKey: [NAME_SPACE, 'fetchPluginsInMarketPlaceByInfo', infos], queryKey: [NAME_SPACE, 'fetchPluginsInMarketPlaceByInfo', infos],
@ -448,7 +489,7 @@ const usePluginTaskListKey = [NAME_SPACE, 'pluginTaskList']
export const usePluginTaskList = (category?: PluginType) => { export const usePluginTaskList = (category?: PluginType) => {
const { const {
canManagement, canManagement,
} = usePermission() } = useReferenceSetting()
const { refreshPluginList } = useRefreshPluginList() const { refreshPluginList } = useRefreshPluginList()
const { const {
data, data,

Loading…
Cancel
Save