feat: support New Agent templates and trials (#42837)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-28 09:21:50 +00:00
1 parent 5f6bb5e6d3
commit 005f019a80
173 files changed
+6996 -808

No files matched your search

+14
View File
@@ -697,6 +697,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-package-service-boundary]
name = Recommended package application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_package_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden
@@ -7,14 +7,16 @@ import io
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
from functools import wraps
from typing import Any, Concatenate, Literal
from uuid import UUID
from flask import Response, request, send_file, url_for
from flask import Response, make_response, request, send_file, url_for
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from controllers.common.fields import BinaryFileResponse
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
from controllers.common.schema import (
query_params_from_model,
@@ -25,6 +27,8 @@ from controllers.common.schema import (
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import AppUnavailableError
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.model import App, AppMode
@@ -50,6 +58,8 @@ from services.agent_config_service import (
ConfigPushPayload,
ConfigPushSkillItem,
)
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import AppPreviewRef
class AgentConfigQuery(BaseModel):
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
register_schema_models(console_ns, AgentConfigFileUploadPayload)
register_response_schema_models(
console_ns,
BinaryFileResponse,
AgentConfigDeleteResponse,
AgentConfigDownloadResponse,
AgentConfigFileItemResponse,
@@ -239,7 +250,7 @@ register_response_schema_models(
class _ResolvedConsoleTarget:
tenant_id: str
agent_id: str
account_id: str
account_id: str | None
version_id: str
version_kind: AgentConfigVersionKind
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class TrialAgentConfigQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
path: str = Field(description="Normalized member path inside the published Skill package")
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
@wraps(view)
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
try:
state = AgentAppComposerResponse.model_validate(
application_services().app_previews.get_agent_composer(app=app)
)
snapshot = state.active_config_snapshot
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
raise AgentConfigServiceError(
"config_version_not_found", "published template version is unavailable", status_code=404
)
target = _ResolvedConsoleTarget(
tenant_id=app.tenant_id,
agent_id=state.agent.id,
account_id=None,
version_id=snapshot.id,
version_kind=AgentConfigVersionKind.SNAPSHOT,
)
response = make_response(view(self, target, query, *args, **kwargs))
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except AgentConfigServiceError as exc:
response = make_response(_handle(exc))
response.headers["Cache-Control"] = "no-store"
return response
return decorated
def _service() -> AgentConfigService:
return AgentConfigService()
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
assert target.account_id is not None
return _upload_skill_for_target(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
def _file_upload_response(
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
) -> tuple[dict[str, object], int]:
assert target.account_id is not None
manifest = _service().push_file_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
class TrialAgentConfigSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
class TrialAgentConfigFilesApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
class TrialAgentConfigSkillInspectApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_inspect_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
class TrialAgentConfigSkillDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_download_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
class TrialAgentConfigSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
return dump_response(
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
class TrialAgentConfigSkillFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
assert request.view_args is not None
return _skill_file_download_response(
target,
name=name,
path=query.path,
raw_endpoint="console.trial_agent_config_skill_file_content",
route_params={"app_id": str(request.view_args["app_id"])},
)
@console_ns.route(
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
endpoint="trial_agent_config_skill_file_content",
)
class TrialAgentConfigSkillFileContentApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.doc(
produces=["application/octet-stream"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
)
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
return _skill_file_raw_download_response(target, name, query.path)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
class TrialAgentConfigFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
class TrialAgentConfigFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _file_download_response(target, name)
# pyrefly: ignore [unresolvable-dunder-all]
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]
@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.error import AppNotFoundError
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
@@ -17,6 +18,7 @@ from services.agent.errors import InvalidRosterAgentPackageError
from services.app.console_service import ConsoleAppNotFoundError
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.recommended_app_query_service import RecommendedAppNotFoundError
from .. import console_ns
@@ -86,6 +88,8 @@ class AppImportApi(Resource):
result = application_services().apps.console.import_app(context, payload, source=source)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
except RecommendedAppNotFoundError:
raise RecommendedAppNotFoundHttpError() from None
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
result.status, HTTPStatus.OK
)
@@ -1,9 +1,10 @@
from http import HTTPStatus
from typing import Any
from typing import Any, Literal
from uuid import UUID
from flask import make_response, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@@ -11,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.app_export_fields import AppExportResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import build_icon_url, dump_response
from machinery.context import RequestContext
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
@@ -21,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
language: str = Field(default="en-US", description="Language code for recommended app localization")
class RecommendedAgentExportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID = Field(description="Current published template snapshot ID")
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
class RecommendedAppInfoResponse(ResponseModel):
id: str
name: str | None = None
@@ -78,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
mode: str
export_data: str
can_trial: bool
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAgentExportQuery,
)
register_response_schema_models(
console_ns,
@@ -91,6 +104,7 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
AppExportResponse,
)
@@ -135,3 +149,37 @@ class RecommendedAppApi(Resource):
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)
@console_ns.route("/trial-apps/<uuid:app_id>/export")
class RecommendedAgentExportApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
@console_ns.doc(
produces=["application/json", "application/zip"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
)
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
@console_ns.response(404, "Published template unavailable")
@model_validate(RecommendedAgentExportQuery)
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
# Both formats require public catalog membership and the current published version.
try:
exported = application_services().recommended_app_packages.export(
app_id=str(app_id), version_id=query.version_id, format=query.format
)
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
if isinstance(exported, str):
response = make_response(dump_response(AppExportResponse, {"data": exported}))
response.headers["Cache-Control"] = "no-store"
return response
try:
response = send_file(
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
)
except Exception:
exported.close()
raise
response.headers["Cache-Control"] = "no-store"
response.call_on_close(exported.close)
return response
+44 -4
View File
@@ -57,12 +57,14 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial_app_admission import get_trial_app
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
from controllers.console.flask_admission import console_account_admission
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
@@ -72,6 +74,7 @@ from core.errors.error import (
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
from fields.file_fields import FileResponse, FileWithSignedUrl
@@ -84,6 +87,7 @@ from libs.stream import close_stream
from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models.enums import CreatorUserRole
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import (
@@ -153,7 +157,7 @@ class TrialDatasetListQuery(BaseModel):
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
type TrialIconType = Literal["emoji", "image", "link"]
type JsonObject = dict[str, Any]
@@ -448,6 +452,7 @@ register_response_schema_models(
SiteResponse,
SuggestedQuestionsResponse,
TrialAppDetailResponse,
AgentAppComposerResponse,
TrialDatasetListResponse,
TrialWorkflowResponse,
)
@@ -603,6 +608,8 @@ class TrialChatApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except (AgentAppGeneratorError, AgentAppNotPublishedError):
raise AppUnavailableError() from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -620,6 +627,27 @@ class TrialChatApi(Resource):
raise InternalServerError()
@console_ns.route(
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
endpoint="trial_app_stop_chat_completion",
)
class TrialChatTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app_for_stop
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
app_mode=AppMode.value_of(trial_app.app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
@@ -629,7 +657,7 @@ class TrialMessageSuggestedQuestionApi(Resource):
@console_account_admission()
@get_trial_app
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
try:
@@ -837,7 +865,7 @@ class TrialAppParameterApi(Resource):
"""Retrieve app parameters."""
try:
parameters = application_services().app_definitions.get_parameters(app.app_id)
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
@@ -875,6 +903,18 @@ class AppApi(Resource):
return dump_response(TrialAppDetailResponse, source)
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
class TrialAgentComposerApi(Resource):
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef):
try:
preview = application_services().app_previews.get_agent_composer(app=app)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(AgentAppComposerResponse, preview)
@console_ns.route(
"/trial-apps/<uuid:app_id>/workflows",
endpoint="trial_app_workflow",
@@ -28,8 +28,10 @@ def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def get_trial_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
*,
check_usage: bool = True,
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Check trial availability and inject an admitted app after Console admission."""
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
@@ -38,7 +40,7 @@ def get_trial_app[T, **P, R](
raise RuntimeError("The trial app admission route must provide app_id")
try:
trial_app = application_services().trial_apps.access.get_access(
app_id=str(app_id), account_id=request_context.account_id
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
)
except TrialAppUnavailableError as error:
raise TrialAppNotAllowed() from error
@@ -47,3 +49,10 @@ def get_trial_app[T, **P, R](
return view(self, request_context, trial_app, *args, **kwargs)
return trial_feature_enable(decorated)
def get_trial_app_for_stop[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Keep trial access checks without requiring remaining generation quota."""
return get_trial_app(view, check_usage=False)
@@ -20,6 +20,7 @@ from services.app_dsl_service import AppDslService
from services.app_package_service import AppPackageService
from services.app_tracing_config_gateway import OpsTraceManagerGateway
from services.oauth_server_service import OAuthServerService
from services.recommended_app_package_service import RecommendedAppPackageService
@dataclass(frozen=True, slots=True)
@@ -33,6 +34,7 @@ def build_app_services(
*,
database_client: sessionmaker[Session],
oauth: OAuthServerService,
recommended_packages: RecommendedAppPackageService,
) -> AppServices:
repository = ConsoleAppRepository(session_factory=database_client)
transfers = AppTransferGateway(
@@ -41,6 +43,7 @@ def build_app_services(
packages=AppPackageService(),
agent_packages=RosterAgentPackageExporter(),
agent_importer=RosterAgentPackageImporter(),
recommended_packages=recommended_packages,
)
return AppServices(
imports=AppImportService(accounts=SQLAlchemyAccountRepository(database_client), definitions=transfers),
@@ -147,6 +147,7 @@ from services.account_oauth_service import AccountOAuthService, OAuthProviderGat
from services.account_password_hasher import DefaultAccountPasswordHasher
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService
from services.app.api_key_service import AppApiKeyService
from services.app.mcp_server_service import AppMCPServerService
@@ -213,6 +214,7 @@ from services.recommended_app_catalog_gateway import (
RecommendedAppCatalogRouter,
RemoteRecommendedAppCatalogGateway,
)
from services.recommended_app_package_service import RecommendedAppPackageService
from services.recommended_app_query_service import RecommendedAppQueryService
from services.remote_file_service import RemoteFileService
from services.retention.workflow_run.archive_download_adapters import (
@@ -374,6 +376,7 @@ class ApplicationServices:
step_by_step_tour: StepByStepTourService
partner_tenant_bindings: PartnerTenantBindingService
recommended_app_queries: RecommendedAppQueryService
recommended_app_packages: RecommendedAppPackageService
remote_files: RemoteFileService
saved_messages: SavedMessageService
app_tasks: AppTaskControlService
@@ -580,6 +583,9 @@ def build_application_services(
trial_apps=trial_apps,
trial_enabled=trial_app_enabled,
)
recommended_app_packages = RecommendedAppPackageService(
sources=database_catalog, exporter=RosterAgentPackageExporter()
)
workspace_query_repository = WorkspaceQueryRepository(session_factory=database_client)
app_scoped_end_user_repository = AppScopedEndUserRepo(session_factory=database_client)
file_service = FileService(session_factory=database_client)
@@ -751,6 +757,7 @@ def build_application_services(
apps=build_app_services(
database_client=database_client,
oauth=oauth_server,
recommended_packages=recommended_app_packages,
),
agent_apps=build_agent_app_services(database_client=database_client),
advanced_prompt_templates=AdvancedPromptTemplateService(),
@@ -875,6 +882,7 @@ def build_application_services(
sync_bindings=BillingService.sync_partner_tenants_bindings,
),
recommended_app_queries=recommended_app_queries,
recommended_app_packages=recommended_app_packages,
remote_files=remote_file_service,
saved_messages=SavedMessageService(
saved_messages=SQLAlchemySavedMessageRepository(session_factory=database_client),
+5
View File
@@ -0,0 +1,5 @@
from fields.base import ResponseModel
class AppExportResponse(ResponseModel):
data: str
+204
View File
@@ -9328,6 +9328,155 @@ Remove one or more tag bindings from a target.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialAppDetailResponse](#trialappdetailresponse)<br> |
### [GET] /trial-apps/{app_id}/agent-composer
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published Agent configuration | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files/{name}/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/octet-stream**: binary<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/inspect
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
### [POST] /trial-apps/{app_id}/audio-to-text
#### Parameters
@@ -9360,6 +9509,20 @@ Remove one or more tag bindings from a target.
| ---- | ----------- |
| 200 | Success |
### [POST] /trial-apps/{app_id}/chat-messages/{task_id}/stop
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /trial-apps/{app_id}/completion-messages
#### Parameters
@@ -9395,6 +9558,22 @@ Remove one or more tag bindings from a target.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)<br> |
### [GET] /trial-apps/{app_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| format | query | Export format; defaults to ifpkg | No | string, <br>**Available values:** "ifpkg", "yaml", <br>**Default:** ifpkg |
| version_id | query | Current published template snapshot ID | Yes | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published Agent template export | **application/json**: [AppExportResponse](#appexportresponse)<br>**application/zip**: binary<br> |
| 404 | Published template unavailable | |
### [POST] /trial-apps/{app_id}/files/upload
**Upload a file into the tenant that owns the trial app**
@@ -16246,6 +16425,9 @@ This class is used to store the schema information of an api based tool.
| icon_type | string | | No |
| mode | string | Import mode | Yes |
| name | string | | No |
| package_url | string | | No |
| template_id | string | | No |
| version_id | string | | No |
| yaml_content | string | | No |
| yaml_url | string | | No |
@@ -22485,6 +22667,13 @@ Resource types understood by access policies.
| permission_keys | [ string ] | | No |
| updated_at | integer | | Yes |
#### RecommendedAgentExportQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| format | string, <br>**Available values:** "ifpkg", "yaml", <br>**Default:** ifpkg | Export format; defaults to ifpkg<br>*Enum:* `"ifpkg"`, `"yaml"` | No |
| version_id | string (uuid) | Current published template snapshot ID | Yes |
#### RecommendedAppDetailResponse
| Name | Type | Description | Required |
@@ -22496,6 +22685,8 @@ Resource types understood by access policies.
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| package_url | string | Download URL for a New Agent .ifpkg template | No |
| version_id | string | Published version for direct local template creation | No |
#### RecommendedAppInfoResponse
@@ -24370,6 +24561,19 @@ Enum class for tool provider
| ---- | ---- | ----------- | -------- |
| tracing_provider | string | Tracing provider name | Yes |
#### TrialAgentConfigQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| version_id | string | Must match the current published template snapshot | No |
#### TrialAgentSkillFileQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| path | string | Normalized member path inside the published Skill package | Yes |
| version_id | string | Must match the current published template snapshot | No |
#### TrialAppAgentMode
| Name | Type | Description | Required |
@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import cast, override
from pydantic import JsonValue
from pydantic import JsonValue, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
@@ -12,11 +12,13 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.utils.uuid_utils import is_valid_uuid
from models.account import Account, Tenant
from models.dataset import Dataset
from models.model import App, AppModelConfig, Site, load_annotation_reply_config
from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
from models.tools import ApiToolProvider
from models.workflow import Workflow
from repositories.app_definition_query_repository import map_site_configuration
from services.account_errors import AccountNotFoundError
from services.agent.composer_service import AgentComposerService
from services.agent.errors import AgentNotFoundError, AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_details_service import (
AppPreviewAccount,
@@ -41,6 +43,24 @@ from services.app_preview_query_service import (
class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object] | None:
with self._session_factory() as session:
try:
model = self._get_app(session=session, app=app)
except AppPreviewUnavailableError:
return None
if model.mode != AppMode.AGENT:
return None
agent = model.agent_app_binding_with_session(session=session)
if agent is None:
return None
try:
return AgentComposerService.load_published_agent_composer(
session=session, tenant_id=app.tenant_id, agent_id=agent.id
)
except (AgentNotFoundError, AgentVersionNotFoundError, ValidationError, ValueError):
return None
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@@ -267,6 +287,8 @@ class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
annotation_reply=load_annotation_reply_config(session, model_config.app_id)
)
result: dict[str, JsonValue | datetime] = dict(cast(Mapping[str, JsonValue], configuration))
if not result.get("model"):
result["model"] = None
result.update(
created_by=model_config.created_by,
created_at=model_config.created_at,
@@ -4,14 +4,19 @@ import json
import logging
from collections.abc import Sequence
from typing import cast, override
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from constants.languages import languages
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from extensions.ext_redis import RedisClientWrapper
from models.model import App, RecommendedApp, Site
from models.account import Tenant, TenantStatus
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus
from models.model import App, AppMode, RecommendedApp, Site
from services.app_dsl_service import AppDslService
from services.recommended_app_package_service import RecommendedAgentPackageSource
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
@@ -57,6 +62,43 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
with self._session_factory() as session:
return self._get_detail(app_id, session=session)
def get_package_source(self, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource | None:
with self._session_factory() as session:
source = self._get_agent_package_source(app_id, session=session)
return source if source is not None and source.version_id == version_id else None
@staticmethod
def _get_agent_package_source(app_id: str, *, session: Session) -> RecommendedAgentPackageSource | None:
row = session.execute(
select(Agent.tenant_id, Agent.id, Agent.active_config_snapshot_id)
.join(App, (App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id))
.join(Tenant, Tenant.id == App.tenant_id)
.join(RecommendedApp, RecommendedApp.app_id == App.id)
.join(
AgentConfigSnapshot,
(AgentConfigSnapshot.id == Agent.active_config_snapshot_id)
& (AgentConfigSnapshot.agent_id == Agent.id)
& (AgentConfigSnapshot.tenant_id == Agent.tenant_id),
)
.where(
App.id == app_id,
App.mode == AppMode.AGENT,
App.status == "normal",
App.is_public.is_(True),
RecommendedApp.is_listed.is_(True),
Tenant.status != TenantStatus.ARCHIVE,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
workflow_callable_active_snapshot_filter(),
(Agent.backing_app_id == App.id) | Agent.backing_app_id.is_(None),
)
.limit(1)
).one_or_none()
if row is None:
return None
return RecommendedAgentPackageSource(tenant_id=row[0], agent_id=row[1], version_id=UUID(row[2]))
@override
def contains(self, app_id: str) -> bool:
with self._session_factory() as session:
@@ -179,13 +221,19 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
if app is None or not app.is_public:
return None
version_id = None
if app.mode == AppMode.AGENT:
source = DatabaseRecommendedAppCatalogRepository._get_agent_package_source(app.id, session=session)
if source is not None:
version_id = str(source.version_id)
return RecommendedAppDetailRecord(
id=app.id,
name=app.name,
icon=cast(str | None, app.icon),
icon_background=app.icon_background,
mode=app.mode.value,
export_data=AppDslService.export_dsl(app_model=app, session=session),
export_data="" if app.mode == AppMode.AGENT else AppDslService.export_dsl(app_model=app, session=session),
version_id=version_id,
)
@staticmethod
+39 -3
View File
@@ -3,10 +3,12 @@
from collections.abc import Sequence, Set
from typing import override
from sqlalchemy import and_, select
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord, App, TrialApp
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services.recommended_app_query_service import TrialAppQuery
from services.trial_app_access_service import TrialAppAccessQuery, TrialAppAccessSnapshot, TrialAppRef
from services.trial_app_usage import TrialAppUsageRecorder
@@ -21,8 +23,42 @@ class TrialAppRepository(TrialAppQuery, TrialAppAccessQuery, TrialAppUsageRecord
if not app_ids:
return frozenset()
runnable_agent_exists = (
select(Agent.id)
.join(
AgentConfigSnapshot,
and_(
AgentConfigSnapshot.tenant_id == Agent.tenant_id,
AgentConfigSnapshot.agent_id == Agent.id,
AgentConfigSnapshot.id == Agent.active_config_snapshot_id,
),
)
.where(
Agent.tenant_id == App.tenant_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(
Agent.app_id == App.id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
),
Agent.backing_app_id == App.id,
),
workflow_callable_active_snapshot_filter(),
)
.correlate(App)
.exists()
)
query = (
select(TrialApp.app_id)
.outerjoin(App, App.id == TrialApp.app_id)
.where(
TrialApp.app_id.in_(app_ids),
or_(App.id.is_(None), App.mode != AppMode.AGENT, runnable_agent_exists),
)
)
with self._session_factory() as session:
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
return frozenset(session.scalars(query).all())
@override
def resolve(self, *, app_id: str, account_id: str) -> TrialAppAccessSnapshot | None:
+44 -9
View File
@@ -35,6 +35,7 @@ from models.agent_config_entities import (
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.dsl_entities import make_portable_agent_soul
from services.agent.errors import (
AgentBuildSandboxNotFoundError,
AgentModelNotConfiguredError,
@@ -388,6 +389,32 @@ class AgentComposerService:
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
return cls._load_agent_composer_for_agent(session=session, tenant_id=tenant_id, agent=agent)
@classmethod
def load_published_agent_composer(cls, *, session: Session, tenant_id: str, agent_id: str) -> dict[str, Any]:
"""Read public Composer state after the caller admits template access."""
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
raise AgentVersionNotFoundError()
state = cls._load_agent_composer_for_agent(
session=session, tenant_id=tenant_id, agent=agent, published_only=True
)
soul = make_portable_agent_soul(AgentSoulConfig.model_validate(state["agent_soul"]))
# Export portability alone does not remove plaintext runtime values.
soul.env = type(soul.env)()
soul.human = type(soul.human)()
soul.sandbox = type(soul.sandbox)()
soul.memory = type(soul.memory)()
for tool in soul.tools.dify_tools:
tool.runtime_parameters = {}
for cli_tool in soul.tools.cli_tools:
cli_tool.env = type(cli_tool.env)()
cli_tool.invoke_metadata = {}
state["agent_soul"] = soul.model_dump(mode="json")
state["chat_endpoint"] = None
if state["active_config_snapshot"] is not None:
state["active_config_snapshot"]["created_by"] = None
return state
@classmethod
def prepare_agent_composer_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str
@@ -425,18 +452,25 @@ class AgentComposerService:
return AgentSoulConfig.model_validate(state["agent_soul"])
@classmethod
def _load_agent_composer_for_agent(cls, *, session: Session, tenant_id: str, agent: Agent) -> dict[str, Any]:
def _load_agent_composer_for_agent(
cls, *, session: Session, tenant_id: str, agent: Agent, published_only: bool = False
) -> dict[str, Any]:
"""Read effective configuration without creating or rebasing persisted drafts.
Stale workflow-only drafts read from the current snapshot. Their draft
metadata still describes the persisted row; write paths own rebasing.
Published-only reads never query drafts or advertise editing capabilities.
"""
draft = cls._get_agent_draft(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft = (
None
if published_only
else cls._get_agent_draft(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
)
)
version = cls._get_version_if_present(
session=session, tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
@@ -455,10 +489,11 @@ class AgentComposerService:
"variant": ComposerVariant.AGENT_APP.value,
"agent": cls._serialize_agent(agent),
"active_config_snapshot": cls._serialize_version(version),
"active_config_is_published": bool(agent.active_config_snapshot_id and agent.active_config_is_published),
"active_config_is_published": published_only
or bool(agent.active_config_snapshot_id and agent.active_config_is_published),
"draft": cls._serialize_draft(draft),
"agent_soul": agent_soul,
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"save_options": [] if published_only else [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),
+3 -3
View File
@@ -244,9 +244,9 @@ class AgentDslService:
name=self.unique_roster_name(tenant_id=app.tenant_id, requested=app.name or metadata.name),
description=app.description or metadata.description,
role=metadata.role,
icon_type=self._agent_icon_type(metadata.icon_type),
icon=metadata.icon,
icon_background=metadata.icon_background,
icon_type=self._agent_icon_type(app.icon_type),
icon=app.icon,
icon_background=app.icon_background,
source=AgentSource.IMPORTED,
initial_soul=soul,
revision_operation=AgentConfigRevisionOperation.IMPORT_PACKAGE,
@@ -163,6 +163,80 @@ class AgentPackageResourceExporter:
)
self._workspace_sources.clear()
def read_local_resources(
self, package_ref: str
) -> tuple[dict[str, bytes], AgentPackageResources, list[PackageIcon]]:
"""Read bounded local assets without building an outer archive."""
skill_sources, file_sources = self.sources[package_ref]
if len(skill_sources) + len(file_sources) + len(self.icon_sources) + 2 > dify_config.AGENT_PACKAGE_MAX_ENTRIES:
raise RosterAgentPackageTooLargeError("Agent template has too many resources")
payloads: dict[str, bytes] = {}
total_size = 0
def read(path: str, storage_key: str, limit: int) -> tuple[int, str]:
nonlocal total_size
content = bytearray()
try:
for chunk in self._storage.load_stream(storage_key):
if len(content) + len(chunk) > min(limit, dify_config.AGENT_PACKAGE_MAX_BYTES - total_size):
raise RosterAgentPackageTooLargeError("Agent template resources exceed the size limit")
content.extend(chunk)
except RosterAgentPackageTooLargeError:
raise
except Exception as exc:
raise RosterAgentPackageExportFailedError(f"Unable to read template resource {path!r}") from exc
payload = bytes(content)
payloads[path] = payload
total_size += len(payload)
return len(payload), hashlib.sha256(payload).hexdigest()
skills: list[RosterAgentPackageSkill] = []
files: list[RosterAgentPackageFile] = []
nested_uncompressed_size = 0
skill_packages = SkillPackageService()
for item in skill_sources:
size, digest = read(item.path, item.storage_key, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
try:
inspection = skill_packages.inspect(content=payloads[item.path], filename=item.path)
except SkillPackageError as exc:
raise RosterAgentPackageExportFailedError(
f"Agent template contains unusable Skill {item.name!r}"
) from exc
if inspection.name != item.name:
raise RosterAgentPackageExportFailedError(f"Agent template Skill {item.name!r} has a name mismatch")
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError("Agent template nested Skill contents exceed the size limit")
skills.append(
RosterAgentPackageSkill(
id=item.id,
scope=item.scope,
name=item.name,
path=item.path,
size=size,
sha256=digest,
audit=RosterAgentPackageAudit(ref=item.audit_ref),
)
)
for file_source in file_sources:
size, digest = read(file_source.path, file_source.storage_key, dify_config.AGENT_PACKAGE_MAX_BYTES)
files.append(
RosterAgentPackageFile(
id=file_source.id,
path=file_source.path,
size=size,
sha256=digest,
audit=RosterAgentPackageAudit(ref=file_source.audit_ref),
)
)
icons = []
for path, key in self.icon_sources.values():
size, digest = read(path, key, dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024)
icons.append(PackageIcon(id=path.split(".")[0], path=path, size=size, sha256=digest))
resources = AgentPackageResources(skills=skills, files=files)
resources.validate_packages([self.packages[package_ref]])
return payloads, resources, icons
def write_resources(self, archive: zipfile.ZipFile) -> tuple[dict[str, AgentPackageResources], int]:
entry_count = sum(len(skills) + len(files) for skills, files in self.sources.values()) + 2
if entry_count > dify_config.AGENT_PACKAGE_MAX_ENTRIES:
+40 -11
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import mimetypes
import os
from collections.abc import Callable
from typing import Protocol
from uuid import uuid4
@@ -82,7 +83,26 @@ class AgentPackageResourceImporter:
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Upload outside database transactions, then commit all file records together."""
return self.materialize_resources(
read_member=lambda path, limit: self._reader.read_member_bytes(archive, path, max_bytes=limit),
invalid_skills=archive.invalid_skills,
resources=resources,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account_id,
)
def materialize_resources(
self,
*,
read_member: Callable[[str, int], bytes],
invalid_skills: dict[str, str],
resources: AgentPackageResources,
agent_package: AgentPackage,
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Materialize portable references from archive members or local resource bytes."""
files: list[ToolFile | UploadFile] = []
warnings: list[DslImportWarning] = []
soul_data = agent_package.soul.model_dump(mode="json")
@@ -112,15 +132,11 @@ class AgentPackageResourceImporter:
"mime_type": "application/zip",
}
)
reason = archive.invalid_skills.get(skill_resource.id)
reason = invalid_skills.get(skill_resource.id)
normalized = None
try:
if reason is None:
payload = self._reader.read_member_bytes(
archive,
skill_resource.path,
max_bytes=dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024,
)
payload = read_member(skill_resource.path, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
normalized = self._skill_packages.validate_and_normalize(
content=payload, filename=skill_resource.path
)
@@ -165,7 +181,7 @@ class AgentPackageResourceImporter:
file_kind = package_ref["file_kind"]
mime_type = package_ref["mime_type"] or "application/octet-stream"
extension = self._extension(package_ref["name"])
payload = self._reader.read_member_bytes(archive, file_resource.path, max_bytes=file_resource.size)
payload = read_member(file_resource.path, file_resource.size)
if file_kind == "tool_file":
storage_key = f"tools/{tenant_id}/{uuid4().hex}.{extension or 'bin'}"
row: ToolFile | UploadFile = ToolFile(
@@ -216,13 +232,26 @@ class AgentPackageResourceImporter:
def materialize_icons(
self, *, archive: PreparedPackageArchive, icons: list[PackageIcon], tenant_id: str, account_id: str
) -> dict[str, str]:
return self.materialize_icon_resources(
read_member=lambda path, limit: self._reader.read_member_bytes(archive, path, max_bytes=limit),
icons=icons,
tenant_id=tenant_id,
account_id=account_id,
)
def materialize_icon_resources(
self,
*,
read_member: Callable[[str, int], bytes],
icons: list[PackageIcon],
tenant_id: str,
account_id: str,
) -> dict[str, str]:
files: list[ToolFile | UploadFile] = []
mapping: dict[str, str] = {}
for icon in icons:
payload = self._reader.read_member_bytes(
archive, icon.path, max_bytes=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024
)
payload = read_member(icon.path, dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024)
extension = self._extension(icon.path)
file_id = str(uuid4())
key = f"upload_files/{tenant_id}/{file_id}.{extension}"
+42 -29
View File
@@ -1,4 +1,4 @@
"""Export active Roster Agents as portable ``.ifpkg`` archives."""
"""Export active Roster Agents as portable YAML definitions or ``.ifpkg`` archives."""
from __future__ import annotations
@@ -12,6 +12,7 @@ from uuid import UUID
import yaml
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
@@ -49,6 +50,7 @@ from services.agent.roster_package_entities import (
RosterAgentPackageManifest,
)
from services.agent.roster_service import AgentRosterService
from services.app_dsl_service import AppDslService
from services.entities.site_dsl import SiteDsl
from services.plugin.dependencies_analysis import DependenciesAnalysisService
@@ -66,31 +68,46 @@ class RosterAgentPackageExporter:
self._dependency_provider = dependency_provider or DependenciesAnalysisService.generate_dependencies
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> RosterAgentPackageExport:
"""Export a visible version, or the shared draft with an active snapshot fallback."""
app, resources, audit = self.collect(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
return self._build_archive(app=app, audit=audit, resources=resources)
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str:
"""Reuse the standard YAML export semantics, including omitted asset references."""
with session_factory.create_session() as session:
_, app_model = self._load_source(session=session, tenant_id=tenant_id, agent_id=agent_id)
return AppDslService.export_dsl(app_model=app_model, session=session, version_id=version_id)
@staticmethod
def _load_source(*, session: Session, tenant_id: str, agent_id: str) -> tuple[Agent, App]:
row = session.execute(
select(Agent, App)
.join(
App,
(App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id),
)
.where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)),
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
).one_or_none()
if row is None:
raise AgentNotFoundError()
return row[0], row[1]
def collect(
self, *, tenant_id: str, agent_id: str, version_id: UUID | None
) -> tuple[AgentAppDsl, AgentPackageResourceExporter, RosterAgentPackageAudit]:
"""Collect configuration and resource references without serializing a package."""
resources = AgentPackageResourceExporter(storage_backend=self._storage)
with session_factory.create_session() as session:
row = session.execute(
select(Agent, App)
.join(
App,
(App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id),
)
.where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
Agent.app_id.is_not(None),
or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)),
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
).one_or_none()
if row is None:
raise AgentNotFoundError()
agent, app_model = row
agent, app_model = self._load_source(session=session, tenant_id=tenant_id, agent_id=agent_id)
draft = None
snapshot_id = agent.active_config_snapshot_id
@@ -146,11 +163,7 @@ class RosterAgentPackageExporter:
resources.collect_workspace_skills()
app.dependencies = self._dependency_provider(tenant_id, dependency_ids)
return self._build_archive(
app=app,
audit=audit,
resources=resources,
)
return app, resources, audit
def _build_archive(
self,
+213 -77
View File
@@ -3,13 +3,17 @@
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import BinaryIO
from urllib.parse import urlsplit
from uuid import uuid4
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from configs import dify_config
from constants.model_template import default_app_templates
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
@@ -25,7 +29,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, IconType, UploadFile
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata
from services.agent.dsl_entities import AgentAppDsl, AgentPackage, AgentPackageMetadata
from services.agent.dsl_service import AgentDslService
from services.agent.errors import (
AgentNameConflictError,
@@ -35,15 +39,19 @@ from services.agent.errors import (
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_importer import AgentPackageResourceImporter, _Storage
from services.agent.roster_package_entities import AgentPackageResources, PackageIcon
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.agent.roster_service import AgentRosterService
from services.app_creation_records import create_installed_app_record, create_site_record
from services.app_dsl_service import AppDslService
from services.app_import_source import download_app_import_source
from services.app_service import AppService
from services.entities.dsl_entities import DslImportWarning
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.feature_service import FeatureService
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE, is_valid_image_icon
from services.recommended_app_package_service import RecommendedAgentPackageSource
logger = logging.getLogger(__name__)
@@ -61,101 +69,229 @@ class RosterAgentPackageImporter:
def __init__(self, *, storage_backend: _Storage = storage) -> None:
self._resources = AgentPackageResourceImporter(storage_backend=storage_backend)
def import_template(
self,
*,
source: RecommendedAgentPackageSource,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
"""Import a catalog-admitted published source without an outer archive."""
app, resources, _ = RosterAgentPackageExporter().collect(
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
)
payloads, resource_index, icons = resources.read_local_resources(app.agent.package_ref)
return self.import_collected(
app_dsl=app,
resources=resource_index,
icons=icons,
# Local payloads already passed per-resource and aggregate limits during collection.
read_member=lambda path, _limit: payloads[path],
invalid_skills={},
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_package_url(
self,
*,
url: str,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
try:
parsed = urlsplit(url)
credential_free = not parsed.username and not parsed.password
except ValueError:
credential_free = False
if not credential_free:
raise InvalidRosterAgentPackageError("Package URL must be an HTTP(S) URL without credentials")
with download_app_import_source(url, max_bytes=dify_config.AGENT_PACKAGE_MAX_BYTES) as source:
return self.import_package(
source=source,
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_package(
self,
*,
source: BinaryIO,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
with RosterAgentPackageReader().read(source) as package:
if len(package.apps) != 1:
raise InvalidRosterAgentPackageError("Import requires exactly one Agent App")
app_dsl = next(iter(package.apps.values()))
agent_package = app_dsl.package
self._resources.validate(resources=package.manifest, agent_package=agent_package)
return self.import_collected(
app_dsl=next(iter(package.apps.values())),
resources=package.manifest,
icons=package.manifest.icons,
read_member=lambda path, limit: RosterAgentPackageReader().read_member_bytes(
package, path, max_bytes=limit
),
invalid_skills=package.invalid_skills,
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_collected(
self,
*,
app_dsl: AgentAppDsl,
resources: AgentPackageResources,
icons: list[PackageIcon],
read_member: Callable[[str, int], bytes],
invalid_skills: dict[str, str],
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
if icon_type is not None:
try:
# Resolve billing before uploading package members or opening the write transaction.
allow_premium_site_settings = app_dsl.site is None or FeatureService.can_import_premium_site_settings(
tenant_id
)
icons = self._resources.materialize_icons(
archive=package, icons=package.manifest.icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in icons:
agent_package.metadata.icon = icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in icons:
app_metadata.icon = icons[app_metadata.icon]
site_data = app_dsl.site.model_copy(deep=True) if app_dsl.site is not None else None
if site_data is not None and site_data.icon_type == "image":
site_icon = site_data.icon
if site_icon is not None and site_icon in icons:
site_data = site_data.model_copy(update={"icon": icons[site_icon]})
materialized, skill_warnings = self._resources.materialize(
archive=package,
resources=package.manifest,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
)
with session_factory.create_session() as session:
resolved_soul, warnings = AgentDslService(session).resolve_package_soul(
tenant_id=tenant_id,
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
app_id, agent_id = self._persist_import(
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
site_data=site_data,
allow_premium_site_settings=allow_premium_site_settings,
soul=resolved_soul,
)
except Exception as exc:
if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc):
raise AgentNameConflictError() from exc
if isinstance(
exc,
(
AgentNameConflictError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
RosterAgentPackageResourceUnavailableError,
),
):
raise
raise RosterAgentPackageImportFailedError() from exc
IconType(icon_type)
except ValueError as exc:
raise InvalidRosterAgentPackageError("Invalid icon type") from exc
with session_factory.create_session() as session:
if not is_valid_image_icon(session=session, tenant_id=tenant_id, icon_type=icon_type, icon=icon):
raise InvalidRosterAgentPackageError("Image icon must belong to the current workspace")
agent_package = app_dsl.package
self._resources.validate(resources=resources, agent_package=agent_package)
overrides = {
key: value for key, value in {"name": name, "description": description}.items() if value is not None
}
try:
agent_package.metadata = AgentPackageMetadata.model_validate(
{**agent_package.metadata.model_dump(), **overrides}
)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("Invalid Agent metadata overrides") from exc
try:
# Resolve billing before uploading package members or opening the write transaction.
allow_premium_site_settings = app_dsl.site is None or FeatureService.can_import_premium_site_settings(
tenant_id
)
materialized_icons = self._resources.materialize_icon_resources(
read_member=read_member, icons=icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in materialized_icons:
agent_package.metadata.icon = materialized_icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in materialized_icons:
app_metadata.icon = materialized_icons[app_metadata.icon]
site_data = app_dsl.site.model_copy(deep=True) if app_dsl.site is not None else None
if site_data is not None and site_data.icon_type == "image":
site_icon = site_data.icon
if site_icon is not None and site_icon in materialized_icons:
site_data = site_data.model_copy(update={"icon": materialized_icons[site_icon]})
for field_name, value in {
"icon_type": icon_type,
"icon": icon,
"icon_background": icon_background,
}.items():
if value is not None:
setattr(agent_package.metadata, field_name, value)
setattr(app_metadata, field_name, value)
materialized, skill_warnings = self._resources.materialize_resources(
read_member=read_member,
invalid_skills=invalid_skills,
resources=resources,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
)
with session_factory.create_session() as session:
resolved_soul, warnings = AgentDslService(session).resolve_package_soul(
tenant_id=tenant_id,
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
app_id, agent_id = self._persist_import(
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
site_data=site_data,
allow_premium_site_settings=allow_premium_site_settings,
soul=resolved_soul,
)
except Exception as exc:
if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc):
raise AgentNameConflictError() from exc
if isinstance(
exc,
(
AgentNameConflictError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
RosterAgentPackageResourceUnavailableError,
),
):
raise
raise RosterAgentPackageImportFailedError() from exc
try:
self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account)
except Exception:
logger.warning(
"Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
if app_dsl.dependencies:
try:
self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account)
AppDslService.cache_import_dependencies(app_id=app_id, dependencies=app_dsl.dependencies)
except Exception:
logger.warning(
"Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s",
"Imported Agent App dependency check could not be cached: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
if app_dsl.dependencies:
try:
AppDslService.cache_import_dependencies(app_id=app_id, dependencies=app_dsl.dependencies)
except Exception:
logger.warning(
"Imported Agent App dependency check could not be cached: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
@staticmethod
def _persist_import(
@@ -164,9 +300,9 @@ class RosterAgentPackageImporter:
account: Account,
metadata: AgentPackageMetadata,
soul: AgentSoulConfig,
allow_premium_site_settings: bool,
app_metadata: AgentPackageMetadata | None = None,
site_data: SiteDsl | None = None,
allow_premium_site_settings: bool,
) -> tuple[str, str]:
with session_factory.create_session() as session, session.begin():
name = AgentDslService(session).unique_roster_name(tenant_id=tenant_id, requested=metadata.name)
+35
View File
@@ -63,6 +63,7 @@ from services.entities.dsl_entities import (
)
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.recommended_app_package_service import RecommendedAppPackageService
from services.system_feature_service import SystemFeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
@@ -207,12 +208,14 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
packages: AppPackageService,
agent_packages: RosterAgentPackageExporter,
agent_importer: RosterAgentPackageImporter,
recommended_packages: RecommendedAppPackageService,
) -> None:
self._session_factory = session_factory
self._dsl_factory = dsl_factory
self._packages = packages
self._agent_packages = agent_packages
self._agent_importer = agent_importer
self._recommended_packages = recommended_packages
@override
def download_import(self, url: str) -> AbstractContextManager[BinaryIO]:
@@ -290,6 +293,38 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
assert params.template_id is not None
assert params.version_id is not None
source = self._recommended_packages.get_source(app_id=str(params.template_id), version_id=params.version_id)
result = self._agent_importer.import_template(
source=source,
tenant_id=context.active_workspace_id,
account=self._import_actor(context),
name=params.name,
description=params.description,
icon_type=params.icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
assert params.package_url is not None
result = self._agent_importer.import_package_url(
url=params.package_url,
tenant_id=context.active_workspace_id,
account=self._import_actor(context),
name=params.name,
description=params.description,
icon_type=params.icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
return AppDslService.check_app_dependencies(tenant_id=context.active_workspace_id, app_id=app_id)
+31
View File
@@ -137,6 +137,10 @@ class AppTransfers(Protocol):
def import_agent_package(self, context: RequestContext, source: BinaryIO) -> ImportedAppPackage: ...
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage: ...
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage: ...
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult: ...
def export_dsl(self, context: RequestContext, app_id: str, options: AppExportOptions) -> str: ...
@@ -203,6 +207,30 @@ class ConsoleAppService:
def import_app(self, context: RequestContext, params: AppImportParams, *, source: BinaryIO | None = None) -> Import:
if source is not None:
return self._import_package(context, params, source)
if params.mode == "template":
if (
params.template_id is None
or params.version_id is None
or params.app_id
or any(value is not None for value in (params.package_url, params.yaml_content, params.yaml_url))
):
raise InvalidRosterAgentPackageError(
"template mode requires template_id and version_id without other sources"
)
self._access.require_import(context, "agent")
return self._import_agent_result(self._transfers.import_agent_template(context, params))
if params.mode == "ifpkg-url":
if (
not params.package_url
or params.app_id
or params.yaml_content is not None
or params.yaml_url is not None
):
raise InvalidRosterAgentPackageError(
"ifpkg-url requires package_url and does not accept YAML or app_id"
)
self._access.require_import(context, "agent")
return self._import_agent_result(self._transfers.import_agent_package_url(context, params))
if params.mode == "yaml-url" and params.yaml_url:
self._access.require_import(context, "url")
with self._transfers.download_import(params.yaml_url) as downloaded:
@@ -225,6 +253,9 @@ class ConsoleAppService:
if params.app_id:
raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App")
result = self._transfers.import_agent_package(context, source)
return self._import_agent_result(result)
def _import_agent_result(self, result: ImportedAppPackage) -> Import:
return Import(
id=str(uuid4()),
status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED,
+5 -2
View File
@@ -17,8 +17,11 @@ from services.dsl_content import DSL_MAX_SIZE
@contextmanager
def download_app_import_source(url: str) -> Generator[BinaryIO]:
def download_app_import_source(url: str, *, max_bytes: int | None = None) -> Generator[BinaryIO]:
"""Fetch a bounded source without opening import transactions or creating resources."""
size_limit = max(dify_config.AGENT_PACKAGE_MAX_BYTES, DSL_MAX_SIZE) if max_bytes is None else max_bytes
if size_limit <= 0:
raise ValueError("Import download size limit must be positive")
url = url.strip()
try:
parsed = urlsplit(url)
@@ -49,7 +52,7 @@ def download_app_import_source(url: str) -> Generator[BinaryIO]:
size = 0
for chunk in response.iter_bytes(chunk_size=1024 * 1024):
size += len(chunk)
if size > max(dify_config.AGENT_PACKAGE_MAX_BYTES, DSL_MAX_SIZE):
if size > size_limit:
raise RosterAgentPackageTooLargeError("App import download exceeds the size limit")
source.write(chunk)
finally:
+10 -2
View File
@@ -1,11 +1,11 @@
"""Read-only app previews, independent of trial execution and account quotas."""
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from services.app_definition_query_service import AppSiteConfiguration
from services.app_definition_query_service import AppDefinitionUnavailableError, AppSiteConfiguration
@dataclass(frozen=True, slots=True)
@@ -33,6 +33,8 @@ class AppPreviewDataset:
class AppPreviewQuery(Protocol):
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object] | None: ...
def get_app(self, *, app_id: str) -> AppPreviewRef | None: ...
def get_site(self, *, app: AppPreviewRef) -> AppPreviewSite | None: ...
@@ -53,6 +55,12 @@ class AppPreviewOwnerUnavailableError(LookupError):
class AppPreviewQueryService:
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object]:
preview = self._apps.get_agent_composer(app=app)
if preview is None:
raise AppDefinitionUnavailableError("Agent preview is unavailable")
return preview
def __init__(self, *, apps: AppPreviewQuery, is_previewable: Callable[[str], bool]) -> None:
self._apps: AppPreviewQuery = apps
self._is_previewable: Callable[[str], bool] = is_previewable
+4
View File
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Protocol
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@@ -23,6 +24,9 @@ class ImportStatus(StrEnum):
class AppImportParams(BaseModel):
mode: str = Field(..., description="Import mode")
template_id: UUID | None = None
version_id: UUID | None = None
package_url: str | None = None
yaml_content: str | None = None
yaml_url: str | None = None
name: str | None = None
@@ -328,7 +328,11 @@ def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoReco
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
app_id = source["id"]
name = source["name"]
export_data = source["export_data"]
mode = _enum_string(source["mode"], field="mode")
package_url = source.get("package_url")
if package_url is not None and (not isinstance(package_url, str) or not package_url.strip()):
raise TypeError("package_url must be a non-empty string")
export_data = source.get("export_data", "") if mode == "agent" and package_url else source["export_data"]
if not isinstance(app_id, str):
raise TypeError("id must be a string")
if not isinstance(name, str):
@@ -336,7 +340,6 @@ def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
if not isinstance(export_data, str):
raise TypeError("export_data must be a string")
mode = _enum_string(source["mode"], field="mode")
if mode is None:
raise TypeError("mode must be a string or string enum")
return RecommendedAppDetailRecord(
@@ -346,6 +349,7 @@ def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
icon_background=cast(str | None, source.get("icon_background")),
mode=mode,
export_data=export_data,
package_url=package_url,
)
@@ -0,0 +1,53 @@
"""Resolve publicly listed Agent templates for direct creation and published exports."""
from typing import BinaryIO, Literal, NamedTuple, Protocol
from uuid import UUID
from services.recommended_app_query_service import RecommendedAppNotFoundError
class RecommendedAgentPackageSource(NamedTuple):
tenant_id: str
agent_id: str
version_id: UUID
class RecommendedAgentPackageQuery(Protocol):
def get_package_source(self, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource | None: ...
class AgentPackageDownload(Protocol):
@property
def archive(self) -> BinaryIO: ...
@property
def filename(self) -> str: ...
def close(self) -> None: ...
class AgentPackageExporter(Protocol):
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> AgentPackageDownload: ...
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str: ...
class RecommendedAppPackageService:
def __init__(self, *, sources: RecommendedAgentPackageQuery, exporter: AgentPackageExporter) -> None:
self._sources = sources
self._exporter = exporter
def get_source(self, *, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource:
source = self._sources.get_package_source(app_id, version_id)
if source is None:
raise RecommendedAppNotFoundError
return source
def export(self, *, app_id: str, version_id: UUID, format: Literal["yaml", "ifpkg"]) -> str | AgentPackageDownload:
source = self.get_source(app_id=app_id, version_id=version_id)
# Resolve template admission and close the read session before export performs storage I/O.
if format == "yaml":
return self._exporter.export_yaml(
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
)
return self._exporter.export(tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id)
@@ -41,6 +41,8 @@ class RecommendedAppDetailRecord(NamedTuple):
icon_background: str | None
mode: str
export_data: str
package_url: str | None = None
version_id: str | None = None
class RecommendedAppCatalogQuery(Protocol):
@@ -89,6 +91,8 @@ class RecommendedAppDetailSummary(NamedTuple):
mode: str
export_data: str
can_trial: bool
package_url: str | None = None
version_id: str | None = None
class RecommendedAppNotFoundError(Exception):
@@ -154,6 +158,8 @@ class RecommendedAppQueryService:
mode=detail.mode,
export_data=detail.export_data,
can_trial=can_trial,
package_url=detail.package_url,
version_id=detail.version_id,
)
def _with_trial_status(self, apps: Sequence[RecommendedAppRecord]) -> tuple[RecommendedAppSummary, ...]:
+2 -2
View File
@@ -34,13 +34,13 @@ class TrialAppAccessService:
def __init__(self, *, apps: TrialAppAccessQuery) -> None:
self._apps: TrialAppAccessQuery = apps
def get_access(self, *, app_id: str, account_id: str) -> TrialAppRef:
def get_access(self, *, app_id: str, account_id: str, check_usage: bool = True) -> TrialAppRef:
access = self._apps.resolve(app_id=app_id, account_id=account_id)
if access is None:
raise TrialAppUnavailableError(f"App {app_id} is unavailable for trial execution")
# Preserve the first-use allowance, including when the configured limit is zero.
if access.used_count is not None and access.used_count >= access.trial_limit:
if check_usage and access.used_count is not None and access.used_count >= access.trial_limit:
raise TrialAppUsageLimitExceededError(
f"Account {account_id} has used {access.used_count} trials of app {app_id} "
f"with a limit of {access.trial_limit}"
+1 -1
View File
@@ -52,7 +52,7 @@ class TrialAppGenerationService:
def generate_chat(
self, *, trial_app: TrialAppRef, account_id: str, args: Mapping[str, object]
) -> GenerationResponse:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise TrialAppNotChatError(f"App {trial_app.app_id} is not a chat app")
return self._generate(
trial_app=trial_app, account_id=account_id, args={**args, "auto_generate_name": False}, streaming=True
+4
View File
@@ -250,13 +250,17 @@ def persist_service_api_dataset_owner(
@pytest.fixture
def app_services(sqlite_session_factory: sessionmaker[Session]) -> AppServices:
from unittest.mock import Mock
from extensions.application_services.app import build_app_services
from extensions.ext_application_services import _build_oauth_server_service
from extensions.ext_redis import redis_client
from services.recommended_app_package_service import RecommendedAppPackageService
return build_app_services(
database_client=sqlite_session_factory,
oauth=_build_oauth_server_service(database_client=sqlite_session_factory, redis=redis_client),
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
)
@@ -4,7 +4,8 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from inspect import unwrap
from typing import BinaryIO, cast
from uuid import uuid4
from unittest.mock import Mock
from uuid import UUID, uuid4
import httpx
import pytest
@@ -25,6 +26,7 @@ from services.app.console_gateway import AppTransferGateway
from services.app_package_service import AppPackageService, PreparedAppPackage
from services.entities.dsl_entities import AppImportPackage, AppImportParams, DslImportWarning, Import, ImportStatus
from services.entities.feature_entities import FeatureModel, LimitationModel
from services.recommended_app_package_service import RecommendedAgentPackageSource, RecommendedAppPackageService
from tests.unit_tests.controllers.conftest import ControllerTestServices
@@ -331,6 +333,157 @@ def test_export_query_rejects_conflicting_version_selectors() -> None:
)
def test_template_import_resolves_catalog_source_and_preserves_overrides(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
template_id = UUID("11111111-1111-4111-8111-111111111111")
version_id = UUID("22222222-2222-4222-8222-222222222222")
source = RecommendedAgentPackageSource("source-tenant", "source-agent", version_id)
catalog = Mock()
catalog.get_package_source.return_value = source
transfers._recommended_packages = RecommendedAppPackageService(sources=catalog, exporter=Mock())
import_template = Mock(return_value=RosterAgentPackageImportResult("new-app", "new-agent", []))
monkeypatch.setattr(transfers._agent_importer, "import_template", import_template)
data, status = _post(
app,
imports,
json={
"mode": "template",
"template_id": str(template_id),
"version_id": str(version_id),
"name": "My Agent",
"app_id": "",
},
)
assert status == 200
assert data["app_id"] == "new-app"
assert data["app_mode"] == "agent"
catalog.get_package_source.assert_called_once_with(str(template_id), version_id)
import_template.assert_called_once()
assert import_template.call_args.kwargs["source"] == source
assert import_template.call_args.kwargs["tenant_id"] == imports.context.active_workspace_id
assert import_template.call_args.kwargs["account"].id == imports.context.account_id
assert import_template.call_args.kwargs["name"] == "My Agent"
def test_template_import_rejects_unlisted_source(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
catalog = Mock()
catalog.get_package_source.return_value = None
transfers._recommended_packages = RecommendedAppPackageService(sources=catalog, exporter=Mock())
import_template = Mock()
monkeypatch.setattr(transfers._agent_importer, "import_template", import_template)
with pytest.raises(import_module.RecommendedAppNotFoundHttpError):
_post(
app,
imports,
json={
"mode": "template",
"template_id": "11111111-1111-4111-8111-111111111111",
"version_id": "22222222-2222-4222-8222-222222222222",
},
)
import_template.assert_not_called()
def test_package_url_import_preserves_overrides(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
import_url = Mock(return_value=RosterAgentPackageImportResult("new-app", "new-agent", []))
monkeypatch.setattr(transfers._agent_importer, "import_package_url", import_url)
data, status = _post(
app,
imports,
json={"mode": "ifpkg-url", "package_url": "https://example.com/agent.ifpkg", "name": "My Agent"},
)
assert status == 200
assert data["app_id"] == "new-app"
import_url.assert_called_once()
assert import_url.call_args.kwargs["url"] == "https://example.com/agent.ifpkg"
assert import_url.call_args.kwargs["name"] == "My Agent"
def test_package_url_retains_legacy_optional_selectors(
app: Flask, imports: Imports, app_query_services: ControllerTestServices, monkeypatch: pytest.MonkeyPatch
) -> None:
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
import_url = Mock(return_value=RosterAgentPackageImportResult("new-app", "new-agent", []))
monkeypatch.setattr(transfers._agent_importer, "import_package_url", import_url)
data, status = _post(
app,
imports,
json={
"mode": "ifpkg-url",
"package_url": "https://example.com/agent.ifpkg",
"app_id": "",
"template_id": "11111111-1111-4111-8111-111111111111",
"version_id": "22222222-2222-4222-8222-222222222222",
},
)
assert status == 200
assert data["app_id"] == "new-app"
import_url.assert_called_once()
assert import_url.call_args.kwargs["url"] == "https://example.com/agent.ifpkg"
@pytest.mark.parametrize("mode", ["template", "ifpkg-url"])
def test_template_import_checks_permissions_before_accessing_source(
app: Flask,
imports: Imports,
app_query_services: ControllerTestServices,
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
mode: str,
) -> None:
config_overrides(RBAC_ENABLED=True)
monkeypatch.setattr(
"services.app.console_gateway.rbac_service.RBACService.CheckAccess.check", lambda *_a, **_k: False
)
transfers = app_query_services.apps.console._transfers
assert isinstance(transfers, AppTransferGateway)
importer = Mock()
monkeypatch.setattr(transfers, "_agent_importer", importer)
payload: dict[str, object] = (
{
"mode": "template",
"template_id": "11111111-1111-4111-8111-111111111111",
"version_id": "22222222-2222-4222-8222-222222222222",
}
if mode == "template"
else {"mode": "ifpkg-url", "package_url": "https://example.com/agent.ifpkg"}
)
with pytest.raises(Forbidden):
_post(app, imports, json=payload)
importer.assert_not_called()
@pytest.mark.parametrize(
"extra",
[
{"app_id": "existing"},
{"yaml_content": "app: {}"},
],
)
def test_package_url_rejects_ambiguous_sources(app: Flask, imports: Imports, extra: dict[str, str]) -> None:
with pytest.raises(InvalidRosterAgentPackageError):
_post(app, imports, json={"mode": "ifpkg-url", "package_url": "https://example.com/agent.ifpkg", **extra})
@pytest.mark.parametrize("quota", [False, True])
def test_ordinary_package_import_checks_permission_and_quota(
app: Flask, imports: Imports, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], quota: bool
@@ -1,14 +1,19 @@
from inspect import unwrap
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from pydantic import ValidationError
import controllers.console.explore.recommended_app as module
from libs.external_api import ExternalApi
from machinery.context import RequestContext
from models.model import AppMode, IconType
from services.agent.roster_package_entities import RosterAgentPackageExport
from services.recommended_app_package_service import RecommendedAgentPackageSource, RecommendedAppPackageService
from services.recommended_app_query_service import (
LearnDifyAppListResult,
RecommendedAppDetailSummary,
@@ -21,6 +26,77 @@ from services.recommended_app_query_service import (
)
@pytest.mark.parametrize("format", [None, "ifpkg", "yaml"])
def test_template_export_works_without_browser_credentials(
monkeypatch: pytest.MonkeyPatch,
format: str | None,
) -> None:
app = Flask(__name__)
app_id, version_id = uuid4(), uuid4()
artifact = RosterAgentPackageExport(archive=BytesIO(b"package-bytes"), filename="sample.ifpkg", size=13)
sources, exporter = MagicMock(), MagicMock()
sources.get_package_source.return_value = RecommendedAgentPackageSource("source-tenant", "source-agent", version_id)
exporter.export.return_value = artifact
exporter.export_yaml.return_value = "kind: app\n"
packages = RecommendedAppPackageService(sources=sources, exporter=exporter)
monkeypatch.setattr(module, "application_services", lambda: SimpleNamespace(recommended_app_packages=packages))
ExternalApi(app).add_resource(module.RecommendedAgentExportApi, "/trial-apps/<uuid:app_id>/export")
query = {"version_id": str(version_id)}
if format is not None:
query["format"] = format
response = app.test_client().get(f"/trial-apps/{app_id}/export", query_string=query)
assert response.status_code == 200
if format == "yaml":
assert response.json == {"data": "kind: app\n"}
assert response.mimetype == "application/json"
exporter.export.assert_not_called()
exporter.export_yaml.assert_called_once_with(
tenant_id="source-tenant",
agent_id="source-agent",
version_id=version_id,
)
else:
assert response.data == b"package-bytes"
assert response.mimetype == "application/zip"
exporter.export_yaml.assert_not_called()
exporter.export.assert_called_once_with(
tenant_id="source-tenant", agent_id="source-agent", version_id=version_id
)
assert response.headers["Cache-Control"] == "no-store"
sources.get_package_source.assert_called_once_with(str(app_id), version_id)
response.close()
if format != "yaml":
assert artifact.archive.closed
else:
artifact.close()
exporter.reset_mock()
sources.get_package_source.return_value = None
response = app.test_client().get(f"/trial-apps/{app_id}/export", query_string=query)
assert response.status_code == 404
assert response.json is not None
assert response.json["code"] == "recommended_app_not_found"
exporter.export.assert_not_called()
exporter.export_yaml.assert_not_called()
@pytest.mark.parametrize("option", [{"format": "json"}, {"include_secret": "true"}])
def test_template_export_rejects_unsupported_options(
monkeypatch: pytest.MonkeyPatch,
option: dict[str, str],
) -> None:
app = Flask(__name__)
exports = MagicMock()
monkeypatch.setattr(module, "application_services", lambda: SimpleNamespace(recommended_app_packages=exports))
ExternalApi(app).add_resource(module.RecommendedAgentExportApi, "/trial-apps/<uuid:app_id>/export")
response = app.test_client().get(
f"/trial-apps/{uuid4()}/export",
query_string={"version_id": str(uuid4()), **option},
)
assert response.status_code == 422
exports.export.assert_not_called()
def _request_context() -> RequestContext:
return RequestContext(
request_id="request-1",
@@ -113,6 +189,8 @@ class TestRecommendedAppApi:
"mode": "chat",
"export_data": "{}",
"can_trial": False,
"package_url": None,
"version_id": None,
}
def test_get_missing_raises_stable_not_found_error(self, app: Flask) -> None:
@@ -20,6 +20,11 @@ def test_trial_routes_keep_paths_and_endpoints() -> None:
None,
),
"TrialChatApi": (("/trial-apps/<uuid:app_id>/chat-messages",), "trial_app_chat_completion"),
"TrialChatTaskStopApi": (
("/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",),
"trial_app_stop_chat_completion",
),
"TrialAgentComposerApi": (("/trial-apps/<uuid:app_id>/agent-composer",), None),
"TrialMessageSuggestedQuestionApi": (
("/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",),
"trial_app_suggested_question",
@@ -0,0 +1,342 @@
"""Exercise public template assets through real admission, snapshots, and file services."""
import io
import zipfile
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import Mock
from urllib.parse import parse_qs, urlsplit
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.test import TestResponse
from controllers.console.app import agent_config_inspector as inspector
from controllers.console.app import preview_admission
from controllers.console.explore import trial
from extensions.ext_storage import StorageType
from libs.external_api import ExternalApi
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentScope,
AgentSource,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import CreatorUserRole
from models.model import App, AppMode, RecommendedApp, UploadFile
from models.tools import ToolFile
from repositories.app_preview_query_repository import AppPreviewQueryRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
from services.agent_config_service import AgentConfigService
from services.app_preview_query_service import AppPreviewQueryService
@dataclass
class Harness:
app: Flask
factory: sessionmaker[Session]
source: App
agent: Agent
snapshot: AgentConfigSnapshot
upload_id: str
skill_id: str
reads: list[str]
def url(self, suffix: str) -> str:
return f"/console/api/trial-apps/{self.source.id}/agent/config/{suffix}"
def get(self, suffix: str, **query: str) -> TestResponse:
return self.app.test_client().get(self.url(suffix), query_string=query)
@pytest.fixture
def harness(
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
) -> Harness:
config_overrides(SECRET_KEY="trial-config-test", FILES_URL="http://localhost")
tenant_id, agent_id, snapshot_id, account_id = (str(uuid4()) for _ in range(4))
source = App(
id=str(uuid4()),
tenant_id=tenant_id,
name="Template",
mode=AppMode.AGENT,
is_public=True,
enable_site=False,
enable_api=False,
)
agent = Agent(
id=agent_id,
tenant_id=tenant_id,
app_id=source.id,
name="Template",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
active_config_snapshot_id=snapshot_id,
)
skill_id = str(uuid4())
skill_zip = io.BytesIO()
with zipfile.ZipFile(skill_zip, "w") as archive:
archive.writestr("SKILL.md", "---\nname: research\ndescription: Research\n---\nPublished skill")
archive.writestr("scripts/run.py", "print('published')")
payloads = {"skill.zip": skill_zip.getvalue(), "guide.txt": b"Published attachment"}
upload = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.LOCAL,
key="guide.txt",
name="guide.txt",
size=len(payloads["guide.txt"]),
extension="txt",
mime_type="text/plain",
created_by=account_id,
created_by_role=CreatorUserRole.ACCOUNT,
created_at=datetime(2026, 1, 1),
used=False,
)
snapshot = AgentConfigSnapshot(
id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
version=1,
config_snapshot=AgentSoulConfig.model_validate(
{
"config_skills": [{"name": "research", "file_id": skill_id}],
"config_files": [{"name": "guide.txt", "file_kind": "upload_file", "file_id": upload.id}],
}
),
)
skill_file = ToolFile(
tenant_id=tenant_id,
user_id=account_id,
conversation_id=None,
file_key="skill.zip",
mimetype="application/zip",
name="research.zip",
size=len(payloads["skill.zip"]),
)
skill_file.id = skill_id
with sqlite_session_factory.begin() as session:
session.add_all(
[
source,
agent,
snapshot,
upload,
skill_file,
RecommendedApp(
app_id=source.id,
description={},
copyright="",
privacy_policy="",
category="Agent",
categories=["Agent"],
custom_disclaimer="",
position=1,
is_listed=True,
language="en-US",
),
AgentConfigRevision(
tenant_id=tenant_id,
agent_id=agent_id,
current_snapshot_id=snapshot_id,
revision=1,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
),
AgentConfigDraft(
tenant_id=tenant_id,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
base_snapshot_id=snapshot_id,
config_snapshot=AgentSoulConfig.model_validate(
{
"config_files": [{"name": "private.txt", "file_kind": "upload_file", "file_id": upload.id}],
}
),
),
]
)
reads: list[str] = []
def load_once(key: str) -> bytes:
reads.append(key)
return payloads[key]
monkeypatch.setattr("services.agent_config_service.storage", SimpleNamespace(load_once=load_once))
catalog = DatabaseRecommendedAppCatalogRepository(sqlite_session_factory, redis=Mock())
services = SimpleNamespace(
app_previews=AppPreviewQueryService(
apps=AppPreviewQueryRepository(session_factory=sqlite_session_factory),
is_previewable=catalog.contains,
)
)
monkeypatch.setattr(preview_admission, "application_services", lambda: services)
monkeypatch.setattr(inspector, "application_services", lambda: services)
monkeypatch.setattr(trial, "application_services", lambda: services)
monkeypatch.setattr(inspector, "_service", lambda: AgentConfigService(session_factory=sqlite_session_factory))
app = Flask(__name__)
api = ExternalApi(app)
api.add_resource(trial.TrialAgentComposerApi, "/console/api/trial-apps/<uuid:app_id>/agent-composer")
routes = [
(inspector.TrialAgentConfigSkillsApi, "skills"),
(inspector.TrialAgentConfigFilesApi, "files"),
(inspector.TrialAgentConfigSkillInspectApi, "skills/<string:name>/inspect"),
(inspector.TrialAgentConfigSkillDownloadApi, "skills/<string:name>/download"),
(inspector.TrialAgentConfigSkillFilePreviewApi, "skills/<string:name>/files/preview"),
(inspector.TrialAgentConfigSkillFileDownloadApi, "skills/<string:name>/files/download"),
(inspector.TrialAgentConfigFilePreviewApi, "files/<string:name>/preview"),
(inspector.TrialAgentConfigFileDownloadApi, "files/<string:name>/download"),
]
prefix = "/console/api/trial-apps/<uuid:app_id>/agent/config/"
for resource, suffix in routes:
api.add_resource(resource, prefix + suffix)
api.add_resource(
inspector.TrialAgentConfigSkillFileContentApi,
prefix + "skills/<string:name>/files/content",
endpoint="console.trial_agent_config_skill_file_content",
)
return Harness(app, sqlite_session_factory, source, agent, snapshot, upload.id, skill_id, reads)
def test_published_template_configuration_is_readable_without_workspace_membership(harness: Harness) -> None:
composer_url = f"/console/api/trial-apps/{harness.source.id}/agent-composer"
composer = harness.app.test_client().get(composer_url)
assert composer.status_code == 200
assert composer.json is not None
assert composer.json["agent_soul"]["config_files"][0]["name"] == "guide.txt"
assert composer.json["active_config_snapshot"]["id"] == harness.snapshot.id
assert composer.json["draft"] is None
assert composer.json["save_options"] == []
for kind, name in [("skills", "research"), ("files", "guide.txt")]:
response = harness.get(kind)
assert response.status_code == 200
assert response.json is not None
assert response.json["items"][0]["name"] == name
assert response.json["config_version"] == {"id": harness.snapshot.id, "kind": "snapshot", "writable": False}
response = harness.get("skills/research/inspect")
assert response.status_code == 200
assert response.json is not None
assert "Published skill" in response.json["skill_md"]["text"]
response = harness.get("skills/research/files/preview", path="scripts/run.py")
assert response.status_code == 200
assert response.json is not None
assert response.json["text"] == "print('published')"
response = harness.get("files/guide.txt/preview", version_id=harness.snapshot.id)
assert response.status_code == 200
assert response.json is not None
assert response.json["text"] == "Published attachment"
assert response.headers["Cache-Control"] == "no-store"
for path in ["files/guide.txt/download", "skills/research/download"]:
response = harness.get(path)
assert response.status_code == 200
assert response.json is not None
assert {"timestamp", "nonce", "sign"} <= parse_qs(urlsplit(response.json["url"]).query).keys()
response = harness.get("skills/research/files/download", path="scripts/run.py")
assert response.json is not None
url = response.json["url"]
download = harness.app.test_client().get(url)
assert download.status_code == 200
assert download.data == b"print('published')"
assert download.headers["Content-Disposition"].startswith("attachment;")
with harness.factory() as session:
assert session.scalar(select(func.count()).select_from(AgentConfigDraft)) == 1
assert session.scalar(select(func.count()).select_from(UploadFile)) == 1
with harness.factory.begin() as session:
app = session.get(App, harness.source.id)
assert app is not None
app.is_public = False
reads_before = list(harness.reads)
assert harness.app.test_client().get(url).status_code == 404
assert harness.app.test_client().get(composer_url).status_code == 404
assert harness.reads == reads_before
@pytest.mark.parametrize(
("query", "status"),
[
({"draft_type": "draft"}, 422),
({"file_id": "other"}, 422),
({"version_id": str(uuid4())}, 404),
],
)
def test_version_and_resource_selectors_cannot_bypass_admission(
harness: Harness,
query: dict[str, str],
status: int,
) -> None:
assert harness.get("files/guide.txt/preview", **query).status_code == status
assert harness.reads == []
@pytest.mark.parametrize("path", ["../SKILL.md", "scripts/../../SKILL.md"])
def test_skill_member_paths_cannot_escape_archive(harness: Harness, path: str) -> None:
assert harness.get("skills/research/files/preview", path=path).status_code == 400
assert harness.reads == []
@pytest.mark.parametrize("kind", ["file", "skill"])
def test_foreign_tenant_payloads_are_not_read(harness: Harness, kind: str) -> None:
with harness.factory.begin() as session:
if kind == "file":
upload = session.get(UploadFile, harness.upload_id)
assert upload is not None
upload.tenant_id = str(uuid4())
else:
skill = session.get(ToolFile, harness.skill_id)
assert skill is not None
skill.tenant_id = str(uuid4())
suffix = "files/guide.txt/preview" if kind == "file" else "skills/research/files/preview"
assert harness.get(suffix, **({"path": "SKILL.md"} if kind == "skill" else {})).status_code == 404
assert harness.reads == []
def test_draft_only_files_and_writes_are_unavailable(harness: Harness) -> None:
assert harness.get("files/private.txt/preview").status_code == 404
for method in ["POST", "PUT", "PATCH", "DELETE"]:
assert harness.app.test_client().open(harness.url("files"), method=method).status_code == 405
assert harness.reads == []
@pytest.mark.parametrize(
("state", "status"),
[
("unlisted", 404),
("unpublished", 400),
("foreign_snapshot", 400),
],
)
def test_unavailable_template_never_reads_storage(harness: Harness, state: str, status: int) -> None:
with harness.factory.begin() as session:
if state == "unlisted":
listing = session.scalar(select(RecommendedApp).where(RecommendedApp.app_id == harness.source.id))
assert listing is not None
listing.is_listed = False
elif state == "unpublished":
revision = session.scalar(
select(AgentConfigRevision).where(AgentConfigRevision.agent_id == harness.agent.id)
)
assert revision is not None
revision.operation = AgentConfigRevisionOperation.IMPORT_PACKAGE
else:
snapshot = session.get(AgentConfigSnapshot, harness.snapshot.id)
assert snapshot is not None
snapshot.tenant_id = str(uuid4())
assert harness.get("files/guide.txt/preview").status_code == status
composer = harness.app.test_client().get(f"/console/api/trial-apps/{harness.source.id}/agent-composer")
assert composer.status_code == status
if status == 400:
assert composer.json is not None
assert composer.json["code"] == "app_unavailable"
assert harness.reads == []
@@ -16,6 +16,8 @@ import controllers.console.explore.trial as trial_module
import controllers.console.explore.trial_app_admission as admission_module
import controllers.console.wraps as console_wraps
import libs.login as login_module
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
@@ -106,6 +108,11 @@ class _Runtime:
@dataclass
class _Tasks:
calls: list[str] = field(default_factory=list)
chat_calls: list[tuple[str, str, str]] = field(default_factory=list)
def stop_task(self, *, task_id: str, invoke_from: InvokeFrom, user_id: str, app_mode: AppMode) -> None:
assert invoke_from == InvokeFrom.EXPLORE
self.chat_calls.append((task_id, user_id, app_mode))
def stop_workflow_task_no_user_check(self, *, task_id: str) -> None:
self.calls.append(task_id)
@@ -214,6 +221,7 @@ def harness(
for resource, endpoint in (
(trial_module.TrialAppWorkflowRunApi, "workflows/run"),
(trial_module.TrialChatApi, "chat-messages"),
(trial_module.TrialChatTaskStopApi, "chat-messages/<string:task_id>/stop"),
(trial_module.TrialCompletionApi, "completion-messages"),
(trial_module.TrialAppWorkflowTaskStopApi, "workflows/tasks/<string:task_id>/stop"),
):
@@ -267,6 +275,7 @@ def test_completion_blocking_preserves_response_and_uses_app_owner(
[
("workflows/run", AppMode.WORKFLOW, {"inputs": {"number": 0}, "files": []}),
("chat-messages", AppMode.CHAT, {"inputs": {}, "query": "hello"}),
("chat-messages", AppMode.AGENT, {"inputs": {}, "query": "hello"}),
("chat-messages", AppMode.ADVANCED_CHAT, {"inputs": {}, "query": "hello"}),
("chat-messages", AppMode.AGENT_CHAT, {"inputs": {}, "query": "hello"}),
("completion-messages", AppMode.COMPLETION, {"inputs": {}, "response_mode": "streaming"}),
@@ -302,7 +311,8 @@ def test_stream_records_usage_after_runtime_returns_before_consumption(
@pytest.mark.parametrize(
"endpoint", ["completion-messages", "chat-messages", "workflows/run", "workflows/tasks/task/stop"]
"endpoint",
["completion-messages", "chat-messages", "chat-messages/task/stop", "workflows/run", "workflows/tasks/task/stop"],
)
@pytest.mark.parametrize(
("state", "status", "code"), [("setup", 401, "not_setup"), ("feature", 403, "trial_app_feature_disabled")]
@@ -400,6 +410,44 @@ def test_chat_rejects_invalid_uuid_before_runtime(harness: _Harness, field: str)
assert harness.usage() is None
def test_unpublished_agent_remains_unavailable_after_service_migration(harness: _Harness) -> None:
harness.set_mode(AppMode.AGENT)
harness.runtime.error = AgentAppNotPublishedError("Agent has not been published")
response = harness.post("chat-messages", payload={"inputs": {}, "query": "hello"})
_assert_error(response, 400, "app_unavailable")
assert harness.usage() is None
def test_stop_chat_rejects_workflow(harness: _Harness) -> None:
harness.set_mode(AppMode.WORKFLOW)
_assert_error(harness.post("chat-messages/task-1/stop"), 400, "not_chat_app")
assert harness.services.app_tasks.chat_calls == []
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT, AppMode.ADVANCED_CHAT])
def test_last_trial_can_be_stopped_without_allowing_another_generation(harness: _Harness, mode: AppMode) -> None:
harness.set_mode(mode)
with harness.factory.begin() as session:
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=2))
response = harness.post("chat-messages", payload={"inputs": {}, "query": "hello"})
assert response.status_code == 200
assert harness.usage() == 3
stopped = harness.post("chat-messages/task-last/stop")
assert stopped.status_code == 200
assert harness.services.app_tasks.chat_calls == [("task-last", harness.account.id, mode)]
assert harness.usage() == 3
unavailable = harness.app.test_client().post(harness.url("chat-messages/task-last/stop", app_id=str(uuid4())))
_assert_error(unavailable, 403, "trial_app_not_allowed")
assert len(harness.services.app_tasks.chat_calls) == 1
_assert_error(
harness.post("chat-messages", payload={"inputs": {}, "query": "again"}),
403,
"trial_app_limit_exceeded",
)
def test_chat_normalizes_uuid_and_preserves_empty_ids(harness: _Harness) -> None:
harness.set_mode(AppMode.CHAT)
conversation_id = uuid4()
@@ -39,6 +39,15 @@ from libs.token import generate_csrf_token
from machinery.errors import ActiveWorkspaceRequiredError
from models import Account, AccountTrialAppRecord, App, AppMode, Tenant, TrialApp
from models.account import AccountStatus
from models.agent import (
Agent,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentScope,
AgentSource,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import CustomizeTokenStrategy, TagType
from models.model import AppModelConfig, IconType, Site, Tag, TagBinding
from models.provider_ids import GenericProviderID
@@ -768,6 +777,48 @@ def test_missing_model_config_retains_nullable_detail(harness: _Harness) -> None
assert response.get_json()["model_config"] is None
def test_agent_detail_keeps_model_less_presentation_config(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.AGENT))
session.execute(update(AppModelConfig).where(AppModelConfig.id == harness.config.id).values(model=None))
agent_id, snapshot_id = str(uuid4()), str(uuid4())
session.add_all(
[
Agent(
id=agent_id,
tenant_id=harness.owner.id,
app_id=harness.target.id,
name="Preview agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
active_config_snapshot_id=snapshot_id,
),
AgentConfigSnapshot(
id=snapshot_id,
tenant_id=harness.owner.id,
agent_id=agent_id,
version=1,
config_snapshot=AgentSoulConfig(),
),
AgentConfigRevision(
tenant_id=harness.owner.id,
agent_id=agent_id,
current_snapshot_id=snapshot_id,
revision=1,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
),
]
)
response = harness.get()
assert response.status_code == 200, response.get_json()
body = response.get_json()
assert body["mode"] == "agent"
assert body["model_config"]["model"] is None
assert body["model_config"]["opening_statement"] == ""
@pytest.mark.parametrize("missing", ["reference", "workflow"])
def test_missing_workflow_is_app_unavailable(harness: _Harness, missing: str) -> None:
with harness.factory.begin() as session:
@@ -307,13 +307,13 @@ def _assert_error(response: TestResponse, status: int, code: str, message: str |
assert response.headers["Content-Type"] == "application/json"
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT])
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
@pytest.mark.parametrize("questions", [["Next question?", "Another question?"], []])
def test_questions_keep_response_history_owner_and_usage(
harness: _Harness, questions: list[str], mode: AppMode
) -> None:
harness.provider.questions = questions
harness.provider.app_type = "chatbot" if mode == AppMode.CHAT else "agent"
harness.provider.app_type = {AppMode.CHAT: "chatbot", AppMode.AGENT_CHAT: "agent", AppMode.AGENT: "agent_v2"}[mode]
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=mode))
@@ -407,7 +407,7 @@ def test_absent_default_model_keeps_empty_success(harness: _Harness) -> None:
harness.assert_closed()
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW, AppMode.AGENT])
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
def test_unsupported_app_modes_fail_before_runtime(harness: _Harness, mode: AppMode) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
@@ -0,0 +1,189 @@
from uuid import uuid4
import pytest
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
from fields.agent_fields import AgentAppComposerResponse
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentScope,
AgentSource,
)
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from repositories.app_preview_query_repository import AppPreviewQueryRepository
from services.agent.composer_service import AgentComposerService
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import AppPreviewQueryService, AppPreviewRef
def _preview(session: Session, app: AppPreviewRef) -> AgentAppComposerResponse:
repository = AppPreviewQueryRepository(session_factory=sessionmaker(bind=session.get_bind()))
result = AppPreviewQueryService(apps=repository, is_previewable=lambda _app_id: True).get_agent_composer(app=app)
return AgentAppComposerResponse.model_validate(result)
def _published_app(session: Session) -> tuple[App, Agent, AgentConfigSnapshot]:
tenant_id, app_id, agent_id, snapshot_id = (str(uuid4()) for _ in range(4))
app = App(id=app_id, tenant_id=tenant_id, name="Sample", mode=AppMode.AGENT, enable_site=False, enable_api=False)
agent = Agent(
id=agent_id,
tenant_id=tenant_id,
app_id=app_id,
name="Sample",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
active_config_snapshot_id=snapshot_id,
active_config_is_published=False,
)
snapshot = AgentConfigSnapshot(
id=snapshot_id,
tenant_id=tenant_id,
agent_id=agent_id,
version=1,
config_snapshot=AgentSoulConfig.model_validate(
{
"prompt": {"system_prompt": "Published prompt"},
"model": {
"plugin_id": "langgenius/openai",
"model_provider": "langgenius/openai/openai",
"model": "gpt-4o",
"credential_ref": {"type": "provider", "id": "private-credential"},
},
"env": {"variables": [{"name": "TOKEN", "value": "private-token"}]},
"config_skills": [{"name": "research", "file_id": "private-skill", "description": "Research skill"}],
"config_files": [{"name": "guide.txt", "file_kind": "upload_file", "file_id": "private-file"}],
"tools": {
"cli_tools": [
{
"name": "search-cli",
"env": {"variables": [{"name": "TOKEN", "value": "private-cli-token"}]},
"invoke_metadata": {"password": "private-invoke-password"},
}
],
"dify_tools": [
{
"provider_type": "api",
"provider_id": "private-provider",
"tool_name": "search",
"runtime_parameters": {"password": "private-password"},
}
],
},
}
),
)
session.add_all(
[
app,
agent,
snapshot,
AgentConfigRevision(
tenant_id=tenant_id,
agent_id=agent_id,
current_snapshot_id=snapshot_id,
revision=1,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
),
]
)
session.commit()
return app, agent, snapshot
def test_detached_preview_reference_preserves_owner_scope(sqlite_session: Session) -> None:
app, _, _ = _published_app(sqlite_session)
preview = _preview(sqlite_session, AppPreviewRef(app_id=app.id, tenant_id=app.tenant_id))
assert preview.agent_soul.prompt.system_prompt == "Published prompt"
with pytest.raises(AppDefinitionUnavailableError, match="unavailable"):
_preview(sqlite_session, AppPreviewRef(app_id=app.id, tenant_id=str(uuid4())))
def test_preview_reads_published_snapshot_without_creating_draft(sqlite_session: Session) -> None:
app, agent, snapshot = _published_app(sqlite_session)
before = sqlite_session.scalar(select(func.count()).select_from(AgentConfigDraft))
preview = _preview(sqlite_session, AppPreviewRef(app_id=app.id, tenant_id=app.tenant_id))
assert preview.agent_soul.prompt.system_prompt == "Published prompt"
assert preview.agent_soul.model is not None
assert preview.agent_soul.model.model == "gpt-4o"
assert preview.agent_soul.config_skills[0].name == "research"
assert preview.agent_soul.config_files[0].name == "guide.txt"
assert preview.agent_soul.tools.dify_tools[0].tool_name == "search"
assert preview.agent.id == agent.id
assert preview.active_config_snapshot is not None
assert preview.active_config_snapshot.id == snapshot.id
assert preview.active_config_is_published is True
assert preview.draft is None
assert preview.save_options == []
assert preview.chat_endpoint is None
serialized = preview.model_dump_json()
for value in [
"private-credential",
"private-token",
"private-password",
"private-cli-token",
"private-invoke-password",
app.tenant_id,
]:
assert value not in serialized
assert sqlite_session.scalar(select(func.count()).select_from(AgentConfigDraft)) == before
assert not sqlite_session.new
assert not sqlite_session.dirty
def test_preview_does_not_expose_unpublished_edits(sqlite_session: Session) -> None:
app, agent, snapshot = _published_app(sqlite_session)
sqlite_session.add(
AgentConfigDraft(
tenant_id=app.tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
draft_owner_key="",
base_snapshot_id=snapshot.id,
config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "Unpublished secret prompt"}}),
)
)
sqlite_session.commit()
preview = _preview(sqlite_session, AppPreviewRef(app_id=app.id, tenant_id=app.tenant_id))
assert preview.agent_soul.prompt.system_prompt == "Published prompt"
assert "Unpublished" not in preview.model_dump_json()
editable = AgentAppComposerResponse.model_validate(
AgentComposerService.load_agent_composer(
session=sqlite_session,
tenant_id=app.tenant_id,
agent_id=agent.id,
)
)
assert editable.agent_soul.prompt.system_prompt == "Unpublished secret prompt"
assert editable.draft is not None
assert editable.save_options
def test_preview_rejects_seeded_import_snapshot(sqlite_session: Session) -> None:
app, agent, _ = _published_app(sqlite_session)
revision = sqlite_session.scalar(select(AgentConfigRevision).where(AgentConfigRevision.agent_id == agent.id))
assert revision is not None
revision.operation = AgentConfigRevisionOperation.IMPORT_PACKAGE
sqlite_session.commit()
with pytest.raises(AppDefinitionUnavailableError):
_preview(sqlite_session, AppPreviewRef(app_id=app.id, tenant_id=app.tenant_id))
def test_preview_rejects_cross_tenant_snapshot(sqlite_session: Session) -> None:
app, _, snapshot = _published_app(sqlite_session)
snapshot.tenant_id = str(uuid4())
sqlite_session.commit()
with pytest.raises(AppDefinitionUnavailableError, match="unavailable"):
_preview(sqlite_session, AppPreviewRef(app_id=app.id, tenant_id=app.tenant_id))
@@ -1,12 +1,16 @@
import json
from unittest.mock import MagicMock, patch
from uuid import uuid4
from uuid import UUID, uuid4
from sqlalchemy import event
import pytest
from sqlalchemy import event, select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_redis import RedisClientWrapper
from models.account import Tenant, TenantStatus
from models.agent import Agent, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot
from models.agent_config_entities import AgentSoulConfig
from models.enums import CustomizeTokenStrategy
from models.model import App, AppMode, RecommendedApp, Site
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
@@ -66,6 +70,67 @@ def _add_catalog_app(
return app
@pytest.mark.parametrize("unavailable", [None, "private", "unlisted", "unpublished", "foreign-snapshot", "archived"])
def test_agent_package_link_only_exposes_current_public_version(
sqlite_session_factory: sessionmaker[Session],
unavailable: str | None,
) -> None:
agent_id, version_id = str(uuid4()), str(uuid4())
with sqlite_session_factory() as session:
app = _add_catalog_app(session)
app.mode = AppMode.AGENT
app.is_public = unavailable != "private"
tenant = Tenant(name="Source workspace")
tenant.id = app.tenant_id
tenant.status = TenantStatus.ARCHIVE if unavailable == "archived" else TenantStatus.NORMAL
agent = Agent(
id=agent_id,
tenant_id=app.tenant_id,
app_id=app.id,
name="Template",
scope="roster",
source="agent_app",
active_config_snapshot_id=version_id,
active_config_is_published=False,
)
snapshot = AgentConfigSnapshot(
id=version_id,
tenant_id=str(uuid4()) if unavailable == "foreign-snapshot" else app.tenant_id,
agent_id=agent_id,
version=1,
config_snapshot=AgentSoulConfig(),
)
revision = AgentConfigRevision(
tenant_id=app.tenant_id,
agent_id=agent_id,
current_snapshot_id=version_id,
revision=1,
operation=AgentConfigRevisionOperation.IMPORT_PACKAGE
if unavailable == "unpublished"
else AgentConfigRevisionOperation.PUBLISH_DRAFT,
)
session.add_all([tenant, agent, snapshot, revision])
if unavailable == "unlisted":
for entry in session.scalars(select(RecommendedApp).where(RecommendedApp.app_id == app.id)):
entry.is_listed = False
session.commit()
repository = _repository(sqlite_session_factory)
source = repository.get_package_source(app.id, UUID(version_id))
detail = repository.get_detail(app.id)
assert repository.get_package_source(app.id, uuid4()) is None
if unavailable:
assert source is None
assert detail is None or detail.version_id is None
else:
assert source is not None
assert source.agent_id == agent_id
assert detail is not None
assert detail.export_data == ""
assert detail.package_url is None
assert detail.version_id == version_id
def _redis() -> MagicMock:
redis = MagicMock(spec=RedisClientWrapper)
redis.get.return_value = None
@@ -0,0 +1,66 @@
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session, sessionmaker
from models.agent import Agent, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot
from models.model import App, AppMode, TrialApp
from repositories.trial_app_repository import TrialAppRepository
@pytest.mark.parametrize(
("operation", "include_snapshot", "expected"),
[
(AgentConfigRevisionOperation.PUBLISH_DRAFT, True, True),
(AgentConfigRevisionOperation.IMPORT_PACKAGE, True, False),
(AgentConfigRevisionOperation.PUBLISH_DRAFT, False, False),
],
ids=["published-with-dirty-draft", "unpublished-seed", "missing-snapshot"],
)
def test_agent_trial_requires_a_visible_existing_snapshot(
sqlite_session_factory: sessionmaker[Session],
operation: AgentConfigRevisionOperation,
include_snapshot: bool,
expected: bool,
) -> None:
app_id, tenant_id, agent_id, snapshot_id = (str(uuid4()) for _ in range(4))
with sqlite_session_factory.begin() as session:
session.add_all(
[
App(
id=app_id,
tenant_id=tenant_id,
name="Trial Agent",
mode=AppMode.AGENT,
enable_site=True,
enable_api=False,
),
TrialApp(app_id=app_id, tenant_id=tenant_id),
Agent(
id=agent_id,
tenant_id=tenant_id,
app_id=app_id,
name="Agent",
scope="roster",
source="agent_app",
active_config_snapshot_id=snapshot_id,
active_config_is_published=False,
),
AgentConfigRevision(
tenant_id=tenant_id,
agent_id=agent_id,
current_snapshot_id=snapshot_id,
revision=1,
operation=operation,
),
]
)
if include_snapshot:
session.add(
AgentConfigSnapshot(
id=snapshot_id, tenant_id=tenant_id, agent_id=agent_id, version=1, config_snapshot={}
)
)
result = TrialAppRepository(sqlite_session_factory).existing_ids([app_id])
assert result == (frozenset({app_id}) if expected else frozenset())
@@ -26,7 +26,7 @@ from models.agent import (
from models.agent_config_entities import AgentConfigFileRefConfig, AgentConfigSkillRefConfig, AgentSoulConfig
from models.dataset import Dataset
from models.enums import AppStatus
from models.model import App, AppMode
from models.model import App, AppMode, IconType
from models.tools import ToolFile
from models.workflow import Workflow, WorkflowType
from services.agent.dsl_entities import (
@@ -433,6 +433,9 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(
roster_service.create_backing_agent_for_app.side_effect = create_backing_agent
monkeypatch.setattr("services.agent.dsl_service.AgentRosterService", Mock(return_value=roster_service))
app = _app()
app.icon_type = IconType.LINK
app.icon = "https://example.com/override.png"
app.icon_background = "#123456"
sqlite_session.add(app)
sqlite_session.commit()
@@ -443,6 +446,10 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(
)
assert result.warnings == [warning]
creation = roster_service.create_backing_agent_for_app.call_args.kwargs
assert creation["icon_type"] == AgentIconType.LINK
assert creation["icon"] == app.icon
assert creation["icon_background"] == app.icon_background
assert agent.active_config_is_published is False
assert app.name == "Portable Agent"
assert app.description == "description"
@@ -4,8 +4,11 @@ import hashlib
import io
import json
import zipfile
from collections.abc import Generator
from collections.abc import Callable, Generator
from typing import override
from uuid import UUID, uuid4
import httpx
import pytest
import yaml
from sqlalchemy import event, func, select
@@ -15,6 +18,7 @@ from sqlalchemy.orm import Session, sessionmaker
from core.db.session_factory import session_factory
from core.plugin.entities.plugin import PluginDependency, PluginDependencyType
from models import Account
from models.account import Tenant
from models.agent import (
Agent,
AgentConfigDraft,
@@ -31,6 +35,7 @@ from models.agent_config_entities import AgentSoulConfig
from models.base import Base, TypeBase
from models.model import App, AppModelConfig, InstalledApp, Site, UploadFile
from models.tools import ToolFile
from services.agent import roster_package_importer as importer_module
from services.agent.dsl_entities import (
AgentPackage,
AgentPackageMetadata,
@@ -52,12 +57,15 @@ from services.agent.roster_package_entities import (
RosterAgentPackageManifest,
RosterAgentPackageSkill,
)
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_importer import RosterAgentPackageImporter
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.app_dsl_service import AppDslService
from services.app_service import AppService
from services.feature_service import FeatureService
from services.file_service import FileService
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.recommended_app_package_service import RecommendedAgentPackageSource
class _MemoryStorage:
@@ -297,6 +305,180 @@ def test_damaged_ordinary_file_still_rejects_package() -> None:
assert storage.save_count == 0
def test_template_url_imports_archive_and_overrides_metadata(
monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
) -> None:
response = httpx.Response(
200, stream=httpx.ByteStream(_package()), request=httpx.Request("GET", "https://example.com/agent.ifpkg")
)
monkeypatch.setattr("services.app_import_source.remote_fetcher.make_request", lambda *_args, **_kwargs: response)
monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None)
storage = _MemoryStorage()
result = RosterAgentPackageImporter(storage_backend=storage).import_package_url(
url="https://example.com/agent.ifpkg",
tenant_id="tenant-1",
account=_account(),
name="My Template Agent",
description="",
icon_type="emoji",
icon="R",
icon_background="#123456",
)
assert response.is_closed
assert storage.save_count > 0
with sqlite_session_factory() as session:
app = session.get(App, result.app_id)
agent = session.get(Agent, result.agent_id)
assert app is not None
assert agent is not None
assert app.name == agent.name == "My Template Agent"
assert app.icon == agent.icon == "R"
assert app.description == agent.description == ""
assert not agent.active_config_is_published
@pytest.mark.parametrize("case", ["size", "invalid"])
def test_template_url_rejects_failed_or_unsafe_download_before_writes(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], case: str
) -> None:
config_overrides(AGENT_PACKAGE_MAX_BYTES=4)
class OversizedDownload(httpx.SyncByteStream):
@override
def __iter__(self) -> Generator[bytes, None, None]:
yield b"x" * (2 * 1024 * 1024)
pytest.fail("An oversized Agent package must stop downloading before reading the tail")
response = httpx.Response(
200,
stream=OversizedDownload() if case == "size" else httpx.ByteStream(b"junk"),
request=httpx.Request("GET", "https://example.com/agent.ifpkg"),
)
monkeypatch.setattr("services.app_import_source.remote_fetcher.make_request", lambda *_args, **_kwargs: response)
storage = _MemoryStorage()
with pytest.raises(RosterAgentPackageTooLargeError if case == "size" else InvalidRosterAgentPackageError):
RosterAgentPackageImporter(storage_backend=storage).import_package_url(
url="https://example.com/agent.ifpkg",
tenant_id="tenant-1",
account=_account(),
)
assert storage.save_count == 0
assert response.is_closed
def test_template_url_rejects_embedded_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
def unexpected_request(*_args: object, **_kwargs: object) -> None:
pytest.fail("Invalid URL must not be fetched")
monkeypatch.setattr("services.app_import_source.remote_fetcher.make_request", unexpected_request)
with pytest.raises(InvalidRosterAgentPackageError):
RosterAgentPackageImporter(storage_backend=_MemoryStorage()).import_package_url(
url="https://user:password@example.com/agent.ifpkg",
tenant_id="tenant-1",
account=_account(),
)
def test_package_icon_override_rejects_foreign_file_before_staging() -> None:
storage = _MemoryStorage()
with pytest.raises(InvalidRosterAgentPackageError, match="current workspace"):
RosterAgentPackageImporter(storage_backend=storage).import_package(
source=io.BytesIO(_package()),
tenant_id="tenant-1",
account=_account(),
icon_type="image",
icon="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
)
assert storage.save_count == 0
def test_local_template_copies_published_resources_without_outer_archive(
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
) -> None:
storage = _MemoryStorage()
importer = RosterAgentPackageImporter(storage_backend=storage)
monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None)
original = importer.import_package(source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account())
account = _account()
target = Tenant(name="Target workspace")
target.id = str(uuid4())
with sqlite_session_factory() as session:
source_site = session.scalar(select(Site).where(Site.app_id == original.app_id))
assert source_site is not None
source_site.title = "Template site"
draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == original.agent_id))
assert draft is not None
snapshot = AgentConfigSnapshot(
tenant_id="tenant-1",
agent_id=original.agent_id,
version=2,
config_snapshot=AgentSoulConfig.model_validate(draft.config_snapshot_dict),
)
session.add(snapshot)
session.flush()
version_id = UUID(snapshot.id)
agent = session.get(Agent, original.agent_id)
assert agent is not None
agent.active_config_snapshot_id = snapshot.id
session.add(
AgentConfigRevision(
tenant_id="tenant-1",
agent_id=agent.id,
current_snapshot_id=snapshot.id,
revision=2,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
)
)
original_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
draft.config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "unpublished"}})
session.commit()
def unexpected_archive(*_args: object, **_kwargs: object) -> None:
pytest.fail("Local templates must not use HTTP or outer package serialization")
monkeypatch.setattr(RosterAgentPackageExporter, "export", unexpected_archive)
monkeypatch.setattr(RosterAgentPackageReader, "read", unexpected_archive)
monkeypatch.setattr("services.app_import_source.remote_fetcher.make_request", unexpected_archive)
monkeypatch.setattr(
importer_module,
"RosterAgentPackageExporter",
lambda: RosterAgentPackageExporter(
storage_backend=storage,
dependency_provider=lambda *_args: [],
),
)
result = importer.import_template(
source=RecommendedAgentPackageSource("tenant-1", original.agent_id, version_id),
tenant_id=target.id,
account=account,
name="Local copy",
)
assert result.warnings == []
with sqlite_session_factory() as session:
app = session.get(App, result.app_id)
assert app is not None
assert app.tenant_id == target.id
assert app.name == "Local copy"
site = session.scalar(select(Site).where(Site.app_id == result.app_id))
assert site is not None
assert site.title == "Template site"
copied_agent = session.scalar(select(Agent).where(Agent.app_id == app.id))
assert copied_agent is not None
copied_draft = session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == copied_agent.id))
assert copied_draft is not None
copied = AgentSoulConfig.model_validate(copied_draft.config_snapshot_dict)
assert copied.prompt.system_prompt == "Imported prompt"
assert {item.name for item in copied.config_skills} == {"config-skill", "workspace-skill"}
original_ids = {item.file_id for item in [*original_soul.config_skills, *original_soul.config_files]}
for item in [*copied.config_skills, *copied.config_files]:
assert item.file_id not in original_ids
row = session.get(UploadFile if item.file_kind == "upload_file" else ToolFile, item.file_id)
assert row is not None
assert row.tenant_id == target.id
assert row.key in storage.files if isinstance(row, UploadFile) else row.file_key in storage.files
def test_import_rejects_multiple_apps_before_writes() -> None:
with zipfile.ZipFile(io.BytesIO(_package())) as archive:
members = {name: archive.read(name) for name in archive.namelist()}
@@ -690,7 +872,7 @@ def test_import_preserves_error_mapping(
def fail(**_kwargs) -> None:
raise failure
monkeypatch.setattr(importer._resources, "materialize", fail)
monkeypatch.setattr(importer._resources, "materialize_resources", fail)
with pytest.raises(expected) as caught:
importer.import_package(source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account())
if isinstance(failure, expected):
@@ -4,6 +4,7 @@ import hashlib
import io
import zipfile
from collections.abc import Callable, Generator
from functools import partial
from uuid import UUID
import pytest
@@ -713,15 +714,18 @@ def test_member_read_rechecks_limit_and_integrity_after_preflight(rewrite_manife
("case", "error_type", "message"),
[
("missing_manifest", RosterAgentPackageExportFailedError, "unusable Skill"),
("name_mismatch", RosterAgentPackageExportFailedError, "unusable Skill"),
("size_limit", RosterAgentPackageTooLargeError, "exceeds the size limit"),
("name_mismatch", RosterAgentPackageExportFailedError, "Skill"),
("size_limit", RosterAgentPackageTooLargeError, "size limit"),
("missing_payload", RosterAgentPackageExportFailedError, "Unable to read"),
],
)
@pytest.mark.parametrize("local", [False, True])
def test_export_rejects_unusable_or_oversized_skill_payload(
case: str,
error_type: type[RosterAgentPackageExportFailedError | RosterAgentPackageTooLargeError],
message: str,
monkeypatch: pytest.MonkeyPatch,
local: bool,
) -> None:
payload = _zip({"README.md": b"missing skill manifest"}) if case == "missing_manifest" else _skill_archive("other")
if case == "size_limit":
@@ -737,10 +741,18 @@ def test_export_rejects_unusable_or_oversized_skill_payload(
app = _package_app()
app.package.soul.config_files = []
exporter = RosterAgentPackageExporter()
resources = AgentPackageResourceExporter(storage_backend=_MemoryStorage({"skill": payload}))
resources = AgentPackageResourceExporter(
storage_backend=_MemoryStorage({} if case == "missing_payload" else {"skill": payload})
)
resources.sources["agent_1"] = ([source], list[_FileSource]())
resources.packages["agent_1"] = app.package
export = (
partial(resources.read_local_resources, "agent_1")
if local
else partial(exporter._build_archive, app=app, resources=resources)
)
with pytest.raises(error_type, match=message):
exporter._build_archive(app=app, resources=resources)
export()
@pytest.mark.parametrize("max_bytes", [0, 8])
@@ -1216,6 +1228,12 @@ def test_export_uses_current_workspace_skill_bindings(
assert dsl.package.soul.prompt.system_prompt == ""
assert len(dsl.package.workspace_skills) == 1
assert dsl.package.workspace_skills[0].priority == 0
exported_yaml = exporter.export_yaml(
tenant_id=agent.tenant_id,
agent_id=agent.id,
version_id=historical_version_id,
)
assert AgentAppDsl.model_validate(yaml.safe_load(exported_yaml)) == dsl
@pytest.mark.parametrize("source", ["missing", "other_tenant", "other_agent", "internal_snapshot"])
@@ -2,6 +2,7 @@
from collections.abc import Callable, Generator, Iterator
from typing import cast
from unittest.mock import Mock
from uuid import uuid4
from zipfile import ZipFile
@@ -34,6 +35,7 @@ from services.entities.app_entities import (
)
from services.entities.dsl_entities import AppDslExportData, AppImportPackage, AppImportParams, Import, ImportStatus
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.recommended_app_package_service import RecommendedAppPackageService
from services.system_feature_service import SystemFeatureService
from services.workflow_service import WorkflowService
from tests.unit_tests.model_factories import make_account, make_tenant, make_upload_file
@@ -289,6 +291,7 @@ def test_dsl_export_uses_owned_short_session_and_preserves_selectors(sqlite_engi
packages=AppPackageService(),
agent_packages=RosterAgentPackageExporter(),
agent_importer=RosterAgentPackageImporter(),
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
)
context = RequestContext("request", None, "actor", workspace)
options = AppExportOptions(include_secret=True, workflow_id="workflow")
@@ -367,6 +370,7 @@ def test_real_dsl_export_releases_connection_before_plugin_request(
packages=AppPackageService(),
agent_packages=RosterAgentPackageExporter(),
agent_importer=RosterAgentPackageImporter(),
recommended_packages=RecommendedAppPackageService(sources=Mock(), exporter=Mock()),
)
event.listen(sqlite_engine, "checkout", checkout)
event.listen(sqlite_engine, "checkin", checkin)
@@ -182,6 +182,14 @@ class Transfers:
del context, source
pytest.fail("Unexpected Agent package import")
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
del context, params
pytest.fail("Unexpected Agent template import")
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
del context, params
pytest.fail("Unexpected Agent package URL import")
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
del context, app_id
pytest.fail("Unexpected dependency check")
@@ -60,6 +60,21 @@ def _detail_payload() -> dict[str, object]:
}
def test_agent_template_accepts_package_url_without_yaml() -> None:
source = {**_detail_payload(), "mode": "agent", "package_url": "https://templates.example/agent.ifpkg"}
source.pop("export_data")
detail = gateway_module._map_detail(source)
assert detail.package_url == "https://templates.example/agent.ifpkg"
assert detail.export_data == ""
def test_non_agent_template_still_requires_yaml() -> None:
source = _detail_payload()
source.pop("export_data")
with pytest.raises(KeyError):
gateway_module._map_detail(source)
def _expected_page(*, categories: tuple[str, ...] = ("Workflow",)) -> RecommendedAppCatalogPage:
return RecommendedAppCatalogPage(
recommended_apps=(
@@ -179,8 +179,10 @@ def test_get_detail_uses_catalog_result_id_for_trial_status(existing_ids: frozen
name="App",
icon=None,
icon_background=None,
mode="chat",
export_data="{}",
mode="agent",
export_data="",
package_url="https://templates.example/agent.ifpkg",
version_id="published-version",
)
trial_apps = MagicMock()
trial_apps.existing_ids.return_value = existing_ids
@@ -193,4 +195,6 @@ def test_get_detail_uses_catalog_result_id_for_trial_status(existing_ids: frozen
result = service.get_detail("route-app-id")
assert result.can_trial is expected
assert result.package_url == "https://templates.example/agent.ifpkg"
assert result.version_id == "published-version"
trial_apps.existing_ids.assert_called_once_with(("catalog-app-id",))
+1 -1
View File
@@ -4561,7 +4561,7 @@
},
"web/service/use-try-app.ts": {
"@tanstack/query/prefer-query-options": {
"count": 3
"count": 2
}
},
"web/service/use-workflow.ts": {
@@ -60,6 +60,9 @@ export type AppImportPayload = {
icon_type?: string | null
mode: string
name?: string | null
package_url?: string | null
template_id?: string | null
version_id?: string | null
yaml_content?: string | null
yaml_url?: string | null
}
@@ -13,6 +13,9 @@ export const zAppImportPayload = z.object({
icon_type: z.string().nullish(),
mode: z.string(),
name: z.string().nullish(),
package_url: z.string().nullish(),
template_id: z.uuid().nullish(),
version_id: z.uuid().nullish(),
yaml_content: z.string().nullish(),
yaml_url: z.string().nullish(),
})
@@ -21,6 +21,8 @@ export type RecommendedAppDetailResponse = {
id: string
mode: string
name: string
package_url?: string | null
version_id?: string | null
}
export type BannerListResponse = Array<BannerResponse>
@@ -13,6 +13,8 @@ export const zRecommendedAppDetailResponse = z.object({
id: z.string(),
mode: z.string(),
name: z.string(),
package_url: z.string().nullish(),
version_id: z.string().nullish(),
})
/**
@@ -3,9 +3,41 @@
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zGetTrialAppsByAppIdAgentComposerPath,
zGetTrialAppsByAppIdAgentComposerResponse,
zGetTrialAppsByAppIdAgentConfigFilesByNameDownloadPath,
zGetTrialAppsByAppIdAgentConfigFilesByNameDownloadQuery,
zGetTrialAppsByAppIdAgentConfigFilesByNameDownloadResponse,
zGetTrialAppsByAppIdAgentConfigFilesByNamePreviewPath,
zGetTrialAppsByAppIdAgentConfigFilesByNamePreviewQuery,
zGetTrialAppsByAppIdAgentConfigFilesByNamePreviewResponse,
zGetTrialAppsByAppIdAgentConfigFilesPath,
zGetTrialAppsByAppIdAgentConfigFilesQuery,
zGetTrialAppsByAppIdAgentConfigFilesResponse,
zGetTrialAppsByAppIdAgentConfigSkillsByNameDownloadPath,
zGetTrialAppsByAppIdAgentConfigSkillsByNameDownloadQuery,
zGetTrialAppsByAppIdAgentConfigSkillsByNameDownloadResponse,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentPath,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentQuery,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentResponse,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadPath,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadQuery,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewPath,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewQuery,
zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse,
zGetTrialAppsByAppIdAgentConfigSkillsByNameInspectPath,
zGetTrialAppsByAppIdAgentConfigSkillsByNameInspectQuery,
zGetTrialAppsByAppIdAgentConfigSkillsByNameInspectResponse,
zGetTrialAppsByAppIdAgentConfigSkillsPath,
zGetTrialAppsByAppIdAgentConfigSkillsQuery,
zGetTrialAppsByAppIdAgentConfigSkillsResponse,
zGetTrialAppsByAppIdDatasetsPath,
zGetTrialAppsByAppIdDatasetsQuery,
zGetTrialAppsByAppIdDatasetsResponse,
zGetTrialAppsByAppIdExportPath,
zGetTrialAppsByAppIdExportQuery,
zGetTrialAppsByAppIdExportResponse,
zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath,
zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse,
zGetTrialAppsByAppIdParametersPath,
@@ -19,6 +51,8 @@ import {
zPostTrialAppsByAppIdAudioToTextPath,
zPostTrialAppsByAppIdAudioToTextResponse,
zPostTrialAppsByAppIdChatMessagesBody,
zPostTrialAppsByAppIdChatMessagesByTaskIdStopPath,
zPostTrialAppsByAppIdChatMessagesByTaskIdStopResponse,
zPostTrialAppsByAppIdChatMessagesPath,
zPostTrialAppsByAppIdChatMessagesResponse,
zPostTrialAppsByAppIdCompletionMessagesBody,
@@ -40,6 +74,229 @@ import {
zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse,
} from './zod.gen.ts'
export const get = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentComposer',
path: '/trial-apps/{app_id}/agent-composer',
tags: ['console'],
})
.input(z.object({ params: zGetTrialAppsByAppIdAgentComposerPath }))
.output(zGetTrialAppsByAppIdAgentComposerResponse)
export const agentComposer = {
get,
}
export const get2 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigFilesByNameDownload',
path: '/trial-apps/{app_id}/agent/config/files/{name}/download',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigFilesByNameDownloadPath,
query: zGetTrialAppsByAppIdAgentConfigFilesByNameDownloadQuery.optional(),
}),
)
.output(zGetTrialAppsByAppIdAgentConfigFilesByNameDownloadResponse)
export const download = {
get: get2,
}
export const get3 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigFilesByNamePreview',
path: '/trial-apps/{app_id}/agent/config/files/{name}/preview',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigFilesByNamePreviewPath,
query: zGetTrialAppsByAppIdAgentConfigFilesByNamePreviewQuery.optional(),
}),
)
.output(zGetTrialAppsByAppIdAgentConfigFilesByNamePreviewResponse)
export const preview = {
get: get3,
}
export const byName = {
download,
preview,
}
export const get4 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigFiles',
path: '/trial-apps/{app_id}/agent/config/files',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigFilesPath,
query: zGetTrialAppsByAppIdAgentConfigFilesQuery.optional(),
}),
)
.output(zGetTrialAppsByAppIdAgentConfigFilesResponse)
export const files = {
get: get4,
byName,
}
export const get5 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigSkillsByNameDownload',
path: '/trial-apps/{app_id}/agent/config/skills/{name}/download',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigSkillsByNameDownloadPath,
query: zGetTrialAppsByAppIdAgentConfigSkillsByNameDownloadQuery.optional(),
}),
)
.output(zGetTrialAppsByAppIdAgentConfigSkillsByNameDownloadResponse)
export const download2 = {
get: get5,
}
export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigSkillsByNameFilesContent',
path: '/trial-apps/{app_id}/agent/config/skills/{name}/files/content',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentPath,
query: zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentQuery,
}),
)
.output(zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentResponse)
export const content = {
get: get6,
}
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigSkillsByNameFilesDownload',
path: '/trial-apps/{app_id}/agent/config/skills/{name}/files/download',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadPath,
query: zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadQuery,
}),
)
.output(zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse)
export const download3 = {
get: get7,
}
export const get8 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigSkillsByNameFilesPreview',
path: '/trial-apps/{app_id}/agent/config/skills/{name}/files/preview',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewPath,
query: zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewQuery,
}),
)
.output(zGetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse)
export const preview2 = {
get: get8,
}
export const files2 = {
content,
download: download3,
preview: preview2,
}
export const get9 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigSkillsByNameInspect',
path: '/trial-apps/{app_id}/agent/config/skills/{name}/inspect',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigSkillsByNameInspectPath,
query: zGetTrialAppsByAppIdAgentConfigSkillsByNameInspectQuery.optional(),
}),
)
.output(zGetTrialAppsByAppIdAgentConfigSkillsByNameInspectResponse)
export const inspect = {
get: get9,
}
export const byName2 = {
download: download2,
files: files2,
inspect,
}
export const get10 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdAgentConfigSkills',
path: '/trial-apps/{app_id}/agent/config/skills',
tags: ['console'],
})
.input(
z.object({
params: zGetTrialAppsByAppIdAgentConfigSkillsPath,
query: zGetTrialAppsByAppIdAgentConfigSkillsQuery.optional(),
}),
)
.output(zGetTrialAppsByAppIdAgentConfigSkillsResponse)
export const skills = {
get: get10,
byName: byName2,
}
export const config = {
files,
skills,
}
export const agent = {
config,
}
export const post = oc
.route({
inputStructure: 'detailed',
@@ -56,6 +313,25 @@ export const audioToText = {
}
export const post2 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postTrialAppsByAppIdChatMessagesByTaskIdStop',
path: '/trial-apps/{app_id}/chat-messages/{task_id}/stop',
tags: ['console'],
})
.input(z.object({ params: zPostTrialAppsByAppIdChatMessagesByTaskIdStopPath }))
.output(zPostTrialAppsByAppIdChatMessagesByTaskIdStopResponse)
export const stop = {
post: post2,
}
export const byTaskId = {
stop,
}
export const post3 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -72,10 +348,11 @@ export const post2 = oc
.output(zPostTrialAppsByAppIdChatMessagesResponse)
export const chatMessages = {
post: post2,
post: post3,
byTaskId,
}
export const post3 = oc
export const post4 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -92,10 +369,10 @@ export const post3 = oc
.output(zPostTrialAppsByAppIdCompletionMessagesResponse)
export const completionMessages = {
post: post3,
post: post4,
}
export const get = oc
export const get11 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -112,13 +389,30 @@ export const get = oc
.output(zGetTrialAppsByAppIdDatasetsResponse)
export const datasets = {
get,
get: get11,
}
export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getTrialAppsByAppIdExport',
path: '/trial-apps/{app_id}/export',
tags: ['console'],
})
.input(
z.object({ params: zGetTrialAppsByAppIdExportPath, query: zGetTrialAppsByAppIdExportQuery }),
)
.output(zGetTrialAppsByAppIdExportResponse)
export const export_ = {
get: get12,
}
/**
* Upload a file into the tenant that owns the trial app
*/
export const post4 = oc
export const post5 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -137,14 +431,14 @@ export const post4 = oc
.output(zPostTrialAppsByAppIdFilesUploadResponse)
export const upload = {
post: post4,
post: post5,
}
export const files = {
export const files3 = {
upload,
}
export const get2 = oc
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -156,7 +450,7 @@ export const get2 = oc
.output(zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse)
export const suggestedQuestions = {
get: get2,
get: get13,
}
export const byMessageId = {
@@ -170,7 +464,7 @@ export const messages = {
/**
* Retrieve app parameters
*/
export const get3 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -183,13 +477,13 @@ export const get3 = oc
.output(zGetTrialAppsByAppIdParametersResponse)
export const parameters = {
get: get3,
get: get14,
}
/**
* Upload a remote file into the tenant that owns the trial app
*/
export const post5 = oc
export const post6 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -208,7 +502,7 @@ export const post5 = oc
.output(zPostTrialAppsByAppIdRemoteFilesUploadResponse)
export const upload2 = {
post: post5,
post: post6,
}
export const remoteFiles = {
@@ -220,7 +514,7 @@ export const remoteFiles = {
*
* Returns the site configuration for the application including theme, icons, and text.
*/
export const get4 = oc
export const get15 = oc
.route({
description:
'Returns the site configuration for the application including theme, icons, and text.',
@@ -235,10 +529,10 @@ export const get4 = oc
.output(zGetTrialAppsByAppIdSiteResponse)
export const site = {
get: get4,
get: get15,
}
export const post6 = oc
export const post7 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -255,13 +549,13 @@ export const post6 = oc
.output(zPostTrialAppsByAppIdTextToAudioResponse)
export const textToAudio = {
post: post6,
post: post7,
}
/**
* Run workflow
*/
export const post7 = oc
export const post8 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -279,13 +573,13 @@ export const post7 = oc
.output(zPostTrialAppsByAppIdWorkflowsRunResponse)
export const run = {
post: post7,
post: post8,
}
/**
* Stop workflow task
*/
export const post8 = oc
export const post9 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -297,22 +591,22 @@ export const post8 = oc
.input(z.object({ params: zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopPath }))
.output(zPostTrialAppsByAppIdWorkflowsTasksByTaskIdStopResponse)
export const stop = {
post: post8,
export const stop2 = {
post: post9,
}
export const byTaskId = {
stop,
export const byTaskId2 = {
stop: stop2,
}
export const tasks = {
byTaskId,
byTaskId: byTaskId2,
}
/**
* Get a detached workflow definition after catalog preview admission
*/
export const get5 = oc
export const get16 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -325,7 +619,7 @@ export const get5 = oc
.output(zGetTrialAppsByAppIdWorkflowsResponse)
export const workflows = {
get: get5,
get: get16,
run,
tasks,
}
@@ -333,7 +627,7 @@ export const workflows = {
/**
* Get app detail using the viewer's workspace for tool configuration
*/
export const get6 = oc
export const get17 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -346,12 +640,15 @@ export const get6 = oc
.output(zGetTrialAppsByAppIdResponse)
export const byAppId = {
get: get6,
get: get17,
agentComposer,
agent,
audioToText,
chatMessages,
completionMessages,
datasets,
files,
export: export_,
files: files3,
messages,
parameters,
remoteFiles,
@@ -31,6 +31,69 @@ export type TrialAppDetailResponse = {
workflow?: TrialWorkflowPartialResponse | null
}
export type AgentAppComposerResponse = {
active_config_is_published: boolean
active_config_snapshot?: AgentConfigSnapshotSummaryResponse | null
agent: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
app_id?: string | null
backing_app_id?: string | null
chat_endpoint?: string | null
draft?: AgentConfigDraftSummaryResponse | null
hidden_app_backed?: boolean
save_options: Array<ComposerSaveStrategy>
validation?: ComposerValidationFindingsResponse | null
variant: 'agent_app'
}
export type AgentConfigFileListResponse = {
agent_id: string
config_version: AgentConfigVersionResponse
items?: Array<AgentConfigFileItemResponse>
}
export type AgentConfigDownloadResponse = {
url: string
}
export type AgentConfigFilePreviewResponse = {
binary: boolean
name: string
size?: number | null
text?: string | null
truncated: boolean
}
export type AgentConfigSkillListResponse = {
agent_id: string
config_version: AgentConfigVersionResponse
items?: Array<AgentConfigSkillItemResponse>
}
export type AgentConfigSkillFilePreviewResponse = {
binary: boolean
path: string
size?: number | null
text?: string | null
truncated: boolean
}
export type AgentConfigSkillInspectResponse = {
description?: string
file_tree?: Array<{
[key: string]: unknown
}> | null
files?: Array<AgentConfigSkillFileResponse>
hash?: string | null
id: string
mime_type?: string | null
name: string
size?: number | null
skill_md: AgentConfigSkillMarkdownResponse
source: 'config_skill_zip'
warnings?: Array<string>
}
export type AudioTranscriptResponse = {
text: string
}
@@ -46,6 +109,10 @@ export type ChatRequest = {
retriever_from?: string
}
export type SimpleResultResponse = {
result: string
}
export type CompletionRequest = {
files?: Array<unknown> | null
inputs: {
@@ -64,6 +131,10 @@ export type TrialDatasetListResponse = {
total: number
}
export type AppExportResponse = {
data: string
}
export type FileResponse = {
conversation_id?: string | null
created_at?: number | null
@@ -477,10 +548,6 @@ export type WorkflowRunRequest = {
}
}
export type SimpleResultResponse = {
result: string
}
export type TrialDeletedToolResponse = {
provider_id: string
tool_name: string
@@ -489,7 +556,13 @@ export type TrialDeletedToolResponse = {
export type TrialIconType = 'emoji' | 'image' | 'link'
export type TrialAppMode = 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
export type TrialAppMode =
| 'advanced-chat'
| 'agent'
| 'agent-chat'
| 'chat'
| 'completion'
| 'workflow'
export type TrialAppModelConfigResponse = {
agent_mode?: TrialAppAgentMode | null
@@ -560,6 +633,120 @@ export type TrialWorkflowPartialResponse = {
updated_by?: string | null
}
export type AgentConfigSnapshotSummaryResponse = {
agent_id?: string | null
created_at?: number | null
created_by?: string | null
display_version?: number | null
id: string
snapshot_version?: number | null
summary?: string | null
version: number
version_note?: string | null
}
export type AgentComposerAgentResponse = {
active_config_snapshot_id?: string | null
app_id?: string | null
backing_app_id?: string | null
description: string
hidden_app_backed?: boolean
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
name: string
role?: string | null
scope: AgentScope
source?: AgentSource | null
status: AgentStatus
}
export type AgentSoulConfig = {
app_features?: AgentSoulAppFeaturesConfig
app_variables?: Array<AppVariableConfig>
config_files?: Array<AgentConfigFileRefConfig>
config_note?: string
config_skills?: Array<AgentConfigSkillRefConfig>
env?: AgentSoulEnvConfig
human?: AgentSoulHumanConfig
knowledge?: AgentSoulKnowledgeConfig
memory?: AgentSoulMemoryConfig
misc_legacy?: AgentSoulAppFeaturesConfig
model?: AgentSoulModelConfig | null
prompt?: AgentSoulPromptConfig
sandbox?: AgentSoulSandboxConfig
schema_version?: number
tools?: AgentSoulToolsConfig
}
export type AgentConfigDraftSummaryResponse = {
account_id?: string | null
agent_id: string
base_snapshot_id?: string | null
created_at?: number | null
created_by?: string | null
draft_type: AgentConfigDraftType
id: string
updated_at?: number | null
updated_by?: string | null
}
export type ComposerSaveStrategy =
| 'node_job_only'
| 'save_as_new_agent'
| 'save_as_new_version'
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerValidationFindingsResponse = {
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
warnings?: Array<ComposerValidationWarningResponse>
}
export type AgentConfigVersionResponse = {
id: string
kind: 'build_draft' | 'draft' | 'snapshot'
writable: boolean
}
export type AgentConfigFileItemResponse = {
file_id?: string | null
hash?: string | null
id: string
is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
}
export type AgentConfigSkillItemResponse = {
description?: string
file_id?: string | null
hash?: string | null
id: string
is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
}
export type AgentConfigSkillFileResponse = {
downloadable: boolean
name: string
path: string
previewable: boolean
type: 'directory' | 'file'
}
export type AgentConfigSkillMarkdownResponse = {
binary: false
path: 'SKILL.md'
size?: number | null
text: string
truncated: boolean
}
export type TrialDatasetResponse = {
created_at?: number | null
created_by?: string | null
@@ -611,6 +798,474 @@ export type TrialAppModel = {
provider: string
}
export type AgentScope = 'roster' | 'workflow_only'
export type AgentSource = 'agent_app' | 'imported' | 'roster' | 'system' | 'workflow'
export type AgentStatus = 'active' | 'archived'
export type AgentSoulAppFeaturesConfig = {
file_upload?: AgentFileUploadFeatureConfig
opening_statement?: string | null
retriever_resource?: AgentFeatureToggleConfig | null
sensitive_word_avoidance?: AgentSensitiveWordAvoidanceFeatureConfig | null
speech_to_text?: AgentFeatureToggleConfig | null
suggested_questions?: Array<string> | null
suggested_questions_after_answer?: AgentSuggestedQuestionsAfterAnswerFeatureConfig | null
text_to_speech?: AgentTextToSpeechFeatureConfig | null
[key: string]: unknown
}
export type AppVariableConfig = {
default?: unknown
name: string
required?: boolean
type: string
}
export type AgentConfigFileRefConfig = {
file_id?: string
file_kind: 'tool_file' | 'upload_file'
hash?: string | null
is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
}
export type AgentConfigSkillRefConfig = {
description?: string
file_id?: string
file_kind?: 'tool_file'
hash?: string | null
is_missing?: boolean
mime_type?: string | null
name: string
size?: number | null
}
export type AgentSoulEnvConfig = {
secret_refs?: Array<AgentSecretRefConfig>
variables?: Array<AgentEnvVariableConfig>
}
export type AgentSoulHumanConfig = {
contacts?: Array<AgentHumanContactConfig>
tools?: Array<AgentHumanToolConfig>
}
export type AgentSoulKnowledgeConfig = {
sets?: Array<AgentKnowledgeSetConfig>
}
export type AgentSoulMemoryConfig = {
artifacts?: Array<AgentMemoryArtifactConfig>
budget?: string | null
scope?: string | null
}
export type AgentSoulModelConfig = {
credential_ref?: AgentSoulModelCredentialRef | null
model: string
model_provider: string
model_settings?: AgentSoulModelSettings
plugin_id: string
}
export type AgentSoulPromptConfig = {
system_prompt?: string
}
export type AgentSoulSandboxConfig = {
config?: AgentSandboxProviderConfig
provider?: string | null
}
export type AgentSoulToolsConfig = {
cli_tools?: Array<AgentCliToolConfig>
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type AgentConfigDraftType = 'debug_build' | 'draft'
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 AgentFileUploadFeatureConfig = {
allowed_file_extensions?: Array<string>
allowed_file_types?: Array<FileType>
allowed_file_upload_methods?: Array<FileTransferMethod>
enabled?: boolean
image?: AgentFileUploadImageFeatureConfig
number_limits?: number
[key: string]: unknown
}
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
}
export type AgentSensitiveWordAvoidanceFeatureConfig = {
config?: AgentModerationProviderConfig | null
enabled?: boolean
type?: string | null
[key: string]: unknown
}
export type AgentSuggestedQuestionsAfterAnswerFeatureConfig = {
enabled?: boolean
model?: AgentSuggestedQuestionsAfterAnswerModelConfig | null
prompt?: string | null
[key: string]: unknown
}
export type AgentTextToSpeechFeatureConfig = {
autoPlay?: 'disabled' | 'enabled' | null
enabled?: boolean
language?: string | null
voice?: string | null
[key: string]: unknown
}
export type AgentSecretRefConfig = {
credential_id?: string | null
env_name?: string | null
id?: string | null
key?: string | null
name?: string | null
permission?: AgentPermissionConfig | null
permission_status?: string | null
provider?: string | null
provider_credential_id?: string | null
ref?: string | null
type?: string | null
value?: string | null
variable?: string | null
[key: string]: unknown
}
export type AgentEnvVariableConfig = {
default?:
| string
| number
| number
| boolean
| Array<string>
| Array<number>
| Array<number>
| Array<boolean>
| null
env_name?: string | null
key?: string | null
name?: string | null
required?: boolean
type?: string | null
value?:
| string
| number
| number
| boolean
| Array<string>
| Array<number>
| Array<number>
| Array<boolean>
| null
variable?: string | null
[key: string]: unknown
}
export type AgentHumanContactConfig = {
channel?: string | null
contact_id?: string | null
contact_method?: string | null
email?: string | null
human_id?: string | null
id?: string | null
method?: string | null
name?: string | null
tenant_id?: string | null
[key: string]: unknown
}
export type AgentHumanToolConfig = {
description?: string | null
enabled?: boolean
name?: string | null
[key: string]: unknown
}
export type AgentKnowledgeSetConfig = {
datasets: Array<AgentKnowledgeDatasetConfig>
description?: string | null
id: string
metadata_filtering?: AgentKnowledgeMetadataFilteringConfig
name: string
query: AgentKnowledgeQueryConfig
retrieval: AgentKnowledgeRetrievalConfig
}
export type AgentMemoryArtifactConfig = {
id?: string | null
name?: string | null
type?: string | null
url?: string | null
[key: string]: unknown
}
export type AgentSoulModelCredentialRef = {
id?: string | null
provider?: string | null
type: string
}
export type AgentSoulModelSettings = {
frequency_penalty?: number | null
max_tokens?: number | null
presence_penalty?: number | null
response_format?: AgentModelResponseFormatConfig | null
stop?: Array<string> | null
temperature?: number | null
top_p?: number | null
[key: string]: unknown
}
export type AgentSandboxProviderConfig = {
cpu?: number | null
env?: Array<AgentEnvVariableConfig>
image?: string | null
working_dir?: string | null
[key: string]: unknown
}
export type AgentCliToolConfig = {
approved?: boolean
authorization_status?: AgentCliToolAuthorizationStatus | null
command?: string | null
dangerous?: boolean
dangerous_accepted?: boolean
dangerous_acknowledged?: boolean
dangerous_command?: boolean
description?: string | null
enabled?: boolean
env?: AgentCliToolEnvConfig
id?: string | null
inferred_from?: string | null
install?: string | null
install_command?: string | null
install_commands?: Array<string>
invoke_metadata?: {
[key: string]: unknown
}
label?: string | null
name?: string | null
permission?: AgentPermissionConfig | null
pre_authorized?: boolean | null
requires_confirmation?: boolean
risk_accepted?: boolean
risk_level?: AgentCliToolRiskLevel | null
setup_command?: string | null
tool_name?: string | null
[key: string]: unknown
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef | null
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'
description?: string | null
enabled?: boolean
name?: string | null
plugin_id?: string | null
provider?: string | null
provider_id?: string | null
provider_type: ToolProviderType
runtime_parameters?: {
[key: string]:
| string
| number
| number
| boolean
| Array<string>
| Array<number>
| Array<number>
| Array<boolean>
| null
}
tool_name?: string | null
}
export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
export type AgentFileUploadImageFeatureConfig = {
enabled?: boolean
[key: string]: unknown
}
export type AgentModerationProviderConfig = {
api_based_extension_id?: string | null
inputs_config?: AgentModerationIoConfig | null
keywords?: string | null
outputs_config?: AgentModerationIoConfig | null
[key: string]: unknown
}
export type AgentSuggestedQuestionsAfterAnswerModelConfig = {
completion_params?: {
[key: string]: unknown
} | null
mode?: string | null
name: string
provider: string
[key: string]: unknown
}
export type AgentPermissionConfig = {
allowed?: boolean | null
state?: string | null
status?: string | null
}
export type AgentKnowledgeDatasetConfig = {
description?: string | null
id?: string | null
name?: string | null
}
export type AgentKnowledgeMetadataFilteringConfig = {
conditions?: AgentKnowledgeMetadataConditions | null
mode?: 'automatic' | 'disabled' | 'manual'
model_config?: AgentKnowledgeModelConfig | null
}
export type AgentKnowledgeQueryConfig = {
mode: AgentKnowledgeQueryMode
value?: string | null
}
export type AgentKnowledgeRetrievalConfig = {
mode: 'multiple' | 'single'
model?: AgentKnowledgeModelConfig | null
reranking_enable?: boolean
reranking_mode?: string
reranking_model?: AgentKnowledgeRerankingModelConfig | null
score_threshold?: number | null
top_k?: number | null
weights?: AgentKnowledgeWeightedScoreConfig | null
}
export type AgentModelResponseFormatConfig = {
type?: string | null
[key: string]: unknown
}
export type AgentCliToolAuthorizationStatus =
| 'allowed'
| 'authorized'
| 'denied'
| 'forbidden'
| 'not_required'
| 'pending'
| 'pre_authorized'
| 'unauthorized'
export type AgentCliToolEnvConfig = {
secret_refs?: Array<AgentSecretRefConfig>
variables?: Array<AgentEnvVariableConfig>
}
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
export type AgentSoulDifyToolCredentialRef = {
id?: string | null
provider?: string | null
type?: 'provider' | 'tool'
}
export type ToolProviderType =
| 'api'
| 'app'
| 'builtin'
| 'dataset-retrieval'
| 'mcp'
| 'plugin'
| 'workflow'
export type AgentModerationIoConfig = {
enabled?: boolean
preset_response?: string | null
[key: string]: unknown
}
export type AgentKnowledgeMetadataConditions = {
conditions?: Array<AgentKnowledgeMetadataCondition>
logical_operator?: 'and' | 'or'
}
export type AgentKnowledgeModelConfig = {
completion_params?: {
[key: string]: unknown
}
mode: string
name: string
provider: string
}
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
export type AgentKnowledgeRerankingModelConfig = {
model: string
provider: string
}
export type AgentKnowledgeWeightedScoreConfig = {
keyword_setting?: {
[key: string]: unknown
} | null
vector_setting?: {
[key: string]: unknown
} | null
weight_type?: string | null
[key: string]: unknown
}
export type AgentKnowledgeMetadataCondition = {
comparison_operator:
| '<'
| '='
| '>'
| 'after'
| 'before'
| 'contains'
| 'empty'
| 'end with'
| 'in'
| 'is'
| 'is not'
| 'not contains'
| 'not empty'
| 'not in'
| 'start with'
| '≠'
| '≤'
| '≥'
id?: string | null
metadata_id?: string | null
name: string
value?: string | Array<string> | number | null
}
export type SiteWritable = {
chat_color_theme?: string | null
chat_color_theme_inverted: boolean
@@ -644,6 +1299,194 @@ export type GetTrialAppsByAppIdResponses = {
export type GetTrialAppsByAppIdResponse =
GetTrialAppsByAppIdResponses[keyof GetTrialAppsByAppIdResponses]
export type GetTrialAppsByAppIdAgentComposerData = {
body?: never
path: {
app_id: string
}
query?: never
url: '/trial-apps/{app_id}/agent-composer'
}
export type GetTrialAppsByAppIdAgentComposerResponses = {
200: AgentAppComposerResponse
}
export type GetTrialAppsByAppIdAgentComposerResponse =
GetTrialAppsByAppIdAgentComposerResponses[keyof GetTrialAppsByAppIdAgentComposerResponses]
export type GetTrialAppsByAppIdAgentConfigFilesData = {
body?: never
path: {
app_id: string
}
query?: {
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/files'
}
export type GetTrialAppsByAppIdAgentConfigFilesResponses = {
200: AgentConfigFileListResponse
}
export type GetTrialAppsByAppIdAgentConfigFilesResponse =
GetTrialAppsByAppIdAgentConfigFilesResponses[keyof GetTrialAppsByAppIdAgentConfigFilesResponses]
export type GetTrialAppsByAppIdAgentConfigFilesByNameDownloadData = {
body?: never
path: {
app_id: string
name: string
}
query?: {
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/files/{name}/download'
}
export type GetTrialAppsByAppIdAgentConfigFilesByNameDownloadResponses = {
200: AgentConfigDownloadResponse
}
export type GetTrialAppsByAppIdAgentConfigFilesByNameDownloadResponse =
GetTrialAppsByAppIdAgentConfigFilesByNameDownloadResponses[keyof GetTrialAppsByAppIdAgentConfigFilesByNameDownloadResponses]
export type GetTrialAppsByAppIdAgentConfigFilesByNamePreviewData = {
body?: never
path: {
app_id: string
name: string
}
query?: {
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/files/{name}/preview'
}
export type GetTrialAppsByAppIdAgentConfigFilesByNamePreviewResponses = {
200: AgentConfigFilePreviewResponse
}
export type GetTrialAppsByAppIdAgentConfigFilesByNamePreviewResponse =
GetTrialAppsByAppIdAgentConfigFilesByNamePreviewResponses[keyof GetTrialAppsByAppIdAgentConfigFilesByNamePreviewResponses]
export type GetTrialAppsByAppIdAgentConfigSkillsData = {
body?: never
path: {
app_id: string
}
query?: {
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/skills'
}
export type GetTrialAppsByAppIdAgentConfigSkillsResponses = {
200: AgentConfigSkillListResponse
}
export type GetTrialAppsByAppIdAgentConfigSkillsResponse =
GetTrialAppsByAppIdAgentConfigSkillsResponses[keyof GetTrialAppsByAppIdAgentConfigSkillsResponses]
export type GetTrialAppsByAppIdAgentConfigSkillsByNameDownloadData = {
body?: never
path: {
app_id: string
name: string
}
query?: {
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/skills/{name}/download'
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameDownloadResponses = {
200: AgentConfigDownloadResponse
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameDownloadResponse =
GetTrialAppsByAppIdAgentConfigSkillsByNameDownloadResponses[keyof GetTrialAppsByAppIdAgentConfigSkillsByNameDownloadResponses]
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentData = {
body?: never
path: {
app_id: string
name: string
}
query: {
path: string
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/skills/{name}/files/content'
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentResponses = {
200: Blob | File
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentResponse =
GetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentResponses[keyof GetTrialAppsByAppIdAgentConfigSkillsByNameFilesContentResponses]
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadData = {
body?: never
path: {
app_id: string
name: string
}
query: {
path: string
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/skills/{name}/files/download'
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses = {
200: AgentConfigDownloadResponse
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponse =
GetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses[keyof GetTrialAppsByAppIdAgentConfigSkillsByNameFilesDownloadResponses]
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewData = {
body?: never
path: {
app_id: string
name: string
}
query: {
path: string
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/skills/{name}/files/preview'
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses = {
200: AgentConfigSkillFilePreviewResponse
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponse =
GetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses[keyof GetTrialAppsByAppIdAgentConfigSkillsByNameFilesPreviewResponses]
export type GetTrialAppsByAppIdAgentConfigSkillsByNameInspectData = {
body?: never
path: {
app_id: string
name: string
}
query?: {
version_id?: string
}
url: '/trial-apps/{app_id}/agent/config/skills/{name}/inspect'
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameInspectResponses = {
200: AgentConfigSkillInspectResponse
}
export type GetTrialAppsByAppIdAgentConfigSkillsByNameInspectResponse =
GetTrialAppsByAppIdAgentConfigSkillsByNameInspectResponses[keyof GetTrialAppsByAppIdAgentConfigSkillsByNameInspectResponses]
export type PostTrialAppsByAppIdAudioToTextData = {
body?: never
path: {
@@ -678,6 +1521,23 @@ export type PostTrialAppsByAppIdChatMessagesResponses = {
export type PostTrialAppsByAppIdChatMessagesResponse =
PostTrialAppsByAppIdChatMessagesResponses[keyof PostTrialAppsByAppIdChatMessagesResponses]
export type PostTrialAppsByAppIdChatMessagesByTaskIdStopData = {
body?: never
path: {
app_id: string
task_id: string
}
query?: never
url: '/trial-apps/{app_id}/chat-messages/{task_id}/stop'
}
export type PostTrialAppsByAppIdChatMessagesByTaskIdStopResponses = {
200: SimpleResultResponse
}
export type PostTrialAppsByAppIdChatMessagesByTaskIdStopResponse =
PostTrialAppsByAppIdChatMessagesByTaskIdStopResponses[keyof PostTrialAppsByAppIdChatMessagesByTaskIdStopResponses]
export type PostTrialAppsByAppIdCompletionMessagesData = {
body: CompletionRequest
path: {
@@ -716,6 +1576,29 @@ export type GetTrialAppsByAppIdDatasetsResponses = {
export type GetTrialAppsByAppIdDatasetsResponse =
GetTrialAppsByAppIdDatasetsResponses[keyof GetTrialAppsByAppIdDatasetsResponses]
export type GetTrialAppsByAppIdExportData = {
body?: never
path: {
app_id: string
}
query: {
format?: 'ifpkg' | 'yaml'
version_id: string
}
url: '/trial-apps/{app_id}/export'
}
export type GetTrialAppsByAppIdExportErrors = {
404: unknown
}
export type GetTrialAppsByAppIdExportResponses = {
200: AppExportResponse | Blob | File
}
export type GetTrialAppsByAppIdExportResponse =
GetTrialAppsByAppIdExportResponses[keyof GetTrialAppsByAppIdExportResponses]
export type PostTrialAppsByAppIdFilesUploadData = {
body: {
file: Blob | File
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,6 @@ import type { ReactElement } from 'react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { trackEvent } from '@/app/components/base/amplitude'
import AppListContext from '@/context/app-list-context'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
@@ -40,18 +39,24 @@ describe('AppCard', () => {
})
it('exposes template creation only when creation is allowed', () => {
const { rerender } = render(<AppCard app={app} canCreate onCreate={vi.fn()} />)
expect(screen.getByRole('button', { name: 'app.newApp.useTemplate' })).toBeInTheDocument()
const { rerender } = render(
<AppCard app={app} canCreate onCreate={vi.fn()} onPreview={vi.fn()} />,
)
expect(
screen.getByRole('button', { name: 'app.newApp.useTemplate Chat template' }),
).toBeInTheDocument()
rerender(<AppCard app={app} canCreate={false} onCreate={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'app.newApp.useTemplate' })).not.toBeInTheDocument()
rerender(<AppCard app={app} canCreate={false} onCreate={vi.fn()} onPreview={vi.fn()} />)
expect(
screen.queryByRole('button', { name: /^app\.newApp\.useTemplate/ }),
).not.toBeInTheDocument()
})
it('creates the template from the primary action', async () => {
const onCreate = vi.fn()
const user = userEvent.setup()
render(<AppCard app={app} canCreate onCreate={onCreate} />)
await user.click(screen.getByRole('button', { name: 'app.newApp.useTemplate' }))
render(<AppCard app={app} canCreate onCreate={onCreate} onPreview={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'app.newApp.useTemplate Chat template' }))
expect(onCreate).toHaveBeenCalledOnce()
})
@@ -69,6 +74,7 @@ describe('AppCard', () => {
}}
canCreate
onCreate={vi.fn()}
onPreview={vi.fn()}
/>,
)
expect(screen.getByRole('img', { name: 'app icon' })).toHaveAttribute(
@@ -80,24 +86,15 @@ describe('AppCard', () => {
it('tracks and opens template preview in Cloud edition', async () => {
const openPreview = vi.fn()
const user = userEvent.setup()
render(
// oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its Provider API.
<AppListContext.Provider
value={{
openTryAppPanel: openPreview,
}}
>
<AppCard app={app} canCreate onCreate={vi.fn()} />
</AppListContext.Provider>,
)
render(<AppCard app={app} canCreate onCreate={vi.fn()} onPreview={openPreview} />)
await user.click(screen.getByRole('button', { name: 'explore.appCard.try' }))
await user.click(screen.getByRole('button', { name: 'explore.appCard.try Chat template' }))
expect(trackEvent).toHaveBeenCalledWith(
'preview_template',
expect.objectContaining({ template_id: 'app-1', page: 'studio' }),
)
expect(openPreview).toHaveBeenCalledWith(app)
expect(openPreview).toHaveBeenCalledOnce()
})
it('allows creation and preview with nullable metadata without normalizing the selection', async () => {
@@ -110,18 +107,13 @@ describe('AppCard', () => {
const onCreate = vi.fn()
const openPreview = vi.fn()
const user = userEvent.setup()
render(
// oxlint-disable-next-line eslint-react/no-context-provider -- use-context-selector requires its Provider API.
<AppListContext.Provider value={{ openTryAppPanel: openPreview }}>
<AppCard app={nullableApp} canCreate onCreate={onCreate} />
</AppListContext.Provider>,
)
render(<AppCard app={nullableApp} canCreate onCreate={onCreate} onPreview={openPreview} />)
await user.click(screen.getByRole('button', { name: 'app.newApp.useTemplate' }))
expect(onCreate).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'explore.appCard.try' }))
expect(openPreview).toHaveBeenCalledWith(nullableApp)
expect(openPreview).toHaveBeenCalledOnce()
expect(trackEvent).toHaveBeenCalledWith('preview_template', {
template_id: 'nullable-app',
template_name: '',
@@ -5,12 +5,10 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiInformation2Line } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useCallback, useId } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon'
import AppListContext from '@/context/app-list-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
@@ -18,9 +16,11 @@ type AppCardProps = {
app: RecommendedAppResponse
canCreate: boolean
onCreate: () => void
onPreview: () => void
}
const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
const AppCard = ({ app, canCreate, onCreate, onPreview }: AppCardProps) => {
const id = useId()
const { t } = useTranslation(['app', 'explore'])
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@@ -36,7 +36,6 @@ const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
? appBasicInfo.icon_type
: null
const canViewApp = deploymentEdition === 'CLOUD'
const openTryAppPanel = useContextSelector(AppListContext, (ctx) => ctx.openTryAppPanel)
const handleShowTryAppPanel = useCallback(() => {
trackEvent('preview_template', {
template_id: app.app_id,
@@ -45,12 +44,12 @@ const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
template_categories: app.categories ?? [],
page: 'studio',
})
openTryAppPanel(app)
}, [openTryAppPanel, app, appName, appMode])
onPreview()
}, [onPreview, app, appName, appMode])
return (
<div
className={cn(
'group relative flex h-33 cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg',
'group relative flex h-33 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg',
)}
>
<div className="flex shrink-0 grow-0 items-center gap-3 pb-2">
@@ -70,7 +69,11 @@ const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
</div>
<div className="flex grow flex-col gap-1">
<div className="line-clamp-1">
<span className="system-md-semibold text-text-secondary" title={appName}>
<span
id={`${id}-name`}
className="system-md-semibold text-text-secondary"
title={appName}
>
{appName}
</span>
</div>
@@ -83,7 +86,7 @@ const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
{(canCreate || canViewApp) && (
<div
className={cn(
'absolute right-0 bottom-0 left-0 hidden bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex',
'pointer-events-none absolute right-0 bottom-0 left-0 flex bg-linear-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
>
<div
@@ -93,15 +96,17 @@ const AppCard = ({ app, canCreate, onCreate }: AppCardProps) => {
)}
>
{canCreate && (
<Button variant="primary" onClick={() => onCreate()}>
<PlusIcon className="size-4" />
<span className="text-xs">{t(($) => $['newApp.useTemplate'], { ns: 'app' })}</span>
<Button variant="primary" aria-labelledby={`${id}-use ${id}-name`} onClick={onCreate}>
<PlusIcon aria-hidden="true" className="size-4" />
<span id={`${id}-use`} className="text-xs">
{t(($) => $['newApp.useTemplate'], { ns: 'app' })}
</span>
</Button>
)}
{canViewApp && (
<Button onClick={handleShowTryAppPanel}>
<RiInformation2Line className="size-4" />
<span>{t(($) => $['appCard.try'], { ns: 'explore' })}</span>
<Button aria-labelledby={`${id}-details ${id}-name`} onClick={handleShowTryAppPanel}>
<RiInformation2Line aria-hidden="true" className="size-4" />
<span id={`${id}-details`}>{t(($) => $['appCard.try'], { ns: 'explore' })}</span>
</Button>
)}
</div>
@@ -94,17 +94,24 @@ beforeEach(() => {
throw new Error(`Unexpected request: ${url}`)
})
})
const renderApps = (response = catalog, permissions = ['app.create_and_management']) => {
const renderApps = (
response = catalog,
permissions = ['app.create_and_management'],
templateMode?: 'agent',
) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(catalogKey('en-US'), response)
const onClose = vi.fn()
return {
...render(<Apps onClose={onClose} />, { queryClient, workspacePermissionKeys: permissions }),
...render(<Apps onClose={onClose} templateMode={templateMode} />, {
queryClient,
workspacePermissionKeys: permissions,
}),
onClose,
}
}
const openFirst = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getAllByRole('button', { name: 'app.newApp.useTemplate' })[0]!)
await user.click(screen.getAllByRole('button', { name: /^app\.newApp\.useTemplate/ })[0]!)
return screen.findByRole('dialog')
}
@@ -163,6 +170,53 @@ it('combines real type selection with debounced case-insensitive search and rest
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument()
})
it('keeps new Agent templates out of the App selector and its legacy Agent filter', async () => {
const user = userEvent.setup()
renderApps({
recommended_apps: [
createEntry('New Agent', 0, 'agent'),
createEntry('Legacy Agent', 1, 'agent-chat'),
],
categories: ['Assistant'],
})
await user.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
await user.click(screen.getByRole('button', { name: 'app.typeSelector.agent' }))
expect(screen.queryByTitle('New Agent')).not.toBeInTheDocument()
expect(screen.getByTitle('Legacy Agent')).toBeInTheDocument()
})
it('filters new Agent templates by catalog category in the Agent roster template picker', async () => {
const user = userEvent.setup()
renderApps(
{
recommended_apps: [
createEntry('Assistant Agent', 0, 'agent'),
createEntry('Writing Agent', 1, 'agent', 'Writing'),
createEntry('Legacy Agent', 2, 'agent-chat', 'Legacy'),
],
categories: ['Assistant', 'Writing', 'Legacy'],
},
[],
'agent',
)
expect(screen.getByTitle('Assistant Agent')).toBeInTheDocument()
expect(screen.getByTitle('Writing Agent')).toBeInTheDocument()
expect(screen.queryByTitle('Legacy Agent')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'app.typeSelector.all' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Assistant' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Writing' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Legacy' })).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.startFromBlank')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Writing' }))
expect(screen.queryByTitle('Assistant Agent')).not.toBeInTheDocument()
expect(screen.getByTitle('Writing Agent')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Writing' })).toHaveAttribute('aria-pressed', 'true')
})
it('fetches fresh detail by canonical app_id and submits the actual form with an empty initial description', async () => {
const user = userEvent.setup()
const { queryClient, onClose } = renderApps()
@@ -221,6 +275,23 @@ it('closes the submitted modal immediately and reports detail failure without im
expect(redirect).not.toHaveBeenCalled()
})
it('does not import an Agent detail through the Studio App template picker', async () => {
const user = userEvent.setup()
request.mockImplementation(async (url: string) => {
if (new URL(url).pathname.endsWith('/explore/apps/catalog-Alpha'))
return Response.json({ ...detail, mode: 'agent', version_id: 'v1', export_data: '' })
throw new Error(`Unexpected request: ${url}`)
})
renderApps()
await openFirst(user)
await user.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('app.newApp.appCreateFailed'))
expect(request).toHaveBeenCalledOnce()
expect(trackCreateApp).not.toHaveBeenCalled()
})
it('supports nullable metadata without inventing a name and requires a name before importing', async () => {
const user = userEvent.setup()
renderApps({
@@ -237,5 +308,7 @@ it('supports nullable metadata without inventing a name and requires a name befo
it('does not expose creation actions without app management permission', () => {
renderApps(catalog, [])
expect(screen.getByTitle('Alpha')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'app.newApp.useTemplate' })).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /^app\.newApp\.useTemplate/ }),
).not.toBeInTheDocument()
})
@@ -11,6 +11,10 @@ describe('Sidebar', () => {
expect(screen.getByText('app.newAppFromTemplate.sidebar.Recommended')).toBeInTheDocument()
expect(screen.getByText('Cat A')).toBeInTheDocument()
expect(screen.getByText('Cat B')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'app.newAppFromTemplate.sidebar.Recommended' }),
).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Cat A' })).toHaveAttribute('aria-pressed', 'false')
})
it('notifies callbacks when items are clicked', () => {
@@ -2,55 +2,54 @@
import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-ui/input-group'
import { Separator } from '@langgenius/dify-ui/separator'
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useDebouncedValue } from 'foxact/use-debounced-value'
import { useAtomValue } from 'jotai'
import dynamic from 'next/dynamic'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocale } from '#i18n'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import AppTypeSelector from '@/app/components/app/type-selector'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { getTemplateImportSource } from '@/app/components/explore/template-import'
import { toast } from '@/app/notifications'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRouter } from '@/next/navigation'
import { consoleClient, consoleQuery } from '@/service/console'
import { useCanImportAgents } from '@/features/agent-v2/permissions'
import { useImportDSL } from '@/hooks/use-import-dsl'
import { consoleQuery } from '@/service/console'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { trackCreateApp } from '@/utils/create-app-tracking'
import { hasPermission } from '@/utils/permission'
import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
const TryApp = dynamic(() => import('@/app/components/explore/try-app'), { ssr: false })
type AppsProps = {
onClose: () => void
onCreateFromBlank?: () => void
templateMode?: 'agent'
}
const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
const Apps = ({ onClose, onCreateFromBlank, templateMode }: AppsProps) => {
const { t } = useTranslation(['app', 'common'])
const locale = useLocale()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: currentUserId } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile.id,
})
const queryClient = useQueryClient()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const isRbacEnabled = systemFeatures.rbac_enabled
const canImportAgents = useCanImportAgents()
const canCreateAppFromTemplate = hasPermission(
workspacePermissionKeys,
'app.create_and_management',
)
const { push } = useRouter()
const { mutateAsync: importApp } = useMutation(consoleQuery.apps.imports.post.mutationOptions())
const canCreateTemplate = (mode?: string | null) =>
mode === 'agent' ? canImportAgents : canCreateAppFromTemplate
const { handleImportDSL, handleImportDSLConfirm, versions, isFetching } = useImportDSL()
const allCategoriesEn = AppCategories.RECOMMENDED
const [keywords, setKeywords] = useState('')
@@ -72,20 +71,25 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
}),
)
const allList = useMemo(
() => [...(data?.recommended_apps ?? [])].sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
[data?.recommended_apps],
() =>
[...(data?.recommended_apps ?? [])]
.filter((item) =>
templateMode ? item.app?.mode === templateMode : item.app?.mode !== AppModeEnum.AGENT,
)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)),
[data?.recommended_apps, templateMode],
)
const visibleCategories = useMemo(() => {
if (!data) return []
const categoriesWithApps = new Set<string>()
data.recommended_apps.forEach((app) => {
allList.forEach((app) => {
app.categories?.forEach((category) => categoriesWithApps.add(category))
})
return data.categories.filter((category) => categoriesWithApps.has(category))
}, [data])
}, [data, allList])
const activeCategory = visibleCategories.includes(currCategory) ? currCategory : allCategoriesEn
@@ -120,13 +124,17 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
return filteredList.filter(
(item) =>
item.app && item.app.name && item.app.name.toLowerCase().includes(lowerCaseSearchKeywords),
item.app &&
[item.app.name, item.description].some((value) =>
value?.toLowerCase().includes(lowerCaseSearchKeywords),
),
)
}, [searchKeywords, filteredList])
const [currApp, setCurrApp] = React.useState<RecommendedAppResponse | null>(null)
const [previewApp, setPreviewApp] = React.useState<RecommendedAppResponse | null>(null)
const [isShowCreateModal, setIsShowCreateModal] = React.useState(false)
const { handleCheckPluginDependencies } = usePluginDependencies()
const [showDSLConfirmModal, setShowDSLConfirmModal] = React.useState(false)
const onCreate: CreateAppModalProps['onConfirm'] = async ({
name,
icon_type,
@@ -134,44 +142,63 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
icon_background,
description,
}) => {
if (!currApp || !canCreateAppFromTemplate) return
if (!currApp || !canCreateTemplate(currApp.app?.mode)) return
try {
const { export_data, mode } = await consoleClient.explore.apps.byAppId.get({
params: { app_id: currApp.app_id },
const detail = await queryClient.query({
...consoleQuery.explore.apps.byAppId.get.queryOptions({
input: { params: { app_id: currApp.app_id } },
}),
staleTime: 0,
})
const app = await importApp({
body: {
mode: 'yaml-content',
yaml_content: export_data,
if (
!canCreateTemplate(detail.mode) ||
(templateMode ? detail.mode !== templateMode : detail.mode === AppModeEnum.AGENT)
)
throw new Error('Template mode does not match this picker')
await handleImportDSL(
{
...getTemplateImportSource(detail),
name,
icon_type,
icon,
icon_background,
description,
},
})
if (!app.app_id || !app.app_mode) throw new Error('Completed import is missing app metadata')
trackCreateApp({ source: 'studio_template_list', appMode: mode, templateId: currApp.app_id })
setIsShowCreateModal(false)
toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' }))
onClose()
await handleCheckPluginDependencies(app.app_id)
getRedirection(
{ id: app.app_id, mode: app.app_mode, permission_keys: app.permission_keys },
push,
{
currentUserId,
resourceMaintainer: currentUserId,
workspacePermissionKeys,
isRbacEnabled,
onSuccess: (response) => {
if (response.app_mode) {
trackCreateApp({
source: 'studio_template_list',
appMode: response.app_mode,
templateId: currApp.app_id,
})
}
setIsShowCreateModal(false)
onClose()
},
onPending: () => setShowDSLConfirmModal(true),
},
)
} catch {
toast.error(t(($) => $['newApp.appCreateFailed'], { ns: 'app' }))
}
}
const onConfirmDSL = async () => {
await handleImportDSLConfirm({
onSuccess: (response) => {
if (response.app_mode) {
trackCreateApp({
source: 'studio_template_list',
appMode: response.app_mode,
templateId: currApp?.app_id,
})
}
setShowDSLConfirmModal(false)
setIsShowCreateModal(false)
onClose()
},
})
}
if (isLoading) {
return (
@@ -190,10 +217,14 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
</span>
</div>
<div className="flex max-w-137 flex-1 items-center rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1.5 shadow-md">
<AppTypeSelector value={currentType} onChange={setCurrentType} />
<div className="h-3.5">
<Separator decorative className="mx-2" orientation="vertical" />
</div>
{!templateMode && (
<>
<AppTypeSelector value={currentType} onChange={setCurrentType} />
<div className="h-3.5">
<Separator decorative className="mx-2" orientation="vertical" />
</div>
</>
)}
<InputGroup className="flex-1 bg-transparent hover:border-transparent hover:bg-transparent">
<InputGroupInput
ref={searchInputRef}
@@ -261,16 +292,13 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
</div>
)}
</div>
<div
className={cn(
'grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3',
)}
>
<div className="grid shrink-0 grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3">
{searchFilteredList.map((app) => (
<AppCard
key={app.app_id}
app={app}
canCreate={canCreateAppFromTemplate}
canCreate={canCreateTemplate(app.app?.mode)}
onPreview={() => setPreviewApp(app)}
onCreate={() => {
setCurrApp(app)
setIsShowCreateModal(true)
@@ -297,9 +325,34 @@ const Apps = ({ onClose, onCreateFromBlank }: AppsProps) => {
appDescription=""
show={isShowCreateModal}
onConfirm={onCreate}
confirmLoading={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmLoading={isFetching}
/>
)}
{previewApp && (
<TryApp
appId={previewApp.app_id}
canTrial={previewApp.can_trial}
categories={previewApp.categories}
templateName={previewApp.app?.name}
templateMode={previewApp.app?.mode}
canCreate={canCreateTemplate(previewApp.app?.mode)}
onClose={() => setPreviewApp(null)}
onCreate={() => {
setCurrApp(previewApp)
setPreviewApp(null)
setIsShowCreateModal(true)
}}
/>
)}
</div>
)
}
@@ -41,17 +41,21 @@ export default function Sidebar({ current, categories, onClick, onCreateFromBlan
/>
))}
</ul>
<Separator className="my-2 h-[0.5px]" variant="gradient" />
<button
type="button"
className="flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-3 py-1 text-left text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onCreateFromBlank}
>
<RiStickyNoteAddLine className="size-3.5" aria-hidden="true" />
<span className="system-xs-regular">
{t(($) => $['newApp.startFromBlank'], { ns: 'app' })}
</span>
</button>
{onCreateFromBlank && (
<>
<Separator className="my-2 h-[0.5px]" variant="gradient" />
<button
type="button"
className="flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-3 py-1 text-left text-text-tertiary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onCreateFromBlank}
>
<RiStickyNoteAddLine className="size-3.5" aria-hidden="true" />
<span className="system-xs-regular">
{t(($) => $['newApp.startFromBlank'], { ns: 'app' })}
</span>
</button>
</>
)}
</div>
)
}
@@ -66,6 +70,7 @@ function CategoryItem({ category, active, onClick }: CategoryItemProps) {
<li>
<button
type="button"
aria-pressed={active}
className={cn(
'group flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg border-none bg-transparent p-1 pl-3 text-left hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden [&.active]:bg-state-base-active',
active && 'active',
@@ -7,9 +7,15 @@ type CreateAppDialogProps = {
show: boolean
onClose: () => void
onCreateFromBlank?: () => void
templateMode?: 'agent'
}
const CreateAppTemplateDialog = ({ show, onClose, onCreateFromBlank }: CreateAppDialogProps) => {
const CreateAppTemplateDialog = ({
show,
onClose,
onCreateFromBlank,
templateMode,
}: CreateAppDialogProps) => {
const { t } = useTranslation(['app'])
return (
@@ -18,7 +24,11 @@ const CreateAppTemplateDialog = ({ show, onClose, onCreateFromBlank }: CreateApp
title={t(($) => $['newApp.startFromTemplate'], { ns: 'app' })}
onClose={onClose}
>
<AppList onCreateFromBlank={onCreateFromBlank} onClose={onClose} />
<AppList
onCreateFromBlank={onCreateFromBlank}
onClose={onClose}
templateMode={templateMode}
/>
</CreateAppDialogShell>
)
}
@@ -105,7 +105,7 @@ export const AppTypeIcon = React.memo(
</div>
)
}
if (type === AppModeEnum.AGENT_CHAT) {
if (type === AppModeEnum.AGENT_CHAT || type === AppModeEnum.AGENT) {
return (
<div style={style} className={cn(wrapperClassNames, 'bg-components-icon-bg-violet-solid')}>
<span
@@ -231,7 +231,8 @@ function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProp
function getAppTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']) {
if (type === AppModeEnum.CHAT) return t(($) => $['typeSelector.chatbot'], { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT) return t(($) => $['typeSelector.agent'], { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT || type === AppModeEnum.AGENT)
return t(($) => $['typeSelector.agent'], { ns: 'app' })
if (type === AppModeEnum.COMPLETION) return t(($) => $['typeSelector.completion'], { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT) return t(($) => $['typeSelector.advanced'], { ns: 'app' })
if (type === AppModeEnum.WORKFLOW) return t(($) => $['typeSelector.workflow'], { ns: 'app' })
+51 -58
View File
@@ -7,9 +7,9 @@ import { useAtomValue } from 'jotai'
import dynamic from 'next/dynamic'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getTemplateImportSource } from '@/app/components/explore/template-import'
import { EducationExpireNotice } from '@/app/education/expire-notice'
import { toast } from '@/app/notifications'
import AppListContext from '@/context/app-list-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { useImportDSL } from '@/hooks/use-import-dsl'
import { DSLImportMode } from '@/models/app'
@@ -28,7 +28,6 @@ const ImportFromMarketplaceTemplateModal = dynamic(
() => import('./import-from-marketplace-template-modal'),
{ ssr: false },
)
const AppListProvider = AppListContext.Provider
const AppsContent = () => {
const queryClient = useQueryClient()
@@ -154,13 +153,12 @@ const AppsContent = () => {
hideTryAppPanel()
try {
const { export_data, mode } = await consoleClient.explore.apps.byAppId.get({
const detail = await consoleClient.explore.apps.byAppId.get({
params: { app_id: currApp.app_id },
})
currentCreateAppModeRef.current = mode
currentCreateAppModeRef.current = detail.mode
const payload = {
mode: DSLImportMode.YAML_CONTENT,
yaml_content: export_data,
...getTemplateImportSource(detail),
name,
icon_type,
icon,
@@ -186,61 +184,56 @@ const AppsContent = () => {
return (
<>
<EducationExpireNotice />
<AppListProvider
value={{
openTryAppPanel,
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-hidden bg-background-body">
<List onCreateLearnDify={handleCreateLearnDify} onTryLearnDify={openTryAppPanel} />
{isShowTryAppPanel && currApp && (
<TryApp
appId={currApp.app_id}
canTrial={currApp.can_trial}
categories={currApp.categories}
templateName={currApp.app?.name}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
<div className="relative flex h-0 shrink-0 grow flex-col overflow-hidden bg-background-body">
<List onCreateLearnDify={handleCreateLearnDify} onTryLearnDify={openTryAppPanel} />
{isShowTryAppPanel && currApp && (
<TryApp
appId={currApp.app_id}
canTrial={currApp.can_trial}
categories={currApp.categories}
templateName={currApp.app?.name}
templateMode={currApp.app?.mode}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmLoading={isFetching}
/>
)}
{isShowCreateModal && (
<CreateAppModal
appIconType={
currApp?.app?.icon_type === 'image' || currApp?.app?.icon_type === 'link'
? currApp.app.icon_type
: 'emoji'
}
appIcon={currApp?.app?.icon ?? ''}
appIconBackground={currApp?.app?.icon_background ?? ''}
appIconUrl={currApp?.app?.icon_url}
appName={currApp?.app?.name ?? ''}
appDescription=""
show
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
{isShowCreateModal && (
<CreateAppModal
appIconType={
currApp?.app?.icon_type === 'image' || currApp?.app?.icon_type === 'link'
? currApp.app.icon_type
: 'emoji'
}
appIcon={currApp?.app?.icon ?? ''}
appIconBackground={currApp?.app?.icon_background ?? ''}
appIconUrl={currApp?.app?.icon_url}
appName={currApp?.app?.name ?? ''}
appDescription=""
show
onConfirm={onCreate}
confirmLoading={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
{canCreateApp && templateId && !templateDismissedRef.current && (
<ImportFromMarketplaceTemplateModal
templateId={templateId}
onClose={handleCloseTemplateModal}
onConfirm={handleMarketplaceTemplateConfirm}
/>
)}
</div>
</AppListProvider>
{canCreateApp && templateId && !templateDismissedRef.current && (
<ImportFromMarketplaceTemplateModal
templateId={templateId}
onClose={handleCloseTemplateModal}
onConfirm={handleMarketplaceTemplateConfirm}
/>
)}
</div>
</>
)
}
+5 -4
View File
@@ -1,4 +1,5 @@
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { cva } from 'class-variance-authority'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -34,14 +35,14 @@ const Alert: React.FC<Props> = ({ type = 'info', message, onHide, className }) =
<div className="p-1">
<div className="system-xs-regular text-text-secondary">{message}</div>
</div>
<button
type="button"
<IconButton
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="pointer-events-auto flex size-6 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-components-button-secondary-accent-border"
className="pointer-events-auto"
size="md"
onClick={onHide}
>
<span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" />
</button>
</IconButton>
</div>
</div>
)
@@ -1,3 +1,4 @@
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import type { ReactNode } from 'react'
import type { ChatConfig } from '../../types'
import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share'
@@ -5,6 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import { InputVarType } from '@/app/components/workflow/types'
import { AppToastHost } from '@/app/notifications/host'
import { consoleQuery } from '@/service/console'
import {
AppSourceType,
fetchChatList,
@@ -90,8 +92,15 @@ vi.mock('@/service/share', async (importOriginal) => {
})
const STABLE_MOCK_DATA = { data: {} }
const createTryAppDetail = (id: string, title: string): TrialAppDetailResponse => ({
id,
name: title,
mode: 'chat',
enable_api: false,
enable_site: true,
site: { title, default_language: 'en-US' },
})
vi.mock('@/service/use-try-app', () => ({
useGetTryAppInfo: vi.fn(() => STABLE_MOCK_DATA),
useGetTryAppParams: vi.fn(() => STABLE_MOCK_DATA),
}))
@@ -117,8 +126,9 @@ const createWrapper = (queryClient: QueryClient) => {
)
}
const renderWithClient = async <T,>(hook: () => T) => {
const renderWithClient = async <T,>(hook: () => T, setup?: (client: QueryClient) => void) => {
const queryClient = createQueryClient()
setup?.(queryClient)
const wrapper = createWrapper(queryClient)
let result: ReturnType<typeof renderHook<T, unknown>> | undefined
act(() => {
@@ -407,17 +417,20 @@ describe('useEmbeddedChatbot', () => {
describe('TryApp mode', () => {
it('should use tryApp source type and skip URL overrides and user fetch', async () => {
// Arrange
const { useGetTryAppInfo } = await import('@/service/use-try-app')
const mockTryAppInfo = { app_id: 'try-app-1', site: { title: 'Try App' } }
;(useGetTryAppInfo as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: mockTryAppInfo,
})
const mockTryAppInfo = createTryAppDetail('try-app-1', 'Try App')
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({})
// Act
const { result } = await renderWithClient(() =>
useEmbeddedChatbot(AppSourceType.tryApp, 'try-app-1'),
const { result } = await renderWithClient(
() => useEmbeddedChatbot(AppSourceType.tryApp, 'try-app-1'),
(client) => {
const key = consoleQuery.trialApps.byAppId.get.queryKey({
input: { params: { app_id: 'try-app-1' } },
})
client.setQueryDefaults(key, { staleTime: Infinity })
client.setQueryData(key, mockTryAppInfo)
},
)
// Assert
@@ -592,8 +605,15 @@ describe('useEmbeddedChatbot', () => {
})
it('handleNewConversation sets clearChatList to true for tryApp without complex parsing', async () => {
const { result } = await renderWithClient(() =>
useEmbeddedChatbot(AppSourceType.tryApp, 'app-try-1'),
const { result } = await renderWithClient(
() => useEmbeddedChatbot(AppSourceType.tryApp, 'app-try-1'),
(client) => {
const key = consoleQuery.trialApps.byAppId.get.queryKey({
input: { params: { app_id: 'app-try-1' } },
})
client.setQueryDefaults(key, { staleTime: Infinity })
client.setQueryData(key, createTryAppDetail('app-try-1', 'Try App'))
},
)
await act(async () => {
@@ -2,6 +2,7 @@ import type { ChatConfig, ChatItem, OnFeedback } from '../types'
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
import type { Locale } from '@/i18n'
import type { AppData, ConversationItem } from '@/models/share'
import { skipToken, useQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -12,6 +13,7 @@ import { InputVarType } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n/client'
import { consoleQuery } from '@/service/console'
import { AppSourceType, updateFeedback } from '@/service/share'
import {
useInvalidateShareConversations,
@@ -19,7 +21,7 @@ import {
useShareConversationName,
useShareConversations,
} from '@/service/use-share'
import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app'
import { useGetTryAppParams } from '@/service/use-try-app'
import { getWebAppConversationScopeId, resolveWebAppAddress } from '@/service/webapp-address'
import { TransferMethod } from '@/types/app'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
@@ -69,7 +71,11 @@ function getFormattedChatList(messages: any[]) {
export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: string) => {
const isInstalledApp = false // just can be webapp and try app
const isTryApp = appSourceType === AppSourceType.tryApp
const { data: tryAppInfo } = useGetTryAppInfo(isTryApp ? tryAppId! : '')
const { data: tryAppInfo } = useQuery(
consoleQuery.trialApps.byAppId.get.queryOptions({
input: isTryApp && tryAppId ? { params: { app_id: tryAppId } } : skipToken,
}),
)
const webAppInfo = useWebAppStore((s) => s.appInfo)
const appInfo = isTryApp ? tryAppInfo : webAppInfo
const appMeta = useWebAppStore((s) => s.appMeta)
@@ -0,0 +1,43 @@
import type { RecommendedAppDetailResponse } from '@dify/contracts/api/console/explore/types.gen'
import { describe, expect, it } from 'vite-plus/test'
import { getTemplateImportSource } from '../template-import'
const detail = {
id: 'template-id',
name: 'Template',
mode: 'agent',
can_trial: false,
export_data: '',
} satisfies RecommendedAppDetailResponse
describe('getTemplateImportSource', () => {
it('imports a catalog Agent from its published version', () => {
expect(getTemplateImportSource({ ...detail, version_id: 'version-id' })).toEqual({
mode: 'template',
template_id: 'template-id',
version_id: 'version-id',
})
})
it('imports a remote Agent from its package URL', () => {
expect(
getTemplateImportSource({ ...detail, package_url: 'https://example.com/agent.ifpkg' }),
).toEqual({
mode: 'ifpkg-url',
package_url: 'https://example.com/agent.ifpkg',
})
})
it('does not submit an empty YAML payload for an Agent', () => {
expect(() => getTemplateImportSource(detail)).toThrow('Agent template has no import source')
})
it('preserves YAML import for existing app templates', () => {
expect(
getTemplateImportSource({ ...detail, mode: 'workflow', export_data: 'app: example' }),
).toEqual({
mode: 'yaml-content',
yaml_content: 'app: example',
})
})
})
@@ -261,9 +261,14 @@ describe('CreateAppModal', () => {
expect(screen.queryByRole('dialog', { name: 'app.iconPicker.title' })).not.toBeInTheDocument()
})
it('does not submit while the visible confirmation action is disabled', async () => {
const { onConfirm, onHide } = await setup({ confirmDisabled: true })
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
it.each([
{ state: 'disabled', props: { confirmDisabled: true } },
{ state: 'loading', props: { confirmLoading: true } },
])('does not submit while the visible confirmation action is $state', async ({ props }) => {
const { onConfirm, onHide } = await setup(props)
const action = screen.getByRole('button', { name: /common\.operation\.create/ })
if (props.confirmLoading) expect(action).toHaveAttribute('aria-disabled', 'true')
else expect(action).toBeDisabled()
submitWithKeyboard()
await act(async () => {
vi.advanceTimersByTime(300)
@@ -41,6 +41,7 @@ export type CreateAppModalProps = {
max_active_requests?: number | null
onConfirm: (info: UpdateAppPayload) => Promise<void>
confirmDisabled?: boolean
confirmLoading?: boolean
onHide: () => void
}
@@ -60,6 +61,7 @@ const CreateAppModal = ({
max_active_requests,
onConfirm,
confirmDisabled,
confirmLoading,
onHide,
}: CreateAppModalProps) => {
const nameInputId = React.useId()
@@ -121,7 +123,12 @@ const CreateAppModal = ({
appQuota.size >= appQuota.limit
const submit = useCallback(() => {
if (confirmDisabled || (!isEditModal && (isAppQuotaUnavailable || isAppsFull))) return
if (
confirmLoading ||
confirmDisabled ||
(!isEditModal && (isAppQuotaUnavailable || isAppsFull))
)
return
if (!name.trim()) {
toast(
t(($) => $['appCustomize.nameRequired'], { ns: 'explore' }),
@@ -144,6 +151,7 @@ const CreateAppModal = ({
onConfirm(payload)
onHide()
}, [
confirmLoading,
confirmDisabled,
isEditModal,
isAppQuotaUnavailable,
@@ -173,6 +181,7 @@ const CreateAppModal = ({
if (
!show ||
submitDisabled ||
confirmLoading ||
showIconPicker ||
event.defaultPrevented ||
event.nativeEvent.isComposing ||
@@ -310,6 +319,7 @@ const CreateAppModal = ({
</div>
<div className="flex flex-row-reverse">
<Button
loading={confirmLoading}
disabled={submitDisabled}
className="ml-2 w-24"
variant="primary"
@@ -0,0 +1,28 @@
import type { AppImportPayload } from '@dify/contracts/api/console/apps/types.gen'
import type { RecommendedAppDetailResponse } from '@dify/contracts/api/console/explore/types.gen'
type TemplateImportSource =
| {
mode: 'template'
template_id: NonNullable<AppImportPayload['template_id']>
version_id: NonNullable<AppImportPayload['version_id']>
}
| { mode: 'ifpkg-url'; package_url: NonNullable<AppImportPayload['package_url']> }
| { mode: 'yaml-content'; yaml_content: NonNullable<AppImportPayload['yaml_content']> }
export function getTemplateImportSource(
detail: RecommendedAppDetailResponse,
): TemplateImportSource {
if (detail.mode === 'agent') {
if (detail.version_id) {
return { mode: 'template', template_id: detail.id, version_id: detail.version_id }
}
if (detail.package_url) {
return { mode: 'ifpkg-url', package_url: detail.package_url }
}
throw new Error('Agent template has no import source')
}
if (!detail.export_data) throw new Error('Template has no export data')
return { mode: 'yaml-content', yaml_content: detail.export_data }
}
@@ -1,11 +1,29 @@
import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen'
import type { ComponentProps } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import type { ComponentProps, ReactElement, ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
act,
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { consoleQuery } from '@/service/console'
import TryAppComponent from '../index'
import { TypeEnum } from '../types'
function render(ui: ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return rtlRender(ui, {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
})
}
const defaultApp: RecommendedAppResponse = { app_id: 'test-app-id', can_trial: true }
function TryApp({
@@ -28,15 +46,29 @@ function TryApp({
)
}
const mockUseGetTryAppInfo = vi.fn()
const mockDetailQueryResult = vi.fn()
const mockPreviewSuspension = vi.hoisted(() => ({ promise: null as Promise<void> | null }))
vi.mock('@/service/use-try-app', () => ({
useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: (options: Parameters<typeof actual.useQuery>[0]) => {
const appId = ['test-app-id', 'my-specific-app-id', 'my-app-id'].find(
(id) =>
JSON.stringify(options.queryKey) ===
JSON.stringify(
consoleQuery.trialApps.byAppId.get.queryKey({ input: { params: { app_id: id } } }),
),
)
if (appId) return mockDetailQueryResult(appId)
return actual.useQuery(options)
},
}
})
vi.mock('../app', () => ({
default: ({ appId, appDetail }: { appId: string; appDetail: TryAppInfo }) => (
default: ({ appId, appDetail }: { appId: string; appDetail: TrialAppDetailResponse }) => (
<div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}>
App Component
</div>
@@ -44,7 +76,7 @@ vi.mock('../app', () => ({
}))
vi.mock('../preview', () => ({
default: ({ appId, appDetail }: { appId: string; appDetail: TryAppInfo }) => {
default: ({ appId, appDetail }: { appId: string; appDetail: TrialAppDetailResponse }) => {
if (mockPreviewSuspension.promise) throw mockPreviewSuspension.promise
return (
<div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}>
@@ -63,7 +95,7 @@ vi.mock('../app-info', () => ({
onCreate,
}: {
appId: string
appDetail: TryAppInfo
appDetail: TrialAppDetailResponse
categories?: string[]
className?: string
onCreate: () => void
@@ -82,7 +114,7 @@ vi.mock('../app-info', () => ({
),
}))
const createMockAppDetail = (mode: string = 'chat'): TryAppInfo =>
const createMockAppDetail = (mode: string = 'chat'): TrialAppDetailResponse =>
({
id: 'test-app-id',
name: 'Test App Name',
@@ -111,7 +143,7 @@ const createMockAppDetail = (mode: string = 'chat'): TryAppInfo =>
},
user_input_form: [],
},
}) as unknown as TryAppInfo
}) as unknown as TrialAppDetailResponse
describe('TryApp (main index.tsx)', () => {
beforeEach(() => {
@@ -119,7 +151,7 @@ describe('TryApp (main index.tsx)', () => {
mockPreviewSuspension.promise = null
// Suppress expected React act() warnings from internal async state updates
vi.spyOn(console, 'error').mockImplementation(() => {})
mockUseGetTryAppInfo.mockReturnValue({
mockDetailQueryResult.mockReturnValue({
data: createMockAppDetail(),
isLoading: false,
})
@@ -132,7 +164,7 @@ describe('TryApp (main index.tsx)', () => {
describe('loading state', () => {
it('renders loading when isLoading is true', () => {
mockUseGetTryAppInfo.mockReturnValue({
mockDetailQueryResult.mockReturnValue({
data: null,
isLoading: true,
})
@@ -143,8 +175,8 @@ describe('TryApp (main index.tsx)', () => {
})
it('keeps the dialog and tabs mounted when the initial load finishes', () => {
let result = { data: null as TryAppInfo | null, isLoading: true }
mockUseGetTryAppInfo.mockImplementation(() => result)
let result = { data: null as TrialAppDetailResponse | null, isLoading: true }
mockDetailQueryResult.mockImplementation(() => result)
const { rerender } = render(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
const dialog = screen.getByRole('dialog')
const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })
@@ -162,7 +194,7 @@ describe('TryApp (main index.tsx)', () => {
it('keeps the tabs mounted when the initial load fails', () => {
let result = { data: null, isLoading: true, isError: false, refetch: vi.fn() }
mockUseGetTryAppInfo.mockImplementation(() => result)
mockDetailQueryResult.mockImplementation(() => result)
const { rerender } = render(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
const detailTab = screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })
@@ -173,6 +205,30 @@ describe('TryApp (main index.tsx)', () => {
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument()
})
it('starts a new dialog session with loading when a cached failure is retried', () => {
let result = {
data: null,
isLoading: true,
isFetching: true,
isFetched: true,
isFetchedAfterMount: false,
errorUpdateCount: 1,
refetch: vi.fn(),
}
mockDetailQueryResult.mockImplementation(() => result)
const { rerender } = render(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByRole('progressbar')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'common.operation.retry' }),
).not.toBeInTheDocument()
result = { ...result, isLoading: false, isFetching: false, isFetchedAfterMount: true }
rerender(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBeInTheDocument()
})
it('keeps the dialog visible while preview content suspends', async () => {
let resolvePreview: () => void = () => {}
mockPreviewSuspension.promise = new Promise<void>((resolve) => {
@@ -196,7 +252,7 @@ describe('TryApp (main index.tsx)', () => {
})
it('shows retry without the create panel when the app detail request fails', () => {
mockUseGetTryAppInfo.mockReturnValue({
mockDetailQueryResult.mockReturnValue({
data: null,
isError: true,
isFetching: false,
@@ -212,7 +268,7 @@ describe('TryApp (main index.tsx)', () => {
})
it('renders unknown unavailable state when app detail is empty', () => {
mockUseGetTryAppInfo.mockReturnValue({
mockDetailQueryResult.mockReturnValue({
data: null,
isLoading: false,
isError: false,
@@ -225,35 +281,49 @@ describe('TryApp (main index.tsx)', () => {
expect(screen.getByText('explore.tryApp.loadError')).toBeInTheDocument()
})
it('disables retry while fetching and restores the full preview after recovery', async () => {
it('keeps the error block during retry and restores the preview after recovery', async () => {
const refetch = vi.fn().mockResolvedValue({ data: createMockAppDetail() })
let result = {
data: null as TryAppInfo | null,
data: null as TrialAppDetailResponse | null,
isLoading: false,
isFetched: true,
isFetchedAfterMount: true,
isError: true,
isFetching: false,
errorUpdateCount: 1,
refetch,
}
mockUseGetTryAppInfo.mockImplementation(() => result)
mockDetailQueryResult.mockImplementation(() => result)
const { rerender } = render(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
const errorBlock = screen.getByRole('alert')
retryButton.focus()
fireEvent.click(retryButton)
expect(refetch).toHaveBeenCalledTimes(1)
result = { ...result, isFetching: true }
result = { ...result, isLoading: true, isFetching: true }
rerender(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
const retryingButton = screen.getByRole('button', { name: 'explore.tryApp.retrying' })
expect(retryingButton).toBe(retryButton)
expect(screen.getByRole('alert')).toBe(errorBlock)
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(retryingButton).toHaveAttribute('aria-disabled', 'true')
expect(retryingButton).toHaveFocus()
fireEvent.click(retryingButton)
expect(refetch).toHaveBeenCalledTimes(1)
result = { ...result, data: createMockAppDetail(), isError: false, isFetching: false }
result = {
...result,
data: createMockAppDetail(),
isLoading: false,
isError: false,
isFetching: false,
}
rerender(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByTestId('preview-component')).toBeInTheDocument()
expect(screen.getByTestId('create-button')).toBeInTheDocument()
expect(errorBlock.parentElement).toHaveAttribute('aria-hidden', 'true')
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'explore.tryApp.tabHeader.detail' })).toHaveFocus()
})
@@ -261,17 +331,29 @@ describe('TryApp (main index.tsx)', () => {
it('allows another retry after a failed request', () => {
const refetch = vi.fn().mockResolvedValue({ data: null })
mockUseGetTryAppInfo.mockReturnValue({
let result = {
data: null,
isLoading: false,
isFetched: true,
isFetchedAfterMount: true,
isError: true,
isFetching: false,
refetch,
})
render(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
}
mockDetailQueryResult.mockImplementation(() => result)
const { rerender } = render(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.retry' }))
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
const errorBlock = screen.getByRole('alert')
fireEvent.click(retryButton)
result = { ...result, isLoading: true, isFetching: true }
rerender(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
result = { ...result, isLoading: false, isFetching: false }
rerender(<TryApp onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByRole('alert')).toBe(errorBlock)
expect(screen.getByRole('button', { name: 'common.operation.retry' })).toBe(retryButton)
fireEvent.click(retryButton)
expect(refetch).toHaveBeenCalledTimes(2)
})
})
@@ -328,11 +410,11 @@ describe('TryApp (main index.tsx)', () => {
const { rerender } = render(<TryApp app={app} onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument()
mockUseGetTryAppInfo.mockReturnValue({ data: null, isLoading: true })
mockDetailQueryResult.mockReturnValue({ data: null, isLoading: true })
rerender(<TryApp app={app} onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument()
mockUseGetTryAppInfo.mockReturnValue({ data: null, isError: true, refetch: vi.fn() })
mockDetailQueryResult.mockReturnValue({ data: null, isError: true, refetch: vi.fn() })
rerender(<TryApp app={app} onClose={vi.fn()} onCreate={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'Sample template' })).toBeInTheDocument()
})
@@ -468,7 +550,7 @@ describe('TryApp (main index.tsx)', () => {
/>,
)
expect(mockUseGetTryAppInfo).toHaveBeenCalledWith('my-specific-app-id')
expect(mockDetailQueryResult).toHaveBeenCalledWith('my-specific-app-id')
})
})
@@ -1,4 +1,4 @@
import type { TryAppInfo } from '@/service/try-app'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
@@ -10,7 +10,10 @@ vi.mock('../use-get-requirements', () => ({
default: (...args: unknown[]) => mockUseGetRequirements(...args),
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo =>
const createMockAppDetail = (
mode: string,
overrides: Partial<TrialAppDetailResponse> = {},
): TrialAppDetailResponse =>
({
id: 'test-app-id',
name: 'Test App Name',
@@ -40,7 +43,7 @@ const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}):
user_input_form: [],
},
...overrides,
}) as unknown as TryAppInfo
}) as unknown as TrialAppDetailResponse
describe('AppInfo', () => {
beforeEach(() => {
@@ -67,7 +70,7 @@ describe('AppInfo', () => {
it('renders app name with title attribute', () => {
const appDetail = createMockAppDetail('chat', {
name: 'Very Long App Name That Should Be Truncated',
} as Partial<TryAppInfo>)
} as Partial<TrialAppDetailResponse>)
const mockOnCreate = vi.fn()
render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />)
@@ -128,7 +131,7 @@ describe('AppInfo', () => {
it('renders description when provided', () => {
const appDetail = createMockAppDetail('chat', {
description: 'This is a test description',
} as Partial<TryAppInfo>)
} as Partial<TrialAppDetailResponse>)
const mockOnCreate = vi.fn()
render(<AppInfo appId="test-app-id" appDetail={appDetail} onCreate={mockOnCreate} />)
@@ -139,7 +142,7 @@ describe('AppInfo', () => {
it('does not render description when empty', () => {
const appDetail = createMockAppDetail('chat', {
description: '',
} as Partial<TryAppInfo>)
} as Partial<TrialAppDetailResponse>)
const mockOnCreate = vi.fn()
const { container } = render(
@@ -1,4 +1,4 @@
import type { TryAppInfo } from '@/service/try-app'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import { renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import useGetRequirements from '../use-get-requirements'
@@ -14,7 +14,10 @@ vi.mock('@/config', () => ({
MARKETPLACE_API_PREFIX: 'https://marketplace.api',
}))
const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}): TryAppInfo =>
const createMockAppDetail = (
mode: string,
overrides: Partial<TrialAppDetailResponse> = {},
): TrialAppDetailResponse =>
({
id: 'test-app-id',
name: 'Test App',
@@ -44,7 +47,7 @@ const createMockAppDetail = (mode: string, overrides: Partial<TryAppInfo> = {}):
user_input_form: [],
},
...overrides,
}) as unknown as TryAppInfo
}) as unknown as TrialAppDetailResponse
describe('useGetRequirements', () => {
afterEach(() => {
@@ -79,7 +82,7 @@ describe('useGetRequirements', () => {
agent_mode: { tools: [] },
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
} as unknown as Partial<TrialAppDetailResponse>)
const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
@@ -119,7 +122,7 @@ describe('useGetRequirements', () => {
},
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
} as unknown as Partial<TrialAppDetailResponse>)
const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
@@ -157,7 +160,7 @@ describe('useGetRequirements', () => {
},
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
} as unknown as Partial<TrialAppDetailResponse>)
const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
@@ -374,7 +377,7 @@ describe('useGetRequirements', () => {
agent_mode: { tools: [] },
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
} as unknown as Partial<TrialAppDetailResponse>)
const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
@@ -397,7 +400,7 @@ describe('useGetRequirements', () => {
agent_mode: { tools: [] },
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
} as unknown as Partial<TrialAppDetailResponse>)
const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
@@ -428,7 +431,7 @@ describe('useGetRequirements', () => {
},
user_input_form: [],
},
} as unknown as Partial<TryAppInfo>)
} as unknown as Partial<TrialAppDetailResponse>)
const { result } = renderHook(() => useGetRequirements({ appDetail, appId: 'test-app-id' }))
@@ -1,22 +1,28 @@
'use client'
import type {
AgentAppComposerResponse,
TrialAppDetailResponse,
} from '@dify/contracts/api/console/trial-apps/types.gen'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { FileTreeIcon } from '@langgenius/dify-ui/file-tree'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { getFileIconType } from '@/features/agent-v2/file-icon'
import useGetRequirements from './use-get-requirements'
type Props = Readonly<{
appId: string
appDetail: TryAppInfo
appDetail: TrialAppDetailResponse
canCreate?: boolean
categories?: string[]
className?: string
createButtonStepByStepTourTarget?: string
onCreate: () => void
agentComposer?: AgentAppComposerResponse
}>
const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3'
@@ -59,13 +65,18 @@ const AppInfo: FC<Props> = ({
createButtonStepByStepTourTarget,
appDetail,
onCreate,
agentComposer,
}) => {
const { t } = useTranslation(['app', 'explore'])
const requirementsHeadingId = React.useId()
const includedHeadingId = React.useId()
const mode = appDetail?.mode
const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? []))
const { requirements } = useGetRequirements({ appDetail, appId })
const { requirements } = useGetRequirements({ appDetail, appId, agentComposer })
const skills = agentComposer?.agent_soul.config_skills ?? []
const files = agentComposer?.agent_soul.config_files ?? []
return (
<div className={cn('flex h-full flex-col px-4 pt-2', className)}>
<div className={cn('flex h-full flex-col px-4 pt-2', mode === 'agent' && 'pb-4', className)}>
{/* name and icon */}
<div className="flex shrink-0 grow-0 items-center gap-3">
<div className="relative shrink-0">
@@ -99,7 +110,7 @@ const AppInfo: FC<Props> = ({
{t(($) => $['types.chatbot'], { ns: 'app' }).toUpperCase()}
</div>
)}
{mode === 'agent-chat' && (
{(mode === 'agent-chat' || mode === 'agent') && (
<div className="truncate">
{t(($) => $['types.agent'], { ns: 'app' }).toUpperCase()}
</div>
@@ -122,51 +133,93 @@ const AppInfo: FC<Props> = ({
{appDetail.description}
</div>
)}
{!!appDetail.tags?.length && (
<ul className="mt-3 flex flex-wrap gap-1">
{appDetail.tags.map((tag) => (
<li
key={tag.id}
className="flex items-center gap-0.5 rounded border border-divider-deep px-1 system-2xs-medium-uppercase text-text-tertiary"
>
<span aria-hidden className="i-ri-price-tag-3-line size-3" />
{tag.name}
</li>
))}
</ul>
)}
{canCreate && (
<Button
variant="primary"
className="mt-3 flex w-full max-w-full"
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
onClick={onCreate}
>
<span className="i-ri-add-line size-4 shrink-0" />
<span className="truncate">
{t(($) => $['tryApp.createFromSampleApp'], { ns: 'explore' })}
</span>
</Button>
<div className="shrink-0 py-3">
<Button
variant="primary"
className="w-full"
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
onClick={onCreate}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="min-w-0 truncate">
{t(($) => $['tryApp.createFromSampleApp'], { ns: 'explore' })}
</span>
</Button>
</div>
)}
{visibleCategories.length > 0 && (
<div className="mt-6 shrink-0">
<div className={headerClassName}>{t(($) => $['tryApp.category'], { ns: 'explore' })}</div>
<div className="flex flex-wrap gap-1.5">
<div className="mt-3 shrink-0">
<h2 className={headerClassName}>{t(($) => $['tryApp.category'], { ns: 'explore' })}</h2>
<ul className="flex flex-wrap gap-1.5">
{visibleCategories.map((category) => (
<span
<li
key={category}
className="rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-2 py-0.5 system-xs-medium text-text-secondary shadow-xs"
>
{category}
</span>
</li>
))}
</div>
</ul>
</div>
)}
{requirements.length > 0 && (
<div className="mt-5 grow overflow-y-auto">
<div className={headerClassName}>
<section
aria-labelledby={requirementsHeadingId}
className={cn('mt-4 min-h-0 overflow-y-auto', mode !== 'agent' && 'grow')}
>
<h2 id={requirementsHeadingId} className={headerClassName}>
{t(($) => $['tryApp.requirements'], { ns: 'explore' })}
</div>
<div className="space-y-0.5">
</h2>
<ul className="space-y-0.5">
{requirements.map((item) => (
<div className="flex items-center space-x-2 py-1" key={item.name}>
<li className="flex items-center space-x-2 py-1" key={item.name}>
<RequirementIcon iconUrl={item.iconUrl} />
<div className="w-0 grow truncate system-md-regular text-text-secondary">
{item.name}
</div>
</div>
</li>
))}
</div>
</div>
</ul>
</section>
)}
{mode === 'agent' && (skills.length > 0 || files.length > 0) && (
<section aria-labelledby={includedHeadingId} className="mt-4 min-h-0 overflow-y-auto">
<h2 id={includedHeadingId} className={headerClassName}>
{t(($) => $['tryApp.included'], { ns: 'explore' })}
</h2>
<ul className="space-y-0.5 system-md-regular text-text-secondary">
{skills.map((skill) => (
<li key={`skill-${skill.name}`} className="flex min-h-7 items-center gap-2 py-1">
<span
aria-hidden="true"
className="i-custom-vender-agent-v2-building-blocks size-5 shrink-0"
/>
<span className="min-w-0 truncate">{skill.name}</span>
</li>
))}
{files.map((file) => (
<li key={`file-${file.name}`} className="flex min-h-7 items-center gap-2 py-1">
<FileTreeIcon type={getFileIconType(file.name, file.mime_type)} />
<span className="min-w-0 truncate">{file.name}</span>
</li>
))}
</ul>
</section>
)}
</div>
)
@@ -1,4 +1,7 @@
import type { TryAppInfo } from '@/service/try-app'
import type {
AgentAppComposerResponse,
TrialAppDetailResponse,
} from '@dify/contracts/api/console/trial-apps/types.gen'
import type { AgentTool } from '@/types/app'
import { useQuery } from '@tanstack/react-query'
import { uniqBy } from 'es-toolkit/compat'
@@ -7,8 +10,9 @@ import { MARKETPLACE_API_PREFIX } from '@/config'
import { consoleQuery } from '@/service/console'
type Params = {
appDetail: TryAppInfo
appDetail: TrialAppDetailResponse
appId: string
agentComposer?: AgentAppComposerResponse
}
type RequirementItem = {
@@ -101,18 +105,30 @@ const hasToolRequirementData = (
return typeof value.provider_id === 'string' && typeof value.tool_label === 'string'
}
const useGetRequirements = ({ appDetail, appId }: Params) => {
const useGetRequirements = ({ appDetail, appId, agentComposer }: Params) => {
const isBasic = ['chat', 'completion', 'agent-chat'].includes(appDetail.mode)
const isAgent = appDetail.mode === 'agent-chat'
const isAdvanced = !isBasic
const isAdvanced = !isBasic && appDetail.mode !== 'agent'
const { data: flowData } = useQuery(
consoleQuery.trialApps.byAppId.workflows.get.queryOptions({
input: { params: { app_id: appId } },
enabled: !isBasic,
enabled: isAdvanced,
}),
)
const requirements: RequirementItem[] = []
if (appDetail.mode === 'agent' && agentComposer) {
const agentModel = agentComposer.agent_soul.model
if (agentModel)
requirements.push({
name: agentModel.model,
iconUrl: getIconUrl(agentModel.plugin_id, 'model'),
})
for (const tool of agentComposer.agent_soul.tools?.dify_tools ?? []) {
if (tool.enabled === false || !tool.name || !tool.plugin_id) continue
requirements.push({ name: tool.name, iconUrl: getIconUrl(tool.plugin_id, 'tool') })
}
}
const modelConfig = appDetail.model_config
const model = modelConfig?.model
if (isBasic && model) {
@@ -1,4 +1,4 @@
import type { TryAppInfo } from '@/service/try-app'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
import TryApp from '../chat'
@@ -27,7 +27,9 @@ vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropd
default: () => <div data-testid="view-form-dropdown">ViewFormDropdown</div>,
}))
const createMockAppDetail = (overrides: Partial<TryAppInfo> = {}): TryAppInfo =>
const createMockAppDetail = (
overrides: Partial<TrialAppDetailResponse> = {},
): TrialAppDetailResponse =>
({
id: 'test-app-id',
name: 'Test Chat App',
@@ -57,7 +59,7 @@ const createMockAppDetail = (overrides: Partial<TryAppInfo> = {}): TryAppInfo =>
user_input_form: [],
},
...overrides,
}) as unknown as TryAppInfo
}) as unknown as TrialAppDetailResponse
describe('TryApp (chat.tsx)', () => {
beforeEach(() => {
@@ -84,7 +86,9 @@ describe('TryApp (chat.tsx)', () => {
})
it('renders app name with title attribute', () => {
const appDetail = createMockAppDetail({ name: 'Long App Name' } as Partial<TryAppInfo>)
const appDetail = createMockAppDetail({
name: 'Long App Name',
} as Partial<TrialAppDetailResponse>)
render(<TryApp appId="test-app-id" appDetail={appDetail} className="test-class" />)
@@ -1,4 +1,4 @@
import type { TryAppInfo } from '@/service/try-app'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vite-plus/test'
import TryApp from '../index'
@@ -12,12 +12,12 @@ vi.mock('../text-generation', () => ({
),
}))
const createApp = (mode: string): TryAppInfo =>
const createApp = (mode: string): TrialAppDetailResponse =>
({
id: 'app-id',
mode,
site: { title: 'Try App' },
}) as TryAppInfo
}) as TrialAppDetailResponse
describe('TryApp', () => {
it.each(['chat', 'advanced-chat', 'agent-chat'])(
@@ -1,7 +1,7 @@
'use client'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import type { FC } from 'react'
import type { EmbeddedChatbotContextValue } from '@/app/components/base/chat/embedded-chatbot/context'
import type { TryAppInfo } from '@/service/try-app'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
@@ -22,7 +22,7 @@ import { AppSourceType } from '@/service/share'
type Props = Readonly<{
appId: string
appDetail: TryAppInfo
appDetail: TrialAppDetailResponse
className: string
}>
@@ -95,7 +95,7 @@ const TryApp: FC<Props> = ({ appId, appDetail, className }) => {
{currentConversationId && inputsForms.length > 0 && <ViewFormDropdown />}
</div>
</div>
<div className="mx-auto mt-4 flex h-0 w-192.25 grow flex-col">
<div className="mx-auto mt-4 flex h-0 w-full max-w-192.25 min-w-0 grow flex-col">
{!isHideTryNotice && (
<Alert
className="mb-4 shrink-0"
@@ -1,6 +1,6 @@
'use client'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import type { AppData } from '@/models/share'
import type { TryAppInfo } from '@/service/try-app'
import { memo } from 'react'
import { FileUploadContext } from '@/app/components/base/file-uploader/upload-context'
import Chat from './chat'
@@ -8,12 +8,12 @@ import TextGeneration from './text-generation'
type Props = Readonly<{
appId: string
appDetail: TryAppInfo
appDetail: TrialAppDetailResponse
}>
function TryApp({ appId, appDetail }: Props) {
const mode = appDetail?.mode
const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!)
const isChat = ['chat', 'advanced-chat', 'agent-chat', 'agent'].includes(mode)
const isCompletion = !isChat
return (
+137 -61
View File
@@ -1,14 +1,16 @@
'use client'
import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useQuery } from '@tanstack/react-query'
import * as React from 'react'
import { Suspense, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
import { useGetTryAppInfo } from '@/service/use-try-app'
import { consoleQuery } from '@/service/console'
import AppInfo from './app-info'
import Preview from './preview'
import { TypeEnum } from './types'
@@ -20,6 +22,7 @@ type Props = Readonly<{
canTrial: RecommendedAppResponse['can_trial']
categories?: RecommendedAppResponse['categories']
templateName?: NonNullable<RecommendedAppResponse['app']>['name']
templateMode?: NonNullable<RecommendedAppResponse['app']>['mode']
canCreate?: boolean
createButtonStepByStepTourTarget?: string
onClose: () => void
@@ -31,20 +34,63 @@ function TryApp({
canTrial,
categories,
templateName,
templateMode,
canCreate = true,
createButtonStepByStepTourTarget,
onClose,
onCreate,
}: Props) {
const { t } = useTranslation(['common', 'explore'])
const { data: appDetail, isLoading, isFetching, refetch } = useGetTryAppInfo(appId)
const hasLoadError = !isLoading && !appDetail
const {
data: appDetail,
isLoading,
isFetching,
isFetchedAfterMount,
errorUpdateCount,
refetch,
} = useQuery(
consoleQuery.trialApps.byAppId.get.queryOptions({
input: { params: { app_id: appId } },
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}),
)
const isAgent = templateMode === 'agent' || appDetail?.mode === 'agent'
const composerQuery = useQuery(
consoleQuery.trialApps.byAppId.agentComposer.get.queryOptions({
input: { params: { app_id: appId } },
enabled: isAgent,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}),
)
const agentLoading = isAgent && composerQuery.isLoading && !composerQuery.isFetchedAfterMount
// On reopen, the cache can hold an old error; show loading during this observer's first fetch.
const hasLoadError =
(!appDetail && (!isLoading || isFetchedAfterMount)) ||
(isAgent &&
!composerQuery.data &&
(!composerQuery.isLoading || composerQuery.isFetchedAfterMount))
const hasAgentPreview = isAgent && !!appDetail && !!composerQuery.data && !hasLoadError
const isInitialLoading = !hasLoadError && ((isLoading && !isFetchedAfterMount) || agentLoading)
const keepErrorForExit =
hasLoadError || errorUpdateCount > 0 || composerQuery.errorUpdateCount > 0
const isRetrying = isFetching || composerQuery.isFetching
const retryLabelId = useId()
const detailTabRef = useRef<HTMLButtonElement>(null)
const handleRetry = async () => {
const { data } = await refetch()
if (data) detailTabRef.current?.focus()
const results = await Promise.all([
appDetail ? Promise.resolve(appDetail) : refetch().then((result) => result.data),
...(isAgent
? [
composerQuery.data
? Promise.resolve(composerQuery.data)
: composerQuery.refetch().then((result) => result.data),
]
: []),
])
if (results.every((result) => result !== undefined)) detailTabRef.current?.focus()
}
return (
@@ -53,16 +99,26 @@ function TryApp({
onOpenChange={(open) => {
if (!open) onClose()
}}
disablePointerDismissal
>
<DialogContent
initialFocus={detailTabRef}
className="h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-full max-w-[calc(100vw-16px)] min-w-7xl overflow-hidden overflow-x-auto border-none p-2 text-left align-middle"
className="h-[calc(100dvh-16px)] max-h-[calc(100dvh-16px)] w-full max-w-[calc(100vw-16px)] overflow-hidden border-none p-2 text-left align-middle"
>
<DialogTitle className="sr-only">
{templateName ?? appDetail?.name ?? t(($) => $['apps.title'], { ns: 'explore' })}
</DialogTitle>
<IconButton
size="lg"
variant="tertiary"
className="absolute top-2 right-2"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-5" />
</IconButton>
<Tabs defaultValue={TypeEnum.DETAIL} className="flex h-full flex-col">
<div className="flex shrink-0 justify-between pl-4">
<div className="flex shrink-0 pr-10 pl-4">
<TabsList>
<TabsTab
ref={detailTabRef}
@@ -76,7 +132,7 @@ function TryApp({
{canTrial && (
<TabsTab
value={TypeEnum.TRY}
disabled={!appDetail}
disabled={!appDetail || hasLoadError || agentLoading}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">
@@ -85,63 +141,82 @@ function TryApp({
</TabsTab>
)}
</TabsList>
<IconButton
size="lg"
variant="tertiary"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-5" />
</IconButton>
</div>
<div className="mt-2 flex h-0 grow justify-between space-x-2">
<TabsPanel value={TypeEnum.DETAIL} className="min-w-0 flex-1">
{isLoading ? (
<div className="mt-2 flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto lg:flex-row lg:overflow-hidden">
<TabsPanel
value={TypeEnum.DETAIL}
className={cn(
'@container/agent-preview min-w-0 shrink-0 lg:min-h-0 lg:flex-1',
hasAgentPreview ? 'h-auto' : 'h-[75dvh] lg:h-auto',
)}
>
{isInitialLoading ? (
<div className="flex h-full items-center justify-center">
<LoadingPlaceholder />
</div>
) : hasLoadError ? (
<div className="flex h-full flex-col items-center justify-center gap-5">
<div className="flex flex-col items-center gap-5" role="alert">
<span className="flex size-12 items-center justify-center rounded-xl bg-background-body">
<span
aria-hidden
className="i-ri-error-warning-line size-6 text-text-tertiary"
/>
</span>
<p className="title-xl-semi-bold text-text-primary">
{t(($) => $['tryApp.loadError'], { ns: 'explore' })}
</p>
</div>
<Button
variant="secondary-accent"
disabled={isFetching}
focusableWhenDisabled={isFetching}
aria-labelledby={retryLabelId}
onClick={handleRetry}
>
<span id={retryLabelId}>
{isFetching
? t(($) => $['tryApp.retrying'], { ns: 'explore' })
: t(($) => $['operation.retry'], { ns: 'common' })}
</span>
</Button>
</div>
) : appDetail ? (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<LoadingPlaceholder />
) : (
<div className={cn('relative size-full', hasAgentPreview && 'max-lg:h-auto')}>
{keepErrorForExit && (
<div
aria-hidden={!hasLoadError}
inert={!hasLoadError}
className={cn(
'absolute inset-0 flex flex-col items-center justify-center gap-5 transition-opacity duration-150 motion-reduce:transition-none',
hasLoadError ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
>
<div className="flex flex-col items-center gap-5" role="alert">
<span className="flex size-12 items-center justify-center rounded-xl bg-background-body">
<span
aria-hidden
className="i-ri-error-warning-line size-6 text-text-tertiary"
/>
</span>
<p className="title-xl-semi-bold text-text-primary">
{t(($) => $['tryApp.loadError'], { ns: 'explore' })}
</p>
</div>
<Button
variant="secondary-accent"
className="flex-row-reverse"
loading={isRetrying}
aria-labelledby={retryLabelId}
onClick={handleRetry}
>
<span id={retryLabelId}>
{isRetrying
? t(($) => $['tryApp.retrying'], { ns: 'explore' })
: t(($) => $['operation.retry'], { ns: 'common' })}
</span>
</Button>
</div>
}
>
<Preview appId={appId} appDetail={appDetail} />
</Suspense>
) : null}
)}
{appDetail && !hasLoadError && (
<div className={cn('size-full', hasAgentPreview && 'max-lg:h-auto')}>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<LoadingPlaceholder />
</div>
}
>
<Preview
appId={appId}
appDetail={appDetail}
agentComposer={composerQuery.data}
/>
</Suspense>
</div>
)}
</div>
)}
</TabsPanel>
{canTrial && (
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
{appDetail && (
<TabsPanel
value={TypeEnum.TRY}
className="h-[75dvh] min-w-0 shrink-0 lg:h-auto lg:min-h-0 lg:flex-1"
>
{appDetail && !hasLoadError && !agentLoading && (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
@@ -154,16 +229,17 @@ function TryApp({
)}
</TabsPanel>
)}
{appDetail && (
<Suspense fallback={<div className="w-90 shrink-0" />}>
{appDetail && !hasLoadError && !agentLoading && (
<Suspense fallback={<div className="w-full shrink-0 lg:w-90" />}>
<AppInfo
className="w-90 shrink-0"
className="h-auto w-full shrink-0 lg:h-full lg:w-90"
appDetail={appDetail}
appId={appId}
canCreate={canCreate}
categories={categories ?? []}
createButtonStepByStepTourTarget={createButtonStepByStepTourTarget}
onCreate={onCreate}
agentComposer={composerQuery.data}
/>
</Suspense>
)}
@@ -0,0 +1,197 @@
import type {
AgentAppComposerResponse,
TrialAppDetailResponse,
} from '@dify/contracts/api/console/trial-apps/types.gen'
import type { CurrentWorkspaceSummaryResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterAll, describe, expect, it, vi } from 'vite-plus/test'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { QueryClientTestProvider } from '@/test/console/query-provider'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
import AgentAppPreview from '../agent-app-preview'
const appDetail = {
id: 'template-id',
name: 'Tender Analyst',
description: 'Review tender documents',
mode: 'agent',
enable_api: false,
enable_site: false,
site: { title: 'Tender Analyst', default_language: 'en-US', icon_type: 'emoji', icon: '🤖' },
} satisfies TrialAppDetailResponse
const composer = {
variant: 'agent_app',
active_config_is_published: true,
agent: {
id: 'agent-id',
name: 'Tender Analyst',
description: 'Review tender documents',
scope: 'roster',
status: 'active',
},
agent_soul: {
model: { model: 'GPT-4o', model_provider: 'openai', plugin_id: 'langgenius/openai' },
prompt: { system_prompt: 'Review the tender files.' },
config_skills: [{ name: 'Tender Analyzer' }],
config_files: [{ name: 'README.md', file_kind: 'upload_file' }],
knowledge: { sets: [] },
tools: {
dify_tools: [
{
name: 'Web Search',
tool_name: 'web_search',
provider_id: 'langgenius/web_search/web_search',
provider_type: 'builtin',
},
],
},
app_features: {
opening_statement: 'How can I help?',
suggested_questions: ['Summarize the requirements'],
},
},
save_options: [],
} satisfies AgentAppComposerResponse
const builtInTools = [
{
id: 'langgenius/web_search/web_search',
name: 'langgenius/web_search/web_search',
label: { en_US: 'Web Search' },
icon: 'https://example.com/web-search.svg',
tools: [],
},
]
const workspaceSummary = {
id: 'workspace-id',
name: 'Test workspace',
plan: null,
credits: null,
role: 'normal',
} satisfies CurrentWorkspaceSummaryResponse
describe('AgentAppPreview', () => {
afterAll(() => vi.unstubAllGlobals())
it('shows a read-only Agent configuration alongside the template introduction', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
client.setQueryData(systemFeaturesQueryOptions().queryKey, createSystemFeaturesFixture())
const user = userEvent.setup()
vi.stubGlobal(
'fetch',
vi.fn(async (input: Request | string) => {
const url = input instanceof Request ? input.url : input
if (url.endsWith('/workspaces/current/tools/builtin')) return Response.json(builtInTools)
if (url.endsWith('/workspaces/current/summary')) return Response.json(workspaceSummary)
throw new Error(`Unexpected request: ${url}`)
}),
)
render(
<QueryClientTestProvider queryClient={client}>
<AgentAppPreview appDetail={appDetail} composer={composer} />
</QueryClientTestProvider>,
)
const agentUI = screen.getByRole('region', {
name: 'agentV2.agentDetail.configure.templatePreview.agentUI',
})
expect(within(agentUI).getByRole('heading', { name: 'Tender Analyst' })).toBeInTheDocument()
expect(within(agentUI).getByText('How can I help?')).toBeInTheDocument()
expect(within(agentUI).getByRole('listitem')).toHaveTextContent('Summarize the requirements')
expect(within(agentUI).queryByText('Review tender documents')).not.toBeInTheDocument()
expect(within(agentUI).queryByRole('textbox')).not.toBeInTheDocument()
expect(within(agentUI).queryByRole('button')).not.toBeInTheDocument()
const model = screen.getByRole('group', {
name: 'agentV2.agentDetail.configure.model.label',
})
expect(within(model).getByText('GPT-4o')).toBeInTheDocument()
expect(within(model).queryByRole('button')).not.toBeInTheDocument()
const prompt = screen.getByRole('textbox', {
name: 'agentV2.agentDetail.configure.prompt.label',
})
expect(prompt).toHaveAttribute('contenteditable', 'false')
expect(prompt).toHaveTextContent('Review the tender files.')
expect(screen.getByRole('button', { name: 'Tender Analyzer' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'README.md' })).toBeInTheDocument()
expect(screen.getByText('Web Search')).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.skills.add' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'agentV2.agentDetail.configure.files.add' }),
).not.toBeInTheDocument()
for (const field of ['prompt', 'skills', 'files', 'tools']) {
const label = `agentV2.agentDetail.configure.${field}.label`
const infoTrigger = screen.getByRole('button', { name: label, expanded: false })
await user.click(infoTrigger)
expect(await screen.findByRole('dialog', { name: label })).toBeInTheDocument()
await user.keyboard('{Escape}')
}
})
it('opens the existing resource dialogs through trial app endpoints', async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const user = userEvent.setup()
const requests: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: Request | string) => {
const url = input instanceof Request ? input.url : input
requests.push(url)
if (url.endsWith('/workspaces/current/tools/builtin')) return Response.json(builtInTools)
if (url.endsWith('/workspaces/current/summary')) return Response.json(workspaceSummary)
if (url.endsWith('/agent/config/skills/Tender%20Analyzer/inspect')) {
return Response.json({
id: 'skill-id',
name: 'Tender Analyzer',
source: 'config_skill_zip',
files: [],
skill_md: {
binary: false,
path: 'SKILL.md',
text: 'Review requirements carefully.',
truncated: false,
},
})
}
if (url.endsWith('/agent/config/files/README.md/preview')) {
return Response.json({
binary: false,
name: 'README.md',
text: 'Template readme',
truncated: false,
})
}
throw new Error(`Unexpected request: ${url}`)
}),
)
render(
<QueryClientProvider client={client}>
<AgentAppPreview appDetail={appDetail} composer={composer} />
</QueryClientProvider>,
)
await user.click(screen.getByRole('button', { name: 'Tender Analyzer' }))
await waitFor(() => {
expect(screen.getByText('Review requirements carefully.')).toBeInTheDocument()
})
expect(requests).toContain(
'http://localhost:5001/console/api/trial-apps/template-id/agent/config/skills/Tender%20Analyzer/inspect',
)
await user.keyboard('{Escape}')
await user.click(screen.getByRole('button', { name: 'README.md' }))
await waitFor(() => {
expect(screen.getByText('Template readme')).toBeInTheDocument()
})
expect(requests).toContain(
'http://localhost:5001/console/api/trial-apps/template-id/agent/config/files/README.md/preview',
)
})
})
@@ -1,4 +1,4 @@
import type { TryAppInfo } from '@/service/try-app'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vite-plus/test'
import Preview from '../index'
@@ -10,7 +10,7 @@ vi.mock('../flow-app-preview', () => ({
default: () => <section aria-label="Flow app preview" />,
}))
const createApp = (mode: string) => ({ mode }) as TryAppInfo
const createApp = (mode: string) => ({ mode }) as TrialAppDetailResponse
describe('Preview', () => {
it.each(['agent-chat', 'chat', 'completion'])('uses the basic preview for %s apps', (mode) => {
@@ -0,0 +1,112 @@
'use client'
import type {
AgentAppComposerResponse,
TrialAppDetailResponse,
} from '@dify/contracts/api/console/trial-apps/types.gen'
import { useId } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { AgentTemplateOrchestration } from '@/features/agent-v2/template-preview'
import { AgentTemplateGridBackground } from '@/features/agent-v2/template-preview/grid-background'
type Props = Readonly<{
appDetail: TrialAppDetailResponse
composer: AgentAppComposerResponse
}>
export default function AgentAppPreview({ appDetail, composer }: Props) {
const { t } = useTranslation(['agentV2'])
const previewHeadingId = useId()
const features = composer.agent_soul.app_features
return (
<div className="flex h-[75dvh] min-h-0 w-full flex-col gap-2 overflow-visible bg-background-default lg:h-full @max-[599px]/agent-preview:h-auto @min-[600px]/agent-preview:flex-row @min-[600px]/agent-preview:gap-0 @min-[600px]/agent-preview:overflow-hidden">
<AgentTemplateOrchestration
key={composer.active_config_snapshot?.id ?? composer.agent.id}
agentId={composer.agent.id}
appId={appDetail.id}
versionId={composer.active_config_snapshot?.id}
config={composer.agent_soul}
/>
<div className="relative min-w-0 flex-1 overflow-hidden rounded-md bg-background-body pt-1 pr-1 pb-1 @max-[599px]/agent-preview:h-[65dvh] @max-[599px]/agent-preview:flex-none">
<AgentTemplateGridBackground />
<section
aria-labelledby={previewHeadingId}
className="relative z-1 flex h-full min-h-0 flex-col px-4 pb-4"
>
<div className="flex h-12 shrink-0 items-center gap-2 py-3">
<h2 id={previewHeadingId} className="title-xl-semi-bold text-text-primary">
{t(($) => $['agentDetail.configure.templatePreview.agentUI'])}
</h2>
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['agentDetail.configure.rightPanel.preview'])}
</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 rounded-2xl bg-components-panel-bg p-6 shadow-xs">
<div className="min-h-0 flex-1 overflow-y-auto wrap-break-word">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex min-w-0 items-center gap-2">
<AppIcon
decorative
size="tiny"
rounded
iconType={appDetail.site.icon_type}
icon={appDetail.site.icon ?? undefined}
background={appDetail.site.icon_background ?? undefined}
imageUrl={appDetail.site.icon_url ?? undefined}
/>
<h3 className="min-w-0 system-xl-semibold text-text-primary">
{appDetail.name}
</h3>
</div>
{features?.opening_statement && (
<p className="body-sm-regular whitespace-pre-wrap text-text-secondary">
{features.opening_statement}
</p>
)}
</div>
{!!features?.suggested_questions?.length && (
<ul className="flex flex-col items-start gap-1">
{features.suggested_questions.map((question) => (
<li
key={question}
className="max-w-full rounded-xl bg-workflow-canvas-workflow-bg px-2.5 py-2 system-xs-regular text-text-secondary"
>
{question}
</li>
))}
</ul>
)}
</div>
</div>
<div
aria-hidden="true"
className="flex h-12 shrink-0 items-center rounded-xl border border-components-panel-border-subtle bg-components-panel-bg-alt p-2"
>
<span className="min-w-0 flex-1 truncate px-1 body-md-regular text-text-disabled">
{t(($) => $['agentDetail.configure.preview.inputPlaceholder'], {
name: appDetail.name,
})}
</span>
<span className="flex shrink-0 items-center gap-3 text-text-disabled">
<span className="flex items-center gap-1">
<span className="flex size-8 items-center justify-center">
<span className="i-ri-attachment-2 size-4.5" />
</span>
<span className="flex size-8 items-center justify-center">
<span className="i-ri-mic-line size-4.5" />
</span>
</span>
<span className="flex size-8 items-center justify-center rounded-lg bg-components-button-primary-bg-disabled text-components-button-primary-text-disabled">
<span className="i-ri-send-plane-2-fill size-4" />
</span>
</span>
</div>
</div>
</section>
</div>
</div>
)
}
@@ -1,9 +1,9 @@
'use client'
import type { TrialAppDetailResponse } from '@dify/contracts/api/console/trial-apps/types.gen'
import type { FC } from 'react'
import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
import type { TryAppInfo } from '@/service/try-app'
import type { PromptVariable } from '@/types/app'
import { noop } from 'es-toolkit/function'
import { clone } from 'es-toolkit/object'
@@ -27,7 +27,7 @@ import ConfigContext from '@/context/debug-configuration'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { PromptMode } from '@/models/debug'
import { useAllToolProviders } from '@/service/use-tools'
import { useGetTryAppDataSets, useGetTryAppInfo } from '@/service/use-try-app'
import { useGetTryAppDataSets } from '@/service/use-try-app'
import { AgentStrategy, ModelModeType, Resolution, TransferMethod, TtsAutoPlay } from '@/types/app'
import { correctModelProvider, correctToolProvider } from '@/utils'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
@@ -37,6 +37,7 @@ import { useTextGenerationCurrentProviderAndModelAndModelList } from '../../../h
type Props = {
readonly appId: string
readonly appDetail: TrialAppDetailResponse
}
type AgentToolItem = Extract<ModelConfig['agentConfig']['tools'][number], { tool_name: string }>
@@ -293,7 +294,7 @@ const normalizeExternalDataToolFormItem = (item: Record<string, unknown>) => {
const normalizeAgentTool = (
tool: Record<string, unknown>,
deletedTools: TryAppInfo['deleted_tools'] | undefined,
deletedTools: TrialAppDetailResponse['deleted_tools'] | undefined,
collectionList: ReturnType<typeof useAllToolProviders>['data'] | undefined,
): AgentToolItem => {
const providerId = getString(tool.provider_id)
@@ -325,11 +326,10 @@ const normalizeAgentTool = (
} as AgentToolItem
}
const BasicAppPreview: FC<Props> = ({ appId }) => {
const BasicAppPreview: FC<Props> = ({ appId, appDetail }) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { data: appDetail, isLoading: isLoadingAppDetail } = useGetTryAppInfo(appId)
const { data: collectionListFromServer, isLoading: isLoadingToolProviders } =
useAllToolProviders()
const collectionList = collectionListFromServer?.map((item) => {
@@ -342,8 +342,7 @@ const BasicAppPreview: FC<Props> = ({ appId }) => {
}
})
const datasetIds = (() => {
if (isLoadingAppDetail) return []
const modelConfig = appDetail?.model_config
const modelConfig = appDetail.model_config
if (!modelConfig) return []
const agentDatasetTools = getAgentTools(modelConfig.agent_mode).filter(isEnabledDatasetTool)
@@ -360,9 +359,9 @@ const BasicAppPreview: FC<Props> = ({ appId }) => {
datasetIds,
)
const dataSets = dataSetData?.data || []
const isLoading = isLoadingAppDetail || isLoadingDatasets || isLoadingToolProviders
const isLoading = isLoadingDatasets || isLoadingToolProviders
const modelConfig: ModelConfig = ((modelConfig?: TryAppInfo['model_config']) => {
const modelConfig: ModelConfig = ((modelConfig?: TrialAppDetailResponse['model_config']) => {
if (isLoading || !modelConfig?.model) return defaultModelConfig
const model = modelConfig.model
@@ -1,22 +1,31 @@
'use client'
import type {
AgentAppComposerResponse,
TrialAppDetailResponse,
} from '@dify/contracts/api/console/trial-apps/types.gen'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import BasicAppPreview from './basic-app-preview'
import FlowAppPreview from './flow-app-preview'
const AgentAppPreview = React.lazy(() => import('./agent-app-preview'))
type Props = {
readonly appId: string
readonly appDetail: TryAppInfo
readonly appDetail: TrialAppDetailResponse
readonly agentComposer?: AgentAppComposerResponse
}
const Preview: FC<Props> = ({ appId, appDetail }) => {
const Preview: FC<Props> = ({ appId, appDetail, agentComposer }) => {
const isBasicApp = ['agent-chat', 'chat', 'completion'].includes(appDetail.mode)
return (
<div className="size-full">
{isBasicApp ? (
<BasicAppPreview appId={appId} />
<div className={cn('size-full', appDetail.mode === 'agent' && 'max-lg:h-auto')}>
{appDetail.mode === 'agent' && agentComposer ? (
<AgentAppPreview appDetail={appDetail} composer={agentComposer} />
) : isBasicApp ? (
<BasicAppPreview appId={appId} appDetail={appDetail} />
) : (
<FlowAppPreview appId={appId} className="h-full" />
)}
-13
View File
@@ -1,13 +0,0 @@
import type { RecommendedAppResponse } from '@dify/contracts/api/console/explore/types.gen'
import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector'
type Props = Readonly<{
openTryAppPanel: (app: RecommendedAppResponse) => void
}>
const AppListContext = createContext<Props>({
openTryAppPanel: noop,
})
export default AppListContext
@@ -26,7 +26,7 @@ import type {
import type { ModelConfig } from '@/app/components/workflow/types'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { DATASET_DEFAULT } from '@/config'
import { getFileIconType } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/files/file-icon'
import { getFileIconType } from '@/features/agent-v2/file-icon'
import { RETRIEVE_TYPE } from '@/types/app'
import { checkKey } from '@/utils/var'
import { defaultAgentSoulConfigFormState } from './form-state'
@@ -3,7 +3,7 @@ import type { AgentTool } from '@/features/agent-v2/agent-composer/form-state'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { API_PREFIX } from '@/config'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
@@ -12,7 +12,7 @@ import { agentComposerToolsAtom } from '@/features/agent-v2/agent-composer/store
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithState } from '@/test/console/render'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
import { AgentPromptEditor } from '../orchestrate/prompt-editor'
import { AgentPromptEditor, AgentTemplatePromptEditor } from '../orchestrate/prompt-editor'
import { AgentPromptSlashMenu } from '../orchestrate/prompt-editor/slash'
const render = (ui: React.ReactElement) => {
@@ -1270,3 +1270,59 @@ describe('AgentPromptEditor', () => {
})
})
})
describe('AgentTemplatePromptEditor', () => {
it('shows provider and marketplace icons for configured tool references', () => {
const store = createStore()
seedRegisteredConsoleStateFixture(store)
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt:
'Use [§tool:duckduckgo/ddg_search:DuckDuckGo Search§] and [§tool:langgenius/google/google/google_search:Google Search§]',
tools: [
duckDuckGoProviderTool,
{
...duckDuckGoProviderTool,
id: 'langgenius/google/google',
name: 'google',
iconClassName: 'i-custom-public-other-default-tool-icon',
providerType: 'builtin',
actions: [{ ...duckDuckGoSearchAction, toolName: 'google_search' }],
},
],
})
render(
<JotaiProvider store={store}>
<AgentTemplatePromptEditor />
</JotaiProvider>,
)
const promptEditorProps = mockPromptEditor.mock.calls.at(-1)?.[0] as PromptEditorProps
const renderIcon = promptEditorProps.rosterReferenceBlock?.renderIcon
expect(promptEditorProps.editable).toBe(false)
const { container } = render(
<>
{renderIcon?.({ kind: 'tool', id: 'duckduckgo/ddg_search', label: 'DuckDuckGo Search' })}
{renderIcon?.({
kind: 'tool',
id: 'langgenius/google/google/google_search',
label: 'Google Search',
})}
</>,
)
const iconBackgrounds = Array.from(container.querySelectorAll<HTMLElement>('[style]')).map(
(element) => element.style.backgroundImage,
)
expect(iconBackgrounds).toEqual(
expect.arrayContaining([
expect.stringContaining(
`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-123&filename=duckduckgo.svg`,
),
expect.stringContaining(`${MARKETPLACE_API_PREFIX}/plugins/langgenius/google/icon`),
]),
)
})
})
@@ -124,7 +124,7 @@ function AgentContentModerationSettingsContent() {
headingLevel="h4"
panelId={panelId}
rootClassName="gap-1 border-t border-divider-subtle py-3"
headerClassName="mb-0 gap-1 px-3"
headerClassName="gap-1 px-3"
panelContentClassName="px-3 pt-1"
actions={
!readOnly ? (
@@ -544,8 +544,7 @@ export function AgentEnvEditor() {
headingLevel="h4"
panelId={envEditorTableId}
tip={<AgentConfigureTipContent type="env" />}
rootClassName="gap-1 py-3"
headerClassName="mb-0 gap-1 px-3"
headerClassName="gap-1 px-3"
panelContentClassName="px-3"
actions={
!readOnly ? (
@@ -19,8 +19,7 @@ export function AgentAdvancedSettings() {
defaultOpen={false}
buildDraftChangeSection="advancedSettings"
rootClassName="gap-2 pt-1 pb-3"
headerClassName="mb-0 pt-2"
titleRowClassName="min-h-6"
headerClassName="pt-2"
panelContentClassName="flex flex-col rounded-lg bg-background-section"
>
<AgentEnvEditor />
@@ -21,7 +21,6 @@ type ConfigureSectionProps = {
panelId?: string
rootClassName?: string
headerClassName?: string
titleRowClassName?: string
panelContentClassName?: string
}
@@ -38,7 +37,6 @@ export function ConfigureSection({
tip,
rootClassName,
headerClassName,
titleRowClassName,
panelContentClassName,
}: ConfigureSectionProps) {
const Heading = headingLevel
@@ -50,12 +48,12 @@ export function ConfigureSection({
<Collapsible
render={<section />}
defaultOpen={defaultOpen}
className={rootClassName}
className={rootClassName ?? 'gap-1 py-3'}
aria-labelledby={labelId}
>
<div className={cn('mb-2 flex min-h-6 items-center gap-2', headerClassName)}>
<div className={cn('flex min-h-6 items-center gap-2', headerClassName)}>
<div className="min-w-0 flex-1">
<div className={cn('group/collapse-title flex min-w-0 items-center', titleRowClassName)}>
<div className="group/collapse-title flex min-w-0 items-center">
<Heading className="relative min-w-0 shrink-0">
{isBuildDraftChanged && <AgentBuildDraftChangeDot />}
<CollapsibleTrigger className="flex h-6 min-h-0 max-w-full touch-manipulation items-center justify-start gap-0 rounded-sm system-sm-medium text-text-secondary outline-hidden select-none focus-visible:ring-2 focus-visible:ring-state-accent-solid">
@@ -9,6 +9,7 @@ import { consoleQuery } from '@/service/console'
export type AgentConfigApiContext = {
agentId: string
trialAppId?: string
draftType?: 'draft' | 'debug_build'
versionId?: string
workflow?: {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vite-plus/test'
import { getDriveFileIconType, getFileIconType } from '../file-icon'
import { getDriveFileIconType, getFileIconType } from '@/features/agent-v2/file-icon'
describe('agent file icon helpers', () => {
it('should infer supported icons for uploaded drive file pointer kinds', () => {
@@ -17,7 +17,7 @@ import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from '../../read-only-context'
import { AgentFiles } from '../index'
import { AgentFiles, AgentTemplateFiles } from '../index'
type ConfigFileQueryOptionsInput = {
input: {
@@ -139,6 +139,20 @@ vi.mock('@/service/console', () => ({
},
},
},
trialApps: {
byAppId: {
agent: {
config: {
files: {
byName: {
preview: { get: { queryOptions: mocks.previewQueryOptions } },
download: { get: { queryOptions: mocks.downloadQueryOptions } },
},
},
},
},
},
},
files: {
upload: {
post: {
@@ -185,11 +199,13 @@ function renderAgentFiles({
initialDraft = createInitialDraft(),
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
readOnly = false,
template = false,
viewingVersion = false,
}: {
initialDraft?: AgentSoulConfigFormState
apiContext?: AgentConfigApiContext
readOnly?: boolean
template?: boolean
viewingVersion?: boolean
} = {}) {
const queryClient = new QueryClient({
@@ -210,7 +226,7 @@ function renderAgentFiles({
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentFiles />
{template ? <AgentTemplateFiles /> : <AgentFiles />}
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
@@ -743,6 +759,11 @@ describe('AgentFiles', () => {
expect(screen.getByText('diagram.png')).toBeInTheDocument()
expect(screen.getByText('brief.md')).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.files\.download.*diagram\.png/,
}),
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
).not.toBeInTheDocument()
@@ -755,4 +776,53 @@ describe('AgentFiles', () => {
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
).not.toBeInTheDocument()
})
it('should preview template files without offering download actions', async () => {
const user = userEvent.setup()
const { queryClient } = renderAgentFiles({
apiContext: { agentId: 'agent-1', trialAppId: 'trial-1' },
readOnly: true,
template: true,
})
expect(
screen.queryByRole('button', { name: /agentDetail\.configure\.files\.download/ }),
).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'diagram.png' }))
const imageDialog = await screen.findByRole('dialog')
expect(
within(imageDialog).queryByRole('button', { name: /common\.operation\.download/ }),
).not.toBeInTheDocument()
expect(await within(imageDialog).findByRole('img', { name: 'diagram.png' })).toHaveAttribute(
'src',
'https://example.com/diagram.png',
)
expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({ params: { app_id: 'trial-1', name: 'diagram.png' } }),
}),
)
await user.click(within(imageDialog).getByRole('button', { name: 'common.operation.close' }))
queryClient.setQueryData(
[
'preview-config-file',
{ params: { app_id: 'trial-1', name: 'brief.md' }, query: { version_id: undefined } },
],
{ name: 'brief.md', binary: true, truncated: false, text: null },
)
await user.click(screen.getByRole('button', { name: 'brief.md' }))
const binaryDialog = await screen.findByRole('dialog')
expect(
await within(binaryDialog).findByText(/agentDetail\.configure\.files\.preview\.unsupported/),
).toBeInTheDocument()
expect(
within(binaryDialog).queryByRole('link', { name: 'common.operation.download' }),
).not.toBeInTheDocument()
expect(
within(binaryDialog).queryByRole('button', { name: /common\.operation\.download/ }),
).not.toBeInTheDocument()
expect(mocks.downloadUrl).not.toHaveBeenCalled()
})
})
@@ -13,10 +13,10 @@ import {
FileTreeLabel,
} from '@langgenius/dify-ui/file-tree'
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useAtomValue, useSetAtom } from 'jotai'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useId, useRef, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
@@ -72,6 +72,7 @@ function AgentFileItem({
file,
files,
apiContext,
canDownload,
onRemove,
selected,
}: {
@@ -80,6 +81,7 @@ function AgentFileItem({
file: AgentFileNode
files: AgentFileNode[]
apiContext: AgentConfigApiContext
canDownload: boolean
onRemove: (fileId: string) => void
selected: boolean
}) {
@@ -108,7 +110,12 @@ function AgentFileItem({
},
},
}),
enabled: isPreviewOpen && !!previewFileId && !isVirtualPreviewFile && !apiContext.workflow,
enabled:
isPreviewOpen &&
!!previewFileId &&
!isVirtualPreviewFile &&
!apiContext.workflow &&
!apiContext.trialAppId,
})
const workflowPreviewQuery = useQuery({
...consoleQuery.apps.byAppId.agent.config.files.byName.preview.get.queryOptions({
@@ -126,7 +133,24 @@ function AgentFileItem({
}),
enabled: isPreviewOpen && !!previewFileId && !isVirtualPreviewFile && !!apiContext.workflow,
})
const previewQuery = apiContext.workflow ? workflowPreviewQuery : agentPreviewQuery
const trialPreviewQuery = useQuery(
apiContext.trialAppId
? {
...consoleQuery.trialApps.byAppId.agent.config.files.byName.preview.get.queryOptions({
input: {
params: { app_id: apiContext.trialAppId, name: previewFileId ?? '' },
query: { version_id: apiContext.versionId },
},
}),
enabled: isPreviewOpen && !!previewFileId && !isVirtualPreviewFile,
}
: { queryKey: ['agent-v2', 'trial-file-preview-disabled'], queryFn: skipToken },
)
const previewQuery = apiContext.trialAppId
? trialPreviewQuery
: apiContext.workflow
? workflowPreviewQuery
: agentPreviewQuery
const isImagePreviewFile = selectedPreviewFile.icon === 'image'
const shouldDownloadPreviewFile =
isPreviewOpen &&
@@ -147,7 +171,7 @@ function AgentFileItem({
},
}),
staleTime: 0,
enabled: shouldDownloadPreviewFile && !apiContext.workflow,
enabled: shouldDownloadPreviewFile && !apiContext.workflow && !apiContext.trialAppId,
})
const workflowDownloadQuery = useQuery({
...consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
@@ -166,7 +190,25 @@ function AgentFileItem({
staleTime: 0,
enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
})
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
const trialDownloadQuery = useQuery(
apiContext.trialAppId
? {
...consoleQuery.trialApps.byAppId.agent.config.files.byName.download.get.queryOptions({
input: {
params: { app_id: apiContext.trialAppId, name: previewFileId ?? '' },
query: { version_id: apiContext.versionId },
},
}),
staleTime: 0,
enabled: shouldDownloadPreviewFile,
}
: { queryKey: ['agent-v2', 'trial-file-download-disabled'], queryFn: skipToken },
)
const downloadQuery = apiContext.trialAppId
? trialDownloadQuery
: apiContext.workflow
? workflowDownloadQuery
: agentDownloadQuery
const handleRemove = useCallback(() => {
onRemove(file.id)
}, [file.id, onRemove])
@@ -181,6 +223,19 @@ function AgentFileItem({
}
const fileName = getAgentFilePreviewKey(targetFile)
if (apiContext.trialAppId) {
const result = await queryClient.query({
...consoleQuery.trialApps.byAppId.agent.config.files.byName.download.get.queryOptions({
input: {
params: { app_id: apiContext.trialAppId, name: fileName },
query: { version_id: apiContext.versionId },
},
}),
staleTime: 0,
})
downloadUrl({ url: result.url, fileName: targetFile.name })
return
}
if (apiContext.workflow) {
const result = await queryClient.query({
...consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
@@ -227,6 +282,7 @@ function AgentFileItem({
},
[downloadFile],
)
const onDownloadFile = canDownload ? downloadFileAction : undefined
const handleDownload = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
if (file.isMissing) return
@@ -287,7 +343,7 @@ function AgentFileItem({
isImage: isImagePreviewFile,
isLoading: !isVirtualPreviewFile && previewQuery.isPending,
},
onDownloadFile: () => downloadFileAction(selectedPreviewFile),
onDownloadFile: onDownloadFile ? () => onDownloadFile(selectedPreviewFile) : undefined,
onSelectFile: (selectedFile) => setSelectedFileId(selectedFile.id),
selectedFileId: selectedFileId ?? file.id,
sections: [],
@@ -306,7 +362,7 @@ function AgentFileItem({
file.isMissing ? 'right-7' : 'right-1',
)}
>
{!file.isMissing && (
{!file.isMissing && onDownloadFile && (
<button
type="button"
aria-label={t(($) => $['agentDetail.configure.files.download'], { name: file.name })}
@@ -363,12 +419,8 @@ function AgentBuildNoteInfotip() {
return (
<Infotip>
<InfotipTrigger
aria-label={BUILD_NOTE_FILE_NAME}
className="size-5 hover:text-text-quaternary"
iconSize="large"
/>
<InfotipContent aria-label={BUILD_NOTE_FILE_NAME} className="w-57.5">
<InfotipTrigger aria-label={BUILD_NOTE_FILE_NAME} iconSize="large" />
<InfotipContent aria-label={BUILD_NOTE_FILE_NAME}>
<Trans
i18nKey={($) => $['agentDetail.configure.files.buildNote.richTooltip']}
ns="agentV2"
@@ -484,8 +536,6 @@ export function AgentFiles() {
labelId="agent-configure-files-label"
buildDraftChangeSection="files"
tip={<AgentConfigureTipContent type="files" />}
rootClassName="border-b border-divider-subtle pt-4"
panelContentClassName="pb-4"
actions={
!readOnly ? (
<ConfigureSectionAddButton
@@ -516,6 +566,7 @@ export function AgentFiles() {
file={file}
files={previewFiles}
apiContext={apiContext}
canDownload
selected={selected}
onRemove={removeFile}
>
@@ -535,3 +586,49 @@ export function AgentFiles() {
</>
)
}
export function AgentTemplateFiles() {
const { t } = useTranslation(['agentV2'])
const labelId = useId()
const apiContext = useAgentConfigApiContext()
const draft = useAtomValue(agentComposerDraftAtom)
const files = useAtomValue(agentComposerFilesAtom)
const buildNote = getBuildNoteFile(draft.configNote)
const visibleFiles = buildNote ? [buildNote, ...files] : files
const previewFiles = visibleFiles.filter((file) => !file.isMissing)
return (
<ConfigureSection
label={t(($) => $['agentDetail.configure.files.label'])}
labelId={labelId}
tip={<AgentConfigureTipContent type="files" />}
>
{visibleFiles.length === 0 ? (
<ConfigureSectionEmpty
title={t(($) => $['agentDetail.configure.files.empty.title'])}
description={t(($) => $['agentDetail.configure.files.empty.description'])}
/>
) : (
<AgentFileTree
files={visibleFiles}
treeLabelledBy={labelId}
className="rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1 shadow-xs shadow-shadow-shadow-3"
scrollAreaClassName="max-h-[250px] flex-none"
renderFile={({ depth, file, selected, children }) => (
<AgentFileItem
depth={depth}
file={file}
files={previewFiles}
apiContext={apiContext}
canDownload={false}
selected={selected}
onRemove={noop}
>
{file.id === BUILD_NOTE_FILE_ID ? <AgentBuildNoteFileRow /> : children}
</AgentFileItem>
)}
/>
)}
</ConfigureSection>
)
}
@@ -26,10 +26,10 @@ import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { getSupportFileType } from '@/app/components/base/file-uploader/utils'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { toast } from '@/app/notifications'
import { getFileIconType } from '@/features/agent-v2/file-icon'
import { consoleQuery } from '@/service/console'
import { useFileUploadConfig } from '@/service/use-common'
import { formatFileSize } from '@/utils/format'
import { getFileIconType } from './file-icon'
function toAgentFileNode(committedFile: AgentConfigFileItemResponse): AgentFileNode {
return {
@@ -24,18 +24,18 @@ import { AgentAdvancedSettings } from './advanced'
import { AgentOrchestrateBottomActions } from './bottom-actions'
import { AgentBuildDraftChangedKeysProvider } from './build-draft-changes-context'
import { AgentConfigApiContextProvider } from './config-context'
import { AgentFiles } from './files'
import { AgentFiles, AgentTemplateFiles } from './files'
import { AgentOrchestrateHeader } from './header'
import { AgentKnowledgeRetrieval } from './knowledge'
import { AgentModelField } from './model-config/field'
import { AgentPromptEditor } from './prompt-editor'
import { AgentPromptEditor, AgentTemplatePromptEditor } from './prompt-editor'
import { AgentConfigurePublishBar } from './publish-bar'
import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from './read-only-context'
import { AgentSkills } from './skills'
import { AgentTools } from './tools'
import { AgentSkills, AgentTemplateSkills } from './skills'
import { AgentTemplateTools, AgentTools } from './tools'
const EMPTY_BUILD_DRAFT_CHANGED_KEYS: readonly AgentBuildDraftChangedKey[] = []
@@ -49,6 +49,8 @@ type AgentOrchestratePanelProps = {
isPublishing?: boolean
className?: string
readOnly?: boolean
trialAppId?: string
trialVersionId?: string
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
isBuildDraftActive?: boolean
buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[]
@@ -73,6 +75,8 @@ export function AgentOrchestratePanel({
isPublishing,
className,
readOnly = false,
trialAppId,
trialVersionId,
selectedVersionSnapshot,
isBuildDraftActive = false,
buildDraftChangedKeys = [],
@@ -87,6 +91,7 @@ export function AgentOrchestratePanel({
onVersionRestored,
}: AgentOrchestratePanelProps) {
const { t } = useTranslation(['agentV2'])
const isTemplatePreview = !!trialAppId
const orchestrateHeadingId = 'agent-configure-orchestrate-heading'
const orchestrateLabel = t(($) => $['agentDetail.configure.title'])
const orchestrateBottomAction =
@@ -118,10 +123,11 @@ export function AgentOrchestratePanel({
}
: {
agentId,
trialAppId,
draftType,
versionId: selectedVersionSnapshot?.id ?? undefined,
versionId: selectedVersionSnapshot?.id ?? trialVersionId,
},
[agentId, appId, draftType, nodeId, selectedVersionSnapshot?.id],
[agentId, appId, draftType, nodeId, selectedVersionSnapshot?.id, trialAppId, trialVersionId],
)
return (
@@ -146,7 +152,7 @@ export function AgentOrchestratePanel({
)}
>
<AgentOrchestrateViewingVersionContext value={!!selectedVersionSnapshot}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentOrchestrateReadOnlyContext value={readOnly || isTemplatePreview}>
<ScrollAreaViewport
aria-label={showHeader ? undefined : orchestrateLabel}
aria-labelledby={showHeader ? orchestrateHeadingId : undefined}
@@ -154,7 +160,7 @@ export function AgentOrchestratePanel({
role="region"
>
<ScrollAreaContent className="flex min-h-full flex-col" style={{ minWidth: 0 }}>
<div className="flex-1 px-4 py-3">
<div className="flex-1 px-4">
<AgentConfigApiContextProvider value={configApiContext}>
<AgentOrchestrateAddActionsProvider>
<AgentBuildDraftChangedKeysProvider
@@ -165,12 +171,23 @@ export function AgentOrchestratePanel({
}
>
<AgentModelField currentModel={currentModel} onSelect={onSelectModel} />
<AgentPromptEditor />
<AgentSkills />
<AgentFiles />
<AgentTools />
{isTemplatePreview ? (
<>
<AgentTemplatePromptEditor />
<AgentTemplateSkills />
<AgentTemplateFiles />
<AgentTemplateTools />
</>
) : (
<>
<AgentPromptEditor />
<AgentSkills />
<AgentFiles />
<AgentTools />
</>
)}
{ENABLE_AGENT_KNOWLEDGE_RETRIEVAL && <AgentKnowledgeRetrieval />}
<AgentAdvancedSettings />
{!isTemplatePreview && <AgentAdvancedSettings />}
</AgentBuildDraftChangedKeysProvider>
</AgentOrchestrateAddActionsProvider>
</AgentConfigApiContextProvider>
@@ -105,8 +105,7 @@ export function AgentKnowledgeRetrieval() {
labelId="agent-configure-knowledge-retrieval-label"
panelId={retrievalListId}
tip={<AgentConfigureTipContent type="knowledge" />}
rootClassName="border-b border-divider-subtle pt-4"
panelContentClassName="flex flex-col gap-1 pb-4"
panelContentClassName="flex flex-col gap-1"
actions={
<ConfigureSectionAddButton
ariaLabel={t(($) => $['agentDetail.configure.knowledgeRetrieval.add'])}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more