Compare commits

..

1 Commits

Author SHA1 Message Date
7d2f25df8e feat(agent): add roster service api access (#37759)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-22 12:37:27 +00:00
26 changed files with 1606 additions and 886 deletions

View File

@ -36,7 +36,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
- For Jotai-backed surfaces, put shared query atoms, mutation atoms, derived state, and write actions in the feature state file when they coordinate multiple descendants. The lowest-owner rule still applies to independent visual surfaces that do not participate in shared state.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
@ -46,10 +45,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name; avoid pass-through aliases or bundling unrelated UI facts. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own synchronous state transitions that update multiple primitives, reset dependent state, or advance the workflow. Async work with loading, error, caching, retry, or stale-result concerns should be modeled as query or mutation atoms, with write atoms only changing the inputs that drive them.
- Avoid feature hooks that aggregate form values, query results, derived state, and commands for sibling components. Prefer named derived atoms and write atoms so UI components read the exact shared fact or command they need.
- When a form library owns validation, keep submit orchestration in feature state when post-submit result or error state is shared by the surface. Avoid duplicating validation gates or request shaping in UI hooks.
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
@ -111,7 +108,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, children-as-pass-through composition, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook, forwards props, or passes trigger/content through to one child, move the logic into that child or make the wrapper own a real surface.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface.
## You Might Not Need An Effect

View File

@ -3,10 +3,12 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, select
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
)
@ -25,9 +27,13 @@ from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
enterprise_license_required,
is_admin_or_owner_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@ -49,7 +55,8 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.model import IconType
from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
@ -103,6 +110,27 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload):
return role
class AgentApiStatusPayload(BaseModel):
enable_api: bool = Field(..., description="Enable or disable Agent service API")
class AgentApiAccessResponse(BaseModel):
enabled: bool
service_api_base_url: str
streaming_only: bool = True
chat_endpoint: str
stop_endpoint: str
conversations_endpoint: str
messages_endpoint: str
files_upload_endpoint: str
parameters_endpoint: str
info_endpoint: str
meta_endpoint: str
api_rpm: int
api_rph: int
api_key_count: int
class AgentAppPublishedReferenceResponse(BaseModel):
app_id: str
app_name: str
@ -210,6 +238,7 @@ register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
AgentApiStatusPayload,
CopyAppPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
@ -221,6 +250,7 @@ register_schema_models(
register_response_schema_models(
console_ns,
AgentAppPagination,
AgentApiAccessResponse,
AgentAppPublishedReferenceResponse,
AgentAppDetailWithSite,
AgentAppPartial,
@ -329,6 +359,38 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def _agent_api_key_count(app_id: str) -> int:
return (
db.session.scalar(
select(func.count(ApiToken.id)).where(
ApiToken.type == ApiTokenType.APP,
ApiToken.app_id == app_id,
)
)
or 0
)
def _serialize_agent_api_access(app_model: App) -> dict:
base_url = app_model.api_base_url
response = AgentApiAccessResponse(
enabled=bool(app_model.enable_api),
service_api_base_url=base_url,
chat_endpoint=f"{base_url}/chat-messages",
stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop",
conversations_endpoint=f"{base_url}/conversations",
messages_endpoint=f"{base_url}/messages",
files_upload_endpoint=f"{base_url}/files/upload",
parameters_endpoint=f"{base_url}/parameters",
info_endpoint=f"{base_url}/info",
meta_endpoint=f"{base_url}/meta",
api_rpm=app_model.api_rpm or 0,
api_rph=app_model.api_rph or 0,
api_key_count=_agent_api_key_count(str(app_model.id)),
)
return response.model_dump(mode="json")
def _agent_observability_service() -> AgentObservabilityService:
return AgentObservabilityService(db.session)
@ -485,6 +547,75 @@ class AgentAppCopyApi(Resource):
return _serialize_agent_app_detail(copied_app), 201
@console_ns.route("/agent/<uuid:agent_id>/api-access")
class AgentApiAccessApi(Resource):
@console_ns.response(200, "Agent service API access", console_ns.models[AgentApiAccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_api_access(app_model)
@console_ns.route("/agent/<uuid:agent_id>/api-enable")
class AgentApiStatusApi(Resource):
@console_ns.expect(console_ns.models[AgentApiStatusPayload.__name__])
@console_ns.response(200, "Agent service API status updated", console_ns.models[AgentApiAccessResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentApiStatusPayload.model_validate(console_ns.payload)
app_model = AppService().update_app_api_status(app_model, args.enable_api)
return _serialize_agent_api_access(app_model)
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
class AgentApiKeyListApi(BaseApiKeyListResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id))
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def post(self, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyItem, self._create_api_key(str(app_model.id), tenant_id)), 201
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
class AgentApiKeyApi(BaseApiKeyResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
@console_ns.response(204, "Agent service API key deleted")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, api_key_id: UUID) -> tuple[str, int]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user)
return "", 204
@console_ns.route("/agent/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))

View File

@ -2,6 +2,7 @@ from typing import Any, cast
from flask_restx import Resource
from pydantic import Field
from sqlalchemy import select
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
@ -9,7 +10,11 @@ from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from extensions.ext_database import db
from fields.base import ResponseModel
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.app_service import AppService
@ -29,6 +34,40 @@ class AppMetaResponse(ResponseModel):
register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse)
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or not agent.active_config_snapshot_id:
raise AppUnavailableError()
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AppUnavailableError()
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):
"""Resource for app variables."""
@ -61,12 +100,16 @@ class AppParameterApi(Resource):
Returns the input form parameters and configuration for the application.
"""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
features_dict: dict[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode == AppMode.AGENT:
features_dict, user_input_form = _get_agent_app_feature_dict_and_user_input_form(app_model)
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config

View File

@ -21,6 +21,7 @@ from core.app.app_config.entities import (
EasyUIBasedAppModelConfigFrom,
PromptTemplateEntity,
)
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
@ -98,8 +99,7 @@ class AgentAppConfigManager(BaseAppConfigManager):
# pipeline's bookkeeping (token counting, persistence).
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
# Agent App takes the user message directly; no completion-style inputs form.
base.setdefault("user_input_form", [])
base["user_input_form"] = agent_app_variables_to_user_input_form(agent_soul.app_variables)
return base

View File

@ -0,0 +1,37 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from models.agent_config_entities import AppVariableConfig
def agent_app_variables_to_user_input_form(app_variables: Sequence[AppVariableConfig]) -> list[dict[str, Any]]:
"""Project Agent Soul app variables into the legacy service-API parameter form."""
user_input_form: list[dict[str, Any]] = []
for variable in app_variables:
form_type = _form_type_for_agent_variable(variable.type)
form_item: dict[str, Any] = {
"label": variable.name,
"variable": variable.name,
"required": variable.required,
}
if variable.default is not None:
form_item["default"] = variable.default
user_input_form.append({form_type: form_item})
return user_input_form
def _form_type_for_agent_variable(variable_type: str) -> str:
normalized = variable_type.strip().lower()
if normalized in {"number", "integer", "float"}:
return "number"
if normalized in {"boolean", "bool"}:
return "checkbox"
if normalized in {"paragraph", "long_text", "multiline"}:
return "paragraph"
return "text-input"
__all__ = ["agent_app_variables_to_user_input_form"]

View File

@ -391,6 +391,80 @@ Check if activation token is valid
| 400 | Invalid request parameters | |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/api-access
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent service API access | **application/json**: [AgentApiAccessResponse](#agentapiaccessresponse)<br> |
### [POST] /agent/{agent_id}/api-enable
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentApiStatusPayload](#agentapistatuspayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent service API status updated | **application/json**: [AgentApiAccessResponse](#agentapiaccessresponse)<br> |
| 403 | Insufficient permissions | |
### [GET] /agent/{agent_id}/api-keys
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent service API keys | **application/json**: [ApiKeyList](#apikeylist)<br> |
### [POST] /agent/{agent_id}/api-keys
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Agent service API key created | **application/json**: [ApiKeyItem](#apikeyitem)<br> |
| 400 | Maximum keys exceeded | |
### [DELETE] /agent/{agent_id}/api-keys/{api_key_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
| api_key_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description |
| ---- | ----------- |
| 204 | Agent service API key deleted |
### [GET] /agent/{agent_id}/chat-messages
Get Agent App chat messages for a conversation with pagination
@ -12033,6 +12107,31 @@ Default namespace
| chat_prompt_config | object | | No |
| completion_prompt_config | object | | No |
#### AgentApiAccessResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| api_key_count | integer | | Yes |
| api_rph | integer | | Yes |
| api_rpm | integer | | Yes |
| chat_endpoint | string | | Yes |
| conversations_endpoint | string | | Yes |
| enabled | boolean | | Yes |
| files_upload_endpoint | string | | Yes |
| info_endpoint | string | | Yes |
| messages_endpoint | string | | Yes |
| meta_endpoint | string | | Yes |
| parameters_endpoint | string | | Yes |
| service_api_base_url | string | | Yes |
| stop_endpoint | string | | Yes |
| streaming_only | boolean, <br>**Default:** true | | No |
#### AgentApiStatusPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enable_api | boolean | Enable or disable Agent service API | Yes |
#### AgentAppComposerResponse
| Name | Type | Description | Required |

View File

@ -20,6 +20,10 @@ from controllers.console.agent.composer import (
WorkflowAgentComposerValidateApi,
)
from controllers.console.agent.roster import (
AgentApiAccessApi,
AgentApiKeyApi,
AgentApiKeyListApi,
AgentApiStatusApi,
AgentAppApi,
AgentAppCopyApi,
AgentAppListApi,
@ -150,6 +154,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/sandbox/files",
"/agent/<uuid:agent_id>/skills/upload",
"/agent/<uuid:agent_id>/files",
"/agent/<uuid:agent_id>/api-access",
"/agent/<uuid:agent_id>/api-enable",
"/agent/<uuid:agent_id>/api-keys",
"/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>",
"/agent/<uuid:agent_id>/chat-messages",
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
"/agent/<uuid:agent_id>/feedbacks",
@ -177,6 +185,7 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/apps/<uuid:app_id>/agent-features",
"/apps/<uuid:app_id>/agent-referencing-workflows",
"/apps/<uuid:app_id>/agent-sandbox/files",
"/apps/<uuid:agent_id>/api-access",
):
assert route not in paths
@ -449,6 +458,127 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
}
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(
id="app-1",
enable_api=True,
api_base_url="https://api.example.test/v1",
api_rpm=60,
api_rph=600,
)
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 2)
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), "tenant-1", agent_id)
assert response == {
"enabled": True,
"service_api_base_url": "https://api.example.test/v1",
"streaming_only": True,
"chat_endpoint": "https://api.example.test/v1/chat-messages",
"stop_endpoint": "https://api.example.test/v1/chat-messages/{task_id}/stop",
"conversations_endpoint": "https://api.example.test/v1/conversations",
"messages_endpoint": "https://api.example.test/v1/messages",
"files_upload_endpoint": "https://api.example.test/v1/files/upload",
"parameters_endpoint": "https://api.example.test/v1/parameters",
"info_endpoint": "https://api.example.test/v1/info",
"meta_endpoint": "https://api.example.test/v1/meta",
"api_rpm": 60,
"api_rph": 600,
"api_key_count": 2,
}
def test_agent_api_status_and_key_routes_resolve_backing_app(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
api_key_id = "00000000-0000-0000-0000-000000000002"
app_model = SimpleNamespace(
id="app-1",
enable_api=False,
api_base_url="https://api.example.test/v1",
api_rpm=0,
api_rph=0,
)
captured: dict[str, object] = {}
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda app_id: 1)
class FakeAppService:
def update_app_api_status(self, app_obj: object, enable_api: bool) -> object:
captured["enable"] = {"app": app_obj, "enable_api": enable_api}
app_model.enable_api = enable_api
return app_model
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
def fake_get_api_key_list(self, resource_id: str, tenant_id: str):
captured["list_keys"] = {"resource_id": resource_id, "tenant_id": tenant_id}
return roster_controller.ApiKeyList(data=[])
def fake_create_api_key(self, resource_id: str, tenant_id: str):
captured["create_key"] = {"resource_id": resource_id, "tenant_id": tenant_id}
return SimpleNamespace(
id=api_key_id,
type="app",
token="app-test-token",
last_used_at=None,
created_at=None,
)
def fake_delete_api_key(self, resource_id: str, key_id: str, tenant_id: str, current_user: object) -> None:
captured["delete_key"] = {
"resource_id": resource_id,
"api_key_id": key_id,
"tenant_id": tenant_id,
"current_user": current_user,
}
monkeypatch.setattr(AgentApiKeyListApi, "_get_api_key_list", fake_get_api_key_list)
monkeypatch.setattr(AgentApiKeyListApi, "_create_api_key", fake_create_api_key)
monkeypatch.setattr(AgentApiKeyApi, "_delete_api_key", fake_delete_api_key)
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable",
json={"enable_api": True},
):
enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), "tenant-1", agent_id)
assert enabled["enabled"] is True
assert captured["enable"] == {"app": app_model, "enable_api": True}
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), "tenant-1", agent_id)
assert keys == {"data": []}
assert captured["list_keys"] == {"resource_id": "app-1", "tenant_id": "tenant-1"}
created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), "tenant-1", agent_id)
assert status == 201
assert created["id"] == api_key_id
assert created["token"] == "app-test-token"
assert captured["create_key"] == {"resource_id": "app-1", "tenant_id": "tenant-1"}
current_user = SimpleNamespace(id="account-1", is_admin_or_owner=True)
deleted, delete_status = unwrap(AgentApiKeyApi.delete)(
AgentApiKeyApi(),
"tenant-1",
current_user,
agent_id,
api_key_id,
)
assert (deleted, delete_status) == ("", 204)
assert captured["delete_key"] == {
"resource_id": "app-1",
"api_key_id": api_key_id,
"tenant_id": "tenant-1",
"current_user": current_user,
}
def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)

View File

@ -136,6 +136,55 @@ class TestAppParameterApi:
assert "user_input_form" in response
assert "opening_statement" in response
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@patch("controllers.service_api.wraps.db")
@patch("controllers.service_api.app.app._get_agent_app_feature_dict_and_user_input_form")
def test_get_parameters_for_agent_app(
self,
mock_get_agent_parameters,
mock_db,
mock_validate_token,
mock_current_app,
mock_user_logged_in,
app: Flask,
mock_app_model,
):
"""Test retrieving parameters for an Agent App from Agent Soul app variables."""
_configure_current_app_mock(mock_current_app)
mock_app_model.mode = AppMode.AGENT
mock_app_model.app_model_config = None
mock_app_model.workflow = None
mock_get_agent_parameters.return_value = (
{"opening_statement": "Hi from Agent"},
[{"text-input": {"label": "topic", "variable": "topic", "required": True}}],
)
mock_api_token = Mock()
mock_api_token.app_id = mock_app_model.id
mock_api_token.tenant_id = mock_app_model.tenant_id
mock_validate_token.return_value = mock_api_token
mock_tenant = Mock()
mock_tenant.status = TenantStatus.NORMAL
mock_db.session.get.side_effect = [mock_app_model, mock_tenant]
mock_account = Mock()
mock_account.current_tenant = mock_tenant
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account)
with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}):
api = AppParameterApi()
response = api.get()
assert response["opening_statement"] == "Hi from Agent"
assert response["user_input_form"] == [
{"text-input": {"label": "topic", "variable": "topic", "required": True}}
]
mock_get_agent_parameters.assert_called_once_with(mock_app_model)
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@patch("controllers.service_api.wraps.validate_and_get_api_token")

View File

@ -19,6 +19,10 @@ def _soul() -> AgentSoulConfig:
"model_settings": {"temperature": 0.2},
},
"prompt": {"system_prompt": "You are Iris."},
"app_variables": [
{"name": "topic", "type": "string", "required": True},
{"name": "count", "type": "number", "default": 3},
],
}
)
@ -32,7 +36,10 @@ def test_model_and_prompt_come_from_soul():
"completion_params": {"temperature": 0.2},
}
assert d["pre_prompt"] == "You are Iris."
assert d["user_input_form"] == []
assert d["user_input_form"] == [
{"text-input": {"label": "topic", "variable": "topic", "required": True}},
{"number": {"label": "count", "variable": "count", "required": False, "default": 3}},
]
def test_feature_flags_come_from_app_model_config_when_present():

View File

@ -4,6 +4,8 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zDeleteAgentByAgentIdApiKeysByApiKeyIdPath,
zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse,
zDeleteAgentByAgentIdFilesPath,
zDeleteAgentByAgentIdFilesQuery,
zDeleteAgentByAgentIdFilesResponse,
@ -11,6 +13,10 @@ import {
zDeleteAgentByAgentIdResponse,
zDeleteAgentByAgentIdSkillsBySlugPath,
zDeleteAgentByAgentIdSkillsBySlugResponse,
zGetAgentByAgentIdApiAccessPath,
zGetAgentByAgentIdApiAccessResponse,
zGetAgentByAgentIdApiKeysPath,
zGetAgentByAgentIdApiKeysResponse,
zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsPath,
zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse,
zGetAgentByAgentIdChatMessagesPath,
@ -65,6 +71,11 @@ import {
zGetAgentQuery,
zGetAgentResponse,
zPostAgentBody,
zPostAgentByAgentIdApiEnableBody,
zPostAgentByAgentIdApiEnablePath,
zPostAgentByAgentIdApiEnableResponse,
zPostAgentByAgentIdApiKeysPath,
zPostAgentByAgentIdApiKeysResponse,
zPostAgentByAgentIdChatMessagesByTaskIdStopPath,
zPostAgentByAgentIdChatMessagesByTaskIdStopResponse,
zPostAgentByAgentIdComposerValidateBody,
@ -116,10 +127,87 @@ export const inviteOptions = {
get,
}
export const get2 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentByAgentIdApiAccess',
path: '/agent/{agent_id}/api-access',
tags: ['console'],
})
.input(z.object({ params: zGetAgentByAgentIdApiAccessPath }))
.output(zGetAgentByAgentIdApiAccessResponse)
export const apiAccess = {
get: get2,
}
export const post = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAgentByAgentIdApiEnable',
path: '/agent/{agent_id}/api-enable',
tags: ['console'],
})
.input(
z.object({ body: zPostAgentByAgentIdApiEnableBody, params: zPostAgentByAgentIdApiEnablePath }),
)
.output(zPostAgentByAgentIdApiEnableResponse)
export const apiEnable = {
post,
}
export const delete_ = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteAgentByAgentIdApiKeysByApiKeyId',
path: '/agent/{agent_id}/api-keys/{api_key_id}',
successStatus: 204,
tags: ['console'],
})
.input(z.object({ params: zDeleteAgentByAgentIdApiKeysByApiKeyIdPath }))
.output(zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse)
export const byApiKeyId = {
delete: delete_,
}
export const get3 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getAgentByAgentIdApiKeys',
path: '/agent/{agent_id}/api-keys',
tags: ['console'],
})
.input(z.object({ params: zGetAgentByAgentIdApiKeysPath }))
.output(zGetAgentByAgentIdApiKeysResponse)
export const post2 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAgentByAgentIdApiKeys',
path: '/agent/{agent_id}/api-keys',
successStatus: 201,
tags: ['console'],
})
.input(z.object({ params: zPostAgentByAgentIdApiKeysPath }))
.output(zPostAgentByAgentIdApiKeysResponse)
export const apiKeys = {
get: get3,
post: post2,
byApiKeyId,
}
/**
* Get suggested questions for an Agent App message
*/
export const get2 = oc
export const get4 = oc
.route({
description: 'Get suggested questions for an Agent App message',
inputStructure: 'detailed',
@ -132,7 +220,7 @@ export const get2 = oc
.output(zGetAgentByAgentIdChatMessagesByMessageIdSuggestedQuestionsResponse)
export const suggestedQuestions = {
get: get2,
get: get4,
}
export const byMessageId = {
@ -142,7 +230,7 @@ export const byMessageId = {
/**
* Stop a running Agent App chat message generation
*/
export const post = oc
export const post3 = oc
.route({
description: 'Stop a running Agent App chat message generation',
inputStructure: 'detailed',
@ -155,7 +243,7 @@ export const post = oc
.output(zPostAgentByAgentIdChatMessagesByTaskIdStopResponse)
export const stop = {
post,
post: post3,
}
export const byTaskId = {
@ -165,7 +253,7 @@ export const byTaskId = {
/**
* Get Agent App chat messages for a conversation with pagination
*/
export const get3 = oc
export const get5 = oc
.route({
description: 'Get Agent App chat messages for a conversation with pagination',
inputStructure: 'detailed',
@ -183,12 +271,12 @@ export const get3 = oc
.output(zGetAgentByAgentIdChatMessagesResponse)
export const chatMessages = {
get: get3,
get: get5,
byMessageId,
byTaskId,
}
export const get4 = oc
export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -200,10 +288,10 @@ export const get4 = oc
.output(zGetAgentByAgentIdComposerCandidatesResponse)
export const candidates = {
get: get4,
get: get6,
}
export const post2 = oc
export const post4 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -220,10 +308,10 @@ export const post2 = oc
.output(zPostAgentByAgentIdComposerValidateResponse)
export const validate = {
post: post2,
post: post4,
}
export const get5 = oc
export const get7 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -246,13 +334,13 @@ export const put = oc
.output(zPutAgentByAgentIdComposerResponse)
export const composer = {
get: get5,
get: get7,
put,
candidates,
validate,
}
export const post3 = oc
export const post5 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -265,13 +353,13 @@ export const post3 = oc
.output(zPostAgentByAgentIdCopyResponse)
export const copy = {
post: post3,
post: post5,
}
/**
* Time-limited external signed URL for one Agent App drive value
*/
export const get6 = oc
export const get8 = oc
.route({
description: 'Time-limited external signed URL for one Agent App drive value',
inputStructure: 'detailed',
@ -289,13 +377,13 @@ export const get6 = oc
.output(zGetAgentByAgentIdDriveFilesDownloadResponse)
export const download = {
get: get6,
get: get8,
}
/**
* Truncated text preview of one Agent App drive value
*/
export const get7 = oc
export const get9 = oc
.route({
description: 'Truncated text preview of one Agent App drive value',
inputStructure: 'detailed',
@ -313,13 +401,13 @@ export const get7 = oc
.output(zGetAgentByAgentIdDriveFilesPreviewResponse)
export const preview = {
get: get7,
get: get9,
}
/**
* List agent drive entries for an Agent App
*/
export const get8 = oc
export const get10 = oc
.route({
description: 'List agent drive entries for an Agent App',
inputStructure: 'detailed',
@ -337,7 +425,7 @@ export const get8 = oc
.output(zGetAgentByAgentIdDriveFilesResponse)
export const files = {
get: get8,
get: get10,
download,
preview,
}
@ -345,7 +433,7 @@ export const files = {
/**
* Inspect one drive-backed skill for slash-menu hover/detail UI
*/
export const get9 = oc
export const get11 = oc
.route({
description: 'Inspect one drive-backed skill for slash-menu hover/detail UI',
inputStructure: 'detailed',
@ -358,7 +446,7 @@ export const get9 = oc
.output(zGetAgentByAgentIdDriveSkillsBySkillPathInspectResponse)
export const inspect = {
get: get9,
get: get11,
}
export const bySkillPath = {
@ -368,7 +456,7 @@ export const bySkillPath = {
/**
* List drive-backed skills for an Agent App
*/
export const get10 = oc
export const get12 = oc
.route({
description: 'List drive-backed skills for an Agent App',
inputStructure: 'detailed',
@ -381,7 +469,7 @@ export const get10 = oc
.output(zGetAgentByAgentIdDriveSkillsResponse)
export const skills = {
get: get10,
get: get12,
bySkillPath,
}
@ -393,7 +481,7 @@ export const drive = {
/**
* Update an Agent App's presentation features (opener, follow-up, citations, ...)
*/
export const post4 = oc
export const post6 = oc
.route({
description: 'Update an Agent App\'s presentation features (opener, follow-up, citations, ...)',
inputStructure: 'detailed',
@ -408,13 +496,13 @@ export const post4 = oc
.output(zPostAgentByAgentIdFeaturesResponse)
export const features = {
post: post4,
post: post6,
}
/**
* Create or update Agent App message feedback
*/
export const post5 = oc
export const post7 = oc
.route({
description: 'Create or update Agent App message feedback',
inputStructure: 'detailed',
@ -429,13 +517,13 @@ export const post5 = oc
.output(zPostAgentByAgentIdFeedbacksResponse)
export const feedbacks = {
post: post5,
post: post7,
}
/**
* Delete one Agent App drive file by key
*/
export const delete_ = oc
export const delete2 = oc
.route({
description: 'Delete one Agent App drive file by key',
inputStructure: 'detailed',
@ -452,7 +540,7 @@ export const delete_ = oc
/**
* Commit an uploaded file into the Agent App drive under files/<name>
*/
export const post6 = oc
export const post8 = oc
.route({
description: 'Commit an uploaded file into the Agent App drive under files/<name>',
inputStructure: 'detailed',
@ -466,11 +554,11 @@ export const post6 = oc
.output(zPostAgentByAgentIdFilesResponse)
export const files2 = {
delete: delete_,
post: post6,
delete: delete2,
post: post8,
}
export const get11 = oc
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -482,10 +570,10 @@ export const get11 = oc
.output(zGetAgentByAgentIdLogSourcesResponse)
export const logSources = {
get: get11,
get: get13,
}
export const get12 = oc
export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -502,14 +590,14 @@ export const get12 = oc
.output(zGetAgentByAgentIdLogsByConversationIdMessagesResponse)
export const messages = {
get: get12,
get: get14,
}
export const byConversationId = {
messages,
}
export const get13 = oc
export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -523,14 +611,14 @@ export const get13 = oc
.output(zGetAgentByAgentIdLogsResponse)
export const logs = {
get: get13,
get: get15,
byConversationId,
}
/**
* Get Agent App message details by ID
*/
export const get14 = oc
export const get16 = oc
.route({
description: 'Get Agent App message details by ID',
inputStructure: 'detailed',
@ -543,7 +631,7 @@ export const get14 = oc
.output(zGetAgentByAgentIdMessagesByMessageIdResponse)
export const byMessageId2 = {
get: get14,
get: get16,
}
export const messages2 = {
@ -553,7 +641,7 @@ export const messages2 = {
/**
* List workflow apps that reference this Agent App's bound Agent (read-only)
*/
export const get15 = oc
export const get17 = oc
.route({
description: 'List workflow apps that reference this Agent App\'s bound Agent (read-only)',
inputStructure: 'detailed',
@ -566,13 +654,13 @@ export const get15 = oc
.output(zGetAgentByAgentIdReferencingWorkflowsResponse)
export const referencingWorkflows = {
get: get15,
get: get17,
}
/**
* Read a text/binary preview file in an Agent App conversation sandbox
*/
export const get16 = oc
export const get18 = oc
.route({
description: 'Read a text/binary preview file in an Agent App conversation sandbox',
inputStructure: 'detailed',
@ -590,13 +678,13 @@ export const get16 = oc
.output(zGetAgentByAgentIdSandboxFilesReadResponse)
export const read = {
get: get16,
get: get18,
}
/**
* Upload one Agent App sandbox file as a Dify ToolFile mapping
*/
export const post7 = oc
export const post9 = oc
.route({
description: 'Upload one Agent App sandbox file as a Dify ToolFile mapping',
inputStructure: 'detailed',
@ -614,13 +702,13 @@ export const post7 = oc
.output(zPostAgentByAgentIdSandboxFilesUploadResponse)
export const upload = {
post: post7,
post: post9,
}
/**
* List a directory in an Agent App conversation sandbox
*/
export const get17 = oc
export const get19 = oc
.route({
description: 'List a directory in an Agent App conversation sandbox',
inputStructure: 'detailed',
@ -638,7 +726,7 @@ export const get17 = oc
.output(zGetAgentByAgentIdSandboxFilesResponse)
export const files3 = {
get: get17,
get: get19,
read,
upload,
}
@ -650,7 +738,7 @@ export const sandbox = {
/**
* Upload + standardize a Skill into an Agent App drive
*/
export const post8 = oc
export const post10 = oc
.route({
description: 'Upload + standardize a Skill into an Agent App drive',
inputStructure: 'detailed',
@ -669,13 +757,13 @@ export const post8 = oc
.output(zPostAgentByAgentIdSkillsUploadResponse)
export const upload2 = {
post: post8,
post: post10,
}
/**
* Infer CLI tool + ENV suggestions from a standardized Agent App skill
*/
export const post9 = oc
export const post11 = oc
.route({
description: 'Infer CLI tool + ENV suggestions from a standardized Agent App skill',
inputStructure: 'detailed',
@ -688,13 +776,13 @@ export const post9 = oc
.output(zPostAgentByAgentIdSkillsBySlugInferToolsResponse)
export const inferTools = {
post: post9,
post: post11,
}
/**
* Delete a standardized skill from an Agent App drive
*/
export const delete2 = oc
export const delete3 = oc
.route({
description: 'Delete a standardized skill from an Agent App drive',
inputStructure: 'detailed',
@ -707,7 +795,7 @@ export const delete2 = oc
.output(zDeleteAgentByAgentIdSkillsBySlugResponse)
export const bySlug = {
delete: delete2,
delete: delete3,
inferTools,
}
@ -716,7 +804,7 @@ export const skills2 = {
bySlug,
}
export const get18 = oc
export const get20 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -733,14 +821,14 @@ export const get18 = oc
.output(zGetAgentByAgentIdStatisticsSummaryResponse)
export const summary = {
get: get18,
get: get20,
}
export const statistics = {
summary,
}
export const post10 = oc
export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -752,10 +840,10 @@ export const post10 = oc
.output(zPostAgentByAgentIdVersionsByVersionIdRestoreResponse)
export const restore = {
post: post10,
post: post12,
}
export const get19 = oc
export const get21 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -767,11 +855,11 @@ export const get19 = oc
.output(zGetAgentByAgentIdVersionsByVersionIdResponse)
export const byVersionId = {
get: get19,
get: get21,
restore,
}
export const get20 = oc
export const get22 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -783,11 +871,11 @@ export const get20 = oc
.output(zGetAgentByAgentIdVersionsResponse)
export const versions = {
get: get20,
get: get22,
byVersionId,
}
export const delete3 = oc
export const delete4 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
@ -799,7 +887,7 @@ export const delete3 = oc
.input(z.object({ params: zDeleteAgentByAgentIdPath }))
.output(zDeleteAgentByAgentIdResponse)
export const get21 = oc
export const get23 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -822,9 +910,12 @@ export const put2 = oc
.output(zPutAgentByAgentIdResponse)
export const byAgentId = {
delete: delete3,
get: get21,
delete: delete4,
get: get23,
put: put2,
apiAccess,
apiEnable,
apiKeys,
chatMessages,
composer,
copy,
@ -842,7 +933,7 @@ export const byAgentId = {
versions,
}
export const get22 = oc
export const get24 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -853,7 +944,7 @@ export const get22 = oc
.input(z.object({ query: zGetAgentQuery.optional() }))
.output(zGetAgentResponse)
export const post11 = oc
export const post13 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@ -866,8 +957,8 @@ export const post11 = oc
.output(zPostAgentResponse)
export const agent = {
get: get22,
post: post11,
get: get24,
post: post13,
inviteOptions,
byAgentId,
}

View File

@ -74,6 +74,39 @@ export type AgentAppUpdatePayload = {
use_icon_as_answer_icon?: boolean | null
}
export type AgentApiAccessResponse = {
api_key_count: number
api_rph: number
api_rpm: number
chat_endpoint: string
conversations_endpoint: string
enabled: boolean
files_upload_endpoint: string
info_endpoint: string
messages_endpoint: string
meta_endpoint: string
parameters_endpoint: string
service_api_base_url: string
stop_endpoint: string
streaming_only?: boolean
}
export type AgentApiStatusPayload = {
enable_api: boolean
}
export type ApiKeyList = {
data: Array<ApiKeyItem>
}
export type ApiKeyItem = {
created_at?: number | null
id: string
last_used_at?: number | null
token: string
type: string
}
export type MessageInfiniteScrollPaginationResponse = {
data: Array<MessageDetailResponse>
has_more: boolean
@ -1699,6 +1732,95 @@ export type PutAgentByAgentIdResponses = {
export type PutAgentByAgentIdResponse = PutAgentByAgentIdResponses[keyof PutAgentByAgentIdResponses]
export type GetAgentByAgentIdApiAccessData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-access'
}
export type GetAgentByAgentIdApiAccessResponses = {
200: AgentApiAccessResponse
}
export type GetAgentByAgentIdApiAccessResponse
= GetAgentByAgentIdApiAccessResponses[keyof GetAgentByAgentIdApiAccessResponses]
export type PostAgentByAgentIdApiEnableData = {
body: AgentApiStatusPayload
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-enable'
}
export type PostAgentByAgentIdApiEnableErrors = {
403: unknown
}
export type PostAgentByAgentIdApiEnableResponses = {
200: AgentApiAccessResponse
}
export type PostAgentByAgentIdApiEnableResponse
= PostAgentByAgentIdApiEnableResponses[keyof PostAgentByAgentIdApiEnableResponses]
export type GetAgentByAgentIdApiKeysData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-keys'
}
export type GetAgentByAgentIdApiKeysResponses = {
200: ApiKeyList
}
export type GetAgentByAgentIdApiKeysResponse
= GetAgentByAgentIdApiKeysResponses[keyof GetAgentByAgentIdApiKeysResponses]
export type PostAgentByAgentIdApiKeysData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/agent/{agent_id}/api-keys'
}
export type PostAgentByAgentIdApiKeysErrors = {
400: unknown
}
export type PostAgentByAgentIdApiKeysResponses = {
201: ApiKeyItem
}
export type PostAgentByAgentIdApiKeysResponse
= PostAgentByAgentIdApiKeysResponses[keyof PostAgentByAgentIdApiKeysResponses]
export type DeleteAgentByAgentIdApiKeysByApiKeyIdData = {
body?: never
path: {
agent_id: string
api_key_id: string
}
query?: never
url: '/agent/{agent_id}/api-keys/{api_key_id}'
}
export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponses = {
204: void
}
export type DeleteAgentByAgentIdApiKeysByApiKeyIdResponse
= DeleteAgentByAgentIdApiKeysByApiKeyIdResponses[keyof DeleteAgentByAgentIdApiKeysByApiKeyIdResponses]
export type GetAgentByAgentIdChatMessagesData = {
body?: never
path: {

View File

@ -2,6 +2,51 @@
import * as z from 'zod'
/**
* AgentApiAccessResponse
*/
export const zAgentApiAccessResponse = z.object({
api_key_count: z.int(),
api_rph: z.int(),
api_rpm: z.int(),
chat_endpoint: z.string(),
conversations_endpoint: z.string(),
enabled: z.boolean(),
files_upload_endpoint: z.string(),
info_endpoint: z.string(),
messages_endpoint: z.string(),
meta_endpoint: z.string(),
parameters_endpoint: z.string(),
service_api_base_url: z.string(),
stop_endpoint: z.string(),
streaming_only: z.boolean().optional().default(true),
})
/**
* AgentApiStatusPayload
*/
export const zAgentApiStatusPayload = z.object({
enable_api: z.boolean(),
})
/**
* ApiKeyItem
*/
export const zApiKeyItem = z.object({
created_at: z.int().nullish(),
id: z.string(),
last_used_at: z.int().nullish(),
token: z.string(),
type: z.string(),
})
/**
* ApiKeyList
*/
export const zApiKeyList = z.object({
data: z.array(zApiKeyItem),
})
/**
* SuggestedQuestionsResponse
*/
@ -2257,6 +2302,54 @@ export const zPutAgentByAgentIdPath = z.object({
*/
export const zPutAgentByAgentIdResponse = zAgentAppDetailWithSite
export const zGetAgentByAgentIdApiAccessPath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API access
*/
export const zGetAgentByAgentIdApiAccessResponse = zAgentApiAccessResponse
export const zPostAgentByAgentIdApiEnableBody = zAgentApiStatusPayload
export const zPostAgentByAgentIdApiEnablePath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API status updated
*/
export const zPostAgentByAgentIdApiEnableResponse = zAgentApiAccessResponse
export const zGetAgentByAgentIdApiKeysPath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API keys
*/
export const zGetAgentByAgentIdApiKeysResponse = zApiKeyList
export const zPostAgentByAgentIdApiKeysPath = z.object({
agent_id: z.uuid(),
})
/**
* Agent service API key created
*/
export const zPostAgentByAgentIdApiKeysResponse = zApiKeyItem
export const zDeleteAgentByAgentIdApiKeysByApiKeyIdPath = z.object({
agent_id: z.uuid(),
api_key_id: z.uuid(),
})
/**
* Agent service API key deleted
*/
export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void()
export const zGetAgentByAgentIdChatMessagesPath = z.object({
agent_id: z.uuid(),
})

View File

@ -2,21 +2,17 @@
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { useAtomValue, useSetAtom } from 'jotai'
import { useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import {
createReleaseAppInstanceIdAtom,
createReleaseDialogOpenAtom,
createReleaseConfigAtom,
createReleaseLocalAtoms,
isCreatingReleaseAtom,
openCreateReleaseDialogAtom,
requestCloseCreateReleaseDialogAtom,
} from './state'
import { CreateReleaseDialogContent } from './ui/dialog'
import { CreateReleaseDialog } from './ui/dialog'
function CreateReleaseScopedControl({
function CreateReleaseTrigger({
variant,
size,
label,
@ -28,39 +24,17 @@ function CreateReleaseScopedControl({
className?: string
}) {
const { t } = useTranslation('deployments')
const open = useAtomValue(createReleaseDialogOpenAtom)
const isCreatingRelease = useAtomValue(isCreatingReleaseAtom)
const openDialog = useSetAtom(openCreateReleaseDialogAtom)
const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom)
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen) {
openDialog()
return
}
if (!isCreatingRelease)
requestCloseDialog()
}
return (
<Dialog
open={open}
onOpenChange={handleDialogOpenChange}
<Button
size={size}
variant={variant}
className={className}
onClick={openDialog}
>
<DialogTrigger
render={(
<Button
size={size}
variant={variant}
className={className}
/>
)}
>
{label ?? t('versions.createRelease')}
</DialogTrigger>
{open && <CreateReleaseDialogContent />}
</Dialog>
{label ?? t('versions.createRelease')}
</Button>
)
}
@ -81,17 +55,18 @@ export function CreateReleaseControl({
<ScopeProvider
key={appInstanceId}
atoms={[
[createReleaseAppInstanceIdAtom, appInstanceId],
[createReleaseConfigAtom, { appInstanceId }],
...createReleaseLocalAtoms,
]}
name="CreateRelease"
>
<CreateReleaseScopedControl
<CreateReleaseTrigger
variant={variant}
size={size}
label={label}
className={className}
/>
<CreateReleaseDialog />
</ScopeProvider>
)
}

View File

@ -1,115 +1,30 @@
import type { Getter } from 'jotai'
import type { CreateReleaseFormValues } from '../index'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import {
closeCreateReleaseDialogAtom,
createReleaseDescriptionFieldAtom,
createReleaseDialogOpenAtom,
createReleaseDslFileFieldAtom,
createReleaseDslStateAtom,
createReleaseFormValuesAtom,
createReleaseNameFieldAtom,
createReleaseSourceAppFieldAtom,
createReleaseSourceModeFieldAtom,
createReleaseSubmitUnsupportedDslNodesAtom,
openCreateReleaseDialogAtom,
RELEASE_NAME_REQUIRED_ERROR,
selectCreateReleaseSourceModeAtom,
submitCreateReleaseFormAtom,
updateCreateReleaseDslFileAtom,
updateCreateReleaseSourceAppAtom,
} from '../index'
type QueryResult = {
data?: unknown
isError?: boolean
isFetching?: boolean
isLoading?: boolean
isSuccess?: boolean
}
type QueryOptions = {
enabled?: boolean
input?: unknown
queryFn?: () => unknown
queryKey?: readonly unknown[]
retry?: boolean
}
type MutationResult = {
isPending: boolean
mutateAsync: ReturnType<typeof vi.fn>
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
mutateAsync: vi.fn(),
},
}))
vi.mock('jotai-tanstack-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai-tanstack-query')>()
return {
...actual,
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
const queryKey = Array.isArray(options.queryKey) ? options.queryKey[0] : undefined
const queryName = typeof queryKey === 'string' ? queryKey : 'unknown'
const queryResult = options.enabled === false
? undefined
: mockQueryResults.current.get(queryName)
return {
...options,
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
...queryResult,
}
}),
atomWithMutation: () => atom(() => mockCreateReleaseMutation.current),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
get: {
queryOptions: ({ enabled, input }: QueryOptions) => ({
enabled,
input,
queryKey: ['appById', input],
}),
},
},
},
enterprise: {
releaseService: {
listReleases: {
queryOptions: ({ enabled, input }: QueryOptions) => ({
enabled,
input,
queryKey: ['listReleases', input],
}),
},
precheckRelease: {
queryOptions: ({ enabled, input }: QueryOptions) => ({
enabled,
input,
queryKey: ['precheckRelease', input],
}),
},
createRelease: {
mutationOptions: () => ({ mutationKey: ['createRelease'] }),
},
},
},
},
}))
async function loadState() {
return await import('../index')
}
async function mountedStore() {
const state = await loadState()
function mountedStore() {
const store = createStore()
const unsubscribe = store.sub(state.createReleaseFormValuesAtom, () => undefined)
const unsubscribe = store.sub(createReleaseFormValuesAtom, () => undefined)
return {
state,
store,
unsubscribe,
}
@ -119,7 +34,6 @@ function sourceApp(overrides: Partial<NonNullable<CreateReleaseFormValues['sourc
return {
id: 'source-app-1',
name: 'Source App',
mode: 'workflow',
...overrides,
}
}
@ -143,61 +57,11 @@ function workflowDsl() {
].join('\n')
}
function setDefaultSourceApp(defaultSourceApp = sourceApp({ id: 'default-source-app', name: 'Default Source App' })) {
mockQueryResults.current.set('listReleases', {
data: {
releases: [
{
sourceAppId: defaultSourceApp.id,
},
],
},
isSuccess: true,
})
mockQueryResults.current.set('appById', {
data: defaultSourceApp,
isSuccess: true,
})
}
function setPrecheckReleaseResult(overrides: {
canCreate?: boolean
matchedRelease?: unknown
unsupportedNodes?: Array<{ id?: string, type?: string }>
} = {}) {
mockQueryResults.current.set('precheckRelease', {
data: {
gateCommitId: 'gate-commit-1',
canCreate: true,
unsupportedNodes: [],
...overrides,
},
isSuccess: true,
})
}
function setDslFileContentResult(overrides: QueryResult = {}) {
mockQueryResults.current.set('createReleaseDslFileContent', {
data: workflowDsl(),
isSuccess: true,
...overrides,
})
}
describe('create release state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
mockCreateReleaseMutation.current = {
isPending: false,
mutateAsync: vi.fn(),
}
})
it('should keep default form values before editing', () => {
const { store, unsubscribe } = mountedStore()
it('should keep default form values before editing', async () => {
const { state, store, unsubscribe } = await mountedStore()
expect(store.get(state.createReleaseFormValuesAtom)).toEqual({
expect(store.get(createReleaseFormValuesAtom)).toEqual({
dslFile: undefined,
releaseDescription: '',
releaseName: '',
@ -209,190 +73,108 @@ describe('create release state', () => {
})
it('should validate release name only when submitting', async () => {
const { state, store, unsubscribe } = await mountedStore()
const { store, unsubscribe } = mountedStore()
const createRelease = vi.fn((_: CreateReleaseFormValues) => undefined)
await store.set(state.submitCreateReleaseFormAtom)
await store.set(submitCreateReleaseFormAtom, createRelease)
expect(mockCreateReleaseMutation.current.mutateAsync).not.toHaveBeenCalled()
expect(createRelease).not.toHaveBeenCalled()
expect(hasValidationIssue(
store.get(state.createReleaseNameFieldAtom).meta?.errors ?? [],
state.RELEASE_NAME_REQUIRED_ERROR,
store.get(createReleaseNameFieldAtom).meta?.errors ?? [],
RELEASE_NAME_REQUIRED_ERROR,
)).toBe(true)
unsubscribe()
})
it('should coerce DSL source mode to source app mode when DSL import is disabled', async () => {
const { state, store, unsubscribe } = await mountedStore()
it('should submit current form values when the release name is valid', async () => {
const { store, unsubscribe } = mountedStore()
const createRelease = vi.fn((_: CreateReleaseFormValues) => undefined)
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
store.set(createReleaseNameFieldAtom, 'Release 1')
store.set(createReleaseDescriptionFieldAtom, 'Initial rollout')
expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp')
expect(store.get(state.createReleaseSourceModeAtom)).toBe('sourceApp')
await store.set(submitCreateReleaseFormAtom, createRelease)
unsubscribe()
})
it('should derive default source app selection from the latest release source', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp()
expect(store.get(state.createReleaseSelectedSourceAppAtom)).toEqual({
id: 'default-source-app',
name: 'Default Source App',
mode: 'workflow',
expect(createRelease).toHaveBeenCalledTimes(1)
expect(createRelease).toHaveBeenCalledWith({
dslFile: undefined,
releaseDescription: 'Initial rollout',
releaseName: 'Release 1',
releaseSourceMode: 'sourceApp',
sourceApp: undefined,
})
expect(store.get(state.createReleaseSelectedSourceAppAtom)?.id).toBe('default-source-app')
unsubscribe()
})
it('should derive workflow DSL read state when selecting a DSL file', async () => {
const { state, store, unsubscribe } = await mountedStore()
it('should clear source app and derive workflow DSL state when selecting a DSL file', async () => {
const { store, unsubscribe } = mountedStore()
const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' })
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult()
store.set(updateCreateReleaseSourceAppAtom, sourceApp())
store.set(selectCreateReleaseSourceModeAtom, 'dsl')
await store.set(updateCreateReleaseDslFileAtom, file)
expect(store.get(state.createReleaseDslFileFieldAtom).value).toBe(file)
expect(store.get(state.createReleaseDslContentAtom)).toBe(workflowDsl())
expect(store.get(state.createReleaseHasDslContentAtom)).toBe(true)
expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false)
expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(true)
expect(store.get(state.createReleaseEncodedDslContentAtom)).not.toBe('')
const dslState = store.get(createReleaseDslStateAtom)
expect(store.get(createReleaseSourceModeFieldAtom).value).toBe('dsl')
expect(store.get(createReleaseSourceAppFieldAtom).value).toBeUndefined()
expect(store.get(createReleaseDslFileFieldAtom).value).toBe(file)
expect(dslState.dslContent).toBe(workflowDsl())
expect(dslState.hasDslContent).toBe(true)
expect(dslState.isReadingDsl).toBe(false)
expect(dslState.isWorkflowDslContent).toBe(true)
expect(dslState.encodedDslContent).not.toBe('')
unsubscribe()
})
it('should reset DSL state when switching back to source app mode', async () => {
const { state, store, unsubscribe } = await mountedStore()
const { store, unsubscribe } = mountedStore()
const file = new File([workflowDsl()], 'workflow.yml', { type: 'text/yaml' })
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult()
store.set(state.selectCreateReleaseSourceModeAtom, 'sourceApp')
store.set(selectCreateReleaseSourceModeAtom, 'dsl')
await store.set(updateCreateReleaseDslFileAtom, file)
store.set(selectCreateReleaseSourceModeAtom, 'sourceApp')
expect(store.get(state.createReleaseSourceModeFieldAtom).value).toBe('sourceApp')
expect(store.get(state.createReleaseDslFileFieldAtom).value).toBeUndefined()
expect(store.get(state.createReleaseDslContentAtom)).toBe('')
expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false)
expect(store.get(state.createReleaseEncodedDslContentAtom)).toBe('')
expect(store.get(state.createReleaseHasDslContentAtom)).toBe(false)
expect(store.get(state.isReadingCreateReleaseDslAtom)).toBe(false)
expect(store.get(state.createReleaseIsWorkflowDslContentAtom)).toBe(false)
expect(store.get(createReleaseSourceModeFieldAtom).value).toBe('sourceApp')
expect(store.get(createReleaseDslFileFieldAtom).value).toBeUndefined()
expect(store.get(createReleaseDslStateAtom)).toEqual({
dslContent: '',
dslReadError: false,
encodedDslContent: '',
hasDslContent: false,
isReadingDsl: false,
isWorkflowDslContent: false,
})
unsubscribe()
})
it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => {
const { state, store, unsubscribe } = await mountedStore()
const { store, unsubscribe } = mountedStore()
const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' })
store.set(state.updateCreateReleaseDslFileAtom, file)
setDslFileContentResult({
data: undefined,
isError: true,
isSuccess: false,
const readError = new Error('read failed')
Object.defineProperty(file, 'text', {
configurable: true,
value: vi.fn().mockRejectedValue(readError),
})
expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(true)
await store.set(updateCreateReleaseDslFileAtom, file)
store.set(state.openCreateReleaseDialogAtom)
expect(store.get(state.createReleaseDialogOpenAtom)).toBe(true)
expect(store.get(state.createReleaseDslReadErrorAtom)).toBe(false)
expect(store.get(createReleaseDslStateAtom).dslReadError).toBe(true)
expect(store.get(createReleaseSubmitUnsupportedDslNodesAtom)).toEqual([])
store.set(state.closeCreateReleaseDialogAtom)
expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false)
store.set(createReleaseSubmitUnsupportedDslNodesAtom, [{ id: 'node-1' }])
store.set(openCreateReleaseDialogAtom)
expect(store.get(createReleaseDialogOpenAtom)).toBe(true)
expect(store.get(createReleaseDslStateAtom).dslReadError).toBe(false)
expect(store.get(createReleaseSubmitUnsupportedDslNodesAtom)).toEqual([])
unsubscribe()
})
it('should derive content readiness from release content precheck', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp()
setPrecheckReleaseResult()
expect(store.get(state.createReleaseContentReadyAtom)).toBe(true)
store.set(state.createReleaseNameFieldAtom, 'Release 1')
expect(store.get(state.createReleaseContentReadyAtom)).toBe(true)
unsubscribe()
})
it('should close the dialog through the close request action', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.openCreateReleaseDialogAtom)
store.set(state.requestCloseCreateReleaseDialogAtom)
expect(store.get(state.createReleaseDialogOpenAtom)).toBe(false)
unsubscribe()
})
it('should expose unsupported nodes from release content precheck', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp()
setPrecheckReleaseResult({
canCreate: false,
unsupportedNodes: [{ id: 'precheck-node' }],
})
expect(store.get(state.createReleaseUnsupportedDslNodesAtom)).toEqual([{ id: 'precheck-node' }])
unsubscribe()
})
it('should submit source app release with the checked source and metadata', async () => {
const { state, store, unsubscribe } = await mountedStore()
const response = {
release: {
displayName: 'Release 1',
},
}
mockCreateReleaseMutation.current.mutateAsync.mockResolvedValue(response)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp()
setPrecheckReleaseResult()
store.set(state.createReleaseNameFieldAtom, ' Release 1 ')
store.set(state.createReleaseDescriptionFieldAtom, ' Initial rollout ')
const result = await store.set(state.submitCreateReleaseFormAtom)
expect(result).toBe(response)
expect(mockCreateReleaseMutation.current.mutateAsync).toHaveBeenCalledWith({
body: {
appInstanceId: 'app-instance-1',
sourceAppId: 'default-source-app',
displayName: 'Release 1',
description: 'Initial rollout',
createAppInstance: false,
},
})
unsubscribe()
})
it('should propagate create release submission errors', async () => {
const { state, store, unsubscribe } = await mountedStore()
const submitError = new Error('submit failed')
mockCreateReleaseMutation.current.mutateAsync.mockRejectedValue(submitError)
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
setDefaultSourceApp()
setPrecheckReleaseResult()
store.set(state.createReleaseNameFieldAtom, 'Release 1')
await expect(store.set(state.submitCreateReleaseFormAtom)).rejects.toThrow(submitError)
store.set(createReleaseSubmitUnsupportedDslNodesAtom, [{ type: 'unsupported' }])
store.set(closeCreateReleaseDialogAtom)
expect(store.get(createReleaseDialogOpenAtom)).toBe(false)
expect(store.get(createReleaseSubmitUnsupportedDslNodesAtom)).toEqual([])
unsubscribe()
})

View File

@ -1,25 +1,17 @@
'use client'
import type { CreateReleaseResponse } from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai/vanilla'
import type { UnsupportedDslNode } from '../../shared/domain/error'
import type { App } from '@/types/app'
import { atom } from 'jotai'
import type { SourceAppPickerValue } from '../ui/source-app-picker-value'
import { atom, useAtomValue } from 'jotai'
import {
atomWithForm,
createFormAtoms,
} from 'jotai-tanstack-form'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import * as z from 'zod'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
export type ReleaseSourceMode = 'sourceApp' | 'dsl'
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>>
export type CreateReleaseFormValues = {
releaseSourceMode: ReleaseSourceMode
sourceApp?: SourceAppPickerValue
@ -38,33 +30,6 @@ const DEFAULT_CREATE_RELEASE_FORM_VALUES: CreateReleaseFormValues = {
export const RELEASE_NAME_REQUIRED_ERROR = 'releaseNameRequired'
const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1
function deploymentReleaseSourceMode(mode: ReleaseSourceMode): ReleaseSourceMode {
return mode === 'dsl' && !isDeploymentDslImportEnabled
? 'sourceApp'
: mode
}
function workflowSourceAppPickerValue(value: unknown, fallbackId: string): SourceAppPickerValue | undefined {
if (!value || typeof value !== 'object')
return undefined
const record = value as Record<string, unknown>
const mode = typeof record.mode === 'string' ? record.mode : undefined
if (mode !== AppModeEnum.WORKFLOW)
return undefined
const id = typeof record.id === 'string' && record.id ? record.id : fallbackId
const name = typeof record.name === 'string' && record.name ? record.name : id
return {
id,
name,
mode,
}
}
const createReleaseFormSchema = z.object({
releaseSourceMode: z.union([z.literal('sourceApp'), z.literal('dsl')]),
sourceApp: z.custom<CreateReleaseFormValues['sourceApp']>().optional(),
@ -73,7 +38,7 @@ const createReleaseFormSchema = z.object({
releaseDescription: z.string(),
})
type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise<CreateReleaseResponse | undefined> | CreateReleaseResponse | undefined
type CreateReleaseSubmit = (value: CreateReleaseFormValues) => Promise<void> | void
type CreateReleaseSubmitMeta = {
createRelease: CreateReleaseSubmit
@ -81,7 +46,6 @@ type CreateReleaseSubmitMeta = {
const noopCreateRelease: CreateReleaseSubmit = () => undefined
// Form state
export const createReleaseFormAtom = atomWithForm({
defaultValues: DEFAULT_CREATE_RELEASE_FORM_VALUES,
onSubmitMeta: {
@ -97,268 +61,108 @@ const createReleaseFormAtoms = createFormAtoms(createReleaseFormAtom)
export const createReleaseFormValuesAtom = createReleaseFormAtoms.valuesAtom
export const createReleaseFormIsSubmittingAtom = createReleaseFormAtoms.isSubmittingAtom
export const setCreateReleaseFormFieldAtom = createReleaseFormAtoms.setFieldAtom
export const createReleaseSourceModeFieldAtom = createReleaseFormAtoms.fieldAtom('releaseSourceMode')
export const createReleaseSourceAppFieldAtom = createReleaseFormAtoms.fieldAtom('sourceApp')
export const createReleaseDslFileFieldAtom = createReleaseFormAtoms.fieldAtom('dslFile')
export const createReleaseNameFieldAtom = createReleaseFormAtoms.fieldAtom('releaseName')
export const createReleaseDescriptionFieldAtom = createReleaseFormAtoms.fieldAtom('releaseDescription')
export const submitCreateReleaseFormAtom = atom(null, (get, _set, createRelease: CreateReleaseSubmit) => {
const form = get(createReleaseFormAtom)
// Dialog and source primitives
export const createReleaseAppInstanceIdAtom = atom<string | undefined>(undefined)
return form.api.handleSubmit({ createRelease } satisfies CreateReleaseSubmitMeta)
})
type CreateReleaseConfig = {
appInstanceId: string
}
export type CreateReleaseDslState = {
dslContent: string
dslReadError: boolean
encodedDslContent: string
hasDslContent: boolean
isReadingDsl: boolean
isWorkflowDslContent: boolean
}
export const createReleaseConfigAtom = atom<CreateReleaseConfig | undefined>(undefined)
export const createReleaseDialogOpenAtom = atom(false)
const createReleaseDslFileReadVersionAtom = atom(0)
export const createReleaseSubmitUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
function requiredAppInstanceId(get: Getter) {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
if (!appInstanceId)
throw new Error('Missing create release app instance id.')
const createReleaseDslContentAtom = atom('')
const createReleaseDslReadErrorAtom = atom(false)
const createReleaseDslReadingAtom = atom(false)
const createReleaseDslReadTokenAtom = atom(0)
return appInstanceId
}
export const createReleaseLocalAtoms = [
createReleaseDialogOpenAtom,
createReleaseDslContentAtom,
createReleaseDslReadErrorAtom,
createReleaseDslReadingAtom,
createReleaseDslReadTokenAtom,
createReleaseSubmitUnsupportedDslNodesAtom,
] as const
// Query and remote data
const latestSourceReleaseQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
return consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId: appInstanceId ?? '' },
query: {
pageNumber: 1,
resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE,
},
},
enabled: Boolean(appInstanceId && get(createReleaseDialogOpenAtom)),
})
export const clearCreateReleaseSubmissionErrorAtom = atom(null, (_get, set) => {
set(createReleaseSubmitUnsupportedDslNodesAtom, [])
})
function latestReleaseSourceAppId(get: Getter) {
const latestReleaseQuery = get(latestSourceReleaseQueryAtom)
return latestReleaseQuery.data?.releases[0]?.sourceAppId
}
const defaultSourceAppQueryAtom = atomWithQuery((get) => {
const latestSourceAppId = latestReleaseSourceAppId(get)
return consoleQuery.apps.byAppId.get.queryOptions({
input: {
params: { app_id: latestSourceAppId ?? '' },
},
enabled: Boolean(get(createReleaseDialogOpenAtom) && latestSourceAppId),
})
})
function defaultSourceApp(get: Getter) {
const latestSourceAppId = latestReleaseSourceAppId(get)
if (!latestSourceAppId)
return undefined
return workflowSourceAppPickerValue(get(defaultSourceAppQueryAtom).data, latestSourceAppId)
}
const createReleaseDslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(createReleaseDslFileFieldAtom).value
const fileReadVersion = get(createReleaseDslFileReadVersionAtom)
return {
queryKey: [
'createReleaseDslFileContent',
fileReadVersion,
file,
file?.name ?? '',
file?.size ?? 0,
file?.lastModified ?? 0,
],
queryFn: async () => file ? await file.text() : '',
enabled: Boolean(file),
retry: false,
}
})
// Source derived state
function effectiveCreateReleaseSourceMode(get: Getter) {
return deploymentReleaseSourceMode(get(createReleaseSourceModeFieldAtom).value)
}
export const createReleaseSourceModeAtom = atom((get) => {
return effectiveCreateReleaseSourceMode(get)
})
export const createReleaseDslContentAtom = atom((get) => {
return get(createReleaseDslFileContentQueryAtom).data ?? ''
})
export const createReleaseDslReadErrorAtom = atom((get) => {
return Boolean(get(createReleaseDslFileFieldAtom).value && get(createReleaseDslFileContentQueryAtom).isError)
})
export const isReadingCreateReleaseDslAtom = atom((get) => {
const file = get(createReleaseDslFileFieldAtom).value
const dslFileContentQuery = get(createReleaseDslFileContentQueryAtom)
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
})
export const createReleaseHasDslContentAtom = atom((get) => {
return Boolean(get(createReleaseDslContentAtom).trim())
})
export const createReleaseIsWorkflowDslContentAtom = atom((get) => {
const dslContent = get(createReleaseDslContentAtom)
return get(createReleaseHasDslContentAtom) ? isWorkflowDsl(dslContent) : false
})
export const createReleaseEncodedDslContentAtom = atom((get) => {
const dslContent = get(createReleaseDslContentAtom)
return get(createReleaseHasDslContentAtom) && get(createReleaseIsWorkflowDslContentAtom)
? encodeDslContent(dslContent)
: ''
})
export const createReleaseSelectedSourceAppAtom = atom((get) => {
if (effectiveCreateReleaseSourceMode(get) !== 'sourceApp')
return undefined
const fieldSourceApp = get(createReleaseSourceAppFieldAtom).value
const fallbackSourceApp = defaultSourceApp(get)
if (!isDeploymentDslImportEnabled)
return fallbackSourceApp
return fieldSourceApp ?? fallbackSourceApp
})
function selectedSourceAppId(get: Getter) {
return effectiveCreateReleaseSourceMode(get) === 'sourceApp'
? get(createReleaseSelectedSourceAppAtom)?.id
: undefined
}
function hasUnsupportedDslMode(get: Getter) {
if (effectiveCreateReleaseSourceMode(get) !== 'dsl')
return false
return get(createReleaseHasDslContentAtom)
&& !get(isReadingCreateReleaseDslAtom)
&& !get(createReleaseDslReadErrorAtom)
&& !get(createReleaseIsWorkflowDslContentAtom)
}
export const createReleaseHasUnsupportedDslModeAtom = atom((get) => {
return hasUnsupportedDslMode(get)
})
function canCheckReleaseSourceContent(get: Getter) {
if (effectiveCreateReleaseSourceMode(get) === 'sourceApp')
return Boolean(selectedSourceAppId(get))
if (!isDeploymentDslImportEnabled)
return false
return Boolean(
get(createReleaseHasDslContentAtom)
&& !get(isReadingCreateReleaseDslAtom)
&& !get(createReleaseDslReadErrorAtom)
&& !hasUnsupportedDslMode(get),
)
}
function canCheckReleaseContent(get: Getter) {
return Boolean(
get(createReleaseAppInstanceIdAtom)
&& get(createReleaseDialogOpenAtom)
&& canCheckReleaseSourceContent(get),
)
}
// Release content check
const precheckReleaseQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(createReleaseAppInstanceIdAtom)
const releaseSourceMode = effectiveCreateReleaseSourceMode(get)
const sourceAppId = selectedSourceAppId(get)
const canCheck = canCheckReleaseContent(get)
return {
...consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: {
body: {
appInstanceId: appInstanceId ?? '',
...(releaseSourceMode === 'dsl'
? { dsl: get(createReleaseEncodedDslContentAtom) }
: { sourceAppId: sourceAppId ?? '' }),
},
},
enabled: canCheck,
}),
retry: false,
}
})
export const isCheckingCreateReleaseContentAtom = atom((get) => {
const canCheck = canCheckReleaseContent(get)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return canCheck && (precheckReleaseQuery.isLoading || precheckReleaseQuery.isFetching)
})
export const createReleaseMatchedReleaseAtom = atom((get) => {
return canCheckReleaseContent(get)
? get(precheckReleaseQueryAtom).data?.matchedRelease
: undefined
})
export const createReleaseContentCheckFailedAtom = atom((get) => {
return canCheckReleaseContent(get) && get(precheckReleaseQueryAtom).isError
})
export const createReleaseUnsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => {
return canCheckReleaseContent(get)
? get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
: []
})
export const createReleaseContentReadyAtom = atom((get) => {
const canCheck = canCheckReleaseContent(get)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return canCheck
&& precheckReleaseQuery.isSuccess
&& !get(isCheckingCreateReleaseContentAtom)
&& !get(createReleaseContentCheckFailedAtom)
&& Boolean(precheckReleaseQuery.data?.canCreate)
&& get(createReleaseUnsupportedDslNodesAtom).length === 0
})
// Actions
const resetCreateReleaseDslFileAtom = atom(null, (get, set) => {
set(createReleaseDslFileFieldAtom, undefined)
set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1)
set(createReleaseDslReadTokenAtom, get(createReleaseDslReadTokenAtom) + 1)
set(createReleaseDslContentAtom, '')
set(createReleaseDslReadingAtom, false)
set(createReleaseDslReadErrorAtom, false)
})
export const openCreateReleaseDialogAtom = atom(null, (_get, set) => {
set(clearCreateReleaseSubmissionErrorAtom)
set(resetCreateReleaseDslFileAtom)
set(createReleaseDialogOpenAtom, true)
})
export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => {
set(createReleaseDialogOpenAtom, false)
set(clearCreateReleaseSubmissionErrorAtom)
set(resetCreateReleaseDslFileAtom)
})
export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => {
if (get(createReleaseFormIsSubmittingAtom))
const selectCreateReleaseDslFileAtom = atom(null, async (get, set, file?: File) => {
const readToken = get(createReleaseDslReadTokenAtom) + 1
set(createReleaseDslReadTokenAtom, readToken)
set(createReleaseDslContentAtom, '')
set(createReleaseDslReadingAtom, false)
set(createReleaseDslReadErrorAtom, false)
if (!file)
return
set(closeCreateReleaseDialogAtom)
set(createReleaseDslReadingAtom, true)
try {
const content = await file.text()
if (get(createReleaseDslReadTokenAtom) !== readToken)
return
set(createReleaseDslContentAtom, content)
}
catch {
if (get(createReleaseDslReadTokenAtom) !== readToken)
return
set(createReleaseDslReadErrorAtom, true)
}
finally {
if (get(createReleaseDslReadTokenAtom) === readToken)
set(createReleaseDslReadingAtom, false)
}
})
export const selectCreateReleaseSourceModeAtom = atom(null, (_get, set, releaseSourceMode: ReleaseSourceMode) => {
const effectiveReleaseSourceMode = deploymentReleaseSourceMode(releaseSourceMode)
set(createReleaseSourceModeFieldAtom, effectiveReleaseSourceMode)
set(clearCreateReleaseSubmissionErrorAtom)
set(createReleaseSourceModeFieldAtom, releaseSourceMode)
if (effectiveReleaseSourceMode === 'sourceApp') {
if (releaseSourceMode === 'sourceApp') {
set(createReleaseDslFileFieldAtom, undefined)
set(resetCreateReleaseDslFileAtom)
return
}
@ -368,91 +172,34 @@ export const selectCreateReleaseSourceModeAtom = atom(null, (_get, set, releaseS
export const updateCreateReleaseSourceAppAtom = atom(null, (_get, set, sourceApp: CreateReleaseFormValues['sourceApp']) => {
set(createReleaseSourceAppFieldAtom, sourceApp)
set(clearCreateReleaseSubmissionErrorAtom)
})
export const updateCreateReleaseDslFileAtom = atom(null, (get, set, dslFile: CreateReleaseFormValues['dslFile']) => {
set(createReleaseDslFileFieldAtom, dslFile)
set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1)
set(clearCreateReleaseSubmissionErrorAtom)
return set(selectCreateReleaseDslFileAtom, dslFile)
})
// Submission
const createReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
)
export const createReleaseDslStateAtom = atom((get): CreateReleaseDslState => {
const dslContent = get(createReleaseDslContentAtom)
const hasDslContent = Boolean(dslContent.trim())
const isWorkflowDslContent = hasDslContent ? isWorkflowDsl(dslContent) : false
export const isCreatingReleaseAtom = atom((get) => {
return get(createReleaseMutationAtom).isPending
})
export class CreateReleaseSubmissionBlockedError extends Error {
reason: 'unsupportedDslMode'
constructor(reason: 'unsupportedDslMode') {
super(reason)
this.reason = reason
this.name = 'CreateReleaseSubmissionBlockedError'
return {
dslContent,
dslReadError: get(createReleaseDslReadErrorAtom),
encodedDslContent: hasDslContent && isWorkflowDslContent ? encodeDslContent(dslContent) : '',
hasDslContent,
isReadingDsl: get(createReleaseDslReadingAtom),
isWorkflowDslContent,
}
})
export function useCreateReleaseConfig() {
const config = useAtomValue(createReleaseConfigAtom)
if (!config)
throw new Error('Missing create release config.')
return config
}
const createReleaseSubmissionAtom = atom(null, async (get, set, value: CreateReleaseFormValues) => {
const releaseSourceMode = effectiveCreateReleaseSourceMode(get)
const sourceAppId = selectedSourceAppId(get)
const submittedReleaseName = value.releaseName.trim()
if (get(isCheckingCreateReleaseContentAtom) || !submittedReleaseName)
return undefined
if (!canCheckReleaseSourceContent(get) || !get(createReleaseContentReadyAtom))
return undefined
const appInstanceId = requiredAppInstanceId(get)
const commonCreateReleaseRequest = {
appInstanceId,
displayName: submittedReleaseName,
description: value.releaseDescription.trim() || undefined,
createAppInstance: false,
}
if (releaseSourceMode === 'dsl') {
if (!get(createReleaseIsWorkflowDslContentAtom))
throw new CreateReleaseSubmissionBlockedError('unsupportedDslMode')
return await get(createReleaseMutationAtom).mutateAsync({
body: {
...commonCreateReleaseRequest,
dsl: get(createReleaseEncodedDslContentAtom),
},
})
}
if (!sourceAppId)
return undefined
return await get(createReleaseMutationAtom).mutateAsync({
body: {
...commonCreateReleaseRequest,
sourceAppId,
},
})
})
export const submitCreateReleaseFormAtom = atom(null, (get, set) => {
const form = get(createReleaseFormAtom)
let submitResponse: CreateReleaseResponse | undefined
return form.api.handleSubmit({
createRelease: async (value) => {
const response = await set(createReleaseSubmissionAtom, value)
submitResponse = response
return response
},
} satisfies CreateReleaseSubmitMeta)
.then(() => submitResponse)
})
// Scoped atoms
export const createReleaseLocalAtoms = [
createReleaseDialogOpenAtom,
createReleaseDslFileReadVersionAtom,
] as const

View File

@ -17,6 +17,7 @@ function renderSourceAppPicker(disabled: boolean) {
<SourceAppPicker
value={{ id: 'app-1', name: 'Workflow 1' }}
onChange={() => undefined}
ariaLabel="Source app"
disabled={disabled}
/>
</QueryClientProvider>,
@ -28,6 +29,6 @@ describe('SourceAppPicker', () => {
renderSourceAppPicker(true)
expect(screen.getByText('Workflow 1')).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).toBeDisabled()
expect(screen.getByRole('combobox', { name: 'Source app' })).toBeDisabled()
})
})

View File

@ -0,0 +1,36 @@
import type { CreateReleaseSourceSelection } from '../use-release-content-check'
import { describe, expect, it } from 'vitest'
import { canCheckReleaseSourceContent } from '../use-release-content-check'
function releaseSource(overrides: Partial<CreateReleaseSourceSelection> = {}): CreateReleaseSourceSelection {
return {
dslContent: '',
dslReadError: false,
encodedDslContent: '',
hasDslContent: false,
hasUnsupportedDslMode: false,
isReadingDsl: false,
isWorkflowDslContent: false,
releaseSourceMode: 'sourceApp',
selectedSourceAppId: undefined,
...overrides,
}
}
describe('canCheckReleaseSourceContent', () => {
it('should allow source app releases when a source app is selected', () => {
expect(canCheckReleaseSourceContent(releaseSource({
selectedSourceAppId: 'app-1',
}))).toBe(true)
})
it('should block DSL release content checks when deployment DSL import is disabled', () => {
expect(canCheckReleaseSourceContent(releaseSource({
dslContent: 'app:\n mode: workflow',
encodedDslContent: 'encoded-dsl',
hasDslContent: true,
isWorkflowDslContent: true,
releaseSourceMode: 'dsl',
}))).toBe(false)
})
})

View File

@ -4,18 +4,35 @@ import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
createReleaseContentReadyAtom,
closeCreateReleaseDialogAtom,
createReleaseFormIsSubmittingAtom,
isCheckingCreateReleaseContentAtom,
requestCloseCreateReleaseDialogAtom,
createReleaseFormValuesAtom,
} from '../state'
import {
createReleaseReadiness,
useCreateReleaseSourceSelection,
useReleaseContentCheck,
} from './use-release-content-check'
export function CreateReleaseActions() {
const { t } = useTranslation('deployments')
const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom)
const closeDialog = useSetAtom(closeCreateReleaseDialogAtom)
const formValues = useAtomValue(createReleaseFormValuesAtom)
const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom)
const releaseContentReady = useAtomValue(createReleaseContentReadyAtom)
const isCheckingReleaseContent = useAtomValue(isCheckingCreateReleaseContentAtom)
const sourceSelection = useCreateReleaseSourceSelection(formValues)
const releaseContent = useReleaseContentCheck(sourceSelection)
const { canCreate, isCheckingReleaseContent } = createReleaseReadiness({
formValues,
isSubmitting,
releaseContent,
})
function requestClose() {
if (isSubmitting)
return
closeDialog()
}
return (
<div className="flex items-center justify-end gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
@ -27,12 +44,12 @@ export function CreateReleaseActions() {
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
requestClose()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
requestClose()
}}
>
{t('versions.cancelCreate')}
@ -41,8 +58,7 @@ export function CreateReleaseActions() {
type="submit"
variant="primary"
className="min-w-22"
disabled={!releaseContentReady}
loading={isSubmitting}
disabled={!canCreate}
>
{isSubmitting ? t('versions.creating') : isCheckingReleaseContent ? t('versions.checkingReleaseContent') : t('versions.create')}
</Button>

View File

@ -4,36 +4,44 @@ import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert'
import {
createReleaseContentCheckFailedAtom,
createReleaseMatchedReleaseAtom,
createReleaseUnsupportedDslNodesAtom,
isCheckingCreateReleaseContentAtom,
createReleaseFormValuesAtom,
createReleaseSubmitUnsupportedDslNodesAtom,
} from '../state'
import {
useCreateReleaseSourceSelection,
useReleaseContentCheck,
} from './use-release-content-check'
export function ReleaseContentFeedback() {
const { t } = useTranslation('deployments')
const unsupportedDslNodes = useAtomValue(createReleaseUnsupportedDslNodesAtom)
const isCheckingReleaseContent = useAtomValue(isCheckingCreateReleaseContentAtom)
const matchedRelease = useAtomValue(createReleaseMatchedReleaseAtom)
const releaseContentCheckFailed = useAtomValue(createReleaseContentCheckFailedAtom)
const formValues = useAtomValue(createReleaseFormValuesAtom)
const sourceSelection = useCreateReleaseSourceSelection(formValues)
const releaseContent = useReleaseContentCheck(sourceSelection)
const submitUnsupportedDslNodes = useAtomValue(createReleaseSubmitUnsupportedDslNodesAtom)
// Precheck reports unsupported nodes at pick time; the post-submit atom stays
// as the TOCTOU fallback when the content changes server-side between
// precheck and create.
const unsupportedDslNodes = releaseContent.unsupportedNodes.length > 0
? releaseContent.unsupportedNodes
: submitUnsupportedDslNodes
return (
<>
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
{isCheckingReleaseContent && (
<div role="status" className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
{releaseContent.isCheckingReleaseContent && (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
{t('versions.checkingReleaseContent')}
</div>
)}
{matchedRelease && (
{releaseContent.matchedRelease && (
<div role="alert" className="rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-3 py-2 system-sm-regular text-util-colors-warning-warning-700">
{t('versions.releaseAlreadyExists', { name: matchedRelease.displayName })}
{t('versions.releaseAlreadyExists', { name: releaseContent.matchedRelease.displayName })}
</div>
)}
{releaseContentCheckFailed && (
{releaseContent.releaseContentCheckFailed && (
<div role="alert" className="rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700">
{t('versions.releaseContentCheckFailed')}
</div>

View File

@ -1,27 +1,45 @@
'use client'
import { DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import type { CreateReleaseFormValues } from '../state'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { deploymentErrorMessage } from '../../shared/domain/error'
import { consoleQuery } from '@/service/client'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
closeCreateReleaseDialogAtom,
createReleaseDialogOpenAtom,
createReleaseFormAtom,
createReleaseFormIsSubmittingAtom,
CreateReleaseSubmissionBlockedError,
requestCloseCreateReleaseDialogAtom,
createReleaseFormValuesAtom,
openCreateReleaseDialogAtom,
setCreateReleaseFormFieldAtom,
submitCreateReleaseFormAtom,
useCreateReleaseConfig,
} from '../state'
import { CreateReleaseActions } from './actions'
import { ReleaseContentFeedback } from './content-feedback'
import { ReleaseMetadataFields } from './metadata-fields'
import { workflowSourceAppPickerValue } from './source-app-picker-value'
import { ReleaseSourceSection } from './source-section'
import { useCreateReleaseSubmission } from './use-create-release-submission'
function CreateReleaseCloseButton() {
const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom)
const requestCloseDialog = useSetAtom(requestCloseCreateReleaseDialogAtom)
const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1
function CreateReleaseCloseButton({ isSubmitting }: {
isSubmitting: boolean
}) {
const closeDialog = useSetAtom(closeCreateReleaseDialogAtom)
function requestClose() {
if (isSubmitting)
return
closeDialog()
}
return (
<DialogCloseButton
@ -30,18 +48,66 @@ function CreateReleaseCloseButton() {
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
requestClose()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
requestCloseDialog()
requestClose()
}}
/>
)
}
export function CreateReleaseDialogContent() {
function CreateReleaseDefaultSourceApp({ formValues }: {
formValues: CreateReleaseFormValues
}) {
const { appInstanceId } = useCreateReleaseConfig()
const setCreateReleaseFormField = useSetAtom(setCreateReleaseFormFieldAtom)
const isDialogOpen = useAtomValue(createReleaseDialogOpenAtom)
const latestReleaseQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE,
},
},
enabled: isDialogOpen,
}))
const latestSourceAppId = latestReleaseQuery.data?.releases[0]?.sourceAppId
const defaultSourceAppInput = isDialogOpen && latestSourceAppId
? { params: { app_id: latestSourceAppId } }
: undefined
const defaultSourceAppQuery = useQuery(defaultSourceAppInput
? consoleQuery.apps.byAppId.get.queryOptions({
input: defaultSourceAppInput,
})
: {
queryFn: skipToken,
queryKey: ['create-release', 'default-source-app'],
})
const defaultSourceApp = latestSourceAppId
? workflowSourceAppPickerValue(defaultSourceAppQuery.data, latestSourceAppId)
: undefined
const sourceAppLocked = !isDeploymentDslImportEnabled
const releaseSourceMode = formValues.releaseSourceMode === 'dsl' && !isDeploymentDslImportEnabled
? 'sourceApp'
: formValues.releaseSourceMode
useEffect(() => {
if (!isDialogOpen || releaseSourceMode !== 'sourceApp' || !defaultSourceApp)
return
if (formValues.sourceApp && (!sourceAppLocked || formValues.sourceApp.id === defaultSourceApp.id))
return
setCreateReleaseFormField({ name: 'sourceApp', value: defaultSourceApp })
}, [defaultSourceApp, formValues.sourceApp, isDialogOpen, releaseSourceMode, setCreateReleaseFormField, sourceAppLocked])
return null
}
function CreateReleaseDialogForm() {
return (
<ScopeProvider atoms={[createReleaseFormAtom]}>
<CreateReleaseDialogSurface />
@ -50,61 +116,71 @@ export function CreateReleaseDialogContent() {
}
function CreateReleaseDialogSurface() {
const open = useAtomValue(createReleaseDialogOpenAtom)
const formValues = useAtomValue(createReleaseFormValuesAtom)
const isSubmitting = useAtomValue(createReleaseFormIsSubmittingAtom)
const openDialog = useSetAtom(openCreateReleaseDialogAtom)
const closeDialog = useSetAtom(closeCreateReleaseDialogAtom)
const submitCreateReleaseForm = useSetAtom(submitCreateReleaseFormAtom)
const { t } = useTranslation('deployments')
const submission = useCreateReleaseSubmission(formValues)
async function handleSubmit() {
try {
const response = await submitCreateReleaseForm()
if (!response)
return
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen) {
openDialog()
return
}
toast.success(t('versions.createSuccess', { name: response.release.displayName }))
if (!isSubmitting)
closeDialog()
}
catch (error) {
if (error instanceof CreateReleaseSubmissionBlockedError) {
toast.error(t('versions.dslUnsupportedMode'))
return
}
const message = await deploymentErrorMessage(error)
toast.error(message || t('versions.createFailed'))
}
}
return (
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<CreateReleaseCloseButton />
<form
noValidate
autoComplete="off"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void handleSubmit()
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
<Dialog
open={open}
onOpenChange={handleDialogOpenChange}
>
<DialogContent className="top-[18dvh] w-140 max-w-[calc(100vw-32px)] translate-y-0 overflow-hidden p-0">
<CreateReleaseDefaultSourceApp formValues={formValues} />
<CreateReleaseCloseButton isSubmitting={isSubmitting} />
<form
noValidate
autoComplete="off"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
void submitCreateReleaseForm(submission.createRelease)
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
</div>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<ReleaseSourceSection />
<ReleaseContentFeedback />
<ReleaseMetadataFields />
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<ReleaseSourceSection />
<ReleaseContentFeedback />
<ReleaseMetadataFields />
</div>
<CreateReleaseActions />
</form>
</DialogContent>
<CreateReleaseActions />
</form>
</DialogContent>
</Dialog>
)
}
export function CreateReleaseDialog() {
const open = useAtomValue(createReleaseDialogOpenAtom)
if (!open)
return null
return <CreateReleaseDialogForm />
}

View File

@ -0,0 +1,11 @@
import { AppModeEnum } from '@/types/app'
type WorkflowAppMode = Extract<AppModeEnum, 'workflow'>
export function isWorkflowAppMode(mode?: string | null): mode is WorkflowAppMode {
return mode === AppModeEnum.WORKFLOW
}
export function isWorkflowApp<T extends { mode?: string | null }>(app?: T): app is T & { mode: WorkflowAppMode } {
return isWorkflowAppMode(app?.mode)
}

View File

@ -0,0 +1,23 @@
import type { App } from '@/types/app'
import { isWorkflowAppMode } from './source-app-mode'
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>>
export function workflowSourceAppPickerValue(value: unknown, fallbackId: string): SourceAppPickerValue | undefined {
if (!value || typeof value !== 'object')
return undefined
const record = value as Record<string, unknown>
const mode = typeof record.mode === 'string' ? record.mode : undefined
if (!isWorkflowAppMode(mode))
return undefined
const id = typeof record.id === 'string' && record.id ? record.id : fallbackId
const name = typeof record.name === 'string' && record.name ? record.name : id
return {
id,
name,
mode,
}
}

View File

@ -1,5 +1,5 @@
'use client'
import type { SourceAppPickerValue } from '../state'
import type { SourceAppPickerValue } from './source-app-picker-value'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -22,6 +22,7 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { TitleTooltip } from '../../components/title-tooltip'
import { isWorkflowApp } from './source-app-mode'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
@ -30,18 +31,21 @@ function sourceAppSearchText(app: App) {
return `${app.name} ${app.id}`.toLowerCase()
}
function SourceAppTrigger({ app }: {
function SourceAppTrigger({ open, app, disabled }: {
open: boolean
app?: SourceAppPickerValue
disabled: boolean
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
'flex h-10 items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left',
'cursor-pointer hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'group-data-disabled/combobox-trigger:cursor-not-allowed group-data-disabled/combobox-trigger:text-components-input-text-disabled group-data-disabled/combobox-trigger:hover:border-transparent group-data-disabled/combobox-trigger:hover:bg-components-input-bg-normal',
'group-data-popup-open/combobox-trigger:border-components-input-border-active group-data-popup-open/combobox-trigger:bg-components-input-bg-active group-data-popup-open/combobox-trigger:shadow-xs',
'group flex h-10 items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left',
disabled
? 'cursor-not-allowed text-components-input-text-disabled'
: 'cursor-pointer hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
open && 'border-components-input-border-active bg-components-input-bg-active shadow-xs',
app && 'pl-2',
)}
>
@ -69,9 +73,9 @@ function SourceAppTrigger({ app }: {
</TitleTooltip>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover/combobox-trigger:text-text-secondary',
'group-data-disabled/combobox-trigger:text-text-quaternary group-data-disabled/combobox-trigger:opacity-50',
'group-data-popup-open/combobox-trigger:text-text-secondary',
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
disabled && 'opacity-50 group-hover:text-text-quaternary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
@ -124,9 +128,10 @@ function SourceAppPickerSkeleton() {
)
}
export function SourceAppPicker({ value, onChange, disabled = false }: {
export function SourceAppPicker({ value, onChange, ariaLabel, disabled = false }: {
value?: SourceAppPickerValue
onChange: (app: App) => void
ariaLabel?: string
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
@ -156,7 +161,7 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
enabled: !disabled,
})
const apps = data?.pages.flatMap(page => page.data) ?? []
const apps = data?.pages.flatMap(page => page.data).filter(isWorkflowApp) ?? []
return (
<Combobox<App>
@ -194,11 +199,11 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
disabled={disabled}
>
<ComboboxTrigger
aria-label={t('versions.sourceAppOption')}
aria-label={ariaLabel ?? t('createModal.sourceApp')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger app={value} />
<SourceAppTrigger open={!disabled && isShow} app={value} disabled={disabled} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"

View File

@ -8,11 +8,9 @@ import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
createReleaseDslFileFieldAtom,
createReleaseDslReadErrorAtom,
createReleaseHasUnsupportedDslModeAtom,
createReleaseSelectedSourceAppAtom,
createReleaseSourceModeAtom,
isReadingCreateReleaseDslAtom,
createReleaseDslStateAtom,
createReleaseSourceAppFieldAtom,
createReleaseSourceModeFieldAtom,
selectCreateReleaseSourceModeAtom,
updateCreateReleaseDslFileAtom,
updateCreateReleaseSourceAppAtom,
@ -25,7 +23,7 @@ function selectedReleaseSourceMode(value: readonly ReleaseSourceMode[] | undefin
export function ReleaseSourceSection() {
const { t } = useTranslation('deployments')
const releaseSourceMode = useAtomValue(createReleaseSourceModeAtom)
const sourceModeField = useAtomValue(createReleaseSourceModeFieldAtom)
const selectReleaseSourceMode = useSetAtom(selectCreateReleaseSourceModeAtom)
return (
@ -37,10 +35,10 @@ export function ReleaseSourceSection() {
{isDeploymentDslImportEnabled && (
<SegmentedControl<ReleaseSourceMode>
aria-labelledby="release-source-mode-label"
value={[releaseSourceMode]}
value={[sourceModeField.value]}
onValueChange={(value) => {
const nextMode = selectedReleaseSourceMode(value)
if (!nextMode || nextMode === releaseSourceMode)
if (!nextMode || nextMode === sourceModeField.value)
return
selectReleaseSourceMode(nextMode)
@ -60,7 +58,7 @@ export function ReleaseSourceSection() {
</div>
<div className="min-h-12">
{releaseSourceMode === 'sourceApp'
{sourceModeField.value === 'sourceApp' || !isDeploymentDslImportEnabled
? <SourceAppField />
: <DslFileField />}
</div>
@ -69,15 +67,17 @@ export function ReleaseSourceSection() {
}
function SourceAppField() {
const sourceApp = useAtomValue(createReleaseSelectedSourceAppAtom)
const { t } = useTranslation('deployments')
const sourceAppField = useAtomValue(createReleaseSourceAppFieldAtom)
const updateSourceApp = useSetAtom(updateCreateReleaseSourceAppAtom)
const sourceAppLocked = !isDeploymentDslImportEnabled
return (
<div className="flex min-h-12 items-center">
<SourceAppPicker
value={sourceApp}
value={sourceAppField.value}
onChange={updateSourceApp}
ariaLabel={t('versions.sourceAppOption')}
disabled={sourceAppLocked}
/>
</div>
@ -87,8 +87,7 @@ function SourceAppField() {
function DslFileField() {
const { t } = useTranslation('deployments')
const dslFileField = useAtomValue(createReleaseDslFileFieldAtom)
const isReadingDsl = useAtomValue(isReadingCreateReleaseDslAtom)
const dslReadError = useAtomValue(createReleaseDslReadErrorAtom)
const dslState = useAtomValue(createReleaseDslStateAtom)
const updateDslFile = useSetAtom(updateCreateReleaseDslFileAtom)
return (
@ -100,12 +99,12 @@ function DslFileField() {
}}
className="mt-0"
/>
{isReadingDsl && (
<div role="status" className="system-xs-regular text-text-tertiary">
{dslState.isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('versions.dslReading')}
</div>
)}
{dslReadError && (
{dslState.dslReadError && (
<div role="alert" className="system-xs-regular text-util-colors-red-red-600">
{t('versions.dslReadFailed')}
</div>
@ -117,7 +116,11 @@ function DslFileField() {
function DslUnsupportedModeError() {
const { t } = useTranslation('deployments')
const hasUnsupportedDslMode = useAtomValue(createReleaseHasUnsupportedDslModeAtom)
const dslState = useAtomValue(createReleaseDslStateAtom)
const hasUnsupportedDslMode = dslState.hasDslContent
&& !dslState.isReadingDsl
&& !dslState.dslReadError
&& !dslState.isWorkflowDslContent
if (!hasUnsupportedDslMode)
return null

View File

@ -0,0 +1,109 @@
'use client'
import type { CreateReleaseResponse } from '@dify/contracts/enterprise/types.gen'
import type { CreateReleaseFormValues } from '../state'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentErrorMessage, unsupportedDslNodeError } from '../../shared/domain/error'
import {
clearCreateReleaseSubmissionErrorAtom,
closeCreateReleaseDialogAtom,
createReleaseSubmitUnsupportedDslNodesAtom,
useCreateReleaseConfig,
} from '../state'
import {
canCheckReleaseSourceContent,
useCreateReleaseSourceSelection,
useReleaseContentCheck,
} from './use-release-content-check'
export function useCreateReleaseSubmission(formValues: CreateReleaseFormValues) {
const { t } = useTranslation('deployments')
const { appInstanceId } = useCreateReleaseConfig()
const sourceSelection = useCreateReleaseSourceSelection(formValues)
const releaseContent = useReleaseContentCheck(sourceSelection)
const closeDialog = useSetAtom(closeCreateReleaseDialogAtom)
const createReleaseMutation = useMutation(consoleQuery.enterprise.releaseService.createRelease.mutationOptions())
const clearSubmitError = useSetAtom(clearCreateReleaseSubmissionErrorAtom)
const setUnsupportedDslNodes = useSetAtom(createReleaseSubmitUnsupportedDslNodesAtom)
function clearSubmissionError() {
createReleaseMutation.reset()
clearSubmitError()
}
function handleSuccess(response: CreateReleaseResponse) {
const createdName = response.release.displayName
toast.success(t('versions.createSuccess', { name: createdName }))
closeDialog()
}
async function handleError(error: unknown) {
const unsupportedError = await unsupportedDslNodeError(error)
if (unsupportedError?.nodes.length) {
setUnsupportedDslNodes(unsupportedError.nodes)
return
}
const message = await deploymentErrorMessage(error)
toast.error(message || t('versions.createFailed'))
}
async function createRelease(value: CreateReleaseFormValues) {
if (releaseContent.isCheckingReleaseContent)
return
const submittedReleaseName = value.releaseName.trim()
if (!submittedReleaseName)
return
clearSubmissionError()
try {
if (!canCheckReleaseSourceContent(sourceSelection) || !releaseContent.releaseContentReady)
return
if (sourceSelection.releaseSourceMode === 'dsl') {
if (!sourceSelection.isWorkflowDslContent) {
toast.error(t('versions.dslUnsupportedMode'))
return
}
const response = await createReleaseMutation.mutateAsync({
body: {
appInstanceId,
dsl: sourceSelection.encodedDslContent,
displayName: submittedReleaseName,
description: value.releaseDescription.trim() || undefined,
createAppInstance: false,
},
})
handleSuccess(response)
return
}
if (!sourceSelection.selectedSourceAppId)
return
const response = await createReleaseMutation.mutateAsync({
body: {
appInstanceId,
sourceAppId: sourceSelection.selectedSourceAppId,
displayName: submittedReleaseName,
description: value.releaseDescription.trim() || undefined,
createAppInstance: false,
},
})
handleSuccess(response)
}
catch (error) {
await handleError(error)
}
}
return {
createRelease,
}
}

View File

@ -0,0 +1,129 @@
'use client'
import type { CreateReleaseDslState, CreateReleaseFormValues, ReleaseSourceMode } from '../state'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { consoleQuery } from '@/service/client'
import { isDeploymentDslImportEnabled } from '../../shared/domain/feature-flags'
import {
createReleaseDialogOpenAtom,
createReleaseDslStateAtom,
useCreateReleaseConfig,
} from '../state'
export type CreateReleaseSourceSelection = CreateReleaseDslState & {
hasUnsupportedDslMode: boolean
releaseSourceMode: ReleaseSourceMode
selectedSourceAppId?: string
}
function createReleaseSourceSelection(
formValues: CreateReleaseFormValues,
dslState: CreateReleaseDslState,
): CreateReleaseSourceSelection {
const releaseSourceMode = formValues.releaseSourceMode === 'dsl' && !isDeploymentDslImportEnabled
? 'sourceApp'
: formValues.releaseSourceMode
const hasUnsupportedDslMode = releaseSourceMode === 'dsl'
&& dslState.hasDslContent
&& !dslState.isReadingDsl
&& !dslState.dslReadError
&& !dslState.isWorkflowDslContent
const selectedSourceAppId = releaseSourceMode === 'sourceApp' ? formValues.sourceApp?.id : undefined
return {
...dslState,
hasUnsupportedDslMode,
releaseSourceMode,
selectedSourceAppId,
}
}
export function canCheckReleaseSourceContent(releaseSource: CreateReleaseSourceSelection) {
if (releaseSource.releaseSourceMode === 'sourceApp')
return Boolean(releaseSource.selectedSourceAppId)
if (!isDeploymentDslImportEnabled)
return false
return Boolean(
releaseSource.hasDslContent
&& !releaseSource.isReadingDsl
&& !releaseSource.dslReadError
&& !releaseSource.hasUnsupportedDslMode,
)
}
export function useReleaseContentCheck(releaseSource: CreateReleaseSourceSelection) {
const { appInstanceId } = useCreateReleaseConfig()
const isDialogOpen = useAtomValue(createReleaseDialogOpenAtom)
const canCheckReleaseContent = isDialogOpen && canCheckReleaseSourceContent(releaseSource)
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
const precheckSource = releaseSource.releaseSourceMode === 'sourceApp'
? (releaseSource.selectedSourceAppId ? { sourceAppId: releaseSource.selectedSourceAppId } : undefined)
: { dsl: releaseSource.encodedDslContent }
const precheckInput = canCheckReleaseContent && precheckSource
? {
body: {
appInstanceId,
...precheckSource,
},
}
: undefined
const precheckQuery = useQuery({
...(precheckInput
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: precheckInput,
})
: {
queryFn: skipToken,
queryKey: ['create-release', 'release-precheck'],
}),
retry: false,
})
const matchedRelease = canCheckReleaseContent ? precheckQuery.data?.matchedRelease : undefined
const unsupportedNodes = (canCheckReleaseContent ? precheckQuery.data?.unsupportedNodes : undefined) ?? []
const isCheckingReleaseContent = canCheckReleaseContent && (precheckQuery.isLoading || precheckQuery.isFetching)
const releaseContentCheckFailed = canCheckReleaseContent && precheckQuery.isError
const releaseContentReady = canCheckReleaseContent
&& precheckQuery.isSuccess
&& !isCheckingReleaseContent
&& !releaseContentCheckFailed
&& Boolean(precheckQuery.data?.canCreate)
return {
isCheckingReleaseContent,
matchedRelease,
releaseContentCheckFailed,
releaseContentReady,
unsupportedNodes,
}
}
export type ReleaseContentCheck = ReturnType<typeof useReleaseContentCheck>
export function useCreateReleaseSourceSelection(formValues: CreateReleaseFormValues) {
const dslState = useAtomValue(createReleaseDslStateAtom)
return createReleaseSourceSelection(formValues, dslState)
}
export function createReleaseReadiness({
formValues,
isSubmitting,
releaseContent,
}: {
formValues: CreateReleaseFormValues
isSubmitting: boolean
releaseContent: ReleaseContentCheck
}) {
const canCreate = Boolean(
formValues.releaseName.trim()
&& releaseContent.releaseContentReady
&& !isSubmitting,
)
return {
canCreate,
isCheckingReleaseContent: releaseContent.isCheckingReleaseContent,
}
}