Co-authored-by: Yi <yxiaoisme@gmail.com>tags/0.12.0
| from libs.external_api import ExternalApi | from libs.external_api import ExternalApi | ||||
| from .app.app_import import AppImportApi, AppImportConfirmApi | |||||
| from .files import FileApi, FilePreviewApi, FileSupportTypeApi | from .files import FileApi, FilePreviewApi, FileSupportTypeApi | ||||
| from .remote_files import RemoteFileInfoApi, RemoteFileUploadApi | from .remote_files import RemoteFileInfoApi, RemoteFileUploadApi | ||||
| api.add_resource(RemoteFileInfoApi, "/remote-files/<path:url>") | api.add_resource(RemoteFileInfoApi, "/remote-files/<path:url>") | ||||
| api.add_resource(RemoteFileUploadApi, "/remote-files/upload") | 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 | # Import other controllers | ||||
| from . import admin, apikey, extension, feature, ping, setup, version | from . import admin, apikey, extension, feature, ping, setup, version | ||||
| import uuid | import uuid | ||||
| from typing import cast | |||||
| from flask_login import current_user | from flask_login import current_user | ||||
| from flask_restful import Resource, inputs, marshal, marshal_with, reqparse | 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 werkzeug.exceptions import BadRequest, Forbidden, abort | ||||
| from controllers.console import api | from controllers.console import api | ||||
| setup_required, | setup_required, | ||||
| ) | ) | ||||
| from core.ops.ops_trace_manager import OpsTraceManager | from core.ops.ops_trace_manager import OpsTraceManager | ||||
| from extensions.ext_database import db | |||||
| from fields.app_fields import ( | from fields.app_fields import ( | ||||
| app_detail_fields, | app_detail_fields, | ||||
| app_detail_fields_with_site, | app_detail_fields_with_site, | ||||
| app_pagination_fields, | app_pagination_fields, | ||||
| ) | ) | ||||
| from libs.login import login_required | 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 | from services.app_service import AppService | ||||
| ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] | ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"] | ||||
| return app, 201 | 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): | class AppApi(Resource): | ||||
| @setup_required | @setup_required | ||||
| @login_required | @login_required | ||||
| parser.add_argument("icon_background", type=str, location="json") | parser.add_argument("icon_background", type=str, location="json") | ||||
| args = parser.parse_args() | 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 | return app, 201 | ||||
| api.add_resource(AppListApi, "/apps") | 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(AppApi, "/apps/<uuid:app_id>") | ||||
| api.add_resource(AppCopyApi, "/apps/<uuid:app_id>/copy") | api.add_resource(AppCopyApi, "/apps/<uuid:app_id>/copy") | ||||
| api.add_resource(AppExportApi, "/apps/<uuid:app_id>/export") | api.add_resource(AppExportApi, "/apps/<uuid:app_id>/export") |
| 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 |
| from libs.login import current_user, login_required | from libs.login import current_user, login_required | ||||
| from models import App | from models import App | ||||
| from models.model import AppMode | from models.model import AppMode | ||||
| from services.app_dsl_service import AppDslService | |||||
| from services.app_generate_service import AppGenerateService | from services.app_generate_service import AppGenerateService | ||||
| from services.errors.app import WorkflowHashNotEqualError | from services.errors.app import WorkflowHashNotEqualError | ||||
| from services.workflow_service import WorkflowService | from services.workflow_service import WorkflowService | ||||
| } | } | ||||
| 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): | class AdvancedChatDraftWorkflowRunApi(Resource): | ||||
| @setup_required | @setup_required | ||||
| @login_required | @login_required | ||||
| api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft") | 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(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run") | ||||
| api.add_resource(DraftWorkflowRunApi, "/apps/<uuid:app_id>/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") | api.add_resource(WorkflowTaskStopApi, "/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop") |
| "show_workflow_steps": fields.Boolean, | "show_workflow_steps": fields.Boolean, | ||||
| "use_icon_as_answer_icon": 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, | |||||
| } |
| if obj is None: | if obj is None: | ||||
| return 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 file_helpers.get_signed_file_url(obj.icon) | ||||
| return None | return None | ||||
| name = db.Column(db.String(255), nullable=False) | name = db.Column(db.String(255), nullable=False) | ||||
| description = db.Column(db.Text, nullable=False, server_default=db.text("''::character varying")) | description = db.Column(db.Text, nullable=False, server_default=db.text("''::character varying")) | ||||
| mode = db.Column(db.String(255), nullable=False) | 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 = db.Column(db.String(255)) | ||||
| icon_background = db.Column(db.String(255)) | icon_background = db.Column(db.String(255)) | ||||
| app_model_config_id = db.Column(StringUUID, nullable=True) | app_model_config_id = db.Column(StringUUID, nullable=True) |
| 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() |
| from .service import AppDslService | |||||
| __all__ = ["AppDslService"] |
| 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.""" |
| 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 |
| """ | """ | ||||
| # get original app model config | # get original app model config | ||||
| if app.mode == AppMode.AGENT_CHAT.value or app.is_agent: | 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 | agent_mode = model_config.agent_mode_dict | ||||
| # decrypt agent tool parameters if it's secret-input | # decrypt agent tool parameters if it's secret-input | ||||
| for tool in agent_mode.get("tools") or []: | for tool in agent_mode.get("tools") or []: |
| 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" |
| import Modal from '@/app/components/base/modal' | import Modal from '@/app/components/base/modal' | ||||
| import { ToastContext } from '@/app/components/base/toast' | import { ToastContext } from '@/app/components/base/toast' | ||||
| import { | import { | ||||
| importApp, | |||||
| importAppFromUrl, | |||||
| importDSL, | |||||
| importDSLConfirm, | |||||
| } from '@/service/apps' | } from '@/service/apps' | ||||
| import { | |||||
| DSLImportMode, | |||||
| DSLImportStatus, | |||||
| } from '@/models/app' | |||||
| import { useAppContext } from '@/context/app-context' | import { useAppContext } from '@/context/app-context' | ||||
| import { useProviderContext } from '@/context/provider-context' | import { useProviderContext } from '@/context/provider-context' | ||||
| import AppsFull from '@/app/components/billing/apps-full-in-dialog' | import AppsFull from '@/app/components/billing/apps-full-in-dialog' | ||||
| const [fileContent, setFileContent] = useState<string>() | const [fileContent, setFileContent] = useState<string>() | ||||
| const [currentTab, setCurrentTab] = useState(activeTab) | const [currentTab, setCurrentTab] = useState(activeTab) | ||||
| const [dslUrlValue, setDslUrlValue] = useState(dslUrl) | 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 readFile = (file: File) => { | ||||
| const reader = new FileReader() | const reader = new FileReader() | ||||
| const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) | const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps) | ||||
| const isCreatingRef = useRef(false) | const isCreatingRef = useRef(false) | ||||
| const onCreate: MouseEventHandler = async () => { | const onCreate: MouseEventHandler = async () => { | ||||
| if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) | if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile) | ||||
| return | return | ||||
| return | return | ||||
| isCreatingRef.current = true | isCreatingRef.current = true | ||||
| try { | try { | ||||
| let app | |||||
| let response | |||||
| if (currentTab === CreateFromDSLModalTab.FROM_FILE) { | 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) { | 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) { | catch (e) { | ||||
| notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) | notify({ type: 'error', message: t('app.newApp.appCreateFailed') }) | ||||
| isCreatingRef.current = false | 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 = [ | const tabs = [ | ||||
| { | { | ||||
| key: CreateFromDSLModalTab.FROM_FILE, | key: CreateFromDSLModalTab.FROM_FILE, | ||||
| }, [isAppsFull, currentTab, currentFile, dslUrlValue]) | }, [isAppsFull, currentTab, currentFile, dslUrlValue]) | ||||
| return ( | 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> | |||||
| <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> | ||||
| )} | |||||
| <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> | |||||
| </> | |||||
| ) | ) | ||||
| } | } | ||||
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useContext } from 'use-context-selector' | import { useContext } from 'use-context-selector' | ||||
| import { formatFileSize } from '@/utils/format' | |||||
| import cn from '@/utils/classnames' | import cn from '@/utils/classnames' | ||||
| import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files' | import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files' | ||||
| import { ToastContext } from '@/app/components/base/toast' | import { ToastContext } from '@/app/components/base/toast' | ||||
| updateFile(files[0]) | updateFile(files[0]) | ||||
| } | } | ||||
| const selectHandle = () => { | const selectHandle = () => { | ||||
| if (fileUploader.current) | |||||
| const originalFile = file | |||||
| if (fileUploader.current) { | |||||
| fileUploader.current.value = '' | |||||
| fileUploader.current.click() | fileUploader.current.click() | ||||
| // If no file is selected, restore the original file | |||||
| fileUploader.current.oncancel = () => updateFile(originalFile) | |||||
| } | |||||
| } | } | ||||
| const removeFile = () => { | const removeFile = () => { | ||||
| if (fileUploader.current) | if (fileUploader.current) | ||||
| /> | /> | ||||
| <div ref={dropRef}> | <div ref={dropRef}> | ||||
| {!file && ( | {!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'> | <div className='w-full flex items-center justify-center space-x-2'> | ||||
| <UploadCloud01 className='w-6 h-6 mr-2' /> | <UploadCloud01 className='w-6 h-6 mr-2' /> | ||||
| <div className='text-gray-500'> | <div className='text-gray-500'> | ||||
| </div> | </div> | ||||
| )} | )} | ||||
| {file && ( | {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> | ||||
| <div className='hidden group-hover:flex items-center'> | <div className='hidden group-hover:flex items-center'> | ||||
| <Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button> | <Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button> | ||||
| <div className='mx-2 w-px h-4 bg-gray-200' /> | <div className='mx-2 w-px h-4 bg-gray-200' /> | ||||
| <div className='p-2 cursor-pointer' onClick={removeFile}> | <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> | </div> | ||||
| </div> | </div> |
| import React, { useEffect, useState } from 'react' | import React, { useEffect, useState } from 'react' | ||||
| import { createRoot } from 'react-dom/client' | import { createRoot } from 'react-dom/client' | ||||
| import { | 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 { createContext, useContext } from 'use-context-selector' | ||||
| import ActionButton from '@/app/components/base/action-button' | |||||
| import classNames from '@/utils/classnames' | import classNames from '@/utils/classnames' | ||||
| export type IToastProps = { | export type IToastProps = { | ||||
| type?: 'success' | 'error' | 'warning' | 'info' | type?: 'success' | 'error' | 'warning' | 'info' | ||||
| size?: 'md' | 'sm' | |||||
| duration?: number | duration?: number | ||||
| message: string | message: string | ||||
| children?: ReactNode | children?: ReactNode | ||||
| } | } | ||||
| type IToastContext = { | type IToastContext = { | ||||
| notify: (props: IToastProps) => void | notify: (props: IToastProps) => void | ||||
| close: () => void | |||||
| } | } | ||||
| export const ToastContext = createContext<IToastContext>({} as IToastContext) | export const ToastContext = createContext<IToastContext>({} as IToastContext) | ||||
| export const useToastContext = () => useContext(ToastContext) | export const useToastContext = () => useContext(ToastContext) | ||||
| const Toast = ({ | const Toast = ({ | ||||
| type = 'info', | type = 'info', | ||||
| size = 'md', | |||||
| message, | message, | ||||
| children, | children, | ||||
| className, | className, | ||||
| }: IToastProps) => { | }: IToastProps) => { | ||||
| const { close } = useToastContext() | |||||
| // sometimes message is react node array. Not handle it. | // sometimes message is react node array. Not handle it. | ||||
| if (typeof message !== 'string') | if (typeof message !== 'string') | ||||
| return null | return null | ||||
| return <div className={classNames( | return <div className={classNames( | ||||
| className, | 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', | 'top-0', | ||||
| 'right-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> | ||||
| <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} | {children} | ||||
| </div> | </div> | ||||
| } | } | ||||
| </div> | </div> | ||||
| <ActionButton className='z-[1000]' onClick={close}> | |||||
| <RiCloseLine className='w-4 h-4 flex-shrink-0 text-text-tertiary' /> | |||||
| </ActionButton> | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| } | } | ||||
| setMounted(true) | setMounted(true) | ||||
| setParams(props) | setParams(props) | ||||
| }, | }, | ||||
| close: () => setMounted(false), | |||||
| }}> | }}> | ||||
| {mounted && <Toast {...params} />} | {mounted && <Toast {...params} />} | ||||
| {children} | {children} | ||||
| Toast.notify = ({ | Toast.notify = ({ | ||||
| type, | type, | ||||
| size = 'md', | |||||
| message, | message, | ||||
| duration, | duration, | ||||
| className, | className, | ||||
| }: Pick<IToastProps, 'type' | 'message' | 'duration' | 'className'>) => { | |||||
| }: Pick<IToastProps, 'type' | 'size' | 'message' | 'duration' | 'className'>) => { | |||||
| const defaultDuring = (type === 'success' || type === 'info') ? 3000 : 6000 | const defaultDuring = (type === 'success' || type === 'info') ? 3000 : 6000 | ||||
| if (typeof window === 'object') { | if (typeof window === 'object') { | ||||
| const holder = document.createElement('div') | const holder = document.createElement('div') | ||||
| const root = createRoot(holder) | 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) | document.body.appendChild(holder) | ||||
| setTimeout(() => { | setTimeout(() => { | ||||
| if (holder) | if (holder) |
| import { useContext } from 'use-context-selector' | import { useContext } from 'use-context-selector' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { | import { | ||||
| RiAlertLine, | |||||
| RiAlertFill, | |||||
| RiCloseLine, | RiCloseLine, | ||||
| RiFileDownloadLine, | |||||
| } from '@remixicon/react' | } from '@remixicon/react' | ||||
| import { WORKFLOW_DATA_UPDATE } from './constants' | import { WORKFLOW_DATA_UPDATE } from './constants' | ||||
| import { | import { | ||||
| initialEdges, | initialEdges, | ||||
| initialNodes, | initialNodes, | ||||
| } from './utils' | } 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 Uploader from '@/app/components/app/create-from-dsl-modal/uploader' | ||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import Modal from '@/app/components/base/modal' | import Modal from '@/app/components/base/modal' | ||||
| import { ToastContext } from '@/app/components/base/toast' | import { ToastContext } from '@/app/components/base/toast' | ||||
| import { updateWorkflowDraftFromDSL } from '@/service/workflow' | |||||
| import { useEventEmitterContextContext } from '@/context/event-emitter' | import { useEventEmitterContextContext } from '@/context/event-emitter' | ||||
| import { useStore as useAppStore } from '@/app/components/app/store' | import { useStore as useAppStore } from '@/app/components/app/store' | ||||
| import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' | import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' | ||||
| const [fileContent, setFileContent] = useState<string>() | const [fileContent, setFileContent] = useState<string>() | ||||
| const [loading, setLoading] = useState(false) | const [loading, setLoading] = useState(false) | ||||
| const { eventEmitter } = useEventEmitterContextContext() | 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 readFile = (file: File) => { | ||||
| const reader = new FileReader() | const reader = new FileReader() | ||||
| setFileContent('') | 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 isCreatingRef = useRef(false) | ||||
| const handleImport: MouseEventHandler = useCallback(async () => { | const handleImport: MouseEventHandler = useCallback(async () => { | ||||
| if (isCreatingRef.current) | if (isCreatingRef.current) | ||||
| try { | try { | ||||
| if (appDetail && fileContent) { | if (appDetail && fileContent) { | ||||
| setLoading(true) | 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) | if (onImport) | ||||
| onImport() | onImport() | ||||
| notify({ type: 'success', message: t('workflow.common.importSuccess') }) | notify({ type: 'success', message: t('workflow.common.importSuccess') }) | ||||
| setLoading(false) | setLoading(false) | ||||
| onCancel() | onCancel() | ||||
| } | } | ||||
| else if (status === DSLImportStatus.FAILED) { | |||||
| setLoading(false) | |||||
| notify({ type: 'error', message: t('workflow.common.importFailure') }) | |||||
| } | |||||
| } | } | ||||
| catch (e) { | catch (e) { | ||||
| setLoading(false) | setLoading(false) | ||||
| notify({ type: 'error', message: t('workflow.common.importFailure') }) | notify({ type: 'error', message: t('workflow.common.importFailure') }) | ||||
| } | } | ||||
| isCreatingRef.current = false | |||||
| }, [currentFile, fileContent, onCancel, notify, t, eventEmitter, appDetail, onImport]) | |||||
| } | |||||
| return ( | 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> | |||||
| <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> | ||||
| <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 | <Button | ||||
| variant='secondary-accent' | |||||
| onClick={onBackup} | |||||
| disabled={!currentFile || loading} | |||||
| variant='warning' | |||||
| onClick={handleImport} | |||||
| loading={loading} | |||||
| > | > | ||||
| {t('workflow.common.backupCurrentDraft')} | |||||
| {t('workflow.common.overwriteAndImport')} | |||||
| </Button> | </Button> | ||||
| </div> | </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> | </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> | |||||
| </> | |||||
| ) | ) | ||||
| } | } | ||||
| hideTemplates: 'Go back to mode selection', | hideTemplates: 'Go back to mode selection', | ||||
| Create: 'Create', | Create: 'Create', | ||||
| Cancel: 'Cancel', | Cancel: 'Cancel', | ||||
| Confirm: 'Confirm', | |||||
| nameNotEmpty: 'Name cannot be empty', | nameNotEmpty: 'Name cannot be empty', | ||||
| appTemplateNotSelected: 'Please select a template', | appTemplateNotSelected: 'Please select a template', | ||||
| appTypeRequired: 'Please select an app type', | appTypeRequired: 'Please select an app type', | ||||
| appCreated: 'App created', | 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', | appCreateFailed: 'Failed to create app', | ||||
| }, | }, | ||||
| editApp: 'Edit Info', | editApp: 'Edit Info', |
| viewDetailInTracingPanel: 'View details', | viewDetailInTracingPanel: 'View details', | ||||
| syncingData: 'Syncing data, just a few seconds.', | syncingData: 'Syncing data, just a few seconds.', | ||||
| importDSL: 'Import DSL', | 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', | backupCurrentDraft: 'Backup Current Draft', | ||||
| chooseDSL: 'Choose DSL(yml) file', | |||||
| chooseDSL: 'Choose DSL file', | |||||
| overwriteAndImport: 'Overwrite and Import', | 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', | parallelRun: 'Parallel Run', | ||||
| parallelTip: { | parallelTip: { | ||||
| click: { | click: { |
| appTemplateNotSelected: '请选择应用模版', | appTemplateNotSelected: '请选择应用模版', | ||||
| appTypeRequired: '请选择应用类型', | appTypeRequired: '请选择应用类型', | ||||
| appCreated: '应用已创建', | appCreated: '应用已创建', | ||||
| caution: '注意', | |||||
| appCreateDSLWarning: '注意:DSL 版本差异可能影响部分功能表现', | |||||
| appCreateDSLErrorTitle: '版本不兼容', | |||||
| appCreateDSLErrorPart1: '检测到 DSL 版本差异较大,强制导入应用可能无法正常运行。', | |||||
| appCreateDSLErrorPart2: '是否继续?', | |||||
| appCreateDSLErrorPart3: '当前应用 DSL 版本:', | |||||
| appCreateDSLErrorPart4: '系统支持 DSL 版本:', | |||||
| appCreateFailed: '应用创建失败', | appCreateFailed: '应用创建失败', | ||||
| }, | }, | ||||
| editApp: '编辑信息', | editApp: '编辑信息', |
| chooseDSL: '选择 DSL(yml) 文件', | chooseDSL: '选择 DSL(yml) 文件', | ||||
| overwriteAndImport: '覆盖并导入', | overwriteAndImport: '覆盖并导入', | ||||
| importFailure: '导入失败', | importFailure: '导入失败', | ||||
| importWarning: '注意', | |||||
| importWarningDetails: 'DSL 版本差异可能影响部分功能表现', | |||||
| importSuccess: '导入成功', | importSuccess: '导入成功', | ||||
| parallelRun: '并行运行', | parallelRun: '并行运行', | ||||
| parallelTip: { | parallelTip: { |
| prompt_public: boolean | 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 = { | export type AppListResponse = { | ||||
| data: App[] | data: App[] | ||||
| has_more: boolean | has_more: boolean | ||||
| } | } | ||||
| export type AppDetailResponse = App | 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 AppSSOResponse = { enabled: AppSSO['enable_sso'] } | ||||
| export type AppTemplatesResponse = { | export type AppTemplatesResponse = { |
| import type { Fetcher } from 'swr' | import type { Fetcher } from 'swr' | ||||
| import { del, get, patch, post, put } from './base' | 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 { CommonResponse } from '@/models/common' | ||||
| import type { AppIconType, AppMode, ModelConfig } from '@/types/app' | import type { AppIconType, AppMode, ModelConfig } from '@/types/app' | ||||
| import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' | import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type' | ||||
| return get<{ data: string }>(`apps/${appID}/export?include_secret=${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 }) => { | 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 } }) | 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 }) => { | 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 } }) | 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 }) => { | 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 } }) | return post<{ new_app_id: string }>(`apps/${appID}/convert-to-workflow`, { body: { name, icon_type, icon, icon_background } }) | ||||
| } | } |
| }) | }) | ||||
| } | } | ||||
| // TODO: archived | |||||
| export const updateWorkflowDraftFromDSL = (appId: string, data: string) => { | export const updateWorkflowDraftFromDSL = (appId: string, data: string) => { | ||||
| return post<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } }) | return post<FetchWorkflowDraftResponse>(`apps/${appId}/workflows/draft/import`, { body: { data } }) | ||||
| } | } |