mirror of
https://github.com/langgenius/dify.git
synced 2026-06-16 22:11:09 +08:00
Merge remote-tracking branch 'origin/main' into feat/openapi-error-contract
This commit is contained in:
commit
0c661ac06b
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
167
api/controllers/openapi/app_dsl.py
Normal file
167
api/controllers/openapi/app_dsl.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
86
api/core/workflow/graph_topology.py
Normal file
86
api/core/workflow/graph_topology.py
Normal 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"]
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
@ -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 |
|
||||
|
||||
210
api/services/agent/composer_candidates.py
Normal file
210
api/services/agent/composer_candidates.py
Normal 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",
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
264
api/services/agent/prompt_mentions.py
Normal file
264
api/services/agent/prompt_mentions.py
Normal 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",
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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])
|
||||
|
||||
51
api/tests/unit_tests/core/workflow/test_graph_topology.py
Normal file
51
api/tests/unit_tests/core/workflow/test_graph_topology.py
Normal 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"}
|
||||
@ -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 == {}
|
||||
|
||||
204
api/tests/unit_tests/services/agent/test_composer_candidates.py
Normal file
204
api/tests/unit_tests/services/agent/test_composer_candidates.py
Normal 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
|
||||
@ -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": []}
|
||||
179
api/tests/unit_tests/services/agent/test_prompt_mentions.py
Normal file
179
api/tests/unit_tests/services/agent/test_prompt_mentions.py
Normal 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
|
||||
@ -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
113
cli/src/api/app-dsl.test.ts
Normal 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
59
cli/src/api/app-dsl.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
}
|
||||
45
cli/src/commands/export/app/index.ts
Normal file
45
cli/src/commands/export/app/index.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
69
cli/src/commands/export/app/run.test.ts
Normal file
69
cli/src/commands/export/app/run.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
61
cli/src/commands/export/app/run.ts
Normal file
61
cli/src/commands/export/app/run.ts
Normal 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 }
|
||||
}
|
||||
60
cli/src/commands/import/app/index.ts
Normal file
60
cli/src/commands/import/app/index.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
cli/src/commands/import/app/run.test.ts
Normal file
188
cli/src/commands/import/app/run.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
138
cli/src/commands/import/app/run.ts
Normal file
138
cli/src/commands/import/app/run.ts
Normal 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>'
|
||||
}
|
||||
@ -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
1
cli/src/util/uuid.ts
Normal file
@ -0,0 +1 @@
|
||||
export const ZERO = '00000000-0000-0000-0000-000000000000'
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
196
cli/test/e2e/suites/dsl/export-app.e2e.ts
Normal file
196
cli/test/e2e/suites/dsl/export-app.e2e.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
])
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
9
cli/test/fixtures/dify-mock/scenarios.ts
vendored
9
cli/test/fixtures/dify-mock/scenarios.ts
vendored
@ -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',
|
||||
|
||||
40
cli/test/fixtures/dify-mock/server.ts
vendored
40
cli/test/fixtures/dify-mock/server.ts
vendored
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -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"
|
||||
|
||||
89
packages/dify-ui/src/collapsible/__tests__/index.spec.tsx
Normal file
89
packages/dify-ui/src/collapsible/__tests__/index.spec.tsx
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
183
packages/dify-ui/src/collapsible/index.stories.tsx
Normal file
183
packages/dify-ui/src/collapsible/index.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
71
packages/dify-ui/src/collapsible/index.tsx
Normal file
71
packages/dify-ui/src/collapsible/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')}>
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 = {}
|
||||
@ -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)
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
6
web/app/components/explore/try-app/types.ts
Normal file
6
web/app/components/explore/try-app/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const TypeEnum = {
|
||||
TRY: 'try',
|
||||
DETAIL: 'detail',
|
||||
} as const
|
||||
|
||||
export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum]
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
@ -0,0 +1,7 @@
|
||||
export const TabType = {
|
||||
settings: 'settings',
|
||||
lastRun: 'lastRun',
|
||||
relations: 'relations',
|
||||
} as const
|
||||
|
||||
export type TabType = typeof TabType[keyof typeof TabType]
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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": "تعديل متغير محادثة",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "ویرایش متغیر مکالمه",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "वार्तालाप चर संपादित करें",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "会話変数を編集",
|
||||
|
||||
@ -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": "대화 변수 편집",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Редактировать переменную разговора",
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user