Merge remote-tracking branch 'origin/main' into feat/openapi-error-contract

This commit is contained in:
GareArc 2026-06-10 04:17:37 -07:00
commit 0c661ac06b
No known key found for this signature in database
105 changed files with 4716 additions and 1229 deletions

View File

@ -90,10 +90,12 @@ class WorkflowAgentComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model: App, node_id: str):
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
@ -105,10 +107,17 @@ class WorkflowAgentComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
AgentComposerService.get_workflow_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
user_id=current_user_id,
),
)
@ -167,7 +176,7 @@ class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
return dump_response(
@ -181,7 +190,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model()
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, app_model: App):
@ -206,11 +215,13 @@ class AgentAppComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def post(self, app_model: App):
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
@ -221,9 +232,15 @@ class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
user_id=current_user_id,
),
)

View File

@ -2,15 +2,17 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.error import AlreadyActivateError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from models import AccountStatus
from services.account_service import RegisterService
from services.billing_service import BillingService
class ActivateCheckQuery(BaseModel):
@ -120,9 +122,12 @@ class ActivateApi(Resource):
if invitation is None:
raise AlreadyActivateError()
account = invitation["account"]
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
raise AccountInFreezeError()
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
account = invitation["account"]
account.name = args.name
account.interface_language = args.interface_language

View File

@ -27,6 +27,9 @@ from controllers.openapi._models import (
AppDescribeInfo,
AppDescribeQuery,
AppDescribeResponse,
AppDslExportQuery,
AppDslExportResponse,
AppDslImportPayload,
AppInfoResponse,
AppListQuery,
AppListResponse,
@ -66,10 +69,14 @@ from controllers.openapi._models import (
WorkspaceSummaryResponse,
)
from fields.file_fields import FileResponse
from services.app_dsl_service import Import
from services.entities.dsl_entities import CheckDependenciesResult
register_schema_models(
openapi_ns,
AppDescribeQuery,
AppDslImportPayload,
AppDslExportQuery,
AppListQuery,
AppRunRequest,
DeviceCodeRequest,
@ -93,6 +100,9 @@ register_response_schema_models(
AppInfoResponse,
AppDescribeInfo,
AppDescribeResponse,
AppDslExportResponse,
Import,
CheckDependenciesResult,
WorkflowRunData,
AccountPayload,
WorkspacePayload,
@ -121,6 +131,7 @@ register_response_schema_models(
from . import (
_meta,
account,
app_dsl,
app_run,
apps,
apps_permitted_external,
@ -138,6 +149,7 @@ from . import (
__all__ = [
"_meta",
"account",
"app_dsl",
"app_run",
"apps",
"apps_permitted_external",

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
from models.model import AppMode
@ -424,6 +424,45 @@ class TaskStopResponse(BaseModel):
result: Literal["success"]
class AppDslImportPayload(BaseModel):
"""Request body for POST /workspaces/<workspace_id>/apps/imports."""
model_config = ConfigDict(extra="forbid")
mode: Literal["yaml-content", "yaml-url"] = Field(..., description="Import mode: yaml-content or yaml-url")
yaml_content: str | None = Field(None, description="Inline YAML DSL string (required when mode is yaml-content)")
yaml_url: str | None = Field(None, description="Remote URL to fetch YAML from (required when mode is yaml-url)")
name: str | None = Field(None, description="Override the app name from the DSL")
description: str | None = Field(None, description="Override the app description from the DSL")
icon_type: str | None = Field(None)
icon: str | None = Field(None)
icon_background: str | None = Field(None)
app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
@model_validator(mode="after")
def _validate_source_by_mode(self) -> AppDslImportPayload:
if self.mode == "yaml-content" and not self.yaml_content:
raise ValueError("yaml_content is required when mode is 'yaml-content'")
if self.mode == "yaml-url" and not self.yaml_url:
raise ValueError("yaml_url is required when mode is 'yaml-url'")
return self
class AppDslExportQuery(BaseModel):
"""Query parameters for GET /apps/<app_id>/export."""
include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL")
workflow_id: UUIDStr | None = Field(
None, description="Export a specific workflow version instead of the current draft"
)
class AppDslExportResponse(BaseModel):
"""Export DSL response."""
data: str = Field(..., description="DSL YAML string")
class FormSubmitResponse(BaseModel):
"""Empty 200 body for POST /apps/<id>/form/human_input/<token>. `extra='forbid'`
pins `additionalProperties: false` so the generated contract is an exact `{}` rather

View File

@ -0,0 +1,167 @@
from __future__ import annotations
from typing import cast
from flask_restx import Resource
from sqlalchemy.orm import Session
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import Account, App
from models.account import TenantAccountRole
from services.app_dsl_service import AppDslService, Import
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.app import WorkflowNotFoundError
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports")
class AppDslImportApi(Resource):
"""Import a DSL YAML string into the specified workspace.
Use ``mode=yaml-content`` with ``yaml_content`` for inline YAML, or
``mode=yaml-url`` with ``yaml_url`` for a remote URL. Provide ``app_id``
to overwrite an existing workflow or advanced-chat app; omit it to create
a new app.
Returns 202 when the DSL version requires an explicit confirmation step
(major version mismatch). Callers must then POST to the confirm endpoint.
Returns 400 when the import failed due to invalid DSL or a business error.
"""
@auth_router.guard_workspace(
scope=Scope.WORKSPACE_WRITE,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, Import, "Import completed")
@returns(202, Import, "Import pending confirmation")
@returns(400, Import, "Import failed")
@accepts(body=AppDslImportPayload)
def post(self, workspace_id: str, *, auth_data: AuthData, body: AppDslImportPayload):
account = cast(Account, auth_data.caller)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.import_app(
account=account,
import_mode=body.mode,
yaml_content=body.yaml_content,
yaml_url=body.yaml_url,
name=body.name,
description=body.description,
icon_type=body.icon_type,
icon=body.icon,
icon_background=body.icon_background,
app_id=body.app_id,
)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
match result.status:
case ImportStatus.FAILED:
return result, 400
case ImportStatus.PENDING:
return result, 202
case _:
return result, 200
@openapi_ns.route("/workspaces/<string:workspace_id>/apps/imports/<string:import_id>/confirm")
class AppDslImportConfirmApi(Resource):
"""Confirm a pending DSL import identified by ``import_id``.
Required only when the initial import returned 202 (major DSL version
mismatch that requires explicit acknowledgement). The pending state is
stored in Redis for 10 minutes; this call retrieves it and completes the
import under the given workspace.
Returns 400 when the pending data has expired or the import fails.
"""
@auth_router.guard_workspace(
scope=Scope.WORKSPACE_WRITE,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, Import, "Import confirmed")
@returns(400, Import, "Import failed")
def post(self, workspace_id: str, import_id: str, *, auth_data: AuthData):
account = cast(Account, auth_data.caller)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.confirm_import(import_id=import_id, account=account)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
if result.status == ImportStatus.FAILED:
return result, 400
return result, 200
@openapi_ns.route("/apps/<string:app_id>/export")
class AppDslExportApi(Resource):
"""Export an app's current draft configuration as a DSL YAML string.
The auth pipeline resolves the app and its tenant from ``app_id``. Pass
``include_secret=true`` to embed encrypted credential values (e.g. tool
node secrets); omit it to produce a portable, sharable DSL safe to share.
Note: the pipeline enforces ``app.enable_api`` for all ``/apps/<app_id>``
routes in the openapi group. Apps with the service API disabled will
receive a 403; enable the API in the console first if needed.
"""
@auth_router.guard(
scope=Scope.APPS_READ,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@accepts(query=AppDslExportQuery)
@returns(200, AppDslExportResponse, "Export successful")
def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery):
app = cast(App, auth_data.app)
try:
data = AppDslService.export_dsl(
app_model=app,
include_secret=query.include_secret,
workflow_id=query.workflow_id,
)
except WorkflowNotFoundError as exc:
return str(exc), 404
return AppDslExportResponse(data=data), 200
@openapi_ns.route("/apps/<string:app_id>/check-dependencies")
class AppDslCheckDependenciesApi(Resource):
"""Check for leaked plugin dependencies after a DSL import.
Call this after an import that reported ``COMPLETED_WITH_WARNINGS`` to
find which plugin dependencies referenced in the DSL are not yet installed
in the workspace. Returns an empty ``leaked_dependencies`` list when all
dependencies are satisfied.
"""
@auth_router.guard(
scope=Scope.APPS_READ,
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
)
@returns(200, CheckDependenciesResult, "Dependencies checked")
def get(self, app_id: str, *, auth_data: AuthData):
app = cast(App, auth_data.app)
with Session(db.engine, expire_on_commit=False) as session:
service = AppDslService(session)
result = service.check_dependencies(app_model=app)
return result, 200

View File

@ -35,6 +35,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
from models.agent_config_entities import AgentSoulConfig
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
class AgentAppRuntimeRequestBuildError(ValueError):
@ -135,7 +136,12 @@ class AgentAppRuntimeRequestBuilder:
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
agent_mode="agent_app",
),
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
# ENG-616: expand slash-menu mention tokens to canonical names so
# no frontend-internal {{#…#}} marker ever reaches the model.
agent_soul_prompt=expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
or None,
user_prompt=context.user_query,
tools=tools_layer,
include_shell=dify_config.AGENT_SHELL_ENABLED,

View File

@ -0,0 +1,86 @@
"""Draft-workflow graph topology helper, shared by Agent v2 publish validation
and the agent-composer candidates endpoint (ENG-615).
Extracted from ``core/workflow/nodes/agent_v2/validators.py`` so both call sites
parse the same ``Workflow.graph`` JSON shape (``nodes`` with string ids,
``edges`` with ``source``/``target``).
"""
from __future__ import annotations
from collections import defaultdict, deque
from collections.abc import Mapping, Sequence
from typing import Any
class WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
def upstream_node_ids(self, target_node_id: str) -> set[str]:
"""All graph nodes reachable upstream of ``target_node_id`` (excluding it).
Edges may reference ids missing from ``nodes`` (half-deleted graphs);
only real nodes are returned.
"""
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
visited.discard(target_node_id)
return visited & self._node_ids
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids
__all__ = ["WorkflowGraphTopology"]

View File

@ -45,6 +45,11 @@ from models.agent_config_entities import (
effective_declared_outputs as _effective_declared_outputs,
)
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import (
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
)
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
@ -129,7 +134,16 @@ class WorkflowAgentRuntimeRequestBuilder:
metadata = self._build_metadata(context, agent_soul, node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
# ENG-616: expand slash-menu mention tokens into model-readable names.
# node_output mentions expand to their reference name only — the value
# stays in the Workflow context block (user_prompt) below.
workflow_job_prompt = (
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
or "Run this workflow Agent Node for the current run."
)
soul_prompt = expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
@ -187,7 +201,7 @@ class WorkflowAgentRuntimeRequestBuilder:
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
),
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
agent_soul_prompt=soul_prompt or None,
workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),

View File

@ -1,12 +1,12 @@
from __future__ import annotations
from collections import defaultdict, deque
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import Iterator, Mapping
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent_config_entities import (
@ -523,54 +523,6 @@ class WorkflowAgentNodeValidator:
)
class _WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids
# Extracted to core/workflow/graph_topology.py (shared with the agent-composer
# candidates endpoint, ENG-615); kept as a private alias for existing call sites.
_WorkflowGraphTopology = WorkflowGraphTopology

View File

@ -1,4 +1,4 @@
from typing import Literal
from typing import Annotated, Literal
from pydantic import Field
@ -14,6 +14,7 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
@ -154,6 +155,7 @@ class WorkflowAgentComposerResponse(ResponseModel):
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
@ -165,11 +167,32 @@ class AgentAppComposerResponse(ResponseModel):
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
validation: "ComposerValidationFindingsResponse | None" = None
class ComposerValidationWarningResponse(ResponseModel):
code: str
surface: str | None = None
kind: str | None = None
id: str | None = None
message: str | None = None
class ComposerKnowledgePlaceholderResponse(ResponseModel):
id: str
placeholder_name: str
class ComposerValidationFindingsResponse(ResponseModel):
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerDifyToolCandidateResponse(ResponseModel):
@ -181,6 +204,20 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
plugin_id: str | None = None
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
kind: Literal["skill"] = "skill"
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
kind: Literal["file"] = "file"
AgentComposerSkillFileCandidateResponse = Annotated[
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
Field(discriminator="kind"),
]
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
@ -188,7 +225,7 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[AgentSkillRefConfig] = Field(default_factory=list)
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
@ -204,3 +241,4 @@ class AgentComposerCandidatesResponse(ResponseModel):
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
truncated: bool = False

View File

@ -11808,6 +11808,7 @@ Get banner list
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
#### AgentAppFeaturesPayload
@ -11903,6 +11904,7 @@ Risk marker for CLI tool bootstrap commands.
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
| truncated | boolean | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerDifyToolCandidateResponse
@ -11916,6 +11918,22 @@ Risk marker for CLI tool bootstrap commands.
| provider | string | | No |
| provider_id | string | | No |
#### AgentComposerFileCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_id | string | | No |
| id | string | | No |
| kind | string | | No |
| name | string | | No |
| reference | string | | No |
| remote_url | string | | No |
| tenant_id | string | | No |
| transfer_method | string | | No |
| type | string | | No |
| upload_file_id | string | | No |
| url | string | | No |
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
@ -11940,6 +11958,17 @@ Risk marker for CLI tool bootstrap commands.
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
#### AgentComposerSkillCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| id | string | | No |
| kind | string | | No |
| name | string | | No |
| path | string | | No |
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
@ -11948,7 +11977,7 @@ Risk marker for CLI tool bootstrap commands.
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| skills_files | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
| skills_files | [ ] | | No |
#### AgentComposerSoulLockResponse
@ -11963,7 +11992,9 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| result | string | | Yes |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigRevisionOperation
@ -13286,6 +13317,13 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
#### ComposerKnowledgePlaceholderResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
| placeholder_name | string | | Yes |
#### ComposerSavePayload
| Name | Type | Description | Required |
@ -13314,6 +13352,23 @@ Button styles for user actions.
| locked | boolean | | No |
| unlocked_from_version_id | string | | No |
#### ComposerValidationFindingsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### ComposerValidationWarningResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | string | | Yes |
| id | string | | No |
| kind | string | | No |
| message | string | | No |
| surface | string | | No |
#### ComposerVariant
| Name | Type | Description | Required |
@ -17571,6 +17626,7 @@ How a workflow node is bound to an Agent.
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
| workflow_id | string | | No |

View File

@ -103,6 +103,21 @@ User-scoped operations
| ---- | ----------- | ------ |
| 200 | App list | [AppListResponse](#applistresponse) |
### /apps/{app_id}/check-dependencies
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
### /apps/{app_id}/describe
#### GET
@ -119,6 +134,23 @@ User-scoped operations
| ---- | ----------- | ------ |
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
### /apps/{app_id}/export
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string |
| include_secret | query | Include encrypted secret values in the exported DSL | No | boolean |
| workflow_id | query | Export a specific workflow version instead of the current draft | No | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
### /apps/{app_id}/files/upload
#### POST
@ -338,6 +370,41 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
### /workspaces/{workspace_id}/apps/imports
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [AppDslImportPayload](#appdslimportpayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Import completed | [Import](#import) |
| 202 | Import pending confirmation | [Import](#import) |
| 400 | Import failed | [Import](#import) |
### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| import_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Import confirmed | [Import](#import) |
| 400 | Import failed | [Import](#import) |
### /workspaces/{workspace_id}/members
#### GET
@ -471,6 +538,39 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
| input_schema | object | | No |
| parameters | object | | No |
#### AppDslExportQuery
Query parameters for GET /apps/<app_id>/export.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| include_secret | boolean | Include encrypted secret values in the exported DSL | No |
| workflow_id | string | Export a specific workflow version instead of the current draft | No |
#### AppDslExportResponse
Export DSL response.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | string | DSL YAML string | Yes |
#### AppDslImportPayload
Request body for POST /workspaces/<workspace_id>/apps/imports.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | Existing app ID to overwrite (workflow/advanced-chat apps only) | No |
| description | string | Override the app description from the DSL | No |
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| mode | string | Import mode: yaml-content or yaml-url<br>*Enum:* `"yaml-content"`, `"yaml-url"` | Yes |
| name | string | Override the app name from the DSL | No |
| yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No |
| yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No |
#### AppInfoResponse
| Name | Type | Description | Required |
@ -537,6 +637,12 @@ mode is a closed enum.
| workflow_id | string | | No |
| workspace_id | string | | No |
#### CheckDependenciesResult
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No |
#### DeviceCodeRequest
| Name | Type | Description | Required |
@ -616,6 +722,15 @@ than an under-annotated open object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
#### Github
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| github_plugin_unique_identifier | string | | Yes |
| package | string | | Yes |
| repo | string | | Yes |
| version | string | | Yes |
#### HealthResponse
Liveness payload for `GET /openapi/v1/_health` — no auth required.
@ -631,12 +746,37 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required.
| action | string | | Yes |
| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes |
#### Import
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | No |
| app_mode | string | | No |
| current_dsl_version | string | | No |
| error | string | | No |
| id | string | | Yes |
| imported_dsl_version | string | | No |
| status | [ImportStatus](#importstatus) | | Yes |
#### ImportStatus
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ImportStatus | string | | |
#### JsonValue
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| JsonValue | | | |
#### Marketplace
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| marketplace_plugin_unique_identifier | string | | Yes |
| version | string | | No |
#### MemberActionResponse
| Name | Type | Description | Required |
@ -704,6 +844,13 @@ Strict (extra='forbid').
| retriever_resources | [ object ] | | No |
| usage | [UsageInfo](#usageinfo) | | No |
#### Package
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| plugin_unique_identifier | string | | Yes |
| version | string | | No |
#### PermittedExternalAppsListQuery
Strict (extra='forbid').
@ -725,6 +872,14 @@ Strict (extra='forbid').
| page | integer | | Yes |
| total | integer | | Yes |
#### PluginDependency
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| current_identifier | string | | No |
| type | [Type](#type) | | Yes |
| value | [Github](#github)<br>[Marketplace](#marketplace)<br>[Package](#package) | | Yes |
#### RevokeResponse
| Name | Type | Description | Required |
@ -787,6 +942,12 @@ types it as a required `'success'` rather than an optional field.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### Type
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| Type | string | | |
#### UsageInfo
| Name | Type | Description | Required |

View File

@ -0,0 +1,210 @@
"""Slash-menu candidates assembly (ENG-615).
Pure assembly over injected loaders so the upstream-graph computation and the
per-source mapping are unit-testable without a database. IO wiring (draft
workflow / bindings / draft variables / datasets / workspace tools) lives in
``AgentComposerService.get_*_candidates``.
``previous_node_outputs`` entries are emitted in the stored
``WorkflowPreviousNodeOutputRef`` shape (``selector``/``node_id``/``output``/
``name``) so the frontend can write a selected candidate back into
``node_job.previous_node_output_refs`` verbatim; display extras
(``node_title``/``node_kind``/``value_type``/``inferred``) ride along via the
flexible config schema. Output enumeration follows the Node Output Inspector:
start variables + recorded ``sys.*`` variables are static, Agent v2 nodes use
their binding's declared outputs, and every other node kind is inferred from
the latest draft-run variables (``inferred: true``).
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
)
MAX_CANDIDATES_PER_LIST = 200
_SYSTEM_NODE_ID = "sys"
# loader signatures injected by the service layer
DeclaredOutputsLoader = Callable[[str], list[DeclaredOutputConfig] | None]
DraftVariablesLoader = Callable[[str], list[tuple[str, str | None]]]
SystemVariablesLoader = Callable[[], list[tuple[str, str | None]]]
DatasetLookup = Callable[[list[str]], Mapping[str, Any]]
WorkspaceToolsLoader = Callable[[], list[dict[str, Any]]]
def previous_node_output_candidates(
*,
graph: Mapping[str, Any],
node_id: str,
declared_outputs_loader: DeclaredOutputsLoader,
draft_variables_loader: DraftVariablesLoader,
system_variables_loader: SystemVariablesLoader,
) -> tuple[list[dict[str, Any]], bool]:
"""Enumerate upstream node outputs for ``node_id`` as writable ref candidates."""
from core.workflow.graph_topology import WorkflowGraphTopology
topology = WorkflowGraphTopology.from_graph(graph)
upstream = topology.upstream_node_ids(node_id)
entries: list[dict[str, Any]] = []
for name, value_type in system_variables_loader():
entries.append(
_ref_entry(
node_id=_SYSTEM_NODE_ID,
output=name,
node_title="System",
node_kind="system",
value_type=value_type,
inferred=True,
)
)
nodes = graph.get("nodes")
for node in nodes if isinstance(nodes, list) else []:
if not isinstance(node, Mapping):
continue
nid = node.get("id")
if not isinstance(nid, str) or nid not in upstream:
continue
raw_data = node.get("data")
data: Mapping[str, Any] = raw_data if isinstance(raw_data, Mapping) else {}
kind = str(data.get("type") or "unknown")
title = str(data.get("title") or nid)
if kind == "start":
for variable in data.get("variables") or []:
if not isinstance(variable, Mapping):
continue
var_name = variable.get("variable")
if isinstance(var_name, str) and var_name:
entries.append(
_ref_entry(
node_id=nid,
output=var_name,
node_title=title,
node_kind=kind,
value_type=variable.get("type") if isinstance(variable.get("type"), str) else None,
inferred=False,
)
)
continue
declared: list[DeclaredOutputConfig] | None = None
if kind == "agent" and str(data.get("version", "")) == "2":
declared = declared_outputs_loader(nid)
if declared is not None:
for output in declared:
entries.append(
_ref_entry(
node_id=nid,
output=output.name,
node_title=title,
node_kind=kind,
value_type=output.type.value,
inferred=False,
)
)
continue
for var_name, value_type in draft_variables_loader(nid):
entries.append(
_ref_entry(
node_id=nid,
output=var_name,
node_title=title,
node_kind=kind,
value_type=value_type,
inferred=True,
)
)
return _capped(entries)
def soul_candidates(
*,
agent_soul: AgentSoulConfig | None,
dataset_lookup: DatasetLookup,
workspace_tools_loader: WorkspaceToolsLoader,
) -> tuple[dict[str, list[dict[str, Any]]], bool]:
"""Assemble the soul-surface candidate lists (design §3.2)."""
soul = agent_soul or AgentSoulConfig()
truncated = False
skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
knowledge_datasets: list[dict[str, Any]] = []
for dataset in soul.knowledge.datasets:
if not dataset.id:
continue
row = dataset_rows.get(dataset.id)
knowledge_datasets.append(
{
"id": dataset.id,
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
}
)
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
dify_tools = workspace_tools_loader()
lists = {
"skills_files": skills_files,
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,
"human_contacts": human_contacts,
}
capped: dict[str, list[dict[str, Any]]] = {}
for key, values in lists.items():
clipped, was_clipped = _capped(values)
truncated = truncated or was_clipped
capped[key] = clipped
return capped, truncated
def _ref_entry(
*,
node_id: str,
output: str,
node_title: str,
node_kind: str,
value_type: str | None,
inferred: bool,
) -> dict[str, Any]:
return {
"selector": [node_id, output],
"node_id": node_id,
"output": output,
"name": f"{node_title}/{output}",
"node_title": node_title,
"node_kind": node_kind,
"value_type": value_type,
"inferred": inferred,
}
def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
if len(values) > MAX_CANDIDATES_PER_LIST:
return values[:MAX_CANDIDATES_PER_LIST], True
return values, False
__all__ = [
"MAX_CANDIDATES_PER_LIST",
"previous_node_output_candidates",
"soul_candidates",
]

View File

@ -1,3 +1,4 @@
import logging
from typing import Any
from sqlalchemy import func, select
@ -39,6 +40,8 @@ from services.entities.agent_entities import (
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
_DRAFT_WORKFLOW_VERSION = "draft"
logger = logging.getLogger(__name__)
class AgentComposerService:
@classmethod
@ -108,7 +111,9 @@ class AgentComposerService:
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return state
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
@ -205,42 +210,241 @@ class AgentComposerService:
agent.updated_by = account_id
db.session.commit()
return cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return state
@classmethod
def get_workflow_candidates(cls, *, app_id: str) -> dict[str, Any]:
def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
mentioned_ids: set[str] = set()
if payload.agent_soul is not None:
mentioned_ids |= {
mention.ref_id
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
if mention.kind == MentionKind.KNOWLEDGE
}
existing_dataset_ids: set[str] | None = None
if mentioned_ids:
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
@classmethod
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the workflow Agent node composer (ENG-615)."""
from services.agent.composer_candidates import previous_node_output_candidates, soul_candidates
try:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
except ValueError:
workflow = None
node_job: WorkflowNodeJobConfig | None = None
agent_soul: AgentSoulConfig | None = None
if workflow is not None:
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
if binding is not None:
node_job = cls._parse_node_job(binding)
agent_soul = cls._load_binding_soul(tenant_id=tenant_id, binding=binding)
truncated = False
previous_outputs: list[dict[str, Any]] = []
if workflow is not None:
draft_variable_session = cls._draft_variable_session()
try:
previous_outputs, outputs_truncated = previous_node_output_candidates(
graph=workflow.graph_dict,
node_id=node_id,
declared_outputs_loader=lambda nid: cls._binding_declared_outputs(
tenant_id=tenant_id, workflow_id=workflow.id, node_id=nid
),
draft_variables_loader=lambda nid: cls._draft_node_variables(
session=draft_variable_session, app_id=app_id, node_id=nid, user_id=user_id
),
system_variables_loader=lambda: cls._draft_system_variables(
session=draft_variable_session, app_id=app_id, user_id=user_id
),
)
finally:
draft_variable_session.close()
truncated = truncated or outputs_truncated
soul_lists, soul_truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
truncated = truncated or soul_truncated
response = ComposerCandidatesResponse(
variant=ComposerVariant.WORKFLOW,
allowed_node_job_candidates={
"previous_node_outputs": [],
"previous_node_outputs": previous_outputs,
"declare_output_types": ["string", "number", "object", "array", "boolean", "file"],
"human_contacts": [],
},
allowed_soul_candidates={
"skills_files": [],
"dify_tools": [],
"cli_tools": [],
"knowledge_datasets": [],
"human_contacts": [],
"human_contacts": [
contact.model_dump(exclude_none=True) for contact in (node_job.human_contacts if node_job else [])
],
},
allowed_soul_candidates=soul_lists,
truncated=truncated,
)
return response.model_dump(mode="json")
@classmethod
def get_agent_app_candidates(cls, *, app_id: str) -> dict[str, Any]:
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
from services.agent.composer_candidates import soul_candidates
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
soul_lists, truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
response = ComposerCandidatesResponse(
variant=ComposerVariant.AGENT_APP,
allowed_node_job_candidates={},
allowed_soul_candidates={
"skills_files": [],
"dify_tools": [],
"cli_tools": [],
"knowledge_datasets": [],
"human_contacts": [],
},
allowed_soul_candidates=soul_lists,
truncated=truncated,
)
return response.model_dump(mode="json")
# ── candidates IO helpers (ENG-615) ──────────────────────────────────────
@staticmethod
def _parse_node_job(binding: WorkflowAgentNodeBinding) -> WorkflowNodeJobConfig | None:
try:
return WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
except Exception:
logger.warning("candidates: malformed node_job_config for binding %s", binding.id, exc_info=True)
return None
@classmethod
def _load_binding_soul(cls, *, tenant_id: str, binding: WorkflowAgentNodeBinding) -> AgentSoulConfig | None:
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._parse_soul_snapshot(version)
@classmethod
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
if agent is None:
return None
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return cls._parse_soul_snapshot(version)
@staticmethod
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
if version is None:
return None
try:
return AgentSoulConfig.model_validate(version.config_snapshot_dict)
except Exception:
logger.warning("candidates: malformed soul snapshot %s", version.id, exc_info=True)
return None
@classmethod
def _binding_declared_outputs(
cls, *, tenant_id: str, workflow_id: str, node_id: str
) -> list[DeclaredOutputConfig] | None:
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow_id, node_id=node_id)
if binding is None:
return None
node_job = cls._parse_node_job(binding)
if node_job is None:
return None
return list(_effective_declared_outputs(node_job.declared_outputs))
@staticmethod
def _draft_variable_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=db.engine, expire_on_commit=False)()
@staticmethod
def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
from services.workflow_draft_variable_service import WorkflowDraftVariableService
variables = WorkflowDraftVariableService(session=session).list_node_variables(app_id, node_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _draft_system_variables(*, session: Any, app_id: str, user_id: str) -> list[tuple[str, str | None]]:
from services.workflow_draft_variable_service import WorkflowDraftVariableService
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Tenant-scoped dataset lookup tolerating malformed ids.
Mention ids come from user-editable prompt text; a non-UUID id can never
match a dataset row, so it is simply absent from the result (-> missing/
placeholder semantics) instead of breaking the UUID-typed query.
"""
from uuid import UUID
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (ValueError, TypeError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
@staticmethod
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
"""Workspace Dify Plugin tools, same source as the tool selector.
A plugin-daemon outage must degrade the slash menu to an empty tools
tab, not break the whole candidates endpoint.
"""
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
try:
providers = BuiltinToolManageService.list_builtin_tools(user_id, tenant_id)
except Exception:
logger.warning("candidates: failed to list workspace tools for tenant %s", tenant_id, exc_info=True)
return []
tools: list[dict[str, Any]] = []
for provider in providers:
for tool in provider.tools or []:
tools.append(
{
"id": f"{provider.name}/{tool.name}",
"name": tool.name,
"description": tool.label.en_US if tool.label else tool.name,
"provider": provider.name,
"plugin_id": provider.plugin_id or None,
}
)
return tools
@classmethod
def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]:
bindings = list(

View File

@ -4,6 +4,17 @@ from typing import Any
from pydantic import ValidationError
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
from services.agent.prompt_mentions import (
MAX_MENTIONS_PER_PROMPT,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
MentionResolver,
build_node_job_mention_resolver,
build_soul_mention_resolver,
find_malformed_mention_markers,
parse_prompt_mentions,
)
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerSavePayload,
@ -46,6 +57,158 @@ class ComposerConfigValidator:
cls.validate_agent_soul(payload.agent_soul)
if payload.node_job is not None:
cls.validate_node_job(payload.node_job)
cls._validate_prompt_mentions(payload)
@classmethod
def _validate_prompt_mentions(cls, payload: ComposerSavePayload) -> None:
"""ENG-616 §2.4 allowlists + ENG-617 §5.2 human-must-be-referenced.
Error messages start with a stable code token (``mention_kind_not_allowed``
/ ``mention_limit_exceeded`` / ``human_involvement_not_referenced``) so
the frontend can switch on it.
"""
if payload.agent_soul is not None:
cls._validate_surface_mentions(
prompt=payload.agent_soul.prompt.system_prompt,
allowed=SOUL_PROMPT_ALLOWED_KINDS,
surface="agent soul prompt",
)
cls._require_human_mentions(
prompt=payload.agent_soul.prompt.system_prompt,
contacts=payload.agent_soul.human.contacts,
surface="agent soul prompt",
)
if payload.node_job is not None:
cls._validate_surface_mentions(
prompt=payload.node_job.workflow_prompt,
allowed=NODE_JOB_PROMPT_ALLOWED_KINDS,
surface="workflow job prompt",
)
cls._require_human_mentions(
prompt=payload.node_job.workflow_prompt,
contacts=payload.node_job.human_contacts,
surface="workflow job prompt",
)
@classmethod
def _validate_surface_mentions(cls, *, prompt: str, allowed: frozenset[MentionKind], surface: str) -> None:
mentions = parse_prompt_mentions(prompt)
if len(mentions) > MAX_MENTIONS_PER_PROMPT:
raise InvalidComposerConfigError(
f"mention_limit_exceeded: {surface} has {len(mentions)} mentions, "
f"exceeding the limit of {MAX_MENTIONS_PER_PROMPT}."
)
for mention in mentions:
if mention.kind not in allowed:
raise InvalidComposerConfigError(
f"mention_kind_not_allowed: {surface} cannot reference {mention.kind.value} (id={mention.ref_id})."
)
@classmethod
def _require_human_mentions(cls, *, prompt: str, contacts: list[Any], surface: str) -> None:
"""ENG-617 §5.2 (PRD: human involvement must be slash-referenced or save errors).
Every configured human contact must appear as ``{{#human:<id>#}}`` in the
corresponding prompt. A contact matches via any identity alias; contacts
carrying no identity at all cannot be referenced and are skipped.
"""
if not contacts:
return
mentioned = {mention.ref_id for mention in parse_prompt_mentions(prompt) if mention.kind == MentionKind.HUMAN}
for contact in contacts:
aliases = {
alias
for alias in (contact.id, contact.contact_id, contact.human_id, contact.email, contact.name)
if alias
}
if not aliases:
continue
if aliases.isdisjoint(mentioned):
display = contact.name or contact.email or contact.id or "human involvement"
raise InvalidComposerConfigError(
f"human_involvement_not_referenced: configured human involvement '{display}' "
f"must be referenced in the {surface} via the slash menu."
)
@classmethod
def collect_soft_findings(
cls,
payload: ComposerSavePayload,
*,
existing_dataset_ids: set[str] | None = None,
) -> dict[str, Any]:
"""ENG-617 §5.3/§5.4 soft findings — never block save.
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets surface as placeholders too.
"""
warnings: list[dict[str, Any]] = []
placeholders: list[dict[str, str]] = []
surfaces: list[tuple[str, str, MentionResolver, frozenset[MentionKind]]] = []
if payload.agent_soul is not None:
surfaces.append(
(
"agent_soul",
payload.agent_soul.prompt.system_prompt,
build_soul_mention_resolver(payload.agent_soul),
SOUL_PROMPT_ALLOWED_KINDS,
)
)
if payload.node_job is not None:
surfaces.append(
(
"node_job",
payload.node_job.workflow_prompt,
build_node_job_mention_resolver(payload.node_job),
NODE_JOB_PROMPT_ALLOWED_KINDS,
)
)
for surface, prompt, resolver, allowed in surfaces:
for mention in parse_prompt_mentions(prompt):
if mention.kind not in allowed:
continue # hard-rejected by validate_save_payload
resolved = resolver(mention)
if mention.kind == MentionKind.KNOWLEDGE:
dangling = resolved is None or (
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
)
if dangling:
placeholders.append(
{
"id": mention.ref_id,
"placeholder_name": mention.label or f"Knowledge {mention.ref_id[:8]}",
}
)
continue
if resolved is None:
warnings.append(
{
"code": "mention_target_missing",
"surface": surface,
"kind": mention.kind.value,
"id": mention.ref_id,
"message": f"{mention.kind.value} mention (id={mention.ref_id}) does not match "
"any configured item.",
}
)
for marker in find_malformed_mention_markers(prompt):
warnings.append(
{
"code": "mention_malformed",
"surface": surface,
"kind": None,
"id": None,
"message": f"mention-shaped marker {marker!r} is malformed and will be "
"degraded to plain text at runtime.",
}
)
return {"warnings": warnings, "knowledge_retrieval_placeholder": placeholders}
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:

View File

@ -0,0 +1,264 @@
"""Prompt mention (slash-reference) serialization contract — ENG-616.
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
[§<kind>:<id>[:<label>]§]
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent config
lists (mentions are pointers the entity itself lives in ``skills_files`` /
``tools`` / ``knowledge.datasets`` / ``human.contacts`` /
``previous_node_output_refs`` / ``declared_outputs``); ``label`` is an optional
plain-text fallback only (the backend always re-resolves by id, so renames never
break references). A single ``:`` separates all three fields; ``label`` is the
trailing remainder and may itself contain ``:``.
The ``[§§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
so these tokens can never collide with the existing template parsers. Runtime
expansion (and the final scrub that guarantees no internal marker ever reaches
the model) is owned by the run-request builders.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from models.agent_config_entities import (
AgentHumanContactConfig,
AgentSoulConfig,
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
class MentionKind(StrEnum):
SKILL = "skill"
FILE = "file"
TOOL = "tool"
CLI_TOOL = "cli_tool"
KNOWLEDGE = "knowledge"
HUMAN = "human"
NODE_OUTPUT = "node_output"
OUTPUT = "output"
MENTION_PATTERN = re.compile(
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
)
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
# ordinary bracketed text out of scope.
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
MAX_MENTIONS_PER_PROMPT = 200
MAX_MENTION_FIELD_LENGTH = 255
# Per-surface allowlists (design §2.4): the soul prompt may only reference
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
{
MentionKind.SKILL,
MentionKind.FILE,
MentionKind.TOOL,
MentionKind.CLI_TOOL,
MentionKind.KNOWLEDGE,
MentionKind.HUMAN,
}
)
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
@dataclass(frozen=True, slots=True)
class PromptMention:
kind: MentionKind
ref_id: str
label: str | None
start: int
end: int
raw: str
# Returns the model-readable replacement for a mention, or None when the id does
# not resolve (the expander then degrades to label/id).
MentionResolver = Callable[[PromptMention], str | None]
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
(treated as malformed) the runtime scrub still degrades them safely."""
mentions: list[PromptMention] = []
for match in MENTION_PATTERN.finditer(prompt or ""):
ref_id = match.group(2)
label = match.group(3)
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
continue
mentions.append(
PromptMention(
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label or None,
start=match.start(),
end=match.end(),
raw=match.group(0),
)
)
return mentions
def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
"""Replace every mention with resolver output, degrading unresolved ones to
their label (then id), and scrub any residual mention-shaped marker so no
frontend-internal token ever reaches the model."""
if not prompt:
return prompt
def _replace(match: re.Match[str]) -> str:
ref_id = match.group(2)
label = match.group(3) or None
fallback = (label or ref_id)[:MAX_MENTION_FIELD_LENGTH]
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
return fallback
mention = PromptMention(
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label,
start=match.start(),
end=match.end(),
raw=match.group(0),
)
resolved = resolver(mention)
if resolved is None or not resolved.strip():
return fallback
return resolved[:MAX_MENTION_FIELD_LENGTH]
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
def find_malformed_mention_markers(prompt: str) -> list[str]:
"""Mention-shaped markers the strict grammar does not accept (unknown kind,
oversized id/label, broken body). Soft-flagged at validate; the runtime
scrub still degrades them safely."""
if not prompt:
return []
parsed_spans = {(mention.start, mention.end) for mention in parse_prompt_mentions(prompt)}
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
def scrub_mention_markers(text: str) -> str:
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
def _degrade(match: re.Match[str]) -> str:
# inner is ``kind:id[:label]``; prefer the label, else the id.
parts = match.group(1).split(":", 2)
if len(parts) >= 3 and parts[2].strip():
return parts[2].strip()[:MAX_MENTION_FIELD_LENGTH]
if len(parts) >= 2 and parts[1].strip():
return parts[1].strip()[:MAX_MENTION_FIELD_LENGTH]
return match.group(1)[:MAX_MENTION_FIELD_LENGTH]
return _RESIDUAL_MENTION_PATTERN.sub(_degrade, text)
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
"""Resolve soul-surface mentions to canonical display names from the soul config."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.SKILL:
for skill in agent_soul.skills_files.skills:
if mention.ref_id in (skill.id, skill.name):
return skill.name or skill.id
case MentionKind.FILE:
for file in agent_soul.skills_files.files:
if mention.ref_id in (file.id, file.name):
return file.name or file.id
case MentionKind.TOOL:
for tool in agent_soul.tools.dify_tools:
aliases = {tool.tool_name} | {
f"{prefix}/{tool.tool_name}"
for prefix in (tool.provider, tool.provider_id, tool.plugin_id)
if prefix
}
if mention.ref_id in aliases:
return tool.name or tool.tool_name
case MentionKind.CLI_TOOL:
for cli_tool in agent_soul.tools.cli_tools:
if cli_tool.name and mention.ref_id == cli_tool.name:
return cli_tool.name
case MentionKind.KNOWLEDGE:
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:
return None
return None
return _resolve
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
"""Resolve job-surface mentions. ``node_output`` expands to the stored
reference name only values stay in the Workflow context block (design §4.2)."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.NODE_OUTPUT:
for ref in node_job.previous_node_output_refs:
selector = _selector_from_ref(ref)
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
return ref.name or mention.label or mention.ref_id
case MentionKind.OUTPUT:
for output in node_job.declared_outputs:
if output.name == mention.ref_id:
return f"{output.name} ({output.type.value})"
case MentionKind.HUMAN:
return _resolve_human_contact(node_job.human_contacts, mention.ref_id)
case _:
return None
return None
return _resolve
def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str) -> str | None:
for contact in contacts:
if ref_id in (contact.id, contact.contact_id, contact.human_id):
channel = contact.channel or contact.method or contact.contact_method
who = contact.name or contact.email or ref_id
return f"{channel.upper()} · {who}" if channel else who
return None
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
if isinstance(candidate, list) and len(candidate) >= 2:
return str(candidate[0]), str(candidate[1])
if ref.node_id:
output = ref.output or ref.variable or ref.key
if output:
return ref.node_id, output
return None
__all__ = [
"MAX_MENTIONS_PER_PROMPT",
"MAX_MENTION_FIELD_LENGTH",
"MENTION_PATTERN",
"NODE_JOB_PROMPT_ALLOWED_KINDS",
"SOUL_PROMPT_ALLOWED_KINDS",
"MentionKind",
"MentionResolver",
"PromptMention",
"build_node_job_mention_resolver",
"build_soul_mention_resolver",
"expand_prompt_mentions",
"find_malformed_mention_markers",
"parse_prompt_mentions",
"scrub_mention_markers",
]

View File

@ -41,6 +41,7 @@ from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.workflow import Workflow
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.errors.app import WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
@ -557,7 +558,7 @@ class AppDslService:
workflow_service = WorkflowService()
workflow = workflow_service.get_draft_workflow(app_model, workflow_id)
if not workflow:
raise ValueError("Missing draft workflow configuration, please check.")
raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
workflow_dict = workflow.to_dict(include_secret=include_secret)
# TODO: refactor: we need a better way to filter workspace related data from nodes

View File

@ -91,3 +91,5 @@ class ComposerCandidatesResponse(BaseModel):
allowed_node_job_candidates: dict[str, Any] = Field(default_factory=dict)
allowed_soul_candidates: dict[str, Any] = Field(default_factory=dict)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
# True when any candidate list was clipped to the per-list cap (ENG-615 §3.3).
truncated: bool = False

View File

@ -38,6 +38,7 @@ from services.app_dsl_service import (
)
from services.app_service import AppService, CreateAppParams
from services.dsl_version import check_version_compatibility
from services.errors.app import WorkflowNotFoundError
from tests.test_containers_integration_tests.helpers import generate_valid_password
_DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001"
@ -1027,7 +1028,7 @@ class TestAppDslService:
mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None
with pytest.raises(
ValueError,
WorkflowNotFoundError,
match="Missing draft workflow configuration, please check.",
):
AppDslService.export_dsl(app, include_secret=False, workflow_id=str(uuid4()))
@ -1139,7 +1140,7 @@ class TestAppDslService:
workflow_service.get_draft_workflow.return_value = None
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
with pytest.raises(ValueError, match="Missing draft workflow configuration"):
with pytest.raises(WorkflowNotFoundError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=_app_stub(),

View File

@ -24,6 +24,7 @@ from controllers.console.agent.roster import (
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
)
from models.model import AppMode
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
@ -111,6 +112,22 @@ def _candidates_response(variant: str) -> dict:
}
def _get_app_model_modes(view) -> list[AppMode]:
current = view
while current is not None:
closure = getattr(current, "__closure__", None)
if closure is not None:
for cell in closure:
try:
value = cell.cell_contents
except ValueError:
continue
if isinstance(value, list) and all(isinstance(item, AppMode) for item in value):
return value
current = getattr(current, "__wrapped__", None)
return []
class _PayloadWithDescription(Protocol):
description: object
@ -289,12 +306,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
)
assert saved_state["save_options"] == ["node_job_only"]
assert unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
WorkflowAgentComposerValidateApi(), "tenant-1", app_model, "node-1"
) == {"result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": []}
assert (
unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), "tenant-1", account_id, app_model, "node-1"
)["variant"]
== "workflow"
)
with app.test_request_context(json=payload):
@ -349,9 +366,20 @@ def test_agent_app_composer_get_put_validate_and_candidates(
unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"]
== "agent_app"
)
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == {
"result": "success",
"errors": [],
"warnings": [],
"knowledge_retrieval_placeholder": [],
}
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(
AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model
)
assert agent_app_candidates["variant"] == "agent_app"
def test_agent_app_composer_routes_are_agent_mode_only() -> None:
assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT]

View File

@ -14,7 +14,7 @@ import pytest
from flask import Flask
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
from controllers.console.error import AlreadyActivateError
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from models.account import AccountStatus
@ -255,6 +255,47 @@ class TestActivateApi:
with pytest.raises(AlreadyActivateError):
api.post()
@patch("controllers.console.auth.activate.dify_config.BILLING_ENABLED", True)
@patch("controllers.console.auth.activate.BillingService.is_email_in_freeze")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_rejects_account_in_billing_freeze(
self,
mock_db,
mock_revoke_token,
mock_get_invitation,
mock_is_email_in_freeze,
app: Flask,
mock_invitation,
mock_account,
):
"""Frozen deleted-account emails cannot be reactivated through invitation links."""
mock_account.email = "Invitee@Example.com"
mock_get_invitation.return_value = mock_invitation
mock_is_email_in_freeze.return_value = True
with app.test_request_context(
"/activate",
method="POST",
json={
"workspace_id": "workspace-123",
"email": "invitee@example.com",
"token": "valid_token",
"name": "John Doe",
"interface_language": "en-US",
"timezone": "UTC",
},
):
api = ActivateApi()
with pytest.raises(AccountInFreezeError):
api.post()
mock_is_email_in_freeze.assert_called_once_with("Invitee@Example.com")
mock_revoke_token.assert_not_called()
mock_db.session.commit.assert_not_called()
assert mock_account.status == AccountStatus.PENDING
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")

View File

@ -599,3 +599,40 @@ def test_effective_declared_outputs_passthrough_when_user_declared():
declared = [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
effective = WorkflowAgentRuntimeRequestBuilder.effective_declared_outputs(declared)
assert list(effective) == declared
def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
"""ENG-616: slash-menu mention tokens expand to canonical names; node_output
mentions expand to the reference name only (the value stays in the Workflow
context user prompt), and no ``[§§]`` marker leaks into the request."""
import json
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={"system_prompt": "Careful. Ask [§human:c-1:EMAIL · DAVE§] when unsure."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
human={"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
)
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": (
"Read [§node_output:previous-node.text:PREV/text§] and produce [§output:summary§]. "
"Unknown [§knowledge:gone:旧手册§] degrades."
),
"previous_node_output_refs": [
{"selector": ["previous-node", "text"], "name": "PREV/text"},
],
"declared_outputs": [{"name": "summary", "type": "string"}],
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert dumped["composition"]["layers"][0]["config"]["prefix"] == ("Careful. Ask EMAIL · David Hayes when unsure.")
assert dumped["composition"]["layers"][1]["config"]["prefix"] == (
"Read PREV/text and produce summary (string). Unknown 旧手册 degrades."
)
# the value still rides the Workflow context block, not the job prompt
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert "" not in json.dumps(dumped["composition"]["layers"][:3])

View File

@ -0,0 +1,51 @@
"""Unit tests for the shared workflow graph topology helper (ENG-615)."""
from __future__ import annotations
from core.workflow.graph_topology import WorkflowGraphTopology
_GRAPH = {
"nodes": [
{"id": "start"},
{"id": "llm-1"},
{"id": "llm-2"},
{"id": "agent"},
{"id": "end"},
],
"edges": [
{"source": "start", "target": "llm-1"},
{"source": "start", "target": "llm-2"},
{"source": "llm-1", "target": "agent"},
{"source": "llm-2", "target": "agent"},
{"source": "agent", "target": "end"},
# ghost edge: source node was deleted from nodes[]
{"source": "ghost", "target": "agent"},
],
}
def test_upstream_node_ids_collects_all_ancestors_excluding_ghosts():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.upstream_node_ids("agent") == {"start", "llm-1", "llm-2"}
def test_upstream_node_ids_differ_per_target_node():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.upstream_node_ids("llm-1") == {"start"}
assert topology.upstream_node_ids("end") == {"start", "llm-1", "llm-2", "agent"}
assert topology.upstream_node_ids("start") == set()
def test_is_upstream_kept_for_publish_validation():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.is_upstream(source_node_id="start", target_node_id="end")
assert not topology.is_upstream(source_node_id="end", target_node_id="start")
def test_cycle_safe():
graph = {
"nodes": [{"id": "a"}, {"id": "b"}],
"edges": [{"source": "a", "target": "b"}, {"source": "b", "target": "a"}],
}
topology = WorkflowGraphTopology.from_graph(graph)
assert topology.upstream_node_ids("a") == {"b"}

View File

@ -148,6 +148,7 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"state": "ok"}
assert calls
assert fake_session.commits == 1
@ -189,6 +190,7 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch):
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert fake_session.added[0].name == "Analyst"
assert fake_session.added[0].active_config_snapshot_id == "version-1"
@ -222,6 +224,7 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch):
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert updated["operation"].value == "save_current_version"
assert fake_session._scalar == []
@ -235,12 +238,28 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch):
]
monkeypatch.setattr(composer_service.db, "session", FakeSession(scalars=[bindings]))
workflow_candidates = AgentComposerService.get_workflow_candidates(app_id="app-1")
agent_app_candidates = AgentComposerService.get_agent_app_candidates(app_id="app-1")
# Candidates assembly is covered in test_composer_candidates.py; here we stub
# the IO loaders and assert the response envelope per variant (ENG-615).
def _no_draft_workflow(**kwargs):
raise ValueError("draft workflow not found")
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow)
monkeypatch.setattr(AgentComposerService, "_load_agent_app_soul", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: [])
workflow_candidates = AgentComposerService.get_workflow_candidates(
tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1"
)
agent_app_candidates = AgentComposerService.get_agent_app_candidates(
tenant_id="tenant-1", app_id="app-1", user_id="account-1"
)
impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1")
assert workflow_candidates["variant"] == "workflow"
assert workflow_candidates["allowed_node_job_candidates"]["previous_node_outputs"] == []
assert workflow_candidates["truncated"] is False
assert agent_app_candidates["variant"] == "agent_app"
assert agent_app_candidates["allowed_soul_candidates"]["dify_tools"] == []
assert impact["workflow_node_count"] == 2
assert impact["bindings"][1]["node_id"] == "node-2"
@ -875,3 +894,27 @@ class TestListWorkflowsReferencingAppAgent:
service = AgentRosterService(session)
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
def test_dataset_rows_filters_malformed_ids(monkeypatch):
"""Mention ids are user-editable text: a non-UUID id must read as missing
(placeholder semantics), never reach the UUID-typed dataset query (E2E 500)."""
captured = {}
def fake_get_datasets_by_ids(ids, tenant_id):
captured["ids"] = ids
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
valid = "550e8400-e29b-41d4-a716-446655440000"
rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
assert rows == {}
assert captured["ids"] == [valid]
# all-malformed input never touches the DB
captured.clear()
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
assert captured == {}

View File

@ -0,0 +1,204 @@
"""Unit tests for slash-menu candidates assembly (ENG-615)."""
from __future__ import annotations
from types import SimpleNamespace
from fields.agent_fields import AgentComposerCandidatesResponse
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, DeclaredOutputType
from services.agent.composer_candidates import (
MAX_CANDIDATES_PER_LIST,
previous_node_output_candidates,
soul_candidates,
)
_GRAPH = {
"nodes": [
{
"id": "start-1",
"data": {
"type": "start",
"title": "START",
"variables": [{"variable": "tenders", "type": "file-list"}],
},
},
{"id": "llm-1", "data": {"type": "llm", "title": "LLM"}},
{"id": "agent-up", "data": {"type": "agent", "version": "2", "title": "Upstream Agent"}},
{"id": "agent-target", "data": {"type": "agent", "version": "2", "title": "Target Agent"}},
{"id": "end", "data": {"type": "end", "title": "END"}},
],
"edges": [
{"source": "start-1", "target": "llm-1"},
{"source": "llm-1", "target": "agent-up"},
{"source": "agent-up", "target": "agent-target"},
{"source": "agent-target", "target": "end"},
],
}
def _declared_loader(nid: str) -> list[DeclaredOutputConfig] | None:
if nid == "agent-up":
return [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
return None
def _draft_vars(nid: str) -> list[tuple[str, str | None]]:
if nid == "llm-1":
return [("text", "string")]
return []
def _collect(node_id: str, *, system_vars=()):
entries, truncated = previous_node_output_candidates(
graph=_GRAPH,
node_id=node_id,
declared_outputs_loader=_declared_loader,
draft_variables_loader=_draft_vars,
system_variables_loader=lambda: list(system_vars),
)
return entries, truncated
def test_upstream_outputs_follow_inspector_semantics():
entries, truncated = _collect("agent-target", system_vars=[("query", "string")])
assert truncated is False
by_node = {}
for entry in entries:
by_node.setdefault(entry["node_id"], []).append(entry)
# sys vars ride as a pseudo node, run-derived
assert by_node["sys"][0]["selector"] == ["sys", "query"]
assert by_node["sys"][0]["inferred"] is True
# start variables are static graph facts
start = by_node["start-1"][0]
assert start["selector"] == ["start-1", "tenders"]
assert start["name"] == "START/tenders"
assert start["inferred"] is False
assert start["value_type"] == "file-list"
# agent v2 upstream node uses its declared outputs
agent = by_node["agent-up"][0]
assert agent["output"] == "summary"
assert agent["value_type"] == "string"
assert agent["inferred"] is False
# other kinds fall back to draft variables (inferred)
llm = by_node["llm-1"][0]
assert llm["output"] == "text"
assert llm["inferred"] is True
# the target node itself and downstream nodes never appear
assert "agent-target" not in by_node
assert "end" not in by_node
def test_results_differ_per_node_id():
entries_target, _ = _collect("agent-target")
entries_llm, _ = _collect("llm-1")
assert {e["node_id"] for e in entries_target} == {"start-1", "llm-1", "agent-up"}
assert {e["node_id"] for e in entries_llm} == {"start-1"}
def test_previous_outputs_capped_and_flagged():
graph = {
"nodes": [{"id": "start-1", "data": {"type": "start", "title": "S", "variables": []}}, {"id": "t"}],
"edges": [{"source": "start-1", "target": "t"}],
}
many: list[tuple[str, str | None]] = [(f"v{i}", "string") for i in range(MAX_CANDIDATES_PER_LIST + 5)]
entries, truncated = previous_node_output_candidates(
graph=graph,
node_id="t",
declared_outputs_loader=lambda nid: None,
draft_variables_loader=lambda nid: [],
system_variables_loader=lambda: many,
)
assert len(entries) == MAX_CANDIDATES_PER_LIST
assert truncated is True
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"cli_tools": [{"name": "ffmpeg"}, {"name": "disabled-one", "enabled": False}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
def test_soul_candidates_lists_configured_items_only():
lists, truncated = soul_candidates(
agent_soul=_soul(),
dataset_lookup=lambda ids: {"ds-1": SimpleNamespace(name="产品手册", description="desc")},
workspace_tools_loader=lambda: [
{"id": "tavily/tavily_search", "name": "tavily_search", "provider": "tavily", "plugin_id": "lg/tavily"}
],
)
assert truncated is False
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
assert knowledge["ds-1"]["name"] == "产品手册"
assert knowledge["ds-1"]["missing"] is False
assert knowledge["ds-gone"]["missing"] is True
assert knowledge["ds-gone"]["name"] == "已删"
assert lists["human_contacts"][0]["id"] == "c-1"
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
def test_candidates_response_preserves_skill_and_file_candidate_shapes():
response = AgentComposerCandidatesResponse.model_validate(
{
"variant": "agent_app",
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {
"skills_files": [
{"kind": "skill", "id": "sk-1", "name": "tender-analyzer", "path": "skills/tender.md"},
{
"kind": "file",
"id": "f-1",
"name": "qna_report.pdf",
"transfer_method": "local_file",
"reference": "upload-1",
"url": "https://files.example/qna_report.pdf",
},
]
},
"capabilities": {"human_roster_available": False},
}
).model_dump(mode="json")
skill, file = response["allowed_soul_candidates"]["skills_files"]
assert skill["kind"] == "skill"
assert skill["path"] == "skills/tender.md"
assert file["kind"] == "file"
assert file["transfer_method"] == "local_file"
assert file["reference"] == "upload-1"
assert file["url"] == "https://files.example/qna_report.pdf"
def test_soul_candidates_empty_config_yields_empty_lists():
lists, truncated = soul_candidates(
agent_soul=None,
dataset_lookup=lambda ids: {},
workspace_tools_loader=lambda: [],
)
assert truncated is False
assert all(value == [] for value in lists.values())
def test_soul_candidates_caps_lists():
lists, truncated = soul_candidates(
agent_soul=None,
dataset_lookup=lambda ids: {},
workspace_tools_loader=lambda: [{"id": str(i)} for i in range(MAX_CANDIDATES_PER_LIST + 1)],
)
assert len(lists["dify_tools"]) == MAX_CANDIDATES_PER_LIST
assert truncated is True

View File

@ -0,0 +1,188 @@
"""Composer save/validate mention rules (ENG-616 §2.4 allowlists)."""
from __future__ import annotations
import pytest
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import InvalidComposerConfigError
from services.entities.agent_entities import ComposerSavePayload
def _soul_payload(system_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {"prompt": {"system_prompt": system_prompt}},
"save_strategy": "save_to_current_version",
}
)
def _node_job_payload(workflow_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "workflow",
"node_job": {"workflow_prompt": workflow_prompt},
"save_strategy": "node_job_only",
}
)
def test_soul_prompt_accepts_soul_kinds():
payload = _soul_payload("Use [§skill:s1§] [§file:f1§] [§tool:p/t§] [§cli_tool:c§] [§knowledge:k1§] [§human:h1§]")
ComposerConfigValidator.validate_save_payload(payload)
def test_soul_prompt_rejects_node_output_mention():
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_soul_payload("Read [§node_output:n1.text§]"))
def test_soul_prompt_rejects_declared_output_mention():
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_soul_payload("Produce [§output:report§]"))
def test_node_job_prompt_accepts_job_kinds():
payload = _node_job_payload("Read [§node_output:n1.text:START/text§], produce [§output:report§], ask [§human:h1§]")
ComposerConfigValidator.validate_save_payload(payload)
@pytest.mark.parametrize("token", ["[§skill:s1§]", "[§tool:p/t§]", "[§cli_tool:c§]", "[§knowledge:k1§]"])
def test_node_job_prompt_rejects_soul_only_kinds(token: str):
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_node_job_payload(f"Use {token}"))
def test_mention_limit_enforced():
prompt = " ".join(f"[§human:h{i}§]" for i in range(201))
with pytest.raises(InvalidComposerConfigError, match="mention_limit_exceeded"):
ComposerConfigValidator.validate_save_payload(_soul_payload(prompt))
def test_prompt_without_mentions_still_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload("plain prompt, {{var}} and {{#context#}} untouched"))
# ── ENG-617: human must be referenced (hard) ─────────────────────────────────
def _soul_payload_with_human(system_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": system_prompt},
"human": {
"contacts": [{"id": "c-1", "name": "David Hayes", "email": "david@acme.com", "channel": "email"}]
},
},
"save_strategy": "save_to_current_version",
}
)
def test_configured_human_without_mention_is_rejected():
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("no human reference here"))
def test_configured_human_referenced_by_id_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:c-1§] when unsure"))
def test_configured_human_referenced_by_email_alias_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:david@acme.com§]"))
def test_node_job_human_must_be_referenced_too():
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"node_job": {
"workflow_prompt": "do the work",
"human_contacts": [{"id": "c-2", "name": "Reviewer"}],
},
"save_strategy": "node_job_only",
}
)
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
ComposerConfigValidator.validate_save_payload(payload)
payload.node_job.workflow_prompt = "escalate to [§human:c-2§]"
ComposerConfigValidator.validate_save_payload(payload)
def test_identity_less_human_contact_is_skipped():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "plain"},
"human": {"contacts": [{"channel": "email"}]},
},
"save_strategy": "save_to_current_version",
}
)
ComposerConfigValidator.validate_save_payload(payload)
# ── ENG-617: soft findings ───────────────────────────────────────────────────
def _findings(payload: ComposerSavePayload, **kwargs):
return ComposerConfigValidator.collect_soft_findings(payload, **kwargs)
def test_dangling_knowledge_mention_becomes_placeholder_with_label():
payload = _soul_payload("ground in [§knowledge:gone-1:旧产品手册§]")
findings = _findings(payload)
assert findings["knowledge_retrieval_placeholder"] == [{"id": "gone-1", "placeholder_name": "旧产品手册"}]
assert findings["warnings"] == []
def test_dangling_knowledge_without_label_gets_fallback_name():
findings = _findings(_soul_payload("see [§knowledge:deadbeef-cafe§]"))
assert findings["knowledge_retrieval_placeholder"] == [
{"id": "deadbeef-cafe", "placeholder_name": "Knowledge deadbeef"}
]
def test_configured_but_deleted_dataset_surfaces_as_placeholder():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
},
"save_strategy": "save_to_current_version",
}
)
# configured + DB row exists -> clean
assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == []
# configured but deleted in DB -> placeholder
assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [
{"id": "ds-1", "placeholder_name": "产品手册"}
]
def test_unresolved_non_knowledge_mentions_warn_target_missing():
findings = _findings(_soul_payload("use [§skill:nope:Ghost Skill§] and [§human:missing§]"))
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
assert ("mention_target_missing", "skill") in codes
assert ("mention_target_missing", "human") in codes
assert findings["knowledge_retrieval_placeholder"] == []
def test_malformed_marker_warns_but_does_not_block():
payload = _soul_payload("hello [§wat:x:y§] world")
ComposerConfigValidator.validate_save_payload(payload) # no hard error
findings = _findings(payload)
assert [w["code"] for w in findings["warnings"]] == ["mention_malformed"]
def test_clean_prompt_yields_empty_findings():
findings = _findings(_soul_payload("plain prompt with {{#context#}} legacy form"))
assert findings == {"warnings": [], "knowledge_retrieval_placeholder": []}

View File

@ -0,0 +1,179 @@
"""Unit tests for the prompt mention contract (ENG-616).
Token form: ``[§<kind>:<id>[:<label>]§]``. Mentions are pointers into the Agent
config lists; expansion replaces them with canonical names and the scrub pass
guarantees no mention-shaped marker survives to the model.
"""
from __future__ import annotations
import pytest
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from services.agent.prompt_mentions import (
MAX_MENTION_FIELD_LENGTH,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
parse_prompt_mentions,
scrub_mention_markers,
)
# ── parse ─────────────────────────────────────────────────────────────────────
def test_parse_extracts_kind_id_and_optional_label():
prompt = "Use [§skill:abc-1:tender-analyzer§] then ask [§human:c-1§]."
mentions = parse_prompt_mentions(prompt)
assert [(m.kind, m.ref_id, m.label) for m in mentions] == [
(MentionKind.SKILL, "abc-1", "tender-analyzer"),
(MentionKind.HUMAN, "c-1", None),
]
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw
def test_parse_supports_ids_with_slash_and_dot():
mentions = parse_prompt_mentions("[§tool:langgenius/tavily/tavily_search:tavily§] [§node_output:node-1.tenders§]")
assert mentions[0].ref_id == "langgenius/tavily/tavily_search"
assert mentions[1].ref_id == "node-1.tenders"
def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
prompt = "{{var}} {{#context#}} {{#sys.query#}} [§bogus_kind:x§]"
assert parse_prompt_mentions(prompt) == []
def test_parse_skips_oversized_id_or_label():
long_id = "x" * (MAX_MENTION_FIELD_LENGTH + 1)
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
# ── expand + scrub ────────────────────────────────────────────────────────────
def test_expand_uses_resolver_and_degrades_unresolved_to_label_then_id():
prompt = "A [§skill:s1:Skill One§] B [§human:h1:EMAIL · DAVE§] C [§knowledge:k1§]"
def resolver(mention):
return "resolved-skill" if mention.kind == MentionKind.SKILL else None
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == "A resolved-skill B EMAIL · DAVE C k1"
assert "" not in expanded
def test_expand_scrubs_unknown_kind_tokens_but_keeps_legacy_forms():
prompt = "x [§wat:id-1:Label§] y {{#context#}} z {{#node.var#}}"
expanded = expand_prompt_mentions(prompt, lambda m: None)
# unknown mention-shaped token degraded to its label; legacy forms untouched
assert expanded == "x Label y {{#context#}} z {{#node.var#}}"
def test_scrub_degrades_colon_tokens_without_label_to_id_part():
assert scrub_mention_markers("see [§weird_kind:some-id§]") == "see some-id"
def test_expand_empty_prompt_is_noop():
assert expand_prompt_mentions("", lambda m: "x") == ""
# ── soul resolver ─────────────────────────────────────────────────────────────
@pytest.fixture
def soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"dify_tools": [
{
"plugin_id": "langgenius/tavily",
"provider": "tavily",
"tool_name": "tavily_search",
"credential_type": "unauthorized",
},
],
"cli_tools": [{"name": "ffmpeg"}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
resolver = build_soul_mention_resolver(soul)
prompt = (
"Use [§skill:sk-1§] with [§file:f-1§], search via "
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ffmpeg§], "
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
)
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == (
"Use tender-analyzer with qna_report.pdf, search via tavily_search, "
"run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes."
)
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
expanded = expand_prompt_mentions("[§knowledge:missing:旧产品手册§]", build_soul_mention_resolver(soul))
assert expanded == "旧产品手册"
# ── node-job resolver ─────────────────────────────────────────────────────────
@pytest.fixture
def node_job() -> WorkflowNodeJobConfig:
return WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "",
"previous_node_output_refs": [{"selector": ["start-1", "tenders"], "name": "START/tenders"}],
# declared output names are JSON-schema-friendly identifiers (no dots)
"declared_outputs": [{"name": "qna_report", "type": "file"}],
"human_contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}],
}
)
def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
resolver = build_node_job_mention_resolver(node_job)
prompt = "Read [§node_output:start-1.tenders§] and produce [§output:qna_report§]; if unsure contact [§human:c-1§]."
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == ("Read START/tenders and produce qna_report (file); if unsure contact EMAIL · David Hayes.")
def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "n-2", "output": "text"}]}
)
expanded = expand_prompt_mentions("[§node_output:n-2.text:LLM/text§]", build_node_job_mention_resolver(node_job))
# ref has no display name -> degrade to the mention label
assert expanded == "LLM/text"
# ── allowlists ────────────────────────────────────────────────────────────────
def test_per_surface_allowlists_match_design():
assert {
MentionKind.SKILL,
MentionKind.FILE,
MentionKind.TOOL,
MentionKind.CLI_TOOL,
MentionKind.KNOWLEDGE,
MentionKind.HUMAN,
} == SOUL_PROMPT_ALLOWED_KINDS
assert {MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN} == NODE_JOB_PROMPT_ALLOWED_KINDS

View File

@ -78,7 +78,7 @@ export default class MyCommand extends DifyCommand {
const { args, flags } = this.parse(MyCommand, argv)
// Authed: authedCtx() sets outputFormat + builds context
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format: flags.output })
const ctx = await this.authedCtx({ format: flags.output })
process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }))
}

113
cli/src/api/app-dsl.test.ts Normal file
View File

@ -0,0 +1,113 @@
import type { StubServer } from '@test/fixtures/stub-server'
import { testHttpClient } from '@test/fixtures/http-client'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isHttpClientError } from '@/errors/base'
import { AppDslClient } from './app-dsl.js'
const DSL_YAML = `app:\n mode: chat\n name: Test\nversion: '0.1.4'\n`
const COMPLETED_IMPORT = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
function makeClient(host: string): AppDslClient {
return new AppDslClient(testHttpClient(host, 'dfoa_test'))
}
describe('AppDslClient.exportDsl', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('returns the data string from the response', async () => {
stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap))
const yaml = await makeClient(stub.url).exportDsl('app-1')
expect(stub.captured.method).toBe('GET')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/export')
expect(yaml).toBe(DSL_YAML)
})
it('throws when response has no data field', async () => {
stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap))
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field')
})
it('propagates 404 as a classified HttpClientError', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap))
await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
describe('AppDslClient.importApp', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).importApp('ws-1', {
mode: 'yaml-content',
yaml_content: DSL_YAML,
})
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports')
expect(result.status).toBe('completed')
expect(result.app_id).toBe('app-1')
})
it('returns pending import on 202', async () => {
const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }
stub = await startStubServer(cap => jsonResponder(202, pending, cap))
const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML })
expect(result.status).toBe('pending')
expect(result.id).toBe('imp-1')
})
})
describe('AppDslClient.confirmImport', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('POST to confirm URL and returns completed Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
expect(stub.captured.method).toBe('POST')
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1/confirm')
expect(result.status).toBe('completed')
})
})
describe('AppDslClient.checkDependencies', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('returns empty leaked_dependencies on healthy app', async () => {
stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap))
const result = await makeClient(stub.url).checkDependencies('app-1')
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/check-dependencies')
expect(result.leaked_dependencies).toEqual([])
})
})

59
cli/src/api/app-dsl.ts Normal file
View File

@ -0,0 +1,59 @@
import type {
AppDslImportPayload,
CheckDependenciesResult,
Import,
} from '@dify/contracts/api/openapi/types.gen'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'
export type ExportQuery = {
readonly includeSecret?: boolean
readonly workflowId?: string
}
export class AppDslClient {
private readonly orpc: OpenApiClient
constructor(http: HttpClient) {
this.orpc = createOpenApiClient(http)
}
async importApp(workspaceId: string, payload: AppDslImportPayload): Promise<Import> {
return this.orpc.workspaces.byWorkspaceId.apps.imports.post({
params: { workspace_id: workspaceId },
body: payload,
})
}
async confirmImport(workspaceId: string, importId: string): Promise<Import> {
return this.orpc.workspaces.byWorkspaceId.apps.imports.byImportId.confirm.post({
params: { workspace_id: workspaceId, import_id: importId },
})
}
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
const resp = await this.orpc.apps.byAppId.export.get({
params: { app_id: appId },
query: query !== undefined
? {
include_secret: query.includeSecret,
workflow_id: query.workflowId,
}
: undefined,
})
// The response schema is an open object {"data": "<yaml string>"}; the
// contract generator marks it as loose because the backend annotation
// does not narrow the shape. Extract `data` directly.
const data = (resp as Record<string, unknown>).data
if (typeof data !== 'string')
throw new Error('export response missing data field')
return data
}
async checkDependencies(appId: string): Promise<CheckDependenciesResult> {
return this.orpc.apps.byAppId.checkDependencies.get({
params: { app_id: appId },
})
}
}

View File

@ -0,0 +1,45 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Args, Flags } from '@/framework/flags'
import { runExportApp } from './run'
export default class ExportApp extends DifyCommand {
static override description = 'Export an app\'s DSL configuration as YAML'
static override examples = [
'<%= config.bin %> export app <app-id>',
'<%= config.bin %> export app <app-id> --output ./my-app.yaml',
'<%= config.bin %> export app <app-id> --include-secret',
'<%= config.bin %> export app <app-id> --workflow-id <workflow-id>',
]
static override args = {
id: Args.string({ description: 'app ID to export', required: true }),
}
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'output': Flags.string({ description: 'write DSL YAML to this file path (prints to stdout if omitted)', char: 'o' }),
'include-secret': Flags.boolean({ description: 'include encrypted secret values in the exported DSL', default: false }),
'workflow-id': Flags.string({ description: 'export a specific workflow by ID (workflow apps only)' }),
'http-retry': httpRetryFlag,
}
async run(argv: string[]) {
const { args, flags } = this.parse(ExportApp, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const result = await runExportApp({
appId: args.id,
workspace: flags.workspace,
output: flags.output,
includeSecret: flags['include-secret'],
workflowId: flags['workflow-id'],
}, { active: ctx.active, http: ctx.http, io: ctx.io })
if (result.writtenTo === undefined) {
ctx.io.out.write(result.yaml)
if (!result.yaml.endsWith('\n'))
ctx.io.out.write('\n')
}
}
}

View File

@ -0,0 +1,69 @@
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import os from 'node:os'
import { join } from 'node:path'
import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { bufferStreams } from '@/sys/io/streams'
import { runExportApp } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
scheme: 'http',
}
describe('runExportApp', () => {
let mock: DifyMock
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
})
afterEach(async () => {
await mock.stop()
})
function http() {
return testHttpClient(mock.url, 'dfoa_test')
}
it('returns the DSL YAML string from the server', async () => {
const result = await runExportApp({ appId: 'app-1' }, { active: baseActive, http: http() })
expect(result.yaml).toBe(DSL_YAML)
expect(result.writtenTo).toBeUndefined()
})
it('writes to file when --output is given', async () => {
const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`)
const result = await runExportApp(
{ appId: 'app-1', output: tmpFile },
{ active: baseActive, http: http() },
)
expect(result.writtenTo).toBe(tmpFile)
const { readFileSync } = await import('node:fs')
expect(readFileSync(tmpFile, 'utf8')).toBe(DSL_YAML)
})
it('err stream receives written-to path when --output is given', async () => {
const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`)
const io = bufferStreams()
await runExportApp(
{ appId: 'app-1', output: tmpFile },
{ active: baseActive, http: http(), io },
)
expect(io.errBuf()).toContain(tmpFile)
})
})

View File

@ -0,0 +1,61 @@
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import fs from 'node:fs'
import { dirname } from 'node:path'
import { AppDslClient } from '@/api/app-dsl'
import { getEnv } from '@/sys/index'
import { runWithSpinner } from '@/sys/io/spinner'
import { nullStreams } from '@/sys/io/streams'
import { resolveWorkspaceId } from '@/workspace/resolver'
export type ExportAppOptions = {
readonly appId: string
readonly workspace?: string
readonly output?: string
readonly includeSecret?: boolean
readonly workflowId?: string
}
export type ExportAppDeps = {
readonly active: ActiveContext
readonly http: HttpClient
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly dslFactory?: (http: HttpClient) => AppDslClient
}
export type ExportAppResult = {
readonly yaml: string
readonly writtenTo: string | undefined
}
export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): Promise<ExportAppResult> {
const env = deps.envLookup ?? getEnv
const io = deps.io ?? nullStreams()
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
// workspace is needed to satisfy the auth pipeline; resolving it here
// mirrors what other commands do even though the export endpoint does not
// take workspace_id as a query parameter (it loads tenant from app).
resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const client = dslFactory(deps.http)
const yaml = await runWithSpinner(
{ io, label: `Exporting DSL for app ${opts.appId}` },
() => client.exportDsl(opts.appId, {
includeSecret: opts.includeSecret,
workflowId: opts.workflowId,
}),
)
if (opts.output !== undefined && opts.output !== '') {
fs.mkdirSync(dirname(opts.output), { recursive: true })
fs.writeFileSync(opts.output, yaml, 'utf8')
io.err.write(`DSL written to ${opts.output}\n`)
return { yaml, writtenTo: opts.output }
}
return { yaml, writtenTo: undefined }
}

View File

@ -0,0 +1,60 @@
import { DifyCommand } from '@/commands/_shared/dify-command'
import { httpRetryFlag } from '@/commands/_shared/global-flags'
import { Flags } from '@/framework/flags'
import { pluginDependencyLabel, runImportApp } from './run'
export default class ImportApp extends DifyCommand {
static override description = 'Import an app from a DSL YAML file or URL'
static override examples = [
'<%= config.bin %> import app --from-file ./app.yaml',
'<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"',
'<%= config.bin %> import app --from-url https://example.com/my-app.yaml',
'<%= config.bin %> import app --from-file ./app.yaml --app-id <existing-app-id>',
]
static override flags = {
'from-file': Flags.string({ description: 'import DSL from a local file (relative or absolute path)', char: 'f' }),
'from-url': Flags.string({ description: 'import DSL from an HTTP(S) URL' }),
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'name': Flags.string({ description: 'override the app name from the DSL' }),
'description': Flags.string({ description: 'override the app description from the DSL' }),
'app-id': Flags.string({ description: 'overwrite an existing app (workflow/advanced-chat only)' }),
'icon-type': Flags.string({ description: 'override icon type' }),
'icon': Flags.string({ description: 'override icon' }),
'icon-background': Flags.string({ description: 'override icon background colour' }),
'http-retry': httpRetryFlag,
}
async run(argv: string[]) {
const { flags } = this.parse(ImportApp, argv)
if (flags['from-file'] === undefined && flags['from-url'] === undefined)
this.error('one of --from-file or --from-url is required', { exit: 1 })
if (flags['from-file'] !== undefined && flags['from-url'] !== undefined)
this.error('--from-file and --from-url are mutually exclusive', { exit: 1 })
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const { result, leakedDependencies } = await runImportApp({
fromFile: flags['from-file'],
fromUrl: flags['from-url'],
workspace: flags.workspace,
name: flags.name,
description: flags.description,
appId: flags['app-id'],
iconType: flags['icon-type'],
icon: flags.icon,
iconBackground: flags['icon-background'],
}, { active: ctx.active, http: ctx.http, io: ctx.io })
const status = result.status === 'completed-with-warnings' ? 'completed (with warnings)' : result.status
ctx.io.err.write(`Import ${status}`)
if (result.app_id !== undefined && result.app_id !== null)
ctx.io.err.write(`: app ${result.app_id}`)
ctx.io.err.write('\n')
if (leakedDependencies.length > 0) {
ctx.io.err.write(`\nMissing plugin dependencies (${leakedDependencies.length}); install them before using the app:\n`)
for (const dep of leakedDependencies)
ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
}
}
}

View File

@ -0,0 +1,188 @@
import type { Import } from '@dify/contracts/api/openapi/types.gen'
import type { DifyMock } from '@test/fixtures/dify-mock/server'
import type { ActiveContext } from '@/auth/hosts'
import { writeFileSync } from 'node:fs'
import os from 'node:os'
import { join } from 'node:path'
import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios'
import { startMock } from '@test/fixtures/dify-mock/server'
import { testHttpClient } from '@test/fixtures/http-client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AppDslClient } from '@/api/app-dsl'
import { bufferStreams } from '@/sys/io/streams'
import { ZERO } from '@/util/uuid.js'
import { pluginDependencyLabel, runImportApp } from './run.js'
const baseActive: ActiveContext = {
host: '127.0.0.1',
email: 'tester@dify.ai',
ctx: {
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
},
scheme: 'http',
}
describe('runImportApp', () => {
let mock: DifyMock
beforeEach(async () => {
mock = await startMock({ scenario: 'happy' })
})
afterEach(async () => {
await mock.stop()
})
function http() {
return testHttpClient(mock.url, 'dfoa_test')
}
function tmpDslFile(): string {
const filePath = join(os.tmpdir(), `difyctl-import-test-${Date.now()}.yaml`)
writeFileSync(filePath, DSL_YAML, 'utf8')
return filePath
}
it('completes import from a local file path', async () => {
const dslFile = tmpDslFile()
const { result } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
expect(result.status).toBe('completed')
expect(result.app_id).toBe('app-1')
})
it('sends yaml_content in the request body', async () => {
const dslFile = tmpDslFile()
await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
expect(mock.lastImportBody?.mode).toBe('yaml-content')
expect(mock.lastImportBody?.yaml_content).toBe(DSL_YAML)
})
it('sends yaml_url when given --from-url', async () => {
await runImportApp(
{ fromUrl: 'https://example.com/app.yaml' },
{ active: baseActive, http: http() },
)
expect(mock.lastImportBody?.mode).toBe('yaml-url')
expect(mock.lastImportBody?.yaml_url).toBe('https://example.com/app.yaml')
})
it('forwards optional name and description overrides', async () => {
const dslFile = tmpDslFile()
await runImportApp(
{ fromFile: dslFile, name: 'My App', description: 'desc' },
{ active: baseActive, http: http() },
)
expect(mock.lastImportBody?.name).toBe('My App')
expect(mock.lastImportBody?.description).toBe('desc')
})
it('auto-confirms a pending (202) import and emits a note on err stream', async () => {
mock.setScenario('import-pending')
const io = bufferStreams()
const dslFile = tmpDslFile()
const { result } = await runImportApp(
{ fromFile: dslFile },
{ active: baseActive, http: http(), io },
)
expect(result.status).toBe('completed')
expect(io.errBuf()).toContain('confirming automatically')
})
it('throws on import-failed (400) response', async () => {
mock.setScenario('import-failed')
const dslFile = tmpDslFile()
await expect(
runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }),
).rejects.toThrow('Import failed')
})
it('uses workspace from --workspace flag over context default', async () => {
const dslFile = tmpDslFile()
await runImportApp(
{ fromFile: dslFile, workspace: ZERO },
{ active: baseActive, http: http() },
)
expect(mock.lastImportBody).not.toBeNull()
})
it('throws UsageInvalidFlag when fromFile path does not exist', async () => {
await expect(
runImportApp({ fromFile: '/tmp/difyctl-no-such-file-ever.yaml' }, { active: baseActive, http: http() }),
).rejects.toThrow('file not found')
})
it('throws UsageInvalidFlag when both fromFile and fromUrl are given', async () => {
const dslFile = tmpDslFile()
await expect(
runImportApp({ fromFile: dslFile, fromUrl: 'https://example.com/app.yaml' }, { active: baseActive, http: http() }),
).rejects.toThrow('mutually exclusive')
})
it('throws UsageInvalidFlag when neither fromFile nor fromUrl is given', async () => {
await expect(
runImportApp({}, { active: baseActive, http: http() }),
).rejects.toThrow('required')
})
it('returns empty leakedDependencies when the app has no missing plugins', async () => {
const dslFile = tmpDslFile()
const { leakedDependencies } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
expect(leakedDependencies).toEqual([])
})
it('surfaces leaked dependencies reported by check-dependencies', async () => {
const dslFile = tmpDslFile()
const completed: Import = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
const stub = Object.assign(Object.create(AppDslClient.prototype), {
importApp: async () => completed,
confirmImport: async () => completed,
checkDependencies: async () => ({
leaked_dependencies: [
{ type: 'marketplace', value: { marketplace_plugin_unique_identifier: 'langgenius/openai:0.0.1' } },
],
}),
}) as AppDslClient
const { leakedDependencies } = await runImportApp(
{ fromFile: dslFile },
{ active: baseActive, http: http(), dslFactory: () => stub },
)
expect(leakedDependencies).toHaveLength(1)
const [dep] = leakedDependencies
if (dep === undefined)
throw new Error('expected one leaked dependency')
expect(pluginDependencyLabel(dep)).toBe('langgenius/openai:0.0.1')
})
})
describe('pluginDependencyLabel', () => {
it('reads the github plugin identifier', () => {
const label = pluginDependencyLabel({
type: 'github',
value: { github_plugin_unique_identifier: 'owner/repo:1.0.0' },
})
expect(label).toBe('owner/repo:1.0.0')
})
it('reads the package plugin identifier', () => {
const label = pluginDependencyLabel({ type: 'package', value: { plugin_unique_identifier: 'pkg:2.0.0' } })
expect(label).toBe('pkg:2.0.0')
})
it('falls back to current_identifier then a placeholder', () => {
expect(pluginDependencyLabel({ type: 'package', value: {}, current_identifier: 'fallback' })).toBe('fallback')
expect(pluginDependencyLabel({ type: 'package', value: null })).toBe('<unknown>')
})
})

View File

@ -0,0 +1,138 @@
import type { Import, PluginDependency } from '@dify/contracts/api/openapi/types.gen'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
import type { IOStreams } from '@/sys/io/streams'
import fs from 'node:fs'
import { AppDslClient } from '@/api/app-dsl'
import { newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { getEnv } from '@/sys/index'
import { runWithSpinner } from '@/sys/io/spinner'
import { nullStreams } from '@/sys/io/streams'
import { resolveWorkspaceId } from '@/workspace/resolver'
export type ImportAppOptions = {
readonly fromFile?: string
readonly fromUrl?: string
readonly workspace?: string
readonly name?: string
readonly description?: string
readonly appId?: string
readonly iconType?: string
readonly icon?: string
readonly iconBackground?: string
}
export type ImportAppDeps = {
readonly active: ActiveContext
readonly http: HttpClient
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly dslFactory?: (http: HttpClient) => AppDslClient
}
export type ImportAppResult = {
readonly result: Import
readonly leakedDependencies: readonly PluginDependency[]
}
export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): Promise<ImportAppResult> {
const env = deps.envLookup ?? getEnv
const io = deps.io ?? nullStreams()
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
const workspaceId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const client = dslFactory(deps.http)
if (opts.fromFile !== undefined && opts.fromUrl !== undefined)
throw newError(ErrorCode.UsageInvalidFlag, '--from-file and --from-url are mutually exclusive')
let mode: 'yaml-content' | 'yaml-url'
let yamlContent: string | undefined
let yamlUrl: string | undefined
if (opts.fromFile !== undefined) {
mode = 'yaml-content'
try {
yamlContent = fs.readFileSync(opts.fromFile, 'utf8')
}
catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === 'ENOENT')
throw newError(ErrorCode.UsageInvalidFlag, `--from-file: file not found: ${opts.fromFile}`)
throw err
}
}
else if (opts.fromUrl !== undefined) {
mode = 'yaml-url'
yamlUrl = opts.fromUrl
}
else {
throw newError(ErrorCode.UsageInvalidFlag, 'one of --from-file or --from-url is required')
}
let result = await runWithSpinner(
{ io, label: 'Importing app DSL' },
() => client.importApp(workspaceId, {
mode,
yaml_content: yamlContent,
yaml_url: yamlUrl,
name: opts.name,
description: opts.description,
app_id: opts.appId,
icon_type: opts.iconType,
icon: opts.icon,
icon_background: opts.iconBackground,
}),
)
if (result.status === 'failed') {
throw newError(
ErrorCode.Server4xxOther,
`Import failed: ${result.error !== '' ? result.error : 'unknown error'}`,
)
}
// DSL version mismatch: the server needs an explicit acknowledgement before
// finalising. Auto-confirm here so the user does not need a second command.
if (result.status === 'pending') {
io.err.write(`note: DSL version mismatch (imported ${result.imported_dsl_version ?? '?'}, current ${result.current_dsl_version ?? '?'}); confirming automatically\n`)
result = await runWithSpinner(
{ io, label: 'Confirming import' },
() => client.confirmImport(workspaceId, result.id),
)
}
if (result.status === 'failed') {
throw newError(
ErrorCode.Server4xxOther,
`Import failed after confirmation: ${result.error !== '' ? result.error : 'unknown error'}`,
)
}
const appId = result.app_id
if (appId === undefined || appId === null)
return { result, leakedDependencies: [] }
const { leaked_dependencies } = await runWithSpinner(
{ io, label: 'Checking plugin dependencies' },
() => client.checkDependencies(appId),
)
return { result, leakedDependencies: leaked_dependencies ?? [] }
}
// `value` is a loosely-typed wire object (Github | Marketplace | Package); narrow it here to
// surface a human-readable identifier without depending on which variant the server returned.
export function pluginDependencyLabel(dep: PluginDependency): string {
const value = dep.value
if (typeof value === 'object' && value !== null) {
const fields = value as Record<string, unknown>
const id = fields.marketplace_plugin_unique_identifier
?? fields.github_plugin_unique_identifier
?? fields.plugin_unique_identifier
if (typeof id === 'string' && id !== '')
return id
}
return dep.current_identifier ?? '<unknown>'
}

View File

@ -17,9 +17,11 @@ import CreateMember from '@/commands/create/member/index'
import DeleteMember from '@/commands/delete/member/index'
import DescribeApp from '@/commands/describe/app/index'
import EnvList from '@/commands/env/list/index'
import ExportApp from '@/commands/export/app/index'
import GetApp from '@/commands/get/app/index'
import GetMember from '@/commands/get/member/index'
import GetWorkspace from '@/commands/get/workspace/index'
import ImportApp from '@/commands/import/app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
import SetMember from '@/commands/set/member/index'
@ -73,6 +75,11 @@ export const commandTree: CommandTree = {
list: { command: EnvList, subcommands: {} },
},
},
export: {
subcommands: {
app: { command: ExportApp, subcommands: {} },
},
},
get: {
subcommands: {
app: { command: GetApp, subcommands: {} },
@ -80,6 +87,11 @@ export const commandTree: CommandTree = {
workspace: { command: GetWorkspace, subcommands: {} },
},
},
import: {
subcommands: {
app: { command: ImportApp, subcommands: {} },
},
},
resume: {
subcommands: {
app: { command: ResumeApp, subcommands: {} },

1
cli/src/util/uuid.ts Normal file
View File

@ -0,0 +1 @@
export const ZERO = '00000000-0000-0000-0000-000000000000'

View File

@ -27,9 +27,11 @@
import type { TestProject } from 'vitest/node'
import type { E2ECapabilities } from './env.js'
import { Buffer } from 'node:buffer'
import { readFile, writeFile } from 'node:fs/promises'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { injectAuth, run } from '../helpers/cli.js'
import { loadE2EEnv } from './env.js'
const TOKEN_MINT_APPROVE_ATTEMPTS = 5
@ -259,6 +261,10 @@ export async function setup(project: TestProject): Promise<void> {
secondaryWsId,
fixturesDir,
E.edition,
primaryToken,
apiBase,
E.email,
primaryWsName,
)
console.warn(`[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`)
}
@ -474,6 +480,10 @@ async function provisionApps(
secondaryWsId: string,
fixturesDir: string,
edition: 'ce' | 'ee',
token: string,
host: string,
email: string,
primaryWsName: string,
): Promise<Record<string, string>> {
const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat'])
@ -498,6 +508,28 @@ async function provisionApps(
: []),
]
const configDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-provision-'))
await injectAuth(configDir, {
host,
bearer: token,
email,
workspaceId: primaryWsId,
workspaceName: primaryWsName,
})
async function importAppCli(filePath: string, wsId: string): Promise<string> {
const result = await run(
['import', 'app', '--from-file', filePath, '--workspace', wsId],
{ configDir, timeout: 60_000 },
)
if (result.exitCode !== 0)
throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`)
const match = result.stderr.match(/app ([0-9a-f-]{36})/)
if (!match?.[1])
throw new Error(`import app: could not parse app_id: ${result.stderr}`)
return match[1]
}
async function switchWorkspace(wsId: string): Promise<void> {
const r = await fetch(`${consoleBase}/console/api/workspaces/switch`, {
method: 'POST',
@ -518,30 +550,6 @@ async function provisionApps(
return d.data?.find(a => a.name === name)?.id ?? null
}
async function importFromDsl(yamlContent: string): Promise<string> {
const r = await fetch(`${consoleBase}/console/api/apps/imports`, {
method: 'POST',
headers: mkHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ mode: 'yaml-content', yaml_content: yamlContent }),
signal: AbortSignal.timeout(30_000),
})
const d = await r.json() as { app_id?: string, import_id?: string, status?: string }
if (r.status === 202 && d.import_id) {
const cr = await fetch(`${consoleBase}/console/api/apps/imports/${d.import_id}/confirm`, {
method: 'POST',
headers: mkHeaders(),
signal: AbortSignal.timeout(15_000),
})
const c = await cr.json() as { app_id?: string }
if (!c.app_id)
throw new Error(`import confirm failed: HTTP ${cr.status}`)
return c.app_id
}
if (!d.app_id)
throw new Error(`import failed: HTTP ${r.status} ${JSON.stringify(d)}`)
return d.app_id
}
async function enableApi(appId: string): Promise<void> {
await fetch(`${consoleBase}/console/api/apps/${appId}/api-enable`, {
method: 'POST',
@ -603,7 +611,7 @@ async function provisionApps(
console.warn(`[E2E provision] ${dslFile}: exists in workspace id=${appId}; skip import`)
}
else {
appId = await importFromDsl(dsl)
appId = await importAppCli(join(fixturesDir, dslFile), wsId)
console.warn(`[E2E provision] ${dslFile}: imported id=${appId}`)
}
@ -619,6 +627,9 @@ async function provisionApps(
}
}
await rm(configDir, { recursive: true, force: true }).catch((err: unknown) =>
console.warn(`[E2E provision] failed to clean up configDir: ${err}`),
)
return results
}

View File

@ -7,23 +7,17 @@
import type { RunResult } from '../../helpers/cli.js'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertErrorEnvelope, assertExitCode } from '../../helpers/assert.js'
import { assertExitCode } from '../../helpers/assert.js'
import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { enterpriseOnlyIt } from '../../helpers/skip.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
const eeIt = enterpriseOnlyIt(caps)
// Secondary workspace used in tests — injected into available_workspaces
const WS2_ID = 'ws-e2e-secondary-0000-000000000002'
// Real second workspace on staging — used by 1.84
// IDs are now loaded from DIFY_E2E_WS2_ID / DIFY_E2E_WS2_APP_ID env vars.
// Workspace belonging to another account — used by 1.88 (WTA-256)
const OTHER_ACCOUNT_WS_ID = '8d1a7693-2d86-4766-a7b8-c276a04c3fbf'
const WS2_ID = '00000000-e2e2-0000-0001-000000000002'
const WS2_NAME = 'Secondary Workspace'
describe('E2E / difyctl use workspace', () => {
@ -152,7 +146,7 @@ describe('E2E / difyctl use workspace', () => {
it('[P0] switching to a non-existent workspace returns an error', async () => {
// Spec: switching to a non-existent workspace returns an error
await withTwoWorkspaces()
const result = await r(['use', 'workspace', 'ws-does-not-exist-xyz'])
const result = await r(['auth', 'use', 'ffffffff-dead-0000-0000-000000000000'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/server_5xx|not found|workspace|error/i)
})
@ -160,7 +154,7 @@ describe('E2E / difyctl use workspace', () => {
it('[P0] current_workspace_id is unchanged when workspace switch fails', async () => {
// Spec: current_workspace_id is unchanged when workspace switch fails
await withTwoWorkspaces()
await r(['use', 'workspace', 'ws-does-not-exist-xyz'])
await r(['auth', 'use', 'ffffffff-dead-0000-0000-000000000000'])
// Read hosts.yml directly; the original workspace id should still be present
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
@ -202,238 +196,4 @@ describe('E2E / difyctl use workspace', () => {
})
// ── Post-switch get app ──────────────────────────────────────────────────────
it('[P1] get app returns app list of the new workspace after auth use', async () => {
// Spec 1.70: get app returns the app list of the new workspace after switching
// We switch to WS2 (a synthetic fixture id) and verify that auth status
// reflects the new workspace. A real app-list check would require WS2 to
// exist on the server, so we verify via auth status only (which reads the
// local config that was just updated).
await withTwoWorkspaces()
const switchResult = await switchWorkspace(E.workspaceId)
if (switchResult === undefined)
return
assertExitCode(switchResult, 0)
const hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
})
// ── Switch by workspace name ─────────────────────────────────────────────────
it('[P1] auth use accepts a workspace name and switches successfully', async () => {
// Spec 1.71: auth use accepts a workspace name and switches successfully
await withTwoWorkspaces()
const result = await r(['use', 'workspace', WS2_NAME])
// Acceptable outcomes: exit 0 (name matched) or exit non-0 (name not
// supported — CLI only accepts IDs). If exit 0, stdout must mention the
// workspace name or a success indicator.
if (result.exitCode === 0) {
expect(result.stdout).toMatch(/switched|workspace/i)
const hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(WS2_ID)
}
else {
// CLI does not support name-based lookup — acceptable; verify the error
// message is clear and the original workspace is unchanged.
const hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
}
})
// ── Unauthorised workspace ───────────────────────────────────────────────────
it('[P0] auth use on an unauthorised workspace returns an error', async () => {
// Spec 1.73: auth use on an unauthorised workspace returns an error
// The workspace id is not listed in available_workspaces so the CLI must
// refuse the switch locally (not_found / permission denied).
await withTwoWorkspaces()
const result = await r(['use', 'workspace', 'ws-unauthorized-0000-000000000099'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/server_5xx|not found|permission|unauthorized|workspace|error/i)
// Original workspace must be unchanged
const hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
})
// ── Consecutive switches ─────────────────────────────────────────────────────
it('[P1] consecutive auth use calls always update to the latest workspace', async () => {
// Spec 1.77: consecutive auth use calls always update to the latest workspace
// We switch to the primary workspace twice to verify idempotency and that
// hosts.yml is always refreshed from the server response.
await withTwoWorkspaces()
const r1 = await switchWorkspace(E.workspaceId)
if (r1 === undefined)
return
assertExitCode(r1, 0)
let hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
const r2 = await switchWorkspace(E.workspaceId)
if (r2 === undefined)
return
assertExitCode(r2, 0)
hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
const r3 = await switchWorkspace(E.workspaceId)
if (r3 === undefined)
return
assertExitCode(r3, 0)
hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
})
// ── Empty string argument ────────────────────────────────────────────────────
it('[P1] auth use with an empty string argument returns a usage error', async () => {
// Spec 1.81: auth use with an empty string argument returns a usage error
await withTwoWorkspaces()
const result = await r(['use', 'workspace', ''])
expect(result.exitCode).not.toBe(0)
// empty string passed as workspace id causes server error — any non-zero exit is acceptable
expect(result.stderr.trim().length).toBeGreaterThan(0)
// Original workspace must be unchanged
const hostsContent = await (await import('node:fs/promises')).readFile(
join(configDir, 'hosts.yml'),
'utf8',
)
expect(hostsContent).toContain(E.workspaceId)
})
// ── JSON error envelope ──────────────────────────────────────────────────────
it('[P1] stderr contains JSON error envelope when workspace does not exist in JSON mode', async () => {
// Spec 1.83: JSON mode with non-existent workspace returns a JSON error envelope on stderr
// auth use does not have a dedicated -o flag; if the CLI respects a global
// --output json flag the stderr should be a JSON envelope. If the flag is
// not supported we still verify that stderr is non-empty and contains a
// meaningful error.
await withTwoWorkspaces()
const result = await r(['use', 'workspace', 'ws-nonexistent-json-test', '--output', 'json'])
expect(result.exitCode).not.toBe(0)
if (result.stderr.trim().startsWith('{')) {
// JSON error envelope path — validate the structure
assertErrorEnvelope(result)
}
else {
// Plain text error path — acceptable fallback
expect(result.stderr.trim().length).toBeGreaterThan(0)
}
})
// ── Network error ────────────────────────────────────────────────────────────
it('[P1] auth use returns an error when the network is unavailable', async () => {
// Spec 1.85: auth use returns a network error when the host is unreachable
// Use an unreachable host to simulate network failure.
await injectAuth(configDir, {
host: 'http://unreachable-host-xyz.invalid',
bearer: 'dfoa_network_test_token',
email: E.email,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
availableWorkspaces: [
{ id: E.workspaceId, name: E.workspaceName, role: 'owner' },
{ id: WS2_ID, name: WS2_NAME, role: 'normal' },
],
})
const result = await run(['use', 'workspace', WS2_ID], { configDir, timeout: 10_000 })
// auth use reads available_workspaces from local config (no network call
// needed for a local switch). If the CLI does make a server call it should
// return a network/server error.
if (result.exitCode !== 0) {
expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i)
}
// If exit 0, the CLI completed the switch locally — also acceptable.
})
// ── Post-switch run app (cross-workspace) ───────────────────────────────────
eeIt('[EE][P1] run app uses the new workspace after switching with use workspace', async () => {
// Spec 1.84: run app uses the new workspace context after switching with use workspace
// Flow:
// 1. start on primary workspace (E.workspaceId)
// 2. use workspace E.ws2Id (auto_test)
// 3. run app E.ws2AppId — succeeds only when workspace context is correct
if (!E.ws2Id || !E.ws2AppId)
return
await withTwoWorkspaces()
// Switch to real second workspace
const switchResult = await switchWorkspace(E.ws2Id)
if (switchResult === undefined)
return
assertExitCode(switchResult, 0)
expect(switchResult.stdout).toMatch(/switched/i)
expect(switchResult.stdout).toContain(E.ws2Id)
// Run the app that lives in ws2 — exit 0 confirms workspace context is active
let runResult: Awaited<ReturnType<typeof r>>
try {
runResult = await withRetry(async () => {
const result = await r(['run', 'app', E.ws2AppId, '--inputs', '{}'])
if (result.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(result.stderr))
throw new Error(result.stderr)
return result
}, {
attempts: 3,
delayMs: 1_000,
shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)),
})
}
catch (err) {
if (/server_5xx|HTTP 5\d\d/i.test(String(err))) {
console.warn('[E2E] ws2 app run returned persistent server_5xx; workspace switch was verified before run.')
return
}
throw err
}
assertExitCode(runResult, 0)
// stdout should contain app output (not an auth/workspace error)
expect(runResult.stderr).not.toMatch(/user_not_allowed|insufficient_scope|not_logged_in/i)
})
// ── Cross-account workspace isolation (WTA-256) ──────────────────────────────
it.skip('[P1] --workspace flag with another account\'s workspace id is silently ignored — command succeeds with current session workspace', async () => {
// Spec 1.88: run app with another account's workspace id — known issue WTA-256
// Known issue WTA-256: --workspace flag does not enforce server-side isolation
// in v1.0; the CLI uses the session workspace and ignores the flag value.
// This test documents the CURRENT behaviour (silent success, not 403/404).
await withTwoWorkspaces()
const chatAppId = E.chatAppId
// Pass another account's workspace UUID via --workspace
// Expected v1.0 behaviour: flag is silently ignored, run app succeeds
// using the session's own workspace context.
const result = await r(['run', 'app', chatAppId, 'hello', '--workspace', OTHER_ACCOUNT_WS_ID])
// WTA-256: current version exits 0 and runs against the session workspace
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
// No cross-account data should leak — result should be from our own workspace
expect(result.stderr).not.toMatch(/403|forbidden|not_allowed/i)
})
})

View File

@ -0,0 +1,196 @@
/**
* E2E: difyctl export app DSL export
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_WORKFLOW_APP_ID echo-workflow app (no model provider dependency)
* DIFY_E2E_CHAT_APP_ID echo-chat app
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import {
assertExitCode,
} from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / difyctl export app', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Basic export ──────────────────────────────────────────────────────────
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
})
it('[P0] exported YAML contains kind: app', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
})
it('[P0] exported YAML contains version field', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^version:/m)
})
it('[P0] exported YAML contains app section with mode', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId])
assertExitCode(result, 0)
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P1] chat app export also succeeds and includes mode', async () => {
const result = await fx.r(['export', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/^kind:\s*app/m)
expect(result.stdout).toMatch(/^\s+mode:/m)
})
// ── --output flag ─────────────────────────────────────────────────────────
it('[P1] --output writes DSL to file and exits 0', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
const outPath = join(dir, 'exported.yaml')
try {
const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
assertExitCode(result, 0)
const content = await readFile(outPath, 'utf8')
expect(content).toMatch(/^kind:\s*app/m)
expect(content).toMatch(/^version:/m)
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
it('[P1] --output writes same content as stdout', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-cmp-'))
const outPath = join(dir, 'exported.yaml')
try {
const [stdoutResult, fileResult] = await Promise.all([
fx.r(['export', 'app', E.workflowAppId]),
fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
const content = await readFile(outPath, 'utf8')
return { exitCode: r.exitCode, content }
}),
])
assertExitCode(stdoutResult, 0)
expect(fileResult.exitCode).toBe(0)
expect(fileResult.content.trim()).toBe(stdoutResult.stdout.trim())
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
// ── Roundtrip: export → import ────────────────────────────────────────────
it('[P1] roundtrip: exported DSL can be re-imported as a new app', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
const dslPath = join(dir, 'roundtrip.yaml')
try {
const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
assertExitCode(exportResult, 0)
const importResult = await fx.r([
'import',
'app',
'--from-file',
dslPath,
'--name',
'e2e-export-roundtrip',
])
assertExitCode(importResult, 0)
const match = importResult.stderr.match(/app ([0-9a-f-]{36})/)
expect(match?.[1], 'import stderr must contain the new app UUID').toBeTruthy()
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
// ── Error scenarios ───────────────────────────────────────────────────────
it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
expect(result.exitCode).toBe(1)
expect(result.stderr.length).toBeGreaterThan(0)
})
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
const unauthTmp = await withTempConfig()
try {
const result = await run(['export', 'app', E.workflowAppId], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P1] export with missing app id argument exits non-zero', async () => {
const result = await fx.r(['export', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|required|app id/i)
})
it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
})
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
const result = await fx.r([
'export',
'app',
E.workflowAppId,
'--workflow-id',
'00000000-0000-0000-0000-000000000000',
])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/http_status:\s*404/)
})
it('[P1] non-existent app in --output mode leaves no file behind', async () => {
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
const outPath = join(dir, 'should-not-exist.yaml')
try {
const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
expect(result.exitCode).not.toBe(0)
const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
expect(exists, 'output file must not be created on export failure').toBe(false)
}
finally {
await rm(dir, { recursive: true, force: true })
}
})
})

View File

@ -34,6 +34,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { ZERO } from '@/util/uuid.js'
import {
assertErrorEnvelope,
assertNoAnsi,
@ -203,7 +204,7 @@ describe('E2E / error message standards (spec 5.3)', () => {
// Spec 5.83: without --debug the CLI must never print a stack trace.
// We trigger a server_5xx by querying a non-existent app id and verify
// that no "at <FunctionName>" stack-trace lines appear in stderr.
const result = await fx.r(['get', 'app', '00000000-0000-0000-0000-000000000000'])
const result = await fx.r(['get', 'app', ZERO])
assertNonZeroExit(result)
// Stack trace lines look like " at Object.xxx (/path/to/file.js:123:45)"
expect(result.stderr).not.toMatch(/^\s+at\s+\S/m)

View File

@ -31,16 +31,17 @@
* 5.117 panic output cannot reliably trigger panic
*/
import type { AuthFixture } from '../../helpers/cli.js'
import type { AuthFixture } from '@test/e2e/helpers/cli.js'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { assertExitCode, assertNonZeroExit } from '@test/e2e/helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '@test/e2e/helpers/cli.js'
import { resolveEnv } from '@test/e2e/setup/env.js'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode, assertNonZeroExit } from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { resolveEnv } from '../../setup/env.js'
import { ZERO } from '@/util/uuid.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const caps = inject('e2eCapabilities') as import('@test/e2e/setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / exit code standards (spec 5.4)', () => {
@ -85,7 +86,7 @@ describe('E2E / exit code standards (spec 5.4)', () => {
const result = await fx.r([
'get',
'app',
'00000000-0000-0000-0000-000000000000',
ZERO,
'-o',
'json',
])

View File

@ -201,19 +201,6 @@ describe('E2E / global flags (spec 5.5)', () => {
expect(result.stderr).toMatch(/flag -o expects a value/i)
})
// ── 5.136 --workspace nonexistent → workspace not found, exit 1 ──────────
it('[P0] 5.136 --workspace with a nonexistent id returns workspace not found with exit 1', async () => {
// Spec 5.136: --workspace must validate the workspace exists; if not, exit 1.
const result = await fx.r([
'use',
'workspace',
'ffffffff-0000-0000-0000-nonexistent-ws',
])
expect(result.exitCode).toBe(1)
expect(result.stderr).toMatch(/workspace.*(not found|404)|server_4xx/i)
})
// ── 5.140 help + -o json doesn't crash ───────────────────────────────────
it('[P1] 5.140 difyctl --help -o json runs without crashing and exits 0', async () => {

View File

@ -14,6 +14,8 @@ export type Scenario
| 'server-version-empty'
| 'server-version-unsupported'
| 'run-422-stale'
| 'import-pending'
| 'import-failed'
export type AccountFixture = {
id: string
@ -141,6 +143,13 @@ export const APPS: AppFixture[] = [
},
]
export const DSL_YAML = `app:
description: A simple greeting bot
mode: chat
name: Greeter
version: '0.1.4'
`
export const SESSIONS: SessionFixture[] = [
{
id: 'tok-1',

View File

@ -2,7 +2,7 @@ import type { AddressInfo } from 'node:net'
import type { Scenario } from './scenarios.js'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { ACCOUNT, APPS, SESSIONS, WORKSPACES } from './scenarios.js'
import { ACCOUNT, APPS, DSL_YAML, SESSIONS, WORKSPACES } from './scenarios.js'
export type DifyMockOptions = {
scenario?: Scenario
@ -19,6 +19,8 @@ export type DifyMock = {
lastRunBody: Record<string, unknown> | null
/** Number of times POST /apps/:id/files/upload was called */
uploadCallCount: number
/** Body of the most recent POST to /workspaces/:id/apps/imports */
lastImportBody: Record<string, unknown> | null
}
const TOKEN_RE = /^Bearer\s+dfo[ae]_[\w-]+$/
@ -106,6 +108,7 @@ function hitlResumedResponse(): string {
export type MockState = {
lastRunBody: Record<string, unknown> | null
uploadCallCount: number
lastImportBody: Record<string, unknown> | null
}
export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
@ -277,6 +280,38 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
})
})
app.get('/openapi/v1/apps/:id/export', (c) => {
const id = c.req.param('id')
const found = APPS.find(a => a.id === id)
if (found === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
return c.json({ data: DSL_YAML })
})
app.get('/openapi/v1/apps/:id/check-dependencies', (c) => {
const id = c.req.param('id')
const found = APPS.find(a => a.id === id)
if (found === undefined)
return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
return c.json({ leaked_dependencies: [] })
})
app.post('/openapi/v1/workspaces/:wsId/apps/imports', async (c) => {
const body = await c.req.json() as Record<string, unknown>
if (state !== undefined)
state.lastImportBody = body
const scenario = getScenario()
if (scenario === 'import-failed')
return c.json({ id: 'imp-1', status: 'failed', error: 'unsupported DSL version' }, { status: 200 })
if (scenario === 'import-pending')
return c.json({ id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }, { status: 202 })
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
})
app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId/confirm', (c) => {
return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
})
app.post('/openapi/v1/apps/:id/run', async (c) => {
const id = c.req.param('id')
const body = await c.req.json() as { query?: string, inputs?: unknown }
@ -393,7 +428,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> {
let scenario: Scenario = opts.scenario ?? 'happy'
const state: MockState = { lastRunBody: null, uploadCallCount: 0 }
const state: MockState = { lastRunBody: null, uploadCallCount: 0, lastImportBody: null }
const app = buildApp(() => scenario, state)
return new Promise((resolve, reject) => {
const server = serve({
@ -416,6 +451,7 @@ export function startMock(opts: DifyMockOptions = {}): Promise<DifyMock> {
},
get lastRunBody() { return state.lastRunBody },
get uploadCallCount() { return state.uploadCallCount },
get lastImportBody() { return state.lastImportBody },
})
})
server.on('error', reject)

View File

@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite-plus'
import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js'
@ -36,6 +37,12 @@ catch {
* Run: bun vitest --config vitest.e2e.config.ts
*/
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@test': fileURLToPath(new URL('./test', import.meta.url)),
},
},
pack: {
entry: ['src/index.ts'],
format: ['esm'],
@ -85,6 +92,8 @@ export default defineConfig({
'test/e2e/suites/framework/**/*.e2e.ts',
// discovery (get app / describe app)
'test/e2e/suites/discovery/**/*.e2e.ts',
// dsl (export / import)
'test/e2e/suites/dsl/**/*.e2e.ts',
// run tests (require valid token)
'test/e2e/suites/run/**/*.e2e.ts',
'test/e2e/suites/agent/**/*.e2e.ts',

View File

@ -255,11 +255,6 @@
"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
@ -2366,14 +2361,6 @@
"count": 2
}
},
"web/app/components/explore/try-app/tab.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/goto-anything/actions/commands/command-bus.ts": {
"ts/no-explicit-any": {
"count": 2
@ -3529,11 +3516,6 @@
"count": 5
}
},
"web/app/components/workflow/nodes/_base/components/collapse/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": {
"ts/no-explicit-any": {
"count": 6
@ -3721,17 +3703,6 @@
"count": 7
}
},
"web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -173,6 +173,7 @@ export type AgentAppComposerResponse = {
agent: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
save_options: Array<ComposerSaveStrategy>
validation?: ComposerValidationFindingsResponse
variant: string
}
@ -193,12 +194,15 @@ export type AgentComposerCandidatesResponse = {
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
capabilities?: ComposerCandidateCapabilities
truncated?: boolean
variant: ComposerVariant
}
export type AgentComposerValidateResponse = {
errors?: Array<string>
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
result: string
warnings?: Array<ComposerValidationWarningResponse>
}
export type AgentAppFeaturesPayload = {
@ -797,6 +801,7 @@ export type WorkflowAgentComposerResponse = {
node_job: WorkflowNodeJobConfig
save_options: Array<ComposerSaveStrategy>
soul_lock: AgentComposerSoulLockResponse
validation?: ComposerValidationFindingsResponse
variant: string
workflow_id?: string | null
}
@ -1099,6 +1104,11 @@ export type ComposerSaveStrategy
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerValidationFindingsResponse = {
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
warnings?: Array<ComposerValidationWarningResponse>
}
export type ComposerBindingPayload = {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
@ -1133,13 +1143,26 @@ export type AgentComposerSoulCandidatesResponse = {
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
skills_files?: Array<AgentSkillRefConfig>
skills_files?: Array<unknown>
}
export type ComposerCandidateCapabilities = {
human_roster_available?: boolean
}
export type ComposerKnowledgePlaceholderResponse = {
id: string
placeholder_name: string
}
export type ComposerValidationWarningResponse = {
code: string
id?: string | null
kind?: string | null
message?: string | null
surface?: string | null
}
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
@ -1732,15 +1755,31 @@ export type AgentKnowledgeDatasetConfig = {
[key: string]: unknown
}
export type AgentSkillRefConfig = {
export type AgentComposerSkillCandidateResponse = {
description?: string | null
file_id?: string | null
id?: string | null
kind?: string
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentComposerFileCandidateResponse = {
file_id?: string | null
id?: string | null
kind?: string
name?: string | null
reference?: string | null
remote_url?: string | null
tenant_id?: string | null
transfer_method?: string | null
type?: string | null
upload_file_id?: string | null
url?: string | null
[key: string]: unknown
}
export type AgentModerationProviderConfig = {
api_based_extension_id?: string | null
inputs_config?: AgentModerationIoConfig
@ -1941,6 +1980,15 @@ export type AgentFileRefConfig = {
[key: string]: unknown
}
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
id?: string | null
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'

View File

@ -77,14 +77,6 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
query: z.string().optional().default(''),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
result: z.string(),
})
/**
* SimpleResultResponse
*/
@ -802,6 +794,43 @@ export const zComposerCandidateCapabilities = z.object({
human_roster_available: z.boolean().optional().default(false),
})
/**
* ComposerKnowledgePlaceholderResponse
*/
export const zComposerKnowledgePlaceholderResponse = z.object({
id: z.string(),
placeholder_name: z.string(),
})
/**
* ComposerValidationWarningResponse
*/
export const zComposerValidationWarningResponse = z.object({
code: z.string(),
id: z.string().nullish(),
kind: z.string().nullish(),
message: z.string().nullish(),
surface: z.string().nullish(),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
result: z.string(),
warnings: z.array(zComposerValidationWarningResponse).optional(),
})
/**
* ComposerValidationFindingsResponse
*/
export const zComposerValidationFindingsResponse = z.object({
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
warnings: z.array(zComposerValidationWarningResponse).optional(),
})
/**
* AgentFeatureToggleConfig
*/
@ -1763,16 +1792,34 @@ export const zAgentKnowledgeDatasetConfig = z.object({
})
/**
* AgentSkillRefConfig
* AgentComposerSkillCandidateResponse
*/
export const zAgentSkillRefConfig = z.object({
export const zAgentComposerSkillCandidateResponse = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.string().optional().default('skill'),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentComposerFileCandidateResponse
*/
export const zAgentComposerFileCandidateResponse = z.object({
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.string().optional().default('file'),
name: z.string().max(255).nullish(),
reference: z.string().max(255).nullish(),
remote_url: z.string().nullish(),
tenant_id: z.string().max(255).nullish(),
transfer_method: z.string().max(64).nullish(),
type: z.string().max(64).nullish(),
upload_file_id: z.string().max(255).nullish(),
url: z.string().nullish(),
})
/**
* SimpleModelConfig
*/
@ -2156,14 +2203,6 @@ export const zAgentFileRefConfig = z.object({
url: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
export const zAgentSoulSkillsFilesConfig = z.object({
files: z.array(zAgentFileRefConfig).optional(),
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* WorkflowNodeJobMetadata
*/
@ -2172,6 +2211,25 @@ export const zWorkflowNodeJobMetadata = z.object({
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentSkillRefConfig
*/
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
export const zAgentSoulSkillsFilesConfig = z.object({
files: z.array(zAgentFileRefConfig).optional(),
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* AgentCliToolAuthorizationStatus
*
@ -2270,7 +2328,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z.array(zAgentSkillRefConfig).optional(),
skills_files: z.array(z.unknown()).optional(),
})
/**
@ -2280,6 +2338,7 @@ export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
truncated: z.boolean().optional().default(false),
variant: zComposerVariant,
})
@ -2557,6 +2616,7 @@ export const zAgentAppComposerResponse = z.object({
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
validation: zComposerValidationFindingsResponse.optional(),
variant: z.string(),
})
@ -2591,6 +2651,7 @@ export const zWorkflowAgentComposerResponse = z.object({
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
validation: zComposerValidationFindingsResponse.optional(),
variant: z.string(),
workflow_id: z.string().nullish(),
})

View File

@ -12,9 +12,14 @@ import {
zGetAccountResponse,
zGetAccountSessionsQuery,
zGetAccountSessionsResponse,
zGetAppsByAppIdCheckDependenciesPath,
zGetAppsByAppIdCheckDependenciesResponse,
zGetAppsByAppIdDescribePath,
zGetAppsByAppIdDescribeQuery,
zGetAppsByAppIdDescribeResponse,
zGetAppsByAppIdExportPath,
zGetAppsByAppIdExportQuery,
zGetAppsByAppIdExportResponse,
zGetAppsByAppIdFormHumanInputByFormTokenPath,
zGetAppsByAppIdFormHumanInputByFormTokenResponse,
zGetAppsByAppIdTasksByTaskIdEventsPath,
@ -51,6 +56,11 @@ import {
zPostOauthDeviceDenyResponse,
zPostOauthDeviceTokenBody,
zPostOauthDeviceTokenResponse,
zPostWorkspacesByWorkspaceIdAppsImportsBody,
zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath,
zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse,
zPostWorkspacesByWorkspaceIdAppsImportsPath,
zPostWorkspacesByWorkspaceIdAppsImportsResponse,
zPostWorkspacesByWorkspaceIdMembersBody,
zPostWorkspacesByWorkspaceIdMembersPath,
zPostWorkspacesByWorkspaceIdMembersResponse,
@ -151,6 +161,21 @@ export const account = {
}
export const get5 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdCheckDependencies',
path: '/apps/{app_id}/check-dependencies',
tags: ['openapi'],
})
.input(z.object({ params: zGetAppsByAppIdCheckDependenciesPath }))
.output(zGetAppsByAppIdCheckDependenciesResponse)
export const checkDependencies = {
get: get5,
}
export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -167,7 +192,24 @@ export const get5 = oc
.output(zGetAppsByAppIdDescribeResponse)
export const describe = {
get: get5,
get: get6,
}
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAppsByAppIdExport',
path: '/apps/{app_id}/export',
tags: ['openapi'],
})
.input(
z.object({ params: zGetAppsByAppIdExportPath, query: zGetAppsByAppIdExportQuery.optional() }),
)
.output(zGetAppsByAppIdExportResponse)
export const export_ = {
get: get7,
}
/**
@ -199,7 +241,7 @@ export const files = {
*
* @deprecated
*/
export const get6 = oc
export const get8 = oc
.route({
deprecated: true,
description:
@ -230,7 +272,7 @@ export const post2 = oc
.output(zPostAppsByAppIdFormHumanInputByFormTokenResponse)
export const byFormToken = {
get: get6,
get: get8,
post: post2,
}
@ -270,7 +312,7 @@ export const run = {
*
* @deprecated
*/
export const get7 = oc
export const get9 = oc
.route({
deprecated: true,
description:
@ -285,7 +327,7 @@ export const get7 = oc
.output(zGetAppsByAppIdTasksByTaskIdEventsResponse)
export const events = {
get: get7,
get: get9,
}
export const post4 = oc
@ -313,14 +355,16 @@ export const tasks = {
}
export const byAppId = {
checkDependencies,
describe,
export: export_,
files,
form,
run,
tasks,
}
export const get8 = oc
export const get10 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -332,7 +376,7 @@ export const get8 = oc
.output(zGetAppsResponse)
export const apps = {
get: get8,
get: get10,
byAppId,
}
@ -381,7 +425,7 @@ export const deny = {
post: post7,
}
export const get9 = oc
export const get11 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -393,7 +437,7 @@ export const get9 = oc
.output(zGetOauthDeviceLookupResponse)
export const lookup = {
get: get9,
get: get11,
}
/**
@ -431,7 +475,7 @@ export const oauth = {
device,
}
export const get10 = oc
export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -443,7 +487,51 @@ export const get10 = oc
.output(zGetPermittedExternalAppsResponse)
export const permittedExternalApps = {
get: get10,
get: get12,
}
export const post9 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdAppsImportsByImportIdConfirm',
path: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm',
tags: ['openapi'],
})
.input(z.object({ params: zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath }))
.output(zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse)
export const confirm = {
post: post9,
}
export const byImportId = {
confirm,
}
export const post10 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdAppsImports',
path: '/workspaces/{workspace_id}/apps/imports',
tags: ['openapi'],
})
.input(
z.object({
body: zPostWorkspacesByWorkspaceIdAppsImportsBody,
params: zPostWorkspacesByWorkspaceIdAppsImportsPath,
}),
)
.output(zPostWorkspacesByWorkspaceIdAppsImportsResponse)
export const imports = {
post: post10,
byImportId,
}
export const apps2 = {
imports,
}
export const put = oc
@ -482,7 +570,7 @@ export const byMemberId = {
role,
}
export const get11 = oc
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -498,7 +586,7 @@ export const get11 = oc
)
.output(zGetWorkspacesByWorkspaceIdMembersResponse)
export const post9 = oc
export const post11 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -516,12 +604,12 @@ export const post9 = oc
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
export const members = {
get: get11,
post: post9,
get: get13,
post: post11,
byMemberId,
}
export const post10 = oc
export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -533,10 +621,10 @@ export const post10 = oc
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
export const switch_ = {
post: post10,
post: post12,
}
export const get12 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -548,12 +636,13 @@ export const get12 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
get: get12,
get: get14,
apps: apps2,
members,
switch: switch_,
}
export const get13 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -564,7 +653,7 @@ export const get13 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get13,
get: get15,
byWorkspaceId,
}

View File

@ -45,6 +45,27 @@ export type AppDescribeResponse = {
} | null
}
export type AppDslExportQuery = {
include_secret?: boolean
workflow_id?: string | null
}
export type AppDslExportResponse = {
data: string
}
export type AppDslImportPayload = {
app_id?: string | null
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
mode: 'yaml-content' | 'yaml-url'
name?: string | null
yaml_content?: string | null
yaml_url?: string | null
}
export type AppInfoResponse = {
author?: string | null
description?: string | null
@ -107,6 +128,10 @@ export type AppRunRequest = {
workspace_id?: string | null
}
export type CheckDependenciesResult = {
leaked_dependencies?: Array<PluginDependency>
}
export type DeviceCodeRequest = {
client_id: string
device_label: string
@ -179,6 +204,13 @@ export type FormSubmitResponse = {
[key: string]: never
}
export type Github = {
github_plugin_unique_identifier: string
package: string
repo: string
version: string
}
export type HealthResponse = {
ok: boolean
}
@ -190,8 +222,25 @@ export type HumanInputFormSubmitPayload = {
}
}
export type Import = {
app_id?: string | null
app_mode?: string | null
current_dsl_version?: string
error?: string
id: string
imported_dsl_version?: string
status: ImportStatus
}
export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending'
export type JsonValue = unknown
export type Marketplace = {
marketplace_plugin_unique_identifier: string
version?: string | null
}
export type MemberActionResponse = {
result?: string
}
@ -243,6 +292,11 @@ export type MessageMetadata = {
usage?: UsageInfo
}
export type Package = {
plugin_unique_identifier: string
version?: string | null
}
export type PermittedExternalAppsListQuery = {
limit?: number
mode?: AppMode
@ -258,6 +312,12 @@ export type PermittedExternalAppsListResponse = {
total: number
}
export type PluginDependency = {
current_identifier?: string | null
type: Type
value: unknown
}
export type RevokeResponse = {
status: string
}
@ -298,6 +358,8 @@ export type TaskStopResponse = {
result: string
}
export type Type = 'github' | 'marketplace' | 'package'
export type UsageInfo = {
completion_tokens?: number
prompt_tokens?: number
@ -498,6 +560,22 @@ export type GetAppsResponses = {
export type GetAppsResponse = GetAppsResponses[keyof GetAppsResponses]
export type GetAppsByAppIdCheckDependenciesData = {
body?: never
path: {
app_id: string
}
query?: never
url: '/apps/{app_id}/check-dependencies'
}
export type GetAppsByAppIdCheckDependenciesResponses = {
200: CheckDependenciesResult
}
export type GetAppsByAppIdCheckDependenciesResponse
= GetAppsByAppIdCheckDependenciesResponses[keyof GetAppsByAppIdCheckDependenciesResponses]
export type GetAppsByAppIdDescribeData = {
body?: never
path: {
@ -524,6 +602,25 @@ export type GetAppsByAppIdDescribeResponses = {
export type GetAppsByAppIdDescribeResponse
= GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses]
export type GetAppsByAppIdExportData = {
body?: never
path: {
app_id: string
}
query?: {
include_secret?: boolean
workflow_id?: string
}
url: '/apps/{app_id}/export'
}
export type GetAppsByAppIdExportResponses = {
200: AppDslExportResponse
}
export type GetAppsByAppIdExportResponse
= GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses]
export type PostAppsByAppIdFilesUploadData = {
body?: never
path: {
@ -813,6 +910,54 @@ export type GetWorkspacesByWorkspaceIdResponses = {
export type GetWorkspacesByWorkspaceIdResponse
= GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses]
export type PostWorkspacesByWorkspaceIdAppsImportsData = {
body: AppDslImportPayload
path: {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/apps/imports'
}
export type PostWorkspacesByWorkspaceIdAppsImportsErrors = {
400: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsError
= PostWorkspacesByWorkspaceIdAppsImportsErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsErrors]
export type PostWorkspacesByWorkspaceIdAppsImportsResponses = {
200: Import
202: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsResponse
= PostWorkspacesByWorkspaceIdAppsImportsResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsResponses]
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
body?: never
path: {
import_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm'
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
400: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError
= PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors]
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses = {
200: Import
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse
= PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses]
export type GetWorkspacesByWorkspaceIdMembersData = {
body?: never
path: {

View File

@ -22,6 +22,42 @@ export const zAppDescribeQuery = z.object({
fields: z.string().optional(),
})
/**
* AppDslExportQuery
*
* Query parameters for GET /apps/<app_id>/export.
*/
export const zAppDslExportQuery = z.object({
include_secret: z.boolean().optional().default(false),
workflow_id: z.string().nullish(),
})
/**
* AppDslExportResponse
*
* Export DSL response.
*/
export const zAppDslExportResponse = z.object({
data: z.string(),
})
/**
* AppDslImportPayload
*
* Request body for POST /workspaces/<workspace_id>/apps/imports.
*/
export const zAppDslImportPayload = z.object({
app_id: z.string().nullish(),
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
mode: z.enum(['yaml-content', 'yaml-url']),
name: z.string().nullish(),
yaml_content: z.string().nullish(),
yaml_url: z.string().nullish(),
})
/**
* AppMode
*/
@ -174,6 +210,16 @@ export const zFileResponse = z.object({
*/
export const zFormSubmitResponse = z.record(z.string(), z.never())
/**
* Github
*/
export const zGithub = z.object({
github_plugin_unique_identifier: z.string(),
package: z.string(),
repo: z.string(),
version: z.string(),
})
/**
* HealthResponse
*
@ -183,6 +229,24 @@ export const zHealthResponse = z.object({
ok: z.boolean(),
})
/**
* ImportStatus
*/
export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending'])
/**
* Import
*/
export const zImport = z.object({
app_id: z.string().nullish(),
app_mode: z.string().nullish(),
current_dsl_version: z.string().optional().default('0.6.0'),
error: z.string().optional().default(''),
id: z.string(),
imported_dsl_version: z.string().optional().default(''),
status: zImportStatus,
})
export const zJsonValue = z.unknown()
/**
@ -193,6 +257,14 @@ export const zHumanInputFormSubmitPayload = z.object({
inputs: z.record(z.string(), zJsonValue),
})
/**
* Marketplace
*/
export const zMarketplace = z.object({
marketplace_plugin_unique_identifier: z.string(),
version: z.string().nullish(),
})
/**
* MemberActionResponse
*/
@ -260,6 +332,14 @@ export const zMemberRoleUpdatePayload = z.object({
role: z.enum(['admin', 'normal']),
})
/**
* Package
*/
export const zPackage = z.object({
plugin_unique_identifier: z.string(),
version: z.string().nullish(),
})
/**
* PermittedExternalAppsListQuery
*
@ -414,6 +494,27 @@ export const zTaskStopResponse = z.object({
result: z.string(),
})
/**
* Type
*/
export const zType = z.enum(['github', 'marketplace', 'package'])
/**
* PluginDependency
*/
export const zPluginDependency = z.object({
current_identifier: z.string().nullish(),
type: zType,
value: z.unknown(),
})
/**
* CheckDependenciesResult
*/
export const zCheckDependenciesResult = z.object({
leaked_dependencies: z.array(zPluginDependency).optional(),
})
/**
* UsageInfo
*/
@ -551,6 +652,15 @@ export const zGetAppsQuery = z.object({
*/
export const zGetAppsResponse = zAppListResponse
export const zGetAppsByAppIdCheckDependenciesPath = z.object({
app_id: z.string(),
})
/**
* Dependencies checked
*/
export const zGetAppsByAppIdCheckDependenciesResponse = zCheckDependenciesResult
export const zGetAppsByAppIdDescribePath = z.object({
app_id: z.string(),
})
@ -564,6 +674,20 @@ export const zGetAppsByAppIdDescribeQuery = z.object({
*/
export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse
export const zGetAppsByAppIdExportPath = z.object({
app_id: z.string(),
})
export const zGetAppsByAppIdExportQuery = z.object({
include_secret: z.boolean().optional().default(false),
workflow_id: z.string().optional(),
})
/**
* Export successful
*/
export const zGetAppsByAppIdExportResponse = zAppDslExportResponse
export const zPostAppsByAppIdFilesUploadPath = z.object({
app_id: z.string(),
})
@ -689,6 +813,27 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({
*/
export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse
export const zPostWorkspacesByWorkspaceIdAppsImportsBody = zAppDslImportPayload
export const zPostWorkspacesByWorkspaceIdAppsImportsPath = z.object({
workspace_id: z.string(),
})
/**
* Import completed
*/
export const zPostWorkspacesByWorkspaceIdAppsImportsResponse = zImport
export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath = z.object({
import_id: z.string(),
workspace_id: z.string(),
})
/**
* Import confirmed
*/
export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse = zImport
export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({
workspace_id: z.string(),
})

View File

@ -47,7 +47,7 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
| Display | `./collapsible`, `./kbd` | Collapsible disclosure primitive; keyboard input and shortcut keycap primitives. |
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |

View File

@ -37,6 +37,10 @@
"types": "./src/combobox/index.tsx",
"import": "./src/combobox/index.tsx"
},
"./collapsible": {
"types": "./src/collapsible/index.tsx",
"import": "./src/collapsible/index.tsx"
},
"./context-menu": {
"types": "./src/context-menu/index.tsx",
"import": "./src/context-menu/index.tsx"

View File

@ -0,0 +1,89 @@
import { render } from 'vitest-browser-react'
import {
CollapsiblePanel,
CollapsibleRoot,
CollapsibleTrigger,
} from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Collapsible wrappers', () => {
it('renders the Base UI anatomy with an accessible trigger', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen data-testid="collapsible-root">
<CollapsibleTrigger>Recovery keys</CollapsibleTrigger>
<CollapsiblePanel>Panel content</CollapsiblePanel>
</CollapsibleRoot>,
)
await expect.element(screen.getByTestId('collapsible-root')).toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Recovery keys' })).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Panel content')).toBeInTheDocument()
})
it('toggles open state through the trigger without caller-owned state', async () => {
const screen = await render(
<CollapsibleRoot>
<CollapsibleTrigger>Toggle section</CollapsibleTrigger>
<CollapsiblePanel>Hidden content</CollapsiblePanel>
</CollapsibleRoot>,
)
const trigger = screen.getByRole('button', { name: 'Toggle section' })
await expect.element(trigger).not.toHaveAttribute('data-panel-open')
asHTMLElement(trigger.element()).click()
await expect.element(trigger).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Hidden content')).toBeInTheDocument()
})
it('forwards className to every compound part', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen className="custom-root">
<CollapsibleTrigger className="custom-trigger">Custom</CollapsibleTrigger>
<CollapsiblePanel className="custom-panel">Custom panel</CollapsiblePanel>
</CollapsibleRoot>,
)
await expect.element(screen.getByRole('button', { name: 'Custom' })).toHaveClass('custom-trigger')
expect(screen.getByText('Custom panel').element()).toHaveClass('custom-panel')
expect(screen.container.querySelector('.custom-root')).toBeInTheDocument()
})
it('passes Base UI panel props through to the panel', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen>
<CollapsibleTrigger>Styled trigger</CollapsibleTrigger>
<CollapsiblePanel keepMounted>Styled panel</CollapsiblePanel>
</CollapsibleRoot>,
)
await expect.element(screen.getByRole('button', { name: 'Styled trigger' })).toHaveAttribute('data-panel-open', '')
await expect.element(screen.getByText('Styled panel')).toBeInTheDocument()
})
it('applies Dify disclosure defaults without a pressed active style', async () => {
const screen = await render(
<CollapsibleRoot defaultOpen>
<CollapsibleTrigger>Styled trigger</CollapsibleTrigger>
<CollapsiblePanel>Styled panel</CollapsiblePanel>
</CollapsibleRoot>,
)
const trigger = screen.getByRole('button', { name: 'Styled trigger' }).element()
const panel = screen.getByText('Styled panel').element()
expect(trigger).toHaveClass(
'hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover',
'focus-visible:ring-2',
'focus-visible:ring-state-accent-solid',
'data-panel-open:text-text-primary',
)
expect(trigger.className).not.toContain('active:')
expect(panel).toHaveClass(
'h-(--collapsible-panel-height)',
'data-ending-style:h-0',
'data-starting-style:h-0',
)
})
})

View File

@ -0,0 +1,183 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import {
CollapsiblePanel,
CollapsibleRoot,
CollapsibleTrigger,
} from '.'
import { cn } from '../cn'
const meta = {
title: 'Base/UI/Collapsible',
component: CollapsibleRoot,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Unstyled Base UI Collapsible primitive. The examples mirror the official Root, Trigger, and Panel anatomy, with presentation supplied at the call site using Dify UI tokens.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof CollapsibleRoot>
export default meta
type Story = StoryObj<typeof meta>
const rootClassName = 'w-72 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'
const triggerClassName = 'h-8'
const panelClassName = 'system-sm-regular text-text-secondary'
const contentClassName = 'flex flex-col gap-2 px-2.5 pb-2 pt-1'
const iconClassName = 'i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open:rotate-90 motion-reduce:transition-none'
const sectionRootClassName = 'w-90 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'
const sectionTriggerClassName = cn(
triggerClassName,
'h-auto min-h-12 px-3 py-2',
)
const sectionPanelClassName = panelClassName
function TriggerIcon() {
return <span aria-hidden="true" className={iconClassName} />
}
function RecoveryKeys({
panelProps,
}: {
panelProps?: React.ComponentProps<typeof CollapsiblePanel>
}) {
return (
<>
<CollapsibleTrigger className={triggerClassName}>
Recovery keys
<TriggerIcon />
</CollapsibleTrigger>
<CollapsiblePanel className={panelClassName} {...panelProps}>
<div className={contentClassName}>
<div>alien-bean-pasta</div>
<div>wild-irish-burrito</div>
<div>horse-battery-staple</div>
</div>
</CollapsiblePanel>
</>
)
}
export const Anatomy: Story = {
args: {
defaultOpen: true,
},
render: args => (
<CollapsibleRoot {...args} className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const DefaultClosed: Story = {
render: () => (
<CollapsibleRoot className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const DefaultOpen: Story = {
render: () => (
<CollapsibleRoot defaultOpen className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const Controlled: Story = {
render: () => {
const [open, setOpen] = React.useState(true)
return (
<div className="flex flex-col items-start gap-3">
<button
type="button"
className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 system-sm-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 hover:bg-state-base-hover focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setOpen(value => !value)}
>
{open ? 'Close panel' : 'Open panel'}
</button>
<CollapsibleRoot open={open} onOpenChange={setOpen} className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
</div>
)
},
}
export const Disabled: Story = {
render: () => (
<CollapsibleRoot disabled className={rootClassName}>
<RecoveryKeys />
</CollapsibleRoot>
),
}
export const KeepMounted: Story = {
render: () => (
<CollapsibleRoot className={rootClassName}>
<RecoveryKeys panelProps={{ keepMounted: true }} />
</CollapsibleRoot>
),
}
export const HiddenUntilFound: Story = {
render: () => (
<CollapsibleRoot className={rootClassName}>
<RecoveryKeys panelProps={{ hiddenUntilFound: true }} />
</CollapsibleRoot>
),
}
const settingSections = [
{
title: 'Model routing',
description: 'Fallback model enabled, retry budget set to 2 attempts.',
defaultOpen: true,
},
{
title: 'Knowledge access',
description: 'Retrieval is limited to approved workspace datasets.',
defaultOpen: false,
},
{
title: 'Observability',
description: 'Request logs and workflow traces stay available for debugging.',
defaultOpen: false,
},
] as const
export const SettingsSections: Story = {
parameters: {
layout: 'padded',
},
render: () => (
<div className={sectionRootClassName}>
{settingSections.map((section, index) => (
<CollapsibleRoot
key={section.title}
defaultOpen={section.defaultOpen}
className={cn(index > 0 && 'mt-px')}
>
<CollapsibleTrigger className={sectionTriggerClassName}>
<span className="flex min-w-0 flex-col gap-1">
<span className="truncate system-sm-medium text-text-primary">{section.title}</span>
<span className="line-clamp-2 system-xs-regular text-text-tertiary">{section.description}</span>
</span>
<TriggerIcon />
</CollapsibleTrigger>
<CollapsiblePanel className={sectionPanelClassName}>
<div className="px-3 pb-3 pt-1 system-sm-regular text-text-secondary">
{section.description}
</div>
</CollapsiblePanel>
</CollapsibleRoot>
))}
</div>
),
}

View File

@ -0,0 +1,71 @@
'use client'
import type { Collapsible as BaseCollapsibleNS } from '@base-ui/react/collapsible'
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
import { cn } from '../cn'
export type CollapsibleRootProps
= Omit<BaseCollapsibleNS.Root.Props, 'className'>
& {
className?: string
}
export function CollapsibleRoot({
className,
...props
}: CollapsibleRootProps) {
return (
<BaseCollapsible.Root
className={cn('flex min-w-0 flex-col', className)}
{...props}
/>
)
}
export type CollapsibleTriggerProps
= Omit<BaseCollapsibleNS.Trigger.Props, 'className'>
& {
className?: string
}
export function CollapsibleTrigger({
className,
...props
}: CollapsibleTriggerProps) {
return (
<BaseCollapsible.Trigger
className={cn(
'group flex min-h-8 w-full touch-manipulation items-center justify-between gap-2 rounded-lg px-2.5 text-left system-sm-medium text-text-secondary outline-hidden select-none',
'hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover hover:not-data-disabled:text-text-primary',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-panel-open:text-text-primary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent',
className,
)}
{...props}
/>
)
}
export type CollapsiblePanelProps
= Omit<BaseCollapsibleNS.Panel.Props, 'className'>
& {
className?: string
}
export function CollapsiblePanel({
className,
...props
}: CollapsiblePanelProps) {
return (
<BaseCollapsible.Panel
className={cn(
'h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-150 ease-out motion-reduce:transition-none',
'[&[hidden]:not([hidden=\'until-found\'])]:hidden',
'data-ending-style:h-0 data-starting-style:h-0',
className,
)}
{...props}
/>
)
}

View File

@ -39,10 +39,15 @@ describe('Tabs wrappers', () => {
await expect.element(screen.getByRole('tablist')).toHaveClass(
'flex',
'gap-4',
)
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass(
'touch-manipulation',
'focus-visible:outline-hidden',
'border-b-2',
'border-transparent',
'data-active:border-components-tab-active',
'data-active:text-text-primary',
)
})

View File

@ -26,17 +26,11 @@ type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<Tabs defaultValue="overview" className="w-96">
<TabsList className="gap-4 border-b border-divider-subtle">
<TabsTab
value="overview"
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
>
<TabsList>
<TabsTab value="overview">
Overview
</TabsTab>
<TabsTab
value="activity"
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
>
<TabsTab value="activity">
Activity
</TabsTab>
</TabsList>

View File

@ -18,7 +18,7 @@ export function TabsList({
}: TabsListProps) {
return (
<BaseTabs.List
className={cn('flex', className)}
className={cn('flex gap-4', className)}
{...props}
/>
)
@ -34,7 +34,7 @@ export function TabsTab({
}: TabsTabProps) {
return (
<BaseTabs.Tab
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)}
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold text-text-tertiary data-active:border-components-tab-active data-active:text-text-primary data-disabled:cursor-not-allowed data-disabled:text-text-tertiary data-disabled:opacity-30 data-active:data-disabled:text-text-primary', className)}
{...props}
/>
)

View File

@ -197,13 +197,13 @@ describe('TextGeneration', () => {
expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('Gamma')
})
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' }))
expect(screen.getByRole('button', { name: 'run-batch' })).toBeInTheDocument()
fireEvent.click(screen.getByTestId('tab-header-item-saved'))
fireEvent.click(screen.getByRole('tab', { name: /^share\.generation\.tabs\.saved/ }))
expect(screen.getByTestId('saved-items-mock')).toHaveTextContent('2')
fireEvent.click(screen.getByTestId('tab-header-item-create'))
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.create' }))
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
})
@ -220,7 +220,7 @@ describe('TextGeneration', () => {
})
expect(screen.getByTestId('result-single')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' }))
fireEvent.click(screen.getByRole('button', { name: 'run-batch' }))
await waitFor(() => {
expect(screen.getByText('idle')).toBeInTheDocument()

View File

@ -46,8 +46,8 @@ const NavLink = ({
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')
? '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')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>

View File

@ -1,114 +0,0 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TabHeader from '../index'
describe('TabHeader Component', () => {
const mockItems = [
{ id: 'tab1', name: 'General' },
{ id: 'tab2', name: 'Settings' },
{ id: 'tab3', name: 'Profile', isRight: true },
{ id: 'tab4', name: 'Disabled Tab', disabled: true },
]
it('should render all items with correct names', () => {
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('Profile')).toBeInTheDocument()
expect(screen.getByText('Disabled Tab')).toBeInTheDocument()
})
it('should separate items into left and right containers correctly', () => {
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
const leftContainer = screen.getByTestId('tab-header-left')
const rightContainer = screen.getByTestId('tab-header-right')
// Verify children count
expect(leftContainer.children.length).toBe(3)
expect(rightContainer.children.length).toBe(1)
// Verify specific item placement using within and toContainElement
const profileTab = screen.getByTestId('tab-header-item-tab3')
expect(rightContainer).toContainElement(profileTab)
const disabledTab = screen.getByTestId('tab-header-item-tab4')
expect(leftContainer).toContainElement(disabledTab)
})
it('should apply active styles to the selected tab', () => {
const activeClass = 'custom-active-style'
render(
<TabHeader
items={mockItems}
value="tab2"
activeItemClassName={activeClass}
onChange={() => { }}
/>,
)
const activeTab = screen.getByTestId('tab-header-item-tab2')
expect(activeTab).toHaveClass('border-components-tab-active')
expect(activeTab).toHaveClass(activeClass)
const inactiveTab = screen.getByTestId('tab-header-item-tab1')
expect(inactiveTab).toHaveClass('text-text-tertiary')
})
it('should call onChange when a non-disabled tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
await user.click(screen.getByText('Settings'))
expect(handleChange).toHaveBeenCalledWith('tab2')
})
it('should not call onChange when a disabled tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
const disabledTab = screen.getByTestId('tab-header-item-tab4')
expect(disabledTab).toHaveClass('cursor-not-allowed')
await user.click(disabledTab)
expect(handleChange).not.toHaveBeenCalled()
})
it('should render icon and extra content when provided', () => {
const itemsWithExtras = [
{
id: 'extra',
name: 'Extra',
icon: <span data-testid="tab-icon">🚀</span>,
extra: <span data-testid="tab-extra">New</span>,
},
]
render(<TabHeader items={itemsWithExtras} value="extra" onChange={() => { }} />)
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
expect(screen.getByTestId('tab-extra')).toBeInTheDocument()
})
it('should apply custom class names for items and wrappers', () => {
render(
<TabHeader
items={mockItems}
value="tab1"
itemClassName="my-text-class"
itemWrapClassName="my-wrap-class"
onChange={() => { }}
/>,
)
const tabWrap = screen.getByTestId('tab-header-item-tab1')
// We target the inner div for the name class check
const tabText = within(tabWrap).getByText('General')
expect(tabWrap).toHaveClass('my-wrap-class')
expect(tabText).toHaveClass('my-text-class')
})
})

View File

@ -1,66 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ITabHeaderProps } from '.'
import { useState } from 'react'
import TabHeader from '.'
const items: ITabHeaderProps['items'] = [
{ id: 'overview', name: 'Overview' },
{ id: 'playground', name: 'Playground' },
{ id: 'changelog', name: 'Changelog', extra: <span className="ml-1 rounded-full bg-primary-50 px-2 py-0.5 text-xs text-primary-600">New</span> },
{ id: 'docs', name: 'Docs', isRight: true },
{ id: 'settings', name: 'Settings', isRight: true, disabled: true },
]
const TabHeaderDemo = ({
initialTab = 'overview',
}: {
initialTab?: string
}) => {
const [activeTab, setActiveTab] = useState(initialTab)
return (
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Tabs</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
active="
{activeTab}
"
</code>
</div>
<TabHeader
items={items}
value={activeTab}
onChange={setActiveTab}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/TabHeader',
component: TabHeaderDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.',
},
},
},
argTypes: {
initialTab: {
control: 'radio',
options: items.map(item => item.id),
},
},
args: {
initialTab: 'overview',
},
tags: ['autodocs'],
} satisfies Meta<typeof TabHeaderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -1,60 +0,0 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
type Item = {
id: string
name: string
isRight?: boolean
icon?: React.ReactNode
extra?: React.ReactNode
disabled?: boolean
}
export type ITabHeaderProps = Readonly<{
items: Item[]
value: string
itemClassName?: string
itemWrapClassName?: string
activeItemClassName?: string
onChange: (value: string) => void
}>
const TabHeader: FC<ITabHeaderProps> = ({
items,
value,
itemClassName,
itemWrapClassName,
activeItemClassName,
onChange,
}) => {
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
<div
key={id}
data-testid={`tab-header-item-${id}`}
className={cn(
'relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold',
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
disabled && 'cursor-not-allowed opacity-30',
itemWrapClassName,
)}
onClick={() => !disabled && onChange(id)}
>
{icon || ''}
<div className={cn('ml-2', itemClassName)}>{name}</div>
{extra || ''}
</div>
)
return (
<div data-testid="tab-header" className="flex justify-between">
<div data-testid="tab-header-left" className="flex space-x-4">
{items.filter(item => !item.isRight).map(renderItem)}
</div>
<div data-testid="tab-header-right" className="flex space-x-4">
{items.filter(item => item.isRight).map(renderItem)}
</div>
</div>
)
}
export default React.memo(TabHeader)

View File

@ -3,7 +3,7 @@ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import TryApp from '../index'
import { TypeEnum } from '../tab'
import { TypeEnum } from '../types'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal() as object
@ -213,8 +213,7 @@ describe('TryApp (main index.tsx)', () => {
)
await waitFor(() => {
const buttons = document.body.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
})
})
@ -281,16 +280,11 @@ describe('TryApp (main index.tsx)', () => {
)
await waitFor(() => {
const buttons = document.body.querySelectorAll('button')
const closeButton = Array.from(buttons).find(btn =>
btn.querySelector('svg') || btn.className.includes('rounded-[10px]'),
)
expect(closeButton).toBeInTheDocument()
if (closeButton)
fireEvent.click(closeButton)
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(mockOnClose).toHaveBeenCalled()
})

View File

@ -1,54 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Tab, { TypeEnum } from '../tab'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal() as object
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
describe('Tab', () => {
afterEach(() => {
cleanup()
})
it('renders tab with TRY value selected', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
})
it('renders tab with DETAIL value selected', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
})
it('calls onChange when clicking a tab', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL)
})
it('calls onChange when clicking Try tab', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
})
it('exports TypeEnum correctly', () => {
expect(TypeEnum.TRY).toBe('try')
expect(TypeEnum.DETAIL).toBe('detail')
})
})

View File

@ -4,9 +4,11 @@ import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { IS_CLOUD_EDITION } from '@/config'
@ -15,7 +17,7 @@ import { useGetTryAppInfo } from '@/service/use-try-app'
import App from './app'
import AppInfo from './app-info'
import Preview from './preview'
import Tab, { TypeEnum } from './tab'
import { TypeEnum } from './types'
type Props = {
appId: string
@ -32,6 +34,7 @@ const TryApp: FC<Props> = ({
onClose,
onCreate,
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
@ -62,25 +65,49 @@ const TryApp: FC<Props> = ({
<AppUnavailable className="size-auto" isUnknownReason />
</div>
) : (
<div className="flex h-full flex-col">
<Tabs
value={activeType}
onValueChange={selectedValue => setType(selectedValue)}
className="flex h-full flex-col"
>
<div className="flex shrink-0 justify-between pl-4">
<Tab
value={activeType}
onChange={setType}
disableTry={app ? !isTrialApp : false}
/>
<TabsList>
{IS_CLOUD_EDITION && (
<TabsTab
value={TypeEnum.TRY}
disabled={app ? !isTrialApp : false}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.try', { ns: 'explore' })}</span>
</TabsTab>
)}
<TabsTab
value={TypeEnum.DETAIL}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.detail', { ns: 'explore' })}</span>
</TabsTab>
</TabsList>
<Button
size="large"
variant="tertiary"
aria-label={t('common.operation.close')}
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
onClick={onClose}
>
<span className="i-ri-close-line size-5" />
<span aria-hidden className="i-ri-close-line size-5" />
</Button>
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />}
{IS_CLOUD_EDITION && (
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
<App appId={appId} appDetail={appDetail} />
</TabsPanel>
)}
<TabsPanel value={TypeEnum.DETAIL} className="min-w-0 flex-1">
<Preview appId={appId} appDetail={appDetail} />
</TabsPanel>
<AppInfo
className="w-[360px] shrink-0"
appDetail={appDetail}
@ -89,7 +116,7 @@ const TryApp: FC<Props> = ({
onCreate={onCreate}
/>
</div>
</div>
</Tabs>
)}
</DialogContent>
</Dialog>

View File

@ -1,43 +0,0 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import TabHeader from '../../base/tab-header'
export enum TypeEnum {
TRY = 'try',
DETAIL = 'detail',
}
type Props = {
value: TypeEnum
onChange: (value: TypeEnum) => void
disableTry?: boolean
}
const Tab: FC<Props> = ({
value,
onChange,
disableTry,
}) => {
const { t } = useTranslation()
const tabs = React.useMemo(() => {
return [
IS_CLOUD_EDITION ? { id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }), disabled: disableTry } : null,
{ id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) },
].filter(item => item !== null) as { id: TypeEnum, name: string }[]
}, [t, disableTry])
return (
<TabHeader
items={tabs}
value={value}
onChange={onChange as (value: string) => void}
itemClassName="ml-0 system-md-semibold-uppercase"
itemWrapClassName="pt-2"
activeItemClassName="border-util-colors-blue-brand-blue-brand-500"
/>
)
}
export default React.memo(Tab)

View File

@ -0,0 +1,6 @@
export const TypeEnum = {
TRY: 'try',
DETAIL: 'detail',
} as const
export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum]

View File

@ -113,6 +113,7 @@ describe('TextGenerationSidebar', () => {
expect(screen.getByText('Text Generation')).toBeInTheDocument()
expect(screen.getByText('Share description')).toBeInTheDocument()
expect(screen.getByRole('tablist')).toHaveClass('w-full')
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
expect(runOncePropsSpy).toHaveBeenCalledWith(expect.objectContaining({
inputs: { name: 'Alice' },
@ -134,7 +135,7 @@ describe('TextGenerationSidebar', () => {
vars: promptConfig.prompt_variables,
isAllFinished: true,
}))
expect(screen.queryByTestId('tab-header-item-saved')).not.toBeInTheDocument()
expect(screen.queryByRole('tab', { name: /^share\.generation\.tabs\.saved/ })).not.toBeInTheDocument()
})
it('should render saved items and allow switching back to create tab', () => {

View File

@ -5,6 +5,7 @@ import type { PromptConfig, SavedMessage, TextToSpeechConfig } from '@/models/de
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useTranslation } from 'react-i18next'
import SavedItems from '@/app/components/app/text-generate/saved-items'
import AppIcon from '@/app/components/base/app-icon'
@ -12,7 +13,6 @@ import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { appDefaultIconBackground } from '@/config'
import { AccessMode } from '@/models/access-control'
import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import RunOnce from './run-once'
@ -71,7 +71,9 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
const { t } = useTranslation()
return (
<div
<Tabs
value={currentTab}
onValueChange={onTabChange}
className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%-64px)]' : '',
@ -93,29 +95,25 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
<TabHeader
items={[
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <span aria-hidden className="i-ri-bookmark-3-line size-4" />,
extra: savedMessages.length > 0
? (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={onTabChange}
/>
<TabsList className="w-full">
<TabsTab value="create">
<span className="ml-2">{t('generation.tabs.create', { ns: 'share' })}</span>
</TabsTab>
<TabsTab value="batch">
<span className="ml-2">{t('generation.tabs.batch', { ns: 'share' })}</span>
</TabsTab>
{!isWorkflow && (
<TabsTab value="saved" className="ml-auto">
<span aria-hidden className="i-ri-bookmark-3-line size-4" />
<span className="ml-2">{t('generation.tabs.saved', { ns: 'share' })}</span>
{savedMessages.length > 0 && (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)}
</TabsTab>
)}
</TabsList>
</div>
<div
className={cn(
@ -124,7 +122,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<TabsPanel value="create" keepMounted>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
@ -136,22 +134,24 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
onVisionFilesChange={onVisionFilesChange}
runControl={runControl}
/>
</div>
<div className={cn(currentTab === 'batch' ? 'block' : 'hidden')}>
</TabsPanel>
<TabsPanel value="batch" keepMounted>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={onBatchSend}
isAllFinished={allTasksRun}
/>
</div>
{currentTab === 'saved' && (
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
</TabsPanel>
{!isWorkflow && (
<TabsPanel value="saved">
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
</TabsPanel>
)}
</div>
{!customConfig?.remove_webapp_brand && (
@ -170,7 +170,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
: <DifyLogo size="small" />}
</div>
)}
</div>
</Tabs>
)
}

View File

@ -1,6 +1,47 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Collapse from '../index'
import {
Collapse,
CollapseActions,
CollapseContent,
CollapseHeader,
CollapseIndicator,
CollapseTitle,
CollapseTrigger,
} from '../index'
function TestCollapse({
children,
title,
actions,
...props
}: {
title: string
children: React.ReactNode
actions?: React.ReactNode
disabled?: boolean
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}) {
return (
<Collapse {...props}>
<CollapseHeader>
<CollapseTrigger>
<CollapseTitle>{title}</CollapseTitle>
<CollapseIndicator />
</CollapseTrigger>
{actions != null && (
<CollapseActions>
{actions}
</CollapseActions>
)}
</CollapseHeader>
<CollapseContent>
{children}
</CollapseContent>
</Collapse>
)
}
describe('Collapse', () => {
beforeEach(() => {
@ -14,12 +55,12 @@ describe('Collapse', () => {
const onCollapse = vi.fn()
render(
<Collapse
trigger={<div>Advanced</div>}
<TestCollapse
title="Advanced"
onCollapse={onCollapse}
>
<div>Collapse content</div>
</Collapse>,
</TestCollapse>,
)
expect(screen.queryByText('Collapse content')).not.toBeInTheDocument()
@ -35,13 +76,13 @@ describe('Collapse', () => {
const onCollapse = vi.fn()
render(
<Collapse
<TestCollapse
disabled
trigger={<div>Disabled section</div>}
title="Disabled section"
onCollapse={onCollapse}
>
<div>Hidden content</div>
</Collapse>,
</TestCollapse>,
)
await user.click(screen.getByText('Disabled section'))
@ -50,25 +91,19 @@ describe('Collapse', () => {
expect(onCollapse).not.toHaveBeenCalled()
})
it('should respect controlled collapse state and render function triggers', async () => {
it('should respect controlled collapse state and render actions separately', async () => {
const user = userEvent.setup()
const onCollapse = vi.fn()
render(
<Collapse
<TestCollapse
collapsed={false}
hideCollapseIcon
operations={<button type="button">Operation</button>}
trigger={collapseIcon => (
<div>
<span>Controlled section</span>
{collapseIcon}
</div>
)}
actions={<button type="button">Operation</button>}
title="Controlled section"
onCollapse={onCollapse}
>
<div>Visible content</div>
</Collapse>,
</TestCollapse>,
)
expect(screen.getByText('Visible content')).toBeInTheDocument()

View File

@ -1,36 +0,0 @@
import type { ReactNode } from 'react'
import Collapse from '.'
type FieldCollapseProps = {
title: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
}
const FieldCollapse = ({
title,
children,
collapsed,
onCollapse,
operations,
}: FieldCollapseProps) => {
return (
<div className="py-4">
<Collapse
trigger={
<div className="flex h-6 cursor-pointer items-center system-sm-semibold-uppercase text-text-secondary">{title}</div>
}
operations={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>
<div className="px-4">
{children}
</div>
</Collapse>
</div>
)
}
export default FieldCollapse

View File

@ -1,69 +1,152 @@
import type { ReactNode } from 'react'
import type {
ComponentProps,
ReactNode,
} from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useMemo, useState } from 'react'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import {
CollapsiblePanel,
CollapsibleRoot,
CollapsibleTrigger,
} from '@langgenius/dify-ui/collapsible'
export { default as FieldCollapse } from './field-collapse'
type CollapseProps = {
disabled?: boolean
trigger: React.JSX.Element | ((collapseIcon: React.JSX.Element | null) => React.JSX.Element)
children: React.JSX.Element
type CollapseProps = Omit<ComponentProps<typeof CollapsibleRoot>, 'open' | 'onOpenChange'> & {
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
operations?: ReactNode
hideCollapseIcon?: boolean
}
const Collapse = ({
disabled,
trigger,
children,
export function Collapse({
collapsed,
onCollapse,
operations,
hideCollapseIcon,
}: CollapseProps) => {
const [collapsedLocal, setCollapsedLocal] = useState(true)
const collapsedMerged = collapsed !== undefined ? collapsed : collapsedLocal
const collapseIcon = useMemo(() => {
if (disabled)
return null
return (
<ArrowDownRoundFill
className={cn(
'size-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapsedMerged && 'rotate-270',
)}
/>
)
}, [collapsedMerged, disabled])
...props
}: CollapseProps) {
return (
<>
<div className="group/collapse flex items-center">
<div
className="ml-4 flex grow items-center"
onClick={() => {
if (!disabled) {
setCollapsedLocal(!collapsedMerged)
onCollapse?.(!collapsedMerged)
}
}}
>
{typeof trigger === 'function' ? trigger(collapseIcon) : trigger}
{!hideCollapseIcon && (
<div className="size-4 shrink-0">
{collapseIcon}
</div>
)}
</div>
{operations}
</div>
{
!collapsedMerged && children
}
</>
<CollapsibleRoot
open={collapsed === undefined ? undefined : !collapsed}
onOpenChange={open => onCollapse?.(!open)}
{...props}
/>
)
}
export default Collapse
type CollapseHeaderProps = {
children: ReactNode
}
export function CollapseHeader({
children,
}: CollapseHeaderProps) {
return (
<div className="group/collapse flex items-center">
{children}
</div>
)
}
type CollapseActionsProps = {
children: ReactNode
}
export function CollapseActions({
children,
}: CollapseActionsProps) {
return (
<div className="ml-auto shrink-0">
{children}
</div>
)
}
type CollapseTriggerProps = ComponentProps<typeof CollapsibleTrigger>
export function CollapseTrigger({
className,
...props
}: CollapseTriggerProps) {
return (
<CollapsibleTrigger
className={cn(
'group/collapse ml-4 flex h-6 min-h-0 w-auto min-w-0 shrink-0 items-center justify-start gap-0 rounded-md px-0 py-0 text-text-secondary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-secondary',
className,
)}
{...props}
/>
)
}
type CollapseTitleProps = {
children: ReactNode
className?: string
}
export function CollapseTitle({
children,
className,
}: CollapseTitleProps) {
return (
<span className={cn('min-w-0 truncate system-sm-semibold-uppercase text-text-secondary', className)}>
{children}
</span>
)
}
export function CollapseIndicator() {
return (
<span
aria-hidden="true"
className="i-custom-vender-solid-general-arrow-down-round-fill size-4 rotate-270 cursor-pointer text-text-quaternary transition-transform group-hover/collapse:text-text-secondary group-data-panel-open/collapse:rotate-0 motion-reduce:transition-none"
/>
)
}
type CollapseContentProps = ComponentProps<typeof CollapsiblePanel>
export function CollapseContent({
className,
...props
}: CollapseContentProps) {
return (
<CollapsiblePanel
className={cn(className)}
{...props}
/>
)
}
type FieldCollapseProps = {
title: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
actions?: ReactNode
}
export function FieldCollapse({
title,
children,
collapsed,
onCollapse,
actions,
}: FieldCollapseProps) {
return (
<div className="py-4">
<Collapse collapsed={collapsed} onCollapse={onCollapse}>
<CollapseHeader>
<CollapseTrigger>
<CollapseTitle>{title}</CollapseTitle>
<CollapseIndicator />
</CollapseTrigger>
{actions != null && (
<CollapseActions>
{actions}
</CollapseActions>
)}
</CollapseHeader>
<CollapseContent>
<div className="px-4">
{children}
</div>
</CollapseContent>
</Collapse>
</div>
)
}

View File

@ -3,10 +3,17 @@ import type {
CommonNodeType,
Node,
} from '@/app/components/workflow/types'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Collapse from '../collapse'
import {
Collapse,
CollapseActions,
CollapseContent,
CollapseHeader,
CollapseIndicator,
CollapseTitle,
CollapseTrigger,
} from '../collapse'
import DefaultValue from './default-value'
import ErrorHandleTypeSelector from './error-handle-type-selector'
import FailBranchCard from './fail-branch-card'
@ -17,6 +24,7 @@ import {
import { ErrorHandleTypeEnum } from './types'
type ErrorHandleProps = Pick<Node, 'id' | 'data'>
const ErrorHandle = ({
id,
data,
@ -30,64 +38,53 @@ const ErrorHandle = ({
} = useErrorHandle(id, data)
const { handleFormChange } = useDefaultValue(id)
const getHandleErrorHandleTypeChange = useCallback((data: CommonNodeType) => {
return (value: ErrorHandleTypeEnum) => {
handleErrorHandleTypeChange(value, data)
}
}, [handleErrorHandleTypeChange])
const handleTypeChange = (value: ErrorHandleTypeEnum) => {
handleErrorHandleTypeChange(value, data as CommonNodeType)
}
const getHandleFormChange = useCallback((data: CommonNodeType) => {
return (v: DefaultValueForm) => {
handleFormChange(v, data)
}
}, [handleFormChange])
const handleDefaultValueChange = (value: DefaultValueForm) => {
handleFormChange(value, data as CommonNodeType)
}
return (
<>
<div className="py-4">
<Collapse
disabled={!error_strategy}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
trigger={
collapseIcon => (
<div className="flex grow items-center justify-between pr-4">
<div className="flex items-center">
<div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary">
{t('nodes.common.errorHandle.title', { ns: 'workflow' })}
</div>
<Infotip aria-label={t('nodes.common.errorHandle.tip', { ns: 'workflow' })}>
{t('nodes.common.errorHandle.tip', { ns: 'workflow' })}
</Infotip>
{collapseIcon}
</div>
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={getHandleErrorHandleTypeChange(data)}
/>
</div>
)
}
>
<>
{
error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && (
<FailBranchCard />
)
}
{
error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && (
<DefaultValue
forms={default_value}
onFormChange={getHandleFormChange(data)}
/>
)
}
</>
</Collapse>
</div>
</>
<div className="py-4">
<Collapse
disabled={!error_strategy}
collapsed={collapsed}
onCollapse={setCollapsed}
>
<CollapseHeader>
<CollapseTrigger>
<CollapseTitle>
{t('nodes.common.errorHandle.title', { ns: 'workflow' })}
</CollapseTitle>
{!!error_strategy && <CollapseIndicator />}
</CollapseTrigger>
<Infotip aria-label={t('nodes.common.errorHandle.tip', { ns: 'workflow' })}>
{t('nodes.common.errorHandle.tip', { ns: 'workflow' })}
</Infotip>
<CollapseActions>
<div className="pr-4">
<ErrorHandleTypeSelector
value={error_strategy || ErrorHandleTypeEnum.none}
onSelected={handleTypeChange}
/>
</div>
</CollapseActions>
</CollapseHeader>
<CollapseContent>
{error_strategy === ErrorHandleTypeEnum.failBranch && !collapsed && (
<FailBranchCard />
)}
{error_strategy === ErrorHandleTypeEnum.defaultValue && !collapsed && !!default_value?.length && (
<DefaultValue
forms={default_value}
onFormChange={handleDefaultValueChange}
/>
)}
</CollapseContent>
</Collapse>
</div>
)
}

View File

@ -26,7 +26,7 @@ const OutputVars: FC<Props> = ({
return (
<FieldCollapse
title={title || t('nodes.common.outputVars', { ns: 'workflow' })}
operations={operations}
actions={operations}
collapsed={collapsed}
onCollapse={onCollapse}
>

View File

@ -274,18 +274,6 @@ vi.mock('../last-run', () => ({
),
}))
vi.mock('../tab', () => ({
__esModule: true,
TabType: { settings: 'settings', lastRun: 'lastRun' },
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<div>
<button onClick={() => onChange('settings')}>settings-tab</button>
<button onClick={() => onChange('lastRun')}>last-run-tab</button>
<span>{value}</span>
</div>
),
}))
vi.mock('../trigger-subscription', () => ({
TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => (
<div>
@ -321,7 +309,7 @@ describe('workflow-panel index', () => {
})
it('should render the settings panel and wire title, description, run, and close actions', async () => {
const { container } = renderWorkflowComponent(
renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
@ -351,8 +339,7 @@ describe('workflow-panel index', () => {
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true)
@ -395,6 +382,7 @@ describe('workflow-panel index', () => {
)
expect(screen.getByText('last-run-panel')).toBeInTheDocument()
expect(screen.getByRole('tabpanel')).toHaveClass('flex', 'flex-1', 'flex-col')
})
it('should render the plain tab layout and allow last-run status updates', async () => {

View File

@ -2,6 +2,7 @@ import type { FC, ReactNode } from 'react'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import {
Tooltip,
TooltipContent,
@ -90,8 +91,8 @@ import {
} from './helpers'
import LastRun from './last-run'
import useLastRun from './last-run/use-last-run'
import Tab, { TabType } from './tab'
import { TriggerSubscription } from './trigger-subscription'
import { TabType } from './types'
type BasePanelProps = {
children: ReactNode
@ -480,6 +481,17 @@ const BasePanel: FC<BasePanelProps> = ({
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
: runThisStepLabel
const panelTabs = (
<TabsList>
<TabsTab value={TabType.settings}>
{t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase()}
</TabsTab>
<TabsTab value={TabType.lastRun}>
{t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase()}
</TabsTab>
</TabsList>
)
return (
<div
className={cn(
@ -496,8 +508,10 @@ const BasePanel: FC<BasePanelProps> = ({
>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</div>
<div
<Tabs
ref={containerRef}
value={tabType}
onValueChange={selectedValue => setTabType(selectedValue)}
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
style={{
width: `${nodePanelWidth}px`,
@ -558,12 +572,14 @@ const BasePanel: FC<BasePanelProps> = ({
<HelpLink nodeType={data.type} />
<NodeActionsDropdown id={id} data={data} showHelpLink={false} />
<div className="mx-3 h-3.5 w-px bg-divider-regular" />
<div
className="flex size-6 cursor-pointer items-center justify-center"
<button
type="button"
aria-label={t('common.operation.close')}
className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
onClick={() => handleNodeSelect(id, true)}
>
<RiCloseLine className="size-4 text-text-tertiary" />
</div>
<RiCloseLine aria-hidden className="size-4 text-text-tertiary" />
</button>
</div>
</div>
<div className="p-2">
@ -584,10 +600,7 @@ const BasePanel: FC<BasePanelProps> = ({
}}
>
<div className="flex items-center justify-between pr-3 pl-4">
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
<AuthorizedInNode
pluginPayload={{
provider: currToolCollection?.name || '',
@ -609,10 +622,7 @@ const BasePanel: FC<BasePanelProps> = ({
isAuthorized={currentDataSource.is_authorized}
>
<div className="flex items-center justify-between pr-3 pl-4">
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
<AuthorizedInDataSourceNode
onJumpToDataSourcePage={handleJumpToDataSourcePage}
authorizationsNum={3}
@ -627,76 +637,68 @@ const BasePanel: FC<BasePanelProps> = ({
subscriptionIdSelected={data.subscription_id}
onSubscriptionChange={handleSubscriptionChange}
>
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
</TriggerSubscription>
)
}
{
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
<div className="flex items-center justify-between pr-3 pl-4">
<Tab
value={tabType}
onChange={setTabType}
/>
{panelTabs}
</div>
)
}
<Split />
</div>
{tabType === TabType.settings && (
<div className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
</div>
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-2 system-xs-regular text-text-tertiary">
{t('panel.addNextStep', { ns: 'workflow' })}
</div>
<NextStep selectedNode={selectedNode} />
</div>
)
}
{readmeEntranceComponent}
<TabsPanel value={TabType.settings} className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
</div>
)}
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-2 system-xs-regular text-text-tertiary">
{t('panel.addNextStep', { ns: 'workflow' })}
</div>
<NextStep selectedNode={selectedNode} />
</div>
)
}
{readmeEntranceComponent}
</TabsPanel>
{tabType === TabType.lastRun && (
<TabsPanel value={TabType.lastRun} className="flex flex-1 flex-col">
<LastRun
appId={appDetail?.id || ''}
nodeId={id}
@ -710,9 +712,9 @@ const BasePanel: FC<BasePanelProps> = ({
isPaused={isPaused}
{...passedLogParams}
/>
)}
</TabsPanel>
</div>
</Tabs>
</div>
)
}

View File

@ -38,7 +38,7 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { isSupportCustomRunForm } from '@/app/components/workflow/utils'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { useInvalidLastRun } from '@/service/use-workflow'
import { TabType } from '../tab'
import { TabType } from '../types'
const singleRunFormParamsHooks: Record<BlockEnum, any> = {
[BlockEnum.LLM]: useLLMSingleRunFormParams,

View File

@ -1,35 +0,0 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import TabHeader from '@/app/components/base/tab-header'
export enum TabType {
settings = 'settings',
lastRun = 'lastRun',
relations = 'relations',
}
type Props = {
value: TabType
onChange: (value: TabType) => void
}
const Tab: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<TabHeader
items={[
{ id: TabType.settings, name: t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase() },
{ id: TabType.lastRun, name: t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase() },
]}
itemClassName="ml-0"
value={value}
onChange={onChange as any}
/>
)
}
export default React.memo(Tab)

View File

@ -0,0 +1,7 @@
export const TabType = {
settings: 'settings',
lastRun: 'lastRun',
relations: 'relations',
} as const
export type TabType = typeof TabType[keyof typeof TabType]

View File

@ -7,7 +7,15 @@ import {
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import Collapse from '@/app/components/workflow/nodes/_base/components/collapse'
import {
Collapse,
CollapseActions,
CollapseContent,
CollapseHeader,
CollapseIndicator,
CollapseTitle,
CollapseTrigger,
} from '@/app/components/workflow/nodes/_base/components/collapse'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import MetadataTrigger from '../metadata-trigger'
import MetadataFilterSelector from './metadata-filter-selector'
@ -16,6 +24,7 @@ type MetadataFilterProps = {
metadataFilterMode?: MetadataFilteringModeEnum
handleMetadataFilterModeChange: (mode: MetadataFilteringModeEnum) => void
} & MetadataShape
const MetadataFilter = ({
metadataFilterMode = MetadataFilteringModeEnum.disabled,
handleMetadataFilterModeChange,
@ -39,59 +48,54 @@ const MetadataFilter = ({
disabled={metadataFilterMode === MetadataFilteringModeEnum.disabled || metadataFilterMode === MetadataFilteringModeEnum.manual}
collapsed={collapsed}
onCollapse={setCollapsed}
hideCollapseIcon
trigger={collapseIcon => (
<div className="flex grow items-center justify-between pr-4">
<div className="flex items-center">
<div className="mr-0.5 system-sm-semibold-uppercase text-text-secondary">
{t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
</div>
<Infotip aria-label={t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} popupClassName="w-[200px]">
{t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
</Infotip>
{collapseIcon}
</div>
<div className="flex items-center">
>
<CollapseHeader>
<CollapseTrigger>
<CollapseTitle>
{t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
</CollapseTitle>
{metadataFilterMode === MetadataFilteringModeEnum.automatic && <CollapseIndicator />}
</CollapseTrigger>
<Infotip aria-label={t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })} popupClassName="w-[200px]">
{t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
</Infotip>
<CollapseActions>
<div className="flex items-center pr-4">
<MetadataFilterSelector
value={metadataFilterMode}
onSelect={handleMetadataFilterModeChangeWrapped}
/>
{
metadataFilterMode === MetadataFilteringModeEnum.manual && (
<div className="ml-1">
<MetadataTrigger {...restProps} />
</div>
)
}
{metadataFilterMode === MetadataFilteringModeEnum.manual && (
<div className="ml-1">
<MetadataTrigger {...restProps} />
</div>
)}
</div>
</div>
)}
>
<>
{
metadataFilterMode === MetadataFilteringModeEnum.automatic && (
<>
<div className="px-4 body-xs-regular text-text-tertiary">
{t('nodes.knowledgeRetrieval.metadata.options.automatic.desc', { ns: 'workflow' })}
</div>
<div className="mt-1 px-4">
<ModelParameterModal
popupClassName="w-[387px]!"
isInWorkflow
isAdvancedMode={true}
provider={metadataModelConfig?.provider || ''}
completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }}
modelId={metadataModelConfig?.name || ''}
setModel={handleMetadataModelChange || noop}
onCompletionParamsChange={handleMetadataCompletionParamsChange || noop}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
/>
</div>
</>
)
}
</>
</CollapseActions>
</CollapseHeader>
<CollapseContent>
{metadataFilterMode === MetadataFilteringModeEnum.automatic && (
<>
<div className="px-4 body-xs-regular text-text-tertiary">
{t('nodes.knowledgeRetrieval.metadata.options.automatic.desc', { ns: 'workflow' })}
</div>
<div className="mt-1 px-4">
<ModelParameterModal
popupClassName="w-[387px]!"
isInWorkflow
isAdvancedMode={true}
provider={metadataModelConfig?.provider || ''}
completionParams={metadataModelConfig?.completion_params || { temperature: 0.7 }}
modelId={metadataModelConfig?.name || ''}
setModel={handleMetadataModelChange || noop}
onCompletionParamsChange={handleMetadataCompletionParamsChange || noop}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
/>
</div>
</>
)}
</CollapseContent>
</Collapse>
)
}

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "القيمة",
"chatVariable.modal.description": "الوصف",
"chatVariable.modal.descriptionPlaceholder": "وصف المتغير",
"chatVariable.modal.descriptionTooLong": "يجب ألا يتجاوز الوصف {{maxLength}} حرفًا",
"chatVariable.modal.editInForm": "تعديل في النموذج",
"chatVariable.modal.editInJSON": "تعديل في JSON",
"chatVariable.modal.editTitle": "تعديل متغير محادثة",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Wert",
"chatVariable.modal.description": "Beschreibung",
"chatVariable.modal.descriptionPlaceholder": "Beschreiben Sie die Variable",
"chatVariable.modal.descriptionTooLong": "Die Beschreibung darf maximal {{maxLength}} Zeichen lang sein",
"chatVariable.modal.editInForm": "Im Formular bearbeiten",
"chatVariable.modal.editInJSON": "In JSON bearbeiten",
"chatVariable.modal.editTitle": "Gesprächsvariable bearbeiten",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Valor",
"chatVariable.modal.description": "Descripción",
"chatVariable.modal.descriptionPlaceholder": "Describa la variable",
"chatVariable.modal.descriptionTooLong": "La descripción debe tener {{maxLength}} caracteres o menos",
"chatVariable.modal.editInForm": "Editar en Formulario",
"chatVariable.modal.editInJSON": "Editar en JSON",
"chatVariable.modal.editTitle": "Editar Variable de Conversación",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "مقدار",
"chatVariable.modal.description": "توضیحات",
"chatVariable.modal.descriptionPlaceholder": "توصیف متغیر",
"chatVariable.modal.descriptionTooLong": "توضیحات باید {{maxLength}} کاراکتر یا کمتر باشد",
"chatVariable.modal.editInForm": "ویرایش در فرم",
"chatVariable.modal.editInJSON": "ویرایش در JSON",
"chatVariable.modal.editTitle": "ویرایش متغیر مکالمه",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Valeur",
"chatVariable.modal.description": "Description",
"chatVariable.modal.descriptionPlaceholder": "Décrivez la variable",
"chatVariable.modal.descriptionTooLong": "La description ne doit pas dépasser {{maxLength}} caractères",
"chatVariable.modal.editInForm": "Éditer dans le Formulaire",
"chatVariable.modal.editInJSON": "Éditer en JSON",
"chatVariable.modal.editTitle": "Modifier une Variable de Conversation",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "मान",
"chatVariable.modal.description": "विवरण",
"chatVariable.modal.descriptionPlaceholder": "चर का वर्णन करें",
"chatVariable.modal.descriptionTooLong": "विवरण {{maxLength}} वर्णों या उससे कम का होना चाहिए",
"chatVariable.modal.editInForm": "फॉर्म में संपादित करें",
"chatVariable.modal.editInJSON": "JSON में संपादित करें",
"chatVariable.modal.editTitle": "वार्तालाप चर संपादित करें",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Nilai",
"chatVariable.modal.description": "Deskripsi",
"chatVariable.modal.descriptionPlaceholder": "Jelaskan variabel",
"chatVariable.modal.descriptionTooLong": "Deskripsi harus {{maxLength}} karakter atau kurang",
"chatVariable.modal.editInForm": "Edit dalam Formulir",
"chatVariable.modal.editInJSON": "Edit dalam JSON",
"chatVariable.modal.editTitle": "Edit Variabel Percakapan",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Valore",
"chatVariable.modal.description": "Descrizione",
"chatVariable.modal.descriptionPlaceholder": "Descrivi la variabile",
"chatVariable.modal.descriptionTooLong": "La descrizione deve contenere al massimo {{maxLength}} caratteri",
"chatVariable.modal.editInForm": "Modifica nel Modulo",
"chatVariable.modal.editInJSON": "Modifica in JSON",
"chatVariable.modal.editTitle": "Modifica Variabile di Conversazione",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "値",
"chatVariable.modal.description": "説明",
"chatVariable.modal.descriptionPlaceholder": "変数の説明を入力",
"chatVariable.modal.descriptionTooLong": "説明は {{maxLength}} 文字以内で入力してください",
"chatVariable.modal.editInForm": "フォームで編集",
"chatVariable.modal.editInJSON": "JSON で編集",
"chatVariable.modal.editTitle": "会話変数を編集",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "값",
"chatVariable.modal.description": "설명",
"chatVariable.modal.descriptionPlaceholder": "변수에 대해 설명하세요",
"chatVariable.modal.descriptionTooLong": "설명은 {{maxLength}}자 이하여야 합니다",
"chatVariable.modal.editInForm": "양식에서 편집",
"chatVariable.modal.editInJSON": "JSON 으로 편집",
"chatVariable.modal.editTitle": "대화 변수 편집",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Value",
"chatVariable.modal.description": "Description",
"chatVariable.modal.descriptionPlaceholder": "Describe the variable",
"chatVariable.modal.descriptionTooLong": "Description must be {{maxLength}} characters or less",
"chatVariable.modal.editInForm": "Edit in Form",
"chatVariable.modal.editInJSON": "Edit in JSON",
"chatVariable.modal.editTitle": "Edit Conversation Variable",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Wartość",
"chatVariable.modal.description": "Opis",
"chatVariable.modal.descriptionPlaceholder": "Opisz zmienną",
"chatVariable.modal.descriptionTooLong": "Opis nie może mieć więcej niż {{maxLength}} znaków",
"chatVariable.modal.editInForm": "Edytuj w Formularzu",
"chatVariable.modal.editInJSON": "Edytuj w JSON",
"chatVariable.modal.editTitle": "Edytuj Zmienną Konwersacji",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Valor",
"chatVariable.modal.description": "Descrição",
"chatVariable.modal.descriptionPlaceholder": "Descreva a variável",
"chatVariable.modal.descriptionTooLong": "A descrição deve ter no máximo {{maxLength}} caracteres",
"chatVariable.modal.editInForm": "Editar no Formulário",
"chatVariable.modal.editInJSON": "Editar em JSON",
"chatVariable.modal.editTitle": "Editar Variável de Conversação",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Valoare",
"chatVariable.modal.description": "Descriere",
"chatVariable.modal.descriptionPlaceholder": "Descrieți variabila",
"chatVariable.modal.descriptionTooLong": "Descrierea trebuie să aibă cel mult {{maxLength}} caractere",
"chatVariable.modal.editInForm": "Editare în Formular",
"chatVariable.modal.editInJSON": "Editare în JSON",
"chatVariable.modal.editTitle": "Editare Variabilă de Conversație",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Значение",
"chatVariable.modal.description": "Описание",
"chatVariable.modal.descriptionPlaceholder": "Опишите переменную",
"chatVariable.modal.descriptionTooLong": "Описание должно содержать не более {{maxLength}} символов",
"chatVariable.modal.editInForm": "Редактировать в форме",
"chatVariable.modal.editInJSON": "Редактировать в JSON",
"chatVariable.modal.editTitle": "Редактировать переменную разговора",

View File

@ -90,6 +90,7 @@
"chatVariable.modal.arrayValue": "Vrednost",
"chatVariable.modal.description": "Opis",
"chatVariable.modal.descriptionPlaceholder": "Opisujte spremenljivko",
"chatVariable.modal.descriptionTooLong": "Opis ne sme presegati {{maxLength}} znakov",
"chatVariable.modal.editInForm": "Uredi v obrazcu",
"chatVariable.modal.editInJSON": "Uredi v JSON",
"chatVariable.modal.editTitle": "Uredi spremenljivko pogovora",

Some files were not shown because too many files have changed in this diff Show More