mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
173 files changed
+6996
-808
No files matched your search
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from fields.base import ResponseModel
|
||||
|
||||
|
||||
class AppExportResponse(ResponseModel):
|
||||
data: str
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",))
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
+58
-2
@@ -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`),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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 ? (
|
||||
|
||||
+1
-2
@@ -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 ? (
|
||||
|
||||
+1
-2
@@ -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 />
|
||||
|
||||
+3
-5
@@ -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
-1
@@ -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', () => {
|
||||
|
||||
+72
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
+113
-16
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-2
@@ -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
Reference in new issue
Block a user