您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

workflow_draft_variable.py 16KB

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