File size: 17,416 Bytes
a8b3f00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
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