mirror of
https://github.com/langgenius/dify.git
synced 2026-06-07 16:23:44 +08:00
feat: snippet (#37046)
Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
2c323104eb
commit
00ac937934
@ -122,6 +122,7 @@ from .explore import (
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .snippets import snippet_workflow, snippet_workflow_draft_variable
|
||||
from .socketio import workflow as socketio_workflow
|
||||
|
||||
# Import tag controllers
|
||||
@ -137,6 +138,7 @@ from .workspace import (
|
||||
model_providers,
|
||||
models,
|
||||
plugin,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
workspace,
|
||||
@ -212,6 +214,9 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
"socketio_workflow",
|
||||
"spec",
|
||||
"statistic",
|
||||
|
||||
@ -64,6 +64,7 @@ register_enum_models(console_ns, IconType)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
|
||||
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
|
||||
|
||||
|
||||
class AppListQuery(BaseModel):
|
||||
@ -74,6 +75,7 @@ class AppListQuery(BaseModel):
|
||||
)
|
||||
name: str | None = Field(default=None, description="Filter by app name")
|
||||
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
|
||||
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
|
||||
is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
|
||||
|
||||
@field_validator("tag_ids", mode="before")
|
||||
@ -94,10 +96,29 @@ class AppListQuery(BaseModel):
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid UUID format in tag_ids.") from exc
|
||||
|
||||
@field_validator("creator_ids", mode="before")
|
||||
@classmethod
|
||||
def validate_creator_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Unsupported creator_ids type.")
|
||||
|
||||
items = [str(item).strip() for item in value if item and str(item).strip()]
|
||||
if not items:
|
||||
return None
|
||||
|
||||
try:
|
||||
return [str(uuid.UUID(item)) for item in items]
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid UUID format in creator_ids.") from exc
|
||||
|
||||
|
||||
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
|
||||
normalized: dict[str, str | list[str]] = {}
|
||||
indexed_tag_ids: list[tuple[int, str]] = []
|
||||
indexed_creator_ids: list[tuple[int, str]] = []
|
||||
|
||||
for key in query_args:
|
||||
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
@ -105,12 +126,19 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str,
|
||||
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
if match:
|
||||
indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
value = query_args.get(key)
|
||||
if value is not None:
|
||||
normalized[key] = value
|
||||
|
||||
if indexed_tag_ids:
|
||||
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
|
||||
if indexed_creator_ids:
|
||||
normalized["creator_ids"] = [value for _, value in sorted(indexed_creator_ids)]
|
||||
|
||||
return normalized
|
||||
|
||||
@ -486,6 +514,7 @@ class AppListApi(Resource):
|
||||
mode=args.mode,
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
)
|
||||
|
||||
|
||||
164
api/controllers/console/snippets/payloads.py
Normal file
164
api/controllers/console/snippets/payloads.py
Normal file
@ -0,0 +1,164 @@
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class SnippetListQuery(BaseModel):
|
||||
"""Query parameters for listing snippets."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=99999)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
keyword: str | None = None
|
||||
is_published: bool | None = Field(default=None, description="Filter by published status")
|
||||
creators: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Filter by creator account IDs",
|
||||
validation_alias=AliasChoices("creators", "creator_id"),
|
||||
)
|
||||
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
|
||||
|
||||
@field_validator("creators", mode="before")
|
||||
@classmethod
|
||||
def parse_creators(cls, value: object) -> list[str] | None:
|
||||
"""Normalize creators filter from query string or list input."""
|
||||
return cls._normalize_string_list(value)
|
||||
|
||||
@field_validator("tag_ids", mode="before")
|
||||
@classmethod
|
||||
def parse_tag_ids(cls, value: object) -> list[str] | None:
|
||||
"""Normalize and validate tag IDs from query string or list input."""
|
||||
items = cls._normalize_string_list(value)
|
||||
if not items:
|
||||
return None
|
||||
try:
|
||||
return [str(uuid.UUID(item)) for item in items]
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid UUID format in tag_ids.") from exc
|
||||
|
||||
@staticmethod
|
||||
def _normalize_string_list(value: object) -> list[str] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()] or None
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()] or None
|
||||
return None
|
||||
|
||||
|
||||
class IconInfo(BaseModel):
|
||||
"""Icon information model."""
|
||||
|
||||
icon: str | None = None
|
||||
icon_type: Literal["emoji", "image"] | None = None
|
||||
icon_background: str | None = None
|
||||
icon_url: str | None = None
|
||||
|
||||
|
||||
class InputFieldDefinition(BaseModel):
|
||||
"""Input field definition for snippet parameters."""
|
||||
|
||||
default: str | None = None
|
||||
hint: bool | None = None
|
||||
label: str | None = None
|
||||
max_length: int | None = None
|
||||
options: list[str] | None = None
|
||||
placeholder: str | None = None
|
||||
required: bool | None = None
|
||||
type: str | None = None # e.g., "text-input"
|
||||
|
||||
|
||||
class CreateSnippetPayload(BaseModel):
|
||||
"""Payload for creating a new snippet."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
type: Literal["node", "group"] = "node"
|
||||
icon_info: IconInfo | None = None
|
||||
graph: dict[str, Any] | None = None
|
||||
input_fields: list[InputFieldDefinition] | None = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateSnippetPayload(BaseModel):
|
||||
"""Payload for updating a snippet."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
icon_info: IconInfo | None = None
|
||||
|
||||
|
||||
class SnippetDraftSyncPayload(BaseModel):
|
||||
"""Payload for syncing snippet draft workflow."""
|
||||
|
||||
graph: dict[str, Any]
|
||||
hash: str | 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
|
||||
|
||||
|
||||
class SnippetWorkflowListQuery(BaseModel):
|
||||
"""Query parameters for listing snippet published workflows."""
|
||||
|
||||
page: int = Field(default=1, ge=1, le=99999)
|
||||
limit: int = Field(default=10, ge=1, le=100)
|
||||
|
||||
|
||||
class WorkflowRunQuery(BaseModel):
|
||||
"""Query parameters for workflow runs."""
|
||||
|
||||
last_id: str | None = None
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
|
||||
|
||||
class SnippetDraftRunPayload(BaseModel):
|
||||
"""Payload for running snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any]
|
||||
files: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SnippetDraftNodeRunPayload(BaseModel):
|
||||
"""Payload for running a single node in snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any]
|
||||
query: str = ""
|
||||
files: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class SnippetIterationNodeRunPayload(BaseModel):
|
||||
"""Payload for running an iteration node in snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SnippetLoopNodeRunPayload(BaseModel):
|
||||
"""Payload for running a loop node in snippet draft workflow."""
|
||||
|
||||
inputs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PublishWorkflowPayload(BaseModel):
|
||||
"""Payload for publishing snippet workflow."""
|
||||
|
||||
knowledge_base_setting: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class SnippetImportPayload(BaseModel):
|
||||
"""Payload for importing snippet from DSL."""
|
||||
|
||||
mode: str = Field(..., description="Import mode: yaml-content or yaml-url")
|
||||
yaml_content: str | None = Field(default=None, description="YAML content (required for yaml-content mode)")
|
||||
yaml_url: str | None = Field(default=None, description="YAML URL (required for yaml-url mode)")
|
||||
name: str | None = Field(default=None, description="Override snippet name")
|
||||
description: str | None = Field(default=None, description="Override snippet description")
|
||||
snippet_id: str | None = Field(default=None, description="Snippet ID to update (optional)")
|
||||
|
||||
|
||||
class IncludeSecretQuery(BaseModel):
|
||||
"""Query parameter for including secret variables in export."""
|
||||
|
||||
include_secret: str = Field(default="false", description="Whether to include secret variables")
|
||||
678
api/controllers/console/snippets/snippet_workflow.py
Normal file
678
api/controllers/console/snippets/snippet_workflow.py
Normal file
@ -0,0 +1,678 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync
|
||||
from controllers.console.app.workflow import (
|
||||
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowResponse,
|
||||
)
|
||||
from controllers.console.snippets.payloads import (
|
||||
PublishWorkflowPayload,
|
||||
SnippetDraftNodeRunPayload,
|
||||
SnippetDraftRunPayload,
|
||||
SnippetDraftSyncPayload,
|
||||
SnippetIterationNodeRunPayload,
|
||||
SnippetLoopNodeRunPayload,
|
||||
SnippetWorkflowListQuery,
|
||||
WorkflowRunQuery,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
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 fields.workflow_run_fields import (
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
WorkflowRunNodeExecutionResponse,
|
||||
WorkflowRunPaginationResponse,
|
||||
)
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
from libs import helper
|
||||
from libs.helper import TimestampField
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
from services.snippet_service import SnippetService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Register Pydantic models with Swagger
|
||||
|
||||
|
||||
def _snippet_session_maker() -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _snippet_service() -> SnippetService:
|
||||
return SnippetService(_snippet_session_maker())
|
||||
|
||||
|
||||
class SnippetWorkflowResponse(WorkflowResponse):
|
||||
input_fields: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
SnippetDraftSyncPayload,
|
||||
SnippetDraftNodeRunPayload,
|
||||
SnippetDraftRunPayload,
|
||||
SnippetIterationNodeRunPayload,
|
||||
SnippetLoopNodeRunPayload,
|
||||
SnippetWorkflowListQuery,
|
||||
WorkflowRunQuery,
|
||||
PublishWorkflowPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
SnippetWorkflowResponse,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowRunPaginationResponse,
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
WorkflowRunNodeExecutionResponse,
|
||||
)
|
||||
|
||||
|
||||
class SnippetNotFoundError(Exception):
|
||||
"""Snippet not found error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def get_snippet[**P, R](view_func: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Decorator to fetch and validate snippet access."""
|
||||
|
||||
@wraps(view_func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if not kwargs.get("snippet_id"):
|
||||
raise ValueError("missing snippet_id in path parameters")
|
||||
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_id = str(kwargs.get("snippet_id"))
|
||||
del kwargs["snippet_id"]
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
kwargs["snippet"] = snippet
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return decorated_view
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft")
|
||||
class SnippetDraftWorkflowApi(Resource):
|
||||
@console_ns.doc("get_snippet_draft_workflow")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Draft workflow retrieved successfully",
|
||||
console_ns.models[SnippetWorkflowResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get draft workflow for snippet."""
|
||||
snippet_service = _snippet_service()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
|
||||
if not workflow:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
workflow.conversation_variables = []
|
||||
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
@console_ns.doc("sync_snippet_draft_workflow")
|
||||
@console_ns.expect(console_ns.models.get(SnippetDraftSyncPayload.__name__))
|
||||
@console_ns.response(200, "Draft workflow synced successfully")
|
||||
@console_ns.response(400, "Hash mismatch")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet):
|
||||
"""Sync draft workflow for snippet."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
snippet_service = _snippet_service()
|
||||
workflow = snippet_service.sync_draft_workflow(
|
||||
snippet=snippet,
|
||||
graph=payload.graph,
|
||||
unique_hash=payload.hash,
|
||||
account=current_user,
|
||||
input_fields=payload.input_fields,
|
||||
)
|
||||
except WorkflowHashNotEqualError:
|
||||
raise DraftWorkflowNotSync()
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"hash": workflow.unique_hash,
|
||||
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/config")
|
||||
class SnippetDraftConfigApi(Resource):
|
||||
@console_ns.doc("get_snippet_draft_config")
|
||||
@console_ns.response(200, "Draft config retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get snippet draft workflow configuration limits."""
|
||||
return {
|
||||
"parallel_depth_limit": 3,
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/publish")
|
||||
class SnippetPublishedWorkflowApi(Resource):
|
||||
@console_ns.doc("get_snippet_published_workflow")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Published workflow retrieved successfully",
|
||||
console_ns.models[SnippetWorkflowResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get published workflow for snippet."""
|
||||
if not snippet.is_published:
|
||||
return None
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
workflow = snippet_service.get_published_workflow(snippet=snippet)
|
||||
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
@console_ns.doc("publish_snippet_workflow")
|
||||
@console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__))
|
||||
@console_ns.response(200, "Workflow published successfully")
|
||||
@console_ns.response(400, "No draft workflow found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet):
|
||||
"""Publish snippet workflow."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
snippet_service = _snippet_service()
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
try:
|
||||
workflow = snippet_service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=current_user,
|
||||
)
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
session.commit()
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/default-workflow-block-configs")
|
||||
class SnippetDefaultBlockConfigsApi(Resource):
|
||||
@console_ns.doc("get_snippet_default_block_configs")
|
||||
@console_ns.response(200, "Default block configs retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get default block configurations for snippet workflow."""
|
||||
snippet_service = _snippet_service()
|
||||
return snippet_service.get_default_block_configs()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows")
|
||||
class SnippetPublishedAllWorkflowApi(Resource):
|
||||
@console_ns.expect(console_ns.models[SnippetWorkflowListQuery.__name__])
|
||||
@console_ns.doc("get_all_snippet_published_workflows")
|
||||
@console_ns.doc(description="Get all published workflows for a snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Published workflows retrieved successfully",
|
||||
console_ns.models[WorkflowPaginationResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get all published workflow versions for snippet."""
|
||||
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
with Session(db.engine) as session:
|
||||
workflows, has_more = snippet_service.get_all_published_workflows(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
return WorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": workflows,
|
||||
"page": args.page,
|
||||
"limit": args.limit,
|
||||
"has_more": has_more,
|
||||
},
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>/restore")
|
||||
class SnippetDraftWorkflowRestoreApi(Resource):
|
||||
@console_ns.doc("restore_snippet_workflow_to_draft")
|
||||
@console_ns.doc(description="Restore a published snippet workflow version into the draft workflow")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Published workflow ID"})
|
||||
@console_ns.response(200, "Workflow restored successfully")
|
||||
@console_ns.response(400, "Source workflow must be published")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, workflow_id: str):
|
||||
"""Restore a published snippet workflow version into the draft workflow."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
snippet_service = _snippet_service()
|
||||
|
||||
try:
|
||||
workflow = snippet_service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id=workflow_id,
|
||||
account=current_user,
|
||||
)
|
||||
except IsDraftWorkflowError as exc:
|
||||
raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc
|
||||
except WorkflowNotFoundError as exc:
|
||||
raise NotFound(str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"hash": workflow.unique_hash,
|
||||
"updated_at": TimestampField().format(workflow.updated_at or workflow.created_at),
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
|
||||
class SnippetWorkflowRunsApi(Resource):
|
||||
@console_ns.doc("list_snippet_workflow_runs")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow runs retrieved successfully",
|
||||
console_ns.models[WorkflowRunPaginationResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""List workflow runs for snippet."""
|
||||
query = WorkflowRunQuery.model_validate(
|
||||
{
|
||||
"last_id": request.args.get("last_id"),
|
||||
"limit": request.args.get("limit", type=int, default=20),
|
||||
}
|
||||
)
|
||||
args = {
|
||||
"last_id": query.last_id,
|
||||
"limit": query.limit,
|
||||
}
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
result = snippet_service.get_snippet_workflow_runs(snippet=snippet, args=args)
|
||||
|
||||
return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/<uuid:run_id>")
|
||||
class SnippetWorkflowRunDetailApi(Resource):
|
||||
@console_ns.doc("get_snippet_workflow_run_detail")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow run detail retrieved successfully",
|
||||
console_ns.models[WorkflowRunDetailResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Workflow run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet, run_id):
|
||||
"""Get workflow run detail for snippet."""
|
||||
run_id = str(run_id)
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
workflow_run = snippet_service.get_snippet_workflow_run(snippet=snippet, run_id=run_id)
|
||||
|
||||
if not workflow_run:
|
||||
raise NotFound("Workflow run not found")
|
||||
|
||||
return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/<uuid:run_id>/node-executions")
|
||||
class SnippetWorkflowRunNodeExecutionsApi(Resource):
|
||||
@console_ns.doc("list_snippet_workflow_run_node_executions")
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Node executions retrieved successfully",
|
||||
console_ns.models[WorkflowRunNodeExecutionListResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet, run_id):
|
||||
"""List node executions for a workflow run."""
|
||||
run_id = str(run_id)
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
node_executions = snippet_service.get_snippet_workflow_run_node_executions(
|
||||
snippet=snippet,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return WorkflowRunNodeExecutionListResponse.model_validate(
|
||||
{"data": node_executions}, from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/run")
|
||||
class SnippetDraftNodeRunApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_node")
|
||||
@console_ns.doc(description="Run a single node in snippet draft workflow (single-step debugging)")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models.get(SnippetDraftNodeRunPayload.__name__))
|
||||
@console_ns.response(
|
||||
200, "Node run completed successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__]
|
||||
)
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a single node in snippet draft workflow.
|
||||
|
||||
Executes a specific node with provided inputs for single-step debugging.
|
||||
Returns the node execution result including status, outputs, and timing.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = SnippetDraftNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
user_inputs = payload.inputs
|
||||
|
||||
# Get draft workflow for file parsing
|
||||
snippet_service = _snippet_service()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise NotFound("Draft workflow not found")
|
||||
|
||||
files = SnippetGenerateService.parse_files(draft_workflow, payload.files)
|
||||
|
||||
workflow_node_execution = SnippetGenerateService.run_draft_node(
|
||||
snippet=snippet,
|
||||
node_id=node_id,
|
||||
user_inputs=user_inputs,
|
||||
account=current_user,
|
||||
query=payload.query,
|
||||
files=files,
|
||||
session_maker=_snippet_session_maker(),
|
||||
)
|
||||
|
||||
return WorkflowRunNodeExecutionResponse.model_validate(
|
||||
workflow_node_execution, from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/last-run")
|
||||
class SnippetDraftNodeLastRunApi(Resource):
|
||||
@console_ns.doc("get_snippet_draft_node_last_run")
|
||||
@console_ns.doc(description="Get last run result for a node in snippet draft workflow")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.response(
|
||||
200, "Node last run retrieved successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__]
|
||||
)
|
||||
@console_ns.response(404, "Snippet, draft workflow, or node last run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
def get(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Get the last run result for a specific node in snippet draft workflow.
|
||||
|
||||
Returns the most recent execution record for the given node,
|
||||
including status, inputs, outputs, and timing information.
|
||||
"""
|
||||
snippet_service = _snippet_service()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise NotFound("Draft workflow not found")
|
||||
|
||||
node_exec = snippet_service.get_snippet_node_last_run(
|
||||
snippet=snippet,
|
||||
workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
)
|
||||
if node_exec is None:
|
||||
raise NotFound("Node last run not found")
|
||||
|
||||
return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/iteration/nodes/<string:node_id>/run")
|
||||
class SnippetDraftRunIterationNodeApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_iteration_node")
|
||||
@console_ns.doc(description="Run draft workflow iteration node for snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models.get(SnippetIterationNodeRunPayload.__name__))
|
||||
@console_ns.response(200, "Iteration node run started successfully (SSE stream)")
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow iteration node for snippet.
|
||||
|
||||
Iteration nodes execute their internal sub-graph multiple times over an input list.
|
||||
Returns an SSE event stream with iteration progress and results.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = SnippetIterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
response = SnippetGenerateService.generate_single_iteration(
|
||||
snippet=snippet,
|
||||
user=current_user,
|
||||
node_id=node_id,
|
||||
args=args,
|
||||
streaming=True,
|
||||
session_maker=_snippet_session_maker(),
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/loop/nodes/<string:node_id>/run")
|
||||
class SnippetDraftRunLoopNodeApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_loop_node")
|
||||
@console_ns.doc(description="Run draft workflow loop node for snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models.get(SnippetLoopNodeRunPayload.__name__))
|
||||
@console_ns.response(200, "Loop node run started successfully (SSE stream)")
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow loop node for snippet.
|
||||
|
||||
Loop nodes execute their internal sub-graph repeatedly until a condition is met.
|
||||
Returns an SSE event stream with loop progress and results.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = SnippetLoopNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
response = SnippetGenerateService.generate_single_loop(
|
||||
snippet=snippet,
|
||||
user=current_user,
|
||||
node_id=node_id,
|
||||
args=args,
|
||||
streaming=True,
|
||||
session_maker=_snippet_session_maker(),
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/draft/run")
|
||||
class SnippetDraftWorkflowRunApi(Resource):
|
||||
@console_ns.doc("run_snippet_draft_workflow")
|
||||
@console_ns.expect(console_ns.models.get(SnippetDraftRunPayload.__name__))
|
||||
@console_ns.response(200, "Draft workflow run started successfully (SSE stream)")
|
||||
@console_ns.response(404, "Snippet or draft workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet):
|
||||
"""
|
||||
Run draft workflow for snippet.
|
||||
|
||||
Executes the snippet's draft workflow with the provided inputs
|
||||
and returns an SSE event stream with execution progress and results.
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
payload = SnippetDraftRunPayload.model_validate(console_ns.payload or {})
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
response = SnippetGenerateService.generate(
|
||||
snippet=snippet,
|
||||
user=current_user,
|
||||
args=args,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
streaming=True,
|
||||
session_maker=_snippet_session_maker(),
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs/tasks/<string:task_id>/stop")
|
||||
class SnippetWorkflowTaskStopApi(Resource):
|
||||
@console_ns.doc("stop_snippet_workflow_task")
|
||||
@console_ns.response(200, "Task stopped successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
def post(self, snippet: CustomizedSnippet, task_id: str):
|
||||
"""
|
||||
Stop a running snippet workflow task.
|
||||
|
||||
Uses both the legacy stop flag mechanism and the graph engine
|
||||
command channel for backward compatibility.
|
||||
"""
|
||||
# Stop using both mechanisms for backward compatibility
|
||||
# Legacy stop flag mechanism (without user check)
|
||||
AppQueueManager.set_stop_flag_no_user_check(task_id)
|
||||
|
||||
# New graph engine command channel mechanism
|
||||
GraphEngineManager(redis_client).send_stop_command(task_id)
|
||||
|
||||
return {"result": "success"}
|
||||
@ -0,0 +1,320 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource, marshal, marshal_with
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
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
|
||||
|
||||
_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset(
|
||||
{SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
|
||||
)
|
||||
|
||||
|
||||
def _snippet_service() -> SnippetService:
|
||||
return SnippetService(sessionmaker(bind=db.engine, expire_on_commit=False))
|
||||
|
||||
|
||||
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[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]:
|
||||
"""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 | Response:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/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 = _snippet_service()
|
||||
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/<uuid:snippet_id>/workflows/draft/nodes/<string:node_id>/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/<uuid:snippet_id>/workflows/draft/variables/<uuid:variable_id>")
|
||||
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/<uuid:snippet_id>/workflows/draft/variables/<uuid:variable_id>/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 = _snippet_service()
|
||||
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/<uuid:snippet_id>/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/<uuid:snippet_id>/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/<uuid:snippet_id>/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 = _snippet_service()
|
||||
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}
|
||||
@ -51,7 +51,7 @@ class TagBindingRemovePayload(BaseModel):
|
||||
|
||||
|
||||
class TagListQueryParam(BaseModel):
|
||||
type: Literal["knowledge", "app", ""] = Field("", description="Tag type filter")
|
||||
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
|
||||
keyword: str | None = Field(None, description="Search keyword")
|
||||
|
||||
|
||||
@ -96,7 +96,10 @@ class TagListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@console_ns.doc(
|
||||
params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."}
|
||||
params={
|
||||
"type": 'Tag type filter. Can be "knowledge", "app", or "snippet".',
|
||||
"keyword": "Search keyword for tag name.",
|
||||
}
|
||||
)
|
||||
@console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])})
|
||||
@with_current_tenant_id
|
||||
|
||||
428
api/controllers/console/workspace/snippets.py
Normal file
428
api/controllers/console/workspace/snippets.py
Normal file
@ -0,0 +1,428 @@
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource, marshal
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.datastructures import MultiDict
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.snippets.payloads import (
|
||||
CreateSnippetPayload,
|
||||
IncludeSecretQuery,
|
||||
SnippetImportPayload,
|
||||
SnippetListQuery,
|
||||
UpdateSnippetPayload,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.snippet_fields import snippet_fields, snippet_list_fields, snippet_pagination_fields
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.snippet import SnippetType
|
||||
from services.app_dsl_service import ImportStatus
|
||||
from services.snippet_dsl_service import SnippetDslService
|
||||
from services.snippet_service import SnippetService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
|
||||
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
|
||||
|
||||
|
||||
def _snippet_service() -> SnippetService:
|
||||
return SnippetService(sessionmaker(bind=db.engine, expire_on_commit=False))
|
||||
|
||||
|
||||
def _normalize_snippet_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
|
||||
normalized: dict[str, str | list[str]] = {}
|
||||
indexed_tag_ids: list[tuple[int, str]] = []
|
||||
indexed_creator_ids: list[tuple[int, str]] = []
|
||||
|
||||
for key in query_args:
|
||||
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
if match:
|
||||
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
if match:
|
||||
indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
value = query_args.get(key)
|
||||
if value is not None:
|
||||
normalized[key] = value
|
||||
|
||||
if indexed_tag_ids:
|
||||
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
|
||||
if indexed_creator_ids:
|
||||
normalized["creators"] = [value for _, value in sorted(indexed_creator_ids)]
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
# Register Pydantic models with Swagger
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
SnippetListQuery,
|
||||
CreateSnippetPayload,
|
||||
UpdateSnippetPayload,
|
||||
SnippetImportPayload,
|
||||
IncludeSecretQuery,
|
||||
)
|
||||
|
||||
# Create namespace models for marshaling
|
||||
snippet_model = console_ns.model("Snippet", snippet_fields)
|
||||
snippet_list_model = console_ns.model("SnippetList", snippet_list_fields)
|
||||
snippet_pagination_model = console_ns.model("SnippetPagination", snippet_pagination_fields)
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets")
|
||||
class CustomizedSnippetsApi(Resource):
|
||||
@console_ns.doc("list_customized_snippets")
|
||||
@console_ns.expect(console_ns.models.get(SnippetListQuery.__name__))
|
||||
@console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self):
|
||||
"""List customized snippets with pagination and search."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
query = SnippetListQuery.model_validate(_normalize_snippet_list_query_args(request.args))
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippets, total, has_more = snippet_service.get_snippets(
|
||||
tenant_id=current_tenant_id,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
keyword=query.keyword,
|
||||
is_published=query.is_published,
|
||||
creators=query.creators,
|
||||
tag_ids=query.tag_ids,
|
||||
)
|
||||
|
||||
return {
|
||||
"data": marshal(snippets, snippet_list_fields),
|
||||
"page": query.page,
|
||||
"limit": query.limit,
|
||||
"total": total,
|
||||
"has_more": has_more,
|
||||
}, 200
|
||||
|
||||
@console_ns.doc("create_customized_snippet")
|
||||
@console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__))
|
||||
@console_ns.response(201, "Snippet created successfully", snippet_model)
|
||||
@console_ns.response(400, "Invalid request")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self):
|
||||
"""Create a new customized snippet."""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
payload = CreateSnippetPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
snippet_type = SnippetType(payload.type)
|
||||
except ValueError:
|
||||
snippet_type = SnippetType.NODE
|
||||
|
||||
try:
|
||||
if payload.graph is not None:
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(payload.graph)
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.create_snippet(
|
||||
tenant_id=current_tenant_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
snippet_type=snippet_type,
|
||||
icon_info=payload.icon_info.model_dump() if payload.icon_info else None,
|
||||
input_fields=[f.model_dump() for f in payload.input_fields] if payload.input_fields else None,
|
||||
account=current_user,
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return marshal(snippet, snippet_fields), 201
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>")
|
||||
class CustomizedSnippetDetailApi(Resource):
|
||||
@console_ns.doc("get_customized_snippet")
|
||||
@console_ns.response(200, "Snippet retrieved successfully", snippet_model)
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, snippet_id: str):
|
||||
"""Get customized snippet details."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
return marshal(snippet, snippet_fields), 200
|
||||
|
||||
@console_ns.doc("update_customized_snippet")
|
||||
@console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__))
|
||||
@console_ns.response(200, "Snippet updated successfully", snippet_model)
|
||||
@console_ns.response(400, "Invalid request")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def patch(self, snippet_id: str):
|
||||
"""Update customized snippet."""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
payload = UpdateSnippetPayload.model_validate(console_ns.payload or {})
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "icon_info" in update_data and update_data["icon_info"] is not None:
|
||||
update_data["icon_info"] = payload.icon_info.model_dump() if payload.icon_info else None
|
||||
|
||||
if not update_data:
|
||||
return {"message": "No valid fields to update"}, 400
|
||||
|
||||
try:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
snippet = session.merge(snippet)
|
||||
snippet = SnippetService.update_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account_id=current_user.id,
|
||||
data=update_data,
|
||||
)
|
||||
session.commit()
|
||||
except ValueError as e:
|
||||
return {"message": str(e)}, 400
|
||||
|
||||
return marshal(snippet, snippet_fields), 200
|
||||
|
||||
@console_ns.doc("delete_customized_snippet")
|
||||
@console_ns.response(204, "Snippet deleted successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete(self, snippet_id: str):
|
||||
"""Delete customized snippet."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
SnippetService.delete_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/export")
|
||||
class CustomizedSnippetExportApi(Resource):
|
||||
@console_ns.doc("export_customized_snippet")
|
||||
@console_ns.doc(description="Export snippet configuration as DSL")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID to export"})
|
||||
@console_ns.response(200, "Snippet exported successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def get(self, snippet_id: str):
|
||||
"""Export snippet as DSL."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
# Get include_secret parameter
|
||||
query = IncludeSecretQuery.model_validate(request.args.to_dict())
|
||||
|
||||
with Session(db.engine) as session:
|
||||
export_service = SnippetDslService(session)
|
||||
result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true")
|
||||
|
||||
# Set filename with .snippet extension
|
||||
filename = f"{snippet.name}.snippet"
|
||||
encoded_filename = quote(filename)
|
||||
|
||||
response = Response(
|
||||
result,
|
||||
mimetype="application/x-yaml",
|
||||
)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
|
||||
response.headers["Content-Type"] = "application/x-yaml"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/imports")
|
||||
class CustomizedSnippetImportApi(Resource):
|
||||
@console_ns.doc("import_customized_snippet")
|
||||
@console_ns.doc(description="Import snippet from DSL")
|
||||
@console_ns.expect(console_ns.models.get(SnippetImportPayload.__name__))
|
||||
@console_ns.response(200, "Snippet imported successfully")
|
||||
@console_ns.response(202, "Import pending confirmation")
|
||||
@console_ns.response(400, "Import failed")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self):
|
||||
"""Import snippet from DSL."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = SnippetImportPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
with Session(db.engine) as session:
|
||||
import_service = SnippetDslService(session)
|
||||
result = import_service.import_snippet(
|
||||
account=current_user,
|
||||
import_mode=payload.mode,
|
||||
yaml_content=payload.yaml_content,
|
||||
yaml_url=payload.yaml_url,
|
||||
snippet_id=payload.snippet_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Return appropriate status code based on result
|
||||
status = result.status
|
||||
if status == ImportStatus.FAILED:
|
||||
return result.model_dump(mode="json"), 400
|
||||
elif status == ImportStatus.PENDING:
|
||||
return result.model_dump(mode="json"), 202
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/imports/<string:import_id>/confirm")
|
||||
class CustomizedSnippetImportConfirmApi(Resource):
|
||||
@console_ns.doc("confirm_snippet_import")
|
||||
@console_ns.doc(description="Confirm a pending snippet import")
|
||||
@console_ns.doc(params={"import_id": "Import ID to confirm"})
|
||||
@console_ns.response(200, "Import confirmed successfully")
|
||||
@console_ns.response(400, "Import failed")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self, import_id: str):
|
||||
"""Confirm a pending snippet import."""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
with Session(db.engine) as session:
|
||||
import_service = SnippetDslService(session)
|
||||
result = import_service.confirm_import(import_id=import_id, account=current_user)
|
||||
session.commit()
|
||||
|
||||
if result.status == ImportStatus.FAILED:
|
||||
return result.model_dump(mode="json"), 400
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/check-dependencies")
|
||||
class CustomizedSnippetCheckDependenciesApi(Resource):
|
||||
@console_ns.doc("check_snippet_dependencies")
|
||||
@console_ns.doc(description="Check dependencies for a snippet")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID"})
|
||||
@console_ns.response(200, "Dependencies checked successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def get(self, snippet_id: str):
|
||||
"""Check dependencies for a snippet."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
import_service = SnippetDslService(session)
|
||||
result = import_service.check_dependencies(snippet=snippet)
|
||||
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/customized-snippets/<uuid:snippet_id>/use-count/increment")
|
||||
class CustomizedSnippetUseCountIncrementApi(Resource):
|
||||
@console_ns.doc("increment_snippet_use_count")
|
||||
@console_ns.doc(description="Increment snippet use count by 1")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID"})
|
||||
@console_ns.response(200, "Use count incremented successfully")
|
||||
@console_ns.response(404, "Snippet not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def post(self, snippet_id: str):
|
||||
"""Increment snippet use count when it is inserted into a workflow."""
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
|
||||
with Session(db.engine) as session:
|
||||
snippet = session.merge(snippet)
|
||||
SnippetService.increment_use_count(session=session, snippet=snippet)
|
||||
session.commit()
|
||||
session.refresh(snippet)
|
||||
|
||||
return {"result": "success", "use_count": snippet.use_count}, 200
|
||||
@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
from flask import Flask, current_app
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import contexts
|
||||
from configs import dify_config
|
||||
@ -68,6 +68,25 @@ def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) ->
|
||||
|
||||
|
||||
class WorkflowAppGenerator(BaseAppGenerator):
|
||||
@staticmethod
|
||||
def _ensure_snippet_start_node_in_worker(*, session: Session, workflow: Workflow) -> Workflow:
|
||||
"""Re-apply snippet virtual Start injection after worker reloads workflow from DB."""
|
||||
if workflow.kind_or_standard != "snippet":
|
||||
return workflow
|
||||
|
||||
from models.snippet import CustomizedSnippet
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
|
||||
snippet = session.scalar(
|
||||
select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == workflow.app_id,
|
||||
CustomizedSnippet.tenant_id == workflow.tenant_id,
|
||||
)
|
||||
)
|
||||
if snippet is None:
|
||||
return workflow
|
||||
return SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet)
|
||||
|
||||
@staticmethod
|
||||
def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool:
|
||||
return not bool(args.get(SKIP_PREPARE_USER_INPUTS_KEY))
|
||||
@ -592,6 +611,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
if workflow is None:
|
||||
raise ValueError("Workflow not found")
|
||||
|
||||
workflow = self._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
# Determine system_user_id based on invocation source
|
||||
is_external_api_call = application_generate_entity.invoke_from in {
|
||||
InvokeFrom.WEB_APP,
|
||||
|
||||
@ -11,6 +11,7 @@ from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, Workfl
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.node_factory import get_default_root_node_id
|
||||
from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer
|
||||
from core.workflow.snippet_start import get_compatible_start_aliases
|
||||
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
|
||||
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
|
||||
from core.workflow.workflow_entry import WorkflowEntry
|
||||
@ -118,7 +119,15 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
|
||||
),
|
||||
)
|
||||
root_node_id = self._root_node_id or get_default_root_node_id(self._workflow.graph_dict)
|
||||
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
|
||||
add_node_inputs_to_pool(
|
||||
variable_pool,
|
||||
node_id=root_node_id,
|
||||
inputs=inputs,
|
||||
aliases=get_compatible_start_aliases(
|
||||
workflow_kind=getattr(self._workflow, "kind_or_standard", None),
|
||||
root_node_id=root_node_id,
|
||||
),
|
||||
)
|
||||
|
||||
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
|
||||
graph = self._init_graph(
|
||||
|
||||
20
api/core/workflow/snippet_start.py
Normal file
20
api/core/workflow/snippet_start.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Shared snippet virtual Start-node identifiers and compatibility helpers.
|
||||
|
||||
Snippet workflows do not persist a real canvas Start node, so the backend
|
||||
injects one at runtime. Existing workflow references commonly use the public
|
||||
selector shape ``#start.<var>#``; keep that contract stable by treating the
|
||||
runtime-only snippet Start node as compatible with the legacy ``start`` id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LEGACY_START_NODE_ID = "start"
|
||||
SNIPPET_VIRTUAL_START_NODE_ID = "__snippet_virtual_start__"
|
||||
|
||||
|
||||
def get_compatible_start_aliases(*, workflow_kind: str | None, root_node_id: str | None) -> tuple[str, ...]:
|
||||
"""Return additional selector ids that should mirror snippet Start inputs."""
|
||||
if workflow_kind == "snippet" and root_node_id == SNIPPET_VIRTUAL_START_NODE_ID:
|
||||
return (LEGACY_START_NODE_ID,)
|
||||
|
||||
return ()
|
||||
@ -10,6 +10,19 @@ def add_variables_to_pool(variable_pool: VariablePool, variables: Sequence[Varia
|
||||
variable_pool.add(variable.selector, variable)
|
||||
|
||||
|
||||
def add_node_inputs_to_pool(variable_pool: VariablePool, *, node_id: str, inputs: Mapping[str, Any]) -> None:
|
||||
for key, value in inputs.items():
|
||||
variable_pool.add((node_id, key), value)
|
||||
def add_node_inputs_to_pool(
|
||||
variable_pool: VariablePool,
|
||||
*,
|
||||
node_id: str,
|
||||
inputs: Mapping[str, Any],
|
||||
aliases: Sequence[str] = (),
|
||||
) -> None:
|
||||
"""Store node inputs under the primary node id and any compatible aliases."""
|
||||
node_ids: list[str] = [node_id]
|
||||
for alias in aliases:
|
||||
if alias not in node_ids:
|
||||
node_ids.append(alias)
|
||||
|
||||
for current_node_id in node_ids:
|
||||
for key, value in inputs.items():
|
||||
variable_pool.add((current_node_id, key), value)
|
||||
|
||||
52
api/fields/snippet_fields.py
Normal file
52
api/fields/snippet_fields.py
Normal file
@ -0,0 +1,52 @@
|
||||
from flask_restx import fields
|
||||
|
||||
from fields.member_fields import simple_account_fields
|
||||
from libs.helper import TimestampField
|
||||
|
||||
tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String}
|
||||
|
||||
# Snippet list item fields (lightweight for list display)
|
||||
snippet_list_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"description": fields.String,
|
||||
"type": fields.String,
|
||||
"version": fields.Integer,
|
||||
"use_count": fields.Integer,
|
||||
"is_published": fields.Boolean,
|
||||
"icon_info": fields.Raw,
|
||||
"tags": fields.List(fields.Nested(tag_fields)),
|
||||
"created_by": fields.String,
|
||||
"author_name": fields.String,
|
||||
"created_at": TimestampField,
|
||||
"updated_by": fields.String,
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
# Full snippet fields (includes creator info and graph data)
|
||||
snippet_fields = {
|
||||
"id": fields.String,
|
||||
"name": fields.String,
|
||||
"description": fields.String,
|
||||
"type": fields.String,
|
||||
"version": fields.Integer,
|
||||
"use_count": fields.Integer,
|
||||
"is_published": fields.Boolean,
|
||||
"icon_info": fields.Raw,
|
||||
"graph": fields.Raw(attribute="graph_dict"),
|
||||
"input_fields": fields.Raw(attribute="input_fields_list"),
|
||||
"tags": fields.List(fields.Nested(tag_fields)),
|
||||
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True),
|
||||
"created_at": TimestampField,
|
||||
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
|
||||
"updated_at": TimestampField,
|
||||
}
|
||||
|
||||
# Pagination response fields
|
||||
snippet_pagination_fields = {
|
||||
"data": fields.List(fields.Nested(snippet_list_fields)),
|
||||
"page": fields.Integer,
|
||||
"limit": fields.Integer,
|
||||
"total": fields.Integer,
|
||||
"has_more": fields.Boolean,
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
"""add customized snippets
|
||||
|
||||
Revision ID: 2b3c4d5e6f70
|
||||
Revises: 8d4c2a1b9f03
|
||||
Create Date: 2026-06-03 11:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models.types
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2b3c4d5e6f70"
|
||||
down_revision = "8d4c2a1b9f03"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg() -> bool:
|
||||
return op.get_bind().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _current_timestamp_default():
|
||||
return sa.text("CURRENT_TIMESTAMP(0)") if _is_pg() else sa.func.current_timestamp()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"customized_snippets",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", models.types.LongText(), nullable=True),
|
||||
sa.Column("type", sa.String(length=50), server_default=sa.text("'node'"), nullable=False),
|
||||
sa.Column("workflow_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("is_published", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False),
|
||||
sa.Column("use_count", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
||||
sa.Column("icon_info", models.types.AdjustedJSON(), nullable=True),
|
||||
sa.Column("input_fields", models.types.LongText(), nullable=True),
|
||||
sa.Column("created_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False),
|
||||
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"),
|
||||
)
|
||||
|
||||
with op.batch_alter_table("customized_snippets", schema=None) as batch_op:
|
||||
batch_op.create_index("customized_snippet_tenant_idx", ["tenant_id"], unique=False)
|
||||
|
||||
with op.batch_alter_table("workflows", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("kind", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("workflows", schema=None) as batch_op:
|
||||
batch_op.drop_column("kind")
|
||||
|
||||
with op.batch_alter_table("customized_snippets", schema=None) as batch_op:
|
||||
batch_op.drop_index("customized_snippet_tenant_idx")
|
||||
|
||||
op.drop_table("customized_snippets")
|
||||
@ -106,6 +106,7 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .snippet import CustomizedSnippet, SnippetType
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
@ -131,12 +132,14 @@ from .workflow import (
|
||||
WorkflowAppLog,
|
||||
WorkflowAppLogCreatedFrom,
|
||||
WorkflowArchiveLog,
|
||||
WorkflowKind,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
WorkflowPause,
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
resolve_workflow_kind,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -179,6 +182,7 @@ __all__ = [
|
||||
"CreatorUserRole",
|
||||
"CredentialPermission",
|
||||
"CredentialPermissionType",
|
||||
"CustomizedSnippet",
|
||||
"DataSourceApiKeyAuthBinding",
|
||||
"DataSourceOauthBinding",
|
||||
"Dataset",
|
||||
@ -227,6 +231,7 @@ __all__ = [
|
||||
"RecommendedApp",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
"Tenant",
|
||||
@ -259,6 +264,7 @@ __all__ = [
|
||||
"WorkflowComment",
|
||||
"WorkflowCommentMention",
|
||||
"WorkflowCommentReply",
|
||||
"WorkflowKind",
|
||||
"WorkflowNodeExecutionModel",
|
||||
"WorkflowNodeExecutionOffload",
|
||||
"WorkflowNodeExecutionTriggeredFrom",
|
||||
@ -269,4 +275,5 @@ __all__ = [
|
||||
"WorkflowToolProvider",
|
||||
"WorkflowTriggerStatus",
|
||||
"WorkflowType",
|
||||
"resolve_workflow_kind",
|
||||
]
|
||||
|
||||
@ -224,6 +224,7 @@ class TagType(StrEnum):
|
||||
|
||||
KNOWLEDGE = "knowledge"
|
||||
APP = "app"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
|
||||
class DatasetMetadataType(StrEnum):
|
||||
|
||||
@ -2499,7 +2499,7 @@ class Tag(TypeBase):
|
||||
sa.Index("tag_name_idx", "name"),
|
||||
)
|
||||
|
||||
TAG_TYPE_LIST = ["knowledge", "app"]
|
||||
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
|
||||
|
||||
123
api/models/snippet.py
Normal file
123
api/models/snippet.py
Normal file
@ -0,0 +1,123 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from libs.uuid_utils import uuidv7
|
||||
|
||||
from .account import Account
|
||||
from .base import Base
|
||||
from .engine import db
|
||||
from .model import Tag, TagBinding
|
||||
from .types import AdjustedJSON, LongText, StringUUID
|
||||
|
||||
|
||||
class SnippetType(StrEnum):
|
||||
"""Snippet Type Enum"""
|
||||
|
||||
NODE = "node"
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class CustomizedSnippet(Base):
|
||||
"""
|
||||
Customized Snippet Model
|
||||
|
||||
Stores reusable workflow components (nodes or node groups) that can be
|
||||
shared across applications within a workspace.
|
||||
"""
|
||||
|
||||
__tablename__ = "customized_snippets"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"),
|
||||
sa.Index("customized_snippet_tenant_idx", "tenant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7()))
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
type: Mapped[str] = mapped_column(String(50), nullable=False, server_default=sa.text("'node'"))
|
||||
|
||||
# Workflow reference for published version
|
||||
workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
# State flags
|
||||
is_published: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("1"))
|
||||
use_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"))
|
||||
|
||||
# Visual customization
|
||||
icon_info: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True)
|
||||
|
||||
# Snippet configuration (stored as JSON text)
|
||||
input_fields: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
)
|
||||
|
||||
@property
|
||||
def graph_dict(self) -> dict[str, Any]:
|
||||
"""Get graph from associated workflow."""
|
||||
if self.workflow_id:
|
||||
from .workflow import Workflow
|
||||
|
||||
workflow = db.session.get(Workflow, self.workflow_id)
|
||||
if workflow:
|
||||
return json.loads(workflow.graph) if workflow.graph else {}
|
||||
return {}
|
||||
|
||||
@property
|
||||
def input_fields_list(self) -> list[dict[str, Any]]:
|
||||
"""Parse input_fields JSON to list."""
|
||||
return json.loads(self.input_fields) if self.input_fields else []
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
"""Get snippet tags."""
|
||||
tags = db.session.scalars(
|
||||
sa.select(Tag)
|
||||
.join(TagBinding, Tag.id == TagBinding.tag_id)
|
||||
.where(
|
||||
TagBinding.target_id == self.id,
|
||||
TagBinding.tenant_id == self.tenant_id,
|
||||
Tag.tenant_id == self.tenant_id,
|
||||
Tag.type == "snippet",
|
||||
)
|
||||
).all()
|
||||
|
||||
return tags or []
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
"""Get the account that created this snippet."""
|
||||
if self.created_by:
|
||||
return db.session.get(Account, self.created_by)
|
||||
return None
|
||||
|
||||
@property
|
||||
def author_name(self) -> str | None:
|
||||
"""Get the creator account name."""
|
||||
account = self.created_by_account
|
||||
return account.name if account else None
|
||||
|
||||
@property
|
||||
def updated_by_account(self) -> Account | None:
|
||||
"""Get the account that last updated this snippet."""
|
||||
if self.updated_by:
|
||||
return db.session.get(Account, self.updated_by)
|
||||
return None
|
||||
|
||||
@property
|
||||
def version_str(self) -> str:
|
||||
"""Get version as string for API response."""
|
||||
return str(self.version)
|
||||
@ -112,6 +112,7 @@ class WorkflowType(StrEnum):
|
||||
WORKFLOW = "workflow"
|
||||
CHAT = "chat"
|
||||
RAG_PIPELINE = "rag-pipeline"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
@classmethod
|
||||
def value_of(cls, value: str) -> "WorkflowType":
|
||||
@ -140,6 +141,26 @@ class WorkflowType(StrEnum):
|
||||
return cls.WORKFLOW if app_mode == AppMode.WORKFLOW else cls.CHAT
|
||||
|
||||
|
||||
class WorkflowKind(StrEnum):
|
||||
STANDARD = "standard"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
@classmethod
|
||||
def value_of(cls, value: str) -> "WorkflowKind":
|
||||
for kind in cls:
|
||||
if kind.value == value:
|
||||
return kind
|
||||
raise ValueError(f"invalid workflow kind value {value}")
|
||||
|
||||
|
||||
def resolve_workflow_kind(kind: str | WorkflowKind | None) -> WorkflowKind:
|
||||
if kind is None:
|
||||
return WorkflowKind.STANDARD
|
||||
if isinstance(kind, WorkflowKind):
|
||||
return kind
|
||||
return WorkflowKind.value_of(kind)
|
||||
|
||||
|
||||
class _InvalidGraphDefinitionError(Exception):
|
||||
pass
|
||||
|
||||
@ -187,6 +208,12 @@ class Workflow(Base): # bug
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
type: Mapped[WorkflowType] = mapped_column(EnumText(WorkflowType, length=255), nullable=False)
|
||||
kind: Mapped[WorkflowKind | None] = mapped_column(
|
||||
EnumText(WorkflowKind, length=255),
|
||||
nullable=True,
|
||||
default=WorkflowKind.STANDARD,
|
||||
server_default=sa.text("'standard'"),
|
||||
)
|
||||
version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="")
|
||||
@ -228,12 +255,14 @@ class Workflow(Base): # bug
|
||||
rag_pipeline_variables: list[dict],
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
kind: str | None = WorkflowKind.STANDARD.value,
|
||||
) -> "Workflow":
|
||||
workflow = Workflow()
|
||||
workflow.id = str(uuid4())
|
||||
workflow.tenant_id = tenant_id
|
||||
workflow.app_id = app_id
|
||||
workflow.type = WorkflowType(type)
|
||||
workflow.kind = resolve_workflow_kind(kind)
|
||||
workflow.version = version
|
||||
workflow.graph = graph
|
||||
workflow.features = features
|
||||
@ -255,6 +284,14 @@ class Workflow(Base): # bug
|
||||
def updated_by_account(self):
|
||||
return db.session.get(Account, self.updated_by) if self.updated_by else None
|
||||
|
||||
@property
|
||||
def kind_or_standard(self) -> str:
|
||||
return self.resolved_kind.value
|
||||
|
||||
@property
|
||||
def resolved_kind(self) -> WorkflowKind:
|
||||
return resolve_workflow_kind(self.kind)
|
||||
|
||||
@property
|
||||
def graph_dict(self) -> Mapping[str, Any]:
|
||||
# TODO(QuantumGhost): Consider caching `graph_dict` to avoid repeated JSON decoding.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,7 @@ class AppListParams(BaseModel):
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all"
|
||||
name: str | None = None
|
||||
tag_ids: list[str] | None = None
|
||||
creator_ids: list[str] | None = None
|
||||
is_created_by_me: bool | None = None
|
||||
status: str | None = None
|
||||
openapi_visible: bool = False
|
||||
@ -138,6 +139,8 @@ class AppService:
|
||||
filters.append(App.enable_api.is_(True))
|
||||
if params.is_created_by_me:
|
||||
filters.append(App.created_by == user_id)
|
||||
if params.creator_ids:
|
||||
filters.append(App.created_by.in_(params.creator_ids))
|
||||
if params.name:
|
||||
from libs.helper import escape_like_pattern
|
||||
|
||||
|
||||
587
api/services/snippet_dsl_service.py
Normal file
587
api/services/snippet_dsl_service.py
Normal file
@ -0,0 +1,587 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml # type: ignore
|
||||
from packaging import version
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
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 graphon.enums import BuiltinNodeTypes
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from models import Account
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.workflow import Workflow
|
||||
from services.plugin.dependencies_analysis import DependenciesAnalysisService
|
||||
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:"
|
||||
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"
|
||||
|
||||
|
||||
class ImportStatus(StrEnum):
|
||||
COMPLETED = "completed"
|
||||
COMPLETED_WITH_WARNINGS = "completed-with-warnings"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class SnippetImportInfo(BaseModel):
|
||||
id: str
|
||||
status: ImportStatus
|
||||
snippet_id: str | None = None
|
||||
current_dsl_version: str = CURRENT_DSL_VERSION
|
||||
imported_dsl_version: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
class CheckDependenciesResult(BaseModel):
|
||||
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _check_version_compatibility(imported_version: str) -> ImportStatus:
|
||||
"""Determine import status based on version comparison"""
|
||||
try:
|
||||
current_ver = version.parse(CURRENT_DSL_VERSION)
|
||||
imported_ver = version.parse(imported_version)
|
||||
except version.InvalidVersion:
|
||||
return ImportStatus.FAILED
|
||||
|
||||
# If imported version is newer than current, always return PENDING
|
||||
if imported_ver > current_ver:
|
||||
return ImportStatus.PENDING
|
||||
|
||||
# If imported version is older than current's major, return PENDING
|
||||
if imported_ver.major < current_ver.major:
|
||||
return ImportStatus.PENDING
|
||||
|
||||
# If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS
|
||||
if imported_ver.minor < current_ver.minor:
|
||||
return ImportStatus.COMPLETED_WITH_WARNINGS
|
||||
|
||||
# If imported version equals or is older than current's micro, return COMPLETED
|
||||
return ImportStatus.COMPLETED
|
||||
|
||||
|
||||
class SnippetPendingData(BaseModel):
|
||||
import_mode: str
|
||||
yaml_content: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
snippet_id: str | None
|
||||
|
||||
|
||||
class CheckDependenciesPendingData(BaseModel):
|
||||
dependencies: list[PluginDependency]
|
||||
snippet_id: str | None
|
||||
|
||||
|
||||
class SnippetDslService:
|
||||
def __init__(self, session: Session):
|
||||
self._session = session
|
||||
|
||||
def _snippet_service(self) -> SnippetService:
|
||||
return SnippetService(session=self._session)
|
||||
|
||||
def import_snippet(
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
import_mode: str,
|
||||
yaml_content: str | None = None,
|
||||
yaml_url: str | None = None,
|
||||
snippet_id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> SnippetImportInfo:
|
||||
"""Import a snippet from YAML content or URL."""
|
||||
import_id = str(uuid.uuid4())
|
||||
|
||||
# Validate import mode
|
||||
try:
|
||||
mode = ImportMode(import_mode)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid import_mode: {import_mode}")
|
||||
|
||||
# Get YAML content
|
||||
content: str = ""
|
||||
if mode == ImportMode.YAML_URL:
|
||||
if not yaml_url:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="yaml_url is required when import_mode is yaml-url",
|
||||
)
|
||||
try:
|
||||
parsed_url = urlparse(yaml_url)
|
||||
if parsed_url.scheme not in ["http", "https"]:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid URL scheme, only http and https are allowed",
|
||||
)
|
||||
response = ssrf_proxy.get(yaml_url, timeout=(10, 30))
|
||||
if response.status_code != 200:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Failed to fetch YAML from URL: {response.status_code}",
|
||||
)
|
||||
content = response.text
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch YAML from URL")
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Failed to fetch YAML from URL: {str(e)}",
|
||||
)
|
||||
elif mode == ImportMode.YAML_CONTENT:
|
||||
if not yaml_content:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="yaml_content is required when import_mode is yaml-content",
|
||||
)
|
||||
content = yaml_content
|
||||
if len(content) > DSL_MAX_SIZE:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse YAML
|
||||
data = yaml.safe_load(content)
|
||||
if not isinstance(data, dict):
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid YAML format: expected a dictionary",
|
||||
)
|
||||
|
||||
# Validate and fix DSL version
|
||||
if not data.get("version"):
|
||||
data["version"] = "0.1.0"
|
||||
|
||||
# Strictly validate kind field
|
||||
kind = data.get("kind")
|
||||
if not kind:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Missing 'kind' field in DSL. Expected 'kind: snippet'.",
|
||||
)
|
||||
if kind != "snippet":
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Invalid DSL kind: expected 'snippet', got '{kind}'. This DSL is for {kind}, not snippet.",
|
||||
)
|
||||
|
||||
imported_version = data.get("version", "0.1.0")
|
||||
if not isinstance(imported_version, str):
|
||||
raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}")
|
||||
status = _check_version_compatibility(imported_version)
|
||||
|
||||
# Extract snippet data
|
||||
snippet_data = data.get("snippet")
|
||||
if not snippet_data:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Missing snippet data in YAML content",
|
||||
)
|
||||
|
||||
# Validate workflow nodes - check for forbidden node types
|
||||
workflow_data = data.get("workflow", {})
|
||||
if workflow_data:
|
||||
graph = workflow_data.get("graph", {})
|
||||
nodes = graph.get("nodes", [])
|
||||
forbidden_nodes_found = []
|
||||
for node in nodes:
|
||||
node_data = node.get("data", {})
|
||||
if not node_data:
|
||||
continue
|
||||
node_type = node_data.get("type", "")
|
||||
if node_type in SNIPPET_FORBIDDEN_NODE_TYPES:
|
||||
forbidden_nodes_found.append(node_type)
|
||||
|
||||
if forbidden_nodes_found:
|
||||
forbidden_types_str = ", ".join(set(forbidden_nodes_found))
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Snippet cannot contain the following node types: {forbidden_types_str}",
|
||||
)
|
||||
|
||||
# If snippet_id is provided, check if it exists
|
||||
snippet = None
|
||||
if snippet_id:
|
||||
stmt = select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == snippet_id,
|
||||
CustomizedSnippet.tenant_id == account.current_tenant_id,
|
||||
)
|
||||
snippet = self._session.scalar(stmt)
|
||||
|
||||
if not snippet:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Snippet not found",
|
||||
)
|
||||
|
||||
# If major version mismatch, store import info in Redis
|
||||
if status == ImportStatus.PENDING:
|
||||
pending_data = SnippetPendingData(
|
||||
import_mode=import_mode,
|
||||
yaml_content=content,
|
||||
name=name,
|
||||
description=description,
|
||||
snippet_id=snippet_id,
|
||||
)
|
||||
redis_client.setex(
|
||||
f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}",
|
||||
IMPORT_INFO_REDIS_EXPIRY,
|
||||
pending_data.model_dump_json(),
|
||||
)
|
||||
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=status,
|
||||
snippet_id=snippet_id,
|
||||
imported_dsl_version=imported_version,
|
||||
)
|
||||
|
||||
# Extract dependencies
|
||||
dependencies = data.get("dependencies", [])
|
||||
check_dependencies_pending_data = None
|
||||
if dependencies:
|
||||
check_dependencies_pending_data = [PluginDependency.model_validate(d) for d in dependencies]
|
||||
|
||||
# Create or update snippet
|
||||
snippet = self._create_or_update_snippet(
|
||||
snippet=snippet,
|
||||
data=data,
|
||||
account=account,
|
||||
name=name,
|
||||
description=description,
|
||||
dependencies=check_dependencies_pending_data,
|
||||
)
|
||||
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=status,
|
||||
snippet_id=snippet.id,
|
||||
imported_dsl_version=imported_version,
|
||||
)
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=f"Invalid YAML format: {str(e)}",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to import snippet")
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def confirm_import(self, *, import_id: str, account: Account) -> SnippetImportInfo:
|
||||
"""
|
||||
Confirm an import that requires confirmation
|
||||
"""
|
||||
redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}"
|
||||
pending_data = redis_client.get(redis_key)
|
||||
|
||||
if not pending_data:
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Import information expired or does not exist",
|
||||
)
|
||||
|
||||
try:
|
||||
if not isinstance(pending_data, str | bytes):
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid import information",
|
||||
)
|
||||
|
||||
pending_data_str = pending_data.decode("utf-8") if isinstance(pending_data, bytes) else pending_data
|
||||
pending = SnippetPendingData.model_validate_json(pending_data_str)
|
||||
|
||||
data = yaml.safe_load(pending.yaml_content)
|
||||
if not isinstance(data, dict):
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error="Invalid YAML format: expected a dictionary",
|
||||
)
|
||||
|
||||
snippet = None
|
||||
if pending.snippet_id:
|
||||
stmt = select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == pending.snippet_id,
|
||||
CustomizedSnippet.tenant_id == account.current_tenant_id,
|
||||
)
|
||||
snippet = self._session.scalar(stmt)
|
||||
|
||||
snippet = self._create_or_update_snippet(
|
||||
snippet=snippet,
|
||||
data=data,
|
||||
account=account,
|
||||
name=pending.name,
|
||||
description=pending.description,
|
||||
)
|
||||
|
||||
redis_client.delete(redis_key)
|
||||
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.COMPLETED,
|
||||
snippet_id=snippet.id,
|
||||
imported_dsl_version=data.get("version", "0.1.0"),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to confirm import")
|
||||
return SnippetImportInfo(
|
||||
id=import_id,
|
||||
status=ImportStatus.FAILED,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def check_dependencies(self, snippet: CustomizedSnippet) -> CheckDependenciesResult:
|
||||
"""
|
||||
Check dependencies for a snippet
|
||||
"""
|
||||
snippet_service = self._snippet_service()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
return CheckDependenciesResult(leaked_dependencies=[])
|
||||
|
||||
dependencies = self._extract_dependencies_from_workflow(workflow)
|
||||
leaked_dependencies = DependenciesAnalysisService.generate_dependencies(
|
||||
tenant_id=snippet.tenant_id, dependencies=dependencies
|
||||
)
|
||||
|
||||
return CheckDependenciesResult(leaked_dependencies=leaked_dependencies)
|
||||
|
||||
def _create_or_update_snippet(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet | None,
|
||||
data: dict,
|
||||
account: Account,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
dependencies: list[PluginDependency] | None = None,
|
||||
) -> CustomizedSnippet:
|
||||
"""
|
||||
Create or update snippet from DSL data
|
||||
"""
|
||||
snippet_data = data.get("snippet", {})
|
||||
workflow_data = data.get("workflow", {})
|
||||
|
||||
# Extract snippet info
|
||||
snippet_name = name or snippet_data.get("name") or "Untitled Snippet"
|
||||
snippet_description = description or snippet_data.get("description") or ""
|
||||
snippet_type_str = snippet_data.get("type", "node")
|
||||
try:
|
||||
snippet_type = SnippetType(snippet_type_str)
|
||||
except ValueError:
|
||||
snippet_type = SnippetType.NODE
|
||||
|
||||
icon_info = snippet_data.get("icon_info", {})
|
||||
input_fields = snippet_data.get("input_fields", [])
|
||||
|
||||
# Create or update snippet
|
||||
if snippet:
|
||||
# Update existing snippet
|
||||
snippet.name = snippet_name
|
||||
snippet.description = snippet_description
|
||||
snippet.type = snippet_type.value
|
||||
snippet.icon_info = icon_info or None
|
||||
snippet.input_fields = json.dumps(input_fields) if input_fields else None
|
||||
snippet.updated_by = account.id
|
||||
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
# Create new snippet
|
||||
snippet = CustomizedSnippet(
|
||||
tenant_id=account.current_tenant_id,
|
||||
name=snippet_name,
|
||||
description=snippet_description,
|
||||
type=snippet_type.value,
|
||||
icon_info=icon_info or None,
|
||||
input_fields=json.dumps(input_fields) if input_fields else None,
|
||||
created_by=account.id,
|
||||
)
|
||||
self._session.add(snippet)
|
||||
self._session.flush()
|
||||
|
||||
# Create or update draft workflow
|
||||
if workflow_data:
|
||||
graph = workflow_data.get("graph", {})
|
||||
|
||||
snippet_service = self._snippet_service()
|
||||
# Get existing workflow hash if exists
|
||||
existing_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
unique_hash = existing_workflow.unique_hash if existing_workflow else None
|
||||
|
||||
snippet_service.sync_draft_workflow(
|
||||
snippet=snippet,
|
||||
graph=graph,
|
||||
unique_hash=unique_hash,
|
||||
account=account,
|
||||
input_fields=input_fields,
|
||||
)
|
||||
|
||||
self._session.commit()
|
||||
return snippet
|
||||
|
||||
def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str:
|
||||
"""
|
||||
Export snippet as DSL
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param include_secret: Whether include secret variable
|
||||
:return: YAML string
|
||||
"""
|
||||
snippet_service = self._snippet_service()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Missing draft workflow configuration, please check.")
|
||||
|
||||
icon_info = snippet.icon_info or {}
|
||||
export_data = {
|
||||
"version": CURRENT_DSL_VERSION,
|
||||
"kind": "snippet",
|
||||
"snippet": {
|
||||
"name": snippet.name,
|
||||
"description": snippet.description or "",
|
||||
"type": snippet.type,
|
||||
"icon_info": icon_info,
|
||||
"input_fields": snippet.input_fields_list,
|
||||
},
|
||||
}
|
||||
|
||||
self._append_workflow_export_data(
|
||||
export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret
|
||||
)
|
||||
|
||||
return yaml.dump(export_data, allow_unicode=True) # type: ignore
|
||||
|
||||
def _append_workflow_export_data(
|
||||
self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool
|
||||
) -> None:
|
||||
"""
|
||||
Append workflow export data
|
||||
"""
|
||||
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", {})
|
||||
if not node_data:
|
||||
continue
|
||||
data_type = node_data.get("type", "")
|
||||
if data_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL:
|
||||
dataset_ids = node_data.get("dataset_ids", [])
|
||||
node["data"]["dataset_ids"] = [
|
||||
self._encrypt_dataset_id(dataset_id=dataset_id, tenant_id=snippet.tenant_id)
|
||||
for dataset_id in dataset_ids
|
||||
]
|
||||
# filter credential id from tool node
|
||||
if not include_secret and data_type == BuiltinNodeTypes.TOOL:
|
||||
node_data.pop("credential_id", None)
|
||||
# filter credential id from agent node
|
||||
if not include_secret and data_type == BuiltinNodeTypes.AGENT:
|
||||
for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []):
|
||||
tool.pop("credential_id", None)
|
||||
|
||||
export_data["workflow"] = workflow_dict
|
||||
dependencies = self._extract_dependencies_from_workflow(workflow)
|
||||
export_data["dependencies"] = [
|
||||
jsonable_encoder(d.model_dump())
|
||||
for d in DependenciesAnalysisService.generate_dependencies(
|
||||
tenant_id=snippet.tenant_id, dependencies=dependencies
|
||||
)
|
||||
]
|
||||
|
||||
def _encrypt_dataset_id(self, *, dataset_id: str, tenant_id: str) -> str:
|
||||
"""
|
||||
Encrypt dataset ID for export
|
||||
"""
|
||||
# For now, just return the dataset_id as-is
|
||||
# In the future, we might want to encrypt it
|
||||
return dataset_id
|
||||
|
||||
def _extract_dependencies_from_workflow(self, workflow: Workflow) -> list[str]:
|
||||
"""
|
||||
Extract dependencies from workflow
|
||||
:param workflow: Workflow instance
|
||||
:return: dependencies list format like ["langgenius/google"]
|
||||
"""
|
||||
graph = workflow.graph_dict
|
||||
dependencies = self._extract_dependencies_from_workflow_graph(graph)
|
||||
return dependencies
|
||||
|
||||
def _extract_dependencies_from_workflow_graph(self, graph: Mapping) -> list[str]:
|
||||
"""
|
||||
Extract dependencies from workflow graph
|
||||
:param graph: Workflow graph
|
||||
:return: dependencies list format like ["langgenius/google"]
|
||||
"""
|
||||
dependencies = []
|
||||
for node in graph.get("nodes", []):
|
||||
node_data = node.get("data", {})
|
||||
if not node_data:
|
||||
continue
|
||||
data_type = node_data.get("type", "")
|
||||
if data_type == BuiltinNodeTypes.TOOL:
|
||||
tool_config = node_data.get("tool_configurations", {})
|
||||
provider_type = tool_config.get("provider_type")
|
||||
provider_name = tool_config.get("provider")
|
||||
if provider_type and provider_name:
|
||||
dependencies.append(f"{provider_name}/{provider_name}")
|
||||
elif data_type == BuiltinNodeTypes.AGENT:
|
||||
agent_parameters = node_data.get("agent_parameters", {})
|
||||
tools = agent_parameters.get("tools", {}).get("value", [])
|
||||
for tool in tools:
|
||||
provider_type = tool.get("provider_type")
|
||||
provider_name = tool.get("provider")
|
||||
if provider_type and provider_name:
|
||||
dependencies.append(f"{provider_name}/{provider_name}")
|
||||
|
||||
return dependencies
|
||||
475
api/services/snippet_generate_service.py
Normal file
475
api/services/snippet_generate_service.py
Normal file
@ -0,0 +1,475 @@
|
||||
"""
|
||||
Service for generating snippet workflow executions.
|
||||
|
||||
Uses an adapter pattern to bridge CustomizedSnippet with the App-based
|
||||
WorkflowAppGenerator. The adapter (_SnippetAsApp) provides the minimal App-like
|
||||
interface needed by the generator, avoiding modifications to core workflow
|
||||
infrastructure.
|
||||
|
||||
Key invariants:
|
||||
- Snippets always run as WORKFLOW mode (not CHAT or ADVANCED_CHAT).
|
||||
- The adapter maps snippet.id to app_id in workflow execution records.
|
||||
- Snippet debugging has no rate limiting (max_active_requests = 0).
|
||||
|
||||
Supported execution modes:
|
||||
- Full workflow run (generate): Runs the entire draft workflow as SSE stream.
|
||||
- Single node run (run_draft_node): Synchronous single-step debugging for regular nodes.
|
||||
- Single iteration run (generate_single_iteration): SSE stream for iteration container nodes.
|
||||
- Single loop run (generate_single_loop): SSE stream for loop container nodes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import Any, Union, cast
|
||||
|
||||
from sqlalchemy.orm import Session, make_transient, sessionmaker
|
||||
|
||||
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
|
||||
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID
|
||||
from factories import file_factory
|
||||
from graphon.file.models import File
|
||||
from models import Account
|
||||
from models.model import App, AppMode, EndUser
|
||||
from models.snippet import CustomizedSnippet
|
||||
from models.workflow import Workflow, WorkflowNodeExecutionModel
|
||||
from services.snippet_service import SnippetService
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_file_access_controller = DatabaseFileAccessController()
|
||||
|
||||
|
||||
class _SnippetAsApp:
|
||||
"""
|
||||
Minimal adapter that wraps a CustomizedSnippet to satisfy the App-like
|
||||
interface required by WorkflowAppGenerator, WorkflowAppConfigManager,
|
||||
and WorkflowService.run_draft_workflow_node.
|
||||
|
||||
Used properties:
|
||||
- id: maps to snippet.id (stored as app_id in workflows table)
|
||||
- tenant_id: maps to snippet.tenant_id
|
||||
- mode: hardcoded to AppMode.WORKFLOW since snippets always run as workflows
|
||||
- max_active_requests: defaults to 0 (no limit) for snippet debugging
|
||||
- app_model_config_id: None (snippets don't have app model configs)
|
||||
"""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
mode: str
|
||||
max_active_requests: int
|
||||
app_model_config_id: str | None
|
||||
|
||||
def __init__(self, snippet: CustomizedSnippet) -> None:
|
||||
self.id = snippet.id
|
||||
self.tenant_id = snippet.tenant_id
|
||||
self.mode = AppMode.WORKFLOW.value
|
||||
self.max_active_requests = 0
|
||||
self.app_model_config_id = None
|
||||
|
||||
|
||||
class SnippetGenerateService:
|
||||
"""
|
||||
Service for running snippet workflow executions.
|
||||
|
||||
Adapts CustomizedSnippet to work with the existing App-based
|
||||
WorkflowAppGenerator infrastructure, avoiding duplication of the
|
||||
complex workflow execution pipeline.
|
||||
"""
|
||||
|
||||
# Specific ID for the injected virtual Start node so it can be recognised
|
||||
_VIRTUAL_START_NODE_ID = SNIPPET_VIRTUAL_START_NODE_ID
|
||||
|
||||
@classmethod
|
||||
def _is_virtual_start_event(cls, message: Mapping[str, Any] | str) -> bool:
|
||||
"""
|
||||
Return True when *message* is a snippet-only virtual Start node event.
|
||||
|
||||
The virtual Start node is injected purely for snippet execution and is
|
||||
not part of the persisted draft graph. Filter its node lifecycle events
|
||||
out of the SSE stream so the frontend only receives nodes that exist on
|
||||
the canvas.
|
||||
"""
|
||||
if not isinstance(message, Mapping):
|
||||
return False
|
||||
|
||||
if message.get("event") not in {"node_started", "node_finished"}:
|
||||
return False
|
||||
|
||||
data = message.get("data")
|
||||
if not isinstance(data, Mapping):
|
||||
return False
|
||||
|
||||
return data.get("node_id") == cls._VIRTUAL_START_NODE_ID
|
||||
|
||||
@classmethod
|
||||
def _filter_virtual_start_events(
|
||||
cls,
|
||||
response: Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None],
|
||||
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None]:
|
||||
"""
|
||||
Drop snippet virtual Start node lifecycle events from stream responses.
|
||||
|
||||
Blocking responses are returned unchanged because they never expose the
|
||||
injected node as a standalone event payload.
|
||||
"""
|
||||
if isinstance(response, Mapping):
|
||||
return response
|
||||
|
||||
def _stream() -> Generator[Mapping[str, Any] | str, None, None]:
|
||||
for message in response:
|
||||
if cls._is_virtual_start_event(message):
|
||||
continue
|
||||
yield message
|
||||
|
||||
return _stream()
|
||||
|
||||
@classmethod
|
||||
def generate(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
streaming: bool = True,
|
||||
session_maker: sessionmaker[Session] | None = None,
|
||||
) -> Mapping[str, Any] | Generator[str, None, None]:
|
||||
"""
|
||||
Run a snippet's draft workflow.
|
||||
|
||||
Retrieves the draft workflow, adapts the snippet to an App-like proxy,
|
||||
then delegates execution to WorkflowAppGenerator.
|
||||
|
||||
If the workflow graph has no Start node, a virtual Start node is injected
|
||||
in-memory so that:
|
||||
1. Graph validation passes (root node must have execution_type=ROOT).
|
||||
2. User inputs are processed into the variable pool by the StartNode logic.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param args: Workflow inputs (must include "inputs" key)
|
||||
:param invoke_from: Source of invocation (typically DEBUGGER)
|
||||
:param streaming: Whether to stream the response
|
||||
:return: Blocking response mapping or SSE streaming generator
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService(session_maker)
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
# Inject a virtual Start node when the graph doesn't have one.
|
||||
workflow = cls._ensure_start_node(workflow, snippet)
|
||||
|
||||
# Adapt snippet to App-like interface for WorkflowAppGenerator
|
||||
app_proxy = cast(App, _SnippetAsApp(snippet))
|
||||
|
||||
response = WorkflowAppGenerator().generate(
|
||||
app_model=app_proxy,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=streaming,
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(cls._filter_virtual_start_events(response))
|
||||
|
||||
@classmethod
|
||||
def run_published(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
session_maker: sessionmaker[Session] | None = None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""
|
||||
Run a snippet's published workflow in non-streaming (blocking) mode.
|
||||
|
||||
Similar to :meth:`generate` but targets the published workflow instead
|
||||
of the draft, and returns the raw blocking response without SSE
|
||||
wrapping. Designed for programmatic callers that need direct workflow outputs.
|
||||
|
||||
:param snippet: CustomizedSnippet instance (must be published)
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param args: Workflow inputs (must include "inputs" key)
|
||||
:param invoke_from: Source of invocation
|
||||
:return: Blocking response mapping with workflow outputs
|
||||
:raises ValueError: If the snippet has no published workflow
|
||||
"""
|
||||
snippet_service = SnippetService(session_maker)
|
||||
workflow = snippet_service.get_published_workflow(snippet)
|
||||
if not workflow:
|
||||
raise ValueError("No published workflow found for snippet")
|
||||
|
||||
# Inject a virtual Start node when the graph doesn't have one.
|
||||
workflow = cls._ensure_start_node(workflow, snippet)
|
||||
|
||||
app_proxy = cast(App, _SnippetAsApp(snippet))
|
||||
|
||||
response = WorkflowAppGenerator().generate(
|
||||
app_model=app_proxy,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
call_depth=0,
|
||||
)
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
def ensure_start_node_for_worker(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow:
|
||||
"""Public wrapper for worker-thread start-node injection."""
|
||||
return cls._ensure_start_node(workflow, snippet)
|
||||
|
||||
@classmethod
|
||||
def _ensure_start_node(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow:
|
||||
"""
|
||||
Return *workflow* with a Start node.
|
||||
|
||||
If the graph already contains a Start node, the original workflow is
|
||||
returned unchanged. Otherwise a virtual Start node is injected and the
|
||||
workflow object is detached from the SQLAlchemy session so the in-memory
|
||||
change is never flushed to the database.
|
||||
"""
|
||||
graph_dict = workflow.graph_dict
|
||||
nodes: list[dict[str, Any]] = graph_dict.get("nodes", [])
|
||||
|
||||
has_start = any(node.get("data", {}).get("type") == "start" for node in nodes)
|
||||
if has_start:
|
||||
return workflow
|
||||
|
||||
modified_graph = cls._inject_virtual_start_node(
|
||||
graph_dict=graph_dict,
|
||||
input_fields=snippet.input_fields_list,
|
||||
)
|
||||
|
||||
# Detach from session to prevent accidental DB persistence of the
|
||||
# modified graph. All attributes remain accessible for read.
|
||||
make_transient(workflow)
|
||||
workflow.graph = json.dumps(modified_graph)
|
||||
return workflow
|
||||
|
||||
@classmethod
|
||||
def _inject_virtual_start_node(
|
||||
cls,
|
||||
graph_dict: Mapping[str, Any],
|
||||
input_fields: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a new graph dict with a virtual Start node prepended.
|
||||
|
||||
The virtual Start node is wired to every existing node that has no
|
||||
incoming edges (i.e. the current root candidates). This guarantees:
|
||||
|
||||
:param graph_dict: Original graph configuration.
|
||||
:param input_fields: Snippet input field definitions from
|
||||
``CustomizedSnippet.input_fields_list``.
|
||||
:return: New graph dict containing the virtual Start node and edges.
|
||||
"""
|
||||
nodes: list[dict[str, Any]] = list(graph_dict.get("nodes", []))
|
||||
edges: list[dict[str, Any]] = list(graph_dict.get("edges", []))
|
||||
|
||||
# Identify nodes with no incoming edges.
|
||||
nodes_with_incoming: set[str] = set()
|
||||
for edge in edges:
|
||||
target = edge.get("target")
|
||||
if isinstance(target, str):
|
||||
nodes_with_incoming.add(target)
|
||||
root_candidate_ids = [n["id"] for n in nodes if n["id"] not in nodes_with_incoming]
|
||||
|
||||
# Build Start node ``variables`` from snippet input fields.
|
||||
start_variables: list[dict[str, Any]] = []
|
||||
for field in input_fields:
|
||||
var: dict[str, Any] = {
|
||||
"variable": field.get("variable", ""),
|
||||
"label": field.get("label", field.get("variable", "")),
|
||||
"type": field.get("type", "text-input"),
|
||||
"required": field.get("required", False),
|
||||
"options": field.get("options", []),
|
||||
}
|
||||
if field.get("max_length") is not None:
|
||||
var["max_length"] = field["max_length"]
|
||||
start_variables.append(var)
|
||||
|
||||
virtual_start_node: dict[str, Any] = {
|
||||
"id": cls._VIRTUAL_START_NODE_ID,
|
||||
"data": {
|
||||
"type": "start",
|
||||
"title": "Start",
|
||||
"variables": start_variables,
|
||||
},
|
||||
}
|
||||
|
||||
# Create edges from virtual Start to each root candidate.
|
||||
new_edges: list[dict[str, Any]] = [
|
||||
{
|
||||
"source": cls._VIRTUAL_START_NODE_ID,
|
||||
"sourceHandle": "source",
|
||||
"target": root_id,
|
||||
"targetHandle": "target",
|
||||
}
|
||||
for root_id in root_candidate_ids
|
||||
]
|
||||
|
||||
return {
|
||||
**graph_dict,
|
||||
"nodes": [virtual_start_node, *nodes],
|
||||
"edges": [*edges, *new_edges],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def run_draft_node(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
node_id: str,
|
||||
user_inputs: Mapping[str, Any],
|
||||
account: Account,
|
||||
query: str = "",
|
||||
files: Sequence[File] | None = None,
|
||||
session_maker: sessionmaker[Session] | None = None,
|
||||
) -> WorkflowNodeExecutionModel:
|
||||
"""
|
||||
Run a single node in a snippet's draft workflow (single-step debugging).
|
||||
|
||||
Retrieves the draft workflow, adapts the snippet to an App-like proxy,
|
||||
parses file inputs, then delegates to WorkflowService.run_draft_workflow_node.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param node_id: ID of the node to run
|
||||
:param user_inputs: User input values for the node
|
||||
:param account: Account initiating the run
|
||||
:param query: Optional query string
|
||||
:param files: Optional parsed file objects
|
||||
:return: WorkflowNodeExecutionModel with execution results
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService(session_maker)
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = cast(App, _SnippetAsApp(snippet))
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
return workflow_service.run_draft_workflow_node(
|
||||
app_model=app_proxy,
|
||||
draft_workflow=draft_workflow,
|
||||
node_id=node_id,
|
||||
user_inputs=user_inputs,
|
||||
account=account,
|
||||
query=query,
|
||||
files=files,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def generate_single_iteration(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
node_id: str,
|
||||
args: Mapping[str, Any],
|
||||
streaming: bool = True,
|
||||
session_maker: sessionmaker[Session] | None = None,
|
||||
) -> Mapping[str, Any] | Generator[str, None, None]:
|
||||
"""
|
||||
Run a single iteration node in a snippet's draft workflow.
|
||||
|
||||
Iteration nodes are container nodes that execute their sub-graph multiple
|
||||
times, producing many events. Therefore, this uses the full WorkflowAppGenerator
|
||||
pipeline with SSE streaming (unlike regular single-step node run).
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param node_id: ID of the iteration node to run
|
||||
:param args: Dict containing 'inputs' key with iteration input data
|
||||
:param streaming: Whether to stream the response (should be True)
|
||||
:return: SSE streaming generator
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService(session_maker)
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = cast(App, _SnippetAsApp(snippet))
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_iteration_generate(
|
||||
app_model=app_proxy,
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args,
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def generate_single_loop(
|
||||
cls,
|
||||
snippet: CustomizedSnippet,
|
||||
user: Union[Account, EndUser],
|
||||
node_id: str,
|
||||
args: Any,
|
||||
streaming: bool = True,
|
||||
session_maker: sessionmaker[Session] | None = None,
|
||||
) -> Mapping[str, Any] | Generator[str, None, None]:
|
||||
"""
|
||||
Run a single loop node in a snippet's draft workflow.
|
||||
|
||||
Loop nodes are container nodes that execute their sub-graph repeatedly,
|
||||
producing many events. Therefore, this uses the full WorkflowAppGenerator
|
||||
pipeline with SSE streaming (unlike regular single-step node run).
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param user: Account or EndUser initiating the run
|
||||
:param node_id: ID of the loop node to run
|
||||
:param args: Pydantic model with 'inputs' attribute containing loop input data
|
||||
:param streaming: Whether to stream the response (should be True)
|
||||
:return: SSE streaming generator
|
||||
:raises ValueError: If the snippet has no draft workflow
|
||||
"""
|
||||
snippet_service = SnippetService(session_maker)
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = cast(App, _SnippetAsApp(snippet))
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_loop_generate(
|
||||
app_model=app_proxy,
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args, # type: ignore[arg-type]
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_files(workflow: Workflow, files: list[dict] | None = None) -> Sequence[File]:
|
||||
"""
|
||||
Parse file mappings into File objects based on workflow configuration.
|
||||
|
||||
:param workflow: Workflow instance for file upload config
|
||||
:param files: Raw file mapping dicts
|
||||
:return: Parsed File objects
|
||||
"""
|
||||
files = files or []
|
||||
file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
|
||||
if file_extra_config is None:
|
||||
return []
|
||||
return file_factory.build_from_mappings(
|
||||
mappings=files,
|
||||
tenant_id=workflow.tenant_id,
|
||||
config=file_extra_config,
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
833
api/services/snippet_service.py
Normal file
833
api/services/snippet_service.py
Normal file
@ -0,0 +1,833 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.db import session_factory
|
||||
from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account, TagBinding
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from models.model import UploadFile
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.tools import WorkflowToolProvider
|
||||
from models.workflow import (
|
||||
Workflow,
|
||||
WorkflowAppLog,
|
||||
WorkflowArchiveLog,
|
||||
WorkflowDraftVariable,
|
||||
WorkflowDraftVariableFile,
|
||||
WorkflowKind,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
)
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.tag_service import TagService
|
||||
from services.workflow_restore import apply_published_workflow_snapshot_to_draft
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Node types not allowed in snippet workflows (sync, publish, DSL import).
|
||||
SNIPPET_FORBIDDEN_NODE_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
BuiltinNodeTypes.START,
|
||||
BuiltinNodeTypes.HUMAN_INPUT,
|
||||
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SnippetService:
|
||||
"""Service for managing customized snippets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_maker: sessionmaker[Session] | Session | None = None,
|
||||
session: Session | None = None,
|
||||
):
|
||||
"""Initialize SnippetService with repository dependencies."""
|
||||
if isinstance(session_maker, Session):
|
||||
session = session_maker
|
||||
session_maker = None
|
||||
if session is not None:
|
||||
session_maker = sessionmaker(bind=session.get_bind(), expire_on_commit=False)
|
||||
elif session_maker is None:
|
||||
session_maker = session_factory.get_session_maker()
|
||||
assert session_maker is not None
|
||||
self._session = session
|
||||
self._session_maker = session_maker
|
||||
self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker
|
||||
)
|
||||
self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
|
||||
|
||||
@contextmanager
|
||||
def _session_scope(self) -> Iterator[Session]:
|
||||
current_session = getattr(self, "_session", None)
|
||||
if current_session is not None:
|
||||
yield current_session
|
||||
return
|
||||
|
||||
with self._session_maker() as session:
|
||||
yield session
|
||||
|
||||
def _commit_if_owned(self, session: Session) -> None:
|
||||
if getattr(self, "_session", None) is None:
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def _snippet_kind_filter():
|
||||
"""Match snippet workflows by business kind."""
|
||||
return Workflow.kind == WorkflowKind.SNIPPET.value
|
||||
|
||||
@staticmethod
|
||||
def _delete_draft_variable_files(*, session: Session, snippet: CustomizedSnippet) -> None:
|
||||
file_ids = list(
|
||||
session.scalars(
|
||||
select(WorkflowDraftVariable.file_id).where(
|
||||
WorkflowDraftVariable.app_id == snippet.id,
|
||||
WorkflowDraftVariable.file_id.is_not(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if not file_ids:
|
||||
return
|
||||
|
||||
file_records = session.execute(
|
||||
select(WorkflowDraftVariableFile.id, WorkflowDraftVariableFile.upload_file_id, UploadFile.key)
|
||||
.join(UploadFile, UploadFile.id == WorkflowDraftVariableFile.upload_file_id)
|
||||
.where(
|
||||
WorkflowDraftVariableFile.tenant_id == snippet.tenant_id,
|
||||
WorkflowDraftVariableFile.app_id == snippet.id,
|
||||
WorkflowDraftVariableFile.id.in_(file_ids),
|
||||
)
|
||||
).all()
|
||||
upload_file_ids: list[str] = []
|
||||
|
||||
from extensions.ext_storage import storage
|
||||
|
||||
for _, upload_file_id, storage_key in file_records:
|
||||
try:
|
||||
storage.delete(storage_key)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete snippet draft variable storage object %s", storage_key)
|
||||
upload_file_ids.append(upload_file_id)
|
||||
|
||||
if upload_file_ids:
|
||||
session.execute(
|
||||
delete(UploadFile)
|
||||
.where(UploadFile.id.in_(upload_file_ids))
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(WorkflowDraftVariableFile)
|
||||
.where(
|
||||
WorkflowDraftVariableFile.tenant_id == snippet.tenant_id,
|
||||
WorkflowDraftVariableFile.app_id == snippet.id,
|
||||
WorkflowDraftVariableFile.id.in_(file_ids),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_archived_workflow_run_files(*, snippet: CustomizedSnippet) -> None:
|
||||
from configs import dify_config
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
|
||||
|
||||
if not (dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED):
|
||||
return
|
||||
|
||||
prefix = f"{snippet.tenant_id}/app_id={snippet.id}/"
|
||||
try:
|
||||
archive_storage = get_archive_storage()
|
||||
except ArchiveStorageNotConfiguredError as e:
|
||||
logger.info("Archive storage not configured, skipping snippet archive file cleanup: %s", e)
|
||||
return
|
||||
|
||||
try:
|
||||
keys = archive_storage.list_objects(prefix)
|
||||
except Exception:
|
||||
logger.exception("Failed to list snippet archive files for prefix %s", prefix)
|
||||
return
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
archive_storage.delete_object(key)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete snippet archive file %s", key)
|
||||
|
||||
@staticmethod
|
||||
def validate_snippet_graph_forbidden_nodes(graph: Mapping[str, Any]) -> None:
|
||||
"""Reject graphs that contain node types not allowed in snippets."""
|
||||
nodes = graph.get("nodes") or []
|
||||
disallowed: list[tuple[str, str]] = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
node_data = node.get("data") or {}
|
||||
node_type = node_data.get("type")
|
||||
if not isinstance(node_type, str):
|
||||
continue
|
||||
if node_type in SNIPPET_FORBIDDEN_NODE_TYPES:
|
||||
node_id = node.get("id")
|
||||
disallowed.append((str(node_id) if node_id is not None else "?", node_type))
|
||||
if not disallowed:
|
||||
return
|
||||
detail = ", ".join(f"{nid}:{t}" for nid, t in disallowed)
|
||||
raise ValueError(
|
||||
f"Snippet workflow cannot contain start, human-input, or knowledge-retrieval nodes. Found: {detail}"
|
||||
)
|
||||
|
||||
# --- CRUD Operations ---
|
||||
|
||||
def get_snippets(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
keyword: str | None = None,
|
||||
is_published: bool | None = None,
|
||||
creators: list[str] | None = None,
|
||||
tag_ids: list[str] | None = None,
|
||||
) -> tuple[Sequence[CustomizedSnippet], int, bool]:
|
||||
"""
|
||||
Get paginated list of snippets with optional search.
|
||||
|
||||
:param tenant_id: Tenant ID
|
||||
:param page: Page number (1-indexed)
|
||||
:param limit: Number of items per page
|
||||
:param keyword: Optional search keyword for name/description
|
||||
:param is_published: Optional filter by published status (True/False/None for all)
|
||||
:param creators: Optional filter by creator account IDs
|
||||
:param tag_ids: Optional filter by tag IDs
|
||||
:return: Tuple of (snippets list, total count, has_more flag)
|
||||
"""
|
||||
stmt = (
|
||||
select(CustomizedSnippet)
|
||||
.where(CustomizedSnippet.tenant_id == tenant_id)
|
||||
.order_by(CustomizedSnippet.created_at.desc())
|
||||
)
|
||||
|
||||
if keyword:
|
||||
stmt = stmt.where(
|
||||
CustomizedSnippet.name.ilike(f"%{keyword}%") | CustomizedSnippet.description.ilike(f"%{keyword}%")
|
||||
)
|
||||
|
||||
if is_published is not None:
|
||||
stmt = stmt.where(CustomizedSnippet.is_published == is_published)
|
||||
|
||||
if creators:
|
||||
stmt = stmt.where(CustomizedSnippet.created_by.in_(creators))
|
||||
|
||||
if tag_ids:
|
||||
target_ids = TagService.get_target_ids_by_tag_ids("snippet", tenant_id, tag_ids)
|
||||
if target_ids:
|
||||
stmt = stmt.where(CustomizedSnippet.id.in_(target_ids))
|
||||
else:
|
||||
return [], 0, False
|
||||
|
||||
with self._session_scope() as session:
|
||||
# Get total count
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = session.scalar(count_stmt) or 0
|
||||
|
||||
# Apply pagination
|
||||
stmt = stmt.limit(limit + 1).offset((page - 1) * limit)
|
||||
snippets = list(session.scalars(stmt).all())
|
||||
|
||||
has_more = len(snippets) > limit
|
||||
if has_more:
|
||||
snippets = snippets[:-1]
|
||||
|
||||
return snippets, total, has_more
|
||||
|
||||
def get_snippet_by_id(
|
||||
self,
|
||||
*,
|
||||
snippet_id: str,
|
||||
tenant_id: str,
|
||||
) -> CustomizedSnippet | None:
|
||||
"""
|
||||
Get snippet by ID with tenant isolation.
|
||||
|
||||
:param snippet_id: Snippet ID
|
||||
:param tenant_id: Tenant ID
|
||||
:return: CustomizedSnippet or None
|
||||
"""
|
||||
with self._session_scope() as session:
|
||||
stmt = select(CustomizedSnippet).where(
|
||||
CustomizedSnippet.id == snippet_id,
|
||||
CustomizedSnippet.tenant_id == tenant_id,
|
||||
)
|
||||
return session.scalar(stmt)
|
||||
|
||||
def create_snippet(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
name: str,
|
||||
description: str | None,
|
||||
snippet_type: SnippetType,
|
||||
icon_info: dict | None,
|
||||
input_fields: list[dict] | None,
|
||||
account: Account,
|
||||
) -> CustomizedSnippet:
|
||||
"""
|
||||
Create a new snippet.
|
||||
|
||||
:param tenant_id: Tenant ID
|
||||
:param name: Snippet name
|
||||
:param description: Snippet description
|
||||
:param snippet_type: Type of snippet (node or group)
|
||||
:param icon_info: Icon information
|
||||
:param input_fields: Input field definitions
|
||||
:param account: Creator account
|
||||
:return: Created CustomizedSnippet
|
||||
"""
|
||||
snippet = CustomizedSnippet(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
description=description or "",
|
||||
type=snippet_type.value,
|
||||
icon_info=icon_info,
|
||||
input_fields=json.dumps(input_fields) if input_fields else None,
|
||||
created_by=account.id,
|
||||
)
|
||||
|
||||
with self._session_scope() as session:
|
||||
session.add(snippet)
|
||||
self._commit_if_owned(session)
|
||||
|
||||
return snippet
|
||||
|
||||
@staticmethod
|
||||
def update_snippet(
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
account_id: str,
|
||||
data: dict,
|
||||
) -> CustomizedSnippet:
|
||||
"""
|
||||
Update snippet attributes.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: Snippet to update
|
||||
:param account_id: ID of account making the update
|
||||
:param data: Dictionary of fields to update
|
||||
:return: Updated CustomizedSnippet
|
||||
"""
|
||||
if "name" in data:
|
||||
snippet.name = data["name"]
|
||||
|
||||
if "description" in data:
|
||||
snippet.description = data["description"]
|
||||
|
||||
if "icon_info" in data:
|
||||
snippet.icon_info = data["icon_info"]
|
||||
|
||||
snippet.updated_by = account_id
|
||||
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
session.add(snippet)
|
||||
return snippet
|
||||
|
||||
@staticmethod
|
||||
def delete_snippet(
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a snippet.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: Snippet to delete
|
||||
:return: True if deleted successfully
|
||||
"""
|
||||
SnippetService._delete_draft_variable_files(session=session, snippet=snippet)
|
||||
session.execute(
|
||||
delete(WorkflowDraftVariable)
|
||||
.where(WorkflowDraftVariable.app_id == snippet.id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(WorkflowToolProvider)
|
||||
.where(
|
||||
WorkflowToolProvider.tenant_id == snippet.tenant_id,
|
||||
WorkflowToolProvider.app_id == snippet.id,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(WorkflowAppLog)
|
||||
.where(
|
||||
WorkflowAppLog.tenant_id == snippet.tenant_id,
|
||||
WorkflowAppLog.app_id == snippet.id,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(WorkflowArchiveLog)
|
||||
.where(
|
||||
WorkflowArchiveLog.tenant_id == snippet.tenant_id,
|
||||
WorkflowArchiveLog.app_id == snippet.id,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
SnippetService._delete_archived_workflow_run_files(snippet=snippet)
|
||||
session.execute(
|
||||
delete(WorkflowNodeExecutionModel)
|
||||
.where(
|
||||
WorkflowNodeExecutionModel.tenant_id == snippet.tenant_id,
|
||||
WorkflowNodeExecutionModel.app_id == snippet.id,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(WorkflowRun)
|
||||
.where(
|
||||
WorkflowRun.tenant_id == snippet.tenant_id,
|
||||
WorkflowRun.app_id == snippet.id,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
SnippetService._snippet_kind_filter(),
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(
|
||||
delete(TagBinding)
|
||||
.where(
|
||||
TagBinding.tenant_id == snippet.tenant_id,
|
||||
TagBinding.target_id == snippet.id,
|
||||
)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.delete(snippet)
|
||||
return True
|
||||
|
||||
# --- Workflow Operations ---
|
||||
|
||||
def get_draft_workflow(self, snippet: CustomizedSnippet) -> Workflow | None:
|
||||
"""
|
||||
Get draft workflow for snippet.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:return: Draft Workflow or None
|
||||
"""
|
||||
with self._session_scope() as session:
|
||||
stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version == "draft",
|
||||
)
|
||||
return session.scalar(stmt)
|
||||
|
||||
def get_published_workflow(self, snippet: CustomizedSnippet) -> Workflow | None:
|
||||
"""
|
||||
Get published workflow for snippet.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:return: Published Workflow or None
|
||||
"""
|
||||
if not snippet.workflow_id:
|
||||
return None
|
||||
|
||||
with self._session_scope() as session:
|
||||
stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.id == snippet.workflow_id,
|
||||
)
|
||||
return session.scalar(stmt)
|
||||
|
||||
def get_published_workflow_by_id(self, snippet: CustomizedSnippet, workflow_id: str) -> Workflow | None:
|
||||
"""
|
||||
Get a published workflow snapshot by ID for snippet history restore.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow_id: Workflow ID
|
||||
:return: Published Workflow or None
|
||||
:raises IsDraftWorkflowError: If the workflow ID points to a draft workflow
|
||||
"""
|
||||
with self._session_scope() as session:
|
||||
stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.id == workflow_id,
|
||||
)
|
||||
workflow = session.scalar(stmt)
|
||||
if not workflow:
|
||||
return None
|
||||
if workflow.version == Workflow.VERSION_DRAFT:
|
||||
raise IsDraftWorkflowError("source workflow must be published")
|
||||
return workflow
|
||||
|
||||
def sync_draft_workflow(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
graph: dict,
|
||||
unique_hash: str | None,
|
||||
account: Account,
|
||||
input_fields: list[dict] | None = None,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Sync draft workflow for snippet.
|
||||
|
||||
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 input_fields: Input fields for snippet
|
||||
:return: Synced Workflow
|
||||
:raises WorkflowHashNotEqualError: If hash mismatch
|
||||
"""
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(graph)
|
||||
|
||||
workflow = self.get_draft_workflow(snippet=snippet)
|
||||
|
||||
if workflow and workflow.unique_hash != unique_hash:
|
||||
raise WorkflowHashNotEqualError()
|
||||
|
||||
# Create draft workflow if not found
|
||||
if not workflow:
|
||||
workflow = Workflow(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
features="{}",
|
||||
type=WorkflowType.WORKFLOW,
|
||||
kind=WorkflowKind.SNIPPET,
|
||||
version="draft",
|
||||
graph=json.dumps(graph),
|
||||
created_by=account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
else:
|
||||
# Update existing draft workflow
|
||||
workflow.graph = json.dumps(graph)
|
||||
workflow.type = WorkflowType.WORKFLOW
|
||||
workflow.kind = WorkflowKind.SNIPPET
|
||||
workflow.updated_by = account.id
|
||||
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
workflow.environment_variables = []
|
||||
workflow.conversation_variables = []
|
||||
|
||||
# Update snippet's input_fields if provided
|
||||
if input_fields is not None:
|
||||
snippet.input_fields = json.dumps(input_fields)
|
||||
snippet.updated_by = account.id
|
||||
snippet.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
with self._session_scope() as session:
|
||||
session.add(workflow)
|
||||
session.add(snippet)
|
||||
self._commit_if_owned(session)
|
||||
return workflow
|
||||
|
||||
def restore_published_workflow_to_draft(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
workflow_id: str,
|
||||
account: Account,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Restore a published snippet workflow snapshot into the draft workflow.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow_id: Published workflow ID
|
||||
:param account: Account making the change
|
||||
:return: Restored draft Workflow
|
||||
:raises WorkflowNotFoundError: If the source workflow does not exist
|
||||
:raises IsDraftWorkflowError: If the source workflow is a draft
|
||||
:raises ValueError: If the restored graph is invalid for snippets
|
||||
"""
|
||||
source_workflow = self.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id)
|
||||
if not source_workflow:
|
||||
raise WorkflowNotFoundError("Workflow not found.")
|
||||
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(source_workflow.graph_dict)
|
||||
|
||||
draft_workflow = self.get_draft_workflow(snippet=snippet)
|
||||
draft_workflow, _is_new_draft = apply_published_workflow_snapshot_to_draft(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
source_workflow=source_workflow,
|
||||
draft_workflow=draft_workflow,
|
||||
account=account,
|
||||
updated_at_factory=lambda: datetime.now(UTC).replace(tzinfo=None),
|
||||
)
|
||||
|
||||
with self._session_scope() as session:
|
||||
session.add(draft_workflow)
|
||||
self._commit_if_owned(session)
|
||||
return draft_workflow
|
||||
|
||||
def publish_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
account: Account,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Publish the draft workflow as a new version.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param account: Account making the change
|
||||
:return: Published Workflow
|
||||
:raises ValueError: If no draft workflow exists
|
||||
"""
|
||||
draft_workflow_stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version == "draft",
|
||||
)
|
||||
draft_workflow = session.scalar(draft_workflow_stmt)
|
||||
if not draft_workflow:
|
||||
raise ValueError("No valid workflow found.")
|
||||
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(draft_workflow.graph_dict)
|
||||
|
||||
# Create new published workflow
|
||||
workflow = Workflow.new(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
version=str(datetime.now(UTC).replace(tzinfo=None)),
|
||||
graph=draft_workflow.graph,
|
||||
features=draft_workflow.features,
|
||||
created_by=account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=draft_workflow.rag_pipeline_variables,
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
marked_name="",
|
||||
marked_comment="",
|
||||
)
|
||||
session.add(workflow)
|
||||
|
||||
# Update snippet version
|
||||
snippet.version += 1
|
||||
snippet.is_published = True
|
||||
snippet.workflow_id = workflow.id
|
||||
snippet.updated_by = account.id
|
||||
session.add(snippet)
|
||||
|
||||
return workflow
|
||||
|
||||
def get_all_published_workflows(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
page: int,
|
||||
limit: int,
|
||||
) -> tuple[Sequence[Workflow], bool]:
|
||||
"""
|
||||
Get all published workflow versions for snippet.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param page: Page number
|
||||
:param limit: Items per page
|
||||
:return: Tuple of (workflows list, has_more flag)
|
||||
"""
|
||||
if not snippet.workflow_id:
|
||||
return [], False
|
||||
|
||||
stmt = (
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version != "draft",
|
||||
)
|
||||
.order_by(Workflow.version.desc())
|
||||
.limit(limit + 1)
|
||||
.offset((page - 1) * limit)
|
||||
)
|
||||
|
||||
workflows = list(session.scalars(stmt).all())
|
||||
has_more = len(workflows) > limit
|
||||
if has_more:
|
||||
workflows = workflows[:-1]
|
||||
|
||||
return workflows, has_more
|
||||
|
||||
# --- Default Block Configs ---
|
||||
|
||||
def get_default_block_configs(self) -> list[dict]:
|
||||
"""
|
||||
Get default block configurations for all node types.
|
||||
|
||||
:return: List of default configurations
|
||||
"""
|
||||
default_block_configs: list[dict[str, Any]] = []
|
||||
for node_class_mapping in NODE_TYPE_CLASSES_MAPPING.values():
|
||||
node_class = node_class_mapping[LATEST_VERSION]
|
||||
default_config = node_class.get_default_config()
|
||||
if default_config:
|
||||
default_block_configs.append(dict(default_config))
|
||||
|
||||
return default_block_configs
|
||||
|
||||
def get_default_block_config(self, node_type: str, filters: dict | None = None) -> Mapping[str, object] | None:
|
||||
"""
|
||||
Get default config for specific node type.
|
||||
|
||||
:param node_type: Node type string
|
||||
:param filters: Optional filters
|
||||
:return: Default configuration or None
|
||||
"""
|
||||
node_type_enum: NodeType = node_type
|
||||
|
||||
if node_type_enum not in NODE_TYPE_CLASSES_MAPPING:
|
||||
return None
|
||||
|
||||
node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION]
|
||||
default_config = node_class.get_default_config(filters=filters)
|
||||
if not default_config:
|
||||
return None
|
||||
|
||||
return default_config
|
||||
|
||||
# --- Workflow Run Operations ---
|
||||
|
||||
def get_snippet_workflow_runs(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
args: dict,
|
||||
) -> InfiniteScrollPagination:
|
||||
"""
|
||||
Get paginated workflow runs for snippet.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param args: Request arguments (last_id, limit)
|
||||
:return: InfiniteScrollPagination result
|
||||
"""
|
||||
limit = int(args.get("limit", 20))
|
||||
last_id = args.get("last_id")
|
||||
|
||||
triggered_from_values = [
|
||||
WorkflowRunTriggeredFrom.DEBUGGING,
|
||||
]
|
||||
|
||||
return self._workflow_run_repo.get_paginated_workflow_runs(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
triggered_from=triggered_from_values,
|
||||
limit=limit,
|
||||
last_id=last_id,
|
||||
)
|
||||
|
||||
def get_snippet_workflow_run(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
run_id: str,
|
||||
) -> WorkflowRun | None:
|
||||
"""
|
||||
Get workflow run details.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param run_id: Workflow run ID
|
||||
:return: WorkflowRun or None
|
||||
"""
|
||||
return self._workflow_run_repo.get_workflow_run_by_id(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
def get_snippet_workflow_run_node_executions(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
run_id: str,
|
||||
) -> Sequence[WorkflowNodeExecutionModel]:
|
||||
"""
|
||||
Get workflow run node execution list.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param run_id: Workflow run ID
|
||||
:return: List of WorkflowNodeExecutionModel
|
||||
"""
|
||||
workflow_run = self.get_snippet_workflow_run(snippet=snippet, run_id=run_id)
|
||||
if not workflow_run:
|
||||
return []
|
||||
|
||||
node_executions = self._node_execution_service_repo.get_executions_by_workflow_run(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
|
||||
return node_executions
|
||||
|
||||
# --- Node Execution Operations ---
|
||||
|
||||
def get_snippet_node_last_run(
|
||||
self,
|
||||
*,
|
||||
snippet: CustomizedSnippet,
|
||||
workflow: Workflow,
|
||||
node_id: str,
|
||||
) -> WorkflowNodeExecutionModel | None:
|
||||
"""
|
||||
Get the most recent execution for a specific node in a snippet workflow.
|
||||
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow: Workflow instance
|
||||
:param node_id: Node identifier
|
||||
:return: WorkflowNodeExecutionModel or None
|
||||
"""
|
||||
return self._node_execution_service_repo.get_node_last_execution(
|
||||
tenant_id=snippet.tenant_id,
|
||||
app_id=snippet.id,
|
||||
workflow_id=workflow.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
# --- Use Count ---
|
||||
|
||||
@staticmethod
|
||||
def increment_use_count(
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
) -> None:
|
||||
"""
|
||||
Increment the use_count when snippet is used.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
"""
|
||||
snippet.use_count += 1
|
||||
session.add(snippet)
|
||||
@ -12,6 +12,7 @@ from extensions.ext_database import db
|
||||
from models.dataset import Dataset
|
||||
from models.enums import TagType
|
||||
from models.model import App, Tag, TagBinding
|
||||
from models.snippet import CustomizedSnippet
|
||||
|
||||
|
||||
class SaveTagPayload(BaseModel):
|
||||
@ -159,7 +160,14 @@ class TagService:
|
||||
@staticmethod
|
||||
def save_tag_binding(payload: TagBindingCreatePayload):
|
||||
TagService.check_target_exists(payload.type, payload.target_id)
|
||||
for tag_id in payload.tag_ids:
|
||||
valid_tag_ids = db.session.scalars(
|
||||
select(Tag.id).where(
|
||||
Tag.id.in_(payload.tag_ids),
|
||||
Tag.tenant_id == current_user.current_tenant_id,
|
||||
Tag.type == payload.type,
|
||||
)
|
||||
).all()
|
||||
for tag_id in valid_tag_ids:
|
||||
tag_binding = db.session.scalar(
|
||||
select(TagBinding)
|
||||
.where(TagBinding.tag_id == tag_id, TagBinding.target_id == payload.target_id)
|
||||
@ -186,6 +194,12 @@ class TagService:
|
||||
TagBinding.target_id == payload.target_id,
|
||||
TagBinding.tag_id.in_(payload.tag_ids),
|
||||
TagBinding.tenant_id == current_user.current_tenant_id,
|
||||
TagBinding.tag_id.in_(
|
||||
select(Tag.id).where(
|
||||
Tag.tenant_id == current_user.current_tenant_id,
|
||||
Tag.type == payload.type,
|
||||
)
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
@ -209,5 +223,13 @@ class TagService:
|
||||
)
|
||||
if not app:
|
||||
raise NotFound("App not found")
|
||||
elif type == "snippet":
|
||||
snippet = db.session.scalar(
|
||||
select(CustomizedSnippet)
|
||||
.where(CustomizedSnippet.tenant_id == current_user.current_tenant_id, CustomizedSnippet.id == target_id)
|
||||
.limit(1)
|
||||
)
|
||||
if not snippet:
|
||||
raise NotFound("Snippet not found")
|
||||
else:
|
||||
raise NotFound("Invalid binding type")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Mapping, Sequence, Set
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
@ -271,12 +271,20 @@ class WorkflowDraftVariableService:
|
||||
)
|
||||
|
||||
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: Set[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
|
||||
base_stmt = select(WorkflowDraftVariable).where(*criteria)
|
||||
if page == 1:
|
||||
|
||||
@ -307,6 +307,61 @@ class TestAppService:
|
||||
)
|
||||
assert len(my_apps.items) == 1
|
||||
|
||||
def test_get_paginate_apps_filters_by_creator_ids(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
"""
|
||||
Test paginated app list with creator ID filters.
|
||||
"""
|
||||
fake = Faker()
|
||||
|
||||
first_account = AccountService.create_account(
|
||||
email=fake.email(),
|
||||
name=fake.name(),
|
||||
interface_language="en-US",
|
||||
password=generate_valid_password(fake),
|
||||
)
|
||||
TenantService.create_owner_tenant_if_not_exist(first_account, name=fake.company())
|
||||
tenant = first_account.current_tenant
|
||||
second_account = AccountService.create_account(
|
||||
email=fake.email(),
|
||||
name=fake.name(),
|
||||
interface_language="en-US",
|
||||
password=generate_valid_password(fake),
|
||||
)
|
||||
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
|
||||
app_service = AppService()
|
||||
app_params = CreateAppParams(
|
||||
name="First Creator App",
|
||||
description="Created by the first account",
|
||||
mode="chat",
|
||||
icon_type="emoji",
|
||||
icon="💬",
|
||||
icon_background="#FF6B6B",
|
||||
)
|
||||
app_service.create_app(tenant.id, app_params, first_account)
|
||||
other_app_params = CreateAppParams(
|
||||
name="Second Creator App",
|
||||
description="Created by the second account",
|
||||
mode="chat",
|
||||
icon_type="emoji",
|
||||
icon="✍️",
|
||||
icon_background="#4ECDC4",
|
||||
)
|
||||
app_service.create_app(tenant.id, other_app_params, second_account)
|
||||
|
||||
filtered_apps = app_service.get_paginate_apps(
|
||||
first_account.id,
|
||||
tenant.id,
|
||||
AppListParams(page=1, limit=10, mode="chat", creator_ids=[second_account.id]),
|
||||
)
|
||||
|
||||
assert filtered_apps is not None
|
||||
assert len(filtered_apps.items) == 1
|
||||
assert filtered_apps.items[0].created_by == second_account.id
|
||||
|
||||
def test_get_paginate_apps_with_tag_filters(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
):
|
||||
|
||||
@ -212,6 +212,24 @@ def test_app_list_query_normalizes_orpc_bracket_tag_ids(app_module):
|
||||
assert query.tag_ids == [first_tag_id, second_tag_id]
|
||||
|
||||
|
||||
def test_app_list_query_normalizes_orpc_bracket_creator_ids(app_module):
|
||||
first_creator_id = "9e8959cf-a67b-4d34-9906-1d687517b248"
|
||||
second_creator_id = "1886f96a-5bf0-42bf-961d-8d2129049076"
|
||||
query_args = MultiDict(
|
||||
[
|
||||
("page", "1"),
|
||||
("limit", "30"),
|
||||
("creator_ids[1]", second_creator_id),
|
||||
("creator_ids[0]", first_creator_id),
|
||||
]
|
||||
)
|
||||
|
||||
normalized = app_module._normalize_app_list_query_args(query_args)
|
||||
query = app_module.AppListQuery.model_validate(normalized)
|
||||
|
||||
assert query.creator_ids == [first_creator_id, second_creator_id]
|
||||
|
||||
|
||||
def test_app_list_query_preserves_regular_query_params(app_module):
|
||||
query_args = MultiDict(
|
||||
[
|
||||
@ -263,6 +281,13 @@ def test_app_list_query_rejects_invalid_bracket_tag_id(app_module):
|
||||
app_module.AppListQuery.model_validate(normalized)
|
||||
|
||||
|
||||
def test_app_list_query_rejects_invalid_bracket_creator_id(app_module):
|
||||
normalized = app_module._normalize_app_list_query_args(MultiDict([("creator_ids[0]", "not-a-uuid")]))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
app_module.AppListQuery.model_validate(normalized)
|
||||
|
||||
|
||||
def test_app_list_query_sorts_bracket_tag_ids_by_index(app_module):
|
||||
first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08"
|
||||
second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c"
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.datastructures import MultiDict
|
||||
|
||||
from controllers.console.snippets.payloads import SnippetListQuery
|
||||
from controllers.console.workspace.snippets import _normalize_snippet_list_query_args
|
||||
|
||||
|
||||
def test_snippet_list_query_accepts_comma_separated_tag_ids() -> None:
|
||||
first = "11111111-1111-1111-1111-111111111111"
|
||||
second = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
query = SnippetListQuery.model_validate({"tag_ids": f"{first},{second}"})
|
||||
|
||||
assert query.tag_ids == [first, second]
|
||||
|
||||
|
||||
def test_snippet_list_query_returns_none_for_blank_tag_ids() -> None:
|
||||
query = SnippetListQuery.model_validate({"tag_ids": " , "})
|
||||
|
||||
assert query.tag_ids is None
|
||||
|
||||
|
||||
def test_snippet_list_query_rejects_invalid_tag_id() -> None:
|
||||
with pytest.raises(ValidationError, match="Invalid UUID format in tag_ids"):
|
||||
SnippetListQuery.model_validate({"tag_ids": "not-a-uuid"})
|
||||
|
||||
|
||||
def test_snippet_list_query_accepts_creator_id_alias() -> None:
|
||||
creator_id = "1886f96a-5bf0-42bf-961d-8d2129049076"
|
||||
|
||||
query = SnippetListQuery.model_validate({"creator_id": creator_id})
|
||||
|
||||
assert query.creators == [creator_id]
|
||||
|
||||
|
||||
def test_snippet_list_query_normalizes_creator_lists() -> None:
|
||||
query = SnippetListQuery.model_validate({"creators": ["account-1", "", " account-2 "]})
|
||||
|
||||
assert query.creators == ["account-1", "account-2"]
|
||||
|
||||
|
||||
def test_snippet_list_query_ignores_unsupported_list_value_type() -> None:
|
||||
query = SnippetListQuery.model_validate({"creators": {"bad": "value"}})
|
||||
|
||||
assert query.creators is None
|
||||
|
||||
|
||||
def test_normalize_snippet_list_query_accepts_indexed_creator_ids() -> None:
|
||||
first = "9e8959cf-a67b-4d34-9906-1d687517b248"
|
||||
second = "1886f96a-5bf0-42bf-961d-8d2129049076"
|
||||
|
||||
normalized = _normalize_snippet_list_query_args(
|
||||
MultiDict(
|
||||
[
|
||||
("creator_ids[1]", second),
|
||||
("creator_ids[0]", first),
|
||||
("keyword", "search"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert normalized == {"keyword": "search", "creators": [first, second]}
|
||||
|
||||
|
||||
def test_normalize_snippet_list_query_accepts_indexed_tag_ids() -> None:
|
||||
first = "11111111-1111-1111-1111-111111111111"
|
||||
second = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
normalized = _normalize_snippet_list_query_args(
|
||||
MultiDict(
|
||||
[
|
||||
("tag_ids[1]", second),
|
||||
("tag_ids[0]", first),
|
||||
("keyword", "search"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert normalized == {"keyword": "search", "tag_ids": [first, second]}
|
||||
@ -0,0 +1,364 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import HTTPException, NotFound
|
||||
|
||||
from controllers.console.snippets import snippet_workflow as snippet_workflow_module
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def factory():
|
||||
service_factory = snippet_workflow_module.SnippetService
|
||||
if isinstance(service_factory, type):
|
||||
return service_factory.__new__(service_factory)
|
||||
return service_factory()
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "_snippet_service", factory)
|
||||
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=Mock()))
|
||||
|
||||
|
||||
def test_get_snippet_requires_snippet_id(app):
|
||||
@snippet_workflow_module.get_snippet
|
||||
def view(**kwargs):
|
||||
return kwargs
|
||||
|
||||
with app.test_request_context("/snippets"):
|
||||
with pytest.raises(ValueError, match="missing snippet_id"):
|
||||
view()
|
||||
|
||||
|
||||
def test_get_snippet_injects_resolved_snippet(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
@snippet_workflow_module.get_snippet
|
||||
def view(**kwargs):
|
||||
return kwargs["snippet"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (SimpleNamespace(id="account-1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(snippet_workflow_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1"):
|
||||
result = view(snippet_id="snippet-1")
|
||||
|
||||
assert result is snippet
|
||||
|
||||
|
||||
def test_get_snippet_raises_not_found_when_snippet_missing(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@snippet_workflow_module.get_snippet
|
||||
def view(**kwargs):
|
||||
return kwargs
|
||||
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (SimpleNamespace(id="account-1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(snippet_workflow_module.SnippetService, "get_snippet_by_id", Mock(return_value=None))
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1"):
|
||||
with pytest.raises(NotFound, match="Snippet not found"):
|
||||
view(snippet_id="snippet-1")
|
||||
|
||||
|
||||
def test_draft_workflow_get_raises_when_missing(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/draft"):
|
||||
with pytest.raises(snippet_workflow_module.DraftWorkflowNotExist):
|
||||
handler(api, snippet=snippet)
|
||||
|
||||
|
||||
def test_draft_workflow_post_returns_400_for_invalid_graph(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
sync_draft_workflow = Mock(side_effect=ValueError("invalid graph"))
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(sync_draft_workflow=sync_draft_workflow),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/draft",
|
||||
method="POST",
|
||||
json={"graph": {"nodes": [], "edges": []}, "hash": "hash-1"},
|
||||
):
|
||||
response, status_code = handler(api, snippet=snippet)
|
||||
|
||||
assert status_code == 400
|
||||
assert response == {"message": "invalid graph"}
|
||||
|
||||
|
||||
def test_draft_config_returns_parallel_depth_limit(app) -> None:
|
||||
api = snippet_workflow_module.SnippetDraftConfigApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/draft/config"):
|
||||
assert handler(api, snippet=SimpleNamespace(id="snippet-1")) == {"parallel_depth_limit": 3}
|
||||
|
||||
|
||||
def test_published_workflow_get_returns_none_when_not_published(app) -> None:
|
||||
api = snippet_workflow_module.SnippetPublishedWorkflowApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/publish"):
|
||||
assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None
|
||||
|
||||
|
||||
def test_published_workflow_post_returns_400_when_publish_fails(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
merged_snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock())
|
||||
|
||||
class SessionContext:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(publish_workflow=Mock(side_effect=ValueError("No valid workflow found."))),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetPublishedWorkflowApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/publish", method="POST", json={}):
|
||||
response, status_code = handler(api, snippet=snippet)
|
||||
|
||||
assert status_code == 400
|
||||
assert response == {"message": "No valid workflow found."}
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_default_block_configs_delegates_to_service(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_default_block_configs = Mock(return_value=[{"type": "llm"}])
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(get_default_block_configs=get_default_block_configs),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDefaultBlockConfigsApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/default-workflow-block-configs"):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1"))
|
||||
|
||||
assert result == [{"type": "llm"}]
|
||||
get_default_block_configs.assert_called_once()
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow = SimpleNamespace(
|
||||
unique_hash="restored-hash",
|
||||
updated_at=None,
|
||||
created_at=datetime(2024, 1, 1),
|
||||
)
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/published-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
response = handler(api, snippet=snippet, workflow_id="published-workflow")
|
||||
|
||||
assert response["result"] == "success"
|
||||
assert response["hash"] == "restored-hash"
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
snippet_workflow_module.WorkflowNotFoundError("Workflow not found")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/published-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(NotFound):
|
||||
handler(api, snippet=snippet, workflow_id="published-workflow")
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_returns_400_for_draft_source(
|
||||
app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
snippet_workflow_module.IsDraftWorkflowError("source workflow must be published")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/draft-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler(api, snippet=snippet, workflow_id="draft-workflow")
|
||||
|
||||
assert exc.value.code == 400
|
||||
assert exc.value.description == snippet_workflow_module.RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_graph(
|
||||
app, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw(
|
||||
ValueError("invalid snippet workflow graph")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/published-workflow/restore",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler(api, snippet=snippet, workflow_id="published-workflow")
|
||||
|
||||
assert exc.value.code == 400
|
||||
assert exc.value.description == "invalid snippet workflow graph"
|
||||
|
||||
|
||||
def test_workflow_run_detail_raises_not_found_when_run_missing(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(get_snippet_workflow_run=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetWorkflowRunDetailApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflow-runs/run-1"):
|
||||
with pytest.raises(NotFound, match="Workflow run not found"):
|
||||
handler(api, snippet=snippet, run_id="run-1")
|
||||
|
||||
|
||||
def test_draft_node_last_run_raises_not_found_when_execution_missing(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
draft_workflow = SimpleNamespace(id="workflow-1")
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(
|
||||
get_draft_workflow=Mock(return_value=draft_workflow),
|
||||
get_snippet_node_last_run=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetDraftNodeLastRunApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/draft/nodes/llm-1/last-run"):
|
||||
with pytest.raises(NotFound, match="Node last run not found"):
|
||||
handler(api, snippet=snippet, node_id="llm-1")
|
||||
|
||||
|
||||
def test_workflow_task_stop_uses_queue_flag_and_graph_command(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
set_stop_flag = Mock()
|
||||
send_stop_command = Mock()
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module.AppQueueManager,
|
||||
"set_stop_flag_no_user_check",
|
||||
set_stop_flag,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"GraphEngineManager",
|
||||
Mock(return_value=SimpleNamespace(send_stop_command=send_stop_command)),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetWorkflowTaskStopApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflow-runs/tasks/task-1/stop", method="POST"):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1"), task_id="task-1")
|
||||
|
||||
assert result == {"result": "success"}
|
||||
set_stop_flag.assert_called_once_with("task-1")
|
||||
send_stop_command.assert_called_once_with("task-1")
|
||||
@ -0,0 +1,279 @@
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.console.snippets import snippet_workflow_draft_variable as module
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList
|
||||
|
||||
app_workflow_draft_variable_module = importlib.import_module("controllers.console.app.workflow_draft_variable")
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_snippet_service_factory(monkeypatch):
|
||||
def factory():
|
||||
service_factory = module.SnippetService
|
||||
if isinstance(service_factory, type):
|
||||
return service_factory.__new__(service_factory)
|
||||
return service_factory()
|
||||
|
||||
monkeypatch.setattr(module, "_snippet_service", factory)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = Flask("test_snippet_workflow_draft_variable")
|
||||
app.config["TESTING"] = True
|
||||
return app
|
||||
|
||||
|
||||
def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable():
|
||||
variable = SimpleNamespace(node_id=SYSTEM_VARIABLE_NODE_ID)
|
||||
|
||||
with pytest.raises(module.NotFoundError, match="variable not found"):
|
||||
module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1")
|
||||
|
||||
|
||||
def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable():
|
||||
variable = SimpleNamespace(node_id=CONVERSATION_VARIABLE_NODE_ID)
|
||||
|
||||
with pytest.raises(module.NotFoundError, match="variable not found"):
|
||||
module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1")
|
||||
|
||||
|
||||
def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable():
|
||||
variable = SimpleNamespace(node_id="llm-1")
|
||||
|
||||
module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1")
|
||||
|
||||
|
||||
def test_conversation_variables_returns_empty_list(app):
|
||||
api = module.SnippetConversationVariableCollectionApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1"))
|
||||
|
||||
assert result == WorkflowDraftVariableList(variables=[])
|
||||
|
||||
|
||||
def test_system_variables_returns_empty_list(app):
|
||||
api = module.SnippetSystemVariableCollectionApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1"))
|
||||
|
||||
assert result == WorkflowDraftVariableList(variables=[])
|
||||
|
||||
|
||||
def test_delete_variable_collection_deletes_current_user_variables(app, monkeypatch):
|
||||
draft_var_service = SimpleNamespace(delete_user_workflow_variables=Mock())
|
||||
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
|
||||
monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1"))
|
||||
db_session = Mock()
|
||||
db_session.return_value = SimpleNamespace()
|
||||
monkeypatch.setattr(module.db, "session", db_session)
|
||||
api = module.SnippetWorkflowVariableCollectionApi()
|
||||
handler = _unwrap(api.delete)
|
||||
|
||||
with app.test_request_context("/", method="DELETE"):
|
||||
response = handler(api, snippet=SimpleNamespace(id="snippet-1"))
|
||||
|
||||
assert response.status_code == 204
|
||||
draft_var_service.delete_user_workflow_variables.assert_called_once_with("snippet-1", user_id="user-1")
|
||||
db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_variable_collection_get_raises_when_draft_workflow_missing(app, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"SnippetService",
|
||||
Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=None))),
|
||||
)
|
||||
|
||||
api = module.SnippetWorkflowVariableCollectionApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/?page=1&limit=20"):
|
||||
with pytest.raises(module.DraftWorkflowNotExist):
|
||||
handler(api, snippet=SimpleNamespace(id="snippet-1"))
|
||||
|
||||
|
||||
def test_node_variable_collection_get_lists_node_variables(app, monkeypatch):
|
||||
variables = WorkflowDraftVariableList(variables=[SimpleNamespace(id="var-1")])
|
||||
list_node_variables = Mock(return_value=variables)
|
||||
|
||||
class SessionContext:
|
||||
def __init__(self, bind, expire_on_commit=False):
|
||||
self.bind = bind
|
||||
self.expire_on_commit = expire_on_commit
|
||||
|
||||
def __enter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(module, "Session", SessionContext)
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"WorkflowDraftVariableService",
|
||||
Mock(return_value=SimpleNamespace(list_node_variables=list_node_variables)),
|
||||
)
|
||||
|
||||
api = module.SnippetNodeVariableCollectionApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1")
|
||||
|
||||
assert result is variables
|
||||
list_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1")
|
||||
|
||||
|
||||
def test_node_variable_collection_delete_deletes_node_variables(app, monkeypatch):
|
||||
delete_node_variables = Mock()
|
||||
draft_var_service = SimpleNamespace(delete_node_variables=delete_node_variables)
|
||||
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
|
||||
monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1"))
|
||||
db_session = Mock()
|
||||
db_session.return_value = SimpleNamespace()
|
||||
monkeypatch.setattr(module.db, "session", db_session)
|
||||
|
||||
api = module.SnippetNodeVariableCollectionApi()
|
||||
handler = _unwrap(api.delete)
|
||||
|
||||
with app.test_request_context("/", method="DELETE"):
|
||||
response = handler(api, snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1")
|
||||
|
||||
assert response.status_code == 204
|
||||
delete_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1")
|
||||
db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_variable_patch_returns_variable_when_no_changes(app, monkeypatch):
|
||||
variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1")
|
||||
draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), update_variable=Mock())
|
||||
db_session = Mock()
|
||||
db_session.return_value = SimpleNamespace()
|
||||
monkeypatch.setattr(module.db, "session", db_session)
|
||||
monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(app_workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
|
||||
|
||||
api = module.SnippetVariableApi()
|
||||
handler = _unwrap(api.patch)
|
||||
|
||||
with app.test_request_context("/", method="PATCH", json={}):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), variable_id="var-1")
|
||||
|
||||
assert result is variable
|
||||
draft_var_service.update_variable.assert_not_called()
|
||||
db_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_variable_delete_deletes_variable(app, monkeypatch):
|
||||
variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1")
|
||||
delete_variable = Mock()
|
||||
draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), delete_variable=delete_variable)
|
||||
db_session = Mock()
|
||||
db_session.return_value = SimpleNamespace()
|
||||
monkeypatch.setattr(module.db, "session", db_session)
|
||||
monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(app_workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
|
||||
|
||||
api = module.SnippetVariableApi()
|
||||
handler = _unwrap(api.delete)
|
||||
|
||||
with app.test_request_context("/", method="DELETE"):
|
||||
response = handler(api, snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1")
|
||||
|
||||
assert response.status_code == 204
|
||||
delete_variable.assert_called_once_with(variable)
|
||||
db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_variable_reset_returns_no_content_when_reset_result_is_none(app, monkeypatch):
|
||||
variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1")
|
||||
draft_workflow = SimpleNamespace(id="workflow-1")
|
||||
draft_var_service = SimpleNamespace(
|
||||
get_variable=Mock(return_value=variable),
|
||||
reset_variable=Mock(return_value=None),
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.return_value = SimpleNamespace()
|
||||
monkeypatch.setattr(module.db, "session", db_session)
|
||||
monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(app_workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1"))
|
||||
monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service))
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"SnippetService",
|
||||
Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))),
|
||||
)
|
||||
|
||||
api = module.SnippetVariableResetApi()
|
||||
handler = _unwrap(api.put)
|
||||
|
||||
with app.test_request_context("/", method="PUT"):
|
||||
response = handler(api, snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1")
|
||||
|
||||
assert response.status_code == 204
|
||||
draft_var_service.reset_variable.assert_called_once_with(draft_workflow, variable)
|
||||
db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_environment_variables_returns_workflow_environment_variables(app, monkeypatch):
|
||||
env_var = SimpleNamespace(
|
||||
id="env-1",
|
||||
name="API_KEY",
|
||||
description="secret",
|
||||
selector=["env", "API_KEY"],
|
||||
value_type=SimpleNamespace(exposed_type=Mock(return_value=SimpleNamespace(value="secret"))),
|
||||
value="sk-test",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"SnippetService",
|
||||
Mock(
|
||||
return_value=SimpleNamespace(
|
||||
get_draft_workflow=Mock(return_value=SimpleNamespace(environment_variables=[env_var]))
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
api = module.SnippetEnvironmentVariableCollectionApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/"):
|
||||
result = handler(api, snippet=SimpleNamespace(id="snippet-1"))
|
||||
|
||||
assert result == {
|
||||
"items": [
|
||||
{
|
||||
"id": "env-1",
|
||||
"type": "env",
|
||||
"name": "API_KEY",
|
||||
"description": "secret",
|
||||
"selector": ["env", "API_KEY"],
|
||||
"value_type": "secret",
|
||||
"value": "sk-test",
|
||||
"edited": False,
|
||||
"visible": True,
|
||||
"editable": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -105,6 +105,30 @@ class TestTagListApi:
|
||||
assert status == 200
|
||||
assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}]
|
||||
|
||||
def test_get_snippet_tags(self, app: Flask):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/?type=snippet"):
|
||||
with (
|
||||
patch(
|
||||
"controllers.console.tag.tags.TagService.get_tags",
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
id="1",
|
||||
name="snippet-tag",
|
||||
type=TagType.SNIPPET,
|
||||
binding_count=1,
|
||||
)
|
||||
],
|
||||
) as get_tags_mock,
|
||||
):
|
||||
result, status = method(api, "tenant-1")
|
||||
|
||||
get_tags_mock.assert_called_once_with("snippet", "tenant-1", None)
|
||||
assert status == 200
|
||||
assert result == [{"id": "1", "name": "snippet-tag", "type": "snippet", "binding_count": "1"}]
|
||||
|
||||
def test_post_success(self, app: Flask, admin_user, tag, payload_patch):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.post)
|
||||
@ -215,6 +239,30 @@ class TestTagBindingCollectionApi:
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_create_snippet_binding_success(self, app: Flask, admin_user, payload_patch):
|
||||
api = TagBindingCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {
|
||||
"tag_ids": ["tag-1"],
|
||||
"target_id": "snippet-1",
|
||||
"type": "snippet",
|
||||
}
|
||||
|
||||
with app.test_request_context("/", json=payload):
|
||||
with (
|
||||
payload_patch(payload),
|
||||
patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock,
|
||||
):
|
||||
result, status = method(api, admin_user)
|
||||
|
||||
save_mock.assert_called_once()
|
||||
binding_payload = save_mock.call_args.args[0]
|
||||
assert binding_payload.type == TagType.SNIPPET
|
||||
assert binding_payload.target_id == "snippet-1"
|
||||
assert status == 200
|
||||
assert result["result"] == "success"
|
||||
|
||||
def test_create_forbidden(self, app: Flask, readonly_user, payload_patch):
|
||||
api = TagBindingCollectionApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@ -0,0 +1,509 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.console.workspace import snippets as snippets_module
|
||||
from services.snippet_dsl_service import ImportStatus, SnippetImportInfo
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_snippet_service_factory(monkeypatch):
|
||||
def factory():
|
||||
return snippets_module.SnippetService.__new__(snippets_module.SnippetService)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "_snippet_service", factory)
|
||||
|
||||
|
||||
class _SessionContext:
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
self.engine = engine
|
||||
self.session = kwargs.pop("session", None)
|
||||
|
||||
def __enter__(self):
|
||||
return self.session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
def test_normalize_snippet_list_query_args_sorts_indexed_values():
|
||||
query_args = snippets_module.MultiDict(
|
||||
[
|
||||
("tag_ids[1]", "tag-b"),
|
||||
("tag_ids[0]", "tag-a"),
|
||||
("creator_ids[1]", "account-b"),
|
||||
("creator_ids[0]", "account-a"),
|
||||
("keyword", "search"),
|
||||
]
|
||||
)
|
||||
|
||||
assert snippets_module._normalize_snippet_list_query_args(query_args) == {
|
||||
"tag_ids": ["tag-a", "tag-b"],
|
||||
"creators": ["account-a", "account-b"],
|
||||
"keyword": "search",
|
||||
}
|
||||
|
||||
|
||||
def test_list_snippets_returns_pagination(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippets = [SimpleNamespace(id="snippet-1")]
|
||||
tag_id = "11111111-1111-1111-1111-111111111111"
|
||||
get_snippets = Mock(return_value=(snippets, 1, False))
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippets", get_snippets)
|
||||
monkeypatch.setattr(snippets_module, "marshal", Mock(return_value=[{"id": "snippet-1"}]))
|
||||
|
||||
api = snippets_module.CustomizedSnippetsApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context(
|
||||
f"/workspaces/current/customized-snippets?page=2&limit=10&tag_ids[0]={tag_id}&creator_ids[0]=account-2"
|
||||
):
|
||||
response, status_code = handler(api)
|
||||
|
||||
assert status_code == 200
|
||||
assert response == {
|
||||
"data": [{"id": "snippet-1"}],
|
||||
"page": 2,
|
||||
"limit": 10,
|
||||
"total": 1,
|
||||
"has_more": False,
|
||||
}
|
||||
get_snippets.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
page=2,
|
||||
limit=10,
|
||||
keyword=None,
|
||||
is_published=None,
|
||||
creators=["account-2"],
|
||||
tag_ids=[tag_id],
|
||||
)
|
||||
|
||||
|
||||
def test_create_snippet_defaults_unknown_type_and_returns_created(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
create_snippet = Mock(return_value=snippet)
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet)
|
||||
monkeypatch.setattr(
|
||||
snippets_module.CreateSnippetPayload,
|
||||
"model_validate",
|
||||
Mock(
|
||||
return_value=SimpleNamespace(
|
||||
name="Snippet",
|
||||
type="unknown",
|
||||
description="Description",
|
||||
graph=None,
|
||||
icon_info=None,
|
||||
input_fields=[],
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"}))
|
||||
|
||||
api = snippets_module.CustomizedSnippetsApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets",
|
||||
method="POST",
|
||||
json={"name": "Snippet", "type": "node", "description": "Description"},
|
||||
):
|
||||
response, status_code = handler(api)
|
||||
|
||||
assert status_code == 201
|
||||
assert response == {"id": "snippet-1"}
|
||||
assert create_snippet.call_args.kwargs["snippet_type"] == snippets_module.SnippetType.NODE
|
||||
|
||||
|
||||
def test_create_snippet_rejects_forbidden_nodes(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
create_snippet = Mock()
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet)
|
||||
|
||||
api = snippets_module.CustomizedSnippetsApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets",
|
||||
method="POST",
|
||||
json={
|
||||
"name": "snippet with invalid node",
|
||||
"type": "node",
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{"id": "knowledge-1", "data": {"type": "knowledge-retrieval"}},
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
},
|
||||
):
|
||||
response, status_code = handler(api)
|
||||
|
||||
assert status_code == 400
|
||||
assert "knowledge-retrieval" in response["message"]
|
||||
create_snippet.assert_not_called()
|
||||
|
||||
|
||||
def test_get_snippet_detail_raises_when_missing(app, monkeypatch):
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None))
|
||||
|
||||
api = snippets_module.CustomizedSnippetDetailApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/workspaces/current/customized-snippets/snippet-1"):
|
||||
with pytest.raises(NotFound, match="Snippet not found"):
|
||||
handler(api, snippet_id="snippet-1")
|
||||
|
||||
|
||||
def test_get_snippet_detail_returns_snippet(app, monkeypatch):
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"}))
|
||||
|
||||
api = snippets_module.CustomizedSnippetDetailApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/workspaces/current/customized-snippets/snippet-1"):
|
||||
response, status_code = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert status_code == 200
|
||||
assert response == {"id": "snippet-1"}
|
||||
|
||||
|
||||
def test_patch_snippet_returns_400_for_empty_payload(app, monkeypatch):
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
monkeypatch.setattr(
|
||||
snippets_module,
|
||||
"current_account_with_tenant",
|
||||
lambda: (SimpleNamespace(id="user-1"), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
|
||||
api = snippets_module.CustomizedSnippetDetailApi()
|
||||
handler = _unwrap(api.patch)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/snippet-1",
|
||||
method="PATCH",
|
||||
json={},
|
||||
):
|
||||
response, status_code = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert status_code == 400
|
||||
assert response == {"message": "No valid fields to update"}
|
||||
|
||||
|
||||
def test_patch_snippet_updates_and_commits(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
updated_snippet = SimpleNamespace(id="snippet-1", name="New")
|
||||
session = SimpleNamespace(merge=Mock(return_value=snippet), commit=Mock())
|
||||
update_snippet = Mock(return_value=updated_snippet)
|
||||
|
||||
class SessionContext(_SessionContext):
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
super().__init__(engine, *args, session=session, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "update_snippet", update_snippet)
|
||||
monkeypatch.setattr(snippets_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1", "name": "New"}))
|
||||
|
||||
api = snippets_module.CustomizedSnippetDetailApi()
|
||||
handler = _unwrap(api.patch)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/snippet-1",
|
||||
method="PATCH",
|
||||
json={"name": "New", "icon_info": {"icon": "star"}},
|
||||
):
|
||||
response, status_code = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert status_code == 200
|
||||
assert response == {"id": "snippet-1", "name": "New"}
|
||||
update_snippet.assert_called_once()
|
||||
assert update_snippet.call_args.kwargs["data"] == {
|
||||
"name": "New",
|
||||
"icon_info": {"icon": "star", "icon_background": None, "icon_type": None, "icon_url": None},
|
||||
}
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_snippet_deletes_and_commits(app, monkeypatch):
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
session = SimpleNamespace(merge=Mock(return_value=snippet), commit=Mock())
|
||||
delete_snippet = Mock()
|
||||
|
||||
class SessionContext(_SessionContext):
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
super().__init__(engine, *args, session=session, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "delete_snippet", delete_snippet)
|
||||
monkeypatch.setattr(snippets_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = snippets_module.CustomizedSnippetDetailApi()
|
||||
handler = _unwrap(api.delete)
|
||||
|
||||
with app.test_request_context("/workspaces/current/customized-snippets/snippet-1", method="DELETE"):
|
||||
response, status_code = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert status_code == 204
|
||||
assert response == ""
|
||||
delete_snippet.assert_called_once_with(session=session, snippet=snippet)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_export_snippet_returns_yaml_attachment(app, monkeypatch):
|
||||
snippet = SimpleNamespace(id="snippet-1", name="Snippet One")
|
||||
export_snippet_dsl = Mock(return_value="version: 0.1.0\nkind: snippet\n")
|
||||
session = SimpleNamespace()
|
||||
|
||||
class SessionContext(_SessionContext):
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
super().__init__(engine, *args, session=session, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
monkeypatch.setattr(
|
||||
snippets_module,
|
||||
"SnippetDslService",
|
||||
Mock(return_value=SimpleNamespace(export_snippet_dsl=export_snippet_dsl)),
|
||||
)
|
||||
monkeypatch.setattr(snippets_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = snippets_module.CustomizedSnippetExportApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/export?include_secret=true"):
|
||||
response = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_data(as_text=True) == "version: 0.1.0\nkind: snippet\n"
|
||||
assert response.headers["Content-Type"] == "application/x-yaml"
|
||||
assert "Snippet%20One.snippet" in response.headers["Content-Disposition"]
|
||||
export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True)
|
||||
|
||||
|
||||
def test_import_snippet_returns_202_for_pending_confirmation(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
result = SnippetImportInfo(id="import-1", status=ImportStatus.PENDING, imported_dsl_version="999.0.0")
|
||||
import_snippet = Mock(return_value=result)
|
||||
session = SimpleNamespace(commit=Mock())
|
||||
|
||||
class _SessionContext:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module, "Session", _SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
snippets_module,
|
||||
"SnippetDslService",
|
||||
Mock(return_value=SimpleNamespace(import_snippet=import_snippet)),
|
||||
)
|
||||
|
||||
api = snippets_module.CustomizedSnippetImportApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/imports",
|
||||
method="POST",
|
||||
json={"mode": "yaml-content", "yaml_content": "kind: snippet"},
|
||||
):
|
||||
response, status_code = handler(api)
|
||||
|
||||
assert status_code == 202
|
||||
assert response["status"] == ImportStatus.PENDING.value
|
||||
import_snippet.assert_called_once()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_import_snippet_returns_400_for_failed_import(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
result = SnippetImportInfo(id="import-1", status=ImportStatus.FAILED, error="Invalid DSL")
|
||||
import_snippet = Mock(return_value=result)
|
||||
session = SimpleNamespace(commit=Mock())
|
||||
|
||||
class SessionContext(_SessionContext):
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
super().__init__(engine, *args, session=session, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
snippets_module,
|
||||
"SnippetDslService",
|
||||
Mock(return_value=SimpleNamespace(import_snippet=import_snippet)),
|
||||
)
|
||||
|
||||
api = snippets_module.CustomizedSnippetImportApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/imports",
|
||||
method="POST",
|
||||
json={"mode": "yaml-content", "yaml_content": "kind: snippet"},
|
||||
):
|
||||
response, status_code = handler(api)
|
||||
|
||||
assert status_code == 400
|
||||
assert response["error"] == "Invalid DSL"
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_import_confirm_returns_200_for_completed_import(app, monkeypatch):
|
||||
user = SimpleNamespace(id="account-1")
|
||||
result = SnippetImportInfo(id="import-1", status=ImportStatus.COMPLETED, snippet_id="snippet-1")
|
||||
confirm_import = Mock(return_value=result)
|
||||
session = SimpleNamespace(commit=Mock())
|
||||
|
||||
class SessionContext(_SessionContext):
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
super().__init__(engine, *args, session=session, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
snippets_module,
|
||||
"SnippetDslService",
|
||||
Mock(return_value=SimpleNamespace(confirm_import=confirm_import)),
|
||||
)
|
||||
|
||||
api = snippets_module.CustomizedSnippetImportConfirmApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/imports/import-1/confirm",
|
||||
method="POST",
|
||||
):
|
||||
response, status_code = handler(api, import_id="import-1")
|
||||
|
||||
assert status_code == 200
|
||||
assert response["snippet_id"] == "snippet-1"
|
||||
confirm_import.assert_called_once_with(import_id="import-1", account=user)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_check_dependencies_raises_when_snippet_missing(app, monkeypatch):
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None))
|
||||
|
||||
api = snippets_module.CustomizedSnippetCheckDependenciesApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/check-dependencies"):
|
||||
with pytest.raises(NotFound, match="Snippet not found"):
|
||||
handler(api, snippet_id="snippet-1")
|
||||
|
||||
|
||||
def test_check_dependencies_returns_dependency_result(app, monkeypatch):
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
check_dependencies = Mock(
|
||||
return_value=SimpleNamespace(model_dump=Mock(return_value={"dependencies": [], "missing_dependencies": []}))
|
||||
)
|
||||
session = SimpleNamespace()
|
||||
|
||||
class SessionContext(_SessionContext):
|
||||
def __init__(self, engine, *args, **kwargs):
|
||||
super().__init__(engine, *args, session=session, **kwargs)
|
||||
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
monkeypatch.setattr(snippets_module, "Session", SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
snippets_module,
|
||||
"SnippetDslService",
|
||||
Mock(return_value=SimpleNamespace(check_dependencies=check_dependencies)),
|
||||
)
|
||||
|
||||
api = snippets_module.CustomizedSnippetCheckDependenciesApi()
|
||||
handler = _unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/check-dependencies"):
|
||||
response, status_code = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert status_code == 200
|
||||
assert response == {"dependencies": [], "missing_dependencies": []}
|
||||
check_dependencies.assert_called_once_with(snippet=snippet)
|
||||
|
||||
|
||||
def test_increment_use_count_raises_when_snippet_missing(app, monkeypatch):
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None))
|
||||
|
||||
api = snippets_module.CustomizedSnippetUseCountIncrementApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/snippet-1/use-count/increment",
|
||||
method="POST",
|
||||
):
|
||||
with pytest.raises(NotFound, match="Snippet not found"):
|
||||
handler(api, snippet_id="snippet-1")
|
||||
|
||||
|
||||
def test_increment_use_count_returns_refreshed_count(app, monkeypatch):
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", use_count=2)
|
||||
merged_snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", use_count=3)
|
||||
session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock(), refresh=Mock())
|
||||
|
||||
class _SessionContext:
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
increment_use_count = Mock()
|
||||
monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1"))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet))
|
||||
monkeypatch.setattr(snippets_module.SnippetService, "increment_use_count", increment_use_count)
|
||||
monkeypatch.setattr(snippets_module, "Session", _SessionContext)
|
||||
monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = snippets_module.CustomizedSnippetUseCountIncrementApi()
|
||||
handler = _unwrap(api.post)
|
||||
|
||||
with app.test_request_context(
|
||||
"/workspaces/current/customized-snippets/snippet-1/use-count/increment",
|
||||
method="POST",
|
||||
):
|
||||
response, status_code = handler(api, snippet_id="snippet-1")
|
||||
|
||||
assert status_code == 200
|
||||
assert response == {"result": "success", "use_count": 3}
|
||||
increment_use_count.assert_called_once_with(session=session, snippet=merged_snippet)
|
||||
session.commit.assert_called_once()
|
||||
session.refresh.assert_called_once_with(merged_snippet)
|
||||
@ -25,6 +25,45 @@ def test_should_prepare_user_inputs_keeps_validation_when_flag_false():
|
||||
assert WorkflowAppGenerator()._should_prepare_user_inputs(args)
|
||||
|
||||
|
||||
def test_ensure_snippet_start_node_in_worker_returns_standard_workflow_without_lookup():
|
||||
session = MagicMock()
|
||||
workflow = SimpleNamespace(kind_or_standard="standard")
|
||||
|
||||
result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
assert result is workflow
|
||||
session.scalar.assert_not_called()
|
||||
|
||||
|
||||
def test_ensure_snippet_start_node_in_worker_returns_snippet_workflow_when_snippet_missing():
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = None
|
||||
workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
assert result is workflow
|
||||
session.scalar.assert_called_once()
|
||||
|
||||
|
||||
def test_ensure_snippet_start_node_in_worker_applies_snippet_start_injection(mocker: MockerFixture):
|
||||
session = MagicMock()
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
session.scalar.return_value = snippet
|
||||
workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1")
|
||||
updated_workflow = SimpleNamespace(name="updated-workflow")
|
||||
ensure_start_node = mocker.patch(
|
||||
"services.snippet_generate_service.SnippetGenerateService.ensure_start_node_for_worker",
|
||||
return_value=updated_workflow,
|
||||
)
|
||||
|
||||
result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
assert result is updated_workflow
|
||||
session.scalar.assert_called_once()
|
||||
ensure_start_node.assert_called_once_with(workflow, snippet)
|
||||
|
||||
|
||||
def test_generate_includes_parent_trace_context_in_extras(monkeypatch):
|
||||
generator = WorkflowAppGenerator()
|
||||
|
||||
|
||||
@ -165,3 +165,71 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert seen_configs == [workflow.graph_dict["nodes"][0]]
|
||||
|
||||
|
||||
def test_run_adds_inputs_with_snippet_compatible_start_aliases() -> None:
|
||||
app_config = MagicMock()
|
||||
app_config.app_id = "app"
|
||||
app_config.tenant_id = "tenant"
|
||||
app_config.workflow_id = "workflow"
|
||||
|
||||
app_generate_entity = MagicMock(spec=WorkflowAppGenerateEntity)
|
||||
app_generate_entity.app_config = app_config
|
||||
app_generate_entity.inputs = {"question": "hello"}
|
||||
app_generate_entity.files = []
|
||||
app_generate_entity.user_id = "user"
|
||||
app_generate_entity.invoke_from = InvokeFrom.SERVICE_API
|
||||
app_generate_entity.workflow_execution_id = "execution-id"
|
||||
app_generate_entity.task_id = "task-id"
|
||||
app_generate_entity.call_depth = 0
|
||||
app_generate_entity.trace_manager = None
|
||||
app_generate_entity.extras = {}
|
||||
app_generate_entity.single_iteration_run = None
|
||||
app_generate_entity.single_loop_run = None
|
||||
|
||||
workflow = MagicMock(spec=Workflow)
|
||||
workflow.tenant_id = "tenant"
|
||||
workflow.app_id = "app"
|
||||
workflow.id = "workflow"
|
||||
workflow.type = "workflow"
|
||||
workflow.version = "v1"
|
||||
workflow.graph_dict = {"nodes": [], "edges": []}
|
||||
workflow.environment_variables = []
|
||||
workflow.kind_or_standard = "snippet"
|
||||
|
||||
runner = WorkflowAppRunner(
|
||||
application_generate_entity=app_generate_entity,
|
||||
queue_manager=MagicMock(spec=AppQueueManager),
|
||||
variable_loader=MagicMock(),
|
||||
workflow=workflow,
|
||||
system_user_id="system-user",
|
||||
workflow_execution_repository=MagicMock(),
|
||||
workflow_node_execution_repository=MagicMock(),
|
||||
)
|
||||
|
||||
mock_workflow_entry = MagicMock()
|
||||
mock_workflow_entry.graph_engine = MagicMock()
|
||||
mock_workflow_entry.graph_engine.layer = MagicMock()
|
||||
mock_workflow_entry.run.return_value = iter([])
|
||||
|
||||
with (
|
||||
patch("core.app.apps.workflow.app_runner.RedisChannel"),
|
||||
patch("core.app.apps.workflow.app_runner.redis_client"),
|
||||
patch("core.app.apps.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry),
|
||||
patch("core.app.apps.workflow.app_runner.build_system_variables", return_value={}),
|
||||
patch("core.app.apps.workflow.app_runner.build_bootstrap_variables", return_value=[]),
|
||||
patch("core.app.apps.workflow.app_runner.add_variables_to_pool"),
|
||||
patch("core.app.apps.workflow.app_runner.get_default_root_node_id", return_value="root-node"),
|
||||
patch(
|
||||
"core.app.apps.workflow.app_runner.get_compatible_start_aliases", return_value=("legacy-start",)
|
||||
) as aliases,
|
||||
patch("core.app.apps.workflow.app_runner.add_node_inputs_to_pool") as add_inputs,
|
||||
patch.object(runner, "_init_graph", return_value=MagicMock()),
|
||||
):
|
||||
runner.run()
|
||||
|
||||
aliases.assert_called_once_with(workflow_kind="snippet", root_node_id="root-node")
|
||||
add_inputs.assert_called_once()
|
||||
assert add_inputs.call_args.kwargs["node_id"] == "root-node"
|
||||
assert add_inputs.call_args.kwargs["inputs"] == {"question": "hello"}
|
||||
assert add_inputs.call_args.kwargs["aliases"] == ("legacy-start",)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -13,6 +15,40 @@ from models.model import AppMode
|
||||
|
||||
|
||||
class TestWorkflowAppGeneratorValidation:
|
||||
def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow(self):
|
||||
workflow = SimpleNamespace(kind_or_standard="workflow")
|
||||
session = SimpleNamespace(scalar=Mock())
|
||||
|
||||
result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
assert result is workflow
|
||||
session.scalar.assert_not_called()
|
||||
|
||||
def test_ensure_snippet_start_node_returns_original_when_snippet_missing(self):
|
||||
workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1")
|
||||
session = SimpleNamespace(scalar=Mock(return_value=None))
|
||||
|
||||
result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
assert result is workflow
|
||||
session.scalar.assert_called_once()
|
||||
|
||||
def test_ensure_snippet_start_node_delegates_when_snippet_exists(self, monkeypatch: pytest.MonkeyPatch):
|
||||
workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1")
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
injected_workflow = SimpleNamespace(id="workflow-injected")
|
||||
session = SimpleNamespace(scalar=Mock(return_value=snippet))
|
||||
ensure_start_node = Mock(return_value=injected_workflow)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetGenerateService.ensure_start_node_for_worker",
|
||||
ensure_start_node,
|
||||
)
|
||||
|
||||
result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow)
|
||||
|
||||
assert result is injected_workflow
|
||||
ensure_start_node.assert_called_once_with(workflow, snippet)
|
||||
|
||||
def test_should_prepare_user_inputs(self):
|
||||
generator = WorkflowAppGenerator()
|
||||
|
||||
@ -391,3 +427,83 @@ class TestWorkflowAppGeneratorResume:
|
||||
assert result.ok is True
|
||||
assert captured_entity is not None
|
||||
assert captured_entity.trace_manager is existing_trace_manager
|
||||
|
||||
|
||||
class TestWorkflowAppGeneratorWorker:
|
||||
def test_generate_worker_uses_end_user_session_for_external_invocation(self, monkeypatch: pytest.MonkeyPatch):
|
||||
generator = WorkflowAppGenerator()
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
id="workflow-id",
|
||||
tenant_id="tenant",
|
||||
app_id="app",
|
||||
graph_dict={},
|
||||
type="workflow",
|
||||
version="1",
|
||||
)
|
||||
end_user = SimpleNamespace(id="end-user-id", session_id="session-id")
|
||||
session = SimpleNamespace(scalar=Mock(side_effect=[workflow, end_user]))
|
||||
|
||||
class _SessionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
runner_kwargs = {}
|
||||
|
||||
class _Runner:
|
||||
def __init__(self, **kwargs):
|
||||
runner_kwargs.update(kwargs)
|
||||
|
||||
def run(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.preserve_flask_contexts",
|
||||
lambda flask_app, context_vars: contextlib.nullcontext(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.session_factory.create_session",
|
||||
lambda: _SessionContext(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.WorkflowAppGenerator._ensure_snippet_start_node_in_worker",
|
||||
lambda self, *, session, workflow: workflow,
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppRunner", _Runner)
|
||||
|
||||
app_config = WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant",
|
||||
app_id="app",
|
||||
app_mode=AppMode.WORKFLOW,
|
||||
additional_features=AppAdditionalFeatures(),
|
||||
variables=[],
|
||||
workflow_id="workflow-id",
|
||||
)
|
||||
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
|
||||
task_id="task",
|
||||
app_config=app_config,
|
||||
inputs={},
|
||||
files=[],
|
||||
user_id="end-user-id",
|
||||
stream=False,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
extras={},
|
||||
trace_manager=None,
|
||||
workflow_execution_id="run-id",
|
||||
call_depth=0,
|
||||
)
|
||||
|
||||
generator._generate_worker(
|
||||
flask_app=SimpleNamespace(),
|
||||
application_generate_entity=application_generate_entity,
|
||||
queue_manager=SimpleNamespace(),
|
||||
context=SimpleNamespace(),
|
||||
variable_loader=SimpleNamespace(),
|
||||
workflow_execution_repository=SimpleNamespace(),
|
||||
workflow_node_execution_repository=SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert runner_kwargs["system_user_id"] == "session-id"
|
||||
|
||||
23
api/tests/unit_tests/core/workflow/test_snippet_start.py
Normal file
23
api/tests/unit_tests/core/workflow/test_snippet_start.py
Normal file
@ -0,0 +1,23 @@
|
||||
from core.workflow.snippet_start import (
|
||||
LEGACY_START_NODE_ID,
|
||||
SNIPPET_VIRTUAL_START_NODE_ID,
|
||||
get_compatible_start_aliases,
|
||||
)
|
||||
|
||||
|
||||
def test_get_compatible_start_aliases_returns_legacy_start_for_snippet_virtual_start() -> None:
|
||||
aliases = get_compatible_start_aliases(
|
||||
workflow_kind="snippet",
|
||||
root_node_id=SNIPPET_VIRTUAL_START_NODE_ID,
|
||||
)
|
||||
|
||||
assert aliases == (LEGACY_START_NODE_ID,)
|
||||
|
||||
|
||||
def test_get_compatible_start_aliases_returns_empty_for_non_snippet_roots() -> None:
|
||||
aliases = get_compatible_start_aliases(
|
||||
workflow_kind="workflow",
|
||||
root_node_id=SNIPPET_VIRTUAL_START_NODE_ID,
|
||||
)
|
||||
|
||||
assert aliases == ()
|
||||
@ -5,7 +5,7 @@ from collections import defaultdict
|
||||
import pytest
|
||||
|
||||
from core.workflow.system_variables import build_system_variables, system_variables_to_mapping
|
||||
from core.workflow.variable_pool_initializer import add_variables_to_pool
|
||||
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
|
||||
from core.workflow.variable_prefixes import (
|
||||
CONVERSATION_VARIABLE_NODE_ID,
|
||||
ENVIRONMENT_VARIABLE_NODE_ID,
|
||||
@ -80,6 +80,25 @@ def test_get_file_attribute(pool, file):
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_add_node_inputs_to_pool_stores_inputs_under_aliases():
|
||||
pool = VariablePool()
|
||||
|
||||
add_node_inputs_to_pool(
|
||||
pool,
|
||||
node_id="__snippet_virtual_start__",
|
||||
inputs={"query": "hello"},
|
||||
aliases=("start", "__snippet_virtual_start__"),
|
||||
)
|
||||
|
||||
primary_value = pool.get(["__snippet_virtual_start__", "query"])
|
||||
alias_value = pool.get(["start", "query"])
|
||||
|
||||
assert primary_value is not None
|
||||
assert primary_value.value == "hello"
|
||||
assert alias_value is not None
|
||||
assert alias_value.value == "hello"
|
||||
|
||||
|
||||
class TestVariablePool:
|
||||
def test_constructor(self):
|
||||
pool = VariablePool()
|
||||
|
||||
28
api/tests/unit_tests/fields/test_snippet_fields.py
Normal file
28
api/tests/unit_tests/fields/test_snippet_fields.py
Normal file
@ -0,0 +1,28 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from flask_restx import marshal
|
||||
|
||||
from fields.snippet_fields import snippet_list_fields
|
||||
|
||||
|
||||
def test_snippet_list_fields_include_author_name() -> None:
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
name="Snippet",
|
||||
description="Reusable node",
|
||||
type="node",
|
||||
version=1,
|
||||
use_count=0,
|
||||
is_published=False,
|
||||
icon_info=None,
|
||||
tags=[],
|
||||
created_by="account-1",
|
||||
author_name="Alice",
|
||||
created_at=None,
|
||||
updated_by="account-1",
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
result = marshal(snippet, snippet_list_fields)
|
||||
|
||||
assert result["author_name"] == "Alice"
|
||||
76
api/tests/unit_tests/models/test_snippet.py
Normal file
76
api/tests/unit_tests/models/test_snippet.py
Normal file
@ -0,0 +1,76 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from models.snippet import CustomizedSnippet
|
||||
|
||||
|
||||
def test_graph_dict_returns_empty_without_workflow_id() -> None:
|
||||
snippet = CustomizedSnippet(workflow_id=None)
|
||||
|
||||
assert snippet.graph_dict == {}
|
||||
|
||||
|
||||
def test_graph_dict_loads_published_workflow_graph(monkeypatch) -> None:
|
||||
workflow = SimpleNamespace(graph=json.dumps({"nodes": [{"id": "llm-1"}], "edges": []}))
|
||||
session = SimpleNamespace(get=Mock(return_value=workflow))
|
||||
monkeypatch.setattr("models.snippet.db.session", session)
|
||||
snippet = CustomizedSnippet(workflow_id="workflow-1")
|
||||
|
||||
assert snippet.graph_dict == {"nodes": [{"id": "llm-1"}], "edges": []}
|
||||
session.get.assert_called_once()
|
||||
|
||||
|
||||
def test_graph_dict_returns_empty_when_workflow_missing(monkeypatch) -> None:
|
||||
session = SimpleNamespace(get=Mock(return_value=None))
|
||||
monkeypatch.setattr("models.snippet.db.session", session)
|
||||
snippet = CustomizedSnippet(workflow_id="missing-workflow")
|
||||
|
||||
assert snippet.graph_dict == {}
|
||||
|
||||
|
||||
def test_input_fields_list_parses_json_or_returns_empty() -> None:
|
||||
assert CustomizedSnippet(input_fields=None).input_fields_list == []
|
||||
assert CustomizedSnippet(input_fields=json.dumps([{"variable": "query"}])).input_fields_list == [
|
||||
{"variable": "query"}
|
||||
]
|
||||
|
||||
|
||||
def test_tags_returns_query_results_or_empty(monkeypatch) -> None:
|
||||
tags = [SimpleNamespace(id="tag-1")]
|
||||
session = SimpleNamespace(scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=tags))))
|
||||
monkeypatch.setattr("models.snippet.db.session", session)
|
||||
snippet = CustomizedSnippet(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
assert snippet.tags == tags
|
||||
|
||||
session.scalars.return_value.all.return_value = None
|
||||
assert snippet.tags == []
|
||||
|
||||
|
||||
def test_account_properties_and_author_name(monkeypatch) -> None:
|
||||
account = SimpleNamespace(id="account-1", name="Ada")
|
||||
updated_account = SimpleNamespace(id="account-2", name="Grace")
|
||||
session = SimpleNamespace(
|
||||
get=Mock(side_effect=lambda _model, account_id: account if account_id == "account-1" else updated_account)
|
||||
)
|
||||
monkeypatch.setattr("models.snippet.db.session", session)
|
||||
snippet = CustomizedSnippet(created_by="account-1", updated_by="account-2")
|
||||
|
||||
assert snippet.created_by_account is account
|
||||
assert snippet.author_name == "Ada"
|
||||
assert snippet.updated_by_account is updated_account
|
||||
|
||||
|
||||
def test_account_properties_return_none_without_account_ids() -> None:
|
||||
snippet = CustomizedSnippet(created_by=None, updated_by=None)
|
||||
|
||||
assert snippet.created_by_account is None
|
||||
assert snippet.author_name is None
|
||||
assert snippet.updated_by_account is None
|
||||
|
||||
|
||||
def test_version_str_returns_string_value() -> None:
|
||||
snippet = CustomizedSnippet(version=7)
|
||||
|
||||
assert snippet.version_str == "7"
|
||||
643
api/tests/unit_tests/services/test_snippet_dsl_service.py
Normal file
643
api/tests/unit_tests/services/test_snippet_dsl_service.py
Normal file
@ -0,0 +1,643 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from graphon.nodes import BuiltinNodeTypes
|
||||
from services.snippet_dsl_service import (
|
||||
ImportMode,
|
||||
ImportStatus,
|
||||
SnippetDslService,
|
||||
SnippetPendingData,
|
||||
_check_version_compatibility,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[
|
||||
("not-a-version", ImportStatus.FAILED),
|
||||
("999.0.0", ImportStatus.PENDING),
|
||||
("0.1.0", ImportStatus.COMPLETED),
|
||||
],
|
||||
)
|
||||
def test_check_version_compatibility_special_cases(version, expected):
|
||||
assert _check_version_compatibility(version) == expected
|
||||
|
||||
|
||||
def test_check_version_compatibility_returns_pending_for_older_major() -> None:
|
||||
assert _check_version_compatibility("0.0.9") == ImportStatus.COMPLETED_WITH_WARNINGS
|
||||
|
||||
|
||||
def test_import_snippet_rejects_invalid_mode():
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid import_mode"):
|
||||
service.import_snippet(account=SimpleNamespace(current_tenant_id="tenant-1"), import_mode="bad-mode")
|
||||
|
||||
|
||||
def test_import_snippet_requires_yaml_content():
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "yaml_content is required when import_mode is yaml-content"
|
||||
|
||||
|
||||
def test_import_snippet_requires_yaml_url() -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "yaml_url is required when import_mode is yaml-url"
|
||||
|
||||
|
||||
def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
yaml_url="file:///tmp/snippet.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Invalid URL scheme, only http and https are allowed"
|
||||
|
||||
|
||||
def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.ssrf_proxy.get",
|
||||
Mock(return_value=SimpleNamespace(status_code=404, text="not found")),
|
||||
)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
yaml_url="https://example.com/snippet.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Failed to fetch YAML from URL: 404"
|
||||
|
||||
|
||||
def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.ssrf_proxy.get",
|
||||
Mock(return_value=SimpleNamespace(status_code=200, text="too large")),
|
||||
)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
yaml_url="https://example.com/snippet.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "YAML content size exceeds maximum limit" in result.error
|
||||
|
||||
|
||||
def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.ssrf_proxy.get",
|
||||
Mock(side_effect=RuntimeError("network down")),
|
||||
)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_URL.value,
|
||||
yaml_url="https://example.com/snippet.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Failed to fetch YAML from URL: network down"
|
||||
|
||||
|
||||
def test_import_snippet_rejects_oversized_yaml_content(monkeypatch) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3)
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content="too large",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "YAML content size exceeds maximum limit" in result.error
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("yaml_content", "expected_error"),
|
||||
[
|
||||
("- item", "Invalid YAML format: expected a dictionary"),
|
||||
("version: 0.1.0\nsnippet:\n name: Missing Kind\n", "Missing 'kind' field in DSL"),
|
||||
(
|
||||
"version: 0.1.0\nkind: app\nsnippet:\n name: Wrong Kind\n",
|
||||
"Invalid DSL kind: expected 'snippet', got 'app'",
|
||||
),
|
||||
("version: 0.1.0\nkind: snippet\n", "Missing snippet data in YAML content"),
|
||||
],
|
||||
)
|
||||
def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error) -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content=yaml_content,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert expected_error in result.error
|
||||
|
||||
|
||||
def test_import_snippet_returns_failed_for_invalid_version_type() -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content="version: 1\nkind: snippet\nsnippet:\n name: Bad Version\n",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert "Invalid version type" in result.error
|
||||
|
||||
|
||||
def test_import_snippet_returns_failed_for_invalid_yaml_syntax() -> None:
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content="kind: snippet\nsnippet: [",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error.startswith("Invalid YAML format:")
|
||||
|
||||
|
||||
def test_import_snippet_rejects_forbidden_nodes():
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
yaml_content = """
|
||||
version: 0.3.0
|
||||
kind: snippet
|
||||
snippet:
|
||||
name: Bad Snippet
|
||||
workflow:
|
||||
graph:
|
||||
nodes:
|
||||
- id: start-1
|
||||
data:
|
||||
type: start
|
||||
edges: []
|
||||
"""
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content=yaml_content,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Snippet cannot contain the following node types: start"
|
||||
|
||||
|
||||
def test_import_snippet_stores_pending_data_for_newer_dsl(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
|
||||
setex = Mock()
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.setex", setex)
|
||||
yaml_content = """
|
||||
version: 999.0.0
|
||||
kind: snippet
|
||||
snippet:
|
||||
name: Future Snippet
|
||||
workflow:
|
||||
graph:
|
||||
nodes: []
|
||||
edges: []
|
||||
"""
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content=yaml_content,
|
||||
name="Override",
|
||||
description="Override description",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.PENDING
|
||||
setex.assert_called_once()
|
||||
pending = SnippetPendingData.model_validate_json(setex.call_args.args[2])
|
||||
assert pending.name == "Override"
|
||||
assert pending.description == "Override description"
|
||||
|
||||
|
||||
def test_import_snippet_returns_failed_when_update_target_missing():
|
||||
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
|
||||
yaml_content = """
|
||||
version: 0.1.0
|
||||
kind: snippet
|
||||
snippet:
|
||||
name: Existing Snippet
|
||||
workflow:
|
||||
graph:
|
||||
nodes: []
|
||||
edges: []
|
||||
"""
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content=yaml_content,
|
||||
snippet_id="missing-snippet",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Snippet not found"
|
||||
|
||||
|
||||
def test_import_snippet_passes_dependencies_to_create_or_update(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
|
||||
snippet = SimpleNamespace(id="snippet-1")
|
||||
create_or_update = Mock(return_value=snippet)
|
||||
monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update)
|
||||
yaml_content = """
|
||||
version: 0.1.0
|
||||
kind: snippet
|
||||
snippet:
|
||||
name: Dependency Snippet
|
||||
dependencies:
|
||||
- type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/openai:0.0.1
|
||||
workflow:
|
||||
graph:
|
||||
nodes: []
|
||||
edges: []
|
||||
"""
|
||||
|
||||
result = service.import_snippet(
|
||||
account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"),
|
||||
import_mode=ImportMode.YAML_CONTENT.value,
|
||||
yaml_content=yaml_content,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.COMPLETED
|
||||
assert result.snippet_id == "snippet-1"
|
||||
dependencies = create_or_update.call_args.kwargs["dependencies"]
|
||||
assert dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1"
|
||||
|
||||
|
||||
def test_confirm_import_returns_failed_when_pending_data_missing(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=None))
|
||||
|
||||
result = service.confirm_import(import_id="missing", account=SimpleNamespace(current_tenant_id="tenant-1"))
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Import information expired or does not exist"
|
||||
|
||||
|
||||
def test_confirm_import_returns_failed_for_invalid_pending_payload(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=object()))
|
||||
|
||||
result = service.confirm_import(import_id="bad", account=SimpleNamespace(current_tenant_id="tenant-1"))
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Invalid import information"
|
||||
|
||||
|
||||
def test_confirm_import_creates_snippet_from_pending_data(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
|
||||
account = SimpleNamespace(id="account-1", current_tenant_id="tenant-1")
|
||||
snippet = SimpleNamespace(id="snippet-new")
|
||||
yaml_content = """
|
||||
version: 9.0.0
|
||||
kind: snippet
|
||||
snippet:
|
||||
name: From DSL
|
||||
type: node
|
||||
workflow:
|
||||
graph:
|
||||
nodes: []
|
||||
edges: []
|
||||
"""
|
||||
pending = SnippetPendingData(
|
||||
import_mode="yaml-content",
|
||||
yaml_content=yaml_content,
|
||||
name="Override name",
|
||||
description="Override description",
|
||||
snippet_id=None,
|
||||
)
|
||||
create_or_update = Mock(return_value=snippet)
|
||||
monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update)
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
|
||||
redis_delete = Mock()
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.delete", redis_delete)
|
||||
|
||||
result = service.confirm_import(import_id="import-1", account=account)
|
||||
|
||||
assert result.status == ImportStatus.COMPLETED
|
||||
assert result.snippet_id == "snippet-new"
|
||||
assert result.imported_dsl_version == "9.0.0"
|
||||
create_or_update.assert_called_once()
|
||||
_, kwargs = create_or_update.call_args
|
||||
assert kwargs["snippet"] is None
|
||||
assert kwargs["account"] is account
|
||||
assert kwargs["name"] == "Override name"
|
||||
assert kwargs["description"] == "Override description"
|
||||
redis_delete.assert_called_once_with("snippet_import_info:import-1")
|
||||
|
||||
|
||||
def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
pending = SnippetPendingData(
|
||||
import_mode="yaml-content",
|
||||
yaml_content="- item",
|
||||
snippet_id=None,
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
|
||||
|
||||
result = service.confirm_import(import_id="import-1", account=SimpleNamespace(current_tenant_id="tenant-1"))
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "Invalid YAML format: expected a dictionary"
|
||||
|
||||
|
||||
def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None)))
|
||||
pending = SnippetPendingData(
|
||||
import_mode="yaml-content",
|
||||
yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n",
|
||||
snippet_id="snippet-1",
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
|
||||
monkeypatch.setattr(service, "_create_or_update_snippet", Mock(side_effect=RuntimeError("boom")))
|
||||
|
||||
result = service.confirm_import(
|
||||
import_id="import-1",
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "boom"
|
||||
|
||||
|
||||
def test_check_dependencies_returns_empty_without_draft_workflow(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(get_bind=Mock()))
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1"))
|
||||
|
||||
assert result.leaked_dependencies == []
|
||||
|
||||
|
||||
def test_check_dependencies_returns_generated_dependencies(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(get_bind=Mock()))
|
||||
workflow = SimpleNamespace(graph_dict={"nodes": []})
|
||||
leaked_dependencies = [
|
||||
{
|
||||
"type": "marketplace",
|
||||
"value": {"marketplace_plugin_unique_identifier": "langgenius/openai:0.0.1"},
|
||||
}
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
monkeypatch.setattr(service, "_extract_dependencies_from_workflow", Mock(return_value=["langgenius/openai"]))
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies",
|
||||
Mock(return_value=leaked_dependencies),
|
||||
)
|
||||
|
||||
result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1"))
|
||||
|
||||
assert result.leaked_dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1"
|
||||
|
||||
|
||||
def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(monkeypatch):
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
name="Old",
|
||||
description="Old",
|
||||
type="node",
|
||||
icon_info=None,
|
||||
input_fields=None,
|
||||
updated_by=None,
|
||||
updated_at=None,
|
||||
)
|
||||
session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock())
|
||||
service = SnippetDslService(session=session)
|
||||
draft_workflow = SimpleNamespace(unique_hash="hash-1")
|
||||
snippet_service = SimpleNamespace(
|
||||
get_draft_workflow=Mock(return_value=draft_workflow),
|
||||
sync_draft_workflow=Mock(),
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service)
|
||||
|
||||
result = service._create_or_update_snippet(
|
||||
snippet=snippet,
|
||||
data={
|
||||
"snippet": {
|
||||
"name": "New",
|
||||
"description": "New description",
|
||||
"type": "unknown-type",
|
||||
"icon_info": {"icon": "x"},
|
||||
"input_fields": [{"variable": "query"}],
|
||||
},
|
||||
"workflow": {"graph": {"nodes": [], "edges": []}},
|
||||
},
|
||||
account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
assert result is snippet
|
||||
assert snippet.name == "New"
|
||||
assert snippet.type == "node"
|
||||
assert snippet.icon_info == {"icon": "x"}
|
||||
snippet_service.sync_draft_workflow.assert_called_once()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_create_or_update_snippet_creates_new_snippet_and_flushes(monkeypatch):
|
||||
session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock())
|
||||
service = SnippetDslService(session=session)
|
||||
snippet_service = SimpleNamespace(get_draft_workflow=Mock(return_value=None), sync_draft_workflow=Mock())
|
||||
monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service)
|
||||
|
||||
result = service._create_or_update_snippet(
|
||||
snippet=None,
|
||||
data={
|
||||
"snippet": {
|
||||
"name": "New Snippet",
|
||||
"description": "Description",
|
||||
"type": "group",
|
||||
"input_fields": [{"variable": "query"}],
|
||||
},
|
||||
"workflow": {"graph": {"nodes": [], "edges": []}},
|
||||
},
|
||||
account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"),
|
||||
)
|
||||
|
||||
assert result.name == "New Snippet"
|
||||
assert result.type == "group"
|
||||
session.add.assert_called_once_with(result)
|
||||
session.flush.assert_called_once()
|
||||
snippet_service.sync_draft_workflow.assert_called_once()
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_export_snippet_dsl_raises_without_draft_workflow(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(get_bind=Mock()))
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Missing draft workflow"):
|
||||
service.export_snippet_dsl(SimpleNamespace())
|
||||
|
||||
|
||||
def test_export_snippet_dsl_returns_yaml(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace(get_bind=Mock()))
|
||||
workflow = SimpleNamespace(
|
||||
to_dict=Mock(return_value={"graph": {"nodes": []}}),
|
||||
graph_dict={"nodes": []},
|
||||
)
|
||||
snippet = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
name="Exported",
|
||||
description=None,
|
||||
type="node",
|
||||
icon_info=None,
|
||||
input_fields_list=[{"variable": "query"}],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies",
|
||||
Mock(return_value=[]),
|
||||
)
|
||||
|
||||
result = service.export_snippet_dsl(snippet)
|
||||
|
||||
assert "kind: snippet" in result
|
||||
assert "name: Exported" in result
|
||||
assert "input_fields:" in result
|
||||
|
||||
|
||||
def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
workflow_dict = {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{"data": {}},
|
||||
{
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.TOOL,
|
||||
"credential_id": "secret",
|
||||
"tool_configurations": {"provider_type": "builtin", "provider": "langgenius/google"},
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.AGENT,
|
||||
"agent_parameters": {
|
||||
"tools": {
|
||||
"value": [
|
||||
{
|
||||
"provider_type": "builtin",
|
||||
"provider": "langgenius/openai",
|
||||
"credential_id": "agent-secret",
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
"environment_variables": [{"name": "SECRET"}],
|
||||
"conversation_variables": [{"name": "memory"}],
|
||||
}
|
||||
workflow = SimpleNamespace(
|
||||
to_dict=Mock(return_value=workflow_dict),
|
||||
graph_dict=workflow_dict["graph"],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies",
|
||||
Mock(return_value=[]),
|
||||
)
|
||||
export_data = {}
|
||||
|
||||
service._append_workflow_export_data(
|
||||
export_data=export_data,
|
||||
snippet=SimpleNamespace(tenant_id="tenant-1"),
|
||||
workflow=workflow,
|
||||
include_secret=False,
|
||||
)
|
||||
|
||||
nodes = export_data["workflow"]["graph"]["nodes"]
|
||||
assert export_data["workflow"]["environment_variables"] == []
|
||||
assert export_data["workflow"]["conversation_variables"] == []
|
||||
assert "credential_id" not in nodes[1]["data"]
|
||||
assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0]
|
||||
|
||||
|
||||
def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch):
|
||||
service = SnippetDslService(session=SimpleNamespace())
|
||||
workflow_dict = {
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL,
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
workflow = SimpleNamespace(to_dict=Mock(return_value=workflow_dict), graph_dict=workflow_dict["graph"])
|
||||
monkeypatch.setattr(
|
||||
service,
|
||||
"_encrypt_dataset_id",
|
||||
Mock(side_effect=lambda dataset_id, tenant_id: f"{tenant_id}:{dataset_id}"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies",
|
||||
Mock(return_value=[]),
|
||||
)
|
||||
export_data = {}
|
||||
|
||||
service._append_workflow_export_data(
|
||||
export_data=export_data,
|
||||
snippet=SimpleNamespace(tenant_id="tenant-1"),
|
||||
workflow=workflow,
|
||||
include_secret=True,
|
||||
)
|
||||
|
||||
assert export_data["workflow"]["graph"]["nodes"][0]["data"]["dataset_ids"] == [
|
||||
"tenant-1:dataset-1",
|
||||
"tenant-1:dataset-2",
|
||||
]
|
||||
383
api/tests/unit_tests/services/test_snippet_generate_service.py
Normal file
383
api/tests/unit_tests/services/test_snippet_generate_service.py
Normal file
@ -0,0 +1,383 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID
|
||||
from models.workflow import Workflow, WorkflowKind, WorkflowType
|
||||
from services.snippet_generate_service import SnippetGenerateService
|
||||
|
||||
|
||||
def _workflow(graph: dict) -> Workflow:
|
||||
return Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="snippet-1",
|
||||
type=WorkflowType.WORKFLOW,
|
||||
kind=WorkflowKind.SNIPPET,
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(graph),
|
||||
features="{}",
|
||||
created_by="account-1",
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
|
||||
def test_filter_virtual_start_events_keeps_blocking_response_unchanged():
|
||||
response = {"data": {"outputs": {"text": "ok"}}}
|
||||
|
||||
assert SnippetGenerateService._filter_virtual_start_events(response) is response
|
||||
|
||||
|
||||
def test_filter_virtual_start_events_removes_virtual_start_node_events():
|
||||
stream = iter(
|
||||
[
|
||||
{"event": "node_started", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}},
|
||||
{"event": "node_finished", "data": {"node_id": "llm-1"}},
|
||||
"raw-event",
|
||||
]
|
||||
)
|
||||
|
||||
filtered = SnippetGenerateService._filter_virtual_start_events(stream)
|
||||
|
||||
assert list(filtered) == [{"event": "node_finished", "data": {"node_id": "llm-1"}}, "raw-event"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message", "expected"),
|
||||
[
|
||||
("raw-event", False),
|
||||
({"event": "message", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}}, False),
|
||||
({"event": "node_started", "data": "not-a-dict"}, False),
|
||||
({"event": "node_started", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}}, True),
|
||||
],
|
||||
)
|
||||
def test_is_virtual_start_event(message, expected):
|
||||
assert SnippetGenerateService._is_virtual_start_event(message) is expected
|
||||
|
||||
|
||||
def test_ensure_start_node_returns_workflow_when_start_already_exists():
|
||||
workflow = _workflow({"nodes": [{"id": "start", "data": {"type": "start"}}], "edges": []})
|
||||
snippet = SimpleNamespace(input_fields_list=[])
|
||||
|
||||
result = SnippetGenerateService._ensure_start_node(workflow, snippet)
|
||||
|
||||
assert result is workflow
|
||||
|
||||
|
||||
def test_ensure_start_node_injects_virtual_start_for_root_candidates(monkeypatch):
|
||||
graph = {
|
||||
"nodes": [
|
||||
{"id": "llm-1", "data": {"type": "llm"}},
|
||||
{"id": "answer-1", "data": {"type": "answer"}},
|
||||
],
|
||||
"edges": [{"source": "llm-1", "target": "answer-1"}],
|
||||
}
|
||||
workflow = _workflow(graph)
|
||||
snippet = SimpleNamespace(
|
||||
input_fields_list=[
|
||||
{
|
||||
"variable": "query",
|
||||
"label": "Query",
|
||||
"type": "text-input",
|
||||
"required": True,
|
||||
"max_length": 128,
|
||||
}
|
||||
]
|
||||
)
|
||||
make_transient = Mock()
|
||||
monkeypatch.setattr("services.snippet_generate_service.make_transient", make_transient)
|
||||
|
||||
result = SnippetGenerateService._ensure_start_node(workflow, snippet)
|
||||
|
||||
assert result is workflow
|
||||
updated_graph = workflow.graph_dict
|
||||
assert updated_graph["nodes"][0]["id"] == SNIPPET_VIRTUAL_START_NODE_ID
|
||||
assert updated_graph["nodes"][0]["data"]["variables"][0]["max_length"] == 128
|
||||
assert updated_graph["edges"][-1]["source"] == SNIPPET_VIRTUAL_START_NODE_ID
|
||||
assert updated_graph["edges"][-1]["target"] == "llm-1"
|
||||
make_transient.assert_called_once_with(workflow)
|
||||
|
||||
|
||||
def test_parse_files_returns_empty_when_upload_config_disabled(monkeypatch):
|
||||
workflow = _workflow({"nodes": [], "edges": []})
|
||||
monkeypatch.setattr("services.snippet_generate_service.FileUploadConfigManager.convert", Mock(return_value=None))
|
||||
|
||||
assert SnippetGenerateService.parse_files(workflow, files=[{"id": "file-1"}]) == []
|
||||
|
||||
|
||||
def test_parse_files_delegates_to_file_factory(monkeypatch):
|
||||
workflow = _workflow({"nodes": [], "edges": []})
|
||||
upload_config = SimpleNamespace(enabled=True)
|
||||
files = [SimpleNamespace(id="file-1")]
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.FileUploadConfigManager.convert", Mock(return_value=upload_config)
|
||||
)
|
||||
build_from_mappings = Mock(return_value=files)
|
||||
monkeypatch.setattr("services.snippet_generate_service.file_factory.build_from_mappings", build_from_mappings)
|
||||
|
||||
result = SnippetGenerateService.parse_files(workflow, files=[{"id": "file-1"}])
|
||||
|
||||
assert result == files
|
||||
build_from_mappings.assert_called_once()
|
||||
|
||||
|
||||
def test_generate_raises_when_draft_workflow_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
SnippetGenerateService.generate(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
args={"inputs": {}},
|
||||
invoke_from="debugger",
|
||||
)
|
||||
|
||||
|
||||
def test_generate_delegates_to_workflow_generator_and_filters_stream(monkeypatch):
|
||||
workflow = _workflow({"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []})
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", input_fields_list=[])
|
||||
user = SimpleNamespace(id="user-1")
|
||||
raw_stream = iter(
|
||||
[
|
||||
{"event": "node_started", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}},
|
||||
{"event": "node_finished", "data": {"node_id": "llm-1"}},
|
||||
]
|
||||
)
|
||||
generator = SimpleNamespace(generate=Mock(return_value=raw_stream))
|
||||
workflow_generator_class = Mock(return_value=generator)
|
||||
workflow_generator_class.convert_to_event_stream = Mock(side_effect=lambda response: response)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
ensure_start_node = Mock(return_value=workflow)
|
||||
monkeypatch.setattr(SnippetGenerateService, "_ensure_start_node", ensure_start_node)
|
||||
monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", workflow_generator_class)
|
||||
|
||||
result = SnippetGenerateService.generate(
|
||||
snippet=snippet,
|
||||
user=user,
|
||||
args={"inputs": {"query": "hello"}},
|
||||
invoke_from="debugger",
|
||||
)
|
||||
|
||||
assert list(result) == [{"event": "node_finished", "data": {"node_id": "llm-1"}}]
|
||||
ensure_start_node.assert_called_once_with(workflow, snippet)
|
||||
generator.generate.assert_called_once()
|
||||
kwargs = generator.generate.call_args.kwargs
|
||||
assert kwargs["app_model"].id == "snippet-1"
|
||||
assert kwargs["workflow"] is workflow
|
||||
assert kwargs["user"] is user
|
||||
assert kwargs["streaming"] is True
|
||||
assert kwargs["call_depth"] == 0
|
||||
workflow_generator_class.convert_to_event_stream.assert_called_once()
|
||||
|
||||
|
||||
def test_run_published_delegates_to_workflow_generator_non_streaming(monkeypatch):
|
||||
workflow = _workflow({"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []})
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", input_fields_list=[])
|
||||
user = SimpleNamespace(id="user-1")
|
||||
generator = SimpleNamespace(generate=Mock(return_value={"data": {"outputs": {"answer": "ok"}}}))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_published_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
ensure_start_node = Mock(return_value=workflow)
|
||||
monkeypatch.setattr(SnippetGenerateService, "_ensure_start_node", ensure_start_node)
|
||||
monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", Mock(return_value=generator))
|
||||
|
||||
result = SnippetGenerateService.run_published(
|
||||
snippet=snippet,
|
||||
user=user,
|
||||
args={"inputs": {"query": "hello"}},
|
||||
invoke_from="service-api",
|
||||
)
|
||||
|
||||
assert result == {"data": {"outputs": {"answer": "ok"}}}
|
||||
ensure_start_node.assert_called_once_with(workflow, snippet)
|
||||
generator.generate.assert_called_once()
|
||||
kwargs = generator.generate.call_args.kwargs
|
||||
assert kwargs["app_model"].id == "snippet-1"
|
||||
assert kwargs["streaming"] is False
|
||||
assert kwargs["call_depth"] == 0
|
||||
|
||||
|
||||
def test_ensure_start_node_for_worker_delegates(monkeypatch):
|
||||
workflow = _workflow({"nodes": [], "edges": []})
|
||||
snippet = SimpleNamespace(input_fields_list=[])
|
||||
ensure_start_node = Mock(return_value=workflow)
|
||||
monkeypatch.setattr(SnippetGenerateService, "_ensure_start_node", ensure_start_node)
|
||||
|
||||
result = SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet)
|
||||
|
||||
assert result is workflow
|
||||
ensure_start_node.assert_called_once_with(workflow, snippet)
|
||||
|
||||
|
||||
def test_run_draft_node_delegates_to_workflow_service(monkeypatch):
|
||||
workflow = _workflow({"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []})
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
execution = SimpleNamespace(id="execution-1")
|
||||
workflow_service = SimpleNamespace(run_draft_workflow_node=Mock(return_value=execution))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_generate_service.WorkflowService", Mock(return_value=workflow_service))
|
||||
|
||||
result = SnippetGenerateService.run_draft_node(
|
||||
snippet=snippet,
|
||||
node_id="llm-1",
|
||||
user_inputs={"query": "hello"},
|
||||
account=account,
|
||||
query="question",
|
||||
files=[],
|
||||
)
|
||||
|
||||
assert result is execution
|
||||
workflow_service.run_draft_workflow_node.assert_called_once()
|
||||
kwargs = workflow_service.run_draft_workflow_node.call_args.kwargs
|
||||
assert kwargs["app_model"].id == "snippet-1"
|
||||
assert kwargs["draft_workflow"] is workflow
|
||||
assert kwargs["node_id"] == "llm-1"
|
||||
assert kwargs["user_inputs"] == {"query": "hello"}
|
||||
assert kwargs["account"] is account
|
||||
assert kwargs["query"] == "question"
|
||||
assert kwargs["files"] == []
|
||||
|
||||
|
||||
def test_run_draft_node_raises_when_draft_workflow_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
SnippetGenerateService.run_draft_node(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
node_id="llm-1",
|
||||
user_inputs={},
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
)
|
||||
|
||||
|
||||
def test_generate_single_iteration_delegates_to_workflow_generator(monkeypatch):
|
||||
workflow = _workflow({"nodes": [{"id": "iteration-1", "data": {"type": "iteration"}}], "edges": []})
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
user = SimpleNamespace(id="user-1")
|
||||
response = iter(["event"])
|
||||
generator = SimpleNamespace(single_iteration_generate=Mock(return_value=response))
|
||||
workflow_generator_class = Mock(return_value=generator)
|
||||
workflow_generator_class.convert_to_event_stream = Mock(side_effect=lambda item: item)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", workflow_generator_class)
|
||||
|
||||
result = SnippetGenerateService.generate_single_iteration(
|
||||
snippet=snippet,
|
||||
user=user,
|
||||
node_id="iteration-1",
|
||||
args={"inputs": {"items": [1]}},
|
||||
)
|
||||
|
||||
assert list(result) == ["event"]
|
||||
generator.single_iteration_generate.assert_called_once()
|
||||
kwargs = generator.single_iteration_generate.call_args.kwargs
|
||||
assert kwargs["app_model"].id == "snippet-1"
|
||||
assert kwargs["workflow"] is workflow
|
||||
assert kwargs["node_id"] == "iteration-1"
|
||||
assert kwargs["user"] is user
|
||||
assert kwargs["streaming"] is True
|
||||
workflow_generator_class.convert_to_event_stream.assert_called_once_with(response)
|
||||
|
||||
|
||||
def test_generate_single_iteration_raises_when_draft_workflow_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
SnippetGenerateService.generate_single_iteration(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
node_id="iteration-1",
|
||||
args={"inputs": {}},
|
||||
)
|
||||
|
||||
|
||||
def test_generate_single_loop_delegates_to_workflow_generator(monkeypatch):
|
||||
workflow = _workflow({"nodes": [{"id": "loop-1", "data": {"type": "loop"}}], "edges": []})
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
user = SimpleNamespace(id="user-1")
|
||||
response = iter(["event"])
|
||||
generator = SimpleNamespace(single_loop_generate=Mock(return_value=response))
|
||||
workflow_generator_class = Mock(return_value=generator)
|
||||
workflow_generator_class.convert_to_event_stream = Mock(side_effect=lambda item: item)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)),
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", workflow_generator_class)
|
||||
|
||||
result = SnippetGenerateService.generate_single_loop(
|
||||
snippet=snippet,
|
||||
user=user,
|
||||
node_id="loop-1",
|
||||
args=SimpleNamespace(inputs={"items": [1]}),
|
||||
)
|
||||
|
||||
assert list(result) == ["event"]
|
||||
generator.single_loop_generate.assert_called_once()
|
||||
kwargs = generator.single_loop_generate.call_args.kwargs
|
||||
assert kwargs["app_model"].id == "snippet-1"
|
||||
assert kwargs["workflow"] is workflow
|
||||
assert kwargs["node_id"] == "loop-1"
|
||||
assert kwargs["user"] is user
|
||||
assert kwargs["streaming"] is True
|
||||
workflow_generator_class.convert_to_event_stream.assert_called_once_with(response)
|
||||
|
||||
|
||||
def test_generate_single_loop_raises_when_draft_workflow_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
SnippetGenerateService.generate_single_loop(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
node_id="loop-1",
|
||||
args=SimpleNamespace(inputs={}),
|
||||
)
|
||||
|
||||
|
||||
def test_run_published_raises_when_published_workflow_missing(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_generate_service.SnippetService",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(get_published_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="No published workflow found"):
|
||||
SnippetGenerateService.run_published(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
args={"inputs": {}},
|
||||
invoke_from="service-api",
|
||||
)
|
||||
636
api/tests/unit_tests/services/test_snippet_service.py
Normal file
636
api/tests/unit_tests/services/test_snippet_service.py
Normal file
@ -0,0 +1,636 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from models.snippet import SnippetType
|
||||
from models.workflow import Workflow, WorkflowKind, WorkflowType
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.snippet_service import SnippetService
|
||||
|
||||
|
||||
class _SessionWithoutNameLookup:
|
||||
def __init__(self) -> None:
|
||||
self.add = Mock()
|
||||
self.commit = Mock()
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
raise AssertionError("snippet name uniqueness lookup should not be used")
|
||||
|
||||
|
||||
class _SessionContext:
|
||||
def __init__(self, session) -> None:
|
||||
self._session = session
|
||||
|
||||
def __enter__(self):
|
||||
return self._session
|
||||
|
||||
def __exit__(self, *args) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _session_maker(session):
|
||||
return lambda: _SessionContext(session)
|
||||
|
||||
|
||||
def _create_workflow(*, workflow_id: str, version: str, graph: dict, features: dict) -> Workflow:
|
||||
return Workflow(
|
||||
id=workflow_id,
|
||||
tenant_id="tenant-1",
|
||||
app_id="snippet-1",
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
version=version,
|
||||
graph=json.dumps(graph),
|
||||
features=json.dumps(features),
|
||||
created_by="account-1",
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
|
||||
def test_create_snippet_allows_duplicate_names(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session = _SessionWithoutNameLookup()
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
service._session_maker = _session_maker(session)
|
||||
|
||||
snippet = service.create_snippet(
|
||||
tenant_id="tenant-1",
|
||||
name="shared name",
|
||||
description=None,
|
||||
snippet_type=SnippetType.NODE,
|
||||
icon_info=None,
|
||||
input_fields=None,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert snippet.name == "shared name"
|
||||
session.add.assert_called_once_with(snippet)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_validate_snippet_graph_forbidden_nodes_ignores_malformed_nodes() -> None:
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes(
|
||||
{
|
||||
"nodes": [
|
||||
"not-a-node",
|
||||
{"id": "empty-data", "data": {}},
|
||||
{"id": "bad-type", "data": {"type": 123}},
|
||||
{"id": "llm-1", "data": {"type": "llm"}},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_snippet_graph_forbidden_nodes_raises_with_node_details() -> None:
|
||||
with pytest.raises(ValueError, match="start-1:start"):
|
||||
SnippetService.validate_snippet_graph_forbidden_nodes({"nodes": [{"id": "start-1", "data": {"type": "start"}}]})
|
||||
|
||||
|
||||
def test_get_snippets_returns_empty_when_tag_filter_has_no_targets(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.snippet_service.TagService.get_target_ids_by_tag_ids", Mock(return_value=[]))
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
result = service.get_snippets(tenant_id="tenant-1", tag_ids=["tag-1"])
|
||||
|
||||
assert result == ([], 0, False)
|
||||
|
||||
|
||||
def test_get_snippets_applies_filters_and_paginates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippets = [
|
||||
SimpleNamespace(id="snippet-1"),
|
||||
SimpleNamespace(id="snippet-2"),
|
||||
SimpleNamespace(id="snippet-3"),
|
||||
]
|
||||
session = SimpleNamespace(
|
||||
scalar=Mock(return_value=3),
|
||||
scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=snippets))),
|
||||
)
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
service._session_maker = _session_maker(session)
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_service.TagService.get_target_ids_by_tag_ids",
|
||||
Mock(return_value=["snippet-1", "snippet-2", "snippet-3"]),
|
||||
)
|
||||
|
||||
result, total, has_more = service.get_snippets(
|
||||
tenant_id="tenant-1",
|
||||
page=2,
|
||||
limit=2,
|
||||
keyword="search",
|
||||
is_published=True,
|
||||
creators=["account-1"],
|
||||
tag_ids=["tag-1"],
|
||||
)
|
||||
|
||||
assert result == snippets[:2]
|
||||
assert total == 3
|
||||
assert has_more is True
|
||||
session.scalar.assert_called_once()
|
||||
session.scalars.assert_called_once()
|
||||
|
||||
|
||||
def test_update_snippet_allows_duplicate_names() -> None:
|
||||
session = _SessionWithoutNameLookup()
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
tenant_id="tenant-1",
|
||||
name="old name",
|
||||
description="",
|
||||
icon_info=None,
|
||||
)
|
||||
|
||||
result = SnippetService.update_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account_id="account-1",
|
||||
data={"name": "shared name"},
|
||||
)
|
||||
|
||||
assert result is snippet
|
||||
assert snippet.name == "shared name"
|
||||
session.add.assert_called_once_with(snippet)
|
||||
|
||||
|
||||
def test_update_snippet_updates_optional_fields() -> None:
|
||||
session = _SessionWithoutNameLookup()
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
tenant_id="tenant-1",
|
||||
name="old name",
|
||||
description="old description",
|
||||
icon_info=None,
|
||||
)
|
||||
|
||||
result = SnippetService.update_snippet(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account_id="account-1",
|
||||
data={"description": "new description", "icon_info": {"icon": "star"}},
|
||||
)
|
||||
|
||||
assert result is snippet
|
||||
assert snippet.description == "new description"
|
||||
assert snippet.icon_info == {"icon": "star"}
|
||||
assert snippet.updated_by == "account-1"
|
||||
session.add.assert_called_once_with(snippet)
|
||||
|
||||
|
||||
def test_sync_draft_workflow_creates_draft_and_updates_input_fields(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=None))
|
||||
session = SimpleNamespace(add=Mock(), commit=Mock())
|
||||
service._session_maker = _session_maker(session)
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
tenant_id="tenant-1",
|
||||
input_fields=None,
|
||||
updated_by=None,
|
||||
updated_at=None,
|
||||
)
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
workflow = service.sync_draft_workflow(
|
||||
snippet=snippet,
|
||||
graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []},
|
||||
unique_hash=None,
|
||||
account=account,
|
||||
input_fields=[{"variable": "query"}],
|
||||
)
|
||||
|
||||
assert workflow.app_id == snippet.id
|
||||
assert workflow.kind == WorkflowKind.SNIPPET
|
||||
assert json.loads(snippet.input_fields) == [{"variable": "query"}]
|
||||
session.add.assert_any_call(workflow)
|
||||
session.add.assert_any_call(snippet)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_sync_draft_workflow_raises_when_hash_mismatches() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
service._session_maker = _session_maker(SimpleNamespace(commit=Mock(), add=Mock()))
|
||||
service.get_draft_workflow = Mock(return_value=SimpleNamespace(unique_hash="server-hash"))
|
||||
|
||||
with pytest.raises(WorkflowHashNotEqualError):
|
||||
service.sync_draft_workflow(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
graph={"nodes": [], "edges": []},
|
||||
unique_hash="client-hash",
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
)
|
||||
|
||||
|
||||
def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
workflow = _create_workflow(
|
||||
workflow_id="workflow-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph={"nodes": [], "edges": []},
|
||||
features={},
|
||||
)
|
||||
unique_hash = workflow.unique_hash
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
tenant_id="tenant-1",
|
||||
input_fields=None,
|
||||
updated_by=None,
|
||||
updated_at=None,
|
||||
)
|
||||
account = SimpleNamespace(id="account-1")
|
||||
session = SimpleNamespace(add=Mock(), commit=Mock())
|
||||
|
||||
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=workflow))
|
||||
service._session_maker = _session_maker(session)
|
||||
|
||||
result = service.sync_draft_workflow(
|
||||
snippet=snippet,
|
||||
graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []},
|
||||
unique_hash=unique_hash,
|
||||
account=account,
|
||||
input_fields=[{"variable": "query"}],
|
||||
)
|
||||
|
||||
assert result is workflow
|
||||
assert workflow.graph_dict["nodes"][0]["id"] == "llm-1"
|
||||
assert workflow.type == WorkflowType.WORKFLOW
|
||||
assert workflow.kind == WorkflowKind.SNIPPET
|
||||
assert workflow.updated_by == account.id
|
||||
assert workflow.environment_variables == []
|
||||
assert workflow.conversation_variables == []
|
||||
assert json.loads(snippet.input_fields) == [{"variable": "query"}]
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_get_default_block_configs_skips_empty_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
node_with_default = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"}))
|
||||
node_without_default = SimpleNamespace(get_default_config=Mock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_service.NODE_TYPE_CLASSES_MAPPING",
|
||||
{
|
||||
"llm": {"1": node_with_default},
|
||||
"empty": {"1": node_without_default},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("services.snippet_service.LATEST_VERSION", "1")
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
assert service.get_default_block_configs() == [{"type": "llm"}]
|
||||
|
||||
|
||||
def test_get_default_block_config_returns_none_for_unknown_node(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.snippet_service.NODE_TYPE_CLASSES_MAPPING", {})
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
assert service.get_default_block_config("missing") is None
|
||||
|
||||
|
||||
def test_get_default_block_config_returns_node_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
node_class = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"}))
|
||||
monkeypatch.setattr("services.snippet_service.NODE_TYPE_CLASSES_MAPPING", {"llm": {"1": node_class}})
|
||||
monkeypatch.setattr("services.snippet_service.LATEST_VERSION", "1")
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
assert service.get_default_block_config("llm", filters={"k": "v"}) == {"type": "llm"}
|
||||
node_class.get_default_config.assert_called_once_with(filters={"k": "v"})
|
||||
|
||||
|
||||
def test_get_default_block_config_returns_none_for_empty_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
node_class = SimpleNamespace(get_default_config=Mock(return_value=None))
|
||||
monkeypatch.setattr("services.snippet_service.NODE_TYPE_CLASSES_MAPPING", {"llm": {"1": node_class}})
|
||||
monkeypatch.setattr("services.snippet_service.LATEST_VERSION", "1")
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
assert service.get_default_block_config("llm") is None
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_copies_source_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-2")
|
||||
source_graph = {"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}
|
||||
source_features = {"opening_statement": "hello"}
|
||||
source_workflow = _create_workflow(
|
||||
workflow_id="published-workflow",
|
||||
version="2026-04-28 00:00:00",
|
||||
graph=source_graph,
|
||||
features=source_features,
|
||||
)
|
||||
draft_workflow = _create_workflow(
|
||||
workflow_id="draft-workflow",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph={"nodes": [], "edges": []},
|
||||
features={},
|
||||
)
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
session = SimpleNamespace(add=Mock(), commit=Mock())
|
||||
service._session_maker = _session_maker(session)
|
||||
|
||||
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow))
|
||||
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=draft_workflow))
|
||||
|
||||
result = service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id=source_workflow.id,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert result is draft_workflow
|
||||
assert draft_workflow.graph_dict == source_graph
|
||||
assert draft_workflow.features_dict == source_features
|
||||
assert draft_workflow.updated_by == account.id
|
||||
session.add.assert_called_once_with(draft_workflow)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_raises_when_source_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-2")
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
service._session_maker = _session_maker(SimpleNamespace(add=Mock(), commit=Mock()))
|
||||
|
||||
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=None))
|
||||
|
||||
with pytest.raises(WorkflowNotFoundError):
|
||||
service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id="missing-workflow",
|
||||
account=account,
|
||||
)
|
||||
|
||||
|
||||
def test_restore_published_snippet_workflow_to_draft_adds_new_draft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-2")
|
||||
source_workflow = _create_workflow(
|
||||
workflow_id="published-workflow",
|
||||
version="2026-04-28 00:00:00",
|
||||
graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []},
|
||||
features={},
|
||||
)
|
||||
new_draft_workflow = _create_workflow(
|
||||
workflow_id="draft-workflow",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph={"nodes": [], "edges": []},
|
||||
features={},
|
||||
)
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
session = SimpleNamespace(add=Mock(), commit=Mock())
|
||||
service._session_maker = _session_maker(session)
|
||||
|
||||
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow))
|
||||
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
"services.snippet_service.apply_published_workflow_snapshot_to_draft",
|
||||
Mock(return_value=(new_draft_workflow, True)),
|
||||
)
|
||||
|
||||
result = service.restore_published_workflow_to_draft(
|
||||
snippet=snippet,
|
||||
workflow_id=source_workflow.id,
|
||||
account=account,
|
||||
)
|
||||
|
||||
assert result is new_draft_workflow
|
||||
session.add.assert_called_once_with(new_draft_workflow)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_get_published_workflow_returns_none_without_workflow_id() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
result = service.get_published_workflow(SimpleNamespace(id="snippet-1", tenant_id="tenant-1", workflow_id=None))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_get_published_workflow_by_id_raises_for_draft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
draft_workflow = SimpleNamespace(version=Workflow.VERSION_DRAFT)
|
||||
session = SimpleNamespace(scalar=Mock(return_value=draft_workflow))
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
service._session_maker = _session_maker(session)
|
||||
|
||||
with pytest.raises(IsDraftWorkflowError):
|
||||
service.get_published_workflow_by_id(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
workflow_id="workflow-1",
|
||||
)
|
||||
|
||||
|
||||
def test_publish_workflow_raises_when_draft_missing() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
session = SimpleNamespace(scalar=Mock(return_value=None))
|
||||
|
||||
with pytest.raises(ValueError, match="No valid workflow found"):
|
||||
service.publish_workflow(
|
||||
session=session,
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
)
|
||||
|
||||
|
||||
def test_publish_workflow_creates_snapshot_and_updates_snippet(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
draft_workflow = _create_workflow(
|
||||
workflow_id="draft-workflow",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []},
|
||||
features={"opening_statement": "hello"},
|
||||
)
|
||||
snippet = SimpleNamespace(
|
||||
id="snippet-1",
|
||||
tenant_id="tenant-1",
|
||||
version=1,
|
||||
is_published=False,
|
||||
workflow_id=None,
|
||||
updated_by=None,
|
||||
)
|
||||
session = SimpleNamespace(scalar=Mock(return_value=draft_workflow), add=Mock())
|
||||
|
||||
result = service.publish_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
)
|
||||
|
||||
assert result.kind == WorkflowKind.SNIPPET
|
||||
assert snippet.version == 2
|
||||
assert snippet.is_published is True
|
||||
assert snippet.workflow_id == result.id
|
||||
assert snippet.updated_by == "account-1"
|
||||
assert session.add.call_args_list[-1].args == (snippet,)
|
||||
|
||||
|
||||
def test_get_all_published_workflows_returns_empty_without_current_workflow() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
|
||||
result = service.get_all_published_workflows(
|
||||
session=SimpleNamespace(),
|
||||
snippet=SimpleNamespace(id="snippet-1", workflow_id=None),
|
||||
page=1,
|
||||
limit=20,
|
||||
)
|
||||
|
||||
assert result == ([], False)
|
||||
|
||||
|
||||
def test_get_all_published_workflows_paginates() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
workflows = [SimpleNamespace(id="workflow-1"), SimpleNamespace(id="workflow-2"), SimpleNamespace(id="workflow-3")]
|
||||
session = SimpleNamespace(scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=workflows))))
|
||||
|
||||
result, has_more = service.get_all_published_workflows(
|
||||
session=session,
|
||||
snippet=SimpleNamespace(id="snippet-1", workflow_id="workflow-current"),
|
||||
page=1,
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert result == workflows[:2]
|
||||
assert has_more is True
|
||||
session.scalars.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_snippet_removes_related_records() -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
session = SimpleNamespace(
|
||||
execute=Mock(),
|
||||
scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=[]))),
|
||||
delete=Mock(),
|
||||
)
|
||||
|
||||
result = SnippetService.delete_snippet(session=session, snippet=snippet)
|
||||
|
||||
assert result is True
|
||||
executed_sql = "\n".join(str(call.args[0]) for call in session.execute.call_args_list)
|
||||
assert "workflow_draft_variables" in executed_sql
|
||||
assert "tool_workflow_providers" in executed_sql
|
||||
assert "workflow_app_logs" in executed_sql
|
||||
assert "workflow_archive_logs" in executed_sql
|
||||
assert "workflow_node_executions" in executed_sql
|
||||
assert "workflow_runs" in executed_sql
|
||||
assert "workflows" in executed_sql
|
||||
assert "kind" in executed_sql
|
||||
assert "tag_bindings" in executed_sql
|
||||
session.delete.assert_called_once_with(snippet)
|
||||
|
||||
|
||||
def test_delete_draft_variable_files_removes_storage_objects(monkeypatch) -> None:
|
||||
from extensions.ext_storage import storage
|
||||
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
storage_delete = Mock()
|
||||
monkeypatch.setattr(storage, "delete", storage_delete)
|
||||
session = SimpleNamespace(
|
||||
scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=["file-1"]))),
|
||||
execute=Mock(
|
||||
side_effect=[
|
||||
SimpleNamespace(all=Mock(return_value=[("file-1", "upload-1", "storage-key")])),
|
||||
None,
|
||||
None,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
SnippetService._delete_draft_variable_files(session=session, snippet=snippet)
|
||||
|
||||
storage_delete.assert_called_once_with("storage-key")
|
||||
executed_sql = "\n".join(str(call.args[0]) for call in session.execute.call_args_list)
|
||||
assert "upload_files" in executed_sql
|
||||
assert "workflow_draft_variable_files" in executed_sql
|
||||
|
||||
|
||||
def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch) -> None:
|
||||
from configs import dify_config
|
||||
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
archive_storage = SimpleNamespace(
|
||||
list_objects=Mock(return_value=["tenant-1/app_id=snippet-1/run.json"]),
|
||||
delete_object=Mock(),
|
||||
)
|
||||
monkeypatch.setattr(dify_config, "BILLING_ENABLED", True)
|
||||
monkeypatch.setattr(dify_config, "ARCHIVE_STORAGE_ENABLED", True)
|
||||
monkeypatch.setattr("libs.archive_storage.get_archive_storage", Mock(return_value=archive_storage))
|
||||
|
||||
SnippetService._delete_archived_workflow_run_files(snippet=snippet)
|
||||
|
||||
archive_storage.list_objects.assert_called_once_with("tenant-1/app_id=snippet-1/")
|
||||
archive_storage.delete_object.assert_called_once_with("tenant-1/app_id=snippet-1/run.json")
|
||||
|
||||
|
||||
def test_workflow_run_queries_delegate_to_repositories() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
workflow_run_repo = SimpleNamespace(
|
||||
get_paginated_workflow_runs=Mock(return_value=SimpleNamespace(data=[])),
|
||||
get_workflow_run_by_id=Mock(return_value=SimpleNamespace(id="run-1")),
|
||||
)
|
||||
node_execution_repo = SimpleNamespace(
|
||||
get_executions_by_workflow_run=Mock(return_value=[SimpleNamespace(id="node-execution-1")]),
|
||||
get_node_last_execution=Mock(return_value=SimpleNamespace(id="last-run-1")),
|
||||
)
|
||||
service._workflow_run_repo = workflow_run_repo
|
||||
service._node_execution_service_repo = node_execution_repo
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
|
||||
assert service.get_snippet_workflow_runs(snippet=snippet, args={"limit": "5", "last_id": "run-0"}).data == []
|
||||
assert service.get_snippet_workflow_run(snippet=snippet, run_id="run-1").id == "run-1"
|
||||
assert service.get_snippet_workflow_run_node_executions(snippet=snippet, run_id="run-1")[0].id == (
|
||||
"node-execution-1"
|
||||
)
|
||||
assert (
|
||||
service.get_snippet_node_last_run(
|
||||
snippet=snippet,
|
||||
workflow=SimpleNamespace(id="workflow-1"),
|
||||
node_id="llm-1",
|
||||
).id
|
||||
== "last-run-1"
|
||||
)
|
||||
workflow_run_repo.get_paginated_workflow_runs.assert_called_once()
|
||||
workflow_run_repo.get_workflow_run_by_id.assert_called_with(
|
||||
tenant_id="tenant-1",
|
||||
app_id="snippet-1",
|
||||
run_id="run-1",
|
||||
)
|
||||
node_execution_repo.get_executions_by_workflow_run.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
app_id="snippet-1",
|
||||
workflow_run_id="run-1",
|
||||
)
|
||||
node_execution_repo.get_node_last_execution.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
app_id="snippet-1",
|
||||
workflow_id="workflow-1",
|
||||
node_id="llm-1",
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_run_node_executions_returns_empty_when_run_missing() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
service._node_execution_service_repo = SimpleNamespace(get_executions_by_workflow_run=Mock())
|
||||
service.get_snippet_workflow_run = Mock(return_value=None)
|
||||
|
||||
result = service.get_snippet_workflow_run_node_executions(
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
run_id="missing-run",
|
||||
)
|
||||
|
||||
assert result == []
|
||||
service._node_execution_service_repo.get_executions_by_workflow_run.assert_not_called()
|
||||
|
||||
|
||||
def test_increment_use_count_adds_updated_snippet() -> None:
|
||||
snippet = SimpleNamespace(use_count=2)
|
||||
session = SimpleNamespace(add=Mock())
|
||||
|
||||
SnippetService.increment_use_count(session=session, snippet=snippet)
|
||||
|
||||
assert snippet.use_count == 3
|
||||
session.add.assert_called_once_with(snippet)
|
||||
103
api/tests/unit_tests/services/test_tag_service.py
Normal file
103
api/tests/unit_tests/services/test_tag_service.py
Normal file
@ -0,0 +1,103 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from models.enums import TagType
|
||||
from services.tag_service import TagBindingCreatePayload, TagBindingDeletePayload, TagService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def current_user(mocker):
|
||||
user = SimpleNamespace(id="user-1", current_tenant_id="tenant-1")
|
||||
mocker.patch("services.tag_service.current_user", user)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(mocker):
|
||||
mock_db = mocker.patch("services.tag_service.db")
|
||||
return mock_db.session
|
||||
|
||||
|
||||
def test_save_tag_binding_only_creates_bindings_for_valid_snippet_tags(mocker, current_user, db_session):
|
||||
mocker.patch("services.tag_service.TagService.check_target_exists")
|
||||
db_session.scalars.return_value.all.return_value = ["tag-1"]
|
||||
db_session.scalar.return_value = None
|
||||
|
||||
TagService.save_tag_binding(
|
||||
TagBindingCreatePayload(
|
||||
tag_ids=["tag-1", "tag-from-other-tenant"],
|
||||
target_id="snippet-1",
|
||||
type=TagType.SNIPPET,
|
||||
)
|
||||
)
|
||||
|
||||
db_session.add.assert_called_once()
|
||||
tag_binding = db_session.add.call_args.args[0]
|
||||
assert tag_binding.tag_id == "tag-1"
|
||||
assert tag_binding.target_id == "snippet-1"
|
||||
assert tag_binding.tenant_id == current_user.current_tenant_id
|
||||
assert tag_binding.created_by == current_user.id
|
||||
db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_tag_binding_limits_deletion_to_valid_snippet_tags(mocker, current_user, db_session):
|
||||
mocker.patch("services.tag_service.TagService.check_target_exists")
|
||||
db_session.execute.return_value = SimpleNamespace(rowcount=1)
|
||||
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_ids=["tag-1", "tag-from-other-tenant"],
|
||||
target_id="snippet-1",
|
||||
type=TagType.SNIPPET,
|
||||
)
|
||||
)
|
||||
|
||||
db_session.execute.assert_called_once()
|
||||
db_session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker, current_user, db_session):
|
||||
mocker.patch("services.tag_service.TagService.check_target_exists")
|
||||
db_session.execute.return_value = SimpleNamespace(rowcount=0)
|
||||
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_ids=["tag-1"],
|
||||
target_id="snippet-1",
|
||||
type=TagType.SNIPPET,
|
||||
)
|
||||
)
|
||||
|
||||
db_session.execute.assert_called_once()
|
||||
db_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_get_target_ids_by_tag_ids_returns_empty_without_query_for_empty_input(db_session):
|
||||
result = TagService.get_target_ids_by_tag_ids(TagType.SNIPPET, "tenant-1", [])
|
||||
|
||||
assert result == []
|
||||
db_session.scalars.assert_not_called()
|
||||
|
||||
|
||||
def test_check_target_exists_accepts_existing_snippet(current_user, db_session):
|
||||
db_session.scalar.return_value = SimpleNamespace(id="snippet-1")
|
||||
|
||||
TagService.check_target_exists("snippet", "snippet-1")
|
||||
|
||||
db_session.scalar.assert_called_once()
|
||||
|
||||
|
||||
def test_check_target_exists_raises_when_snippet_missing(current_user, db_session):
|
||||
db_session.scalar.return_value = None
|
||||
|
||||
with pytest.raises(NotFound, match="Snippet not found"):
|
||||
TagService.check_target_exists("snippet", "missing-snippet")
|
||||
|
||||
|
||||
def test_check_target_exists_raises_for_invalid_binding_type(current_user, db_session):
|
||||
with pytest.raises(NotFound, match="Invalid binding type"):
|
||||
TagService.check_target_exists("unknown", "target-1")
|
||||
|
||||
db_session.scalar.assert_not_called()
|
||||
@ -343,6 +343,34 @@ class TestWorkflowDraftVariableService:
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
|
||||
def test_list_variables_without_values_excludes_node_ids(self, mock_session):
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
variable = WorkflowDraftVariable.new_node_variable(
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
name="output",
|
||||
value=StringSegment(value="value"),
|
||||
node_execution_id="execution-1",
|
||||
)
|
||||
mock_session.scalar.return_value = 1
|
||||
mock_session.scalars.return_value = [variable]
|
||||
|
||||
result = service.list_variables_without_values(
|
||||
app_id="app-1",
|
||||
page=1,
|
||||
limit=20,
|
||||
user_id="user-1",
|
||||
exclude_node_ids={SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID},
|
||||
)
|
||||
|
||||
assert result.total == 1
|
||||
assert result.variables == [variable]
|
||||
|
||||
stmt = mock_session.scalars.call_args.args[0]
|
||||
compiled = stmt.compile()
|
||||
excluded_node_ids = next(value for value in compiled.params.values() if isinstance(value, (list, tuple)))
|
||||
assert set(excluded_node_ids) == {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
|
||||
|
||||
def test_reset_conversation_variable(self, mock_session):
|
||||
"""Test resetting a conversation variable"""
|
||||
service = WorkflowDraftVariableService(mock_session)
|
||||
|
||||
@ -145,6 +145,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(shareLayout)/components/splash.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -250,6 +255,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app-sidebar/nav-link/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@ -322,11 +332,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/app-publisher/features-wrapper.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/app/configuration/base/var-highlight/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 1
|
||||
@ -657,11 +662,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/apps/list.tsx": {
|
||||
"no-restricted-globals": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/apps/new-app-card.tsx": {
|
||||
"react-hooks-extra/no-direct-set-state-in-use-effect": {
|
||||
"count": 1
|
||||
@ -3146,6 +3146,16 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-snippet-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -5153,6 +5163,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/access-control.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
@ -5420,6 +5435,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-snippet-workflows.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -5468,9 +5488,6 @@
|
||||
}
|
||||
},
|
||||
"web/types/app.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 9
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
@ -892,8 +892,9 @@ export type WorkflowDraftVariableUpdatePayload = {
|
||||
}
|
||||
|
||||
export type PublishWorkflowPayload = {
|
||||
marked_comment?: string | null
|
||||
marked_name?: string | null
|
||||
knowledge_base_setting?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
export type WebhookTriggerResponse = {
|
||||
@ -2028,6 +2029,7 @@ export type GetAppsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
creator_ids?: Array<string> | null
|
||||
is_created_by_me?: boolean | null
|
||||
limit?: number
|
||||
mode?:
|
||||
|
||||
@ -577,10 +577,11 @@ export const zWorkflowDraftVariableUpdatePayload = z.object({
|
||||
|
||||
/**
|
||||
* PublishWorkflowPayload
|
||||
*
|
||||
* Payload for publishing snippet workflow.
|
||||
*/
|
||||
export const zPublishWorkflowPayload = z.object({
|
||||
marked_comment: z.string().max(100).nullish(),
|
||||
marked_name: z.string().max(20).nullish(),
|
||||
knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@ -2710,6 +2711,7 @@ export const zWorkflowCommentDetailWritable = z.object({
|
||||
})
|
||||
|
||||
export const zGetAppsQuery = z.object({
|
||||
creator_ids: z.array(z.string()).nullish(),
|
||||
is_created_by_me: z.boolean().nullish(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
mode: z
|
||||
|
||||
@ -38,6 +38,7 @@ import { resetPassword } from './reset-password/orpc.gen'
|
||||
import { ruleCodeGenerate } from './rule-code-generate/orpc.gen'
|
||||
import { ruleGenerate } from './rule-generate/orpc.gen'
|
||||
import { ruleStructuredOutputGenerate } from './rule-structured-output-generate/orpc.gen'
|
||||
import { snippets } from './snippets/orpc.gen'
|
||||
import { spec } from './spec/orpc.gen'
|
||||
import { systemFeatures } from './system-features/orpc.gen'
|
||||
import { tagBindings } from './tag-bindings/orpc.gen'
|
||||
@ -89,6 +90,7 @@ export const contract = {
|
||||
ruleCodeGenerate,
|
||||
ruleGenerate,
|
||||
ruleStructuredOutputGenerate,
|
||||
snippets,
|
||||
spec,
|
||||
systemFeatures,
|
||||
tagBindings,
|
||||
|
||||
878
packages/contracts/generated/api/console/snippets/orpc.gen.ts
Normal file
878
packages/contracts/generated/api/console/snippets/orpc.gen.ts
Normal file
@ -0,0 +1,878 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath,
|
||||
zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse,
|
||||
zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
|
||||
zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse,
|
||||
zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath,
|
||||
zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath,
|
||||
zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath,
|
||||
zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowRunsPath,
|
||||
zGetSnippetsBySnippetIdWorkflowRunsResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftConfigPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery,
|
||||
zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsPublishPath,
|
||||
zGetSnippetsBySnippetIdWorkflowsPublishResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsQuery,
|
||||
zGetSnippetsBySnippetIdWorkflowsResponse,
|
||||
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody,
|
||||
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
|
||||
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath,
|
||||
zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath,
|
||||
zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftBody,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftPath,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftRunBody,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftRunPath,
|
||||
zPostSnippetsBySnippetIdWorkflowsDraftRunResponse,
|
||||
zPostSnippetsBySnippetIdWorkflowsPublishBody,
|
||||
zPostSnippetsBySnippetIdWorkflowsPublishPath,
|
||||
zPostSnippetsBySnippetIdWorkflowsPublishResponse,
|
||||
zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath,
|
||||
zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
* Stop a running snippet workflow task
|
||||
*
|
||||
* Uses both the legacy stop flag mechanism and the graph engine
|
||||
* command channel for backward compatibility.
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Uses both the legacy stop flag mechanism and the graph engine\ncommand channel for backward compatibility.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStop',
|
||||
path: '/snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop',
|
||||
summary: 'Stop a running snippet workflow task',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath }))
|
||||
.output(zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse)
|
||||
|
||||
export const stop = {
|
||||
post,
|
||||
}
|
||||
|
||||
export const byTaskId = {
|
||||
stop,
|
||||
}
|
||||
|
||||
export const tasks = {
|
||||
byTaskId,
|
||||
}
|
||||
|
||||
/**
|
||||
* List node executions for a workflow run
|
||||
*/
|
||||
export const get = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutions',
|
||||
path: '/snippets/{snippet_id}/workflow-runs/{run_id}/node-executions',
|
||||
summary: 'List node executions for a workflow run',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse)
|
||||
|
||||
export const nodeExecutions = {
|
||||
get,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow run detail for snippet
|
||||
*/
|
||||
export const get2 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowRunsByRunId',
|
||||
path: '/snippets/{snippet_id}/workflow-runs/{run_id}',
|
||||
summary: 'Get workflow run detail for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse)
|
||||
|
||||
export const byRunId = {
|
||||
get: get2,
|
||||
nodeExecutions,
|
||||
}
|
||||
|
||||
/**
|
||||
* List workflow runs for snippet
|
||||
*/
|
||||
export const get3 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowRuns',
|
||||
path: '/snippets/{snippet_id}/workflow-runs',
|
||||
summary: 'List workflow runs for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowRunsResponse)
|
||||
|
||||
export const workflowRuns = {
|
||||
get: get3,
|
||||
tasks,
|
||||
byRunId,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default block configurations for snippet workflow
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get4 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigs',
|
||||
path: '/snippets/{snippet_id}/workflows/default-workflow-block-configs',
|
||||
summary: 'Get default block configurations for snippet workflow',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse)
|
||||
|
||||
export const defaultWorkflowBlockConfigs = {
|
||||
get: get4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snippet draft workflow configuration limits
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get5 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftConfig',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/config',
|
||||
summary: 'Get snippet draft workflow configuration limits',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftConfigPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse)
|
||||
|
||||
export const config = {
|
||||
get: get5,
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation variables are not used in snippet workflows; returns an empty list for API parity
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get6 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Conversation variables are not used in snippet workflows; returns an empty list for API parity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftConversationVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/conversation-variables',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse)
|
||||
|
||||
export const conversationVariables = {
|
||||
get: get6,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables from snippet draft workflow graph
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get7 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Get environment variables from snippet draft workflow graph\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftEnvironmentVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/environment-variables',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse)
|
||||
|
||||
export const environmentVariables = {
|
||||
get: get7,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a draft workflow iteration node for snippet
|
||||
*
|
||||
* Run draft workflow iteration node for snippet
|
||||
* Iteration nodes execute their internal sub-graph multiple times over an input list.
|
||||
* Returns an SSE event stream with iteration progress and results.
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post2 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Run draft workflow iteration node for snippet\nIteration nodes execute their internal sub-graph multiple times over an input list.\nReturns an SSE event stream with iteration progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRun',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run',
|
||||
summary: 'Run a draft workflow iteration node for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody,
|
||||
params: zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse)
|
||||
|
||||
export const run = {
|
||||
post: post2,
|
||||
}
|
||||
|
||||
export const byNodeId = {
|
||||
run,
|
||||
}
|
||||
|
||||
export const nodes = {
|
||||
byNodeId,
|
||||
}
|
||||
|
||||
export const iteration = {
|
||||
nodes,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a draft workflow loop node for snippet
|
||||
*
|
||||
* Run draft workflow loop node for snippet
|
||||
* Loop nodes execute their internal sub-graph repeatedly until a condition is met.
|
||||
* Returns an SSE event stream with loop progress and results.
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post3 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Run draft workflow loop node for snippet\nLoop nodes execute their internal sub-graph repeatedly until a condition is met.\nReturns an SSE event stream with loop progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRun',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run',
|
||||
summary: 'Run a draft workflow loop node for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody,
|
||||
params: zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse)
|
||||
|
||||
export const run2 = {
|
||||
post: post3,
|
||||
}
|
||||
|
||||
export const byNodeId2 = {
|
||||
run: run2,
|
||||
}
|
||||
|
||||
export const nodes2 = {
|
||||
byNodeId: byNodeId2,
|
||||
}
|
||||
|
||||
export const loop = {
|
||||
nodes: nodes2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last run result for a specific node in snippet draft workflow
|
||||
*
|
||||
* Get last run result for a node in snippet draft workflow
|
||||
* Returns the most recent execution record for the given node,
|
||||
* including status, inputs, outputs, and timing information.
|
||||
*/
|
||||
export const get8 = oc
|
||||
.route({
|
||||
description:
|
||||
'Get last run result for a node in snippet draft workflow\nReturns the most recent execution record for the given node,\nincluding status, inputs, outputs, and timing information.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRun',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run',
|
||||
summary: 'Get the last run result for a specific node in snippet draft workflow',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse)
|
||||
|
||||
export const lastRun = {
|
||||
get: get8,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single node in snippet draft workflow
|
||||
*
|
||||
* Run a single node in snippet draft workflow (single-step debugging)
|
||||
* Executes a specific node with provided inputs for single-step debugging.
|
||||
* Returns the node execution result including status, outputs, and timing.
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post4 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Run a single node in snippet draft workflow (single-step debugging)\nExecutes a specific node with provided inputs for single-step debugging.\nReturns the node execution result including status, outputs, and timing.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRun',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run',
|
||||
summary: 'Run a single node in snippet draft workflow',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody,
|
||||
params: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse)
|
||||
|
||||
export const run3 = {
|
||||
post: post4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all variables for a specific node (snippet draft workflow)
|
||||
*/
|
||||
export const delete_ = oc
|
||||
.route({
|
||||
description: 'Delete all variables for a specific node (snippet draft workflow)',
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables',
|
||||
successStatus: 204,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath }))
|
||||
.output(zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse)
|
||||
|
||||
/**
|
||||
* Get variables for a specific node (snippet draft workflow)
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get9 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Get variables for a specific node (snippet draft workflow)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse)
|
||||
|
||||
export const variables = {
|
||||
delete: delete_,
|
||||
get: get9,
|
||||
}
|
||||
|
||||
export const byNodeId3 = {
|
||||
lastRun,
|
||||
run: run3,
|
||||
variables,
|
||||
}
|
||||
|
||||
export const nodes3 = {
|
||||
byNodeId: byNodeId3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run draft workflow for snippet
|
||||
*
|
||||
* Executes the snippet's draft workflow with the provided inputs
|
||||
* and returns an SSE event stream with execution progress and results.
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post5 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Executes the snippet\'s draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsDraftRun',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/run',
|
||||
summary: 'Run draft workflow for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostSnippetsBySnippetIdWorkflowsDraftRunBody,
|
||||
params: zPostSnippetsBySnippetIdWorkflowsDraftRunPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsDraftRunResponse)
|
||||
|
||||
export const run4 = {
|
||||
post: post5,
|
||||
}
|
||||
|
||||
/**
|
||||
* System variables are not used in snippet workflows; returns an empty list for API parity
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get10 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'System variables are not used in snippet workflows; returns an empty list for API parity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftSystemVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/system-variables',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse)
|
||||
|
||||
export const systemVariables = {
|
||||
get: get10,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a draft workflow variable to its default value (snippet scope)
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const put = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Reset a draft workflow variable to its default value (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdReset',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath }))
|
||||
.output(zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse)
|
||||
|
||||
export const reset = {
|
||||
put,
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a draft workflow variable (snippet scope)
|
||||
*/
|
||||
export const delete2 = oc
|
||||
.route({
|
||||
description: 'Delete a draft workflow variable (snippet scope)',
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}',
|
||||
successStatus: 204,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath }))
|
||||
.output(zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse)
|
||||
|
||||
/**
|
||||
* Get a specific draft workflow variable (snippet scope)
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get11 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Get a specific draft workflow variable (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse)
|
||||
|
||||
/**
|
||||
* Update a draft workflow variable (snippet scope)
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const patch = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Update a draft workflow variable (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody,
|
||||
params: zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
|
||||
}),
|
||||
)
|
||||
.output(zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse)
|
||||
|
||||
export const byVariableId = {
|
||||
delete: delete2,
|
||||
get: get11,
|
||||
patch,
|
||||
reset,
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all draft workflow variables for the current user (snippet scope)
|
||||
*/
|
||||
export const delete3 = oc
|
||||
.route({
|
||||
description: 'Delete all draft workflow variables for the current user (snippet scope)',
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteSnippetsBySnippetIdWorkflowsDraftVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/variables',
|
||||
successStatus: 204,
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath }))
|
||||
.output(zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse)
|
||||
|
||||
/**
|
||||
* List draft workflow variables without values (paginated, snippet scope)
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get12 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'List draft workflow variables without values (paginated, snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraftVariables',
|
||||
path: '/snippets/{snippet_id}/workflows/draft/variables',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath,
|
||||
query: zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse)
|
||||
|
||||
export const variables2 = {
|
||||
delete: delete3,
|
||||
get: get12,
|
||||
byVariableId,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get draft workflow for snippet
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get13 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsDraft',
|
||||
path: '/snippets/{snippet_id}/workflows/draft',
|
||||
summary: 'Get draft workflow for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsDraftResponse)
|
||||
|
||||
/**
|
||||
* Sync draft workflow for snippet
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post6 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsDraft',
|
||||
path: '/snippets/{snippet_id}/workflows/draft',
|
||||
summary: 'Sync draft workflow for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostSnippetsBySnippetIdWorkflowsDraftBody,
|
||||
params: zPostSnippetsBySnippetIdWorkflowsDraftPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsDraftResponse)
|
||||
|
||||
export const draft = {
|
||||
get: get13,
|
||||
post: post6,
|
||||
config,
|
||||
conversationVariables,
|
||||
environmentVariables,
|
||||
iteration,
|
||||
loop,
|
||||
nodes: nodes3,
|
||||
run: run4,
|
||||
systemVariables,
|
||||
variables: variables2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get published workflow for snippet
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get14 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflowsPublish',
|
||||
path: '/snippets/{snippet_id}/workflows/publish',
|
||||
summary: 'Get published workflow for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsPublishPath }))
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsPublishResponse)
|
||||
|
||||
/**
|
||||
* Publish snippet workflow
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post7 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsPublish',
|
||||
path: '/snippets/{snippet_id}/workflows/publish',
|
||||
summary: 'Publish snippet workflow',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostSnippetsBySnippetIdWorkflowsPublishBody,
|
||||
params: zPostSnippetsBySnippetIdWorkflowsPublishPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsPublishResponse)
|
||||
|
||||
export const publish = {
|
||||
get: get14,
|
||||
post: post7,
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a published snippet workflow version into the draft workflow
|
||||
*
|
||||
* Restore a published snippet workflow version into the draft workflow
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post8 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Restore a published snippet workflow version into the draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postSnippetsBySnippetIdWorkflowsByWorkflowIdRestore',
|
||||
path: '/snippets/{snippet_id}/workflows/{workflow_id}/restore',
|
||||
summary: 'Restore a published snippet workflow version into the draft workflow',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath }))
|
||||
.output(zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse)
|
||||
|
||||
export const restore = {
|
||||
post: post8,
|
||||
}
|
||||
|
||||
export const byWorkflowId = {
|
||||
restore,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all published workflow versions for snippet
|
||||
*
|
||||
* Get all published workflows for a snippet
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const get15 = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Get all published workflows for a snippet\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getSnippetsBySnippetIdWorkflows',
|
||||
path: '/snippets/{snippet_id}/workflows',
|
||||
summary: 'Get all published workflow versions for snippet',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetSnippetsBySnippetIdWorkflowsPath,
|
||||
query: zGetSnippetsBySnippetIdWorkflowsQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetSnippetsBySnippetIdWorkflowsResponse)
|
||||
|
||||
export const workflows = {
|
||||
get: get15,
|
||||
defaultWorkflowBlockConfigs,
|
||||
draft,
|
||||
publish,
|
||||
byWorkflowId,
|
||||
}
|
||||
|
||||
export const bySnippetId = {
|
||||
workflowRuns,
|
||||
workflows,
|
||||
}
|
||||
|
||||
export const snippets = {
|
||||
bySnippetId,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
snippets,
|
||||
}
|
||||
928
packages/contracts/generated/api/console/snippets/types.gen.ts
Normal file
928
packages/contracts/generated/api/console/snippets/types.gen.ts
Normal file
@ -0,0 +1,928 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type WorkflowRunPaginationResponse = {
|
||||
data: Array<WorkflowRunForListResponse>
|
||||
has_more: boolean
|
||||
limit: number
|
||||
}
|
||||
|
||||
export type WorkflowRunDetailResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccount
|
||||
created_by_end_user?: SimpleEndUser
|
||||
created_by_role?: string | null
|
||||
elapsed_time?: number | null
|
||||
error?: string | null
|
||||
exceptions_count?: number | null
|
||||
finished_at?: number | null
|
||||
graph: unknown
|
||||
id: string
|
||||
inputs: unknown
|
||||
outputs: unknown
|
||||
status?: string | null
|
||||
total_steps?: number | null
|
||||
total_tokens?: number | null
|
||||
version?: string | null
|
||||
}
|
||||
|
||||
export type WorkflowRunNodeExecutionListResponse = {
|
||||
data: Array<WorkflowRunNodeExecutionResponse>
|
||||
}
|
||||
|
||||
export type WorkflowPaginationResponse = {
|
||||
has_more: boolean
|
||||
items: Array<WorkflowResponse>
|
||||
limit: number
|
||||
page: number
|
||||
}
|
||||
|
||||
export type SnippetWorkflowResponse = {
|
||||
conversation_variables: Array<WorkflowConversationVariableResponse>
|
||||
created_at: number
|
||||
created_by?: SimpleAccount
|
||||
environment_variables: Array<WorkflowEnvironmentVariableResponse>
|
||||
features: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
graph: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
hash: string
|
||||
id: string
|
||||
input_fields?: Array<{
|
||||
[key: string]: unknown
|
||||
}>
|
||||
marked_comment: string
|
||||
marked_name: string
|
||||
rag_pipeline_variables: Array<PipelineVariableResponse>
|
||||
tool_published: boolean
|
||||
updated_at: number
|
||||
updated_by?: SimpleAccount
|
||||
version: string
|
||||
}
|
||||
|
||||
export type SnippetDraftSyncPayload = {
|
||||
conversation_variables?: Array<{
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
graph: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
hash?: string | null
|
||||
input_fields?: Array<{
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
}
|
||||
|
||||
export type WorkflowDraftVariableList = {
|
||||
items?: Array<WorkflowDraftVariable>
|
||||
}
|
||||
|
||||
export type SnippetIterationNodeRunPayload = {
|
||||
inputs?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
export type SnippetLoopNodeRunPayload = {
|
||||
inputs?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
export type WorkflowRunNodeExecutionResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccount
|
||||
created_by_end_user?: SimpleEndUser
|
||||
created_by_role?: string | null
|
||||
elapsed_time?: number | null
|
||||
error?: string | null
|
||||
execution_metadata?: unknown
|
||||
extras?: unknown
|
||||
finished_at?: number | null
|
||||
id: string
|
||||
index?: number | null
|
||||
inputs?: unknown
|
||||
inputs_truncated?: boolean | null
|
||||
node_id?: string | null
|
||||
node_type?: string | null
|
||||
outputs?: unknown
|
||||
outputs_truncated?: boolean | null
|
||||
predecessor_node_id?: string | null
|
||||
process_data?: unknown
|
||||
process_data_truncated?: boolean | null
|
||||
status?: string | null
|
||||
title?: string | null
|
||||
}
|
||||
|
||||
export type SnippetDraftNodeRunPayload = {
|
||||
files?: Array<{
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
query?: string
|
||||
}
|
||||
|
||||
export type SnippetDraftRunPayload = {
|
||||
files?: Array<{
|
||||
[key: string]: unknown
|
||||
}> | null
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowDraftVariableListWithoutValue = {
|
||||
items?: Array<WorkflowDraftVariableWithoutValue>
|
||||
total?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkflowDraftVariable = {
|
||||
description?: string
|
||||
edited?: boolean
|
||||
full_content?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
id?: string
|
||||
is_truncated?: boolean
|
||||
name?: string
|
||||
selector?: Array<string>
|
||||
type?: string
|
||||
value?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
value_type?: string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export type WorkflowDraftVariableUpdatePayload = {
|
||||
name?: string | null
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type PublishWorkflowPayload = {
|
||||
knowledge_base_setting?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
}
|
||||
|
||||
export type WorkflowRunForListResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccount
|
||||
elapsed_time?: number | null
|
||||
exceptions_count?: number | null
|
||||
finished_at?: number | null
|
||||
id: string
|
||||
retry_index?: number | null
|
||||
status?: string | null
|
||||
total_steps?: number | null
|
||||
total_tokens?: number | null
|
||||
version?: string | null
|
||||
}
|
||||
|
||||
export type SimpleAccount = {
|
||||
email: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SimpleEndUser = {
|
||||
id: string
|
||||
is_anonymous: boolean
|
||||
session_id?: string | null
|
||||
type: string
|
||||
}
|
||||
|
||||
export type WorkflowResponse = {
|
||||
conversation_variables: Array<WorkflowConversationVariableResponse>
|
||||
created_at: number
|
||||
created_by?: SimpleAccount
|
||||
environment_variables: Array<WorkflowEnvironmentVariableResponse>
|
||||
features: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
graph: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
hash: string
|
||||
id: string
|
||||
marked_comment: string
|
||||
marked_name: string
|
||||
rag_pipeline_variables: Array<PipelineVariableResponse>
|
||||
tool_published: boolean
|
||||
updated_at: number
|
||||
updated_by?: SimpleAccount
|
||||
version: string
|
||||
}
|
||||
|
||||
export type WorkflowConversationVariableResponse = {
|
||||
description: string
|
||||
id: string
|
||||
name: string
|
||||
value: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
value_type: string
|
||||
}
|
||||
|
||||
export type WorkflowEnvironmentVariableResponse = {
|
||||
description: string
|
||||
id: string
|
||||
name: string
|
||||
value: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
value_type: string
|
||||
}
|
||||
|
||||
export type PipelineVariableResponse = {
|
||||
allowed_file_extensions?: Array<string> | null
|
||||
allowed_file_types?: Array<string> | null
|
||||
allowed_file_upload_methods?: Array<string> | null
|
||||
belong_to_node_id: string
|
||||
default_value?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
label: string
|
||||
max_length?: number | null
|
||||
options?: Array<string> | null
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
tooltips?: string | null
|
||||
type: string
|
||||
unit?: string | null
|
||||
variable: string
|
||||
}
|
||||
|
||||
export type WorkflowDraftVariableWithoutValue = {
|
||||
description?: string
|
||||
edited?: boolean
|
||||
id?: string
|
||||
is_truncated?: boolean
|
||||
name?: string
|
||||
selector?: Array<string>
|
||||
type?: string
|
||||
value_type?: string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflow-runs'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsResponses = {
|
||||
200: WorkflowRunPaginationResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsResponse
|
||||
= GetSnippetsBySnippetIdWorkflowRunsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
task_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopError
|
||||
= PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse
|
||||
= PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
run_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflow-runs/{run_id}'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdError
|
||||
= GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses = {
|
||||
200: WorkflowRunDetailResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponse
|
||||
= GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
run_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflow-runs/{run_id}/node-executions'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses = {
|
||||
200: WorkflowRunNodeExecutionListResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse
|
||||
= GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: {
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
url: '/snippets/{snippet_id}/workflows'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsResponses = {
|
||||
200: WorkflowPaginationResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsResponses[keyof GetSnippetsBySnippetIdWorkflowsResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/default-workflow-block-configs'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftError
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftErrors]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftResponses = {
|
||||
200: SnippetWorkflowResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftData = {
|
||||
body: SnippetDraftSyncPayload
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftError
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftConfigData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/config'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftConfigResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConfigResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/conversation-variables'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses = {
|
||||
200: WorkflowDraftVariableList
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/environment-variables'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesError
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunData = {
|
||||
body: SnippetIterationNodeRunPayload
|
||||
path: {
|
||||
node_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunError
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunData = {
|
||||
body: SnippetLoopNodeRunPayload
|
||||
path: {
|
||||
node_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunError
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunData = {
|
||||
body?: never
|
||||
path: {
|
||||
node_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunError
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses = {
|
||||
200: WorkflowRunNodeExecutionResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunData = {
|
||||
body: SnippetDraftNodeRunPayload
|
||||
path: {
|
||||
node_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunError
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses = {
|
||||
200: WorkflowRunNodeExecutionResponse
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses]
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
node_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables'
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses = {
|
||||
204: {
|
||||
[key: string]: never
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse
|
||||
= DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
node_id: string
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses = {
|
||||
200: WorkflowDraftVariableList
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftRunData = {
|
||||
body: SnippetDraftRunPayload
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/run'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftRunErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftRunError
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftRunErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftRunResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsDraftRunResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsDraftRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftRunResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/system-variables'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses = {
|
||||
200: WorkflowDraftVariableList
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses]
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/variables'
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses = {
|
||||
204: {
|
||||
[key: string]: never
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse
|
||||
= DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: {
|
||||
limit?: number
|
||||
page?: number
|
||||
}
|
||||
url: '/snippets/{snippet_id}/workflows/draft/variables'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses = {
|
||||
200: WorkflowDraftVariableListWithoutValue
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses]
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
variable_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}'
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError
|
||||
= DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors]
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = {
|
||||
204: {
|
||||
[key: string]: never
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse
|
||||
= DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
variable_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = {
|
||||
200: WorkflowDraftVariable
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses]
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = {
|
||||
body: WorkflowDraftVariableUpdatePayload
|
||||
path: {
|
||||
snippet_id: string
|
||||
variable_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}'
|
||||
}
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError
|
||||
= PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors]
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = {
|
||||
200: WorkflowDraftVariable
|
||||
}
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse
|
||||
= PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses]
|
||||
|
||||
export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
variable_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset'
|
||||
}
|
||||
|
||||
export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetError
|
||||
= PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors]
|
||||
|
||||
export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses = {
|
||||
200: WorkflowDraftVariable
|
||||
204: {
|
||||
[key: string]: never
|
||||
}
|
||||
}
|
||||
|
||||
export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse
|
||||
= PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsPublishData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/publish'
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsPublishErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsPublishError
|
||||
= GetSnippetsBySnippetIdWorkflowsPublishErrors[keyof GetSnippetsBySnippetIdWorkflowsPublishErrors]
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsPublishResponses = {
|
||||
200: SnippetWorkflowResponse
|
||||
}
|
||||
|
||||
export type GetSnippetsBySnippetIdWorkflowsPublishResponse
|
||||
= GetSnippetsBySnippetIdWorkflowsPublishResponses[keyof GetSnippetsBySnippetIdWorkflowsPublishResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsPublishData = {
|
||||
body: PublishWorkflowPayload
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/publish'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsPublishErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsPublishError
|
||||
= PostSnippetsBySnippetIdWorkflowsPublishErrors[keyof PostSnippetsBySnippetIdWorkflowsPublishErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsPublishResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsPublishResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
workflow_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/{workflow_id}/restore'
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreError
|
||||
= PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses]
|
||||
633
packages/contracts/generated/api/console/snippets/zod.gen.ts
Normal file
633
packages/contracts/generated/api/console/snippets/zod.gen.ts
Normal file
@ -0,0 +1,633 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* SnippetDraftSyncPayload
|
||||
*
|
||||
* Payload for syncing snippet draft workflow.
|
||||
*/
|
||||
export const zSnippetDraftSyncPayload = z.object({
|
||||
conversation_variables: z.array(z.record(z.string(), z.unknown())).nullish(),
|
||||
graph: z.record(z.string(), z.unknown()),
|
||||
hash: z.string().nullish(),
|
||||
input_fields: z.array(z.record(z.string(), z.unknown())).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetIterationNodeRunPayload
|
||||
*
|
||||
* Payload for running an iteration node in snippet draft workflow.
|
||||
*/
|
||||
export const zSnippetIterationNodeRunPayload = z.object({
|
||||
inputs: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetLoopNodeRunPayload
|
||||
*
|
||||
* Payload for running a loop node in snippet draft workflow.
|
||||
*/
|
||||
export const zSnippetLoopNodeRunPayload = z.object({
|
||||
inputs: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetDraftNodeRunPayload
|
||||
*
|
||||
* Payload for running a single node in snippet draft workflow.
|
||||
*/
|
||||
export const zSnippetDraftNodeRunPayload = z.object({
|
||||
files: z.array(z.record(z.string(), z.unknown())).nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
query: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetDraftRunPayload
|
||||
*
|
||||
* Payload for running snippet draft workflow.
|
||||
*/
|
||||
export const zSnippetDraftRunPayload = z.object({
|
||||
files: z.array(z.record(z.string(), z.unknown())).nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
|
||||
export const zWorkflowDraftVariable = z.object({
|
||||
description: z.string().optional(),
|
||||
edited: z.boolean().optional(),
|
||||
full_content: z.record(z.string(), z.unknown()).optional(),
|
||||
id: z.string().optional(),
|
||||
is_truncated: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
selector: z.array(z.string()).optional(),
|
||||
type: z.string().optional(),
|
||||
value: z.record(z.string(), z.unknown()).optional(),
|
||||
value_type: z.string().optional(),
|
||||
visible: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const zWorkflowDraftVariableList = z.object({
|
||||
items: z.array(zWorkflowDraftVariable).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowDraftVariableUpdatePayload
|
||||
*/
|
||||
export const zWorkflowDraftVariableUpdatePayload = z.object({
|
||||
name: z.string().nullish(),
|
||||
value: z.unknown().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PublishWorkflowPayload
|
||||
*
|
||||
* Payload for publishing snippet workflow.
|
||||
*/
|
||||
export const zPublishWorkflowPayload = z.object({
|
||||
knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleAccount
|
||||
*/
|
||||
export const zSimpleAccount = z.object({
|
||||
email: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunForListResponse
|
||||
*/
|
||||
export const zWorkflowRunForListResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
created_by_account: zSimpleAccount.optional(),
|
||||
elapsed_time: z.number().nullish(),
|
||||
exceptions_count: z.int().nullish(),
|
||||
finished_at: z.int().nullish(),
|
||||
id: z.string(),
|
||||
retry_index: z.int().nullish(),
|
||||
status: z.string().nullish(),
|
||||
total_steps: z.int().nullish(),
|
||||
total_tokens: z.int().nullish(),
|
||||
version: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunPaginationResponse
|
||||
*/
|
||||
export const zWorkflowRunPaginationResponse = z.object({
|
||||
data: z.array(zWorkflowRunForListResponse),
|
||||
has_more: z.boolean(),
|
||||
limit: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleEndUser
|
||||
*/
|
||||
export const zSimpleEndUser = z.object({
|
||||
id: z.string(),
|
||||
is_anonymous: z.boolean(),
|
||||
session_id: z.string().nullish(),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunDetailResponse
|
||||
*/
|
||||
export const zWorkflowRunDetailResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
created_by_account: zSimpleAccount.optional(),
|
||||
created_by_end_user: zSimpleEndUser.optional(),
|
||||
created_by_role: z.string().nullish(),
|
||||
elapsed_time: z.number().nullish(),
|
||||
error: z.string().nullish(),
|
||||
exceptions_count: z.int().nullish(),
|
||||
finished_at: z.int().nullish(),
|
||||
graph: z.unknown(),
|
||||
id: z.string(),
|
||||
inputs: z.unknown(),
|
||||
outputs: z.unknown(),
|
||||
status: z.string().nullish(),
|
||||
total_steps: z.int().nullish(),
|
||||
total_tokens: z.int().nullish(),
|
||||
version: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunNodeExecutionResponse
|
||||
*/
|
||||
export const zWorkflowRunNodeExecutionResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
created_by_account: zSimpleAccount.optional(),
|
||||
created_by_end_user: zSimpleEndUser.optional(),
|
||||
created_by_role: z.string().nullish(),
|
||||
elapsed_time: z.number().nullish(),
|
||||
error: z.string().nullish(),
|
||||
execution_metadata: z.unknown().optional(),
|
||||
extras: z.unknown().optional(),
|
||||
finished_at: z.int().nullish(),
|
||||
id: z.string(),
|
||||
index: z.int().nullish(),
|
||||
inputs: z.unknown().optional(),
|
||||
inputs_truncated: z.boolean().nullish(),
|
||||
node_id: z.string().nullish(),
|
||||
node_type: z.string().nullish(),
|
||||
outputs: z.unknown().optional(),
|
||||
outputs_truncated: z.boolean().nullish(),
|
||||
predecessor_node_id: z.string().nullish(),
|
||||
process_data: z.unknown().optional(),
|
||||
process_data_truncated: z.boolean().nullish(),
|
||||
status: z.string().nullish(),
|
||||
title: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunNodeExecutionListResponse
|
||||
*/
|
||||
export const zWorkflowRunNodeExecutionListResponse = z.object({
|
||||
data: z.array(zWorkflowRunNodeExecutionResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowConversationVariableResponse
|
||||
*/
|
||||
export const zWorkflowConversationVariableResponse = z.object({
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
value: z.record(z.string(), z.unknown()),
|
||||
value_type: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowEnvironmentVariableResponse
|
||||
*/
|
||||
export const zWorkflowEnvironmentVariableResponse = z.object({
|
||||
description: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
value: z.record(z.string(), z.unknown()),
|
||||
value_type: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PipelineVariableResponse
|
||||
*/
|
||||
export const zPipelineVariableResponse = z.object({
|
||||
allowed_file_extensions: z.array(z.string()).nullish(),
|
||||
allowed_file_types: z.array(z.string()).nullish(),
|
||||
allowed_file_upload_methods: z.array(z.string()).nullish(),
|
||||
belong_to_node_id: z.string(),
|
||||
default_value: z.record(z.string(), z.unknown()).optional(),
|
||||
label: z.string(),
|
||||
max_length: z.int().nullish(),
|
||||
options: z.array(z.string()).nullish(),
|
||||
placeholder: z.string().nullish(),
|
||||
required: z.boolean(),
|
||||
tooltips: z.string().nullish(),
|
||||
type: z.string(),
|
||||
unit: z.string().nullish(),
|
||||
variable: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SnippetWorkflowResponse
|
||||
*/
|
||||
export const zSnippetWorkflowResponse = z.object({
|
||||
conversation_variables: z.array(zWorkflowConversationVariableResponse),
|
||||
created_at: z.int(),
|
||||
created_by: zSimpleAccount.optional(),
|
||||
environment_variables: z.array(zWorkflowEnvironmentVariableResponse),
|
||||
features: z.record(z.string(), z.unknown()),
|
||||
graph: z.record(z.string(), z.unknown()),
|
||||
hash: z.string(),
|
||||
id: z.string(),
|
||||
input_fields: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
marked_comment: z.string(),
|
||||
marked_name: z.string(),
|
||||
rag_pipeline_variables: z.array(zPipelineVariableResponse),
|
||||
tool_published: z.boolean(),
|
||||
updated_at: z.int(),
|
||||
updated_by: zSimpleAccount.optional(),
|
||||
version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowResponse
|
||||
*/
|
||||
export const zWorkflowResponse = z.object({
|
||||
conversation_variables: z.array(zWorkflowConversationVariableResponse),
|
||||
created_at: z.int(),
|
||||
created_by: zSimpleAccount.optional(),
|
||||
environment_variables: z.array(zWorkflowEnvironmentVariableResponse),
|
||||
features: z.record(z.string(), z.unknown()),
|
||||
graph: z.record(z.string(), z.unknown()),
|
||||
hash: z.string(),
|
||||
id: z.string(),
|
||||
marked_comment: z.string(),
|
||||
marked_name: z.string(),
|
||||
rag_pipeline_variables: z.array(zPipelineVariableResponse),
|
||||
tool_published: z.boolean(),
|
||||
updated_at: z.int(),
|
||||
updated_by: zSimpleAccount.optional(),
|
||||
version: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowPaginationResponse
|
||||
*/
|
||||
export const zWorkflowPaginationResponse = z.object({
|
||||
has_more: z.boolean(),
|
||||
items: z.array(zWorkflowResponse),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
})
|
||||
|
||||
export const zWorkflowDraftVariableWithoutValue = z.object({
|
||||
description: z.string().optional(),
|
||||
edited: z.boolean().optional(),
|
||||
id: z.string().optional(),
|
||||
is_truncated: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
selector: z.array(z.string()).optional(),
|
||||
type: z.string().optional(),
|
||||
value_type: z.string().optional(),
|
||||
visible: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const zWorkflowDraftVariableListWithoutValue = z.object({
|
||||
items: z.array(zWorkflowDraftVariableWithoutValue).optional(),
|
||||
total: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowRunsPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow runs retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowRunsResponse = zWorkflowRunPaginationResponse
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
task_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Task stopped successfully
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath = z.object({
|
||||
run_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow run detail retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({
|
||||
run_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node executions retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse
|
||||
= zWorkflowRunNodeExecutionListResponse
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsQuery = z.object({
|
||||
limit: z.int().gte(1).lte(100).optional().default(10),
|
||||
page: z.int().gte(1).lte(99999).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Published workflows retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsResponse = zWorkflowPaginationResponse
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Default block configs retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft workflow retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftResponse = zSnippetWorkflowResponse
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftBody = zSnippetDraftSyncPayload
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft workflow synced successfully
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftConfigPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft config retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Conversation variables retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse
|
||||
= zWorkflowDraftVariableList
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Environment variables retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody
|
||||
= zSnippetIterationNodeRunPayload
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({
|
||||
node_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Iteration node run started successfully (SSE stream)
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody
|
||||
= zSnippetLoopNodeRunPayload
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({
|
||||
node_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Loop node run started successfully (SSE stream)
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({
|
||||
node_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node last run retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse
|
||||
= zWorkflowRunNodeExecutionResponse
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody
|
||||
= zSnippetDraftNodeRunPayload
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath = z.object({
|
||||
node_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node run completed successfully
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse
|
||||
= zWorkflowRunNodeExecutionResponse
|
||||
|
||||
export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({
|
||||
node_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node variables deleted successfully
|
||||
*/
|
||||
export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record(
|
||||
z.string(),
|
||||
z.never(),
|
||||
)
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({
|
||||
node_id: z.string(),
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node variables retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse
|
||||
= zWorkflowDraftVariableList
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftRunBody = zSnippetDraftRunPayload
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftRunPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Draft workflow run started successfully (SSE stream)
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsDraftRunResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* System variables retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse
|
||||
= zWorkflowDraftVariableList
|
||||
|
||||
export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow variables deleted successfully
|
||||
*/
|
||||
export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse = z.record(
|
||||
z.string(),
|
||||
z.never(),
|
||||
)
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery = z.object({
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).lte(100000).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow variables retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse
|
||||
= zWorkflowDraftVariableListWithoutValue
|
||||
|
||||
export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
variable_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Variable deleted successfully
|
||||
*/
|
||||
export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = z.record(
|
||||
z.string(),
|
||||
z.never(),
|
||||
)
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
variable_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Variable retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse
|
||||
= zWorkflowDraftVariable
|
||||
|
||||
export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody
|
||||
= zWorkflowDraftVariableUpdatePayload
|
||||
|
||||
export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
variable_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Variable updated successfully
|
||||
*/
|
||||
export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse
|
||||
= zWorkflowDraftVariable
|
||||
|
||||
export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
variable_id: z.string(),
|
||||
})
|
||||
|
||||
export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([
|
||||
zWorkflowDraftVariable,
|
||||
z.record(z.string(), z.never()),
|
||||
])
|
||||
|
||||
export const zGetSnippetsBySnippetIdWorkflowsPublishPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Published workflow retrieved successfully
|
||||
*/
|
||||
export const zGetSnippetsBySnippetIdWorkflowsPublishResponse = zSnippetWorkflowResponse
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsPublishBody = zPublishWorkflowPayload
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow published successfully
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = z.record(z.string(), z.unknown())
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({
|
||||
snippet_id: z.string(),
|
||||
workflow_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow restored successfully
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
@ -20,7 +20,7 @@ export type TagBindingRemovePayload = {
|
||||
type: TagType
|
||||
}
|
||||
|
||||
export type TagType = 'app' | 'knowledge'
|
||||
export type TagType = 'app' | 'knowledge' | 'snippet'
|
||||
|
||||
export type PostTagBindingsData = {
|
||||
body: TagBindingPayload
|
||||
|
||||
@ -14,7 +14,7 @@ export const zSimpleResultResponse = z.object({
|
||||
*
|
||||
* Tag type
|
||||
*/
|
||||
export const zTagType = z.enum(['app', 'knowledge'])
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
|
||||
|
||||
/**
|
||||
* TagBindingPayload
|
||||
|
||||
@ -20,7 +20,7 @@ export type TagUpdateRequestPayload = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export type TagType = 'app' | 'knowledge'
|
||||
export type TagType = 'app' | 'knowledge' | 'snippet'
|
||||
|
||||
export type GetTagsData = {
|
||||
body?: never
|
||||
|
||||
@ -24,7 +24,7 @@ export const zTagUpdateRequestPayload = z.object({
|
||||
*
|
||||
* Tag type
|
||||
*/
|
||||
export const zTagType = z.enum(['app', 'knowledge'])
|
||||
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
|
||||
|
||||
/**
|
||||
* TagBasePayload
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,6 +19,68 @@ export type TenantInfoResponse = {
|
||||
trial_end_reason?: string | null
|
||||
}
|
||||
|
||||
export type SnippetPagination = {
|
||||
data?: Array<AnonymousInlineModel7B67Ac8A4Db8>
|
||||
has_more?: boolean
|
||||
limit?: number
|
||||
page?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export type CreateSnippetPayload = {
|
||||
description?: string | null
|
||||
graph?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
icon_info?: IconInfo
|
||||
input_fields?: Array<InputFieldDefinition> | null
|
||||
name: string
|
||||
type?: 'group' | 'node'
|
||||
}
|
||||
|
||||
export type Snippet = {
|
||||
created_at?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
created_by?: AnonymousInlineModelB0Fd3F86D9D5
|
||||
description?: string
|
||||
graph?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
icon_info?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
id?: string
|
||||
input_fields?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
is_published?: boolean
|
||||
name?: string
|
||||
tags?: Array<AnonymousInlineModel7B8B49Ca164e>
|
||||
type?: string
|
||||
updated_at?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
updated_by?: AnonymousInlineModelB0Fd3F86D9D5
|
||||
use_count?: number
|
||||
version?: number
|
||||
}
|
||||
|
||||
export type SnippetImportPayload = {
|
||||
description?: string | null
|
||||
mode: string
|
||||
name?: string | null
|
||||
snippet_id?: string | null
|
||||
yaml_content?: string | null
|
||||
yaml_url?: string | null
|
||||
}
|
||||
|
||||
export type UpdateSnippetPayload = {
|
||||
description?: string | null
|
||||
icon_info?: IconInfo
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type AccountWithRoleList = {
|
||||
accounts: Array<AccountWithRole>
|
||||
}
|
||||
@ -504,6 +566,59 @@ export type WorkspaceCustomConfigResponse = {
|
||||
replace_webapp_logo?: string | null
|
||||
}
|
||||
|
||||
export type AnonymousInlineModel7B67Ac8A4Db8 = {
|
||||
author_name?: string
|
||||
created_at?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
created_by?: string
|
||||
description?: string
|
||||
icon_info?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
id?: string
|
||||
is_published?: boolean
|
||||
name?: string
|
||||
tags?: Array<AnonymousInlineModel7B8B49Ca164e>
|
||||
type?: string
|
||||
updated_at?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
updated_by?: string
|
||||
use_count?: number
|
||||
version?: number
|
||||
}
|
||||
|
||||
export type IconInfo = {
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: 'emoji' | 'image' | null
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
export type InputFieldDefinition = {
|
||||
default?: string | null
|
||||
hint?: boolean | null
|
||||
label?: string | null
|
||||
max_length?: number | null
|
||||
options?: Array<string> | null
|
||||
placeholder?: string | null
|
||||
required?: boolean | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
export type AnonymousInlineModelB0Fd3F86D9D5 = {
|
||||
email?: string
|
||||
id?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type AnonymousInlineModel7B8B49Ca164e = {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
export type AccountWithRole = {
|
||||
avatar?: string | null
|
||||
created_at?: number | null
|
||||
@ -629,6 +744,266 @@ export type GetWorkspacesCurrentAgentProvidersResponses = {
|
||||
export type GetWorkspacesCurrentAgentProvidersResponse
|
||||
= GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
creators?: Array<string> | null
|
||||
is_published?: boolean | null
|
||||
keyword?: string | null
|
||||
limit?: number
|
||||
page?: number
|
||||
tag_ids?: Array<string> | null
|
||||
}
|
||||
url: '/workspaces/current/customized-snippets'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsResponses = {
|
||||
200: SnippetPagination
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsResponse
|
||||
= GetWorkspacesCurrentCustomizedSnippetsResponses[keyof GetWorkspacesCurrentCustomizedSnippetsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsData = {
|
||||
body: CreateSnippetPayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsError
|
||||
= PostWorkspacesCurrentCustomizedSnippetsErrors[keyof PostWorkspacesCurrentCustomizedSnippetsErrors]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsResponses = {
|
||||
201: Snippet
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsResponse
|
||||
= PostWorkspacesCurrentCustomizedSnippetsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsData = {
|
||||
body: SnippetImportPayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/imports'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsError
|
||||
= PostWorkspacesCurrentCustomizedSnippetsImportsErrors[keyof PostWorkspacesCurrentCustomizedSnippetsImportsErrors]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
202: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsResponse
|
||||
= PostWorkspacesCurrentCustomizedSnippetsImportsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsResponses]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmData = {
|
||||
body?: never
|
||||
path: {
|
||||
import_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/imports/{import_id}/confirm'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmError
|
||||
= PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse
|
||||
= PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses]
|
||||
|
||||
export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/{snippet_id}'
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdError
|
||||
= DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors]
|
||||
|
||||
export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = {
|
||||
204: {
|
||||
[key: string]: never
|
||||
}
|
||||
}
|
||||
|
||||
export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse
|
||||
= DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/{snippet_id}'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdError
|
||||
= GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = {
|
||||
200: Snippet
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse
|
||||
= GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses]
|
||||
|
||||
export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdData = {
|
||||
body: UpdateSnippetPayload
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/{snippet_id}'
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdError
|
||||
= PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors]
|
||||
|
||||
export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = {
|
||||
200: Snippet
|
||||
}
|
||||
|
||||
export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse
|
||||
= PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/{snippet_id}/check-dependencies'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesError
|
||||
= GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse
|
||||
= GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/{snippet_id}/export'
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportError
|
||||
= GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors]
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse
|
||||
= GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementData = {
|
||||
body?: never
|
||||
path: {
|
||||
snippet_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/workspaces/current/customized-snippets/{snippet_id}/use-count/increment'
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors = {
|
||||
404: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementError
|
||||
= PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors]
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse
|
||||
= PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses]
|
||||
|
||||
export type GetWorkspacesCurrentDatasetOperatorsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@ -2,6 +2,20 @@
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* SnippetImportPayload
|
||||
*
|
||||
* Payload for importing snippet from DSL.
|
||||
*/
|
||||
export const zSnippetImportPayload = z.object({
|
||||
description: z.string().nullish(),
|
||||
mode: z.string(),
|
||||
name: z.string().nullish(),
|
||||
snippet_id: z.string().nullish(),
|
||||
yaml_content: z.string().nullish(),
|
||||
yaml_url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
@ -463,6 +477,114 @@ export const zTenantInfoResponse = z.object({
|
||||
trial_end_reason: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* IconInfo
|
||||
*
|
||||
* Icon information model.
|
||||
*/
|
||||
export const zIconInfo = z.object({
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.enum(['emoji', 'image']).nullish(),
|
||||
icon_url: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* UpdateSnippetPayload
|
||||
*
|
||||
* Payload for updating a snippet.
|
||||
*/
|
||||
export const zUpdateSnippetPayload = z.object({
|
||||
description: z.string().max(2000).nullish(),
|
||||
icon_info: zIconInfo.optional(),
|
||||
name: z.string().min(1).max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* InputFieldDefinition
|
||||
*
|
||||
* Input field definition for snippet parameters.
|
||||
*/
|
||||
export const zInputFieldDefinition = z.object({
|
||||
default: z.string().nullish(),
|
||||
hint: z.boolean().nullish(),
|
||||
label: z.string().nullish(),
|
||||
max_length: z.int().nullish(),
|
||||
options: z.array(z.string()).nullish(),
|
||||
placeholder: z.string().nullish(),
|
||||
required: z.boolean().nullish(),
|
||||
type: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CreateSnippetPayload
|
||||
*
|
||||
* Payload for creating a new snippet.
|
||||
*/
|
||||
export const zCreateSnippetPayload = z.object({
|
||||
description: z.string().max(2000).nullish(),
|
||||
graph: z.record(z.string(), z.unknown()).nullish(),
|
||||
icon_info: zIconInfo.optional(),
|
||||
input_fields: z.array(zInputFieldDefinition).nullish(),
|
||||
name: z.string().min(1).max(255),
|
||||
type: z.enum(['group', 'node']).optional().default('node'),
|
||||
})
|
||||
|
||||
export const zAnonymousInlineModelB0Fd3F86D9D5 = z.object({
|
||||
email: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zAnonymousInlineModel7B8B49Ca164e = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zSnippet = z.object({
|
||||
created_at: z.record(z.string(), z.unknown()).optional(),
|
||||
created_by: zAnonymousInlineModelB0Fd3F86D9D5.optional(),
|
||||
description: z.string().optional(),
|
||||
graph: z.record(z.string(), z.unknown()).optional(),
|
||||
icon_info: z.record(z.string(), z.unknown()).optional(),
|
||||
id: z.string().optional(),
|
||||
input_fields: z.record(z.string(), z.unknown()).optional(),
|
||||
is_published: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(zAnonymousInlineModel7B8B49Ca164e).optional(),
|
||||
type: z.string().optional(),
|
||||
updated_at: z.record(z.string(), z.unknown()).optional(),
|
||||
updated_by: zAnonymousInlineModelB0Fd3F86D9D5.optional(),
|
||||
use_count: z.int().optional(),
|
||||
version: z.int().optional(),
|
||||
})
|
||||
|
||||
export const zAnonymousInlineModel7B67Ac8A4Db8 = z.object({
|
||||
author_name: z.string().optional(),
|
||||
created_at: z.record(z.string(), z.unknown()).optional(),
|
||||
created_by: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
icon_info: z.record(z.string(), z.unknown()).optional(),
|
||||
id: z.string().optional(),
|
||||
is_published: z.boolean().optional(),
|
||||
name: z.string().optional(),
|
||||
tags: z.array(zAnonymousInlineModel7B8B49Ca164e).optional(),
|
||||
type: z.string().optional(),
|
||||
updated_at: z.record(z.string(), z.unknown()).optional(),
|
||||
updated_by: z.string().optional(),
|
||||
use_count: z.int().optional(),
|
||||
version: z.int().optional(),
|
||||
})
|
||||
|
||||
export const zSnippetPagination = z.object({
|
||||
data: z.array(zAnonymousInlineModel7B67Ac8A4Db8).optional(),
|
||||
has_more: z.boolean().optional(),
|
||||
limit: z.int().optional(),
|
||||
page: z.int().optional(),
|
||||
total: z.int().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AccountWithRole
|
||||
*/
|
||||
@ -809,6 +931,112 @@ export const zGetWorkspacesCurrentAgentProvidersResponse = z.array(
|
||||
z.record(z.string(), z.unknown()),
|
||||
)
|
||||
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsQuery = z.object({
|
||||
creators: z.array(z.string()).nullish(),
|
||||
is_published: z.boolean().nullish(),
|
||||
keyword: z.string().nullish(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
page: z.int().gte(1).lte(99999).optional().default(1),
|
||||
tag_ids: z.array(z.string()).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Snippets retrieved successfully
|
||||
*/
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsResponse = zSnippetPagination
|
||||
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsBody = zCreateSnippetPayload
|
||||
|
||||
/**
|
||||
* Snippet created successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsResponse = zSnippet
|
||||
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsImportsBody = zSnippetImportPayload
|
||||
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsImportsResponse = z.union([
|
||||
z.record(z.string(), z.unknown()),
|
||||
z.record(z.string(), z.unknown()),
|
||||
])
|
||||
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPath = z.object({
|
||||
import_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Import confirmed successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Snippet deleted successfully
|
||||
*/
|
||||
export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = z.record(
|
||||
z.string(),
|
||||
z.never(),
|
||||
)
|
||||
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Snippet retrieved successfully
|
||||
*/
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = zSnippet
|
||||
|
||||
export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody = zUpdateSnippetPayload
|
||||
|
||||
export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Snippet updated successfully
|
||||
*/
|
||||
export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = zSnippet
|
||||
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Dependencies checked successfully
|
||||
*/
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Snippet exported successfully
|
||||
*/
|
||||
export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse = z.record(
|
||||
z.string(),
|
||||
z.unknown(),
|
||||
)
|
||||
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementPath = z.object({
|
||||
snippet_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Use count incremented successfully
|
||||
*/
|
||||
export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse
|
||||
= z.record(z.string(), z.unknown())
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 2.25C3.41421 2.25 3.75 2.58579 3.75 3V15C3.75 15.4142 3.41421 15.75 3 15.75C2.58579 15.75 2.25 15.4142 2.25 15V3C2.25 2.58579 2.58579 2.25 3 2.25Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3V15C15.75 15.4142 15.4142 15.75 15 15.75C14.5858 15.75 14.25 15.4142 14.25 15V3C14.25 2.58579 14.5858 2.25 15 2.25Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 4.5C10.5392 4.5 10.875 4.83579 10.875 5.25V12.75C10.875 13.1642 10.5392 13.5 10.125 13.5H7.875C7.46079 13.5 7.125 13.1642 7.125 12.75V5.25C7.125 4.83579 7.46079 4.5 7.875 4.5H10.125ZM8.625 12H9.375V6H8.625V12Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 751 B |
@ -0,0 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 14.25C15.4142 14.25 15.75 14.5858 15.75 15C15.75 15.4142 15.4142 15.75 15 15.75H3C2.58579 15.75 2.25 15.4142 2.25 15C2.25 14.5858 2.58579 14.25 3 14.25H15Z" fill="#676F83"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 7.125C13.9142 7.125 14.25 7.46079 14.25 7.875V10.125C14.25 10.5392 13.9142 10.875 13.5 10.875H4.5C4.08579 10.875 3.75 10.5392 3.75 10.125V7.875C3.75 7.46079 4.08579 7.125 4.5 7.125H13.5ZM5.25 9.375H12.75V8.625H5.25V9.375Z" fill="#676F83"/>
|
||||
<path d="M15 2.25C15.4142 2.25 15.75 2.58579 15.75 3C15.75 3.41421 15.4142 3.75 15 3.75H3C2.58579 3.75 2.25 3.41421 2.25 3C2.25 2.58579 2.58579 2.25 3 2.25H15Z" fill="#676F83"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
@ -0,0 +1,3 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15V3.75V12.2625V10.6688V15ZM2.5 16.5C1.94772 16.5 1.5 16.0523 1.5 15.5V3.25C1.5 2.69771 1.94772 2.25 2.5 2.25H15.5C16.0523 2.25 16.5 2.69772 16.5 3.25V10.5H15V3.75H3V15H9V16.5H2.5ZM13.0125 17.25L10.35 14.5875L11.4188 13.5375L13.0125 15.1312L16.2 11.9438L17.25 13.0125L13.0125 17.25ZM7.5 9.75H13.5V8.25H7.5V9.75ZM7.5 6.75H13.5V5.25H7.5V6.75ZM4.5 9.75H6V8.25H4.5V9.75ZM4.5 6.75H6V5.25H4.5V6.75Z" fill="#495464"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
@ -513,12 +513,27 @@
|
||||
"width": 14,
|
||||
"height": 14
|
||||
},
|
||||
"line-others-dhs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M3 2.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3A.75.75 0 0 1 3 2.25m12 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75\"/><path fill-rule=\"evenodd\" d=\"M10.125 4.5a.75.75 0 0 1 .75.75v7.5a.75.75 0 0 1-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75zm-1.5 7.5h.75V6h-.75z\" clip-rule=\"evenodd\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-drag-handle": {
|
||||
"body": "<g fill=\"none\"><g id=\"Drag Handle\"><path id=\"drag-handle\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"line-others-dvs": {
|
||||
"body": "<g fill=\"currentColor\"><path d=\"M15 14.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/><path fill-rule=\"evenodd\" d=\"M13.5 7.125a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.75.75h-9a.75.75 0 0 1-.75-.75v-2.25a.75.75 0 0 1 .75-.75zm-8.25 2.25h7.5v-.75h-7.5z\" clip-rule=\"evenodd\"/><path d=\"M15 2.25a.75.75 0 0 1 0 1.5H3a.75.75 0 0 1 0-1.5z\"/></g>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-env": {
|
||||
"body": "<g fill=\"none\"><g id=\"env\"><g id=\"Vector\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 3.33325C1.33325 2.22868 2.22868 1.33325 3.33325 1.33325H12.6666C13.7712 1.33325 14.6666 2.22869 14.6666 3.33325V3.66659C14.6666 4.03478 14.3681 4.33325 13.9999 4.33325C13.6317 4.33325 13.3333 4.03478 13.3333 3.66659V3.33325C13.3333 2.96506 13.0348 2.66659 12.6666 2.66659H3.33325C2.96506 2.66659 2.66659 2.96506 2.66659 3.33325V3.66659C2.66659 4.03478 2.36811 4.33325 1.99992 4.33325C1.63173 4.33325 1.33325 4.03478 1.33325 3.66659V3.33325Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M14.6666 12.6666C14.6666 13.7712 13.7712 14.6666 12.6666 14.6666L3.33325 14.6666C2.22866 14.6666 1.33325 13.7711 1.33325 12.6666L1.33325 12.3333C1.33325 11.9651 1.63173 11.6666 1.99992 11.6666C2.36811 11.6666 2.66659 11.9651 2.66659 12.3333V12.6666C2.66659 13.0348 2.96505 13.3333 3.33325 13.3333L12.6666 13.3333C13.0348 13.3333 13.3333 13.0348 13.3333 12.6666V12.3333C13.3333 11.9651 13.6317 11.6666 13.9999 11.6666C14.3681 11.6666 14.6666 11.9651 14.6666 12.3333V12.6666Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1.33325 5.99992C1.33325 5.63173 1.63173 5.33325 1.99992 5.33325H4.33325C4.70144 5.33325 4.99992 5.63173 4.99992 5.99992C4.99992 6.36811 4.70144 6.66658 4.33325 6.66658H2.66659V7.33325H3.99992C4.36811 7.33325 4.66659 7.63173 4.66659 7.99992C4.66659 8.36811 4.36811 8.66658 3.99992 8.66658H2.66659V9.33325H4.33325C4.70144 9.33325 4.99992 9.63173 4.99992 9.99992C4.99992 10.3681 4.70144 10.6666 4.33325 10.6666H1.99992C1.63173 10.6666 1.33325 10.3681 1.33325 9.99992V5.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.4734 5.36186C6.75457 5.27673 7.05833 5.38568 7.22129 5.63012L8.66659 7.79807V5.99992C8.66659 5.63173 8.96506 5.33325 9.33325 5.33325C9.70144 5.33325 9.99992 5.63173 9.99992 5.99992V9.99992C9.99992 10.2937 9.80761 10.5528 9.52644 10.638C9.24527 10.7231 8.94151 10.6142 8.77855 10.3697L7.33325 8.20177V9.99992C7.33325 10.3681 7.03478 10.6666 6.66659 10.6666C6.2984 10.6666 5.99992 10.3681 5.99992 9.99992V5.99992C5.99992 5.70614 6.19222 5.44699 6.4734 5.36186Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M11.0768 5.38453C11.4167 5.24292 11.807 5.40364 11.9486 5.74351L12.9999 8.26658L14.0512 5.74351C14.1928 5.40364 14.5831 5.24292 14.923 5.38453C15.2629 5.52614 15.4236 5.91646 15.282 6.25633L13.6153 10.2563C13.5118 10.5048 13.2691 10.6666 12.9999 10.6666C12.7308 10.6666 12.488 10.5048 12.3845 10.2563L10.7179 6.25633C10.5763 5.91646 10.737 5.52614 11.0768 5.38453Z\" fill=\"currentColor\"/></g></g></g>"
|
||||
},
|
||||
"line-others-evaluation": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M3 15V3.75v8.513v-1.594zm-.5 1.5a1 1 0 0 1-1-1V3.25a1 1 0 0 1 1-1h13a1 1 0 0 1 1 1v7.25H15V3.75H3V15h6v1.5zm10.513.75l-2.663-2.662l1.069-1.05l1.593 1.593l3.188-3.187l1.05 1.068zM7.5 9.75h6v-1.5h-6zm0-3h6v-1.5h-6zm-3 3H6v-1.5H4.5zm0-3H6v-1.5H4.5z\"/>",
|
||||
"width": 18,
|
||||
"height": 18
|
||||
},
|
||||
"line-others-global-variable": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M6.23814 1.33333H9.76188C10.4844 1.33332 11.0672 1.33332 11.5391 1.37187C12.025 1.41157 12.4518 1.49545 12.8466 1.69664C13.4739 2.01622 13.9838 2.52615 14.3034 3.15336C14.5046 3.54822 14.5884 3.97501 14.6281 4.46091C14.6667 4.93283 14.6667 5.51559 14.6667 6.23811V9.76188C14.6667 10.4844 14.6667 11.0672 14.6281 11.5391C14.5884 12.025 14.5046 12.4518 14.3034 12.8466C13.9838 13.4738 13.4739 13.9838 12.8466 14.3033C12.4518 14.5045 12.025 14.5884 11.5391 14.6281C11.0672 14.6667 10.4844 14.6667 9.7619 14.6667H6.23812C5.51561 14.6667 4.93284 14.6667 4.46093 14.6281C3.97503 14.5884 3.54824 14.5045 3.15338 14.3033C2.52617 13.9838 2.01623 13.4738 1.69666 12.8466C1.49546 12.4518 1.41159 12.025 1.37189 11.5391C1.33333 11.0672 1.33334 10.4844 1.33334 9.76187V6.23812C1.33334 5.5156 1.33333 4.93283 1.37189 4.46091C1.41159 3.97501 1.49546 3.54822 1.69666 3.15336C2.01623 2.52615 2.52617 2.01622 3.15338 1.69664C3.54824 1.49545 3.97503 1.41157 4.46093 1.37187C4.93285 1.33332 5.51561 1.33332 6.23814 1.33333ZM4.5695 2.70078C4.16606 2.73374 3.93427 2.79519 3.7587 2.88465C3.38237 3.0764 3.07641 3.38236 2.88466 3.75868C2.79521 3.93425 2.73376 4.16604 2.70079 4.56949C2.6672 4.98072 2.66668 5.50892 2.66668 6.26666V9.73333C2.66668 10.4911 2.6672 11.0193 2.70079 11.4305C2.73376 11.8339 2.79521 12.0657 2.88466 12.2413C3.07641 12.6176 3.38237 12.9236 3.7587 13.1153C3.93427 13.2048 4.16606 13.2662 4.5695 13.2992C4.98073 13.3328 5.50894 13.3333 6.26668 13.3333H9.73334C10.4911 13.3333 11.0193 13.3328 11.4305 13.2992C11.834 13.2662 12.0658 13.2048 12.2413 13.1153C12.6176 12.9236 12.9236 12.6176 13.1154 12.2413C13.2048 12.0657 13.2663 11.8339 13.2992 11.4305C13.3328 11.0193 13.3333 10.4911 13.3333 9.73333V6.26666C13.3333 5.50892 13.3328 4.98072 13.2992 4.56949C13.2663 4.16604 13.2048 3.93425 13.1154 3.75868C12.9236 3.38236 12.6176 3.0764 12.2413 2.88465C12.0658 2.79519 11.834 2.73374 11.4305 2.70078C11.0193 2.66718 10.4911 2.66666 9.73334 2.66666H6.26668C5.50894 2.66666 4.98073 2.66718 4.5695 2.70078ZM5.08339 5.33333C5.08339 4.96514 5.38187 4.66666 5.75006 4.66666H6.68433C7.324 4.66666 7.87606 5.09677 8.04724 5.70542L8.30138 6.60902L9.2915 5.43554C9.7018 4.94926 10.3035 4.66666 10.9399 4.66666H11C11.3682 4.66666 11.6667 4.96514 11.6667 5.33333C11.6667 5.70152 11.3682 5.99999 11 5.99999H10.9399C10.7005 5.99999 10.4702 6.10616 10.3106 6.29537L8.73751 8.15972L9.23641 9.93357C9.24921 9.97909 9.28574 10 9.31579 10H10.2501C10.6182 10 10.9167 10.2985 10.9167 10.6667C10.9167 11.0349 10.6182 11.3333 10.2501 11.3333H9.31579C8.67612 11.3333 8.12406 10.9032 7.95288 10.2946L7.69871 9.39088L6.70852 10.5644C6.29822 11.0507 5.6965 11.3333 5.06011 11.3333H5.00001C4.63182 11.3333 4.33334 11.0349 4.33334 10.6667C4.33334 10.2985 4.63182 10 5.00001 10H5.06011C5.29949 10 5.52982 9.89383 5.68946 9.70462L7.26258 7.84019L6.76371 6.06642C6.75091 6.0209 6.71438 5.99999 6.68433 5.99999H5.75006C5.38187 5.99999 5.08339 5.70152 5.08339 5.33333Z\" fill=\"currentColor\"/></g>"
|
||||
},
|
||||
@ -1025,6 +1040,11 @@
|
||||
"workflow-if-else": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/if-else\"><path id=\"Vector (Stroke)\" fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.16667 2.98975C7.80423 2.98975 7.51042 2.69593 7.51042 2.3335C7.51042 1.97106 7.80423 1.67725 8.16667 1.67725H11.0833C11.4458 1.67725 11.7396 1.97106 11.7396 2.3335V5.25016C11.7396 5.6126 11.4458 5.90641 11.0833 5.90641C10.7209 5.90641 10.4271 5.6126 10.4271 5.25016V3.91782L7.34474 7.00016L10.4271 10.0825V8.75016C10.4271 8.38773 10.7209 8.09391 11.0833 8.09391C11.4458 8.09391 11.7396 8.38773 11.7396 8.75016V11.6668C11.7396 12.0293 11.4458 12.3231 11.0833 12.3231H8.16667C7.80423 12.3231 7.51042 12.0293 7.51042 11.6668C7.51042 11.3044 7.80423 11.0106 8.16667 11.0106H9.49901L6.14484 7.65641H1.75C1.38756 7.65641 1.09375 7.3626 1.09375 7.00016C1.09375 6.63773 1.38756 6.34391 1.75 6.34391H6.14484L9.49901 2.98975H8.16667Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
"workflow-input-field": {
|
||||
"body": "<path fill=\"currentColor\" d=\"M5.333 3.333h2v9.334h-2V14h5.333v-1.333h-2V3.333h2V2H5.333zm-4 1.334a.667.667 0 0 0-.666.666v5.334c0 .368.298.666.666.666h4V10H2V6h3.333V4.667zM10.667 6H14v4h-3.334v1.333h4a.667.667 0 0 0 .667-.666V5.333a.667.667 0 0 0-.667-.666h-4z\"/>",
|
||||
"width": 16,
|
||||
"height": 16
|
||||
},
|
||||
"workflow-iteration": {
|
||||
"body": "<g fill=\"none\"><g id=\"icons/iteration\"><path id=\"Vector\" d=\"M6.82849 0.754349C6.6007 0.526545 6.23133 0.526545 6.00354 0.754349C5.77573 0.982158 5.77573 1.3515 6.00354 1.57931L6.82849 0.754349ZM8.16602 2.91683L8.57849 3.32931C8.80628 3.1015 8.80628 2.73216 8.57849 2.50435L8.16602 2.91683ZM6.00354 4.25435C5.77573 4.48216 5.77573 4.8515 6.00354 5.07931C6.23133 5.30711 6.6007 5.30711 6.82849 5.07931L6.00354 4.25435ZM7.99516 9.74597C8.22295 9.51818 8.22295 9.14881 7.99516 8.92102C7.76737 8.69323 7.398 8.69323 7.17021 8.92102L7.99516 9.74597ZM5.83268 11.0835L5.4202 10.671C5.1924 10.8988 5.1924 11.2682 5.4202 11.496L5.83268 11.0835ZM7.17021 13.246C7.398 13.4738 7.76737 13.4738 7.99516 13.246C8.22295 13.0182 8.22295 12.6488 7.99516 12.421L7.17021 13.246ZM11.4993 3.73414C11.2738 3.50404 10.9045 3.5003 10.6744 3.72578C10.4443 3.95127 10.4405 4.32059 10.6661 4.55069L11.4993 3.73414ZM7.58268 3.50016C7.90486 3.50016 8.16602 3.23899 8.16602 2.91683C8.16602 2.59467 7.90486 2.3335 7.58268 2.3335L7.58268 3.50016ZM2.49938 10.2662C2.72486 10.4963 3.09419 10.5 3.32429 10.2745C3.55439 10.0491 3.55814 9.6797 3.33266 9.44964L2.49938 10.2662ZM6.00354 1.57931L7.75354 3.32931L8.57849 2.50435L6.82849 0.754349L6.00354 1.57931ZM7.75354 2.50435L6.00354 4.25435L6.82849 5.07931L8.57849 3.32931L7.75354 2.50435ZM7.17021 8.92102L5.4202 10.671L6.24516 11.496L7.99516 9.74597L7.17021 8.92102ZM5.4202 11.496L7.17021 13.246L7.99516 12.421L6.24516 10.671L5.4202 11.496ZM8.16602 10.5002L6.41602 10.5002V11.6668L8.16602 11.6668V10.5002ZM11.666 7.00016C11.666 8.93316 10.099 10.5002 8.16602 10.5002V11.6668C10.7434 11.6668 12.8327 9.57751 12.8327 7.00016H11.666ZM12.8327 7.00016C12.8327 5.72882 12.3235 4.57524 11.4993 3.73414L10.6661 4.55069C11.2852 5.18256 11.666 6.0463 11.666 7.00016H12.8327ZM5.83268 3.50016H7.58268L7.58268 2.3335H5.83268L5.83268 3.50016ZM2.33268 7.00016C2.33268 5.06717 3.89968 3.50016 5.83268 3.50016L5.83268 2.3335C3.25535 2.3335 1.16602 4.42283 1.16602 7.00016H2.33268ZM1.16602 7.00016C1.16602 8.27148 1.67517 9.42508 2.49938 10.2662L3.33266 9.44964C2.71348 8.81777 2.33268 7.95403 2.33268 7.00016H1.16602Z\" fill=\"currentColor\"/></g></g>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 277,
|
||||
"total": 281,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -341,16 +341,11 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render all category tabs', () => {
|
||||
it('should render the app type dropdown trigger', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -381,21 +376,19 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
it('should not render a standalone "created by me" checkbox in the current header layout', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
it('should keep the current layout stable without a "created by me" control', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { redirect, usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import SnippetList from '@/app/components/snippet-list'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <SnippetList />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@ -168,6 +168,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@ -28,12 +28,16 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
appInfoActions?: AppInfoActions
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
iconType = 'app',
|
||||
appInfoActions,
|
||||
}: IAppDetailNavProps) => {
|
||||
@ -112,18 +116,20 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{renderHeader
|
||||
? renderHeader(appSidebarExpand)
|
||||
: iconType === 'app' && (
|
||||
appInfoActions
|
||||
? (
|
||||
<AppInfoView
|
||||
expand={expand}
|
||||
actions={appInfoActions}
|
||||
renderDetail={false}
|
||||
/>
|
||||
)
|
||||
: <AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@ -152,18 +158,20 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{renderNavigation
|
||||
? renderNavigation(appSidebarExpand)
|
||||
: navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
|
||||
@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@ -0,0 +1,270 @@
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
tags: [],
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
177
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
177
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
}), [snippet.description, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description }: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-100">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="title-md-semi-bold text-text-primary">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
46
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
46
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
if (!expand)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-2 pt-2 pb-1">
|
||||
<div className="flex flex-col gap-2 rounded-xl p-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<SnippetInfoDropdown snippet={snippet} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate system-md-semibold text-text-secondary">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.description && (
|
||||
<p className="line-clamp-3 system-xs-regular break-words text-text-tertiary">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@ -1,7 +1,6 @@
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type { Features, FileUpload } from '@/app/components/base/features/types'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -21,9 +20,15 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type PublishedModelConfig = ModelConfig & {
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise<unknown> | unknown
|
||||
publishedConfig: {
|
||||
modelConfig: PublishedModelConfig
|
||||
}
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
const handlePublish = useCallback((params?: AppPublisherPublishParams) => {
|
||||
return props.onPublish?.(params, features)
|
||||
}, [features, props])
|
||||
|
||||
|
||||
@ -85,8 +85,10 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P']
|
||||
|
||||
export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams
|
||||
|
||||
type AppPublisherPublishHandler
|
||||
= | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise<unknown> | unknown)
|
||||
= | ((params?: AppPublisherPublishParams) => Promise<unknown> | unknown)
|
||||
| ((params?: unknown) => Promise<unknown> | unknown)
|
||||
|
||||
type AppPublisherRestoreHandler = () => Promise<unknown> | unknown
|
||||
|
||||
@ -211,6 +211,12 @@ describe('ConfigModalFormFields', () => {
|
||||
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
textInputView.unmount()
|
||||
|
||||
const hiddenFieldDisabledProps = createBaseProps()
|
||||
const hiddenFieldDisabledView = render(<ConfigModalFormFields {...hiddenFieldDisabledProps} showHiddenField={false} />)
|
||||
expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument()
|
||||
hiddenFieldDisabledView.unmount()
|
||||
|
||||
const singleFileProps = createBaseProps()
|
||||
singleFileProps.tempPayload = {
|
||||
...singleFileProps.tempPayload,
|
||||
|
||||
@ -49,6 +49,7 @@ type ConfigModalFormFieldsProps = {
|
||||
onVarNameChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
options?: string[]
|
||||
selectOptions: SelectOptionItem[]
|
||||
showHiddenField?: boolean
|
||||
tempPayload: InputVar
|
||||
t: Translate
|
||||
}
|
||||
@ -67,6 +68,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
onVarNameChange,
|
||||
options,
|
||||
selectOptions,
|
||||
showHiddenField = true,
|
||||
tempPayload,
|
||||
t,
|
||||
}) => {
|
||||
@ -242,7 +244,7 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
<span className="system-sm-semibold text-text-secondary">{t('variableConfig.required', { ns: 'appDebug' })}</span>
|
||||
</label>
|
||||
|
||||
{!isFileInput && (
|
||||
{showHiddenField && !isFileInput && (
|
||||
<div className="mt-5! flex h-6 items-center gap-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
||||
@ -33,6 +33,7 @@ type IConfigModalProps = {
|
||||
onClose: () => void
|
||||
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
|
||||
supportFile?: boolean
|
||||
showHiddenField?: boolean
|
||||
}
|
||||
|
||||
const ConfigModal: FC<IConfigModalProps> = ({
|
||||
@ -41,6 +42,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
isShow,
|
||||
onClose,
|
||||
onConfirm,
|
||||
showHiddenField,
|
||||
supportFile,
|
||||
}) => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
@ -173,6 +175,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
onVarNameChange={handleVarNameChange}
|
||||
options={options}
|
||||
selectOptions={selectOptions}
|
||||
showHiddenField={showHiddenField}
|
||||
tempPayload={tempPayload}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@ -96,7 +96,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -78,7 +78,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
mode: mode as unknown as ModelModeType,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { Features as FeaturesData, OnFeaturesChange } from '@/app/components/base/features/types'
|
||||
@ -21,7 +22,6 @@ import type {
|
||||
TextToSpeechConfig,
|
||||
} from '@/models/debug'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useBoolean, useGetState } from 'ahooks'
|
||||
import { clone } from 'es-toolkit/object'
|
||||
import { produce } from 'immer'
|
||||
@ -481,7 +481,7 @@ export const useConfiguration = (): ConfigurationViewModel => {
|
||||
resolvedModelModeType,
|
||||
])
|
||||
|
||||
const onPublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams, features?: FeaturesData) => {
|
||||
const onPublish = useCallback(async (params?: AppPublisherPublishParams, features?: FeaturesData) => {
|
||||
const modelAndParameter = params && 'model' in params && 'provider' in params && 'parameters' in params
|
||||
? params
|
||||
: undefined
|
||||
|
||||
@ -349,29 +349,40 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
|
||||
function AppPreview({ mode }: { mode: AppModeEnum }) {
|
||||
const { t } = useTranslation()
|
||||
const modeToPreviewInfoMap = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.ADVANCED_CHAT]: {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.AGENT_CHAT]: {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.COMPLETION]: {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
},
|
||||
[AppModeEnum.WORKFLOW]: {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
},
|
||||
}
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
const previewInfo = (() => {
|
||||
switch (mode) {
|
||||
case AppModeEnum.CHAT:
|
||||
return {
|
||||
title: t('types.chatbot', { ns: 'app' }),
|
||||
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.ADVANCED_CHAT:
|
||||
return {
|
||||
title: t('types.advanced', { ns: 'app' }),
|
||||
description: t('newApp.advancedUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.AGENT_CHAT:
|
||||
return {
|
||||
title: t('types.agent', { ns: 'app' }),
|
||||
description: t('newApp.agentUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.COMPLETION:
|
||||
return {
|
||||
title: t('newApp.completeApp', { ns: 'app' }),
|
||||
description: t('newApp.completionUserDescription', { ns: 'app' }),
|
||||
}
|
||||
case AppModeEnum.WORKFLOW:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: t('types.workflow', { ns: 'app' }),
|
||||
description: t('newApp.workflowUserDescription', { ns: 'app' }),
|
||||
}
|
||||
}
|
||||
})()
|
||||
return (
|
||||
<div className="px-8 py-4">
|
||||
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
|
||||
|
||||
92
web/app/components/apps/__tests__/creators-filter.spec.tsx
Normal file
92
web/app/components/apps/__tests__/creators-filter.spec.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import CreatorsFilter from '../creators-filter'
|
||||
|
||||
const mockOnChange = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: { id: 'member-2' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'member-1', name: 'Zoe', avatar_url: null, status: 'active' },
|
||||
{ id: 'member-2', name: 'Alice', avatar_url: null, status: 'active' },
|
||||
{ id: 'member-3', name: 'Bob', avatar_url: null, status: 'active' },
|
||||
{ id: 'member-4', name: 'Pending User', avatar_url: null, status: 'pending' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('CreatorsFilter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should sort the current user first and filter out pending members', () => {
|
||||
render(<CreatorsFilter value={[]} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.allCreators/i }))
|
||||
|
||||
const options = screen.getAllByRole('button').filter(button =>
|
||||
['Alice', 'Bob', 'Zoe'].some(name => button.textContent?.includes(name)),
|
||||
)
|
||||
|
||||
expect(options.map(option => option.textContent)).toEqual([
|
||||
expect.stringContaining('Alice'),
|
||||
expect.stringContaining('Bob'),
|
||||
expect.stringContaining('Zoe'),
|
||||
])
|
||||
expect(screen.getByText('app.studio.filters.you')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pending User')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should search creators, clear keywords, and select a creator', () => {
|
||||
render(<CreatorsFilter value={[]} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.allCreators/i }))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.studio.filters.searchCreators'), {
|
||||
target: { value: 'zo' },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /Zoe/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Bob/ })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(screen.getByPlaceholderText('app.studio.filters.searchCreators')).toHaveValue('')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['member-3'])
|
||||
})
|
||||
|
||||
it('should remove selected creators from the trigger reset and menu reset controls', () => {
|
||||
const { rerender } = render(<CreatorsFilter value={['member-2', 'member-3']} onChange={mockOnChange} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: /app\.studio\.filters\.creators/i })
|
||||
fireEvent.click(within(trigger).getByRole('button', { name: 'app.studio.filters.reset' }))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
|
||||
rerender(<CreatorsFilter value={['member-2', 'member-3']} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'app.studio.filters.reset' }).at(-1)!)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('should remove a selected creator when toggled from the menu', () => {
|
||||
render(<CreatorsFilter value={['member-2', 'member-3']} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /app\.studio\.filters\.creators/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Alice/ }))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['member-3'])
|
||||
})
|
||||
})
|
||||
@ -2,6 +2,8 @@ import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import Empty from '../empty'
|
||||
|
||||
const defaultMessage = 'workflow.tabs.noSnippetsFound'
|
||||
|
||||
describe('Empty', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -9,32 +11,32 @@ describe('Empty', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render 36 placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const placeholderCards = container.querySelectorAll('.bg-background-default-lighter')
|
||||
expect(placeholderCards).toHaveLength(36)
|
||||
})
|
||||
|
||||
it('should display the no apps found message', () => {
|
||||
render(<Empty />)
|
||||
it('should display the provided message', () => {
|
||||
render(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct container styling for overlay', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const overlay = container.querySelector('.pointer-events-none')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
expect(overlay).toHaveClass('absolute', 'inset-0', 'z-20')
|
||||
})
|
||||
|
||||
it('should have correct styling for placeholder cards', () => {
|
||||
const { container } = render(<Empty />)
|
||||
const { container } = render(<Empty message={defaultMessage} />)
|
||||
const card = container.querySelector('.bg-background-default-lighter')
|
||||
expect(card).toHaveClass('inline-flex', 'h-[160px]', 'rounded-xl')
|
||||
})
|
||||
@ -42,10 +44,10 @@ describe('Empty', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(<Empty />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
const { rerender } = render(<Empty message={defaultMessage} />)
|
||||
expect(screen.getByText(defaultMessage)).toBeInTheDocument()
|
||||
|
||||
rerender(<Empty />)
|
||||
rerender(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -51,16 +51,17 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
const mockSetCreatorIDs = vi.fn()
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorIDs: [] as string[],
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -68,7 +69,19 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
query: mockQueryState,
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
setCreatorIDs: mockSetCreatorIDs,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-1', name: 'Alice', avatar_url: null, status: 'active' },
|
||||
{ id: 'creator-2', name: 'Bob', avatar_url: null, status: 'active' },
|
||||
],
|
||||
},
|
||||
setCreatorIDs: mockSetCreatorIDs,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -220,14 +233,14 @@ vi.mock('../footer', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | null = null
|
||||
let intersectionCallbacks: IntersectionObserverCallback[] = []
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
intersectionCallback = callback
|
||||
intersectionCallbacks.push(callback)
|
||||
}
|
||||
|
||||
observe = mockObserve
|
||||
@ -250,6 +263,10 @@ const renderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
const openTypeFilter = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
input: (pageParam: number) => { query: Record<string, unknown> }
|
||||
getNextPageParam: (lastPage: { has_more: boolean, page: number }) => number | undefined
|
||||
@ -268,20 +285,21 @@ describe('List', () => {
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockQueryState.creatorIDs = []
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
intersectionCallbacks = []
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
it('should render app type dropdown with all app types', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -301,9 +319,15 @@ describe('List', () => {
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
it('should render creators filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link to snippets on apps page', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('link', { name: 'app.studio.viewSnippets' })).toHaveAttribute('href', '/snippets')
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
@ -338,20 +362,22 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update category when workflow tab is clicked', () => {
|
||||
describe('Type Filter', () => {
|
||||
it('should update category when workflow type is selected', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.workflow' }))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update category when all tab is clicked', () => {
|
||||
it('should update category when all type is selected', () => {
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'app.types.all' }))
|
||||
|
||||
expect(mockSetCategory).toHaveBeenCalledWith('all')
|
||||
})
|
||||
@ -377,7 +403,7 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.clear' }).at(-1)!)
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -386,7 +412,7 @@ describe('List', () => {
|
||||
describe('App List Query', () => {
|
||||
it('should build paged query input from active filters', () => {
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.creatorIDs = ['creator-1', 'creator-2']
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList()
|
||||
@ -400,7 +426,7 @@ describe('List', () => {
|
||||
limit: 30,
|
||||
name: 'sales',
|
||||
tag_ids: ['tag-1'],
|
||||
is_created_by_me: true,
|
||||
creator_ids: ['creator-1', 'creator-2'],
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
})
|
||||
@ -427,19 +453,19 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
describe('Creators Filter', () => {
|
||||
it('should render creators filter with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
it('should handle creator selection as a multi creator filter', () => {
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'app.showMyCreatedAppsOnly' })
|
||||
fireEvent.click(checkbox)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
|
||||
expect(mockSetIsCreatedByMe).toHaveBeenCalledWith(true)
|
||||
expect(mockSetCreatorIDs).toHaveBeenCalledWith(['creator-2'])
|
||||
})
|
||||
})
|
||||
|
||||
@ -485,11 +511,11 @@ describe('List', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { unmount } = renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
@ -502,9 +528,10 @@ describe('List', () => {
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -521,9 +548,10 @@ describe('List', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
describe('App Type Dropdown', () => {
|
||||
it('should render all app type options', () => {
|
||||
renderList()
|
||||
openTypeFilter()
|
||||
|
||||
expect(screen.getByText('app.types.all'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow'))!.toBeInTheDocument()
|
||||
@ -533,9 +561,7 @@ describe('List', () => {
|
||||
expect(screen.getByText('app.types.completion'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update category for each app type tab click', () => {
|
||||
renderList()
|
||||
|
||||
it('should update category for each app type option click', () => {
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
@ -546,8 +572,11 @@ describe('List', () => {
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
mockSetCategory.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
const { unmount } = renderList()
|
||||
openTypeFilter()
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: text }))
|
||||
expect(mockSetCategory).toHaveBeenCalledWith(mode)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -623,16 +652,18 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', async () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
await waitFor(() => expect(mockObserve).toHaveBeenCalled())
|
||||
|
||||
if (intersectionCallbacks.length) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
intersectionCallbacks.forEach(callback => callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -643,12 +674,12 @@ describe('List', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
if (intersectionCallbacks.length) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
intersectionCallbacks.forEach(callback => callback(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@ -660,12 +691,12 @@ describe('List', () => {
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
if (intersectionCallbacks.length) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
intersectionCallbacks.forEach(callback => callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
16
web/app/components/apps/app-type-filter-shared.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
export type { AppListCategory }
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
76
web/app/components/apps/app-type-filter.tsx
Normal file
76
web/app/components/apps/app-type-filter.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { AppListCategory } from './app-type-filter-shared'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
value: AppListCategory
|
||||
onChange: (value: AppListCategory) => void
|
||||
}
|
||||
|
||||
export function AppTypeFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === value)
|
||||
const isSelected = value !== 'all'
|
||||
const triggerLabel = isSelected ? activeOption?.text : t('studio.filters.types', { ns: 'app' })
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
chipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span className="px-1 text-text-tertiary">{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={nextValue => isAppListCategory(nextValue) && onChange(nextValue)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
230
web/app/components/apps/creators-filter.tsx
Normal file
230
web/app/components/apps/creators-filter.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
|
||||
type CreatorsFilterProps = {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
}
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
avatarUrl: string | null
|
||||
isYou: boolean
|
||||
}
|
||||
|
||||
const baseChipClassName = 'flex h-8 items-center rounded-lg border-[0.5px] px-2 text-[13px] leading-4 transition-colors'
|
||||
|
||||
const CreatorsFilter = ({
|
||||
value,
|
||||
onChange,
|
||||
}: CreatorsFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { userProfile } = useAppContext()
|
||||
const { data: membersData } = useMembers()
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const creatorOptions = useMemo<CreatorOption[]>(() => {
|
||||
const currentUserId = userProfile?.id
|
||||
const members = membersData?.accounts ?? []
|
||||
|
||||
return [...members]
|
||||
.filter(member => member.status !== 'pending')
|
||||
.sort((left, right) => {
|
||||
if (left.id === currentUserId)
|
||||
return -1
|
||||
if (right.id === currentUserId)
|
||||
return 1
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
.map(member => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
avatarUrl: member.avatar_url,
|
||||
isYou: member.id === currentUserId,
|
||||
}))
|
||||
}, [membersData?.accounts, userProfile?.id])
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter((creator) => {
|
||||
const keyword = normalizedKeywords
|
||||
return creator.name.toLowerCase().includes(keyword)
|
||||
})
|
||||
}, [creatorOptions, keywords])
|
||||
|
||||
const selectedCreators = useMemo(() => {
|
||||
const creatorMap = new Map(creatorOptions.map(creator => [creator.id, creator]))
|
||||
return value
|
||||
.map(id => creatorMap.get(id))
|
||||
.filter((creator): creator is CreatorOption => Boolean(creator))
|
||||
}, [creatorOptions, value])
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
if (value.includes(creatorId)) {
|
||||
onChange(value.filter(id => id !== creatorId))
|
||||
return
|
||||
}
|
||||
|
||||
onChange([...value, creatorId])
|
||||
}, [onChange, value])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
onChange([])
|
||||
setKeywords('')
|
||||
}, [onChange])
|
||||
|
||||
const selectedCount = value.length
|
||||
const selectedAvatarCreators = selectedCreators.slice(0, 3)
|
||||
const isSelected = selectedCount > 0
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
baseChipClassName,
|
||||
isSelected
|
||||
? 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs hover:bg-state-base-hover'
|
||||
: 'border-transparent bg-components-input-bg-normal text-text-tertiary hover:bg-components-input-bg-hover',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
{!isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</>
|
||||
)}
|
||||
{isSelected && (
|
||||
<>
|
||||
<span className="px-1 text-text-tertiary">{t('studio.filters.creators', { ns: 'app' })}</span>
|
||||
<span className="flex items-center pr-1">
|
||||
{selectedAvatarCreators.map((creator, index) => (
|
||||
<Avatar
|
||||
key={creator.id}
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className={cn(
|
||||
'border border-components-panel-bg',
|
||||
index > 0 && '-ml-1',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
<span className="text-xs leading-4 font-medium text-text-tertiary">{`+${selectedCount}`}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('studio.filters.reset', { ns: 'app' })}
|
||||
className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-xs text-text-quaternary hover:text-text-tertiary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ')
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetCreators()
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-1 p-2 pb-1">
|
||||
<div className="relative min-w-0 grow">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-sm px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto px-1 pb-1">
|
||||
{filteredCreators.map((creator) => {
|
||||
const checked = value.includes(creator.id)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={creator.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 hover:bg-state-base-hover"
|
||||
onClick={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<Checkbox
|
||||
id={creator.id}
|
||||
checked={checked}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center gap-2 px-1">
|
||||
<Avatar
|
||||
avatar={creator.avatarUrl}
|
||||
name={creator.name}
|
||||
size="xs"
|
||||
className="border-[0.5px] border-divider-regular"
|
||||
/>
|
||||
<div className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-text-secondary">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-sm text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@ -17,7 +17,11 @@ const DefaultCards = React.memo(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const Empty = () => {
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@ -25,7 +29,7 @@ const Empty = () => {
|
||||
<DefaultCards />
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
|
||||
<span className="system-md-medium text-text-tertiary">
|
||||
{t('newApp.noAppsFound', { ns: 'app' })}
|
||||
{message ?? t('newApp.noAppsFound', { ns: 'app' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -20,22 +20,22 @@ describe('useAppsQueryState', () => {
|
||||
expect(result.current.query).toEqual({
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorIDs: [],
|
||||
})
|
||||
expect(typeof result.current.setCategory).toBe('function')
|
||||
expect(typeof result.current.setKeywords).toBe('function')
|
||||
expect(typeof result.current.setIsCreatedByMe).toBe('function')
|
||||
expect(typeof result.current.setCreatorIDs).toBe('function')
|
||||
})
|
||||
|
||||
it('should parse app list filters from URL', () => {
|
||||
const { result } = renderWithAdapter(
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term&isCreatedByMe=true',
|
||||
'?category=workflow&tagIDs=tag1;tag2&keywords=search+term',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
keywords: 'search term',
|
||||
isCreatedByMe: true,
|
||||
creatorIDs: [],
|
||||
})
|
||||
})
|
||||
|
||||
@ -115,30 +115,29 @@ describe('useAppsQueryState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should update created-by-me URL state', async () => {
|
||||
it('should update creator IDs in local state without writing to the URL', () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(true)
|
||||
result.current.setCreatorIDs(['creator-1', 'creator-2'])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(true)
|
||||
expect(update.searchParams.get('isCreatedByMe')).toBe('true')
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(result.current.query.creatorIDs).toEqual(['creator-1', 'creator-2'])
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
it('should clear creator IDs from local state without writing to the URL', () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(false)
|
||||
result.current.setCreatorIDs(['creator-1'])
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(result.current.query.isCreatedByMe).toBe(false)
|
||||
expect(update.searchParams.has('isCreatedByMe')).toBe(false)
|
||||
act(() => {
|
||||
result.current.setCreatorIDs([])
|
||||
})
|
||||
|
||||
expect(result.current.query.creatorIDs).toEqual([])
|
||||
expect(onUrlUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,48 +1,41 @@
|
||||
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { AppModes } from '@/types/app'
|
||||
import type { AppListCategory } from '../app-type-filter-shared'
|
||||
import { debounce, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { parseAsAppListCategory } from '../app-type-filter-shared'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const appListQueryParsers = {
|
||||
category: parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' }),
|
||||
category: parseAsAppListCategory,
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(APP_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
isCreatedByMe: parseAsBoolean
|
||||
.withDefault(false)
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
export function useAppsQueryState() {
|
||||
const [query, setQuery] = useQueryStates(appListQueryParsers)
|
||||
const [urlQuery, setUrlQuery] = useQueryStates(appListQueryParsers)
|
||||
const [creatorIDs, setCreatorIDs] = useState<string[]>([])
|
||||
|
||||
const setCategory = useCallback((category: AppListCategory) => {
|
||||
setQuery({ category })
|
||||
}, [setQuery])
|
||||
setUrlQuery({ category })
|
||||
}, [setUrlQuery])
|
||||
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery({ keywords })
|
||||
}, [setQuery])
|
||||
setUrlQuery({ keywords })
|
||||
}, [setUrlQuery])
|
||||
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
}, [setQuery])
|
||||
const handleSetCreatorIDs = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorIDs(creatorIDs)
|
||||
}, [])
|
||||
|
||||
const query = useMemo(() => ({
|
||||
...urlQuery,
|
||||
creatorIDs,
|
||||
}), [creatorIDs, urlQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setIsCreatedByMe])
|
||||
setCreatorIDs: handleSetCreatorIDs,
|
||||
}), [handleSetCreatorIDs, query, setCategory, setKeywords])
|
||||
}
|
||||
|
||||
@ -1,29 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import { useLocalStorage } from '@/hooks/use-local-storage'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import { AppTypeFilter } from './app-type-filter'
|
||||
import { APP_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { isAppListCategory, useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useAppsQueryState } from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import { useWorkflowOnlineUsers } from './hooks/use-workflow-online-users'
|
||||
import NewAppCard from './new-app-card'
|
||||
@ -50,10 +52,10 @@ function List({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, keywords, isCreatedByMe },
|
||||
query: { category, keywords, creatorIDs },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setIsCreatedByMe,
|
||||
setCreatorIDs,
|
||||
} = useAppsQueryState()
|
||||
const [tagIDs, setTagIDs] = useState<string[]>([])
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
@ -62,6 +64,7 @@ function List({
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const [needRefreshAppList, setNeedRefreshAppList] = useLocalStorage<string>(NEED_REFRESH_APP_LIST_KEY, '0', { raw: true })
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
setDroppedDSLFile(file)
|
||||
@ -89,9 +92,9 @@ function List({
|
||||
limit: 30,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(creatorIDs.length ? { creator_ids: creatorIDs } : {}),
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
}), [category, creatorIDs, debouncedKeywords, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -125,21 +128,13 @@ function List({
|
||||
}, [controlRefreshList, refetch])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="mr-1 i-ri-apps-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="mr-1 i-ri-exchange-2-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="mr-1 i-ri-message-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="mr-1 i-ri-robot-3-line h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="mr-1 i-ri-file-4-line h-[14px] w-[14px]" /> },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
if (needRefreshAppList === '1') {
|
||||
setNeedRefreshAppList(null)
|
||||
refetch()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [needRefreshAppList, refetch, setNeedRefreshAppList])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
@ -171,10 +166,6 @@ function List({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const handleCreatedByMeChange = useCallback((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
}, [setIsCreatedByMe])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
|
||||
@ -206,31 +197,44 @@ function List({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<TabSliderNew
|
||||
value={category}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setCategory(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheckedChange={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<SearchInput
|
||||
className="w-52"
|
||||
value={keywords}
|
||||
onValueChange={setKeywords}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })}
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 bg-background-body px-12 pt-7 pb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AppTypeFilter
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
<CreatorsFilter
|
||||
value={creatorIDs}
|
||||
onChange={setCreatorIDs}
|
||||
/>
|
||||
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
|
||||
<div className="relative w-50">
|
||||
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" />
|
||||
<SearchInput
|
||||
className="w-52"
|
||||
value={keywords}
|
||||
onValueChange={setKeywords}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })}
|
||||
/>
|
||||
{!!keywords && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
className="absolute top-1/2 right-2 flex size-4 -translate-y-1/2 items-center justify-center text-components-input-text-placeholder hover:text-components-input-text-filled"
|
||||
onClick={() => setKeywords('')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-circle-fill size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary hover:bg-state-base-hover hover:text-text-primary"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
|
||||
@ -104,12 +104,23 @@ vi.mock('../../nav', () => ({
|
||||
onCreate,
|
||||
onLoadMore,
|
||||
navigationItems,
|
||||
activeSegment,
|
||||
activeLink,
|
||||
text,
|
||||
}: {
|
||||
onCreate: (state: string) => void
|
||||
onLoadMore?: () => void
|
||||
navigationItems?: Array<{ id: string, name: string, link: string }>
|
||||
activeSegment?: string | string[]
|
||||
activeLink?: { segment: string, text: string, link: string }
|
||||
text?: string
|
||||
}) => (
|
||||
<div data-testid="nav">
|
||||
<div data-testid="nav-text">{text}</div>
|
||||
<div data-testid="nav-active-segment">{JSON.stringify(activeSegment)}</div>
|
||||
{activeLink && (
|
||||
<div data-testid="nav-active-link">{`${activeLink.segment}:${activeLink.text}->${activeLink.link}`}</div>
|
||||
)}
|
||||
<ul data-testid="nav-items">
|
||||
{(navigationItems ?? []).map(item => (
|
||||
<li key={item.id}>{`${item.name} -> ${item.link}`}</li>
|
||||
@ -201,6 +212,15 @@ describe('AppNav', () => {
|
||||
expect(options.getNextPageParam({ has_more: false, page: 3 })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should configure snippets as an active studio child link', () => {
|
||||
setupDefaultMocks()
|
||||
render(<AppNav />)
|
||||
|
||||
expect(screen.getByTestId('nav-text')).toHaveTextContent('menus.apps')
|
||||
expect(screen.getByTestId('nav-active-segment')).toHaveTextContent(JSON.stringify(['apps', 'app', 'snippets']))
|
||||
expect(screen.getByTestId('nav-active-link')).toHaveTextContent('snippets:tabs.snippets->/snippets')
|
||||
})
|
||||
|
||||
it('should build editor links and update app name when app detail changes', async () => {
|
||||
setupDefaultMocks({
|
||||
isEditor: true,
|
||||
|
||||
@ -103,8 +103,13 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="size-4" />}
|
||||
activeIcon={<RiRobot2Fill className="size-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app']}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
link="/apps"
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: t('tabs.snippets', { ns: 'workflow' }),
|
||||
link: '/snippets',
|
||||
}}
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
createText={t('menus.newApp', { ns: 'common' })}
|
||||
|
||||
@ -102,6 +102,13 @@ describe('DatasetNav', () => {
|
||||
icon_info: { icon: 'pipeline' },
|
||||
provider: 'vendor',
|
||||
},
|
||||
{
|
||||
id: 'dataset-5',
|
||||
name: 'Null Icon Dataset',
|
||||
runtime_mode: 'general',
|
||||
icon_info: null,
|
||||
provider: 'vendor',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@ -141,6 +148,16 @@ describe('DatasetNav', () => {
|
||||
render(<DatasetNav />)
|
||||
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render current dataset when icon info is null', () => {
|
||||
vi.mocked(useDatasetDetail).mockReturnValue({
|
||||
data: { ...mockDataset, icon_info: null },
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
render(<DatasetNav />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Test Dataset/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation Items logic', () => {
|
||||
@ -154,6 +171,7 @@ describe('DatasetNav', () => {
|
||||
expect(within(menu).getByText('Test Dataset')).toBeInTheDocument()
|
||||
expect(within(menu).getByText('Pipeline Dataset')).toBeInTheDocument()
|
||||
expect(within(menu).getByText('External Dataset')).toBeInTheDocument()
|
||||
expect(within(menu).getByText('Null Icon Dataset')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should navigate to correct link when an item is clicked', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user