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

workflow_draft_variable.py 17KB

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