Co-authored-by: Yi <yxiaoisme@gmail.com>tags/0.12.0
| @@ -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 | |||
| @@ -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") | |||
| @@ -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 | |||
| @@ -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") | |||
| @@ -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, | |||
| } | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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() | |||
| @@ -1,3 +0,0 @@ | |||
| from .service import AppDslService | |||
| __all__ = ["AppDslService"] | |||
| @@ -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.""" | |||
| @@ -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 | |||
| @@ -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 []: | |||
| @@ -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" | |||
| @@ -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> | |||
| </> | |||
| ) | |||
| } | |||
| @@ -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> | |||
| @@ -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) | |||
| @@ -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> | |||
| </> | |||
| ) | |||
| } | |||
| @@ -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', | |||
| @@ -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: { | |||
| @@ -64,6 +64,13 @@ const translation = { | |||
| appTemplateNotSelected: '请选择应用模版', | |||
| appTypeRequired: '请选择应用类型', | |||
| appCreated: '应用已创建', | |||
| caution: '注意', | |||
| appCreateDSLWarning: '注意:DSL 版本差异可能影响部分功能表现', | |||
| appCreateDSLErrorTitle: '版本不兼容', | |||
| appCreateDSLErrorPart1: '检测到 DSL 版本差异较大,强制导入应用可能无法正常运行。', | |||
| appCreateDSLErrorPart2: '是否继续?', | |||
| appCreateDSLErrorPart3: '当前应用 DSL 版本:', | |||
| appCreateDSLErrorPart4: '系统支持 DSL 版本:', | |||
| appCreateFailed: '应用创建失败', | |||
| }, | |||
| editApp: '编辑信息', | |||
| @@ -80,6 +80,8 @@ const translation = { | |||
| chooseDSL: '选择 DSL(yml) 文件', | |||
| overwriteAndImport: '覆盖并导入', | |||
| importFailure: '导入失败', | |||
| importWarning: '注意', | |||
| importWarningDetails: 'DSL 版本差异可能影响部分功能表现', | |||
| importSuccess: '导入成功', | |||
| parallelRun: '并行运行', | |||
| parallelTip: { | |||
| @@ -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 = { | |||
| @@ -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 } }) | |||
| } | |||
| @@ -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 } }) | |||
| } | |||