diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 475280716e..e952e33465 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -121,7 +121,7 @@ from .explore import ( ) # Import snippet controllers -from .snippets import snippet_workflow +from .snippets import snippet_workflow, snippet_workflow_draft_variable # Import tag controllers from .tag import tags @@ -210,6 +210,7 @@ __all__ = [ "setup", "site", "snippet_workflow", + "snippet_workflow_draft_variable", "snippets", "spec", "statistic", diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py index b0bf69aa3c..980506ccc4 100644 --- a/api/controllers/console/snippets/payloads.py +++ b/api/controllers/console/snippets/payloads.py @@ -71,7 +71,10 @@ class SnippetDraftSyncPayload(BaseModel): graph: dict[str, Any] hash: str | None = None - conversation_variables: list[dict[str, Any]] | None = None + conversation_variables: list[dict[str, Any]] | None = Field( + default=None, + description="Ignored. Snippet workflows do not persist conversation variables.", + ) input_fields: list[dict[str, Any]] | None = None diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py index aa7b1fc491..d4b3ec5e4e 100644 --- a/api/controllers/console/snippets/snippet_workflow.py +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -36,7 +36,6 @@ from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from extensions.ext_redis import redis_client -from factories import variable_factory from graphon.graph_engine.manager import GraphEngineManager from libs import helper from libs.helper import TimestampField @@ -117,6 +116,8 @@ class SnippetDraftWorkflowApi(Resource): if not workflow: raise DraftWorkflowNotExist() + db.session.expunge(workflow) + workflow.conversation_variables = [] return workflow @console_ns.doc("sync_snippet_draft_workflow") @@ -135,17 +136,12 @@ class SnippetDraftWorkflowApi(Resource): payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {}) try: - conversation_variables_list = payload.conversation_variables or [] - conversation_variables = [ - variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list - ] snippet_service = SnippetService() workflow = snippet_service.sync_draft_workflow( snippet=snippet, graph=payload.graph, unique_hash=payload.hash, account=current_user, - conversation_variables=conversation_variables, input_fields=payload.input_fields, ) except WorkflowHashNotEqualError: diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py new file mode 100644 index 0000000000..ce3f5cef52 --- /dev/null +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -0,0 +1,319 @@ +""" +Snippet draft workflow variable APIs. + +Mirrors console app routes under /apps/.../workflows/draft/variables for snippet scope, +using CustomizedSnippet.id as WorkflowDraftVariable.app_id (same invariant as snippet execution). + +Snippet workflows do not expose system variables (`node_id == sys`) or conversation variables +(`node_id == conversation`): paginated list queries exclude those rows; single-variable GET/PATCH/DELETE/reset +reject them; `GET .../system-variables` and `GET .../conversation-variables` return empty lists for API parity. +Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`. +""" + +from collections.abc import Callable +from functools import wraps +from typing import Any, ParamSpec, TypeVar + +from flask import Response, request +from flask_restx import Resource, marshal, marshal_with +from sqlalchemy.orm import Session + +from controllers.console import console_ns +from controllers.console.app.error import DraftWorkflowNotExist +from controllers.console.app.workflow_draft_variable import ( + WorkflowDraftVariableListQuery, + WorkflowDraftVariableUpdatePayload, + _ensure_variable_access, + _file_access_controller, + validate_node_id, + workflow_draft_variable_list_model, + workflow_draft_variable_list_without_value_model, + workflow_draft_variable_model, +) +from controllers.console.snippets.snippet_workflow import get_snippet +from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from controllers.web.error import InvalidArgumentError, NotFoundError +from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from extensions.ext_database import db +from factories.file_factory import build_from_mapping, build_from_mappings +from factories.variable_factory import build_segment_with_type +from graphon.variables.types import SegmentType +from libs.login import current_user, login_required +from models.snippet import CustomizedSnippet +from models.workflow import WorkflowDraftVariable +from services.snippet_service import SnippetService +from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService + +P = ParamSpec("P") +R = TypeVar("R") + +_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset( + {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID} +) + + +def _ensure_snippet_draft_variable_row_allowed( + *, + variable: WorkflowDraftVariable, + variable_id: str, +) -> None: + """Snippet scope only supports canvas-node draft variables; treat sys/conversation rows as not found.""" + if variable.node_id in _SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: + raise NotFoundError(description=f"variable not found, id={variable_id}") + + +def _snippet_draft_var_prerequisite(f: Callable[P, R]) -> Callable[P, R]: + """Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs).""" + + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + return f(*args, **kwargs) + + return wrapper + + +@console_ns.route("/snippets//workflows/draft/variables") +class SnippetWorkflowVariableCollectionApi(Resource): + @console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__]) + @console_ns.doc("get_snippet_workflow_variables") + @console_ns.doc(description="List draft workflow variables without values (paginated, snippet scope)") + @console_ns.response( + 200, + "Workflow variables retrieved successfully", + workflow_draft_variable_list_without_value_model, + ) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_without_value_model) + def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + + snippet_service = SnippetService() + if snippet_service.get_draft_workflow(snippet=snippet) is None: + raise DraftWorkflowNotExist() + + with Session(bind=db.engine, expire_on_commit=False) as session: + draft_var_srv = WorkflowDraftVariableService(session=session) + workflow_vars = draft_var_srv.list_variables_without_values( + app_id=snippet.id, + page=args.page, + limit=args.limit, + user_id=current_user.id, + exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS, + ) + + return workflow_vars + + @console_ns.doc("delete_snippet_workflow_variables") + @console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)") + @console_ns.response(204, "Workflow variables deleted successfully") + @_snippet_draft_var_prerequisite + def delete(self, snippet: CustomizedSnippet) -> Response: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id) + db.session.commit() + return Response("", 204) + + +@console_ns.route("/snippets//workflows/draft/nodes//variables") +class SnippetNodeVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_node_variables") + @console_ns.doc(description="Get variables for a specific node (snippet draft workflow)") + @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_model) + def get(self, snippet: CustomizedSnippet, node_id: str) -> WorkflowDraftVariableList: + validate_node_id(node_id) + with Session(bind=db.engine, expire_on_commit=False) as session: + draft_var_srv = WorkflowDraftVariableService(session=session) + node_vars = draft_var_srv.list_node_variables(snippet.id, node_id, user_id=current_user.id) + + return node_vars + + @console_ns.doc("delete_snippet_node_variables") + @console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)") + @console_ns.response(204, "Node variables deleted successfully") + @_snippet_draft_var_prerequisite + def delete(self, snippet: CustomizedSnippet, node_id: str) -> Response: + validate_node_id(node_id) + srv = WorkflowDraftVariableService(db.session()) + srv.delete_node_variables(snippet.id, node_id, user_id=current_user.id) + db.session.commit() + return Response("", 204) + + +@console_ns.route("/snippets//workflows/draft/variables/") +class SnippetVariableApi(Resource): + @console_ns.doc("get_snippet_workflow_variable") + @console_ns.doc(description="Get a specific draft workflow variable (snippet scope)") + @console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model) + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_model) + def get(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + return variable + + @console_ns.doc("update_snippet_workflow_variable") + @console_ns.doc(description="Update a draft workflow variable (snippet scope)") + @console_ns.expect(console_ns.models[WorkflowDraftVariableUpdatePayload.__name__]) + @console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model) + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_model) + def patch(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {}) + + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + + new_name = args_model.name + raw_value = args_model.value + if new_name is None and raw_value is None: + return variable + + new_value = None + if raw_value is not None: + if variable.value_type == SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + raw_value = build_from_mapping( + mapping=raw_value, + tenant_id=snippet.tenant_id, + access_controller=_file_access_controller, + ) + elif variable.value_type == SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + if len(raw_value) > 0 and not isinstance(raw_value[0], dict): + raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") + raw_value = build_from_mappings( + mappings=raw_value, + tenant_id=snippet.tenant_id, + access_controller=_file_access_controller, + ) + new_value = build_segment_with_type(variable.value_type, raw_value) + draft_var_srv.update_variable(variable, name=new_name, value=new_value) + db.session.commit() + return variable + + @console_ns.doc("delete_snippet_workflow_variable") + @console_ns.doc(description="Delete a draft workflow variable (snippet scope)") + @console_ns.response(204, "Variable deleted successfully") + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + def delete(self, snippet: CustomizedSnippet, variable_id: str) -> Response: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + draft_var_srv.delete_variable(variable) + db.session.commit() + return Response("", 204) + + +@console_ns.route("/snippets//workflows/draft/variables//reset") +class SnippetVariableResetApi(Resource): + @console_ns.doc("reset_snippet_workflow_variable") + @console_ns.doc(description="Reset a draft workflow variable to its default value (snippet scope)") + @console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model) + @console_ns.response(204, "Variable reset (no content)") + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + def put(self, snippet: CustomizedSnippet, variable_id: str) -> Response | Any: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + snippet_service = SnippetService() + draft_workflow = snippet_service.get_draft_workflow(snippet=snippet) + if draft_workflow is None: + raise NotFoundError( + f"Draft workflow not found, snippet_id={snippet.id}", + ) + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + + resetted = draft_var_srv.reset_variable(draft_workflow, variable) + db.session.commit() + if resetted is None: + return Response("", 204) + return marshal(resetted, workflow_draft_variable_model) + + +@console_ns.route("/snippets//workflows/draft/conversation-variables") +class SnippetConversationVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_conversation_variables") + @console_ns.doc( + description="Conversation variables are not used in snippet workflows; returns an empty list for API parity" + ) + @console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_model) + def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + return WorkflowDraftVariableList(variables=[]) + + +@console_ns.route("/snippets//workflows/draft/system-variables") +class SnippetSystemVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_system_variables") + @console_ns.doc( + description="System variables are not used in snippet workflows; returns an empty list for API parity" + ) + @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_model) + def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + return WorkflowDraftVariableList(variables=[]) + + +@console_ns.route("/snippets//workflows/draft/environment-variables") +class SnippetEnvironmentVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_environment_variables") + @console_ns.doc(description="Get environment variables from snippet draft workflow graph") + @console_ns.response(200, "Environment variables retrieved successfully") + @console_ns.response(404, "Draft workflow not found") + @_snippet_draft_var_prerequisite + def get(self, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]: + snippet_service = SnippetService() + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if workflow is None: + raise DraftWorkflowNotExist() + + env_vars_list: list[dict[str, Any]] = [] + for v in workflow.environment_variables: + env_vars_list.append( + { + "id": v.id, + "type": "env", + "name": v.name, + "description": v.description, + "selector": v.selector, + "value_type": v.value_type.exposed_type().value, + "value": v.value, + "edited": False, + "visible": True, + "editable": True, + } + ) + + return {"items": env_vars_list} diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py index 6f3cddc794..f074a40f09 100644 --- a/api/services/snippet_dsl_service.py +++ b/api/services/snippet_dsl_service.py @@ -15,7 +15,6 @@ from sqlalchemy.orm import Session from core.helper import ssrf_proxy from core.plugin.entities.plugin import PluginDependency from extensions.ext_redis import redis_client -from factories import variable_factory from graphon.enums import BuiltinNodeTypes from graphon.model_runtime.utils.encoders import jsonable_encoder from models import Account @@ -32,6 +31,7 @@ IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB CURRENT_DSL_VERSION = "0.1.0" + class ImportMode(StrEnum): YAML_CONTENT = "yaml-content" YAML_URL = "yaml-url" @@ -420,11 +420,6 @@ class SnippetDslService: # Create or update draft workflow if workflow_data: graph = workflow_data.get("graph", {}) - conversation_variables_list = workflow_data.get("conversation_variables", []) - - conversation_variables = [ - variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list - ] snippet_service = SnippetService() # Get existing workflow hash if exists @@ -436,7 +431,6 @@ class SnippetDslService: graph=graph, unique_hash=unique_hash, account=account, - conversation_variables=conversation_variables, input_fields=input_fields, ) @@ -483,6 +477,7 @@ class SnippetDslService: workflow_dict = workflow.to_dict(include_secret=include_secret) # Filter workspace related data from nodes workflow_dict["environment_variables"] = [] + workflow_dict["conversation_variables"] = [] for node in workflow_dict.get("graph", {}).get("nodes", []): node_data = node.get("data", {}) diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index 2b50663c52..a2cdc23f3d 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -10,7 +10,6 @@ from sqlalchemy.orm import Session, sessionmaker from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING from extensions.ext_database import db from graphon.enums import BuiltinNodeTypes, NodeType -from graphon.variables.variables import VariableBase from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account from models.enums import WorkflowRunTriggeredFrom @@ -308,19 +307,18 @@ class SnippetService: graph: dict, unique_hash: str | None, account: Account, - conversation_variables: Sequence[VariableBase], input_fields: list[dict] | None = None, ) -> Workflow: """ Sync draft workflow for snippet. - Snippet workflows do not persist environment variables (always empty). + Snippet workflows do not persist environment variables (always empty) or + conversation variables (always empty). :param snippet: CustomizedSnippet instance :param graph: Workflow graph configuration :param unique_hash: Hash for conflict detection :param account: Account making the change - :param conversation_variables: Conversation variables :param input_fields: Input fields for snippet :return: Synced Workflow :raises WorkflowHashNotEqualError: If hash mismatch @@ -343,7 +341,7 @@ class SnippetService: graph=json.dumps(graph), created_by=account.id, environment_variables=[], - conversation_variables=conversation_variables, + conversation_variables=[], ) db.session.add(workflow) db.session.flush() @@ -353,7 +351,7 @@ class SnippetService: workflow.updated_by = account.id workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) workflow.environment_variables = [] - workflow.conversation_variables = conversation_variables + workflow.conversation_variables = [] # Update snippet's input_fields if provided if input_fields is not None: @@ -402,7 +400,7 @@ class SnippetService: features=draft_workflow.features, created_by=account.id, environment_variables=[], - conversation_variables=draft_workflow.conversation_variables, + conversation_variables=[], rag_pipeline_variables=draft_workflow.rag_pipeline_variables, marked_name="", marked_comment="", diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 0b5c89e574..de64d85a64 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -1,7 +1,7 @@ import dataclasses import json import logging -from collections.abc import Mapping, Sequence +from collections.abc import AbstractSet, Mapping, Sequence from concurrent.futures import ThreadPoolExecutor from enum import StrEnum from typing import Any, ClassVar @@ -270,12 +270,20 @@ class WorkflowDraftVariableService: return query.all() def list_variables_without_values( - self, app_id: str, page: int, limit: int, user_id: str + self, + app_id: str, + page: int, + limit: int, + user_id: str, + *, + exclude_node_ids: AbstractSet[str] | None = None, ) -> WorkflowDraftVariableList: criteria = [ WorkflowDraftVariable.app_id == app_id, WorkflowDraftVariable.user_id == user_id, ] + if exclude_node_ids: + criteria.append(WorkflowDraftVariable.node_id.notin_(list(exclude_node_ids))) total = None query = self._session.query(WorkflowDraftVariable).where(*criteria) if page == 1: