Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

workflow_draft_variable.py 17KB

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