service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. import logging
  2. from collections.abc import Mapping
  3. from typing import Any
  4. import yaml
  5. from packaging import version
  6. from core.helper import ssrf_proxy
  7. from events.app_event import app_model_config_was_updated, app_was_created
  8. from extensions.ext_database import db
  9. from factories import variable_factory
  10. from models.account import Account
  11. from models.model import App, AppMode, AppModelConfig
  12. from models.workflow import Workflow
  13. from services.workflow_service import WorkflowService
  14. from .exc import (
  15. ContentDecodingError,
  16. DSLVersionNotSupportedError,
  17. EmptyContentError,
  18. FileSizeLimitExceededError,
  19. InvalidAppModeError,
  20. InvalidYAMLFormatError,
  21. MissingAppDataError,
  22. MissingModelConfigError,
  23. MissingWorkflowDataError,
  24. )
  25. logger = logging.getLogger(__name__)
  26. current_dsl_version = "0.1.2"
  27. dsl_to_dify_version_mapping: dict[str, str] = {
  28. "0.1.2": "0.8.0",
  29. "0.1.1": "0.6.0", # dsl version -> from dify version
  30. }
  31. class AppDslService:
  32. @classmethod
  33. def import_and_create_new_app_from_url(cls, tenant_id: str, url: str, args: dict, account: Account) -> App:
  34. """
  35. Import app dsl from url and create new app
  36. :param tenant_id: tenant id
  37. :param url: import url
  38. :param args: request args
  39. :param account: Account instance
  40. """
  41. max_size = 10 * 1024 * 1024 # 10MB
  42. response = ssrf_proxy.get(url.strip(), follow_redirects=True, timeout=(10, 10))
  43. response.raise_for_status()
  44. content = response.content
  45. if len(content) > max_size:
  46. raise FileSizeLimitExceededError("File size exceeds the limit of 10MB")
  47. if not content:
  48. raise EmptyContentError("Empty content from url")
  49. try:
  50. data = content.decode("utf-8")
  51. except UnicodeDecodeError as e:
  52. raise ContentDecodingError(f"Error decoding content: {e}")
  53. return cls.import_and_create_new_app(tenant_id, data, args, account)
  54. @classmethod
  55. def import_and_create_new_app(cls, tenant_id: str, data: str, args: dict, account: Account) -> App:
  56. """
  57. Import app dsl and create new app
  58. :param tenant_id: tenant id
  59. :param data: import data
  60. :param args: request args
  61. :param account: Account instance
  62. """
  63. try:
  64. import_data = yaml.safe_load(data)
  65. except yaml.YAMLError:
  66. raise InvalidYAMLFormatError("Invalid YAML format in data argument.")
  67. # check or repair dsl version
  68. import_data = _check_or_fix_dsl(import_data)
  69. app_data = import_data.get("app")
  70. if not app_data:
  71. raise MissingAppDataError("Missing app in data argument")
  72. # get app basic info
  73. name = args.get("name") or app_data.get("name")
  74. description = args.get("description") or app_data.get("description", "")
  75. icon_type = args.get("icon_type") or app_data.get("icon_type")
  76. icon = args.get("icon") or app_data.get("icon")
  77. icon_background = args.get("icon_background") or app_data.get("icon_background")
  78. use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False)
  79. # import dsl and create app
  80. app_mode = AppMode.value_of(app_data.get("mode"))
  81. if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  82. workflow_data = import_data.get("workflow")
  83. if not workflow_data or not isinstance(workflow_data, dict):
  84. raise MissingWorkflowDataError(
  85. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  86. )
  87. app = cls._import_and_create_new_workflow_based_app(
  88. tenant_id=tenant_id,
  89. app_mode=app_mode,
  90. workflow_data=workflow_data,
  91. account=account,
  92. name=name,
  93. description=description,
  94. icon_type=icon_type,
  95. icon=icon,
  96. icon_background=icon_background,
  97. use_icon_as_answer_icon=use_icon_as_answer_icon,
  98. )
  99. elif app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION}:
  100. model_config = import_data.get("model_config")
  101. if not model_config or not isinstance(model_config, dict):
  102. raise MissingModelConfigError(
  103. "Missing model_config in data argument when app mode is chat, agent-chat or completion"
  104. )
  105. app = cls._import_and_create_new_model_config_based_app(
  106. tenant_id=tenant_id,
  107. app_mode=app_mode,
  108. model_config_data=model_config,
  109. account=account,
  110. name=name,
  111. description=description,
  112. icon_type=icon_type,
  113. icon=icon,
  114. icon_background=icon_background,
  115. use_icon_as_answer_icon=use_icon_as_answer_icon,
  116. )
  117. else:
  118. raise InvalidAppModeError("Invalid app mode")
  119. return app
  120. @classmethod
  121. def import_and_overwrite_workflow(cls, app_model: App, data: str, account: Account) -> Workflow:
  122. """
  123. Import app dsl and overwrite workflow
  124. :param app_model: App instance
  125. :param data: import data
  126. :param account: Account instance
  127. """
  128. try:
  129. import_data = yaml.safe_load(data)
  130. except yaml.YAMLError:
  131. raise InvalidYAMLFormatError("Invalid YAML format in data argument.")
  132. # check or repair dsl version
  133. import_data = _check_or_fix_dsl(import_data)
  134. app_data = import_data.get("app")
  135. if not app_data:
  136. raise MissingAppDataError("Missing app in data argument")
  137. # import dsl and overwrite app
  138. app_mode = AppMode.value_of(app_data.get("mode"))
  139. if app_mode not in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  140. raise InvalidAppModeError("Only support import workflow in advanced-chat or workflow app.")
  141. if app_data.get("mode") != app_model.mode:
  142. raise ValueError(f"App mode {app_data.get('mode')} is not matched with current app mode {app_mode.value}")
  143. workflow_data = import_data.get("workflow")
  144. if not workflow_data or not isinstance(workflow_data, dict):
  145. raise MissingWorkflowDataError(
  146. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  147. )
  148. return cls._import_and_overwrite_workflow_based_app(
  149. app_model=app_model,
  150. workflow_data=workflow_data,
  151. account=account,
  152. )
  153. @classmethod
  154. def export_dsl(cls, app_model: App, include_secret: bool = False) -> str:
  155. """
  156. Export app
  157. :param app_model: App instance
  158. :return:
  159. """
  160. app_mode = AppMode.value_of(app_model.mode)
  161. export_data = {
  162. "version": current_dsl_version,
  163. "kind": "app",
  164. "app": {
  165. "name": app_model.name,
  166. "mode": app_model.mode,
  167. "icon": "🤖" if app_model.icon_type == "image" else app_model.icon,
  168. "icon_background": "#FFEAD5" if app_model.icon_type == "image" else app_model.icon_background,
  169. "description": app_model.description,
  170. "use_icon_as_answer_icon": app_model.use_icon_as_answer_icon,
  171. },
  172. }
  173. if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  174. cls._append_workflow_export_data(
  175. export_data=export_data, app_model=app_model, include_secret=include_secret
  176. )
  177. else:
  178. cls._append_model_config_export_data(export_data, app_model)
  179. return yaml.dump(export_data, allow_unicode=True)
  180. @classmethod
  181. def _import_and_create_new_workflow_based_app(
  182. cls,
  183. tenant_id: str,
  184. app_mode: AppMode,
  185. workflow_data: Mapping[str, Any],
  186. account: Account,
  187. name: str,
  188. description: str,
  189. icon_type: str,
  190. icon: str,
  191. icon_background: str,
  192. use_icon_as_answer_icon: bool,
  193. ) -> App:
  194. """
  195. Import app dsl and create new workflow based app
  196. :param tenant_id: tenant id
  197. :param app_mode: app mode
  198. :param workflow_data: workflow data
  199. :param account: Account instance
  200. :param name: app name
  201. :param description: app description
  202. :param icon_type: app icon type, "emoji" or "image"
  203. :param icon: app icon
  204. :param icon_background: app icon background
  205. :param use_icon_as_answer_icon: use app icon as answer icon
  206. """
  207. if not workflow_data:
  208. raise MissingWorkflowDataError(
  209. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  210. )
  211. app = cls._create_app(
  212. tenant_id=tenant_id,
  213. app_mode=app_mode,
  214. account=account,
  215. name=name,
  216. description=description,
  217. icon_type=icon_type,
  218. icon=icon,
  219. icon_background=icon_background,
  220. use_icon_as_answer_icon=use_icon_as_answer_icon,
  221. )
  222. # init draft workflow
  223. environment_variables_list = workflow_data.get("environment_variables") or []
  224. environment_variables = [
  225. variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list
  226. ]
  227. conversation_variables_list = workflow_data.get("conversation_variables") or []
  228. conversation_variables = [
  229. variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list
  230. ]
  231. workflow_service = WorkflowService()
  232. draft_workflow = workflow_service.sync_draft_workflow(
  233. app_model=app,
  234. graph=workflow_data.get("graph", {}),
  235. features=workflow_data.get("features", {}),
  236. unique_hash=None,
  237. account=account,
  238. environment_variables=environment_variables,
  239. conversation_variables=conversation_variables,
  240. )
  241. workflow_service.publish_workflow(app_model=app, account=account, draft_workflow=draft_workflow)
  242. return app
  243. @classmethod
  244. def _import_and_overwrite_workflow_based_app(
  245. cls, app_model: App, workflow_data: Mapping[str, Any], account: Account
  246. ) -> Workflow:
  247. """
  248. Import app dsl and overwrite workflow based app
  249. :param app_model: App instance
  250. :param workflow_data: workflow data
  251. :param account: Account instance
  252. """
  253. if not workflow_data:
  254. raise MissingWorkflowDataError(
  255. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  256. )
  257. # fetch draft workflow by app_model
  258. workflow_service = WorkflowService()
  259. current_draft_workflow = workflow_service.get_draft_workflow(app_model=app_model)
  260. if current_draft_workflow:
  261. unique_hash = current_draft_workflow.unique_hash
  262. else:
  263. unique_hash = None
  264. # sync draft workflow
  265. environment_variables_list = workflow_data.get("environment_variables") or []
  266. environment_variables = [
  267. variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list
  268. ]
  269. conversation_variables_list = workflow_data.get("conversation_variables") or []
  270. conversation_variables = [
  271. variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list
  272. ]
  273. draft_workflow = workflow_service.sync_draft_workflow(
  274. app_model=app_model,
  275. graph=workflow_data.get("graph", {}),
  276. features=workflow_data.get("features", {}),
  277. unique_hash=unique_hash,
  278. account=account,
  279. environment_variables=environment_variables,
  280. conversation_variables=conversation_variables,
  281. )
  282. return draft_workflow
  283. @classmethod
  284. def _import_and_create_new_model_config_based_app(
  285. cls,
  286. tenant_id: str,
  287. app_mode: AppMode,
  288. model_config_data: Mapping[str, Any],
  289. account: Account,
  290. name: str,
  291. description: str,
  292. icon_type: str,
  293. icon: str,
  294. icon_background: str,
  295. use_icon_as_answer_icon: bool,
  296. ) -> App:
  297. """
  298. Import app dsl and create new model config based app
  299. :param tenant_id: tenant id
  300. :param app_mode: app mode
  301. :param model_config_data: model config data
  302. :param account: Account instance
  303. :param name: app name
  304. :param description: app description
  305. :param icon: app icon
  306. :param icon_background: app icon background
  307. """
  308. if not model_config_data:
  309. raise MissingModelConfigError(
  310. "Missing model_config in data argument when app mode is chat, agent-chat or completion"
  311. )
  312. app = cls._create_app(
  313. tenant_id=tenant_id,
  314. app_mode=app_mode,
  315. account=account,
  316. name=name,
  317. description=description,
  318. icon_type=icon_type,
  319. icon=icon,
  320. icon_background=icon_background,
  321. use_icon_as_answer_icon=use_icon_as_answer_icon,
  322. )
  323. app_model_config = AppModelConfig()
  324. app_model_config = app_model_config.from_model_config_dict(model_config_data)
  325. app_model_config.app_id = app.id
  326. app_model_config.created_by = account.id
  327. app_model_config.updated_by = account.id
  328. db.session.add(app_model_config)
  329. db.session.commit()
  330. app.app_model_config_id = app_model_config.id
  331. app_model_config_was_updated.send(app, app_model_config=app_model_config)
  332. return app
  333. @classmethod
  334. def _create_app(
  335. cls,
  336. tenant_id: str,
  337. app_mode: AppMode,
  338. account: Account,
  339. name: str,
  340. description: str,
  341. icon_type: str,
  342. icon: str,
  343. icon_background: str,
  344. use_icon_as_answer_icon: bool,
  345. ) -> App:
  346. """
  347. Create new app
  348. :param tenant_id: tenant id
  349. :param app_mode: app mode
  350. :param account: Account instance
  351. :param name: app name
  352. :param description: app description
  353. :param icon_type: app icon type, "emoji" or "image"
  354. :param icon: app icon
  355. :param icon_background: app icon background
  356. :param use_icon_as_answer_icon: use app icon as answer icon
  357. """
  358. app = App(
  359. tenant_id=tenant_id,
  360. mode=app_mode.value,
  361. name=name,
  362. description=description,
  363. icon_type=icon_type,
  364. icon=icon,
  365. icon_background=icon_background,
  366. enable_site=True,
  367. enable_api=True,
  368. use_icon_as_answer_icon=use_icon_as_answer_icon,
  369. created_by=account.id,
  370. updated_by=account.id,
  371. )
  372. db.session.add(app)
  373. db.session.commit()
  374. app_was_created.send(app, account=account)
  375. return app
  376. @classmethod
  377. def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None:
  378. """
  379. Append workflow export data
  380. :param export_data: export data
  381. :param app_model: App instance
  382. """
  383. workflow_service = WorkflowService()
  384. workflow = workflow_service.get_draft_workflow(app_model)
  385. if not workflow:
  386. raise ValueError("Missing draft workflow configuration, please check.")
  387. export_data["workflow"] = workflow.to_dict(include_secret=include_secret)
  388. @classmethod
  389. def _append_model_config_export_data(cls, export_data: dict, app_model: App) -> None:
  390. """
  391. Append model config export data
  392. :param export_data: export data
  393. :param app_model: App instance
  394. """
  395. app_model_config = app_model.app_model_config
  396. if not app_model_config:
  397. raise ValueError("Missing app configuration, please check.")
  398. export_data["model_config"] = app_model_config.to_dict()
  399. def _check_or_fix_dsl(import_data: dict[str, Any]) -> Mapping[str, Any]:
  400. """
  401. Check or fix dsl
  402. :param import_data: import data
  403. :raises DSLVersionNotSupportedError: if the imported DSL version is newer than the current version
  404. """
  405. if not import_data.get("version"):
  406. import_data["version"] = "0.1.0"
  407. if not import_data.get("kind") or import_data.get("kind") != "app":
  408. import_data["kind"] = "app"
  409. imported_version = import_data.get("version")
  410. if imported_version != current_dsl_version:
  411. if imported_version and version.parse(imported_version) > version.parse(current_dsl_version):
  412. raise DSLVersionNotSupportedError(
  413. f"The imported DSL version {imported_version} is newer than "
  414. f"the current supported version {current_dsl_version}. "
  415. f"Please upgrade your Dify instance to import this configuration."
  416. )
  417. else:
  418. logger.warning(
  419. f"DSL version {imported_version} is older than "
  420. f"the current version {current_dsl_version}. "
  421. f"This may cause compatibility issues."
  422. )
  423. return import_data