You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

workflow_draft_variable.py 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import logging
  2. from typing import Any, NoReturn
  3. from flask import Response
  4. from flask_restx import Resource, fields, inputs, marshal, marshal_with, reqparse
  5. from sqlalchemy.orm import Session
  6. from werkzeug.exceptions import Forbidden
  7. from controllers.console import api
  8. from controllers.console.app.error import (
  9. DraftWorkflowNotExist,
  10. )
  11. from controllers.console.app.wraps import get_app_model
  12. from controllers.console.wraps import account_initialization_required, setup_required
  13. from controllers.web.error import InvalidArgumentError, NotFoundError
  14. from core.variables.segment_group import SegmentGroup
  15. from core.variables.segments import ArrayFileSegment, FileSegment, Segment
  16. from core.variables.types import SegmentType
  17. from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
  18. from factories.file_factory import build_from_mapping, build_from_mappings
  19. from factories.variable_factory import build_segment_with_type
  20. from libs.login import current_user, login_required
  21. from models import App, AppMode, db
  22. from models.account import Account
  23. from models.workflow import WorkflowDraftVariable
  24. from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
  25. from services.workflow_service import WorkflowService
  26. logger = logging.getLogger(__name__)
  27. def _convert_values_to_json_serializable_object(value: Segment) -> Any:
  28. if isinstance(value, FileSegment):
  29. return value.value.model_dump()
  30. elif isinstance(value, ArrayFileSegment):
  31. return [i.model_dump() for i in value.value]
  32. elif isinstance(value, SegmentGroup):
  33. return [_convert_values_to_json_serializable_object(i) for i in value.value]
  34. else:
  35. return value.value
  36. def _serialize_var_value(variable: WorkflowDraftVariable) -> Any:
  37. value = variable.get_value()
  38. # create a copy of the value to avoid affecting the model cache.
  39. value = value.model_copy(deep=True)
  40. # Refresh the url signature before returning it to client.
  41. if isinstance(value, FileSegment):
  42. file = value.value
  43. file.remote_url = file.generate_url()
  44. elif isinstance(value, ArrayFileSegment):
  45. files = value.value
  46. for file in files:
  47. file.remote_url = file.generate_url()
  48. return _convert_values_to_json_serializable_object(value)
  49. def _create_pagination_parser():
  50. parser = reqparse.RequestParser()
  51. parser.add_argument(
  52. "page",
  53. type=inputs.int_range(1, 100_000),
  54. required=False,
  55. default=1,
  56. location="args",
  57. help="the page of data requested",
  58. )
  59. parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
  60. return parser
  61. def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str:
  62. value_type = workflow_draft_var.value_type
  63. return value_type.exposed_type().value
  64. _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS = {
  65. "id": fields.String,
  66. "type": fields.String(attribute=lambda model: model.get_variable_type()),
  67. "name": fields.String,
  68. "description": fields.String,
  69. "selector": fields.List(fields.String, attribute=lambda model: model.get_selector()),
  70. "value_type": fields.String(attribute=_serialize_variable_type),
  71. "edited": fields.Boolean(attribute=lambda model: model.edited),
  72. "visible": fields.Boolean,
  73. }
  74. _WORKFLOW_DRAFT_VARIABLE_FIELDS = dict(
  75. _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS,
  76. value=fields.Raw(attribute=_serialize_var_value),
  77. )
  78. _WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS = {
  79. "id": fields.String,
  80. "type": fields.String(attribute=lambda _: "env"),
  81. "name": fields.String,
  82. "description": fields.String,
  83. "selector": fields.List(fields.String, attribute=lambda model: model.get_selector()),
  84. "value_type": fields.String(attribute=_serialize_variable_type),
  85. "edited": fields.Boolean(attribute=lambda model: model.edited),
  86. "visible": fields.Boolean,
  87. }
  88. _WORKFLOW_DRAFT_ENV_VARIABLE_LIST_FIELDS = {
  89. "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS)),
  90. }
  91. def _get_items(var_list: WorkflowDraftVariableList) -> list[WorkflowDraftVariable]:
  92. return var_list.variables
  93. _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS = {
  94. "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS), attribute=_get_items),
  95. "total": fields.Raw(),
  96. }
  97. _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = {
  98. "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_FIELDS), attribute=_get_items),
  99. }
  100. def _api_prerequisite(f):
  101. """Common prerequisites for all draft workflow variable APIs.
  102. It ensures the following conditions are satisfied:
  103. - Dify has been property setup.
  104. - The request user has logged in and initialized.
  105. - The requested app is a workflow or a chat flow.
  106. - The request user has the edit permission for the app.
  107. """
  108. @setup_required
  109. @login_required
  110. @account_initialization_required
  111. @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
  112. def wrapper(*args, **kwargs):
  113. assert isinstance(current_user, Account)
  114. if not current_user.is_editor:
  115. raise Forbidden()
  116. return f(*args, **kwargs)
  117. return wrapper
  118. class WorkflowVariableCollectionApi(Resource):
  119. @_api_prerequisite
  120. @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS)
  121. def get(self, app_model: App):
  122. """
  123. Get draft workflow
  124. """
  125. parser = _create_pagination_parser()
  126. args = parser.parse_args()
  127. # fetch draft workflow by app_model
  128. workflow_service = WorkflowService()
  129. workflow_exist = workflow_service.is_workflow_exist(app_model=app_model)
  130. if not workflow_exist:
  131. raise DraftWorkflowNotExist()
  132. # fetch draft workflow by app_model
  133. with Session(bind=db.engine, expire_on_commit=False) as session:
  134. draft_var_srv = WorkflowDraftVariableService(
  135. session=session,
  136. )
  137. workflow_vars = draft_var_srv.list_variables_without_values(
  138. app_id=app_model.id,
  139. page=args.page,
  140. limit=args.limit,
  141. )
  142. return workflow_vars
  143. @_api_prerequisite
  144. def delete(self, app_model: App):
  145. draft_var_srv = WorkflowDraftVariableService(
  146. session=db.session(),
  147. )
  148. draft_var_srv.delete_workflow_variables(app_model.id)
  149. db.session.commit()
  150. return Response("", 204)
  151. def validate_node_id(node_id: str) -> NoReturn | None:
  152. if node_id in [
  153. CONVERSATION_VARIABLE_NODE_ID,
  154. SYSTEM_VARIABLE_NODE_ID,
  155. ]:
  156. # NOTE(QuantumGhost): While we store the system and conversation variables as node variables
  157. # with specific `node_id` in database, we still want to make the API separated. By disallowing
  158. # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`,
  159. # we mitigate the risk that user of the API depending on the implementation detail of the API.
  160. #
  161. # ref: [Hyrum's Law](https://www.hyrumslaw.com/)
  162. raise InvalidArgumentError(
  163. f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}",
  164. )
  165. return None
  166. class NodeVariableCollectionApi(Resource):
  167. @_api_prerequisite
  168. @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
  169. def get(self, app_model: App, node_id: str):
  170. validate_node_id(node_id)
  171. with Session(bind=db.engine, expire_on_commit=False) as session:
  172. draft_var_srv = WorkflowDraftVariableService(
  173. session=session,
  174. )
  175. node_vars = draft_var_srv.list_node_variables(app_model.id, node_id)
  176. return node_vars
  177. @_api_prerequisite
  178. def delete(self, app_model: App, node_id: str):
  179. validate_node_id(node_id)
  180. srv = WorkflowDraftVariableService(db.session())
  181. srv.delete_node_variables(app_model.id, node_id)
  182. db.session.commit()
  183. return Response("", 204)
  184. class VariableApi(Resource):
  185. _PATCH_NAME_FIELD = "name"
  186. _PATCH_VALUE_FIELD = "value"
  187. @_api_prerequisite
  188. @marshal_with(_WORKFLOW_DRAFT_VARIABLE_FIELDS)
  189. def get(self, app_model: App, variable_id: str):
  190. draft_var_srv = WorkflowDraftVariableService(
  191. session=db.session(),
  192. )
  193. variable = draft_var_srv.get_variable(variable_id=variable_id)
  194. if variable is None:
  195. raise NotFoundError(description=f"variable not found, id={variable_id}")
  196. if variable.app_id != app_model.id:
  197. raise NotFoundError(description=f"variable not found, id={variable_id}")
  198. return variable
  199. @_api_prerequisite
  200. @marshal_with(_WORKFLOW_DRAFT_VARIABLE_FIELDS)
  201. def patch(self, app_model: App, variable_id: str):
  202. # Request payload for file types:
  203. #
  204. # Local File:
  205. #
  206. # {
  207. # "type": "image",
  208. # "transfer_method": "local_file",
  209. # "url": "",
  210. # "upload_file_id": "daded54f-72c7-4f8e-9d18-9b0abdd9f190"
  211. # }
  212. #
  213. # Remote File:
  214. #
  215. #
  216. # {
  217. # "type": "image",
  218. # "transfer_method": "remote_url",
  219. # "url": "http://127.0.0.1:5001/files/1602650a-4fe4-423c-85a2-af76c083e3c4/file-preview?timestamp=1750041099&nonce=...&sign=...=",
  220. # "upload_file_id": "1602650a-4fe4-423c-85a2-af76c083e3c4"
  221. # }
  222. parser = reqparse.RequestParser()
  223. parser.add_argument(self._PATCH_NAME_FIELD, type=str, required=False, nullable=True, location="json")
  224. # Parse 'value' field as-is to maintain its original data structure
  225. parser.add_argument(self._PATCH_VALUE_FIELD, type=lambda x: x, required=False, nullable=True, location="json")
  226. draft_var_srv = WorkflowDraftVariableService(
  227. session=db.session(),
  228. )
  229. args = parser.parse_args(strict=True)
  230. variable = draft_var_srv.get_variable(variable_id=variable_id)
  231. if variable is None:
  232. raise NotFoundError(description=f"variable not found, id={variable_id}")
  233. if variable.app_id != app_model.id:
  234. raise NotFoundError(description=f"variable not found, id={variable_id}")
  235. new_name = args.get(self._PATCH_NAME_FIELD, None)
  236. raw_value = args.get(self._PATCH_VALUE_FIELD, None)
  237. if new_name is None and raw_value is None:
  238. return variable
  239. new_value = None
  240. if raw_value is not None:
  241. if variable.value_type == SegmentType.FILE:
  242. if not isinstance(raw_value, dict):
  243. raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
  244. raw_value = build_from_mapping(mapping=raw_value, tenant_id=app_model.tenant_id)
  245. elif variable.value_type == SegmentType.ARRAY_FILE:
  246. if not isinstance(raw_value, list):
  247. raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
  248. if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
  249. raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
  250. raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id)
  251. new_value = build_segment_with_type(variable.value_type, raw_value)
  252. draft_var_srv.update_variable(variable, name=new_name, value=new_value)
  253. db.session.commit()
  254. return variable
  255. @_api_prerequisite
  256. def delete(self, app_model: App, variable_id: str):
  257. draft_var_srv = WorkflowDraftVariableService(
  258. session=db.session(),
  259. )
  260. variable = draft_var_srv.get_variable(variable_id=variable_id)
  261. if variable is None:
  262. raise NotFoundError(description=f"variable not found, id={variable_id}")
  263. if variable.app_id != app_model.id:
  264. raise NotFoundError(description=f"variable not found, id={variable_id}")
  265. draft_var_srv.delete_variable(variable)
  266. db.session.commit()
  267. return Response("", 204)
  268. class VariableResetApi(Resource):
  269. @_api_prerequisite
  270. def put(self, app_model: App, variable_id: str):
  271. draft_var_srv = WorkflowDraftVariableService(
  272. session=db.session(),
  273. )
  274. workflow_srv = WorkflowService()
  275. draft_workflow = workflow_srv.get_draft_workflow(app_model)
  276. if draft_workflow is None:
  277. raise NotFoundError(
  278. f"Draft workflow not found, app_id={app_model.id}",
  279. )
  280. variable = draft_var_srv.get_variable(variable_id=variable_id)
  281. if variable is None:
  282. raise NotFoundError(description=f"variable not found, id={variable_id}")
  283. if variable.app_id != app_model.id:
  284. raise NotFoundError(description=f"variable not found, id={variable_id}")
  285. resetted = draft_var_srv.reset_variable(draft_workflow, variable)
  286. db.session.commit()
  287. if resetted is None:
  288. return Response("", 204)
  289. else:
  290. return marshal(resetted, _WORKFLOW_DRAFT_VARIABLE_FIELDS)
  291. def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList:
  292. with Session(bind=db.engine, expire_on_commit=False) as session:
  293. draft_var_srv = WorkflowDraftVariableService(
  294. session=session,
  295. )
  296. if node_id == CONVERSATION_VARIABLE_NODE_ID:
  297. draft_vars = draft_var_srv.list_conversation_variables(app_model.id)
  298. elif node_id == SYSTEM_VARIABLE_NODE_ID:
  299. draft_vars = draft_var_srv.list_system_variables(app_model.id)
  300. else:
  301. draft_vars = draft_var_srv.list_node_variables(app_id=app_model.id, node_id=node_id)
  302. return draft_vars
  303. class ConversationVariableCollectionApi(Resource):
  304. @_api_prerequisite
  305. @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
  306. def get(self, app_model: App):
  307. # NOTE(QuantumGhost): Prefill conversation variables into the draft variables table
  308. # so their IDs can be returned to the caller.
  309. workflow_srv = WorkflowService()
  310. draft_workflow = workflow_srv.get_draft_workflow(app_model)
  311. if draft_workflow is None:
  312. raise NotFoundError(description=f"draft workflow not found, id={app_model.id}")
  313. draft_var_srv = WorkflowDraftVariableService(db.session())
  314. draft_var_srv.prefill_conversation_variable_default_values(draft_workflow)
  315. db.session.commit()
  316. return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
  317. class SystemVariableCollectionApi(Resource):
  318. @_api_prerequisite
  319. @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
  320. def get(self, app_model: App):
  321. return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID)
  322. class EnvironmentVariableCollectionApi(Resource):
  323. @_api_prerequisite
  324. def get(self, app_model: App):
  325. """
  326. Get draft workflow
  327. """
  328. # fetch draft workflow by app_model
  329. workflow_service = WorkflowService()
  330. workflow = workflow_service.get_draft_workflow(app_model=app_model)
  331. if workflow is None:
  332. raise DraftWorkflowNotExist()
  333. env_vars = workflow.environment_variables
  334. env_vars_list = []
  335. for v in env_vars:
  336. env_vars_list.append(
  337. {
  338. "id": v.id,
  339. "type": "env",
  340. "name": v.name,
  341. "description": v.description,
  342. "selector": v.selector,
  343. "value_type": v.value_type.exposed_type().value,
  344. "value": v.value,
  345. # Do not track edited for env vars.
  346. "edited": False,
  347. "visible": True,
  348. "editable": True,
  349. }
  350. )
  351. return {"items": env_vars_list}
  352. api.add_resource(
  353. WorkflowVariableCollectionApi,
  354. "/apps/<uuid:app_id>/workflows/draft/variables",
  355. )
  356. api.add_resource(NodeVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
  357. api.add_resource(VariableApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>")
  358. api.add_resource(VariableResetApi, "/apps/<uuid:app_id>/workflows/draft/variables/<uuid:variable_id>/reset")
  359. api.add_resource(ConversationVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/conversation-variables")
  360. api.add_resource(SystemVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/system-variables")
  361. api.add_resource(EnvironmentVariableCollectionApi, "/apps/<uuid:app_id>/workflows/draft/environment-variables")