From 5172f0bf397e3a9893500eae5a60e34d8fdaa937 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Fri, 22 Nov 2024 15:05:04 +0800 Subject: [PATCH] feat: Check and compare the DSL version before import an app (#10969) Co-authored-by: Yi --- api/controllers/console/__init__.py | 5 + api/controllers/console/app/app.py | 86 +--- api/controllers/console/app/app_import.py | 90 ++++ api/controllers/console/app/workflow.py | 27 - api/fields/app_fields.py | 9 + api/libs/helper.py | 7 +- api/models/model.py | 2 +- api/services/app_dsl_service.py | 485 ++++++++++++++++++ api/services/app_dsl_service/__init__.py | 3 - api/services/app_dsl_service/exc.py | 34 -- api/services/app_dsl_service/service.py | 484 ----------------- api/services/app_service.py | 2 +- .../app_dsl_service/test_app_dsl_service.py | 47 -- .../app/create-from-dsl-modal/index.tsx | 249 ++++++--- .../app/create-from-dsl-modal/uploader.tsx | 28 +- web/app/components/base/toast/index.tsx | 74 +-- .../components/workflow/update-dsl-modal.tsx | 281 +++++++--- web/i18n/en-US/app.ts | 8 + web/i18n/en-US/workflow.ts | 10 +- web/i18n/zh-Hans/app.ts | 7 + web/i18n/zh-Hans/workflow.ts | 2 + web/models/app.ts | 22 + web/service/apps.ts | 12 +- web/service/workflow.ts | 1 + 24 files changed, 1101 insertions(+), 874 deletions(-) create mode 100644 api/controllers/console/app/app_import.py create mode 100644 api/services/app_dsl_service.py delete mode 100644 api/services/app_dsl_service/__init__.py delete mode 100644 api/services/app_dsl_service/exc.py delete mode 100644 api/services/app_dsl_service/service.py delete mode 100644 api/tests/unit_tests/services/app_dsl_service/test_app_dsl_service.py diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 8a5c2e5b8f..f46d5b6b13 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -2,6 +2,7 @@ from flask import Blueprint from libs.external_api import ExternalApi +from .app.app_import import AppImportApi, AppImportConfirmApi from .files import FileApi, FilePreviewApi, FileSupportTypeApi from .remote_files import RemoteFileInfoApi, RemoteFileUploadApi @@ -17,6 +18,10 @@ api.add_resource(FileSupportTypeApi, "/files/support-type") api.add_resource(RemoteFileInfoApi, "/remote-files/") api.add_resource(RemoteFileUploadApi, "/remote-files/upload") +# Import App +api.add_resource(AppImportApi, "/apps/imports") +api.add_resource(AppImportConfirmApi, "/apps/imports//confirm") + # Import other controllers from . import admin, apikey, extension, feature, ping, setup, version diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 5a4cd7684f..9687b59cd1 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -1,7 +1,10 @@ import uuid +from typing import cast from flask_login import current_user from flask_restful import Resource, inputs, marshal, marshal_with, reqparse +from sqlalchemy import select +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, Forbidden, abort from controllers.console import api @@ -13,13 +16,15 @@ from controllers.console.wraps import ( setup_required, ) from core.ops.ops_trace_manager import OpsTraceManager +from extensions.ext_database import db from fields.app_fields import ( app_detail_fields, app_detail_fields_with_site, app_pagination_fields, ) from libs.login import login_required -from services.app_dsl_service import AppDslService +from models import Account, App +from services.app_dsl_service import AppDslService, ImportMode from services.app_service import AppService ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] @@ -92,61 +97,6 @@ class AppListApi(Resource): return app, 201 -class AppImportApi(Resource): - @setup_required - @login_required - @account_initialization_required - @marshal_with(app_detail_fields_with_site) - @cloud_edition_billing_resource_check("apps") - def post(self): - """Import app""" - # The role of the current user in the ta table must be admin, owner, or editor - if not current_user.is_editor: - raise Forbidden() - - parser = reqparse.RequestParser() - parser.add_argument("data", type=str, required=True, nullable=False, location="json") - parser.add_argument("name", type=str, location="json") - parser.add_argument("description", type=str, location="json") - parser.add_argument("icon_type", type=str, location="json") - parser.add_argument("icon", type=str, location="json") - parser.add_argument("icon_background", type=str, location="json") - args = parser.parse_args() - - app = AppDslService.import_and_create_new_app( - tenant_id=current_user.current_tenant_id, data=args["data"], args=args, account=current_user - ) - - return app, 201 - - -class AppImportFromUrlApi(Resource): - @setup_required - @login_required - @account_initialization_required - @marshal_with(app_detail_fields_with_site) - @cloud_edition_billing_resource_check("apps") - def post(self): - """Import app from url""" - # The role of the current user in the ta table must be admin, owner, or editor - if not current_user.is_editor: - raise Forbidden() - - parser = reqparse.RequestParser() - parser.add_argument("url", type=str, required=True, nullable=False, location="json") - parser.add_argument("name", type=str, location="json") - parser.add_argument("description", type=str, location="json") - parser.add_argument("icon", type=str, location="json") - parser.add_argument("icon_background", type=str, location="json") - args = parser.parse_args() - - app = AppDslService.import_and_create_new_app_from_url( - tenant_id=current_user.current_tenant_id, url=args["url"], args=args, account=current_user - ) - - return app, 201 - - class AppApi(Resource): @setup_required @login_required @@ -224,10 +174,24 @@ class AppCopyApi(Resource): parser.add_argument("icon_background", type=str, location="json") args = parser.parse_args() - data = AppDslService.export_dsl(app_model=app_model, include_secret=True) - app = AppDslService.import_and_create_new_app( - tenant_id=current_user.current_tenant_id, data=data, args=args, account=current_user - ) + with Session(db.engine) as session: + import_service = AppDslService(session) + yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True) + account = cast(Account, current_user) + result = import_service.import_app( + account=account, + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content=yaml_content, + name=args.get("name"), + description=args.get("description"), + icon_type=args.get("icon_type"), + icon=args.get("icon"), + icon_background=args.get("icon_background"), + ) + session.commit() + + stmt = select(App).where(App.id == result.app.id) + app = session.scalar(stmt) return app, 201 @@ -368,8 +332,6 @@ class AppTraceApi(Resource): api.add_resource(AppListApi, "/apps") -api.add_resource(AppImportApi, "/apps/import") -api.add_resource(AppImportFromUrlApi, "/apps/import/url") api.add_resource(AppApi, "/apps/") api.add_resource(AppCopyApi, "/apps//copy") api.add_resource(AppExportApi, "/apps//export") diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py new file mode 100644 index 0000000000..244dcd75de --- /dev/null +++ b/api/controllers/console/app/app_import.py @@ -0,0 +1,90 @@ +from typing import cast + +from flask_login import current_user +from flask_restful import Resource, marshal_with, reqparse +from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden + +from controllers.console.wraps import ( + account_initialization_required, + setup_required, +) +from extensions.ext_database import db +from fields.app_fields import app_import_fields +from libs.login import login_required +from models import Account +from services.app_dsl_service import AppDslService, ImportStatus + + +class AppImportApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_import_fields) + def post(self): + # Check user role first + if not current_user.is_editor: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument("mode", type=str, required=True, location="json") + parser.add_argument("yaml_content", type=str, location="json") + parser.add_argument("yaml_url", type=str, location="json") + parser.add_argument("name", type=str, location="json") + parser.add_argument("description", type=str, location="json") + parser.add_argument("icon_type", type=str, location="json") + parser.add_argument("icon", type=str, location="json") + parser.add_argument("icon_background", type=str, location="json") + parser.add_argument("app_id", type=str, location="json") + args = parser.parse_args() + + # Create service with session + with Session(db.engine) as session: + import_service = AppDslService(session) + # Import app + account = cast(Account, current_user) + result = import_service.import_app( + account=account, + import_mode=args["mode"], + yaml_content=args.get("yaml_content"), + yaml_url=args.get("yaml_url"), + name=args.get("name"), + description=args.get("description"), + icon_type=args.get("icon_type"), + icon=args.get("icon"), + icon_background=args.get("icon_background"), + app_id=args.get("app_id"), + ) + session.commit() + + # Return appropriate status code based on result + status = result.status + if status == ImportStatus.FAILED.value: + return result.model_dump(mode="json"), 400 + elif status == ImportStatus.PENDING.value: + return result.model_dump(mode="json"), 202 + return result.model_dump(mode="json"), 200 + + +class AppImportConfirmApi(Resource): + @setup_required + @login_required + @account_initialization_required + @marshal_with(app_import_fields) + def post(self, import_id): + # Check user role first + if not current_user.is_editor: + raise Forbidden() + + # Create service with session + with Session(db.engine) as session: + import_service = AppDslService(session) + # Confirm import + account = cast(Account, current_user) + result = import_service.confirm_import(import_id=import_id, account=account) + session.commit() + + # Return appropriate status code based on result + if result.status == ImportStatus.FAILED.value: + return result.model_dump(mode="json"), 400 + return result.model_dump(mode="json"), 200 diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index f7027fb226..cc05a0d509 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -20,7 +20,6 @@ from libs.helper import TimestampField, uuid_value from libs.login import current_user, login_required from models import App from models.model import AppMode -from services.app_dsl_service import AppDslService from services.app_generate_service import AppGenerateService from services.errors.app import WorkflowHashNotEqualError from services.workflow_service import WorkflowService @@ -126,31 +125,6 @@ class DraftWorkflowApi(Resource): } -class DraftWorkflowImportApi(Resource): - @setup_required - @login_required - @account_initialization_required - @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) - @marshal_with(workflow_fields) - def post(self, app_model: App): - """ - Import draft workflow - """ - # The role of the current user in the ta table must be admin, owner, or editor - if not current_user.is_editor: - raise Forbidden() - - parser = reqparse.RequestParser() - parser.add_argument("data", type=str, required=True, nullable=False, location="json") - args = parser.parse_args() - - workflow = AppDslService.import_and_overwrite_workflow( - app_model=app_model, data=args["data"], account=current_user - ) - - return workflow - - class AdvancedChatDraftWorkflowRunApi(Resource): @setup_required @login_required @@ -453,7 +427,6 @@ class ConvertToWorkflowApi(Resource): api.add_resource(DraftWorkflowApi, "/apps//workflows/draft") -api.add_resource(DraftWorkflowImportApi, "/apps//workflows/draft/import") api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps//advanced-chat/workflows/draft/run") api.add_resource(DraftWorkflowRunApi, "/apps//workflows/draft/run") api.add_resource(WorkflowTaskStopApi, "/apps//workflow-runs/tasks//stop") diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py index aa353a3cc1..abb27fdad1 100644 --- a/api/fields/app_fields.py +++ b/api/fields/app_fields.py @@ -190,3 +190,12 @@ app_site_fields = { "show_workflow_steps": fields.Boolean, "use_icon_as_answer_icon": fields.Boolean, } + +app_import_fields = { + "id": fields.String, + "status": fields.String, + "app_id": fields.String, + "current_dsl_version": fields.String, + "imported_dsl_version": fields.String, + "error": fields.String, +} diff --git a/api/libs/helper.py b/api/libs/helper.py index 7638796508..023240a9a4 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -31,9 +31,12 @@ class AppIconUrlField(fields.Raw): if obj is None: return None - from models.model import IconType + from models.model import App, IconType - if obj.icon_type == IconType.IMAGE.value: + if isinstance(obj, dict) and "app" in obj: + obj = obj["app"] + + if isinstance(obj, App) and obj.icon_type == IconType.IMAGE.value: return file_helpers.get_signed_file_url(obj.icon) return None diff --git a/api/models/model.py b/api/models/model.py index b7c89ce97c..8930d896c6 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -68,7 +68,7 @@ class App(db.Model): name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text, nullable=False, server_default=db.text("''::character varying")) mode = db.Column(db.String(255), nullable=False) - icon_type = db.Column(db.String(255), nullable=True) + icon_type = db.Column(db.String(255), nullable=True) # image, emoji icon = db.Column(db.String(255)) icon_background = db.Column(db.String(255)) app_model_config_id = db.Column(StringUUID, nullable=True) diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py new file mode 100644 index 0000000000..ef25c8404e --- /dev/null +++ b/api/services/app_dsl_service.py @@ -0,0 +1,485 @@ +import logging +import uuid +from enum import Enum +from typing import Optional +from uuid import uuid4 + +import yaml +from packaging import version +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.helper import ssrf_proxy +from events.app_event import app_model_config_was_updated, app_was_created +from extensions.ext_redis import redis_client +from factories import variable_factory +from models import Account, App, AppMode +from models.model import AppModelConfig +from services.workflow_service import WorkflowService + +logger = logging.getLogger(__name__) + +IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:" +IMPORT_INFO_REDIS_EXPIRY = 180 # 3 minutes +CURRENT_DSL_VERSION = "0.2.0" + + +class ImportMode(str, Enum): + YAML_CONTENT = "yaml-content" + YAML_URL = "yaml-url" + + +class ImportStatus(str, Enum): + COMPLETED = "completed" + COMPLETED_WITH_WARNINGS = "completed-with-warnings" + PENDING = "pending" + FAILED = "failed" + + +class Import(BaseModel): + id: str + status: ImportStatus + app_id: Optional[str] = None + current_dsl_version: str = CURRENT_DSL_VERSION + imported_dsl_version: str = "" + error: str = "" + + +def _check_version_compatibility(imported_version: str) -> ImportStatus: + """Determine import status based on version comparison""" + try: + current_ver = version.parse(CURRENT_DSL_VERSION) + imported_ver = version.parse(imported_version) + except version.InvalidVersion: + return ImportStatus.FAILED + + # Compare major version and minor version + if current_ver.major != imported_ver.major or current_ver.minor != imported_ver.minor: + return ImportStatus.PENDING + + if current_ver.micro != imported_ver.micro: + return ImportStatus.COMPLETED_WITH_WARNINGS + + return ImportStatus.COMPLETED + + +class PendingData(BaseModel): + import_mode: str + yaml_content: str + name: str | None + description: str | None + icon_type: str | None + icon: str | None + icon_background: str | None + app_id: str | None + + +class AppDslService: + def __init__(self, session: Session): + self._session = session + + def import_app( + self, + *, + account: Account, + import_mode: str, + yaml_content: Optional[str] = None, + yaml_url: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + icon_type: Optional[str] = None, + icon: Optional[str] = None, + icon_background: Optional[str] = None, + app_id: Optional[str] = None, + ) -> Import: + """Import an app from YAML content or URL.""" + import_id = str(uuid.uuid4()) + + # Validate import mode + try: + mode = ImportMode(import_mode) + except ValueError: + raise ValueError(f"Invalid import_mode: {import_mode}") + + # Get YAML content + content = "" + if mode == ImportMode.YAML_URL: + if not yaml_url: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="yaml_url is required when import_mode is yaml-url", + ) + try: + max_size = 10 * 1024 * 1024 # 10MB + response = ssrf_proxy.get(yaml_url.strip(), follow_redirects=True, timeout=(10, 10)) + response.raise_for_status() + content = response.content + + if len(content) > max_size: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="File size exceeds the limit of 10MB", + ) + + if not content: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="Empty content from url", + ) + + try: + content = content.decode("utf-8") + except UnicodeDecodeError as e: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error=f"Error decoding content: {e}", + ) + except Exception as e: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error=f"Error fetching YAML from URL: {str(e)}", + ) + elif mode == ImportMode.YAML_CONTENT: + if not yaml_content: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="yaml_content is required when import_mode is yaml-content", + ) + content = yaml_content + + # Process YAML content + try: + # Parse YAML to validate format + data = yaml.safe_load(content) + if not isinstance(data, dict): + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="Invalid YAML format: content must be a mapping", + ) + + # Validate and fix DSL version + if not data.get("version"): + data["version"] = "0.1.0" + if not data.get("kind") or data.get("kind") != "app": + data["kind"] = "app" + + imported_version = data.get("version", "0.1.0") + status = _check_version_compatibility(imported_version) + + # Extract app data + app_data = data.get("app") + if not app_data: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="Missing app data in YAML content", + ) + + # If app_id is provided, check if it exists + app = None + if app_id: + stmt = select(App).where(App.id == app_id, App.tenant_id == account.current_tenant_id) + app = self._session.scalar(stmt) + + if not app: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="App not found", + ) + + if app.mode not in [AppMode.WORKFLOW.value, AppMode.ADVANCED_CHAT.value]: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="Only workflow or advanced chat apps can be overwritten", + ) + + # If major version mismatch, store import info in Redis + if status == ImportStatus.PENDING: + panding_data = PendingData( + import_mode=import_mode, + yaml_content=content, + name=name, + description=description, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, + app_id=app_id, + ) + redis_client.setex( + f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}", + IMPORT_INFO_REDIS_EXPIRY, + panding_data.model_dump_json(), + ) + + return Import( + id=import_id, + status=status, + app_id=app_id, + imported_dsl_version=imported_version, + ) + + # Create or update app + app = self._create_or_update_app( + app=app, + data=data, + account=account, + name=name, + description=description, + icon_type=icon_type, + icon=icon, + icon_background=icon_background, + ) + + return Import( + id=import_id, + status=status, + app_id=app.id, + imported_dsl_version=imported_version, + ) + + except yaml.YAMLError as e: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error=f"Invalid YAML format: {str(e)}", + ) + + except Exception as e: + logger.exception("Failed to import app") + return Import( + id=import_id, + status=ImportStatus.FAILED, + error=str(e), + ) + + def confirm_import(self, *, import_id: str, account: Account) -> Import: + """ + Confirm an import that requires confirmation + """ + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + pending_data = redis_client.get(redis_key) + + if not pending_data: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="Import information expired or does not exist", + ) + + try: + if not isinstance(pending_data, str | bytes): + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="Invalid import information", + ) + pending_data = PendingData.model_validate_json(pending_data) + data = yaml.safe_load(pending_data.yaml_content) + + app = None + if pending_data.app_id: + stmt = select(App).where(App.id == pending_data.app_id, App.tenant_id == account.current_tenant_id) + app = self._session.scalar(stmt) + + # Create or update app + app = self._create_or_update_app( + app=app, + data=data, + account=account, + name=pending_data.name, + description=pending_data.description, + icon_type=pending_data.icon_type, + icon=pending_data.icon, + icon_background=pending_data.icon_background, + ) + + # Delete import info from Redis + redis_client.delete(redis_key) + + return Import( + id=import_id, + status=ImportStatus.COMPLETED, + app_id=app.id, + current_dsl_version=CURRENT_DSL_VERSION, + imported_dsl_version=data.get("version", "0.1.0"), + ) + + except Exception as e: + logger.exception("Error confirming import") + return Import( + id=import_id, + status=ImportStatus.FAILED, + error=str(e), + ) + + def _create_or_update_app( + self, + *, + app: Optional[App], + data: dict, + account: Account, + name: Optional[str] = None, + description: Optional[str] = None, + icon_type: Optional[str] = None, + icon: Optional[str] = None, + icon_background: Optional[str] = None, + ) -> App: + """Create a new app or update an existing one.""" + app_data = data.get("app", {}) + app_mode = AppMode(app_data["mode"]) + + # Set icon type + icon_type_value = icon_type or app_data.get("icon_type") + if icon_type_value in ["emoji", "link"]: + icon_type = icon_type_value + else: + icon_type = "emoji" + icon = icon or str(app_data.get("icon", "")) + + if app: + # Update existing app + app.name = name or app_data.get("name", app.name) + app.description = description or app_data.get("description", app.description) + app.icon_type = icon_type + app.icon = icon + app.icon_background = icon_background or app_data.get("icon_background", app.icon_background) + app.updated_by = account.id + else: + # Create new app + app = App() + app.id = str(uuid4()) + app.tenant_id = account.current_tenant_id + app.mode = app_mode.value + app.name = name or app_data.get("name", "") + app.description = description or app_data.get("description", "") + app.icon_type = icon_type + app.icon = icon + app.icon_background = icon_background or app_data.get("icon_background", "#FFFFFF") + app.enable_site = True + app.enable_api = True + app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False) + app.created_by = account.id + app.updated_by = account.id + + self._session.add(app) + self._session.commit() + app_was_created.send(app, account=account) + + # Initialize app based on mode + if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + workflow_data = data.get("workflow") + if not workflow_data or not isinstance(workflow_data, dict): + raise ValueError("Missing workflow data for workflow/advanced chat app") + + environment_variables_list = workflow_data.get("environment_variables", []) + environment_variables = [ + variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list + ] + conversation_variables_list = workflow_data.get("conversation_variables", []) + conversation_variables = [ + variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list + ] + + workflow_service = WorkflowService() + current_draft_workflow = workflow_service.get_draft_workflow(app_model=app) + if current_draft_workflow: + unique_hash = current_draft_workflow.unique_hash + else: + unique_hash = None + workflow_service.sync_draft_workflow( + app_model=app, + graph=workflow_data.get("graph", {}), + features=workflow_data.get("features", {}), + unique_hash=unique_hash, + account=account, + environment_variables=environment_variables, + conversation_variables=conversation_variables, + ) + elif app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION}: + # Initialize model config + model_config = data.get("model_config") + if not model_config or not isinstance(model_config, dict): + raise ValueError("Missing model_config for chat/agent-chat/completion app") + # Initialize or update model config + if not app.app_model_config: + app_model_config = AppModelConfig().from_model_config_dict(model_config) + app_model_config.id = str(uuid4()) + app_model_config.app_id = app.id + app_model_config.created_by = account.id + app_model_config.updated_by = account.id + + app.app_model_config_id = app_model_config.id + + self._session.add(app_model_config) + app_model_config_was_updated.send(app, app_model_config=app_model_config) + else: + raise ValueError("Invalid app mode") + return app + + @classmethod + def export_dsl(cls, app_model: App, include_secret: bool = False) -> str: + """ + Export app + :param app_model: App instance + :return: + """ + app_mode = AppMode.value_of(app_model.mode) + + export_data = { + "version": CURRENT_DSL_VERSION, + "kind": "app", + "app": { + "name": app_model.name, + "mode": app_model.mode, + "icon": "🤖" if app_model.icon_type == "image" else app_model.icon, + "icon_background": "#FFEAD5" if app_model.icon_type == "image" else app_model.icon_background, + "description": app_model.description, + "use_icon_as_answer_icon": app_model.use_icon_as_answer_icon, + }, + } + + if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: + cls._append_workflow_export_data( + export_data=export_data, app_model=app_model, include_secret=include_secret + ) + else: + cls._append_model_config_export_data(export_data, app_model) + + return yaml.dump(export_data, allow_unicode=True) + + @classmethod + def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None: + """ + Append workflow export data + :param export_data: export data + :param app_model: App instance + """ + workflow_service = WorkflowService() + workflow = workflow_service.get_draft_workflow(app_model) + if not workflow: + raise ValueError("Missing draft workflow configuration, please check.") + + export_data["workflow"] = workflow.to_dict(include_secret=include_secret) + + @classmethod + def _append_model_config_export_data(cls, export_data: dict, app_model: App) -> None: + """ + Append model config export data + :param export_data: export data + :param app_model: App instance + """ + app_model_config = app_model.app_model_config + if not app_model_config: + raise ValueError("Missing app configuration, please check.") + + export_data["model_config"] = app_model_config.to_dict() diff --git a/api/services/app_dsl_service/__init__.py b/api/services/app_dsl_service/__init__.py deleted file mode 100644 index 9fc988ffb3..0000000000 --- a/api/services/app_dsl_service/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .service import AppDslService - -__all__ = ["AppDslService"] diff --git a/api/services/app_dsl_service/exc.py b/api/services/app_dsl_service/exc.py deleted file mode 100644 index 6da4b1938f..0000000000 --- a/api/services/app_dsl_service/exc.py +++ /dev/null @@ -1,34 +0,0 @@ -class DSLVersionNotSupportedError(ValueError): - """Raised when the imported DSL version is not supported by the current Dify version.""" - - -class InvalidYAMLFormatError(ValueError): - """Raised when the provided YAML format is invalid.""" - - -class MissingAppDataError(ValueError): - """Raised when the app data is missing in the provided DSL.""" - - -class InvalidAppModeError(ValueError): - """Raised when the app mode is invalid.""" - - -class MissingWorkflowDataError(ValueError): - """Raised when the workflow data is missing in the provided DSL.""" - - -class MissingModelConfigError(ValueError): - """Raised when the model config data is missing in the provided DSL.""" - - -class FileSizeLimitExceededError(ValueError): - """Raised when the file size exceeds the allowed limit.""" - - -class EmptyContentError(ValueError): - """Raised when the content fetched from the URL is empty.""" - - -class ContentDecodingError(ValueError): - """Raised when there is an error decoding the content.""" diff --git a/api/services/app_dsl_service/service.py b/api/services/app_dsl_service/service.py deleted file mode 100644 index e6b0d9a272..0000000000 --- a/api/services/app_dsl_service/service.py +++ /dev/null @@ -1,484 +0,0 @@ -import logging -from collections.abc import Mapping -from typing import Any - -import yaml -from packaging import version - -from core.helper import ssrf_proxy -from events.app_event import app_model_config_was_updated, app_was_created -from extensions.ext_database import db -from factories import variable_factory -from models.account import Account -from models.model import App, AppMode, AppModelConfig -from models.workflow import Workflow -from services.workflow_service import WorkflowService - -from .exc import ( - ContentDecodingError, - EmptyContentError, - FileSizeLimitExceededError, - InvalidAppModeError, - InvalidYAMLFormatError, - MissingAppDataError, - MissingModelConfigError, - MissingWorkflowDataError, -) - -logger = logging.getLogger(__name__) - -current_dsl_version = "0.1.3" - - -class AppDslService: - @classmethod - def import_and_create_new_app_from_url(cls, tenant_id: str, url: str, args: dict, account: Account) -> App: - """ - Import app dsl from url and create new app - :param tenant_id: tenant id - :param url: import url - :param args: request args - :param account: Account instance - """ - max_size = 10 * 1024 * 1024 # 10MB - response = ssrf_proxy.get(url.strip(), follow_redirects=True, timeout=(10, 10)) - response.raise_for_status() - content = response.content - - if len(content) > max_size: - raise FileSizeLimitExceededError("File size exceeds the limit of 10MB") - - if not content: - raise EmptyContentError("Empty content from url") - - try: - data = content.decode("utf-8") - except UnicodeDecodeError as e: - raise ContentDecodingError(f"Error decoding content: {e}") - - return cls.import_and_create_new_app(tenant_id, data, args, account) - - @classmethod - def import_and_create_new_app(cls, tenant_id: str, data: str, args: dict, account: Account) -> App: - """ - Import app dsl and create new app - :param tenant_id: tenant id - :param data: import data - :param args: request args - :param account: Account instance - """ - try: - import_data = yaml.safe_load(data) - except yaml.YAMLError: - raise InvalidYAMLFormatError("Invalid YAML format in data argument.") - - # check or repair dsl version - import_data = _check_or_fix_dsl(import_data) - - app_data = import_data.get("app") - if not app_data: - raise MissingAppDataError("Missing app in data argument") - - # get app basic info - name = args.get("name") or app_data.get("name") - description = args.get("description") or app_data.get("description", "") - icon_type = args.get("icon_type") or app_data.get("icon_type") - icon = args.get("icon") or app_data.get("icon") - icon_background = args.get("icon_background") or app_data.get("icon_background") - use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False) - - # import dsl and create app - app_mode = AppMode.value_of(app_data.get("mode")) - - if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow_data = import_data.get("workflow") - if not workflow_data or not isinstance(workflow_data, dict): - raise MissingWorkflowDataError( - "Missing workflow in data argument when app mode is advanced-chat or workflow" - ) - - app = cls._import_and_create_new_workflow_based_app( - tenant_id=tenant_id, - app_mode=app_mode, - workflow_data=workflow_data, - account=account, - name=name, - description=description, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - use_icon_as_answer_icon=use_icon_as_answer_icon, - ) - elif app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION}: - model_config = import_data.get("model_config") - if not model_config or not isinstance(model_config, dict): - raise MissingModelConfigError( - "Missing model_config in data argument when app mode is chat, agent-chat or completion" - ) - - app = cls._import_and_create_new_model_config_based_app( - tenant_id=tenant_id, - app_mode=app_mode, - model_config_data=model_config, - account=account, - name=name, - description=description, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - use_icon_as_answer_icon=use_icon_as_answer_icon, - ) - else: - raise InvalidAppModeError("Invalid app mode") - - return app - - @classmethod - def import_and_overwrite_workflow(cls, app_model: App, data: str, account: Account) -> Workflow: - """ - Import app dsl and overwrite workflow - :param app_model: App instance - :param data: import data - :param account: Account instance - """ - try: - import_data = yaml.safe_load(data) - except yaml.YAMLError: - raise InvalidYAMLFormatError("Invalid YAML format in data argument.") - - # check or repair dsl version - import_data = _check_or_fix_dsl(import_data) - - app_data = import_data.get("app") - if not app_data: - raise MissingAppDataError("Missing app in data argument") - - # import dsl and overwrite app - app_mode = AppMode.value_of(app_data.get("mode")) - if app_mode not in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - raise InvalidAppModeError("Only support import workflow in advanced-chat or workflow app.") - - if app_data.get("mode") != app_model.mode: - raise ValueError(f"App mode {app_data.get('mode')} is not matched with current app mode {app_mode.value}") - - workflow_data = import_data.get("workflow") - if not workflow_data or not isinstance(workflow_data, dict): - raise MissingWorkflowDataError( - "Missing workflow in data argument when app mode is advanced-chat or workflow" - ) - - return cls._import_and_overwrite_workflow_based_app( - app_model=app_model, - workflow_data=workflow_data, - account=account, - ) - - @classmethod - def export_dsl(cls, app_model: App, include_secret: bool = False) -> str: - """ - Export app - :param app_model: App instance - :return: - """ - app_mode = AppMode.value_of(app_model.mode) - - export_data = { - "version": current_dsl_version, - "kind": "app", - "app": { - "name": app_model.name, - "mode": app_model.mode, - "icon": "🤖" if app_model.icon_type == "image" else app_model.icon, - "icon_background": "#FFEAD5" if app_model.icon_type == "image" else app_model.icon_background, - "description": app_model.description, - "use_icon_as_answer_icon": app_model.use_icon_as_answer_icon, - }, - } - - if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - cls._append_workflow_export_data( - export_data=export_data, app_model=app_model, include_secret=include_secret - ) - else: - cls._append_model_config_export_data(export_data, app_model) - - return yaml.dump(export_data, allow_unicode=True) - - @classmethod - def _import_and_create_new_workflow_based_app( - cls, - tenant_id: str, - app_mode: AppMode, - workflow_data: Mapping[str, Any], - account: Account, - name: str, - description: str, - icon_type: str, - icon: str, - icon_background: str, - use_icon_as_answer_icon: bool, - ) -> App: - """ - Import app dsl and create new workflow based app - - :param tenant_id: tenant id - :param app_mode: app mode - :param workflow_data: workflow data - :param account: Account instance - :param name: app name - :param description: app description - :param icon_type: app icon type, "emoji" or "image" - :param icon: app icon - :param icon_background: app icon background - :param use_icon_as_answer_icon: use app icon as answer icon - """ - if not workflow_data: - raise MissingWorkflowDataError( - "Missing workflow in data argument when app mode is advanced-chat or workflow" - ) - - app = cls._create_app( - tenant_id=tenant_id, - app_mode=app_mode, - account=account, - name=name, - description=description, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - use_icon_as_answer_icon=use_icon_as_answer_icon, - ) - - # init draft workflow - environment_variables_list = workflow_data.get("environment_variables") or [] - environment_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list - ] - conversation_variables_list = workflow_data.get("conversation_variables") or [] - conversation_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list - ] - workflow_service = WorkflowService() - draft_workflow = workflow_service.sync_draft_workflow( - app_model=app, - graph=workflow_data.get("graph", {}), - features=workflow_data.get("features", {}), - unique_hash=None, - account=account, - environment_variables=environment_variables, - conversation_variables=conversation_variables, - ) - workflow_service.publish_workflow(app_model=app, account=account, draft_workflow=draft_workflow) - - return app - - @classmethod - def _import_and_overwrite_workflow_based_app( - cls, app_model: App, workflow_data: Mapping[str, Any], account: Account - ) -> Workflow: - """ - Import app dsl and overwrite workflow based app - - :param app_model: App instance - :param workflow_data: workflow data - :param account: Account instance - """ - if not workflow_data: - raise MissingWorkflowDataError( - "Missing workflow in data argument when app mode is advanced-chat or workflow" - ) - - # fetch draft workflow by app_model - workflow_service = WorkflowService() - current_draft_workflow = workflow_service.get_draft_workflow(app_model=app_model) - if current_draft_workflow: - unique_hash = current_draft_workflow.unique_hash - else: - unique_hash = None - - # sync draft workflow - environment_variables_list = workflow_data.get("environment_variables") or [] - environment_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list - ] - conversation_variables_list = workflow_data.get("conversation_variables") or [] - conversation_variables = [ - variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list - ] - draft_workflow = workflow_service.sync_draft_workflow( - app_model=app_model, - graph=workflow_data.get("graph", {}), - features=workflow_data.get("features", {}), - unique_hash=unique_hash, - account=account, - environment_variables=environment_variables, - conversation_variables=conversation_variables, - ) - - return draft_workflow - - @classmethod - def _import_and_create_new_model_config_based_app( - cls, - tenant_id: str, - app_mode: AppMode, - model_config_data: Mapping[str, Any], - account: Account, - name: str, - description: str, - icon_type: str, - icon: str, - icon_background: str, - use_icon_as_answer_icon: bool, - ) -> App: - """ - Import app dsl and create new model config based app - - :param tenant_id: tenant id - :param app_mode: app mode - :param model_config_data: model config data - :param account: Account instance - :param name: app name - :param description: app description - :param icon: app icon - :param icon_background: app icon background - """ - if not model_config_data: - raise MissingModelConfigError( - "Missing model_config in data argument when app mode is chat, agent-chat or completion" - ) - - app = cls._create_app( - tenant_id=tenant_id, - app_mode=app_mode, - account=account, - name=name, - description=description, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - use_icon_as_answer_icon=use_icon_as_answer_icon, - ) - - app_model_config = AppModelConfig() - app_model_config = app_model_config.from_model_config_dict(model_config_data) - app_model_config.app_id = app.id - app_model_config.created_by = account.id - app_model_config.updated_by = account.id - - db.session.add(app_model_config) - db.session.commit() - - app.app_model_config_id = app_model_config.id - - app_model_config_was_updated.send(app, app_model_config=app_model_config) - - return app - - @classmethod - def _create_app( - cls, - tenant_id: str, - app_mode: AppMode, - account: Account, - name: str, - description: str, - icon_type: str, - icon: str, - icon_background: str, - use_icon_as_answer_icon: bool, - ) -> App: - """ - Create new app - - :param tenant_id: tenant id - :param app_mode: app mode - :param account: Account instance - :param name: app name - :param description: app description - :param icon_type: app icon type, "emoji" or "image" - :param icon: app icon - :param icon_background: app icon background - :param use_icon_as_answer_icon: use app icon as answer icon - """ - app = App( - tenant_id=tenant_id, - mode=app_mode.value, - name=name, - description=description, - icon_type=icon_type, - icon=icon, - icon_background=icon_background, - enable_site=True, - enable_api=True, - use_icon_as_answer_icon=use_icon_as_answer_icon, - created_by=account.id, - updated_by=account.id, - ) - - db.session.add(app) - db.session.commit() - - app_was_created.send(app, account=account) - - return app - - @classmethod - def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None: - """ - Append workflow export data - :param export_data: export data - :param app_model: App instance - """ - workflow_service = WorkflowService() - workflow = workflow_service.get_draft_workflow(app_model) - if not workflow: - raise ValueError("Missing draft workflow configuration, please check.") - - export_data["workflow"] = workflow.to_dict(include_secret=include_secret) - - @classmethod - def _append_model_config_export_data(cls, export_data: dict, app_model: App) -> None: - """ - Append model config export data - :param export_data: export data - :param app_model: App instance - """ - app_model_config = app_model.app_model_config - if not app_model_config: - raise ValueError("Missing app configuration, please check.") - - export_data["model_config"] = app_model_config.to_dict() - - -def _check_or_fix_dsl(import_data: dict[str, Any]) -> Mapping[str, Any]: - """ - Check or fix dsl - - :param import_data: import data - :raises DSLVersionNotSupportedError: if the imported DSL version is newer than the current version - """ - if not import_data.get("version"): - import_data["version"] = "0.1.0" - - if not import_data.get("kind") or import_data.get("kind") != "app": - import_data["kind"] = "app" - - imported_version = import_data.get("version") - if imported_version != current_dsl_version: - if imported_version and version.parse(imported_version) > version.parse(current_dsl_version): - errmsg = ( - f"The imported DSL version {imported_version} is newer than " - f"the current supported version {current_dsl_version}. " - f"Please upgrade your Dify instance to import this configuration." - ) - logger.warning(errmsg) - # raise DSLVersionNotSupportedError(errmsg) - else: - logger.warning( - f"DSL version {imported_version} is older than " - f"the current version {current_dsl_version}. " - f"This may cause compatibility issues." - ) - - return import_data diff --git a/api/services/app_service.py b/api/services/app_service.py index af2b77d633..26e3d004b6 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -155,7 +155,7 @@ class AppService: """ # get original app model config if app.mode == AppMode.AGENT_CHAT.value or app.is_agent: - model_config: AppModelConfig = app.app_model_config + model_config = app.app_model_config agent_mode = model_config.agent_mode_dict # decrypt agent tool parameters if it's secret-input for tool in agent_mode.get("tools") or []: diff --git a/api/tests/unit_tests/services/app_dsl_service/test_app_dsl_service.py b/api/tests/unit_tests/services/app_dsl_service/test_app_dsl_service.py deleted file mode 100644 index 842e8268d1..0000000000 --- a/api/tests/unit_tests/services/app_dsl_service/test_app_dsl_service.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest -from packaging import version - -from services.app_dsl_service import AppDslService -from services.app_dsl_service.exc import DSLVersionNotSupportedError -from services.app_dsl_service.service import _check_or_fix_dsl, current_dsl_version - - -class TestAppDSLService: - @pytest.mark.skip(reason="Test skipped") - def test_check_or_fix_dsl_missing_version(self): - import_data = {} - result = _check_or_fix_dsl(import_data) - assert result["version"] == "0.1.0" - assert result["kind"] == "app" - - @pytest.mark.skip(reason="Test skipped") - def test_check_or_fix_dsl_missing_kind(self): - import_data = {"version": "0.1.0"} - result = _check_or_fix_dsl(import_data) - assert result["kind"] == "app" - - @pytest.mark.skip(reason="Test skipped") - def test_check_or_fix_dsl_older_version(self): - import_data = {"version": "0.0.9", "kind": "app"} - result = _check_or_fix_dsl(import_data) - assert result["version"] == "0.0.9" - - @pytest.mark.skip(reason="Test skipped") - def test_check_or_fix_dsl_current_version(self): - import_data = {"version": current_dsl_version, "kind": "app"} - result = _check_or_fix_dsl(import_data) - assert result["version"] == current_dsl_version - - @pytest.mark.skip(reason="Test skipped") - def test_check_or_fix_dsl_newer_version(self): - current_version = version.parse(current_dsl_version) - newer_version = f"{current_version.major}.{current_version.minor + 1}.0" - import_data = {"version": newer_version, "kind": "app"} - with pytest.raises(DSLVersionNotSupportedError): - _check_or_fix_dsl(import_data) - - @pytest.mark.skip(reason="Test skipped") - def test_check_or_fix_dsl_invalid_kind(self): - import_data = {"version": current_dsl_version, "kind": "invalid"} - result = _check_or_fix_dsl(import_data) - assert result["kind"] == "app" diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index e238ce0e91..ce06b113bc 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -12,9 +12,13 @@ import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast' import { - importApp, - importAppFromUrl, + importDSL, + importDSLConfirm, } from '@/service/apps' +import { + DSLImportMode, + DSLImportStatus, +} from '@/models/app' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import AppsFull from '@/app/components/billing/apps-full-in-dialog' @@ -43,6 +47,9 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const [fileContent, setFileContent] = useState() const [currentTab, setCurrentTab] = useState(activeTab) const [dslUrlValue, setDslUrlValue] = useState(dslUrl) + const [showErrorModal, setShowErrorModal] = useState(false) + const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>() + const [importId, setImportId] = useState() const readFile = (file: File) => { const reader = new FileReader() @@ -66,6 +73,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) const isCreatingRef = useRef(false) + const onCreate: MouseEventHandler = async () => { if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) return @@ -75,25 +83,54 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS return isCreatingRef.current = true try { - let app + let response if (currentTab === CreateFromDSLModalTab.FROM_FILE) { - app = await importApp({ - data: fileContent || '', + response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent || '', }) } if (currentTab === CreateFromDSLModalTab.FROM_URL) { - app = await importAppFromUrl({ - url: dslUrlValue || '', + response = await importDSL({ + mode: DSLImportMode.YAML_URL, + yaml_url: dslUrlValue || '', }) } - if (onSuccess) - onSuccess() - if (onClose) - onClose() - notify({ type: 'success', message: t('app.newApp.appCreated') }) - localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') - getRedirection(isCurrentWorkspaceEditor, app, push) + + if (!response) + return + + const { id, status, app_id, imported_dsl_version, current_dsl_version } = response + if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { + if (onSuccess) + onSuccess() + if (onClose) + onClose() + + notify({ + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + message: t(status === DSLImportStatus.COMPLETED ? 'app.newApp.appCreated' : 'app.newApp.caution'), + children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('app.newApp.appCreateDSLWarning'), + }) + localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + getRedirection(isCurrentWorkspaceEditor, { id: app_id }, push) + } + else if (status === DSLImportStatus.PENDING) { + setVersions({ + importedVersion: imported_dsl_version ?? '', + systemVersion: current_dsl_version ?? '', + }) + if (onClose) + onClose() + setTimeout(() => { + setShowErrorModal(true) + }, 300) + setImportId(id) + } + else { + notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) + } } catch (e) { notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) @@ -101,6 +138,38 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS isCreatingRef.current = false } + const onDSLConfirm: MouseEventHandler = async () => { + try { + if (!importId) + return + const response = await importDSLConfirm({ + import_id: importId, + }) + + const { status, app_id } = response + + if (status === DSLImportStatus.COMPLETED) { + if (onSuccess) + onSuccess() + if (onClose) + onClose() + + notify({ + type: 'success', + message: t('app.newApp.appCreated'), + }) + localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1') + getRedirection(isCurrentWorkspaceEditor, { id: app_id }, push) + } + else if (status === DSLImportStatus.FAILED) { + notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) + } + } + catch (e) { + notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) + } + } + const tabs = [ { key: CreateFromDSLModalTab.FROM_FILE, @@ -123,74 +192,96 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS }, [isAppsFull, currentTab, currentFile, dslUrlValue]) return ( - { }} - > -
- {t('app.importFromDSL')} -
onClose()} - > - + <> + { }} + > +
+ {t('app.importFromDSL')} +
onClose()} + > + +
-
-
- { - tabs.map(tab => ( -
setCurrentTab(tab.key)} - > - {tab.label} - { - currentTab === tab.key && ( -
- ) - } -
- )) - } -
-
- { - currentTab === CreateFromDSLModalTab.FROM_FILE && ( - - ) - } - { - currentTab === CreateFromDSLModalTab.FROM_URL && ( -
-
DSL URL
- setDslUrlValue(e.target.value)} +
+ { + tabs.map(tab => ( +
setCurrentTab(tab.key)} + > + {tab.label} + { + currentTab === tab.key && ( +
+ ) + } +
+ )) + } +
+
+ { + currentTab === CreateFromDSLModalTab.FROM_FILE && ( + -
- ) - } -
- {isAppsFull && ( -
- + ) + } + { + currentTab === CreateFromDSLModalTab.FROM_URL && ( +
+
DSL URL
+ setDslUrlValue(e.target.value)} + /> +
+ ) + }
- )} -
- - -
- + {isAppsFull && ( +
+ +
+ )} +
+ + +
+ + setShowErrorModal(false)} + className='w-[480px]' + > +
+
{t('app.newApp.appCreateDSLErrorTitle')}
+
+
{t('app.newApp.appCreateDSLErrorPart1')}
+
{t('app.newApp.appCreateDSLErrorPart2')}
+
+
{t('app.newApp.appCreateDSLErrorPart3')}{versions?.importedVersion}
+
{t('app.newApp.appCreateDSLErrorPart4')}{versions?.systemVersion}
+
+
+
+ + +
+
+ ) } diff --git a/web/app/components/app/create-from-dsl-modal/uploader.tsx b/web/app/components/app/create-from-dsl-modal/uploader.tsx index fa5554f9cf..beb2b4b1a0 100644 --- a/web/app/components/app/create-from-dsl-modal/uploader.tsx +++ b/web/app/components/app/create-from-dsl-modal/uploader.tsx @@ -6,6 +6,7 @@ import { } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' +import { formatFileSize } from '@/utils/format' import cn from '@/utils/classnames' import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files' import { ToastContext } from '@/app/components/base/toast' @@ -58,8 +59,13 @@ const Uploader: FC = ({ updateFile(files[0]) } const selectHandle = () => { - if (fileUploader.current) + const originalFile = file + if (fileUploader.current) { + fileUploader.current.value = '' fileUploader.current.click() + // If no file is selected, restore the original file + fileUploader.current.oncancel = () => updateFile(originalFile) + } } const removeFile = () => { if (fileUploader.current) @@ -96,7 +102,7 @@ const Uploader: FC = ({ />
{!file && ( -
+
@@ -108,17 +114,23 @@ const Uploader: FC = ({
)} {file && ( -
- -
- {file.name.replace(/(.yaml|.yml)$/, '')} - .yml +
+
+ +
+
+ {file.name} +
+ YAML + · + {formatFileSize(file.size)} +
- +
diff --git a/web/app/components/base/toast/index.tsx b/web/app/components/base/toast/index.tsx index 3e13db5d7f..b1b9ffe8c4 100644 --- a/web/app/components/base/toast/index.tsx +++ b/web/app/components/base/toast/index.tsx @@ -3,16 +3,19 @@ import type { ReactNode } from 'react' import React, { useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' import { - CheckCircleIcon, - ExclamationTriangleIcon, - InformationCircleIcon, - XCircleIcon, -} from '@heroicons/react/20/solid' + RiAlertFill, + RiCheckboxCircleFill, + RiCloseLine, + RiErrorWarningFill, + RiInformation2Fill, +} from '@remixicon/react' import { createContext, useContext } from 'use-context-selector' +import ActionButton from '@/app/components/base/action-button' import classNames from '@/utils/classnames' export type IToastProps = { type?: 'success' | 'error' | 'warning' | 'info' + size?: 'md' | 'sm' duration?: number message: string children?: ReactNode @@ -21,60 +24,55 @@ export type IToastProps = { } type IToastContext = { notify: (props: IToastProps) => void + close: () => void } export const ToastContext = createContext({} as IToastContext) export const useToastContext = () => useContext(ToastContext) const Toast = ({ type = 'info', + size = 'md', message, children, className, }: IToastProps) => { + const { close } = useToastContext() // sometimes message is react node array. Not handle it. if (typeof message !== 'string') return null return
-
-
- {type === 'success' &&