mirror of
https://github.com/langgenius/dify.git
synced 2026-06-27 17:47:08 +08:00
Compare commits
372 Commits
1.15.0
...
feat/dev-s
| Author | SHA1 | Date | |
|---|---|---|---|
| 887de73c78 | |||
| bccc948031 | |||
| 3c1bc6ac36 | |||
| 00cf21a1b7 | |||
| 705d317f57 | |||
| e0158fe9fd | |||
| 48e6902f5f | |||
| efe98b1e52 | |||
| a62c616664 | |||
| 6ed691a2c9 | |||
| 41ba73835d | |||
| 55c1d1d4be | |||
| 59adfffbb4 | |||
| ed442771f4 | |||
| 5730ede96f | |||
| 107bba0116 | |||
| c46a313d78 | |||
| e1fec86a2a | |||
| 6ec893cb0e | |||
| 8be34ee000 | |||
| 458f669883 | |||
| 94fd4e9c67 | |||
| f5da3ce499 | |||
| 08f2971f72 | |||
| 54ac42fbc4 | |||
| 1d1d571213 | |||
| 056caa8b2f | |||
| 1cf6cdb764 | |||
| ac9083fbf1 | |||
| fdfc9ab3d3 | |||
| 83cd1a8d7a | |||
| a3dfd670b0 | |||
| facace019b | |||
| fd9543868d | |||
| 0e6cb87f08 | |||
| ef9c607f04 | |||
| 9d082489c9 | |||
| 81553d8813 | |||
| c550d6b085 | |||
| 99167ace74 | |||
| db5d5bfffe | |||
| 0f2cbc2968 | |||
| 892387ea38 | |||
| 2f351641e4 | |||
| 36a51dca8b | |||
| 93d9423c95 | |||
| 3cabe9058b | |||
| d925ed2f28 | |||
| 8cca26010c | |||
| d859728dd7 | |||
| 89188256e1 | |||
| bba3a1bcee | |||
| d66bfc7434 | |||
| 7c0be7f905 | |||
| 599e3475f2 | |||
| 718fe548e9 | |||
| 060ceaffd1 | |||
| 00908ca0fb | |||
| 2812d61e24 | |||
| 8adcac87a5 | |||
| 544d8567c9 | |||
| be1d6520f9 | |||
| eeb1cd19bd | |||
| 7fb2e4751f | |||
| e4620b4b22 | |||
| 8af1766081 | |||
| 5441992604 | |||
| 9d0597c22d | |||
| 5d489ab92d | |||
| 930da499d1 | |||
| f1527ef7c1 | |||
| 20f89b6e90 | |||
| 05e69b104a | |||
| f39b1b6731 | |||
| a7005efab3 | |||
| f605288429 | |||
| 2bb3b439e0 | |||
| 75daf8e61b | |||
| bf30b11d0d | |||
| 20e0b329d3 | |||
| 778e472173 | |||
| 31e2e5d01b | |||
| 8f9e2a895a | |||
| b91de7e54b | |||
| 2885ba8519 | |||
| e23c3d1491 | |||
| 888292564b | |||
| 1a0c8f6173 | |||
| d8851a4994 | |||
| 8a21679ea8 | |||
| b8a594def0 | |||
| 69a77ad9ce | |||
| 93728bb39f | |||
| c4da7a0bed | |||
| 05fd412670 | |||
| a4821288cc | |||
| fc0a4a6b56 | |||
| 0a3bb67778 | |||
| 5e9f419154 | |||
| 6b84383590 | |||
| d7f99d6458 | |||
| 6c80ee8f48 | |||
| ea71990388 | |||
| 36e8677b1a | |||
| 5c31a774ea | |||
| 9e137e12ab | |||
| 18e2ecd6c5 | |||
| 8a23126f29 | |||
| 6c5f6699d2 | |||
| 124b786dfb | |||
| dd54ca0cab | |||
| 8a72e46ce8 | |||
| f00f8e020f | |||
| aa078a854c | |||
| 712aae4d98 | |||
| bacadc4d35 | |||
| b060e81824 | |||
| b45f83492e | |||
| d1e1a4a8ab | |||
| 4519847e81 | |||
| 3763efbc7c | |||
| 552f202ca8 | |||
| dc76f4082f | |||
| 6d01095586 | |||
| b914e48a41 | |||
| da482ec455 | |||
| 48c38ace54 | |||
| 2b1496c857 | |||
| c15e437ff7 | |||
| 0ac0eccce4 | |||
| 678327e994 | |||
| b0478f4df7 | |||
| 00319f0e43 | |||
| 55eb894d8e | |||
| c59a80a41f | |||
| 24b482893d | |||
| ad58895b25 | |||
| 25fc518c5d | |||
| d92722e7ab | |||
| 4041fd7e5c | |||
| 06ea73a19b | |||
| 7384a3c121 | |||
| c18c953a7c | |||
| ae2df0c35e | |||
| dacc7fc740 | |||
| 9af2c1252c | |||
| 35bfe26a3a | |||
| 8686362aeb | |||
| f5955489ec | |||
| aaa15770d5 | |||
| 08c01c4f3f | |||
| 0903c30060 | |||
| b420298398 | |||
| 2607eb8d32 | |||
| d8173b1cda | |||
| c56f1a8216 | |||
| 31e74371ef | |||
| e48f13f173 | |||
| c574363cf6 | |||
| 70fd4a5c88 | |||
| e62a67c719 | |||
| 57c1195253 | |||
| 42889d23e5 | |||
| 3a7f09a250 | |||
| d95d4335bf | |||
| 735e88f673 | |||
| c55105bff3 | |||
| 77afc805e1 | |||
| 9dd73b4d47 | |||
| f2b12bfef7 | |||
| dbeaf79d77 | |||
| 63dcb4dd6c | |||
| 9df3a7bcf9 | |||
| 89163edd16 | |||
| eaa55aab1e | |||
| 8d3a690c0a | |||
| 5263a65ed6 | |||
| 24d3e8edba | |||
| b371dd2cdf | |||
| 597ad8c425 | |||
| 33f9d96caa | |||
| 689571df22 | |||
| a3242f0634 | |||
| f5112928b3 | |||
| bcd87ddc58 | |||
| 7c8a87af05 | |||
| 8e2d507e5c | |||
| b6fbec066d | |||
| bd136cadce | |||
| 0a934e1143 | |||
| c44ba62da3 | |||
| 76c0aed05c | |||
| e7fc22c6b3 | |||
| b91727b804 | |||
| 534fd79377 | |||
| 3ea4742b29 | |||
| 364c0eb6e2 | |||
| 322b3ff641 | |||
| 38736c154b | |||
| 129f681c59 | |||
| d776fc0827 | |||
| 7af6074cb5 | |||
| 7aa700bf2b | |||
| 0d47750b15 | |||
| a9dc57eeef | |||
| 5bfebd371d | |||
| f1da2c76d1 | |||
| b5dc774093 | |||
| b7fe45d800 | |||
| 7f5bbe0ee3 | |||
| 40632589a2 | |||
| e6e063138e | |||
| 605af8d60e | |||
| 03660c19ef | |||
| 8747e3a2d3 | |||
| 7fd549fd39 | |||
| 1712a2732a | |||
| 46bc76bae3 | |||
| e24b6c27b0 | |||
| 8c6dda125f | |||
| f6047aafe8 | |||
| dce5715982 | |||
| ea910b8e7d | |||
| e51af66d95 | |||
| f93b287949 | |||
| 627fbd2e86 | |||
| e4c056a57a | |||
| 23291398ec | |||
| 79fc352a5a | |||
| 8b6b3cddea | |||
| d1ca468c1e | |||
| ce28ad771c | |||
| ba951b01de | |||
| 670ab16ea1 | |||
| 4680535ecd | |||
| f96e63460e | |||
| 2df79c0404 | |||
| acef9630d5 | |||
| 12c3b2e0cd | |||
| 577707ae50 | |||
| 03325e9750 | |||
| a7ef8f9c12 | |||
| 40284d9f95 | |||
| 5efe8b8bd7 | |||
| 8dc6d736ee | |||
| 5316372772 | |||
| 4d1499ef75 | |||
| 0438285277 | |||
| 4879ea5cd5 | |||
| 2a1761ac06 | |||
| c29245c1cb | |||
| 5069694bba | |||
| d1a80a85c0 | |||
| 5c93d74dec | |||
| e52dbd49be | |||
| ccc8a5f278 | |||
| cfb5b9dfea | |||
| 73d95245f8 | |||
| fb91984fcb | |||
| 29cb1fa12e | |||
| 78240ed199 | |||
| 8f8707fd77 | |||
| ed3db06154 | |||
| 7c05a68876 | |||
| 6cfc0dd8e1 | |||
| 81baeae5c4 | |||
| a3010bdc0b | |||
| 8133e550ed | |||
| 2bb0eab636 | |||
| 5311b5d00d | |||
| 9b02ccdd12 | |||
| 231783eebe | |||
| 756606f478 | |||
| 6651c1c5da | |||
| 61e257b2a8 | |||
| 3ac4caf735 | |||
| 268ae1751d | |||
| 015cbf850b | |||
| 873e13c2fb | |||
| 688bf7e7a1 | |||
| a6ffff3b39 | |||
| 023fc55bd5 | |||
| 351b909a53 | |||
| 6bec4f65c9 | |||
| 74f87ce152 | |||
| 92c472ccc7 | |||
| b92b8becd1 | |||
| 23d0d6a65d | |||
| 1660067d6e | |||
| 0642475b85 | |||
| 8cb634c9bc | |||
| 768b41c3cf | |||
| ca88516d54 | |||
| 871a2a149f | |||
| 60e381eff0 | |||
| 768b3eb6f9 | |||
| 2f88da4a6d | |||
| a8cdf6964c | |||
| 985c3db4fd | |||
| 9636472db7 | |||
| 0ad268aa7d | |||
| a4ea33167d | |||
| 0f13aabea8 | |||
| 1e76ef5ccb | |||
| e6e3229d17 | |||
| dccf8e723a | |||
| c41ba7d627 | |||
| a6e9316de3 | |||
| 559d326cbd | |||
| abedf2506f | |||
| d01428b5bc | |||
| 0de1f17e5c | |||
| 17d07a5a43 | |||
| 3bdbea99a3 | |||
| b7683aedb1 | |||
| 515036e758 | |||
| 22b382527f | |||
| 2cfe4b5b86 | |||
| 6876c8041c | |||
| 7de45584ce | |||
| 5572d7c7e8 | |||
| db0a2fe52e | |||
| f0ae8d6167 | |||
| 2514e181ba | |||
| be2e6e9a14 | |||
| 875e2eac1b | |||
| c3c73ceb1f | |||
| 6318bf0a2a | |||
| 5e1f252046 | |||
| df3b960505 | |||
| 26bc108bf1 | |||
| a5cff32743 | |||
| d418dd8eec | |||
| 61702fe346 | |||
| 43f0c780c3 | |||
| 30ebf2bfa9 | |||
| 7e3027b5f7 | |||
| b3acf83090 | |||
| 36c3d6e48a | |||
| f782ac6b3c | |||
| feef2dd1fa | |||
| a716d8789d | |||
| 6816f89189 | |||
| bfcac64a9d | |||
| 664eb601a2 | |||
| 8e5cc4e0aa | |||
| 9f28575903 | |||
| 4b9a26a5e6 | |||
| 7b85adf1cc | |||
| 917d362a58 | |||
| 3c27a90eb9 | |||
| c964708ebe | |||
| 883eb498c0 | |||
| b85af2ec47 | |||
| 2f0f97aa66 | |||
| a6e03c6735 | |||
| e7cbfb89d6 | |||
| 6c2decfbfb | |||
| b33d6d1d4a | |||
| 1b32e70dc5 | |||
| 4d3738d225 | |||
| b5e90e77aa | |||
| dd0dee739d | |||
| 4d19914fcb | |||
| 887c7710e9 | |||
| 7a722773c7 | |||
| a763aff58b | |||
| c1011f4e5c | |||
| f7afa103a5 | |||
| d0bd5b473b | |||
| 08b28b4029 | |||
| 269bf883c2 |
@ -97,6 +97,7 @@ RUN \
|
||||
# Copy Python environment and packages
|
||||
ENV VIRTUAL_ENV=/app/api/.venv
|
||||
COPY --from=packages --chown=dify:dify ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --from=packages --chown=dify:dify /app/dify-agent /app/dify-agent
|
||||
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
|
||||
|
||||
# Download nltk data
|
||||
|
||||
@ -119,6 +119,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
|
||||
@ -134,6 +135,7 @@ from .workspace import (
|
||||
model_providers,
|
||||
models,
|
||||
plugin,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
workspace,
|
||||
@ -206,6 +208,9 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
"socketio_workflow",
|
||||
"spec",
|
||||
"statistic",
|
||||
|
||||
160
api/controllers/console/snippets/payloads.py
Normal file
160
api/controllers/console/snippets/payloads.py
Normal file
@ -0,0 +1,160 @@
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import 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")
|
||||
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")
|
||||
638
api/controllers/console/snippets/snippet_workflow.py
Normal file
638
api/controllers/console/snippets/snippet_workflow.py
Normal file
@ -0,0 +1,638 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import ParamSpec, TypeVar
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
from sqlalchemy.orm import Session
|
||||
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__)
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
# Register Pydantic models with Swagger
|
||||
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(view_func: Callable[P, R]):
|
||||
"""Decorator to fetch and validate snippet access."""
|
||||
|
||||
@wraps(view_func)
|
||||
def decorated_view(*args: P.args, **kwargs: P.kwargs):
|
||||
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 = SnippetService.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 = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
|
||||
if not workflow:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
db.session.expunge(workflow)
|
||||
workflow.conversation_variables = []
|
||||
workflow.input_fields = snippet.input_fields_list
|
||||
return SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
workflow = snippet_service.get_published_workflow(snippet=snippet)
|
||||
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
workflow.input_fields = snippet.input_fields_list
|
||||
return SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@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 = SnippetService()
|
||||
|
||||
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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
|
||||
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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
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,
|
||||
)
|
||||
|
||||
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 = SnippetService()
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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,319 @@
|
||||
"""
|
||||
Snippet draft workflow variable APIs.
|
||||
|
||||
Mirrors console app routes under /apps/.../workflows/draft/variables for snippet scope,
|
||||
using CustomizedSnippet.id as WorkflowDraftVariable.app_id (same invariant as snippet execution).
|
||||
|
||||
Snippet workflows do not expose system variables (`node_id == sys`) or conversation variables
|
||||
(`node_id == conversation`): paginated list queries exclude those rows; single-variable GET/PATCH/DELETE/reset
|
||||
reject them; `GET .../system-variables` and `GET .../conversation-variables` return empty lists for API parity.
|
||||
Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, ParamSpec, TypeVar
|
||||
|
||||
from flask import Response, request
|
||||
from flask_restx import Resource, marshal, marshal_with
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import DraftWorkflowNotExist
|
||||
from controllers.console.app.workflow_draft_variable import (
|
||||
WorkflowDraftVariableListQuery,
|
||||
WorkflowDraftVariableUpdatePayload,
|
||||
_ensure_variable_access,
|
||||
_file_access_controller,
|
||||
validate_node_id,
|
||||
workflow_draft_variable_list_model,
|
||||
workflow_draft_variable_list_without_value_model,
|
||||
workflow_draft_variable_model,
|
||||
)
|
||||
from controllers.console.snippets.snippet_workflow import get_snippet
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from controllers.web.error import InvalidArgumentError, NotFoundError
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
from factories.variable_factory import build_segment_with_type
|
||||
from graphon.variables.types import SegmentType
|
||||
from libs.login import current_user, login_required
|
||||
from models.snippet import CustomizedSnippet
|
||||
from models.workflow import WorkflowDraftVariable
|
||||
from services.snippet_service import SnippetService
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset(
|
||||
{SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}
|
||||
)
|
||||
|
||||
|
||||
def _ensure_snippet_draft_variable_row_allowed(
|
||||
*,
|
||||
variable: WorkflowDraftVariable,
|
||||
variable_id: str,
|
||||
) -> None:
|
||||
"""Snippet scope only supports canvas-node draft variables; treat sys/conversation rows as not found."""
|
||||
if variable.node_id in _SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id}")
|
||||
|
||||
|
||||
def _snippet_draft_var_prerequisite(f: Callable[P, R]) -> Callable[P, R]:
|
||||
"""Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs)."""
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@wraps(f)
|
||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<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 = SnippetService()
|
||||
if snippet_service.get_draft_workflow(snippet=snippet) is None:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
with Session(bind=db.engine, expire_on_commit=False) as session:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=session)
|
||||
workflow_vars = draft_var_srv.list_variables_without_values(
|
||||
app_id=snippet.id,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
user_id=current_user.id,
|
||||
exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS,
|
||||
)
|
||||
|
||||
return workflow_vars
|
||||
|
||||
@console_ns.doc("delete_snippet_workflow_variables")
|
||||
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
|
||||
@console_ns.response(204, "Workflow variables deleted successfully")
|
||||
@_snippet_draft_var_prerequisite
|
||||
def delete(self, snippet: CustomizedSnippet) -> Response:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
|
||||
db.session.commit()
|
||||
return Response("", 204)
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<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 = SnippetService()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if draft_workflow is None:
|
||||
raise NotFoundError(
|
||||
f"Draft workflow not found, snippet_id={snippet.id}",
|
||||
)
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id),
|
||||
app_id=snippet.id,
|
||||
variable_id=variable_id,
|
||||
)
|
||||
_ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id)
|
||||
|
||||
resetted = draft_var_srv.reset_variable(draft_workflow, variable)
|
||||
db.session.commit()
|
||||
if resetted is None:
|
||||
return Response("", 204)
|
||||
return marshal(resetted, workflow_draft_variable_model)
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<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 = SnippetService()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if workflow is None:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
env_vars_list: list[dict[str, Any]] = []
|
||||
for v in workflow.environment_variables:
|
||||
env_vars_list.append(
|
||||
{
|
||||
"id": v.id,
|
||||
"type": "env",
|
||||
"name": v.name,
|
||||
"description": v.description,
|
||||
"selector": v.selector,
|
||||
"value_type": v.value_type.exposed_type().value,
|
||||
"value": v.value,
|
||||
"edited": False,
|
||||
"visible": True,
|
||||
"editable": True,
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": env_vars_list}
|
||||
@ -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
|
||||
|
||||
407
api/controllers/console/workspace/snippets.py
Normal file
407
api/controllers/console/workspace/snippets.py
Normal file
@ -0,0 +1,407 @@
|
||||
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
|
||||
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+)\]$")
|
||||
|
||||
|
||||
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]] = []
|
||||
|
||||
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
|
||||
|
||||
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)]
|
||||
|
||||
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))
|
||||
|
||||
snippets, total, has_more = SnippetService.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 = SnippetService.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 = SnippetService.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 = SnippetService.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 = SnippetService.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 = SnippetService.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 = SnippetService.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 = SnippetService.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
|
||||
@ -4,7 +4,7 @@ from typing import Any, cast
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
from werkzeug.exceptions import BadRequest, Unauthorized
|
||||
|
||||
from constants import HEADER_NAME_APP_CODE
|
||||
from controllers.common import fields
|
||||
@ -58,6 +58,9 @@ class AppParameterApi(WebApiResource):
|
||||
)
|
||||
def get(self, app_model: App, end_user: EndUser):
|
||||
"""Retrieve app parameters."""
|
||||
if not app_model.enable_site:
|
||||
raise BadRequest("Site is disabled.")
|
||||
|
||||
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
|
||||
workflow = app_model.workflow
|
||||
if workflow is None:
|
||||
|
||||
@ -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
|
||||
@ -58,6 +58,25 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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))
|
||||
@ -575,6 +594,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,
|
||||
|
||||
21
api/core/workflow/snippet_start.py
Normal file
21
api/core/workflow/snippet_start.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""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)
|
||||
|
||||
51
api/fields/snippet_fields.py
Normal file
51
api/fields/snippet_fields.py
Normal file
@ -0,0 +1,51 @@
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
@ -100,6 +100,7 @@ from .provider import (
|
||||
TenantDefaultModel,
|
||||
TenantPreferredModelProvider,
|
||||
)
|
||||
from .snippet import CustomizedSnippet, SnippetType
|
||||
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from .task import CeleryTask, CeleryTaskSet
|
||||
from .tools import (
|
||||
@ -125,12 +126,14 @@ from .workflow import (
|
||||
WorkflowAppLog,
|
||||
WorkflowAppLogCreatedFrom,
|
||||
WorkflowArchiveLog,
|
||||
WorkflowKind,
|
||||
WorkflowNodeExecutionModel,
|
||||
WorkflowNodeExecutionOffload,
|
||||
WorkflowNodeExecutionTriggeredFrom,
|
||||
WorkflowPause,
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
resolve_workflow_kind,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@ -168,6 +171,7 @@ __all__ = [
|
||||
"Conversation",
|
||||
"ConversationVariable",
|
||||
"CreatorUserRole",
|
||||
"CustomizedSnippet",
|
||||
"DataSourceApiKeyAuthBinding",
|
||||
"DataSourceOauthBinding",
|
||||
"Dataset",
|
||||
@ -213,6 +217,7 @@ __all__ = [
|
||||
"RecommendedApp",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
"Tenant",
|
||||
@ -242,6 +247,7 @@ __all__ = [
|
||||
"WorkflowAppLog",
|
||||
"WorkflowAppLogCreatedFrom",
|
||||
"WorkflowArchiveLog",
|
||||
"WorkflowKind",
|
||||
"WorkflowComment",
|
||||
"WorkflowCommentMention",
|
||||
"WorkflowCommentReply",
|
||||
@ -255,4 +261,5 @@ __all__ = [
|
||||
"WorkflowToolProvider",
|
||||
"WorkflowTriggerStatus",
|
||||
"WorkflowType",
|
||||
"resolve_workflow_kind",
|
||||
]
|
||||
|
||||
@ -224,6 +224,7 @@ class TagType(StrEnum):
|
||||
|
||||
KNOWLEDGE = "knowledge"
|
||||
APP = "app"
|
||||
SNIPPET = "snippet"
|
||||
|
||||
|
||||
class DatasetMetadataType(StrEnum):
|
||||
|
||||
@ -2474,7 +2474,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
|
||||
|
||||
117
api/models/snippet.py
Normal file
117
api/models/snippet.py
Normal file
@ -0,0 +1,117 @@
|
||||
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 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.
|
||||
|
||||
584
api/services/snippet_dsl_service.py
Normal file
584
api/services/snippet_dsl_service.py
Normal file
@ -0,0 +1,584 @@
|
||||
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 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 = SnippetService()
|
||||
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 = SnippetService()
|
||||
# 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 = SnippetService()
|
||||
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
|
||||
468
api/services/snippet_generate_service.py
Normal file
468
api/services/snippet_generate_service.py
Normal file
@ -0,0 +1,468 @@
|
||||
"""
|
||||
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
|
||||
|
||||
from sqlalchemy.orm import make_transient
|
||||
|
||||
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.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 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__)
|
||||
|
||||
|
||||
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,
|
||||
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | 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()
|
||||
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 = _SnippetAsApp(snippet)
|
||||
|
||||
response = WorkflowAppGenerator().generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
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,
|
||||
) -> 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()
|
||||
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 = _SnippetAsApp(snippet)
|
||||
|
||||
response: Mapping[str, Any] = WorkflowAppGenerator().generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
)
|
||||
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,
|
||||
) -> 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()
|
||||
draft_workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not draft_workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
return workflow_service.run_draft_workflow_node(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
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,
|
||||
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | 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()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_iteration_generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
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,
|
||||
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | 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()
|
||||
workflow = snippet_service.get_draft_workflow(snippet=snippet)
|
||||
if not workflow:
|
||||
raise ValueError("Workflow not initialized")
|
||||
|
||||
app_proxy = _SnippetAsApp(snippet)
|
||||
|
||||
return WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_loop_generate(
|
||||
app_model=app_proxy, # type: ignore[arg-type]
|
||||
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,
|
||||
)
|
||||
675
api/services/snippet_service.py
Normal file
675
api/services/snippet_service.py
Normal file
@ -0,0 +1,675 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
|
||||
from extensions.ext_database import db
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from models import Account, TagBinding
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from models.snippet import CustomizedSnippet, SnippetType
|
||||
from models.workflow import (
|
||||
Workflow,
|
||||
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 | None = None):
|
||||
"""Initialize SnippetService with repository dependencies."""
|
||||
if session_maker is None:
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def _snippet_kind_filter():
|
||||
"""Match snippet workflows by business kind."""
|
||||
return Workflow.kind == WorkflowKind.SNIPPET.value
|
||||
|
||||
@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(
|
||||
"Snippet workflow cannot contain start, human-input, or knowledge-retrieval nodes. "
|
||||
f"Found: {detail}"
|
||||
)
|
||||
|
||||
# --- CRUD Operations ---
|
||||
|
||||
@staticmethod
|
||||
def get_snippets(
|
||||
*,
|
||||
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
|
||||
|
||||
# Get total count
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = db.session.scalar(count_stmt) or 0
|
||||
|
||||
# Apply pagination
|
||||
stmt = stmt.limit(limit + 1).offset((page - 1) * limit)
|
||||
snippets = list(db.session.scalars(stmt).all())
|
||||
|
||||
has_more = len(snippets) > limit
|
||||
if has_more:
|
||||
snippets = snippets[:-1]
|
||||
|
||||
return snippets, total, has_more
|
||||
|
||||
@staticmethod
|
||||
def get_snippet_by_id(
|
||||
*,
|
||||
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
|
||||
"""
|
||||
return (
|
||||
db.session.query(CustomizedSnippet)
|
||||
.where(
|
||||
CustomizedSnippet.id == snippet_id,
|
||||
CustomizedSnippet.tenant_id == tenant_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_snippet(
|
||||
*,
|
||||
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,
|
||||
)
|
||||
|
||||
db.session.add(snippet)
|
||||
db.session.commit()
|
||||
|
||||
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
|
||||
"""
|
||||
session.execute(
|
||||
delete(TagBinding).where(
|
||||
TagBinding.tenant_id == snippet.tenant_id,
|
||||
TagBinding.target_id == snippet.id,
|
||||
)
|
||||
)
|
||||
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
|
||||
"""
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version == "draft",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return workflow
|
||||
|
||||
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
|
||||
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.id == snippet.workflow_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return workflow
|
||||
|
||||
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
|
||||
"""
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.id == workflow_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
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.value,
|
||||
kind=WorkflowKind.SNIPPET.value,
|
||||
version="draft",
|
||||
graph=json.dumps(graph),
|
||||
created_by=account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
db.session.add(workflow)
|
||||
db.session.flush()
|
||||
else:
|
||||
# Update existing draft workflow
|
||||
workflow.graph = json.dumps(graph)
|
||||
workflow.type = WorkflowType.WORKFLOW.value
|
||||
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)
|
||||
|
||||
db.session.commit()
|
||||
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),
|
||||
)
|
||||
|
||||
if is_new_draft:
|
||||
db.session.add(draft_workflow)
|
||||
|
||||
db.session.commit()
|
||||
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:
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
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_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,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
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
|
||||
|
||||
|
||||
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"
|
||||
@ -0,0 +1,40 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from controllers.console.workspace import snippets as snippets_module
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
return func
|
||||
|
||||
|
||||
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()
|
||||
@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
|
||||
from controllers.web.error import AppUnavailableError
|
||||
@ -16,13 +17,20 @@ from controllers.web.error import AppUnavailableError
|
||||
# AppParameterApi
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestAppParameterApi:
|
||||
def test_disabled_web_app_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="chat", enable_site=False)
|
||||
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(BadRequest, match="Site is disabled"):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_advanced_chat_mode_uses_workflow(self, app: Flask) -> None:
|
||||
features_dict = {"opening_statement": "Hello"}
|
||||
workflow = SimpleNamespace(
|
||||
features_dict=features_dict,
|
||||
user_input_form=lambda to_old_structure=False: [],
|
||||
)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=workflow, enable_site=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
@ -41,7 +49,7 @@ class TestAppParameterApi:
|
||||
features_dict=features_dict,
|
||||
user_input_form=lambda to_old_structure=False: [{"var": "x"}],
|
||||
)
|
||||
app_model = SimpleNamespace(mode="workflow", workflow=workflow)
|
||||
app_model = SimpleNamespace(mode="workflow", workflow=workflow, enable_site=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
@ -54,14 +62,14 @@ class TestAppParameterApi:
|
||||
mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[{"var": "x"}])
|
||||
|
||||
def test_advanced_chat_mode_no_workflow_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=None)
|
||||
app_model = SimpleNamespace(mode="advanced-chat", workflow=None, enable_site=True)
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_standard_mode_uses_app_model_config(self, app: Flask) -> None:
|
||||
config = SimpleNamespace(to_dict=lambda: {"user_input_form": [{"var": "y"}], "key": "val"})
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=config)
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=config, enable_site=True)
|
||||
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
@ -75,7 +83,7 @@ class TestAppParameterApi:
|
||||
assert call_kwargs.kwargs["user_input_form"] == [{"var": "y"}]
|
||||
|
||||
def test_standard_mode_no_config_raises(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=None)
|
||||
app_model = SimpleNamespace(mode="chat", app_model_config=None, enable_site=True)
|
||||
with app.test_request_context("/parameters"):
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
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()
|
||||
|
||||
46
api/tests/unit_tests/services/test_snippet_dsl_service.py
Normal file
46
api/tests/unit_tests/services/test_snippet_dsl_service.py
Normal file
@ -0,0 +1,46 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from services.snippet_dsl_service import ImportStatus, SnippetDslService, SnippetPendingData
|
||||
|
||||
|
||||
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")
|
||||
149
api/tests/unit_tests/services/test_snippet_service.py
Normal file
149
api/tests/unit_tests/services/test_snippet_service.py
Normal file
@ -0,0 +1,149 @@
|
||||
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 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")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
monkeypatch.setattr("services.snippet_service.db.session", session)
|
||||
|
||||
snippet = SnippetService.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_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_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())
|
||||
|
||||
monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow))
|
||||
monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=draft_workflow))
|
||||
monkeypatch.setattr("services.snippet_service.db.session", session)
|
||||
|
||||
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_not_called()
|
||||
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)
|
||||
|
||||
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_delete_snippet_removes_tag_bindings() -> None:
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
session = SimpleNamespace(execute=Mock(), delete=Mock())
|
||||
|
||||
result = SnippetService.delete_snippet(session=session, snippet=snippet)
|
||||
|
||||
assert result is True
|
||||
session.execute.assert_called_once()
|
||||
session.delete.assert_called_once_with(snippet)
|
||||
@ -147,6 +147,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(humanInputLayout)/form/[token]/form.tsx": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -243,6 +248,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
|
||||
@ -3162,6 +3172,16 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"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
|
||||
@ -3332,6 +3352,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/blocks.tsx": {
|
||||
"unused-imports/no-unused-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/block-selector/hooks.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 1
|
||||
@ -4896,6 +4921,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/device/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/education-apply/hooks.ts": {
|
||||
"react/set-state-in-effect": {
|
||||
"count": 5
|
||||
@ -5202,6 +5232,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
|
||||
@ -5467,6 +5502,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/service/use-snippet-workflows.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/service/use-tools.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
||||
@ -635,6 +635,13 @@ export const zHitTestingQuery = z.object({
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* HitTestingQuery
|
||||
*/
|
||||
export const zHitTestingQuery = z.object({
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DatasetMetadataListItemResponse
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -346,29 +346,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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -47,16 +47,17 @@ vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetKeywords = vi.fn()
|
||||
const mockSetIsCreatedByMe = vi.fn()
|
||||
const mockSetCreatorID = vi.fn()
|
||||
const mockSetCategory = vi.fn()
|
||||
const mockQueryState = {
|
||||
category: 'all',
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
creatorID: '',
|
||||
}
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
isAppListCategory: (value: string) => value === 'all' || Object.values(AppModeEnum).includes(value as AppModeEnum),
|
||||
@ -64,7 +65,19 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
query: mockQueryState,
|
||||
setCategory: mockSetCategory,
|
||||
setKeywords: mockSetKeywords,
|
||||
setIsCreatedByMe: mockSetIsCreatedByMe,
|
||||
setCreatorID: mockSetCreatorID,
|
||||
}),
|
||||
}))
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
setCreatorID: mockSetCreatorID,
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -199,9 +212,9 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../new-app-card', () => ({
|
||||
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||
}),
|
||||
default: ({ ref: _ref }: { ref?: React.Ref<HTMLDivElement> }) => {
|
||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button', 'ref': _ref }, 'New App Card')
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
@ -238,12 +251,16 @@ beforeAll(() => {
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper plus a seeded
|
||||
// systemFeatures cache so List can resolve its useSuspenseQuery.
|
||||
const renderList = (searchParams = '') => {
|
||||
const renderList = (searchParams = '', pageType: 'apps' | 'snippets' = 'apps') => {
|
||||
mockSearchParams = new URLSearchParams(searchParams)
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List /></SystemFeaturesWrapper>, { searchParams })
|
||||
return renderWithNuqs(<SystemFeaturesWrapper><List pageType={pageType} /></SystemFeaturesWrapper>, { searchParams })
|
||||
}
|
||||
|
||||
const openTypeFilter = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^app\.(studio\.filters\.types|types\.)/ }))
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
@ -264,7 +281,7 @@ describe('List', () => {
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.category = 'all'
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockQueryState.creatorID = ''
|
||||
mockUseWorkflowOnlineUsers.mockClear()
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
@ -273,11 +290,12 @@ describe('List', () => {
|
||||
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()
|
||||
@ -297,9 +315,21 @@ 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 not render link to snippets on snippets page', () => {
|
||||
renderList('', 'snippets')
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
@ -334,20 +364,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')
|
||||
})
|
||||
@ -373,10 +405,7 @@ describe('List', () => {
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton)!.toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
@ -385,7 +414,7 @@ describe('List', () => {
|
||||
describe('App List Query', () => {
|
||||
it('should build paged query input from active filters', () => {
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.isCreatedByMe = true
|
||||
mockQueryState.creatorID = 'creator-1'
|
||||
mockQueryState.category = AppModeEnum.WORKFLOW
|
||||
|
||||
renderList()
|
||||
@ -399,7 +428,7 @@ describe('List', () => {
|
||||
limit: 30,
|
||||
name: 'sales',
|
||||
tag_ids: ['tag-1'],
|
||||
is_created_by_me: true,
|
||||
creator_id: 'creator-1',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
},
|
||||
})
|
||||
@ -426,19 +455,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 single 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(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
|
||||
})
|
||||
})
|
||||
|
||||
@ -484,11 +513,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', () => {
|
||||
@ -501,9 +530,10 @@ describe('List', () => {
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.types'))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.allCreators'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -520,9 +550,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()
|
||||
@ -532,9 +563,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' },
|
||||
@ -545,8 +574,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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
creatorID: '',
|
||||
})
|
||||
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.setCreatorID).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&creatorID=creator-1',
|
||||
)
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
category: AppModeEnum.WORKFLOW,
|
||||
keywords: 'search term',
|
||||
isCreatedByMe: true,
|
||||
creatorID: 'creator-1',
|
||||
})
|
||||
})
|
||||
|
||||
@ -115,30 +115,30 @@ describe('useAppsQueryState', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should update created-by-me URL state', async () => {
|
||||
it('should update creator ID URL state', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(true)
|
||||
result.current.setCreatorID('creator-1')
|
||||
})
|
||||
|
||||
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(result.current.query.creatorID).toBe('creator-1')
|
||||
expect(update.searchParams.get('creatorID')).toBe('creator-1')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should remove isCreatedByMe from URL when disabled', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?isCreatedByMe=true')
|
||||
it('should remove creatorID from URL when cleared', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?creatorID=creator-1')
|
||||
|
||||
act(() => {
|
||||
result.current.setIsCreatedByMe(false)
|
||||
result.current.setCreatorID('')
|
||||
})
|
||||
|
||||
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)
|
||||
expect(result.current.query.creatorID).toBe('')
|
||||
expect(update.searchParams.has('creatorID')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,26 +1,16 @@
|
||||
import { debounce, parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import type { AppListCategory } from '../app-type-filter-shared'
|
||||
import { debounce, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { AppModes } from '@/types/app'
|
||||
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)
|
||||
creatorID: parseAsString
|
||||
.withDefault('')
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
@ -35,14 +25,14 @@ export function useAppsQueryState() {
|
||||
setQuery({ keywords })
|
||||
}, [setQuery])
|
||||
|
||||
const setIsCreatedByMe = useCallback((isCreatedByMe: boolean) => {
|
||||
setQuery({ isCreatedByMe })
|
||||
const setCreatorID = useCallback((creatorID: string) => {
|
||||
setQuery({ creatorID })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setIsCreatedByMe,
|
||||
}), [query, setCategory, setKeywords, setIsCreatedByMe])
|
||||
setCreatorID,
|
||||
}), [query, setCategory, setKeywords, setCreatorID])
|
||||
}
|
||||
|
||||
@ -15,19 +15,29 @@ import { fetchAppDetail } from '@/service/explore'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import List from './list'
|
||||
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false })
|
||||
|
||||
const Apps = () => {
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const { replace } = useRouter()
|
||||
const templateId = searchParams.get('template-id')
|
||||
const templateDismissedRef = useRef(false)
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@ -165,7 +175,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@ -1,30 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { AppListQuery } from '@/contract/console/apps'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
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 Input from '@/app/components/base/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 { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
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 { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
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'
|
||||
@ -38,9 +40,11 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@ -51,10 +55,10 @@ const List: FC<Props> = ({
|
||||
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { category, keywords, isCreatedByMe },
|
||||
query: { category, keywords, creatorID },
|
||||
setCategory,
|
||||
setKeywords,
|
||||
setIsCreatedByMe,
|
||||
setCreatorID,
|
||||
} = useAppsQueryState()
|
||||
const [tagIDs, setTagIDs] = useState<string[]>([])
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: APP_LIST_SEARCH_DEBOUNCE_MS })
|
||||
@ -90,9 +94,9 @@ const List: FC<Props> = ({
|
||||
limit: 30,
|
||||
name: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(isCreatedByMe ? { is_created_by_me: isCreatedByMe } : {}),
|
||||
...(creatorID ? { creator_id: creatorID } : {}),
|
||||
...(category !== 'all' ? { mode: category } : {}),
|
||||
}), [category, debouncedKeywords, isCreatedByMe, tagIDs])
|
||||
}), [category, creatorID, debouncedKeywords, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -126,14 +130,6 @@ const List: FC<Props> = ({
|
||||
}, [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') {
|
||||
@ -172,9 +168,9 @@ const List: FC<Props> = ({
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
|
||||
const handleCreatedByMeChange = useCallback((checked: boolean) => {
|
||||
setIsCreatedByMe(checked)
|
||||
}, [setIsCreatedByMe])
|
||||
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorID(creatorIDs.at(-1) ?? '')
|
||||
}, [setCreatorID])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const apps = useMemo(() => pages.flatMap(({ data: pageApps }) => pageApps), [pages])
|
||||
@ -207,32 +203,45 @@ const List: FC<Props> = ({
|
||||
</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)} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
<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={creatorID ? [creatorID] : []}
|
||||
onChange={handleCreatorsChange}
|
||||
/>
|
||||
<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" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('operation.search', { ns: 'common' })}
|
||||
/>
|
||||
{!!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>
|
||||
{pageType === 'apps' && (
|
||||
<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',
|
||||
@ -260,7 +269,7 @@ const List: FC<Props> = ({
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
/>
|
||||
))
|
||||
: <Empty />}
|
||||
: <Empty message={pageType === 'snippets' ? t('tabs.noSnippetsFound', { ns: 'workflow' }) : undefined} />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { NavItem } from '../nav/nav-selector'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import {
|
||||
RiBook2Fill,
|
||||
RiBook2Line,
|
||||
} from '@remixicon/react'
|
||||
import type { DataSet, IconInfo } from '@/models/datasets'
|
||||
import { flatten } from 'es-toolkit/compat'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -14,6 +10,24 @@ import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-datase
|
||||
import { basePath } from '@/utils/var'
|
||||
import Nav from '../nav'
|
||||
|
||||
const DEFAULT_DATASET_ICON: IconInfo = {
|
||||
icon_type: 'emoji',
|
||||
icon: '📙',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
type NullableDatasetIconInfo = Partial<{
|
||||
[Key in keyof IconInfo]: IconInfo[Key] | null
|
||||
}>
|
||||
|
||||
const normalizeDatasetIconInfo = (iconInfo?: NullableDatasetIconInfo | null): IconInfo => ({
|
||||
icon_type: iconInfo?.icon_type ?? DEFAULT_DATASET_ICON.icon_type,
|
||||
icon: iconInfo?.icon ?? DEFAULT_DATASET_ICON.icon,
|
||||
icon_background: iconInfo?.icon_background ?? DEFAULT_DATASET_ICON.icon_background,
|
||||
icon_url: iconInfo?.icon_url ?? DEFAULT_DATASET_ICON.icon_url,
|
||||
})
|
||||
|
||||
const DatasetNav = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
@ -33,15 +47,16 @@ const DatasetNav = () => {
|
||||
const curNav = useMemo(() => {
|
||||
if (!currentDataset)
|
||||
return
|
||||
const iconInfo = normalizeDatasetIconInfo(currentDataset.icon_info)
|
||||
return {
|
||||
id: currentDataset.id,
|
||||
name: currentDataset.name,
|
||||
icon: currentDataset.icon_info.icon,
|
||||
icon_type: currentDataset.icon_info.icon_type,
|
||||
icon_background: currentDataset.icon_info.icon_background,
|
||||
icon_url: currentDataset.icon_info.icon_url,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
} as Omit<NavItem, 'link'>
|
||||
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
|
||||
}, [currentDataset])
|
||||
|
||||
const getDatasetLink = useCallback((dataset: DataSet) => {
|
||||
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
@ -56,14 +71,15 @@ const DatasetNav = () => {
|
||||
const navigationItems = useMemo(() => {
|
||||
return datasetItems.map((dataset) => {
|
||||
const link = getDatasetLink(dataset)
|
||||
const iconInfo = normalizeDatasetIconInfo(dataset.icon_info)
|
||||
return {
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
link,
|
||||
icon: dataset.icon_info.icon,
|
||||
icon_type: dataset.icon_info.icon_type,
|
||||
icon_background: dataset.icon_info.icon_background,
|
||||
icon_url: dataset.icon_info.icon_url,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
}
|
||||
}) as NavItem[]
|
||||
}, [datasetItems, getDatasetLink])
|
||||
@ -84,8 +100,8 @@ const DatasetNav = () => {
|
||||
return (
|
||||
<Nav
|
||||
isApp={false}
|
||||
icon={<RiBook2Line className="size-4" />}
|
||||
activeIcon={<RiBook2Fill className="size-4" />}
|
||||
icon={<span className="i-ri-book-2-line size-4" />}
|
||||
activeIcon={<span className="i-ri-book-2-fill size-4" />}
|
||||
text={t('menus.datasets', { ns: 'common' })}
|
||||
activeSegment="datasets"
|
||||
link="/datasets"
|
||||
|
||||
@ -15,7 +15,7 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
|
||||
@ -123,6 +123,27 @@ describe('Nav Component', () => {
|
||||
expect(screen.getByTestId('active-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render active child link when activeLink matches the current segment', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
|
||||
render(
|
||||
<Nav
|
||||
{...defaultProps}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
activeLink={{
|
||||
segment: 'snippets',
|
||||
text: 'SNIPPETS',
|
||||
link: '/snippets',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Nav Text')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nav Text')).toHaveClass('max-[1024px]:hidden')
|
||||
expect(screen.getByRole('link', { name: 'SNIPPETS' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByRole('link', { name: 'SNIPPETS' })).not.toHaveClass('max-[1024px]:hidden')
|
||||
})
|
||||
|
||||
it('should not show hover background if not activated', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('other')
|
||||
const { container } = render(<Nav {...defaultProps} />)
|
||||
@ -148,6 +169,14 @@ describe('Nav Component', () => {
|
||||
expect(mockSetAppDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call setAppDetail from snippets segment', () => {
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} />)
|
||||
const link = screen.getByRole('link')
|
||||
fireEvent.click(link.firstChild!)
|
||||
expect(mockSetAppDetail).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show ArrowNarrowLeft on hover when curNav is provided and activated', () => {
|
||||
const curNav = navigationItems[0]
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
@ -185,19 +214,20 @@ describe('Nav Component', () => {
|
||||
})
|
||||
|
||||
it('should navigate when an item is selected', async () => {
|
||||
render(<Nav {...defaultProps} curNav={curNav} />)
|
||||
vi.mocked(useSelectedLayoutSegment).mockReturnValue('snippets')
|
||||
render(<Nav {...defaultProps} activeSegment={['apps', 'app', 'snippets']} curNav={curNav} />)
|
||||
const selectorButton = screen.getByRole('button', { name: /Item 1/i })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(selectorButton)
|
||||
})
|
||||
mockSetAppDetail.mockClear()
|
||||
|
||||
const item2 = await screen.findByText('Item 2')
|
||||
await act(async () => {
|
||||
fireEvent.click(item2)
|
||||
})
|
||||
|
||||
expect(mockSetAppDetail).toHaveBeenCalled()
|
||||
expect(mockPush).toHaveBeenCalledWith('/item2')
|
||||
})
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import Link from '@/next/link'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import NavSelector from './nav-selector'
|
||||
@ -16,6 +15,11 @@ type INavProps = {
|
||||
text: string
|
||||
activeSegment: string | string[]
|
||||
link: string
|
||||
activeLink?: {
|
||||
segment: string
|
||||
text: string
|
||||
link: string
|
||||
}
|
||||
isApp: boolean
|
||||
} & INavSelectorProps
|
||||
|
||||
@ -25,6 +29,7 @@ const Nav = ({
|
||||
text,
|
||||
activeSegment,
|
||||
link,
|
||||
activeLink,
|
||||
curNav,
|
||||
navigationItems,
|
||||
createText,
|
||||
@ -37,10 +42,11 @@ const Nav = ({
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const isActivated = Array.isArray(activeSegment) ? activeSegment.includes(segment!) : segment === activeSegment
|
||||
const shouldShowActiveLink = isActivated && activeLink && segment === activeLink.segment
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
flex h-8 max-w-[670px] shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-[400px]
|
||||
flex h-8 max-w-167.5 shrink-0 items-center rounded-xl px-0.5 text-sm font-medium max-[1024px]:max-w-100
|
||||
${isActivated && 'bg-components-main-nav-nav-button-bg-active font-semibold shadow-md'}
|
||||
${!curNav && !isActivated && 'hover:bg-components-main-nav-nav-button-bg-hover'}
|
||||
`}
|
||||
@ -51,6 +57,8 @@ const Nav = ({
|
||||
// Don't clear state if opening in new tab/window
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0)
|
||||
return
|
||||
if (segment === 'snippets')
|
||||
return
|
||||
setAppDetail()
|
||||
}}
|
||||
className={cn('flex h-7 cursor-pointer items-center rounded-[10px] px-2.5', isActivated ? 'text-components-main-nav-nav-button-text-active' : 'text-components-main-nav-nav-button-text', curNav && isActivated && 'hover:bg-components-main-nav-nav-button-bg-active-hover')}
|
||||
@ -60,7 +68,7 @@ const Nav = ({
|
||||
<div>
|
||||
{
|
||||
(hovered && curNav)
|
||||
? <ArrowNarrowLeft className="size-4" />
|
||||
? <span className="i-custom-vender-line-arrows-arrow-narrow-left size-4" />
|
||||
: isActivated
|
||||
? activeIcon
|
||||
: icon
|
||||
@ -87,6 +95,19 @@ const Nav = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!curNav && shouldShowActiveLink && (
|
||||
<>
|
||||
<div className="font-light text-divider-deep">/</div>
|
||||
<Link
|
||||
href={activeLink.link}
|
||||
className="hover:bg-components-main-nav-nav-button-bg-active-hover flex h-7 cursor-pointer items-center rounded-[10px] px-2.5 text-components-main-nav-nav-button-text-active"
|
||||
>
|
||||
{activeLink.text}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
278
web/app/components/snippet-list/__tests__/index.spec.tsx
Normal file
278
web/app/components/snippet-list/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import SnippetList from '..'
|
||||
|
||||
const mockUseInfiniteSnippetList = vi.hoisted(() => vi.fn())
|
||||
const mockSetKeywords = vi.hoisted(() => vi.fn())
|
||||
const mockSetTagIDs = vi.hoisted(() => vi.fn())
|
||||
const mockSetCreatorID = vi.hoisted(() => vi.fn())
|
||||
const mockQueryState = vi.hoisted(() => ({
|
||||
tagIDs: [] as string[],
|
||||
keywords: '',
|
||||
creatorID: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useInfiniteSnippetList: (params: unknown, options: unknown) => mockUseInfiniteSnippetList(params, options),
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-snippets-query-state', () => ({
|
||||
useSnippetsQueryState: () => ({
|
||||
query: mockQueryState,
|
||||
setKeywords: mockSetKeywords,
|
||||
setTagIDs: mockSetTagIDs,
|
||||
setCreatorID: mockSetCreatorID,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
systemFeatures: vi.fn(),
|
||||
},
|
||||
consoleQuery: {
|
||||
tags: {
|
||||
list: {
|
||||
queryOptions: (options: unknown) => options,
|
||||
},
|
||||
},
|
||||
systemFeatures: {
|
||||
queryKey: () => ['console', 'systemFeatures'],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
isLoadingCurrentWorkspace: false,
|
||||
userProfile: { id: 'creator-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(''),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => {
|
||||
return function MockDynamicComponent() {
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
TagSelector: () => <div data-testid="snippet-card-tags" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockObserve = vi.fn()
|
||||
const mockDisconnect = vi.fn()
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
||||
constructor(_callback: IntersectionObserverCallback) {}
|
||||
|
||||
observe = mockObserve
|
||||
disconnect = mockDisconnect
|
||||
unobserve = vi.fn()
|
||||
root = null
|
||||
rootMargin = ''
|
||||
thresholds = []
|
||||
takeRecords = () => []
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
const mockSnippetListState = {
|
||||
data: {
|
||||
pages: [{
|
||||
data: [
|
||||
{
|
||||
id: 'snippet-1',
|
||||
name: 'Sales Snippet',
|
||||
description: 'Builds a sales follow-up.',
|
||||
type: 'node',
|
||||
is_published: true,
|
||||
use_count: 12,
|
||||
tags: [],
|
||||
created_at: 1704067200,
|
||||
created_by: 'creator-1',
|
||||
updated_at: 1704153600,
|
||||
updated_by: 'creator-2',
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
limit: 30,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
error: null as Error | null,
|
||||
}
|
||||
|
||||
const renderList = () => {
|
||||
const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: { branding: { enabled: false } },
|
||||
})
|
||||
|
||||
return renderWithNuqs(
|
||||
<SystemFeaturesWrapper>
|
||||
<SnippetList />
|
||||
</SystemFeaturesWrapper>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SnippetList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.creatorID = ''
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
...mockSnippetListState,
|
||||
refetch: mockRefetch,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the dedicated snippets list layout', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.studio.filters.allCreators')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('workflow.tabs.searchSnippets')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'snippet.create' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Sales Snippet/ })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
|
||||
expect(screen.getByTestId('tag-management-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('passes creator, tag, and search filters to the snippets list query', () => {
|
||||
mockQueryState.tagIDs = ['tag-1', 'tag-2']
|
||||
mockQueryState.keywords = 'sales'
|
||||
mockQueryState.creatorID = 'creator-1'
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: 'sales',
|
||||
tag_ids: ['tag-1', 'tag-2'],
|
||||
creator_id: 'creator-1',
|
||||
}, {
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates the search query state from the search input', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'summary' } })
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('summary')
|
||||
})
|
||||
|
||||
it('clears the search query state', () => {
|
||||
mockQueryState.keywords = 'summary'
|
||||
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(mockSetKeywords).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('updates the creator query state as a single creator filter', () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.studio.filters.allCreators' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Bob/ }))
|
||||
|
||||
expect(mockSetCreatorID).toHaveBeenCalledWith('creator-2')
|
||||
})
|
||||
|
||||
it('hides the create button for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'snippet.create' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state when no snippets are returned', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
...mockSnippetListState,
|
||||
data: {
|
||||
pages: [{
|
||||
data: [],
|
||||
page: 1,
|
||||
limit: 30,
|
||||
total: 0,
|
||||
has_more: false,
|
||||
}],
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
})
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCard from '../snippet-card'
|
||||
|
||||
const {
|
||||
mockDeleteMutate,
|
||||
mockDownloadBlob,
|
||||
mockExportMutateAsync,
|
||||
mockOnRefresh,
|
||||
mockToastError,
|
||||
mockToastSuccess,
|
||||
mockUpdateMutate,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeleteMutate: vi.fn(),
|
||||
mockDownloadBlob: vi.fn(),
|
||||
mockExportMutateAsync: vi.fn(),
|
||||
mockOnRefresh: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockUpdateMutate: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useMembers: () => ({
|
||||
data: {
|
||||
accounts: [
|
||||
{ id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
|
||||
{ id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
}),
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/time', () => ({
|
||||
formatTime: () => 'formatted-time',
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mockDownloadBlob,
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/tag-management/components/tag-selector', () => ({
|
||||
TagSelector: ({ value }: { value: Array<{ name: string }> }) => (
|
||||
<div data-testid="snippet-tags">{value.map(tag => tag.name).join(', ')}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'Rewrites rough drafts.',
|
||||
type: 'node',
|
||||
is_published: true,
|
||||
use_count: 19,
|
||||
tags: [],
|
||||
created_at: 1_704_067_200,
|
||||
created_by: 'creator-id',
|
||||
updated_at: 1_704_153_600,
|
||||
updated_by: 'updater-id',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render updater name and updated time from member data', () => {
|
||||
render(<SnippetCard snippet={createSnippet()} />)
|
||||
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Updater')).toBeInTheDocument()
|
||||
expect(screen.getByText('formatted-time')).toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.usageCount:{"count":19}')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to creator name when updater is unavailable', () => {
|
||||
render(<SnippetCard snippet={createSnippet({ updated_by: 'missing-user' })} />)
|
||||
|
||||
expect(screen.getByText('Creator')).toBeInTheDocument()
|
||||
expect(screen.getByText('formatted-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render draft status for unpublished snippets', () => {
|
||||
render(<SnippetCard snippet={createSnippet({ is_published: false })} />)
|
||||
|
||||
expect(screen.queryByText('snippet.draft')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render supported operations only', async () => {
|
||||
render(<SnippetCard snippet={createSnippet()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
expect(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'snippet.menu.exportSnippet' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'snippet.menu.deleteSnippet' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: /duplicate/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should export a snippet from the operations menu', async () => {
|
||||
mockExportMutateAsync.mockResolvedValue('snippet-yaml')
|
||||
|
||||
render(<SnippetCard snippet={createSnippet()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.exportSnippet' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: 'snippet-1' })
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
fileName: 'Tone Rewriter.yml',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should update snippet info from the operations menu', async () => {
|
||||
mockUpdateMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.editInfo' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'Updated Snippet' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
body: {
|
||||
name: 'Updated Snippet',
|
||||
description: 'Rewrites rough drafts.',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete a snippet from the operations menu', async () => {
|
||||
mockDeleteMutate.mockImplementation((_payload, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetCard snippet={createSnippet()} onRefresh={mockOnRefresh} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'snippet.menu.deleteSnippet' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockOnRefresh).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,111 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCreateButton from '../snippet-create-button'
|
||||
|
||||
const { mockPush, mockCreateMutate, mockImportMutateAsync, mockConfirmImportMutateAsync, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockCreateMutate: vi.fn(),
|
||||
mockImportMutateAsync: vi.fn(),
|
||||
mockConfirmImportMutateAsync: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: mockCreateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutateAsync: mockImportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutateAsync: mockConfirmImportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('SnippetCreateButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should open the create dialog and create a snippet from the modal', async () => {
|
||||
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
|
||||
options?.onSuccess?.({ id: 'snippet-123' })
|
||||
})
|
||||
|
||||
render(<SnippetCreateButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
|
||||
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'My Snippet' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
|
||||
target: { value: 'Useful snippet description' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
|
||||
|
||||
expect(mockCreateMutate).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'My Snippet',
|
||||
description: 'Useful snippet description',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
})
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
})
|
||||
|
||||
it('should import a snippet from a DSL URL', async () => {
|
||||
mockImportMutateAsync.mockResolvedValue({
|
||||
id: 'import-1',
|
||||
status: 'completed',
|
||||
snippet_id: 'snippet-imported',
|
||||
error: '',
|
||||
})
|
||||
|
||||
render(<SnippetCreateButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.create' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.importDSLFile' }))
|
||||
expect(screen.getByText('snippet.importDialogTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.importFromDSLUrl' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('snippet.importFromDSLUrlPlaceholder'), {
|
||||
target: { value: 'https://example.com/snippet.yml' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockImportMutateAsync).toHaveBeenCalledWith({
|
||||
mode: 'yaml-url',
|
||||
yamlContent: undefined,
|
||||
yamlUrl: 'https://example.com/snippet.yml',
|
||||
})
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.importSuccess')
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
|
||||
})
|
||||
})
|
||||
259
web/app/components/snippet-list/components/snippet-card.tsx
Normal file
259
web/app/components/snippet-list/components/snippet-card.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetListItem } from '@/types/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 { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagSelector } from '@/features/tag-management/components/tag-selector'
|
||||
import Link from '@/next/link'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { formatTime } from '@/utils/time'
|
||||
|
||||
type Props = {
|
||||
snippet: SnippetListItem
|
||||
onOpenTagManagement?: () => void
|
||||
onRefresh?: () => void
|
||||
onTagsChange?: () => void
|
||||
}
|
||||
|
||||
const SnippetCard = ({
|
||||
snippet,
|
||||
onOpenTagManagement = () => {},
|
||||
onRefresh,
|
||||
onTagsChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { t: tCommon } = useTranslation()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { data: membersData } = useMembers()
|
||||
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const memberNameById = useMemo(() => {
|
||||
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
|
||||
}, [membersData?.accounts])
|
||||
|
||||
const updatedByName = memberNameById.get(snippet.updated_by)
|
||||
|| memberNameById.get(snippet.created_by)
|
||||
|| t('unknownUser')
|
||||
|
||||
const updatedAt = snippet.updated_at || snippet.created_at
|
||||
const updatedAtText = formatTime({
|
||||
date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000),
|
||||
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
|
||||
})
|
||||
const initialValue = useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
}), [snippet.description, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = () => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleExportSnippet = async () => {
|
||||
setIsOperationsMenuOpen(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'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSnippet = () => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
onRefresh?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleUpdateSnippet = ({ name, description }: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
onRefresh?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<article className="group relative col-span-1 inline-flex h-40 w-full cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-sm transition-shadow duration-200 ease-in-out hover:shadow-lg">
|
||||
<Link href={`/snippets/${snippet.id}/orchestrate`} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-16.5 shrink-0 grow-0 flex-col justify-center px-3.5 pt-3.5 pb-3">
|
||||
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
|
||||
<div className="truncate" title={snippet.name}>{snippet.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-2xs leading-4.5 font-medium text-text-tertiary">
|
||||
<div className="truncate" title={updatedByName}>{updatedByName}</div>
|
||||
<div>·</div>
|
||||
<div className="truncate" title={updatedAtText}>{updatedAtText}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-22.5 px-3.5 text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{snippet.description}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-1.5 pb-1.5 pl-3.5">
|
||||
<div
|
||||
className="flex w-0 grow items-center gap-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="mr-10.25 min-w-0 grow overflow-hidden">
|
||||
<TagSelector
|
||||
placement="bottom-start"
|
||||
type="snippet"
|
||||
targetId={snippet.id}
|
||||
value={snippet.tags}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 right-1.5 flex -translate-y-1/2 items-center transition-opacity',
|
||||
isOperationsMenuOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<div className="mx-1 h-3.5 w-px shrink-0 bg-divider-regular" />
|
||||
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={tCommon('operation.more', { ns: 'common' })}
|
||||
className="flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset data-popup-open:bg-state-base-hover data-popup-open:shadow-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div className="flex size-8 cursor-pointer items-center justify-center rounded-md">
|
||||
<span className="sr-only">{tCommon('operation.more', { ns: 'common' })}</span>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[216px]"
|
||||
>
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={handleOpenEditDialog}>
|
||||
<span className="system-sm-regular text-text-secondary">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2 px-3" onClick={handleExportSnippet}>
|
||||
<span className="system-sm-regular text-text-secondary">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setIsOperationsMenuOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={tCommon('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleUpdateSnippet}
|
||||
/>
|
||||
)}
|
||||
<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 disabled={deleteSnippetMutation.isPending}>
|
||||
{tCommon('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCard
|
||||
@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog'
|
||||
import ImportSnippetDSLDialog from '@/app/components/snippets/import-snippet-dsl-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
useCreateSnippetMutation,
|
||||
} from '@/service/use-snippets'
|
||||
|
||||
const SnippetCreateButton = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const handleCreateSnippet = ({
|
||||
name,
|
||||
description,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
}) => {
|
||||
createSnippetMutation.mutate({
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
},
|
||||
}, {
|
||||
onSuccess: (snippet) => {
|
||||
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
|
||||
setIsCreateDialogOpen(false)
|
||||
push(`/snippets/${snippet.id}/orchestrate`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<Button disabled={createSnippetMutation.isPending}>
|
||||
<span aria-hidden className="mr-0.5 i-ri-add-line size-4" />
|
||||
<span>{t('create')}</span>
|
||||
<span aria-hidden className="ml-0.5 i-ri-arrow-down-s-line size-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={6}
|
||||
popupClassName="w-[228px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-xs"
|
||||
>
|
||||
<div className="px-2 pt-2 pb-1 text-xs leading-4.5 font-medium text-text-tertiary">
|
||||
{t('createFrom')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false)
|
||||
setIsCreateDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-plus-01 size-4 shrink-0" />
|
||||
<span>{t('createFromBlank')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg px-2 py-1.75 text-[13px] leading-4.5 font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false)
|
||||
setIsImportDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="mr-2 i-custom-vender-line-files-file-arrow-01 size-4 shrink-0" />
|
||||
<span>{t('importDSLFile')}</span>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isCreateDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
isSubmitting={createSnippetMutation.isPending}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
)}
|
||||
{isImportDialogOpen && (
|
||||
<ImportSnippetDSLDialog
|
||||
isOpen={isImportDialogOpen}
|
||||
onClose={() => setIsImportDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCreateButton
|
||||
1
web/app/components/snippet-list/constants.ts
Normal file
1
web/app/components/snippet-list/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const SNIPPET_LIST_SEARCH_DEBOUNCE_MS = 500
|
||||
@ -0,0 +1,38 @@
|
||||
import { debounce, parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from '../constants'
|
||||
|
||||
const snippetListQueryParsers = {
|
||||
tagIDs: parseAsArrayOf(parseAsString, ';')
|
||||
.withDefault([])
|
||||
.withOptions({ history: 'push' }),
|
||||
keywords: parseAsString.withDefault('').withOptions({
|
||||
limitUrlUpdates: debounce(SNIPPET_LIST_SEARCH_DEBOUNCE_MS),
|
||||
}),
|
||||
creatorID: parseAsString
|
||||
.withDefault('')
|
||||
.withOptions({ history: 'push' }),
|
||||
}
|
||||
|
||||
export function useSnippetsQueryState() {
|
||||
const [query, setQuery] = useQueryStates(snippetListQueryParsers)
|
||||
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery({ keywords })
|
||||
}, [setQuery])
|
||||
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery({ tagIDs })
|
||||
}, [setQuery])
|
||||
|
||||
const setCreatorID = useCallback((creatorID: string) => {
|
||||
setQuery({ creatorID })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setCreatorID,
|
||||
}), [query, setCreatorID, setKeywords, setTagIDs])
|
||||
}
|
||||
195
web/app/components/snippet-list/index.tsx
Normal file
195
web/app/components/snippet-list/index.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { useInfiniteSnippetList } from '@/service/use-snippets'
|
||||
import CreatorsFilter from '../apps/creators-filter'
|
||||
import Empty from '../apps/empty'
|
||||
import Footer from '../apps/footer'
|
||||
import SnippetCard from './components/snippet-card'
|
||||
import SnippetCreateButton from './components/snippet-create-button'
|
||||
import { SNIPPET_LIST_SEARCH_DEBOUNCE_MS } from './constants'
|
||||
import { useSnippetsQueryState } from './hooks/use-snippets-query-state'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/features/tag-management/components/tag-management-modal').then(mod => mod.TagManagementModal), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const SNIPPET_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
|
||||
|
||||
type SnippetCardSkeletonProps = {
|
||||
count: number
|
||||
}
|
||||
|
||||
const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => {
|
||||
return (
|
||||
<>
|
||||
{SNIPPET_CARD_SKELETON_KEYS.slice(0, count).map(key => (
|
||||
<div
|
||||
key={key}
|
||||
className="col-span-1 h-55 animate-pulse rounded-xl bg-background-default-lighter"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetList = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
// eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState
|
||||
const {
|
||||
query: { tagIDs, keywords, creatorID },
|
||||
setKeywords,
|
||||
setTagIDs,
|
||||
setCreatorID,
|
||||
} = useSnippetsQueryState()
|
||||
const debouncedKeywords = useDebounce(keywords, { wait: SNIPPET_LIST_SEARCH_DEBOUNCE_MS })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
|
||||
useDocumentTitle(t('tabs.snippets', { ns: 'workflow' }))
|
||||
|
||||
const snippetListQuery = useMemo(() => ({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: debouncedKeywords,
|
||||
...(tagIDs.length ? { tag_ids: tagIDs } : {}),
|
||||
...(creatorID ? { creator_id: creatorID } : {}),
|
||||
}), [creatorID, debouncedKeywords, tagIDs])
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteSnippetList(snippetListQuery, {
|
||||
enabled: !isCurrentWorkspaceDatasetOperator,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return
|
||||
|
||||
const hasMore = hasNextPage ?? true
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
if (observer)
|
||||
observer.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (anchorRef.current && containerRef.current) {
|
||||
const containerHeight = containerRef.current.clientHeight
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
||||
fetchNextPage()
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
threshold: 0.1,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
|
||||
return () => observer?.disconnect()
|
||||
}, [error, fetchNextPage, hasNextPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
|
||||
|
||||
const handleCreatorsChange = useCallback((creatorIDs: string[]) => {
|
||||
setCreatorID(creatorIDs.at(-1) ?? '')
|
||||
}, [setCreatorID])
|
||||
|
||||
const pages = useMemo(() => data?.pages ?? [], [data?.pages])
|
||||
const snippets = useMemo<SnippetListItem[]>(() => pages.flatMap(({ data: pageSnippets }) => pageSnippets), [pages])
|
||||
const hasAnySnippet = (pages[0]?.total ?? 0) > 0
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<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">
|
||||
<CreatorsFilter
|
||||
value={creatorID ? [creatorID] : []}
|
||||
onChange={handleCreatorsChange}
|
||||
/>
|
||||
<TagFilter type="snippet" 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" />
|
||||
<Input
|
||||
className={cn('pl-6.5', keywords && 'pr-6.5')}
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
placeholder={t('tabs.searchSnippets', { ns: 'workflow' })}
|
||||
/>
|
||||
{!!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>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<SnippetCreateButton />
|
||||
)}
|
||||
</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',
|
||||
!hasAnySnippet && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{showSkeleton
|
||||
? <SnippetCardSkeleton count={6} />
|
||||
: hasAnySnippet
|
||||
? snippets.map(snippet => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
onRefresh={refetch}
|
||||
onTagsChange={refetch}
|
||||
/>
|
||||
))
|
||||
: <Empty message={t('tabs.noSnippetsFound', { ns: 'workflow' })} />}
|
||||
{isFetchingNextPage && (
|
||||
<SnippetCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<div ref={anchorRef} className="h-0"> </div>
|
||||
<TagManagementModal
|
||||
type="snippet"
|
||||
show={showTagManagementModal}
|
||||
onClose={() => setShowTagManagementModal(false)}
|
||||
onTagsChange={refetch}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetList
|
||||
137
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
137
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetPage from '..'
|
||||
|
||||
const mockUseSnippetInit = vi.fn()
|
||||
const mockSetAppSidebarExpand = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-snippet-init', () => ({
|
||||
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
|
||||
}))
|
||||
|
||||
vi.mock('../components/snippet-main', () => ({
|
||||
default: ({ snippetId }: { snippetId: string }) => <div data-testid="snippet-main">{snippetId}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-default-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
initialNodes: (nodes: unknown[]) => nodes,
|
||||
initialEdges: (edges: unknown[]) => edges,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: {
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="app-sidebar">
|
||||
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
|
||||
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
|
||||
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{name}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
|
||||
default: () => <div data-testid="snippet-info" />,
|
||||
}))
|
||||
|
||||
const mockSnippetDetail: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
updatedAt: 'Updated 2h ago',
|
||||
usage: 'Used 19 times',
|
||||
tags: [],
|
||||
status: 'Draft',
|
||||
},
|
||||
graph: {
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputFields: [],
|
||||
uiMeta: {
|
||||
inputFieldCount: 0,
|
||||
checklistCount: 0,
|
||||
autoSavedAt: 'Auto-saved · a few seconds ago',
|
||||
},
|
||||
}
|
||||
|
||||
describe('SnippetPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: mockSnippetDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the orchestrate route shell with independent main content', () => {
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
|
||||
})
|
||||
|
||||
it('should render loading fallback when orchestrate data is unavailable', () => {
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<SnippetPage snippetId="missing-snippet" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,60 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetLayout from '../snippet-layout'
|
||||
|
||||
const mockUseDocumentTitle = vi.fn()
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: (title: string) => mockUseDocumentTitle(title),
|
||||
}))
|
||||
|
||||
const createSnippet = (overrides: Partial<SnippetDetail> = {}): SnippetDetail => ({
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet Title',
|
||||
description: 'Snippet description',
|
||||
updatedAt: '2026-04-15',
|
||||
usage: '42',
|
||||
tags: [],
|
||||
is_published: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Document title', () => {
|
||||
it('should set the document title to the snippet name when snippet detail is available', () => {
|
||||
render(
|
||||
<SnippetLayout
|
||||
snippetId="snippet-1"
|
||||
snippet={createSnippet()}
|
||||
section="orchestrate"
|
||||
>
|
||||
<div>content</div>
|
||||
</SnippetLayout>,
|
||||
)
|
||||
|
||||
expect(mockUseDocumentTitle).toHaveBeenCalledWith('Snippet Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout', () => {
|
||||
it('should render the detail content without the app detail sidebar navigation', () => {
|
||||
render(
|
||||
<SnippetLayout
|
||||
snippetId="snippet-1"
|
||||
snippet={createSnippet()}
|
||||
section="orchestrate"
|
||||
>
|
||||
<div>content</div>
|
||||
</SnippetLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('content')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'snippet.sectionOrchestrate' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,513 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import SnippetMain from '../snippet-main'
|
||||
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockSetFields = vi.fn()
|
||||
const mockPublishSnippetMutateAsync = vi.fn()
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
const mockFetchInspectVars = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
const mockHandleStartWorkflowRun = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockHandleCheckBeforePublish = vi.fn()
|
||||
const mockPush = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableNodesMetaData = vi.hoisted(() => vi.fn())
|
||||
const mockInspectVarsCrud = {
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
fetchInspectVarValue: vi.fn(),
|
||||
editInspectVarValue: vi.fn(),
|
||||
renameInspectVarName: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
deleteInspectVar: vi.fn(),
|
||||
deleteNodeInspectorVars: vi.fn(),
|
||||
deleteAllInspectorVars: vi.fn(),
|
||||
isInspectVarEdited: vi.fn(),
|
||||
resetToLastRunVar: vi.fn(),
|
||||
invalidateSysVarValues: vi.fn(),
|
||||
resetConversationVar: vi.fn(),
|
||||
invalidateConversationVarValues: vi.fn(),
|
||||
}
|
||||
let capturedHooksStore: Record<string, unknown> | undefined
|
||||
let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined
|
||||
let snippetDetailStoreState: {
|
||||
fields: SnippetInputField[]
|
||||
reset: typeof mockReset
|
||||
setFields: typeof mockSetFields
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
usePublishSnippetWorkflowMutation: () => ({
|
||||
mutateAsync: mockPublishSnippetMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useSnippetPublishedWorkflow: () => mockUseSnippetPublishedWorkflow(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => ({
|
||||
flowId: 'snippet-1',
|
||||
flowType: 'snippet',
|
||||
fileSettings: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: mockFetchInspectVars,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
|
||||
useChecklistBeforePublish: () => ({
|
||||
handleCheckBeforePublish: mockHandleCheckBeforePublish,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks', () => ({
|
||||
useAvailableNodesMetaData: () => mockUseAvailableNodesMetaData(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-inspect-vars-crud', () => ({
|
||||
useInspectVarsCrud: () => mockInspectVarsCrud,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
syncInputFieldsDraft: mockSyncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
|
||||
useSnippetRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({
|
||||
useSnippetRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
|
||||
handleRun: mockHandleRun,
|
||||
handleStopRun: mockHandleStopRun,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({
|
||||
useSnippetStartRun: () => ({
|
||||
handleStartWorkflowRun: mockHandleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
WorkflowWithInnerContext: ({
|
||||
children,
|
||||
hooksStore,
|
||||
nodes,
|
||||
}: {
|
||||
children: ReactNode
|
||||
hooksStore?: Record<string, unknown>
|
||||
nodes?: WorkflowProps['nodes']
|
||||
}) => {
|
||||
capturedHooksStore = hooksStore
|
||||
capturedWorkflowNodes = nodes
|
||||
|
||||
return (
|
||||
<div data-testid="workflow-inner-context">{children}</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
|
||||
default: ({
|
||||
onCancel,
|
||||
onEdit,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
isEditing,
|
||||
}: {
|
||||
isEditing: boolean
|
||||
onCancel: () => void
|
||||
onEdit: () => void
|
||||
onExitEditingWithoutSave: () => void
|
||||
onPublish: () => void
|
||||
}) => (
|
||||
<div>
|
||||
{!isEditing && <button type="button" onClick={onEdit}>edit</button>}
|
||||
<a href="/snippets">snippets list</a>
|
||||
<button type="button" onClick={onExitEditingWithoutSave}>exit without save</button>
|
||||
<button type="button" onClick={onPublish}>publish</button>
|
||||
<button type="button" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-sidebar', () => ({
|
||||
default: ({
|
||||
fields,
|
||||
onFieldsChange,
|
||||
}: {
|
||||
fields: SnippetInputField[]
|
||||
onFieldsChange: (fields: SnippetInputField[]) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onFieldsChange([])}>remove</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFieldsChange([
|
||||
...fields,
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
])}
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const payload: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet',
|
||||
description: 'desc',
|
||||
updatedAt: '2026-03-29 10:00',
|
||||
usage: '0',
|
||||
tags: [],
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
inputFields: [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
uiMeta: {
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 0,
|
||||
autoSavedAt: '2026-03-29 10:00',
|
||||
},
|
||||
}
|
||||
|
||||
const renderSnippetMain = ({
|
||||
hasInitialDraftChanges = false,
|
||||
}: {
|
||||
hasInitialDraftChanges?: boolean
|
||||
} = {}) => {
|
||||
return renderWorkflowComponent(
|
||||
<SnippetMain
|
||||
payload={payload}
|
||||
draftPayload={payload}
|
||||
hasInitialDraftChanges={hasInitialDraftChanges}
|
||||
hasPublishedWorkflow
|
||||
snippetId="snippet-1"
|
||||
nodes={[] as WorkflowProps['nodes']}
|
||||
edges={[] as WorkflowProps['edges']}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
draftNodes={[] as WorkflowProps['nodes']}
|
||||
draftEdges={[] as WorkflowProps['edges']}
|
||||
draftViewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
const createNodeMetadata = (type: BlockEnum) => ({
|
||||
metaData: {
|
||||
type,
|
||||
},
|
||||
defaultValue: {},
|
||||
checkValid: vi.fn(),
|
||||
})
|
||||
|
||||
describe('SnippetMain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: {
|
||||
graph: payload.graph,
|
||||
input_fields: payload.inputFields,
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
const llmNodeMetadata = createNodeMetadata(BlockEnum.LLM)
|
||||
const humanInputNodeMetadata = createNodeMetadata(BlockEnum.HumanInput)
|
||||
const endNodeMetadata = createNodeMetadata(BlockEnum.End)
|
||||
const knowledgeRetrievalNodeMetadata = createNodeMetadata(BlockEnum.KnowledgeRetrieval)
|
||||
mockUseAvailableNodesMetaData.mockReturnValue({
|
||||
nodes: [
|
||||
llmNodeMetadata,
|
||||
humanInputNodeMetadata,
|
||||
endNodeMetadata,
|
||||
knowledgeRetrievalNodeMetadata,
|
||||
],
|
||||
nodesMap: {
|
||||
[BlockEnum.LLM]: llmNodeMetadata,
|
||||
[BlockEnum.HumanInput]: humanInputNodeMetadata,
|
||||
[BlockEnum.End]: endNodeMetadata,
|
||||
[BlockEnum.KnowledgeRetrieval]: knowledgeRetrievalNodeMetadata,
|
||||
},
|
||||
})
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
||||
capturedHooksStore = undefined
|
||||
capturedWorkflowNodes = undefined
|
||||
snippetDetailStoreState = {
|
||||
fields: [...payload.inputFields],
|
||||
reset: mockReset,
|
||||
setFields: mockSetFields,
|
||||
}
|
||||
})
|
||||
|
||||
describe('Input Fields Sync', () => {
|
||||
it('should sync draft input_fields when removing a field from the panel', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync draft input_fields when adding a field from the sidebar', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
|
||||
payload.inputFields[0],
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Draft Sync', () => {
|
||||
it('should sync workflow draft during normal editing changes', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as (() => Promise<void>)
|
||||
await doSyncWorkflowDraft()
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('should sync workflow draft before routing without saving changes', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'snippets list' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'snippet.doNotSave' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(mockDoSyncWorkflowDraft.mock.invocationCallOrder[0]!).toBeLessThan(mockPush.mock.invocationCallOrder[0]!)
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should sync workflow draft before exiting editing without saving changes', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip forced draft sync caused by re-entering editing mode', async () => {
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
mockDoSyncWorkflowDraft.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
|
||||
const doSyncWorkflowDraft = capturedHooksStore?.doSyncWorkflowDraft as ((notRefreshWhenSyncError?: boolean) => Promise<void>)
|
||||
await doSyncWorkflowDraft(true)
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use latest synced draft when re-entering editing mode', async () => {
|
||||
const latestDraftNode = {
|
||||
id: 'latest-node',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { type: BlockEnum.Code, title: 'Latest draft node' },
|
||||
} as WorkflowProps['nodes'][number]
|
||||
mockDoSyncWorkflowDraft.mockResolvedValueOnce({
|
||||
graph: {
|
||||
nodes: [latestDraftNode],
|
||||
edges: [],
|
||||
viewport: { x: 30, y: 40, zoom: 1.2 },
|
||||
},
|
||||
input_fields: [payload.inputFields[0]],
|
||||
})
|
||||
renderSnippetMain({ hasInitialDraftChanges: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'exit without save' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'edit' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedWorkflowNodes?.map(node => node.id)).toContain('latest-node')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish', () => {
|
||||
it('should call the publish mutation', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel', () => {
|
||||
it('should restore from the published workflow and reset published input fields', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalledWith({
|
||||
graph: payload.graph,
|
||||
input_fields: payload.inputFields,
|
||||
})
|
||||
expect(mockSetFields).toHaveBeenCalledWith(payload.inputFields)
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(payload.inputFields, {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inspect Vars', () => {
|
||||
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
expect(capturedHooksStore?.fetchInspectVars).toBe(mockFetchInspectVars)
|
||||
expect(capturedHooksStore?.hasNodeInspectVars).toBe(mockInspectVarsCrud.hasNodeInspectVars)
|
||||
expect(capturedHooksStore?.hasSetInspectVar).toBe(mockInspectVarsCrud.hasSetInspectVar)
|
||||
expect(capturedHooksStore?.fetchInspectVarValue).toBe(mockInspectVarsCrud.fetchInspectVarValue)
|
||||
expect(capturedHooksStore?.editInspectVarValue).toBe(mockInspectVarsCrud.editInspectVarValue)
|
||||
expect(capturedHooksStore?.renameInspectVarName).toBe(mockInspectVarsCrud.renameInspectVarName)
|
||||
expect(capturedHooksStore?.appendNodeInspectVars).toBe(mockInspectVarsCrud.appendNodeInspectVars)
|
||||
expect(capturedHooksStore?.deleteInspectVar).toBe(mockInspectVarsCrud.deleteInspectVar)
|
||||
expect(capturedHooksStore?.deleteNodeInspectorVars).toBe(mockInspectVarsCrud.deleteNodeInspectorVars)
|
||||
expect(capturedHooksStore?.deleteAllInspectorVars).toBe(mockInspectVarsCrud.deleteAllInspectorVars)
|
||||
expect(capturedHooksStore?.isInspectVarEdited).toBe(mockInspectVarsCrud.isInspectVarEdited)
|
||||
expect(capturedHooksStore?.resetToLastRunVar).toBe(mockInspectVarsCrud.resetToLastRunVar)
|
||||
expect(capturedHooksStore?.invalidateSysVarValues).toBe(mockInspectVarsCrud.invalidateSysVarValues)
|
||||
expect(capturedHooksStore?.resetConversationVar).toBe(mockInspectVarsCrud.resetConversationVar)
|
||||
expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Block Selector', () => {
|
||||
it('should filter unsupported snippet block types from available node metadata', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
const availableNodesMetaData = capturedHooksStore?.availableNodesMetaData as {
|
||||
nodes: Array<{ metaData: { type: BlockEnum } }>
|
||||
nodesMap: Partial<Record<BlockEnum, unknown>>
|
||||
}
|
||||
|
||||
expect(availableNodesMetaData.nodes.map(node => node.metaData.type)).toEqual([BlockEnum.LLM])
|
||||
expect(availableNodesMetaData.nodesMap[BlockEnum.LLM]).toBeDefined()
|
||||
expect(availableNodesMetaData.nodesMap[BlockEnum.HumanInput]).toBeUndefined()
|
||||
expect(availableNodesMetaData.nodesMap[BlockEnum.End]).toBeUndefined()
|
||||
expect(availableNodesMetaData.nodesMap[BlockEnum.KnowledgeRetrieval]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Run Hooks', () => {
|
||||
it('should pass snippet run handlers to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft)
|
||||
expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft)
|
||||
expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow)
|
||||
expect(capturedHooksStore?.handleRun).toBe(mockHandleRun)
|
||||
expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun)
|
||||
expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun)
|
||||
expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow)
|
||||
})
|
||||
|
||||
it('should pass snippet workflow run detail urls to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
const getWorkflowRunAndTraceUrl = capturedHooksStore?.getWorkflowRunAndTraceUrl as ((runId?: string) => { runUrl: string, traceUrl: string }) | undefined
|
||||
|
||||
expect(getWorkflowRunAndTraceUrl?.('run-1')).toEqual({
|
||||
runUrl: '/snippets/snippet-1/workflow-runs/run-1',
|
||||
traceUrl: '/snippets/snippet-1/workflow-runs/run-1/node-executions',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,43 @@
|
||||
import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import SnippetWorkflowPanel from '../workflow-panel'
|
||||
|
||||
let capturedPanelProps: PanelProps | null = null
|
||||
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: (props: PanelProps) => {
|
||||
capturedPanelProps = props
|
||||
return <div data-testid="workflow-panel" />
|
||||
},
|
||||
}))
|
||||
|
||||
const defaultFields: SnippetInputField[] = []
|
||||
|
||||
describe('SnippetWorkflowPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedPanelProps = null
|
||||
})
|
||||
|
||||
// Verifies snippet panel wires version history support into the shared workflow panel.
|
||||
describe('Rendering', () => {
|
||||
it('should pass snippet version history panel props to the shared workflow panel', async () => {
|
||||
render(
|
||||
<SnippetWorkflowPanel
|
||||
snippetId="snippet-1"
|
||||
fields={defaultFields}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe('/snippets/snippet-1/workflows')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
|
||||
expect(capturedPanelProps?.components?.right).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,70 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
|
||||
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockSetFields = vi.fn()
|
||||
|
||||
let snippetDetailStoreState: {
|
||||
fields: SnippetInputField[]
|
||||
setFields: typeof mockSetFields
|
||||
}
|
||||
|
||||
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
syncInputFieldsDraft: mockSyncInputFieldsDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
|
||||
}))
|
||||
|
||||
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useSnippetInputFieldActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
snippetDetailStoreState = {
|
||||
fields: [],
|
||||
setFields: mockSetFields,
|
||||
}
|
||||
mockSetFields.mockImplementation((fields: SnippetInputField[]) => {
|
||||
snippetDetailStoreState.fields = fields
|
||||
})
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('Field sync', () => {
|
||||
it('should update fields and sync the draft', () => {
|
||||
snippetDetailStoreState.fields = [createField()]
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
const nextFields = [
|
||||
createField(),
|
||||
createField({
|
||||
label: 'Topic',
|
||||
variable: 'topic',
|
||||
}),
|
||||
]
|
||||
|
||||
act(() => {
|
||||
result.current.handleFieldsChange(nextFields)
|
||||
})
|
||||
|
||||
expect(result.current.fields).toEqual([createField()])
|
||||
expect(mockSetFields).toHaveBeenCalledWith(nextFields)
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith(nextFields, {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,119 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useSnippetPublish } from '../use-snippet-publish'
|
||||
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockSetPublishedAt = vi.fn()
|
||||
const mockSetQueryData = vi.fn()
|
||||
const mockHandleCheckBeforePublish = vi.fn<() => Promise<boolean>>()
|
||||
|
||||
let isPending = false
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQueryClient: () => ({
|
||||
setQueryData: mockSetQueryData,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
usePublishSnippetWorkflowMutation: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-checklist', () => ({
|
||||
useChecklistBeforePublish: () => ({
|
||||
handleCheckBeforePublish: mockHandleCheckBeforePublish,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useSnippetPublish', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isPending = false
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(true)
|
||||
mockMutateAsync.mockResolvedValue({ created_at: 1_712_345_678 })
|
||||
})
|
||||
|
||||
describe('Publish action', () => {
|
||||
it('should publish the snippet and show success feedback', async () => {
|
||||
const { result } = renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePublish()
|
||||
})
|
||||
|
||||
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
})
|
||||
expect(mockSetQueryData).toHaveBeenCalledTimes(1)
|
||||
const setQueryDataCall = mockSetQueryData.mock.calls[0]
|
||||
expect(setQueryDataCall).toBeDefined()
|
||||
const updateSnippetDetail = setQueryDataCall![1] as (old: { is_published: boolean }) => { is_published: boolean }
|
||||
expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
expect(toast.success).toHaveBeenCalledWith('snippet.saveSuccess')
|
||||
})
|
||||
|
||||
it('should not publish the snippet when checklist validation fails', async () => {
|
||||
mockHandleCheckBeforePublish.mockResolvedValue(false)
|
||||
|
||||
const { result } = renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePublish()
|
||||
})
|
||||
|
||||
expect(mockHandleCheckBeforePublish).toHaveBeenCalledTimes(1)
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
expect(mockSetQueryData).not.toHaveBeenCalled()
|
||||
expect(mockSetPublishedAt).not.toHaveBeenCalled()
|
||||
expect(toast.success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should surface publish errors through toast feedback', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('publish failed'))
|
||||
|
||||
const { result } = renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePublish()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('publish failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose publishing pending state', () => {
|
||||
isPending = true
|
||||
|
||||
const { result } = renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
expect(result.current.isPublishing).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
|
||||
import { useSnippetDetailStore } from '../../store'
|
||||
|
||||
type UseSnippetInputFieldActionsOptions = {
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
export const useSnippetInputFieldActions = ({
|
||||
snippetId,
|
||||
}: UseSnippetInputFieldActionsOptions) => {
|
||||
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
|
||||
const {
|
||||
fields,
|
||||
setFields,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
fields: state.fields,
|
||||
setFields: state.setFields,
|
||||
})))
|
||||
|
||||
const handleFieldsChange = useCallback((newFields: SnippetInputField[]) => {
|
||||
setFields(newFields)
|
||||
void syncInputFieldsDraft(newFields, {
|
||||
onRefresh: setFields,
|
||||
})
|
||||
}, [setFields, syncInputFieldsDraft])
|
||||
|
||||
return {
|
||||
fields,
|
||||
handleFieldsChange,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import type { Snippet as SnippetContract } from '@/types/snippet'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks/use-checklist'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
|
||||
|
||||
type UseSnippetPublishOptions = {
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
export const useSnippetPublish = ({
|
||||
snippetId,
|
||||
}: UseSnippetPublishOptions) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const workflowStore = useWorkflowStore()
|
||||
const queryClient = useQueryClient()
|
||||
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
|
||||
const { handleCheckBeforePublish } = useChecklistBeforePublish()
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
try {
|
||||
const canPublish = await handleCheckBeforePublish()
|
||||
if (!canPublish)
|
||||
return
|
||||
|
||||
const publishedWorkflow = await publishSnippetMutation.mutateAsync({
|
||||
params: { snippetId },
|
||||
})
|
||||
queryClient.setQueryData<SnippetContract | undefined>(
|
||||
consoleQuery.snippets.detail.queryKey({
|
||||
input: {
|
||||
params: { snippetId },
|
||||
},
|
||||
}),
|
||||
old => old ? { ...old, is_published: true } : old,
|
||||
)
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
toast.success(t('saveSuccess'))
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('publishFailed'))
|
||||
return false
|
||||
}
|
||||
}, [handleCheckBeforePublish, publishSnippetMutation, queryClient, snippetId, t, workflowStore])
|
||||
|
||||
return {
|
||||
handlePublish,
|
||||
isPublishing: publishSnippetMutation.isPending,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactElement } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type SaveBeforeLeavingDialogProps = {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
trigger?: ReactElement
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
onDiscard: () => void | Promise<void>
|
||||
onSave: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const SaveBeforeLeavingDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
trigger,
|
||||
disabled,
|
||||
loading,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: SaveBeforeLeavingDialogProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
{trigger && (
|
||||
<AlertDialogTrigger render={trigger} />
|
||||
)}
|
||||
<AlertDialogContent className="w-165">
|
||||
<div className="space-y-2 p-8 pb-12">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('saveBeforeLeavingTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-secondary">
|
||||
{t('saveBeforeLeavingDescription')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="px-8 pt-0">
|
||||
<AlertDialogCancelButton disabled={disabled || loading}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone="destructive"
|
||||
disabled={disabled || loading}
|
||||
onClick={onDiscard}
|
||||
>
|
||||
{t('doNotSave')}
|
||||
</AlertDialogConfirmButton>
|
||||
<AlertDialogConfirmButton
|
||||
tone="default"
|
||||
loading={loading}
|
||||
disabled={disabled || loading}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t('saveAndExit')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SaveBeforeLeavingDialog
|
||||
62
web/app/components/snippets/components/snippet-children.tsx
Normal file
62
web/app/components/snippets/components/snippet-children.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import SnippetHeader from './snippet-header'
|
||||
import SnippetWorkflowPanel from './workflow-panel'
|
||||
|
||||
type SnippetChildrenProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
canDiscardChanges: boolean
|
||||
hasDraftChanges: boolean
|
||||
isEditing: boolean
|
||||
isPublishing: boolean
|
||||
onCancel: () => void
|
||||
onEdit: () => void
|
||||
onExitEditing: () => void | Promise<void>
|
||||
onExitEditingWithoutSave: () => void | Promise<void>
|
||||
onPublish: () => void
|
||||
onSaveAndExitEditing: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const SnippetChildren = ({
|
||||
snippetId,
|
||||
fields,
|
||||
canDiscardChanges,
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
isPublishing,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
onSaveAndExitEditing,
|
||||
}: SnippetChildrenProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-linear-to-b from-background-body to-transparent" />
|
||||
|
||||
<SnippetHeader
|
||||
snippetId={snippetId}
|
||||
canDiscardChanges={canDiscardChanges}
|
||||
hasDraftChanges={hasDraftChanges}
|
||||
isEditing={isEditing}
|
||||
isPublishing={isPublishing}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onExitEditing={onExitEditing}
|
||||
onExitEditingWithoutSave={onExitEditingWithoutSave}
|
||||
onPublish={onPublish}
|
||||
onSaveAndExitEditing={onSaveAndExitEditing}
|
||||
/>
|
||||
|
||||
<SnippetWorkflowPanel
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetChildren
|
||||
@ -0,0 +1,125 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetHeader from '..'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
|
||||
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
|
||||
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? <button type="button">{children}</button>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: (props: HeaderProps) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="workflow-header"
|
||||
data-show-env={String(props.normal?.controls?.showEnvButton ?? true)}
|
||||
data-show-global-variable={String(props.normal?.controls?.showGlobalVariableButton ?? true)}
|
||||
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
|
||||
>
|
||||
{props.normal?.components?.title}
|
||||
{props.normal?.components?.left}
|
||||
<button type="button">
|
||||
{props.normal?.runAndHistoryProps?.runButtonText ?? 'snippet.testRunButton'}
|
||||
</button>
|
||||
{props.normal?.components?.middle}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SnippetHeader', () => {
|
||||
const mockCancel = vi.fn()
|
||||
const mockEdit = vi.fn()
|
||||
const mockExitEditing = vi.fn()
|
||||
const mockExitEditingWithoutSave = vi.fn()
|
||||
const mockPublish = vi.fn()
|
||||
const mockSaveAndExit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verifies the wrapper passes the expected workflow header configuration.
|
||||
describe('Rendering', () => {
|
||||
it('should configure workflow header slots and hide workflow-only controls', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
hasDraftChanges={false}
|
||||
isEditing={false}
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-env', 'false')
|
||||
expect(header).toHaveAttribute('data-show-global-variable', 'false')
|
||||
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
|
||||
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies forwarded callbacks still drive the snippet-specific controls.
|
||||
describe('User Interactions', () => {
|
||||
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
|
||||
|
||||
expect(mockPublish).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should hide the discard draft action when there is no published workflow', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
canDiscardChanges={false}
|
||||
hasDraftChanges
|
||||
isEditing
|
||||
isPublishing={false}
|
||||
onCancel={mockCancel}
|
||||
onEdit={mockEdit}
|
||||
onExitEditing={mockExitEditing}
|
||||
onExitEditingWithoutSave={mockExitEditingWithoutSave}
|
||||
onPublish={mockPublish}
|
||||
onSaveAndExitEditing={mockSaveAndExit}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('snippet.discardDraft')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CancelChangesProps = {
|
||||
canDiscardChanges: boolean
|
||||
onCancel: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const CancelChanges = ({
|
||||
canDiscardChanges,
|
||||
onCancel,
|
||||
}: CancelChangesProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isDiscarding, setIsDiscarding] = useState(false)
|
||||
|
||||
const handleDiscardChanges = useCallback(async () => {
|
||||
setIsDiscarding(true)
|
||||
try {
|
||||
await onCancel()
|
||||
setOpen(false)
|
||||
}
|
||||
finally {
|
||||
setIsDiscarding(false)
|
||||
}
|
||||
}, [onCancel])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 system-sm-regular">
|
||||
{canDiscardChanges && (
|
||||
<>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
className="system-sm-semibold text-text-accent hover:text-text-accent-secondary"
|
||||
>
|
||||
{t('discardDraft')}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="w-160">
|
||||
<div className="space-y-2 p-8 pb-12">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('discardChangesTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-secondary">
|
||||
{t('discardChangesDescription')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="px-8 pt-0">
|
||||
<AlertDialogCancelButton disabled={isDiscarding}>
|
||||
{t('continueEditing')}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isDiscarding}
|
||||
disabled={isDiscarding}
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
{t('discardChanges')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<span className="text-text-quaternary">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-tertiary">{t('editingDraft')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CancelChanges)
|
||||
167
web/app/components/snippets/components/snippet-header/index.tsx
Normal file
167
web/app/components/snippets/components/snippet-header/index.tsx
Normal file
@ -0,0 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Header from '@/app/components/workflow/header'
|
||||
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
|
||||
import CancelChanges from './cancel-changes'
|
||||
import RunMode from './run-mode'
|
||||
|
||||
type SnippetHeaderProps = {
|
||||
snippetId: string
|
||||
canDiscardChanges: boolean
|
||||
hasDraftChanges: boolean
|
||||
isEditing: boolean
|
||||
isPublishing: boolean
|
||||
onCancel: () => void
|
||||
onEdit: () => void
|
||||
onExitEditing: () => void | Promise<void>
|
||||
onExitEditingWithoutSave: () => void | Promise<void>
|
||||
onPublish: () => void
|
||||
onSaveAndExitEditing: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const ViewOnlyBadge = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-components-badge-status-light-normal-border-inner bg-components-badge-bg-blue-light-soft px-1.5 py-0.5 system-xs-semibold-uppercase text-text-accent">
|
||||
{t('viewOnly')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditActions = ({
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
isPublishing,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
onSaveAndExitEditing,
|
||||
}: Pick<SnippetHeaderProps, 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onEdit' | 'onExitEditing' | 'onExitEditingWithoutSave' | 'onPublish' | 'onSaveAndExitEditing'>) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const [exitConfirmOpen, setExitConfirmOpen] = useState(false)
|
||||
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<Button variant="primary" onClick={onEdit}>
|
||||
{t('edit')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SaveBeforeLeavingDialog
|
||||
open={exitConfirmOpen}
|
||||
onOpenChange={setExitConfirmOpen}
|
||||
trigger={(
|
||||
<Button
|
||||
disabled={isPublishing}
|
||||
onClick={(event) => {
|
||||
if (!hasDraftChanges) {
|
||||
event.preventDefault()
|
||||
void onExitEditing()
|
||||
return
|
||||
}
|
||||
|
||||
setExitConfirmOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('exitEditing')}
|
||||
</Button>
|
||||
)}
|
||||
disabled={isPublishing}
|
||||
loading={isPublishing}
|
||||
onDiscard={async () => {
|
||||
await onExitEditingWithoutSave()
|
||||
setExitConfirmOpen(false)
|
||||
}}
|
||||
onSave={async () => {
|
||||
await onSaveAndExitEditing()
|
||||
setExitConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isPublishing}
|
||||
disabled={isPublishing}
|
||||
onClick={onPublish}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetHeader = ({
|
||||
snippetId,
|
||||
canDiscardChanges,
|
||||
hasDraftChanges,
|
||||
isEditing,
|
||||
isPublishing,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onExitEditing,
|
||||
onExitEditingWithoutSave,
|
||||
onPublish,
|
||||
onSaveAndExitEditing,
|
||||
}: SnippetHeaderProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const viewHistoryProps = useMemo(() => {
|
||||
return {
|
||||
historyUrl: `/snippets/${snippetId}/workflow-runs`,
|
||||
}
|
||||
}, [snippetId])
|
||||
|
||||
const headerProps: HeaderProps = useMemo(() => {
|
||||
return {
|
||||
normal: {
|
||||
components: {
|
||||
title: isEditing
|
||||
? (hasDraftChanges ? <CancelChanges canDiscardChanges={canDiscardChanges} onCancel={onCancel} /> : <></>)
|
||||
: <ViewOnlyBadge />,
|
||||
left: (
|
||||
<EditActions
|
||||
hasDraftChanges={hasDraftChanges}
|
||||
isEditing={isEditing}
|
||||
isPublishing={isPublishing}
|
||||
onEdit={onEdit}
|
||||
onExitEditing={onExitEditing}
|
||||
onExitEditingWithoutSave={onExitEditingWithoutSave}
|
||||
onPublish={onPublish}
|
||||
onSaveAndExitEditing={onSaveAndExitEditing}
|
||||
/>
|
||||
),
|
||||
},
|
||||
controls: {
|
||||
showEnvButton: false,
|
||||
showGlobalVariableButton: false,
|
||||
},
|
||||
runAndHistoryProps: {
|
||||
showRunButton: true,
|
||||
runButtonText: t('testRunButton'),
|
||||
viewHistoryProps,
|
||||
components: {
|
||||
RunMode,
|
||||
},
|
||||
},
|
||||
},
|
||||
viewHistory: {
|
||||
viewHistoryProps,
|
||||
},
|
||||
}
|
||||
}, [canDiscardChanges, hasDraftChanges, isEditing, isPublishing, onCancel, onEdit, onExitEditing, onExitEditingWithoutSave, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
|
||||
|
||||
return <Header {...headerProps} />
|
||||
}
|
||||
|
||||
export default memo(SnippetHeader)
|
||||
@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type PublisherProps = {
|
||||
isPublishing: boolean
|
||||
onPublish: () => void
|
||||
}
|
||||
|
||||
const Publisher = ({
|
||||
isPublishing,
|
||||
onPublish,
|
||||
}: PublisherProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isPublishing}
|
||||
disabled={isPublishing}
|
||||
onClick={onPublish}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Publisher)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user