service.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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 core.model_runtime.utils.encoders import jsonable_encoder
  8. from core.plugin.entities.plugin import PluginDependency
  9. from core.workflow.nodes.enums import NodeType
  10. from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData
  11. from core.workflow.nodes.llm.entities import LLMNodeData
  12. from core.workflow.nodes.parameter_extractor.entities import ParameterExtractorNodeData
  13. from core.workflow.nodes.question_classifier.entities import QuestionClassifierNodeData
  14. from core.workflow.nodes.tool.entities import ToolNodeData
  15. from events.app_event import app_model_config_was_updated, app_was_created
  16. from extensions.ext_database import db
  17. from factories import variable_factory
  18. from models.account import Account
  19. from models.model import App, AppMode, AppModelConfig
  20. from models.workflow import Workflow
  21. from services.plugin.dependencies_analysis import DependenciesAnalysisService
  22. from services.workflow_service import WorkflowService
  23. from .exc import (
  24. ContentDecodingError,
  25. EmptyContentError,
  26. FileSizeLimitExceededError,
  27. InvalidAppModeError,
  28. InvalidYAMLFormatError,
  29. MissingAppDataError,
  30. MissingModelConfigError,
  31. MissingWorkflowDataError,
  32. )
  33. logger = logging.getLogger(__name__)
  34. current_dsl_version = "0.1.3"
  35. class AppDslService:
  36. @classmethod
  37. def import_and_create_new_app_from_url(cls, tenant_id: str, url: str, args: dict, account: Account) -> App:
  38. """
  39. Import app dsl from url and create new app
  40. :param tenant_id: tenant id
  41. :param url: import url
  42. :param args: request args
  43. :param account: Account instance
  44. """
  45. max_size = 10 * 1024 * 1024 # 10MB
  46. response = ssrf_proxy.get(url.strip(), follow_redirects=True, timeout=(10, 10))
  47. response.raise_for_status()
  48. content = response.content
  49. if len(content) > max_size:
  50. raise FileSizeLimitExceededError("File size exceeds the limit of 10MB")
  51. if not content:
  52. raise EmptyContentError("Empty content from url")
  53. try:
  54. data = content.decode("utf-8")
  55. except UnicodeDecodeError as e:
  56. raise ContentDecodingError(f"Error decoding content: {e}")
  57. return cls.import_and_create_new_app(tenant_id, data, args, account)
  58. @classmethod
  59. def check_dependencies(cls, tenant_id: str, data: str, account: Account) -> list[PluginDependency]:
  60. """
  61. Returns the leaked dependencies in current workspace
  62. """
  63. try:
  64. import_data = yaml.safe_load(data) or {}
  65. except yaml.YAMLError:
  66. raise InvalidYAMLFormatError("Invalid YAML format in data argument.")
  67. dependencies = [PluginDependency(**dep) for dep in import_data.get("dependencies", [])]
  68. if not dependencies:
  69. return []
  70. return DependenciesAnalysisService.check_dependencies(tenant_id=tenant_id, dependencies=dependencies)
  71. @classmethod
  72. def import_and_create_new_app(cls, tenant_id: str, data: str, args: dict, account: Account) -> App:
  73. """
  74. Import app dsl and create new app
  75. :param tenant_id: tenant id
  76. :param data: import data
  77. :param args: request args
  78. :param account: Account instance
  79. """
  80. try:
  81. import_data = yaml.safe_load(data)
  82. except yaml.YAMLError:
  83. raise InvalidYAMLFormatError("Invalid YAML format in data argument.")
  84. # check or repair dsl version
  85. import_data = _check_or_fix_dsl(import_data)
  86. app_data = import_data.get("app")
  87. if not app_data:
  88. raise MissingAppDataError("Missing app in data argument")
  89. # get app basic info
  90. name = args.get("name") or app_data.get("name")
  91. description = args.get("description") or app_data.get("description", "")
  92. icon_type = args.get("icon_type") or app_data.get("icon_type")
  93. icon = args.get("icon") or app_data.get("icon")
  94. icon_background = args.get("icon_background") or app_data.get("icon_background")
  95. use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False)
  96. # import dsl and create app
  97. app_mode = AppMode.value_of(app_data.get("mode"))
  98. if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  99. workflow_data = import_data.get("workflow")
  100. if not workflow_data or not isinstance(workflow_data, dict):
  101. raise MissingWorkflowDataError(
  102. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  103. )
  104. app = cls._import_and_create_new_workflow_based_app(
  105. tenant_id=tenant_id,
  106. app_mode=app_mode,
  107. workflow_data=workflow_data,
  108. account=account,
  109. name=name,
  110. description=description,
  111. icon_type=icon_type,
  112. icon=icon,
  113. icon_background=icon_background,
  114. use_icon_as_answer_icon=use_icon_as_answer_icon,
  115. )
  116. elif app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION}:
  117. model_config = import_data.get("model_config")
  118. if not model_config or not isinstance(model_config, dict):
  119. raise MissingModelConfigError(
  120. "Missing model_config in data argument when app mode is chat, agent-chat or completion"
  121. )
  122. app = cls._import_and_create_new_model_config_based_app(
  123. tenant_id=tenant_id,
  124. app_mode=app_mode,
  125. model_config_data=model_config,
  126. account=account,
  127. name=name,
  128. description=description,
  129. icon_type=icon_type,
  130. icon=icon,
  131. icon_background=icon_background,
  132. use_icon_as_answer_icon=use_icon_as_answer_icon,
  133. )
  134. else:
  135. raise InvalidAppModeError("Invalid app mode")
  136. return app
  137. @classmethod
  138. def import_and_overwrite_workflow(cls, app_model: App, data: str, account: Account) -> Workflow:
  139. """
  140. Import app dsl and overwrite workflow
  141. :param app_model: App instance
  142. :param data: import data
  143. :param account: Account instance
  144. """
  145. try:
  146. import_data = yaml.safe_load(data)
  147. except yaml.YAMLError:
  148. raise InvalidYAMLFormatError("Invalid YAML format in data argument.")
  149. # check or repair dsl version
  150. import_data = _check_or_fix_dsl(import_data)
  151. app_data = import_data.get("app")
  152. if not app_data:
  153. raise MissingAppDataError("Missing app in data argument")
  154. # import dsl and overwrite app
  155. app_mode = AppMode.value_of(app_data.get("mode"))
  156. if app_mode not in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  157. raise InvalidAppModeError("Only support import workflow in advanced-chat or workflow app.")
  158. if app_data.get("mode") != app_model.mode:
  159. raise ValueError(f"App mode {app_data.get('mode')} is not matched with current app mode {app_mode.value}")
  160. workflow_data = import_data.get("workflow")
  161. if not workflow_data or not isinstance(workflow_data, dict):
  162. raise MissingWorkflowDataError(
  163. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  164. )
  165. return cls._import_and_overwrite_workflow_based_app(
  166. app_model=app_model,
  167. workflow_data=workflow_data,
  168. account=account,
  169. )
  170. @classmethod
  171. def export_dsl(cls, app_model: App, include_secret: bool = False) -> str:
  172. """
  173. Export app
  174. :param app_model: App instance
  175. :return:
  176. """
  177. app_mode = AppMode.value_of(app_model.mode)
  178. export_data = {
  179. "version": current_dsl_version,
  180. "kind": "app",
  181. "app": {
  182. "name": app_model.name,
  183. "mode": app_model.mode,
  184. "icon": "🤖" if app_model.icon_type == "image" else app_model.icon,
  185. "icon_background": "#FFEAD5" if app_model.icon_type == "image" else app_model.icon_background,
  186. "description": app_model.description,
  187. "use_icon_as_answer_icon": app_model.use_icon_as_answer_icon,
  188. },
  189. }
  190. if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  191. cls._append_workflow_export_data(
  192. export_data=export_data, app_model=app_model, include_secret=include_secret
  193. )
  194. else:
  195. cls._append_model_config_export_data(export_data, app_model)
  196. return yaml.dump(export_data, allow_unicode=True)
  197. @classmethod
  198. def _import_and_create_new_workflow_based_app(
  199. cls,
  200. tenant_id: str,
  201. app_mode: AppMode,
  202. workflow_data: Mapping[str, Any],
  203. account: Account,
  204. name: str,
  205. description: str,
  206. icon_type: str,
  207. icon: str,
  208. icon_background: str,
  209. use_icon_as_answer_icon: bool,
  210. ) -> App:
  211. """
  212. Import app dsl and create new workflow based app
  213. :param tenant_id: tenant id
  214. :param app_mode: app mode
  215. :param workflow_data: workflow data
  216. :param account: Account instance
  217. :param name: app name
  218. :param description: app description
  219. :param icon_type: app icon type, "emoji" or "image"
  220. :param icon: app icon
  221. :param icon_background: app icon background
  222. :param use_icon_as_answer_icon: use app icon as answer icon
  223. """
  224. if not workflow_data:
  225. raise MissingWorkflowDataError(
  226. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  227. )
  228. app = cls._create_app(
  229. tenant_id=tenant_id,
  230. app_mode=app_mode,
  231. account=account,
  232. name=name,
  233. description=description,
  234. icon_type=icon_type,
  235. icon=icon,
  236. icon_background=icon_background,
  237. use_icon_as_answer_icon=use_icon_as_answer_icon,
  238. )
  239. # init draft workflow
  240. environment_variables_list = workflow_data.get("environment_variables") or []
  241. environment_variables = [
  242. variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list
  243. ]
  244. conversation_variables_list = workflow_data.get("conversation_variables") or []
  245. conversation_variables = [
  246. variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list
  247. ]
  248. workflow_service = WorkflowService()
  249. draft_workflow = workflow_service.sync_draft_workflow(
  250. app_model=app,
  251. graph=workflow_data.get("graph", {}),
  252. features=workflow_data.get("features", {}),
  253. unique_hash=None,
  254. account=account,
  255. environment_variables=environment_variables,
  256. conversation_variables=conversation_variables,
  257. )
  258. workflow_service.publish_workflow(app_model=app, account=account, draft_workflow=draft_workflow)
  259. return app
  260. @classmethod
  261. def _import_and_overwrite_workflow_based_app(
  262. cls, app_model: App, workflow_data: Mapping[str, Any], account: Account
  263. ) -> Workflow:
  264. """
  265. Import app dsl and overwrite workflow based app
  266. :param app_model: App instance
  267. :param workflow_data: workflow data
  268. :param account: Account instance
  269. """
  270. if not workflow_data:
  271. raise MissingWorkflowDataError(
  272. "Missing workflow in data argument when app mode is advanced-chat or workflow"
  273. )
  274. # fetch draft workflow by app_model
  275. workflow_service = WorkflowService()
  276. current_draft_workflow = workflow_service.get_draft_workflow(app_model=app_model)
  277. if current_draft_workflow:
  278. unique_hash = current_draft_workflow.unique_hash
  279. else:
  280. unique_hash = None
  281. # sync draft workflow
  282. environment_variables_list = workflow_data.get("environment_variables") or []
  283. environment_variables = [
  284. variable_factory.build_variable_from_mapping(obj) for obj in environment_variables_list
  285. ]
  286. conversation_variables_list = workflow_data.get("conversation_variables") or []
  287. conversation_variables = [
  288. variable_factory.build_variable_from_mapping(obj) for obj in conversation_variables_list
  289. ]
  290. draft_workflow = workflow_service.sync_draft_workflow(
  291. app_model=app_model,
  292. graph=workflow_data.get("graph", {}),
  293. features=workflow_data.get("features", {}),
  294. unique_hash=unique_hash,
  295. account=account,
  296. environment_variables=environment_variables,
  297. conversation_variables=conversation_variables,
  298. )
  299. return draft_workflow
  300. @classmethod
  301. def _import_and_create_new_model_config_based_app(
  302. cls,
  303. tenant_id: str,
  304. app_mode: AppMode,
  305. model_config_data: Mapping[str, Any],
  306. account: Account,
  307. name: str,
  308. description: str,
  309. icon_type: str,
  310. icon: str,
  311. icon_background: str,
  312. use_icon_as_answer_icon: bool,
  313. ) -> App:
  314. """
  315. Import app dsl and create new model config based app
  316. :param tenant_id: tenant id
  317. :param app_mode: app mode
  318. :param model_config_data: model config data
  319. :param account: Account instance
  320. :param name: app name
  321. :param description: app description
  322. :param icon: app icon
  323. :param icon_background: app icon background
  324. """
  325. if not model_config_data:
  326. raise MissingModelConfigError(
  327. "Missing model_config in data argument when app mode is chat, agent-chat or completion"
  328. )
  329. app = cls._create_app(
  330. tenant_id=tenant_id,
  331. app_mode=app_mode,
  332. account=account,
  333. name=name,
  334. description=description,
  335. icon_type=icon_type,
  336. icon=icon,
  337. icon_background=icon_background,
  338. use_icon_as_answer_icon=use_icon_as_answer_icon,
  339. )
  340. app_model_config = AppModelConfig()
  341. app_model_config = app_model_config.from_model_config_dict(model_config_data)
  342. app_model_config.app_id = app.id
  343. app_model_config.created_by = account.id
  344. app_model_config.updated_by = account.id
  345. db.session.add(app_model_config)
  346. db.session.commit()
  347. app.app_model_config_id = app_model_config.id
  348. app_model_config_was_updated.send(app, app_model_config=app_model_config)
  349. return app
  350. @classmethod
  351. def _create_app(
  352. cls,
  353. tenant_id: str,
  354. app_mode: AppMode,
  355. account: Account,
  356. name: str,
  357. description: str,
  358. icon_type: str,
  359. icon: str,
  360. icon_background: str,
  361. use_icon_as_answer_icon: bool,
  362. ) -> App:
  363. """
  364. Create new app
  365. :param tenant_id: tenant id
  366. :param app_mode: app mode
  367. :param account: Account instance
  368. :param name: app name
  369. :param description: app description
  370. :param icon_type: app icon type, "emoji" or "image"
  371. :param icon: app icon
  372. :param icon_background: app icon background
  373. :param use_icon_as_answer_icon: use app icon as answer icon
  374. """
  375. app = App(
  376. tenant_id=tenant_id,
  377. mode=app_mode.value,
  378. name=name,
  379. description=description,
  380. icon_type=icon_type,
  381. icon=icon,
  382. icon_background=icon_background,
  383. enable_site=True,
  384. enable_api=True,
  385. use_icon_as_answer_icon=use_icon_as_answer_icon,
  386. created_by=account.id,
  387. updated_by=account.id,
  388. )
  389. db.session.add(app)
  390. db.session.commit()
  391. app_was_created.send(app, account=account)
  392. return app
  393. @classmethod
  394. def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None:
  395. """
  396. Append workflow export data
  397. :param export_data: export data
  398. :param app_model: App instance
  399. """
  400. workflow_service = WorkflowService()
  401. workflow = workflow_service.get_draft_workflow(app_model)
  402. if not workflow:
  403. raise ValueError("Missing draft workflow configuration, please check.")
  404. export_data["workflow"] = workflow.to_dict(include_secret=include_secret)
  405. dependencies = cls._extract_dependencies_from_workflow(workflow)
  406. export_data["dependencies"] = [
  407. jsonable_encoder(d.model_dump())
  408. for d in DependenciesAnalysisService.generate_dependencies(
  409. tenant_id=app_model.tenant_id, dependencies=dependencies
  410. )
  411. ]
  412. @classmethod
  413. def _append_model_config_export_data(cls, export_data: dict, app_model: App) -> None:
  414. """
  415. Append model config export data
  416. :param export_data: export data
  417. :param app_model: App instance
  418. """
  419. app_model_config = app_model.app_model_config
  420. if not app_model_config:
  421. raise ValueError("Missing app configuration, please check.")
  422. export_data["model_config"] = app_model_config.to_dict()
  423. dependencies = cls._extract_dependencies_from_model_config(app_model_config)
  424. export_data["dependencies"] = [
  425. jsonable_encoder(d.model_dump())
  426. for d in DependenciesAnalysisService.generate_dependencies(
  427. tenant_id=app_model.tenant_id, dependencies=dependencies
  428. )
  429. ]
  430. @classmethod
  431. def _extract_dependencies_from_workflow(cls, workflow: Workflow) -> list[str]:
  432. """
  433. Extract dependencies from workflow
  434. :param workflow: Workflow instance
  435. :return: dependencies list format like ["langgenius/google"]
  436. """
  437. graph = workflow.graph_dict
  438. dependencies = []
  439. for node in graph.get("nodes", []):
  440. try:
  441. typ = node.get("data", {}).get("type")
  442. match typ:
  443. case NodeType.TOOL.value:
  444. tool_entity = ToolNodeData(**node["data"])
  445. dependencies.append(
  446. DependenciesAnalysisService.analyze_tool_dependency(tool_entity.provider_id),
  447. )
  448. case NodeType.LLM.value:
  449. llm_entity = LLMNodeData(**node["data"])
  450. dependencies.append(
  451. DependenciesAnalysisService.analyze_model_provider_dependency(llm_entity.model.provider),
  452. )
  453. case NodeType.QUESTION_CLASSIFIER.value:
  454. question_classifier_entity = QuestionClassifierNodeData(**node["data"])
  455. dependencies.append(
  456. DependenciesAnalysisService.analyze_model_provider_dependency(
  457. question_classifier_entity.model.provider
  458. ),
  459. )
  460. case NodeType.PARAMETER_EXTRACTOR.value:
  461. parameter_extractor_entity = ParameterExtractorNodeData(**node["data"])
  462. dependencies.append(
  463. DependenciesAnalysisService.analyze_model_provider_dependency(
  464. parameter_extractor_entity.model.provider
  465. ),
  466. )
  467. case NodeType.KNOWLEDGE_RETRIEVAL.value:
  468. knowledge_retrieval_entity = KnowledgeRetrievalNodeData(**node["data"])
  469. if knowledge_retrieval_entity.retrieval_mode == "multiple":
  470. if knowledge_retrieval_entity.multiple_retrieval_config:
  471. if (
  472. knowledge_retrieval_entity.multiple_retrieval_config.reranking_mode
  473. == "reranking_model"
  474. ):
  475. if knowledge_retrieval_entity.multiple_retrieval_config.reranking_model:
  476. dependencies.append(
  477. DependenciesAnalysisService.analyze_model_provider_dependency(
  478. knowledge_retrieval_entity.multiple_retrieval_config.reranking_model.provider
  479. ),
  480. )
  481. elif (
  482. knowledge_retrieval_entity.multiple_retrieval_config.reranking_mode
  483. == "weighted_score"
  484. ):
  485. if knowledge_retrieval_entity.multiple_retrieval_config.weights:
  486. vector_setting = (
  487. knowledge_retrieval_entity.multiple_retrieval_config.weights.vector_setting
  488. )
  489. dependencies.append(
  490. DependenciesAnalysisService.analyze_model_provider_dependency(
  491. vector_setting.embedding_provider_name
  492. ),
  493. )
  494. elif knowledge_retrieval_entity.retrieval_mode == "single":
  495. model_config = knowledge_retrieval_entity.single_retrieval_config
  496. if model_config:
  497. dependencies.append(
  498. DependenciesAnalysisService.analyze_model_provider_dependency(
  499. model_config.model.provider
  500. ),
  501. )
  502. case _:
  503. # Handle default case or unknown node types
  504. pass
  505. except Exception as e:
  506. logger.exception("Error extracting node dependency", exc_info=e)
  507. return dependencies
  508. @classmethod
  509. def _extract_dependencies_from_model_config(cls, model_config: AppModelConfig) -> list[str]:
  510. """
  511. Extract dependencies from model config
  512. :param model_config: AppModelConfig instance
  513. :return: dependencies list format like ["langgenius/google:1.0.0@abcdef1234567890"]
  514. """
  515. dependencies = []
  516. try:
  517. # completion model
  518. model_dict = model_config.model_dict
  519. if model_dict:
  520. dependencies.append(
  521. DependenciesAnalysisService.analyze_model_provider_dependency(model_dict.get("provider"))
  522. )
  523. # reranking model
  524. dataset_configs = model_config.dataset_configs_dict
  525. if dataset_configs:
  526. for dataset_config in dataset_configs:
  527. if dataset_config.get("reranking_model"):
  528. dependencies.append(
  529. DependenciesAnalysisService.analyze_model_provider_dependency(
  530. dataset_config.get("reranking_model", {})
  531. .get("reranking_provider_name", {})
  532. .get("provider")
  533. )
  534. )
  535. # tools
  536. agent_configs = model_config.agent_mode_dict
  537. if agent_configs:
  538. for agent_config in agent_configs:
  539. if agent_config.get("tools"):
  540. for tool in agent_config.get("tools", []):
  541. dependencies.append(
  542. DependenciesAnalysisService.analyze_tool_dependency(tool.get("provider_id"))
  543. )
  544. except Exception as e:
  545. logger.exception("Error extracting model config dependency", exc_info=e)
  546. return dependencies
  547. def _check_or_fix_dsl(import_data: dict[str, Any]) -> Mapping[str, Any]:
  548. """
  549. Check or fix dsl
  550. :param import_data: import data
  551. :raises DSLVersionNotSupportedError: if the imported DSL version is newer than the current version
  552. """
  553. if not import_data.get("version"):
  554. import_data["version"] = "0.1.0"
  555. if not import_data.get("kind") or import_data.get("kind") != "app":
  556. import_data["kind"] = "app"
  557. imported_version = import_data.get("version")
  558. if imported_version != current_dsl_version:
  559. if imported_version and version.parse(imported_version) > version.parse(current_dsl_version):
  560. errmsg = (
  561. f"The imported DSL version {imported_version} is newer than "
  562. f"the current supported version {current_dsl_version}. "
  563. f"Please upgrade your Dify instance to import this configuration."
  564. )
  565. logger.warning(errmsg)
  566. # raise DSLVersionNotSupportedError(errmsg)
  567. else:
  568. logger.warning(
  569. f"DSL version {imported_version} is older than "
  570. f"the current version {current_dsl_version}. "
  571. f"This may cause compatibility issues."
  572. )
  573. return import_data