app_dsl_service.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. import logging
  2. import uuid
  3. from enum import StrEnum
  4. from typing import Optional, cast
  5. from uuid import uuid4
  6. import yaml # type: ignore
  7. from packaging import version
  8. from pydantic import BaseModel
  9. from sqlalchemy import select
  10. from sqlalchemy.orm import Session
  11. from core.helper import ssrf_proxy
  12. from events.app_event import app_model_config_was_updated, app_was_created
  13. from extensions.ext_redis import redis_client
  14. from factories import variable_factory
  15. from models import Account, App, AppMode
  16. from models.model import AppModelConfig
  17. from services.workflow_service import WorkflowService
  18. logger = logging.getLogger(__name__)
  19. IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
  20. IMPORT_INFO_REDIS_EXPIRY = 180 # 3 minutes
  21. CURRENT_DSL_VERSION = "0.1.5"
  22. class ImportMode(StrEnum):
  23. YAML_CONTENT = "yaml-content"
  24. YAML_URL = "yaml-url"
  25. class ImportStatus(StrEnum):
  26. COMPLETED = "completed"
  27. COMPLETED_WITH_WARNINGS = "completed-with-warnings"
  28. PENDING = "pending"
  29. FAILED = "failed"
  30. class Import(BaseModel):
  31. id: str
  32. status: ImportStatus
  33. app_id: Optional[str] = None
  34. current_dsl_version: str = CURRENT_DSL_VERSION
  35. imported_dsl_version: str = ""
  36. error: str = ""
  37. def _check_version_compatibility(imported_version: str) -> ImportStatus:
  38. """Determine import status based on version comparison"""
  39. try:
  40. current_ver = version.parse(CURRENT_DSL_VERSION)
  41. imported_ver = version.parse(imported_version)
  42. except version.InvalidVersion:
  43. return ImportStatus.FAILED
  44. # Compare major version and minor version
  45. if current_ver.major != imported_ver.major or current_ver.minor != imported_ver.minor:
  46. return ImportStatus.PENDING
  47. if current_ver.micro != imported_ver.micro:
  48. return ImportStatus.COMPLETED_WITH_WARNINGS
  49. return ImportStatus.COMPLETED
  50. class PendingData(BaseModel):
  51. import_mode: str
  52. yaml_content: str
  53. name: str | None
  54. description: str | None
  55. icon_type: str | None
  56. icon: str | None
  57. icon_background: str | None
  58. app_id: str | None
  59. class AppDslService:
  60. def __init__(self, session: Session):
  61. self._session = session
  62. def import_app(
  63. self,
  64. *,
  65. account: Account,
  66. import_mode: str,
  67. yaml_content: Optional[str] = None,
  68. yaml_url: Optional[str] = None,
  69. name: Optional[str] = None,
  70. description: Optional[str] = None,
  71. icon_type: Optional[str] = None,
  72. icon: Optional[str] = None,
  73. icon_background: Optional[str] = None,
  74. app_id: Optional[str] = None,
  75. ) -> Import:
  76. """Import an app from YAML content or URL."""
  77. import_id = str(uuid.uuid4())
  78. # Validate import mode
  79. try:
  80. mode = ImportMode(import_mode)
  81. except ValueError:
  82. raise ValueError(f"Invalid import_mode: {import_mode}")
  83. # Get YAML content
  84. content: bytes | str = b""
  85. if mode == ImportMode.YAML_URL:
  86. if not yaml_url:
  87. return Import(
  88. id=import_id,
  89. status=ImportStatus.FAILED,
  90. error="yaml_url is required when import_mode is yaml-url",
  91. )
  92. try:
  93. max_size = 10 * 1024 * 1024 # 10MB
  94. # tricky way to handle url from github to github raw url
  95. if yaml_url.startswith("https://github.com") and yaml_url.endswith((".yml", ".yaml")):
  96. yaml_url = yaml_url.replace("https://github.com", "https://raw.githubusercontent.com")
  97. yaml_url = yaml_url.replace("/blob/", "/")
  98. response = ssrf_proxy.get(yaml_url.strip(), follow_redirects=True, timeout=(10, 10))
  99. response.raise_for_status()
  100. content = response.content
  101. if len(content) > max_size:
  102. return Import(
  103. id=import_id,
  104. status=ImportStatus.FAILED,
  105. error="File size exceeds the limit of 10MB",
  106. )
  107. if not content:
  108. return Import(
  109. id=import_id,
  110. status=ImportStatus.FAILED,
  111. error="Empty content from url",
  112. )
  113. try:
  114. content = cast(bytes, content).decode("utf-8")
  115. except UnicodeDecodeError as e:
  116. return Import(
  117. id=import_id,
  118. status=ImportStatus.FAILED,
  119. error=f"Error decoding content: {e}",
  120. )
  121. except Exception as e:
  122. return Import(
  123. id=import_id,
  124. status=ImportStatus.FAILED,
  125. error=f"Error fetching YAML from URL: {str(e)}",
  126. )
  127. elif mode == ImportMode.YAML_CONTENT:
  128. if not yaml_content:
  129. return Import(
  130. id=import_id,
  131. status=ImportStatus.FAILED,
  132. error="yaml_content is required when import_mode is yaml-content",
  133. )
  134. content = yaml_content
  135. # Process YAML content
  136. try:
  137. # Parse YAML to validate format
  138. data = yaml.safe_load(content)
  139. if not isinstance(data, dict):
  140. return Import(
  141. id=import_id,
  142. status=ImportStatus.FAILED,
  143. error="Invalid YAML format: content must be a mapping",
  144. )
  145. # Validate and fix DSL version
  146. if not data.get("version"):
  147. data["version"] = "0.1.0"
  148. if not data.get("kind") or data.get("kind") != "app":
  149. data["kind"] = "app"
  150. imported_version = data.get("version", "0.1.0")
  151. # check if imported_version is a float-like string
  152. if not isinstance(imported_version, str):
  153. raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}")
  154. status = _check_version_compatibility(imported_version)
  155. # Extract app data
  156. app_data = data.get("app")
  157. if not app_data:
  158. return Import(
  159. id=import_id,
  160. status=ImportStatus.FAILED,
  161. error="Missing app data in YAML content",
  162. )
  163. # If app_id is provided, check if it exists
  164. app = None
  165. if app_id:
  166. stmt = select(App).where(App.id == app_id, App.tenant_id == account.current_tenant_id)
  167. app = self._session.scalar(stmt)
  168. if not app:
  169. return Import(
  170. id=import_id,
  171. status=ImportStatus.FAILED,
  172. error="App not found",
  173. )
  174. if app.mode not in [AppMode.WORKFLOW.value, AppMode.ADVANCED_CHAT.value]:
  175. return Import(
  176. id=import_id,
  177. status=ImportStatus.FAILED,
  178. error="Only workflow or advanced chat apps can be overwritten",
  179. )
  180. # If major version mismatch, store import info in Redis
  181. if status == ImportStatus.PENDING:
  182. panding_data = PendingData(
  183. import_mode=import_mode,
  184. yaml_content=content,
  185. name=name,
  186. description=description,
  187. icon_type=icon_type,
  188. icon=icon,
  189. icon_background=icon_background,
  190. app_id=app_id,
  191. )
  192. redis_client.setex(
  193. f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}",
  194. IMPORT_INFO_REDIS_EXPIRY,
  195. panding_data.model_dump_json(),
  196. )
  197. return Import(
  198. id=import_id,
  199. status=status,
  200. app_id=app_id,
  201. imported_dsl_version=imported_version,
  202. )
  203. # Create or update app
  204. app = self._create_or_update_app(
  205. app=app,
  206. data=data,
  207. account=account,
  208. name=name,
  209. description=description,
  210. icon_type=icon_type,
  211. icon=icon,
  212. icon_background=icon_background,
  213. )
  214. return Import(
  215. id=import_id,
  216. status=status,
  217. app_id=app.id,
  218. imported_dsl_version=imported_version,
  219. )
  220. except yaml.YAMLError as e:
  221. return Import(
  222. id=import_id,
  223. status=ImportStatus.FAILED,
  224. error=f"Invalid YAML format: {str(e)}",
  225. )
  226. except Exception as e:
  227. logger.exception("Failed to import app")
  228. return Import(
  229. id=import_id,
  230. status=ImportStatus.FAILED,
  231. error=str(e),
  232. )
  233. def confirm_import(self, *, import_id: str, account: Account) -> Import:
  234. """
  235. Confirm an import that requires confirmation
  236. """
  237. redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
  238. pending_data = redis_client.get(redis_key)
  239. if not pending_data:
  240. return Import(
  241. id=import_id,
  242. status=ImportStatus.FAILED,
  243. error="Import information expired or does not exist",
  244. )
  245. try:
  246. if not isinstance(pending_data, str | bytes):
  247. return Import(
  248. id=import_id,
  249. status=ImportStatus.FAILED,
  250. error="Invalid import information",
  251. )
  252. pending_data = PendingData.model_validate_json(pending_data)
  253. data = yaml.safe_load(pending_data.yaml_content)
  254. app = None
  255. if pending_data.app_id:
  256. stmt = select(App).where(App.id == pending_data.app_id, App.tenant_id == account.current_tenant_id)
  257. app = self._session.scalar(stmt)
  258. # Create or update app
  259. app = self._create_or_update_app(
  260. app=app,
  261. data=data,
  262. account=account,
  263. name=pending_data.name,
  264. description=pending_data.description,
  265. icon_type=pending_data.icon_type,
  266. icon=pending_data.icon,
  267. icon_background=pending_data.icon_background,
  268. )
  269. # Delete import info from Redis
  270. redis_client.delete(redis_key)
  271. return Import(
  272. id=import_id,
  273. status=ImportStatus.COMPLETED,
  274. app_id=app.id,
  275. current_dsl_version=CURRENT_DSL_VERSION,
  276. imported_dsl_version=data.get("version", "0.1.0"),
  277. )
  278. except Exception as e:
  279. logger.exception("Error confirming import")
  280. return Import(
  281. id=import_id,
  282. status=ImportStatus.FAILED,
  283. error=str(e),
  284. )
  285. def _create_or_update_app(
  286. self,
  287. *,
  288. app: Optional[App],
  289. data: dict,
  290. account: Account,
  291. name: Optional[str] = None,
  292. description: Optional[str] = None,
  293. icon_type: Optional[str] = None,
  294. icon: Optional[str] = None,
  295. icon_background: Optional[str] = None,
  296. ) -> App:
  297. """Create a new app or update an existing one."""
  298. app_data = data.get("app", {})
  299. app_mode = app_data.get("mode")
  300. if not app_mode:
  301. raise ValueError("loss app mode")
  302. app_mode = AppMode(app_mode)
  303. # Set icon type
  304. icon_type_value = icon_type or app_data.get("icon_type")
  305. if icon_type_value in ["emoji", "link"]:
  306. icon_type = icon_type_value
  307. else:
  308. icon_type = "emoji"
  309. icon = icon or str(app_data.get("icon", ""))
  310. if app:
  311. # Update existing app
  312. app.name = name or app_data.get("name", app.name)
  313. app.description = description or app_data.get("description", app.description)
  314. app.icon_type = icon_type
  315. app.icon = icon
  316. app.icon_background = icon_background or app_data.get("icon_background", app.icon_background)
  317. app.updated_by = account.id
  318. else:
  319. if account.current_tenant_id is None:
  320. raise ValueError("Current tenant is not set")
  321. # Create new app
  322. app = App()
  323. app.id = str(uuid4())
  324. app.tenant_id = account.current_tenant_id
  325. app.mode = app_mode.value
  326. app.name = name or app_data.get("name", "")
  327. app.description = description or app_data.get("description", "")
  328. app.icon_type = icon_type
  329. app.icon = icon
  330. app.icon_background = icon_background or app_data.get("icon_background", "#FFFFFF")
  331. app.enable_site = True
  332. app.enable_api = True
  333. app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False)
  334. app.created_by = account.id
  335. app.updated_by = account.id
  336. self._session.add(app)
  337. self._session.commit()
  338. app_was_created.send(app, account=account)
  339. # Initialize app based on mode
  340. if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  341. workflow_data = data.get("workflow")
  342. if not workflow_data or not isinstance(workflow_data, dict):
  343. raise ValueError("Missing workflow data for workflow/advanced chat app")
  344. environment_variables_list = workflow_data.get("environment_variables", [])
  345. environment_variables = [
  346. variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
  347. ]
  348. conversation_variables_list = workflow_data.get("conversation_variables", [])
  349. conversation_variables = [
  350. variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
  351. ]
  352. workflow_service = WorkflowService()
  353. current_draft_workflow = workflow_service.get_draft_workflow(app_model=app)
  354. if current_draft_workflow:
  355. unique_hash = current_draft_workflow.unique_hash
  356. else:
  357. unique_hash = None
  358. workflow_service.sync_draft_workflow(
  359. app_model=app,
  360. graph=workflow_data.get("graph", {}),
  361. features=workflow_data.get("features", {}),
  362. unique_hash=unique_hash,
  363. account=account,
  364. environment_variables=environment_variables,
  365. conversation_variables=conversation_variables,
  366. )
  367. elif app_mode in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION}:
  368. # Initialize model config
  369. model_config = data.get("model_config")
  370. if not model_config or not isinstance(model_config, dict):
  371. raise ValueError("Missing model_config for chat/agent-chat/completion app")
  372. # Initialize or update model config
  373. if not app.app_model_config:
  374. app_model_config = AppModelConfig().from_model_config_dict(model_config)
  375. app_model_config.id = str(uuid4())
  376. app_model_config.app_id = app.id
  377. app_model_config.created_by = account.id
  378. app_model_config.updated_by = account.id
  379. app.app_model_config_id = app_model_config.id
  380. self._session.add(app_model_config)
  381. app_model_config_was_updated.send(app, app_model_config=app_model_config)
  382. else:
  383. raise ValueError("Invalid app mode")
  384. return app
  385. @classmethod
  386. def export_dsl(cls, app_model: App, include_secret: bool = False) -> str:
  387. """
  388. Export app
  389. :param app_model: App instance
  390. :return:
  391. """
  392. app_mode = AppMode.value_of(app_model.mode)
  393. export_data = {
  394. "version": CURRENT_DSL_VERSION,
  395. "kind": "app",
  396. "app": {
  397. "name": app_model.name,
  398. "mode": app_model.mode,
  399. "icon": "🤖" if app_model.icon_type == "image" else app_model.icon,
  400. "icon_background": "#FFEAD5" if app_model.icon_type == "image" else app_model.icon_background,
  401. "description": app_model.description,
  402. "use_icon_as_answer_icon": app_model.use_icon_as_answer_icon,
  403. },
  404. }
  405. if app_mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
  406. cls._append_workflow_export_data(
  407. export_data=export_data, app_model=app_model, include_secret=include_secret
  408. )
  409. else:
  410. cls._append_model_config_export_data(export_data, app_model)
  411. return yaml.dump(export_data, allow_unicode=True) # type: ignore
  412. @classmethod
  413. def _append_workflow_export_data(cls, *, export_data: dict, app_model: App, include_secret: bool) -> None:
  414. """
  415. Append workflow export data
  416. :param export_data: export data
  417. :param app_model: App instance
  418. """
  419. workflow_service = WorkflowService()
  420. workflow = workflow_service.get_draft_workflow(app_model)
  421. if not workflow:
  422. raise ValueError("Missing draft workflow configuration, please check.")
  423. export_data["workflow"] = workflow.to_dict(include_secret=include_secret)
  424. @classmethod
  425. def _append_model_config_export_data(cls, export_data: dict, app_model: App) -> None:
  426. """
  427. Append model config export data
  428. :param export_data: export data
  429. :param app_model: App instance
  430. """
  431. app_model_config = app_model.app_model_config
  432. if not app_model_config:
  433. raise ValueError("Missing app configuration, please check.")
  434. export_data["model_config"] = app_model_config.to_dict()