浏览代码

feat: Check and compare the DSL version before import an app (#10969)

Co-authored-by: Yi <yxiaoisme@gmail.com>
-LAN- 5 月之前
父节点
当前提交
5172f0bf39

+ 5 - 0
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/<path:url>")
 api.add_resource(RemoteFileUploadApi, "/remote-files/upload")
 
+# Import App
+api.add_resource(AppImportApi, "/apps/imports")
+api.add_resource(AppImportConfirmApi, "/apps/imports/<string:import_id>/confirm")
+
 # Import other controllers
 from . import admin, apikey, extension, feature, ping, setup, version
 

+ 24 - 62
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/<uuid:app_id>")
 api.add_resource(AppCopyApi, "/apps/<uuid:app_id>/copy")
 api.add_resource(AppExportApi, "/apps/<uuid:app_id>/export")

+ 90 - 0
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

+ 0 - 27
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/<uuid:app_id>/workflows/draft")
-api.add_resource(DraftWorkflowImportApi, "/apps/<uuid:app_id>/workflows/draft/import")
 api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
 api.add_resource(DraftWorkflowRunApi, "/apps/<uuid:app_id>/workflows/draft/run")
 api.add_resource(WorkflowTaskStopApi, "/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop")

+ 9 - 0
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,
+}

+ 5 - 2
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
 

+ 1 - 1
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)

+ 485 - 0
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()

+ 0 - 3
api/services/app_dsl_service/__init__.py

@@ -1,3 +0,0 @@
-from .service import AppDslService
-
-__all__ = ["AppDslService"]

+ 0 - 34
api/services/app_dsl_service/exc.py

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

+ 0 - 484
api/services/app_dsl_service/service.py

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

+ 1 - 1
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 []:

+ 0 - 47
api/tests/unit_tests/services/app_dsl_service/test_app_dsl_service.py

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

+ 170 - 79
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<string>()
   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<string>()
 
   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 (!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') })
       }
-      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)
     }
     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 (
-    <Modal
-      className='p-0 w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
-      isShow={show}
-      onClose={() => { }}
-    >
-      <div className='flex items-center justify-between pt-6 pl-6 pr-5 pb-3 text-text-primary title-2xl-semi-bold'>
-        {t('app.importFromDSL')}
-        <div
-          className='flex items-center w-8 h-8 cursor-pointer'
-          onClick={() => onClose()}
-        >
-          <RiCloseLine className='w-5 h-5 text-text-tertiary' />
+    <>
+      <Modal
+        className='p-0 w-[520px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
+        isShow={show}
+        onClose={() => { }}
+      >
+        <div className='flex items-center justify-between pt-6 pl-6 pr-5 pb-3 text-text-primary title-2xl-semi-bold'>
+          {t('app.importFromDSL')}
+          <div
+            className='flex items-center w-8 h-8 cursor-pointer'
+            onClick={() => onClose()}
+          >
+            <RiCloseLine className='w-5 h-5 text-text-tertiary' />
+          </div>
+        </div>
+        <div className='flex items-center px-6 h-9 space-x-6 system-md-semibold text-text-tertiary border-b border-divider-subtle'>
+          {
+            tabs.map(tab => (
+              <div
+                key={tab.key}
+                className={cn(
+                  'relative flex items-center h-full cursor-pointer',
+                  currentTab === tab.key && 'text-text-primary',
+                )}
+                onClick={() => setCurrentTab(tab.key)}
+              >
+                {tab.label}
+                {
+                  currentTab === tab.key && (
+                    <div className='absolute bottom-0 w-full h-[2px] bg-util-colors-blue-brand-blue-brand-600'></div>
+                  )
+                }
+              </div>
+            ))
+          }
         </div>
-      </div>
-      <div className='flex items-center px-6 h-9 space-x-6 system-md-semibold text-text-tertiary border-b border-divider-subtle'>
-        {
-          tabs.map(tab => (
-            <div
-              key={tab.key}
-              className={cn(
-                'relative flex items-center h-full cursor-pointer',
-                currentTab === tab.key && 'text-text-primary',
-              )}
-              onClick={() => setCurrentTab(tab.key)}
-            >
-              {tab.label}
-              {
-                currentTab === tab.key && (
-                  <div className='absolute bottom-0 w-full h-[2px] bg-util-colors-blue-brand-blue-brand-600'></div>
-                )
-              }
-            </div>
-          ))
-        }
-      </div>
-      <div className='px-6 py-4'>
-        {
-          currentTab === CreateFromDSLModalTab.FROM_FILE && (
-            <Uploader
-              className='mt-0'
-              file={currentFile}
-              updateFile={handleFile}
-            />
-          )
-        }
-        {
-          currentTab === CreateFromDSLModalTab.FROM_URL && (
-            <div>
-              <div className='mb-1 system-md-semibold leading6'>DSL URL</div>
-              <Input
-                placeholder={t('app.importFromDSLUrlPlaceholder') || ''}
-                value={dslUrlValue}
-                onChange={e => setDslUrlValue(e.target.value)}
+        <div className='px-6 py-4'>
+          {
+            currentTab === CreateFromDSLModalTab.FROM_FILE && (
+              <Uploader
+                className='mt-0'
+                file={currentFile}
+                updateFile={handleFile}
               />
-            </div>
-          )
-        }
-      </div>
-      {isAppsFull && (
-        <div className='px-6'>
-          <AppsFull className='mt-0' loc='app-create-dsl' />
+            )
+          }
+          {
+            currentTab === CreateFromDSLModalTab.FROM_URL && (
+              <div>
+                <div className='mb-1 system-md-semibold leading6'>DSL URL</div>
+                <Input
+                  placeholder={t('app.importFromDSLUrlPlaceholder') || ''}
+                  value={dslUrlValue}
+                  onChange={e => setDslUrlValue(e.target.value)}
+                />
+              </div>
+            )
+          }
+        </div>
+        {isAppsFull && (
+          <div className='px-6'>
+            <AppsFull className='mt-0' loc='app-create-dsl' />
+          </div>
+        )}
+        <div className='flex justify-end px-6 py-5'>
+          <Button className='mr-2' onClick={onClose}>{t('app.newApp.Cancel')}</Button>
+          <Button disabled={buttonDisabled} variant="primary" onClick={onCreate}>{t('app.newApp.Create')}</Button>
+        </div>
+      </Modal>
+      <Modal
+        isShow={showErrorModal}
+        onClose={() => setShowErrorModal(false)}
+        className='w-[480px]'
+      >
+        <div className='flex pb-4 flex-col items-start gap-2 self-stretch'>
+          <div className='text-text-primary title-2xl-semi-bold'>{t('app.newApp.appCreateDSLErrorTitle')}</div>
+          <div className='flex flex-grow flex-col text-text-secondary system-md-regular'>
+            <div>{t('app.newApp.appCreateDSLErrorPart1')}</div>
+            <div>{t('app.newApp.appCreateDSLErrorPart2')}</div>
+            <br />
+            <div>{t('app.newApp.appCreateDSLErrorPart3')}<span className='system-md-medium'>{versions?.importedVersion}</span></div>
+            <div>{t('app.newApp.appCreateDSLErrorPart4')}<span className='system-md-medium'>{versions?.systemVersion}</span></div>
+          </div>
+        </div>
+        <div className='flex pt-6 justify-end items-start gap-2 self-stretch'>
+          <Button variant='secondary' onClick={() => setShowErrorModal(false)}>{t('app.newApp.Cancel')}</Button>
+          <Button variant='primary' destructive onClick={onDSLConfirm}>{t('app.newApp.Confirm')}</Button>
         </div>
-      )}
-      <div className='flex justify-end px-6 py-5'>
-        <Button className='mr-2' onClick={onClose}>{t('app.newApp.Cancel')}</Button>
-        <Button disabled={buttonDisabled} variant="primary" onClick={onCreate}>{t('app.newApp.Create')}</Button>
-      </div>
-    </Modal>
+      </Modal>
+    </>
   )
 }
 

+ 20 - 8
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<Props> = ({
     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<Props> = ({
       />
       <div ref={dropRef}>
         {!file && (
-          <div className={cn('flex items-center h-20 rounded-xl bg-gray-50 border border-dashed border-gray-200 text-sm font-normal', dragging && 'bg-[#F5F8FF] border border-[#B2CCFF]')}>
+          <div className={cn('flex items-center h-12 rounded-xl bg-gray-50 border border-dashed border-gray-200 text-sm font-normal', dragging && 'bg-[#F5F8FF] border border-[#B2CCFF]')}>
             <div className='w-full flex items-center justify-center space-x-2'>
               <UploadCloud01 className='w-6 h-6 mr-2' />
               <div className='text-gray-500'>
@@ -108,17 +114,23 @@ const Uploader: FC<Props> = ({
           </div>
         )}
         {file && (
-          <div className={cn('flex items-center h-20 px-6 rounded-xl bg-gray-50 border border-gray-200 text-sm font-normal group', 'hover:bg-[#F5F8FF] hover:border-[#B2CCFF]')}>
-            <YamlIcon className="shrink-0" />
-            <div className='flex ml-2 w-0 grow'>
-              <span className='max-w-[calc(100%_-_30px)] text-ellipsis whitespace-nowrap overflow-hidden text-gray-800'>{file.name.replace(/(.yaml|.yml)$/, '')}</span>
-              <span className='shrink-0 text-gray-500'>.yml</span>
+          <div className={cn('flex items-center rounded-lg bg-components-panel-on-panel-item-bg border-[0.5px] border-components-panel-border shadow-xs group', 'hover:bg-[#F5F8FF] hover:border-[#B2CCFF]')}>
+            <div className='flex p-3 justify-center items-center'>
+              <YamlIcon className="w-6 h-6 shrink-0" />
+            </div>
+            <div className='flex py-1 pr-2 grow flex-col items-start gap-0.5'>
+              <span className='max-w-[calc(100%_-_30px)] text-ellipsis whitespace-nowrap overflow-hidden text-text-secondary font-inter text-[12px] font-medium leading-4'>{file.name}</span>
+              <div className='flex h-3 items-center gap-1 self-stretch text-text-tertiary font-inter text-[10px] font-medium leading-3 uppercase'>
+                <span>YAML</span>
+                <span className='text-text-quaternary'>·</span>
+                <span>{formatFileSize(file.size)}</span>
+              </div>
             </div>
             <div className='hidden group-hover:flex items-center'>
               <Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
               <div className='mx-2 w-px h-4 bg-gray-200' />
               <div className='p-2 cursor-pointer' onClick={removeFile}>
-                <RiDeleteBinLine className='w-4 h-4 text-gray-500' />
+                <RiDeleteBinLine className='w-4 h-4 text-text-tertiary' />
               </div>
             </div>
           </div>

+ 37 - 37
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<IToastContext>({} 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 <div className={classNames(
     className,
-    'fixed rounded-md p-4 my-4 mx-8 z-[9999]',
+    'fixed w-[360px] rounded-xl my-4 mx-8 flex-grow z-[9999] overflow-hidden',
+    size === 'md' ? 'p-3' : 'p-2',
+    'border border-components-panel-border-subtle bg-components-panel-bg-blur shadow-sm',
     'top-0',
     'right-0',
-    type === 'success' ? 'bg-green-50' : '',
-    type === 'error' ? 'bg-red-50' : '',
-    type === 'warning' ? 'bg-yellow-50' : '',
-    type === 'info' ? 'bg-blue-50' : '',
   )}>
-    <div className="flex">
-      <div className="flex-shrink-0">
-        {type === 'success' && <CheckCircleIcon className="w-5 h-5 text-green-400" aria-hidden="true" />}
-        {type === 'error' && <XCircleIcon className="w-5 h-5 text-red-400" aria-hidden="true" />}
-        {type === 'warning' && <ExclamationTriangleIcon className="w-5 h-5 text-yellow-400" aria-hidden="true" />}
-        {type === 'info' && <InformationCircleIcon className="w-5 h-5 text-blue-400" aria-hidden="true" />}
+    <div className={`absolute inset-0 opacity-40 ${
+      (type === 'success' && 'bg-[linear-gradient(92deg,rgba(23,178,106,0.25)_0%,rgba(255,255,255,0.00)_100%)]')
+      || (type === 'warning' && 'bg-[linear-gradient(92deg,rgba(247,144,9,0.25)_0%,rgba(255,255,255,0.00)_100%)]')
+      || (type === 'error' && 'bg-[linear-gradient(92deg,rgba(240,68,56,0.25)_0%,rgba(255,255,255,0.00)_100%)]')
+      || (type === 'info' && 'bg-[linear-gradient(92deg,rgba(11,165,236,0.25)_0%,rgba(255,255,255,0.00)_100%)]')
+    }`}
+    />
+    <div className={`flex ${size === 'md' ? 'gap-1' : 'gap-0.5'}`}>
+      <div className={`flex justify-center items-center ${size === 'md' ? 'p-0.5' : 'p-1'}`}>
+        {type === 'success' && <RiCheckboxCircleFill className={`${size === 'md' ? 'w-5 h-5' : 'w-4 h-4'} text-text-success`} aria-hidden="true" />}
+        {type === 'error' && <RiErrorWarningFill className={`${size === 'md' ? 'w-5 h-5' : 'w-4 h-4'} text-text-destructive`} aria-hidden="true" />}
+        {type === 'warning' && <RiAlertFill className={`${size === 'md' ? 'w-5 h-5' : 'w-4 h-4'} text-text-warning-secondary`} aria-hidden="true" />}
+        {type === 'info' && <RiInformation2Fill className={`${size === 'md' ? 'w-5 h-5' : 'w-4 h-4'} text-text-accent`} aria-hidden="true" />}
       </div>
-      <div className="ml-3">
-        <h3 className={
-          classNames(
-            'text-sm font-medium',
-            type === 'success' ? 'text-green-800' : '',
-            type === 'error' ? 'text-red-800' : '',
-            type === 'warning' ? 'text-yellow-800' : '',
-            type === 'info' ? 'text-blue-800' : '',
-          )
-        }>{message}</h3>
-        {children && <div className={
-          classNames(
-            'mt-2 text-sm',
-            type === 'success' ? 'text-green-700' : '',
-            type === 'error' ? 'text-red-700' : '',
-            type === 'warning' ? 'text-yellow-700' : '',
-            type === 'info' ? 'text-blue-700' : '',
-          )
-        }>
+      <div className={`flex py-1 ${size === 'md' ? 'px-1' : 'px-0.5'} flex-col items-start gap-1 flex-grow`}>
+        <div className='text-text-primary system-sm-semibold'>{message}</div>
+        {children && <div className='text-text-secondary system-xs-regular'>
           {children}
         </div>
         }
       </div>
+      <ActionButton className='z-[1000]' onClick={close}>
+        <RiCloseLine className='w-4 h-4 flex-shrink-0 text-text-tertiary' />
+      </ActionButton>
     </div>
   </div>
 }
@@ -106,6 +104,7 @@ export const ToastProvider = ({
       setMounted(true)
       setParams(props)
     },
+    close: () => setMounted(false),
   }}>
     {mounted && <Toast {...params} />}
     {children}
@@ -114,16 +113,17 @@ export const ToastProvider = ({
 
 Toast.notify = ({
   type,
+  size = 'md',
   message,
   duration,
   className,
-}: Pick<IToastProps, 'type' | 'message' | 'duration' | 'className'>) => {
+}: Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className'>) => {
   const defaultDuring = (type === 'success' || type === 'info') ? 3000 : 6000
   if (typeof window === 'object') {
     const holder = document.createElement('div')
     const root = createRoot(holder)
 
-    root.render(<Toast type={type} message={message} duration={duration} className={className} />)
+    root.render(<Toast type={type} size={size} message={message} duration={duration} className={className} />)
     document.body.appendChild(holder)
     setTimeout(() => {
       if (holder)

+ 194 - 81
web/app/components/workflow/update-dsl-modal.tsx

@@ -10,8 +10,9 @@ import {
 import { useContext } from 'use-context-selector'
 import { useTranslation } from 'react-i18next'
 import {
-  RiAlertLine,
+  RiAlertFill,
   RiCloseLine,
+  RiFileDownloadLine,
 } from '@remixicon/react'
 import { WORKFLOW_DATA_UPDATE } from './constants'
 import {
@@ -21,11 +22,19 @@ import {
   initialEdges,
   initialNodes,
 } from './utils'
+import {
+  importDSL,
+  importDSLConfirm,
+} from '@/service/apps'
+import { fetchWorkflowDraft } from '@/service/workflow'
+import {
+  DSLImportMode,
+  DSLImportStatus,
+} from '@/models/app'
 import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
 import Button from '@/app/components/base/button'
 import Modal from '@/app/components/base/modal'
 import { ToastContext } from '@/app/components/base/toast'
-import { updateWorkflowDraftFromDSL } from '@/service/workflow'
 import { useEventEmitterContextContext } from '@/context/event-emitter'
 import { useStore as useAppStore } from '@/app/components/app/store'
 import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
@@ -48,6 +57,10 @@ const UpdateDSLModal = ({
   const [fileContent, setFileContent] = useState<string>()
   const [loading, setLoading] = useState(false)
   const { eventEmitter } = useEventEmitterContextContext()
+  const [show, setShow] = useState(true)
+  const [showErrorModal, setShowErrorModal] = useState(false)
+  const [versions, setVersions] = useState<{ importedVersion: string; systemVersion: string }>()
+  const [importId, setImportId] = useState<string>()
 
   const readFile = (file: File) => {
     const reader = new FileReader()
@@ -66,6 +79,51 @@ const UpdateDSLModal = ({
       setFileContent('')
   }
 
+  const handleWorkflowUpdate = async (app_id: string) => {
+    const {
+      graph,
+      features,
+      hash,
+    } = await fetchWorkflowDraft(`/apps/${app_id}/workflows/draft`)
+
+    const { nodes, edges, viewport } = graph
+    const newFeatures = {
+      file: {
+        image: {
+          enabled: !!features.file_upload?.image?.enabled,
+          number_limits: features.file_upload?.image?.number_limits || 3,
+          transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
+        },
+        enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
+        allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
+        allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
+        allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
+        number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
+      },
+      opening: {
+        enabled: !!features.opening_statement,
+        opening_statement: features.opening_statement,
+        suggested_questions: features.suggested_questions,
+      },
+      suggested: features.suggested_questions_after_answer || { enabled: false },
+      speech2text: features.speech_to_text || { enabled: false },
+      text2speech: features.text_to_speech || { enabled: false },
+      citation: features.retriever_resource || { enabled: false },
+      moderation: features.sensitive_word_avoidance || { enabled: false },
+    }
+
+    eventEmitter?.emit({
+      type: WORKFLOW_DATA_UPDATE,
+      payload: {
+        nodes: initialNodes(nodes, edges),
+        edges: initialEdges(edges, nodes),
+        viewport,
+        features: newFeatures,
+        hash,
+      },
+    } as any)
+  }
+
   const isCreatingRef = useRef(false)
   const handleImport: MouseEventHandler = useCallback(async () => {
     if (isCreatingRef.current)
@@ -76,106 +134,161 @@ const UpdateDSLModal = ({
     try {
       if (appDetail && fileContent) {
         setLoading(true)
-        const {
-          graph,
-          features,
-          hash,
-        } = await updateWorkflowDraftFromDSL(appDetail.id, fileContent)
-        const { nodes, edges, viewport } = graph
-        const newFeatures = {
-          file: {
-            image: {
-              enabled: !!features.file_upload?.image?.enabled,
-              number_limits: features.file_upload?.image?.number_limits || 3,
-              transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
-            },
-            enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
-            allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
-            allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
-            allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
-            number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
-          },
-          opening: {
-            enabled: !!features.opening_statement,
-            opening_statement: features.opening_statement,
-            suggested_questions: features.suggested_questions,
-          },
-          suggested: features.suggested_questions_after_answer || { enabled: false },
-          speech2text: features.speech_to_text || { enabled: false },
-          text2speech: features.text_to_speech || { enabled: false },
-          citation: features.retriever_resource || { enabled: false },
-          moderation: features.sensitive_word_avoidance || { enabled: false },
+        const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, app_id: appDetail.id })
+        const { id, status, app_id, imported_dsl_version, current_dsl_version } = response
+        if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
+          if (!app_id) {
+            notify({ type: 'error', message: t('workflow.common.importFailure') })
+            return
+          }
+          handleWorkflowUpdate(app_id)
+          if (onImport)
+            onImport()
+          notify({
+            type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning',
+            message: t(status === DSLImportStatus.COMPLETED ? 'workflow.common.importSuccess' : 'workflow.common.importWarning'),
+            children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('workflow.common.importWarningDetails'),
+          })
+          setLoading(false)
+          onCancel()
+        }
+        else if (status === DSLImportStatus.PENDING) {
+          setShow(false)
+          setTimeout(() => {
+            setShowErrorModal(true)
+          }, 300)
+          setVersions({
+            importedVersion: imported_dsl_version ?? '',
+            systemVersion: current_dsl_version ?? '',
+          })
+          setImportId(id)
+        }
+        else {
+          setLoading(false)
+          notify({ type: 'error', message: t('workflow.common.importFailure') })
+        }
+      }
+    }
+    catch (e) {
+      setLoading(false)
+      notify({ type: 'error', message: t('workflow.common.importFailure') })
+    }
+    isCreatingRef.current = false
+  }, [currentFile, fileContent, onCancel, notify, t, eventEmitter, appDetail, onImport])
+
+  const onUpdateDSLConfirm: MouseEventHandler = async () => {
+    try {
+      if (!importId)
+        return
+      const response = await importDSLConfirm({
+        import_id: importId,
+      })
+
+      const { status, app_id } = response
+
+      if (status === DSLImportStatus.COMPLETED) {
+        if (!app_id) {
+          notify({ type: 'error', message: t('workflow.common.importFailure') })
+          return
         }
-        eventEmitter?.emit({
-          type: WORKFLOW_DATA_UPDATE,
-          payload: {
-            nodes: initialNodes(nodes, edges),
-            edges: initialEdges(edges, nodes),
-            viewport,
-            features: newFeatures,
-            hash,
-          },
-        } as any)
+        handleWorkflowUpdate(app_id)
         if (onImport)
           onImport()
         notify({ type: 'success', message: t('workflow.common.importSuccess') })
         setLoading(false)
         onCancel()
       }
+      else if (status === DSLImportStatus.FAILED) {
+        setLoading(false)
+        notify({ type: 'error', message: t('workflow.common.importFailure') })
+      }
     }
     catch (e) {
       setLoading(false)
       notify({ type: 'error', message: t('workflow.common.importFailure') })
     }
-    isCreatingRef.current = false
-  }, [currentFile, fileContent, onCancel, notify, t, eventEmitter, appDetail, onImport])
+  }
 
   return (
-    <Modal
-      className='p-6 w-[520px] rounded-2xl'
-      isShow={true}
-      onClose={() => {}}
-    >
-      <div className='flex items-center justify-between mb-6'>
-        <div className='text-2xl font-semibold text-[#101828]'>{t('workflow.common.importDSL')}</div>
-        <div className='flex items-center justify-center w-[22px] h-[22px] cursor-pointer' onClick={onCancel}>
-          <RiCloseLine className='w-5 h-5 text-gray-500' />
+    <>
+      <Modal
+        className='p-6 w-[520px] rounded-2xl'
+        isShow={show}
+        onClose={onCancel}
+      >
+        <div className='flex items-center justify-between mb-3'>
+          <div className='title-2xl-semi-bold text-text-primary'>{t('workflow.common.importDSL')}</div>
+          <div className='flex items-center justify-center w-[22px] h-[22px] cursor-pointer' onClick={onCancel}>
+            <RiCloseLine className='w-[18px] h-[18px] text-text-tertiary' />
+          </div>
+        </div>
+        <div className='flex relative p-2 mb-2 gap-0.5 flex-grow rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xs overflow-hidden'>
+          <div className='absolute top-0 left-0 w-full h-full opacity-40 bg-[linear-gradient(92deg,rgba(247,144,9,0.25)_0%,rgba(255,255,255,0.00)_100%)]' />
+          <div className='flex p-1 justify-center items-start'>
+            <RiAlertFill className='w-4 h-4 flex-shrink-0 text-text-warning-secondary' />
+          </div>
+          <div className='flex py-1 flex-col items-start gap-0.5 flex-grow'>
+            <div className='text-text-primary system-xs-medium whitespace-pre-line'>{t('workflow.common.importDSLTip')}</div>
+            <div className='flex pt-1 pb-0.5 items-start gap-1 self-stretch'>
+              <Button
+                size='small'
+                variant='secondary'
+                className='z-[1000]'
+                onClick={onBackup}
+              >
+                <RiFileDownloadLine className='w-3.5 h-3.5 text-components-button-secondary-text' />
+                <div className='flex px-[3px] justify-center items-center gap-1'>
+                  {t('workflow.common.backupCurrentDraft')}
+                </div>
+              </Button>
+            </div>
+          </div>
         </div>
-      </div>
-      <div className='flex mb-4 px-4 py-3 bg-[#FFFAEB] rounded-xl border border-[#FEDF89]'>
-        <RiAlertLine className='shrink-0 mt-0.5 mr-2 w-4 h-4 text-[#F79009]' />
         <div>
-          <div className='mb-2 text-sm font-medium text-[#354052]'>{t('workflow.common.importDSLTip')}</div>
+          <div className='pt-2 text-text-primary system-md-semibold'>
+            {t('workflow.common.chooseDSL')}
+          </div>
+          <div className='flex w-full py-4 flex-col justify-center items-start gap-4 self-stretch'>
+            <Uploader
+              file={currentFile}
+              updateFile={handleFile}
+              className='!mt-0 w-full'
+            />
+          </div>
+        </div>
+        <div className='flex pt-5 gap-2 items-center justify-end self-stretch'>
+          <Button onClick={onCancel}>{t('app.newApp.Cancel')}</Button>
           <Button
-            variant='secondary-accent'
-            onClick={onBackup}
+            disabled={!currentFile || loading}
+            variant='warning'
+            onClick={handleImport}
+            loading={loading}
           >
-            {t('workflow.common.backupCurrentDraft')}
+            {t('workflow.common.overwriteAndImport')}
           </Button>
         </div>
-      </div>
-      <div className='mb-8'>
-        <div className='mb-1 text-[13px] font-semibold text-[#354052]'>
-          {t('workflow.common.chooseDSL')}
+      </Modal>
+      <Modal
+        isShow={showErrorModal}
+        onClose={() => setShowErrorModal(false)}
+        className='w-[480px]'
+      >
+        <div className='flex pb-4 flex-col items-start gap-2 self-stretch'>
+          <div className='text-text-primary title-2xl-semi-bold'>{t('app.newApp.appCreateDSLErrorTitle')}</div>
+          <div className='flex flex-grow flex-col text-text-secondary system-md-regular'>
+            <div>{t('app.newApp.appCreateDSLErrorPart1')}</div>
+            <div>{t('app.newApp.appCreateDSLErrorPart2')}</div>
+            <br />
+            <div>{t('app.newApp.appCreateDSLErrorPart3')}<span className='system-md-medium'>{versions?.importedVersion}</span></div>
+            <div>{t('app.newApp.appCreateDSLErrorPart4')}<span className='system-md-medium'>{versions?.systemVersion}</span></div>
+          </div>
+        </div>
+        <div className='flex pt-6 justify-end items-start gap-2 self-stretch'>
+          <Button variant='secondary' onClick={() => setShowErrorModal(false)}>{t('app.newApp.Cancel')}</Button>
+          <Button variant='primary' destructive onClick={onUpdateDSLConfirm}>{t('app.newApp.Confirm')}</Button>
         </div>
-        <Uploader
-          file={currentFile}
-          updateFile={handleFile}
-          className='!mt-0'
-        />
-      </div>
-      <div className='flex justify-end'>
-        <Button className='mr-2' onClick={onCancel}>{t('app.newApp.Cancel')}</Button>
-        <Button
-          disabled={!currentFile || loading}
-          variant='warning'
-          onClick={handleImport}
-          loading={loading}
-        >
-          {t('workflow.common.overwriteAndImport')}
-        </Button>
-      </div>
-    </Modal>
+      </Modal>
+    </>
   )
 }
 

+ 8 - 0
web/i18n/en-US/app.ts

@@ -61,10 +61,18 @@ const translation = {
     hideTemplates: 'Go back to mode selection',
     Create: 'Create',
     Cancel: 'Cancel',
+    Confirm: 'Confirm',
     nameNotEmpty: 'Name cannot be empty',
     appTemplateNotSelected: 'Please select a template',
     appTypeRequired: 'Please select an app type',
     appCreated: 'App created',
+    caution: 'Caution',
+    appCreateDSLWarning: 'Caution: DSL version difference may affect certain features',
+    appCreateDSLErrorTitle: 'Version Incompatibility',
+    appCreateDSLErrorPart1: 'A significant difference in DSL versions has been detected. Forcing the import may cause the application to malfunction.',
+    appCreateDSLErrorPart2: 'Do you want to continue?',
+    appCreateDSLErrorPart3: 'Current application DSL version: ',
+    appCreateDSLErrorPart4: 'System-supported DSL version: ',
     appCreateFailed: 'Failed to create app',
   },
   editApp: 'Edit Info',

+ 6 - 4
web/i18n/en-US/workflow.ts

@@ -75,12 +75,14 @@ const translation = {
     viewDetailInTracingPanel: 'View details',
     syncingData: 'Syncing data, just a few seconds.',
     importDSL: 'Import DSL',
-    importDSLTip: 'Current draft will be overwritten. Export workflow as backup before importing.',
+    importDSLTip: 'Current draft will be overwritten.\nExport workflow as backup before importing.',
     backupCurrentDraft: 'Backup Current Draft',
-    chooseDSL: 'Choose DSL(yml) file',
+    chooseDSL: 'Choose DSL file',
     overwriteAndImport: 'Overwrite and Import',
-    importFailure: 'Import failure',
-    importSuccess: 'Import success',
+    importFailure: 'Import Failed',
+    importWarning: 'Caution',
+    importWarningDetails: 'DSL version difference may affect certain features',
+    importSuccess: 'Import Successfully',
     parallelRun: 'Parallel Run',
     parallelTip: {
       click: {

+ 7 - 0
web/i18n/zh-Hans/app.ts

@@ -64,6 +64,13 @@ const translation = {
     appTemplateNotSelected: '请选择应用模版',
     appTypeRequired: '请选择应用类型',
     appCreated: '应用已创建',
+    caution: '注意',
+    appCreateDSLWarning: '注意:DSL 版本差异可能影响部分功能表现',
+    appCreateDSLErrorTitle: '版本不兼容',
+    appCreateDSLErrorPart1: '检测到 DSL 版本差异较大,强制导入应用可能无法正常运行。',
+    appCreateDSLErrorPart2: '是否继续?',
+    appCreateDSLErrorPart3: '当前应用 DSL 版本:',
+    appCreateDSLErrorPart4: '系统支持 DSL 版本:',
     appCreateFailed: '应用创建失败',
   },
   editApp: '编辑信息',

+ 2 - 0
web/i18n/zh-Hans/workflow.ts

@@ -80,6 +80,8 @@ const translation = {
     chooseDSL: '选择 DSL(yml) 文件',
     overwriteAndImport: '覆盖并导入',
     importFailure: '导入失败',
+    importWarning: '注意',
+    importWarningDetails: 'DSL 版本差异可能影响部分功能表现',
     importSuccess: '导入成功',
     parallelRun: '并行运行',
     parallelTip: {

+ 22 - 0
web/models/app.ts

@@ -58,6 +58,18 @@ export type SiteConfig = {
   prompt_public: boolean
 } */
 
+export enum DSLImportMode {
+  YAML_CONTENT = 'yaml-content',
+  YAML_URL = 'yaml-url',
+}
+
+export enum DSLImportStatus {
+  COMPLETED = 'completed',
+  COMPLETED_WITH_WARNINGS = 'completed-with-warnings',
+  PENDING = 'pending',
+  FAILED = 'failed',
+}
+
 export type AppListResponse = {
   data: App[]
   has_more: boolean
@@ -67,6 +79,16 @@ export type AppListResponse = {
 }
 
 export type AppDetailResponse = App
+
+export type DSLImportResponse = {
+  id: string
+  status: DSLImportStatus
+  app_id?: string
+  current_dsl_version?: string
+  imported_dsl_version?: string
+  error: string
+}
+
 export type AppSSOResponse = { enabled: AppSSO['enable_sso'] }
 
 export type AppTemplatesResponse = {

+ 11 - 1
web/service/apps.ts

@@ -1,6 +1,6 @@
 import type { Fetcher } from 'swr'
 import { del, get, patch, post, put } from './base'
-import type { ApiKeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppDetailResponse, AppListResponse, AppSSOResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app'
+import type { ApiKeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppDetailResponse, AppListResponse, AppSSOResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, DSLImportMode, DSLImportResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app'
 import type { CommonResponse } from '@/models/common'
 import type { AppIconType, AppMode, ModelConfig } from '@/types/app'
 import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type'
@@ -40,14 +40,24 @@ export const exportAppConfig: Fetcher<{ data: string }, { appID: string; include
   return get<{ data: string }>(`apps/${appID}/export?include_secret=${include}`)
 }
 
+// TODO: delete
 export const importApp: Fetcher<AppDetailResponse, { data: string; name?: string; description?: string; icon_type?: AppIconType; icon?: string; icon_background?: string }> = ({ data, name, description, icon_type, icon, icon_background }) => {
   return post<AppDetailResponse>('apps/import', { body: { data, name, description, icon_type, icon, icon_background } })
 }
 
+// TODO: delete
 export const importAppFromUrl: Fetcher<AppDetailResponse, { url: string; name?: string; description?: string; icon?: string; icon_background?: string }> = ({ url, name, description, icon, icon_background }) => {
   return post<AppDetailResponse>('apps/import/url', { body: { url, name, description, icon, icon_background } })
 }
 
+export const importDSL: Fetcher<DSLImportResponse, { mode: DSLImportMode; yaml_content?: string; yaml_url?: string; app_id?: string; name?: string; description?: string; icon_type?: AppIconType; icon?: string; icon_background?: string }> = ({ mode, yaml_content, yaml_url, app_id, name, description, icon_type, icon, icon_background }) => {
+  return post<DSLImportResponse>('apps/imports', { body: { mode, yaml_content, yaml_url, app_id, name, description, icon, icon_type, icon_background } })
+}
+
+export const importDSLConfirm: Fetcher<DSLImportResponse, { import_id: string }> = ({ import_id }) => {
+  return post<DSLImportResponse>(`apps/imports/${import_id}/confirm`, { body: {} })
+}
+
 export const switchApp: Fetcher<{ new_app_id: string }, { appID: string; name: string; icon_type: AppIconType; icon: string; icon_background?: string | null }> = ({ appID, name, icon_type, icon, icon_background }) => {
   return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon_type, icon, icon_background } })
 }

+ 1 - 0
web/service/workflow.ts

@@ -56,6 +56,7 @@ export const fetchNodeDefault = (appId: string, blockType: BlockEnum, query = {}
   })
 }
 
+// TODO: archived
 export const updateWorkflowDraftFromDSL = (appId: string, data: string) => {
   return post<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } })
 }