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>tags/1.7.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= |
| 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 |
| ) | ) | ||||
| 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", | ||||
| # hosted services config | # hosted services config | ||||
| HostedServiceConfig, | HostedServiceConfig, | ||||
| CeleryBeatConfig, | CeleryBeatConfig, | ||||
| CeleryScheduleTasksConfig, | |||||
| ): | ): | ||||
| pass | pass |
| 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 | ||||
| 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") | ||||
| 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") |
| 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}) |
| 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} |
| celery_app.set_default() | celery_app.set_default() | ||||
| app.extensions["celery"] = celery_app | app.extensions["celery"] = celery_app | ||||
| imports = [ | |||||
| "schedule.clean_embedding_cache_task", | |||||
| "schedule.clean_unused_datasets_task", | |||||
| "schedule.create_tidb_serverless_task", | |||||
| "schedule.update_tidb_serverless_status_task", | |||||
| "schedule.clean_messages", | |||||
| "schedule.mail_clean_document_notify_task", | |||||
| "schedule.queue_monitor_task", | |||||
| ] | |||||
| imports = [] | |||||
| day = dify_config.CELERY_BEAT_SCHEDULER_TIME | 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 | |||||
| "mail_clean_document_notify_task": { | |||||
| } | |||||
| if dify_config.ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: | |||||
| imports.append("schedule.mail_clean_document_notify_task") | |||||
| beat_schedule["mail_clean_document_notify_task"] = { | |||||
| "task": "schedule.mail_clean_document_notify_task.mail_clean_document_notify_task", | "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 |
| """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 ### |
| ) | ) | ||||
| 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) | |||||
| install_permission: Mapped[InstallPermission] = mapped_column(db.String(16), server_default="everyone") | |||||
| debug_permission: Mapped[DebugPermission] = mapped_column(db.String(16), server_default="noone") | |||||
| tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) | |||||
| install_permission: Mapped[InstallPermission] = mapped_column( | |||||
| db.String(16), nullable=False, server_default="everyone" | |||||
| ) | |||||
| debug_permission: Mapped[DebugPermission] = mapped_column(db.String(16), nullable=False, server_default="noone") | |||||
| class TenantPluginAutoUpgradeStrategy(Base): | |||||
| class StrategySetting(enum.StrEnum): | |||||
| DISABLED = "disabled" | |||||
| FIX_ONLY = "fix_only" | |||||
| LATEST = "latest" | |||||
| class UpgradeMode(enum.StrEnum): | |||||
| ALL = "all" | |||||
| PARTIAL = "partial" | |||||
| EXCLUDE = "exclude" | |||||
| __tablename__ = "tenant_plugin_auto_upgrade_strategies" | |||||
| __table_args__ = ( | |||||
| db.PrimaryKeyConstraint("id", name="tenant_plugin_auto_upgrade_strategy_pkey"), | |||||
| db.UniqueConstraint("tenant_id", name="unique_tenant_plugin_auto_upgrade_strategy"), | |||||
| ) | |||||
| id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()")) | |||||
| tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) | |||||
| strategy_setting: Mapped[StrategySetting] = mapped_column(db.String(16), nullable=False, server_default="fix_only") | |||||
| upgrade_time_of_day: Mapped[int] = mapped_column(db.Integer, nullable=False, default=0) # seconds of the day | |||||
| upgrade_mode: Mapped[UpgradeMode] = mapped_column(db.String(16), nullable=False, server_default="exclude") | |||||
| exclude_plugins: Mapped[list[str]] = mapped_column( | |||||
| db.ARRAY(db.String(255)), nullable=False | |||||
| ) # plugin_id (author/name) | |||||
| include_plugins: Mapped[list[str]] = mapped_column( | |||||
| db.ARRAY(db.String(255)), nullable=False | |||||
| ) # plugin_id (author/name) | |||||
| created_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) | |||||
| updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp()) |
| 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", | |||||
| ) | |||||
| ) |
| Tenant, | Tenant, | ||||
| TenantAccountJoin, | TenantAccountJoin, | ||||
| TenantAccountRole, | TenantAccountRole, | ||||
| TenantPluginAutoUpgradeStrategy, | |||||
| TenantStatus, | TenantStatus, | ||||
| ) | ) | ||||
| from models.model import DifySetup | from models.model import DifySetup | ||||
| 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 |
| 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 |
| 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 |
| 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 |
| - 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 |
| 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 | ||||
| - 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 |
| 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 ( |
| 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> | ||||
| ) | ) |
| 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) | ||||
| 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} | ||||
| 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 | |||||
| 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} | |||||
| /> | |||||
| {inputElem} | |||||
| <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', | ||||
| </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} |
| const Options: FC<TimeOptionsProps> = ({ | const Options: FC<TimeOptionsProps> = ({ | ||||
| selectedTime, | selectedTime, | ||||
| minuteFilter, | |||||
| handleSelectHour, | handleSelectHour, | ||||
| handleSelectMinute, | handleSelectMinute, | ||||
| handleSelectPeriod, | handleSelectPeriod, | ||||
| {/* 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 | ||||
| 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> |
| 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 | ||||
| } | } | ||||
| 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 = { | ||||
| 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 |
| 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) | ||||
| 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)}` | |||||
| } |
| <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> |
| <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> |
| 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(` |
| 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'>>(( | |||||
| props, | |||||
| ref, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} />) | |||||
| const Icon = ( | |||||
| { | |||||
| ref, | |||||
| ...props | |||||
| }: React.SVGProps<SVGSVGElement> & { | |||||
| ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>; | |||||
| }, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} /> | |||||
| Icon.displayName = 'AliyunIcon' | Icon.displayName = 'AliyunIcon' | ||||
| 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'>>(( | |||||
| props, | |||||
| ref, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} />) | |||||
| const Icon = ( | |||||
| { | |||||
| ref, | |||||
| ...props | |||||
| }: React.SVGProps<SVGSVGElement> & { | |||||
| ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>; | |||||
| }, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} /> | |||||
| Icon.displayName = 'AliyunIconBig' | Icon.displayName = 'AliyunIconBig' | ||||
| 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'>>(( | |||||
| props, | |||||
| ref, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} />) | |||||
| const Icon = ( | |||||
| { | |||||
| ref, | |||||
| ...props | |||||
| }: React.SVGProps<SVGSVGElement> & { | |||||
| ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>; | |||||
| }, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} /> | |||||
| Icon.displayName = 'WeaveIcon' | Icon.displayName = 'WeaveIcon' | ||||
| 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'>>(( | |||||
| props, | |||||
| ref, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} />) | |||||
| const Icon = ( | |||||
| { | |||||
| ref, | |||||
| ...props | |||||
| }: React.SVGProps<SVGSVGElement> & { | |||||
| ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>; | |||||
| }, | |||||
| ) => <IconBase {...props} ref={ref} data={data as IconData} /> | |||||
| Icon.displayName = 'WeaveIconBig' | Icon.displayName = 'WeaveIconBig' | ||||
| 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' | ||||
| 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' |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Citations" | "name": "Citations" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ContentModeration" | "name": "ContentModeration" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Document" | "name": "Document" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FolderUpload" | "name": "FolderUpload" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "LoveMessage" | "name": "LoveMessage" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "MessageFast" | "name": "MessageFast" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Microphone01" | "name": "Microphone01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "TextToAudio" | "name": "TextToAudio" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "VirtualAssistant" | "name": "VirtualAssistant" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Vision" | "name": "Vision" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "AlertTriangle" | "name": "AlertTriangle" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ThumbsDown" | "name": "ThumbsDown" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ThumbsUp" | "name": "ThumbsUp" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ArrowNarrowLeft" | "name": "ArrowNarrowLeft" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ArrowUpRight" | "name": "ArrowUpRight" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ChevronDownDouble" | "name": "ChevronDownDouble" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ChevronRight" | "name": "ChevronRight" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ChevronSelectorVertical" | "name": "ChevronSelectorVertical" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "RefreshCcw01" | "name": "RefreshCcw01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "RefreshCw05" | "name": "RefreshCw05" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ReverseLeft" | "name": "ReverseLeft" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "AiText" | "name": "AiText" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ChatBot" | "name": "ChatBot" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ChatBotSlim" | "name": "ChatBotSlim" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "CuteRobot" | "name": "CuteRobot" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "MessageCheckRemove" | "name": "MessageCheckRemove" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "MessageFastPlus" | "name": "MessageFastPlus" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ArtificialBrain" | "name": "ArtificialBrain" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "BarChartSquare02" | "name": "BarChartSquare02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "BracketsX" | "name": "BracketsX" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "CodeBrowser" | "name": "CodeBrowser" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Container" | "name": "Container" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Database01" | "name": "Database01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Database03" | "name": "Database03" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FileHeart02" | "name": "FileHeart02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "GitBranch01" | "name": "GitBranch01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "PromptEngineering" | "name": "PromptEngineering" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "PuzzlePiece01" | "name": "PuzzlePiece01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "TerminalSquare" | "name": "TerminalSquare" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Variable" | "name": "Variable" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Webhooks" | "name": "Webhooks" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "AlignLeft" | "name": "AlignLeft" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "BezierCurve03" | "name": "BezierCurve03" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Collapse" | "name": "Collapse" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Colors" | "name": "Colors" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ImageIndentLeft" | "name": "ImageIndentLeft" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "LeftIndent02" | "name": "LeftIndent02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "LetterSpacing01" | "name": "LetterSpacing01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "TypeSquare" | "name": "TypeSquare" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "BookOpen01" | "name": "BookOpen01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "File02" | "name": "File02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FileArrow01" | "name": "FileArrow01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FileCheck02" | "name": "FileCheck02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FileDownload02" | "name": "FileDownload02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FilePlus01" | "name": "FilePlus01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FilePlus02" | "name": "FilePlus02" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FileText" | "name": "FileText" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "FileUpload" | "name": "FileUpload" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Folder" | "name": "Folder" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Balance" | "name": "Balance" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "CoinsStacked01" | "name": "CoinsStacked01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "GoldCoin" | "name": "GoldCoin" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "ReceiptList" | "name": "ReceiptList" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Tag01" | "name": "Tag01" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Tag03" | "name": "Tag03" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "AtSign" | "name": "AtSign" | ||||
| } | |||||
| } |
| ] | ] | ||||
| }, | }, | ||||
| "name": "Bookmark" | "name": "Bookmark" | ||||
| } | |||||
| } |