service.py 17 KB

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