From 7aba539e820331b8a4f773c0fc385bb6d617ca39 Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:55:36 +0800 Subject: [PATCH] feat: app deployment v2 (#39829) Co-authored-by: zhangx1n Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/app/workflow.py | 3 + api/controllers/inner_api/app/dsl.py | 57 +- ...1c7f4e9b3d2_add_workflow_version_number.py | 60 + api/models/__init__.py | 2 + api/models/workflow.py | 30 + api/openapi/markdown/console-openapi.md | 2 + api/services/app_dsl_service.py | 6 + api/services/enterprise/rbac_service.py | 3 + api/services/workflow_service.py | 13 +- .../workflow_version_number_service.py | 59 + .../services/test_app_dsl_service.py | 2 +- .../console/app/test_app_response_models.py | 14 +- .../console/workspace/test_rbac.py | 21 + .../controllers/inner_api/app/test_dsl.py | 164 ++ .../services/enterprise/test_rbac_service.py | 4 + .../services/test_app_dsl_service.py | 18 + .../services/test_workflow_service.py | 162 ++ e2e/features/apps/web-app-service.feature | 3 +- .../access-point-service-api.steps.ts | 16 +- .../agent-v2/access-point-web-app.steps.ts | 2 +- .../apps/app-detail-navigation.steps.ts | 4 +- .../apps/publish-app.steps.ts | 5 +- .../apps/web-app-service.steps.ts | 11 +- knip.config.ts | 2 + oxlint-suppressions.json | 39 - packages/contracts/console.ts | 5 + .../generated/api/console/apps/types.gen.ts | 1 + .../generated/api/console/apps/zod.gen.ts | 1 + .../generated/api/console/rag/types.gen.ts | 1 + .../generated/api/console/rag/zod.gen.ts | 1 + .../api/console/snippets/types.gen.ts | 1 + .../generated/api/console/snippets/zod.gen.ts | 1 + .../enterprise-app-deploy/orpc.gen.ts | 307 +++ .../enterprise-app-deploy/types.gen.ts | 1326 ++++++++++++ .../enterprise-app-deploy/zod.gen.ts | 1224 +++++++++++ ...openapi-ts.enterprise-app-deploy.config.ts | 338 +++ packages/contracts/package.json | 5 + .../app-publisher/deploying-chevron.svg | 3 + .../assets/vender/deploy/code-block.svg | 3 + .../assets/vender/deploy/line-5.svg | 3 + .../assets/vender/deploy/rocket.svg | 4 + .../custom-public/icons.json | 2 +- .../custom-public/index.d.ts | 14 +- .../custom-public/index.js | 2 +- .../custom-vender/icons.json | 20 +- .../custom-vender/index.d.ts | 14 +- .../custom-vender/index.js | 2 +- .../custom-vender/info.json | 2 +- web/__tests__/embedded-user-id-auth.test.tsx | 10 +- .../app/[appId]/access-point/page.tsx | 5 + .../app/[appId]/deploy/page.tsx | 5 + .../[appId]/__tests__/layout-main.spec.tsx | 57 +- .../[appId]/access-point/page.tsx | 11 + .../(appDetailLayout)/[appId]/deploy/page.tsx | 5 + .../(appDetailLayout)/[appId]/layout-main.tsx | 5 +- .../overview/__tests__/card-view.spec.tsx | 228 -- .../[appId]/overview/card-view.tsx | 251 --- .../deployments/__tests__/layout.spec.tsx | 41 +- web/app/(commonLayout)/deployments/layout.tsx | 21 +- .../__tests__/authenticated-layout.spec.tsx | 8 +- .../components/__tests__/splash.spec.tsx | 1 + .../components/authenticated-layout.tsx | 6 +- web/app/(shareLayout)/components/splash.tsx | 21 +- .../env/workflow/[token]/page.tsx | 13 + .../__tests__/login-redirect.spec.ts | 15 + .../webapp-signin/__tests__/page.spec.tsx | 53 +- .../webapp-signin/check-code/page.tsx | 2 +- .../components/mail-and-password-auth.tsx | 2 +- .../webapp-signin/login-redirect.ts | 9 +- web/app/(shareLayout)/webapp-signin/page.tsx | 6 +- .../__tests__/app-detail-section.spec.tsx | 60 +- .../app-sidebar/__tests__/basic.spec.tsx | 104 - .../app-sidebar/app-detail-section.tsx | 69 +- .../__tests__/app-info-detail-panel.spec.tsx | 378 ---- .../__tests__/app-info-trigger.spec.tsx | 197 +- .../__tests__/use-app-info-actions.spec.ts | 39 +- .../app-info/app-info-detail-drawer.tsx | 41 - .../app-info/app-info-detail-panel.tsx | 198 -- .../app-sidebar/app-info/app-info-modals.tsx | 2 +- .../app-sidebar/app-info/app-info-trigger.tsx | 174 +- .../app-sidebar/app-info/app-operations.tsx | 252 +-- .../components/app-sidebar/app-info/index.tsx | 70 +- .../app-info/use-app-info-actions.ts | 30 +- web/app/components/app-sidebar/basic.tsx | 127 -- .../__tests__/access-point-card.spec.tsx | 49 + .../__tests__/access-point-url.spec.tsx | 33 + .../__tests__/api-secret-key-button.spec.tsx | 98 + .../__tests__/built-in-access-points.spec.tsx | 221 ++ ...eployed-environment-access-points.spec.tsx | 79 + .../environment-access-control.spec.tsx | 205 ++ .../environment-access-point-cards.spec.tsx | 403 ++++ .../app/access-point/__tests__/index.spec.tsx | 279 +++ .../access-point/__tests__/mcp-card.spec.tsx | 164 ++ .../__tests__/service-api-card.spec.tsx | 120 ++ .../__tests__/trigger-card.spec.tsx | 148 ++ .../__tests__/web-app-card.spec.tsx | 200 ++ .../built-in-access-points/index.tsx | 162 ++ .../built-in-access-points/mcp-card.tsx | 223 ++ .../service-api-card.tsx | 44 + .../built-in-access-points/trigger-card.tsx | 208 ++ .../built-in-access-points/web-app-card.tsx | 276 +++ .../environment-access-control.tsx | 171 ++ .../environment-service-api-card.tsx | 84 + .../environment-web-app-card.tsx | 290 +++ .../environment-web-app-utils.ts | 8 + .../index.tsx | 100 + web/app/components/app/access-point/index.tsx | 151 ++ .../access-point/shared/access-point-card.tsx | 176 ++ .../shared/access-point-status.ts | 11 + .../access-point/shared/access-point-url.tsx | 134 ++ .../shared/api-secret-key-button.tsx | 73 + .../shared/service-api-card-view.tsx | 93 + .../shared/use-access-point-actions.ts | 133 ++ .../app/access-point/shared/utils.ts | 80 + .../shared/web-app-access-control.tsx | 60 + web/app/components/app/access-point/state.ts | 60 + .../__tests__/access-control.spec.tsx | 333 +-- .../add-member-or-group-pop.spec.tsx | 82 +- .../__tests__/index.spec.tsx | 163 +- .../specific-groups-or-members.spec.tsx | 125 +- .../access-control-form.tsx | 143 ++ .../add-member-or-group-pop.tsx | 407 ---- .../add-member-or-group-pop/breadcrumb.tsx | 60 + .../add-member-or-group-pop/index.tsx | 226 ++ .../add-member-or-group-pop/subject-item.tsx | 160 ++ .../app/app-access-control/index.tsx | 204 +- .../specific-groups-or-members.tsx | 155 +- .../environment-deployment-flow.spec.tsx | 818 ++++++++ .../__tests__/environment-tabs.spec.tsx | 185 ++ .../__tests__/features-wrapper.spec.tsx | 20 +- .../app-publisher/__tests__/index.spec.tsx | 936 ++++++--- .../publish-with-multiple-model.spec.tsx | 21 + .../app-publisher/__tests__/sections.spec.tsx | 604 ++++-- .../app-publisher/__tests__/state.spec.tsx | 635 ++++++ .../__tests__/suggested-action.spec.tsx | 148 +- .../app/app-publisher/__tests__/utils.spec.ts | 28 +- .../__tests__/version-info-modal.spec.tsx | 10 +- .../built-in-publisher/actions-section.tsx | 164 ++ .../built-in-publisher/constants.ts | 7 + .../built-in-publisher/index.tsx | 17 + .../built-in-publisher/summary-section.tsx | 248 +++ .../actions-section.tsx | 43 + .../environment-deployment-flow/index.tsx | 101 + .../latest-version-row.tsx | 63 + .../summary-section.tsx | 207 ++ .../environment-tabs/__tests__/layout.spec.ts | 81 + .../environment-tabs/environment-button.tsx | 39 + .../environment-menu-item.tsx | 19 + .../app-publisher/environment-tabs/index.tsx | 201 ++ .../app-publisher/environment-tabs/layout.ts | 103 + .../environment-tabs/measurement-probe.tsx | 51 + .../app-publisher/environment-tabs/types.ts | 20 + .../environment-tabs/use-measurements.ts | 120 ++ .../app/app-publisher/features-wrapper.tsx | 19 +- ...p-environments-after-deployment-polling.ts | 121 ++ .../components/app/app-publisher/index.tsx | 532 +---- .../publish-with-multiple-model.tsx | 10 +- .../app-publisher/publisher-content/index.tsx | 286 +++ .../publisher-panel/index.tsx | 60 + .../use-marketplace-publish.ts | 28 + .../use-publish-controller.ts | 169 ++ .../publisher-content/use-version-info.ts | 64 + .../publisher-content/use-workflow-launch.ts | 27 + .../publisher-content/use-workflow-tool.ts | 103 + .../index.module.css | 100 + .../publisher-deploying-marker/index.tsx | 31 + .../components/app/app-publisher/sections.tsx | 390 ---- .../app-publisher/shared/timeline-marker.tsx | 24 + web/app/components/app/app-publisher/state.ts | 362 ++++ .../app/app-publisher/suggested-action.tsx | 211 +- web/app/components/app/app-publisher/types.ts | 35 + web/app/components/app/app-publisher/utils.ts | 47 - .../app/app-publisher/version-info-modal.tsx | 10 +- .../workflow-tool-action/disabled-reason.tsx | 11 + .../workflow-tool-action/index.tsx | 124 ++ .../workflow-tool-action/loading-status.tsx | 15 + .../workflow-tool-action/setup-status.tsx | 17 + .../workflow-tool-action/state-label.tsx | 25 + .../__tests__/configuration-view.spec.tsx | 7 +- .../config-var/__tests__/index.spec.tsx | 20 + .../app/configuration/config-var/index.tsx | 15 +- .../index.tsx} | 41 +- .../configuration-view/legacy-agent-badge.tsx | 41 + .../__tests__/use-configuration-utils.spec.ts | 164 +- .../__tests__/use-configuration.spec.tsx | 169 ++ .../hooks/build-configuration-context.ts | 107 + .../hooks/configuration-lifecycle/dataset.ts | 206 ++ .../hooks/configuration-lifecycle/load.ts | 115 + .../hooks/configuration-lifecycle/model.ts | 169 ++ .../configuration-lifecycle/prompt-config.ts | 15 + .../hooks/configuration-lifecycle/publish.ts | 251 +++ .../published-config.ts | 169 ++ .../hooks/configuration-lifecycle/types.ts | 13 + .../use-configuration-app-context.ts | 66 + .../use-configuration-loader.ts | 48 + .../use-configuration-publish.ts | 127 ++ .../use-dataset-configuration-state.ts | 35 + .../use-feature-configuration-state.ts | 72 + .../use-model-configuration-state.ts | 105 + .../use-multiple-model-debug.ts | 44 + .../use-published-config-sync.ts | 118 ++ .../hooks/configuration-view-model.ts | 52 + .../hooks/use-configuration-utils.ts | 770 ------- .../configuration/hooks/use-configuration.ts | 816 ++------ .../app/deploy/__tests__/index.spec.tsx | 1851 +++++++++++++++++ .../app/deploy/__tests__/state.spec.tsx | 219 ++ .../app/deploy/__tests__/version.spec.ts | 36 + web/app/components/app/deploy/access-point.ts | 17 + .../components/app/deploy/assets/github.png | Bin 0 -> 2961 bytes .../components/app/deploy/assets/moonshot.png | Bin 0 -> 2596 bytes .../components/app/deploy/assets/slack.png | Bin 0 -> 2792 bytes .../built-in-environment-card/index.tsx | 114 + .../__tests__/credential-field.spec.tsx | 92 + .../deployment-configuration/content.tsx | 262 +++ .../credential-field.tsx | 124 ++ .../deployment-precheck-alert.tsx | 63 + .../environment-variable-field.tsx | 129 ++ .../deployment-configuration/index.tsx | 139 ++ .../use-deployment-configuration-queries.ts | 70 + .../use-deployment-configuration-values.ts | 23 + .../use-provider-icon.ts | 67 + .../workflow-deployment-input.ts | 82 + .../app/deploy/deployment-dialog/index.tsx | 62 + .../app/deploy/deployment-dialog/types.ts | 18 + .../version-selection/index.tsx | 279 +++ .../environment-deployment-flow/index.tsx | 102 + .../environment-table/activity-cell.tsx | 62 + .../environment-table/deploy-menu/index.tsx | 73 + .../environment-table/empty-state/data.ts | 119 ++ .../environment-table/empty-state/index.tsx | 58 + .../empty-state/skeleton.tsx | 191 ++ .../app/deploy/environment-table/index.tsx | 148 ++ .../deploy/environment-table/row-actions.tsx | 184 ++ .../app/deploy/environment-table/row.tsx | 79 + .../app/deploy/environment-table/types.ts | 3 + .../undeploy-confirm-dialog/index.tsx | 74 + .../__tests__/use-infinite-scroll.spec.ts | 149 ++ .../app/deploy/hooks/use-infinite-scroll.ts | 142 ++ web/app/components/app/deploy/index.tsx | 155 ++ .../app/deploy/shared/access-point-icon.tsx | 69 + .../app/deploy/shared/deployment-status.tsx | 74 + .../app/deploy/shared/version-label.tsx | 112 + web/app/components/app/deploy/state.ts | 268 +++ .../app/deploy/use-deploy-workflow.ts | 41 + ...p-environments-after-deployment-polling.ts | 54 + .../app/deploy/use-undeploy-workflow.ts | 28 + web/app/components/app/deploy/version.ts | 41 + .../__tests__/app-card-sections.spec.tsx | 349 ---- .../overview/__tests__/app-card-utils.spec.ts | 240 +-- .../app/overview/__tests__/app-card.spec.tsx | 600 ------ .../overview/__tests__/toggle-logic.test.ts | 133 -- .../overview/__tests__/trigger-card.spec.tsx | 415 ---- .../__tests__/workflow-launch-dialog.spec.tsx | 99 + .../app/overview/app-card-sections.tsx | 511 ----- .../components/app/overview/app-card-utils.ts | 148 +- web/app/components/app/overview/app-card.tsx | 473 ----- .../app/overview/assets/code-browser.svg | 3 - .../app/overview/assets/refresh-hover.svg | 3 - .../app/overview/assets/refresh.svg | 3 - .../settings/__tests__/index.spec.tsx | 25 +- .../app/overview/settings/index.tsx | 38 +- .../components/app/overview/style.module.css | 32 - .../components/app/overview/trigger-card.tsx | 237 --- .../overview/workflow-launch-dialog/index.tsx | 110 + .../apps/__tests__/app-card.spec.tsx | 125 +- .../components/apps/__tests__/list.spec.tsx | 15 +- .../action-bar/index.tsx} | 508 +---- web/app/components/apps/app-card/index.tsx | 233 +++ .../apps/app-card/operations-menu.tsx | 201 ++ web/app/components/apps/starred-app-card.tsx | 2 +- .../annotation-reply/index.tsx | 2 +- .../datasets/extra-info/service-api/index.tsx | 1 + .../__tests__/secret-key-button.spec.tsx | 9 +- .../__tests__/secret-key-modal.spec.tsx | 407 ++-- .../develop/secret-key/secret-key-button.tsx | 2 +- .../secret-key/secret-key-modal/index.tsx | 154 ++ .../view.tsx} | 179 +- .../__tests__/index.spec.tsx | 1 + .../main-nav/__tests__/index.spec.tsx | 21 +- web/app/components/main-nav/routes.ts | 10 - .../hooks/use-app-inputs-form-schema.ts | 24 +- .../__tests__/use-nodes-sync-draft.spec.ts | 108 +- .../hooks/__tests__/use-pipeline-init.spec.ts | 8 + .../hooks/use-nodes-sync-draft.ts | 64 +- .../rag-pipeline/hooks/use-pipeline-init.ts | 1 + .../__tests__/menu-dropdown.spec.tsx | 8 +- .../share/text-generation/menu-dropdown.tsx | 4 +- .../hooks/__tests__/use-snippet-init.spec.ts | 10 + .../snippets/hooks/use-snippet-init.ts | 1 + .../mcp/__tests__/mcp-service-card.spec.tsx | 519 ----- .../__tests__/use-mcp-service-card.spec.ts | 460 ---- .../tools/mcp/hooks/use-mcp-service-card.ts | 210 -- .../components/tools/mcp/mcp-service-card.tsx | 468 ----- .../__tests__/configure-button.spec.tsx | 1500 ------------- .../tools/workflow-tool/configure-button.tsx | 108 - .../workflow-app/__tests__/index.spec.tsx | 52 +- .../__tests__/features-trigger.spec.tsx | 2 +- .../workflow-header/features-trigger.tsx | 2 +- .../__tests__/use-nodes-sync-draft.spec.ts | 51 + .../hooks/__tests__/use-workflow-init.spec.ts | 41 +- .../hooks/use-nodes-sync-draft.ts | 35 +- .../workflow-app/hooks/use-workflow-init.ts | 43 +- web/app/components/workflow-app/index.tsx | 16 - .../workflow/__tests__/index.spec.tsx | 55 + .../__tests__/workflow-edge-events.spec.tsx | 19 + .../header/__tests__/header-layouts.spec.tsx | 3 +- .../header/__tests__/restoring-title.spec.tsx | 16 + .../workflow/header/header-in-restoring.tsx | 8 +- .../workflow/header/restoring-title.tsx | 15 +- .../workflow/hooks/use-tool-icon.ts | 12 +- web/app/components/workflow/index.tsx | 10 +- .../__tests__/delete-confirm-modal.spec.tsx | 15 + .../__tests__/index.spec.tsx | 133 +- .../__tests__/restore-confirm-modal.spec.tsx | 13 + .../__tests__/version-history-item.spec.tsx | 43 + .../action-menu/__tests__/index.spec.tsx | 7 +- .../__tests__/use-action-menu.spec.tsx | 4 + .../action-menu/action-menu-item.tsx | 15 +- .../action-menu/index.tsx | 11 +- .../action-menu/use-action-menu.ts | 5 +- .../delete-confirm-modal.tsx | 6 +- .../panel/version-history-panel/index.tsx | 35 +- .../restore-confirm-modal.tsx | 6 +- .../version-history-item.tsx | 22 +- .../workflow/store/workflow/history-slice.ts | 5 + .../workflow/utils/__tests__/version.spec.ts | 39 + web/app/components/workflow/utils/version.ts | 14 + web/context/access-control-store.ts | 35 - web/context/web-app-context.tsx | 7 +- .../__tests__/access-surface-cards.spec.tsx | 17 + .../continue-work/__tests__/item.spec.tsx | 4 +- web/i18n/ar-TN/agent-v-2.json | 4 +- web/i18n/ar-TN/app-api.json | 2 +- web/i18n/ar-TN/app-overview.json | 9 +- web/i18n/ar-TN/common.json | 2 + web/i18n/ar-TN/deployments.json | 61 +- web/i18n/ar-TN/permission-keys.json | 1 + web/i18n/ar-TN/tools.json | 2 +- web/i18n/ar-TN/workflow.json | 17 + web/i18n/de-DE/agent-v-2.json | 2 +- web/i18n/de-DE/app-api.json | 2 +- web/i18n/de-DE/app-overview.json | 11 +- web/i18n/de-DE/common.json | 2 + web/i18n/de-DE/deployments.json | 61 +- web/i18n/de-DE/permission-keys.json | 1 + web/i18n/de-DE/tools.json | 2 +- web/i18n/de-DE/workflow.json | 17 + web/i18n/en-US/agent-v-2.json | 6 +- web/i18n/en-US/app-api.json | 2 +- web/i18n/en-US/app-overview.json | 13 +- web/i18n/en-US/common.json | 4 +- web/i18n/en-US/deployments.json | 61 +- web/i18n/en-US/permission-keys.json | 1 + web/i18n/en-US/tools.json | 2 +- web/i18n/en-US/workflow.json | 21 +- web/i18n/es-ES/agent-v-2.json | 4 +- web/i18n/es-ES/app-api.json | 2 +- web/i18n/es-ES/app-overview.json | 9 +- web/i18n/es-ES/common.json | 2 + web/i18n/es-ES/deployments.json | 61 +- web/i18n/es-ES/permission-keys.json | 1 + web/i18n/es-ES/tools.json | 2 +- web/i18n/es-ES/workflow.json | 17 + web/i18n/fa-IR/agent-v-2.json | 4 +- web/i18n/fa-IR/app-api.json | 2 +- web/i18n/fa-IR/app-overview.json | 9 +- web/i18n/fa-IR/common.json | 2 + web/i18n/fa-IR/deployments.json | 61 +- web/i18n/fa-IR/permission-keys.json | 1 + web/i18n/fa-IR/tools.json | 2 +- web/i18n/fa-IR/workflow.json | 17 + web/i18n/fr-FR/agent-v-2.json | 4 +- web/i18n/fr-FR/app-api.json | 2 +- web/i18n/fr-FR/app-overview.json | 7 +- web/i18n/fr-FR/common.json | 2 + web/i18n/fr-FR/deployments.json | 61 +- web/i18n/fr-FR/permission-keys.json | 1 + web/i18n/fr-FR/tools.json | 2 +- web/i18n/fr-FR/workflow.json | 17 + web/i18n/hi-IN/agent-v-2.json | 4 +- web/i18n/hi-IN/app-api.json | 2 +- web/i18n/hi-IN/app-overview.json | 9 +- web/i18n/hi-IN/common.json | 2 + web/i18n/hi-IN/deployments.json | 61 +- web/i18n/hi-IN/permission-keys.json | 1 + web/i18n/hi-IN/tools.json | 2 +- web/i18n/hi-IN/workflow.json | 17 + web/i18n/id-ID/agent-v-2.json | 4 +- web/i18n/id-ID/app-api.json | 2 +- web/i18n/id-ID/app-overview.json | 9 +- web/i18n/id-ID/common.json | 2 + web/i18n/id-ID/deployments.json | 61 +- web/i18n/id-ID/permission-keys.json | 1 + web/i18n/id-ID/tools.json | 2 +- web/i18n/id-ID/workflow.json | 17 + web/i18n/it-IT/agent-v-2.json | 4 +- web/i18n/it-IT/app-api.json | 2 +- web/i18n/it-IT/app-overview.json | 9 +- web/i18n/it-IT/common.json | 2 + web/i18n/it-IT/deployments.json | 61 +- web/i18n/it-IT/permission-keys.json | 1 + web/i18n/it-IT/tools.json | 2 +- web/i18n/it-IT/workflow.json | 17 + web/i18n/ja-JP/agent-v-2.json | 4 +- web/i18n/ja-JP/app-api.json | 2 +- web/i18n/ja-JP/app-overview.json | 9 +- web/i18n/ja-JP/common.json | 2 + web/i18n/ja-JP/deployments.json | 61 +- web/i18n/ja-JP/permission-keys.json | 1 + web/i18n/ja-JP/tools.json | 2 +- web/i18n/ja-JP/workflow.json | 17 + web/i18n/ko-KR/agent-v-2.json | 4 +- web/i18n/ko-KR/app-api.json | 2 +- web/i18n/ko-KR/app-overview.json | 9 +- web/i18n/ko-KR/common.json | 2 + web/i18n/ko-KR/deployments.json | 61 +- web/i18n/ko-KR/permission-keys.json | 1 + web/i18n/ko-KR/tools.json | 2 +- web/i18n/ko-KR/workflow.json | 17 + web/i18n/lo-LA/agent-v-2.json | 2 +- web/i18n/lo-LA/app-api.json | 2 +- web/i18n/lo-LA/app-overview.json | 8 +- web/i18n/lo-LA/deployments.json | 17 + web/i18n/lo-LA/tools.json | 2 +- web/i18n/lo-LA/workflow.json | 2 + web/i18n/nl-NL/agent-v-2.json | 2 +- web/i18n/nl-NL/app-api.json | 2 +- web/i18n/nl-NL/app-overview.json | 13 +- web/i18n/nl-NL/common.json | 4 +- web/i18n/nl-NL/deployments.json | 59 +- web/i18n/nl-NL/permission-keys.json | 1 + web/i18n/nl-NL/tools.json | 2 +- web/i18n/nl-NL/workflow.json | 17 + web/i18n/pl-PL/agent-v-2.json | 4 +- web/i18n/pl-PL/app-api.json | 2 +- web/i18n/pl-PL/app-overview.json | 9 +- web/i18n/pl-PL/common.json | 2 + web/i18n/pl-PL/deployments.json | 61 +- web/i18n/pl-PL/permission-keys.json | 1 + web/i18n/pl-PL/tools.json | 2 +- web/i18n/pl-PL/workflow.json | 17 + web/i18n/pt-BR/agent-v-2.json | 4 +- web/i18n/pt-BR/app-api.json | 2 +- web/i18n/pt-BR/app-overview.json | 9 +- web/i18n/pt-BR/common.json | 4 +- web/i18n/pt-BR/deployments.json | 61 +- web/i18n/pt-BR/permission-keys.json | 1 + web/i18n/pt-BR/tools.json | 2 +- web/i18n/pt-BR/workflow.json | 17 + web/i18n/ro-RO/agent-v-2.json | 4 +- web/i18n/ro-RO/app-api.json | 2 +- web/i18n/ro-RO/app-overview.json | 9 +- web/i18n/ro-RO/common.json | 2 + web/i18n/ro-RO/deployments.json | 61 +- web/i18n/ro-RO/permission-keys.json | 1 + web/i18n/ro-RO/tools.json | 2 +- web/i18n/ro-RO/workflow.json | 17 + web/i18n/ru-RU/agent-v-2.json | 4 +- web/i18n/ru-RU/app-api.json | 2 +- web/i18n/ru-RU/app-overview.json | 9 +- web/i18n/ru-RU/common.json | 2 + web/i18n/ru-RU/deployments.json | 61 +- web/i18n/ru-RU/permission-keys.json | 1 + web/i18n/ru-RU/tools.json | 2 +- web/i18n/ru-RU/workflow.json | 17 + web/i18n/sl-SI/agent-v-2.json | 4 +- web/i18n/sl-SI/app-api.json | 2 +- web/i18n/sl-SI/app-overview.json | 9 +- web/i18n/sl-SI/common.json | 2 + web/i18n/sl-SI/deployments.json | 61 +- web/i18n/sl-SI/permission-keys.json | 1 + web/i18n/sl-SI/tools.json | 2 +- web/i18n/sl-SI/workflow.json | 17 + web/i18n/th-TH/agent-v-2.json | 4 +- web/i18n/th-TH/app-api.json | 2 +- web/i18n/th-TH/app-overview.json | 9 +- web/i18n/th-TH/common.json | 2 + web/i18n/th-TH/deployments.json | 61 +- web/i18n/th-TH/permission-keys.json | 1 + web/i18n/th-TH/tools.json | 2 +- web/i18n/th-TH/workflow.json | 17 + web/i18n/tr-TR/agent-v-2.json | 4 +- web/i18n/tr-TR/app-api.json | 2 +- web/i18n/tr-TR/app-overview.json | 13 +- web/i18n/tr-TR/common.json | 4 +- web/i18n/tr-TR/deployments.json | 61 +- web/i18n/tr-TR/permission-keys.json | 1 + web/i18n/tr-TR/tools.json | 2 +- web/i18n/tr-TR/workflow.json | 17 + web/i18n/uk-UA/agent-v-2.json | 4 +- web/i18n/uk-UA/app-api.json | 2 +- web/i18n/uk-UA/app-overview.json | 9 +- web/i18n/uk-UA/common.json | 2 + web/i18n/uk-UA/deployments.json | 61 +- web/i18n/uk-UA/permission-keys.json | 1 + web/i18n/uk-UA/tools.json | 2 +- web/i18n/uk-UA/workflow.json | 17 + web/i18n/vi-VN/agent-v-2.json | 4 +- web/i18n/vi-VN/app-api.json | 2 +- web/i18n/vi-VN/app-overview.json | 9 +- web/i18n/vi-VN/common.json | 2 + web/i18n/vi-VN/deployments.json | 61 +- web/i18n/vi-VN/permission-keys.json | 1 + web/i18n/vi-VN/tools.json | 2 +- web/i18n/vi-VN/workflow.json | 17 + web/i18n/zh-Hans/agent-v-2.json | 4 +- web/i18n/zh-Hans/app-overview.json | 13 +- web/i18n/zh-Hans/common.json | 2 + web/i18n/zh-Hans/deployments.json | 61 +- web/i18n/zh-Hans/permission-keys.json | 1 + web/i18n/zh-Hans/tools.json | 2 +- web/i18n/zh-Hans/workflow.json | 17 + web/i18n/zh-Hant/agent-v-2.json | 4 +- web/i18n/zh-Hant/app-overview.json | 13 +- web/i18n/zh-Hant/common.json | 2 + web/i18n/zh-Hant/deployments.json | 61 +- web/i18n/zh-Hant/permission-keys.json | 1 + web/i18n/zh-Hant/tools.json | 2 +- web/i18n/zh-Hant/workflow.json | 17 + web/service/__tests__/use-workflow.spec.tsx | 56 + web/service/base.spec.ts | 61 +- web/service/base.ts | 38 +- web/service/client.spec.ts | 136 ++ web/service/client.ts | 110 + web/service/console-router-loader.ts | 13 +- web/service/fetch.spec.ts | 74 + web/service/fetch.ts | 32 +- web/service/use-base.ts | 12 - web/service/use-share.spec.tsx | 15 + web/service/use-share.ts | 23 +- web/service/use-workflow.ts | 28 +- web/service/webapp-address.spec.ts | 46 + web/service/webapp-address.ts | 74 + web/service/webapp-auth.spec.ts | 86 + web/service/webapp-auth.ts | 34 +- web/service/workflow-queries.ts | 38 + web/service/workflow.ts | 4 - web/types/workflow.ts | 7 + web/utils/app-redirection.spec.ts | 26 +- web/utils/app-redirection.ts | 5 +- web/utils/permission.spec.ts | 10 + web/utils/permission.ts | 7 + 542 files changed, 29450 insertions(+), 13824 deletions(-) create mode 100644 api/migrations/versions/2026_08_05_1030-a1c7f4e9b3d2_add_workflow_version_number.py create mode 100644 api/services/workflow_version_number_service.py create mode 100644 packages/contracts/generated/enterprise-app-deploy/orpc.gen.ts create mode 100644 packages/contracts/generated/enterprise-app-deploy/types.gen.ts create mode 100644 packages/contracts/generated/enterprise-app-deploy/zod.gen.ts create mode 100644 packages/contracts/openapi-ts.enterprise-app-deploy.config.ts create mode 100644 packages/iconify-collections/assets/vender/app-publisher/deploying-chevron.svg create mode 100644 packages/iconify-collections/assets/vender/deploy/code-block.svg create mode 100644 packages/iconify-collections/assets/vender/deploy/line-5.svg create mode 100644 packages/iconify-collections/assets/vender/deploy/rocket.svg create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/access-point/page.tsx create mode 100644 web/app/(commonLayout)/@detailSidebar/app/[appId]/deploy/page.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-point/page.tsx create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/deploy/page.tsx delete mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx delete mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx create mode 100644 web/app/(shareLayout)/env/workflow/[token]/page.tsx delete mode 100644 web/app/components/app-sidebar/__tests__/basic.spec.tsx delete mode 100644 web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx delete mode 100644 web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx delete mode 100644 web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx delete mode 100644 web/app/components/app-sidebar/basic.tsx create mode 100644 web/app/components/app/access-point/__tests__/access-point-card.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/access-point-url.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/api-secret-key-button.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/environment-access-control.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/index.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/mcp-card.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/service-api-card.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/trigger-card.spec.tsx create mode 100644 web/app/components/app/access-point/__tests__/web-app-card.spec.tsx create mode 100644 web/app/components/app/access-point/built-in-access-points/index.tsx create mode 100644 web/app/components/app/access-point/built-in-access-points/mcp-card.tsx create mode 100644 web/app/components/app/access-point/built-in-access-points/service-api-card.tsx create mode 100644 web/app/components/app/access-point/built-in-access-points/trigger-card.tsx create mode 100644 web/app/components/app/access-point/built-in-access-points/web-app-card.tsx create mode 100644 web/app/components/app/access-point/deployed-environment-access-points/environment-access-control.tsx create mode 100644 web/app/components/app/access-point/deployed-environment-access-points/environment-service-api-card.tsx create mode 100644 web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-card.tsx create mode 100644 web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-utils.ts create mode 100644 web/app/components/app/access-point/deployed-environment-access-points/index.tsx create mode 100644 web/app/components/app/access-point/index.tsx create mode 100644 web/app/components/app/access-point/shared/access-point-card.tsx create mode 100644 web/app/components/app/access-point/shared/access-point-status.ts create mode 100644 web/app/components/app/access-point/shared/access-point-url.tsx create mode 100644 web/app/components/app/access-point/shared/api-secret-key-button.tsx create mode 100644 web/app/components/app/access-point/shared/service-api-card-view.tsx create mode 100644 web/app/components/app/access-point/shared/use-access-point-actions.ts create mode 100644 web/app/components/app/access-point/shared/utils.ts create mode 100644 web/app/components/app/access-point/shared/web-app-access-control.tsx create mode 100644 web/app/components/app/access-point/state.ts create mode 100644 web/app/components/app/app-access-control/access-control-form.tsx delete mode 100644 web/app/components/app/app-access-control/add-member-or-group-pop.tsx create mode 100644 web/app/components/app/app-access-control/add-member-or-group-pop/breadcrumb.tsx create mode 100644 web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx create mode 100644 web/app/components/app/app-access-control/add-member-or-group-pop/subject-item.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/environment-tabs.spec.tsx create mode 100644 web/app/components/app/app-publisher/__tests__/state.spec.tsx create mode 100644 web/app/components/app/app-publisher/built-in-publisher/actions-section.tsx create mode 100644 web/app/components/app/app-publisher/built-in-publisher/constants.ts create mode 100644 web/app/components/app/app-publisher/built-in-publisher/index.tsx create mode 100644 web/app/components/app/app-publisher/built-in-publisher/summary-section.tsx create mode 100644 web/app/components/app/app-publisher/environment-deployment-flow/actions-section.tsx create mode 100644 web/app/components/app/app-publisher/environment-deployment-flow/index.tsx create mode 100644 web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx create mode 100644 web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx create mode 100644 web/app/components/app/app-publisher/environment-tabs/__tests__/layout.spec.ts create mode 100644 web/app/components/app/app-publisher/environment-tabs/environment-button.tsx create mode 100644 web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx create mode 100644 web/app/components/app/app-publisher/environment-tabs/index.tsx create mode 100644 web/app/components/app/app-publisher/environment-tabs/layout.ts create mode 100644 web/app/components/app/app-publisher/environment-tabs/measurement-probe.tsx create mode 100644 web/app/components/app/app-publisher/environment-tabs/types.ts create mode 100644 web/app/components/app/app-publisher/environment-tabs/use-measurements.ts create mode 100644 web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts create mode 100644 web/app/components/app/app-publisher/publisher-content/index.tsx create mode 100644 web/app/components/app/app-publisher/publisher-content/publisher-panel/index.tsx create mode 100644 web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts create mode 100644 web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts create mode 100644 web/app/components/app/app-publisher/publisher-content/use-version-info.ts create mode 100644 web/app/components/app/app-publisher/publisher-content/use-workflow-launch.ts create mode 100644 web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts create mode 100644 web/app/components/app/app-publisher/publisher-deploying-marker/index.module.css create mode 100644 web/app/components/app/app-publisher/publisher-deploying-marker/index.tsx delete mode 100644 web/app/components/app/app-publisher/sections.tsx create mode 100644 web/app/components/app/app-publisher/shared/timeline-marker.tsx create mode 100644 web/app/components/app/app-publisher/state.ts create mode 100644 web/app/components/app/app-publisher/types.ts create mode 100644 web/app/components/app/app-publisher/workflow-tool-action/disabled-reason.tsx create mode 100644 web/app/components/app/app-publisher/workflow-tool-action/index.tsx create mode 100644 web/app/components/app/app-publisher/workflow-tool-action/loading-status.tsx create mode 100644 web/app/components/app/app-publisher/workflow-tool-action/setup-status.tsx create mode 100644 web/app/components/app/app-publisher/workflow-tool-action/state-label.tsx rename web/app/components/app/configuration/{configuration-view.tsx => configuration-view/index.tsx} (87%) create mode 100644 web/app/components/app/configuration/configuration-view/legacy-agent-badge.tsx create mode 100644 web/app/components/app/configuration/hooks/build-configuration-context.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/dataset.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/model.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/prompt-config.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/publish.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/published-config.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/types.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-loader.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-publish.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-dataset-configuration-state.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-feature-configuration-state.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-model-configuration-state.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-multiple-model-debug.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-lifecycle/use-published-config-sync.ts create mode 100644 web/app/components/app/configuration/hooks/configuration-view-model.ts delete mode 100644 web/app/components/app/configuration/hooks/use-configuration-utils.ts create mode 100644 web/app/components/app/deploy/__tests__/index.spec.tsx create mode 100644 web/app/components/app/deploy/__tests__/state.spec.tsx create mode 100644 web/app/components/app/deploy/__tests__/version.spec.ts create mode 100644 web/app/components/app/deploy/access-point.ts create mode 100644 web/app/components/app/deploy/assets/github.png create mode 100644 web/app/components/app/deploy/assets/moonshot.png create mode 100644 web/app/components/app/deploy/assets/slack.png create mode 100644 web/app/components/app/deploy/built-in-environment-card/index.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-provider-icon.ts create mode 100644 web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts create mode 100644 web/app/components/app/deploy/deployment-dialog/index.tsx create mode 100644 web/app/components/app/deploy/deployment-dialog/types.ts create mode 100644 web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx create mode 100644 web/app/components/app/deploy/environment-deployment-flow/index.tsx create mode 100644 web/app/components/app/deploy/environment-table/activity-cell.tsx create mode 100644 web/app/components/app/deploy/environment-table/deploy-menu/index.tsx create mode 100644 web/app/components/app/deploy/environment-table/empty-state/data.ts create mode 100644 web/app/components/app/deploy/environment-table/empty-state/index.tsx create mode 100644 web/app/components/app/deploy/environment-table/empty-state/skeleton.tsx create mode 100644 web/app/components/app/deploy/environment-table/index.tsx create mode 100644 web/app/components/app/deploy/environment-table/row-actions.tsx create mode 100644 web/app/components/app/deploy/environment-table/row.tsx create mode 100644 web/app/components/app/deploy/environment-table/types.ts create mode 100644 web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx create mode 100644 web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts create mode 100644 web/app/components/app/deploy/hooks/use-infinite-scroll.ts create mode 100644 web/app/components/app/deploy/index.tsx create mode 100644 web/app/components/app/deploy/shared/access-point-icon.tsx create mode 100644 web/app/components/app/deploy/shared/deployment-status.tsx create mode 100644 web/app/components/app/deploy/shared/version-label.tsx create mode 100644 web/app/components/app/deploy/state.ts create mode 100644 web/app/components/app/deploy/use-deploy-workflow.ts create mode 100644 web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts create mode 100644 web/app/components/app/deploy/use-undeploy-workflow.ts create mode 100644 web/app/components/app/deploy/version.ts delete mode 100644 web/app/components/app/overview/__tests__/app-card-sections.spec.tsx delete mode 100644 web/app/components/app/overview/__tests__/app-card.spec.tsx delete mode 100644 web/app/components/app/overview/__tests__/toggle-logic.test.ts delete mode 100644 web/app/components/app/overview/__tests__/trigger-card.spec.tsx create mode 100644 web/app/components/app/overview/__tests__/workflow-launch-dialog.spec.tsx delete mode 100644 web/app/components/app/overview/app-card-sections.tsx delete mode 100644 web/app/components/app/overview/app-card.tsx delete mode 100644 web/app/components/app/overview/assets/code-browser.svg delete mode 100644 web/app/components/app/overview/assets/refresh-hover.svg delete mode 100644 web/app/components/app/overview/assets/refresh.svg delete mode 100644 web/app/components/app/overview/style.module.css delete mode 100644 web/app/components/app/overview/trigger-card.tsx create mode 100644 web/app/components/app/overview/workflow-launch-dialog/index.tsx rename web/app/components/apps/{app-card.tsx => app-card/action-bar/index.tsx} (53%) create mode 100644 web/app/components/apps/app-card/index.tsx create mode 100644 web/app/components/apps/app-card/operations-menu.tsx create mode 100644 web/app/components/develop/secret-key/secret-key-modal/index.tsx rename web/app/components/develop/secret-key/{secret-key-modal.tsx => secret-key-modal/view.tsx} (61%) delete mode 100644 web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx delete mode 100644 web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts delete mode 100644 web/app/components/tools/mcp/hooks/use-mcp-service-card.ts delete mode 100644 web/app/components/tools/mcp/mcp-service-card.tsx delete mode 100644 web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx delete mode 100644 web/app/components/tools/workflow-tool/configure-button.tsx create mode 100644 web/app/components/workflow/utils/__tests__/version.spec.ts create mode 100644 web/app/components/workflow/utils/version.ts delete mode 100644 web/context/access-control-store.ts create mode 100644 web/service/__tests__/use-workflow.spec.tsx create mode 100644 web/service/webapp-address.spec.ts create mode 100644 web/service/webapp-address.ts create mode 100644 web/service/webapp-auth.spec.ts create mode 100644 web/service/workflow-queries.ts diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 1da5b27b77f..d506a443a3a 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -315,6 +315,9 @@ class WorkflowResponse(ResponseModel): ) hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash")) version: str + # NULL for drafts and for versions published before numbering was introduced; those + # render as "Untitled Version" instead of `#N`. Never 0, so clients must test for null. + version_number: int | None = None marked_name: str marked_comment: str created_by: SimpleAccount | None = Field( diff --git a/api/controllers/inner_api/app/dsl.py b/api/controllers/inner_api/app/dsl.py index 8b206cc36be..e4276d44f20 100644 --- a/api/controllers/inner_api/app/dsl.py +++ b/api/controllers/inner_api/app/dsl.py @@ -5,13 +5,15 @@ to attribute the created app; workspace/membership validation is done by the Go admin-api caller. """ +from uuid import UUID + from flask import request from flask_restx import Resource -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError, field_validator from sqlalchemy import select from sqlalchemy.orm import Session -from controllers.common.schema import register_schema_model +from controllers.common.schema import query_params_from_model, register_schema_model from controllers.console.wraps import setup_required from controllers.inner_api import inner_api_ns from controllers.inner_api.wraps import enterprise_inner_api_only @@ -20,6 +22,7 @@ from models import Account, App from models.account import AccountStatus from services.app_dsl_service import AppDslService from services.entities.dsl_entities import ImportMode, ImportStatus +from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError class InnerAppDSLImportPayload(BaseModel): @@ -29,6 +32,18 @@ class InnerAppDSLImportPayload(BaseModel): description: str | None = Field(default=None, description="Override app description from DSL") +class EnterpriseAppDSLExportQuery(BaseModel): + include_secret: bool = Field(default=False, description="Whether to include secret values in the exported DSL") + workflow_id: UUID | None = Field(default=None, description="Published workflow version ID to export") + + @field_validator("include_secret", mode="before") + @classmethod + def parse_include_secret(cls, value: object) -> bool: + if isinstance(value, str): + return value.lower() == "true" + return bool(value) + + register_schema_model(inner_api_ns, InnerAppDSLImportPayload) @@ -82,24 +97,48 @@ class EnterpriseAppDSLExport(Resource): @enterprise_inner_api_only @inner_api_ns.doc( "enterprise_app_dsl_export", + params=query_params_from_model(EnterpriseAppDSLExportQuery), responses={ 200: "Export successful", - 404: "App not found", + 400: "Invalid workflow ID or unpublished workflow version", + 404: "App or workflow version not found", }, ) def get(self, app_id: str): """Export an app's DSL as YAML.""" - include_secret = request.args.get("include_secret", "false").lower() == "true" + try: + query = EnterpriseAppDSLExportQuery.model_validate(request.args.to_dict(flat=True)) + except ValidationError: + return { + "code": "invalid_workflow_id", + "message": "workflow_id must be a valid UUID", + "status": 400, + }, 400 + + workflow_id = str(query.workflow_id) if query.workflow_id else None app_model = db.session.get(App, app_id) if not app_model: return {"message": "app not found"}, 404 - data = AppDslService.export_dsl( - app_model=app_model, - session=db.session(), - include_secret=include_secret, - ) + if not workflow_id: + data = AppDslService.export_dsl( + app_model=app_model, + session=db.session(), + include_secret=query.include_secret, + ) + else: + try: + data = AppDslService.export_dsl( + app_model=app_model, + session=db.session(), + include_secret=query.include_secret, + workflow_id=workflow_id, + ) + except WorkflowNotFoundError as exc: + return {"code": "workflow_version_not_found", "message": str(exc), "status": 404}, 404 + except IsDraftWorkflowError as exc: + return {"code": "workflow_version_not_published", "message": str(exc), "status": 400}, 400 return {"data": data}, 200 diff --git a/api/migrations/versions/2026_08_05_1030-a1c7f4e9b3d2_add_workflow_version_number.py b/api/migrations/versions/2026_08_05_1030-a1c7f4e9b3d2_add_workflow_version_number.py new file mode 100644 index 00000000000..3e092e01d8a --- /dev/null +++ b/api/migrations/versions/2026_08_05_1030-a1c7f4e9b3d2_add_workflow_version_number.py @@ -0,0 +1,60 @@ +"""add workflow version number + +Revision ID: a1c7f4e9b3d2 +Revises: e4708db55c1d +Create Date: 2026-08-05 10:30:00.000000 + +Introduces user-facing workflow version numbers (`#N`), unique and monotonically +increasing per app. `workflow_version_counters` holds one row per app with the +highest number handed out so far, so numbers are never reused when a published +version is deleted. + +DDL only. Versions published before this revision keep `version_number` NULL and +continue to render as "Untitled Version"; numbering starts at #1 on the first +publish after the upgrade. + +""" + +import sqlalchemy as sa +from alembic import op + +import models as models + +# revision identifiers, used by Alembic. +revision = "a1c7f4e9b3d2" +down_revision = "e4708db55c1d" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "workflow_version_counters", + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("last_version_number", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("app_id", name="workflow_version_counter_pkey"), + ) + + with op.batch_alter_table("workflows", schema=None) as batch_op: + batch_op.add_column(sa.Column("version_number", sa.Integer(), nullable=True)) + + # Excluding NULLs keeps the index off every pre-existing version row. The + # partial-WHERE clause is PG-only (SQLAlchemy drops the kwarg on MySQL → + # plain unique index); both dialects treat NULLs as distinct, so unnumbered + # rows stay unconstrained either way. + op.create_index( + "workflow_app_version_number_idx", + "workflows", + ["app_id", "version_number"], + unique=True, + postgresql_where=sa.text("version_number IS NOT NULL"), + ) + + +def downgrade(): + op.drop_index("workflow_app_version_number_idx", table_name="workflows") + + with op.batch_alter_table("workflows", schema=None) as batch_op: + batch_op.drop_column("version_number") + + op.drop_table("workflow_version_counters") diff --git a/api/models/__init__.py b/api/models/__init__.py index 387a6761820..b0c3058f0b7 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -148,6 +148,7 @@ from .workflow import ( WorkflowRun, WorkflowRunArchiveBundle, WorkflowType, + WorkflowVersionCounter, resolve_workflow_kind, ) @@ -293,5 +294,6 @@ __all__ = [ "WorkflowToolProvider", "WorkflowTriggerStatus", "WorkflowType", + "WorkflowVersionCounter", "resolve_workflow_kind", ] diff --git a/api/models/workflow.py b/api/models/workflow.py index 58dba52f59e..fa44dee7ec0 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -211,6 +211,13 @@ class Workflow(Base): # bug __table_args__ = ( sa.PrimaryKeyConstraint("id", name="workflow_pkey"), sa.Index("workflow_version_idx", "tenant_id", "app_id", "version"), + sa.Index( + "workflow_app_version_number_idx", + "app_id", + "version_number", + unique=True, + postgresql_where=sa.text("version_number IS NOT NULL"), + ), ) id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) @@ -224,6 +231,9 @@ class Workflow(Base): # bug server_default=sa.text("'standard'"), ) version: Mapped[str] = mapped_column(String(255), nullable=False) + # User-facing version number, unique and monotonically increasing within an app, displayed as `#N`. + # NULL for draft workflows and for versions published before numbering was introduced. + version_number: Mapped[int | None] = mapped_column(sa.Integer, nullable=True, default=None) marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="") marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="") graph: Mapped[str] = mapped_column(LongText) @@ -265,6 +275,7 @@ class Workflow(Base): # bug marked_name: str = "", marked_comment: str = "", kind: str | None = WorkflowKind.STANDARD.value, + version_number: int | None = None, ) -> "Workflow": workflow = Workflow() workflow.id = str(uuid4()) @@ -273,6 +284,7 @@ class Workflow(Base): # bug workflow.type = WorkflowType(type) workflow.kind = resolve_workflow_kind(kind) workflow.version = version + workflow.version_number = version_number workflow.graph = graph workflow.features = features workflow.created_by = created_by @@ -735,6 +747,24 @@ class Workflow(Base): # bug return str(d) +class WorkflowVersionCounter(Base): + """Monotonic per-app allocator for `Workflow.version_number`. + + One row per app, holding the highest number handed out so far. Numbers are never + reused, so deleting a published version does not free its number. + + `app_id` mirrors `Workflow.app_id`, which is polymorphic: it holds an app id, a + pipeline id or a snippet id depending on the workflow kind. UUID uniqueness across + those tables is why no owner-type column is needed here. + """ + + __tablename__ = "workflow_version_counters" + __table_args__ = (sa.PrimaryKeyConstraint("app_id", name="workflow_version_counter_pkey"),) + + app_id: Mapped[str] = mapped_column(StringUUID) + last_version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0) + + class WorkflowRunDict(TypedDict): id: str tenant_id: str diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 5bef6b9b8d8..b3475132056 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -22097,6 +22097,7 @@ Query parameters for listing snippet published workflows. | updated_at | integer | | Yes | | updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | version | string | | Yes | +| version_number | integer | | No | #### StarredAppListQuery @@ -24112,6 +24113,7 @@ tenant's default model. The underlying generator never raises — an empty | updated_at | integer | | Yes | | updated_by | [SimpleAccountResponse](#simpleaccountresponse) | | No | | version | string | | Yes | +| version_number | integer | | No | #### WorkflowRestoreResponse diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 460fb3e4aca..98fe5f9d758 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -652,6 +652,9 @@ class AppDslService: :param app_model: App instance :param session: Database session used to load export data :param include_secret: Whether include secret variable + :param workflow_id: Optional published workflow version to export + :raises WorkflowNotFoundError: If the selected workflow version does not exist + :raises IsDraftWorkflowError: If the selected workflow is a draft :return: """ app_mode = AppMode.value_of(app_model.mode) @@ -711,10 +714,13 @@ class AppDslService: Append workflow export data :param export_data: export data :param app_model: App instance + :param workflow_id: Optional published workflow version to export """ workflow_service = WorkflowService() workflow = workflow_service.get_draft_workflow(app_model, workflow_id, session=session) if not workflow: + if workflow_id: + raise WorkflowNotFoundError(f"Workflow version not found. Workflow ID: {workflow_id}.") raise WorkflowNotFoundError("Missing draft workflow configuration, please check.") workflow_dict = workflow.to_dict(include_secret=include_secret) diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 5bfefd8a916..97bcd98f1e2 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -397,6 +397,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [ "app.acl.import_export_dsl", "app.acl.delete", "app.acl.release_and_version", + "app.acl.deploy", "app.acl.monitor", "app.acl.access_config", "app.acl.tracing_config", @@ -411,6 +412,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [ "app.acl.import_export_dsl", "app.acl.delete", "app.acl.release_and_version", + "app.acl.deploy", "app.acl.monitor", "app.acl.access_config", "app.acl.access_config", @@ -426,6 +428,7 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [ "app.acl.import_export_dsl", "app.acl.delete", "app.acl.release_and_version", + "app.acl.deploy", "app.acl.monitor", "app.acl.log_and_annotation", "app.acl.access_config", diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 1fb0cf6ff36..c821e7b7c80 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -142,6 +142,7 @@ HumanInputNode = _DebugHumanInputNode from services.human_input_service import HumanInputService from services.workflow.workflow_converter import WorkflowConverter from services.workflow_ref_service import WorkflowRef +from services.workflow_version_number_service import allocate_version_number from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError from .human_input_delivery_test_service import ( @@ -356,7 +357,16 @@ class WorkflowService: stmt = ( select(Workflow) .where(Workflow.app_id == app_model.id) - .order_by(Workflow.version.desc()) + # The draft leads the list; its `created_at` is the app's creation time, so it would + # otherwise sort last. Published versions then order by publish time: `version` is a + # stringified timestamp whose microseconds are omitted when zero, so ordering by it + # misplaces versions across second boundaries, and `version_number` is NULL for + # versions published before numbering was introduced. + .order_by( + (Workflow.version == Workflow.VERSION_DRAFT).desc(), + Workflow.created_at.desc(), + Workflow.id.desc(), + ) .limit(limit + 1) .offset((page - 1) * limit) ) @@ -720,6 +730,7 @@ class WorkflowService: app_id=app_model.id, type=draft_workflow.type, version=Workflow.version_from_datetime(naive_utc_now()), + version_number=allocate_version_number(session=session, app_id=app_model.id), graph=draft_workflow.graph, created_by=account.id, environment_variables=draft_workflow.environment_variables, diff --git a/api/services/workflow_version_number_service.py b/api/services/workflow_version_number_service.py new file mode 100644 index 00000000000..506702ab58d --- /dev/null +++ b/api/services/workflow_version_number_service.py @@ -0,0 +1,59 @@ +"""Allocation of user-facing workflow version numbers (`#N`). + +Numbers are unique and monotonically increasing within an app, and are never +reused: `workflow_version_counters` keeps the highest number handed out so far, +so deleting a published version does not free its number. + +The counter is keyed by `Workflow.app_id`, which is polymorphic — it holds an app +id, a pipeline id or a snippet id depending on the workflow kind. UUID uniqueness +across those tables is why the same counter table serves all of them. +""" + +from sqlalchemy import select +from sqlalchemy.dialects.mysql import insert as mysql_insert +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from configs import dify_config +from models.workflow import WorkflowVersionCounter + + +def allocate_version_number(*, session: Session, app_id: str) -> int: + """Reserve and return the next version number for `app_id`. + + The upsert acquires a row lock that is held until the caller's transaction + commits, so concurrent publishes of the same app serialize and never receive + the same number. Callers must run inside a transaction; if it rolls back, + both the counter update and workflow creation roll back, leaving the number + available for the next successful publish. + """ + # Dialect-specific upsert, mirroring `workflow_draft_variable_service`: the + # ORM cannot express "insert or increment" and a read-then-write would race. + # PostgreSQL returns the new value inline; MySQL has no RETURNING, so the + # value is read back within the same transaction while the row is still + # locked by the upsert. + if dify_config.SQLALCHEMY_DATABASE_URI_SCHEME == "postgresql": + stmt = ( + pg_insert(WorkflowVersionCounter) + .values(app_id=app_id, last_version_number=1) + .on_conflict_do_update( + index_elements=[WorkflowVersionCounter.app_id], + set_={"last_version_number": WorkflowVersionCounter.last_version_number + 1}, + ) + .returning(WorkflowVersionCounter.last_version_number) + ) + version_number = session.scalar(stmt) + else: + insert_stmt = mysql_insert(WorkflowVersionCounter).values(app_id=app_id, last_version_number=1) + session.execute( + insert_stmt.on_duplicate_key_update( # type: ignore[attr-defined] + last_version_number=WorkflowVersionCounter.last_version_number + 1, + ) + ) + version_number = session.scalar( + select(WorkflowVersionCounter.last_version_number).where(WorkflowVersionCounter.app_id == app_id) + ) + + if version_number is None: + raise ValueError(f"Failed to allocate a workflow version number for app {app_id}.") + return version_number diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 3f7d9729471..8ea3a7b8c81 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -1318,7 +1318,7 @@ class TestAppDslService: with pytest.raises( WorkflowNotFoundError, - match="Missing draft workflow configuration, please check.", + match="Workflow version not found. Workflow ID:", ): AppDslService.export_dsl( app, include_secret=False, workflow_id=str(uuid4()), session=db_session_with_containers diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index 9716c6ca314..bf953ff3b4f 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -1004,7 +1004,12 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, u overrides=[ app_module.enterprise_rbac_service.ResourcePermissionKeys( resource_id="app-1", - permission_keys=["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"], + permission_keys=[ + "app.acl.view_layout", + "app.acl.edit", + "app.acl.deploy", + "app.acl.monitor", + ], ) ] ) @@ -1026,7 +1031,12 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, u get_app.assert_called_once_with(app_obj, session=unbound_session) get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1", session=unbound_session) - assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"] + assert resp["permission_keys"] == [ + "app.acl.view_layout", + "app.acl.edit", + "app.acl.deploy", + "app.acl.monitor", + ] def test_app_copy_api_attaches_permission_keys(app, app_module, sqlite_session: Session, sqlite_engine: Engine): diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py index 44ab34a927d..33952546b31 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -55,6 +55,27 @@ class TestCurrentIds: assert rbac_mod._current_ids() == ("tenant-1", "acct-1") +class TestMyPermissions: + def test_returns_app_deploy_permission(self, app): + permissions = rbac_mod.svc.MyPermissionsResponse( + app=rbac_mod.svc.ResourcePermissionSnapshot( + default_permission_keys=["app.acl.deploy"], + ) + ) + with ( + app.test_request_context("/workspaces/current/rbac/my-permissions"), + patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")), + patch( + "controllers.console.workspace.rbac.svc.RBACService.MyPermissions.get", + return_value=permissions, + ) as mock_get, + ): + response = inspect.unwrap(rbac_mod.RBACMyPermissionsApi.get)(rbac_mod.RBACMyPermissionsApi()) + + assert response["app"]["default_permission_keys"] == ["app.acl.deploy"] + mock_get.assert_called_once() + + class TestAccessMatrixAccountNames: def test_hydrates_missing_account_names(self): items = [ diff --git a/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py b/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py index 8caeb7174ce..c11a6a97302 100644 --- a/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py +++ b/api/tests/unit_tests/controllers/inner_api/app/test_dsl.py @@ -28,6 +28,7 @@ from models import Account, App from models.account import AccountStatus from models.model import AppMode, IconType from services.app_dsl_service import Import, ImportStatus +from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError def _persist_app(session: Session) -> App: @@ -235,6 +236,15 @@ class TestEnterpriseAppDSLExport: Uses inspect.unwrap() to bypass auth/setup decorators. """ + def test_export_documents_query_parameters(self): + params = EnterpriseAppDSLExport.get.__apidoc__["params"] + + assert params["include_secret"]["in"] == "query" + assert params["include_secret"]["type"] == "boolean" + assert params["workflow_id"]["in"] == "query" + assert params["workflow_id"]["type"] == "string" + assert params["workflow_id"]["format"] == "uuid" + @pytest.fixture def api_instance(self): return EnterpriseAppDSLExport() @@ -293,6 +303,160 @@ class TestEnterpriseAppDSLExport: assert call_kwargs["session"] is scoped_db() assert call_kwargs["include_secret"] is True + @patch("controllers.inner_api.app.dsl.AppDslService") + def test_export_selected_workflow_forwards_canonical_uuid( + self, + mock_dsl_cls, + api_instance, + app: Flask, + sqlite_session: Session, + scoped_db, + ): + app_model = _persist_app(sqlite_session) + mock_dsl_cls.export_dsl.return_value = "yaml-data" + workflow_id = "F1FD7266-56FC-45C7-9D81-A72CD5A1B4F6" + + unwrapped = inspect.unwrap(api_instance.get) + with app.test_request_context(f"?workflow_id={workflow_id}"): + body, status_code = unwrapped(api_instance, app_id=app_model.id) + + assert status_code == 200 + assert body["data"] == "yaml-data" + call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs + assert call_kwargs["app_model"].id == app_model.id + assert call_kwargs["session"] is scoped_db() + assert call_kwargs["include_secret"] is False + assert call_kwargs["workflow_id"] == "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6" + + @patch("controllers.inner_api.app.dsl.AppDslService") + def test_export_selected_workflow_with_secret( + self, + mock_dsl_cls, + api_instance, + app: Flask, + sqlite_session: Session, + scoped_db, + ): + app_model = _persist_app(sqlite_session) + mock_dsl_cls.export_dsl.return_value = "yaml-data" + workflow_id = "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6" + + unwrapped = inspect.unwrap(api_instance.get) + with app.test_request_context(f"?include_secret=true&workflow_id={workflow_id}"): + body, status_code = unwrapped(api_instance, app_id=app_model.id) + + assert status_code == 200 + assert body["data"] == "yaml-data" + call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs + assert call_kwargs["app_model"].id == app_model.id + assert call_kwargs["session"] is scoped_db() + assert call_kwargs["include_secret"] is True + assert call_kwargs["workflow_id"] == workflow_id + + @patch("controllers.inner_api.app.dsl.AppDslService") + def test_export_rejects_invalid_selected_workflow_id( + self, + mock_dsl_cls, + api_instance, + app: Flask, + scoped_db, + ): + assert scoped_db() is not None + unwrapped = inspect.unwrap(api_instance.get) + with app.test_request_context("?workflow_id=not-a-uuid"): + body, status_code = unwrapped(api_instance, app_id=str(uuid4())) + + assert status_code == 400 + assert body == { + "code": "invalid_workflow_id", + "message": "workflow_id must be a valid UUID", + "status": 400, + } + mock_dsl_cls.export_dsl.assert_not_called() + + @patch("controllers.inner_api.app.dsl.AppDslService") + def test_export_selected_missing_workflow_returns_404( + self, + mock_dsl_cls, + api_instance, + app: Flask, + sqlite_session: Session, + scoped_db, + ): + app_model = _persist_app(sqlite_session) + mock_dsl_cls.export_dsl.side_effect = WorkflowNotFoundError("selected workflow not found") + workflow_id = "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6" + + unwrapped = inspect.unwrap(api_instance.get) + with app.test_request_context(f"?workflow_id={workflow_id}"): + body, status_code = unwrapped(api_instance, app_id=app_model.id) + + assert status_code == 404 + assert body == { + "code": "workflow_version_not_found", + "message": "selected workflow not found", + "status": 404, + } + call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs + assert call_kwargs["app_model"].id == app_model.id + assert call_kwargs["session"] is scoped_db() + assert call_kwargs["include_secret"] is False + assert call_kwargs["workflow_id"] == workflow_id + + @patch("controllers.inner_api.app.dsl.AppDslService") + def test_export_selected_draft_workflow_returns_400( + self, + mock_dsl_cls, + api_instance, + app: Flask, + sqlite_session: Session, + scoped_db, + ): + app_model = _persist_app(sqlite_session) + mock_dsl_cls.export_dsl.side_effect = IsDraftWorkflowError("selected workflow is a draft") + workflow_id = "f1fd7266-56fc-45c7-9d81-a72cd5a1b4f6" + + unwrapped = inspect.unwrap(api_instance.get) + with app.test_request_context(f"?workflow_id={workflow_id}"): + body, status_code = unwrapped(api_instance, app_id=app_model.id) + + assert status_code == 400 + assert body == { + "code": "workflow_version_not_published", + "message": "selected workflow is a draft", + "status": 400, + } + call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs + assert call_kwargs["app_model"].id == app_model.id + assert call_kwargs["session"] is scoped_db() + assert call_kwargs["include_secret"] is False + assert call_kwargs["workflow_id"] == workflow_id + + @patch("controllers.inner_api.app.dsl.AppDslService") + def test_export_without_selected_workflow_preserves_workflow_error( + self, + mock_dsl_cls, + api_instance, + app: Flask, + sqlite_session: Session, + scoped_db, + ): + app_model = _persist_app(sqlite_session) + mock_dsl_cls.export_dsl.side_effect = WorkflowNotFoundError( + "Missing draft workflow configuration, please check." + ) + + unwrapped = inspect.unwrap(api_instance.get) + with app.test_request_context(): + with pytest.raises(WorkflowNotFoundError, match="Missing draft workflow configuration"): + unwrapped(api_instance, app_id=app_model.id) + + call_kwargs = mock_dsl_cls.export_dsl.call_args.kwargs + assert call_kwargs["app_model"].id == app_model.id + assert call_kwargs["session"] is scoped_db() + assert call_kwargs["include_secret"] is False + assert "workflow_id" not in call_kwargs + def test_export_app_not_found_returns_404(self, api_instance, app: Flask, scoped_db): assert scoped_db() is not None unwrapped = inspect.unwrap(api_instance.get) diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index b50ba0e5edb..9cc71181703 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -636,6 +636,10 @@ class TestMyPermissions: assert not any(key.startswith("billing.") for key in out.workspace.permission_keys) if role == "editor": assert "app.acl.log_and_annotation" in out.app.default_permission_keys + if role in {"owner", "admin", "editor"}: + assert "app.acl.deploy" in out.app.default_permission_keys + else: + assert "app.acl.deploy" not in out.app.default_permission_keys @pytest.mark.parametrize( ("role", "expected_snippet_keys"), diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index e6d1b0ea939..7afbb0fa042 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -15,6 +15,7 @@ from models.workflow import Workflow from services.app_dsl_service import AppDslService, PendingData from services.entities.dsl_entities import ImportStatus from services.errors.account import NoPermissionError +from services.errors.app import WorkflowNotFoundError def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None: @@ -407,3 +408,20 @@ def test_import_app_reraises_permission_denial_instead_of_failed_result( ) assert not unbound_session.in_transaction() + + +def test_append_workflow_export_data_reports_missing_selected_workflow(monkeypatch: pytest.MonkeyPatch) -> None: + workflow_id = "11111111-1111-4111-8111-111111111111" + workflow_service = Mock() + workflow_service.get_draft_workflow.return_value = None + monkeypatch.setattr("services.app_dsl_service.WorkflowService", Mock(return_value=workflow_service)) + app = cast(App, SimpleNamespace(id="app-1", tenant_id="tenant-1")) + + with pytest.raises(WorkflowNotFoundError, match=f"Workflow version not found. Workflow ID: {workflow_id}"): + AppDslService._append_workflow_export_data( + export_data={}, + app_model=app, + include_secret=False, + session=Mock(), + workflow_id=workflow_id, + ) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index da5a800cd59..d0ff4a085e9 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -11,6 +11,7 @@ This test suite covers: import json import uuid +from datetime import datetime, timedelta from types import SimpleNamespace from typing import Any, cast from unittest.mock import ANY, MagicMock, patch, sentinel @@ -356,6 +357,31 @@ class TestWorkflowService: assert result is None + @pytest.mark.parametrize( + ("tenant_id", "app_id"), + [("other-tenant", "app-123"), ("tenant-456", "other-app")], + ) + def test_get_published_workflow_by_id_rejects_foreign_workflow( + self, + tenant_id: str, + app_id: str, + workflow_service: WorkflowService, + sqlite_session: Session, + ): + app = TestWorkflowAssociatedDataFactory.create_app() + workflow = TestWorkflowAssociatedDataFactory.create_workflow( + workflow_id="workflow-123", + tenant_id=tenant_id, + app_id=app_id, + version="v1", + ) + sqlite_session.add(workflow) + sqlite_session.commit() + + result = workflow_service.get_published_workflow_by_id(app, workflow.id, session=sqlite_session) + + assert result is None + def test_get_published_workflow_success(self, workflow_service: WorkflowService, sqlite_session: Session): """Test get_published_workflow returns published workflow.""" workflow_id = "workflow-123" @@ -1057,6 +1083,104 @@ class TestWorkflowService: assert result.marked_comment == "Initial release" assert retirement_candidates == set() + def test_publish_workflow_numbers_versions_from_one( + self, workflow_service: WorkflowService, sqlite_session: Session + ): + """ + Test publish_workflow assigns an app-scoped version number starting at #1. + + The number is what users see when a version carries no name, so it has to be + stable and monotonic per app rather than derived from list position. + """ + app = TestWorkflowAssociatedDataFactory.create_app() + account = TestWorkflowAssociatedDataFactory.create_account() + graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph() + + draft = TestWorkflowAssociatedDataFactory.create_workflow(version=Workflow.VERSION_DRAFT, graph=graph) + sqlite_session.add(draft) + sqlite_session.commit() + + with ( + patch("services.workflow_service.app_published_workflow_was_updated"), + patch( + "services.workflow_service.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ), + ): + first, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) + second, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) + + assert first.version_number == 1 + assert second.version_number == 2 + + def test_publish_workflow_does_not_reuse_a_deleted_version_number( + self, workflow_service: WorkflowService, sqlite_session: Session + ): + """ + Test version numbers are never handed out twice, even after a version is deleted. + + Deployment records and audit logs refer to versions by number, so reusing one + would make two different workflows share an identity. + """ + app = TestWorkflowAssociatedDataFactory.create_app() + account = TestWorkflowAssociatedDataFactory.create_account() + graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph() + + draft = TestWorkflowAssociatedDataFactory.create_workflow(version=Workflow.VERSION_DRAFT, graph=graph) + sqlite_session.add(draft) + sqlite_session.commit() + + with ( + patch("services.workflow_service.app_published_workflow_was_updated"), + patch( + "services.workflow_service.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ), + ): + published, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) + sqlite_session.flush() + sqlite_session.delete(published) + sqlite_session.flush() + + republished, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) + + assert republished.version_number == 2 + + def test_publish_workflow_numbers_each_app_independently( + self, workflow_service: WorkflowService, sqlite_session: Session + ): + """ + Test the counter is scoped per app rather than global. + + Every app starts its own sequence at #1; a busy neighbour must not advance it. + """ + account = TestWorkflowAssociatedDataFactory.create_account() + graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph() + + published: list[Workflow] = [] + for app_id in ("app-first", "app-second"): + app = TestWorkflowAssociatedDataFactory.create_app(app_id=app_id) + draft = TestWorkflowAssociatedDataFactory.create_workflow( + workflow_id=f"draft-{app_id}", + app_id=app_id, + version=Workflow.VERSION_DRAFT, + graph=graph, + ) + sqlite_session.add(draft) + sqlite_session.commit() + + with ( + patch("services.workflow_service.app_published_workflow_was_updated"), + patch( + "services.workflow_service.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ), + ): + workflow, _ = workflow_service.publish_workflow(session=sqlite_session, app_model=app, account=account) + published.append(workflow) + + assert [workflow.version_number for workflow in published] == [1, 1] + def test_publish_workflow_no_draft_raises_error(self, workflow_service: WorkflowService, sqlite_session: Session): """ Test publish_workflow raises error when no draft exists. @@ -1180,6 +1304,44 @@ class TestWorkflowService: assert len(workflows) == 5 assert has_more is False + def test_get_all_published_workflow_lists_the_draft_first( + self, workflow_service: WorkflowService, sqlite_session: Session + ): + """ + Test the draft heads the version list no matter how old it is. + + A draft is created together with its app and its `created_at` is never refreshed, + so ordering purely by publish time would put it last — off the first page entirely + once the app has accumulated enough published versions. + """ + app = TestWorkflowAssociatedDataFactory.create_app(workflow_id="workflow-3") + app_created_at = datetime(2026, 1, 1) + + sqlite_session.add( + TestWorkflowAssociatedDataFactory.create_workflow( + workflow_id="workflow-draft", + version=Workflow.VERSION_DRAFT, + created_at=app_created_at, + ) + ) + sqlite_session.add_all( + [ + TestWorkflowAssociatedDataFactory.create_workflow( + workflow_id=f"workflow-{i}", + version=f"2026-02-0{i} 00:00:00", + created_at=app_created_at + timedelta(days=i), + ) + for i in range(1, 4) + ] + ) + sqlite_session.commit() + + workflows, _ = workflow_service.get_all_published_workflow( + session=sqlite_session, app_model=app, page=1, limit=2, user_id=None + ) + + assert [workflow.id for workflow in workflows] == ["workflow-draft", "workflow-3"] + def test_get_all_published_workflow_has_more(self, workflow_service: WorkflowService, sqlite_session: Session): """ Test get_all_published_workflow indicates has_more when results exceed limit. diff --git a/e2e/features/apps/web-app-service.feature b/e2e/features/apps/web-app-service.feature index f027ed3e9da..3dc55c23437 100644 --- a/e2e/features/apps/web-app-service.feature +++ b/e2e/features/apps/web-app-service.feature @@ -4,8 +4,7 @@ Feature: Manage Web App service Scenario: Disable and restore a published workflow Web App Given I am signed in as the default E2E admin And a new runnable workflow app has been published - When I navigate to the app overview page - And I open the app information panel + When I navigate to the app access point page Then the Web App should be in service When an anonymous visitor opens the Web App Then the published workflow Web App should be accessible diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index 77aab73f1cc..330d7abca77 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -9,6 +9,8 @@ import { import { SERVICE_API_RUNTIME_STEP_TIMEOUT_MS } from '../../agent-v2/support/service-api-sse' import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' +const API_KEY_DIALOG_NAME = /^API Key$/i + async function createAgentApiKey(world: DifyWorld) { const agentId = getCurrentAgentId(world) const client = world.getConsoleClient() @@ -61,7 +63,7 @@ When('I open Agent v2 API key management', async function (this: DifyWorld) { Then('Agent v2 API keys should not expose a secret by default', async function (this: DifyWorld) { const page = this.getPage() - const dialog = page.getByRole('dialog', { name: /API Secret key/i }) + const dialog = page.getByRole('dialog', { name: API_KEY_DIALOG_NAME }) const existingSecret = this.agentBuilder.accessPoint.generatedApiKey await expect(dialog).toBeVisible() @@ -76,14 +78,14 @@ Then('Agent v2 API keys should not expose a secret by default', async function ( }) When('I create a new Agent v2 API key', async function (this: DifyWorld) { - const dialog = this.getPage().getByRole('dialog', { name: /API Secret key/i }) + const dialog = this.getPage().getByRole('dialog', { name: API_KEY_DIALOG_NAME }) await dialog.getByRole('button', { name: 'Create new Secret key' }).click() }) Then('I should see the newly generated Agent v2 API key once', async function (this: DifyWorld) { const generatedKeyDialog = this.getPage() - .getByRole('dialog', { name: /API Secret key/i }) + .getByRole('dialog', { name: API_KEY_DIALOG_NAME }) .last() const generatedKey = generatedKeyDialog.getByText(/^app-/) @@ -101,7 +103,7 @@ Then('I should see the newly generated Agent v2 API key once', async function (t When('I copy the newly generated Agent v2 API key', async function (this: DifyWorld) { const generatedKeyDialog = this.getPage() - .getByRole('dialog', { name: /API Secret key/i }) + .getByRole('dialog', { name: API_KEY_DIALOG_NAME }) .last() await generatedKeyDialog.getByLabel('Copy').first().click() @@ -111,7 +113,7 @@ Then( 'the newly generated Agent v2 API key should show it was copied', async function (this: DifyWorld) { const generatedKeyDialog = this.getPage() - .getByRole('dialog', { name: /API Secret key/i }) + .getByRole('dialog', { name: API_KEY_DIALOG_NAME }) .last() await expect(generatedKeyDialog.getByLabel('Copied')).toBeVisible() @@ -120,7 +122,7 @@ Then( When('I close the newly generated Agent v2 API key', async function (this: DifyWorld) { const page = this.getPage() - const generatedKeyDialog = page.getByRole('dialog', { name: /API Secret key/i }).last() + const generatedKeyDialog = page.getByRole('dialog', { name: API_KEY_DIALOG_NAME }).last() await generatedKeyDialog.getByRole('button', { name: 'OK' }).click() await expect(page.getByText('Keep this key in a secure and accessible place.')).not.toBeVisible() @@ -132,7 +134,7 @@ Then( const fullSecret = this.agentBuilder.accessPoint.generatedApiKey if (!fullSecret) throw new Error('No generated Agent v2 API key found.') - const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i }) + const apiKeyDialog = this.getPage().getByRole('dialog', { name: API_KEY_DIALOG_NAME }) await expect(apiKeyDialog).toBeVisible() await expect(apiKeyDialog.getByText(fullSecret, { exact: true })).not.toBeVisible() diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index 9712b44b7a4..53eba93fdae 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -22,7 +22,7 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW const webAppCard = getWebAppCard(this) await expect(webAppCard.getByRole('heading', { name: 'Web app' })).toBeVisible() - await expect(webAppCard.getByText('Access URL')).toBeVisible() + await expect(webAppCard.getByText('Web App URL')).toBeVisible() await expect(webAppCard.getByLabel('Copy access URL')).toBeEnabled() await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible() }) diff --git a/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts b/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts index 743aafbc9f1..6110d2f4305 100644 --- a/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts +++ b/e2e/features/step-definitions/apps/app-detail-navigation.steps.ts @@ -1,7 +1,7 @@ import type { DifyWorld } from '../../support/world' import { When } from '@cucumber/cucumber' -When('I navigate to the app overview page', async function (this: DifyWorld) { +When('I navigate to the app access point page', async function (this: DifyWorld) { const appId = this.createdAppIds.at(-1) - await this.getPage().goto(`/app/${appId}/overview`) + await this.getPage().goto(`/app/${appId}/access-point`) }) diff --git a/e2e/features/step-definitions/apps/publish-app.steps.ts b/e2e/features/step-definitions/apps/publish-app.steps.ts index aca00b220e4..9b7c402a065 100644 --- a/e2e/features/step-definitions/apps/publish-app.steps.ts +++ b/e2e/features/step-definitions/apps/publish-app.steps.ts @@ -7,9 +7,8 @@ When('I open the publish panel', async function (this: DifyWorld) { }) When('I publish the app', async function (this: DifyWorld) { - await this.getPage() - .getByRole('button', { name: /Publish Update/ }) - .click() + const publishPanel = this.getPage().getByRole('dialog') + await publishPanel.getByRole('button', { name: 'Publish', exact: true }).click() }) Then('the app should be marked as published', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts index d7f9d39d2e9..8bd2717a62e 100644 --- a/e2e/features/step-definitions/apps/web-app-service.steps.ts +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -23,15 +23,6 @@ Given('a new runnable workflow app has been published', async function (this: Di this.shareURL = getAppSiteURL(appDetail) }) -When('I open the app information panel', async function (this: DifyWorld) { - const appName = this.lastCreatedAppName - if (!appName) { - throw new Error('No app name available. Create an app before opening its information panel.') - } - - await this.getPage().getByRole('button', { name: appName }).click() -}) - const getWebAppSwitch = (world: DifyWorld) => { const webAppCard = world.getPage().getByRole('region', { name: 'Web App' }) return webAppCard.getByRole('switch', { name: 'Web App' }) @@ -73,7 +64,7 @@ When('I enable the Web App', async function (this: DifyWorld) { Then('the Web App should be in service', async function (this: DifyWorld) { const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) - await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ + await expect(webAppCard.getByText(/^In service$/i)).toBeVisible({ timeout: 10_000, }) }) diff --git a/knip.config.ts b/knip.config.ts index 63e60aeab63..887534fa1ab 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -10,6 +10,8 @@ const config: KnipConfig = { workspaces: { web: { entry: [ + // todo: Keep the deploy drawer analyzed while the deployments routes are disabled. Delete this entry when relative files are deleted. + 'features/deployments/deploy-drawer/index.tsx!', 'scripts/**/*.{js,ts,mjs}', 'bin/**/*.{js,ts,mjs}', 'tsslint.config.ts', diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 0a66c9f8722..a971b716dc2 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -145,16 +145,6 @@ "count": 1 } }, - "web/app/components/app-sidebar/app-info/app-info-modals.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/app-sidebar/app-info/app-operations.tsx": { - "eslint-react/set-state-in-effect": { - "count": 4 - } - }, "web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -548,14 +538,6 @@ "count": 1 } }, - "web/app/components/app/overview/app-card-sections.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/app/overview/workflow-hidden-input-fields.tsx": { "no-restricted-imports": { "count": 1 @@ -3454,22 +3436,6 @@ "count": 4 } }, - "web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, - "web/app/components/tools/workflow-tool/configure-button.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/tools/workflow-tool/index.tsx": { "no-restricted-imports": { "count": 1 @@ -5664,11 +5630,6 @@ "count": 6 } }, - "web/service/fetch.spec.ts": { - "no-restricted-imports": { - "count": 1 - } - }, "web/service/fetch.ts": { "no-restricted-imports": { "count": 1 diff --git a/packages/contracts/console.ts b/packages/contracts/console.ts index af4c1f89f3e..ea712f2924a 100644 --- a/packages/contracts/console.ts +++ b/packages/contracts/console.ts @@ -1,7 +1,12 @@ import { consoleRouterContract as generatedConsoleRouterContract } from './generated/api/console/router.gen' +import { contract as enterpriseAppDeployContract } from './generated/enterprise-app-deploy/orpc.gen' import { contract as knowledgeFsContract } from './generated/knowledge-fs/orpc.gen' export const consoleRouterContract = { ...generatedConsoleRouterContract, + enterprise: { + ...generatedConsoleRouterContract.enterprise, + appDeploy: enterpriseAppDeployContract, + }, knowledgeFs: knowledgeFsContract, } diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 7778a9d768a..03d2a09d35c 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -1004,6 +1004,7 @@ export type WorkflowResponse = { updated_at: number updated_by?: SimpleAccountResponse | null version: string + version_number?: number | null } export type SyncDraftWorkflowPayload = { diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 6fe308d4fd5..12f765715d6 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2029,6 +2029,7 @@ export const zWorkflowResponse = z.object({ updated_at: z.int(), updated_by: zSimpleAccountResponse.nullish(), version: z.string(), + version_number: z.int().nullish(), }) /** diff --git a/packages/contracts/generated/api/console/rag/types.gen.ts b/packages/contracts/generated/api/console/rag/types.gen.ts index 2777d150024..97e42faf262 100644 --- a/packages/contracts/generated/api/console/rag/types.gen.ts +++ b/packages/contracts/generated/api/console/rag/types.gen.ts @@ -175,6 +175,7 @@ export type WorkflowResponse = { updated_at: number updated_by?: SimpleAccountResponse | null version: string + version_number?: number | null } export type DraftWorkflowSyncPayload = { diff --git a/packages/contracts/generated/api/console/rag/zod.gen.ts b/packages/contracts/generated/api/console/rag/zod.gen.ts index 3b9a4ad4a71..7ff07aabc10 100644 --- a/packages/contracts/generated/api/console/rag/zod.gen.ts +++ b/packages/contracts/generated/api/console/rag/zod.gen.ts @@ -480,6 +480,7 @@ export const zWorkflowResponse = z.object({ updated_at: z.int(), updated_by: zSimpleAccountResponse.nullish(), version: z.string(), + version_number: z.int().nullish(), }) /** diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index a1731d32168..67e5d738e3e 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -71,6 +71,7 @@ export type SnippetWorkflowResponse = { updated_at: number updated_by?: SimpleAccountResponse | null version: string + version_number?: number | null } export type SnippetDraftSyncPayload = { diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index d58b3028350..958018613d8 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -317,6 +317,7 @@ export const zSnippetWorkflowResponse = z.object({ updated_at: z.int(), updated_by: zSimpleAccountResponse.nullish(), version: z.string(), + version_number: z.int().nullish(), }) /** diff --git a/packages/contracts/generated/enterprise-app-deploy/orpc.gen.ts b/packages/contracts/generated/enterprise-app-deploy/orpc.gen.ts new file mode 100644 index 00000000000..6df55a62422 --- /dev/null +++ b/packages/contracts/generated/enterprise-app-deploy/orpc.gen.ts @@ -0,0 +1,307 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' +import { + zConsoleAccessServiceCreateEnvironmentApiKeyPath, + zConsoleAccessServiceCreateEnvironmentApiKeyResponse, + zConsoleAccessServiceDeleteEnvironmentApiKeyPath, + zConsoleAccessServiceDeleteEnvironmentApiKeyResponse, + zConsoleAccessServiceGetEnvironmentApiPath, + zConsoleAccessServiceGetEnvironmentApiResponse, + zConsoleAccessServiceGetEnvironmentMcpServerPath, + zConsoleAccessServiceGetEnvironmentMcpServerResponse, + zConsoleAccessServiceGetEnvironmentSitePath, + zConsoleAccessServiceGetEnvironmentSiteResponse, + zConsoleAccessServiceGetEnvironmentWebAppSubjectsPath, + zConsoleAccessServiceGetEnvironmentWebAppSubjectsResponse, + zConsoleAccessServiceListEnvironmentApiKeysPath, + zConsoleAccessServiceListEnvironmentApiKeysResponse, + zConsoleAccessServiceListEnvironmentTriggersPath, + zConsoleAccessServiceListEnvironmentTriggersResponse, + zConsoleAccessServiceResetEnvironmentSiteAccessTokenPath, + zConsoleAccessServiceResetEnvironmentSiteAccessTokenResponse, + zConsoleAccessServiceUpdateEnvironmentApiBody, + zConsoleAccessServiceUpdateEnvironmentApiPath, + zConsoleAccessServiceUpdateEnvironmentApiResponse, + zConsoleAccessServiceUpdateEnvironmentSiteBody, + zConsoleAccessServiceUpdateEnvironmentSitePath, + zConsoleAccessServiceUpdateEnvironmentSiteResponse, + zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeBody, + zConsoleAccessServiceUpdateEnvironmentWebAppAccessModePath, + zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponse, + zConsoleDeploymentServiceDeployWorkflowBody, + zConsoleDeploymentServiceDeployWorkflowPath, + zConsoleDeploymentServiceDeployWorkflowResponse, + zConsoleDeploymentServiceGetEnvironmentDeploymentPath, + zConsoleDeploymentServiceGetEnvironmentDeploymentResponse, + zConsoleDeploymentServiceGetWorkflowDeploymentOptionsPath, + zConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponse, + zConsoleDeploymentServiceListAppEnvironmentsPath, + zConsoleDeploymentServiceListAppEnvironmentsResponse, + zConsoleDeploymentServiceListEnvironmentDeploymentsPath, + zConsoleDeploymentServiceListEnvironmentDeploymentsResponse, + zConsoleDeploymentServicePrecheckWorkflowDeploymentPath, + zConsoleDeploymentServicePrecheckWorkflowDeploymentResponse, + zConsoleDeploymentServiceUndeployWorkflowPath, + zConsoleDeploymentServiceUndeployWorkflowResponse, +} from './zod.gen' + +export const listAppEnvironments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleDeploymentService_ListAppEnvironments', + path: '/enterprise/app-deploy/apps/{app_id}/environments', + tags: ['ConsoleDeploymentService'], + }) + .input(z.object({ params: zConsoleDeploymentServiceListAppEnvironmentsPath })) + .output(zConsoleDeploymentServiceListAppEnvironmentsResponse) + +export const listEnvironmentDeployments = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleDeploymentService_ListEnvironmentDeployments', + path: '/enterprise/app-deploy/apps/{app_id}/workflows/environment-deployments', + tags: ['ConsoleDeploymentService'], + }) + .input(z.object({ params: zConsoleDeploymentServiceListEnvironmentDeploymentsPath })) + .output(zConsoleDeploymentServiceListEnvironmentDeploymentsResponse) + +export const getEnvironmentDeployment = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleDeploymentService_GetEnvironmentDeployment', + path: '/enterprise/app-deploy/apps/{app_id}/workflows/environment-deployments/{environment_id}', + tags: ['ConsoleDeploymentService'], + }) + .input(z.object({ params: zConsoleDeploymentServiceGetEnvironmentDeploymentPath })) + .output(zConsoleDeploymentServiceGetEnvironmentDeploymentResponse) + +export const getWorkflowDeploymentOptions = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleDeploymentService_GetWorkflowDeploymentOptions', + path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment-options', + tags: ['ConsoleDeploymentService'], + }) + .input(z.object({ params: zConsoleDeploymentServiceGetWorkflowDeploymentOptionsPath })) + .output(zConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponse) + +export const deployWorkflow = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ConsoleDeploymentService_DeployWorkflow', + path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment:deploy', + tags: ['ConsoleDeploymentService'], + }) + .input( + z.object({ + body: zConsoleDeploymentServiceDeployWorkflowBody, + params: zConsoleDeploymentServiceDeployWorkflowPath, + }), + ) + .output(zConsoleDeploymentServiceDeployWorkflowResponse) + +export const undeployWorkflow = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ConsoleDeploymentService_UndeployWorkflow', + path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment:undeploy', + tags: ['ConsoleDeploymentService'], + }) + .input(z.object({ params: zConsoleDeploymentServiceUndeployWorkflowPath })) + .output(zConsoleDeploymentServiceUndeployWorkflowResponse) + +export const precheckWorkflowDeployment = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleDeploymentService_PrecheckWorkflowDeployment', + path: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}:precheck', + tags: ['ConsoleDeploymentService'], + }) + .input(z.object({ params: zConsoleDeploymentServicePrecheckWorkflowDeploymentPath })) + .output(zConsoleDeploymentServicePrecheckWorkflowDeploymentResponse) + +export const deploymentService = { + listAppEnvironments, + listEnvironmentDeployments, + getEnvironmentDeployment, + getWorkflowDeploymentOptions, + deployWorkflow, + undeployWorkflow, + precheckWorkflowDeployment, +} + +export const getEnvironmentApi = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleAccessService_GetEnvironmentAPI', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceGetEnvironmentApiPath })) + .output(zConsoleAccessServiceGetEnvironmentApiResponse) + +export const updateEnvironmentApi = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'ConsoleAccessService_UpdateEnvironmentAPI', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api', + tags: ['ConsoleAccessService'], + }) + .input( + z.object({ + body: zConsoleAccessServiceUpdateEnvironmentApiBody, + params: zConsoleAccessServiceUpdateEnvironmentApiPath, + }), + ) + .output(zConsoleAccessServiceUpdateEnvironmentApiResponse) + +export const listEnvironmentApiKeys = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleAccessService_ListEnvironmentApiKeys', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceListEnvironmentApiKeysPath })) + .output(zConsoleAccessServiceListEnvironmentApiKeysResponse) + +export const createEnvironmentApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ConsoleAccessService_CreateEnvironmentApiKey', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceCreateEnvironmentApiKeyPath })) + .output(zConsoleAccessServiceCreateEnvironmentApiKeyResponse) + +export const deleteEnvironmentApiKey = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'ConsoleAccessService_DeleteEnvironmentApiKey', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys/{api_key_id}', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceDeleteEnvironmentApiKeyPath })) + .output(zConsoleAccessServiceDeleteEnvironmentApiKeyResponse) + +export const getEnvironmentMcpServer = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleAccessService_GetEnvironmentMCPServer', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/server', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceGetEnvironmentMcpServerPath })) + .output(zConsoleAccessServiceGetEnvironmentMcpServerResponse) + +export const getEnvironmentSite = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleAccessService_GetEnvironmentSite', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceGetEnvironmentSitePath })) + .output(zConsoleAccessServiceGetEnvironmentSiteResponse) + +export const updateEnvironmentSite = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'ConsoleAccessService_UpdateEnvironmentSite', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site', + tags: ['ConsoleAccessService'], + }) + .input( + z.object({ + body: zConsoleAccessServiceUpdateEnvironmentSiteBody, + params: zConsoleAccessServiceUpdateEnvironmentSitePath, + }), + ) + .output(zConsoleAccessServiceUpdateEnvironmentSiteResponse) + +export const resetEnvironmentSiteAccessToken = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ConsoleAccessService_ResetEnvironmentSiteAccessToken', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site/access-token-reset', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceResetEnvironmentSiteAccessTokenPath })) + .output(zConsoleAccessServiceResetEnvironmentSiteAccessTokenResponse) + +export const listEnvironmentTriggers = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleAccessService_ListEnvironmentTriggers', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/triggers', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceListEnvironmentTriggersPath })) + .output(zConsoleAccessServiceListEnvironmentTriggersResponse) + +export const updateEnvironmentWebAppAccessMode = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'ConsoleAccessService_UpdateEnvironmentWebAppAccessMode', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/webapp/access-mode', + tags: ['ConsoleAccessService'], + }) + .input( + z.object({ + body: zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeBody, + params: zConsoleAccessServiceUpdateEnvironmentWebAppAccessModePath, + }), + ) + .output(zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponse) + +export const getEnvironmentWebAppSubjects = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'ConsoleAccessService_GetEnvironmentWebAppSubjects', + path: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/webapp/subjects', + tags: ['ConsoleAccessService'], + }) + .input(z.object({ params: zConsoleAccessServiceGetEnvironmentWebAppSubjectsPath })) + .output(zConsoleAccessServiceGetEnvironmentWebAppSubjectsResponse) + +export const accessService = { + getEnvironmentApi, + updateEnvironmentApi, + listEnvironmentApiKeys, + createEnvironmentApiKey, + deleteEnvironmentApiKey, + getEnvironmentMcpServer, + getEnvironmentSite, + updateEnvironmentSite, + resetEnvironmentSiteAccessToken, + listEnvironmentTriggers, + updateEnvironmentWebAppAccessMode, + getEnvironmentWebAppSubjects, +} + +export const contract = { + deploymentService, + accessService, +} diff --git a/packages/contracts/generated/enterprise-app-deploy/types.gen.ts b/packages/contracts/generated/enterprise-app-deploy/types.gen.ts new file mode 100644 index 00000000000..c64c433d4b6 --- /dev/null +++ b/packages/contracts/generated/enterprise-app-deploy/types.gen.ts @@ -0,0 +1,1326 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}) +} + +export const EnvironmentStatus = { + ENVIRONMENT_STATUS_UNSPECIFIED: 'ENVIRONMENT_STATUS_UNSPECIFIED', + ENVIRONMENT_STATUS_PENDING: 'ENVIRONMENT_STATUS_PENDING', + ENVIRONMENT_STATUS_READY: 'ENVIRONMENT_STATUS_READY', + ENVIRONMENT_STATUS_FAILED: 'ENVIRONMENT_STATUS_FAILED', + ENVIRONMENT_STATUS_DELETING: 'ENVIRONMENT_STATUS_DELETING', +} as const + +export type EnvironmentStatus = (typeof EnvironmentStatus)[keyof typeof EnvironmentStatus] + +export const ApplicationInteractionStatus = { + APPLICATION_INTERACTION_STATUS_UNSPECIFIED: 'APPLICATION_INTERACTION_STATUS_UNSPECIFIED', + APPLICATION_INTERACTION_STATUS_RUNNING: 'APPLICATION_INTERACTION_STATUS_RUNNING', + APPLICATION_INTERACTION_STATUS_SUCCEEDED: 'APPLICATION_INTERACTION_STATUS_SUCCEEDED', + APPLICATION_INTERACTION_STATUS_FAILED: 'APPLICATION_INTERACTION_STATUS_FAILED', + APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED: + 'APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED', +} as const + +export type ApplicationInteractionStatus = + (typeof ApplicationInteractionStatus)[keyof typeof ApplicationInteractionStatus] + +export const EnvironmentMode = { + ENVIRONMENT_MODE_UNSPECIFIED: 'ENVIRONMENT_MODE_UNSPECIFIED', + ENVIRONMENT_MODE_SHARED: 'ENVIRONMENT_MODE_SHARED', + ENVIRONMENT_MODE_ISOLATED: 'ENVIRONMENT_MODE_ISOLATED', +} as const + +export type EnvironmentMode = (typeof EnvironmentMode)[keyof typeof EnvironmentMode] + +export const PluginCategory = { + PLUGIN_CATEGORY_UNSPECIFIED: 'PLUGIN_CATEGORY_UNSPECIFIED', + PLUGIN_CATEGORY_MODEL: 'PLUGIN_CATEGORY_MODEL', + PLUGIN_CATEGORY_TOOL: 'PLUGIN_CATEGORY_TOOL', +} as const + +export type PluginCategory = (typeof PluginCategory)[keyof typeof PluginCategory] + +export const DeploymentOperationType = { + DEPLOYMENT_OPERATION_TYPE_UNSPECIFIED: 'DEPLOYMENT_OPERATION_TYPE_UNSPECIFIED', + DEPLOYMENT_OPERATION_TYPE_DEPLOY: 'DEPLOYMENT_OPERATION_TYPE_DEPLOY', + DEPLOYMENT_OPERATION_TYPE_UNDEPLOY: 'DEPLOYMENT_OPERATION_TYPE_UNDEPLOY', +} as const + +export type DeploymentOperationType = + (typeof DeploymentOperationType)[keyof typeof DeploymentOperationType] + +export const DeploymentOperationOutcome = { + DEPLOYMENT_OPERATION_OUTCOME_UNSPECIFIED: 'DEPLOYMENT_OPERATION_OUTCOME_UNSPECIFIED', + DEPLOYMENT_OPERATION_OUTCOME_IN_PROGRESS: 'DEPLOYMENT_OPERATION_OUTCOME_IN_PROGRESS', + DEPLOYMENT_OPERATION_OUTCOME_SUCCEEDED: 'DEPLOYMENT_OPERATION_OUTCOME_SUCCEEDED', + DEPLOYMENT_OPERATION_OUTCOME_FAILED: 'DEPLOYMENT_OPERATION_OUTCOME_FAILED', +} as const + +export type DeploymentOperationOutcome = + (typeof DeploymentOperationOutcome)[keyof typeof DeploymentOperationOutcome] + +export const DeploymentOperationFailureCode = { + DEPLOYMENT_OPERATION_FAILURE_CODE_UNSPECIFIED: 'DEPLOYMENT_OPERATION_FAILURE_CODE_UNSPECIFIED', + DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_IN_PROGRESS: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_IN_PROGRESS', + DEPLOYMENT_OPERATION_FAILURE_CODE_NOTHING_TO_UNDEPLOY: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_NOTHING_TO_UNDEPLOY', + DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_STATE_CHANGED: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_STATE_CHANGED', + DEPLOYMENT_OPERATION_FAILURE_CODE_APPLICATION_UNAVAILABLE: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_APPLICATION_UNAVAILABLE', + DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_UNAVAILABLE: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_UNAVAILABLE', + DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_REMOVED: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_REMOVED', + DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_INVALID: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_INVALID', + DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_ACCESS_DENIED: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_ACCESS_DENIED', + DEPLOYMENT_OPERATION_FAILURE_CODE_VERSION_UNAVAILABLE: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_VERSION_UNAVAILABLE', + DEPLOYMENT_OPERATION_FAILURE_CODE_VERSION_NOT_DEPLOYABLE: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_VERSION_NOT_DEPLOYABLE', + DEPLOYMENT_OPERATION_FAILURE_CODE_REQUIRED_CONFIGURATION_MISSING: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_REQUIRED_CONFIGURATION_MISSING', + DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_CONFIGURATION_INVALID: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_CONFIGURATION_INVALID', + DEPLOYMENT_OPERATION_FAILURE_CODE_CREDENTIAL_UNAVAILABLE: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_CREDENTIAL_UNAVAILABLE', + DEPLOYMENT_OPERATION_FAILURE_CODE_RUNTIME_UNAVAILABLE: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_RUNTIME_UNAVAILABLE', + DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_TIMEOUT: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_TIMEOUT', + DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_INTERRUPTED: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_INTERRUPTED', + DEPLOYMENT_OPERATION_FAILURE_CODE_INTERNAL_ERROR: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_INTERNAL_ERROR', + DEPLOYMENT_OPERATION_FAILURE_CODE_ENVIRONMENT_CPU_POOL_EXHAUSTED: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_ENVIRONMENT_CPU_POOL_EXHAUSTED', + DEPLOYMENT_OPERATION_FAILURE_CODE_APP_RUNNER_ENV_CPU_LIMIT_EXCEEDED: + 'DEPLOYMENT_OPERATION_FAILURE_CODE_APP_RUNNER_ENV_CPU_LIMIT_EXCEEDED', +} as const + +export type DeploymentOperationFailureCode = + (typeof DeploymentOperationFailureCode)[keyof typeof DeploymentOperationFailureCode] + +export const DeploymentOperationStatus = { + DEPLOYMENT_OPERATION_STATUS_UNSPECIFIED: 'DEPLOYMENT_OPERATION_STATUS_UNSPECIFIED', + DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS: 'DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS', + DEPLOYMENT_OPERATION_STATUS_SUCCEEDED: 'DEPLOYMENT_OPERATION_STATUS_SUCCEEDED', + DEPLOYMENT_OPERATION_STATUS_FAILED: 'DEPLOYMENT_OPERATION_STATUS_FAILED', +} as const + +export type DeploymentOperationStatus = + (typeof DeploymentOperationStatus)[keyof typeof DeploymentOperationStatus] + +export const EnvironmentBackend = { + ENVIRONMENT_BACKEND_UNSPECIFIED: 'ENVIRONMENT_BACKEND_UNSPECIFIED', + ENVIRONMENT_BACKEND_KUBERNETES: 'ENVIRONMENT_BACKEND_KUBERNETES', + ENVIRONMENT_BACKEND_EXTERNAL: 'ENVIRONMENT_BACKEND_EXTERNAL', +} as const + +export type EnvironmentBackend = (typeof EnvironmentBackend)[keyof typeof EnvironmentBackend] + +export const EnvironmentManagedBy = { + ENVIRONMENT_MANAGED_BY_UNSPECIFIED: 'ENVIRONMENT_MANAGED_BY_UNSPECIFIED', + ENVIRONMENT_MANAGED_BY_SYSTEM: 'ENVIRONMENT_MANAGED_BY_SYSTEM', + ENVIRONMENT_MANAGED_BY_USER: 'ENVIRONMENT_MANAGED_BY_USER', +} as const + +export type EnvironmentManagedBy = (typeof EnvironmentManagedBy)[keyof typeof EnvironmentManagedBy] + +export const EnvironmentDeployedAppStatus = { + ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED', + ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED', + ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING: 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING', + ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED', + ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED', +} as const + +export type EnvironmentDeployedAppStatus = + (typeof EnvironmentDeployedAppStatus)[keyof typeof EnvironmentDeployedAppStatus] + +export const DeploymentStatus = { + DEPLOYMENT_STATUS_UNSPECIFIED: 'DEPLOYMENT_STATUS_UNSPECIFIED', + DEPLOYMENT_STATUS_UNDEPLOYED: 'DEPLOYMENT_STATUS_UNDEPLOYED', + DEPLOYMENT_STATUS_DEPLOYING: 'DEPLOYMENT_STATUS_DEPLOYING', + DEPLOYMENT_STATUS_RUNNING: 'DEPLOYMENT_STATUS_RUNNING', + DEPLOYMENT_STATUS_UNDEPLOYING: 'DEPLOYMENT_STATUS_UNDEPLOYING', + DEPLOYMENT_STATUS_INVALID: 'DEPLOYMENT_STATUS_INVALID', + DEPLOYMENT_STATUS_FAILED: 'DEPLOYMENT_STATUS_FAILED', +} as const + +export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus] + +export const EnvVarValueSource = { + ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + ENV_VAR_VALUE_SOURCE_CONFIGURED: 'ENV_VAR_VALUE_SOURCE_CONFIGURED', + ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED: 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED', + ENV_VAR_VALUE_SOURCE_CUSTOM: 'ENV_VAR_VALUE_SOURCE_CUSTOM', +} as const + +export type EnvVarValueSource = (typeof EnvVarValueSource)[keyof typeof EnvVarValueSource] + +export const EnvVarValueType = { + ENV_VAR_VALUE_TYPE_UNSPECIFIED: 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + ENV_VAR_VALUE_TYPE_STRING: 'ENV_VAR_VALUE_TYPE_STRING', + ENV_VAR_VALUE_TYPE_NUMBER: 'ENV_VAR_VALUE_TYPE_NUMBER', + ENV_VAR_VALUE_TYPE_SECRET: 'ENV_VAR_VALUE_TYPE_SECRET', +} as const + +export type EnvVarValueType = (typeof EnvVarValueType)[keyof typeof EnvVarValueType] + +export const OperatorType = { + OPERATOR_TYPE_UNSPECIFIED: 'OPERATOR_TYPE_UNSPECIFIED', + OPERATOR_TYPE_END_USER: 'OPERATOR_TYPE_END_USER', + OPERATOR_TYPE_ACCOUNT: 'OPERATOR_TYPE_ACCOUNT', + OPERATOR_TYPE_SERVICE_ACCOUNT: 'OPERATOR_TYPE_SERVICE_ACCOUNT', + OPERATOR_TYPE_SYSTEM: 'OPERATOR_TYPE_SYSTEM', + OPERATOR_TYPE_DASHBOARD_ACCOUNT: 'OPERATOR_TYPE_DASHBOARD_ACCOUNT', +} as const + +export type OperatorType = (typeof OperatorType)[keyof typeof OperatorType] + +export const RouteTargetKind = { + ROUTE_TARGET_KIND_UNSPECIFIED: 'ROUTE_TARGET_KIND_UNSPECIFIED', + ROUTE_TARGET_KIND_K8S_SERVICE: 'ROUTE_TARGET_KIND_K8S_SERVICE', + ROUTE_TARGET_KIND_DIRECT_UPSTREAM: 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +} as const + +export type RouteTargetKind = (typeof RouteTargetKind)[keyof typeof RouteTargetKind] + +export type AppEnvironment = { + id: string + display_name: string + description: string + status: EnvironmentStatus + in_use: boolean +} + +export type ApplicationInteraction = { + id: string + timestamp: string + workflowRunId: string + status: ApplicationInteractionStatus + durationSeconds: number + totalTokens: string + workspace: NamedRef + environment: NamedRef + app: NamedRef + operator?: Operator + invokeFrom: string + traceId: string + difyTraceId: string + deploymentVersionId: string + error?: string + body?: string + attributesJson?: string + resourceAttributesJson?: string +} + +export type BatchGetSourceVersionDeploymentsRequest = { + appId?: string + sourceVersionIds?: Array +} + +export type BatchGetSourceVersionDeploymentsResponse = { + items?: Array +} + +export type CheckAppDeletableRequest = { + appId?: string +} + +export type CheckAppDeletableResponse = { + deletable?: boolean + reason?: string +} + +export type CheckSourceVersionDeletableRequest = { + appId?: string + sourceVersionId?: string +} + +export type CheckSourceVersionDeletableResponse = { + deletable?: boolean + reason?: string +} + +export type CreateDockerComposeBundleRequest = { + environmentId?: string + dockerNetwork: string +} + +export type CreateDockerComposeBundleResponse = { + bundle: DockerComposeBundle +} + +export type CreateEnvironmentRequest = { + displayName: string + description?: string + mode: EnvironmentMode + cpuPool: number + namespace?: string + maxMemoryMib?: string +} + +export type CreateEnvironmentResponse = { + environment: Environment +} + +export type CredentialCandidate = { + credential_id: string + provider_id: string + category: PluginCategory + display_name: string + from_enterprise: boolean +} + +export type CredentialSelectionInput = { + provider_id: string + category: PluginCategory + credential_id: string +} + +export type CredentialSlot = { + provider_id: string + category: PluginCategory + candidates: Array + last_deployed_credential_id?: string + icon?: string + icon_dark?: string +} + +export type DashboardApp = { + id: string + workspaceId: string + displayName: string +} + +export type DeleteEnvironmentApiKeyResponse = { + [key: string]: unknown +} + +export type DeleteEnvironmentResponse = { + [key: string]: unknown +} + +export type DeployWorkflowResponse = { + operation: DeploymentOperationReceipt +} + +export type DeploymentEnvironment = { + id: string + display_name: string + status: EnvironmentStatus + description: string +} + +export type DeploymentOperation = { + id: string + type: DeploymentOperationType + outcome: DeploymentOperationOutcome + workspace: NamedRef + operator: Operator + app: NamedRef + environment: NamedRef + version: WorkflowVersion + failureCode?: DeploymentOperationFailureCode + failureMessage?: string + requestedAt: string + finalizedAt?: string + durationMilliseconds?: string +} + +export type DeploymentOperationReceipt = { + id: string + type: DeploymentOperationType + status: DeploymentOperationStatus +} + +export type DockerComposeBundle = { + filename: string + archive: string + composeYaml: string + configYaml: string +} + +export type Environment = { + id: string + displayName: string + description: string + mode: EnvironmentMode + backend: EnvironmentBackend + status: EnvironmentStatus + statusMessage: string + lastError?: Error + namespace?: string + managedBy?: EnvironmentManagedBy + cpuPool: number + createdAt: string + updatedAt: string + memory?: RunnerMemory + usage?: EnvironmentPoolUsage +} + +export type EnvironmentApi = { + enabled: boolean + base_url: string + api_key_count: number +} + +export type EnvironmentApiUpdate = { + enabled: boolean +} + +export type EnvironmentAccess = { + enable_site: boolean + enable_api: boolean +} + +export type EnvironmentApiKey = { + id: string + type: string + token: string + last_used_at?: number + created_at: number +} + +export type EnvironmentDeployedApp = { + deploymentId: string + workspace: NamedRef + app: NamedRef + status: EnvironmentDeployedAppStatus + currentVersion?: WorkflowVersion + deployedAt?: string + deployedBy?: Operator + latestAttempt?: EnvironmentDeployedAppAttempt + sizing?: RunnerSizing + occupiesPool?: boolean +} + +export type EnvironmentDeployedAppAttempt = { + operationId: string + type: DeploymentOperationType + outcome: DeploymentOperationOutcome + failureCode?: DeploymentOperationFailureCode + failureMessage?: string + requestedAt: string + finalizedAt?: string +} + +export type EnvironmentDeployedAppSummary = { + total: number + deployed: number + deploying: number + failed: number +} + +export type EnvironmentDeployment = { + environment: DeploymentEnvironment + deployment?: EnvironmentDeploymentState + access: EnvironmentAccess +} + +export type EnvironmentDeploymentOperation = { + id: string + type: DeploymentOperationType + status: DeploymentOperationStatus + target_version?: WorkflowVersion + operator: Operator + activity_at: number +} + +export type EnvironmentDeploymentState = { + status: DeploymentStatus + current_version?: WorkflowVersion + versions_behind?: number + deployed_at?: number + deployed_by?: Operator + latest_operation?: EnvironmentDeploymentOperation +} + +export type EnvironmentMcpServer = { + [key: string]: unknown +} + +export type EnvironmentPoolComposition = { + topApps?: Array + otherCpu?: number + otherAppCount?: number +} + +export type EnvironmentPoolShare = { + app: NamedRef + isolatedCpu: number +} + +export type EnvironmentPoolUsage = { + occupiedCpu: number + appCount: number +} + +export type EnvironmentSite = { + enabled: boolean + code: string + app_base_url: string + access_mode: string +} + +export type EnvironmentSiteUpdate = { + enabled: boolean +} + +export type EnvironmentTrigger = { + [key: string]: unknown +} + +export type EnvironmentVariableInput = { + key: string + value_source: EnvVarValueSource + value?: string +} + +export type EnvironmentVariableSlot = { + key: string + value_type: EnvVarValueType + description: string + has_configured_value: boolean + has_last_deployed_value: boolean + configured_value?: string + last_deployed_value?: string +} + +export type EnvironmentWebAppAccessModeUpdate = { + access_mode: string + subjects?: Array +} + +export type EnvironmentWebAppSubject = { + subject_id?: string + subject_type?: string + account_data?: EnvironmentWebAppSubjectAccountData + group_data?: EnvironmentWebAppSubjectGroupData +} + +export type EnvironmentWebAppSubjectAccountData = { + id?: string + name?: string + email?: string + avatar?: string +} + +export type EnvironmentWebAppSubjectGroupData = { + id?: string + name?: string + group_size?: number +} + +export type Error = { + reason?: + | 'APPDEPLOY_ERROR_REASON_UNSPECIFIED' + | 'APPDEPLOY_INTERNAL' + | 'APPDEPLOY_INVALID_ARGUMENT' + | 'APPDEPLOY_INVALID_TENANT_ID' + | 'APPDEPLOY_INVALID_APP_ID' + | 'APPDEPLOY_INVALID_SOURCE_VERSION_ID' + | 'APPDEPLOY_INVALID_ENVIRONMENT_ID' + | 'APPDEPLOY_INVALID_REVISION_ID' + | 'APPDEPLOY_INVALID_DEPLOYMENT_ID' + | 'APPDEPLOY_INVALID_CREDENTIAL_ID' + | 'APPDEPLOY_INVALID_SUBJECT_ID' + | 'APPDEPLOY_INVALID_API_KEY_ID' + | 'APPDEPLOY_INVALID_ACCOUNT' + | 'APPDEPLOY_ENVIRONMENT_MODE_REQUIRED' + | 'APPDEPLOY_ENVIRONMENT_BACKEND_REQUIRED' + | 'APPDEPLOY_APP_LOG_TIME_RANGE_REQUIRED' + | 'APPDEPLOY_APP_LOG_INVALID_TIME_RANGE' + | 'APPDEPLOY_APP_LOG_INVALID_CURSOR' + | 'APPDEPLOY_APP_LOG_CURSOR_FILTER_MISMATCH' + | 'APPDEPLOY_APP_LOG_ID_INVALID' + | 'APPDEPLOY_UNSUPPORTED_NODE_TYPE' + | 'APPDEPLOY_UNSUPPORTED_TOOL_PROVIDER_TYPE' + | 'APPDEPLOY_TOOL_PROVIDER_TYPE_INVALID' + | 'APPDEPLOY_TOOL_PROVIDER_NOT_INSTALLED' + | 'APPDEPLOY_MODEL_PROVIDER_NOT_INSTALLED' + | 'APPDEPLOY_MODEL_TYPE_UNSUPPORTED' + | 'APPDEPLOY_CREDENTIAL_MISMATCH' + | 'APPDEPLOY_APP_RUNNER_JOIN_TOKEN_REQUIRED' + | 'APPDEPLOY_INVALID_WORKFLOW_ID' + | 'APPDEPLOY_INVALID_DEPLOYMENT_VERSION_ID' + | 'APPDEPLOY_DEVELOPER_API_URL_NOT_CONFIGURED' + | 'APPDEPLOY_INVALID_DEPLOYMENT_OPERATION_ID' + | 'APPDEPLOY_APP_LOG_EXPORT_RANGE_TOO_WIDE' + | 'APPDEPLOY_APP_LOG_EXPORT_TOO_MANY_ROWS' + | 'APPDEPLOY_APP_LOG_EXPORT_TOO_LARGE' + | 'APPDEPLOY_UNAUTHORIZED' + | 'APPDEPLOY_FORBIDDEN' + | 'APPDEPLOY_APP_RUNNER_AUTH_REQUIRED' + | 'APPDEPLOY_APP_RUNNER_INVALID_JOIN_TOKEN' + | 'APPDEPLOY_APP_RUNNER_INVALID_CONTROL_TOKEN' + | 'APPDEPLOY_ENVIRONMENT_NOT_FOUND' + | 'APPDEPLOY_DEPLOYMENT_NOT_FOUND' + | 'APPDEPLOY_REVISION_NOT_FOUND' + | 'APPDEPLOY_ROLLBACK_TARGET_NOT_FOUND' + | 'APPDEPLOY_APP_NOT_FOUND' + | 'APPDEPLOY_CREDENTIAL_NOT_FOUND' + | 'APPDEPLOY_ENVIRONMENT_WEB_APP_SETTING_NOT_FOUND' + | 'APPDEPLOY_ACCESS_SUBJECT_NOT_FOUND' + | 'APPDEPLOY_API_KEY_NOT_FOUND' + | 'APPDEPLOY_SOURCE_VERSION_NOT_FOUND' + | 'APPDEPLOY_APP_LOG_NOT_FOUND' + | 'APPDEPLOY_WORKSPACE_NOT_FOUND' + | 'APPDEPLOY_APP_RUNNER_NOT_FOUND' + | 'APPDEPLOY_WORKFLOW_NOT_FOUND' + | 'APPDEPLOY_DEPLOYMENT_OPERATION_NOT_FOUND' + | 'APPDEPLOY_RUN_FILE_NOT_FOUND' + | 'APPDEPLOY_APPLICATION_UNAVAILABLE' + | 'APPDEPLOY_TARGET_ENVIRONMENT_REMOVED' + | 'APPDEPLOY_VERSION_UNAVAILABLE' + | 'APPDEPLOY_CONFLICT' + | 'APPDEPLOY_DEPLOYMENT_IN_PROGRESS' + | 'APPDEPLOY_ALREADY_UNDEPLOYED' + | 'APPDEPLOY_REVISION_ALREADY_TERMINAL' + | 'APPDEPLOY_NO_IN_FLIGHT_DEPLOYMENT' + | 'APPDEPLOY_ENVIRONMENT_CONFLICT' + | 'APPDEPLOY_ENVIRONMENT_BUSY' + | 'APPDEPLOY_ENV_VAR_KEY_CONFLICT' + | 'APPDEPLOY_APP_HAS_ACTIVE_DEPLOYMENTS' + | 'APPDEPLOY_SOURCE_VERSION_IN_USE' + | 'APPDEPLOY_NOTHING_TO_UNDEPLOY' + | 'APPDEPLOY_DEPLOYMENT_STATE_CHANGED' + | 'APPDEPLOY_ENVIRONMENT_HAS_ACTIVE_DEPLOYMENTS' + | 'APPDEPLOY_ENVIRONMENT_NOT_READY' + | 'APPDEPLOY_ENVIRONMENT_CAPACITY_EXCEEDED' + | 'APPDEPLOY_APP_RUNNER_ENV_CPU_LIMIT_EXCEEDED' + | 'APPDEPLOY_SOURCE_VERSION_NOT_DEPLOYABLE' + | 'APPDEPLOY_CREDENTIAL_GONE' + | 'APPDEPLOY_SLOT_UNSATISFIED' + | 'APPDEPLOY_WORKSPACE_INACTIVE' + | 'APPDEPLOY_ENVIRONMENT_BACKEND_UNAVAILABLE' + | 'APPDEPLOY_APP_RUNNER_REGISTRATION_NOT_ALLOWED' + | 'APPDEPLOY_WORKFLOW_NOT_DEPLOYABLE' + | 'APPDEPLOY_API_KEY_LIMIT_EXCEEDED' + | 'APPDEPLOY_ENVIRONMENT_NOT_FAILED' + | 'APPDEPLOY_TARGET_ENVIRONMENT_UNAVAILABLE' + | 'APPDEPLOY_TARGET_ENVIRONMENT_INVALID' + | 'APPDEPLOY_TARGET_ENVIRONMENT_ACCESS_DENIED' + | 'APPDEPLOY_VERSION_NOT_DEPLOYABLE' + | 'APPDEPLOY_REQUIRED_CONFIGURATION_MISSING' + | 'APPDEPLOY_DEPLOYMENT_CONFIGURATION_INVALID' + | 'APPDEPLOY_CREDENTIAL_UNAVAILABLE' + | 'APPDEPLOY_RUNTIME_UNAVAILABLE' + | 'APPDEPLOY_DEPLOYMENT_TIMEOUT' + | 'APPDEPLOY_DEPLOYMENT_INTERRUPTED' + | 'APPDEPLOY_ENVIRONMENT_CPU_POOL_EXHAUSTED' + | 'APPDEPLOY_RESOURCE_NOT_APPLICABLE_FOR_MODE' + | 'APPDEPLOY_ENVIRONMENT_CPU_POOL_BELOW_ALLOCATED' + | 'APPDEPLOY_APP_RUNNER_CONTROL_NOT_CONFIGURED' + | 'APPDEPLOY_RUNTIME_ASSIGNMENT_FAILED' + | 'APPDEPLOY_REVISION_TIMEOUT' + | 'APPDEPLOY_INTERNAL_ERROR' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_AUTH_REJECTED' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_NAMESPACE_MISSING' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INSUFFICIENT_RBAC' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_DECRYPT_FAILED' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INVALID_K8S_CONFIG' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_RESOURCE_COLLISION' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_APPLY_FAILED' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_SIGNING_KEY_MISSING' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_CONTROL_ENDPOINT_INVALID' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_K8S_UNREACHABLE' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_APP_RUNNER_NOT_READY' + | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_SSAR_UNAVAILABLE' + message?: string + phase?: string + occurredAt?: string + detailCode?: string +} + +export type GetApplicationInteractionResponse = { + interaction: ApplicationInteraction +} + +export type GetDeploymentOperationResponse = { + operation: DeploymentOperation +} + +export type GetEnvironmentCapabilitiesResponse = { + backend: EnvironmentBackend + supportedModes: Array< + 'ENVIRONMENT_MODE_UNSPECIFIED' | 'ENVIRONMENT_MODE_SHARED' | 'ENVIRONMENT_MODE_ISOLATED' + > + namespaceRequired: boolean +} + +export type GetEnvironmentDeploymentResponse = { + environment_deployment: EnvironmentDeployment +} + +export type GetEnvironmentResponse = { + environment: Environment + pool?: EnvironmentPoolComposition +} + +export type GetEnvironmentWebAppSubjectsResponse = { + subjects: Array +} + +export type GetWebAppAccessModeResponse = { + accessMode?: string +} + +export type GetWebAppPermissionResponse = { + result?: boolean +} + +export type GetWorkflowDeploymentOptionsResponse = { + environment_variable_slots: Array + credential_slots: Array +} + +export type ListAppEnvironmentsResponse = { + data: Array +} + +export type ListApplicationInteractionsResponse = { + data: Array + pagination: Pagination +} + +export type ListAppsResponse = { + data: Array + pagination: Pagination +} + +export type ListDeploymentOperationsResponse = { + data: Array + pagination: Pagination +} + +export type ListEnvironmentApiKeysResponse = { + data: Array +} + +export type ListEnvironmentDeployedAppsResponse = { + data: Array + summary: EnvironmentDeployedAppSummary + pagination: Pagination +} + +export type ListEnvironmentDeploymentsResponse = { + environment_deployments: Array +} + +export type ListEnvironmentTriggersResponse = { + data: Array +} + +export type ListEnvironmentsResponse = { + data: Array + pagination: Pagination +} + +export type NamedRef = { + id: string + displayName: string +} + +export type Operator = { + type: OperatorType + id: string + display_name: string +} + +export type PrecheckWorkflowDeploymentResponse = { + unsupported_nodes: Array +} + +export type PrepareAppDeletionRequest = { + tenantId?: string + appId?: string +} + +export type ResolveApiTokenRouteRequest = { + token?: string +} + +export type ResolveApiTokenRouteResponse = { + environmentId?: string + namespace?: string + serviceName?: string + servicePort?: number + environmentStatus?: EnvironmentStatus + appId?: string + tenantId?: string + deploymentId?: string + servingRevisionId?: string + deploymentStatus?: DeploymentStatus + revoked?: boolean + unavailableReason?: string + targetKind?: RouteTargetKind + directUpstream?: string + deploymentGeneration?: string +} + +export type ResolveWebAppRouteRequest = { + appCode?: string + passport?: string +} + +export type ResolveWebAppRouteResponse = { + decision?: string + environmentId?: string + namespace?: string + serviceName?: string + servicePort?: number + environmentStatus?: EnvironmentStatus + appId?: string + tenantId?: string + deploymentId?: string + servingRevisionId?: string + deploymentStatus?: DeploymentStatus + unavailableReason?: string + targetKind?: RouteTargetKind + directUpstream?: string + deploymentGeneration?: string + endUserId?: string + authType?: string +} + +export type RetryEnvironmentBootstrapRequest = { + environmentId?: string +} + +export type RetryEnvironmentBootstrapResponse = { + environment: Environment +} + +export type RunnerMemory = { + maxMib?: string + readonly effectiveMaxMib?: string + readonly minMib?: string +} + +export type RunnerSizing = { + isolatedCpu: number + memory: RunnerMemory +} + +export type SimpleAccount = { + id: string + name?: string + email?: string +} + +export type SourceVersionDeployment = { + sourceVersionId?: string + environments?: Array +} + +export type TestConnectionRequest = { + environmentId?: string +} + +export type TestConnectionResponse = { + reachable?: boolean + message?: string +} + +export type UndeployEnvironmentAppResponse = { + operationId: string +} + +export type UndeployWorkflowResponse = { + operation: DeploymentOperationReceipt +} + +export type UnsupportedNode = { + id: string + type: string + title: string + provider?: UnsupportedNodeProvider +} + +export type UnsupportedNodeProvider = { + plugin_id: string + provider_id: string + provider_type: string + provider_name: string +} + +export type UpdateEnvironmentDeployedAppResourcesRequest = { + environmentId: string + deploymentId: string + isolatedCpu: number + maxMemoryMib?: string +} + +export type UpdateEnvironmentDeployedAppResourcesResponse = { + deploymentId: string + isolatedCpu: number + allocatedCpuCount: number + poolCpuCount: number + memory: RunnerMemory +} + +export type UpdateEnvironmentRequest = { + environmentId?: string + displayName?: string + description?: string + cpuPool?: number + maxMemoryMib?: string +} + +export type UpdateEnvironmentResponse = { + environment: Environment +} + +export type WorkflowDeploymentEnvironment = { + id?: string + name?: string +} + +export type WorkflowDeploymentInput = { + environment_variables?: Array + credentials?: Array +} + +export type WorkflowVersion = { + version?: string + marked_name?: string + id: string + marked_comment?: string + version_number?: number + created_at?: number + created_by?: SimpleAccount + dsl_hash?: string + deleted?: boolean +} + +export type Pagination = { + totalCount?: number + perPage?: number + currentPage?: number + totalPages?: number +} + +export type CreateEnvironmentResponseWritable = { + environment: EnvironmentWritable +} + +export type DeleteEnvironmentApiKeyResponseWritable = { + [key: string]: unknown +} + +export type DeleteEnvironmentResponseWritable = { + [key: string]: unknown +} + +export type EnvironmentWritable = { + id: string + displayName: string + description: string + mode: EnvironmentMode + backend: EnvironmentBackend + status: EnvironmentStatus + statusMessage: string + lastError?: Error + namespace?: string + managedBy?: EnvironmentManagedBy + cpuPool: number + createdAt: string + updatedAt: string + memory?: RunnerMemoryWritable + usage?: EnvironmentPoolUsage +} + +export type EnvironmentDeployedAppWritable = { + deploymentId: string + workspace: NamedRef + app: NamedRef + status: EnvironmentDeployedAppStatus + currentVersion?: WorkflowVersion + deployedAt?: string + deployedBy?: Operator + latestAttempt?: EnvironmentDeployedAppAttempt + sizing?: RunnerSizingWritable + occupiesPool?: boolean +} + +export type EnvironmentMcpServerWritable = { + [key: string]: unknown +} + +export type EnvironmentTriggerWritable = { + [key: string]: unknown +} + +export type GetEnvironmentResponseWritable = { + environment: EnvironmentWritable + pool?: EnvironmentPoolComposition +} + +export type ListEnvironmentDeployedAppsResponseWritable = { + data: Array + summary: EnvironmentDeployedAppSummary + pagination: Pagination +} + +export type ListEnvironmentsResponseWritable = { + data: Array + pagination: Pagination +} + +export type RetryEnvironmentBootstrapResponseWritable = { + environment: EnvironmentWritable +} + +export type RunnerMemoryWritable = { + maxMib?: string +} + +export type RunnerSizingWritable = { + isolatedCpu: number + memory: RunnerMemoryWritable +} + +export type UpdateEnvironmentDeployedAppResourcesResponseWritable = { + deploymentId: string + isolatedCpu: number + allocatedCpuCount: number + poolCpuCount: number + memory: RunnerMemoryWritable +} + +export type UpdateEnvironmentResponseWritable = { + environment: EnvironmentWritable +} + +export type ConsoleDeploymentServiceListAppEnvironmentsData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments' +} + +export type ConsoleDeploymentServiceListAppEnvironmentsResponses = { + 200: ListAppEnvironmentsResponse +} + +export type ConsoleDeploymentServiceListAppEnvironmentsResponse = + ConsoleDeploymentServiceListAppEnvironmentsResponses[keyof ConsoleDeploymentServiceListAppEnvironmentsResponses] + +export type ConsoleAccessServiceGetEnvironmentApiData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api' +} + +export type ConsoleAccessServiceGetEnvironmentApiResponses = { + 200: EnvironmentApi +} + +export type ConsoleAccessServiceGetEnvironmentApiResponse = + ConsoleAccessServiceGetEnvironmentApiResponses[keyof ConsoleAccessServiceGetEnvironmentApiResponses] + +export type ConsoleAccessServiceUpdateEnvironmentApiData = { + body: EnvironmentApiUpdate + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api' +} + +export type ConsoleAccessServiceUpdateEnvironmentApiResponses = { + 200: EnvironmentApi +} + +export type ConsoleAccessServiceUpdateEnvironmentApiResponse = + ConsoleAccessServiceUpdateEnvironmentApiResponses[keyof ConsoleAccessServiceUpdateEnvironmentApiResponses] + +export type ConsoleAccessServiceListEnvironmentApiKeysData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys' +} + +export type ConsoleAccessServiceListEnvironmentApiKeysResponses = { + 200: ListEnvironmentApiKeysResponse +} + +export type ConsoleAccessServiceListEnvironmentApiKeysResponse = + ConsoleAccessServiceListEnvironmentApiKeysResponses[keyof ConsoleAccessServiceListEnvironmentApiKeysResponses] + +export type ConsoleAccessServiceCreateEnvironmentApiKeyData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys' +} + +export type ConsoleAccessServiceCreateEnvironmentApiKeyResponses = { + 200: EnvironmentApiKey +} + +export type ConsoleAccessServiceCreateEnvironmentApiKeyResponse = + ConsoleAccessServiceCreateEnvironmentApiKeyResponses[keyof ConsoleAccessServiceCreateEnvironmentApiKeyResponses] + +export type ConsoleAccessServiceDeleteEnvironmentApiKeyData = { + body?: never + path: { + app_id: string + environment_id: string + api_key_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/api-keys/{api_key_id}' +} + +export type ConsoleAccessServiceDeleteEnvironmentApiKeyResponses = { + 200: DeleteEnvironmentApiKeyResponse +} + +export type ConsoleAccessServiceDeleteEnvironmentApiKeyResponse = + ConsoleAccessServiceDeleteEnvironmentApiKeyResponses[keyof ConsoleAccessServiceDeleteEnvironmentApiKeyResponses] + +export type ConsoleAccessServiceGetEnvironmentMcpServerData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/server' +} + +export type ConsoleAccessServiceGetEnvironmentMcpServerResponses = { + 200: EnvironmentMcpServer +} + +export type ConsoleAccessServiceGetEnvironmentMcpServerResponse = + ConsoleAccessServiceGetEnvironmentMcpServerResponses[keyof ConsoleAccessServiceGetEnvironmentMcpServerResponses] + +export type ConsoleAccessServiceGetEnvironmentSiteData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site' +} + +export type ConsoleAccessServiceGetEnvironmentSiteResponses = { + 200: EnvironmentSite +} + +export type ConsoleAccessServiceGetEnvironmentSiteResponse = + ConsoleAccessServiceGetEnvironmentSiteResponses[keyof ConsoleAccessServiceGetEnvironmentSiteResponses] + +export type ConsoleAccessServiceUpdateEnvironmentSiteData = { + body: EnvironmentSiteUpdate + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site' +} + +export type ConsoleAccessServiceUpdateEnvironmentSiteResponses = { + 200: EnvironmentSite +} + +export type ConsoleAccessServiceUpdateEnvironmentSiteResponse = + ConsoleAccessServiceUpdateEnvironmentSiteResponses[keyof ConsoleAccessServiceUpdateEnvironmentSiteResponses] + +export type ConsoleAccessServiceResetEnvironmentSiteAccessTokenData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/site/access-token-reset' +} + +export type ConsoleAccessServiceResetEnvironmentSiteAccessTokenResponses = { + 200: EnvironmentSite +} + +export type ConsoleAccessServiceResetEnvironmentSiteAccessTokenResponse = + ConsoleAccessServiceResetEnvironmentSiteAccessTokenResponses[keyof ConsoleAccessServiceResetEnvironmentSiteAccessTokenResponses] + +export type ConsoleAccessServiceListEnvironmentTriggersData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/triggers' +} + +export type ConsoleAccessServiceListEnvironmentTriggersResponses = { + 200: ListEnvironmentTriggersResponse +} + +export type ConsoleAccessServiceListEnvironmentTriggersResponse = + ConsoleAccessServiceListEnvironmentTriggersResponses[keyof ConsoleAccessServiceListEnvironmentTriggersResponses] + +export type ConsoleAccessServiceUpdateEnvironmentWebAppAccessModeData = { + body: EnvironmentWebAppAccessModeUpdate + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/webapp/access-mode' +} + +export type ConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponses = { + 200: EnvironmentSite +} + +export type ConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponse = + ConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponses[keyof ConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponses] + +export type ConsoleAccessServiceGetEnvironmentWebAppSubjectsData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/environments/{environment_id}/webapp/subjects' +} + +export type ConsoleAccessServiceGetEnvironmentWebAppSubjectsResponses = { + 200: GetEnvironmentWebAppSubjectsResponse +} + +export type ConsoleAccessServiceGetEnvironmentWebAppSubjectsResponse = + ConsoleAccessServiceGetEnvironmentWebAppSubjectsResponses[keyof ConsoleAccessServiceGetEnvironmentWebAppSubjectsResponses] + +export type ConsoleDeploymentServiceListEnvironmentDeploymentsData = { + body?: never + path: { + app_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/workflows/environment-deployments' +} + +export type ConsoleDeploymentServiceListEnvironmentDeploymentsResponses = { + 200: ListEnvironmentDeploymentsResponse +} + +export type ConsoleDeploymentServiceListEnvironmentDeploymentsResponse = + ConsoleDeploymentServiceListEnvironmentDeploymentsResponses[keyof ConsoleDeploymentServiceListEnvironmentDeploymentsResponses] + +export type ConsoleDeploymentServiceGetEnvironmentDeploymentData = { + body?: never + path: { + app_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/workflows/environment-deployments/{environment_id}' +} + +export type ConsoleDeploymentServiceGetEnvironmentDeploymentResponses = { + 200: GetEnvironmentDeploymentResponse +} + +export type ConsoleDeploymentServiceGetEnvironmentDeploymentResponse = + ConsoleDeploymentServiceGetEnvironmentDeploymentResponses[keyof ConsoleDeploymentServiceGetEnvironmentDeploymentResponses] + +export type ConsoleDeploymentServiceGetWorkflowDeploymentOptionsData = { + body?: never + path: { + app_id: string + workflow_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment-options' +} + +export type ConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponses = { + 200: GetWorkflowDeploymentOptionsResponse +} + +export type ConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponse = + ConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponses[keyof ConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponses] + +export type ConsoleDeploymentServiceDeployWorkflowData = { + body: WorkflowDeploymentInput + path: { + app_id: string + workflow_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment:deploy' +} + +export type ConsoleDeploymentServiceDeployWorkflowResponses = { + 200: DeployWorkflowResponse +} + +export type ConsoleDeploymentServiceDeployWorkflowResponse = + ConsoleDeploymentServiceDeployWorkflowResponses[keyof ConsoleDeploymentServiceDeployWorkflowResponses] + +export type ConsoleDeploymentServiceUndeployWorkflowData = { + body?: never + path: { + app_id: string + workflow_id: string + environment_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}/environments/{environment_id}/deployment:undeploy' +} + +export type ConsoleDeploymentServiceUndeployWorkflowResponses = { + 200: UndeployWorkflowResponse +} + +export type ConsoleDeploymentServiceUndeployWorkflowResponse = + ConsoleDeploymentServiceUndeployWorkflowResponses[keyof ConsoleDeploymentServiceUndeployWorkflowResponses] + +export type ConsoleDeploymentServicePrecheckWorkflowDeploymentData = { + body?: never + path: { + app_id: string + workflow_id: string + } + query?: never + url: '/enterprise/app-deploy/apps/{app_id}/workflows/{workflow_id}:precheck' +} + +export type ConsoleDeploymentServicePrecheckWorkflowDeploymentResponses = { + 200: PrecheckWorkflowDeploymentResponse +} + +export type ConsoleDeploymentServicePrecheckWorkflowDeploymentResponse = + ConsoleDeploymentServicePrecheckWorkflowDeploymentResponses[keyof ConsoleDeploymentServicePrecheckWorkflowDeploymentResponses] diff --git a/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts b/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts new file mode 100644 index 00000000000..c823bce2015 --- /dev/null +++ b/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts @@ -0,0 +1,1224 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +export const zEnvironmentStatus = z.enum([ + 'ENVIRONMENT_STATUS_UNSPECIFIED', + 'ENVIRONMENT_STATUS_PENDING', + 'ENVIRONMENT_STATUS_READY', + 'ENVIRONMENT_STATUS_FAILED', + 'ENVIRONMENT_STATUS_DELETING', +]) + +export const zApplicationInteractionStatus = z.enum([ + 'APPLICATION_INTERACTION_STATUS_UNSPECIFIED', + 'APPLICATION_INTERACTION_STATUS_RUNNING', + 'APPLICATION_INTERACTION_STATUS_SUCCEEDED', + 'APPLICATION_INTERACTION_STATUS_FAILED', + 'APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED', +]) + +export const zEnvironmentMode = z.enum([ + 'ENVIRONMENT_MODE_UNSPECIFIED', + 'ENVIRONMENT_MODE_SHARED', + 'ENVIRONMENT_MODE_ISOLATED', +]) + +export const zPluginCategory = z.enum([ + 'PLUGIN_CATEGORY_UNSPECIFIED', + 'PLUGIN_CATEGORY_MODEL', + 'PLUGIN_CATEGORY_TOOL', +]) + +export const zDeploymentOperationType = z.enum([ + 'DEPLOYMENT_OPERATION_TYPE_UNSPECIFIED', + 'DEPLOYMENT_OPERATION_TYPE_DEPLOY', + 'DEPLOYMENT_OPERATION_TYPE_UNDEPLOY', +]) + +export const zDeploymentOperationOutcome = z.enum([ + 'DEPLOYMENT_OPERATION_OUTCOME_UNSPECIFIED', + 'DEPLOYMENT_OPERATION_OUTCOME_IN_PROGRESS', + 'DEPLOYMENT_OPERATION_OUTCOME_SUCCEEDED', + 'DEPLOYMENT_OPERATION_OUTCOME_FAILED', +]) + +export const zDeploymentOperationFailureCode = z.enum([ + 'DEPLOYMENT_OPERATION_FAILURE_CODE_UNSPECIFIED', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_IN_PROGRESS', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_NOTHING_TO_UNDEPLOY', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_STATE_CHANGED', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_APPLICATION_UNAVAILABLE', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_UNAVAILABLE', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_REMOVED', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_INVALID', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_TARGET_ENVIRONMENT_ACCESS_DENIED', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_VERSION_UNAVAILABLE', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_VERSION_NOT_DEPLOYABLE', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_REQUIRED_CONFIGURATION_MISSING', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_CONFIGURATION_INVALID', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_CREDENTIAL_UNAVAILABLE', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_RUNTIME_UNAVAILABLE', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_TIMEOUT', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_DEPLOYMENT_INTERRUPTED', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_INTERNAL_ERROR', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_ENVIRONMENT_CPU_POOL_EXHAUSTED', + 'DEPLOYMENT_OPERATION_FAILURE_CODE_APP_RUNNER_ENV_CPU_LIMIT_EXCEEDED', +]) + +export const zDeploymentOperationStatus = z.enum([ + 'DEPLOYMENT_OPERATION_STATUS_UNSPECIFIED', + 'DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS', + 'DEPLOYMENT_OPERATION_STATUS_SUCCEEDED', + 'DEPLOYMENT_OPERATION_STATUS_FAILED', +]) + +export const zEnvironmentBackend = z.enum([ + 'ENVIRONMENT_BACKEND_UNSPECIFIED', + 'ENVIRONMENT_BACKEND_KUBERNETES', + 'ENVIRONMENT_BACKEND_EXTERNAL', +]) + +export const zEnvironmentManagedBy = z.enum([ + 'ENVIRONMENT_MANAGED_BY_UNSPECIFIED', + 'ENVIRONMENT_MANAGED_BY_SYSTEM', + 'ENVIRONMENT_MANAGED_BY_USER', +]) + +export const zEnvironmentDeployedAppStatus = z.enum([ + 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED', + 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED', + 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING', + 'ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED', + 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED', +]) + +export const zDeploymentStatus = z.enum([ + 'DEPLOYMENT_STATUS_UNSPECIFIED', + 'DEPLOYMENT_STATUS_UNDEPLOYED', + 'DEPLOYMENT_STATUS_DEPLOYING', + 'DEPLOYMENT_STATUS_RUNNING', + 'DEPLOYMENT_STATUS_UNDEPLOYING', + 'DEPLOYMENT_STATUS_INVALID', + 'DEPLOYMENT_STATUS_FAILED', +]) + +export const zEnvVarValueSource = z.enum([ + 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', + 'ENV_VAR_VALUE_SOURCE_CONFIGURED', + 'ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED', + 'ENV_VAR_VALUE_SOURCE_CUSTOM', +]) + +export const zEnvVarValueType = z.enum([ + 'ENV_VAR_VALUE_TYPE_UNSPECIFIED', + 'ENV_VAR_VALUE_TYPE_STRING', + 'ENV_VAR_VALUE_TYPE_NUMBER', + 'ENV_VAR_VALUE_TYPE_SECRET', +]) + +export const zOperatorType = z.enum([ + 'OPERATOR_TYPE_UNSPECIFIED', + 'OPERATOR_TYPE_END_USER', + 'OPERATOR_TYPE_ACCOUNT', + 'OPERATOR_TYPE_SERVICE_ACCOUNT', + 'OPERATOR_TYPE_SYSTEM', + 'OPERATOR_TYPE_DASHBOARD_ACCOUNT', +]) + +export const zRouteTargetKind = z.enum([ + 'ROUTE_TARGET_KIND_UNSPECIFIED', + 'ROUTE_TARGET_KIND_K8S_SERVICE', + 'ROUTE_TARGET_KIND_DIRECT_UPSTREAM', +]) + +export const zAppEnvironment = z.object({ + id: z.string(), + display_name: z.string(), + description: z.string(), + status: zEnvironmentStatus, + in_use: z.boolean(), +}) + +export const zBatchGetSourceVersionDeploymentsRequest = z.object({ + appId: z.string().optional(), + sourceVersionIds: z.array(z.string()).optional(), +}) + +export const zCheckAppDeletableRequest = z.object({ + appId: z.string().optional(), +}) + +export const zCheckAppDeletableResponse = z.object({ + deletable: z.boolean().optional(), + reason: z.string().optional(), +}) + +export const zCheckSourceVersionDeletableRequest = z.object({ + appId: z.string().optional(), + sourceVersionId: z.string().optional(), +}) + +export const zCheckSourceVersionDeletableResponse = z.object({ + deletable: z.boolean().optional(), + reason: z.string().optional(), +}) + +export const zCreateDockerComposeBundleRequest = z.object({ + environmentId: z.string().optional(), + dockerNetwork: z.string(), +}) + +export const zCreateEnvironmentRequest = z.object({ + displayName: z.string(), + description: z.string().optional(), + mode: zEnvironmentMode, + cpuPool: z.number(), + namespace: z.string().optional(), + maxMemoryMib: z.string().optional(), +}) + +export const zCredentialCandidate = z.object({ + credential_id: z.string(), + provider_id: z.string(), + category: zPluginCategory, + display_name: z.string(), + from_enterprise: z.boolean(), +}) + +export const zCredentialSelectionInput = z.object({ + provider_id: z.string(), + category: zPluginCategory, + credential_id: z.string(), +}) + +export const zCredentialSlot = z.object({ + provider_id: z.string(), + category: zPluginCategory, + candidates: z.array(zCredentialCandidate), + last_deployed_credential_id: z.string().optional(), + icon: z.string().optional(), + icon_dark: z.string().optional(), +}) + +export const zDashboardApp = z.object({ + id: z.string(), + workspaceId: z.string(), + displayName: z.string(), +}) + +export const zDeleteEnvironmentApiKeyResponse = z.record(z.string(), z.unknown()) + +export const zDeleteEnvironmentResponse = z.record(z.string(), z.unknown()) + +export const zDeploymentEnvironment = z.object({ + id: z.string(), + display_name: z.string(), + status: zEnvironmentStatus, + description: z.string(), +}) + +export const zDeploymentOperationReceipt = z.object({ + id: z.string(), + type: zDeploymentOperationType, + status: zDeploymentOperationStatus, +}) + +export const zDeployWorkflowResponse = z.object({ + operation: zDeploymentOperationReceipt, +}) + +export const zDockerComposeBundle = z.object({ + filename: z.string(), + archive: z.string(), + composeYaml: z.string(), + configYaml: z.string(), +}) + +export const zCreateDockerComposeBundleResponse = z.object({ + bundle: zDockerComposeBundle, +}) + +export const zEnvironmentApi = z.object({ + enabled: z.boolean(), + base_url: z.string(), + api_key_count: z + .int() + .min(0, { error: 'Invalid value: Expected uint32 to be >= 0' }) + .max(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }), +}) + +export const zEnvironmentApiUpdate = z.object({ + enabled: z.boolean(), +}) + +export const zEnvironmentAccess = z.object({ + enable_site: z.boolean(), + enable_api: z.boolean(), +}) + +export const zEnvironmentApiKey = z.object({ + id: z.string(), + type: z.string(), + token: z.string(), + last_used_at: z.number().optional(), + created_at: z.number(), +}) + +export const zEnvironmentDeployedAppAttempt = z.object({ + operationId: z.string(), + type: zDeploymentOperationType, + outcome: zDeploymentOperationOutcome, + failureCode: zDeploymentOperationFailureCode.optional(), + failureMessage: z.string().optional(), + requestedAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), +}) + +export const zEnvironmentDeployedAppSummary = z.object({ + total: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + deployed: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + deploying: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + failed: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zEnvironmentMcpServer = z.record(z.string(), z.unknown()) + +export const zEnvironmentPoolUsage = z.object({ + occupiedCpu: z.number(), + appCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), +}) + +export const zEnvironmentSite = z.object({ + enabled: z.boolean(), + code: z.string(), + app_base_url: z.string(), + access_mode: z.string(), +}) + +export const zEnvironmentSiteUpdate = z.object({ + enabled: z.boolean(), +}) + +export const zEnvironmentTrigger = z.record(z.string(), z.unknown()) + +export const zEnvironmentVariableInput = z.object({ + key: z.string(), + value_source: zEnvVarValueSource, + value: z.string().optional(), +}) + +export const zEnvironmentVariableSlot = z.object({ + key: z.string(), + value_type: zEnvVarValueType, + description: z.string(), + has_configured_value: z.boolean(), + has_last_deployed_value: z.boolean(), + configured_value: z.string().optional(), + last_deployed_value: z.string().optional(), +}) + +export const zEnvironmentWebAppSubjectAccountData = z.object({ + id: z.string().optional(), + name: z.string().optional(), + email: z.string().optional(), + avatar: z.string().optional(), +}) + +export const zEnvironmentWebAppSubjectGroupData = z.object({ + id: z.string().optional(), + name: z.string().optional(), + group_size: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zEnvironmentWebAppSubject = z.object({ + subject_id: z.string().optional(), + subject_type: z.string().optional(), + account_data: zEnvironmentWebAppSubjectAccountData.optional(), + group_data: zEnvironmentWebAppSubjectGroupData.optional(), +}) + +export const zEnvironmentWebAppAccessModeUpdate = z.object({ + access_mode: z.string(), + subjects: z.array(zEnvironmentWebAppSubject).optional(), +}) + +export const zError = z.object({ + reason: z + .enum([ + 'APPDEPLOY_ERROR_REASON_UNSPECIFIED', + 'APPDEPLOY_INTERNAL', + 'APPDEPLOY_INVALID_ARGUMENT', + 'APPDEPLOY_INVALID_TENANT_ID', + 'APPDEPLOY_INVALID_APP_ID', + 'APPDEPLOY_INVALID_SOURCE_VERSION_ID', + 'APPDEPLOY_INVALID_ENVIRONMENT_ID', + 'APPDEPLOY_INVALID_REVISION_ID', + 'APPDEPLOY_INVALID_DEPLOYMENT_ID', + 'APPDEPLOY_INVALID_CREDENTIAL_ID', + 'APPDEPLOY_INVALID_SUBJECT_ID', + 'APPDEPLOY_INVALID_API_KEY_ID', + 'APPDEPLOY_INVALID_ACCOUNT', + 'APPDEPLOY_ENVIRONMENT_MODE_REQUIRED', + 'APPDEPLOY_ENVIRONMENT_BACKEND_REQUIRED', + 'APPDEPLOY_APP_LOG_TIME_RANGE_REQUIRED', + 'APPDEPLOY_APP_LOG_INVALID_TIME_RANGE', + 'APPDEPLOY_APP_LOG_INVALID_CURSOR', + 'APPDEPLOY_APP_LOG_CURSOR_FILTER_MISMATCH', + 'APPDEPLOY_APP_LOG_ID_INVALID', + 'APPDEPLOY_UNSUPPORTED_NODE_TYPE', + 'APPDEPLOY_UNSUPPORTED_TOOL_PROVIDER_TYPE', + 'APPDEPLOY_TOOL_PROVIDER_TYPE_INVALID', + 'APPDEPLOY_TOOL_PROVIDER_NOT_INSTALLED', + 'APPDEPLOY_MODEL_PROVIDER_NOT_INSTALLED', + 'APPDEPLOY_MODEL_TYPE_UNSUPPORTED', + 'APPDEPLOY_CREDENTIAL_MISMATCH', + 'APPDEPLOY_APP_RUNNER_JOIN_TOKEN_REQUIRED', + 'APPDEPLOY_INVALID_WORKFLOW_ID', + 'APPDEPLOY_INVALID_DEPLOYMENT_VERSION_ID', + 'APPDEPLOY_DEVELOPER_API_URL_NOT_CONFIGURED', + 'APPDEPLOY_INVALID_DEPLOYMENT_OPERATION_ID', + 'APPDEPLOY_APP_LOG_EXPORT_RANGE_TOO_WIDE', + 'APPDEPLOY_APP_LOG_EXPORT_TOO_MANY_ROWS', + 'APPDEPLOY_APP_LOG_EXPORT_TOO_LARGE', + 'APPDEPLOY_UNAUTHORIZED', + 'APPDEPLOY_FORBIDDEN', + 'APPDEPLOY_APP_RUNNER_AUTH_REQUIRED', + 'APPDEPLOY_APP_RUNNER_INVALID_JOIN_TOKEN', + 'APPDEPLOY_APP_RUNNER_INVALID_CONTROL_TOKEN', + 'APPDEPLOY_ENVIRONMENT_NOT_FOUND', + 'APPDEPLOY_DEPLOYMENT_NOT_FOUND', + 'APPDEPLOY_REVISION_NOT_FOUND', + 'APPDEPLOY_ROLLBACK_TARGET_NOT_FOUND', + 'APPDEPLOY_APP_NOT_FOUND', + 'APPDEPLOY_CREDENTIAL_NOT_FOUND', + 'APPDEPLOY_ENVIRONMENT_WEB_APP_SETTING_NOT_FOUND', + 'APPDEPLOY_ACCESS_SUBJECT_NOT_FOUND', + 'APPDEPLOY_API_KEY_NOT_FOUND', + 'APPDEPLOY_SOURCE_VERSION_NOT_FOUND', + 'APPDEPLOY_APP_LOG_NOT_FOUND', + 'APPDEPLOY_WORKSPACE_NOT_FOUND', + 'APPDEPLOY_APP_RUNNER_NOT_FOUND', + 'APPDEPLOY_WORKFLOW_NOT_FOUND', + 'APPDEPLOY_DEPLOYMENT_OPERATION_NOT_FOUND', + 'APPDEPLOY_RUN_FILE_NOT_FOUND', + 'APPDEPLOY_APPLICATION_UNAVAILABLE', + 'APPDEPLOY_TARGET_ENVIRONMENT_REMOVED', + 'APPDEPLOY_VERSION_UNAVAILABLE', + 'APPDEPLOY_CONFLICT', + 'APPDEPLOY_DEPLOYMENT_IN_PROGRESS', + 'APPDEPLOY_ALREADY_UNDEPLOYED', + 'APPDEPLOY_REVISION_ALREADY_TERMINAL', + 'APPDEPLOY_NO_IN_FLIGHT_DEPLOYMENT', + 'APPDEPLOY_ENVIRONMENT_CONFLICT', + 'APPDEPLOY_ENVIRONMENT_BUSY', + 'APPDEPLOY_ENV_VAR_KEY_CONFLICT', + 'APPDEPLOY_APP_HAS_ACTIVE_DEPLOYMENTS', + 'APPDEPLOY_SOURCE_VERSION_IN_USE', + 'APPDEPLOY_NOTHING_TO_UNDEPLOY', + 'APPDEPLOY_DEPLOYMENT_STATE_CHANGED', + 'APPDEPLOY_ENVIRONMENT_HAS_ACTIVE_DEPLOYMENTS', + 'APPDEPLOY_ENVIRONMENT_NOT_READY', + 'APPDEPLOY_ENVIRONMENT_CAPACITY_EXCEEDED', + 'APPDEPLOY_APP_RUNNER_ENV_CPU_LIMIT_EXCEEDED', + 'APPDEPLOY_SOURCE_VERSION_NOT_DEPLOYABLE', + 'APPDEPLOY_CREDENTIAL_GONE', + 'APPDEPLOY_SLOT_UNSATISFIED', + 'APPDEPLOY_WORKSPACE_INACTIVE', + 'APPDEPLOY_ENVIRONMENT_BACKEND_UNAVAILABLE', + 'APPDEPLOY_APP_RUNNER_REGISTRATION_NOT_ALLOWED', + 'APPDEPLOY_WORKFLOW_NOT_DEPLOYABLE', + 'APPDEPLOY_API_KEY_LIMIT_EXCEEDED', + 'APPDEPLOY_ENVIRONMENT_NOT_FAILED', + 'APPDEPLOY_TARGET_ENVIRONMENT_UNAVAILABLE', + 'APPDEPLOY_TARGET_ENVIRONMENT_INVALID', + 'APPDEPLOY_TARGET_ENVIRONMENT_ACCESS_DENIED', + 'APPDEPLOY_VERSION_NOT_DEPLOYABLE', + 'APPDEPLOY_REQUIRED_CONFIGURATION_MISSING', + 'APPDEPLOY_DEPLOYMENT_CONFIGURATION_INVALID', + 'APPDEPLOY_CREDENTIAL_UNAVAILABLE', + 'APPDEPLOY_RUNTIME_UNAVAILABLE', + 'APPDEPLOY_DEPLOYMENT_TIMEOUT', + 'APPDEPLOY_DEPLOYMENT_INTERRUPTED', + 'APPDEPLOY_ENVIRONMENT_CPU_POOL_EXHAUSTED', + 'APPDEPLOY_RESOURCE_NOT_APPLICABLE_FOR_MODE', + 'APPDEPLOY_ENVIRONMENT_CPU_POOL_BELOW_ALLOCATED', + 'APPDEPLOY_APP_RUNNER_CONTROL_NOT_CONFIGURED', + 'APPDEPLOY_RUNTIME_ASSIGNMENT_FAILED', + 'APPDEPLOY_REVISION_TIMEOUT', + 'APPDEPLOY_INTERNAL_ERROR', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_AUTH_REJECTED', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_NAMESPACE_MISSING', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INSUFFICIENT_RBAC', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_DECRYPT_FAILED', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INVALID_K8S_CONFIG', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_RESOURCE_COLLISION', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_APPLY_FAILED', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_SIGNING_KEY_MISSING', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_CONTROL_ENDPOINT_INVALID', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_K8S_UNREACHABLE', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_APP_RUNNER_NOT_READY', + 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_SSAR_UNAVAILABLE', + ]) + .optional(), + message: z.string().optional(), + phase: z.string().optional(), + occurredAt: z.iso.datetime().optional(), + detailCode: z.string().optional(), +}) + +export const zGetEnvironmentCapabilitiesResponse = z.object({ + backend: zEnvironmentBackend, + supportedModes: z.array( + z.enum([ + 'ENVIRONMENT_MODE_UNSPECIFIED', + 'ENVIRONMENT_MODE_SHARED', + 'ENVIRONMENT_MODE_ISOLATED', + ]), + ), + namespaceRequired: z.boolean(), +}) + +export const zGetEnvironmentWebAppSubjectsResponse = z.object({ + subjects: z.array(zEnvironmentWebAppSubject), +}) + +export const zGetWebAppAccessModeResponse = z.object({ + accessMode: z.string().optional(), +}) + +export const zGetWebAppPermissionResponse = z.object({ + result: z.boolean().optional(), +}) + +export const zGetWorkflowDeploymentOptionsResponse = z.object({ + environment_variable_slots: z.array(zEnvironmentVariableSlot), + credential_slots: z.array(zCredentialSlot), +}) + +export const zListAppEnvironmentsResponse = z.object({ + data: z.array(zAppEnvironment), +}) + +export const zListEnvironmentApiKeysResponse = z.object({ + data: z.array(zEnvironmentApiKey), +}) + +export const zListEnvironmentTriggersResponse = z.object({ + data: z.array(zEnvironmentTrigger), +}) + +export const zNamedRef = z.object({ + id: z.string(), + displayName: z.string(), +}) + +export const zEnvironmentPoolShare = z.object({ + app: zNamedRef, + isolatedCpu: z.number(), +}) + +/** + * EnvironmentPoolComposition names the largest few apps drawing on a pool and + * sums the rest, because a bar with forty segments says less than one with five + * and a remainder. + */ +export const zEnvironmentPoolComposition = z.object({ + topApps: z.array(zEnvironmentPoolShare).optional(), + otherCpu: z.number().optional(), + otherAppCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zOperator = z.object({ + type: zOperatorType, + id: z.string(), + display_name: z.string(), +}) + +export const zApplicationInteraction = z.object({ + id: z.string(), + timestamp: z.iso.datetime(), + workflowRunId: z.string(), + status: zApplicationInteractionStatus, + durationSeconds: z.number(), + totalTokens: z.string(), + workspace: zNamedRef, + environment: zNamedRef, + app: zNamedRef, + operator: zOperator.optional(), + invokeFrom: z.string(), + traceId: z.string(), + difyTraceId: z.string(), + deploymentVersionId: z.string(), + error: z.string().optional(), + body: z.string().optional(), + attributesJson: z.string().optional(), + resourceAttributesJson: z.string().optional(), +}) + +export const zGetApplicationInteractionResponse = z.object({ + interaction: zApplicationInteraction, +}) + +export const zPrepareAppDeletionRequest = z.object({ + tenantId: z.string().optional(), + appId: z.string().optional(), +}) + +export const zResolveApiTokenRouteRequest = z.object({ + token: z.string().optional(), +}) + +export const zResolveApiTokenRouteResponse = z.object({ + environmentId: z.string().optional(), + namespace: z.string().optional(), + serviceName: z.string().optional(), + servicePort: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentStatus: zEnvironmentStatus.optional(), + appId: z.string().optional(), + tenantId: z.string().optional(), + deploymentId: z.string().optional(), + servingRevisionId: z.string().optional(), + deploymentStatus: zDeploymentStatus.optional(), + revoked: z.boolean().optional(), + unavailableReason: z.string().optional(), + targetKind: zRouteTargetKind.optional(), + directUpstream: z.string().optional(), + deploymentGeneration: z.string().optional(), +}) + +export const zResolveWebAppRouteRequest = z.object({ + appCode: z.string().optional(), + passport: z.string().optional(), +}) + +export const zResolveWebAppRouteResponse = z.object({ + decision: z.string().optional(), + environmentId: z.string().optional(), + namespace: z.string().optional(), + serviceName: z.string().optional(), + servicePort: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + environmentStatus: zEnvironmentStatus.optional(), + appId: z.string().optional(), + tenantId: z.string().optional(), + deploymentId: z.string().optional(), + servingRevisionId: z.string().optional(), + deploymentStatus: zDeploymentStatus.optional(), + unavailableReason: z.string().optional(), + targetKind: zRouteTargetKind.optional(), + directUpstream: z.string().optional(), + deploymentGeneration: z.string().optional(), + endUserId: z.string().optional(), + authType: z.string().optional(), +}) + +export const zRetryEnvironmentBootstrapRequest = z.object({ + environmentId: z.string().optional(), +}) + +/** + * RunnerMemory sizes one app-runner. max_mib is absent when the operator chose + * nothing, in which case the runner gets effective_max_mib. + */ +export const zRunnerMemory = z.object({ + maxMib: z.string().optional(), + effectiveMaxMib: z.string().readonly().optional(), + minMib: z.string().readonly().optional(), +}) + +export const zEnvironment = z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + mode: zEnvironmentMode, + backend: zEnvironmentBackend, + status: zEnvironmentStatus, + statusMessage: z.string(), + lastError: zError.optional(), + namespace: z.string().optional(), + managedBy: zEnvironmentManagedBy.optional(), + cpuPool: z.number(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + memory: zRunnerMemory.optional(), + usage: zEnvironmentPoolUsage.optional(), +}) + +export const zCreateEnvironmentResponse = z.object({ + environment: zEnvironment, +}) + +export const zGetEnvironmentResponse = z.object({ + environment: zEnvironment, + pool: zEnvironmentPoolComposition.optional(), +}) + +export const zRetryEnvironmentBootstrapResponse = z.object({ + environment: zEnvironment, +}) + +/** + * RunnerSizing describes a runner an app owns outright, so it is present only + * in an isolated environment; elsewhere the runner belongs to the environment. + */ +export const zRunnerSizing = z.object({ + isolatedCpu: z.number(), + memory: zRunnerMemory, +}) + +/** + * SimpleAccount mirrors the account shape Dify's console API returns, so a + * client can render an account the same way whichever API it came from. + */ +export const zSimpleAccount = z.object({ + id: z.string(), + name: z.string().optional(), + email: z.string().optional(), +}) + +export const zTestConnectionRequest = z.object({ + environmentId: z.string().optional(), +}) + +export const zTestConnectionResponse = z.object({ + reachable: z.boolean().optional(), + message: z.string().optional(), +}) + +export const zUndeployEnvironmentAppResponse = z.object({ + operationId: z.string(), +}) + +export const zUndeployWorkflowResponse = z.object({ + operation: zDeploymentOperationReceipt, +}) + +export const zUnsupportedNodeProvider = z.object({ + plugin_id: z.string(), + provider_id: z.string(), + provider_type: z.string(), + provider_name: z.string(), +}) + +export const zUnsupportedNode = z.object({ + id: z.string(), + type: z.string(), + title: z.string(), + provider: zUnsupportedNodeProvider.optional(), +}) + +export const zPrecheckWorkflowDeploymentResponse = z.object({ + unsupported_nodes: z.array(zUnsupportedNode), +}) + +export const zUpdateEnvironmentDeployedAppResourcesRequest = z.object({ + environmentId: z.string(), + deploymentId: z.string(), + isolatedCpu: z.number(), + maxMemoryMib: z.string().optional(), +}) + +export const zUpdateEnvironmentDeployedAppResourcesResponse = z.object({ + deploymentId: z.string(), + isolatedCpu: z.number(), + allocatedCpuCount: z.number(), + poolCpuCount: z.number(), + memory: zRunnerMemory, +}) + +export const zUpdateEnvironmentRequest = z.object({ + environmentId: z.string().optional(), + displayName: z.string().optional(), + description: z.string().optional(), + cpuPool: z.number().optional(), + maxMemoryMib: z.string().optional(), +}) + +export const zUpdateEnvironmentResponse = z.object({ + environment: zEnvironment, +}) + +export const zWorkflowDeploymentEnvironment = z.object({ + id: z.string().optional(), + name: z.string().optional(), +}) + +export const zSourceVersionDeployment = z.object({ + sourceVersionId: z.string().optional(), + environments: z.array(zWorkflowDeploymentEnvironment).optional(), +}) + +export const zBatchGetSourceVersionDeploymentsResponse = z.object({ + items: z.array(zSourceVersionDeployment).optional(), +}) + +export const zWorkflowDeploymentInput = z.object({ + environment_variables: z.array(zEnvironmentVariableInput).optional(), + credentials: z.array(zCredentialSelectionInput).optional(), +}) + +export const zWorkflowVersion = z.object({ + version: z.string().optional(), + marked_name: z.string().optional(), + id: z.string(), + marked_comment: z.string().optional(), + version_number: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + created_at: z.number().optional(), + created_by: zSimpleAccount.optional(), + dsl_hash: z.string().optional(), + deleted: z.boolean().optional(), +}) + +export const zDeploymentOperation = z.object({ + id: z.string(), + type: zDeploymentOperationType, + outcome: zDeploymentOperationOutcome, + workspace: zNamedRef, + operator: zOperator, + app: zNamedRef, + environment: zNamedRef, + version: zWorkflowVersion, + failureCode: zDeploymentOperationFailureCode.optional(), + failureMessage: z.string().optional(), + requestedAt: z.iso.datetime(), + finalizedAt: z.iso.datetime().optional(), + durationMilliseconds: z.string().optional(), +}) + +export const zEnvironmentDeployedApp = z.object({ + deploymentId: z.string(), + workspace: zNamedRef, + app: zNamedRef, + status: zEnvironmentDeployedAppStatus, + currentVersion: zWorkflowVersion.optional(), + deployedAt: z.iso.datetime().optional(), + deployedBy: zOperator.optional(), + latestAttempt: zEnvironmentDeployedAppAttempt.optional(), + sizing: zRunnerSizing.optional(), + occupiesPool: z.boolean().optional(), +}) + +export const zEnvironmentDeploymentOperation = z.object({ + id: z.string(), + type: zDeploymentOperationType, + status: zDeploymentOperationStatus, + target_version: zWorkflowVersion.optional(), + operator: zOperator, + activity_at: z.number(), +}) + +export const zEnvironmentDeploymentState = z.object({ + status: zDeploymentStatus, + current_version: zWorkflowVersion.optional(), + versions_behind: z + .int() + .min(0, { error: 'Invalid value: Expected uint32 to be >= 0' }) + .max(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + .optional(), + deployed_at: z.number().optional(), + deployed_by: zOperator.optional(), + latest_operation: zEnvironmentDeploymentOperation.optional(), +}) + +export const zEnvironmentDeployment = z.object({ + environment: zDeploymentEnvironment, + deployment: zEnvironmentDeploymentState.optional(), + access: zEnvironmentAccess, +}) + +export const zGetDeploymentOperationResponse = z.object({ + operation: zDeploymentOperation, +}) + +export const zGetEnvironmentDeploymentResponse = z.object({ + environment_deployment: zEnvironmentDeployment, +}) + +export const zListEnvironmentDeploymentsResponse = z.object({ + environment_deployments: z.array(zEnvironmentDeployment), +}) + +/** + * Pagination : Just for pagination by page + */ +export const zPagination = z.object({ + totalCount: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + perPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + currentPage: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), + totalPages: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), +}) + +export const zListApplicationInteractionsResponse = z.object({ + data: z.array(zApplicationInteraction), + pagination: zPagination, +}) + +export const zListAppsResponse = z.object({ + data: z.array(zDashboardApp), + pagination: zPagination, +}) + +export const zListDeploymentOperationsResponse = z.object({ + data: z.array(zDeploymentOperation), + pagination: zPagination, +}) + +export const zListEnvironmentDeployedAppsResponse = z.object({ + data: z.array(zEnvironmentDeployedApp), + summary: zEnvironmentDeployedAppSummary, + pagination: zPagination, +}) + +export const zListEnvironmentsResponse = z.object({ + data: z.array(zEnvironment), + pagination: zPagination, +}) + +export const zDeleteEnvironmentApiKeyResponseWritable = z.record(z.string(), z.unknown()) + +export const zDeleteEnvironmentResponseWritable = z.record(z.string(), z.unknown()) + +export const zEnvironmentMcpServerWritable = z.record(z.string(), z.unknown()) + +export const zEnvironmentTriggerWritable = z.record(z.string(), z.unknown()) + +/** + * RunnerMemory sizes one app-runner. max_mib is absent when the operator chose + * nothing, in which case the runner gets effective_max_mib. + */ +export const zRunnerMemoryWritable = z.object({ + maxMib: z.string().optional(), +}) + +export const zEnvironmentWritable = z.object({ + id: z.string(), + displayName: z.string(), + description: z.string(), + mode: zEnvironmentMode, + backend: zEnvironmentBackend, + status: zEnvironmentStatus, + statusMessage: z.string(), + lastError: zError.optional(), + namespace: z.string().optional(), + managedBy: zEnvironmentManagedBy.optional(), + cpuPool: z.number(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + memory: zRunnerMemoryWritable.optional(), + usage: zEnvironmentPoolUsage.optional(), +}) + +export const zCreateEnvironmentResponseWritable = z.object({ + environment: zEnvironmentWritable, +}) + +export const zGetEnvironmentResponseWritable = z.object({ + environment: zEnvironmentWritable, + pool: zEnvironmentPoolComposition.optional(), +}) + +export const zListEnvironmentsResponseWritable = z.object({ + data: z.array(zEnvironmentWritable), + pagination: zPagination, +}) + +export const zRetryEnvironmentBootstrapResponseWritable = z.object({ + environment: zEnvironmentWritable, +}) + +/** + * RunnerSizing describes a runner an app owns outright, so it is present only + * in an isolated environment; elsewhere the runner belongs to the environment. + */ +export const zRunnerSizingWritable = z.object({ + isolatedCpu: z.number(), + memory: zRunnerMemoryWritable, +}) + +export const zEnvironmentDeployedAppWritable = z.object({ + deploymentId: z.string(), + workspace: zNamedRef, + app: zNamedRef, + status: zEnvironmentDeployedAppStatus, + currentVersion: zWorkflowVersion.optional(), + deployedAt: z.iso.datetime().optional(), + deployedBy: zOperator.optional(), + latestAttempt: zEnvironmentDeployedAppAttempt.optional(), + sizing: zRunnerSizingWritable.optional(), + occupiesPool: z.boolean().optional(), +}) + +export const zListEnvironmentDeployedAppsResponseWritable = z.object({ + data: z.array(zEnvironmentDeployedAppWritable), + summary: zEnvironmentDeployedAppSummary, + pagination: zPagination, +}) + +export const zUpdateEnvironmentDeployedAppResourcesResponseWritable = z.object({ + deploymentId: z.string(), + isolatedCpu: z.number(), + allocatedCpuCount: z.number(), + poolCpuCount: z.number(), + memory: zRunnerMemoryWritable, +}) + +export const zUpdateEnvironmentResponseWritable = z.object({ + environment: zEnvironmentWritable, +}) + +export const zConsoleDeploymentServiceListAppEnvironmentsPath = z.object({ + app_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServiceListAppEnvironmentsResponse = zListAppEnvironmentsResponse + +export const zConsoleAccessServiceGetEnvironmentApiPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceGetEnvironmentApiResponse = zEnvironmentApi + +export const zConsoleAccessServiceUpdateEnvironmentApiBody = zEnvironmentApiUpdate + +export const zConsoleAccessServiceUpdateEnvironmentApiPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceUpdateEnvironmentApiResponse = zEnvironmentApi + +export const zConsoleAccessServiceListEnvironmentApiKeysPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceListEnvironmentApiKeysResponse = zListEnvironmentApiKeysResponse + +export const zConsoleAccessServiceCreateEnvironmentApiKeyPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceCreateEnvironmentApiKeyResponse = zEnvironmentApiKey + +export const zConsoleAccessServiceDeleteEnvironmentApiKeyPath = z.object({ + app_id: z.string(), + environment_id: z.string(), + api_key_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceDeleteEnvironmentApiKeyResponse = zDeleteEnvironmentApiKeyResponse + +export const zConsoleAccessServiceGetEnvironmentMcpServerPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceGetEnvironmentMcpServerResponse = zEnvironmentMcpServer + +export const zConsoleAccessServiceGetEnvironmentSitePath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceGetEnvironmentSiteResponse = zEnvironmentSite + +export const zConsoleAccessServiceUpdateEnvironmentSiteBody = zEnvironmentSiteUpdate + +export const zConsoleAccessServiceUpdateEnvironmentSitePath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceUpdateEnvironmentSiteResponse = zEnvironmentSite + +export const zConsoleAccessServiceResetEnvironmentSiteAccessTokenPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceResetEnvironmentSiteAccessTokenResponse = zEnvironmentSite + +export const zConsoleAccessServiceListEnvironmentTriggersPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceListEnvironmentTriggersResponse = zListEnvironmentTriggersResponse + +export const zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeBody = + zEnvironmentWebAppAccessModeUpdate + +export const zConsoleAccessServiceUpdateEnvironmentWebAppAccessModePath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceUpdateEnvironmentWebAppAccessModeResponse = zEnvironmentSite + +export const zConsoleAccessServiceGetEnvironmentWebAppSubjectsPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleAccessServiceGetEnvironmentWebAppSubjectsResponse = + zGetEnvironmentWebAppSubjectsResponse + +export const zConsoleDeploymentServiceListEnvironmentDeploymentsPath = z.object({ + app_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServiceListEnvironmentDeploymentsResponse = + zListEnvironmentDeploymentsResponse + +export const zConsoleDeploymentServiceGetEnvironmentDeploymentPath = z.object({ + app_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServiceGetEnvironmentDeploymentResponse = + zGetEnvironmentDeploymentResponse + +export const zConsoleDeploymentServiceGetWorkflowDeploymentOptionsPath = z.object({ + app_id: z.string(), + workflow_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServiceGetWorkflowDeploymentOptionsResponse = + zGetWorkflowDeploymentOptionsResponse + +export const zConsoleDeploymentServiceDeployWorkflowBody = zWorkflowDeploymentInput + +export const zConsoleDeploymentServiceDeployWorkflowPath = z.object({ + app_id: z.string(), + workflow_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServiceDeployWorkflowResponse = zDeployWorkflowResponse + +export const zConsoleDeploymentServiceUndeployWorkflowPath = z.object({ + app_id: z.string(), + workflow_id: z.string(), + environment_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServiceUndeployWorkflowResponse = zUndeployWorkflowResponse + +export const zConsoleDeploymentServicePrecheckWorkflowDeploymentPath = z.object({ + app_id: z.string(), + workflow_id: z.string(), +}) + +/** + * OK + */ +export const zConsoleDeploymentServicePrecheckWorkflowDeploymentResponse = + zPrecheckWorkflowDeploymentResponse diff --git a/packages/contracts/openapi-ts.enterprise-app-deploy.config.ts b/packages/contracts/openapi-ts.enterprise-app-deploy.config.ts new file mode 100644 index 00000000000..f3e76397e08 --- /dev/null +++ b/packages/contracts/openapi-ts.enterprise-app-deploy.config.ts @@ -0,0 +1,338 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from '@hey-api/openapi-ts' +import { loadOpenApiYaml } from './openapi-yaml' + +type JsonObject = Record + +type OpenApiDocument = JsonObject & { + components?: OpenApiComponents + paths?: Record +} + +type OpenApiComponents = JsonObject & { + schemas?: Record +} + +type OpenApiMediaType = JsonObject & { + schema?: unknown +} + +type OpenApiOperation = JsonObject & { + operationId?: string + responses?: Record +} + +type OpenApiPathItem = Record + +type OpenApiResponse = JsonObject & { + content?: Record +} + +type OpenApiSchema = JsonObject & { + enum?: unknown[] + format?: string + properties?: Record + type?: string | string[] +} + +type ContractOperation = { + id: string + operationId?: string + tags?: readonly string[] +} + +const currentDir = path.dirname(fileURLToPath(import.meta.url)) +const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER + ? path.resolve(process.env.DIFY_ENTERPRISE_SERVER) + : path.resolve(currentDir, '../../../dify-enterprise/server') +const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/appdeploy/openapi.yaml') +const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) + +const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/') + +const isObject = (value: unknown): value is JsonObject => { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +const isOpenApiSchema = (value: unknown): value is OpenApiSchema => { + return isObject(value) +} + +const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => { + return isObject(value) ? (value as OpenApiOperation) : undefined +} + +const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => { + return isObject(value) ? (value as OpenApiResponse) : undefined +} + +const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => { + return isObject(value) ? (value as OpenApiMediaType) : undefined +} + +const stripConsoleApiPrefix = (routePath: string) => { + if (isConsoleApiPath(routePath)) return routePath.replace('/console/api', '') + + return routePath +} + +const stripSchemaNamePrefix = (schemaName: string) => { + return schemaName + .replace(/^dify\.enterprise\.api\.enterprise\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\.v1\./, '') + .replace(/^dify\.enterprise\.api\.appdeploy\./, '') + .replace(/^pagination\./, '') +} + +const contractTagSegment = (tag?: string) => { + if (tag === 'ConsoleDeploymentService') return 'DeploymentService' + if (tag === 'ConsoleAccessService') return 'AccessService' + + return tag || 'default' +} + +const contractNameSegments = (operation: ContractOperation) => { + const operationId = operation.operationId || operation.id + const tag = operation.tags?.[0] + const tagPrefixPattern = tag ? new RegExp(`^${tag}[._/-]`) : undefined + const name = tagPrefixPattern ? operationId.replace(tagPrefixPattern, '') : operationId + const segments = name.split(/[._/-]+/).filter(Boolean) + + return segments.length > 0 ? segments : [operationId] +} + +const contractPathSegments = (operation: ContractOperation) => { + return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)] +} + +const hasSchemaLessResponseContent = (operation: OpenApiOperation) => { + if (!isObject(operation.responses)) return false + + return Object.values(operation.responses).some((response) => { + const openApiResponse = asOpenApiResponse(response) + if (!openApiResponse || !isObject(openApiResponse.content)) return false + + return Object.values(openApiResponse.content).some((mediaType) => { + const openApiMediaType = asOpenApiMediaType(mediaType) + return !!openApiMediaType && !('schema' in openApiMediaType) + }) + }) +} + +// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these +// raw download operations until the source OpenAPI exposes an explicit schema. +const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => { + return Object.fromEntries( + Object.entries(pathItem).filter(([method, operation]) => { + if (!operationMethods.has(method.toLowerCase())) return true + + const openApiOperation = asOpenApiOperation(operation) + return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation) + }), + ) +} + +const toWords = (value: string) => { + return value + .replace(/[{}]/g, '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .split(/[^a-z0-9]+/i) + .filter(Boolean) +} + +const toPascalCase = (words: string[]) => { + return words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('') +} + +const commonWordPrefix = (values: string[]) => { + const wordLists = values.map((value) => value.split('_')) + const firstWords = wordLists[0] ?? [] + const prefix: string[] = [] + + for (const [index, word] of firstWords.entries()) { + if (!wordLists.every((words) => words[index] === word)) break + + prefix.push(word) + } + + return prefix +} + +const enumSchemaNameFromValues = (values: unknown[]) => { + if (values.length === 0 || !values.every((value) => typeof value === 'string')) return undefined + + const prefix = commonWordPrefix(values) + if (prefix.length < 2) return undefined + + return toPascalCase(prefix.map((word) => word.toLowerCase())) +} + +const findSchemaEntry = ( + schemas: Record, + schemaName: string, +): [string, OpenApiSchema] | undefined => { + return Object.entries(schemas).find(([name]) => stripSchemaNamePrefix(name) === schemaName) +} + +const enumValuesKey = (values: unknown[]) => JSON.stringify(values) + +const reusableEnumSchema = (propertySchema: OpenApiSchema): OpenApiSchema => ({ + ...(propertySchema.format ? { format: propertySchema.format } : {}), + enum: propertySchema.enum, + type: propertySchema.type ?? 'string', +}) + +const enumSchemaKey = ( + schemas: Record, + preferredName: string, + valuesKey: string, + valuesToSchemaKey: Map, + schemaName: string, + propertyName: string, +) => { + const existingKey = valuesToSchemaKey.get(valuesKey) + if (existingKey) return existingKey + + const existingEnumEntry = findSchemaEntry(schemas, preferredName) + if (!existingEnumEntry) return preferredName + + const existingEnumValues = existingEnumEntry[1].enum + if (Array.isArray(existingEnumValues) && enumValuesKey(existingEnumValues) === valuesKey) + return existingEnumEntry[0] + + return `${stripSchemaNamePrefix(schemaName)}${toPascalCase(toWords(propertyName))}` +} + +const promoteInlineEnumSchema = ( + schemas: Record, + schemaName: string, + properties: Record, + propertyName: string, + propertySchema: OpenApiSchema, + valuesToSchemaKey: Map, +) => { + if (!Array.isArray(propertySchema.enum)) return + + const preferredName = enumSchemaNameFromValues(propertySchema.enum) + if (!preferredName) return + + const valuesKey = enumValuesKey(propertySchema.enum) + const key = enumSchemaKey( + schemas, + preferredName, + valuesKey, + valuesToSchemaKey, + schemaName, + propertyName, + ) + + if (!schemas[key]) schemas[key] = reusableEnumSchema(propertySchema) + + valuesToSchemaKey.set(valuesKey, key) + properties[propertyName] = { + $ref: `#/components/schemas/${key}`, + } +} + +// gnostic's protoc-gen-openapi inlines proto enum schemas into every field. +// Promote prefixable inline enums to reusable schemas so Hey API can emit +// runtime enum objects from the generated contract. +const promoteReusableEnumSchemasForHeyApi = (document: OpenApiDocument) => { + const schemas = document.components?.schemas + if (!schemas) return + + const valuesToSchemaKey = new Map() + + Object.entries(schemas).forEach(([schemaName, schema]) => { + const properties = schema.properties + if (!properties) return + + Object.entries(properties).forEach(([propertyName, propertySchema]) => { + if (!isOpenApiSchema(propertySchema)) return + + promoteInlineEnumSchema( + schemas, + schemaName, + properties, + propertyName, + propertySchema, + valuesToSchemaKey, + ) + }) + }) +} + +const normalizeEnterpriseOpenApi = () => { + const openApi = loadOpenApiYaml(fs.readFileSync(enterpriseOpenApiPath, 'utf8')) + + if (!openApi || typeof openApi !== 'object' || Array.isArray(openApi)) + throw new Error(`Invalid enterprise OpenAPI document: ${enterpriseOpenApiPath}`) + + const document = openApi as OpenApiDocument + const paths = document.paths ?? {} + + document.paths = Object.fromEntries( + Object.entries(paths) + .filter(([routePath]) => isConsoleApiPath(routePath)) + .map(([routePath, pathItem]) => { + if (!isObject(pathItem)) return [stripConsoleApiPrefix(routePath), pathItem] + + return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)] + }) + .filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0), + ) + + promoteReusableEnumSchemasForHeyApi(document) + + return document +} + +export default defineConfig({ + input: normalizeEnterpriseOpenApi(), + output: { + entryFile: false, + path: 'generated/enterprise-app-deploy', + fileName: { + suffix: '.gen', + }, + postProcess: [ + { + command: 'vp', + args: ['fmt', '{{path}}'], + }, + ], + }, + parser: { + transforms: { + schemaName: stripSchemaNamePrefix, + }, + }, + plugins: [ + { + name: '@hey-api/typescript', + comments: false, + enums: { + mode: 'javascript', + }, + }, + 'zod', + { + name: 'orpc', + contracts: { + strategy: 'single', + contractName: { + name: '{{name}}', + casing: 'camelCase', + }, + nesting: contractPathSegments, + segmentName: { + name: '{{name}}', + casing: 'camelCase', + }, + }, + validator: 'zod', + }, + ], +}) diff --git a/packages/contracts/package.json b/packages/contracts/package.json index ac0c28e7911..b7af1f88ad3 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -20,6 +20,10 @@ "types": "./generated/enterprise/*.ts", "import": "./generated/enterprise/*.ts" }, + "./enterprise-app-deploy/*": { + "types": "./generated/enterprise-app-deploy/*.ts", + "import": "./generated/enterprise-app-deploy/*.ts" + }, "./knowledge-fs/*": { "types": "./generated/knowledge-fs/*.ts", "import": "./generated/knowledge-fs/*.ts" @@ -28,6 +32,7 @@ "scripts": { "gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", + "gen-enterprise-app-deploy-contract": "openapi-ts -f openapi-ts.enterprise-app-deploy.config.ts", "gen-knowledge-fs-contract": "node scripts/generate-knowledge-fs-contract.mjs", "test": "vp test", "type-check": "tsc" diff --git a/packages/iconify-collections/assets/vender/app-publisher/deploying-chevron.svg b/packages/iconify-collections/assets/vender/app-publisher/deploying-chevron.svg new file mode 100644 index 00000000000..0001a2dc5e5 --- /dev/null +++ b/packages/iconify-collections/assets/vender/app-publisher/deploying-chevron.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/deploy/code-block.svg b/packages/iconify-collections/assets/vender/deploy/code-block.svg new file mode 100644 index 00000000000..3f9486aedcb --- /dev/null +++ b/packages/iconify-collections/assets/vender/deploy/code-block.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/deploy/line-5.svg b/packages/iconify-collections/assets/vender/deploy/line-5.svg new file mode 100644 index 00000000000..eac452abbda --- /dev/null +++ b/packages/iconify-collections/assets/vender/deploy/line-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/deploy/rocket.svg b/packages/iconify-collections/assets/vender/deploy/rocket.svg new file mode 100644 index 00000000000..362d31a0ed0 --- /dev/null +++ b/packages/iconify-collections/assets/vender/deploy/rocket.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/iconify-collections/custom-public/icons.json b/packages/iconify-collections/custom-public/icons.json index ef48d368602..510de02889d 100644 --- a/packages/iconify-collections/custom-public/icons.json +++ b/packages/iconify-collections/custom-public/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-public", - "lastModified": 1784870906, + "lastModified": 1785332090, "icons": { "agent-building-blocks": { "body": "" diff --git a/packages/iconify-collections/custom-public/index.d.ts b/packages/iconify-collections/custom-public/index.d.ts index be2442726c3..1963704265e 100644 --- a/packages/iconify-collections/custom-public/index.d.ts +++ b/packages/iconify-collections/custom-public/index.d.ts @@ -1,4 +1,4 @@ -export type IconifyJSON = { +export interface IconifyJSON { prefix: string icons: Record aliases?: Record @@ -7,7 +7,7 @@ export type IconifyJSON = { lastModified?: number } -export type IconifyIcon = { +export interface IconifyIcon { body: string left?: number top?: number @@ -18,11 +18,11 @@ export type IconifyIcon = { vFlip?: boolean } -export type IconifyAlias = { +export interface IconifyAlias extends Omit { parent: string -} & Omit +} -export type IconifyInfo = { +export interface IconifyInfo { prefix: string name: string total: number @@ -40,11 +40,11 @@ export type IconifyInfo = { palette?: boolean } -export type IconifyMetaData = { +export interface IconifyMetaData { [key: string]: unknown } -export type IconifyChars = { +export interface IconifyChars { [key: string]: string } diff --git a/packages/iconify-collections/custom-public/index.js b/packages/iconify-collections/custom-public/index.js index aa7f8d50585..109d0a817cb 100644 --- a/packages/iconify-collections/custom-public/index.js +++ b/packages/iconify-collections/custom-public/index.js @@ -1,8 +1,8 @@ 'use strict' -const chars = require('./chars.json') const icons = require('./icons.json') const info = require('./info.json') const metadata = require('./metadata.json') +const chars = require('./chars.json') module.exports = { icons, info, metadata, chars } diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index b100ce8257a..4c258072c19 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-vender", - "lastModified": 1782516559, + "lastModified": 1786000677, "icons": { "agent-v2-access-point": { "body": "", @@ -42,6 +42,24 @@ "body": "", "width": 17 }, + "app-publisher-deploying-chevron": { + "body": "", + "width": 8.27613, + "height": 5.08087 + }, + "deploy-code-block": { + "body": "" + }, + "deploy-line-5": { + "body": "", + "width": 12, + "height": 39 + }, + "deploy-rocket": { + "body": "", + "width": 14, + "height": 14 + }, "features-citations": { "body": "", "width": 24, diff --git a/packages/iconify-collections/custom-vender/index.d.ts b/packages/iconify-collections/custom-vender/index.d.ts index be2442726c3..1963704265e 100644 --- a/packages/iconify-collections/custom-vender/index.d.ts +++ b/packages/iconify-collections/custom-vender/index.d.ts @@ -1,4 +1,4 @@ -export type IconifyJSON = { +export interface IconifyJSON { prefix: string icons: Record aliases?: Record @@ -7,7 +7,7 @@ export type IconifyJSON = { lastModified?: number } -export type IconifyIcon = { +export interface IconifyIcon { body: string left?: number top?: number @@ -18,11 +18,11 @@ export type IconifyIcon = { vFlip?: boolean } -export type IconifyAlias = { +export interface IconifyAlias extends Omit { parent: string -} & Omit +} -export type IconifyInfo = { +export interface IconifyInfo { prefix: string name: string total: number @@ -40,11 +40,11 @@ export type IconifyInfo = { palette?: boolean } -export type IconifyMetaData = { +export interface IconifyMetaData { [key: string]: unknown } -export type IconifyChars = { +export interface IconifyChars { [key: string]: string } diff --git a/packages/iconify-collections/custom-vender/index.js b/packages/iconify-collections/custom-vender/index.js index aa7f8d50585..109d0a817cb 100644 --- a/packages/iconify-collections/custom-vender/index.js +++ b/packages/iconify-collections/custom-vender/index.js @@ -1,8 +1,8 @@ 'use strict' -const chars = require('./chars.json') const icons = require('./icons.json') const info = require('./info.json') const metadata = require('./metadata.json') +const chars = require('./chars.json') module.exports = { icons, info, metadata, chars } diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index fdc5ad8ce64..2b7b9880f64 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 331, + "total": 335, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/__tests__/embedded-user-id-auth.test.tsx b/web/__tests__/embedded-user-id-auth.test.tsx index 761c730aa5b..eee65dff977 100644 --- a/web/__tests__/embedded-user-id-auth.test.tsx +++ b/web/__tests__/embedded-user-id-auth.test.tsx @@ -95,7 +95,10 @@ describe('embedded user id propagation in authentication flows', () => { }) }) expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('login-token') - expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token') + expect(setWebAppPassportMock).toHaveBeenCalledWith( + { kind: 'default', code: 'test-app' }, + 'passport-token', + ) expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app') }) @@ -166,7 +169,10 @@ describe('embedded user id propagation in authentication flows', () => { }) }) expect(setWebAppAccessTokenMock).toHaveBeenCalledWith('code-token') - expect(setWebAppPassportMock).toHaveBeenCalledWith('test-app', 'passport-token') + expect(setWebAppPassportMock).toHaveBeenCalledWith( + { kind: 'default', code: 'test-app' }, + 'passport-token', + ) expect(replaceMock).toHaveBeenCalledWith('/chatbot/test-app') }) diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/access-point/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/access-point/page.tsx new file mode 100644 index 00000000000..3bcfd6fc5fb --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/access-point/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppAccessPointDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/@detailSidebar/app/[appId]/deploy/page.tsx b/web/app/(commonLayout)/@detailSidebar/app/[appId]/deploy/page.tsx new file mode 100644 index 00000000000..453e8df95ac --- /dev/null +++ b/web/app/(commonLayout)/@detailSidebar/app/[appId]/deploy/page.tsx @@ -0,0 +1,5 @@ +import { AppDetailSidebarSlot } from '../sidebar-page' + +export default function AppDeployDetailSidebarSlot() { + return +} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx index f7c982af6a5..ca6489bcdb6 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/__tests__/layout-main.spec.tsx @@ -185,6 +185,59 @@ describe('AppDetailLayout', () => { expect(useStore.getState().appDetail?.id).toBe('app-1') }) + it('should allow access point pages without app deploy or app ACL permissions', async () => { + mockPathname = '/app/app-1/access-point' + mockFetchAppDetailDirect.mockResolvedValue(createAppDetail({ permission_keys: [] })) + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalled() + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + + it('should redirect deploy pages when app deploy ACL permission is missing', async () => { + mockPathname = '/app/app-1/deploy' + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.ViewLayout] }), + ) + + render( + +
App page content
+
, + ) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/workflow') + }) + expect(screen.queryByText('App page content')).not.toBeInTheDocument() + expect(useStore.getState().appDetail).toBeUndefined() + }) + + it('should allow users with app deploy ACL permission to open deploy directly', async () => { + mockPathname = '/app/app-1/deploy' + mockFetchAppDetailDirect.mockResolvedValue( + createAppDetail({ permission_keys: [AppACLPermission.Deploy] }), + ) + + render( + +
App page content
+
, + ) + + await waitForAppContent() + + expect(mockReplace).not.toHaveBeenCalled() + expect(useStore.getState().appDetail?.id).toBe('app-1') + }) + it('should allow users with layout access to open workflow pages directly', async () => { mockPathname = '/app/app-1/workflow' @@ -211,7 +264,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop') + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() @@ -336,7 +389,7 @@ describe('AppDetailLayout', () => { ) await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith('/app/app-1/develop') + expect(mockReplace).toHaveBeenCalledWith('/app/app-1/access-point') }) expect(screen.queryByText('App page content')).not.toBeInTheDocument() expect(useStore.getState().appDetail).toBeUndefined() diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-point/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-point/page.tsx new file mode 100644 index 00000000000..2ca15f42dfe --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/access-point/page.tsx @@ -0,0 +1,11 @@ +import AccessPoint from '@/app/components/app/access-point' + +type AppAccessPointPageProps = { + params: Promise<{ appId: string }> +} + +export default async function AppAccessPointPage({ params }: AppAccessPointPageProps) { + const { appId } = await params + + return +} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/deploy/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/deploy/page.tsx new file mode 100644 index 00000000000..2dacbaf3d30 --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/deploy/page.tsx @@ -0,0 +1,5 @@ +import AppDeploy from '@/app/components/app/deploy' + +export default function AppDeployPage() { + return +} diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 9ad3e785e87..86a79fdaeb7 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -120,12 +120,15 @@ const AppDetailLayout: FC = (props) => { const isAnnotationsPath = pathname.endsWith('annotations') const isOverviewPath = pathname.endsWith('overview') const isAccessConfigPath = pathname.endsWith('access-config') + const isDeployPath = pathname.endsWith('deploy') if ( (isLayoutPath && !appACLCapabilities.canAccessLayout) || (isLogsPath && !appACLCapabilities.canAccessLogAndAnnotation) || (isAnnotationsPath && !appACLCapabilities.canAccessLogAndAnnotation) || (isOverviewPath && !appACLCapabilities.canMonitor) || - (isAccessConfigPath && !appACLCapabilities.canAccessConfig) + (isAccessConfigPath && !appACLCapabilities.canAccessConfig) || + (isDeployPath && + (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) ) { router.replace( getRedirectionPath(routeAppDetail, { diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx deleted file mode 100644 index b7e3b15bc07..00000000000 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/__tests__/card-view.spec.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import type { App } from '@/types/app' -import { screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' -import { render as renderWithConsoleState } from '@/test/console/render' -import CardView from '../card-view' - -const mockAppState = vi.hoisted(() => ({ - appDetail: { - id: 'app-1', - mode: 'chat', - permission_keys: [] as string[], - }, - setAppDetail: vi.fn(), -})) - -const mockUpdateAppSiteStatus = vi.hoisted(() => vi.fn()) -const mockUpdateAppSiteConfig = vi.hoisted(() => vi.fn()) -const mockUpdateAppSiteAccessToken = vi.hoisted(() => vi.fn()) -const mockFetchAppDetail = vi.hoisted(() => vi.fn()) -const mockInvalidateQueries = vi.hoisted(() => vi.fn()) - -const render = (ui: Parameters[0]) => - renderWithConsoleState(ui, { - wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }), - }) - -vi.mock('@tanstack/react-query', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }), - } -}) - -vi.mock('@/service/client', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - consoleQuery: { - ...actual.consoleQuery, - account: { - profile: { - get: { - queryKey: () => [['console', 'account', 'profile', 'get'], { type: 'query' }], - }, - }, - }, - apps: { - get: { key: () => ['console', 'apps', 'get'] }, - starred: { get: { key: () => ['console', 'apps', 'starred', 'get'] } }, - recent: { get: { key: () => ['console', 'apps', 'recent', 'get'] } }, - }, - }, - } -}) - -vi.mock('@/app/components/app/store', () => ({ - useStore: (selector: (state: typeof mockAppState) => T): T => selector(mockAppState), -})) - -vi.mock('@/service/use-workflow', () => ({ - useAppWorkflow: () => ({ data: undefined }), -})) - -vi.mock('@/service/apps', () => ({ - fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), - updateAppSiteStatus: (...args: unknown[]) => mockUpdateAppSiteStatus(...args), - updateAppSiteConfig: (...args: unknown[]) => mockUpdateAppSiteConfig(...args), - updateAppSiteAccessToken: (...args: unknown[]) => mockUpdateAppSiteAccessToken(...args), -})) - -vi.mock('@/context/workspace-state', async () => { - const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') - return createWorkspaceStateModuleMock(() => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - workspacePermissionKeys: mockAppState.appDetail.permission_keys, - })) -}) -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - return createPermissionStateModuleMock(() => ({ - userProfile: { id: 'user-1' }, - currentWorkspace: { id: 'workspace-1' }, - workspacePermissionKeys: mockAppState.appDetail.permission_keys, - })) -}) - -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { - onAppStateUpdate: vi.fn(() => vi.fn()), - }, -})) - -vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ - webSocketClient: { - getSocket: vi.fn(() => null), - }, -})) - -vi.mock('@/app/components/app/overview/app-card', () => ({ - default: ({ - cardType, - onChangeStatus, - onGenerateCode, - onSaveSiteConfig, - }: { - cardType: string - onChangeStatus?: (value: boolean) => void - onGenerateCode?: () => void - onSaveSiteConfig?: (params: Record) => void - }) => ( -
- - {onGenerateCode && ( - - )} - {onSaveSiteConfig && ( - - )} -
- ), -})) - -vi.mock('@/app/components/app/overview/trigger-card', () => ({ - default: () =>
trigger card
, -})) - -vi.mock('@/app/components/tools/mcp/mcp-service-card', () => ({ - default: () =>
mcp card
, -})) - -describe('CardView ACL edit guards', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAppState.appDetail = { - id: 'app-1', - mode: 'chat', - permission_keys: [], - } - mockUpdateAppSiteStatus.mockResolvedValue(mockAppState.appDetail as App) - mockUpdateAppSiteConfig.mockResolvedValue(mockAppState.appDetail as App) - mockUpdateAppSiteAccessToken.mockResolvedValue({ code: 'token' }) - mockFetchAppDetail.mockResolvedValue({ - id: 'app-1', - mode: 'chat', - permission_keys: ['app.acl.edit'], - site: { - title: 'Saved site title', - }, - } as unknown as App) - }) - - // User-facing card actions should not mutate app settings without app ACL edit permission. - describe('Permissions', () => { - it('should not call write APIs when app ACL edit permission is missing', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByRole('button', { name: /toggle webapp/ })) - await user.click(screen.getByRole('button', { name: /save webapp/ })) - await user.click(screen.getByRole('button', { name: /generate webapp/ })) - await user.click(screen.getByRole('button', { name: /toggle api/ })) - - expect(mockUpdateAppSiteStatus).not.toHaveBeenCalled() - expect(mockUpdateAppSiteConfig).not.toHaveBeenCalled() - expect(mockUpdateAppSiteAccessToken).not.toHaveBeenCalled() - expect(mockFetchAppDetail).not.toHaveBeenCalled() - }) - - it('should call write APIs when app ACL edit permission is present', async () => { - const user = userEvent.setup() - mockAppState.appDetail.permission_keys = ['app.acl.edit'] - - render() - - await user.click(screen.getByRole('button', { name: /toggle webapp/ })) - await user.click(screen.getByRole('button', { name: /save webapp/ })) - await user.click(screen.getByRole('button', { name: /generate webapp/ })) - await user.click(screen.getByRole('button', { name: /toggle api/ })) - - await waitFor(() => { - expect(mockUpdateAppSiteStatus).toHaveBeenCalledTimes(2) - }) - expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({ - url: '/apps/app-1/site-enable', - body: { enable_site: true }, - }) - expect(mockUpdateAppSiteStatus).toHaveBeenCalledWith({ - url: '/apps/app-1/api-enable', - body: { enable_api: true }, - }) - expect(mockUpdateAppSiteConfig).toHaveBeenCalledWith({ - url: '/apps/app-1/site', - body: { title: 'Site title' }, - }) - expect(mockUpdateAppSiteAccessToken).toHaveBeenCalledWith({ - url: '/apps/app-1/site/access-token-reset', - }) - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ['console', 'apps', 'get'], - }) - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ['console', 'apps', 'starred', 'get'], - }) - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ['console', 'apps', 'recent', 'get'], - }) - await waitFor(() => { - expect(mockFetchAppDetail).toHaveBeenCalled() - }) - expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - expect(mockAppState.setAppDetail).toHaveBeenCalledWith( - expect.objectContaining({ - site: expect.objectContaining({ title: 'Saved site title' }), - }), - ) - }) - }) -}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx deleted file mode 100644 index 60f34b36144..00000000000 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view.tsx +++ /dev/null @@ -1,251 +0,0 @@ -'use client' -import type { FC } from 'react' -import type { IAppCardProps } from '@/app/components/app/overview/app-card' -import type { BlockEnum } from '@/app/components/workflow/types' -import type { UpdateAppSiteCodeResponse } from '@/models/app' -import type { App } from '@/types/app' -import type { I18nKeysByPrefix } from '@/types/i18n' -import { toast } from '@langgenius/dify-ui/toast' -import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import { useCallback, useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import AppCard from '@/app/components/app/overview/app-card' -import TriggerCard from '@/app/components/app/overview/trigger-card' -import { useStore as useAppStore } from '@/app/components/app/store' -import Loading from '@/app/components/base/loading' -import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card' -import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' -import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' -import { isTriggerNode } from '@/app/components/workflow/types' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { - fetchAppDetail, - updateAppSiteAccessToken, - updateAppSiteConfig, - updateAppSiteStatus, -} from '@/service/apps' -import { consoleQuery } from '@/service/client' -import { useAppWorkflow } from '@/service/use-workflow' -import { AppModeEnum } from '@/types/app' -import { asyncRunSafe } from '@/utils' -import { getAppACLCapabilities } from '@/utils/permission' - -type ICardViewProps = { - appId: string - isInPanel?: boolean - className?: string -} - -const CardView: FC = ({ appId, isInPanel, className }) => { - const { t } = useTranslation() - const queryClient = useQueryClient() - const appDetail = useAppStore((state) => state.appDetail) - const setAppDetail = useAppStore((state) => state.setAppDetail) - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canEditApp = useMemo( - () => - getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }).canEdit, - [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], - ) - - const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW - const showMCPCard = isInPanel - const showTriggerCard = isInPanel && isWorkflowApp - const { data: currentWorkflow } = useAppWorkflow(isWorkflowApp ? appDetail.id : '') - const hasTriggerNode = useMemo(() => { - if (!isWorkflowApp) return false - if (!currentWorkflow) return null - const nodes = currentWorkflow.graph?.nodes || [] - return nodes.some((node) => { - const nodeType = node.data?.type as BlockEnum | undefined - return !!nodeType && isTriggerNode(nodeType) - }) - }, [isWorkflowApp, currentWorkflow]) - const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false - const disableAppCards = !shouldRenderAppCards - - const buildTriggerModeMessage = useCallback( - (featureName: string) => ( -
-
- {t(($) => $['overview.disableTooltip.triggerMode'], { - ns: 'appOverview', - feature: featureName, - })} -
-
- ), - [t], - ) - - const disableWebAppTooltip = disableAppCards - ? buildTriggerModeMessage(t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })) - : null - const disableApiTooltip = disableAppCards - ? buildTriggerModeMessage(t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })) - : null - const disableMcpTooltip = disableAppCards - ? buildTriggerModeMessage(t(($) => $['mcp.server.title'], { ns: 'tools' })) - : null - - const updateAppDetail = useCallback(async () => { - try { - const res = await fetchAppDetail({ url: '/apps', id: appId }) - setAppDetail({ ...res }) - } catch (error) { - console.error(error) - } - }, [appId, setAppDetail]) - - const handleCallbackResult = ( - err: Error | null, - message?: I18nKeysByPrefix<'common', 'actionMsg.'>, - ) => { - const type = err ? 'error' : 'success' - - message ||= type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully' - - if (type === 'success') { - updateAppDetail() - - // Emit collaboration event to notify other clients of app state changes - const socket = webSocketClient.getSocket(appId) - if (socket) { - socket.emit('collaboration_event', { - type: 'app_state_update', - data: { timestamp: Date.now() }, - timestamp: Date.now(), - }) - } - } - - toast(t(($) => $[`actionMsg.${message}`], { ns: 'common' }) as string, { type }) - } - - // Listen for collaborative app state updates from other clients - useEffect(() => { - if (!appId) return - - const unsubscribe = collaborationManager.onAppStateUpdate(async () => { - try { - // Update app detail when other clients modify app state - await updateAppDetail() - } catch (error) { - console.error('app state update failed:', error) - } - }) - - return unsubscribe - }, [appId, updateAppDetail]) - - const onChangeSiteStatus = async (value: boolean) => { - if (!canEditApp) return - - const [err] = await asyncRunSafe( - updateAppSiteStatus({ - url: `/apps/${appId}/site-enable`, - body: { enable_site: value }, - }) as Promise, - ) - - handleCallbackResult(err) - } - - const onChangeApiStatus = async (value: boolean) => { - if (!canEditApp) return - - const [err] = await asyncRunSafe( - updateAppSiteStatus({ - url: `/apps/${appId}/api-enable`, - body: { enable_api: value }, - }) as Promise, - ) - - handleCallbackResult(err) - } - - const onSaveSiteConfig: IAppCardProps['onSaveSiteConfig'] = async (params) => { - if (!canEditApp) return - - const [err] = await asyncRunSafe( - updateAppSiteConfig({ - url: `/apps/${appId}/site`, - body: params, - }) as Promise, - ) - if (!err) { - void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) - void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) - void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) - } - handleCallbackResult(err) - } - - const onGenerateCode = async () => { - if (!canEditApp) return - - const [err] = await asyncRunSafe( - updateAppSiteAccessToken({ - url: `/apps/${appId}/site/access-token-reset`, - }) as Promise, - ) - - handleCallbackResult(err, err ? 'generatedUnsuccessfully' : 'generatedSuccessfully') - } - - if (!appDetail) return - - const appCards = ( - <> - - - {showMCPCard && ( - - )} - - ) - - const triggerCardNode = showTriggerCard ? ( - - ) : null - - return ( -
- {disableAppCards && triggerCardNode} - {appCards} - {!disableAppCards && triggerCardNode} -
- ) -} - -export default CardView diff --git a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx index 778496ff659..f84508352e8 100644 --- a/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/deployments/__tests__/layout.spec.tsx @@ -1,59 +1,22 @@ -import type { ReactElement, ReactNode } from 'react' -import { render, screen } from '@testing-library/react' - const mocks = vi.hoisted(() => ({ - ensureQueryData: vi.fn(), - systemFeaturesQueryOptions: { queryKey: ['console', 'system-features'] }, notFound: vi.fn(() => { throw new Error('NEXT_NOT_FOUND') }), })) -vi.mock('@/features/system-features/server', () => ({ - getSystemFeaturesQueryClient: () => ({ - ensureQueryData: mocks.ensureQueryData, - }), - systemFeaturesServerQueryOptions: () => mocks.systemFeaturesQueryOptions, -})) - -vi.mock('@/features/deployments/deploy-drawer', () => ({ - DeployDrawer: () =>
Deploy drawer
, -})) - vi.mock('@/next/navigation', () => ({ notFound: () => mocks.notFound(), })) -const renderDeploymentsLayout = async (children: ReactNode) => { - const { default: DeploymentsLayout } = await import('../layout') - const element = await DeploymentsLayout({ children }) - render(element as ReactElement) -} - describe('DeploymentsLayout', () => { beforeEach(() => { vi.clearAllMocks() - mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: true }) }) - it('should render deployments content and drawer when app deploy is enabled', async () => { - await renderDeploymentsLayout(
Deployments content
) - - expect(mocks.ensureQueryData).toHaveBeenCalledWith(mocks.systemFeaturesQueryOptions) - expect(screen.getByText('Deployments content')).toBeInTheDocument() - expect(screen.getByText('Deploy drawer')).toBeInTheDocument() - expect(mocks.notFound).not.toHaveBeenCalled() - }) - - it('should trigger notFound when app deploy is disabled', async () => { - mocks.ensureQueryData.mockResolvedValue({ enable_app_deploy: false }) + it('should always trigger notFound', async () => { const { default: DeploymentsLayout } = await import('../layout') - await expect( - DeploymentsLayout({ - children:
Deployments content
, - }), - ).rejects.toThrow('NEXT_NOT_FOUND') + expect(() => DeploymentsLayout()).toThrow('NEXT_NOT_FOUND') expect(mocks.notFound).toHaveBeenCalledTimes(1) }) diff --git a/web/app/(commonLayout)/deployments/layout.tsx b/web/app/(commonLayout)/deployments/layout.tsx index 72515f4557e..2070a44ff99 100644 --- a/web/app/(commonLayout)/deployments/layout.tsx +++ b/web/app/(commonLayout)/deployments/layout.tsx @@ -1,22 +1,5 @@ -import type { ReactNode } from 'react' -import { DeployDrawer } from '@/features/deployments/deploy-drawer' -import { - getSystemFeaturesQueryClient, - systemFeaturesServerQueryOptions, -} from '@/features/system-features/server' import { notFound } from '@/next/navigation' -export default async function DeploymentsLayout({ children }: { children: ReactNode }) { - const systemFeatures = await getSystemFeaturesQueryClient().ensureQueryData( - systemFeaturesServerQueryOptions(), - ) - - if (!systemFeatures.enable_app_deploy) notFound() - - return ( - <> - {children} - - - ) +export default function DeploymentsLayout() { + notFound() } diff --git a/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx b/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx index f3ba7a9a470..8d9dbad7693 100644 --- a/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/authenticated-layout.spec.tsx @@ -1,4 +1,5 @@ import type { AppData, AppMeta } from '@/models/share' +import type { WebAppAddress } from '@/service/webapp-address' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { webAppLogout } from '@/service/webapp-auth' @@ -20,6 +21,7 @@ const updateAppParams = vi.fn() const updateWebAppMeta = vi.fn() const updateUserCanAccessApp = vi.fn() const replace = vi.fn() +const webAppAddress: WebAppAddress = { kind: 'default', code: 'share-code' } const mockWebAppState = { shareCode: 'share-code', @@ -96,6 +98,10 @@ vi.mock('@/service/webapp-auth', () => ({ webAppLogout: vi.fn(), })) +vi.mock('@/service/webapp-address', () => ({ + resolveWebAppAddress: () => webAppAddress, +})) + const resetQueryStates = () => { appInfoQueryState.data = { app_id: 'app-id', @@ -173,7 +179,7 @@ describe('AuthenticatedLayout', () => { await user.click(screen.getByRole('button', { name: 'common.userProfile.logout' })) - expect(webAppLogout).toHaveBeenCalledWith('share-code') + expect(webAppLogout).toHaveBeenCalledWith(webAppAddress) expect(replace).toHaveBeenCalledWith('/webapp-signin?redirect_url=%2Fworkflow%2Fshare-code') }) }) diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index cf2f43f2417..1287b47fa79 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -47,6 +47,7 @@ describe('Splash', () => { vi.clearAllMocks() webAppState.shareCode = 'share-app' navigationMocks.pathname = '/chatbot/share-app' + window.history.replaceState({}, '', navigationMocks.pathname) navigationMocks.searchParams = new URLSearchParams({ redirect_url: 'https://evil.example/chatbot/evil-app', }) diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index f817b6c2ef6..2f052672562 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -9,11 +9,11 @@ import { useWebAppStore } from '@/context/web-app-context' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share' +import { resolveWebAppAddress } from '@/service/webapp-address' import { webAppLogout } from '@/service/webapp-auth' const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { const { t } = useTranslation() - const shareCode = useWebAppStore((s) => s.shareCode) const updateAppInfo = useWebAppStore((s) => s.updateAppInfo) const updateAppParams = useWebAppStore((s) => s.updateAppParams) const updateWebAppMeta = useWebAppStore((s) => s.updateWebAppMeta) @@ -59,10 +59,10 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => { }, [searchParams, pathname]) const backToHome = useCallback(async () => { - await webAppLogout(shareCode!) + await webAppLogout(resolveWebAppAddress()) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, router, shareCode]) + }, [getSigninUrl, router]) if (appInfoError) { return ( diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index e813f789507..9558c6acaa4 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -11,6 +11,7 @@ import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchAccessToken } from '@/service/share' +import { resolveWebAppAddress } from '@/service/webapp-address' import { setWebAppAccessToken, setWebAppPassport, @@ -45,16 +46,16 @@ function Splash({ children }: PropsWithChildren) { const backToHome = useCallback(async () => { const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin) - const effectiveShareCode = loginRedirect?.appCode || shareCode - if (!effectiveShareCode || (isWebAppSigninPath(pathname) && !loginRedirect)) { + const address = loginRedirect?.address || resolveWebAppAddress() + if (!address || (isWebAppSigninPath(pathname) && !loginRedirect)) { replaceLoginRedirect(getClientLoginFallback(), router.replace, basePath) return } - await webAppLogout(effectiveShareCode) + await webAppLogout(address) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, pathname, redirectUrl, router, shareCode]) + }, [getSigninUrl, pathname, redirectUrl, router]) const [isLoading, setIsLoading] = useState(true) const [unavailableShareCode, setUnavailableShareCode] = useState() @@ -66,8 +67,9 @@ function Splash({ children }: PropsWithChildren) { return } - const effectiveShareCode = loginRedirect?.appCode || shareCode - if (!effectiveShareCode) return + const address = loginRedirect?.address || resolveWebAppAddress() + if (!address) return + const effectiveShareCode = address.code if (message) return @@ -86,6 +88,7 @@ function Splash({ children }: PropsWithChildren) { // if access mode is public, user login is always true, but the app login(passport) may be expired const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( effectiveShareCode, + webAppAccessMode, embeddedUserId || undefined, ) if (userLoggedIn && appLoggedIn) { @@ -100,15 +103,15 @@ function Splash({ children }: PropsWithChildren) { appCode: effectiveShareCode, userId: embeddedUserId || undefined, }) - setWebAppPassport(effectiveShareCode, access_token) + setWebAppPassport(address, access_token) redirectOrFinish() } catch (error) { if (error instanceof Response && error.status === 404) { setUnavailableShareCode(effectiveShareCode) - await webAppLogout(effectiveShareCode) + await webAppLogout(address) return } - await webAppLogout(effectiveShareCode) + await webAppLogout(address) proceedToAuth() } } diff --git a/web/app/(shareLayout)/env/workflow/[token]/page.tsx b/web/app/(shareLayout)/env/workflow/[token]/page.tsx new file mode 100644 index 00000000000..e7450d4c108 --- /dev/null +++ b/web/app/(shareLayout)/env/workflow/[token]/page.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' +import Main from '@/app/components/share/text-generation' +import AuthenticatedLayout from '../../../components/authenticated-layout' + +const EnvironmentWorkflow = () => { + return ( + +
+ + ) +} + +export default React.memo(EnvironmentWorkflow) diff --git a/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts b/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts index 5a50bea8f47..94e0872c01e 100644 --- a/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts +++ b/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts @@ -11,6 +11,7 @@ describe('resolveWebAppLoginRedirect', () => { expect(result).toEqual({ appCode: 'share-app', + address: { kind: 'default', code: 'share-app' }, target: { kind: 'internal', href: '/chatbot/share-app?foo=bar#answer' }, }) }) @@ -23,6 +24,19 @@ describe('resolveWebAppLoginRedirect', () => { expect(result?.target.href).toBe(redirectUrl) expect(result?.appCode).toBe('share-app') }) + + it('should resolve an environment workflow redirect', () => { + const result = resolveWebAppLoginRedirect( + '/env/workflow/workflow-app', + 'https://self-hosted.example.com', + ) + + expect(result).toEqual({ + appCode: 'workflow-app', + address: { kind: 'environment', code: 'workflow-app' }, + target: { kind: 'internal', href: '/env/workflow/workflow-app' }, + }) + }) }) // Covers absolute destinations accepted by the shared login redirect policy. @@ -35,6 +49,7 @@ describe('resolveWebAppLoginRedirect', () => { expect(result).toEqual({ appCode: 'share-app', + address: { kind: 'default', code: 'share-app' }, target: { kind: 'absolute', href: 'http://self-hosted.example.com:8080/chatbot/share-app', diff --git a/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx b/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx index f4dce56745c..2459bf07329 100644 --- a/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx +++ b/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx @@ -1,3 +1,4 @@ +import { zSsoProtocol } from '@dify/contracts/api/console/system-features/zod.gen' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AccessMode } from '@/models/access-control' @@ -6,13 +7,20 @@ import { renderWithConsoleQuery } from '@/test/console/query-data' import WebSSOForm from '../page' const navigationMocks = vi.hoisted(() => ({ + push: vi.fn(), replace: vi.fn(), searchParams: new URLSearchParams(), })) +const serviceMocks = vi.hoisted(() => ({ + fetchWebOAuth2SSOUrl: vi.fn(), + fetchWebOIDCSSOUrl: vi.fn(), + fetchWebSAMLSSOUrl: vi.fn(), +})) + const webAppState = { shareCode: 'share-app', - webAppAccessMode: AccessMode.PUBLIC, + webAppAccessMode: AccessMode.PUBLIC as AccessMode, } vi.mock('@/context/web-app-context', () => ({ @@ -20,14 +28,20 @@ vi.mock('@/context/web-app-context', () => ({ })) vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ replace: navigationMocks.replace }), + useRouter: () => ({ push: navigationMocks.push, replace: navigationMocks.replace }), useSearchParams: () => navigationMocks.searchParams, })) +vi.mock('@/service/share', () => serviceMocks) + vi.mock('@/service/webapp-auth', () => ({ webAppLogout: vi.fn(), })) +afterEach(() => { + window.history.replaceState({}, '', '/') +}) + describe('WebSSOForm redirect security', () => { beforeEach(() => { vi.clearAllMocks() @@ -51,6 +65,7 @@ describe('WebSSOForm redirect security', () => { navigationMocks.searchParams = new URLSearchParams({ redirect_url: encodeURIComponent('/chatbot/share-app'), }) + window.history.replaceState({}, '', '/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app') renderWithConsoleQuery(, { systemFeatures: { webapp_auth: { enabled: true } }, @@ -58,9 +73,41 @@ describe('WebSSOForm redirect security', () => { await user.click(await screen.findByRole('button', { name: 'share.login.backToHome' })) - expect(webAppLogout).toHaveBeenCalledWith('share-app') + expect(webAppLogout).toHaveBeenCalledWith({ kind: 'default', code: 'share-app' }) expect(navigationMocks.replace).toHaveBeenCalledWith( '/webapp-signin?redirect_url=%2Fchatbot%2Fshare-app', ) }) }) + +describe('WebSSOForm environment access modes', () => { + beforeEach(() => { + vi.clearAllMocks() + navigationMocks.searchParams = new URLSearchParams({ + redirect_url: '/env/workflow/workflow-app', + }) + }) + + afterEach(() => { + webAppState.webAppAccessMode = AccessMode.PUBLIC + }) + + it('should start web SSO for an sso verified environment webapp', async () => { + webAppState.webAppAccessMode = AccessMode.EXTERNAL_MEMBERS + serviceMocks.fetchWebSAMLSSOUrl.mockResolvedValue({ url: 'https://idp.example/authorize' }) + + renderWithConsoleQuery(, { + systemFeatures: { + webapp_auth: { enabled: true, sso_config: { protocol: zSsoProtocol.enum.saml } }, + }, + }) + + await waitFor(() => { + expect(serviceMocks.fetchWebSAMLSSOUrl).toHaveBeenCalledWith( + 'workflow-app', + '/env/workflow/workflow-app', + ) + }) + expect(navigationMocks.push).toHaveBeenCalledWith('https://idp.example/authorize') + }) +}) diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index 3d71ec510f1..a44e1c401fd 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -66,7 +66,7 @@ export default function CheckCode() { appCode: loginRedirect.appCode, userId: embeddedUserId || undefined, }) - setWebAppPassport(loginRedirect.appCode, access_token) + setWebAppPassport(loginRedirect.address, access_token) replaceLoginRedirect(loginRedirect.target, router.replace, basePath) } } catch (error) { diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index 3e5a8d414cb..0884b6fb0c7 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -83,7 +83,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut appCode: loginRedirect.appCode, userId: embeddedUserId || undefined, }) - setWebAppPassport(loginRedirect.appCode, access_token) + setWebAppPassport(loginRedirect.address, access_token) replaceLoginRedirect(loginRedirect.target, router.replace, basePath) } else { toast.error(res.data) diff --git a/web/app/(shareLayout)/webapp-signin/login-redirect.ts b/web/app/(shareLayout)/webapp-signin/login-redirect.ts index 54d39df8b0c..216e61f338c 100644 --- a/web/app/(shareLayout)/webapp-signin/login-redirect.ts +++ b/web/app/(shareLayout)/webapp-signin/login-redirect.ts @@ -1,10 +1,13 @@ +import type { WebAppAddress } from '@/service/webapp-address' import type { LoginRedirectTarget } from '@/utils/login-redirect' +import { parseWebAppAddress } from '@/service/webapp-address' import { resolveLoginRedirectTarget } from '@/utils/login-redirect' const INTERNAL_PATH_PARSE_BASE = 'https://login-redirect.invalid' export type WebAppLoginRedirect = { appCode: string + address: WebAppAddress target: LoginRedirectTarget } @@ -40,10 +43,10 @@ export function resolveWebAppLoginRedirect( const url = new URL(target.href, currentOrigin || INTERNAL_PATH_PARSE_BASE) if (isWebAppSigninPath(url.pathname)) return null - const appCode = url.pathname.split('/').filter(Boolean).at(-1) - if (!appCode) return null + const address = parseWebAppAddress(url.pathname) + if (!address) return null - return { appCode, target } + return { appCode: address.code, address, target } } catch { return null } diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 8aa627fc621..e0a3dc00994 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -9,6 +9,7 @@ import { useWebAppStore } from '@/context/web-app-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AccessMode } from '@/models/access-control' import { useRouter, useSearchParams } from '@/next/navigation' +import { resolveWebAppAddress } from '@/service/webapp-address' import { webAppLogout } from '@/service/webapp-auth' import { getClientLoginFallback } from '@/utils/login-redirect' import { replaceLoginRedirect } from '@/utils/login-redirect.client' @@ -44,12 +45,11 @@ function WebSSOForm() { return `/webapp-signin?${params.toString()}` }, [redirectUrl]) - const shareCode = useWebAppStore((s) => s.shareCode) const backToHome = useCallback(async () => { - await webAppLogout(shareCode!) + await webAppLogout(resolveWebAppAddress()) const url = getSigninUrl() router.replace(url) - }, [getSigninUrl, router, shareCode]) + }, [getSigninUrl, router]) if (!loginRedirect) { return ( diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index fa5a3849170..556b9c44bd1 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -18,6 +18,7 @@ const render = (ui: Parameters[0]) => renderWithConsoleQuery(ui, { systemFeatures: { rbac_enabled: mockIsRbacEnabled, + enable_app_deploy: false, }, }) @@ -44,7 +45,12 @@ vi.mock('@/context/permission-state', async () => { const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') return createPermissionStateModuleMock(() => mockConsoleState.current) }) - +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + return createWorkspaceStateModuleMock(() => ({ + isCurrentWorkspaceEditor: false, + })) +}) vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, })) @@ -183,6 +189,58 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) + it('should render access point navigation using its app route', () => { + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.accessPoint' })).toHaveAttribute( + 'href', + '/app/app-1/access-point', + ) + expect( + screen.queryByRole('link', { name: 'common.appMenus.apiAccess' }), + ).not.toBeInTheDocument() + }) + + it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => { + // Arrange + mockAppMode = 'workflow' + mockAppPermissionKeys = [AppACLPermission.Deploy] + + // Act + render() + + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute( + 'href', + '/app/app-1/deploy', + ) + }) + + it.each([ + { + label: 'the app is not a workflow app', + mode: 'chat', + permissionKeys: [AppACLPermission.Deploy], + }, + { + label: 'app deploy ACL permission is missing', + mode: 'workflow', + permissionKeys: [AppACLPermission.Monitor], + }, + ])('should hide deploy navigation when $label', ({ mode, permissionKeys }) => { + // Arrange + mockAppMode = mode + mockAppPermissionKeys = permissionKeys + + // Act + render() + + // Assert + expect(screen.queryByRole('link', { name: 'common.appMenus.deploy' })).not.toBeInTheDocument() + }) + it('should render resource access navigation when app access config permission is granted', () => { // Arrange mockAppPermissionKeys = [AppACLPermission.AccessConfig] diff --git a/web/app/components/app-sidebar/__tests__/basic.spec.tsx b/web/app/components/app-sidebar/__tests__/basic.spec.tsx deleted file mode 100644 index 28fb6246098..00000000000 --- a/web/app/components/app-sidebar/__tests__/basic.spec.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { render, screen } from '@testing-library/react' -import * as React from 'react' -import AppBasic from '../basic' - -vi.mock('@/app/components/base/icons/src/vender/workflow', () => ({ - ApiAggregate: (props: React.SVGProps) => , - WindowCursor: (props: React.SVGProps) => ( - - ), -})) - -vi.mock('../../base/app-icon', () => ({ - default: ({ - icon, - background, - innerIcon, - className, - }: { - icon?: string - background?: string - innerIcon?: React.ReactNode - className?: string - }) => ( -
- {innerIcon} -
- ), -})) - -describe('AppBasic', () => { - describe('Icon rendering', () => { - it('should render app icon when iconType is app with valid icon and background', () => { - render() - expect(screen.getByTestId('app-icon')).toBeInTheDocument() - }) - - it('should not render app icon when icon is empty', () => { - render() - expect(screen.queryByTestId('app-icon')).not.toBeInTheDocument() - }) - - it('should render api icon when iconType is api', () => { - render() - expect(screen.getByTestId('api-icon')).toBeInTheDocument() - }) - - it('should render webapp icon when iconType is webapp', () => { - render() - expect(screen.getByTestId('webapp-icon')).toBeInTheDocument() - }) - - it('should render dataset icon when iconType is dataset', () => { - render() - const icons = screen.getAllByTestId('app-icon') - expect(icons.length).toBeGreaterThan(0) - }) - - it('should render notion icon when iconType is notion', () => { - render() - const icons = screen.getAllByTestId('app-icon') - expect(icons.length).toBeGreaterThan(0) - }) - }) - - describe('Expand mode', () => { - it('should show name and type in expand mode', () => { - render() - expect(screen.getByText('My App')).toBeInTheDocument() - expect(screen.getByText('Chatbot')).toBeInTheDocument() - }) - - it('should hide name and type in collapse mode', () => { - render() - expect(screen.queryByText('My App')).not.toBeInTheDocument() - }) - - it('should show hover tip when provided', () => { - render() - expect(screen.getByLabelText('Some tip')).toBeInTheDocument() - }) - - it('should not show hover tip when not provided', () => { - render() - expect(screen.queryByLabelText('Some tip')).not.toBeInTheDocument() - }) - }) - - describe('Type display', () => { - it('should hide type when hideType is true', () => { - render() - expect(screen.queryByText('Chatbot')).not.toBeInTheDocument() - }) - - it('should show external tag when isExternal is true', () => { - render() - expect(screen.getByText('dataset.externalTag')).toBeInTheDocument() - }) - - it('should show type inline when isExtraInLine is true and hideType is false', () => { - render() - expect(screen.getByText('Chatbot')).toBeInTheDocument() - }) - }) -}) diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 3b59433b260..505c8c15a45 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -3,18 +3,6 @@ import type { ComponentProps } from 'react' import type { NavIcon } from './nav-link' import { cn } from '@langgenius/dify-ui/cn' -import { - RiDashboard2Fill, - RiDashboard2Line, - RiFileList3Fill, - RiFileList3Line, - RiLock2Fill, - RiLock2Line, - RiTerminalBoxFill, - RiTerminalBoxLine, - RiTerminalWindowFill, - RiTerminalWindowLine, -} from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { Fragment, useMemo } from 'react' @@ -45,6 +33,28 @@ const AnnotationNavIcon = ({ className, ...props }: ComponentProps { + const ClassNameNavIcon = ({ className }: ComponentProps<'svg'>) => ( + + ) + + ClassNameNavIcon.displayName = 'ClassNameNavIcon' + + return ClassNameNavIcon +} + +const accessPointNavIcon = createClassNameNavIcon('i-custom-vender-agent-v2-access-point') +const terminalWindowLineNavIcon = createClassNameNavIcon('i-ri-terminal-window-line') +const terminalWindowFillNavIcon = createClassNameNavIcon('i-ri-terminal-window-fill') +const instanceLineNavIcon = createClassNameNavIcon('i-ri-instance-line') +const instanceFillNavIcon = createClassNameNavIcon('i-ri-instance-fill') +const fileListLineNavIcon = createClassNameNavIcon('i-ri-file-list-3-line') +const fileListFillNavIcon = createClassNameNavIcon('i-ri-file-list-3-fill') +const dashboardLineNavIcon = createClassNameNavIcon('i-ri-dashboard-2-line') +const dashboardFillNavIcon = createClassNameNavIcon('i-ri-dashboard-2-fill') +const lockLineNavIcon = createClassNameNavIcon('i-ri-lock-2-line') +const lockFillNavIcon = createClassNameNavIcon('i-ri-lock-2-fill') + const isLogsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/logs') const isAnnotationsNavItem = (item: AppDetailNavItem) => item.href.endsWith('/annotations') @@ -88,6 +98,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { const appId = appDetail.id const isWorkflowApp = appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT + const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW const supportsAnnotations = appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { @@ -103,24 +114,34 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { { name: t(($) => $['appMenus.promptEng'], { ns: 'common' }), href: `/app/${appId}/${isWorkflowApp ? 'workflow' : 'configuration'}`, - icon: RiTerminalWindowLine, - selectedIcon: RiTerminalWindowFill, + icon: terminalWindowLineNavIcon, + selectedIcon: terminalWindowFillNavIcon, }, ] : []), { - name: t(($) => $['appMenus.apiAccess'], { ns: 'common' }), - href: `/app/${appId}/develop`, - icon: RiTerminalBoxLine, - selectedIcon: RiTerminalBoxFill, + name: t(($) => $['appMenus.accessPoint'], { ns: 'common' }), + href: `/app/${appId}/access-point`, + icon: accessPointNavIcon, + selectedIcon: accessPointNavIcon, }, + ...(supportsAppDeploy && appACLCapabilities.canDeploy + ? [ + { + name: t(($) => $['appMenus.deploy'], { ns: 'common' }), + href: `/app/${appId}/deploy`, + icon: instanceLineNavIcon, + selectedIcon: instanceFillNavIcon, + }, + ] + : []), ...(appACLCapabilities.canAccessLogAndAnnotation ? [ { name: t(($) => $['appMenus.logs'], { ns: 'common' }), href: `/app/${appId}/logs`, - icon: RiFileList3Line, - selectedIcon: RiFileList3Fill, + icon: fileListLineNavIcon, + selectedIcon: fileListFillNavIcon, }, ] : []), @@ -139,8 +160,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { { name: t(($) => $['appMenus.overview'], { ns: 'common' }), href: `/app/${appId}/overview`, - icon: RiDashboard2Line, - selectedIcon: RiDashboard2Fill, + icon: dashboardLineNavIcon, + selectedIcon: dashboardFillNavIcon, }, ] : []), @@ -149,8 +170,8 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { { name: t(($) => $['settings.resourceAccess'], { ns: 'common' }), href: `/app/${appId}/access-config`, - icon: RiLock2Line, - selectedIcon: RiLock2Fill, + icon: lockLineNavIcon, + selectedIcon: lockFillNavIcon, }, ] : []), diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx deleted file mode 100644 index e0f08a922fc..00000000000 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-detail-panel.spec.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import type { App, AppSSO } from '@/types/app' -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import * as React from 'react' -import { createConsoleQueryWrapper } from '@/test/console/query-data' -import { render as renderWithConsoleState } from '@/test/console/render' -import { AppModeEnum } from '@/types/app' -import { AppACLPermission } from '@/utils/permission' -import AppInfoDetailPanel from '../app-info-detail-panel' - -const mockWorkspacePermissionKeys = vi.hoisted(() => ({ - value: ['app.create_and_management'] as string[], -})) -const mockConsoleState = vi.hoisted(() => ({ - current: { - userProfile: { id: 'user-1' }, - get workspacePermissionKeys() { - return mockWorkspacePermissionKeys.value - }, - }, -})) - -const render = (ui: Parameters[0]) => - renderWithConsoleState(ui, { - wrapper: createConsoleQueryWrapper({ accountProfile: { id: 'user-1' } }).wrapper, - }) - -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - return createPermissionStateModuleMock(() => mockConsoleState.current) -}) - -vi.mock('../../../base/app-icon', () => ({ - default: ({ size, icon }: { size: string; icon: string }) => ( -
- ), -})) - -vi.mock('../app-info-detail-drawer', () => ({ - AppInfoDetailDrawer: ({ - open, - onClose, - children, - }: { - open: boolean - onClose: () => void - children: React.ReactNode - }) => - open ? ( -
- - {children} -
- ) : null, -})) - -vi.mock('@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view', () => ({ - default: ({ appId }: { appId: string }) =>
, -})) - -vi.mock('../app-operations', () => ({ - default: ({ - primaryOperations, - secondaryOperations, - }: { - primaryOperations?: Array<{ - id: string - title: string - onClick: () => void - disabled?: boolean - loading?: boolean - }> - secondaryOperations?: Array<{ id: string; title: string; onClick: () => void; type?: string }> - }) => ( -
- {primaryOperations?.map((op) => ( - - ))} - {secondaryOperations?.map((op) => - op.type === 'divider' ? ( - - ) : ( - - ), - )} -
- ), -})) - -const defaultAppPermissionKeys = [ - AppACLPermission.Edit, - AppACLPermission.ImportExportDSL, - AppACLPermission.Delete, -] - -const createAppDetail = (overrides: Partial = {}): App & Partial => - ({ - id: 'app-1', - name: 'Test App', - mode: AppModeEnum.CHAT, - icon: '🤖', - icon_type: 'emoji', - icon_background: '#FFEAD5', - icon_url: '', - description: 'A test description', - use_icon_as_answer_icon: false, - permission_keys: defaultAppPermissionKeys, - ...overrides, - }) as App & Partial - -describe('AppInfoDetailPanel', () => { - const defaultProps = { - appDetail: createAppDetail(), - show: true, - onClose: vi.fn(), - openModal: vi.fn(), - isExporting: false, - exportCheck: vi.fn(), - } - - beforeEach(() => { - vi.clearAllMocks() - mockWorkspacePermissionKeys.value = ['app.create_and_management'] - }) - - describe('Rendering', () => { - it('should not render when show is false', () => { - render() - expect(screen.queryByTestId('app-info-detail-drawer')).not.toBeInTheDocument() - }) - - it('should render drawer when show is true', () => { - render() - expect(screen.getByTestId('app-info-detail-drawer')).toBeInTheDocument() - }) - - it('should display app name', () => { - render() - expect(screen.getByText('Test App')).toBeInTheDocument() - }) - - it('should display app mode label', () => { - render() - expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() - }) - - it('should display description when available', () => { - render() - expect(screen.getByText('A test description')).toBeInTheDocument() - }) - - it('should not display description when empty', () => { - render( - , - ) - expect(screen.queryByText('A test description')).not.toBeInTheDocument() - }) - - it('should not display description when undefined', () => { - render( - , - ) - expect(screen.queryByText('A test description')).not.toBeInTheDocument() - }) - - it('should render CardView with correct appId', () => { - render() - const cardView = screen.getByTestId('card-view') - expect(cardView).toHaveAttribute('data-app-id', 'app-1') - }) - - it('should render app icon with large size', () => { - render() - const icon = screen.getByTestId('app-icon') - expect(icon).toHaveAttribute('data-size', 'large') - }) - }) - - describe('Operations', () => { - it('should render edit, duplicate, and export operations', () => { - render() - expect(screen.getByTestId('op-edit')).toBeInTheDocument() - expect(screen.getByTestId('op-duplicate')).toBeInTheDocument() - expect(screen.getByTestId('op-export')).toBeInTheDocument() - }) - - it('should call openModal with edit when edit is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByTestId('op-edit')) - - expect(defaultProps.openModal).toHaveBeenCalledWith('edit') - }) - - it('should call openModal with duplicate when duplicate is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByTestId('op-duplicate')) - - expect(defaultProps.openModal).toHaveBeenCalledWith('duplicate') - }) - - it('should hide duplicate operation when app.create_and_management permission is missing', () => { - mockWorkspacePermissionKeys.value = [] - - render() - - expect(screen.queryByTestId('op-duplicate')).not.toBeInTheDocument() - }) - - it('should call exportCheck when export is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByTestId('op-export')) - - expect(defaultProps.exportCheck).toHaveBeenCalledTimes(1) - }) - - it('should show the export operation as loading while export is pending', () => { - render() - - expect(screen.getByTestId('op-export')).toHaveAttribute('data-loading', 'true') - expect(screen.getByTestId('op-export')).not.toBeDisabled() - }) - - it('should render delete operation', () => { - render() - expect(screen.getByTestId('op-delete')).toBeInTheDocument() - }) - - it('should call openModal with delete when delete is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByTestId('op-delete')) - - expect(defaultProps.openModal).toHaveBeenCalledWith('delete') - }) - }) - - describe('Import DSL option', () => { - it('should show import DSL for advanced_chat mode', () => { - render( - , - ) - expect(screen.getByTestId('op-import')).toBeInTheDocument() - }) - - it('should show import DSL for workflow mode', () => { - render( - , - ) - expect(screen.getByTestId('op-import')).toBeInTheDocument() - }) - - it('should not show import DSL for chat mode', () => { - render() - expect(screen.queryByTestId('op-import')).not.toBeInTheDocument() - }) - - it('should not show import DSL when import/export DSL permission is missing', () => { - render( - , - ) - expect(screen.queryByTestId('op-import')).not.toBeInTheDocument() - }) - - it('should call openModal with importDSL when import is clicked', async () => { - const user = userEvent.setup() - render( - , - ) - await user.click(screen.getByTestId('op-import')) - expect(defaultProps.openModal).toHaveBeenCalledWith('importDSL') - }) - - it('should render divider in secondary operations', async () => { - const user = userEvent.setup() - render() - const divider = screen.getByTestId('op-divider-1') - expect(divider).toBeInTheDocument() - await user.click(divider) - }) - }) - - describe('Switch operation', () => { - it('should show switch button for chat mode', () => { - render() - expect(screen.getByText('app.switch')).toBeInTheDocument() - }) - - it('should show switch button for completion mode', () => { - render( - , - ) - expect(screen.getByText('app.switch')).toBeInTheDocument() - }) - - it('should not show switch button for workflow mode', () => { - render( - , - ) - expect(screen.queryByText('app.switch')).not.toBeInTheDocument() - }) - - it('should not show switch button for advanced_chat mode', () => { - render( - , - ) - expect(screen.queryByText('app.switch')).not.toBeInTheDocument() - }) - - it('should call openModal with switch when switch button is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByText('app.switch')) - - expect(defaultProps.openModal).toHaveBeenCalledWith('switch') - }) - }) - - describe('Drawer interactions', () => { - it('should call onClose when drawer close button is clicked', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByTestId('drawer-close')) - - expect(defaultProps.onClose).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx index e02452648b9..28e97c88c64 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-trigger.spec.tsx @@ -1,10 +1,35 @@ import type { App, AppSSO } from '@/types/app' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' +import { render as renderWithConsoleState } from '@/test/console/render' import { AppModeEnum } from '@/types/app' +import { AppACLPermission } from '@/utils/permission' import AppInfoTrigger from '../app-info-trigger' +const mockWorkspacePermissionKeys = vi.hoisted(() => ({ + value: ['app.create_and_management'] as string[], +})) +const mockConsoleState = vi.hoisted(() => ({ + current: { + userProfile: { id: 'user-1' }, + get workspacePermissionKeys() { + return mockWorkspacePermissionKeys.value + }, + }, +})) + +const render = (ui: Parameters[0]) => + renderWithConsoleState(ui, { + wrapper: createAccountProfileQueryWrapper({ id: 'user-1' }), + }) + +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + return createPermissionStateModuleMock(() => mockConsoleState.current) +}) + vi.mock('../../../base/app-icon', () => ({ default: ({ size, @@ -19,6 +44,12 @@ vi.mock('../../../base/app-icon', () => ({ }) =>
, })) +const defaultAppPermissionKeys = [ + AppACLPermission.Edit, + AppACLPermission.ImportExportDSL, + AppACLPermission.Delete, +] + const createAppDetail = (overrides: Partial = {}): App & Partial => ({ id: 'app-1', @@ -30,83 +61,121 @@ const createAppDetail = (overrides: Partial = {}): App & Partial => icon_url: '', description: 'A test app', use_icon_as_answer_icon: false, + permission_keys: defaultAppPermissionKeys, + maintainer: 'user-1', ...overrides, }) as App & Partial +const createProps = (overrides: Partial> = {}) => ({ + appDetail: createAppDetail(), + expand: true, + openModal: vi.fn(), + isExporting: false, + exportCheck: vi.fn(), + ...overrides, +}) + +const getOperationsTrigger = () => + screen.getByRole('button', { name: /common\.operation\.moreActionsFor/ }) + describe('AppInfoTrigger', () => { - it('should render app icon with correct size when expanded', () => { - render() - const icon = screen.getByTestId('app-icon') - expect(icon).toHaveAttribute('data-size', 'large') + beforeEach(() => { + vi.clearAllMocks() + mockWorkspacePermissionKeys.value = ['app.create_and_management'] }) - it('should render app icon with small size when collapsed', () => { - render() - const icon = screen.getByTestId('app-icon') - expect(icon).toHaveAttribute('data-size', 'medium') - }) - - it('should show app name when expanded', () => { - render( - , - ) - expect(screen.getByText('My Chatbot')).toBeInTheDocument() - }) - - it('should not show app name when collapsed', () => { - render( - , - ) - expect(screen.queryByText('My Chatbot')).not.toBeInTheDocument() - }) - - it('should show app mode label when expanded', () => { - render( - , - ) - expect(screen.getByText('app.types.advanced')).toBeInTheDocument() - }) - - it('should not show mode label when collapsed', () => { - render() - expect(screen.queryByText('app.types.chatbot')).not.toBeInTheDocument() - }) - - it('should call onClick when button is clicked', async () => { + it('renders expanded app metadata without making the app info clickable', async () => { const user = userEvent.setup() - const onClick = vi.fn() - render() + const props = createProps({ + appDetail: createAppDetail({ name: 'My Chatbot', mode: AppModeEnum.ADVANCED_CHAT }), + }) + render() - await user.click(screen.getByRole('button')) + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'large') + expect(screen.getByText('My Chatbot')).toBeInTheDocument() + expect(screen.getByText('app.types.advanced')).toBeInTheDocument() + expect(screen.getByText('My Chatbot').closest('button')).toBeNull() - expect(onClick).toHaveBeenCalledTimes(1) + await user.click(screen.getByTestId('app-icon')) + + expect(props.openModal).not.toHaveBeenCalled() + expect(props.exportCheck).not.toHaveBeenCalled() }) - it('should show settings icon in expanded and collapsed states', () => { - const { container, rerender } = render( - , + it('renders only the medium app icon when collapsed', () => { + render() + + expect(screen.getByTestId('app-icon')).toHaveAttribute('data-size', 'medium') + expect(screen.queryByText('Test App')).not.toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('shows every available chat app operation and keeps workflow conversion last', async () => { + const user = userEvent.setup() + const props = createProps() + render() + + await user.click(getOperationsTrigger()) + + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([ + 'app.editApp', + 'app.duplicate', + 'app.export', + 'common.operation.delete', + 'app.switch', + ]) + + await user.click(screen.getByRole('menuitem', { name: 'app.switch' })) + expect(props.openModal).toHaveBeenCalledWith('switch') + }) + + it('shows import DSL for workflow apps without a workflow conversion operation', async () => { + const user = userEvent.setup() + const props = createProps({ + appDetail: createAppDetail({ mode: AppModeEnum.WORKFLOW }), + }) + render() + + await user.click(getOperationsTrigger()) + + expect(screen.getByRole('menuitem', { name: 'workflow.common.importDSL' })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('menuitem', { name: 'workflow.common.importDSL' })) + expect(props.openModal).toHaveBeenCalledWith('importDSL') + }) + + it('runs export from the menu and disables it while an export is pending', async () => { + const user = userEvent.setup() + const props = createProps({ isExporting: true }) + const { rerender } = render() + + await user.click(getOperationsTrigger()) + expect(screen.getByRole('menuitem', { name: 'app.export' })).toHaveAttribute( + 'aria-disabled', + 'true', ) - expect(container.querySelector('.i-ri-equalizer-2-line')).toBeInTheDocument() - rerender() - expect(container.querySelector('.i-ri-equalizer-2-line')).not.toBeInTheDocument() + const readyProps = createProps() + rerender() + await user.click(screen.getByRole('menuitem', { name: 'app.export' })) + + expect(readyProps.exportCheck).toHaveBeenCalledTimes(1) }) - it('should center the icon wrapper when collapsed', () => { - render() - const iconWrapper = screen.getByTestId('app-icon').parentElement - expect(iconWrapper?.parentElement).toHaveClass('items-center') + it('hides the operations trigger when no operation is permitted', () => { + mockWorkspacePermissionKeys.value = [] + render( + , + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 2f6f6f8080b..31c9bfbc679 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -136,39 +136,12 @@ describe('useAppInfoActions', () => { it('should return initial state correctly', () => { const { result } = renderHook(() => useAppInfoActions({})) expect(result.current.appDetail).toEqual(mockAppDetail) - expect(result.current.panelOpen).toBe(false) expect(result.current.activeModal).toBeNull() expect(result.current.secretEnvList).toEqual([]) }) }) - describe('Panel management', () => { - it('should toggle panelOpen', () => { - const { result } = renderHook(() => useAppInfoActions({})) - - act(() => { - result.current.setPanelOpen(true) - }) - - expect(result.current.panelOpen).toBe(true) - }) - - it('should close panel and call onDetailExpand', () => { - const onDetailExpand = vi.fn() - const { result } = renderHook(() => useAppInfoActions({ onDetailExpand })) - - act(() => { - result.current.setPanelOpen(true) - }) - - act(() => { - result.current.closePanel() - }) - - expect(result.current.panelOpen).toBe(false) - expect(onDetailExpand).toHaveBeenCalledWith(false) - }) - + describe('App-scoped state', () => { it('should reset app-scoped state when resetKey changes', () => { const { result, rerender } = renderHook(({ resetKey }) => useAppInfoActions({ resetKey }), { initialProps: { resetKey: 'app-1' }, @@ -176,34 +149,26 @@ describe('useAppInfoActions', () => { act(() => { result.current.openModal('delete') - result.current.setPanelOpen(true) }) - expect(result.current.panelOpen).toBe(true) expect(result.current.activeModal).toBe('delete') rerender({ resetKey: 'app-2' }) - expect(result.current.panelOpen).toBe(false) expect(result.current.activeModal).toBeNull() expect(result.current.secretEnvList).toEqual([]) }) }) describe('Modal management', () => { - it('should open modal and close panel', () => { + it('should open modal', () => { const { result } = renderHook(() => useAppInfoActions({})) - act(() => { - result.current.setPanelOpen(true) - }) - act(() => { result.current.openModal('edit') }) expect(result.current.activeModal).toBe('edit') - expect(result.current.panelOpen).toBe(false) }) it('should close modal', () => { diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx deleted file mode 100644 index 4fbea0071cc..00000000000 --- a/web/app/components/app-sidebar/app-info/app-info-detail-drawer.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import type { ReactNode } from 'react' -import { - Drawer, - DrawerBackdrop, - DrawerContent, - DrawerPopup, - DrawerPortal, - DrawerViewport, -} from '@langgenius/dify-ui/drawer' - -type AppInfoDetailDrawerProps = { - open: boolean - onClose: () => void - children: ReactNode -} - -export function AppInfoDetailDrawer({ open, onClose, children }: AppInfoDetailDrawerProps) { - return ( - { - if (!nextOpen) onClose() - }} - > - - - - - - {children} - - - - - - ) -} diff --git a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx b/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx deleted file mode 100644 index 800dde6ee75..00000000000 --- a/web/app/components/app-sidebar/app-info/app-info-detail-panel.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import type { Operation } from './app-operations' -import type { AppInfoModalType } from './use-app-info-actions' -import type { App, AppSSO } from '@/types/app' -import { Button } from '@langgenius/dify-ui/button' -import { - RiDeleteBinLine, - RiEditLine, - RiExchange2Line, - RiFileCopy2Line, - RiFileDownloadLine, - RiFileUploadLine, -} from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import * as React from 'react' -import { useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { AppModeEnum } from '@/types/app' -import { getAppACLCapabilities, hasPermission } from '@/utils/permission' -import AppIcon from '../../base/app-icon' -import { AppInfoDetailDrawer } from './app-info-detail-drawer' -import { getAppModeLabel } from './app-mode-labels' -import AppOperations from './app-operations' - -type AppInfoDetailPanelProps = { - appDetail: App & Partial - show: boolean - onClose: () => void - openModal: (modal: Exclude) => void - isExporting: boolean - exportCheck: () => void -} - -const AppInfoDetailPanel = ({ - appDetail, - show, - onClose, - openModal, - isExporting, - exportCheck, -}: AppInfoDetailPanelProps) => { - const { t } = useTranslation() - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = useMemo( - () => - getAppACLCapabilities(appDetail.permission_keys, { - currentUserId, - resourceMaintainer: appDetail.maintainer, - workspacePermissionKeys, - }), - [appDetail.maintainer, appDetail.permission_keys, currentUserId, workspacePermissionKeys], - ) - const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') - - const primaryOperations = useMemo( - () => [ - ...(appACLCapabilities.canEdit - ? [ - { - id: 'edit', - title: t(($) => $.editApp, { ns: 'app' }), - icon: , - onClick: () => openModal('edit'), - }, - ] - : []), - ...(canCreateApp - ? [ - { - id: 'duplicate', - title: t(($) => $.duplicate, { ns: 'app' }), - icon: , - onClick: () => openModal('duplicate'), - }, - ] - : []), - ...(appACLCapabilities.canImportExportDSL - ? [ - { - id: 'export', - title: t(($) => $.export, { ns: 'app' }), - icon: , - onClick: exportCheck, - loading: isExporting, - }, - ] - : []), - ], - [appACLCapabilities, canCreateApp, t, openModal, exportCheck, isExporting], - ) - - const secondaryOperations = useMemo( - () => [ - ...(appACLCapabilities.canImportExportDSL && - (appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) - ? [ - { - id: 'import', - title: t(($) => $['common.importDSL'], { ns: 'workflow' }), - icon: , - onClick: () => openModal('importDSL'), - }, - ] - : []), - ...(appACLCapabilities.canDelete - ? [ - { - id: 'divider-1', - title: '', - icon: <>, - onClick: () => {}, - type: 'divider' as const, - }, - { - id: 'delete', - title: t(($) => $['operation.delete'], { ns: 'common' }), - icon: , - onClick: () => openModal('delete'), - }, - ] - : []), - ], - [appACLCapabilities, appDetail.mode, t, openModal], - ) - - const switchOperation = useMemo(() => { - if (!appACLCapabilities.canEdit) return null - if (appDetail.mode !== AppModeEnum.COMPLETION && appDetail.mode !== AppModeEnum.CHAT) - return null - return { - id: 'switch', - title: t(($) => $.switch, { ns: 'app' }), - icon: , - onClick: () => openModal('switch'), - } - }, [appACLCapabilities.canEdit, appDetail.mode, t, openModal]) - - return ( - -
-
- -
-

- {appDetail.name} -

-
- {getAppModeLabel(appDetail.mode, t)} -
-
-
- {appDetail.description && ( -

- {appDetail.description} -

- )} - -
- - {switchOperation && ( -
- -
- )} -
- ) -} - -export default React.memo(AppInfoDetailPanel) diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx index 136d36d6061..94d28a856be 100644 --- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx @@ -12,10 +12,10 @@ import { AlertDialogDescription, AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' +import { Input } from '@langgenius/dify-ui/input' import * as React from 'react' import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' import { DSLExportConfirmContent } from '@/app/components/workflow/dsl-export-confirm-modal' import dynamic from '@/next/dynamic' diff --git a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx index a6829b951e2..d2c883358b7 100644 --- a/web/app/components/app-sidebar/app-info/app-info-trigger.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-trigger.tsx @@ -1,63 +1,157 @@ +import type { Operation } from './app-operations' +import type { AppInfoModalType } from './use-app-info-actions' import type { App, AppSSO } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { AppModeEnum } from '@/types/app' +import { getAppACLCapabilities, hasPermission } from '@/utils/permission' import AppIcon from '../../base/app-icon' import { getAppModeLabel } from './app-mode-labels' +import AppOperations from './app-operations' type AppInfoTriggerProps = { appDetail: App & Partial expand: boolean - onClick: () => void + openModal: (modal: Exclude) => void + isExporting: boolean + exportCheck: () => void } -const AppInfoTrigger = ({ appDetail, expand, onClick }: AppInfoTriggerProps) => { +const AppInfoTrigger = ({ + appDetail, + expand, + openModal, + isExporting, + exportCheck, +}: AppInfoTriggerProps) => { const { t } = useTranslation() + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const modeLabel = getAppModeLabel(appDetail.mode, t) + const appACLCapabilities = getAppACLCapabilities(appDetail.permission_keys, { + currentUserId, + resourceMaintainer: appDetail.maintainer, + workspacePermissionKeys, + }) + const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') + + const mainOperations: Operation[] = [ + ...(appACLCapabilities.canEdit + ? [ + { + id: 'edit', + title: t(($) => $.editApp, { ns: 'app' }), + icon: 'i-ri-edit-line', + onClick: () => openModal('edit'), + }, + ] + : []), + ...(canCreateApp + ? [ + { + id: 'duplicate', + title: t(($) => $.duplicate, { ns: 'app' }), + icon: 'i-ri-file-copy-2-line', + onClick: () => openModal('duplicate'), + }, + ] + : []), + ...(appACLCapabilities.canImportExportDSL + ? [ + { + id: 'export', + title: t(($) => $.export, { ns: 'app' }), + icon: 'i-ri-file-download-line', + onClick: exportCheck, + loading: isExporting, + }, + ] + : []), + ...(appACLCapabilities.canImportExportDSL && + (appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) + ? [ + { + id: 'import', + title: t(($) => $['common.importDSL'], { ns: 'workflow' }), + icon: 'i-ri-file-upload-line', + onClick: () => openModal('importDSL'), + }, + ] + : []), + ] + + const destructiveOperations: Operation[] = appACLCapabilities.canDelete + ? [ + { + id: 'delete', + title: t(($) => $['operation.delete'], { ns: 'common' }), + icon: 'i-ri-delete-bin-line', + onClick: () => openModal('delete'), + variant: 'destructive', + }, + ] + : [] + + const workflowConversionOperations: Operation[] = + appACLCapabilities.canEdit && + (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT) + ? [ + { + id: 'switch', + title: t(($) => $.switch, { ns: 'app' }), + icon: 'i-ri-exchange-2-line', + onClick: () => openModal('switch'), + }, + ] + : [] return ( - + )} +
) } diff --git a/web/app/components/app-sidebar/app-info/app-operations.tsx b/web/app/components/app-sidebar/app-info/app-operations.tsx index 0c84f1ae698..b99b1fc38ba 100644 --- a/web/app/components/app-sidebar/app-info/app-operations.tsx +++ b/web/app/components/app-sidebar/app-info/app-operations.tsx @@ -1,218 +1,92 @@ -import type { JSX } from 'react' -import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { RiMoreLine } from '@remixicon/react' -import { cloneElement, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment } from 'react' import { useTranslation } from 'react-i18next' export type Operation = { id: string title: string - icon: JSX.Element + icon: string onClick: () => void disabled?: boolean loading?: boolean - type?: 'divider' + variant?: 'default' | 'destructive' } type AppOperationsProps = { - gap: number - operations?: Operation[] - primaryOperations?: Operation[] - secondaryOperations?: Operation[] + appName: string + operationGroups: Operation[][] } -const EMPTY_OPERATIONS: Operation[] = [] - -const AppOperations = ({ - operations, - primaryOperations, - secondaryOperations, - gap, -}: AppOperationsProps) => { +const AppOperations = ({ appName, operationGroups }: AppOperationsProps) => { const { t } = useTranslation() - const [visibleOpreations, setVisibleOperations] = useState([]) - const [moreOperations, setMoreOperations] = useState([]) - const [showMore, setShowMore] = useState(false) - const navRef = useRef(null) + const visibleGroups = operationGroups.filter((group) => group.length > 0) - const primaryOps = useMemo(() => { - if (operations) return operations - if (primaryOperations) return primaryOperations - return EMPTY_OPERATIONS - }, [operations, primaryOperations]) - - const secondaryOps = useMemo(() => { - if (operations) return EMPTY_OPERATIONS - if (secondaryOperations) return secondaryOperations - return EMPTY_OPERATIONS - }, [operations, secondaryOperations]) - const inlineOperations = primaryOps.filter((operation) => operation.type !== 'divider') - - useEffect(() => { - const applyState = (visible: Operation[], overflow: Operation[]) => { - const combinedMore = [...overflow, ...secondaryOps] - if (!overflow.length && combinedMore[0]?.type === 'divider') combinedMore.shift() - setVisibleOperations(visible) - setMoreOperations(combinedMore) - } - - const inline = primaryOps.filter((operation) => operation.type !== 'divider') - - if (!inline.length) { - applyState([], []) - return - } - - const navElement = navRef.current - const moreElement = document.getElementById('more-measure') - - if (!navElement || !moreElement) return - - let width = 0 - const containerWidth = navElement.clientWidth - const moreWidth = moreElement.clientWidth - - if (containerWidth === 0 || moreWidth === 0) return - - const updatedEntries: Record = inline.reduce( - (pre, cur) => { - pre[cur.id] = false - return pre - }, - {} as Record, - ) - const childrens = Array.from(navElement.children).slice(0, -1) - for (let i = 0; i < childrens.length; i++) { - const child = childrens[i] as HTMLElement - const id = child.dataset.targetid - if (!id) break - const childWidth = child.clientWidth - - if (width + gap + childWidth + moreWidth <= containerWidth) { - updatedEntries[id] = true - width += gap + childWidth - } else { - if (i === childrens.length - 1 && width + childWidth <= containerWidth) - updatedEntries[id] = true - else updatedEntries[id] = false - break - } - } - - const visible = inline.filter((item) => updatedEntries[item.id]) - const overflow = inline.filter((item) => !updatedEntries[item.id]) - - applyState(visible, overflow) - }, [gap, primaryOps, secondaryOps]) - - const shouldShowMoreButton = moreOperations.length > 0 + if (!visibleGroups.length) return null return ( - <> - -
- {visibleOpreations.map((operation) => ( - - ))} - {shouldShowMoreButton && ( - - - } - > - <> - - - {t(($) => $['operation.more'], { ns: 'common' })} - - - - - {moreOperations.map((item) => - item.type === 'divider' ? ( - - ) : ( - + + + {visibleGroups.map((group, groupIndex) => ( + operation.id).join('-')}> + {groupIndex > 0 && } + + {group.map((operation) => ( + + {operation.loading ? ( + + ) : ( + + )} + - {cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })} - {item.title} - - ), - )} - - - )} -
- + {operation.title} + + + ))} + + + ))} + + ) } diff --git a/web/app/components/app-sidebar/app-info/index.tsx b/web/app/components/app-sidebar/app-info/index.tsx index bd860dc3f41..695c2560504 100644 --- a/web/app/components/app-sidebar/app-info/index.tsx +++ b/web/app/components/app-sidebar/app-info/index.tsx @@ -1,35 +1,15 @@ import type { AppInfoActions } from './use-app-info-actions' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import * as React from 'react' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { getAppACLCapabilities } from '@/utils/permission' -import AppInfoDetailPanel from './app-info-detail-panel' import AppInfoModals from './app-info-modals' import AppInfoTrigger from './app-info-trigger' -type IAppInfoProps = { +type AppInfoViewProps = { expand: boolean - onlyShowDetail?: boolean - openState?: boolean - onDetailExpand?: (expand: boolean) => void -} - -type AppInfoViewProps = Omit & { actions: AppInfoActions - renderDetail?: boolean } -type AppInfoDetailLayerProps = { - actions: AppInfoActions - open?: boolean -} - -const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetailLayerProps) => { +export const AppInfoView = ({ expand, actions }: AppInfoViewProps) => { const { appDetail, - closePanel, activeModal, openModal, closeModal, @@ -48,10 +28,9 @@ const AppInfoDetailLayer = ({ actions, open = actions.panelOpen }: AppInfoDetail return ( <> - ) } - -export const AppInfoView = ({ - expand, - onlyShowDetail = false, - openState = false, - actions, - renderDetail = true, -}: AppInfoViewProps) => { - const { appDetail, panelOpen, setPanelOpen, activeModal, secretEnvList } = actions - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }) - - if (!appDetail) return null - - const detailLayerOpen = onlyShowDetail ? openState : panelOpen - const shouldRenderDetailLayer = - renderDetail && (detailLayerOpen || activeModal || secretEnvList.length > 0) - - return ( -
- {!onlyShowDetail && ( - { - if (appACLCapabilities.canAccessLayout) setPanelOpen((v) => !v) - }} - /> - )} - {shouldRenderDetailLayer && } -
- ) -} diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index b234cec182d..b1c0ee9f9e9 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -30,13 +30,11 @@ export type AppInfoModalType = | null type UseAppInfoActionsParams = { - onDetailExpand?: (expand: boolean) => void resetKey?: string } type AppInfoUiState = { resetKey?: string - panelOpen: boolean activeModal: AppInfoModalType secretEnvList: EnvironmentVariableItemResponse[] } @@ -75,7 +73,6 @@ const updateCachedAppMetadata = (cachedApp: AppDetailWithSite | undefined, app: const createInitialUiState = (resetKey?: string): AppInfoUiState => ({ resetKey, - panelOpen: false, activeModal: null, secretEnvList: [], }) @@ -88,7 +85,7 @@ const getCurrentUiState = (state: AppInfoUiState, resetKey?: string) => { return state.resetKey === resetKey ? state : createInitialUiState(resetKey) } -export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoActionsParams) { +export function useAppInfoActions({ resetKey }: UseAppInfoActionsParams) { const { t } = useTranslation() const { replace } = useRouter() const queryClient = useQueryClient() @@ -103,23 +100,9 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction const [uiState, setUiState] = useState(() => createInitialUiState(resetKey)) const uiStateMatchesResetKey = uiState.resetKey === resetKey - const panelOpen = uiStateMatchesResetKey ? uiState.panelOpen : false const activeModal = uiStateMatchesResetKey ? uiState.activeModal : null const secretEnvList = uiStateMatchesResetKey ? uiState.secretEnvList : emptySecretEnvList - const setPanelOpen = useCallback>>( - (value) => { - setUiState((state) => { - const current = getCurrentUiState(state, resetKey) - return { - ...current, - panelOpen: resolveStateAction(value, current.panelOpen), - } - }) - }, - [resetKey], - ) - const setActiveModal = useCallback>>( (value) => { setUiState((state) => { @@ -146,17 +129,11 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction [resetKey], ) - const closePanel = useCallback(() => { - setPanelOpen(false) - onDetailExpand?.(false) - }, [onDetailExpand, setPanelOpen]) - const openModal = useCallback( (modal: Exclude) => { - closePanel() setActiveModal(modal) }, - [closePanel, setActiveModal], + [setActiveModal], ) const closeModal = useCallback(() => { @@ -352,9 +329,6 @@ export function useAppInfoActions({ onDetailExpand, resetKey }: UseAppInfoAction return { appDetail, - panelOpen, - setPanelOpen, - closePanel, activeModal, openModal, closeModal, diff --git a/web/app/components/app-sidebar/basic.tsx b/web/app/components/app-sidebar/basic.tsx deleted file mode 100644 index 503b1b3afb0..00000000000 --- a/web/app/components/app-sidebar/basic.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import { ApiAggregate, WindowCursor } from '@/app/components/base/icons/src/vender/workflow' -import { Infotip } from '@/app/components/base/infotip' -import AppIcon from '../base/app-icon' - -type IAppBasicProps = { - iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion' - icon?: string - icon_background?: string | null - isExternal?: boolean - name: string - type: string | React.ReactNode - hoverTip?: string - textStyle?: { main?: string; extra?: string } - isExtraInLine?: boolean - mode?: string - hideType?: boolean -} - -const DatasetSvg = ( - - - -) - -const NotionSvg = ( - - - - - - - - - - - - -) - -const ICON_MAP = { - app: , - api: ( -
- -
- ), - dataset: ( - - ), - webapp: ( -
- -
- ), - notion: ( - - ), -} - -export default function AppBasic({ - icon, - icon_background, - name, - isExternal, - type, - hoverTip, - textStyle, - isExtraInLine, - mode = 'expand', - iconType = 'app', - hideType, -}: IAppBasicProps) { - const { t } = useTranslation() - - return ( -
- {icon && icon_background && iconType === 'app' && ( -
- -
- )} - {iconType !== 'app' &&
{ICON_MAP[iconType]}
} - {mode === 'expand' && ( -
-
-
{name}
- {hoverTip && ( - - {hoverTip} - - )} -
- {!hideType && isExtraInLine && ( -
{type}
- )} - {!hideType && !isExtraInLine && ( -
- {isExternal ? t(($) => $.externalTag, { ns: 'dataset' }) : type} -
- )} -
- )} -
- ) -} diff --git a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx new file mode 100644 index 00000000000..5f740babef4 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx @@ -0,0 +1,49 @@ +import type { AccessPointStatus } from '../shared/access-point-status' +import { screen } from '@testing-library/react' +import { render } from '@/test/console/render' +import { AccessPointCard } from '../shared/access-point-card' + +describe('AccessPointCard', () => { + it('marks the card when it is the highlighted access point', () => { + render( + + Access URL + , + ) + + expect(screen.getByRole('region', { name: 'Web App' })).toHaveAttribute( + 'data-highlighted', + 'true', + ) + }) + + it.each<[AccessPointStatus, string, boolean]>([ + ['loading', 'common.loading', true], + ['unsupported', 'deployments.studio.accessPoint.notSupported', false], + ['unavailable', 'deployments.health.ENVIRONMENT_STATUS_FAILED', false], + ])('renders the %s state independently', (status, label, busy) => { + render( + + Access URL + , + ) + + expect(screen.getByText(label)).toBeInTheDocument() + const card = screen.getByRole('region', { name: 'Web App' }) + if (busy) expect(card).toHaveAttribute('aria-busy', 'true') + else expect(card).not.toHaveAttribute('aria-busy') + expect(screen.queryByRole('switch')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx b/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx new file mode 100644 index 00000000000..90b6f2e8558 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx @@ -0,0 +1,33 @@ +import { screen } from '@testing-library/react' +import { render } from '@/test/console/render' +import { AccessPointUrl } from '../shared/access-point-url' + +const endpointProps = { + label: 'Access URL', + unavailableLabel: 'FAILED', + value: 'https://example.test/access', +} + +describe('AccessPointUrl', () => { + it('keeps a disabled endpoint visible without marking it unavailable', () => { + render() + + expect(screen.getByText(endpointProps.value)).toBeInTheDocument() + expect(screen.queryByText(endpointProps.unavailableLabel)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled() + }) + + it('shows an unavailable endpoint without replacing it with a loading skeleton', () => { + render() + + expect(screen.getByText(endpointProps.unavailableLabel)).toBeInTheDocument() + expect(screen.getByText(endpointProps.value)).toBeInTheDocument() + }) + + it('shows loading independently from the unavailable state', () => { + render() + + expect(screen.queryByText(endpointProps.unavailableLabel)).not.toBeInTheDocument() + expect(screen.queryByText(endpointProps.value)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/api-secret-key-button.spec.tsx b/web/app/components/app/access-point/__tests__/api-secret-key-button.spec.tsx new file mode 100644 index 00000000000..d9aa1447b81 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/api-secret-key-button.spec.tsx @@ -0,0 +1,98 @@ +import type { ApiKeyList } from '@dify/contracts/api/console/apps/types.gen' +import type { ReactElement } from 'react' +import type { SecretKeyScope } from '@/app/components/develop/secret-key/secret-key-modal' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { consoleQuery } from '@/service/client' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { ApiSecretKeyButton } from '../shared/api-secret-key-button' + +const appApiKeys: ApiKeyList = { + data: [ + { id: 'key-1', token: 'app-a', type: 'app', created_at: 1, last_used_at: 1 }, + { id: 'key-2', token: 'app-b', type: 'app', created_at: 2, last_used_at: 2 }, + ], +} + +const render = (ui: ReactElement) => { + const queryClient = createConsoleQueryClient() + queryClient.setQueryData( + consoleQuery.apps.byResourceId.apiKeys.get.queryKey({ + input: { params: { resource_id: 'app-1' } }, + }), + appApiKeys, + ) + return renderWithConsoleQuery(ui, { queryClient }) +} + +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ + canManage, + isShow, + scope, + }: { + canManage: boolean + isShow: boolean + scope: SecretKeyScope + }) => + isShow ? ( +
+ {scope.type === 'dataset' ? '' : scope.appId}: + {scope.type === 'environment' ? scope.environmentId : ''}:{String(canManage)} +
+ ) : null, +})) + +describe('ApiSecretKeyButton', () => { + it('shows the current API key count and opens key management', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { + name: 'appApi.apiKeyModal.apiSecretKey 2', + }) + expect(button).toBeEnabled() + + await user.click(button) + + expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent( + 'app-1::true', + ) + }) + + it('uses the environment API key count and opens environment-scoped key management', async () => { + const user = userEvent.setup() + render() + + const button = screen.getByRole('button', { + name: 'appApi.apiKeyModal.apiSecretKey 5', + }) + expect(button).toBeEnabled() + + await user.click(button) + + expect(screen.getByRole('dialog', { name: 'API key management' })).toHaveTextContent( + 'app-1:staging:true', + ) + }) + + it('keeps the current count visible when service access is disabled', () => { + render() + + expect( + screen.getByRole('button', { + name: 'appApi.apiKeyModal.apiSecretKey 2', + }), + ).toBeDisabled() + }) + + it('keeps the current count visible without management permission', () => { + render() + + expect( + screen.getByRole('button', { + name: 'appApi.apiKeyModal.apiSecretKey 2', + }), + ).toBeDisabled() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx new file mode 100644 index 00000000000..ebed47ed6e0 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -0,0 +1,221 @@ +import { screen } from '@testing-library/react' +import { render } from '@/test/console/render' +import { BuiltInAccessPoints } from '../built-in-access-points' + +const mocks = vi.hoisted(() => ({ + appInfo: { + id: 'app-1', + mode: 'workflow', + enable_site: false, + enable_api: false, + permission_keys: [], + } as Record, + workflow: { + data: null as Record | null, + isPending: false, + }, + webCard: vi.fn(), + apiCard: vi.fn(), + mcpCard: vi.fn(), + triggerCard: vi.fn(), +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock() +}) + +vi.mock('@tanstack/react-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSuspenseQuery: () => ({ + data: { + webapp_auth: { enabled: true }, + }, + }), + } +}) + +vi.mock('jotai', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useAtomValue: () => undefined, + } +}) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => + selector({ appDetail: mocks.appInfo }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => path, +})) + +vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: () => mocks.workflow, +})) + +vi.mock('@/utils/permission', () => ({ + getAppACLCapabilities: () => ({ + canEdit: false, + canDeploy: true, + canReleaseAndVersion: false, + }), +})) + +vi.mock('../shared/use-access-point-actions', () => ({ + useAccessPointActions: () => ({ + changeApiStatus: vi.fn(), + changeSiteStatus: vi.fn(), + handleResult: vi.fn(), + refreshAppDetail: vi.fn(), + regenerateSiteCode: vi.fn(), + saveSiteConfig: vi.fn(), + }), +})) + +vi.mock('../built-in-access-points/web-app-card', () => ({ + WebAppAccessPointCard: (props: Record) => { + mocks.webCard(props) + return
+ }, +})) + +vi.mock('../built-in-access-points/service-api-card', () => ({ + ServiceApiAccessPointCard: (props: Record) => { + mocks.apiCard(props) + return
+ }, +})) + +vi.mock('../built-in-access-points/mcp-card', () => ({ + MCPAccessPointCard: (props: Record) => { + mocks.mcpCard(props) + return
+ }, +})) + +vi.mock('../built-in-access-points/trigger-card', () => ({ + TriggerAccessPointCard: (props: Record) => { + mocks.triggerCard(props) + return
+ }, +})) + +describe('BuiltInAccessPoints', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.appInfo = { + id: 'app-1', + mode: 'workflow', + enable_site: false, + enable_api: false, + permission_keys: [], + } + mocks.workflow = { + data: null, + isPending: false, + } + }) + + it('renders the unpublished state across all access point cards', () => { + render() + + expect(screen.getByText('deployments.studio.accessPoint.noPublishedTitle')).toBeInTheDocument() + expect(screen.getByTestId('web-app-card')).toBeInTheDocument() + expect(screen.getByTestId('service-api-card')).toBeInTheDocument() + expect(screen.getByTestId('mcp-card')).toBeInTheDocument() + expect(screen.getByTestId('trigger-card')).toBeInTheDocument() + expect(mocks.webCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }), + ) + expect(mocks.apiCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable', canEdit: false }), + ) + expect(mocks.triggerCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable', canEdit: false }), + ) + }) + + it('keeps Trigger unavailable when no trigger node is published', () => { + mocks.workflow = { + data: { + graph: { + nodes: [{ data: { type: 'start' } }], + }, + }, + isPending: false, + } + + render() + + expect( + screen.queryByText('deployments.studio.accessPoint.noPublishedTitle'), + ).not.toBeInTheDocument() + expect(mocks.webCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'available', workflow: mocks.workflow.data }), + ) + expect(mocks.apiCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'available' }), + ) + expect(mocks.triggerCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable' }), + ) + }) + + it('highlights only the targeted built-in access point card', () => { + render() + + expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false })) + expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false })) + expect(mocks.mcpCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: true })) + expect(mocks.triggerCard).toHaveBeenCalledWith(expect.objectContaining({ highlighted: false })) + }) + + it('enables Trigger and disables the other access points in trigger mode', () => { + mocks.workflow = { + data: { + graph: { + nodes: [{ data: { type: 'trigger-webhook' } }], + }, + }, + isPending: false, + } + + render() + + expect(mocks.webCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable' }), + ) + expect(mocks.apiCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'unavailable' }), + ) + expect(mocks.mcpCard).toHaveBeenCalledWith( + expect.objectContaining({ triggerModeDisabled: true }), + ) + expect(mocks.triggerCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'available' }), + ) + expect( + screen.getByText('deployments.studio.accessPoint.triggerExclusiveNotice'), + ).toBeInTheDocument() + }) + + it('keeps all cards visible while the published workflow is loading', () => { + mocks.workflow = { + data: null, + isPending: true, + } + + render() + + expect(mocks.webCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) + expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ availability: 'loading' })) + expect(mocks.triggerCard).toHaveBeenCalledWith( + expect.objectContaining({ availability: 'loading' }), + ) + }) +}) diff --git a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx new file mode 100644 index 00000000000..7c5db075a90 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx @@ -0,0 +1,79 @@ +import type { AccessPoint } from '@/app/components/app/deploy/access-point' +import { screen, within } from '@testing-library/react' +import { render } from '@/test/console/render' +import { DeployedEnvironmentAccessPoints } from '../deployed-environment-access-points' + +const mocks = vi.hoisted(() => ({ + serviceApiCard: vi.fn(), + webAppCard: vi.fn(), +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock() +}) + +vi.mock('../deployed-environment-access-points/environment-service-api-card', () => ({ + EnvironmentServiceApiCard: (props: Record) => { + mocks.serviceApiCard(props) + return
+ }, +})) + +vi.mock('../deployed-environment-access-points/environment-web-app-card', () => ({ + EnvironmentWebAppCard: (props: Record) => { + mocks.webAppCard(props) + return
+ }, +})) + +describe('DeployedEnvironmentAccessPoints', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each(['webApp', 'serviceApi'])( + 'highlights only the targeted %s card', + (highlightedAccessPoint) => { + render( + , + ) + + expect(mocks.webAppCard).toHaveBeenCalledWith( + expect.objectContaining({ highlighted: highlightedAccessPoint === 'webApp' }), + ) + expect(mocks.serviceApiCard).toHaveBeenCalledWith( + expect.objectContaining({ highlighted: highlightedAccessPoint === 'serviceApi' }), + ) + }, + ) + + it('renders MCP and Trigger as unsupported without a permanent loading state', () => { + render( + , + ) + + const mcpCard = screen.getByRole('region', { name: /mcp\.server\.title/ }) + const triggerCard = screen.getByRole('region', { name: /settings\.trigger/ }) + + for (const card of [mcpCard, triggerCard]) { + expect( + within(card).getByText('deployments.studio.accessPoint.notSupported'), + ).toBeInTheDocument() + expect( + within(card).getByText('deployments.studio.accessPoint.unsupportedInDeployedEnvironment'), + ).toBeInTheDocument() + expect( + within(card).queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + expect(card).not.toHaveAttribute('aria-busy') + expect(card.querySelector('[aria-busy="true"]')).not.toBeInTheDocument() + } + }) +}) diff --git a/web/app/components/app/access-point/__tests__/environment-access-control.spec.tsx b/web/app/components/app/access-point/__tests__/environment-access-control.spec.tsx new file mode 100644 index 00000000000..8c805d69980 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/environment-access-control.spec.tsx @@ -0,0 +1,205 @@ +import type { ReactElement } from 'react' +import { QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AccessMode } from '@/models/access-control' +import { render } from '@/test/console/render' +import { createTestQueryClient } from '@/test/query-client' +import { EnvironmentAccessControl } from '../deployed-environment-access-points/environment-access-control' + +const mocks = vi.hoisted(() => ({ + getSubjects: vi.fn(), + updateAccessMode: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + appDeploy: { + accessService: { + getEnvironmentSite: { + queryOptions: ({ + input, + }: { + input: { params: { app_id: string; environment_id: string } } + }) => ({ + queryKey: ['environment-site', input.params.app_id, input.params.environment_id], + }), + }, + getEnvironmentWebAppSubjects: { + queryOptions: ({ + input, + }: { + input: { params: { app_id: string; environment_id: string } } + }) => ({ + queryKey: ['environment-subjects', input.params.app_id, input.params.environment_id], + queryFn: () => mocks.getSubjects(input), + }), + }, + updateEnvironmentWebAppAccessMode: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.updateAccessMode, + ...options, + }), + }, + }, + }, + }, + }, +})) + +vi.mock('@/features/system-features/client', () => ({ + systemFeaturesQueryOptions: () => ({ + queryKey: ['system-features'], + queryFn: vi.fn(), + }), +})) + +vi.mock('@/service/access-control', () => ({ + useSearchForWhiteListCandidates: () => ({ + isLoading: false, + isFetchingNextPage: false, + fetchNextPage: vi.fn(), + data: { pages: [] }, + }), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +function renderAccessControl(ui: ReactElement) { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['system-features'], { + webapp_auth: { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, + allow_public_access: true, + }, + }) + + return render({ui}) +} + +describe('EnvironmentAccessControl', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSubjects.mockResolvedValue({ + subjects: [ + { + account_data: { + email: 'ada@example.com', + id: 'account-1', + name: 'Ada', + }, + subject_id: 'account-1', + subject_type: 'account', + }, + ], + }) + mocks.updateAccessMode.mockResolvedValue({ + access_mode: 'private', + enabled: true, + }) + }) + + it('should submit only subjects loaded from the environment endpoint', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + renderAccessControl( + , + ) + + expect(await screen.findByText('Ada')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mocks.updateAccessMode.mock.calls[0]?.[0]).toEqual({ + params: { + app_id: 'app-1', + environment_id: 'staging', + }, + body: { + access_mode: 'private', + subjects: [ + { + subject_id: 'account-1', + subject_type: 'account', + }, + ], + }, + }) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + }) + + it('should allow authenticated external users to access the environment Web app', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + renderAccessControl( + , + ) + + await user.click( + screen.getByRole('radio', { + name: 'app.accessControlDialog.accessItems.external', + }), + ) + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mocks.updateAccessMode.mock.calls[0]?.[0]).toEqual({ + params: { + app_id: 'app-1', + environment_id: 'staging', + }, + body: { + access_mode: AccessMode.EXTERNAL_MEMBERS, + }, + }) + expect(onConfirm).toHaveBeenCalledTimes(1) + }) + }) + + it('should keep confirmation disabled when the environment subjects query fails', async () => { + const user = userEvent.setup() + mocks.getSubjects.mockRejectedValue(new Error('subjects unavailable')) + + renderAccessControl( + , + ) + + expect(await screen.findByRole('alert')).toHaveTextContent('common.dynamicSelect.error') + const confirmButton = screen.getByRole('button', { name: 'common.operation.confirm' }) + expect(confirmButton).toBeDisabled() + + await user.click(confirmButton) + expect(mocks.updateAccessMode).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx new file mode 100644 index 00000000000..95f752897a2 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx @@ -0,0 +1,403 @@ +import type { ReactElement } from 'react' +import { QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { render } from '@/test/console/render' +import { createTestQueryClient } from '@/test/query-client' +import { EnvironmentServiceApiCard } from '../deployed-environment-access-points/environment-service-api-card' +import { EnvironmentWebAppCard } from '../deployed-environment-access-points/environment-web-app-card' + +const mocks = vi.hoisted(() => ({ + getApi: vi.fn(), + getSite: vi.fn(), + getSubjects: vi.fn(), + resetSite: vi.fn(), + updateApi: vi.fn(), + updateSite: vi.fn(), + environmentAccessControlProps: vi.fn(), + apiKeyButtonProps: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + appDeploy: { + accessService: { + getEnvironmentApi: { + queryOptions: ({ + input, + }: { + input: { params: { app_id: string; environment_id: string } } + }) => ({ + queryKey: ['environment-api', input.params.app_id, input.params.environment_id], + queryFn: () => mocks.getApi(input), + }), + }, + getEnvironmentSite: { + queryOptions: ({ + input, + }: { + input: { params: { app_id: string; environment_id: string } } + }) => ({ + queryKey: ['environment-site', input.params.app_id, input.params.environment_id], + queryFn: () => mocks.getSite(input), + }), + }, + getEnvironmentWebAppSubjects: { + queryOptions: ({ + input, + }: { + input: { params: { app_id: string; environment_id: string } } + }) => ({ + queryKey: ['environment-subjects', input.params.app_id, input.params.environment_id], + queryFn: () => mocks.getSubjects(input), + }), + }, + resetEnvironmentSiteAccessToken: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.resetSite, + ...options, + }), + }, + updateEnvironmentApi: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.updateApi, + ...options, + }), + }, + updateEnvironmentSite: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.updateSite, + ...options, + }), + }, + }, + }, + }, + }, +})) + +vi.mock('@/features/system-features/client', () => ({ + systemFeaturesQueryOptions: () => ({ + queryKey: ['system-features'], + queryFn: vi.fn(), + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.test/en${path}`, +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => + selector({ + appDetail: { + id: 'app-1', + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + icon_url: null, + mode: 'workflow', + site: { + access_token: 'built-in-code', + app_base_url: 'https://built-in.example.test', + }, + }, + }), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/app/access-point/shared/use-access-point-actions', () => ({ + useAccessPointActions: () => ({ + saveSiteConfig: vi.fn(), + }), +})) + +vi.mock('@/app/components/app/overview/customize', () => ({ + default: ({ api_base_url, isShow }: { api_base_url: string; isShow: boolean }) => + isShow ? ( +
+ {api_base_url} +
+ ) : null, +})) + +vi.mock('@/app/components/app/overview/settings', () => ({ + default: ({ isShow }: { isShow: boolean }) => + isShow ?
: null, +})) + +vi.mock('../deployed-environment-access-points/environment-access-control', () => ({ + EnvironmentAccessControl: (props: { + appId: string + environmentId: string + accessMode: string + canManage: boolean + }) => { + mocks.environmentAccessControlProps(props) + return
+ }, +})) + +vi.mock('@/app/components/app/access-point/shared/api-secret-key-button', () => ({ + ApiSecretKeyButton: (props: { + apiKeyCount?: number + appId: string + canManage: boolean + disabled?: boolean + environmentId?: string + }) => { + mocks.apiKeyButtonProps(props) + return ( + + ) + }, +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +const environmentParams = { + app_id: 'app-1', + environment_id: 'staging', +} + +const site = { + access_mode: 'private', + app_base_url: 'https://site.example.test', + code: 'site-code', + enabled: true, +} + +const api = { + api_key_count: 3, + base_url: 'https://api.example.test/v1', + enabled: true, +} + +function renderCard(ui: ReactElement) { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['system-features'], { + webapp_auth: { + enabled: true, + }, + }) + + return render({ui}) +} + +describe('environment access point cards', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getApi.mockResolvedValue(api) + mocks.getSite.mockResolvedValue(site) + mocks.getSubjects.mockResolvedValue({ + subjects: [ + { + account_data: { + email: 'ada@example.com', + id: 'account-1', + name: 'Ada', + }, + subject_id: 'account-1', + subject_type: 'account', + }, + ], + }) + mocks.resetSite.mockResolvedValue({ + ...site, + code: 'regenerated-code', + }) + mocks.updateApi.mockResolvedValue({ + ...api, + enabled: false, + }) + mocks.updateSite.mockResolvedValue({ + ...site, + enabled: false, + }) + }) + + it('renders the real environment Web app URL and workflow actions without Embed', async () => { + renderCard() + + expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent( + 'https://site.example.test/env/workflow/site-code', + ) + expect( + await screen.findByRole('button', { + name: /accessControlDialog\.accessItems\.specific/, + }), + ).toBeEnabled() + expect(screen.queryByRole('button', { name: /embedIntoSite/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeEnabled() + expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeEnabled() + }) + + it('renders authenticated external users as the environment Web app access mode', async () => { + mocks.getSite.mockResolvedValue({ + ...site, + access_mode: 'sso_verified', + }) + + renderCard() + + expect( + await screen.findByRole('button', { + name: /accessControlDialog\.accessItems\.external/, + }), + ).toBeEnabled() + }) + + it('shows the environment Web app query as loading instead of failed', () => { + mocks.getSite.mockImplementation(() => new Promise(() => {})) + + renderCard() + + const card = screen.getByRole('region', { name: /webApp\.title/ }) + expect(card).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('common.loading')).toBeInTheDocument() + expect( + screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + }) + + it('uses environment Site mutations for status and URL reset, and opens its access container', async () => { + const user = userEvent.setup() + renderCard() + + const accessModeButton = await screen.findByRole('button', { + name: /accessControlDialog\.accessItems\.specific/, + }) + await user.click(accessModeButton) + await waitFor(() => { + expect(mocks.environmentAccessControlProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + appId: 'app-1', + environmentId: 'staging', + accessMode: 'private', + canManage: true, + }), + ) + }) + + await user.click(screen.getByRole('button', { name: /regenerate/ })) + await user.click(screen.getByRole('button', { name: /operation\.confirm/ })) + + await waitFor(() => { + expect(mocks.resetSite.mock.calls[0]?.[0]).toEqual({ + params: environmentParams, + }) + }) + + await user.click(screen.getByRole('switch')) + + await waitFor(() => { + expect(mocks.updateSite.mock.calls[0]?.[0]).toEqual({ + body: { + enabled: false, + }, + params: environmentParams, + }) + }) + }) + + it('opens Customize and Settings with environment endpoint data', async () => { + const user = userEvent.setup() + renderCard() + + await screen.findByText(/env\/workflow\/site-code/) + await user.click(screen.getByRole('button', { name: /customize\.entry/ })) + expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent( + 'https://api.example.test/v1', + ) + + await user.click(screen.getByRole('button', { name: /settings\.settings/ })) + expect(screen.getByRole('dialog', { name: 'environment settings' })).toBeInTheDocument() + }) + + it('renders the real Service API endpoint, environment keys entry, docs entry, and API toggle', async () => { + const user = userEvent.setup() + renderCard() + + expect(await screen.findByText(api.base_url)).toBeInTheDocument() + expect(mocks.apiKeyButtonProps).toHaveBeenLastCalledWith( + expect.objectContaining({ + apiKeyCount: 3, + appId: 'app-1', + canManage: true, + environmentId: 'staging', + }), + ) + expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeInTheDocument() + const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ }) + expect(apiReferenceLink).toHaveAttribute( + 'href', + 'https://docs.example.test/en/api-reference/guides/workflow', + ) + expect(apiReferenceLink).toHaveAttribute('target', '_blank') + expect(apiReferenceLink).toHaveAttribute('rel', 'noopener noreferrer') + + await user.click(screen.getByRole('switch')) + + await waitFor(() => { + expect(mocks.updateApi.mock.calls[0]?.[0]).toEqual({ + body: { + enabled: false, + }, + params: environmentParams, + }) + }) + }) + + it('keeps environment API keys and external documentation available when the API is stopped', async () => { + mocks.getApi.mockResolvedValue({ + ...api, + enabled: false, + }) + + renderCard() + + await screen.findByText(api.base_url) + expect(await screen.findByRole('button', { name: 'environment-api-keys' })).toBeEnabled() + const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ }) + expect(apiReferenceLink).not.toHaveAttribute('aria-disabled') + expect(apiReferenceLink).toHaveAttribute( + 'href', + 'https://docs.example.test/en/api-reference/guides/workflow', + ) + }) + + it('distinguishes the Service API loading and failed query states', async () => { + mocks.getApi.mockRejectedValue(new Error('API unavailable')) + + renderCard() + + const card = screen.getByRole('region', { name: /serviceApi\.title/ }) + expect(card).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('common.loading')).toBeInTheDocument() + expect( + screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + + expect(await screen.findAllByText('deployments.health.ENVIRONMENT_STATUS_FAILED')).toHaveLength( + 2, + ) + expect(card).not.toHaveAttribute('aria-busy') + expect(screen.queryByText('common.loading')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'environment-api-keys' })).toBeDisabled() + expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) +}) diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx new file mode 100644 index 00000000000..c20935fcae8 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -0,0 +1,279 @@ +import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { ReactNode } from 'react' +import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point' +import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { consoleQuery } from '@/service/client' +import { seedAccountProfileQuery } from '@/test/console/account-profile' +import { QueryClientTestProvider } from '@/test/console/query-provider' +import { render } from '@/test/console/render' +import { createTestQueryClient } from '@/test/query-client' +import { AppACLPermission } from '@/utils/permission' +import AccessPoint from '..' + +let appMode = 'workflow' +let appPermissionKeys: string[] = [AppACLPermission.Deploy] +const mockConsoleState = vi.hoisted(() => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [] as string[], +})) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock({ + 'workflow.nodes.common.memories.builtIn': 'Built-in', + }) +}) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => + selector({ + appDetail: { + id: 'app-1', + mode: appMode, + maintainer: 'user-2', + permission_keys: appPermissionKeys, + }, + }), +})) + +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + return createPermissionStateModuleMock(() => mockConsoleState) +}) + +vi.mock('@/app/components/app/access-point/built-in-access-points', () => ({ + BuiltInAccessPoints: ({ + appId, + highlightedAccessPoint, + }: { + appId: string + highlightedAccessPoint?: AccessPointType + }) => ( +
+ {appId} +
+ ), +})) + +vi.mock('@/app/components/app/access-point/deployed-environment-access-points', () => ({ + DeployedEnvironmentAccessPoints: ({ + appId, + canEdit, + canManage, + environmentId, + highlightedAccessPoint, + }: { + appId: string + canEdit: boolean + canManage: boolean + environmentId: string + highlightedAccessPoint?: AccessPointType + }) => ( +
+ {environmentId} +
+ ), +})) + +const appEnvironments: AppEnvironment[] = [ + { + id: 'staging', + display_name: 'Staging', + description: '', + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + in_use: true, + }, + { + id: 'canary', + display_name: 'Canary', + description: '', + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + in_use: true, + }, + { + id: 'qa', + display_name: 'Quality Assurance', + description: '', + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + in_use: false, + }, +] + +const renderAccessPoint = ({ + environments = appEnvironments, + searchParams = '', +}: { + environments?: AppEnvironment[] + searchParams?: string +} = {}) => { + const queryClient = createTestQueryClient() + seedAccountProfileQuery(queryClient, mockConsoleState.userProfile) + const queryOptions = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: 'app-1', + }, + }, + }) + queryClient.setQueryData(queryOptions.queryKey, { data: environments }) + const onUrlUpdate = vi.fn() + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ) + + return { + ...render(, { wrapper: Wrapper }), + onUrlUpdate, + } +} + +describe('AccessPoint', () => { + beforeEach(() => { + appMode = 'workflow' + appPermissionKeys = [AppACLPermission.Deploy] + }) + + it('renders Built-in and only in-use environments from the API', () => { + renderAccessPoint() + + expect(screen.getByRole('heading', { name: 'common.appMenus.accessPoint' })).toBeInTheDocument() + expect(screen.getByTestId('built-in-access-points')).toHaveTextContent('app-1') + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'Built-in', + 'Staging', + 'Canary', + ]) + expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument() + expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true') + }) + + it('persists the selected environment in the URL', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderAccessPoint() + + await user.click(screen.getByRole('tab', { name: 'Canary' })) + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?environment=canary', + }), + ) + }) + + it('selects the target environment and highlights its access point from the URL', () => { + renderAccessPoint({ + searchParams: '?environment=canary&accessPoint=serviceApi', + }) + + expect(screen.getByRole('tab', { name: 'Canary' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary') + expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( + 'data-highlighted-access-point', + 'serviceApi', + ) + }) + + it('highlights a built-in access point from the URL', () => { + renderAccessPoint({ + searchParams: '?environment=built-in&accessPoint=mcp', + }) + + expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('built-in-access-points')).toHaveAttribute( + 'data-highlighted-access-point', + 'mcp', + ) + }) + + it('clears the access point highlight when switching environment tabs', async () => { + const user = userEvent.setup() + const { onUrlUpdate } = renderAccessPoint({ + searchParams: '?environment=canary&accessPoint=serviceApi', + }) + + await user.click(screen.getByRole('tab', { name: 'Staging' })) + + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '?environment=staging', + }), + ) + expect(screen.getByTestId('deployed-environment-access-points')).not.toHaveAttribute( + 'data-highlighted-access-point', + ) + }) + + it('shows the selected deployed environment with deploy permissions', () => { + renderAccessPoint({ + searchParams: '?environment=canary', + }) + + expect(screen.getByTestId('deployed-environment-access-points')).toHaveTextContent('canary') + expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( + 'data-app-id', + 'app-1', + ) + expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( + 'data-can-edit', + 'false', + ) + expect(screen.getByTestId('deployed-environment-access-points')).toHaveAttribute( + 'data-can-manage', + 'true', + ) + expect(screen.queryByTestId('built-in-access-points')).not.toBeInTheDocument() + }) + + it('falls back to Built-in when the URL targets an unused environment', () => { + renderAccessPoint({ + searchParams: '?environment=qa&accessPoint=mcp', + }) + + expect(screen.getByRole('tab', { name: 'Built-in' })).toHaveAttribute('aria-selected', 'true') + expect(screen.queryByRole('tab', { name: 'Quality Assurance' })).not.toBeInTheDocument() + expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument() + expect(screen.getByTestId('built-in-access-points')).not.toHaveAttribute( + 'data-highlighted-access-point', + ) + expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument() + }) + + it('hides environment tabs for app types without multi-environment support', () => { + appMode = 'chat' + + renderAccessPoint() + + expect(screen.queryByRole('tab')).not.toBeInTheDocument() + expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument() + }) + + it('falls back to built-in access points without app deploy ACL permission', () => { + appPermissionKeys = [] + + renderAccessPoint({ + searchParams: '?environment=canary', + }) + + expect(screen.queryByRole('tab')).not.toBeInTheDocument() + expect(screen.getByTestId('built-in-access-points')).toBeInTheDocument() + expect(screen.queryByTestId('deployed-environment-access-points')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx new file mode 100644 index 00000000000..e543fde4b45 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -0,0 +1,164 @@ +import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum } from '@/app/components/workflow/types' +import { render } from '@/test/console/render' +import { AppModeEnum } from '@/types/app' +import { MCPAccessPointCard } from '../built-in-access-points/mcp-card' + +const mocks = vi.hoisted(() => ({ + invalidateServerDetail: vi.fn(), + serverDetail: { + data: undefined as undefined | { id: string; server_code: string; status: string }, + isPending: false, + }, + modalProps: vi.fn(), + refreshServerCode: vi.fn(), + updateServer: vi.fn(), +})) + +vi.mock('@/service/use-tools', () => ({ + useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail, + useMCPServerDetail: () => mocks.serverDetail, + useRefreshMCPServerCode: () => ({ + isPending: false, + mutateAsync: mocks.refreshServerCode, + }), + useUpdateMCPServer: () => ({ + isPending: false, + mutateAsync: mocks.updateServer, + }), +})) + +vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ + default: (props: Record) => { + mocks.modalProps(props) + return
+ }, +})) + +const appInfo = { + api_base_url: 'https://api.example.test/v1', + id: 'app-1', + mode: AppModeEnum.CHAT, + model_config: { + updated_at: 1_710_000_000, + user_input_form: [ + { + 'text-input': { + label: 'Question', + required: true, + variable: 'question', + }, + }, + ], + }, +} as AccessPointAppInfo + +const workflowAppInfo = { + ...appInfo, + mode: AppModeEnum.WORKFLOW, + model_config: null, +} as unknown as AccessPointAppInfo + +const publishedWorkflow = { + graph: { + nodes: [ + { + data: { + type: BlockEnum.Start, + variables: [{ label: 'Query', variable: 'query' }], + }, + }, + ], + }, +} as unknown as PublishedWorkflow + +describe('MCPAccessPointCard', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.serverDetail.data = undefined + mocks.serverDetail.isPending = false + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the provided basic app model config without refetching app detail', async () => { + const user = userEvent.setup() + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })) + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /addDescription/ })) + + expect(screen.getByRole('dialog', { name: 'MCP server settings' })).toBeInTheDocument() + expect(mocks.modalProps).toHaveBeenCalledWith( + expect.objectContaining({ + latestParams: [ + { + label: 'Question', + required: true, + type: 'text-input', + variable: 'question', + }, + ], + }), + ) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('uses workflow inputs when the app model config is null', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: /addDescription/ })) + + expect(mocks.modalProps).toHaveBeenCalledWith( + expect.objectContaining({ + latestParams: [{ label: 'Query', variable: 'query' }], + }), + ) + }) + + it('shows loading without reporting an environment failure', () => { + mocks.serverDetail.isPending = true + + render( + , + ) + + const card = screen.getByRole('region', { name: /mcp\.server\.title/ }) + expect(card).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('common.loading')).toBeInTheDocument() + expect( + screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx new file mode 100644 index 00000000000..7f1a50268ef --- /dev/null +++ b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx @@ -0,0 +1,120 @@ +import type { AccessPointAppInfo } from '../shared/utils' +import { screen } from '@testing-library/react' +import { render } from '@/test/console/render' +import { AppModeEnum } from '@/types/app' +import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api-card' + +const mocks = vi.hoisted(() => ({ + apiSecretKeyButtonProps: vi.fn(), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.test/en${path}`, +})) + +vi.mock('../shared/api-secret-key-button', () => ({ + ApiSecretKeyButton: (props: { canManage: boolean; disabled?: boolean }) => { + mocks.apiSecretKeyButtonProps(props) + return ( + + ) + }, +})) + +function createAppInfo( + mode: AppModeEnum, + overrides: Partial = {}, +): AccessPointAppInfo { + return { + api_base_url: 'https://api.example.test/v1', + enable_api: true, + id: 'app-1', + mode, + ...overrides, + } as AccessPointAppInfo +} + +describe('ServiceApiAccessPointCard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + [AppModeEnum.ADVANCED_CHAT, '/api-reference/guides/chatflow'], + [AppModeEnum.WORKFLOW, '/api-reference/guides/workflow'], + [AppModeEnum.CHAT, '/api-reference/guides/chat'], + [AppModeEnum.AGENT_CHAT, '/api-reference/guides/chat'], + [AppModeEnum.COMPLETION, '/api-reference/guides/completion'], + ])('links %s apps to the matching external API reference', (mode, path) => { + render( + , + ) + + const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ }) + + expect(apiReferenceLink).toHaveAttribute('href', `https://docs.example.test/en${path}`) + expect(apiReferenceLink).toHaveAttribute('target', '_blank') + expect(apiReferenceLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('shows loading without reporting an environment failure', () => { + render( + , + ) + + const card = screen.getByRole('region', { name: /serviceApi\.title/ }) + expect(card).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('common.loading')).toBeInTheDocument() + expect( + screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + }) + + it('keeps API keys and external documentation available when the API is stopped', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeEnabled() + const apiReferenceLink = screen.getByRole('button', { name: /apiInfo\.doc/ }) + expect(apiReferenceLink).not.toHaveAttribute('aria-disabled') + expect(apiReferenceLink).toHaveAttribute( + 'href', + 'https://docs.example.test/en/api-reference/guides/workflow', + ) + }) + + it('disables API keys and external documentation when the access point is unavailable', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled() + expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) +}) diff --git a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx new file mode 100644 index 00000000000..e35602b6a73 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx @@ -0,0 +1,148 @@ +import type { AccessPointAppInfo } from '../shared/utils' +import type { AppTrigger } from '@/service/use-tools' +import { screen } from '@testing-library/react' +import { render } from '@/test/console/render' +import { AppModeEnum } from '@/types/app' +import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card' + +const mocks = vi.hoisted(() => ({ + invalidateTriggers: vi.fn(), + setTriggerStatus: vi.fn(), + setTriggerStatuses: vi.fn(), + triggerQuery: { + data: undefined as { data: AppTrigger[] } | undefined, + isLoading: false, + }, + updateTriggerStatus: vi.fn(), +})) + +vi.mock('@/app/components/workflow/store/trigger-status', () => ({ + useTriggerStatusStore: () => ({ + setTriggerStatus: mocks.setTriggerStatus, + setTriggerStatuses: mocks.setTriggerStatuses, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.test/en${path}`, +})) + +vi.mock('@/service/use-tools', () => ({ + useAppTriggers: () => mocks.triggerQuery, + useInvalidateAppTriggers: () => mocks.invalidateTriggers, + useUpdateTriggerStatus: () => ({ + isPending: false, + mutateAsync: mocks.updateTriggerStatus, + }), +})) + +vi.mock('@/service/use-triggers', () => ({ + useAllTriggerPlugins: () => ({ data: [] }), +})) + +vi.mock('@/app/components/workflow/block-icon', () => ({ + default: () => null, +})) + +const appInfo = { + id: 'app-1', + mode: AppModeEnum.WORKFLOW, +} as AccessPointAppInfo + +function createTrigger(id: string, status: AppTrigger['status']): AppTrigger { + return { + id, + trigger_type: 'trigger-webhook', + title: `Trigger ${id}`, + node_id: `node-${id}`, + provider_name: 'Webhook', + icon: '', + status, + created_at: '2026-08-04T00:00:00Z', + updated_at: '2026-08-04T00:00:00Z', + } +} + +function renderCard(availability: 'available' | 'loading' | 'unavailable') { + render( + , + ) +} + +describe('TriggerAccessPointCard', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.triggerQuery.data = undefined + mocks.triggerQuery.isLoading = false + }) + + it('shows loading without reporting an environment failure', () => { + renderCard('loading') + + const card = screen.getByRole('region', { name: /settings\.trigger/ }) + expect(card).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('common.loading')).toBeInTheDocument() + expect( + screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + }) + + it('shows every non-enabled trigger as disabled with its switch off', () => { + mocks.triggerQuery.data = { + data: [ + createTrigger('enabled', 'enabled'), + createTrigger('disabled', 'disabled'), + createTrigger('unauthorized', 'unauthorized'), + ], + } + + renderCard('available') + + expect( + screen.getByText( + 'deployments.studio.accessPoint.triggerEnabledCount:{"enabled":1,"total":3}', + ), + ).toBeInTheDocument() + expect(screen.getByText('agentV2.agentDetail.access.status.inService')).toBeInTheDocument() + expect(screen.getAllByText('appOverview.overview.status.disable')).toHaveLength(2) + expect( + screen.queryByText('deployments.studio.accessPoint.triggerDisconnected'), + ).not.toBeInTheDocument() + expect( + screen.queryByText('deployments.studio.accessPoint.triggerMuted'), + ).not.toBeInTheDocument() + + const [enabledSwitch, disabledSwitch, unauthorizedSwitch] = screen.getAllByRole('switch') + expect(enabledSwitch).toBeChecked() + expect(disabledSwitch).not.toBeChecked() + expect(unauthorizedSwitch).not.toBeChecked() + }) + + it('uses the overview empty-state copy and documentation interaction', () => { + renderCard('unavailable') + + expect( + screen.getByText('appOverview.overview.triggerInfo.triggerStatusDescription'), + ).toBeInTheDocument() + const learnLink = screen.getByRole('link', { + name: 'appOverview.overview.triggerInfo.learnAboutTriggers', + }) + expect(learnLink).toHaveAttribute( + 'href', + 'https://docs.example.test/en/use-dify/nodes/trigger/overview', + ) + expect(learnLink).toHaveAttribute('target', '_blank') + expect(learnLink).toHaveAttribute('rel', 'noopener noreferrer') + expect( + screen.queryByText('deployments.studio.accessPoint.triggerServiceModeUnavailable'), + ).not.toBeInTheDocument() + expect( + screen.queryByText('deployments.studio.accessPoint.noTriggerNodes'), + ).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx new file mode 100644 index 00000000000..b32267892a5 --- /dev/null +++ b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx @@ -0,0 +1,200 @@ +import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' +import type { InputVar, Node } from '@/app/components/workflow/types' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BlockEnum, InputVarType } from '@/app/components/workflow/types' +import { AccessMode } from '@/models/access-control' +import { render } from '@/test/console/render' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' +import { WebAppAccessPointCard } from '../built-in-access-points/web-app-card' + +vi.mock('@/service/access-control/use-app-access-control', () => ({ + useAppWhiteListSubjects: () => ({ + data: undefined, + }), +})) + +vi.mock('@/app/components/base/app-icon', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/app/app-access-control', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/app/overview/customize', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/app/overview/settings', () => ({ + default: () => null, +})) + +vi.mock('@/app/components/app/overview/embedded', () => ({ + default: ({ + hiddenInputs = [], + isShow, + }: { + hiddenInputs?: Array<{ variable: string }> + isShow: boolean + }) => + isShow ? ( +
+ {hiddenInputs.map((input) => input.variable).join(',')} +
+ ) : null, +})) + +function createAppInfo(mode: AppModeEnum): AccessPointAppInfo { + return { + access_mode: AccessMode.PUBLIC, + api_base_url: 'https://api.example.test/v1', + enable_site: true, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + icon_url: null, + id: 'app-1', + mode, + site: { + access_token: 'site-code', + app_base_url: 'https://site.example.test', + }, + } as AccessPointAppInfo +} + +function renderCard( + mode: AppModeEnum, + availability: 'available' | 'loading' | 'unavailable' = 'available', + workflow?: PublishedWorkflow, +) { + render( + , + ) +} + +const startNode: Node<{ variables: InputVar[] }> = { + id: 'start', + position: { x: 0, y: 0 }, + data: { + title: 'Start', + desc: '', + type: BlockEnum.Start, + variables: [ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + required: true, + default: '', + }, + ], + }, +} + +const workflowWithHiddenInput: NonNullable = { + conversation_variables: [], + environment_variables: [], + features: {}, + id: 'workflow-id', + graph: { + nodes: [startNode], + edges: [], + }, + created_at: 0, + created_by: { id: 'user-id', name: 'User', email: 'user@example.com' }, + hash: 'workflow-hash', + updated_at: 0, + updated_by: { id: 'user-id', name: 'User', email: 'user@example.com' }, + tool_published: false, + version: '1', + marked_name: '', + marked_comment: '', + rag_pipeline_variables: [], +} + +describe('WebAppAccessPointCard', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('shows the current access mode without a redundant section label', () => { + renderCard(AppModeEnum.CHAT) + + expect(screen.queryByText(/publishApp\.title/)).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: /accessControlDialog\.accessItems\.anyone/ }), + ).toBeEnabled() + }) + + it.each([AppModeEnum.WORKFLOW, AppModeEnum.COMPLETION])( + 'does not offer Embed into site for %s apps', + (mode) => { + renderCard(mode) + + expect(screen.queryByRole('button', { name: /embedIntoSite/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /customize\.entry/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /settings\.settings/ })).toBeInTheDocument() + }, + ) + + it('keeps Embed into site for non-workflow Web apps', async () => { + const user = userEvent.setup() + renderCard(AppModeEnum.CHAT) + + await user.click(screen.getByRole('button', { name: /embedIntoSite/ })) + + expect(screen.getByRole('dialog', { name: 'embed into site' })).toBeInTheDocument() + }) + + it('passes hidden Chatflow inputs to the embed dialog', async () => { + const user = userEvent.setup() + renderCard(AppModeEnum.ADVANCED_CHAT, 'available', workflowWithHiddenInput) + + await user.click(screen.getByRole('button', { name: /embedIntoSite/ })) + + expect(screen.getByRole('dialog', { name: 'embed into site' })).toHaveTextContent('secret') + }) + + it('configures hidden workflow inputs before opening the Web App', async () => { + const user = userEvent.setup() + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + renderCard(AppModeEnum.WORKFLOW, 'available', workflowWithHiddenInput) + + await user.click(screen.getByRole('button', { name: /operation\.config/ })) + await user.type(screen.getByLabelText('Secret'), 'top-secret') + await user.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/ })) + + await waitFor(() => { + expect(openSpy).toHaveBeenCalledWith( + `https://site.example.test${basePath}/workflow/site-code?secret=top-secret`, + '_blank', + ) + }) + }) + + it('shows loading without reporting an environment failure', () => { + renderCard(AppModeEnum.WORKFLOW, 'loading') + + const card = screen.getByRole('region', { name: /webApp\.title/ }) + expect(card).toHaveAttribute('aria-busy', 'true') + expect(screen.getByText('common.loading')).toBeInTheDocument() + expect( + screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), + ).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx new file mode 100644 index 00000000000..5cfdcdbda36 --- /dev/null +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -0,0 +1,162 @@ +'use client' + +import type { AccessPoint } from '@/app/components/app/deploy/access-point' +import { Button } from '@langgenius/dify-ui/button' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import Loading from '@/app/components/base/loading' +import { useDocLink } from '@/context/i18n' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import Link from '@/next/link' +import { useAppWorkflow } from '@/service/use-workflow' +import { getAppACLCapabilities } from '@/utils/permission' +import { useAccessPointActions } from '../shared/use-access-point-actions' +import { getPublishedWorkflowState, isAdvancedApp } from '../shared/utils' +import { MCPAccessPointCard } from './mcp-card' +import { ServiceApiAccessPointCard } from './service-api-card' +import { TriggerAccessPointCard } from './trigger-card' +import { WebAppAccessPointCard } from './web-app-card' + +type BuiltInAccessPointsProps = { + appId: string + highlightedAccessPoint?: AccessPoint | null +} + +export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAccessPointsProps) { + const { t } = useTranslation() + const docLink = useDocLink() + const appInfo = useAppStore((state) => state.appDetail) + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const shouldFetchWorkflow = Boolean(appInfo && isAdvancedApp(appInfo)) + const { data: workflow, isPending: workflowLoading } = useAppWorkflow( + shouldFetchWorkflow ? appId : '', + ) + const capabilities = useMemo( + () => + getAppACLCapabilities(appInfo?.permission_keys, { + currentUserId, + resourceMaintainer: appInfo?.maintainer, + workspacePermissionKeys, + }), + [appInfo?.maintainer, appInfo?.permission_keys, currentUserId, workspacePermissionKeys], + ) + const actions = useAccessPointActions(appId, capabilities.canEdit) + + if (!appInfo) return + + const workflowState = getPublishedWorkflowState(appInfo, workflow) + const builtInLoading = workflowState.isWorkflowApp && workflowLoading + const appCardsUnavailable = + workflowState.isWorkflowApp && (workflowState.isUnpublished || workflowState.hasTriggerNode) + const appCardAvailability = builtInLoading + ? 'loading' + : appCardsUnavailable + ? 'unavailable' + : 'available' + const triggerAvailability = builtInLoading + ? 'loading' + : workflowState.isUnpublished || !workflowState.hasTriggerNode + ? 'unavailable' + : 'available' + + return ( +
+ {workflowState.isUnpublished && !workflowLoading && ( +
+
+ + {t(($) => $['studio.accessPoint.noPublishedTitle'], { + ns: 'deployments', + })} + + + {t(($) => $['studio.accessPoint.noPublishedDescription'], { + ns: 'deployments', + })} + +
+ +
+ )} + +
+ + + + {workflowState.isWorkflowApp && ( + + )} +
+ + {workflowState.hasTriggerNode && ( +
+ + + {t(($) => $['studio.accessPoint.triggerExclusiveNotice'], { + ns: 'deployments', + })}{' '} + + {t(($) => $['operation.learnMore'], { ns: 'common' })} + + +
+ )} +
+ ) +} diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx new file mode 100644 index 00000000000..4c7470e6dd2 --- /dev/null +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -0,0 +1,223 @@ +'use client' + +import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' +import { BlockEnum } from '@/app/components/workflow/types' +import { + useInvalidateMCPServerDetail, + useMCPServerDetail, + useRefreshMCPServerCode, + useUpdateMCPServer, +} from '@/service/use-tools' +import { AppModeEnum } from '@/types/app' +import { AccessPointCard } from '../shared/access-point-card' +import { AccessPointUrl } from '../shared/access-point-url' +import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' + +type MCPAccessPointCardProps = { + appInfo: AccessPointAppInfo + canEdit: boolean + highlighted?: boolean + triggerModeDisabled: boolean + workflow: PublishedWorkflow + workflowLoading: boolean +} + +export function MCPAccessPointCard({ + appInfo, + canEdit, + highlighted, + triggerModeDisabled, + workflow, + workflowLoading, +}: MCPAccessPointCardProps) { + const { t } = useTranslation() + const advancedApp = isAdvancedApp(appInfo) + const basicApp = !advancedApp + const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW + const [showServerModal, setShowServerModal] = useState(false) + const [showRegenerate, setShowRegenerate] = useState(false) + const [pendingStatus, setPendingStatus] = useState(null) + const basicConfig = appInfo.model_config + const basicAppInputForm = basicConfig?.user_input_form + const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail( + appInfo.id, + Boolean(appInfo.id), + ) + const { mutateAsync: updateServer, isPending: statusUpdating } = useUpdateMCPServer() + const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode() + const invalidateServerDetail = useInvalidateMCPServerDetail() + + const serverPublished = Boolean(detail?.id) + const serverActivated = detail?.status === 'active' + const activated = pendingStatus ?? serverActivated + const serverUrl = serverPublished + ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp` + : '***********' + const workflowNodes = getPublishedWorkflowNodes(workflow) + const missingStartNode = + workflowApp && !workflowNodes.some((node) => node.data.type === BlockEnum.Start) + const appUnpublished = advancedApp ? !workflow?.graph : !basicConfig?.updated_at + const loading = serverDetailLoading || (advancedApp && workflowLoading) + const unavailable = !loading && (appUnpublished || missingStartNode || triggerModeDisabled) + + const basicAppInputs = useMemo(() => { + if (!basicApp || !basicAppInputForm) return [] + + return basicAppInputForm.map((item) => { + const [type = 'text-input'] = Object.keys(item) + const [config = {}] = Object.values(item) as object[] + return { + ...config, + type, + } + }) + }, [basicApp, basicAppInputForm]) + + const latestParams = useMemo(() => { + if (!advancedApp) return basicAppInputs + const startNode = workflowNodes.find((node) => node.data.type === BlockEnum.Start) + return ( + ( + startNode?.data as { + variables?: Array<{ variable: string; label: string }> + } + )?.variables ?? [] + ) + }, [advancedApp, basicAppInputs, workflowNodes]) + + const handleStatusChange = async (enabled: boolean) => { + if (!canEdit || loading || unavailable) return + if (enabled && !serverPublished) { + setShowServerModal(true) + return + } + + setPendingStatus(enabled) + try { + await updateServer({ + appID: appInfo.id, + id: detail?.id || '', + description: detail?.description || '', + parameters: detail?.parameters || {}, + status: enabled ? 'active' : 'inactive', + }) + invalidateServerDetail(appInfo.id) + } finally { + setPendingStatus(null) + } + } + + const handleRegenerate = async () => { + if (!canEdit || !detail?.id) return + await refreshServerCode(appInfo.id) + invalidateServerDetail(appInfo.id) + setShowRegenerate(false) + } + + const status = loading + ? 'loading' + : unavailable + ? 'unavailable' + : activated + ? 'inService' + : 'disabled' + + return ( + <> + $['mcp.server.title'], { ns: 'tools' })} + description={t(($) => $['studio.accessPoint.mcpDescription'], { + ns: 'deployments', + })} + icon="i-custom-vender-integrations-mcp" + status={status} + highlighted={highlighted} + busy={statusUpdating} + switchDisabled={!canEdit} + switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })} + onEnabledChange={loading || unavailable ? undefined : handleStatusChange} + actions={ + + } + > + $['mcp.server.url'], { ns: 'tools' })} + value={serverUrl} + enabled={activated} + copyDisabled={!serverPublished} + loading={loading} + unavailable={unavailable} + unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { + ns: 'deployments', + })} + showRegenerate + regenerateLabel={t(($) => $['overview.appInfo.regenerate'], { + ns: 'appOverview', + })} + regenerateDisabled={!canEdit || !serverPublished} + regenerating={regenerating} + onRegenerate={() => setShowRegenerate(true)} + /> + + + {showServerModal && ( + { + setShowServerModal(false) + setPendingStatus(null) + invalidateServerDetail(appInfo.id) + }} + appInfo={appInfo} + /> + )} + + !open && setShowRegenerate(false)}> + +
+ + {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} + + + {t(($) => $['mcp.server.reGen'], { ns: 'tools' })} + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + void handleRegenerate()}> + {t(($) => $['operation.confirm'], { ns: 'common' })} + + +
+
+ + ) +} diff --git a/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx b/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx new file mode 100644 index 00000000000..75a316e7f5f --- /dev/null +++ b/web/app/components/app/access-point/built-in-access-points/service-api-card.tsx @@ -0,0 +1,44 @@ +'use client' + +import type { AccessPointAvailability } from '../shared/access-point-status' +import type { AccessPointAppInfo } from '../shared/utils' +import { getAccessPointStatus } from '../shared/access-point-status' +import { ServiceApiCardView } from '../shared/service-api-card-view' +import { getBuiltInAccessUrls } from '../shared/utils' + +type ServiceApiAccessPointCardProps = { + appInfo: AccessPointAppInfo + availability: AccessPointAvailability + canEdit: boolean + highlighted?: boolean + onChangeStatus: (enabled: boolean) => Promise +} + +export function ServiceApiAccessPointCard({ + appInfo, + availability, + canEdit, + highlighted, + onChangeStatus, +}: ServiceApiAccessPointCardProps) { + const { api: apiUrl } = getBuiltInAccessUrls(appInfo) + const running = availability === 'available' && appInfo.enable_api + const status = getAccessPointStatus(availability, running) + + return ( + + ) +} diff --git a/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx new file mode 100644 index 00000000000..ae173039ba9 --- /dev/null +++ b/web/app/components/app/access-point/built-in-access-points/trigger-card.tsx @@ -0,0 +1,208 @@ +'use client' + +import type { AccessPointAppInfo } from '../shared/utils' +import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' +import type { AppTrigger } from '@/service/use-tools' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { Switch } from '@langgenius/dify-ui/switch' +import { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import BlockIcon from '@/app/components/workflow/block-icon' +import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' +import { BlockEnum } from '@/app/components/workflow/types' +import { useDocLink } from '@/context/i18n' +import Link from '@/next/link' +import { + useAppTriggers, + useInvalidateAppTriggers, + useUpdateTriggerStatus, +} from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' +import { canFindTool } from '@/utils' +import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card' + +function TriggerIcon({ + trigger, + triggerPlugins, +}: { + trigger: AppTrigger + triggerPlugins: TriggerWithProvider[] +}) { + const blockType = + trigger.trigger_type === 'trigger-schedule' + ? BlockEnum.TriggerSchedule + : trigger.trigger_type === 'trigger-plugin' + ? BlockEnum.TriggerPlugin + : BlockEnum.TriggerWebhook + const pluginTrigger = + trigger.trigger_type === 'trigger-plugin' && trigger.provider_name + ? triggerPlugins.find( + (candidate) => + canFindTool(candidate.id, trigger.provider_name!) || + candidate.id.includes(trigger.provider_name!) || + candidate.name === trigger.provider_name, + ) + : undefined + const toolIcon = typeof pluginTrigger?.icon === 'string' ? pluginTrigger.icon : undefined + + return ( + + + + ) +} + +type TriggerAccessPointCardProps = { + appInfo: AccessPointAppInfo + availability: 'available' | 'loading' | 'unavailable' + canEdit: boolean + highlighted?: boolean + onToggleResult: (error: Error | null) => void +} + +export function TriggerAccessPointCard({ + appInfo, + availability, + canEdit, + highlighted, + onToggleResult, +}: TriggerAccessPointCardProps) { + const { t } = useTranslation() + const docLink = useDocLink() + const { data: response, isLoading } = useAppTriggers(appInfo.id) + const { data: triggerPlugins = [] } = useAllTriggerPlugins() + const { mutateAsync: updateTriggerStatus, isPending: statusUpdating } = useUpdateTriggerStatus() + const invalidateTriggers = useInvalidateAppTriggers() + const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore() + const triggers = useMemo(() => response?.data ?? [], [response?.data]) + const loading = availability === 'loading' || isLoading + const active = availability === 'available' && !loading + const status = loading ? 'loading' : active ? 'inService' : 'unavailable' + const enabledCount = triggers.filter((trigger) => trigger.status === 'enabled').length + + useEffect(() => { + if (!triggers.length) return + + setTriggerStatuses( + triggers.reduce( + (statuses, trigger) => { + statuses[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' + return statuses + }, + {} as Record, + ), + ) + }, [setTriggerStatuses, triggers]) + + const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => { + if (!canEdit) return + const status = enabled ? 'enabled' : 'disabled' + setTriggerStatus(trigger.node_id, status) + + try { + await updateTriggerStatus({ + appId: appInfo.id, + triggerId: trigger.id, + enableTrigger: enabled, + }) + invalidateTriggers(appInfo.id) + onToggleResult(null) + } catch (error) { + setTriggerStatus(trigger.node_id, enabled ? 'disabled' : 'enabled') + onToggleResult(error as Error) + } + } + + return ( + $['settings.trigger'], { ns: 'common' })} + description={t(($) => $['studio.accessPoint.triggerDescription'], { + ns: 'deployments', + })} + icon="i-custom-vender-integrations-trigger" + status={status} + highlighted={highlighted} + showStatus={!active} + busy={statusUpdating} + > + {loading && ( +
+ + + +
+ )} + {!loading && (!active || triggers.length === 0) && ( + + + {t(($) => $['overview.triggerInfo.triggerStatusDescription'], { + ns: 'appOverview', + })}{' '} + + {t(($) => $['overview.triggerInfo.learnAboutTriggers'], { + ns: 'appOverview', + })} + + + + )} + {active && triggers.length > 0 && ( +
+
+ {t(($) => $['studio.accessPoint.triggerEnabledCount'], { + ns: 'deployments', + enabled: enabledCount, + total: triggers.length, + })} +
+
+ {triggers.map((trigger) => { + const enabled = trigger.status === 'enabled' + const statusLabel = enabled + ? t(($) => $['agentDetail.access.status.inService'], { + ns: 'agentV2', + }) + : t(($) => $['overview.status.disable'], { + ns: 'appOverview', + }) + + return ( +
+ + + {trigger.title} + + + {trigger.provider_name} + + + + {statusLabel} + + void toggleTrigger(trigger, nextEnabled)} + /> +
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx b/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx new file mode 100644 index 00000000000..aeb13e24c49 --- /dev/null +++ b/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx @@ -0,0 +1,276 @@ +'use client' + +import type { SelectorParam } from 'i18next' +import type { AccessPointAvailability } from '../shared/access-point-status' +import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' +import type { ConfigParams } from '@/app/components/app/overview/settings' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import AccessControl from '@/app/components/app/app-access-control' +import CustomizeModal from '@/app/components/app/overview/customize' +import EmbeddedModal from '@/app/components/app/overview/embedded' +import SettingsModal from '@/app/components/app/overview/settings' +import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog' +import AppIcon from '@/app/components/base/app-icon' +import { AccessMode } from '@/models/access-control' +import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control' +import { AppModeEnum } from '@/types/app' +import { AccessPointCard } from '../shared/access-point-card' +import { getAccessPointStatus } from '../shared/access-point-status' +import { AccessPointUrl } from '../shared/access-point-url' +import { getBuiltInAccessUrls, getHiddenStartInputs } from '../shared/utils' +import { WebAppAccessControlEntry } from '../shared/web-app-access-control' + +const ACCESS_MODE_ICON_MAP: Record = { + [AccessMode.ORGANIZATION]: 'i-ri-building-line', + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line', + [AccessMode.PUBLIC]: 'i-ri-global-line', + [AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line', +} + +const ACCESS_MODE_LABEL_MAP: Record> = { + [AccessMode.ORGANIZATION]: ($) => $['accessControlDialog.accessItems.organization'], + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: ($) => $['accessControlDialog.accessItems.specific'], + [AccessMode.PUBLIC]: ($) => $['accessControlDialog.accessItems.anyone'], + [AccessMode.EXTERNAL_MEMBERS]: ($) => $['accessControlDialog.accessItems.external'], +} + +type WebAppAccessPointCardProps = { + appInfo: AccessPointAppInfo + availability: AccessPointAvailability + canEdit: boolean + canDeploy: boolean + canManageAccess: boolean + highlighted?: boolean + showAccessControl: boolean + onChangeStatus: (enabled: boolean) => Promise + onRefreshApp: () => Promise + onRegenerate: () => Promise + onSaveSiteConfig: (params: ConfigParams) => Promise + workflow: PublishedWorkflow +} + +export function WebAppAccessPointCard({ + appInfo, + availability, + canEdit, + canDeploy, + canManageAccess, + highlighted, + onChangeStatus, + onRefreshApp, + onRegenerate, + onSaveSiteConfig, + showAccessControl, + workflow, +}: WebAppAccessPointCardProps) { + const { t } = useTranslation() + const [showSettings, setShowSettings] = useState(false) + const [showEmbedded, setShowEmbedded] = useState(false) + const [showCustomize, setShowCustomize] = useState(false) + const [showAccess, setShowAccess] = useState(false) + const [showRegenerate, setShowRegenerate] = useState(false) + const [showWorkflowLaunch, setShowWorkflowLaunch] = useState(false) + const [regenerating, setRegenerating] = useState(false) + const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo) + const running = availability === 'available' && appInfo.enable_site + const supportsEmbedded = + appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW + const hiddenLaunchVariables = getHiddenStartInputs(workflow) + const accessIcon = ACCESS_MODE_ICON_MAP[appInfo.access_mode] + const accessLabel = ACCESS_MODE_LABEL_MAP[appInfo.access_mode] + const { data: accessSubjects } = useAppWhiteListSubjects( + appInfo.id, + showAccessControl && + canManageAccess && + appInfo.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, + ) + const accessConfigured = + !accessSubjects || + appInfo.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS || + Boolean(accessSubjects?.groups?.length || accessSubjects?.members?.length) + + const handleRegenerate = async () => { + setRegenerating(true) + await onRegenerate() + setRegenerating(false) + setShowRegenerate(false) + } + + const status = getAccessPointStatus(availability, running) + + return ( + <> + $['agentDetail.access.webApp.title'], { ns: 'agentV2' })} + description={t(($) => $['studio.accessPoint.webAppDescription'], { + ns: 'deployments', + })} + icon={ + + } + status={status} + highlighted={highlighted} + switchDisabled={!canEdit} + switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })} + onEnabledChange={availability === 'available' ? onChangeStatus : undefined} + actions={ + <> + {hiddenLaunchVariables.length > 0 && ( + + )} + {supportsEmbedded && ( + + )} + + + + } + > + $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })} + value={webAppUrl} + enabled={running} + loading={availability === 'loading'} + unavailable={availability === 'unavailable'} + unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { + ns: 'deployments', + })} + showOpen + showQrCode + showRegenerate + openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })} + regenerateLabel={t(($) => $['overview.appInfo.regenerate'], { + ns: 'appOverview', + })} + regenerateDisabled={!canEdit} + regenerating={regenerating} + onOpen={() => window.open(webAppUrl, '_blank')} + onRegenerate={() => setShowRegenerate(true)} + /> + {showAccessControl && ( + setShowAccess(true)} + /> + )} + + + setShowSettings(false)} + onSave={onSaveSiteConfig} + /> + {supportsEmbedded && ( + setShowEmbedded(false)} + appBaseUrl={appInfo.site?.app_base_url} + accessToken={appInfo.site?.access_token} + hiddenInputs={hiddenLaunchVariables} + /> + )} + setShowCustomize(false)} + appId={appInfo.id} + api_base_url={appInfo.api_base_url} + mode={appInfo.mode} + /> + {showAccess && ( + setShowAccess(false)} + onConfirm={async () => { + await onRefreshApp() + setShowAccess(false) + }} + /> + )} + + !open && setShowRegenerate(false)}> + +
+ + {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} + + + {t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })} + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + void handleRegenerate()}> + {t(($) => $['operation.confirm'], { ns: 'common' })} + + +
+
+ + ) +} diff --git a/web/app/components/app/access-point/deployed-environment-access-points/environment-access-control.tsx b/web/app/components/app/access-point/deployed-environment-access-points/environment-access-control.tsx new file mode 100644 index 00000000000..64637ef4afd --- /dev/null +++ b/web/app/components/app/access-point/deployed-environment-access-points/environment-access-control.tsx @@ -0,0 +1,171 @@ +'use client' + +import type { EnvironmentWebAppSubject } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { + AccessControlSubjects, + AccessControlSubjectsStatus, +} from '@/app/components/app/app-access-control/specific-groups-or-members' +import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AccessControlForm } from '@/app/components/app/app-access-control/access-control-form' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { AccessMode, SubjectType } from '@/models/access-control' +import { consoleQuery } from '@/service/client' + +const EMPTY_SUBJECTS: AccessControlSubjects = { + groups: [], + members: [], +} + +type EnvironmentAccessControlProps = { + appId: string + environmentId: string + accessMode: AccessMode + canManage: boolean + onClose: () => void + onConfirm: () => void +} + +export function EnvironmentAccessControl(props: EnvironmentAccessControlProps) { + const key = `${props.appId}:${props.environmentId}` + return +} + +function EnvironmentAccessControlContainer({ + appId, + environmentId, + accessMode: initialAccessMode, + canManage, + onClose, + onConfirm, +}: EnvironmentAccessControlProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const [accessMode, setAccessMode] = useState(initialAccessMode) + const [subjectsDraft, setSubjectsDraft] = useState() + const params = { + app_id: appId, + environment_id: environmentId, + } + const siteQueryOptions = + consoleQuery.enterprise.appDeploy.accessService.getEnvironmentSite.queryOptions({ + input: { params }, + }) + const subjectsQueryOptions = + consoleQuery.enterprise.appDeploy.accessService.getEnvironmentWebAppSubjects.queryOptions({ + input: { params }, + }) + const subjectsQuery = useQuery({ + ...subjectsQueryOptions, + enabled: accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS, + }) + const loadedSubjects = subjectsQuery.data + ? normalizeEnvironmentSubjects(subjectsQuery.data.subjects) + : undefined + const subjects = subjectsDraft ?? loadedSubjects ?? EMPTY_SUBJECTS + const subjectsStatus: AccessControlSubjectsStatus = + subjectsDraft || loadedSubjects + ? 'success' + : subjectsQuery.isFetching || subjectsQuery.isPending + ? 'loading' + : subjectsQuery.isError + ? 'error' + : 'loading' + const updateAccessModeMutation = useMutation( + consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentWebAppAccessMode.mutationOptions( + { + onSuccess: (updatedSite) => { + queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite) + void queryClient.invalidateQueries({ queryKey: subjectsQueryOptions.queryKey }) + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }, + ), + ) + const publicAccessDisabled = !systemFeatures.webapp_auth.allow_public_access + const externalMembersTipHidden = + systemFeatures.webapp_auth.enabled && + (systemFeatures.webapp_auth.allow_sso || + systemFeatures.webapp_auth.allow_email_password_login || + systemFeatures.webapp_auth.allow_email_code_login) + + const handleConfirm = async () => { + if ( + !canManage || + updateAccessModeMutation.isPending || + (accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS && subjectsStatus !== 'success') || + (accessMode === AccessMode.PUBLIC && publicAccessDisabled) + ) + return + + await updateAccessModeMutation.mutateAsync({ + params, + body: { + access_mode: accessMode, + ...(accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS + ? { + subjects: [ + ...subjects.groups.map((group) => ({ + subject_id: group.id, + subject_type: SubjectType.GROUP, + })), + ...subjects.members.map((member) => ({ + subject_id: member.id, + subject_type: SubjectType.ACCOUNT, + })), + ], + } + : {}), + }, + }) + toast.success(t(($) => $['accessControlDialog.updateSuccess'], { ns: 'app' })) + onConfirm() + } + + return ( + void subjectsQuery.refetch()} + onClose={onClose} + onConfirm={() => void handleConfirm()} + /> + ) +} + +function normalizeEnvironmentSubjects(subjects: EnvironmentWebAppSubject[]) { + const groups: AccessControlGroup[] = [] + const members: AccessControlAccount[] = [] + + subjects.forEach((subject) => { + if (subject.subject_type === SubjectType.GROUP) { + const id = subject.subject_id || subject.group_data?.id + const name = subject.group_data?.name + const groupSize = subject.group_data?.group_size + if (id && name && groupSize !== undefined) groups.push({ id, name, groupSize }) + return + } + + if (subject.subject_type === SubjectType.ACCOUNT) { + const id = subject.subject_id || subject.account_data?.id + const name = subject.account_data?.name + const email = subject.account_data?.email + const avatar = subject.account_data?.avatar ?? '' + if (id && name && email) members.push({ id, name, email, avatar, avatarUrl: avatar }) + } + }) + + return { groups, members } +} diff --git a/web/app/components/app/access-point/deployed-environment-access-points/environment-service-api-card.tsx b/web/app/components/app/access-point/deployed-environment-access-points/environment-service-api-card.tsx new file mode 100644 index 00000000000..bf0c851b304 --- /dev/null +++ b/web/app/components/app/access-point/deployed-environment-access-points/environment-service-api-card.tsx @@ -0,0 +1,84 @@ +'use client' + +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import { consoleQuery } from '@/service/client' +import { ServiceApiCardView } from '../shared/service-api-card-view' + +type EnvironmentServiceApiCardProps = { + appId: string + environmentId: string + canManage: boolean + highlighted?: boolean +} + +export function EnvironmentServiceApiCard({ + appId, + environmentId, + canManage, + highlighted, +}: EnvironmentServiceApiCardProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const appMode = useAppStore((state) => state.appDetail?.mode) + const params = { + app_id: appId, + environment_id: environmentId, + } + const apiQueryOptions = + consoleQuery.enterprise.appDeploy.accessService.getEnvironmentApi.queryOptions({ + input: { params }, + }) + const apiQuery = useQuery(apiQueryOptions) + const api = apiQuery.data + const apiMutation = useMutation( + consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentApi.mutationOptions({ + onSuccess: (updatedApi) => { + queryClient.setQueryData(apiQueryOptions.queryKey, updatedApi) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }), + ) + const running = Boolean(apiQuery.isSuccess && api?.enabled) + const status = apiQuery.isPending + ? 'loading' + : apiQuery.isError + ? 'unavailable' + : running + ? 'inService' + : 'disabled' + + const handleEnabledChange = (enabled: boolean) => { + if (!canManage) return + + apiMutation.mutate({ + params, + body: { enabled }, + }) + } + + return ( + + ) +} diff --git a/web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-card.tsx b/web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-card.tsx new file mode 100644 index 00000000000..c2de41aa961 --- /dev/null +++ b/web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-card.tsx @@ -0,0 +1,290 @@ +'use client' + +import type { AccessPointAppInfo } from '../shared/utils' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import CustomizeModal from '@/app/components/app/overview/customize' +import SettingsModal from '@/app/components/app/overview/settings' +import { useStore as useAppStore } from '@/app/components/app/store' +import AppIcon from '@/app/components/base/app-icon' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { AccessMode, isAccessMode } from '@/models/access-control' +import { consoleQuery } from '@/service/client' +import { AccessPointCard } from '../shared/access-point-card' +import { AccessPointUrl } from '../shared/access-point-url' +import { useAccessPointActions } from '../shared/use-access-point-actions' +import { WebAppAccessControlEntry } from '../shared/web-app-access-control' +import { EnvironmentAccessControl } from './environment-access-control' +import { getEnvironmentWebAppUrl } from './environment-web-app-utils' + +const ACCESS_MODE_ICON_MAP: Record = { + [AccessMode.ORGANIZATION]: 'i-ri-building-line', + [AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line', + [AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line', + [AccessMode.PUBLIC]: 'i-ri-global-line', +} + +type EnvironmentWebAppCardProps = { + appId: string + environmentId: string + canEdit: boolean + canManage: boolean + highlighted?: boolean +} + +export function EnvironmentWebAppCard({ + appId, + environmentId, + canEdit, + canManage, + highlighted, +}: EnvironmentWebAppCardProps) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const appInfo = useAppStore((state) => state.appDetail) as AccessPointAppInfo | null + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const actions = useAccessPointActions(appId, canEdit) + const [showSettings, setShowSettings] = useState(false) + const [showCustomize, setShowCustomize] = useState(false) + const [showAccess, setShowAccess] = useState(false) + const [showRegenerate, setShowRegenerate] = useState(false) + const params = { + app_id: appId, + environment_id: environmentId, + } + const siteQueryOptions = + consoleQuery.enterprise.appDeploy.accessService.getEnvironmentSite.queryOptions({ + input: { params }, + }) + const siteQuery = useQuery(siteQueryOptions) + const site = siteQuery.data + const siteAccessMode = site?.access_mode + const apiQuery = useQuery( + consoleQuery.enterprise.appDeploy.accessService.getEnvironmentApi.queryOptions({ + input: { params }, + }), + ) + const accessMode = isAccessMode(siteAccessMode) ? siteAccessMode : AccessMode.ORGANIZATION + const subjectsQueryOptions = + consoleQuery.enterprise.appDeploy.accessService.getEnvironmentWebAppSubjects.queryOptions({ + input: { params }, + }) + const subjectsQuery = useQuery({ + ...subjectsQueryOptions, + enabled: + siteQuery.isSuccess && + canManage && + (showAccess || accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS), + }) + const accessConfigured = + !subjectsQuery.data || + accessMode !== AccessMode.SPECIFIC_GROUPS_MEMBERS || + subjectsQuery.data.subjects.length > 0 + const siteMutation = useMutation( + consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentSite.mutationOptions({ + onSuccess: (updatedSite) => { + queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite) + toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' })) + }, + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }), + ) + const resetAccessTokenMutation = useMutation( + consoleQuery.enterprise.appDeploy.accessService.resetEnvironmentSiteAccessToken.mutationOptions( + { + onSuccess: (updatedSite) => { + queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite) + setShowRegenerate(false) + toast.success(t(($) => $['actionMsg.generatedSuccessfully'], { ns: 'common' })) + }, + onError: () => { + toast.error(t(($) => $['actionMsg.generatedUnsuccessfully'], { ns: 'common' })) + }, + }, + ), + ) + const webAppUrl = getEnvironmentWebAppUrl(site) + const running = Boolean(siteQuery.isSuccess && site?.enabled) + const status = siteQuery.isPending + ? 'loading' + : siteQuery.isError + ? 'unavailable' + : running + ? 'inService' + : 'disabled' + const accessLabel = + accessMode === AccessMode.ORGANIZATION + ? t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' }) + : accessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS + ? t(($) => $['accessControlDialog.accessItems.specific'], { ns: 'app' }) + : accessMode === AccessMode.EXTERNAL_MEMBERS + ? t(($) => $['accessControlDialog.accessItems.external'], { ns: 'app' }) + : t(($) => $['accessControlDialog.accessItems.anyone'], { ns: 'app' }) + const handleEnabledChange = (enabled: boolean) => { + if (!canManage) return + + siteMutation.mutate({ + params, + body: { enabled }, + }) + } + + const handleRegenerate = () => { + if (!canManage) return + + resetAccessTokenMutation.mutate({ params }) + } + + return ( + <> + $['agentDetail.access.webApp.title'], { ns: 'agentV2' })} + description={t(($) => $['studio.accessPoint.webAppDescription'], { + ns: 'deployments', + })} + icon={ + appInfo ? ( + + ) : ( + 'i-ri-robot-2-line' + ) + } + status={status} + highlighted={highlighted} + switchDisabled={!canManage} + switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })} + onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined} + busy={siteMutation.isPending} + actions={ + <> + + + + } + > + $['agentDetail.access.webApp.accessUrl'], { ns: 'agentV2' })} + value={webAppUrl} + enabled={running} + loading={siteQuery.isPending} + unavailable={siteQuery.isError} + unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { + ns: 'deployments', + })} + showOpen + showQrCode + showRegenerate + openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })} + regenerateLabel={t(($) => $['overview.appInfo.regenerate'], { + ns: 'appOverview', + })} + regenerateDisabled={!canManage} + regenerating={resetAccessTokenMutation.isPending} + onOpen={() => window.open(webAppUrl, '_blank')} + onRegenerate={() => setShowRegenerate(true)} + /> + {systemFeatures.webapp_auth.enabled && ( + setShowAccess(true)} + /> + )} + + + {appInfo && ( + setShowSettings(false)} + onSave={actions.saveSiteConfig} + /> + )} + setShowCustomize(false)} + appId={appId} + api_base_url={apiQuery.data?.base_url ?? ''} + mode={appInfo?.mode} + /> + {showAccess && ( + setShowAccess(false)} + onConfirm={() => setShowAccess(false)} + /> + )} + + +
+ + {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} + + + {t(($) => $['overview.appInfo.regenerateNotice'], { + ns: 'appOverview', + })} + +
+ + + {t(($) => $['operation.cancel'], { ns: 'common' })} + + + {t(($) => $['operation.confirm'], { ns: 'common' })} + + +
+
+ + ) +} diff --git a/web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-utils.ts b/web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-utils.ts new file mode 100644 index 00000000000..cf1224e091a --- /dev/null +++ b/web/app/components/app/access-point/deployed-environment-access-points/environment-web-app-utils.ts @@ -0,0 +1,8 @@ +import type { EnvironmentSite } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { basePath } from '@/utils/var' + +export function getEnvironmentWebAppUrl(site?: EnvironmentSite) { + if (!site?.app_base_url || !site.code) return '' + + return `${site.app_base_url.replace(/\/$/, '')}${basePath}/env/workflow/${site.code}` +} diff --git a/web/app/components/app/access-point/deployed-environment-access-points/index.tsx b/web/app/components/app/access-point/deployed-environment-access-points/index.tsx new file mode 100644 index 00000000000..1d1c305ec65 --- /dev/null +++ b/web/app/components/app/access-point/deployed-environment-access-points/index.tsx @@ -0,0 +1,100 @@ +'use client' + +import type { AccessPoint } from '@/app/components/app/deploy/access-point' +import { useTranslation } from 'react-i18next' +import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card' +import { EnvironmentServiceApiCard } from './environment-service-api-card' +import { EnvironmentWebAppCard } from './environment-web-app-card' + +const ACCESS_POINT_CONFIG: Record< + Exclude, + { + description: 'mcp' | 'trigger' + icon: string + title: 'mcp' | 'trigger' + } +> = { + mcp: { + description: 'mcp', + icon: 'i-custom-vender-integrations-mcp', + title: 'mcp', + }, + trigger: { + description: 'trigger', + icon: 'i-custom-vender-integrations-trigger', + title: 'trigger', + }, +} + +const UNSUPPORTED_ACCESS_POINTS = ['mcp', 'trigger'] as const + +type DeployedEnvironmentAccessPointsProps = { + appId: string + environmentId: string + canEdit: boolean + canManage: boolean + highlightedAccessPoint?: AccessPoint | null +} + +export function DeployedEnvironmentAccessPoints({ + appId, + environmentId, + canEdit, + canManage, + highlightedAccessPoint, +}: DeployedEnvironmentAccessPointsProps) { + const { t } = useTranslation() + + const title = (accessPoint: (typeof UNSUPPORTED_ACCESS_POINTS)[number]) => { + const key = ACCESS_POINT_CONFIG[accessPoint].title + if (key === 'mcp') return t(($) => $['mcp.server.title'], { ns: 'tools' }) + return t(($) => $['settings.trigger'], { ns: 'common' }) + } + + const description = (accessPoint: (typeof UNSUPPORTED_ACCESS_POINTS)[number]) => { + const key = ACCESS_POINT_CONFIG[accessPoint].description + if (key === 'mcp') + return t(($) => $['studio.accessPoint.mcpDescription'], { + ns: 'deployments', + }) + return t(($) => $['studio.accessPoint.triggerDescription'], { + ns: 'deployments', + }) + } + + return ( +
+ + + {UNSUPPORTED_ACCESS_POINTS.map((accessPoint) => { + return ( + + + {t(($) => $['studio.accessPoint.unsupportedInDeployedEnvironment'], { + ns: 'deployments', + })} + + + ) + })} +
+ ) +} diff --git a/web/app/components/app/access-point/index.tsx b/web/app/components/app/access-point/index.tsx new file mode 100644 index 00000000000..03572b1dc70 --- /dev/null +++ b/web/app/components/app/access-point/index.tsx @@ -0,0 +1,151 @@ +'use client' + +import { Tabs, TabsList, TabsTab } from '@langgenius/dify-ui/tabs' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs' +import { useTranslation } from 'react-i18next' +import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/access-point' +import { useStore as useAppStore } from '@/app/components/app/store' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { AppModeEnum } from '@/types/app' +import { getAppACLCapabilities } from '@/utils/permission' +import { BuiltInAccessPoints } from './built-in-access-points' +import { DeployedEnvironmentAccessPoints } from './deployed-environment-access-points' +import { + AccessPointStateBoundary, + BUILT_IN_ENVIRONMENT_ID, + inUseAppEnvironmentsAtom, +} from './state' + +const environmentQueryState = parseAsString + .withDefault(BUILT_IN_ENVIRONMENT_ID) + .withOptions({ clearOnDefault: true }) +const accessPointQueryState = parseAsStringLiteral(ACCESS_POINT_ORDER) +const accessPointQueryStates = { + environment: environmentQueryState, + accessPoint: accessPointQueryState, +} + +type AccessPointProps = { + appId: string +} + +type AccessPointContentProps = AccessPointProps & { + canEdit: boolean + canManage: boolean + showEnvironmentTabs: boolean +} + +function AccessPointContent({ + appId, + canEdit, + canManage, + showEnvironmentTabs, +}: AccessPointContentProps) { + const { t } = useTranslation() + const environments = useAtomValue(inUseAppEnvironmentsAtom) + const [queryStates, setQueryStates] = useQueryStates(accessPointQueryStates) + const { accessPoint: highlightedAccessPoint, environment } = queryStates + const selectedEnvironment = + showEnvironmentTabs && + (environment === BUILT_IN_ENVIRONMENT_ID || + environments.some((candidate) => candidate.id === environment)) + ? environment + : BUILT_IN_ENVIRONMENT_ID + const selectedHighlightedAccessPoint = + environment === selectedEnvironment ? highlightedAccessPoint : null + + return ( +
+
+
+
+

+ {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} +

+
+

+ {t(($) => $['studio.accessPoint.description'], { ns: 'deployments' })} +

+
+ {showEnvironmentTabs && ( + void setQueryStates({ accessPoint: null, environment })} + > +
+ $['studio.environments'], { ns: 'deployments' })} + className="min-w-max gap-1" + > + + {t(($) => $['nodes.common.memories.builtIn'], { ns: 'workflow' })} + + {environments.map((environment) => ( + + {environment.display_name} + + ))} + +
+
+ )} +
+ +
+ {selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? ( + + ) : ( + + )} +
+
+ ) +} + +export default function AccessPoint({ appId }: AccessPointProps) { + const appDetail = useAppStore((state) => state.appDetail) + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const capabilities = getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }) + const showEnvironmentTabs = appDetail?.mode === AppModeEnum.WORKFLOW && capabilities.canDeploy + + return ( + + + + ) +} diff --git a/web/app/components/app/access-point/shared/access-point-card.tsx b/web/app/components/app/access-point/shared/access-point-card.tsx new file mode 100644 index 00000000000..664b21a0500 --- /dev/null +++ b/web/app/components/app/access-point/shared/access-point-card.tsx @@ -0,0 +1,176 @@ +'use client' + +import type { ReactNode } from 'react' +import type { AccessPointStatus } from './access-point-status' +import { cn } from '@langgenius/dify-ui/cn' +import { StatusDot, StatusDotSkeleton } from '@langgenius/dify-ui/status-dot' +import { Switch } from '@langgenius/dify-ui/switch' +import { useId } from 'react' +import { useTranslation } from 'react-i18next' + +type AccessPointCardProps = { + actions?: ReactNode + children: ReactNode + description: string + icon: ReactNode | string + status: AccessPointStatus + title: string + busy?: boolean + className?: string + highlighted?: boolean + onEnabledChange?: (enabled: boolean) => void + showStatus?: boolean + switchDisabled?: boolean + switchLabel?: string +} + +export function AccessPointCard({ + actions, + busy = false, + children, + className, + description, + highlighted = false, + icon, + onEnabledChange, + showStatus = true, + status, + switchDisabled = false, + switchLabel, + title, +}: AccessPointCardProps) { + const { t } = useTranslation() + const titleId = useId() + const isEnabled = status === 'inService' + const isLoading = status === 'loading' + const showSwitch = (status === 'disabled' || status === 'inService') && Boolean(onEnabledChange) + const statusLabel: Record = { + disabled: t(($) => $['overview.status.disable'], { ns: 'appOverview' }), + inService: t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' }), + loading: t(($) => $.loading, { ns: 'common' }), + unavailable: t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' }), + unsupported: t(($) => $['studio.accessPoint.notSupported'], { ns: 'deployments' }), + } + + return ( +
+
+ {typeof icon === 'string' ? ( + + + + ) : ( + icon + )} + +

+ {title} +

+ {description} +
+ {showStatus && ( + <> + + {isLoading ? ( + + ) : ( + + )} + {statusLabel[status]} + + {showSwitch && ( + + )} + + )} +
+ +
+
{children}
+ {actions !== undefined && ( +
+ {actions} +
+ )} +
+
+ ) +} + +type AccessPointEndpointProps = { + actions?: ReactNode + label: string + unavailableLabel: string + value: string + dimmed?: boolean + loading?: boolean + unavailable?: boolean +} + +export function AccessPointEndpoint({ + actions, + dimmed = false, + label, + loading = false, + unavailable = false, + unavailableLabel, + value, +}: AccessPointEndpointProps) { + return ( +
+
{label}
+
+ {unavailable && !loading && ( + + {unavailableLabel} + + )} +
+ {loading ? ( + + ) : ( + + {value} + + )} +
+
{actions}
+
+
+ ) +} + +export function AccessPointEmptyContent({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/web/app/components/app/access-point/shared/access-point-status.ts b/web/app/components/app/access-point/shared/access-point-status.ts new file mode 100644 index 00000000000..4a625a530a3 --- /dev/null +++ b/web/app/components/app/access-point/shared/access-point-status.ts @@ -0,0 +1,11 @@ +export type AccessPointAvailability = 'available' | 'loading' | 'unavailable' +export type AccessPointStatus = 'disabled' | 'inService' | 'loading' | 'unavailable' | 'unsupported' + +export function getAccessPointStatus( + availability: AccessPointAvailability, + enabled: boolean, +): AccessPointStatus { + if (availability === 'loading') return 'loading' + if (availability === 'unavailable') return 'unavailable' + return enabled ? 'inService' : 'disabled' +} diff --git a/web/app/components/app/access-point/shared/access-point-url.tsx b/web/app/components/app/access-point/shared/access-point-url.tsx new file mode 100644 index 00000000000..5ce1485ea74 --- /dev/null +++ b/web/app/components/app/access-point/shared/access-point-url.tsx @@ -0,0 +1,134 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import CopyFeedback from '@/app/components/base/copy-feedback' +import ShareQRCode from '@/app/components/base/qrcode' +import ActionButton from '../../../base/action-button' +import { AccessPointEndpoint } from './access-point-card' + +type AccessPointUrlProps = { + enabled: boolean + label: string + unavailableLabel: string + value: string + copyDisabled?: boolean + loading?: boolean + unavailable?: boolean + showOpen?: boolean + showQrCode?: boolean + showRegenerate?: boolean + onOpen?: () => void + onRegenerate?: () => void + openLabel?: string + regenerateLabel?: string + regenerateDisabled?: boolean + regenerating?: boolean +} + +export function AccessPointUrl({ + enabled, + label, + loading = false, + copyDisabled = false, + onOpen, + onRegenerate, + openLabel, + regenerateDisabled = false, + regenerateLabel, + regenerating = false, + showOpen = false, + showQrCode = false, + showRegenerate = false, + unavailable = false, + unavailableLabel, + value, +}: AccessPointUrlProps) { + const detailsAvailable = !loading && !unavailable + + const disabledActions = ( +
+
+ +
+ {showQrCode && ( +
+ +
+ )} + {showRegenerate && ( +
+ +
+ )} + {showOpen && ( + <> + +
+ + {openLabel} +
+ + )} +
+ ) + + const availableActions = ( +
+ {copyDisabled ? ( +
+ +
+ ) : ( + + )} + {showQrCode && + (copyDisabled ? ( +
+ +
+ ) : ( + + ))} + {showRegenerate && ( + + + + )} + {showOpen && ( + <> + + + + )} +
+ ) + + return ( + + ) +} diff --git a/web/app/components/app/access-point/shared/api-secret-key-button.tsx b/web/app/components/app/access-point/shared/api-secret-key-button.tsx new file mode 100644 index 00000000000..ed39b17b6b4 --- /dev/null +++ b/web/app/components/app/access-point/shared/api-secret-key-button.tsx @@ -0,0 +1,73 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { skipToken, useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal' +import { consoleQuery } from '@/service/client' + +type ApiSecretKeyButtonProps = { + appId: string + canManage: boolean + apiKeyCount?: number + disabled?: boolean + environmentId?: string +} + +export function ApiSecretKeyButton({ + appId, + canManage, + apiKeyCount: environmentApiKeyCount, + disabled = false, + environmentId, +}: ApiSecretKeyButtonProps) { + const { t } = useTranslation() + const [modalOpen, setModalOpen] = useState(false) + const isEnvironmentScope = Boolean(environmentId) + const apiKeysQuery = useQuery( + consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({ + input: isEnvironmentScope ? skipToken : { params: { resource_id: appId } }, + }), + ) + const apiKeyCount = isEnvironmentScope + ? (environmentApiKeyCount ?? 0) + : (apiKeysQuery.data?.data.length ?? 0) + const buttonDisabled = + disabled || + !canManage || + (!isEnvironmentScope && (apiKeysQuery.isPending || apiKeysQuery.isError)) + + return ( + <> + + + setModalOpen(false)} + /> + + ) +} diff --git a/web/app/components/app/access-point/shared/service-api-card-view.tsx b/web/app/components/app/access-point/shared/service-api-card-view.tsx new file mode 100644 index 00000000000..e1a39d457ef --- /dev/null +++ b/web/app/components/app/access-point/shared/service-api-card-view.tsx @@ -0,0 +1,93 @@ +'use client' + +import type { ComponentProps } from 'react' +import type { AccessPointStatus } from './access-point-status' +import type { AppModeEnum } from '@/types/app' +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' +import { useDocLink } from '@/context/i18n' +import Link from '@/next/link' +import { AccessPointCard } from './access-point-card' +import { AccessPointUrl } from './access-point-url' +import { ApiSecretKeyButton } from './api-secret-key-button' +import { getAppApiReferencePath } from './utils' + +type ServiceApiCardViewProps = { + apiKeyButtonProps: ComponentProps + apiUrl: string + appMode?: AppModeEnum + available: boolean + status: AccessPointStatus + switchDisabled: boolean + busy?: boolean + highlighted?: boolean + onEnabledChange?: (enabled: boolean) => void +} + +export function ServiceApiCardView({ + apiKeyButtonProps, + apiUrl, + appMode, + available, + busy = false, + highlighted, + onEnabledChange, + status, + switchDisabled, +}: ServiceApiCardViewProps) { + const { t } = useTranslation() + const docLink = useDocLink() + const apiReferencePath = appMode ? getAppApiReferencePath(appMode) : undefined + const apiReferenceUrl = apiReferencePath ? docLink(apiReferencePath) : undefined + + return ( + $['agentDetail.access.serviceApi.title'], { ns: 'agentV2' })} + description={t(($) => $['studio.accessPoint.apiDescription'], { + ns: 'deployments', + })} + icon="i-custom-vender-knowledge-api-aggregate" + status={status} + highlighted={highlighted} + switchDisabled={switchDisabled} + switchLabel={t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })} + onEnabledChange={onEnabledChange} + busy={busy} + actions={ + <> + + + + } + > + $['overview.apiInfo.accessibleAddress'], { + ns: 'appOverview', + })} + value={apiUrl} + enabled={status === 'inService'} + loading={status === 'loading'} + unavailable={status === 'unavailable'} + unavailableLabel={t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { + ns: 'deployments', + })} + /> + + ) +} diff --git a/web/app/components/app/access-point/shared/use-access-point-actions.ts b/web/app/components/app/access-point/shared/use-access-point-actions.ts new file mode 100644 index 00000000000..65d696e64cd --- /dev/null +++ b/web/app/components/app/access-point/shared/use-access-point-actions.ts @@ -0,0 +1,133 @@ +'use client' + +import type { ConfigParams } from '@/app/components/app/overview/settings' +import type { UpdateAppSiteCodeResponse } from '@/models/app' +import type { App } from '@/types/app' +import type { I18nKeysByPrefix } from '@/types/i18n' +import { toast } from '@langgenius/dify-ui/toast' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' +import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' +import { + fetchAppDetail, + updateAppSiteAccessToken, + updateAppSiteConfig, + updateAppSiteStatus, +} from '@/service/apps' +import { consoleQuery } from '@/service/client' +import { asyncRunSafe } from '@/utils' + +export function useAccessPointActions(appId: string, canEdit: boolean) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const setAppDetail = useAppStore((state) => state.setAppDetail) + + const refreshAppDetail = useCallback(async () => { + try { + const appDetail = await fetchAppDetail({ url: '/apps', id: appId }) + setAppDetail({ ...appDetail }) + } catch (error) { + console.error('Failed to refresh app detail:', error) + } + }, [appId, setAppDetail]) + + const handleResult = useCallback( + (error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => { + const type = error ? 'error' : 'success' + const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully') + + if (!error) { + void refreshAppDetail() + const socket = webSocketClient.getSocket(appId) + if (socket) { + const timestamp = Date.now() + socket.emit('collaboration_event', { + type: 'app_state_update', + data: { timestamp }, + timestamp, + }) + } + } + + toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, { + type, + }) + }, + [appId, refreshAppDetail, t], + ) + + useEffect(() => { + if (!appId) return + + return collaborationManager.onAppStateUpdate(refreshAppDetail) + }, [appId, refreshAppDetail]) + + const changeSiteStatus = useCallback( + async (enabled: boolean) => { + if (!canEdit) return + const [error] = await asyncRunSafe( + updateAppSiteStatus({ + url: `/apps/${appId}/site-enable`, + body: { enable_site: enabled }, + }) as Promise, + ) + handleResult(error) + }, + [appId, canEdit, handleResult], + ) + + const changeApiStatus = useCallback( + async (enabled: boolean) => { + if (!canEdit) return + const [error] = await asyncRunSafe( + updateAppSiteStatus({ + url: `/apps/${appId}/api-enable`, + body: { enable_api: enabled }, + }) as Promise, + ) + handleResult(error) + }, + [appId, canEdit, handleResult], + ) + + const saveSiteConfig = useCallback( + async (params: ConfigParams) => { + if (!canEdit) return + const [error] = await asyncRunSafe( + updateAppSiteConfig({ + url: `/apps/${appId}/site`, + body: params, + }) as Promise, + ) + if (!error) { + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.starred.get.key() }) + void queryClient.invalidateQueries({ queryKey: consoleQuery.apps.recent.get.key() }) + } + handleResult(error) + }, + [appId, canEdit, handleResult, queryClient], + ) + + const regenerateSiteCode = useCallback(async () => { + if (!canEdit) return + const [error] = await asyncRunSafe( + updateAppSiteAccessToken({ + url: `/apps/${appId}/site/access-token-reset`, + }) as Promise, + ) + handleResult(error, error ? 'generatedUnsuccessfully' : 'generatedSuccessfully') + }, [appId, canEdit, handleResult]) + + return { + changeApiStatus, + changeSiteStatus, + handleResult, + refreshAppDetail, + regenerateSiteCode, + saveSiteConfig, + } +} diff --git a/web/app/components/app/access-point/shared/utils.ts b/web/app/components/app/access-point/shared/utils.ts new file mode 100644 index 00000000000..ad06c1b8786 --- /dev/null +++ b/web/app/components/app/access-point/shared/utils.ts @@ -0,0 +1,80 @@ +import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { InputVar, Node } from '@/app/components/workflow/types' +import type { AppDetailResponse } from '@/models/app' +import type { AppSSO } from '@/types/app' +import type { DocPathWithoutLang } from '@/types/doc-paths' +import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' + +export type AccessPointAppInfo = AppDetailResponse & Partial +export type PublishedWorkflow = WorkflowResponse | null | undefined + +type AppRouteMode = Exclude + +const EMPTY_WORKFLOW_NODES: Node[] = [] + +const APP_API_REFERENCE_PATHS: Record = { + 'advanced-chat': '/api-reference/guides/chatflow', + 'agent-chat': '/api-reference/guides/chat', + chat: '/api-reference/guides/chat', + completion: '/api-reference/guides/completion', + workflow: '/api-reference/guides/workflow', +} + +export function getAppApiReferencePath(appMode: AppModeEnum) { + if (appMode === 'agent') return undefined + + return APP_API_REFERENCE_PATHS[appMode] +} + +export function getPublishedWorkflowState( + appInfo: AccessPointAppInfo, + workflow: PublishedWorkflow, +) { + const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW + const nodes = getPublishedWorkflowNodes(workflow) + const hasStartNode = nodes.some((node) => node.data.type === BlockEnum.Start) + const hasTriggerNode = nodes.some((node) => isTriggerNode(node.data.type)) + + return { + hasStartNode, + hasTriggerNode, + isUnpublished: isWorkflowApp && !workflow?.graph, + isWorkflowApp, + } +} + +export function getPublishedWorkflowNodes(workflow: PublishedWorkflow) { + return Array.isArray(workflow?.graph?.nodes) + ? (workflow.graph.nodes as Node[]) + : EMPTY_WORKFLOW_NODES +} + +export function getBuiltInAccessUrls(appInfo: AccessPointAppInfo) { + const appMode = + appInfo.mode === AppModeEnum.COMPLETION || appInfo.mode === AppModeEnum.WORKFLOW + ? appInfo.mode + : AppModeEnum.CHAT + + return { + api: appInfo.api_base_url ?? '', + webApp: `${appInfo.site?.app_base_url ?? ''}${basePath}/${appMode}/${ + appInfo.site?.access_token ?? '' + }`, + } +} + +export function getHiddenStartInputs(workflow: PublishedWorkflow) { + const startNode = getPublishedWorkflowNodes(workflow).find( + (node) => node.data.type === BlockEnum.Start, + ) + + return ((startNode?.data as { variables?: InputVar[] } | undefined)?.variables ?? []).filter( + (variable) => variable.hide === true, + ) +} + +export function isAdvancedApp(appInfo: AccessPointAppInfo) { + return appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT +} diff --git a/web/app/components/app/access-point/shared/web-app-access-control.tsx b/web/app/components/app/access-point/shared/web-app-access-control.tsx new file mode 100644 index 00000000000..8c97d0074a3 --- /dev/null +++ b/web/app/components/app/access-point/shared/web-app-access-control.tsx @@ -0,0 +1,60 @@ +'use client' + +import { useTranslation } from 'react-i18next' + +type WebAppAccessControlEntryProps = { + accessConfigured: boolean + accessIcon: string + accessLabel: string + available: boolean + disabled: boolean + onClick: () => void +} + +export function WebAppAccessControlEntry({ + accessConfigured, + accessIcon, + accessLabel, + available, + disabled, + onClick, +}: WebAppAccessControlEntryProps) { + const { t } = useTranslation() + + return ( +
+ {available ? ( + + ) : ( +
+ + + +
+ )} +
+ ) +} diff --git a/web/app/components/app/access-point/state.ts b/web/app/components/app/access-point/state.ts new file mode 100644 index 00000000000..e83e759b670 --- /dev/null +++ b/web/app/components/app/access-point/state.ts @@ -0,0 +1,60 @@ +'use client' + +import type { ReactNode } from 'react' +import { skipToken } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithQuery } from 'jotai-tanstack-query' +import { selectAtom, useHydrateAtoms } from 'jotai/utils' +import { consoleQuery } from '@/service/client' + +export const BUILT_IN_ENVIRONMENT_ID = 'built-in' + +const accessPointAppIdAtom = atom(null) +const environmentQueryEnabledAtom = atom(false) + +export function AccessPointStateBoundary({ + appId, + children, + environmentQueryEnabled, +}: { + appId: string + children: ReactNode + environmentQueryEnabled: boolean +}) { + useHydrateAtoms( + [ + [accessPointAppIdAtom, appId], + [environmentQueryEnabledAtom, environmentQueryEnabled], + ] as const, + { + dangerouslyForceHydrate: true, + }, + ) + + return children +} + +const appEnvironmentsQueryAtom = atomWithQuery((get) => { + const appId = get(accessPointAppIdAtom) + const enabled = get(environmentQueryEnabledAtom) + + return consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: appId + ? { + params: { + app_id: appId, + }, + } + : skipToken, + enabled, + }) +}) + +const appEnvironmentsAtom = selectAtom(appEnvironmentsQueryAtom, (query) => query.data?.data) + +export const inUseAppEnvironmentsAtom = atom( + (get) => + get(appEnvironmentsAtom)?.filter( + (environment) => environment.in_use && environment.id !== BUILT_IN_ENVIRONMENT_ID, + ) ?? [], +) diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index 139df11a257..d2bb9f13a1c 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -1,130 +1,7 @@ -import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' -import type { App } from '@/types/app' -import { toast } from '@langgenius/dify-ui/toast' import { fireEvent, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import useAccessControlStore from '@/context/access-control-store' -import { AccessMode, SubjectType } from '@/models/access-control' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import AccessControlDialog from '../access-control-dialog' -import AddMemberOrGroupDialog from '../add-member-or-group-pop' -import AccessControl from '../index' -import SpecificGroupsOrMembers from '../specific-groups-or-members' -const mockUseAppWhiteListSubjects = vi.fn() -const mockUseSearchForWhiteListCandidates = vi.fn() -const { mockMutateAsync } = vi.hoisted(() => ({ - mockMutateAsync: vi.fn(), -})) -const intersectionObserverMocks = vi.hoisted(() => ({ - callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void), -})) - -vi.mock('@/service/access-control', () => ({ - useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), - useSearchForWhiteListCandidates: (...args: unknown[]) => - mockUseSearchForWhiteListCandidates(...args), -})) - -vi.mock('@/service/client', async (importOriginal) => { - const actual = await importOriginal() - const webAppAuth = new Proxy(actual.consoleQuery.enterprise.webAppAuth, { - get(target, property, receiver) { - if (property === 'updateWebAppWhitelistSubjects') - return { mutationOptions: () => ({ mutationFn: mockMutateAsync }) } - return Reflect.get(target, property, receiver) - }, - }) - const enterprise = new Proxy(actual.consoleQuery.enterprise, { - get(target, property, receiver) { - if (property === 'webAppAuth') return webAppAuth - return Reflect.get(target, property, receiver) - }, - }) - - return { - ...actual, - consoleQuery: new Proxy(actual.consoleQuery, { - get(target, property, receiver) { - if (property === 'enterprise') return enterprise - return Reflect.get(target, property, receiver) - }, - }), - } -}) - -vi.mock('ahooks', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useDebounce: (value: unknown) => value, - } -}) - -const createGroup = (overrides: Partial = {}): AccessControlGroup => - ({ - id: 'group-1', - name: 'Group One', - groupSize: 5, - ...overrides, - }) as AccessControlGroup - -const createMember = (overrides: Partial = {}): AccessControlAccount => - ({ - id: 'member-1', - name: 'Member One', - email: 'member@example.com', - avatar: '', - avatarUrl: '', - ...overrides, - }) as AccessControlAccount - -const baseGroup = createGroup() -const baseMember = createMember() -const groupSubject: Subject = { - subjectId: baseGroup.id, - subjectType: SubjectType.GROUP, - groupData: baseGroup, -} as Subject -const memberSubject: Subject = { - subjectId: baseMember.id, - subjectType: SubjectType.ACCOUNT, - accountData: baseMember, -} as Subject - -beforeAll(() => { - class MockIntersectionObserver { - constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) { - intersectionObserverMocks.callback = callback - } - - observe = vi.fn(() => undefined) - disconnect = vi.fn(() => undefined) - unobserve = vi.fn(() => undefined) - } - // @ts-expect-error test DOM typings do not guarantee IntersectionObserver here - globalThis.IntersectionObserver = MockIntersectionObserver -}) - -beforeEach(() => { - vi.clearAllMocks() - mockMutateAsync.mockResolvedValue(undefined) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: false, - data: { - groups: [baseGroup], - members: [baseMember], - }, - }) - mockUseSearchForWhiteListCandidates.mockReturnValue({ - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: false }] }, - }) -}) - -// AccessControlDialog renders the shared dialog primitive with a close control. describe('AccessControlDialog', () => { it('should render dialog content when visible', () => { render( @@ -145,218 +22,10 @@ describe('AccessControlDialog', () => { , ) - const closeButton = screen.getByRole('button', { name: 'Close' }) - fireEvent.click(closeButton) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) await waitFor(() => { expect(handleClose).toHaveBeenCalledTimes(1) }) }) }) - -// SpecificGroupsOrMembers syncs store state with fetched data and supports removals -describe('SpecificGroupsOrMembers', () => { - it('should render collapsed view when not in specific selection mode', () => { - useAccessControlStore.setState({ currentMenu: AccessMode.ORGANIZATION }) - - render() - - expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() - expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() - }) - - it('should show loading state while pending', async () => { - useAccessControlStore.setState({ - appId: 'app-1', - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - }) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) - - const { container } = render() - - await waitFor(() => { - expect(container.querySelector('.spin-animation')).toBeInTheDocument() - }) - }) - - it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ - appId: 'app-1', - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - }) - - render() - - await waitFor(() => { - expect(screen.getByText(baseGroup.name)).toBeInTheDocument() - expect(screen.getByText(baseMember.name)).toBeInTheDocument() - }) - - const groupRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]! - - fireEvent.click(groupRemove) - - await waitFor(() => { - expect(screen.queryByText(baseGroup.name)).not.toBeInTheDocument() - }) - - const memberRemove = screen.getAllByRole('button', { name: /operation\.remove$/ })[0]! - - fireEvent.click(memberRemove) - - await waitFor(() => { - expect(screen.queryByText(baseMember.name)).not.toBeInTheDocument() - }) - }) -}) - -// AddMemberOrGroupDialog renders search results and updates store selections -describe('AddMemberOrGroupDialog', () => { - it('should open search popover and display candidates', async () => { - const user = userEvent.setup() - - render() - - await user.click(screen.getByText('common.operation.add')) - - expect( - screen.getByPlaceholderText( - 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder', - ), - ).toBeInTheDocument() - expect(screen.getByText(baseGroup.name)).toBeInTheDocument() - expect(screen.getByText(baseMember.name)).toBeInTheDocument() - }) - - it('should allow selecting members and expanding groups', async () => { - const user = userEvent.setup() - render() - - await user.click(screen.getByText('common.operation.add')) - - const expandButton = screen.getByText('app.accessControlDialog.operateGroupAndMember.expand') - await user.click(expandButton) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) - - await user.click(screen.getByRole('option', { name: /Member One/ })) - - expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) - }) - - it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => { - const fetchNextPage = vi.fn() - mockUseSearchForWhiteListCandidates.mockReturnValue({ - isLoading: false, - isFetchingNextPage: true, - fetchNextPage, - data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: true }] }, - }) - - const user = userEvent.setup() - render() - - await user.click(screen.getByText('common.operation.add')) - await user.type( - screen.getByPlaceholderText( - 'app.accessControlDialog.operateGroupAndMember.searchPlaceholder', - ), - 'Group', - ) - expect(document.querySelector('.spin-animation')).toBeInTheDocument() - - const groupOption = screen.getByRole('option', { name: /Group One/ }) - expect(groupOption).not.toHaveAttribute('data-selected') - fireEvent.click(groupOption) - expect(groupOption).toHaveAttribute('data-selected') - fireEvent.click(groupOption) - expect(groupOption).not.toHaveAttribute('data-selected') - - const memberOption = screen.getByRole('option', { name: /Member One/ }) - expect(memberOption).not.toHaveAttribute('data-selected') - fireEvent.click(memberOption) - expect(memberOption).toHaveAttribute('data-selected') - fireEvent.click(memberOption) - expect(memberOption).not.toHaveAttribute('data-selected') - - fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) - fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers')) - - expect(useAccessControlStore.getState().specificGroups).toEqual([]) - expect(useAccessControlStore.getState().specificMembers).toEqual([]) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) - expect(fetchNextPage).not.toHaveBeenCalled() - }) - - it('should show empty state when no candidates are returned', async () => { - mockUseSearchForWhiteListCandidates.mockReturnValue({ - isLoading: false, - isFetchingNextPage: false, - fetchNextPage: vi.fn(), - data: { pages: [] }, - }) - - const user = userEvent.setup() - render() - - await user.click(screen.getByText('common.operation.add')) - - expect(screen.getByRole('status')).toHaveTextContent( - 'app.accessControlDialog.operateGroupAndMember.noResult', - ) - }) -}) - -// AccessControl integrates dialog, selection items, and confirm flow -describe('AccessControl', () => { - it('should initialize menu from app and call update on confirm', async () => { - const onClose = vi.fn() - const onConfirm = vi.fn() - const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') - useAccessControlStore.setState({ - specificGroups: [baseGroup], - specificMembers: [baseMember], - }) - const app = { - id: 'app-id-1', - access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - } as App - - render() - - await waitFor(() => { - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.SPECIFIC_GROUPS_MEMBERS) - }) - - fireEvent.click(screen.getByText('common.operation.confirm')) - - await waitFor(() => { - expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({ - body: { - appId: app.id, - accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - subjects: [ - { subjectId: baseGroup.id, subjectType: SubjectType.GROUP }, - { subjectId: baseMember.id, subjectType: SubjectType.ACCOUNT }, - ], - }, - }) - expect(toastSpy).toHaveBeenCalledWith('app.accessControlDialog.updateSuccess') - expect(onConfirm).toHaveBeenCalled() - }) - }) - - it('should expose the external members tip when SSO is disabled', () => { - const app = { - id: 'app-id-2', - access_mode: AccessMode.PUBLIC, - } as App - - render() - - expect(screen.getByText('app.accessControlDialog.accessItems.external')).toBeInTheDocument() - expect(screen.getByText('app.accessControlDialog.accessItems.anyone')).toBeInTheDocument() - }) -}) diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index d0a3d256587..ca0a2e21468 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,7 +1,8 @@ +import type { AccessControlSubjects } from '../specific-groups-or-members' import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import useAccessControlStore from '@/context/access-control-store' +import { useState } from 'react' import { SubjectType } from '@/models/access-control' import { renderWithAccountProfile as render } from '@/test/console/account-profile' import AddMemberOrGroupDialog from '../add-member-or-group-pop' @@ -34,6 +35,21 @@ const createMember = (overrides: Partial = {}): AccessCont ...overrides, }) as AccessControlAccount +function ControlledDialog({ + onChange = () => {}, +}: { + onChange?: (value: AccessControlSubjects) => void +}) { + const [subjects, setSubjects] = useState({ groups: [], members: [] }) + + const handleChange = (nextSubjects: AccessControlSubjects) => { + setSubjects(nextSubjects) + onChange(nextSubjects) + } + + return +} + describe('AddMemberOrGroupDialog', () => { const baseGroup = createGroup() const baseMember = createMember() @@ -65,13 +81,6 @@ describe('AddMemberOrGroupDialog', () => { beforeEach(() => { vi.clearAllMocks() - useAccessControlStore.setState({ - appId: 'app-1', - specificGroups: [], - specificMembers: [], - currentMenu: SubjectType.GROUP as never, - selectedGroupsForBreadcrumb: [], - }) mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, isFetchingNextPage: false, @@ -84,7 +93,7 @@ describe('AddMemberOrGroupDialog', () => { it('should open the search popover and display candidates', async () => { const user = userEvent.setup() - render() + render() await user.click(screen.getByText('common.operation.add')) @@ -97,18 +106,21 @@ describe('AddMemberOrGroupDialog', () => { expect(screen.getByText(baseMember.name)).toBeInTheDocument() }) - it('should allow expanding groups and selecting members', async () => { + it('should allow expanding groups and report selected members', async () => { const user = userEvent.setup() - render() + const onChange = vi.fn() + render() await user.click(screen.getByText('common.operation.add')) await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([baseGroup]) + expect(mockUseSearchForWhiteListCandidates).toHaveBeenLastCalledWith( + expect.objectContaining({ groupId: baseGroup.id }), + true, + ) await user.click(screen.getByRole('option', { name: /Member One/ })) - - expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember]) + expect(onChange).toHaveBeenCalledWith({ groups: [], members: [baseMember] }) }) it('should show the empty state when no candidates are returned', async () => { @@ -120,7 +132,7 @@ describe('AddMemberOrGroupDialog', () => { }) const user = userEvent.setup() - render() + render() await user.click(screen.getByText('common.operation.add')) @@ -130,37 +142,39 @@ describe('AddMemberOrGroupDialog', () => { }) it('should keep breadcrumbs visible when the current group has no candidates', async () => { - useAccessControlStore.setState({ - selectedGroupsForBreadcrumb: [baseGroup], - }) - mockUseSearchForWhiteListCandidates.mockReturnValue({ + mockUseSearchForWhiteListCandidates.mockImplementation((query: { groupId?: string }) => ({ isLoading: false, isFetchingNextPage: false, fetchNextPage: vi.fn(), - data: { pages: [{ currPage: 1, subjects: [], hasMore: false }] }, - }) + data: { + pages: [ + { + currPage: 1, + subjects: query.groupId ? [] : [groupSubject, memberSubject], + hasMore: false, + }, + ], + }, + })) const user = userEvent.setup() - render() - + render() await user.click(screen.getByText('common.operation.add')) + await user.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand')) - expect( - screen.getByRole('button', { - name: 'app.accessControlDialog.operateGroupAndMember.allMembers', - }), - ).toBeInTheDocument() + const allMembersButton = screen.getByRole('button', { + name: 'app.accessControlDialog.operateGroupAndMember.allMembers', + }) + expect(allMembersButton).toBeInTheDocument() expect(screen.getByText(baseGroup.name)).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent( 'app.accessControlDialog.operateGroupAndMember.noResult', ) - await user.click( - screen.getByRole('button', { - name: 'app.accessControlDialog.operateGroupAndMember.allMembers', - }), + await user.click(allMembersButton) + expect(mockUseSearchForWhiteListCandidates).toHaveBeenLastCalledWith( + expect.objectContaining({ groupId: undefined }), + true, ) - - expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([]) }) }) diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 30bb3d2e75a..4a3463896f0 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -1,9 +1,8 @@ import type { ReactElement } from 'react' import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' import { renderWithConsoleQuery } from '@/test/console/query-data' import AccessControl from '../index' @@ -64,16 +63,12 @@ describe('AccessControl', () => { allow_email_code_login: false, allow_public_access: true, } - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [], - }) mockMutateAsync.mockResolvedValue(undefined) mockUseAppWhiteListSubjects.mockReturnValue({ isPending: false, + isFetching: false, + isError: false, + refetch: vi.fn(), data: { groups: [], members: [], @@ -87,8 +82,8 @@ describe('AccessControl', () => { }) }) - it('should initialize menu from the app and update access mode on confirm', async () => { - const onClose = vi.fn() + it('should initialize the mode from the app and update it on confirm', async () => { + const user = userEvent.setup() const onConfirm = vi.fn() const toastSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success') const app = { @@ -96,14 +91,8 @@ describe('AccessControl', () => { access_mode: AccessMode.PUBLIC, } as App - render() - - await waitFor(() => { - expect(useAccessControlStore.getState().appId).toBe(app.id) - expect(useAccessControlStore.getState().currentMenu).toBe(AccessMode.PUBLIC) - }) - - fireEvent.click(screen.getByText('common.operation.confirm')) + render() + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({ @@ -117,7 +106,77 @@ describe('AccessControl', () => { }) }) - it('should show the external-members option when SSO tip is visible', () => { + it('should submit the successfully loaded specific subjects', async () => { + const user = userEvent.setup() + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + isFetching: false, + isError: false, + refetch: vi.fn(), + data: { + groups: [{ id: 'group-1', name: 'Group', groupSize: 2 }], + members: [ + { + id: 'member-1', + name: 'Member', + email: 'member@example.com', + avatar: '', + avatarUrl: '', + }, + ], + }, + }) + + render( + , + ) + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(mockMutateAsync.mock.calls[0]?.[0]).toEqual({ + body: { + appId: 'app-id-2', + accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS, + subjects: [ + { subjectId: 'group-1', subjectType: 'group' }, + { subjectId: 'member-1', subjectType: 'account' }, + ], + }, + }) + }) + }) + + it('should disable confirmation and preserve the error when specific subjects fail to load', async () => { + const user = userEvent.setup() + const refetch = vi.fn() + mockUseAppWhiteListSubjects.mockReturnValue({ + isPending: false, + isFetching: false, + isError: true, + refetch, + data: undefined, + }) + + render( + , + ) + + expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error') + expect(screen.queryByText('app.accessControlDialog.noGroupsOrMembers')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'common.operation.confirm' })).toBeDisabled() + expect(mockMutateAsync).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(refetch).toHaveBeenCalledTimes(1) + }) + + it('should show the external-members option when the SSO tip is visible', () => { mockWebappAuth = { enabled: false, allow_sso: false, @@ -128,7 +187,7 @@ describe('AccessControl', () => { render( , ) @@ -139,7 +198,7 @@ describe('AccessControl', () => { it('should preserve an unfinished selection when the parent rerenders', async () => { const user = userEvent.setup() - const app = { id: 'app-id-3', access_mode: AccessMode.PUBLIC } as App + const app = { id: 'app-id-5', access_mode: AccessMode.PUBLIC } as App const { rerender } = render() const organization = screen.getByRole('radio', { @@ -149,51 +208,31 @@ describe('AccessControl', () => { expect(organization).toBeChecked() rerender() - expect(organization).toBeChecked() }) - describe('public access control', () => { - it('should render the public option enabled without a tooltip when public access is allowed', () => { - render( - , - ) + it('should disable public access and explain why when it is disabled by the system', () => { + mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, + allow_public_access: false, + } - const publicOption = screen.getByRole('radio', { - name: /app\.accessControlDialog\.accessItems\.anyone/, - }) - expect(publicOption).not.toHaveAttribute('data-disabled') - expect( - screen.queryByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'), - ).not.toBeInTheDocument() - }) + render( + , + ) - it('should render the public option disabled with a tooltip when public access is disabled', () => { - mockWebappAuth = { - enabled: true, - allow_sso: true, - allow_email_password_login: false, - allow_email_code_login: false, - allow_public_access: false, - } - - render( - , - ) - - const publicOption = screen.getByRole('radio', { - name: /app\.accessControlDialog\.accessItems\.anyone/, - }) - expect(publicOption).toHaveAttribute('aria-disabled', 'true') - expect( - screen.getByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'), - ).toBeInTheDocument() + const publicOption = screen.getByRole('radio', { + name: /app\.accessControlDialog\.accessItems\.anyone/, }) + expect(publicOption).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByLabelText('app.accessControlDialog.webAppPublicAccessDisabledTip'), + ).toBeInTheDocument() }) }) diff --git a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx index 9869734179b..ebaab81cbdb 100644 --- a/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/specific-groups-or-members.spec.tsx @@ -1,15 +1,9 @@ import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import useAccessControlStore from '@/context/access-control-store' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { AccessMode } from '@/models/access-control' import SpecificGroupsOrMembers from '../specific-groups-or-members' -const mockUseAppWhiteListSubjects = vi.fn() - -vi.mock('@/service/access-control', () => ({ - useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), -})) - vi.mock('../add-member-or-group-pop', () => ({ default: () =>
, })) @@ -35,67 +29,88 @@ const createMember = (overrides: Partial = {}): AccessCont describe('SpecificGroupsOrMembers', () => { const baseGroup = createGroup() const baseMember = createMember() - - beforeEach(() => { - vi.clearAllMocks() - useAccessControlStore.setState({ - appId: '', - specificGroups: [], - specificMembers: [], - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - selectedGroupsForBreadcrumb: [], - }) - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: false, - data: { - groups: [baseGroup], - members: [baseMember], - }, - }) - }) + const subjects = { + groups: [baseGroup], + members: [baseMember], + } it('should render the collapsed row when not in specific mode', () => { - useAccessControlStore.setState({ - currentMenu: AccessMode.ORGANIZATION, - }) - - render() + render( + , + ) expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument() expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() }) - it('should show loading while whitelist subjects are pending', async () => { - mockUseAppWhiteListSubjects.mockReturnValue({ - isPending: true, - data: undefined, - }) + it('should show loading while whitelist subjects are pending', () => { + const { container } = render( + , + ) - const { container } = render() - - await waitFor(() => { - expect(container.querySelector('.spin-animation')).toBeInTheDocument() - }) + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() }) - it('should render fetched groups and members and support removal', async () => { - useAccessControlStore.setState({ appId: 'app-1' }) + it('should expose the failed load and allow retry without rendering an empty selection', async () => { + const user = userEvent.setup() + const onRetrySubjects = vi.fn() - render() + render( + , + ) - await waitFor(() => { - expect(screen.getByText(baseGroup.name)).toBeInTheDocument() - expect(screen.getByText(baseMember.name)).toBeInTheDocument() - }) + expect(screen.getByRole('alert')).toHaveTextContent('common.dynamicSelect.error') + expect(screen.queryByText('app.accessControlDialog.noGroupsOrMembers')).not.toBeInTheDocument() + expect(screen.queryByTestId('add-member-or-group-dialog')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.retry' })) + expect(onRetrySubjects).toHaveBeenCalledTimes(1) + }) + + it('should render controlled groups and members and report removals', async () => { + const user = userEvent.setup() + const onSubjectsChange = vi.fn() + + render( + , + ) + + expect(screen.getByText(baseGroup.name)).toBeInTheDocument() + expect(screen.getByText(baseMember.name)).toBeInTheDocument() const removeButtons = screen.getAllByRole('button', { name: /operation\.remove$/ }) - const groupRemove = removeButtons[0]! - const memberRemove = removeButtons[1]! + await user.click(removeButtons[0]!) + expect(onSubjectsChange).toHaveBeenCalledWith({ + groups: [], + members: [baseMember], + }) - fireEvent.click(groupRemove) - expect(useAccessControlStore.getState().specificGroups).toEqual([]) - - fireEvent.click(memberRemove) - expect(useAccessControlStore.getState().specificMembers).toEqual([]) + await user.click(removeButtons[1]!) + expect(onSubjectsChange).toHaveBeenCalledWith({ + groups: [baseGroup], + members: [], + }) }) }) diff --git a/web/app/components/app/app-access-control/access-control-form.tsx b/web/app/components/app/app-access-control/access-control-form.tsx new file mode 100644 index 00000000000..84278c4a577 --- /dev/null +++ b/web/app/components/app/app-access-control/access-control-form.tsx @@ -0,0 +1,143 @@ +'use client' + +import type { + AccessControlSubjects, + AccessControlSubjectsStatus, +} from './specific-groups-or-members' +import type { AccessMode } from '@/models/access-control' +import { Button } from '@langgenius/dify-ui/button' +import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { RadioGroup } from '@langgenius/dify-ui/radio' +import { useId } from 'react' +import { useTranslation } from 'react-i18next' +import { AccessMode as AccessModeValue } from '@/models/access-control' +import { Infotip } from '../../base/infotip' +import AccessControlDialog from './access-control-dialog' +import AccessControlItem from './access-control-item' +import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members' + +export type AccessControlFormProps = { + accessMode: AccessMode + subjects: AccessControlSubjects + subjectsStatus: AccessControlSubjectsStatus + updatePending: boolean + publicAccessDisabled: boolean + externalMembersTipHidden: boolean + onAccessModeChange: (accessMode: AccessMode) => void + onSubjectsChange: (subjects: AccessControlSubjects) => void + onRetrySubjects?: () => void + onClose: () => void + onConfirm: () => void +} + +export function AccessControlForm({ + accessMode, + subjects, + subjectsStatus, + updatePending, + publicAccessDisabled, + externalMembersTipHidden, + onAccessModeChange, + onSubjectsChange, + onRetrySubjects, + onClose, + onConfirm, +}: AccessControlFormProps) { + const accessControlOptionsLabelId = useId() + const { t } = useTranslation() + const confirmDisabled = + updatePending || + (accessMode === AccessModeValue.PUBLIC && publicAccessDisabled) || + (accessMode === AccessModeValue.SPECIFIC_GROUPS_MEMBERS && subjectsStatus !== 'success') + + return ( + +
+
+ + {t(($) => $['accessControlDialog.title'], { ns: 'app' })} + + + {t(($) => $['accessControlDialog.description'], { ns: 'app' })} + +
+ + value={accessMode} + onValueChange={onAccessModeChange} + className="flex flex-col items-stretch gap-y-1 px-6 pb-3" + aria-labelledby={accessControlOptionsLabelId} + > +
+

+ {t(($) => $['accessControlDialog.accessLabel'], { ns: 'app' })} +

+
+ +
+
+
+
+
+ + + + +
+
+
+ {!externalMembersTipHidden && } +
+
+ +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop.tsx deleted file mode 100644 index 43bced6ea7c..00000000000 --- a/web/app/components/app/app-access-control/add-member-or-group-pop.tsx +++ /dev/null @@ -1,407 +0,0 @@ -'use client' - -import type { ComboboxChangeEventDetails } from '@langgenius/dify-ui/combobox' -import type { - AccessControlAccount, - AccessControlGroup, - Subject, - SubjectAccount, - SubjectGroup, -} from '@/models/access-control' -import { Avatar } from '@langgenius/dify-ui/avatar' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - Combobox, - ComboboxContent, - ComboboxEmpty, - ComboboxInput, - ComboboxInputGroup, - ComboboxItem, - ComboboxItemText, - ComboboxList, - ComboboxStatus, - ComboboxTrigger, -} from '@langgenius/dify-ui/combobox' -import { RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useDebounce } from 'ahooks' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { SubjectType } from '@/models/access-control' -import { useSearchForWhiteListCandidates } from '@/service/access-control' -import useAccessControlStore from '../../../../context/access-control-store' -import Loading from '../../base/loading' - -export default function AddMemberOrGroupDialog() { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const [keyword, setKeyword] = useState('') - const scrollRootRef = useRef(null) - const anchorRef = useRef(null) - const specificGroups = useAccessControlStore((s) => s.specificGroups) - const setSpecificGroups = useAccessControlStore((s) => s.setSpecificGroups) - const specificMembers = useAccessControlStore((s) => s.specificMembers) - const setSpecificMembers = useAccessControlStore((s) => s.setSpecificMembers) - const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb) - const debouncedKeyword = useDebounce(keyword, { wait: 500 }) - - const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1] - const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates( - { keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, - open, - ) - const pages = data?.pages ?? [] - const subjects = pages.flatMap((page) => page.subjects ?? []) - const selectedSubjects = [ - ...specificGroups.map(groupToSubject), - ...specificMembers.map(memberToSubject), - ] - const hasResults = pages.length > 0 && subjects.length > 0 - const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0 - const hasMore = pages[pages.length - 1]?.hasMore ?? false - - useEffect(() => { - let observer: IntersectionObserver | undefined - if (anchorRef.current) { - observer = new IntersectionObserver( - (entries) => { - if (entries[0]!.isIntersecting && !isLoading && hasMore) fetchNextPage() - }, - { root: scrollRootRef.current, rootMargin: '20px' }, - ) - observer.observe(anchorRef.current) - } - return () => observer?.disconnect() - }, [isLoading, fetchNextPage, hasMore]) - - const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen) setKeyword('') - - setOpen(nextOpen) - } - - const handleInputValueChange = (inputValue: string, details: ComboboxChangeEventDetails) => { - if (details.reason !== 'item-press') setKeyword(inputValue) - } - - const handleValueChange = (nextSubjects: Subject[]) => { - const nextGroups: AccessControlGroup[] = [] - const nextMembers: AccessControlAccount[] = [] - - for (const subject of nextSubjects) { - if (subject.subjectType === SubjectType.GROUP) - nextGroups.push((subject as SubjectGroup).groupData) - else nextMembers.push((subject as SubjectAccount).accountData) - } - - setSpecificGroups(nextGroups) - setSpecificMembers(nextMembers) - } - - return ( - - multiple - open={open} - value={selectedSubjects} - inputValue={keyword} - items={subjects} - itemToStringLabel={getSubjectLabel} - itemToStringValue={getSubjectValue} - isItemEqualToValue={isSameSubject} - filter={null} - onOpenChange={handleOpenChange} - onInputValueChange={handleInputValueChange} - onValueChange={handleValueChange} - > - $['operation.add'], { ns: 'common' })} - icon={false} - size="small" - className="h-6 w-auto min-w-13 shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover" - > - - - - -
-
- - -
- {isLoading ? ( - - - - ) : ( - <> - {shouldShowBreadcrumb && ( -
- -
- )} - {hasResults ? ( - <> - className="max-h-none p-1"> - {(subject) => } - - {isFetchingNextPage && } -
- - ) : ( - - {t(($) => $['accessControlDialog.operateGroupAndMember.noResult'], { ns: 'app' })} - - )} - - )} -
- - - ) -} - -function groupToSubject(group: AccessControlGroup): SubjectGroup { - return { - subjectId: group.id, - subjectType: SubjectType.GROUP, - groupData: group, - } -} - -function memberToSubject(member: AccessControlAccount): SubjectAccount { - return { - subjectId: member.id, - subjectType: SubjectType.ACCOUNT, - accountData: member, - } -} - -function getSubjectLabel(subject: Subject) { - if (subject.subjectType === SubjectType.GROUP) return (subject as SubjectGroup).groupData.name - - return (subject as SubjectAccount).accountData.name -} - -function getSubjectValue(subject: Subject) { - return `${subject.subjectType}:${subject.subjectId}` -} - -function isSameSubject(item: Subject, value: Subject) { - return item.subjectId === value.subjectId && item.subjectType === value.subjectType -} - -function SubjectItem({ subject }: { subject: Subject }) { - if (subject.subjectType === SubjectType.GROUP) - return - - return -} - -function SelectedGroupsBreadCrumb() { - const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb) - const setSelectedGroupsForBreadcrumb = useAccessControlStore( - (s) => s.setSelectedGroupsForBreadcrumb, - ) - const { t } = useTranslation() - - const handleBreadCrumbClick = (index: number) => { - const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1) - setSelectedGroupsForBreadcrumb(newGroups) - } - const handleReset = () => { - setSelectedGroupsForBreadcrumb([]) - } - const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0 - - return ( -
- {hasBreadcrumb ? ( - - ) : ( - - {t(($) => $['accessControlDialog.operateGroupAndMember.allMembers'], { ns: 'app' })} - - )} - {selectedGroupsForBreadcrumb.map((group, index) => { - const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1 - - return ( -
- / - {isLastGroup ? ( - {group.name} - ) : ( - - )} -
- ) - })} -
- ) -} - -type GroupItemProps = { - group: AccessControlGroup - subject: Subject -} -function GroupItem({ group, subject }: GroupItemProps) { - const { t } = useTranslation() - const specificGroups = useAccessControlStore((s) => s.specificGroups) - const selectedGroupsForBreadcrumb = useAccessControlStore((s) => s.selectedGroupsForBreadcrumb) - const setSelectedGroupsForBreadcrumb = useAccessControlStore( - (s) => s.setSelectedGroupsForBreadcrumb, - ) - const isChecked = specificGroups.some((g) => g.id === group.id) - - const handleExpandClick = () => { - setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group]) - } - - return ( -
- - {(selected) => ( - <> - - -
-
-
-
- {group.name} - {group.groupSize} -
- - )} -
- -
- ) -} - -type MemberItemProps = { - member: AccessControlAccount - subject: Subject -} -function MemberItem({ member, subject }: MemberItemProps) { - const { data: currentUser } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile, - }) - const { t } = useTranslation() - return ( - - {(selected) => ( - <> - - -
-
- -
-
- {member.name} - {currentUser.email === member.email && ( - - ({t(($) => $.you, { ns: 'common' })}) - - )} -
- {member.email} - - )} -
- ) -} - -type BaseItemProps = { - className?: string - subject: Subject - children: (selected: boolean) => React.ReactNode -} -function BaseItem({ children, className, subject }: BaseItemProps) { - return ( - ( -
- {children(state.selected)} -
- )} - /> - ) -} - -function SelectionBox({ checked }: { checked: boolean }) { - return ( -
} + isEnvironmentInUse={false} + isDeploymentError={false} + isDeploymentLoading={false} + latestVersion={null} + onGoToPublish={onGoToPublish} + />, + ) + + expect(screen.getByText('No published versions yet')).toBeInTheDocument() + expect( + screen.getByText('Publish the app before deploying it to an environment.'), + ).toBeInTheDocument() + expect(screen.queryByText('Not deployed yet')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Deploy latest' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'All versions' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Go to publish' })) + expect(onGoToPublish).toHaveBeenCalledOnce() + }) + + it('offers the latest version actions when an undeployed environment has a published version', async () => { + const user = userEvent.setup() + const queryClient = createFlowQueryClient('development') + + render( + Environment tabs
} + isEnvironmentInUse={false} + isDeploymentError={false} + isDeploymentLoading={false} + latestVersion={latestVersion} + onGoToPublish={vi.fn()} + />, + { queryClient }, + ) + + expect(screen.getByText('Not deployed yet')).toBeInTheDocument() + expect( + screen.queryByText('Publish the app before deploying it to an environment.'), + ).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Go to publish' })).not.toBeInTheDocument() + expect(screen.getByText('Latest').parentElement).toHaveTextContent( + `Latest: ${latestVersion.name}`, + ) + + await user.click(screen.getByRole('button', { name: 'All versions' })) + expect(screen.getByRole('heading', { name: 'Deploy to Development' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Release 6/ })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /#5/ })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Back' })) + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument() + expect(screen.getByText(latestVersion.name)).toBeInTheDocument() + }) + + it('opens all versions and returns to the original publisher', async () => { + const user = userEvent.setup() + renderFlow() + + await user.click(screen.getByRole('button', { name: 'All versions' })) + + expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Back' })) + + expect(screen.getByText('Environment tabs')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'All versions' })).toBeInTheDocument() + }) + + it.each([ + DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + ])( + 'disables deployment triggers but keeps environment navigation available while the status is %s', + (status) => { + renderFlow(createDeployment({ deployed: false, status })) + + const deployButtonName = + status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING ? 'Deploying...' : 'Deploy latest' + expect(screen.getByRole('button', { name: deployButtonName })).toBeDisabled() + expect(screen.getByRole('button', { name: 'All versions' })).toBeDisabled() + expect(screen.getByRole('link', { name: 'Access Point' })).toHaveAttribute( + 'href', + '/app/app-1/access-point?environment=staging', + ) + expect(screen.getByRole('link', { name: 'Deploy' })).toHaveAttribute( + 'href', + '/app/app-1/deploy?environment=staging', + ) + }, + ) + + it('keeps the deployment target and progress controls when a deploying status refresh fails', () => { + const deployment = createDeployment({ + status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + }) + deployment.deployment!.latest_operation = { + activity_at: 1_785_456_000, + id: 'operation-staging', + operator: { + display_name: 'Evan', + id: 'user-1', + type: OperatorType.OPERATOR_TYPE_ACCOUNT, + }, + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + target_version: { + id: latestVersion.id, + marked_comment: latestVersion.description ?? '', + marked_name: latestVersion.name, + version: latestVersion.name, + }, + type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY, + } + + renderFlow(deployment, { isDeploymentError: true }) + + expect(screen.getByRole('button', { name: 'Deploying...' })).toBeDisabled() + expect(screen.getByText(`Deploying: ${latestVersion.name}`)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'All versions' })).toBeDisabled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Deploy latest' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Deploy another version' })).not.toBeInTheDocument() + }) + + it('keeps current and latest version information when a status refresh fails outside deployment', () => { + renderFlow(createDeployment(), { isDeploymentError: true }) + + expect(screen.getByText('Sprint-42')).toBeInTheDocument() + expect(screen.getByText('Latest').parentElement).toHaveTextContent( + `Latest: ${latestVersion.name}`, + ) + expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled() + expect(screen.queryByText('Deploying...')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it.each([ + DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYED, + DeploymentStatus.DEPLOYMENT_STATUS_FAILED, + ])('shows the undeployed state when terminal status %s has no current version', (status) => { + renderFlow(createDeployment({ deployed: false, status })) + + expect(screen.getByText('Not deployed yet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'All versions' })).toBeEnabled() + }) + + it('deploys the latest version directly and goes back to version selection', async () => { + const user = userEvent.setup() + renderFlow() + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + + expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument() + expect(screen.getByText(latestVersion.name)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Back' })) + + expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument() + }) + + it('hides the environment variables section when deployment options have no slots', async () => { + const user = userEvent.setup() + renderFlow() + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + + expect(screen.getByRole('button', { name: 'Deploy' })).toBeEnabled() + expect(screen.queryByRole('heading', { name: 'Environment Variables' })).not.toBeInTheDocument() + }) + + it('hides the credentials section when deployment options only have environment variables', async () => { + const user = userEvent.setup() + const view = renderFlow() + const deploymentOptionsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions.queryOptions( + { + input: { + params: { + app_id: 'app-1', + environment_id: 'staging', + workflow_id: latestVersion.id, + }, + }, + retry: false, + }, + ) + view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, { + credential_slots: [], + environment_variable_slots: [ + { + configured_value: 'production', + description: '', + has_configured_value: true, + has_last_deployed_value: false, + key: 'ENVIRONMENT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + }, + ], + }) + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + + expect(screen.queryByRole('heading', { name: 'Credentials' })).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Environment Variables' })).toBeInTheDocument() + }) + + it('shows unsupported node titles when the latest version fails precheck', async () => { + const user = userEvent.setup() + const view = renderFlow() + const precheckQuery = + consoleQuery.enterprise.appDeploy.deploymentService.precheckWorkflowDeployment.queryOptions({ + input: { + params: { + app_id: 'app-1', + workflow_id: latestVersion.id, + }, + }, + retry: false, + }) + view.queryClient.setQueryData(precheckQuery.queryKey, { + unsupported_nodes: [ + { + id: 'knowledge-node', + title: 'Knowledge Retrieval', + type: 'knowledge-retrieval', + }, + { + id: 'notion-node', + provider: { + plugin_id: 'langgenius/notion', + provider_id: 'notion', + provider_name: 'Notion', + provider_type: 'mcp', + }, + title: 'Notion MCP', + type: 'tool', + }, + ], + }) + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("This version can't be deployed to this environment") + expect(within(alert).getByText('Knowledge Retrieval')).toBeInTheDocument() + expect(within(alert).getByText('Notion MCP')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Deploy' })).toBeDisabled() + }) + + it('submits the latest version through the deployment API', async () => { + const user = userEvent.setup() + const requests = captureDeploymentRequests() + renderFlow() + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + await user.click(screen.getByRole('button', { name: 'Deploy' })) + + await expectDeploymentRequest(requests, latestVersion.id) + expect(await screen.findByRole('button', { name: 'All versions' })).toBeInTheDocument() + }) + + it('polls the environment deployment after submit and refreshes environments on success', async () => { + const user = userEvent.setup() + const requests = captureDeploymentRequests() + renderFlowWithPolling() + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + await user.click(screen.getByRole('button', { name: 'Deploy' })) + + await waitFor(() => { + expect( + requests.some( + (request) => + request.method === 'GET' && + new URL(request.url).pathname.endsWith( + '/enterprise/app-deploy/apps/app-1/workflows/environment-deployments/staging', + ), + ), + ).toBe(true) + }) + await waitFor(() => { + expect( + requests.some( + (request) => + request.method === 'GET' && + new URL(request.url).pathname.endsWith( + '/enterprise/app-deploy/apps/app-1/environments', + ), + ), + ).toBe(true) + }) + expect(screen.getByText('Polling: none')).toBeInTheDocument() + }) + + it('submits a version selected from all versions through the deployment API', async () => { + const user = userEvent.setup() + const requests = captureDeploymentRequests() + renderFlow() + + await user.click(screen.getByRole('button', { name: 'All versions' })) + await user.click(screen.getByRole('button', { name: /Release 6/ })) + await user.click(screen.getByRole('button', { name: 'Deploy' })) + + await expectDeploymentRequest(requests, 'workflow-version-6') + expect(await screen.findByRole('button', { name: 'All versions' })).toBeInTheDocument() + }) + + it('disables deploy latest and submits another selected version when already latest', async () => { + const user = userEvent.setup() + const requests = captureDeploymentRequests() + renderFlow(createDeployment({ latest: true })) + + expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeDisabled() + expect(screen.getByText('Latest')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'All versions' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Deploy another version' })) + await user.click(screen.getByRole('button', { name: /Sprint-35/ })) + + expect(screen.getByRole('heading', { name: 'Deploy configuration' })).toBeInTheDocument() + expect(screen.getByText('Sprint-35')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Deploy' })) + + await expectDeploymentRequest(requests, 'sprint-35') + expect( + await screen.findByRole('button', { name: 'Deploy another version' }), + ).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/environment-tabs.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-tabs.spec.tsx new file mode 100644 index 00000000000..18b84f117d9 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/environment-tabs.spec.tsx @@ -0,0 +1,185 @@ +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { renderWithConsoleQuery as render } from '@/test/console/query-data' +import { PublisherEnvironmentTabs } from '../environment-tabs' +import { BUILT_IN_ENVIRONMENT_ID } from '../state' + +const environments = [ + { id: 'staging', name: 'Staging' }, + { id: 'pre-release', name: 'Pre-release' }, + { id: 'testing', name: 'Testing' }, + { id: 'demo', name: 'Demo' }, + { id: 'us-prod', name: 'US-Prod' }, +] as const + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock({ + 'common.operation.more': 'More', + 'deployments.card.notDeployed': 'Not deployed', + 'deployments.studio.environments': 'Environments', + 'deployments.studio.moreEnvironments': 'More environments', + 'workflow.nodes.common.memories.builtIn': 'Built-in', + }) +}) + +function EnvironmentTabsHarness({ + initialJoinedEnvironmentIds = [], + initialSelectedEnvironmentId = BUILT_IN_ENVIRONMENT_ID, +}: { + initialJoinedEnvironmentIds?: string[] + initialSelectedEnvironmentId?: string +}) { + const [joinedEnvironmentIds, setJoinedEnvironmentIds] = useState(initialJoinedEnvironmentIds) + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(initialSelectedEnvironmentId) + + return ( + { + setJoinedEnvironmentIds((currentEnvironmentIds) => [ + ...currentEnvironmentIds, + environmentId, + ]) + setSelectedEnvironmentId(environmentId) + }} + onSelectEnvironment={setSelectedEnvironmentId} + /> + ) +} + +describe('PublisherEnvironmentTabs', () => { + it('uses More environments as the only add entry, then adds and selects an environment', async () => { + const user = userEvent.setup() + render() + + const environmentGroup = screen.getByRole('group', { name: 'Environments' }) + expect(within(environmentGroup).queryByRole('tab')).not.toBeInTheDocument() + expect(screen.queryByRole('tablist')).not.toBeInTheDocument() + expect(within(environmentGroup).getByRole('button', { name: 'Built-in' })).toHaveAttribute( + 'aria-current', + 'true', + ) + + await user.click(screen.getByRole('button', { name: 'More environments' })) + + const menu = screen.getByRole('menu') + expect(within(menu).getByText('Not deployed')).toBeInTheDocument() + expect( + within(menu) + .getAllByRole('menuitem') + .map((item) => item.textContent), + ).toEqual(['Staging', 'Pre-release', 'Testing', 'Demo', 'US-Prod']) + + await user.click(within(menu).getByRole('menuitem', { name: 'Staging' })) + + expect(screen.getByRole('button', { name: 'Staging' })).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: 'Built-in' })).not.toHaveAttribute('aria-current') + expect(screen.getByRole('button', { name: 'More' })).toBeInTheDocument() + }) + + it('keeps fixed visible environments in place and marks a selected overflow environment', async () => { + const user = userEvent.setup() + render( + , + ) + + const environmentGroup = screen.getByRole('group', { name: 'Environments' }) + expect( + within(environmentGroup) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['Built-in', 'Staging', 'Pre-release', 'More']) + + await user.click(screen.getByRole('button', { name: 'More' })) + await user.click(screen.getByRole('menuitem', { name: 'Testing' })) + + expect( + within(environmentGroup) + .getAllByRole('button') + .map((button) => button.textContent), + ).toEqual(['Built-in', 'Staging', 'Pre-release', 'Testing']) + expect(screen.getByRole('button', { name: 'Testing' })).toHaveAttribute('aria-current', 'true') + expect( + within(environmentGroup) + .getAllByRole('button') + .filter((button) => button.hasAttribute('aria-current')) + .map((button) => button.textContent), + ).toEqual(['Testing']) + + await user.click(screen.getByRole('button', { name: 'Testing' })) + const menu = screen.getByRole('menu') + expect(within(menu).queryByRole('menuitem', { name: 'Testing' })).not.toBeInTheDocument() + expect( + within(menu) + .getAllByRole('menuitem') + .map((item) => item.textContent), + ).toEqual(['Demo', 'US-Prod']) + }) + + it('removes the not-deployed section when every environment has joined', async () => { + const user = userEvent.setup() + render( + environment.id)} + initialSelectedEnvironmentId="testing" + />, + ) + + await user.click(screen.getByRole('button', { name: 'Testing' })) + + const menu = screen.getByRole('menu') + expect(within(menu).queryByText('Not deployed')).not.toBeInTheDocument() + expect( + within(menu) + .getAllByRole('menuitem') + .map((item) => item.textContent), + ).toEqual(['Demo', 'US-Prod']) + }) + + it('does not render More when every joined environment fits', () => { + render( + , + ) + + expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([ + 'Built-in', + 'Staging', + 'Pre-release', + ]) + expect(screen.queryByRole('button', { name: 'More' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'More environments' })).not.toBeInTheDocument() + }) + + it('shows the full environment name in a tooltip when its button is truncated', async () => { + const user = userEvent.setup() + const longEnvironment = { + id: 'long-production', + name: 'Production environment with a very long name', + } + render( + , + ) + + await user.hover(screen.getByRole('button', { name: longEnvironment.name })) + + expect(await screen.findByRole('tooltip')).toHaveTextContent(longEnvironment.name) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx b/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx index 608edbdf794..a083e594408 100644 --- a/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/features-wrapper.spec.tsx @@ -57,6 +57,7 @@ vi.mock('@/app/components/base/features/hooks', () => ({ })) describe('FeaturesWrappedAppPublisher', () => { + const resetAppConfig = vi.fn() const publishedConfig = { modelConfig: { more_like_this: { enabled: true }, @@ -81,7 +82,6 @@ describe('FeaturesWrappedAppPublisher', () => { allowed_file_upload_methods: ['remote_url'], number_limits: 5, }, - resetAppConfig: vi.fn(), }, } @@ -106,13 +106,18 @@ describe('FeaturesWrappedAppPublisher', () => { }) it('should restore published features after confirmation', async () => { - render() + render( + , + ) fireEvent.click(screen.getByText('restore-through-wrapper')) fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) await waitFor(() => { - expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1) + expect(resetAppConfig).toHaveBeenCalledTimes(1) expect(mockSetFeatures).toHaveBeenCalledWith( expect.objectContaining({ moreLikeThis: { enabled: true }, @@ -133,7 +138,12 @@ describe('FeaturesWrappedAppPublisher', () => { }) it('should close restore confirmation without restoring when cancelled', async () => { - render() + render( + , + ) fireEvent.click(screen.getByText('restore-through-wrapper')) const dialog = screen.getByRole('alertdialog') @@ -145,7 +155,7 @@ describe('FeaturesWrappedAppPublisher', () => { await waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) - expect(publishedConfig.modelConfig.resetAppConfig).not.toHaveBeenCalled() + expect(resetAppConfig).not.toHaveBeenCalled() expect(mockSetFeatures).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 1a476c41a10..556cfa1aace 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -1,9 +1,18 @@ /* oxlint-disable typescript/no-explicit-any */ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { + DeploymentStatus, + EnvironmentStatus, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' +import { WorkflowContext } from '@/app/components/workflow/context' import { AccessMode } from '@/models/access-control' -import { renderWithConsoleQuery } from '@/test/console/query-data' +import { consoleQuery } from '@/service/client' +import { appWorkflowVersionsInfiniteQueryOptions } from '@/service/workflow-queries' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' +import { AppACLPermission } from '@/utils/permission' import { basePath } from '@/utils/var' import { AppPublisher } from '../index' @@ -14,26 +23,40 @@ const render = (ui: React.ReactElement) => const mockOnPublish = vi.fn() const mockOnToggle = vi.fn() -const mockSetAppDetail = vi.fn() const mockTrackEvent = vi.fn() const mockRefetch = vi.fn() const mockUseGetUserCanAccessApp = vi.fn() -const mockOpenAsyncWindow = vi.fn() -const mockFetchInstalledAppList = vi.fn() -const mockFetchAppDetail = vi.fn() const mockToastError = vi.fn() +const mockToastSuccess = vi.fn() const mockWindowOpen = vi.fn() const mockInvalidateAppWorkflow = vi.fn() +const mockUpdateWorkflow = vi.fn() +const mockFetchPublishedWorkflow = vi.fn() +let mockPublishedWorkflow: Record | null = null +let mockPublishedWorkflowQueryState = { + isError: false, + isLoading: false, + isSuccess: true, +} const sectionProps = vi.hoisted(() => ({ summary: null as null | Record, - access: null as null | Record, actions: null as null | Record, })) const hotkeyMocks = vi.hoisted(() => ({ hotkeys: [] as string[], handlers: [] as Array<(event: { preventDefault: () => void }) => void>, })) +const collaborationMocks = vi.hoisted(() => ({ + handler: undefined as + | ((update: { + type: 'app_publish_update' + userId: string + data: Record + timestamp: number + }) => void) + | undefined, +})) let mockAppDetail: Record | null = null let mockWorkspacePermissionKeys: string[] = ['tool.manage'] @@ -46,16 +69,8 @@ vi.mock('@tanstack/react-hotkeys', () => ({ })) vi.mock('@/app/components/app/store', () => ({ - useStore: ( - selector: (state: { - appDetail: Record | null - setAppDetail: typeof mockSetAppDetail - }) => unknown, - ) => - selector({ - appDetail: mockAppDetail, - setAppDetail: mockSetAppDetail, - }), + useStore: (selector: (state: { appDetail: Record | null }) => unknown) => + selector({ appDetail: mockAppDetail }), })) vi.mock('@/hooks/use-format-time-from-now', () => ({ @@ -64,10 +79,6 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ }), })) -vi.mock('@/hooks/use-async-window-open', () => ({ - useAsyncWindowOpen: () => mockOpenAsyncWindow, -})) - vi.mock('@/service/access-control/use-app-access-control', () => ({ useGetUserCanAccessApp: (params: unknown) => { mockUseGetUserCanAccessApp(params) @@ -83,19 +94,40 @@ vi.mock('@/service/access-control/use-app-access-control', () => ({ }), })) -vi.mock('@/service/explore', () => ({ - fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), -})) - const mockPublishToCreatorsPlatform = vi.fn() vi.mock('@/service/apps', () => ({ - fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), })) vi.mock('@/service/use-workflow', () => ({ + useAppWorkflow: () => ({ + data: mockPublishedWorkflow, + ...mockPublishedWorkflowQueryState, + }), useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, + useUpdateWorkflow: () => ({ mutate: mockUpdateWorkflow }), +})) + +vi.mock('@/service/workflow-queries', async (importOriginal) => { + const original = await importOriginal() + + return { + ...original, + appWorkflowQueryOptions: (appId: string) => ({ + queryKey: ['workflow', 'publish', appId], + queryFn: () => mockFetchPublishedWorkflow(appId), + }), + } +}) + +vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + onAppPublishUpdate: (handler: NonNullable<(typeof collaborationMocks)['handler']>) => { + collaborationMocks.handler = handler + return vi.fn() + }, + }, })) vi.mock('@/service/use-tools', () => ({ @@ -110,6 +142,7 @@ vi.mock('@/service/use-tools', () => ({ vi.mock('@/context/workspace-state', async () => { const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') return createWorkspaceStateModuleMock(() => ({ + isCurrentWorkspaceEditor: false, isCurrentWorkspaceManager: true, workspacePermissionKeys: mockWorkspacePermissionKeys, })) @@ -125,6 +158,7 @@ vi.mock('@/context/permission-state', async () => { vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { error: (...args: unknown[]) => mockToastError(...args), + success: (...args: unknown[]) => mockToastSuccess(...args), }, })) @@ -132,36 +166,6 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), })) -vi.mock('@/app/components/app/overview/embedded', () => ({ - default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => - isShow ? ( -
- embedded modal - -
- ) : null, -})) - -vi.mock('../../app-access-control', () => { - const MockAccessControl = ({ - onConfirm, - onClose, - }: { - onConfirm: () => Promise - onClose: () => void - }) => ( -
- - -
- ) - - return { - default: MockAccessControl, - AccessControl: MockAccessControl, - } -}) - vi.mock('@/app/components/tools/workflow-tool', () => ({ WorkflowToolDrawer: ({ onHide }: { onHide: () => void }) => (
@@ -171,35 +175,43 @@ vi.mock('@/app/components/tools/workflow-tool', () => ({ ), })) -vi.mock('../sections', () => ({ +vi.mock('../built-in-publisher/summary-section', () => ({ PublisherSummarySection: (props: Record) => { sectionProps.summary = props return (
- - + {props.environmentTabs} + + +
) }, - PublisherAccessSection: (props: Record) => { - sectionProps.access = props - return - }, +})) + +vi.mock('../built-in-publisher/actions-section', () => ({ PublisherActionsSection: (props: Record) => { sectionProps.actions = props return ( -
- - - {props.handleOpenRunConfig && ( - <> - - - +
+ {props.showRunConfig && props.handleOpenRunConfig && ( + + )} + {props.showMarketplaceAction && ( + )}
@@ -212,31 +224,31 @@ describe('AppPublisher', () => { vi.clearAllMocks() hotkeyMocks.hotkeys.length = 0 hotkeyMocks.handlers.length = 0 + collaborationMocks.handler = undefined sectionProps.summary = null - sectionProps.access = null sectionProps.actions = null + mockPublishedWorkflow = null + mockPublishedWorkflowQueryState = { + isError: false, + isLoading: false, + isSuccess: true, + } + mockFetchPublishedWorkflow.mockResolvedValue(null) mockWorkspacePermissionKeys = ['tool.manage'] mockAppDetail = { id: 'app-1', name: 'Demo App', mode: AppModeEnum.CHAT, + maintainer: 'user-2', + permission_keys: [], access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, site: { app_base_url: 'https://example.com', access_token: 'token-1', }, } - mockFetchInstalledAppList.mockResolvedValue({ - installed_apps: [{ id: 'installed-1' }], - }) - mockFetchAppDetail.mockResolvedValue({ - id: 'app-1', - access_mode: AccessMode.PUBLIC, - }) - mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { - return resolver() - }) Object.defineProperty(window, 'open', { + configurable: true, writable: true, value: mockWindowOpen, }) @@ -259,7 +271,15 @@ describe('AppPublisher', () => { expect(mockRefetch).not.toHaveBeenCalled() }) - it('should publish and track the publish event', async () => { + it('should not render Web app access control in the publish popover', () => { + render() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + + expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument() + }) + + it('should publish once per open session and reset the lock after reopening', async () => { mockOnPublish.mockResolvedValue(undefined) render() @@ -277,19 +297,455 @@ describe('AppPublisher', () => { app_name: 'Demo App', }), ) + expect(sectionProps.summary?.published).toBe(true) + expect(screen.getByText('publisher-summary-publish')).toBeDisabled() + }) + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.summary?.published).toBe(false) + expect(screen.getByText('publisher-summary-publish')).toBeEnabled() + }) + + it('should refresh deployment data after publishing when multi-environment deployment is available', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.Deploy], + } + mockOnPublish.mockResolvedValue(undefined) + const environmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + enabled: true, + input: { + params: { + app_id: 'app-1', + }, + }, + }) + queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) + queryClient.setQueryData(environmentsQuery.queryKey, { data: [] }) + + renderWithConsoleQuery(, { + queryClient, + }) + + await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + await user.click(screen.getByText('publisher-summary-publish')) + + const workflowVersionsQuery = appWorkflowVersionsInfiniteQueryOptions('app-1') + const environmentDeploymentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { + params: { + app_id: 'app-1', + }, + }, + }) + await waitFor(() => { + expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1') + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: workflowVersionsQuery.queryKey, + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: environmentDeploymentsQuery.queryKey, + }) }) }) - it('should open the embedded modal from the actions section', () => { - render() + it('should not refresh deployment data after publishing without multi-environment deployment', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: [], + } + mockOnPublish.mockResolvedValue(undefined) - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-embed')) + renderWithConsoleQuery(, { + queryClient, + }) - expect(screen.getByTestId('embedded-modal'))!.toBeInTheDocument() + await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + await user.click(screen.getByText('publisher-summary-publish')) + + await waitFor(() => { + expect(mockOnPublish).toHaveBeenCalledTimes(1) + }) + expect(invalidateQueries).not.toHaveBeenCalled() }) - it('should collect hidden inputs before opening published run links from config actions', async () => { + it('should edit the current workflow version from the publish summary', async () => { + const user = userEvent.setup() + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockPublishedWorkflow = { + created_at: Math.floor(Date.now() / 1000), + id: 'workflow-version-5', + marked_name: 'Release 5', + marked_comment: 'Initial notes', + } + + render() + + await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.summary).toEqual( + expect.objectContaining({ + isWorkflowApp: true, + versionInfo: mockPublishedWorkflow, + }), + ) + await user.click(screen.getByText('publisher-summary-edit-version')) + + expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() + const [titleInput, notesInput] = screen.getAllByRole('textbox') + await user.clear(titleInput!) + await user.type(titleInput!, 'Release 6') + await user.clear(notesInput!) + await user.type(notesInput!, 'Updated notes') + await user.click( + screen.getByRole('button', { + name: /(?:^|\.)operation\.save(?=$|:)/, + }), + ) + + expect(mockUpdateWorkflow).toHaveBeenCalledWith( + { + url: '/apps/app-1/workflows/workflow-version-5', + title: 'Release 6', + releaseNotes: 'Updated notes', + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + onSettled: expect.any(Function), + }), + ) + + const mutationCallbacks = mockUpdateWorkflow.mock.calls[0]![1] + mutationCallbacks.onSuccess() + expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1') + expect(mockToastSuccess).toHaveBeenCalled() + }) + + it('should expose app deployment with deploy ACL regardless of the legacy workspace role', () => { + const queryClient = createConsoleQueryClient() + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.Deploy], + } + const environmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + enabled: true, + input: { + params: { + app_id: 'app-1', + }, + }, + }) + queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) + queryClient.setQueryData(environmentsQuery.queryKey, { data: [] }) + + renderWithConsoleQuery(, { + queryClient, + systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: false }, + }) + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + + expect(sectionProps.actions?.showDeployAction).toBe(true) + expect(sectionProps.actions?.appURL).toContain('/workflow/token-1') + expect(screen.getByRole('group', { name: /studio\.environments/ })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: /nodes\.common\.memories\.builtIn/ }), + ).toHaveAttribute('aria-current', 'true') + }) + + it('should keep the workflow publisher single-environment without app deploy ACL', () => { + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: [], + } + + renderWithConsoleQuery(, { + systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: false }, + }) + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + + expect(sectionProps.actions?.showDeployAction).toBe(false) + expect(screen.queryByRole('group', { name: /studio\.environments/ })).not.toBeInTheDocument() + expect(sectionProps.summary?.environmentTabs).toBeUndefined() + }) + + it('should keep the single-environment publisher for unsupported app types', () => { + renderWithConsoleQuery(, { + systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true }, + }) + + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + + expect(screen.queryByRole('group', { name: /studio\.environments/ })).not.toBeInTheDocument() + expect(sectionProps.summary?.environmentTabs).toBeUndefined() + }) + + it('should show the matching publisher state for an undeployed environment', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + const publishedAt = Date.now() + const environmentDeploymentDetailRequests: Request[] = [] + const environmentDeploymentListRequests: Request[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const pathname = new URL(request.url).pathname + if ( + pathname.endsWith('/enterprise/app-deploy/apps/app-1/workflows/environment-deployments') + ) { + environmentDeploymentListRequests.push(request.clone()) + return new Response( + JSON.stringify({ + environment_deployments: [], + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + if ( + pathname.endsWith( + '/enterprise/app-deploy/apps/app-1/workflows/environment-deployments/pre-release', + ) + ) { + environmentDeploymentDetailRequests.push(request.clone()) + throw new Error('The undeployed environment detail endpoint must not be queried.') + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + mockPublishedWorkflowQueryState = { + isError: false, + isLoading: true, + isSuccess: false, + } + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.Deploy], + } + const environmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + enabled: true, + input: { + params: { + app_id: 'app-1', + }, + }, + }) + queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) + queryClient.setQueryData(environmentsQuery.queryKey, { + data: [ + { + description: '', + display_name: 'Staging', + id: 'staging', + in_use: true, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + { + description: '', + display_name: 'Canary', + id: 'canary', + in_use: true, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + { + description: '', + display_name: 'Pre-release', + id: 'pre-release', + in_use: false, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + ], + }) + const { rerender } = renderWithConsoleQuery(, { + queryClient, + systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true }, + }) + + await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + await user.click( + screen.getByRole('button', { + name: /(?:studio\.moreEnvironments|operation\.more)/, + }), + ) + await user.click(screen.getByRole('menuitem', { name: 'Pre-release' })) + + await waitFor(() => { + expect(environmentDeploymentListRequests.length).toBeGreaterThan(0) + }) + expect(environmentDeploymentDetailRequests).toHaveLength(0) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText(/studio\.accessPoint\.noPublishedTitle/)).not.toBeInTheDocument() + expect(screen.queryByText(/studio\.publisher\.notDeployedYet/)).not.toBeInTheDocument() + + mockPublishedWorkflowQueryState = { + isError: false, + isLoading: false, + isSuccess: true, + } + rerender() + + expect(await screen.findByText(/studio\.accessPoint\.noPublishedTitle/)).toBeInTheDocument() + expect(screen.getByText(/studio\.publisher\.noPublishedDescription/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /studio\.accessPoint\.goToPublish/ })).toBeEnabled() + expect(screen.queryByText(/studio\.publisher\.notDeployedYet/)).not.toBeInTheDocument() + + mockPublishedWorkflow = { + created_at: 1_710_000_100, + id: 'workflow-version-5', + marked_comment: '', + marked_name: 'Release 5', + version: 'v5', + } + rerender() + + expect(await screen.findByText(/studio\.publisher\.notDeployedYet/)).toBeInTheDocument() + expect(screen.queryByText(/studio\.publisher\.noPublishedDescription/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /studio\.deployLatest/ })).toBeEnabled() + expect(screen.getByText(/overview\.chip\.latest/).parentElement).toHaveTextContent( + 'overview.chip.latest: Release 5', + ) + expect(screen.getByRole('button', { name: /studio\.allVersions/ })).toBeInTheDocument() + expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /nodes\.common\.memories\.builtIn/ })) + + expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument() + }) + + it('returns to the built-in environment when reopened', async () => { + const user = userEvent.setup() + const queryClient = createConsoleQueryClient() + const detailRequests: Request[] = [] + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.Deploy], + } + const environmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + enabled: true, + input: { + params: { + app_id: 'app-1', + }, + }, + }) + queryClient.setQueryDefaults(environmentsQuery.queryKey, { staleTime: Infinity }) + queryClient.setQueryData(environmentsQuery.queryKey, { + data: [ + { + description: '', + display_name: 'Staging', + id: 'staging', + in_use: true, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + ], + }) + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if ( + new URL(request.url).pathname.endsWith( + '/enterprise/app-deploy/apps/app-1/workflows/environment-deployments/staging', + ) + ) { + detailRequests.push(request.clone()) + return new Response( + JSON.stringify({ + environment_deployment: { + access: { + enable_api: true, + enable_site: true, + }, + deployment: { + current_version: { + id: 'workflow-version-5', + marked_comment: '', + marked_name: 'Release 5', + version: 'v5', + }, + status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + }, + environment: { + description: '', + display_name: 'Staging', + id: 'staging', + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + }, + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + + renderWithConsoleQuery(, { + queryClient, + systemFeatures: { webapp_auth: { enabled: true }, enable_app_deploy: true }, + }) + + const publishButton = screen.getByRole('button', { + name: /(?:^|\.)common\.publish(?=$|:)/, + }) + expect(publishButton).toHaveAttribute('aria-expanded', 'false') + + await user.click(publishButton) + expect(publishButton).toHaveAttribute('aria-expanded', 'true') + const stagingButton = screen.getByRole('button', { name: 'Staging' }) + expect(stagingButton).toBeInTheDocument() + + await user.click(stagingButton) + await waitFor(() => { + expect(detailRequests.length).toBeGreaterThan(0) + }) + expect(screen.getByRole('button', { name: 'Staging' })).toHaveAttribute('aria-current', 'true') + + await user.click(publishButton) + + expect(publishButton).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByRole('button', { name: 'Staging' })).not.toBeInTheDocument() + + await user.click(publishButton) + expect(publishButton).toHaveAttribute('aria-expanded', 'true') + expect( + screen.getByRole('button', { name: /nodes\.common\.memories\.builtIn/ }), + ).toHaveAttribute('aria-current', 'true') + expect(screen.getByRole('button', { name: 'Staging' })).not.toHaveAttribute( + 'aria-current', + 'true', + ) + }) + + it('should collect hidden inputs before opening the web app from its config action', async () => { render( { ) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + + expect(sectionProps.actions?.showRunConfig).toBe(true) fireEvent.click(screen.getByText('publisher-run-config')) expect( @@ -328,55 +786,21 @@ describe('AppPublisher', () => { }) }) - it('should open batch run config links with the configured hidden inputs', async () => { - mockAppDetail = { - ...mockAppDetail, - mode: AppModeEnum.WORKFLOW, - } - - render( - , - ) - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-batch-run-config')) - - fireEvent.change(screen.getByLabelText('Batch Secret'), { - target: { value: 'batch-value' }, - }) - fireEvent.click( - screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), - ) - - await waitFor(() => { - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/workflow/token-1?mode=batch&batch_secret=${encodeURIComponent('batch-value')}`, - '_blank', - ) - }) - }) - it('should keep workflow tool drawer mounted after closing the publish popover', () => { mockAppDetail = { ...mockAppDetail, mode: AppModeEnum.WORKFLOW, } - render() + render() fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.actions).toEqual( + expect.objectContaining({ + toolPublished: true, + workflowToolOutdated: false, + }), + ) fireEvent.click(screen.getByText('publisher-workflow-tool')) expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() @@ -399,60 +823,6 @@ describe('AppPublisher', () => { expect(sectionProps.actions?.workflowToolAvailable).toBe(false) }) - it('should close embedded and access control panels through child callbacks', async () => { - render() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-embed')) - fireEvent.click(screen.getByText('close-embedded-modal')) - expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-access-control')) - expect(screen.getByTestId('access-control'))!.toBeInTheDocument() - fireEvent.click(screen.getByText('close-access-control')) - expect(screen.queryByTestId('access-control')).not.toBeInTheDocument() - }) - - it('should refresh app detail after access control confirmation', async () => { - render() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-access-control')) - - expect(screen.getByTestId('access-control'))!.toBeInTheDocument() - - fireEvent.click(screen.getByText('confirm-access-control')) - - await waitFor(() => { - expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - }) - expect(mockSetAppDetail).toHaveBeenCalledWith( - expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - }), - ) - }) - - it('should open the installed explore page through the async window helper', async () => { - let openedUrl = '' - mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise) => { - openedUrl = await resolver() - }) - - render() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-open-in-explore')) - - await waitFor(() => { - expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1) - expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1') - expect(openedUrl).toBe('/installed/installed-1') - expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`) - }) - }) - it('should ignore the trigger when the publish button is disabled', () => { render() @@ -465,14 +835,11 @@ describe('AppPublisher', () => { expect(mockOnToggle).not.toHaveBeenCalled() }) - it('should publish from the keyboard shortcut and restore the popover state', async () => { + it('should apply the per-open publish lock to the keyboard shortcut', async () => { const preventDefault = vi.fn() - const onRestore = vi.fn().mockResolvedValue(undefined) mockOnPublish.mockResolvedValue(undefined) - render( - , - ) + render() expect(hotkeyMocks.hotkeys).toContain('Mod+Shift+P') hotkeyMocks.handlers[0]!({ preventDefault }) @@ -482,16 +849,46 @@ describe('AppPublisher', () => { expect(mockOnPublish).toHaveBeenCalledTimes(1) }) - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-summary-restore')) + hotkeyMocks.handlers.at(-1)!({ preventDefault }) + expect(mockOnPublish).toHaveBeenCalledTimes(1) + fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.summary?.published).toBe(false) + hotkeyMocks.handlers.at(-1)!({ preventDefault }) await waitFor(() => { - expect(onRestore).toHaveBeenCalledTimes(1) + expect(mockOnPublish).toHaveBeenCalledTimes(2) }) - expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument() }) - it('should keep the popover open when restore fails and reset published state after publish failures', async () => { + it('should keep keyboard publishing available in multiple model mode', async () => { + const preventDefault = vi.fn() + mockOnPublish.mockResolvedValue(undefined) + + render( + , + ) + + hotkeyMocks.handlers[0]!({ preventDefault }) + + await waitFor(() => { + expect(preventDefault).toHaveBeenCalled() + expect(mockOnPublish).toHaveBeenCalledTimes(1) + }) + }) + + it('should keep the popover open when restore and publish fail', async () => { const preventDefault = vi.fn() const onRestore = vi.fn().mockRejectedValue(new Error('restore failed')) mockOnPublish.mockRejectedValueOnce(new Error('publish failed')) @@ -517,57 +914,6 @@ describe('AppPublisher', () => { expect(screen.getByText('publisher-summary-publish'))!.toBeInTheDocument() }) - it('should report missing explore installations', async () => { - mockFetchInstalledAppList.mockResolvedValueOnce({ - installed_apps: [], - }) - mockOpenAsyncWindow.mockImplementation( - async (resolver: () => Promise, options: { onError: (error: Error) => void }) => { - try { - await resolver() - } catch (error) { - options.onError(error as Error) - } - }, - ) - - render() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-open-in-explore')) - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith( - expect.stringMatching(/(?:^|\.)notPublishedYet(?=$|:)/), - ) - }) - }) - - it('should report explore errors when the app cannot be opened', async () => { - mockAppDetail = { - ...mockAppDetail, - id: undefined, - } - mockOpenAsyncWindow.mockImplementation( - async (resolver: () => Promise, options: { onError: (error: Error) => void }) => { - try { - await resolver() - } catch (error) { - options.onError(error as Error) - } - }, - ) - - render() - - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-open-in-explore')) - - await waitFor(() => { - expect(mockToastError).toHaveBeenCalledWith('App not found') - }) - }) - it('should show marketplace button and open redirect URL on success', async () => { mockPublishToCreatorsPlatform.mockResolvedValue({ redirect_url: 'https://marketplace.example.com/publish?code=abc', @@ -579,6 +925,12 @@ describe('AppPublisher', () => { }) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.actions).toEqual( + expect.objectContaining({ + marketplaceActionDisabled: false, + showMarketplaceAction: true, + }), + ) fireEvent.click(screen.getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/)) await waitFor(() => { @@ -615,6 +967,12 @@ describe('AppPublisher', () => { }) fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.actions).toEqual( + expect.objectContaining({ + marketplaceActionDisabled: true, + showMarketplaceAction: true, + }), + ) const marketplaceButton = screen .getByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/) .closest('a, button, div[role="button"]') as HTMLElement @@ -628,23 +986,91 @@ describe('AppPublisher', () => { render() fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.actions?.showMarketplaceAction).toBe(false) expect( screen.queryByText(/(?:^|\.)common\.publishToMarketplace(?=$|:)/), ).not.toBeInTheDocument() }) - it('should keep access control open when app detail is unavailable during confirmation', async () => { + it('should not infer an app mode when app detail is unavailable', async () => { + const user = userEvent.setup() mockAppDetail = null render() - fireEvent.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) - fireEvent.click(screen.getByText('publisher-access-control')) - fireEvent.click(screen.getByText('confirm-access-control')) + await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + + expect(sectionProps.summary).toEqual( + expect.objectContaining({ + isChatApp: false, + isWorkflowApp: false, + }), + ) + }) + + it('should keep workflow publishing available for an existing published version', async () => { + const user = userEvent.setup() + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockPublishedWorkflow = { + created_at: 1_710_000_100, + hash: 'published-hash', + } + + mockOnPublish.mockResolvedValue(undefined) + render( + , + ) + + await user.click(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)) + expect(sectionProps.summary).toEqual( + expect.objectContaining({ + published: false, + publishedAt: 1_710_000_100_000, + }), + ) + await user.click(screen.getByText('publisher-summary-publish')) + await waitFor(() => expect(mockOnPublish).toHaveBeenCalledOnce()) + }) + + it('should refresh the shared workflow query and store after a collaborator publishes', async () => { + const setPublishedAt = vi.fn() + const workflowStore = { + getState: () => ({ setPublishedAt }), + } + mockAppDetail = { + ...mockAppDetail, + mode: AppModeEnum.WORKFLOW, + } + mockFetchPublishedWorkflow.mockResolvedValue({ + created_at: 1_710_000_300, + hash: 'published-hash', + }) + + render( + + + , + ) + + act(() => { + collaborationMocks.handler?.({ + type: 'app_publish_update', + userId: 'collaborator-1', + data: { action: 'published' }, + timestamp: 1_710_000_300_000, + }) + }) await waitFor(() => { - expect(mockFetchAppDetail).not.toHaveBeenCalled() + expect(mockFetchPublishedWorkflow).toHaveBeenCalledWith('app-1') + expect(setPublishedAt).toHaveBeenCalledWith(1_710_000_300) }) - expect(screen.getByTestId('access-control'))!.toBeInTheDocument() }) }) diff --git a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx index 1d840da7e9b..be9cd970ed8 100644 --- a/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/publish-with-multiple-model.spec.tsx @@ -58,6 +58,27 @@ describe('PublishWithMultipleModel', () => { expect(screen.queryByText(/(?:^|\.)publishAs(?=$|:)/)).not.toBeInTheDocument() }) + it('should disable the trigger when publishing is unavailable', () => { + render( + , + ) + + expect( + screen.getByRole('button', { name: /(?:^|\.)operation\.applyConfig(?=$|:)/ }), + ).toBeDisabled() + }) + it('should open matching model options and call onSelect', () => { const handleSelect = vi.fn() const modelConfig = { diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index 02b6f1f6102..9090a86bbd4 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -1,65 +1,51 @@ /* oxlint-disable typescript/no-explicit-any */ -import type { ReactNode } from 'react' -import { fireEvent, screen } from '@testing-library/react' +import type { VersionHistory } from '@/types/workflow' +import { fireEvent, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { AccessMode } from '@/models/access-control' import { renderWithConsoleQuery as render } from '@/test/console/query-data' import { AppModeEnum } from '@/types/app' -import { - AccessModeDisplay, - PublisherAccessSection, - PublisherActionsSection, - PublisherSummarySection, -} from '../sections' +import { PublisherActionsSection } from '../built-in-publisher/actions-section' +import { PublisherSummarySection } from '../built-in-publisher/summary-section' vi.mock('../publish-with-multiple-model', () => ({ - default: ({ onSelect }: { onSelect: (item: Record) => void }) => ( - ), })) -vi.mock('../suggested-action', () => ({ - default: ({ - children, - onClick, - link, - disabled, - actionButton, - }: { - children: ReactNode - onClick?: () => void - link?: string - disabled?: boolean - actionButton?: { ariaLabel: string; onClick: () => void } - }) => ( -
- - {actionButton && ( - - )} -
- ), -})) - -vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({ - default: (props: Record) => ( -
- workflow-tool-configure - {String(props.disabledReason || '')} -
- ), -})) +const createVersionInfo = (overrides: Partial = {}): VersionHistory => ({ + id: 'workflow-version-1', + graph: { + nodes: [], + edges: [], + }, + created_at: 1_710_000_000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + hash: 'hash-1', + updated_at: 1_710_000_000, + updated_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + tool_published: false, + version: '2024-03-09T16:00:00Z', + marked_name: '', + marked_comment: '', + ...overrides, +}) describe('app-publisher sections', () => { it('should render restore controls for published chat apps', () => { @@ -84,35 +70,41 @@ describe('app-publisher sections', () => { fireEvent.click(screen.getByText(/(?:^|\.)common\.restore(?=$|:)/)) expect(handleRestore).toHaveBeenCalled() + expect(screen.getByRole('status')).toHaveTextContent(/common\.currentDraft\b/) }) - it('should expose the access control warning and open access settings from the keyboard', async () => { + it('should disable publish and restore after publishing in the current open session', async () => { const user = userEvent.setup() - const onClick = vi.fn() + const handleRestore = vi.fn() render( - '3 minutes ago'} + handlePublish={vi.fn()} + handleRestore={handleRestore} + isChatApp + multipleModelConfigs={[]} + publishDisabled={false} + published + publishedAt={Date.now()} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} />, ) - expect(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)).toBeInTheDocument() - expect(screen.getByText(/(?:^|\.)publishApp\.notSetDesc(?=$|:)/)).toBeInTheDocument() - - const accessButton = screen.getByRole('button', { - name: /accessControlDialog\.accessItems\.specific/, + const restoreButton = screen.getByRole('button', { + name: /(?:^|\.)common\.restore(?=$|:)/, }) - accessButton.focus() - await user.keyboard('{Enter}') - - expect(onClick).toHaveBeenCalledOnce() + expect(restoreButton).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.published\b/ })).toBeDisabled() + expect(screen.getByRole('status')).toHaveTextContent(/common\.upToDate\b/) + await user.click(restoreButton) + expect(handleRestore).not.toHaveBeenCalled() }) - it('should render the publish update action when the draft has not been published yet', () => { + it('should render the initial publish action when the draft has not been published yet', () => { render( { />, ) - expect(screen.getByText(/(?:^|\.)common\.publishUpdate(?=$|:)/)).toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)common\.notPublishedYet(?=$|:)/)).toBeInTheDocument() + expect(screen.getByText(/(?:^|\.)common\.publish(?=$|:)/)).toBeInTheDocument() + expect(screen.getByText('P')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent(/common\.currentDraft\b/) }) - it('should render multiple-model publishing', () => { + it('should expose naming and keep publishing available for an unnamed published workflow', () => { + const onEditVersion = vi.fn() + + render( + '17 days ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + isWorkflowApp + multipleModelConfigs={[]} + onEditVersion={onEditVersion} + publishDisabled={false} + published={false} + publishedAt={1_710_000_100_000} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + versionInfo={createVersionInfo({ version_number: 5 })} + />, + ) + + expect(screen.getByText('# 5')).toBeInTheDocument() + expect(screen.queryByText('2024-03-09T16:00:00Z')).not.toBeInTheDocument() + const nameButton = screen.getByRole('button', { + name: /versionHistory\.nameIt\b/, + }) + fireEvent.click(nameButton) + expect(onEditVersion).toHaveBeenCalledTimes(1) + const publishButton = screen.getByRole('button', { name: /common\.publishUpdate\b/ }) + expect(publishButton).toBeEnabled() + expect(within(publishButton).getByText('P')).toBeInTheDocument() + expect(screen.getByText(/common\.autoSaved\b/)).toBeInTheDocument() + }) + + it('should show named workflow metadata and keep publish update available', () => { + const onEditVersion = vi.fn() + + render( + '2 minutes ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + isWorkflowApp + multipleModelConfigs={[]} + onEditVersion={onEditVersion} + publishDisabled={false} + published={false} + publishedAt={1_710_000_100_000} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + versionInfo={createVersionInfo({ + marked_name: 'Sprint-42', + marked_comment: 'Fixed data synchronization and page loading.', + })} + />, + ) + + expect(screen.getByText('Sprint-42')).toBeInTheDocument() + expect(screen.getByText('Fixed data synchronization and page loading.')).toBeInTheDocument() + fireEvent.click( + screen.getByRole('button', { + name: /versionHistory\.editVersionInfo\b/, + }), + ) + expect(onEditVersion).toHaveBeenCalledTimes(1) + expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled() + expect(screen.getByText(/common\.autoSaved\b/)).toBeInTheDocument() + expect(screen.getAllByText(/2 minutes ago/)).not.toHaveLength(0) + }) + + it('should keep non-workflow apps free of workflow version details and saved time', () => { + render( + '2 minutes ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp + isWorkflowApp={false} + multipleModelConfigs={[]} + publishDisabled={false} + published={false} + publishedAt={1_710_000_100_000} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + />, + ) + + expect(screen.getAllByText(/common\.latestPublished\b/)).toHaveLength(1) + expect(screen.queryByText('#5')).not.toBeInTheDocument() + expect(screen.queryByText(/versionHistory\.nameIt\b/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.publishUpdate\b/ })).toBeEnabled() + expect(screen.getByRole('status')).toHaveTextContent(/common\.currentDraft\b/) + }) + + it('should keep multiple-model publishing available without publish config changes', () => { const handlePublish = vi.fn() render( @@ -147,7 +243,7 @@ describe('app-publisher sections', () => { multipleModelConfigs={[{ id: '1' } as any]} publishDisabled={false} published={false} - publishedAt={undefined} + publishedAt={Date.now()} startNodeLimitExceeded={false} upgradeHighlightStyle={{}} />, @@ -158,6 +254,27 @@ describe('app-publisher sections', () => { expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' }) }) + it('should disable multiple-model publishing when publishing is unavailable', () => { + render( + '1 minute ago'} + handlePublish={vi.fn()} + handleRestore={vi.fn()} + isChatApp={false} + multipleModelConfigs={[{ id: '1' } as any]} + publishDisabled + published={false} + publishedAt={Date.now()} + startNodeLimitExceeded={false} + upgradeHighlightStyle={{}} + />, + ) + + expect(screen.getByRole('button', { name: 'publish-multiple-model' })).toBeDisabled() + }) + it('should render the upgrade hint when the start node limit is exceeded', () => { render( { expect(screen.getByText(/(?:^|\.)publishLimit\.startNodeDesc(?=$|:)/)).toBeInTheDocument() }) - it('should render loading access state and access mode labels when enabled', () => { - const { rerender } = render( - , - ) - - expect(document.querySelector('.spin-animation')).toBeInTheDocument() - - rerender( - , - ) - - expect( - screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/), - ).toBeInTheDocument() - expect(render().container).toBeEmptyDOMElement() - }) - - it('should hide access control content when enabled is false', () => { - render( - , - ) - - expect(screen.queryByText(/(?:^|\.)publishApp\.title(?=$|:)/)).not.toBeInTheDocument() - expect( - screen.queryByText(/(?:^|\.)accessControlDialog\.accessItems\.anyone(?=$|:)/), - ).not.toBeInTheDocument() - }) - - it('should render workflow actions, batch run links, and workflow tool configuration', () => { - const handleOpenInExplore = vi.fn() - const handleEmbed = vi.fn() + it('should render the published workflow actions with Workflow as Tool after Marketplace', async () => { + const user = userEvent.setup() const handleOpenRunConfig = vi.fn() + const onConfigureWorkflowTool = vi.fn() + const onPublishToMarketplace = vi.fn() - const { rerender } = render( + render( { appURL="https://example.com/app" disabledFunctionButton={false} disabledFunctionTooltip="disabled" - handleEmbed={handleEmbed} - handleOpenInExplore={handleOpenInExplore} handleOpenRunConfig={handleOpenRunConfig} - handlePublish={vi.fn()} hasHumanInputNode={false} hasTriggerNode={false} - missingStartNode={false} - published={false} publishedAt={Date.now()} - showBatchRunConfig + showDeployAction + showMarketplaceAction showRunConfig - toolPublished - workflowToolAvailable={false} + workflowToolAvailable workflowToolIsLoading={false} - workflowToolOutdated={false} - workflowToolMessage="workflow-disabled" - onConfigureWorkflowTool={vi.fn()} + onPublishToMarketplace={onPublishToMarketplace} + onConfigureWorkflowTool={onConfigureWorkflowTool} />, ) - expect(screen.getByText(/(?:^|\.)common\.batchRunApp(?=$|:)/)).toHaveAttribute( - 'data-link', - 'https://example.com/app?mode=batch', + expect(screen.getByRole('link', { name: /common\.openWebApp\b/ })).toHaveAttribute( + 'href', + 'https://example.com/app', ) - fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[0]!) + fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })) expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app') - fireEvent.click(screen.getAllByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })[1]!) - expect(handleOpenRunConfig).toHaveBeenCalledWith('https://example.com/app?mode=batch') - fireEvent.click(screen.getByText(/(?:^|\.)common\.openInExplore(?=$|:)/)) - expect(handleOpenInExplore).toHaveBeenCalled() - expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument() - expect(screen.getByText('workflow-disabled')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute( + 'href', + '/app/workflow-app/access-point', + ) + expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute( + 'href', + '/app/workflow-app/deploy', + ) - rerender( + const marketplaceAction = screen.getByRole('button', { + name: /common\.publishToMarketplace\b/, + }) + const workflowToolAction = screen.getByRole('button', { + name: /common\.workflowAsTool\b/, + }) + expect( + marketplaceAction.compareDocumentPosition(workflowToolAction) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect(screen.getByRole('status', { name: /common\.configureRequired\b/ })).toBeInTheDocument() + + await user.click(marketplaceAction) + expect(onPublishToMarketplace).toHaveBeenCalledTimes(1) + + await user.click(workflowToolAction) + expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1) + }) + + it('should expose Configure and Manage in Tools actions for a ready workflow tool', async () => { + const user = userEvent.setup() + const onConfigureWorkflowTool = vi.fn() + + render( , ) - fireEvent.click(screen.getByText(/(?:^|\.)common\.embedIntoSite(?=$|:)/)) - expect(handleEmbed).toHaveBeenCalled() - expect(screen.getByText(/(?:^|\.)common\.accessAPIReference(?=$|:)/)).toBeDisabled() + expect( + screen.getByRole('status', { name: /common\.workflowAsToolReady\b/ }), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: /common\.manageInTools\b/ })).toHaveAttribute( + 'href', + '/integrations/tools/workflow', + ) + + await user.click(screen.getByRole('button', { name: /common\.configure\b/ })) + expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1) + }) + + it('should show the disabled reason below setup and configured workflow tool actions', () => { + const commonProps = { + appDetail: { + id: 'workflow-app', + mode: AppModeEnum.WORKFLOW, + }, + appURL: 'https://example.com/app', + disabledFunctionButton: false, + hasHumanInputNode: false, + hasTriggerNode: false, + onConfigureWorkflowTool: vi.fn(), + publishedAt: Date.now(), + workflowToolAvailable: false, + workflowToolIsLoading: false, + workflowToolMessage: 'Workflow tool unavailable', + } + const { rerender } = render() + + const setupAction = screen.getByRole('button', { name: /common\.workflowAsTool\b/ }) + const setupReason = screen.getByText('Workflow tool unavailable') + expect(setupAction).toBeDisabled() + expect(setupReason).toBeVisible() + expect( + setupAction.compareDocumentPosition(setupReason) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + + rerender() + + const configureAction = screen.getByRole('button', { name: /common\.configure\b/ }) + const manageAction = screen.getByRole('button', { name: /common\.manageInTools\b/ }) + const configuredReason = screen.getByText('Workflow tool unavailable') + expect(configureAction).toBeDisabled() + expect(manageAction).toBeDisabled() + expect(configuredReason).toBeVisible() + expect( + manageAction.compareDocumentPosition(configuredReason) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + }) + + it('should surface update-needed and loading states for a configured workflow tool', async () => { + const user = userEvent.setup() + const onConfigureWorkflowTool = vi.fn() + const commonProps = { + appDetail: { + id: 'workflow-app', + mode: AppModeEnum.WORKFLOW, + }, + appURL: 'https://example.com/app', + disabledFunctionButton: false, + hasHumanInputNode: false, + hasTriggerNode: false, + onConfigureWorkflowTool, + publishedAt: Date.now(), + toolPublished: true, + workflowToolAvailable: true, + } + const { rerender } = render( + , + ) + + expect( + screen.getByRole('status', { name: /common\.workflowAsToolUpdateNeeded\b/ }), + ).toBeInTheDocument() + expect(screen.getByText(/common\.workflowAsToolTip\b/)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /common\.workflowAsToolReconfigure\b/ })) + expect(onConfigureWorkflowTool).toHaveBeenCalledTimes(1) rerender( , + ) + + expect(screen.getByRole('button', { name: /common\.workflowAsTool\b/ })).toBeDisabled() + expect(screen.getByRole('status', { name: /loading\b/ })).toBeInTheDocument() + expect(screen.queryByText(/common\.workflowAsToolTip\b/)).not.toBeInTheDocument() + }) + + it('should keep Access Point and Deploy available for trigger workflows', () => { + render( + , ) - expect(screen.queryByText(/(?:^|\.)common\.runApp(?=$|:)/)).not.toBeInTheDocument() + expect(screen.queryByText(/(?:^|\.)common\.openWebApp(?=$|:)/)).not.toBeInTheDocument() + expect(screen.queryByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/)).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: /appMenus\.accessPoint\b/ })).toHaveAttribute( + 'href', + '/app/trigger-app/access-point', + ) + expect(screen.getByRole('link', { name: /appMenus\.deploy\b/ })).toHaveAttribute( + 'href', + '/app/trigger-app/deploy', + ) + }) + + it('should expose unavailable quick links as disabled buttons before the first publish', () => { + render( + , + ) + + expect(screen.getByText(/(?:^|\.)common\.openWebApp(?=$|:)/).closest('button')).toBeDisabled() + expect( + screen.getByText(/(?:^|\.)appMenus\.accessPoint(?=$|:)/).closest('button'), + ).toBeDisabled() + expect(screen.getByText(/(?:^|\.)appMenus\.deploy(?=$|:)/).closest('button')).toBeDisabled() + expect( + screen.getByText(/(?:^|\.)common\.workflowAsTool(?=$|:)/).closest('button'), + ).toBeDisabled() + }) + + it('should show the disabled reason when hovering an unavailable action', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.hover(screen.getByRole('button', { name: /common\.openWebApp\b/ })) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable') + }) + + it('should keep an unavailable action with a tooltip keyboard focusable', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.tab() + + const action = screen.getByRole('button', { name: /common\.openWebApp\b/ }) + expect(action).toHaveFocus() + expect(action).toHaveAttribute('aria-disabled', 'true') + expect(action).toHaveAccessibleDescription('Open web app unavailable') + expect(await screen.findByRole('tooltip')).toHaveTextContent('Open web app unavailable') }) }) diff --git a/web/app/components/app/app-publisher/__tests__/state.spec.tsx b/web/app/components/app/app-publisher/__tests__/state.spec.tsx new file mode 100644 index 00000000000..5cdba042d59 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/state.spec.tsx @@ -0,0 +1,635 @@ +import { + DeploymentOperationStatus, + DeploymentStatus, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { QueryClientProvider } from '@tanstack/react-query' +import { screen, render as testingLibraryRender, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Provider, useAtomValue, useSetAtom } from 'jotai' +import { createQueryAtomTestStore } from '@/test/query-atom' +import { useRefreshAppEnvironmentsAfterPublisherDeploymentPolling } from '../hooks/use-refresh-app-environments-after-deployment-polling' +import { + addPublisherEnvironmentAtom, + appPublisherEnvironmentsAtom, + appPublisherOpenAtom, + AppPublisherStateBoundary, + joinedPublisherEnvironmentIdsAtom, + publisherEnvironmentDeploymentPollingAtom, + selectedEnvironmentDeploymentAtom, + selectedPublisherEnvironmentIdAtom, + startPublisherEnvironmentDeploymentPollingAtom, +} from '../state' + +type QueryOptions = { + enabled?: boolean + input: unknown + refetchInterval?: (query: { + state: { + data?: { + environment_deployment: { + deployment?: { + latest_operation?: { + id: string + status: string + } + status: string + } + } + } + fetchFailureCount?: number + status?: 'error' | 'pending' | 'success' + } + }) => false | number +} + +const queryMocks = vi.hoisted(() => ({ + deploymentListOptions: vi.fn(), + deploymentListRequest: vi.fn(), + deploymentOptions: vi.fn(), + deploymentRequest: vi.fn(), + environmentOptions: vi.fn(), + environmentRequest: vi.fn(), +})) + +vi.mock('@/service/client', async () => { + const { skipToken } = await import('@tanstack/react-query') + + return { + consoleQuery: { + enterprise: { + appDeploy: { + deploymentService: { + getEnvironmentDeployment: { + queryOptions: (options: QueryOptions) => { + queryMocks.deploymentOptions(options) + const environmentId = + typeof options.input === 'object' && options.input + ? (options.input as { params: { environment_id: string } }).params + .environment_id + : 'disabled' + + return { + ...options, + queryFn: + options.input === skipToken + ? skipToken + : () => queryMocks.deploymentRequest(options.input), + queryKey: ['publisherEnvironmentDeployment', environmentId], + } + }, + }, + listEnvironmentDeployments: { + queryOptions: (options: QueryOptions) => { + queryMocks.deploymentListOptions(options) + + return { + ...options, + queryFn: + options.input === skipToken + ? skipToken + : () => queryMocks.deploymentListRequest(options.input), + queryKey: ['publisherEnvironmentDeployments'], + } + }, + }, + listAppEnvironments: { + queryOptions: (options: QueryOptions) => { + queryMocks.environmentOptions(options) + + return { + ...options, + queryFn: () => queryMocks.environmentRequest(), + queryKey: ['publisherEnvironments'], + } + }, + }, + }, + }, + }, + }, + } +}) + +function StateConsumer() { + useRefreshAppEnvironmentsAfterPublisherDeploymentPolling('app-1') + const environments = useAtomValue(appPublisherEnvironmentsAtom) + const open = useAtomValue(appPublisherOpenAtom) + const joinedEnvironmentIds = useAtomValue(joinedPublisherEnvironmentIdsAtom) + const polling = useAtomValue(publisherEnvironmentDeploymentPollingAtom) + const selectedEnvironmentId = useAtomValue(selectedPublisherEnvironmentIdAtom) + const deployment = useAtomValue(selectedEnvironmentDeploymentAtom) + const addEnvironment = useSetAtom(addPublisherEnvironmentAtom) + const setOpen = useSetAtom(appPublisherOpenAtom) + const selectEnvironment = useSetAtom(selectedPublisherEnvironmentIdAtom) + const startDeploymentPolling = useSetAtom(startPublisherEnvironmentDeploymentPollingAtom) + const development = environments.find((environment) => environment.id === 'development') + + return ( + <> +
{`Environments: ${environments.map((environment) => environment.display_name).join(', ')}`}
+
{`Joined: ${joinedEnvironmentIds.join(', ')}`}
+
{`Development in use: ${String(development?.in_use)}`}
+
{`Polling: ${polling?.operationId ?? 'none'}`}
+
{`Open: ${String(open)}`}
+
{`Selected: ${selectedEnvironmentId}`}
+
+ {`Deployment: ${ + deployment?.deployment?.current_version?.marked_name ?? + deployment?.deployment?.current_version?.version ?? + 'none' + }`} +
+ + + + + + + + + ) +} + +function renderState(initialOpen = true) { + const { queryClient, store } = createQueryAtomTestStore() + store.set(appPublisherOpenAtom, initialOpen) + const state = (mounted: boolean) => ( + + + {mounted && ( + + + + )} + + + ) + const rendered = testingLibraryRender(state(true)) + + return { + ...rendered, + queryClient, + rerenderWithMounted: (mounted: boolean) => rendered.rerender(state(mounted)), + } +} + +function environmentDeploymentResponse({ + deploymentStatus, + operationId, + operationStatus, +}: { + deploymentStatus: string + operationId: string + operationStatus: string +}) { + return { + environment_deployment: { + access: { + enable_api: true, + enable_site: true, + }, + deployment: { + current_version: + deploymentStatus === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING + ? { + id: 'version-development', + marked_comment: '', + marked_name: 'Release development', + version: '2026-07-31.development', + } + : undefined, + latest_operation: { + activity_at: 1_785_456_000, + id: operationId, + operator: { + display_name: 'Evan', + id: 'user-1', + type: 'OPERATOR_TYPE_ACCOUNT', + }, + status: operationStatus, + type: 'DEPLOYMENT_OPERATION_TYPE_DEPLOY', + }, + status: deploymentStatus, + }, + environment: { + description: '', + display_name: 'Development', + id: 'development', + status: 'ENVIRONMENT_STATUS_READY', + }, + }, + } +} + +const appEnvironments = (developmentInUse: boolean) => ({ + data: [ + { + description: '', + display_name: 'Staging', + id: 'staging', + in_use: true, + status: 'ENVIRONMENT_STATUS_READY', + }, + { + description: '', + display_name: 'Development', + id: 'development', + in_use: developmentInUse, + status: 'ENVIRONMENT_STATUS_READY', + }, + ], +}) + +function getDeploymentQueryOptions(environmentId: string) { + return queryMocks.deploymentOptions.mock.calls + .map(([options]) => options as QueryOptions) + .reverse() + .find((options) => { + if (typeof options.input !== 'object' || !options.input) return false + + return ( + (options.input as { params?: { environment_id?: string } }).params?.environment_id === + environmentId + ) + }) +} + +function getLatestDeploymentQueryOptions() { + return queryMocks.deploymentOptions.mock.calls.at(-1)?.[0] as QueryOptions | undefined +} + +function getDeploymentRequestCount(environmentId: string) { + return queryMocks.deploymentRequest.mock.calls.filter((call) => { + const input = call[0] as { params: { environment_id: string } } + return input.params.environment_id === environmentId + }).length +} + +describe('app publisher environment state', () => { + beforeEach(() => { + vi.clearAllMocks() + queryMocks.deploymentListRequest.mockResolvedValue({ + environment_deployments: [], + }) + queryMocks.environmentRequest.mockResolvedValue(appEnvironments(false)) + queryMocks.deploymentRequest.mockImplementation( + async (input: { params: { environment_id: string } }) => ({ + environment_deployment: { + access: { + enable_api: true, + enable_site: true, + }, + deployment: { + current_version: { + id: `version-${input.params.environment_id}`, + marked_comment: '', + marked_name: `Release ${input.params.environment_id}`, + version: `2026-07-31.${input.params.environment_id}`, + }, + status: 'DEPLOYMENT_STATUS_RUNNING', + }, + environment: { + description: '', + display_name: input.params.environment_id, + id: input.params.environment_id, + status: 'ENVIRONMENT_STATUS_READY', + }, + }, + }), + ) + }) + + it('derives deployed tabs from the environment in_use field', async () => { + const user = userEvent.setup() + renderState() + + expect(await screen.findByText('Joined: staging')).toBeInTheDocument() + expect(screen.getByText('Environments: Staging, Development')).toBeInTheDocument() + expect(queryMocks.environmentOptions).toHaveBeenCalledWith({ + enabled: true, + input: { + params: { + app_id: 'app-1', + }, + }, + }) + + await user.click(screen.getByRole('button', { name: 'Add development' })) + + expect(screen.getByText('Joined: staging, development')).toBeInTheDocument() + expect(screen.getByText('Selected: development')).toBeInTheDocument() + await waitFor(() => { + expect(queryMocks.deploymentListRequest).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + }, + }) + }) + expect(queryMocks.deploymentRequest).not.toHaveBeenCalled() + }) + + it('discovers and resumes polling a first deployment while the environment remains not in use', async () => { + const user = userEvent.setup() + const response = environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + operationId: 'operation-development', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + }) + queryMocks.deploymentListRequest.mockResolvedValue({ + environment_deployments: [response.environment_deployment], + }) + queryMocks.deploymentRequest.mockResolvedValue(response) + renderState() + + expect(await screen.findByText('Development in use: false')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Add development' })) + + await waitFor(() => { + expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + environment_id: 'development', + }, + }) + }) + expect( + getDeploymentQueryOptions('development')?.refetchInterval?.({ + state: { + data: response, + }, + }), + ).toBe(3000) + }) + + it('stops automatic status polling while the deployment query is failing', async () => { + const user = userEvent.setup() + const response = environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + operationId: 'operation-staging', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + }) + renderState() + + await screen.findByText('Joined: staging') + await user.click(screen.getByRole('button', { name: 'Select staging' })) + + const refetchInterval = getDeploymentQueryOptions('staging')?.refetchInterval + expect(refetchInterval).toBeTypeOf('function') + expect( + refetchInterval?.({ + state: { + data: response, + fetchFailureCount: 1, + status: 'success', + }, + }), + ).toBe(false) + expect( + refetchInterval?.({ + state: { + data: response, + status: 'error', + }, + }), + ).toBe(false) + }) + + it('queries deployment details only after selecting an in-use non-built-in environment', async () => { + const user = userEvent.setup() + renderState() + + await screen.findByText('Joined: staging') + expect(queryMocks.deploymentRequest).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Select staging' })) + + await waitFor(() => { + expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + environment_id: 'staging', + }, + }) + }) + expect(await screen.findByText('Deployment: Release staging')).toBeInTheDocument() + }) + + it('resets the selected environment to built-in when the publisher reopens', async () => { + const user = userEvent.setup() + renderState() + + await screen.findByText('Joined: staging') + await user.click(screen.getByRole('button', { name: 'Select staging' })) + expect(screen.getByText('Selected: staging')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Close publisher' })) + await user.click(screen.getByRole('button', { name: 'Open publisher' })) + + expect(screen.getByText('Selected: built-in')).toBeInTheDocument() + }) + + it('refreshes environments when a selected deployment has succeeded', async () => { + const user = userEvent.setup() + queryMocks.environmentRequest + .mockResolvedValueOnce(appEnvironments(false)) + .mockResolvedValue(appEnvironments(true)) + queryMocks.deploymentRequest.mockResolvedValue( + environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + operationId: 'operation-development', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, + }), + ) + renderState() + + expect(await screen.findByText('Development in use: false')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Add development' })) + expect(queryMocks.deploymentRequest).not.toHaveBeenCalled() + + await user.click(screen.getByRole('button', { name: 'Start development deployment' })) + + await waitFor(() => { + expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + environment_id: 'development', + }, + }) + }) + expect(await screen.findByText('Deployment: Release development')).toBeInTheDocument() + expect(await screen.findByText('Development in use: true')).toBeInTheDocument() + expect(screen.getByText('Polling: none')).toBeInTheDocument() + + const deploymentQueryOptions = getDeploymentQueryOptions('development') + expect(deploymentQueryOptions?.refetchInterval).toBeTypeOf('function') + expect( + deploymentQueryOptions?.refetchInterval?.({ + state: { + data: environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + operationId: 'operation-development', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + }), + }, + }), + ).toBe(3000) + expect( + deploymentQueryOptions?.refetchInterval?.({ + state: { + data: environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + operationId: 'operation-development', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, + }), + }, + }), + ).toBe(false) + expect(queryMocks.environmentRequest).toHaveBeenCalledTimes(2) + }) + + it('scopes polling to the selected environment and clears it when the publisher closes', async () => { + const user = userEvent.setup() + queryMocks.environmentRequest.mockResolvedValue(appEnvironments(true)) + queryMocks.deploymentRequest.mockImplementation( + async (input: { params: { environment_id: string } }) => + input.params.environment_id === 'staging' + ? environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + operationId: 'operation-staging', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + }) + : environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + operationId: 'operation-development', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, + }), + ) + renderState() + + await screen.findByText('Joined: staging, development') + await user.click(screen.getByRole('button', { name: 'Select staging' })) + await waitFor(() => { + expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + environment_id: 'staging', + }, + }) + }) + expect( + getDeploymentQueryOptions('staging')?.refetchInterval?.({ + state: { + data: environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + operationId: 'operation-staging', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + }), + }, + }), + ).toBe(3000) + + await user.click(screen.getByRole('button', { name: 'Start staging deployment' })) + expect(screen.getByText('Polling: operation-staging')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Select development' })) + + expect(screen.getByText('Polling: none')).toBeInTheDocument() + await waitFor(() => { + expect(queryMocks.deploymentRequest).toHaveBeenCalledWith({ + params: { + app_id: 'app-1', + environment_id: 'development', + }, + }) + }) + expect( + getDeploymentQueryOptions('development')?.refetchInterval?.({ + state: { + data: environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + operationId: 'operation-development', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, + }), + }, + }), + ).toBe(false) + + const stagingRequestCount = getDeploymentRequestCount('staging') + await user.click(screen.getByRole('button', { name: 'Select staging' })) + await waitFor(() => { + const nextStagingRequestCount = getDeploymentRequestCount('staging') + expect(nextStagingRequestCount).toBeGreaterThan(stagingRequestCount) + }) + + await user.click(screen.getByRole('button', { name: 'Start staging deployment' })) + await user.click(screen.getByRole('button', { name: 'Close publisher' })) + + await waitFor(() => { + expect(screen.getByText('Polling: none')).toBeInTheDocument() + }) + await waitFor(() => { + expect(getLatestDeploymentQueryOptions()?.enabled).toBe(false) + }) + }) + + it('resets open state and active polling when the last publisher subscriber unmounts', async () => { + const user = userEvent.setup() + queryMocks.environmentRequest.mockResolvedValue(appEnvironments(true)) + queryMocks.deploymentRequest.mockResolvedValue( + environmentDeploymentResponse({ + deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + operationId: 'operation-staging', + operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + }), + ) + const { rerenderWithMounted } = renderState() + + await screen.findByText('Open: true') + await user.click(screen.getByRole('button', { name: 'Select staging' })) + await waitFor(() => { + expect(queryMocks.deploymentRequest).toHaveBeenCalled() + }) + await user.click(screen.getByRole('button', { name: 'Start staging deployment' })) + expect(screen.getByText('Polling: operation-staging')).toBeInTheDocument() + + rerenderWithMounted(false) + rerenderWithMounted(true) + + expect(await screen.findByText('Open: false')).toBeInTheDocument() + expect(screen.getByText('Polling: none')).toBeInTheDocument() + await waitFor(() => { + expect(getLatestDeploymentQueryOptions()?.enabled).toBe(false) + }) + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx index f27dba11ba5..e861d2c7489 100644 --- a/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/suggested-action.spec.tsx @@ -1,79 +1,133 @@ -import type { MouseEvent as ReactMouseEvent } from 'react' import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import SuggestedAction from '../suggested-action' describe('SuggestedAction', () => { - it('should render an enabled external link', () => { - render(Open docs) + it('should render an enabled external link with supporting copy', () => { + render( + + Open docs + , + ) const link = screen.getByRole('link', { name: 'Open docs' }) expect(link).toHaveAttribute('href', 'https://example.com/docs') expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAccessibleDescription('Read the documentation') + expect(screen.getByText('Read the documentation')).toBeInTheDocument() }) - it('should block clicks when disabled', () => { + it('should render internal destinations without opening a new tab', () => { + render( + + Deploy + , + ) + + const link = screen.getByRole('link', { name: 'Deploy' }) + expect(link).toHaveAttribute('href', '/app/app-1/deploy') + expect(link).not.toHaveAttribute('target') + expect(link).toHaveAccessibleDescription('Push versions to environments') + }) + + it('should use native disabled button semantics for unavailable links', () => { const handleClick = vi.fn() - render( - - Disabled action - , - ) - - const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement - fireEvent.click(link) - - expect(link).not.toHaveAttribute('href') - expect(handleClick).not.toHaveBeenCalled() - }) - - it('should forward click events when enabled', () => { - const handleClick = vi.fn((event: ReactMouseEvent) => { - event.preventDefault() - }) - - render( - - Enabled action - , - ) - - fireEvent.click(screen.getByRole('link', { name: 'Enabled action' })) - - expect(handleClick).toHaveBeenCalledTimes(1) - }) - - it('should render and trigger the trailing action button when configured', () => { - const handleActionClick = vi.fn() - render( + Disabled action + , + ) + + const action = screen.getByRole('button', { name: 'Disabled action' }) + fireEvent.click(action) + + expect(action).toBeDisabled() + expect(screen.queryByRole('link', { name: /Disabled action/ })).not.toBeInTheDocument() + expect(handleClick).not.toHaveBeenCalled() + }) + + it('should keep an explained disabled action in the keyboard tab order', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + + render( + + Disabled action with explanation + , + ) + + await user.tab() + + const action = screen.getByRole('button', { name: 'Disabled action with explanation' }) + expect(action).toHaveFocus() + expect(action).toHaveAttribute('aria-disabled', 'true') + + await user.keyboard('{Enter}') + expect(handleClick).not.toHaveBeenCalled() + }) + + it('should render and trigger an enabled button action', () => { + const handleClick = vi.fn() + + render( + } + onClick={handleClick} + > + Workflow as Tool + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Workflow as Tool' })) + + expect(handleClick).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('configure-icon')).toBeInTheDocument() + }) + + it('should keep the main link separate from a trailing action button', () => { + const handleActionClick = vi.fn() + + render( + config, onClick: handleActionClick, }} > - Configurable action + Open web app , ) - fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) - - expect(screen.getByRole('link', { name: 'Configurable action' })).toHaveAttribute( + expect(screen.getByRole('link', { name: 'Open web app' })).toHaveAttribute( 'href', - 'https://example.com/docs', + 'https://example.com/app', ) + + fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) expect(handleActionClick).toHaveBeenCalledTimes(1) }) - it('should block action button clicks when disabled', () => { + it('should disable both controls when an action with a trailing button is unavailable', () => { const handleActionClick = vi.fn() render( { onClick: handleActionClick, }} > - Disabled with action + Open web app , ) - fireEvent.click(screen.getByRole('button', { name: 'Configure action' })) + expect(screen.getByRole('button', { name: 'Open web app' })).toBeDisabled() + + const actionButton = screen.getByRole('button', { name: 'Configure action' }) + fireEvent.click(actionButton) + expect(actionButton).toBeDisabled() expect(handleActionClick).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/app/app-publisher/__tests__/utils.spec.ts b/web/app/components/app/app-publisher/__tests__/utils.spec.ts index ca735567e58..1d2b4967449 100644 --- a/web/app/components/app/app-publisher/__tests__/utils.spec.ts +++ b/web/app/components/app/app-publisher/__tests__/utils.spec.ts @@ -1,14 +1,8 @@ import type { TFunction } from 'i18next' -import { AccessMode } from '@/models/access-control' import { withSelectorKey } from '@/test/i18n-mock' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -import { - getDisabledFunctionTooltip, - getPublisherAppMode, - getPublisherAppUrl, - isPublisherAccessConfigured, -} from '../utils' +import { getDisabledFunctionTooltip, getPublisherAppMode, getPublisherAppUrl } from '../utils' describe('app-publisher utils', () => { describe('getPublisherAppMode', () => { @@ -33,26 +27,6 @@ describe('app-publisher utils', () => { }) }) - describe('isPublisherAccessConfigured', () => { - it('should require members or groups for specific access mode', () => { - expect( - isPublisherAccessConfigured( - { access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - { groups: [], members: [] }, - ), - ).toBe(false) - }) - - it('should treat public access as configured', () => { - expect( - isPublisherAccessConfigured( - { access_mode: AccessMode.PUBLIC }, - { groups: [], members: [] }, - ), - ).toBe(true) - }) - }) - describe('getDisabledFunctionTooltip', () => { const t = withSelectorKey((key: string) => key, 'app') as unknown as TFunction diff --git a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx index b3d85f3c772..7c697e89f4f 100644 --- a/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/version-info-modal.spec.tsx @@ -41,7 +41,7 @@ describe('VersionInfoModal', () => { const [titleInput] = screen.getAllByRole('textbox') fireEvent.change(titleInput!, { target: { value: 'a'.repeat(16) } }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) + fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ })) expect(toast.error).toHaveBeenCalledWith( expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/), @@ -71,7 +71,7 @@ describe('VersionInfoModal', () => { const [titleInput, notesInput] = screen.getAllByRole('textbox') fireEvent.change(titleInput!, { target: { value: 'Release 2' } }) fireEvent.change(notesInput!, { target: { value: 'Updated notes' } }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) + fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ })) expect(handlePublish).toHaveBeenCalledWith({ title: 'Release 2', @@ -123,20 +123,20 @@ describe('VersionInfoModal', () => { const [titleInput, notesInput] = screen.getAllByRole('textbox') fireEvent.change(titleInput!, { target: { value: 'a'.repeat(16) } }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) + fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ })) expect(toast.error).toHaveBeenCalledWith( expect.stringMatching(/(?:^|\.)versionHistory\.editField\.titleLengthLimit(?=$|:)/), ) fireEvent.change(titleInput!, { target: { value: 'Release 3' } }) fireEvent.change(notesInput!, { target: { value: 'b'.repeat(101) } }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) + fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ })) expect(toast.error).toHaveBeenCalledWith( expect.stringMatching(/(?:^|\.)versionHistory\.editField\.releaseNotesLengthLimit(?=$|:)/), ) fireEvent.change(notesInput!, { target: { value: 'Stable release notes' } }) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)common\.publish(?=$|:)/ })) + fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.save(?=$|:)/ })) expect(handlePublish).toHaveBeenCalledWith({ title: 'Release 3', diff --git a/web/app/components/app/app-publisher/built-in-publisher/actions-section.tsx b/web/app/components/app/app-publisher/built-in-publisher/actions-section.tsx new file mode 100644 index 00000000000..c1fe6a27869 --- /dev/null +++ b/web/app/components/app/app-publisher/built-in-publisher/actions-section.tsx @@ -0,0 +1,164 @@ +import type { AppPublisherProps } from '../types' +import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' +import { AppModeEnum } from '@/types/app' +import SuggestedAction from '../suggested-action' +import WorkflowToolAction from '../workflow-tool-action' + +type PublisherActionsSectionProps = Pick< + AppPublisherProps, + 'hasHumanInputNode' | 'hasTriggerNode' | 'publishedAt' | 'toolPublished' | 'workflowToolAvailable' +> & { + appDetail: + | { + id?: string + icon?: string + icon_type?: string | null + icon_background?: string | null + description?: string + mode?: AppModeEnum + name?: string + } + | null + | undefined + appURL: string + disabledFunctionButton: boolean + disabledFunctionTooltip?: string + handleOpenRunConfig?: (url: string) => void + marketplaceActionDisabled?: boolean + publishingToMarketplace?: boolean + showDeployAction?: boolean + showMarketplaceAction?: boolean + showRunConfig?: boolean + workflowToolIsLoading: boolean + workflowToolMessage?: string + workflowToolOutdated?: boolean + onConfigureWorkflowTool: () => void + onPublishToMarketplace?: () => void +} + +export function PublisherActionsSection({ + appDetail, + appURL, + disabledFunctionButton, + disabledFunctionTooltip, + handleOpenRunConfig, + hasHumanInputNode = false, + hasTriggerNode = false, + marketplaceActionDisabled = false, + publishedAt, + publishingToMarketplace = false, + showDeployAction = false, + showMarketplaceAction = false, + showRunConfig = false, + toolPublished = false, + workflowToolAvailable = true, + workflowToolIsLoading, + workflowToolMessage, + workflowToolOutdated = false, + onConfigureWorkflowTool, + onPublishToMarketplace, +}: PublisherActionsSectionProps) { + const { t } = useTranslation() + + const appId = appDetail?.id + const hasPublishedVersion = Boolean(publishedAt) + const showOpenWebApp = !hasTriggerNode + const showDeploy = Boolean(showDeployAction && appId) + const showWorkflowTool = + appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const navigationDisabled = !hasPublishedVersion || !appId + const workflowToolDisabled = + !hasPublishedVersion || !workflowToolAvailable || (toolPublished && workflowToolIsLoading) + + return ( +
+ {showOpenWebApp && ( + + + } + > + $['common.openWebAppDescription'], { ns: 'workflow' }) + } + external + focusableWhenDisabled={Boolean(disabledFunctionTooltip)} + link={appURL} + icon={} + actionButton={ + showRunConfig && handleOpenRunConfig + ? { + ariaLabel: t(($) => $['operation.config'], { ns: 'common' }), + icon: , + onClick: () => handleOpenRunConfig(appURL), + } + : undefined + } + > + {t(($) => $['common.openWebApp'], { ns: 'workflow' })} + + + {disabledFunctionTooltip} + + )} + $['common.accessPointDescription'], { ns: 'workflow' })} + link={appId ? `/app/${appId}/access-point` : undefined} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + + {showDeploy && ( + $['common.deployDescription'], { ns: 'workflow' })} + link={`/app/${appId}/deploy`} + icon={} + > + {t(($) => $['appMenus.deploy'], { ns: 'common' })} + + )} + {showMarketplaceAction && ( + $['common.publishToMarketplaceDescription'], { + ns: 'workflow', + })} + icon={} + onClick={onPublishToMarketplace} + > + {publishingToMarketplace + ? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' }) + : t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })} + + )} + {showWorkflowTool && ( + <> +
+ + + )} +
+ ) +} diff --git a/web/app/components/app/app-publisher/built-in-publisher/constants.ts b/web/app/components/app/app-publisher/built-in-publisher/constants.ts new file mode 100644 index 00000000000..9894bd68767 --- /dev/null +++ b/web/app/components/app/app-publisher/built-in-publisher/constants.ts @@ -0,0 +1,7 @@ +export const upgradeHighlightStyle = { + background: + 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', + WebkitBackgroundClip: 'text', + backgroundClip: 'text', + WebkitTextFillColor: 'transparent', +} diff --git a/web/app/components/app/app-publisher/built-in-publisher/index.tsx b/web/app/components/app/app-publisher/built-in-publisher/index.tsx new file mode 100644 index 00000000000..a9875f22c37 --- /dev/null +++ b/web/app/components/app/app-publisher/built-in-publisher/index.tsx @@ -0,0 +1,17 @@ +import type { ComponentProps } from 'react' +import { PublisherActionsSection } from './actions-section' +import { PublisherSummarySection } from './summary-section' + +type BuiltInPublisherProps = { + actions: ComponentProps + summary: ComponentProps +} + +export function BuiltInPublisher({ actions, summary }: BuiltInPublisherProps) { + return ( + <> + + + + ) +} diff --git a/web/app/components/app/app-publisher/built-in-publisher/summary-section.tsx b/web/app/components/app/app-publisher/built-in-publisher/summary-section.tsx new file mode 100644 index 00000000000..97eb69357c6 --- /dev/null +++ b/web/app/components/app/app-publisher/built-in-publisher/summary-section.tsx @@ -0,0 +1,248 @@ +import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { CSSProperties, ReactNode } from 'react' +import type { ModelAndParameter } from '../../configuration/debug/types' +import type { AppPublisherProps } from '../types' +import type { PublishWorkflowParams } from '@/types/workflow' +import { Button } from '@langgenius/dify-ui/button' +import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' +import { formatForDisplay } from '@tanstack/react-hotkeys' +import { useTranslation } from 'react-i18next' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' +import { APP_PUBLISH_HOTKEY } from '../hotkeys' +import PublishWithMultipleModel from '../publish-with-multiple-model' +import { PublisherTimelineMarker } from '../shared/timeline-marker' + +type PublisherVersionInfo = Pick< + WorkflowResponse, + 'created_at' | 'marked_comment' | 'marked_name' | 'version_number' +> & { + created_by?: { name: string } | null +} + +type PublisherSummarySectionProps = Pick< + AppPublisherProps, + | 'debugWithMultipleModel' + | 'draftUpdatedAt' + | 'multipleModelConfigs' + | 'publishDisabled' + | 'publishedAt' + | 'startNodeLimitExceeded' +> & { + formatTimeFromNow: (value: number) => string + handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise + handleRestore: () => Promise + environmentTabs?: ReactNode + isChatApp: boolean + isWorkflowApp?: boolean + onEditVersion?: () => void + published: boolean + upgradeHighlightStyle: CSSProperties + versionInfo?: PublisherVersionInfo | null +} + +export function PublisherSummarySection({ + debugWithMultipleModel = false, + draftUpdatedAt, + environmentTabs, + formatTimeFromNow, + handlePublish, + handleRestore, + isChatApp, + isWorkflowApp = false, + multipleModelConfigs = [], + onEditVersion, + publishDisabled = false, + published, + publishedAt, + startNodeLimitExceeded = false, + upgradeHighlightStyle, + versionInfo, +}: PublisherSummarySectionProps) { + const { t } = useTranslation() + const hasPublishedVersion = Boolean(publishedAt) + const publishedTimestamp = + publishedAt || (versionInfo?.created_at ? versionInfo.created_at * 1000 : undefined) + const publisherName = versionInfo?.created_by?.name + const markedName = versionInfo?.marked_name + const markedComment = versionInfo?.marked_comment + const publishButtonDisabled = publishDisabled || published + const publishButtonLabel = published + ? t(($) => $['common.published'], { ns: 'workflow' }) + : hasPublishedVersion + ? t(($) => $['common.publishUpdate'], { ns: 'workflow' }) + : t(($) => $['common.publish'], { ns: 'workflow' }) + + return ( +
+ {environmentTabs} +
+ + {!hasPublishedVersion ? ( +

+ {t(($) => $['common.notPublishedYet'], { ns: 'workflow' })} +

+ ) : isWorkflowApp ? ( +
+
+ + {getWorkflowVersionName( + versionInfo, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + )} + + + · + + {markedName ? ( + + ) : ( + + )} +
+ {markedComment && ( + <> +

+ {markedComment} +

+ + + )} + {!!publishedTimestamp && ( +

+ {publisherName + ? t(($) => $['common.publishedBy'], { + ns: 'workflow', + time: formatTimeFromNow(publishedTimestamp), + author: publisherName, + }) + : `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`} +

+ )} +
+ ) : ( +
+
+

+ {t(($) => $['common.latestPublished'], { ns: 'workflow' })} +

+ {!!publishedTimestamp && ( +

+ {publisherName + ? t(($) => $['common.publishedBy'], { + ns: 'workflow', + time: formatTimeFromNow(publishedTimestamp), + author: publisherName, + }) + : `${t(($) => $['common.publishedAt'], { ns: 'workflow' })} ${formatTimeFromNow(publishedTimestamp)}`} +

+ )} +
+ {isChatApp && ( + + )} +
+ )} +
+
+ {debugWithMultipleModel ? ( + handlePublish(item)} + /> + ) : ( + <> + + {startNodeLimitExceeded && ( +
+

+ + {t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })} + + + {t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })} + +

+

+ {t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })} +

+ +
+ )} + + )} +
+
+ +

+ {published ? ( + isWorkflowApp ? ( + t(($) => $['common.published'], { ns: 'workflow' }) + ) : ( + t(($) => $['common.upToDate'], { ns: 'workflow' }) + ) + ) : isWorkflowApp && Boolean(draftUpdatedAt) ? ( + <> + {t(($) => $['common.autoSaved'], { ns: 'workflow' })} + {' · '} + {formatTimeFromNow(draftUpdatedAt!)} + + ) : ( + t(($) => $['common.currentDraft'], { ns: 'workflow' }) + )} +

+
+
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/actions-section.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/actions-section.tsx new file mode 100644 index 00000000000..34dd368a134 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-deployment-flow/actions-section.tsx @@ -0,0 +1,43 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { useTranslation } from 'react-i18next' +import SuggestedAction from '../suggested-action' + +function environmentHref(path: string, appId: string, environmentId: string) { + return `/app/${appId}/${path}?environment=${encodeURIComponent(environmentId)}` +} + +export function PublisherEnvironmentActionsSection({ + appId, + deployment, + environmentId, +}: { + appId?: string + deployment?: EnvironmentDeployment + environmentId: string +}) { + const { t } = useTranslation() + const actionsDisabled = !appId || !deployment + const accessPointHref = appId ? environmentHref('access-point', appId, environmentId) : undefined + const deployHref = appId ? environmentHref('deploy', appId, environmentId) : undefined + + return ( +
+ $['common.accessPointDescription'], { ns: 'workflow' })} + link={accessPointHref} + icon={} + > + {t(($) => $['appMenus.accessPoint'], { ns: 'common' })} + + $['common.deployDescription'], { ns: 'workflow' })} + link={deployHref} + icon={} + > + {t(($) => $['appMenus.deploy'], { ns: 'common' })} + +
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx new file mode 100644 index 00000000000..dce08832bc0 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx @@ -0,0 +1,101 @@ +'use client' + +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { ReactNode } from 'react' +import type { DeploymentVersion } from '@/app/components/app/deploy/version' +import { useAtomValue, useSetAtom } from 'jotai' +import { useTranslation } from 'react-i18next' +import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/environment-deployment-flow' +import { + publisherEnvironmentDeploymentPollingAtom, + startPublisherEnvironmentDeploymentPollingAtom, +} from '../state' +import { PublisherEnvironmentActionsSection } from './actions-section' +import { PublisherEnvironmentSummarySection } from './summary-section' + +type PublisherEnvironmentFlowProps = { + appId?: string + deployment?: EnvironmentDeployment + environmentId: string + environmentName: string + environmentTabs: ReactNode + isEnvironmentInUse: boolean + isDeploymentError: boolean + isDeploymentLoading: boolean + latestVersion?: DeploymentVersion | null + onGoToPublish: () => void +} + +export function PublisherEnvironmentFlow({ + appId, + deployment, + environmentId, + environmentName, + environmentTabs, + isEnvironmentInUse, + isDeploymentError, + isDeploymentLoading, + latestVersion, + onGoToPublish, +}: PublisherEnvironmentFlowProps) { + const { t } = useTranslation() + const deploymentPolling = useAtomValue(publisherEnvironmentDeploymentPollingAtom) + const startDeploymentPolling = useSetAtom(startPublisherEnvironmentDeploymentPollingAtom) + + if (isDeploymentLoading || (isDeploymentError && !deployment)) { + return ( +
+ {environmentTabs} +
+ {isDeploymentLoading ? ( + <> + + {t(($) => $.loading, { ns: 'common' })} + + ) : ( + t(($) => $['common.loadFailed'], { ns: 'deployments' }) + )} +
+
+ ) + } + + return ( + { + startDeploymentPolling({ environmentId, operationId }) + }} + > + {({ deploymentActionsDisabled, deployVersion, showVersionSelection }) => ( +
+ { + if (latestVersion) deployVersion(latestVersion) + }} + onDeployOtherVersion={showVersionSelection} + onGoToPublish={onGoToPublish} + onShowAllVersions={showVersionSelection} + /> + +
+ )} +
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx new file mode 100644 index 00000000000..e8d8c8f9e36 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx @@ -0,0 +1,63 @@ +import type { DeploymentVersion } from '@/app/components/app/deploy/version' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { PublisherDeployingMarker } from '../publisher-deploying-marker' +import { PublisherTimelineMarker } from '../shared/timeline-marker' + +export function PublisherLatestVersionRow({ + deployingVersionName, + disabled, + isDeploying, + latestVersion, + onShowAllVersions, +}: { + deployingVersionName?: string + disabled: boolean + isDeploying: boolean + latestVersion?: DeploymentVersion | null + onShowAllVersions: () => void +}) { + const { t } = useTranslation() + + return ( +
+ {isDeploying ? : } +

+ {isDeploying ? ( + deployingVersionName ? ( + t(($) => $['studio.publisher.deployingVersion'], { + ns: 'deployments', + version: deployingVersionName, + }) + ) : ( + t(($) => $['deployDrawer.deploying'], { ns: 'deployments' }) + ) + ) : ( + <> + + {t(($) => $['overview.chip.latest'], { ns: 'deployments' })} + + {latestVersion ? `: ${latestVersion.name}` : ''} + + )} +

+ +
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx new file mode 100644 index 00000000000..051d2d8f5e3 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx @@ -0,0 +1,207 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { ReactNode } from 'react' +import type { DeploymentVersion } from '@/app/components/app/deploy/version' +import { DeploymentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import { PublisherTimelineMarker } from '../shared/timeline-marker' +import { PublisherLatestVersionRow } from './latest-version-row' + +export function PublisherEnvironmentSummarySection({ + deployment, + deploymentActionsDisabled, + environmentTabs, + isEnvironmentInUse, + latestVersion, + onDeployLatest, + onDeployOtherVersion, + onGoToPublish, + onShowAllVersions, +}: { + deployment?: EnvironmentDeployment + deploymentActionsDisabled: boolean + environmentTabs: ReactNode + isEnvironmentInUse: boolean + latestVersion?: DeploymentVersion | null + onDeployLatest: () => void + onDeployOtherVersion: () => void + onGoToPublish: () => void + onShowAllVersions: () => void +}) { + const { t } = useTranslation() + const { formatTimeFromNow } = useFormatTimeFromNow() + const deploymentState = deployment?.deployment + const deployedVersion = deploymentState?.current_version + const isDeploying = deploymentState?.status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING + const deployingVersion = deploymentState?.latest_operation?.target_version + const deployingVersionName = deployingVersion + ? getWorkflowVersionName( + deployingVersion, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + ) + : undefined + const versionsBehind = deploymentState?.versions_behind + const versionsBehindLabel = + versionsBehind === undefined + ? undefined + : versionsBehind === 1 + ? t(($) => $['studio.versionsBehind_one'], { + ns: 'deployments', + count: versionsBehind, + }) + : t(($) => $['studio.versionsBehind_other'], { + ns: 'deployments', + count: versionsBehind, + }) + const isLatestVersion = Boolean( + deployedVersion && + (latestVersion + ? deployedVersion.id === latestVersion.id + : deploymentState?.versions_behind === 0), + ) + const publishedAt = deploymentState?.deployed_at ? deploymentState.deployed_at * 1000 : undefined + const publishedBy = deploymentState?.deployed_by?.display_name + const showNoPublishedVersionState = !isEnvironmentInUse && latestVersion === null + + if (!deployedVersion) { + return ( +
+ {environmentTabs} +
+ +
+

+ {showNoPublishedVersionState + ? t(($) => $['studio.accessPoint.noPublishedTitle'], { + ns: 'deployments', + }) + : t(($) => $['studio.publisher.notDeployedYet'], { + ns: 'deployments', + })} +

+ {showNoPublishedVersionState && ( +

+ {t(($) => $['studio.publisher.noPublishedDescription'], { + ns: 'deployments', + })} +

+ )} +
+
+ {showNoPublishedVersionState ? ( + + ) : ( + <> + + + + )} +
+ ) + } + + return ( +
+ {environmentTabs} +
+ +
+
+ + {getWorkflowVersionName( + deployedVersion, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + )} + + {versionsBehindLabel && !isLatestVersion && ( + + + + {versionsBehind} + + } + /> + {versionsBehindLabel} + + )} + {isLatestVersion && ( + + {t(($) => $['overview.chip.latest'], { ns: 'deployments' })} + + )} +
+ {publishedAt !== undefined && publishedBy && ( +

+ {t(($) => $['common.publishedBy'], { + ns: 'workflow', + time: formatTimeFromNow(publishedAt), + author: publishedBy, + })} +

+ )} +
+
+
+ + {isLatestVersion && !isDeploying && ( + + )} +
+ {(isDeploying || !isLatestVersion) && ( + + )} +
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-tabs/__tests__/layout.spec.ts b/web/app/components/app/app-publisher/environment-tabs/__tests__/layout.spec.ts new file mode 100644 index 00000000000..73d800650f9 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/__tests__/layout.spec.ts @@ -0,0 +1,81 @@ +import { getEnvironmentTabLayout } from '../layout' + +const environmentTabWidths = { + canary: 72, + preview: 80, + production: 88, +} + +describe('getEnvironmentTabLayout', () => { + it('shows every joined environment without a More trigger when the row fits', () => { + expect( + getEnvironmentTabLayout({ + availableWidth: 320, + builtInWidth: 64, + environmentTabWidths, + hasUndeployedEnvironments: false, + joinedEnvironmentIds: ['canary', 'preview'], + moreEnvironmentsWidth: 120, + moreWidth: 64, + }), + ).toEqual({ + overflowEnvironmentIds: [], + showMore: false, + visibleEnvironmentIds: ['canary', 'preview'], + }) + }) + + it('reserves room for adding undeployed environments', () => { + expect( + getEnvironmentTabLayout({ + availableWidth: 240, + builtInWidth: 64, + environmentTabWidths, + hasUndeployedEnvironments: true, + joinedEnvironmentIds: ['canary', 'preview'], + moreEnvironmentsWidth: 120, + moreWidth: 64, + }), + ).toEqual({ + overflowEnvironmentIds: ['preview'], + showMore: true, + visibleEnvironmentIds: ['canary'], + }) + }) + + it('keeps overflow environments in their joined order', () => { + expect( + getEnvironmentTabLayout({ + availableWidth: 250, + builtInWidth: 64, + environmentTabWidths, + hasUndeployedEnvironments: false, + joinedEnvironmentIds: ['canary', 'preview', 'production'], + moreEnvironmentsWidth: 120, + moreWidth: 64, + }), + ).toEqual({ + overflowEnvironmentIds: ['preview', 'production'], + showMore: true, + visibleEnvironmentIds: ['canary'], + }) + }) + + it('shows only the add trigger when no environment has joined', () => { + expect( + getEnvironmentTabLayout({ + availableWidth: 320, + builtInWidth: 64, + environmentTabWidths, + hasUndeployedEnvironments: true, + joinedEnvironmentIds: [], + moreEnvironmentsWidth: 120, + moreWidth: 64, + }), + ).toEqual({ + overflowEnvironmentIds: [], + showMore: true, + visibleEnvironmentIds: [], + }) + }) +}) diff --git a/web/app/components/app/app-publisher/environment-tabs/environment-button.tsx b/web/app/components/app/app-publisher/environment-tabs/environment-button.tsx new file mode 100644 index 00000000000..9aa7370fc5f --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/environment-button.tsx @@ -0,0 +1,39 @@ +import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { ENVIRONMENT_TAB_LABEL_MAX_WIDTH } from './layout' + +export function EnvironmentButton({ + active, + name, + textWidth, + onClick, +}: { + active: boolean + name: string + textWidth: number + onClick: () => void +}) { + const truncated = textWidth > ENVIRONMENT_TAB_LABEL_MAX_WIDTH + + return ( + + + {name} + + } + /> + {name} + + ) +} diff --git a/web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx b/web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx new file mode 100644 index 00000000000..d15c9c582fe --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/environment-menu-item.tsx @@ -0,0 +1,19 @@ +import type { PublisherEnvironment } from './types' +import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' + +export function EnvironmentMenuItem({ + environment, + onClick, +}: { + environment: PublisherEnvironment + onClick: () => void +}) { + return ( + + + + {environment.name} + + + ) +} diff --git a/web/app/components/app/app-publisher/environment-tabs/index.tsx b/web/app/components/app/app-publisher/environment-tabs/index.tsx new file mode 100644 index 00000000000..ac2693575b3 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/index.tsx @@ -0,0 +1,201 @@ +'use client' + +import type { PublisherEnvironmentTabsProps } from './types' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' +import { BUILT_IN_ENVIRONMENT_ID } from '../state' +import { EnvironmentButton } from './environment-button' +import { EnvironmentMenuItem } from './environment-menu-item' +import { + ENVIRONMENT_TAB_HORIZONTAL_PADDING, + ENVIRONMENT_TAB_MAX_WIDTH, + estimateFallbackTextWidth, + getEnvironmentTabLayout, + SELECTED_OVERFLOW_LABEL_MAX_WIDTH, +} from './layout' +import { EnvironmentTabsMeasurementProbe } from './measurement-probe' +import { useEnvironmentTabMeasurements } from './use-measurements' + +export function PublisherEnvironmentTabs({ + environments, + joinedEnvironmentIds, + selectedEnvironmentId, + onAddEnvironment, + onSelectEnvironment, +}: PublisherEnvironmentTabsProps) { + const { t } = useTranslation() + const builtInLabel = t(($) => $['nodes.common.memories.builtIn'], { ns: 'workflow' }) + const moreLabel = t(($) => $['operation.more'], { ns: 'common' }) + const moreEnvironmentsLabel = t(($) => $['studio.moreEnvironments'], { + ns: 'deployments', + }) + const { containerRef, measurementRef, measurements } = useEnvironmentTabMeasurements({ + builtInLabel, + environments, + moreEnvironmentsLabel, + moreLabel, + }) + + const environmentById = new Map(environments.map((environment) => [environment.id, environment])) + const joinedEnvironmentIdSet = new Set(joinedEnvironmentIds) + const orderedJoinedEnvironmentIds = environments + .filter((environment) => joinedEnvironmentIdSet.has(environment.id)) + .map((environment) => environment.id) + const undeployedEnvironments = environments.filter( + (environment) => !joinedEnvironmentIdSet.has(environment.id), + ) + const environmentTabWidths = Object.fromEntries( + environments.map((environment) => [ + environment.id, + Math.min( + ENVIRONMENT_TAB_MAX_WIDTH, + (measurements.environmentTextWidths[environment.id] ?? + estimateFallbackTextWidth(environment.name)) + ENVIRONMENT_TAB_HORIZONTAL_PADDING, + ), + ]), + ) + const layout = getEnvironmentTabLayout({ + availableWidth: measurements.availableWidth, + builtInWidth: measurements.builtInWidth, + environmentTabWidths, + hasUndeployedEnvironments: undeployedEnvironments.length > 0, + joinedEnvironmentIds: orderedJoinedEnvironmentIds, + moreEnvironmentsWidth: measurements.moreEnvironmentsWidth, + moreWidth: measurements.moreWidth, + }) + const overflowEnvironmentIdSet = new Set(layout.overflowEnvironmentIds) + const selectedOverflowEnvironment = overflowEnvironmentIdSet.has(selectedEnvironmentId) + ? environmentById.get(selectedEnvironmentId) + : undefined + const overflowEnvironments = layout.overflowEnvironmentIds + .filter((environmentId) => environmentId !== selectedEnvironmentId) + .map((environmentId) => environmentById.get(environmentId)) + .filter((environment) => environment !== undefined) + const triggerLabel = + selectedOverflowEnvironment?.name ?? + (layout.visibleEnvironmentIds.length > 0 ? moreLabel : moreEnvironmentsLabel) + const selectedOverflowTextWidth = selectedOverflowEnvironment + ? (measurements.environmentTextWidths[selectedOverflowEnvironment.id] ?? + estimateFallbackTextWidth(selectedOverflowEnvironment.name)) + : 0 + const selectedOverflowNameTruncated = + Boolean(selectedOverflowEnvironment) && + selectedOverflowTextWidth > SELECTED_OVERFLOW_LABEL_MAX_WIDTH + const showMoreEnvironmentsLabel = + !selectedOverflowEnvironment && layout.visibleEnvironmentIds.length === 0 + + return ( +
$['studio.environments'], { ns: 'deployments' })} + className="relative flex w-full items-start gap-1 pb-1" + > +
+ onSelectEnvironment(BUILT_IN_ENVIRONMENT_ID)} + /> + {layout.visibleEnvironmentIds.map((environmentId) => { + const environment = environmentById.get(environmentId) + if (!environment) return null + + return ( + onSelectEnvironment(environment.id)} + /> + ) + })} +
+ {layout.showMore && ( + + + + {triggerLabel}} + /> + + {selectedOverflowEnvironment?.name} + + + + + } + /> + + {overflowEnvironments.length > 0 && ( + + {overflowEnvironments.map((environment) => ( + onSelectEnvironment(environment.id)} + /> + ))} + + )} + {overflowEnvironments.length > 0 && undeployedEnvironments.length > 0 && ( + + )} + {undeployedEnvironments.length > 0 && ( + + + {t(($) => $['card.notDeployed'], { ns: 'deployments' })} + + {undeployedEnvironments.map((environment) => ( + onAddEnvironment(environment.id)} + /> + ))} + + )} + + + )} + + +
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-tabs/layout.ts b/web/app/components/app/app-publisher/environment-tabs/layout.ts new file mode 100644 index 00000000000..05b3809297d --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/layout.ts @@ -0,0 +1,103 @@ +export const DEFAULT_TABS_WIDTH = 320 +export const ENVIRONMENT_TAB_MAX_WIDTH = 88 +export const ENVIRONMENT_TAB_HORIZONTAL_PADDING = 16 +export const ENVIRONMENT_TAB_LABEL_MAX_WIDTH = + ENVIRONMENT_TAB_MAX_WIDTH - ENVIRONMENT_TAB_HORIZONTAL_PADDING +export const SELECTED_OVERFLOW_LABEL_MAX_WIDTH = ENVIRONMENT_TAB_LABEL_MAX_WIDTH - 16 +export const TAB_GAP = 4 + +type EnvironmentTabLayout = { + overflowEnvironmentIds: string[] + showMore: boolean + visibleEnvironmentIds: string[] +} + +type GetEnvironmentTabLayoutParams = { + availableWidth: number + builtInWidth: number + environmentTabWidths: Record + hasUndeployedEnvironments: boolean + joinedEnvironmentIds: readonly string[] + moreEnvironmentsWidth: number + moreWidth: number +} + +// Used only for SSR, the first render, or when browser layout measurements are unavailable. +// Once mounted, the hidden measurement elements provide the rendered widths with actual styles. +export function estimateFallbackTextWidth(value: string) { + return Array.from(value).reduce((width, character) => { + const codePoint = character.codePointAt(0) ?? 0 + if (codePoint > 0x2e7f) return width + 13 + if (character === ' ') return width + 4 + return width + 6.5 + }, 0) +} + +export function estimateFallbackTabWidth(value: string) { + return Math.min( + ENVIRONMENT_TAB_MAX_WIDTH, + estimateFallbackTextWidth(value) + ENVIRONMENT_TAB_HORIZONTAL_PADDING, + ) +} + +function rowWidth(widths: readonly number[]) { + if (widths.length === 0) return 0 + return widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * TAB_GAP +} + +export function getEnvironmentTabLayout({ + availableWidth, + builtInWidth, + environmentTabWidths, + hasUndeployedEnvironments, + joinedEnvironmentIds, + moreEnvironmentsWidth, + moreWidth, +}: GetEnvironmentTabLayoutParams): EnvironmentTabLayout { + const joinedWidths = joinedEnvironmentIds.map( + (environmentId) => environmentTabWidths[environmentId] ?? ENVIRONMENT_TAB_MAX_WIDTH, + ) + const allTabsFit = + !hasUndeployedEnvironments && rowWidth([builtInWidth, ...joinedWidths]) <= availableWidth + + if (allTabsFit) { + return { + overflowEnvironmentIds: [], + showMore: false, + visibleEnvironmentIds: [...joinedEnvironmentIds], + } + } + + const showMore = hasUndeployedEnvironments || joinedEnvironmentIds.length > 0 + if (!showMore) { + return { + overflowEnvironmentIds: [], + showMore: false, + visibleEnvironmentIds: [], + } + } + + const triggerWidth = + joinedEnvironmentIds.length === 0 + ? moreEnvironmentsWidth + : Math.max(moreWidth, ENVIRONMENT_TAB_MAX_WIDTH) + const visibleEnvironmentIds: string[] = [] + let visibleTabsWidth = builtInWidth + + for (const environmentId of joinedEnvironmentIds) { + const environmentWidth = environmentTabWidths[environmentId] ?? ENVIRONMENT_TAB_MAX_WIDTH + const nextVisibleTabsWidth = visibleTabsWidth + TAB_GAP + environmentWidth + const widthWithTrigger = nextVisibleTabsWidth + TAB_GAP + triggerWidth + + if (widthWithTrigger > availableWidth) break + + visibleEnvironmentIds.push(environmentId) + visibleTabsWidth = nextVisibleTabsWidth + } + + return { + overflowEnvironmentIds: joinedEnvironmentIds.slice(visibleEnvironmentIds.length), + showMore: true, + visibleEnvironmentIds, + } +} diff --git a/web/app/components/app/app-publisher/environment-tabs/measurement-probe.tsx b/web/app/components/app/app-publisher/environment-tabs/measurement-probe.tsx new file mode 100644 index 00000000000..91145f43722 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/measurement-probe.tsx @@ -0,0 +1,51 @@ +import type { RefObject } from 'react' +import type { PublisherEnvironment } from './types' + +export function EnvironmentTabsMeasurementProbe({ + builtInLabel, + environments, + measurementRef, + moreEnvironmentsLabel, + moreLabel, +}: { + builtInLabel: string + environments: readonly PublisherEnvironment[] + measurementRef: RefObject + moreEnvironmentsLabel: string + moreLabel: string +}) { + return ( +
+ + {builtInLabel} + + + {moreLabel} + + + + {moreEnvironmentsLabel} + + + {environments.map((environment) => ( + + {environment.name} + + ))} +
+ ) +} diff --git a/web/app/components/app/app-publisher/environment-tabs/types.ts b/web/app/components/app/app-publisher/environment-tabs/types.ts new file mode 100644 index 00000000000..e4c7a7a9642 --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/types.ts @@ -0,0 +1,20 @@ +export type PublisherEnvironment = { + id: string + name: string +} + +export type EnvironmentTabMeasurements = { + availableWidth: number + builtInWidth: number + environmentTextWidths: Record + moreEnvironmentsWidth: number + moreWidth: number +} + +export type PublisherEnvironmentTabsProps = { + environments: readonly PublisherEnvironment[] + joinedEnvironmentIds: readonly string[] + selectedEnvironmentId: string + onAddEnvironment: (environmentId: string) => void + onSelectEnvironment: (environmentId: string) => void +} diff --git a/web/app/components/app/app-publisher/environment-tabs/use-measurements.ts b/web/app/components/app/app-publisher/environment-tabs/use-measurements.ts new file mode 100644 index 00000000000..4ae0df4385c --- /dev/null +++ b/web/app/components/app/app-publisher/environment-tabs/use-measurements.ts @@ -0,0 +1,120 @@ +'use client' + +import type { EnvironmentTabMeasurements, PublisherEnvironment } from './types' +import { useEffect, useRef, useState } from 'react' +import { + DEFAULT_TABS_WIDTH, + ENVIRONMENT_TAB_HORIZONTAL_PADDING, + ENVIRONMENT_TAB_MAX_WIDTH, + estimateFallbackTabWidth, + estimateFallbackTextWidth, +} from './layout' + +export function useEnvironmentTabMeasurements({ + builtInLabel, + environments, + moreEnvironmentsLabel, + moreLabel, +}: { + builtInLabel: string + environments: readonly PublisherEnvironment[] + moreEnvironmentsLabel: string + moreLabel: string +}) { + const containerRef = useRef(null) + const measurementRef = useRef(null) + const [measurements, setMeasurements] = useState(() => ({ + availableWidth: DEFAULT_TABS_WIDTH, + builtInWidth: estimateFallbackTabWidth(builtInLabel), + environmentTextWidths: Object.fromEntries( + environments.map((environment) => [ + environment.id, + estimateFallbackTextWidth(environment.name), + ]), + ), + moreEnvironmentsWidth: + estimateFallbackTextWidth(moreEnvironmentsLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16, + moreWidth: estimateFallbackTextWidth(moreLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16, + })) + + useEffect(() => { + const container = containerRef.current + const measurementRoot = measurementRef.current + if (!container || !measurementRoot) return + + const readElementWidth = (element: HTMLElement | null, fallback: number) => { + return element?.getBoundingClientRect().width || element?.scrollWidth || fallback + } + const readWidth = (selector: string, fallback: number) => + readElementWidth(measurementRoot.querySelector(selector), fallback) + const updateMeasurements = () => { + const environmentMeasureElements = new Map( + Array.from(measurementRoot.querySelectorAll('[data-environment-measure]')).map( + (element) => [element.dataset.environmentMeasure, element], + ), + ) + const environmentTextWidths = Object.fromEntries( + environments.map((environment) => [ + environment.id, + readElementWidth( + environmentMeasureElements.get(environment.id) ?? null, + estimateFallbackTextWidth(environment.name), + ), + ]), + ) + const nextMeasurements = { + availableWidth: + container.getBoundingClientRect().width || container.clientWidth || DEFAULT_TABS_WIDTH, + builtInWidth: Math.min( + ENVIRONMENT_TAB_MAX_WIDTH, + readWidth( + '[data-built-in-measure]', + estimateFallbackTextWidth(builtInLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING, + ), + ), + environmentTextWidths, + moreEnvironmentsWidth: readWidth( + '[data-more-environments-measure]', + estimateFallbackTextWidth(moreEnvironmentsLabel) + + ENVIRONMENT_TAB_HORIZONTAL_PADDING + + 16, + ), + moreWidth: readWidth( + '[data-more-measure]', + estimateFallbackTextWidth(moreLabel) + ENVIRONMENT_TAB_HORIZONTAL_PADDING + 16, + ), + } + + setMeasurements((current) => { + const environmentWidthsUnchanged = environments.every( + (environment) => + current.environmentTextWidths[environment.id] === + nextMeasurements.environmentTextWidths[environment.id], + ) + if ( + current.availableWidth === nextMeasurements.availableWidth && + current.builtInWidth === nextMeasurements.builtInWidth && + current.moreEnvironmentsWidth === nextMeasurements.moreEnvironmentsWidth && + current.moreWidth === nextMeasurements.moreWidth && + environmentWidthsUnchanged + ) { + return current + } + return nextMeasurements + }) + } + + const animationFrame = requestAnimationFrame(updateMeasurements) + if (typeof ResizeObserver === 'undefined') return () => cancelAnimationFrame(animationFrame) + + const observer = new ResizeObserver(updateMeasurements) + observer.observe(container) + observer.observe(measurementRoot) + return () => { + cancelAnimationFrame(animationFrame) + observer.disconnect() + } + }, [builtInLabel, environments, moreEnvironmentsLabel, moreLabel]) + + return { containerRef, measurementRef, measurements } +} diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 5de3bf0d7b0..4114aadb50a 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,9 +1,9 @@ import type { AppPublisherProps, AppPublisherPublishParams, -} from '@/app/components/app/app-publisher' +} from '@/app/components/app/app-publisher/types' +import type { ConfigurationPublishConfig } from '@/app/components/app/configuration/hooks/configuration-lifecycle/types' import type { Features, FileUpload } from '@/app/components/base/features/types' -import type { ModelConfig } from '@/models/debug' import { AlertDialog, AlertDialogActions, @@ -14,7 +14,6 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { produce } from 'immer' -import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { AppPublisher } from '@/app/components/app/app-publisher' @@ -23,18 +22,12 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { Resolution } from '@/types/app' -type PublishedModelConfig = ModelConfig & { - resetAppConfig?: () => void -} - type Props = Omit & { onPublish?: ( params?: AppPublisherPublishParams, features?: Features, ) => Promise | unknown - publishedConfig: { - modelConfig: PublishedModelConfig - } + publishedConfig: ConfigurationPublishConfig resetAppConfig?: () => void } @@ -43,6 +36,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => { const features = useFeatures((s) => s.features) const featuresStore = useFeaturesStore() const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) + const { resetAppConfig } = props const { more_like_this, opening_statement, @@ -54,10 +48,9 @@ const FeaturesWrappedAppPublisher = (props: Props) => { retriever_resource, annotation_reply, file_upload, - resetAppConfig, } = props.publishedConfig.modelConfig - const handleConfirm = useCallback(() => { + const handleConfirm = () => { resetAppConfig?.() const { features, setFeatures } = featuresStore!.getState() const newFeatures = produce(features, (draft) => { @@ -92,7 +85,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => { }) setFeatures(newFeatures) setRestoreConfirmOpen(false) - }, [featuresStore, props]) + } const handlePublish = useCallback( (params?: AppPublisherPublishParams) => { diff --git a/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts b/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts new file mode 100644 index 00000000000..64f0a45db2e --- /dev/null +++ b/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts @@ -0,0 +1,121 @@ +'use client' + +import { DeploymentOperationStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { useQueryClient } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useRef } from 'react' +import { isEnvironmentDeploymentInProgress } from '@/app/components/app/deploy/state' +import { consoleQuery } from '@/service/client' +import { + appPublisherOpenAtom, + finishPublisherEnvironmentDeploymentPollingAtom, + isDeploymentOperationTerminal, + publisherEnvironmentDeploymentPollingAtom, + selectedEnvironmentDeploymentAtom, + selectedPublisherEnvironmentAtom, +} from '../state' + +export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?: string) { + const queryClient = useQueryClient() + const operationsNeedingEnvironmentRefreshRef = useRef(new Set()) + const refreshedOperationKeysRef = useRef(new Set()) + const open = useAtomValue(appPublisherOpenAtom) + const polling = useAtomValue(publisherEnvironmentDeploymentPollingAtom) + const environment = useAtomValue(selectedPublisherEnvironmentAtom) + const deployment = useAtomValue(selectedEnvironmentDeploymentAtom) + const finishPolling = useSetAtom(finishPublisherEnvironmentDeploymentPollingAtom) + + useEffect(() => { + if ( + !appId || + !open || + !environment || + !deployment || + deployment.environment.id !== environment.id + ) + return + + const operation = deployment?.deployment?.latest_operation + const operationKey = operation ? `${appId}:${environment.id}:${operation.id}` : undefined + if (polling) { + const pollingOperationKey = `${appId}:${polling.environmentId}:${polling.operationId}` + operationsNeedingEnvironmentRefreshRef.current.add(pollingOperationKey) + if ( + polling.environmentId !== environment.id || + operation?.id !== polling.operationId || + !isDeploymentOperationTerminal(operation.status) || + isEnvironmentDeploymentInProgress(deployment) + ) + return + + const finishCurrentPolling = () => finishPolling(polling) + if (operation.status !== DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED) { + operationsNeedingEnvironmentRefreshRef.current.delete(pollingOperationKey) + finishCurrentPolling() + return + } + operationsNeedingEnvironmentRefreshRef.current.delete(pollingOperationKey) + refreshedOperationKeysRef.current.add(pollingOperationKey) + + const appEnvironmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + + void queryClient + .invalidateQueries({ queryKey: appEnvironmentsQuery.queryKey }) + .then(finishCurrentPolling, finishCurrentPolling) + return + } + + if (isEnvironmentDeploymentInProgress(deployment)) { + if (operationKey) operationsNeedingEnvironmentRefreshRef.current.add(operationKey) + return + } + + if ( + operationKey && + operation?.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED && + operationsNeedingEnvironmentRefreshRef.current.has(operationKey) && + !refreshedOperationKeysRef.current.has(operationKey) + ) { + operationsNeedingEnvironmentRefreshRef.current.delete(operationKey) + refreshedOperationKeysRef.current.add(operationKey) + const appEnvironmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + + void queryClient.invalidateQueries({ queryKey: appEnvironmentsQuery.queryKey }) + return + } + if ( + operationKey && + operation?.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED + ) + operationsNeedingEnvironmentRefreshRef.current.delete(operationKey) + + const deploymentIsInUse = Boolean(deployment.deployment?.current_version) + if (environment.in_use === deploymentIsInUse) return + if (operationKey) refreshedOperationKeysRef.current.add(operationKey) + + const appEnvironmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + + void queryClient.invalidateQueries({ queryKey: appEnvironmentsQuery.queryKey }) + }, [appId, deployment, environment, finishPolling, open, polling, queryClient]) +} diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 2485c78a0e2..48278b77cfc 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -1,512 +1,40 @@ -import type { FormEvent } from 'react' -import type { ModelAndParameter } from '../configuration/debug/types' -import type { - WorkflowHiddenStartVariable, - WorkflowLaunchInputValue, -} from '@/app/components/app/overview/app-card-utils' -import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' -import type { InputVar, Variable } from '@/app/components/workflow/types' -import type { PublishWorkflowParams } from '@/types/workflow' -import { Button } from '@langgenius/dify-ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { toast } from '@langgenius/dify-ui/toast' -import { useHotkey } from '@tanstack/react-hotkeys' +import type { AppPublisherProps } from './types' import { useSuspenseQuery } from '@tanstack/react-query' -import { use, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { WorkflowLaunchDialog } from '@/app/components/app/overview/app-card-sections' -import { - buildWorkflowLaunchUrl, - createWorkflowLaunchInitialValues, - isWorkflowLaunchInputSupported, -} from '@/app/components/app/overview/app-card-utils' -import EmbeddedModal from '@/app/components/app/overview/embedded' +import { useAtom, useAtomValue } from 'jotai' import { useStore as useAppStore } from '@/app/components/app/store' -import { trackEvent } from '@/app/components/base/amplitude' -import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' -import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' -import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool' -import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' -import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' -import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' -import { WorkflowContext } from '@/app/components/workflow/context' -import { appDefaultIconBackground } from '@/config' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' -import { AccessMode } from '@/models/access-control' -import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' -import { fetchAppDetail, publishToCreatorsPlatform } from '@/service/apps' -import { fetchInstalledAppList } from '@/service/explore' -import { useInvalidateAppWorkflow } from '@/service/use-workflow' -import { fetchPublishedWorkflow } from '@/service/workflow' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' import { AppModeEnum } from '@/types/app' -import { basePath } from '@/utils/var' -import AccessControl from '../app-access-control' -import { APP_PUBLISH_HOTKEY } from './hotkeys' -import { - PublisherAccessSection, - PublisherActionsSection, - PublisherSummarySection, -} from './sections' -import SuggestedAction from './suggested-action' -import { - getDisabledFunctionTooltip, - getPublisherAppUrl, - isPublisherAccessConfigured, -} from './utils' +import { getAppACLCapabilities } from '@/utils/permission' +import { PublisherContent } from './publisher-content' +import { appPublisherOpenAtom, AppPublisherStateBoundary } from './state' -export type AppPublisherProps = { - disabled?: boolean - publishDisabled?: boolean - publishedAt?: number - /** only needed in workflow / chatflow mode */ - draftUpdatedAt?: number - debugWithMultipleModel?: boolean - multipleModelConfigs?: ModelAndParameter[] - /** modelAndParameter is passed when debugWithMultipleModel is true */ - onPublish?: AppPublisherPublishHandler - onRestore?: AppPublisherRestoreHandler - onToggle?: (state: boolean) => void - crossAxisOffset?: number - toolPublished?: boolean - inputs?: InputVar[] - outputs?: Variable[] - onRefreshData?: () => void - workflowToolAvailable?: boolean - missingStartNode?: boolean - hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist). - startNodeLimitExceeded?: boolean - hasHumanInputNode?: boolean -} - -export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams - -type AppPublisherPublishHandler = - | ((params?: AppPublisherPublishParams) => Promise | unknown) - | ((params?: unknown) => Promise | unknown) - -type AppPublisherRestoreHandler = () => Promise | unknown - -export function AppPublisher({ - disabled = false, - publishDisabled = false, - publishedAt, - draftUpdatedAt, - debugWithMultipleModel = false, - multipleModelConfigs = [], - onPublish, - onRestore, - onToggle, - crossAxisOffset = 0, - toolPublished, - inputs, - outputs, - onRefreshData, - workflowToolAvailable = true, - missingStartNode = false, - hasTriggerNode = false, - startNodeLimitExceeded = false, - hasHumanInputNode = false, -}: AppPublisherProps) { - const { t } = useTranslation() - - const [published, setPublished] = useState(false) - const [open, setOpen] = useState(false) - const [showAppAccessControl, setShowAppAccessControl] = useState(false) - const [workflowToolDrawerOpen, setWorkflowToolDrawerOpen] = useState(false) - - const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) - const [workflowLaunchDialogOpen, setWorkflowLaunchDialogOpen] = useState(false) - const [workflowLaunchTargetUrl, setWorkflowLaunchTargetUrl] = useState('') - const [workflowLaunchValues, setWorkflowLaunchValues] = useState< - Record - >({}) - const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) - - const workflowStore = use(WorkflowContext) +export function AppPublisher(props: AppPublisherProps) { + const [open, setOpen] = useAtom(appPublisherOpenAtom) const appDetail = useAppStore((state) => state.appDetail) - const setAppDetail = useAppStore((state) => state.setAppDetail) - const canManageTools = useCanManageTools() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { formatTimeFromNow } = useFormatTimeFromNow() - const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} - - const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode }) - const appMode = appDetail?.mode || AppModeEnum.CHAT - const isChatApp = - appMode === AppModeEnum.CHAT || - appMode === AppModeEnum.AGENT_CHAT || - appMode === AppModeEnum.COMPLETION - const hiddenLaunchVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter( - (input) => input.hide === true, - ) - const supportedWorkflowLaunchVariables = hiddenLaunchVariables.filter( - isWorkflowLaunchInputSupported, - ) - const unsupportedWorkflowLaunchVariables = hiddenLaunchVariables.filter( - (variable) => !isWorkflowLaunchInputSupported(variable), - ) - const initialWorkflowLaunchValues = createWorkflowLaunchInitialValues( - supportedWorkflowLaunchVariables, - ) - - const shouldLoadUserCanAccessApp = Boolean( - appDetail?.id && open && systemFeatures.webapp_auth.enabled, - ) - const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ - appId: appDetail?.id, - enabled: shouldLoadUserCanAccessApp, + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, }) - const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = - useAppWhiteListSubjects( - appDetail?.id, - open && - systemFeatures.webapp_auth.enabled && - appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, - ) - const invalidateAppWorkflow = useInvalidateAppWorkflow() - const openAsyncWindow = useAsyncWindowOpen() - - const isAppAccessSet = isPublisherAccessConfigured(appDetail, appAccessSubjects) - - const noAccessPermission = Boolean( - systemFeatures.webapp_auth.enabled && - appDetail && - appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && - !userCanAccessApp?.result, - ) - const disabledFunctionButton = !publishedAt || missingStartNode || noAccessPermission - const disabledFunctionTooltip = getDisabledFunctionTooltip({ - t, - publishedAt, - missingStartNode, - noAccessPermission, - }) - - async function handlePublish(params?: ModelAndParameter | PublishWorkflowParams) { - try { - await onPublish?.(params) - setPublished(true) - - const appId = appDetail?.id - const socket = appId ? webSocketClient.getSocket(appId) : null - if (appId) invalidateAppWorkflow(appId) - else console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') - if (socket) { - const timestamp = Date.now() - socket.emit('collaboration_event', { - type: 'app_publish_update', - data: { - action: 'published', - timestamp, - }, - timestamp, - }) - } else if (appId) { - console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) - } - - trackEvent('app_published_time', { - action_mode: 'app', - app_id: appDetail?.id, - app_name: appDetail?.name, - }) - } catch (error) { - console.warn('[app-publisher] publish failed', error) - setPublished(false) - } - } - - async function handleRestore() { - try { - await onRestore?.() - setOpen(false) - } catch {} - } - - function handleOpenChange(nextOpen: boolean) { - if (disabled) { - setOpen(false) - return - } - - onToggle?.(nextOpen) - setOpen(nextOpen) - - if (nextOpen) setPublished(false) - } - - async function handleOpenInExplore() { - await openAsyncWindow( - async () => { - if (!appDetail?.id) throw new Error('App not found') - const { installed_apps } = await fetchInstalledAppList(appDetail.id) - if (installed_apps?.length > 0) - return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` - throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) - }, - { - onError: (err) => { - toast.error(`${err.message || err}`) - }, - }, - ) - } - - async function handleAccessControlUpdate() { - if (!appDetail) return - try { - const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) - setAppDetail({ ...res }) - } finally { - setShowAppAccessControl(false) - } - } - - function handleOpenWorkflowLaunchDialog(targetUrl: string) { - setWorkflowLaunchValues(initialWorkflowLaunchValues) - setWorkflowLaunchTargetUrl(targetUrl) - setWorkflowLaunchDialogOpen(true) - } - - function handleWorkflowLaunchValueChange(variable: string, value: WorkflowLaunchInputValue) { - setWorkflowLaunchValues((prev) => ({ - ...prev, - [variable]: value, - })) - } - - async function handleWorkflowLaunchConfirm(event: FormEvent) { - event.preventDefault() - - const targetUrl = await buildWorkflowLaunchUrl({ - accessibleUrl: workflowLaunchTargetUrl, - variables: supportedWorkflowLaunchVariables, - values: workflowLaunchValues, - }) - - window.open(targetUrl, '_blank') - setWorkflowLaunchDialogOpen(false) - } - - async function handlePublishToMarketplace() { - if (!appDetail?.id || publishingToMarketplace) return - setPublishingToMarketplace(true) - try { - const res = await publishToCreatorsPlatform({ appID: appDetail.id }) - if (res.redirect_url) window.open(res.redirect_url, '_blank') - } catch { - toast.error(t(($) => $['common.publishToMarketplaceFailed'], { ns: 'workflow' })) - } finally { - setPublishingToMarketplace(false) - } - } - - useHotkey(APP_PUBLISH_HOTKEY, (e) => { - e.preventDefault() - if (publishDisabled || published) return - handlePublish() - }) - - useEffect(() => { - const appId = appDetail?.id - if (!appId) return - - const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => { - const action = typeof update.data.action === 'string' ? update.data.action : undefined - if (action === 'published') { - invalidateAppWorkflow(appId) - fetchPublishedWorkflow(`/apps/${appId}/workflows/publish`) - .then((publishedWorkflow) => { - if (publishedWorkflow?.created_at) - workflowStore?.getState().setPublishedAt(publishedWorkflow.created_at) - }) - .catch((error) => { - console.warn('[app-publisher] refresh published workflow failed', error) - }) - } - }) - - return unsubscribe - }, [appDetail?.id, invalidateAppWorkflow, workflowStore]) - - const hasPublishedVersion = !!publishedAt - const workflowToolVisible = - appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode - const workflowToolAvailableForUser = workflowToolAvailable && canManageTools - const workflowToolMessage = - !hasPublishedVersion || !workflowToolAvailable - ? t(($) => $['common.workflowAsToolDisabledHint'], { ns: 'workflow' }) - : undefined - const workflowToolPublished = !!toolPublished - function closeWorkflowToolDrawer() { - setWorkflowToolDrawerOpen(false) - } - const workflowToolIcon = { - content: (appDetail?.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', - background: - (appDetail?.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || - appDefaultIconBackground, - } - const workflowTool = useConfigureButton({ - enabled: workflowToolVisible && canManageTools, - published: workflowToolPublished, - detailNeedUpdate: workflowToolPublished && published, - workflowAppId: appDetail?.id ?? '', - icon: workflowToolIcon, - name: appDetail?.name ?? '', - description: appDetail?.description ?? '', - inputs, - outputs, - handlePublish, - onRefreshData, - onConfigured: closeWorkflowToolDrawer, - }) - function openWorkflowToolDrawer() { - if (!canManageTools) return - - handleOpenChange(false) - setWorkflowToolDrawerOpen(true) - } - const upgradeHighlightStyle = { - background: - 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)', - WebkitBackgroundClip: 'text', - backgroundClip: 'text', - WebkitTextFillColor: 'transparent', - } + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const canDeploy = getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }).canDeploy + const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy return ( - <> - - - {t(($) => $['common.publish'], { ns: 'workflow' })} - - - } - /> - -
- - { - handleOpenChange(false) - setShowAppAccessControl(true) - }} - /> - { - setEmbeddingModalOpen(true) - handleOpenChange(false) - }} - handleOpenInExplore={() => { - handleOpenChange(false) - handleOpenInExplore() - }} - handleOpenRunConfig={handleOpenWorkflowLaunchDialog} - handlePublish={handlePublish} - hasHumanInputNode={hasHumanInputNode} - hasTriggerNode={hasTriggerNode} - missingStartNode={missingStartNode} - published={published} - publishedAt={publishedAt} - showBatchRunConfig={ - hiddenLaunchVariables.length > 0 && - (appDetail?.mode === AppModeEnum.WORKFLOW || - appDetail?.mode === AppModeEnum.COMPLETION) - } - showRunConfig={hiddenLaunchVariables.length > 0} - toolPublished={toolPublished} - workflowToolAvailable={workflowToolAvailableForUser} - workflowToolIsLoading={workflowTool.isLoading} - workflowToolOutdated={workflowTool.outdated} - workflowToolMessage={workflowToolMessage} - onConfigureWorkflowTool={openWorkflowToolDrawer} - /> - {systemFeatures.enable_creators_platform && ( -
- } - disabled={!publishedAt || publishingToMarketplace} - onClick={handlePublishToMarketplace} - > - {publishingToMarketplace - ? t(($) => $['common.publishingToMarketplace'], { ns: 'workflow' }) - : t(($) => $['common.publishToMarketplace'], { ns: 'workflow' })} - -
- )} -
-
- setEmbeddingModalOpen(false)} - appBaseUrl={appBaseURL} - accessToken={accessToken} - hiddenInputs={hiddenLaunchVariables} - /> - {showAppAccessControl && ( - { - setShowAppAccessControl(false) - }} - /> - )} - -
- {workflowToolDrawerOpen && canManageTools && ( - - )} - + + + ) } diff --git a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx index b51568930e1..ae2f69ac78e 100644 --- a/web/app/components/app/app-publisher/publish-with-multiple-model.tsx +++ b/web/app/components/app/app-publisher/publish-with-multiple-model.tsx @@ -19,11 +19,13 @@ import { useProviderContext } from '@/context/provider-context' import ModelIcon from '../../header/account-setting/model-provider-page/model-icon' type PublishWithMultipleModelProps = { + disabled?: boolean multipleModelConfigs: ModelAndParameter[] // textGenerationModelList?: Model[] onSelect: (v: ModelAndParameter) => void } const PublishWithMultipleModel: FC = ({ + disabled = false, multipleModelConfigs, // textGenerationModelList = [], onSelect, @@ -55,13 +57,13 @@ const PublishWithMultipleModel: FC = ({ } }) + const triggerDisabled = disabled || !validModelConfigs.length + return ( - } + disabled={triggerDisabled} + render={ + } + /> + +
+ {showBuiltInPublisher ? ( + + ) : ( + + )} +
+
+ + + ) +} diff --git a/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts b/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts new file mode 100644 index 00000000000..740ee176c65 --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-content/use-marketplace-publish.ts @@ -0,0 +1,28 @@ +import { toast } from '@langgenius/dify-ui/toast' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { publishToCreatorsPlatform } from '@/service/apps' + +export function useMarketplacePublish(appId?: string) { + const { t } = useTranslation() + const [isPublishing, setIsPublishing] = useState(false) + + async function publish() { + if (!appId || isPublishing) return + + setIsPublishing(true) + try { + const response = await publishToCreatorsPlatform({ appID: appId }) + if (response.redirect_url) window.open(response.redirect_url, '_blank') + } catch { + toast.error(t(($) => $['common.publishToMarketplaceFailed'], { ns: 'workflow' })) + } finally { + setIsPublishing(false) + } + } + + return { + isPublishing, + publish, + } +} diff --git a/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts b/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts new file mode 100644 index 00000000000..0fc2f7acb3d --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-content/use-publish-controller.ts @@ -0,0 +1,169 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { AppPublisherProps, AppPublisherPublishParams } from '../types' +import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' +import { useHotkey } from '@tanstack/react-hotkeys' +import { useQueryClient } from '@tanstack/react-query' +import { use, useEffect, useState } from 'react' +import { trackEvent } from '@/app/components/base/amplitude' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' +import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' +import { WorkflowContext } from '@/app/components/workflow/context' +import { consoleQuery } from '@/service/client' +import { useAppWorkflow, useInvalidateAppWorkflow } from '@/service/use-workflow' +import { + appWorkflowQueryOptions, + appWorkflowVersionsInfiniteQueryOptions, +} from '@/service/workflow-queries' +import { AppModeEnum } from '@/types/app' +import { APP_PUBLISH_HOTKEY } from '../hotkeys' + +type UsePublishControllerParams = Pick< + AppPublisherProps, + 'onPublish' | 'onRestore' | 'publishDisabled' | 'publishedAt' +> & { + appId?: string + appMode?: AppModeEnum + appName?: string + supportsMultiEnvironment: boolean + onClose: () => void +} + +function refreshAppDeploymentData(queryClient: QueryClient, appId: string) { + const workflowVersionsQuery = appWorkflowVersionsInfiniteQueryOptions(appId) + const environmentDeploymentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + + void Promise.all([ + queryClient.invalidateQueries({ queryKey: workflowVersionsQuery.queryKey }), + queryClient.invalidateQueries({ queryKey: environmentDeploymentsQuery.queryKey }), + ]).catch((error) => { + console.warn('[app-publisher] refresh deployment data failed', error) + }) +} + +export function usePublishController({ + appId, + appMode, + appName, + onClose, + onPublish, + onRestore, + publishDisabled = false, + publishedAt, + supportsMultiEnvironment, +}: UsePublishControllerParams) { + const [published, setPublished] = useState(false) + const queryClient = useQueryClient() + const workflowStore = use(WorkflowContext) + const invalidateAppWorkflow = useInvalidateAppWorkflow() + const isWorkflowApp = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT + const isChatApp = + appMode === AppModeEnum.CHAT || + appMode === AppModeEnum.AGENT_CHAT || + appMode === AppModeEnum.COMPLETION + const { + data: publishedWorkflow, + isError: isPublishedWorkflowError, + isLoading: isPublishedWorkflowLoading, + isSuccess: isPublishedWorkflowSuccess, + } = useAppWorkflow(isWorkflowApp ? (appId ?? '') : '') + const currentPublishedAt = + isWorkflowApp && isPublishedWorkflowSuccess + ? publishedWorkflow?.created_at + ? publishedWorkflow.created_at * 1000 + : undefined + : publishedAt + const hasPublishedVersion = Boolean(currentPublishedAt) + + async function handlePublish(params?: AppPublisherPublishParams) { + try { + await onPublish?.(params) + setPublished(true) + + const socket = appId ? webSocketClient.getSocket(appId) : null + if (appId) { + invalidateAppWorkflow(appId) + if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId) + } else { + console.warn('[app-publisher] missing appId, skip workflow invalidate and socket emit') + } + if (socket) { + const timestamp = Date.now() + socket.emit('collaboration_event', { + type: 'app_publish_update', + data: { + action: 'published', + timestamp, + }, + timestamp, + }) + } else if (appId) { + console.warn('[app-publisher] socket not ready, skip collaboration_event emit', { appId }) + } + + trackEvent('app_published_time', { + action_mode: 'app', + app_id: appId, + app_name: appName, + }) + } catch (error) { + console.warn('[app-publisher] publish failed', error) + setPublished(false) + } + } + + async function handleRestore() { + try { + await onRestore?.() + onClose() + } catch {} + } + + useHotkey(APP_PUBLISH_HOTKEY, (event) => { + event.preventDefault() + if (publishDisabled || published) return + void handlePublish() + }) + + useEffect(() => { + if (!appId) return + + const unsubscribe = collaborationManager.onAppPublishUpdate((update: CollaborationUpdate) => { + const action = typeof update.data.action === 'string' ? update.data.action : undefined + if (action !== 'published') return + + if (supportsMultiEnvironment) refreshAppDeploymentData(queryClient, appId) + void queryClient + .fetchQuery(appWorkflowQueryOptions(appId)) + .then((publishedWorkflow) => { + workflowStore?.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0) + }) + .catch((error) => { + console.warn('[app-publisher] refresh published workflow failed', error) + }) + }) + + return unsubscribe + }, [appId, queryClient, supportsMultiEnvironment, workflowStore]) + + return { + currentPublishedAt, + handlePublish, + handleRestore, + hasPublishedVersion, + isChatApp, + isPublishedWorkflowError, + isPublishedWorkflowLoading, + isPublishedWorkflowSuccess, + isWorkflowApp, + published, + publishedWorkflow, + resetPublished: () => setPublished(false), + } +} diff --git a/web/app/components/app/app-publisher/publisher-content/use-version-info.ts b/web/app/components/app/app-publisher/publisher-content/use-version-info.ts new file mode 100644 index 00000000000..a9a23b42d9a --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-content/use-version-info.ts @@ -0,0 +1,64 @@ +import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' +import { toast } from '@langgenius/dify-ui/toast' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useInvalidateAppWorkflow, useUpdateWorkflow } from '@/service/use-workflow' + +type VersionInfoUpdate = { + id?: string + title: string + releaseNotes: string +} + +export function useVersionInfo({ + appId, + publishedWorkflow, + onClosePublisher, +}: { + appId?: string + publishedWorkflow?: WorkflowResponse | null + onClosePublisher: () => void +}) { + const { t } = useTranslation() + const [isOpen, setIsOpen] = useState(false) + const { mutate: updateWorkflow } = useUpdateWorkflow() + const invalidateAppWorkflow = useInvalidateAppWorkflow() + + function openEditor() { + if (!publishedWorkflow) return + + onClosePublisher() + setIsOpen(true) + } + + function updateVersionInfo(params: VersionInfoUpdate) { + if (!appId || !params.id) return + + updateWorkflow( + { + url: `/apps/${appId}/workflows/${params.id}`, + title: params.title, + releaseNotes: params.releaseNotes, + }, + { + onSuccess: () => { + toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' })) + invalidateAppWorkflow(appId) + }, + onError: () => { + toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' })) + }, + onSettled: () => { + setIsOpen(false) + }, + }, + ) + } + + return { + closeEditor: () => setIsOpen(false), + isOpen, + openEditor, + updateVersionInfo, + } +} diff --git a/web/app/components/app/app-publisher/publisher-content/use-workflow-launch.ts b/web/app/components/app/app-publisher/publisher-content/use-workflow-launch.ts new file mode 100644 index 00000000000..22ce5c28e29 --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-content/use-workflow-launch.ts @@ -0,0 +1,27 @@ +import type { WorkflowHiddenStartVariable } from '@/app/components/app/overview/app-card-utils' +import type { InputVar } from '@/app/components/workflow/types' +import { useState } from 'react' + +export function useWorkflowLaunch(inputs?: InputVar[]) { + const [targetUrl, setTargetUrl] = useState() + const hiddenVariables: WorkflowHiddenStartVariable[] = (inputs ?? []).filter( + (input) => input.hide === true, + ) + + function openDialog(nextTargetUrl: string) { + setTargetUrl(nextTargetUrl) + } + + function handleOpenChange(open: boolean) { + if (!open) setTargetUrl(undefined) + } + + return { + hasHiddenVariables: hiddenVariables.length > 0, + hiddenVariables, + onOpenChange: handleOpenChange, + open: targetUrl !== undefined, + openDialog, + targetUrl: targetUrl ?? '', + } +} diff --git a/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts b/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts new file mode 100644 index 00000000000..d79b33cf4bb --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-content/use-workflow-tool.ts @@ -0,0 +1,103 @@ +import type { AppPublisherPublishParams } from '../types' +import type { InputVar, Variable } from '@/app/components/workflow/types' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useCanManageTools } from '@/app/components/tools/hooks/use-tool-permissions' +import { useConfigureButton } from '@/app/components/tools/workflow-tool/hooks/use-configure-button' +import { appDefaultIconBackground } from '@/config' +import { AppModeEnum } from '@/types/app' + +type UseWorkflowToolParams = { + appDescription?: string + appIcon?: string + appIconBackground?: string | null + appIconType?: string | null + appId?: string + appMode?: AppModeEnum + appName?: string + appPublished: boolean + hasHumanInputNode: boolean + hasPublishedVersion: boolean + hasTriggerNode: boolean + inputs?: InputVar[] + outputs?: Variable[] + toolPublished?: boolean + workflowToolAvailable: boolean + onClosePublisher: () => void + onPublish: (params?: AppPublisherPublishParams) => Promise + onRefreshData?: () => void +} + +export function useWorkflowTool({ + appDescription, + appIcon, + appIconBackground, + appIconType, + appId, + appMode, + appName, + appPublished, + hasHumanInputNode, + hasPublishedVersion, + hasTriggerNode, + inputs, + onClosePublisher, + onPublish, + onRefreshData, + outputs, + toolPublished, + workflowToolAvailable, +}: UseWorkflowToolParams) { + const { t } = useTranslation() + const [drawerOpen, setDrawerOpen] = useState(false) + const canManageTools = useCanManageTools() + const visible = appMode === AppModeEnum.WORKFLOW && !hasHumanInputNode && !hasTriggerNode + const published = Boolean(toolPublished) + const message = + !hasPublishedVersion || !workflowToolAvailable + ? t(($) => $['common.workflowAsToolDisabledHint'], { ns: 'workflow' }) + : undefined + const icon = { + content: (appIconType === 'image' ? '🤖' : appIcon) || '🤖', + background: + (appIconType === 'image' ? appDefaultIconBackground : appIconBackground) || + appDefaultIconBackground, + } + + function closeDrawer() { + setDrawerOpen(false) + } + + const configuration = useConfigureButton({ + enabled: visible && canManageTools, + published, + detailNeedUpdate: published && appPublished, + workflowAppId: appId ?? '', + icon, + name: appName ?? '', + description: appDescription ?? '', + inputs, + outputs, + handlePublish: onPublish, + onRefreshData, + onConfigured: closeDrawer, + }) + + function openDrawer() { + if (!canManageTools) return + + onClosePublisher() + setDrawerOpen(true) + } + + return { + availableForUser: workflowToolAvailable && canManageTools, + canManageTools, + closeDrawer, + configuration, + drawerOpen, + message, + openDrawer, + published, + } +} diff --git a/web/app/components/app/app-publisher/publisher-deploying-marker/index.module.css b/web/app/components/app/app-publisher/publisher-deploying-marker/index.module.css new file mode 100644 index 00000000000..e2ed1a03b5d --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-deploying-marker/index.module.css @@ -0,0 +1,100 @@ +.deployingChevronTop { + animation: deploying-chevron-top 2s infinite; +} + +.deployingChevronBottom { + animation: deploying-chevron-bottom 2s infinite; +} + +@keyframes deploying-chevron-top { + 0% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: linear; + } + + 10% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: ease-out; + } + + 22.5% { + opacity: 1; + transform: translateY(-0.75px); + animation-timing-function: ease-in-out; + } + + 37.5% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: linear; + } + + 60% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: ease-out; + } + + 72.5% { + opacity: 1; + transform: translateY(-0.75px); + animation-timing-function: ease-in-out; + } + + 87.5%, + 100% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: linear; + } +} + +@keyframes deploying-chevron-bottom { + 0% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: ease-out; + } + + 12.5% { + opacity: 1; + transform: translateY(-0.75px); + animation-timing-function: ease-in-out; + } + + 27.5% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: linear; + } + + 50% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: ease-out; + } + + 62.5% { + opacity: 1; + transform: translateY(-0.75px); + animation-timing-function: ease-in-out; + } + + 77.5%, + 100% { + opacity: 0.25; + transform: translateY(0.75px); + animation-timing-function: linear; + } +} + +@media (prefers-reduced-motion: reduce) { + .deployingChevronTop, + .deployingChevronBottom { + animation: none; + opacity: 1; + transform: none; + } +} diff --git a/web/app/components/app/app-publisher/publisher-deploying-marker/index.tsx b/web/app/components/app/app-publisher/publisher-deploying-marker/index.tsx new file mode 100644 index 00000000000..ac549ac4910 --- /dev/null +++ b/web/app/components/app/app-publisher/publisher-deploying-marker/index.tsx @@ -0,0 +1,31 @@ +import { cn } from '@langgenius/dify-ui/cn' +import styles from './index.module.css' + +export function PublisherDeployingMarker() { + return ( + + + + + + + + + + + + ) +} diff --git a/web/app/components/app/app-publisher/sections.tsx b/web/app/components/app/app-publisher/sections.tsx deleted file mode 100644 index 4067bf22095..00000000000 --- a/web/app/components/app/app-publisher/sections.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import type { CSSProperties, ReactNode } from 'react' -import type { ModelAndParameter } from '../configuration/debug/types' -import type { AppPublisherProps } from './index' -import type { PublishWorkflowParams } from '@/types/workflow' -import { Button } from '@langgenius/dify-ui/button' -import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { formatForDisplay } from '@tanstack/react-hotkeys' -import { useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' -import Loading from '@/app/components/base/loading' -import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' -import { AppModeEnum } from '@/types/app' -import { APP_PUBLISH_HOTKEY } from './hotkeys' -import PublishWithMultipleModel from './publish-with-multiple-model' -import SuggestedAction from './suggested-action' -import { ACCESS_MODE_MAP } from './utils' - -type SummarySectionProps = Pick< - AppPublisherProps, - | 'debugWithMultipleModel' - | 'draftUpdatedAt' - | 'multipleModelConfigs' - | 'publishDisabled' - | 'publishedAt' - | 'startNodeLimitExceeded' -> & { - formatTimeFromNow: (value: number) => string - handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise - handleRestore: () => Promise - isChatApp: boolean - published: boolean - upgradeHighlightStyle: CSSProperties -} - -type AccessSectionProps = { - enabled: boolean - isAppAccessSet: boolean - isLoading: boolean - accessMode?: keyof typeof ACCESS_MODE_MAP - onClick: () => void -} - -type ActionsSectionProps = Pick< - AppPublisherProps, - | 'hasHumanInputNode' - | 'hasTriggerNode' - | 'missingStartNode' - | 'toolPublished' - | 'publishedAt' - | 'workflowToolAvailable' -> & { - appDetail: - | { - id?: string - icon?: string - icon_type?: string | null - icon_background?: string | null - description?: string - mode?: AppModeEnum - name?: string - } - | null - | undefined - appURL: string - disabledFunctionButton: boolean - disabledFunctionTooltip?: string - handleEmbed: () => void - handleOpenInExplore: () => void - handleOpenRunConfig?: (url: string) => void - handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise - published: boolean - showBatchRunConfig?: boolean - showRunConfig?: boolean - workflowToolIsLoading: boolean - workflowToolOutdated: boolean - workflowToolMessage?: string - onConfigureWorkflowTool: () => void -} - -export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => { - const { t } = useTranslation() - - if (!mode || !ACCESS_MODE_MAP[mode]) return null - - const { icon, label } = ACCESS_MODE_MAP[mode] - - return ( - <> - - - - {t(($) => $[`accessControlDialog.accessItems.${label}`], { ns: 'app' })} - - - - ) -} - -export const PublisherSummarySection = ({ - debugWithMultipleModel = false, - draftUpdatedAt, - formatTimeFromNow, - handlePublish, - handleRestore, - isChatApp, - multipleModelConfigs = [], - publishDisabled = false, - published, - publishedAt, - startNodeLimitExceeded = false, - upgradeHighlightStyle, -}: SummarySectionProps) => { - const { t } = useTranslation() - - return ( -
-
- {publishedAt - ? t(($) => $['common.latestPublished'], { ns: 'workflow' }) - : t(($) => $['common.currentDraftUnpublished'], { ns: 'workflow' })} -
- {publishedAt ? ( -
-
- {t(($) => $['common.publishedAt'], { ns: 'workflow' })} {formatTimeFromNow(publishedAt)} -
- {isChatApp && ( - - )} -
- ) : ( -
- {t(($) => $['common.autoSaved'], { ns: 'workflow' })} · - {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} -
- )} - {debugWithMultipleModel ? ( - handlePublish(item)} - /> - ) : ( - <> - - {startNodeLimitExceeded && ( -
-

- - {t(($) => $['publishLimit.startNodeTitlePrefix'], { ns: 'workflow' })} - - - {t(($) => $['publishLimit.startNodeTitleSuffix'], { ns: 'workflow' })} - -

-

- {t(($) => $['publishLimit.startNodeDesc'], { ns: 'workflow' })} -

- -
- )} - - )} -
- ) -} - -export const PublisherAccessSection = ({ - enabled, - isAppAccessSet, - isLoading, - accessMode, - onClick, -}: AccessSectionProps) => { - const { t } = useTranslation() - - if (isLoading) - return ( -
- -
- ) - - return ( - <> - - {enabled && ( -
-
-

- {t(($) => $['publishApp.title'], { ns: 'app' })} -

-
- - {!isAppAccessSet && ( -

- {t(($) => $['publishApp.notSetDesc'], { ns: 'app' })} -

- )} -
- )} - - ) -} - -const ActionTooltip = ({ - disabled, - tooltip, - children, -}: { - disabled: boolean - tooltip?: ReactNode - children: ReactNode -}) => { - if (!disabled || !tooltip) return <>{children} - - return ( - - {children}
} /> - {tooltip} - - ) -} - -export const PublisherActionsSection = ({ - appDetail, - appURL, - disabledFunctionButton, - disabledFunctionTooltip, - handleEmbed, - handleOpenInExplore, - handleOpenRunConfig, - hasHumanInputNode = false, - hasTriggerNode = false, - missingStartNode = false, - publishedAt, - showBatchRunConfig = false, - showRunConfig = false, - toolPublished, - workflowToolAvailable = true, - workflowToolIsLoading, - workflowToolOutdated, - workflowToolMessage, - onConfigureWorkflowTool, -}: ActionsSectionProps) => { - const { t } = useTranslation() - - if (hasTriggerNode) return null - - const workflowToolDisabled = !publishedAt || !workflowToolAvailable - - return ( -
- - } - actionButton={ - showRunConfig - ? { - ariaLabel: t(($) => $['operation.config'], { ns: 'common' }), - icon: , - onClick: () => handleOpenRunConfig?.(appURL), - } - : undefined - } - > - {t(($) => $['common.runApp'], { ns: 'workflow' })} - - - {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION ? ( - - } - actionButton={ - showBatchRunConfig - ? { - ariaLabel: t(($) => $['operation.config'], { ns: 'common' }), - icon: , - onClick: () => - handleOpenRunConfig?.( - `${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`, - ), - } - : undefined - } - > - {t(($) => $['common.batchRunApp'], { ns: 'workflow' })} - - - ) : ( - } - > - {t(($) => $['common.embedIntoSite'], { ns: 'workflow' })} - - )} - - { - if (publishedAt) handleOpenInExplore() - }} - disabled={disabledFunctionButton} - icon={} - > - {t(($) => $['common.openInExplore'], { ns: 'workflow' })} - - - $.notPublishedYet, { ns: 'app' }) - : t(($) => $.noUserInputNode, { ns: 'app' }) - } - > - } - > - {t(($) => $['common.accessAPIReference'], { ns: 'workflow' })} - - - {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( - - )} -
- ) -} diff --git a/web/app/components/app/app-publisher/shared/timeline-marker.tsx b/web/app/components/app/app-publisher/shared/timeline-marker.tsx new file mode 100644 index 00000000000..0b8970fa0bd --- /dev/null +++ b/web/app/components/app/app-publisher/shared/timeline-marker.tsx @@ -0,0 +1,24 @@ +import { cn } from '@langgenius/dify-ui/cn' + +export function PublisherTimelineMarker({ position }: { position: 'top' | 'bottom' }) { + return ( + + + + + ) +} diff --git a/web/app/components/app/app-publisher/state.ts b/web/app/components/app/app-publisher/state.ts new file mode 100644 index 00000000000..bf8937f705d --- /dev/null +++ b/web/app/components/app/app-publisher/state.ts @@ -0,0 +1,362 @@ +'use client' + +import type { ReactNode } from 'react' +import { DeploymentOperationStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { skipToken } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithQuery } from 'jotai-tanstack-query' +import { selectAtom, useHydrateAtoms } from 'jotai/utils' +import { isEnvironmentDeploymentInProgress } from '@/app/components/app/deploy/state' +import { consoleQuery } from '@/service/client' + +export const BUILT_IN_ENVIRONMENT_ID = 'built-in' +const PUBLISHER_DEPLOYMENT_POLLING_INTERVAL = 3000 + +export type PublisherEnvironmentDeploymentPolling = { + environmentId: string + operationId: string +} + +const appPublisherAppIdAtom = atom(null) +const appPublisherEnvironmentQueryEnabledAtom = atom(false) +const appPublisherOpenStateAtom = atom(false) +const selectedEnvironmentByAppIdAtom = atom>({}) +const locallyJoinedEnvironmentIdsByAppIdAtom = atom>({}) +const environmentDeploymentPollingByAppIdAtom = atom< + Record +>({}) + +const clearPublisherEnvironmentDeploymentPollingAtom = atom(null, (get, set) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return + + set(environmentDeploymentPollingByAppIdAtom, (current) => { + if (!current[appId]) return current + + const next = { ...current } + delete next[appId] + return next + }) +}) + +export const appPublisherOpenAtom = atom( + (get) => get(appPublisherOpenStateAtom), + (get, set, open: boolean) => { + const isReopening = open && !get(appPublisherOpenStateAtom) + if (isReopening && get(appPublisherEnvironmentQueryEnabledAtom)) { + const appId = get(appPublisherAppIdAtom) + const selectedEnvironmentId = appId + ? (get(selectedEnvironmentByAppIdAtom)[appId] ?? BUILT_IN_ENVIRONMENT_ID) + : BUILT_IN_ENVIRONMENT_ID + + if (appId && selectedEnvironmentId !== BUILT_IN_ENVIRONMENT_ID) { + set(clearPublisherEnvironmentDeploymentPollingAtom) + set(selectedEnvironmentByAppIdAtom, (current) => ({ + ...current, + [appId]: BUILT_IN_ENVIRONMENT_ID, + })) + } + } + + set(appPublisherOpenStateAtom, open) + if (!open) set(clearPublisherEnvironmentDeploymentPollingAtom) + }, +) + +appPublisherOpenAtom.onMount = (setOpen) => () => { + setOpen(false) +} + +export function AppPublisherStateBoundary({ + appId, + children, + environmentQueryEnabled, +}: { + appId?: string + children: ReactNode + environmentQueryEnabled: boolean +}) { + useHydrateAtoms( + [ + [appPublisherAppIdAtom, appId ?? null], + [appPublisherEnvironmentQueryEnabledAtom, environmentQueryEnabled], + ] as const, + { + dangerouslyForceHydrate: true, + }, + ) + + return children +} + +const appPublisherEnvironmentsQueryAtom = atomWithQuery((get) => { + const appId = get(appPublisherAppIdAtom) + const enabled = get(appPublisherEnvironmentQueryEnabledAtom) + + return consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: appId + ? { + params: { + app_id: appId, + }, + } + : skipToken, + enabled, + }) +}) + +const appPublisherEnvironmentsDataAtom = selectAtom( + appPublisherEnvironmentsQueryAtom, + (query) => query.data?.data, +) + +export const appPublisherEnvironmentsAtom = atom( + (get) => + get(appPublisherEnvironmentsDataAtom)?.filter( + (environment) => environment.id !== BUILT_IN_ENVIRONMENT_ID, + ) ?? [], +) + +export const selectedPublisherEnvironmentIdAtom = atom( + (get) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return BUILT_IN_ENVIRONMENT_ID + + return get(selectedEnvironmentByAppIdAtom)[appId] ?? BUILT_IN_ENVIRONMENT_ID + }, + (get, set, environmentId: string) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return + if (get(selectedPublisherEnvironmentIdAtom) === environmentId) return + + set(clearPublisherEnvironmentDeploymentPollingAtom) + set(selectedEnvironmentByAppIdAtom, (current) => ({ + ...current, + [appId]: environmentId, + })) + }, +) + +export const selectedPublisherEnvironmentAtom = atom((get) => { + const environmentId = get(selectedPublisherEnvironmentIdAtom) + + return get(appPublisherEnvironmentsAtom).find((environment) => environment.id === environmentId) +}) + +export const joinedPublisherEnvironmentIdsAtom = atom((get) => { + const appId = get(appPublisherAppIdAtom) + const locallyJoinedEnvironmentIds = appId + ? (get(locallyJoinedEnvironmentIdsByAppIdAtom)[appId] ?? []) + : [] + const locallyJoinedEnvironmentIdSet = new Set(locallyJoinedEnvironmentIds) + + return get(appPublisherEnvironmentsAtom) + .filter( + (environment) => environment.in_use || locallyJoinedEnvironmentIdSet.has(environment.id), + ) + .map((environment) => environment.id) +}) + +export const addPublisherEnvironmentAtom = atom(null, (get, set, environmentId: string) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return + + set(locallyJoinedEnvironmentIdsByAppIdAtom, (current) => { + const joinedEnvironmentIds = current[appId] ?? [] + if (joinedEnvironmentIds.includes(environmentId)) return current + + return { + ...current, + [appId]: [...joinedEnvironmentIds, environmentId], + } + }) + set(selectedPublisherEnvironmentIdAtom, environmentId) +}) + +export const publisherEnvironmentDeploymentPollingAtom = atom((get) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return + + return get(environmentDeploymentPollingByAppIdAtom)[appId] +}) + +export const startPublisherEnvironmentDeploymentPollingAtom = atom( + null, + (get, set, polling: PublisherEnvironmentDeploymentPolling) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return + + set(environmentDeploymentPollingByAppIdAtom, (current) => ({ + ...current, + [appId]: polling, + })) + }, +) + +export const finishPublisherEnvironmentDeploymentPollingAtom = atom( + null, + (get, set, polling: PublisherEnvironmentDeploymentPolling) => { + const appId = get(appPublisherAppIdAtom) + if (!appId) return + + set(environmentDeploymentPollingByAppIdAtom, (current) => { + const activePolling = current[appId] + if ( + activePolling?.environmentId !== polling.environmentId || + activePolling.operationId !== polling.operationId + ) + return current + + const next = { ...current } + delete next[appId] + return next + }) + }, +) + +export function isDeploymentOperationTerminal(status?: DeploymentOperationStatus) { + return ( + status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED || + status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED + ) +} + +const selectedEnvironmentDeploymentDiscoveryEnabledAtom = atom((get) => { + const appId = get(appPublisherAppIdAtom) + const enabled = get(appPublisherEnvironmentQueryEnabledAtom) + const open = get(appPublisherOpenAtom) + const environmentId = get(selectedPublisherEnvironmentIdAtom) + const selectedEnvironment = get(selectedPublisherEnvironmentAtom) + const polling = get(publisherEnvironmentDeploymentPollingAtom) + + return ( + enabled && + open && + Boolean(appId) && + environmentId !== BUILT_IN_ENVIRONMENT_ID && + selectedEnvironment?.in_use === false && + polling?.environmentId !== environmentId + ) +}) + +const selectedEnvironmentDeploymentsDiscoveryQueryAtom = atomWithQuery((get) => { + const appId = get(appPublisherAppIdAtom) + const discoveryEnabled = get(selectedEnvironmentDeploymentDiscoveryEnabledAtom) + + return consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions( + { + input: + discoveryEnabled && appId + ? { + params: { + app_id: appId, + }, + } + : skipToken, + enabled: discoveryEnabled, + staleTime: 0, + }, + ) +}) + +const selectedEnvironmentDeploymentsDiscoveryDataAtom = selectAtom( + selectedEnvironmentDeploymentsDiscoveryQueryAtom, + (query) => query.data?.environment_deployments, +) + +const selectedEnvironmentDeploymentsDiscoveryIsLoadingAtom = selectAtom( + selectedEnvironmentDeploymentsDiscoveryQueryAtom, + (query) => query.isLoading, +) + +const selectedEnvironmentDeploymentsDiscoveryIsErrorAtom = selectAtom( + selectedEnvironmentDeploymentsDiscoveryQueryAtom, + (query) => query.isError, +) + +const selectedEnvironmentHasDeploymentAtom = atom((get) => { + const environmentId = get(selectedPublisherEnvironmentIdAtom) + + return Boolean( + get(selectedEnvironmentDeploymentsDiscoveryDataAtom)?.some( + (deployment) => deployment.environment.id === environmentId, + ), + ) +}) + +const selectedEnvironmentDeploymentQueryAtom = atomWithQuery((get) => { + const appId = get(appPublisherAppIdAtom) + const enabled = get(appPublisherEnvironmentQueryEnabledAtom) + const open = get(appPublisherOpenAtom) + const environmentId = get(selectedPublisherEnvironmentIdAtom) + const selectedEnvironment = get(selectedPublisherEnvironmentAtom) + const polling = get(publisherEnvironmentDeploymentPollingAtom) + const shouldFetch = + enabled && + open && + Boolean(appId) && + environmentId !== BUILT_IN_ENVIRONMENT_ID && + (selectedEnvironment?.in_use === true || + polling?.environmentId === environmentId || + get(selectedEnvironmentHasDeploymentAtom)) + + return consoleQuery.enterprise.appDeploy.deploymentService.getEnvironmentDeployment.queryOptions({ + input: + shouldFetch && appId + ? { + params: { + app_id: appId, + environment_id: environmentId, + }, + } + : skipToken, + enabled: shouldFetch, + refetchInterval: (query) => { + if (query.state.status === 'error' || query.state.fetchFailureCount > 0) return false + + const deployment = query.state.data?.environment_deployment + if (polling?.environmentId !== environmentId) + return isEnvironmentDeploymentInProgress(deployment) + ? PUBLISHER_DEPLOYMENT_POLLING_INTERVAL + : false + + const operation = deployment?.deployment?.latest_operation + const operationFinished = + operation?.id === polling.operationId && isDeploymentOperationTerminal(operation.status) + + return operationFinished && !isEnvironmentDeploymentInProgress(deployment) + ? false + : PUBLISHER_DEPLOYMENT_POLLING_INTERVAL + }, + staleTime: 0, + }) +}) + +const selectedEnvironmentDeploymentDataAtom = selectAtom( + selectedEnvironmentDeploymentQueryAtom, + (query) => query.data?.environment_deployment, +) + +const selectedEnvironmentDeploymentQueryIsLoadingAtom = selectAtom( + selectedEnvironmentDeploymentQueryAtom, + (query) => query.isLoading, +) + +const selectedEnvironmentDeploymentQueryIsErrorAtom = selectAtom( + selectedEnvironmentDeploymentQueryAtom, + (query) => query.isError, +) + +export const selectedEnvironmentDeploymentAtom = selectedEnvironmentDeploymentDataAtom +export const selectedEnvironmentDeploymentIsLoadingAtom = atom( + (get) => + get(selectedEnvironmentDeploymentQueryIsLoadingAtom) || + (get(selectedEnvironmentDeploymentDiscoveryEnabledAtom) && + get(selectedEnvironmentDeploymentsDiscoveryIsLoadingAtom)), +) +export const selectedEnvironmentDeploymentIsErrorAtom = atom( + (get) => + get(selectedEnvironmentDeploymentQueryIsErrorAtom) || + (get(selectedEnvironmentDeploymentDiscoveryEnabledAtom) && + get(selectedEnvironmentDeploymentsDiscoveryIsErrorAtom)), +) diff --git a/web/app/components/app/app-publisher/suggested-action.tsx b/web/app/components/app/app-publisher/suggested-action.tsx index 9147fe59d21..d9444ff3168 100644 --- a/web/app/components/app/app-publisher/suggested-action.tsx +++ b/web/app/components/app/app-publisher/suggested-action.tsx @@ -1,80 +1,171 @@ -import type { HTMLProps, PropsWithChildren, MouseEvent as ReactMouseEvent } from 'react' +import type { PropsWithChildren, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightUpLine } from '@remixicon/react' +import { useId } from 'react' +import Link from '@/next/link' type SuggestedActionButton = { ariaLabel: string - icon: React.ReactNode + icon: ReactNode onClick: (event: ReactMouseEvent) => void } -type SuggestedActionProps = PropsWithChildren< - HTMLProps & { - icon?: React.ReactNode - link?: string - disabled?: boolean - actionButton?: SuggestedActionButton - } -> +type SuggestedActionProps = PropsWithChildren<{ + icon?: ReactNode + link?: string + external?: boolean + disabled?: boolean + focusableWhenDisabled?: boolean + description?: ReactNode + endIcon?: ReactNode + actionButton?: SuggestedActionButton + className?: string + onClick?: () => void +}> const SuggestedAction = ({ icon, link, - disabled, + external = false, + disabled = false, + focusableWhenDisabled = false, + description, + endIcon, + actionButton, children, className, onClick, - actionButton, - ...props }: SuggestedActionProps) => { - const handleClick = (event: ReactMouseEvent) => { - if (disabled) { - event.preventDefault() - return - } - - onClick?.(event) - } - - const handleActionClick = (event: ReactMouseEvent) => { - if (disabled) { - event.preventDefault() - return - } - - actionButton?.onClick(event) - } - - const mainAction = ( - -
{icon}
-
{children}
- -
+ const id = useId() + const labelId = `${id}-label` + const descriptionId = `${id}-description` + const interactiveClassName = cn( + 'group flex min-h-10 min-w-0 items-center gap-2 border-0 bg-transparent p-1 text-left outline-hidden transition-[min-height,background-color] duration-200 ease-out motion-reduce:transition-none', + actionButton ? 'flex-1 rounded-l-lg' : 'w-full rounded-lg', + disabled + ? cn( + 'cursor-not-allowed', + !actionButton && 'opacity-30', + focusableWhenDisabled && 'focus-visible:ring-2 focus-visible:ring-state-accent-solid', + ) + : 'cursor-pointer hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid', + !actionButton && className, ) + const content = ( + <> + + {icon} + + + + {children} + + {!!description && ( + + + {description} + + + )} + + + {endIcon ?? } + + + ) + const accessibilityProps = { + 'aria-labelledby': labelId, + 'aria-describedby': description ? descriptionId : undefined, + } + + let mainAction: ReactNode + + if (disabled) { + mainAction = ( + + ) + } else if (!link) { + mainAction = ( + + ) + } else if (external) { + mainAction = ( + + {content} + + ) + } else { + mainAction = ( + + {content} + + ) + } if (!actionButton) return mainAction return (
@@ -84,14 +175,16 @@ const SuggestedAction = ({ aria-label={actionButton.ariaLabel} disabled={disabled} className={cn( - 'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary transition-colors', + 'flex w-9 shrink-0 items-center justify-center rounded-r-lg border-l-[0.5px] border-divider-subtle text-text-tertiary outline-hidden transition-colors', disabled ? 'cursor-not-allowed' - : 'cursor-pointer hover:bg-state-accent-hover hover:text-text-accent', + : 'cursor-pointer hover:bg-state-base-hover hover:text-text-accent focus-visible:bg-state-base-hover focus-visible:text-text-accent focus-visible:ring-2 focus-visible:ring-state-accent-solid', )} - onClick={handleActionClick} + onClick={actionButton.onClick} > - {actionButton.icon} + + {actionButton.icon} +
) diff --git a/web/app/components/app/app-publisher/types.ts b/web/app/components/app/app-publisher/types.ts new file mode 100644 index 00000000000..8c8e9917cde --- /dev/null +++ b/web/app/components/app/app-publisher/types.ts @@ -0,0 +1,35 @@ +import type { ModelAndParameter } from '../configuration/debug/types' +import type { InputVar, Variable } from '@/app/components/workflow/types' +import type { PublishWorkflowParams } from '@/types/workflow' + +export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams + +type AppPublisherPublishHandler = + | ((params?: AppPublisherPublishParams) => Promise | unknown) + | ((params?: unknown) => Promise | unknown) + +type AppPublisherRestoreHandler = () => Promise | unknown + +export type AppPublisherProps = { + disabled?: boolean + publishDisabled?: boolean + publishedAt?: number + /** only needed in workflow / chatflow mode */ + draftUpdatedAt?: number + debugWithMultipleModel?: boolean + multipleModelConfigs?: ModelAndParameter[] + /** modelAndParameter is passed when debugWithMultipleModel is true */ + onPublish?: AppPublisherPublishHandler + onRestore?: AppPublisherRestoreHandler + onToggle?: (state: boolean) => void + crossAxisOffset?: number + toolPublished?: boolean + inputs?: InputVar[] + outputs?: Variable[] + onRefreshData?: () => void + workflowToolAvailable?: boolean + missingStartNode?: boolean + hasTriggerNode?: boolean + startNodeLimitExceeded?: boolean + hasHumanInputNode?: boolean +} diff --git a/web/app/components/app/app-publisher/utils.ts b/web/app/components/app/app-publisher/utils.ts index fe81f5713f6..a7edb07da86 100644 --- a/web/app/components/app/app-publisher/utils.ts +++ b/web/app/components/app/app-publisher/utils.ts @@ -1,43 +1,7 @@ import type { TFunction } from 'i18next' -import type { I18nKeysByPrefix } from '@/types/i18n' -import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' -type AccessSubjectsLike = - | { - groups?: unknown[] - members?: unknown[] - } - | null - | undefined - -type AppDetailLike = { - access_mode?: AccessMode - mode?: AppModeEnum -} - -type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'> - -export const ACCESS_MODE_MAP: Record = { - [AccessMode.ORGANIZATION]: { - label: 'organization', - icon: 'i-ri-building-line', - }, - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: { - label: 'specific', - icon: 'i-ri-lock-line', - }, - [AccessMode.PUBLIC]: { - label: 'anyone', - icon: 'i-ri-global-line', - }, - [AccessMode.EXTERNAL_MEMBERS]: { - label: 'external', - icon: 'i-ri-verified-badge-line', - }, -} - export const getPublisherAppMode = (mode?: AppModeEnum) => { if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW) return AppModeEnum.CHAT @@ -54,17 +18,6 @@ export const getPublisherAppUrl = ({ mode?: AppModeEnum }) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}` -export const isPublisherAccessConfigured = ( - appDetail: AppDetailLike | null | undefined, - appAccessSubjects: AccessSubjectsLike, -) => { - if (!appDetail || !appAccessSubjects) return true - - if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) return true - - return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length) -} - export const getDisabledFunctionTooltip = ({ t, publishedAt, diff --git a/web/app/components/app/app-publisher/version-info-modal.tsx b/web/app/components/app/app-publisher/version-info-modal.tsx index 60ba341ee5d..d693c64efea 100644 --- a/web/app/components/app/app-publisher/version-info-modal.tsx +++ b/web/app/components/app/app-publisher/version-info-modal.tsx @@ -1,18 +1,16 @@ +import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' import type { FC } from 'react' -import type { VersionHistory } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' -import { RiCloseLine } from '@remixicon/react' -import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' type VersionInfoModalProps = { isOpen: boolean - versionInfo?: VersionHistory + versionInfo?: Pick onClose: () => void onPublish: (params: { title: string; releaseNotes: string; id?: string }) => void } @@ -87,7 +85,7 @@ const VersionInfoModal: FC = ({ aria-label={t(($) => $['operation.close'], { ns: 'common' })} onClick={onClose} > -
@@ -118,7 +116,7 @@ const VersionInfoModal: FC = ({ {t(($) => $['operation.cancel'], { ns: 'common' })}
diff --git a/web/app/components/app/app-publisher/workflow-tool-action/disabled-reason.tsx b/web/app/components/app/app-publisher/workflow-tool-action/disabled-reason.tsx new file mode 100644 index 00000000000..c9aa0c72dd3 --- /dev/null +++ b/web/app/components/app/app-publisher/workflow-tool-action/disabled-reason.tsx @@ -0,0 +1,11 @@ +type WorkflowToolDisabledReasonProps = { + message: string +} + +const WorkflowToolDisabledReason = ({ message }: WorkflowToolDisabledReasonProps) => { + return ( +

{message}

+ ) +} + +export default WorkflowToolDisabledReason diff --git a/web/app/components/app/app-publisher/workflow-tool-action/index.tsx b/web/app/components/app/app-publisher/workflow-tool-action/index.tsx new file mode 100644 index 00000000000..aeed4d0bf54 --- /dev/null +++ b/web/app/components/app/app-publisher/workflow-tool-action/index.tsx @@ -0,0 +1,124 @@ +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { buildIntegrationPath } from '@/app/components/integrations/routes' +import Link from '@/next/link' +import SuggestedAction from '../suggested-action' +import WorkflowToolDisabledReason from './disabled-reason' +import WorkflowToolLoadingStatus from './loading-status' +import WorkflowToolSetupStatus from './setup-status' +import WorkflowToolStateLabel from './state-label' + +type WorkflowToolActionProps = { + disabled: boolean + isLoading: boolean + message?: string + outdated: boolean + published: boolean + onConfigure: () => void +} + +const WorkflowToolAction = ({ + disabled, + isLoading, + message, + outdated, + published, + onConfigure, +}: WorkflowToolActionProps) => { + const { t } = useTranslation() + const disabledReason = disabled ? message : undefined + const workflowToolLabel = t(($) => $['common.workflowAsTool'], { ns: 'workflow' }) + + if (!published || isLoading) + return ( +
+ $['common.workflowAsToolDescription'], { ns: 'workflow' })} + endIcon={ + isLoading ? ( + $.loading, { ns: 'appApi' })} /> + ) : ( + $['common.configureRequired'], { ns: 'workflow' })} + /> + ) + } + icon={} + onClick={onConfigure} + > + {workflowToolLabel} + + {disabledReason && } +
+ ) + + const configureLabel = outdated + ? t(($) => $['common.workflowAsToolReconfigure'], { ns: 'workflow' }) + : t(($) => $['common.configure'], { ns: 'workflow' }) + const manageInToolsLabel = t(($) => $['common.manageInTools'], { ns: 'workflow' }) + const stateLabel = outdated + ? t(($) => $['common.workflowAsToolUpdateNeeded'], { ns: 'workflow' }) + : t(($) => $['common.workflowAsToolReady'], { ns: 'workflow' }) + + return ( +
+
+ + + +
+
+ + {workflowToolLabel} + + +
+ {outdated && ( +

+ {t(($) => $['common.workflowAsToolTip'], { ns: 'workflow' })} +

+ )} +
+ + {disabled ? ( + + ) : ( + + {manageInToolsLabel} + + + )} +
+
+
+ {disabledReason && } +
+ ) +} + +export default WorkflowToolAction diff --git a/web/app/components/app/app-publisher/workflow-tool-action/loading-status.tsx b/web/app/components/app/app-publisher/workflow-tool-action/loading-status.tsx new file mode 100644 index 00000000000..015ac520780 --- /dev/null +++ b/web/app/components/app/app-publisher/workflow-tool-action/loading-status.tsx @@ -0,0 +1,15 @@ +type WorkflowToolLoadingStatusProps = { + label: string +} + +const WorkflowToolLoadingStatus = ({ label }: WorkflowToolLoadingStatusProps) => { + return ( + + ) +} + +export default WorkflowToolLoadingStatus diff --git a/web/app/components/app/app-publisher/workflow-tool-action/setup-status.tsx b/web/app/components/app/app-publisher/workflow-tool-action/setup-status.tsx new file mode 100644 index 00000000000..6b86d7f0df1 --- /dev/null +++ b/web/app/components/app/app-publisher/workflow-tool-action/setup-status.tsx @@ -0,0 +1,17 @@ +type WorkflowToolSetupStatusProps = { + label: string +} + +const WorkflowToolSetupStatus = ({ label }: WorkflowToolSetupStatusProps) => { + return ( + + {label} + + ) +} + +export default WorkflowToolSetupStatus diff --git a/web/app/components/app/app-publisher/workflow-tool-action/state-label.tsx b/web/app/components/app/app-publisher/workflow-tool-action/state-label.tsx new file mode 100644 index 00000000000..009da114bfe --- /dev/null +++ b/web/app/components/app/app-publisher/workflow-tool-action/state-label.tsx @@ -0,0 +1,25 @@ +import { cn } from '@langgenius/dify-ui/cn' +import { StatusDot } from '@langgenius/dify-ui/status-dot' + +type WorkflowToolStateLabelProps = { + label: string + outdated: boolean +} + +const WorkflowToolStateLabel = ({ label, outdated }: WorkflowToolStateLabelProps) => { + return ( + + + {label} + + ) +} + +export default WorkflowToolStateLabel diff --git a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx index 32dd02f928b..6116def05fb 100644 --- a/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx +++ b/web/app/components/app/configuration/__tests__/configuration-view.spec.tsx @@ -1,5 +1,5 @@ import type { ComponentProps } from 'react' -import type { ConfigurationViewModel } from '../hooks/use-configuration' +import type { ConfigurationViewModel } from '../hooks/configuration-view-model' import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle' import type { Plugin } from '@/app/components/plugins/types' @@ -258,6 +258,11 @@ const createViewModel = ( publishedConfig: { modelConfig: createContextValue().modelConfig, completionParams: {}, + promptMode: createContextValue().promptMode, + chatPromptConfig: createContextValue().chatPromptConfig, + completionPromptConfig: createContextValue().completionPromptConfig, + datasetConfigs: createContextValue().datasetConfigs, + externalDataToolsConfig: createContextValue().externalDataToolsConfig, }, resetAppConfig: vi.fn(), } as ComponentProps, diff --git a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx index 397a001d53b..4fe7b3c9ee9 100644 --- a/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx +++ b/web/app/components/app/configuration/config-var/__tests__/index.spec.tsx @@ -151,6 +151,26 @@ describe('ConfigVar', () => { expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar]) }) + + it('should ignore sortable updates when the variable order is unchanged', () => { + const onPromptVariablesChange = vi.fn() + const firstVar = createPromptVariable({ key: 'first', name: 'First' }) + const secondVar = createPromptVariable({ key: 'second', name: 'Second' }) + + renderConfigVar({ + promptVariables: [firstVar, secondVar], + onPromptVariablesChange, + }) + + act(() => { + latestSortableProps?.setList([ + { id: 'first', variable: firstVar }, + { id: 'second', variable: secondVar }, + ]) + }) + + expect(onPromptVariablesChange).not.toHaveBeenCalled() + }) }) // Variable creation flows using the add menu. diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 04b61803806..f812c9af76c 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -280,6 +280,17 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar }), [promptVariables], ) + const handlePromptVariablesReorder = useCallback( + (list: typeof promptVariablesWithIds) => { + const hasOrderChanged = + list.length !== promptVariables.length || + list.some((item, index) => item.id !== promptVariables[index]?.key) + if (!hasOrderChanged) return + + onPromptVariablesChange?.(list.map((item) => item.variable)) + }, + [onPromptVariablesChange, promptVariables], + ) const canDrag = !readonly && promptVariables.length > 1 @@ -314,9 +325,7 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar { - onPromptVariablesChange?.(list.map((item) => item.variable)) - }} + setList={handlePromptVariablesReorder} handle=".handle" ghostClass="opacity-50" animation={150} diff --git a/web/app/components/app/configuration/configuration-view.tsx b/web/app/components/app/configuration/configuration-view/index.tsx similarity index 87% rename from web/app/components/app/configuration/configuration-view.tsx rename to web/app/components/app/configuration/configuration-view/index.tsx index 317f2a5e011..9412d52e134 100644 --- a/web/app/components/app/configuration/configuration-view.tsx +++ b/web/app/components/app/configuration/configuration-view/index.tsx @@ -1,6 +1,6 @@ 'use client' import type { FC } from 'react' -import type { ConfigurationViewModel } from './hooks/use-configuration' +import type { ConfigurationViewModel } from '../hooks/configuration-view-model' import type { InstallBundleCompleteCallback } from '@/app/components/plugins/install-plugin/install-bundle' import { CodeBracketIcon } from '@heroicons/react/20/solid' import { @@ -22,7 +22,6 @@ import { DrawerPortal, DrawerViewport, } from '@langgenius/dify-ui/drawer' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { produce } from 'immer' import * as React from 'react' import { useTranslation } from 'react-i18next' @@ -40,44 +39,8 @@ import ModelParameterModal from '@/app/components/header/account-setting/model-p import PluginDependency from '@/app/components/workflow/plugin-dependency' import ConfigContext from '@/context/debug-configuration' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' -import Link from '@/next/link' import { AppModeEnum, ModelModeType } from '@/types/app' - -function LegacyAgentBadge() { - const { t } = useTranslation() - const description = t(($) => $['legacyAgentBadge.description'], { ns: 'appDebug' }) - - return ( - - - - {t(($) => $['legacyAgentBadge.label'], { ns: 'appDebug' })} - - -
{description}
- - {t(($) => $['legacyAgentBadge.action'], { ns: 'appDebug' })} - - -
-
- ) -} +import { LegacyAgentBadge } from './legacy-agent-badge' const ConfigurationView: FC = ({ appPublisherProps, diff --git a/web/app/components/app/configuration/configuration-view/legacy-agent-badge.tsx b/web/app/components/app/configuration/configuration-view/legacy-agent-badge.tsx new file mode 100644 index 00000000000..7190eb8b228 --- /dev/null +++ b/web/app/components/app/configuration/configuration-view/legacy-agent-badge.tsx @@ -0,0 +1,41 @@ +'use client' + +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useTranslation } from 'react-i18next' +import Link from '@/next/link' + +export function LegacyAgentBadge() { + const { t } = useTranslation() + const description = t(($) => $['legacyAgentBadge.description'], { ns: 'appDebug' }) + + return ( + + + + {t(($) => $['legacyAgentBadge.label'], { ns: 'appDebug' })} + + +
{description}
+ + {t(($) => $['legacyAgentBadge.action'], { ns: 'appDebug' })} + + +
+
+ ) +} diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts index 3ee71947e66..36e461597fc 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration-utils.spec.ts @@ -1,5 +1,6 @@ /* oxlint-disable typescript/no-explicit-any */ import type { VisionSettings } from '@/types/app' +import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' import { withSelectorKey } from '@/test/i18n-mock' import { AgentStrategy, @@ -11,13 +12,12 @@ import { } from '@/types/app' import { buildConfigurationDatasetConfigs, - buildPublishBody, - buildPublishedConfig, createDatasetSelectHandler, - createModelChangeHandler, - createPublishHandler, - loadConfigurationState, -} from '../use-configuration-utils' +} from '../configuration-lifecycle/dataset' +import { loadConfigurationState } from '../configuration-lifecycle/load' +import { createModelChangeHandler } from '../configuration-lifecycle/model' +import { buildPublishBody, createPublishHandler } from '../configuration-lifecycle/publish' +import { buildPublishedConfig } from '../configuration-lifecycle/published-config' const mockFetchAppDetailDirect = vi.fn() const mockFetchDatasets = vi.fn() @@ -153,12 +153,20 @@ describe('useConfiguration utils', () => { is_team_authorization: false, }, ] as any, + datasetConfigs: { + datasets: { datasets: [] }, + retrieval_model: RETRIEVE_TYPE.multiWay, + top_k: 4, + } as any, deletedTools: [{ provider_id: 'tool-1', tool_name: 'search' }], mode: AppModeEnum.AGENT_CHAT, nextDataSets: [{ id: 'dataset-1' }] as any, }) expect(publishedConfig.completionParams).toEqual({ temperature: 0.7 }) + expect(publishedConfig.datasetConfigs.top_k).toBe(4) + expect(publishedConfig.promptMode).toBe('simple') + expect(publishedConfig.externalDataToolsConfig).toHaveLength(1) expect(publishedConfig.modelConfig).toEqual( expect.objectContaining({ dataSets: [{ id: 'dataset-1' }], @@ -180,6 +188,76 @@ describe('useConfiguration utils', () => { ) }) + it('should normalize an empty chat prompt config for completion apps', () => { + const publishedConfig = buildPublishedConfig({ + backendModelConfig: { + chat_prompt_config: {}, + completion_prompt_config: { + prompt: { text: 'completion' }, + conversation_histories_role: { + assistant_prefix: '', + user_prefix: '', + }, + }, + dataset_configs: { + datasets: { datasets: [] }, + }, + external_data_tools: [], + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4o', + mode: ModelModeType.completion, + completion_params: {}, + }, + user_input_form: [], + } as any, + collectionList: [], + datasetConfigs: { + datasets: { datasets: [] }, + retrieval_model: RETRIEVE_TYPE.multiWay, + } as any, + mode: AppModeEnum.COMPLETION, + nextDataSets: [], + }) + + expect(publishedConfig.chatPromptConfig).toEqual(DEFAULT_CHAT_PROMPT_CONFIG) + expect(publishedConfig.modelConfig.chat_prompt_config).toEqual(DEFAULT_CHAT_PROMPT_CONFIG) + }) + + it('should normalize an empty completion prompt config for chat apps', () => { + const publishedConfig = buildPublishedConfig({ + backendModelConfig: { + chat_prompt_config: { + prompt: [{ role: 'system', text: 'chat' }], + }, + completion_prompt_config: {}, + dataset_configs: { + datasets: { datasets: [] }, + }, + external_data_tools: [], + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4o', + mode: ModelModeType.chat, + completion_params: {}, + }, + user_input_form: [], + } as any, + collectionList: [], + datasetConfigs: { + datasets: { datasets: [] }, + retrieval_model: RETRIEVE_TYPE.multiWay, + } as any, + mode: AppModeEnum.CHAT, + nextDataSets: [], + }) + + expect(publishedConfig.completionPromptConfig).toEqual(DEFAULT_COMPLETION_PROMPT_CONFIG) + expect(publishedConfig.modelConfig.completion_prompt_config).toEqual( + DEFAULT_COMPLETION_PROMPT_CONFIG, + ) + }) + it('should build dataset configs with reranking defaults', () => { const datasetConfigs = buildConfigurationDatasetConfigs({ backendModelConfig: { @@ -665,7 +743,6 @@ describe('useConfiguration utils', () => { const onPublish = createPublishHandler({ appId: 'app-1', chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any, - citationConfig: { enabled: true } as any, completionParamsState: { temperature: 0.7 }, completionPromptConfig: { prompt: { text: 'completion' }, @@ -680,13 +757,13 @@ describe('useConfiguration utils', () => { datasetConfigs: { datasets: { datasets: [] }, retrieval_model: RETRIEVE_TYPE.multiWay, + top_k: 7, } as any, - externalDataToolsConfig: [], + externalDataToolsConfig: [{ enabled: true, variable: 'external' }] as any, hasSetBlockStatus: { history: true, query: true, }, - introduction: 'hello', isAdvancedMode: true, isFunctionCall: true, mode: AppModeEnum.CHAT, @@ -711,36 +788,40 @@ describe('useConfiguration utils', () => { workflow_file_upload_limit: 1, }, } as any, - moreLikeThisConfig: { enabled: true }, promptEmpty: false, promptMode: 'advanced' as any, resolvedModelModeType: ModelModeType.chat, setCanReturnToSimpleMode, setPublishedConfig, - speechToTextConfig: { enabled: false } as any, - suggestedQuestionsAfterAnswerConfig: { enabled: false } as any, t, - textToSpeechConfig: { enabled: false, voice: '', language: '' } as any, }) - const result = await onPublish(mockUpdateAppModelConfig, undefined, { - moreLikeThis: { enabled: true }, - opening: { enabled: false, opening_statement: '', suggested_questions: [] }, - moderation: { enabled: false }, - speech2text: { enabled: false }, - text2speech: { enabled: false, voice: '', language: '' }, - file: { - enabled: false, - image: { + const result = await onPublish( + mockUpdateAppModelConfig, + { + model: 'published-model', + provider: 'published-provider', + parameters: { temperature: 0.2 }, + }, + { + moreLikeThis: { enabled: true }, + opening: { enabled: false, opening_statement: '', suggested_questions: [] }, + moderation: { enabled: true }, + speech2text: { enabled: false }, + text2speech: { enabled: false, voice: '', language: '' }, + file: { enabled: false, - detail: 'low', - number_limits: 1, - transfer_methods: ['local_file'], - }, + image: { + enabled: false, + detail: 'low', + number_limits: 1, + transfer_methods: ['local_file'], + }, + } as any, + suggested: { enabled: false }, + citation: { enabled: true }, } as any, - suggested: { enabled: false }, - citation: { enabled: true }, - } as any) + ) expect(result).toBe(true) expect(mockUpdateAppModelConfig).toHaveBeenCalledWith( @@ -753,7 +834,24 @@ describe('useConfiguration utils', () => { url: '/apps/app-1/model-config', }), ) - expect(setPublishedConfig).toHaveBeenCalledTimes(1) + expect(setPublishedConfig).toHaveBeenCalledWith( + expect.objectContaining({ + chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] }, + completionParams: { temperature: 0.2 }, + datasetConfigs: expect.objectContaining({ top_k: 7 }), + externalDataToolsConfig: [{ enabled: true, variable: 'external' }], + modelConfig: expect.objectContaining({ + file_upload: expect.objectContaining({ + image: expect.objectContaining({ detail: 'low' }), + }), + model_id: 'published-model', + opening_statement: '', + provider: 'published-provider', + sensitive_word_avoidance: { enabled: true }, + }), + promptMode: 'advanced', + }), + ) expect(mockToastSuccess).toHaveBeenCalledWith('api.success') expect(setCanReturnToSimpleMode).toHaveBeenCalledWith(false) }) @@ -764,7 +862,6 @@ describe('useConfiguration utils', () => { createPublishHandler({ appId: 'app-1', chatPromptConfig: { prompt: [{ role: 'system', text: 'hi' }] } as any, - citationConfig: { enabled: false } as any, completionParamsState: { temperature: 0.7 }, completionPromptConfig: { prompt: { text: 'completion' }, @@ -782,7 +879,6 @@ describe('useConfiguration utils', () => { history: true, query: true, }, - introduction: 'hello', isAdvancedMode: true, isFunctionCall: false, mode: AppModeEnum.CHAT, @@ -801,16 +897,12 @@ describe('useConfiguration utils', () => { workflow_file_upload_limit: 1, }, } as any, - moreLikeThisConfig: { enabled: false }, promptEmpty: false, promptMode: 'advanced' as any, resolvedModelModeType: ModelModeType.completion, setCanReturnToSimpleMode: vi.fn(), setPublishedConfig: vi.fn(), - speechToTextConfig: { enabled: false } as any, - suggestedQuestionsAfterAnswerConfig: { enabled: false } as any, t, - textToSpeechConfig: { enabled: false, voice: '', language: '' } as any, ...overrides, }) diff --git a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx index 8666d7b10ff..4781627325e 100644 --- a/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx +++ b/web/app/components/app/configuration/hooks/__tests__/use-configuration.spec.tsx @@ -321,6 +321,175 @@ describe('useConfiguration', () => { expect(queryClient.getQueryState(detailQueryKey)?.isInvalidated).toBe(true) }) + it('should publish and restore the complete configuration snapshot', async () => { + const { result } = renderHook(() => useConfiguration()) + + await waitFor(() => { + expect(result.current.showLoading).toBe(false) + }) + + act(() => { + result.current.contextValue.setDatasetConfigs({ + ...result.current.contextValue.datasetConfigs, + top_k: 8, + }) + }) + + await act(async () => { + await result.current.appPublisherProps.onPublish!(undefined, result.current.featuresData) + }) + expect(result.current.appPublisherProps.publishedConfig.datasetConfigs.top_k).toBe(8) + + act(() => { + result.current.contextValue.setDatasetConfigs({ + ...result.current.contextValue.datasetConfigs, + top_k: 10, + }) + }) + + mockSetChatPromptConfig.mockClear() + mockSetCompletionPromptConfig.mockClear() + act(() => { + result.current.appPublisherProps.resetAppConfig?.() + }) + expect(result.current.contextValue.datasetConfigs.top_k).toBe(8) + expect(mockSetChatPromptConfig).toHaveBeenCalledWith({ + prompt: [{ role: 'system', text: 'hi' }], + }) + expect(mockSetCompletionPromptConfig).toHaveBeenCalledWith({ + prompt: { text: 'completion' }, + conversation_histories_role: { + assistant_prefix: 'assistant', + user_prefix: 'user', + }, + }) + }) + + it('should enable multiple-model mode', async () => { + const { result } = renderHook(() => useConfiguration()) + + await waitFor(() => { + expect(result.current.showLoading).toBe(false) + }) + + act(() => { + result.current.onEnableMultipleModelDebug() + }) + + expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith( + true, + expect.arrayContaining([ + expect.objectContaining({ + model: 'gpt-4o', + provider: 'langgenius/openai/openai', + }), + ]), + ) + }) + + it('should update multiple-model debug configs', async () => { + const { result } = renderHook(() => useConfiguration()) + + await waitFor(() => { + expect(result.current.showLoading).toBe(false) + }) + + const modelConfigs = [ + { + id: 'model-1', + model: 'gpt-4o', + provider: 'langgenius/openai/openai', + parameters: { temperature: 0.7 }, + }, + { + id: 'model-2', + model: 'gpt-4.1', + provider: 'langgenius/openai/openai', + parameters: {}, + }, + { + id: 'model-3', + model: '', + provider: '', + parameters: {}, + }, + ] + + act(() => { + result.current.onMultipleModelConfigsChange(true, modelConfigs) + }) + + expect(mockHandleMultipleModelConfigsChange).toHaveBeenCalledWith(true, modelConfigs) + }) + + it('should keep multiple-model debug state when restoring published config', async () => { + const { result } = renderHook(() => useConfiguration()) + + await waitFor(() => { + expect(result.current.showLoading).toBe(false) + }) + + act(() => { + result.current.onEnableMultipleModelDebug() + }) + + act(() => { + result.current.contextValue.setDatasetConfigs({ + ...result.current.contextValue.datasetConfigs, + top_k: 8, + }) + }) + + mockHandleMultipleModelConfigsChange.mockClear() + act(() => { + result.current.appPublisherProps.resetAppConfig?.() + }) + + expect(mockHandleMultipleModelConfigsChange).not.toHaveBeenCalled() + }) + + it('should sync the selected multiple-model config after publishing', async () => { + const { result } = renderHook(() => useConfiguration()) + + await waitFor(() => { + expect(result.current.showLoading).toBe(false) + }) + + act(() => { + result.current.contextValue.setDatasetConfigs({ + ...result.current.contextValue.datasetConfigs, + top_k: 8, + }) + }) + + await act(async () => { + await result.current.appPublisherProps.onPublish!( + { + id: 'model-2', + model: 'gpt-4.1', + provider: 'langgenius/openai/openai', + parameters: { temperature: 0.2 }, + }, + result.current.featuresData, + ) + }) + + expect(result.current.contextValue.modelConfig.model_id).toBe('gpt-4.1') + expect(result.current.contextValue.modelConfig.provider).toBe('langgenius/openai/openai') + expect(result.current.contextValue.completionParams).toEqual({ temperature: 0.2 }) + expect(result.current.appPublisherProps.publishedConfig.modelConfig.model_id).toBe('gpt-4.1') + }) + + it('should expose the latest published time supplied by the app detail', async () => { + const { result } = renderHook(() => useConfiguration()) + + await waitFor(() => { + expect(result.current.showLoading).toBe(false) + }) + + expect(result.current.appPublisherProps.publishedAt).toBe(1710000000000) + }) + it('should block publishing when app release permission is missing', async () => { mockAppPermissionKeys = [AppACLPermission.ViewLayout] diff --git a/web/app/components/app/configuration/hooks/build-configuration-context.ts b/web/app/components/app/configuration/hooks/build-configuration-context.ts new file mode 100644 index 00000000000..bed0d03f54a --- /dev/null +++ b/web/app/components/app/configuration/hooks/build-configuration-context.ts @@ -0,0 +1,107 @@ +import type { useDatasetConfigurationState } from './configuration-lifecycle/use-dataset-configuration-state' +import type { useFeatureConfigurationState } from './configuration-lifecycle/use-feature-configuration-state' +import type { useModelConfigurationState } from './configuration-lifecycle/use-model-configuration-state' +import type { DebugConfigurationValue } from './configuration-view-model' +import type useAdvancedPromptConfig from './use-advanced-prompt-config' + +type AdvancedPromptConfiguration = ReturnType +type DatasetConfiguration = ReturnType +type FeatureConfiguration = ReturnType +type ModelConfiguration = ReturnType + +type ContextBase = Pick< + DebugConfigurationValue, + | 'appId' + | 'canReturnToSimpleMode' + | 'canTestAndRun' + | 'collectionList' + | 'controlClearChatMessage' + | 'conversationId' + | 'formattingChanged' + | 'hasSetContextVar' + | 'inputs' + | 'isAdvancedMode' + | 'isAgent' + | 'isAllowVideoUpload' + | 'isFunctionCall' + | 'isOpenAI' + | 'isShowAudioConfig' + | 'isShowDocumentConfig' + | 'isShowVisionConfig' + | 'isTrailFinished' + | 'isAPIKeySet' + | 'mode' + | 'modelModeType' + | 'prevPromptConfig' + | 'promptMode' + | 'query' + | 'readonly' + | 'rerankSettingModalOpen' + | 'setCanReturnToSimpleMode' + | 'setControlClearChatMessage' + | 'setConversationId' + | 'setFormattingChanged' + | 'setInputs' + | 'setPrevPromptConfig' + | 'setPromptMode' + | 'setQuery' + | 'setRerankSettingModalOpen' + | 'showHistoryModal' + | 'showSelectDataSet' +> + +export function buildConfigurationContextValue({ + advancedPrompt, + base, + dataset, + feature, + model, +}: { + advancedPrompt: AdvancedPromptConfiguration + base: ContextBase + dataset: DatasetConfiguration + feature: FeatureConfiguration + model: ModelConfiguration +}): DebugConfigurationValue { + return { + ...base, + annotationConfig: feature.annotationConfig, + chatPromptConfig: advancedPrompt.chatPromptConfig, + citationConfig: feature.citationConfig, + completionParams: model.completionParams, + completionPromptConfig: advancedPrompt.completionPromptConfig, + conversationHistoriesRole: advancedPrompt.completionPromptConfig.conversation_histories_role, + currentAdvancedPrompt: advancedPrompt.currentAdvancedPrompt, + dataSets: dataset.dataSets, + datasetConfigs: dataset.datasetConfigs, + datasetConfigsRef: dataset.datasetConfigsRef, + externalDataToolsConfig: feature.externalDataToolsConfig, + hasSetBlockStatus: advancedPrompt.hasSetBlockStatus, + introduction: feature.introduction, + modelConfig: model.modelConfig, + moderationConfig: feature.moderationConfig, + moreLikeThisConfig: feature.moreLikeThisConfig, + setAnnotationConfig: feature.setAnnotationConfig, + setCitationConfig: feature.setCitationConfig, + setCompletionParams: model.setCompletionParams, + setConversationHistoriesRole: advancedPrompt.setConversationHistoriesRole, + setCurrentAdvancedPrompt: advancedPrompt.setCurrentAdvancedPrompt, + setDataSets: dataset.setDataSets, + setDatasetConfigs: dataset.setDatasetConfigs, + setExternalDataToolsConfig: feature.setExternalDataToolsConfig, + setIntroduction: feature.setIntroduction, + setModelConfig: model.setModelConfig, + setModerationConfig: feature.setModerationConfig, + setMoreLikeThisConfig: feature.setMoreLikeThisConfig, + setSpeechToTextConfig: feature.setSpeechToTextConfig, + setSuggestedQuestions: feature.setSuggestedQuestions, + setSuggestedQuestionsAfterAnswerConfig: feature.setSuggestedQuestionsAfterAnswerConfig, + setTextToSpeechConfig: feature.setTextToSpeechConfig, + setVisionConfig: model.setVisionConfig, + speechToTextConfig: feature.speechToTextConfig, + suggestedQuestions: feature.suggestedQuestions, + suggestedQuestionsAfterAnswerConfig: feature.suggestedQuestionsAfterAnswerConfig, + textToSpeechConfig: feature.textToSpeechConfig, + visionConfig: model.visionConfig, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/dataset.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/dataset.ts new file mode 100644 index 00000000000..e7ced57458b --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/dataset.ts @@ -0,0 +1,206 @@ +import type { DataSet } from '@/models/datasets' +import type { DatasetConfigs } from '@/models/debug' +import type { ModelConfig as BackendModelConfig } from '@/types/app' +import { produce } from 'immer' +import { useMemo } from 'react' +import { + getMultipleRetrievalConfig, + getSelectedDatasetsMode, +} from '@/app/components/workflow/nodes/knowledge-retrieval/utils' +import { RETRIEVE_TYPE } from '@/types/app' +import { correctModelProvider } from '@/utils' + +export function buildConfigurationDatasetConfigs({ + backendModelConfig, + currentRerankModel, + currentRerankProvider, + nextDataSets, +}: { + backendModelConfig: BackendModelConfig + currentRerankModel?: string + currentRerankProvider?: string + nextDataSets: DataSet[] +}): DatasetConfigs { + const retrievalConfig = getMultipleRetrievalConfig( + { + ...backendModelConfig.dataset_configs, + reranking_model: backendModelConfig.dataset_configs.reranking_model && { + provider: backendModelConfig.dataset_configs.reranking_model.reranking_provider_name, + model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name, + }, + }, + nextDataSets, + nextDataSets, + { + provider: currentRerankProvider, + model: currentRerankModel, + }, + ) + + const nextDatasetConfigs = { + ...backendModelConfig.dataset_configs, + ...retrievalConfig, + ...(retrievalConfig.reranking_model + ? { + reranking_model: { + reranking_model_name: retrievalConfig.reranking_model.model, + reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider), + }, + } + : {}), + } as DatasetConfigs + + nextDatasetConfigs.retrieval_model = nextDatasetConfigs.retrieval_model ?? RETRIEVE_TYPE.multiWay + + return nextDatasetConfigs +} + +type DatasetSelectHandlerOptions = { + currentRerankModel?: string + currentRerankProvider?: string + dataSets: DataSet[] + datasetConfigs: DatasetConfigs + datasetConfigsRef: { current: DatasetConfigs } + formattingChangedDispatcher: () => void + hideSelectDataSet: () => void + setDataSets: (data: DataSet[]) => void + setDatasetConfigs: (configs: DatasetConfigs) => void + setRerankSettingModalOpen: (visible: boolean) => void +} + +export const createDatasetSelectHandler = + ({ + currentRerankModel, + currentRerankProvider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + }: DatasetSelectHandlerOptions) => + (nextDataSets: DataSet[]) => { + if ( + nextDataSets.map((item) => item.id).join(',') === dataSets.map((item) => item.id).join(',') + ) { + hideSelectDataSet() + return + } + + formattingChangedDispatcher() + let mergedDataSets = nextDataSets + + if (nextDataSets.find((item) => !item.name)) { + const hydrated = produce(nextDataSets, (draft) => { + nextDataSets.forEach((item, index) => { + if (!item.name) { + const originalItem = dataSets.find((existing) => existing.id === item.id) + if (originalItem) draft[index] = originalItem + } + }) + }) + setDataSets(hydrated) + mergedDataSets = hydrated + } else { + setDataSets(nextDataSets) + } + + hideSelectDataSet() + const { + allExternal, + allInternal, + mixtureInternalAndExternal, + mixtureHighQualityAndEconomic, + inconsistentEmbeddingModel, + } = getSelectedDatasetsMode(mergedDataSets) + + if ( + (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) || + mixtureInternalAndExternal || + allExternal + ) { + setRerankSettingModalOpen(true) + } + + const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = datasetConfigs + const { top_k, score_threshold, reranking_model, reranking_mode, weights, reranking_enable } = + restConfigs + const oldRetrievalConfig = { + top_k, + score_threshold, + reranking_model: + reranking_model?.reranking_provider_name && reranking_model?.reranking_model_name + ? { + provider: reranking_model.reranking_provider_name, + model: reranking_model.reranking_model_name, + } + : undefined, + reranking_mode, + weights, + reranking_enable, + } + const retrievalConfig = getMultipleRetrievalConfig( + oldRetrievalConfig, + mergedDataSets, + dataSets, + { + provider: currentRerankProvider, + model: currentRerankModel, + }, + ) + + setDatasetConfigs({ + ...datasetConfigsRef.current, + ...retrievalConfig, + reranking_model: { + reranking_provider_name: retrievalConfig?.reranking_model?.provider || '', + reranking_model_name: retrievalConfig?.reranking_model?.model || '', + }, + retrieval_model, + score_threshold_enabled, + datasets, + }) + } + +export function useDatasetSelectHandler({ + currentRerankModel, + currentRerankProvider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, +}: DatasetSelectHandlerOptions) { + return useMemo( + () => + createDatasetSelectHandler({ + currentRerankModel, + currentRerankProvider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + }), + [ + currentRerankModel, + currentRerankProvider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + ], + ) +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts new file mode 100644 index 00000000000..bb3862e334e --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/load.ts @@ -0,0 +1,115 @@ +import type { DataSet } from '@/models/datasets' +import type { AnnotationReplyConfig } from '@/models/debug' +import type { AppModeEnum, ModelConfig as BackendModelConfig } from '@/types/app' +import { PromptMode } from '@/models/debug' +import { fetchAppDetailDirect } from '@/service/apps' +import { fetchDatasets } from '@/service/datasets' +import { fetchCollectionList } from '@/service/tools' +import { correctModelProvider } from '@/utils' +import { withCollectionIconBasePath } from '../../utils' +import { buildConfigurationDatasetConfigs } from './dataset' +import { normalizeChatPromptConfig, normalizeCompletionPromptConfig } from './prompt-config' +import { buildPublishedConfig } from './published-config' + +function normalizeAnnotationConfig(annotationReply?: BackendModelConfig['annotation_reply']) { + if (!annotationReply) return undefined + if (!annotationReply.enabled) return annotationReply as AnnotationReplyConfig + + return { + ...annotationReply, + embedding_model: { + ...annotationReply.embedding_model, + embedding_provider_name: correctModelProvider( + annotationReply.embedding_model.embedding_provider_name, + ), + }, + } as AnnotationReplyConfig +} + +export async function loadConfigurationState({ + appId, + basePath, + currentRerankModel, + currentRerankProvider, +}: { + appId: string + basePath?: string + currentRerankModel?: string + currentRerankProvider?: string +}) { + const collectionList = withCollectionIconBasePath(await fetchCollectionList(), basePath) + const response = await fetchAppDetailDirect({ url: '/apps', id: appId }) + const backendModelConfig = response.model_config as BackendModelConfig + const nextPromptMode = + backendModelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple + let nextDataSets: DataSet[] = [] + const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as Array<{ + dataset?: { enabled: boolean; id: string } + }> + + if (agentModeTools.find((tool) => tool.dataset?.enabled)) + nextDataSets = agentModeTools as unknown as DataSet[] + else if (backendModelConfig.dataset_configs.datasets?.datasets?.length) + nextDataSets = backendModelConfig.dataset_configs.datasets.datasets as unknown as DataSet[] + + if (nextDataSets.length) { + const datasetIds = (nextDataSets as Array) + .map((item) => item.dataset?.id || item.id) + .filter((id): id is string => Boolean(id)) + const { data } = await fetchDatasets({ + url: '/datasets', + params: { + page: 1, + ids: datasetIds, + }, + }) + nextDataSets = data + } + + const datasetConfigs = buildConfigurationDatasetConfigs({ + backendModelConfig, + currentRerankModel, + currentRerankProvider, + nextDataSets, + }) + + return { + annotationConfig: normalizeAnnotationConfig(backendModelConfig.annotation_reply), + backendModelConfig, + canReturnToSimpleMode: nextPromptMode !== PromptMode.advanced, + collectionList, + completionPromptConfig: normalizeCompletionPromptConfig( + backendModelConfig.completion_prompt_config, + ), + datasetConfigs, + externalDataToolsConfig: backendModelConfig.external_data_tools ?? [], + mode: response.mode as AppModeEnum, + moreLikeThisConfig: backendModelConfig.more_like_this || { enabled: false }, + nextDataSets, + promptMode: nextPromptMode, + publishedConfig: buildPublishedConfig({ + backendModelConfig, + collectionList, + datasetConfigs, + deletedTools: response.deleted_tools, + mode: response.mode as AppModeEnum, + nextDataSets, + }), + response, + speechToTextConfig: backendModelConfig.speech_to_text || { enabled: false }, + suggestedQuestions: backendModelConfig.suggested_questions || [], + suggestedQuestionsAfterAnswerConfig: backendModelConfig.suggested_questions_after_answer || { + enabled: false, + }, + textToSpeechConfig: backendModelConfig.text_to_speech || { + enabled: false, + voice: '', + language: '', + }, + visionConfig: backendModelConfig.file_upload?.image, + citationConfig: backendModelConfig.retriever_resource || { enabled: false }, + chatPromptConfig: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config), + introduction: backendModelConfig.opening_statement, + moderationConfig: backendModelConfig.sensitive_word_avoidance, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/model.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/model.ts new file mode 100644 index 00000000000..94f7ce598e6 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/model.ts @@ -0,0 +1,169 @@ +import type { SelectorTranslate } from '../../utils' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelConfig } from '@/models/debug' +import type { VisionSettings } from '@/types/app' +import { produce } from 'immer' +import { useMemo } from 'react' +import { toast } from '@/app/components/app/configuration/toast' +import { AppModeEnum, ModelModeType } from '@/types/app' +import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' +import { getStringSelectorTranslate } from '../../utils' + +type ModelChangeHandlerOptions = { + chatPromptLength: number + completionParamsState: FormValue + completionPromptConfig: { + conversation_histories_role: { + assistant_prefix: string + user_prefix: string + } + prompt?: { + text?: string + } + } + handleSetVisionConfig: (config: VisionSettings, notNoticeFormattingChanged?: boolean) => void + isAdvancedMode: boolean + migrateToDefaultPrompt: (force?: boolean, modelModeType?: ModelModeType) => Promise + mode: AppModeEnum + modelConfig: ModelConfig + resolvedModelModeType: ModelModeType + setCompletionParams: (value: FormValue) => void + setModelConfig: (config: ModelConfig) => void + t: SelectorTranslate<'appDebug' | 'common'> + visionConfig: VisionSettings +} + +export const createModelChangeHandler = + ({ + chatPromptLength, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t: rawTranslate, + visionConfig, + }: ModelChangeHandlerOptions) => + async ({ + features = [], + mode: nextModelMode = resolvedModelModeType, + modelId, + provider, + }: { + modelId: string + provider: string + mode?: string + features?: string[] + }) => { + const t = getStringSelectorTranslate(rawTranslate) + if (isAdvancedMode) { + if (nextModelMode === ModelModeType.completion) { + if (mode !== AppModeEnum.COMPLETION) { + if ( + !completionPromptConfig.prompt?.text || + !completionPromptConfig.conversation_histories_role.assistant_prefix || + !completionPromptConfig.conversation_histories_role.user_prefix + ) + await migrateToDefaultPrompt(true, ModelModeType.completion) + } else if (!completionPromptConfig.prompt?.text) { + await migrateToDefaultPrompt(true, ModelModeType.completion) + } + } + + if (nextModelMode === ModelModeType.chat && chatPromptLength === 0) + await migrateToDefaultPrompt(true, ModelModeType.chat) + } + + setModelConfig( + produce(modelConfig, (draft: ModelConfig) => { + draft.provider = provider + draft.model_id = modelId + draft.mode = nextModelMode as ModelModeType + }), + ) + handleSetVisionConfig( + { + ...visionConfig, + enabled: !!features?.includes('vision'), + }, + true, + ) + + try { + const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( + provider, + modelId, + completionParamsState, + isAdvancedMode, + ) + + if (Object.keys(removedDetails).length) + toast.warning( + `${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${Object.entries( + removedDetails, + ) + .map(([key, reason]) => `${key} (${reason})`) + .join(', ')}`, + ) + + setCompletionParams(filtered) + } catch { + toast.error(t(($) => $.error, { ns: 'common' })) + setCompletionParams({}) + } + } + +export function useModelChangeHandler({ + chatPromptLength, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t, + visionConfig, +}: ModelChangeHandlerOptions) { + return useMemo( + () => + createModelChangeHandler({ + chatPromptLength, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t, + visionConfig, + }), + [ + chatPromptLength, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t, + visionConfig, + ], + ) +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/prompt-config.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/prompt-config.ts new file mode 100644 index 00000000000..bf44b4565e9 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/prompt-config.ts @@ -0,0 +1,15 @@ +import type { ModelConfig as BackendModelConfig } from '@/types/app' +import { clone } from 'es-toolkit/object' +import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' + +export const normalizeChatPromptConfig = ( + chatPromptConfig: BackendModelConfig['chat_prompt_config'], +): NonNullable => + chatPromptConfig?.prompt?.length ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG) + +export const normalizeCompletionPromptConfig = ( + completionPromptConfig: BackendModelConfig['completion_prompt_config'], +): NonNullable => + completionPromptConfig?.prompt && completionPromptConfig.conversation_histories_role + ? completionPromptConfig + : clone(DEFAULT_COMPLETION_PROMPT_CONFIG) diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/publish.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/publish.ts new file mode 100644 index 00000000000..3751c8fa711 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/publish.ts @@ -0,0 +1,251 @@ +import type { SelectorTranslate } from '../../utils' +import type { ConfigurationPublishConfig } from './types' +import type { Features as FeaturesData } from '@/app/components/base/features/types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { DataSet } from '@/models/datasets' +import type { DatasetConfigs, ModelConfig, PromptVariable } from '@/models/debug' +import type { ModelConfig as BackendModelConfig } from '@/types/app' +import { clone } from 'es-toolkit/object' +import { produce } from 'immer' +import { toast } from '@/app/components/app/configuration/toast' +import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import { PromptMode } from '@/models/debug' +import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app' +import { promptVariablesToUserInputsForm } from '@/utils/model-config' +import { getStringSelectorTranslate } from '../../utils' +import { normalizeChatPromptConfig, normalizeCompletionPromptConfig } from './prompt-config' + +export function buildPublishBody({ + chatPromptConfig, + completionParams, + completionPromptConfig, + contextVar, + dataSets, + datasetConfigs, + externalDataToolsConfig, + features, + isAdvancedMode, + isFunctionCall, + modelConfig, + modelId, + modelProvider, + promptMode, + promptVariables, + promptTemplate, + resolvedModelModeType, +}: { + chatPromptConfig: BackendModelConfig['chat_prompt_config'] + completionParams: FormValue + completionPromptConfig: BackendModelConfig['completion_prompt_config'] + contextVar?: string + dataSets: DataSet[] + datasetConfigs: DatasetConfigs + externalDataToolsConfig: BackendModelConfig['external_data_tools'] + features?: FeaturesData + isAdvancedMode: boolean + isFunctionCall: boolean + modelConfig: ModelConfig + modelId: string + modelProvider: string + promptMode: BackendModelConfig['prompt_type'] + promptVariables: PromptVariable[] + promptTemplate: string + resolvedModelModeType: BackendModelConfig['model']['mode'] +}): BackendModelConfig { + const postDatasets = dataSets.map(({ id }) => ({ + dataset: { + enabled: true, + id, + }, + })) + const fileUpload = { ...features?.file } + delete fileUpload?.fileUploadConfig + + return { + pre_prompt: !isAdvancedMode ? promptTemplate : '', + prompt_type: promptMode, + chat_prompt_config: isAdvancedMode ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: isAdvancedMode + ? completionPromptConfig + : clone(DEFAULT_COMPLETION_PROMPT_CONFIG), + user_input_form: promptVariablesToUserInputsForm(promptVariables), + dataset_query_variable: contextVar || '', + more_like_this: features?.moreLikeThis as never, + opening_statement: features?.opening?.enabled ? features.opening?.opening_statement || '' : '', + suggested_questions: features?.opening?.enabled + ? features.opening?.suggested_questions || [] + : [], + sensitive_word_avoidance: features?.moderation as never, + speech_to_text: features?.speech2text as never, + text_to_speech: features?.text2speech as never, + file_upload: fileUpload as never, + suggested_questions_after_answer: features?.suggested as never, + retriever_resource: features?.citation as never, + agent_mode: { + ...modelConfig.agentConfig, + strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react, + }, + external_data_tools: externalDataToolsConfig, + model: { + provider: modelProvider, + name: modelId, + mode: resolvedModelModeType, + completion_params: completionParams as BackendModelConfig['model']['completion_params'], + }, + dataset_configs: { + ...datasetConfigs, + datasets: { + datasets: [...postDatasets], + } as never, + }, + system_parameters: modelConfig.system_parameters, + } +} + +export const createPublishHandler = + ({ + appId, + chatPromptConfig, + completionParamsState, + completionPromptConfig, + contextVar, + contextVarEmpty, + dataSets, + datasetConfigs, + externalDataToolsConfig, + hasSetBlockStatus, + isAdvancedMode, + isFunctionCall, + mode, + modelConfig, + promptEmpty, + promptMode, + resolvedModelModeType, + setCanReturnToSimpleMode, + setPublishedConfig, + t: rawTranslate, + }: { + appId: string + chatPromptConfig: BackendModelConfig['chat_prompt_config'] + completionParamsState: FormValue + completionPromptConfig: BackendModelConfig['completion_prompt_config'] + contextVar?: string + contextVarEmpty: boolean + dataSets: DataSet[] + datasetConfigs: DatasetConfigs + externalDataToolsConfig: BackendModelConfig['external_data_tools'] + hasSetBlockStatus: { history: boolean; query: boolean } + isAdvancedMode: boolean + isFunctionCall: boolean + mode: AppModeEnum + modelConfig: ModelConfig + promptEmpty: boolean + promptMode: BackendModelConfig['prompt_type'] + resolvedModelModeType: ModelModeType + setCanReturnToSimpleMode: (value: boolean) => void + setPublishedConfig: (config: ConfigurationPublishConfig) => void + t: SelectorTranslate<'appDebug' | 'common'> + }) => + async ( + updateAppModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise, + modelAndParameter?: { model: string; provider: string; parameters: FormValue }, + features?: FeaturesData, + ) => { + const t = getStringSelectorTranslate(rawTranslate) + const modelId = modelAndParameter?.model || modelConfig.model_id + const promptTemplate = modelConfig.configs.prompt_template + const promptVariables = modelConfig.configs.prompt_variables + + if (promptEmpty) { + toast.error(t(($) => $['otherError.promptNoBeEmpty'], { ns: 'appDebug' })) + return + } + if ( + isAdvancedMode && + mode !== AppModeEnum.COMPLETION && + resolvedModelModeType === ModelModeType.completion + ) { + if (!hasSetBlockStatus.history) { + toast.error(t(($) => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' })) + return + } + if (!hasSetBlockStatus.query) { + toast.error(t(($) => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' })) + return + } + } + if (contextVarEmpty) { + toast.error( + t(($) => $['feature.dataSet.queryVariable.contextVarNotEmpty'], { ns: 'appDebug' }), + ) + return + } + + const body = buildPublishBody({ + chatPromptConfig, + completionParams: modelAndParameter?.parameters || completionParamsState, + completionPromptConfig, + contextVar, + dataSets, + datasetConfigs, + externalDataToolsConfig, + features, + isAdvancedMode, + isFunctionCall, + modelConfig, + modelId, + modelProvider: modelAndParameter?.provider || modelConfig.provider, + promptMode, + promptTemplate, + promptVariables, + resolvedModelModeType, + }) + + await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body }) + const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => { + draft.provider = body.model.provider + draft.model_id = body.model.name + draft.mode = body.model.mode + draft.configs.prompt_template = body.pre_prompt + draft.prompt_type = body.prompt_type + draft.chat_prompt_config = normalizeChatPromptConfig(body.chat_prompt_config) + draft.completion_prompt_config = normalizeCompletionPromptConfig( + body.completion_prompt_config, + ) + draft.opening_statement = body.opening_statement + draft.more_like_this = body.more_like_this + draft.suggested_questions = body.suggested_questions ?? [] + draft.suggested_questions_after_answer = body.suggested_questions_after_answer + draft.speech_to_text = body.speech_to_text + draft.text_to_speech = body.text_to_speech + draft.file_upload = body.file_upload ?? null + draft.retriever_resource = body.retriever_resource + draft.sensitive_word_avoidance = body.sensitive_word_avoidance + draft.external_data_tools = body.external_data_tools + draft.system_parameters = body.system_parameters + const publishedAgentConfig = body.agent_mode as ModelConfig['agentConfig'] + draft.agentConfig = { + ...draft.agentConfig, + ...publishedAgentConfig, + max_iteration: publishedAgentConfig.max_iteration || draft.agentConfig.max_iteration, + } + draft.dataSets = dataSets + }) + + setPublishedConfig({ + modelConfig: nextModelConfig, + completionParams: body.model.completion_params, + promptMode: + body.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple, + chatPromptConfig: normalizeChatPromptConfig(body.chat_prompt_config), + completionPromptConfig: normalizeCompletionPromptConfig(body.completion_prompt_config), + datasetConfigs: { + ...datasetConfigs, + datasets: body.dataset_configs.datasets, + }, + externalDataToolsConfig: body.external_data_tools ?? [], + }) + toast.success(t(($) => $['api.success'], { ns: 'common' })) + setCanReturnToSimpleMode(false) + return true + } diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/published-config.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/published-config.ts new file mode 100644 index 00000000000..08499a91a25 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/published-config.ts @@ -0,0 +1,169 @@ +import type { ConfigurationPublishConfig } from './types' +import type { Collection } from '@/app/components/tools/types' +import type { DataSet } from '@/models/datasets' +import type { DatasetConfigs, ModelConfig } from '@/models/debug' +import type { ModelConfig as BackendModelConfig, UserInputFormItem } from '@/types/app' +import { DEFAULT_AGENT_SETTING } from '@/config' +import { PromptMode } from '@/models/debug' +import { AgentStrategy, AppModeEnum } from '@/types/app' +import { correctModelProvider, correctToolProvider } from '@/utils' +import { userInputsFormToPromptVariables } from '@/utils/model-config' +import { normalizeChatPromptConfig, normalizeCompletionPromptConfig } from './prompt-config' + +type BackendAgentTool = ModelConfig['agentConfig']['tools'][number] & { + dataset?: { + enabled: boolean + id: string + } + provider_id: string + provider_name: string + provider_type: string + tool_name: string +} + +type DeletedTool = { + id?: string + provider_id?: string + tool_name: string +} + +function buildPublishedModelConfig({ + backendModelConfig, + collectionList, + deletedTools, + mode, + nextDataSets, +}: { + backendModelConfig: BackendModelConfig + collectionList: Collection[] + deletedTools?: DeletedTool[] + mode: AppModeEnum + nextDataSets: DataSet[] +}): ModelConfig { + const model = backendModelConfig.model + const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as BackendAgentTool[] + + return { + provider: correctModelProvider(model.provider), + model_id: model.name, + mode: model.mode, + configs: { + prompt_template: backendModelConfig.pre_prompt || '', + prompt_variables: userInputsFormToPromptVariables( + [ + ...backendModelConfig.user_input_form, + ...(backendModelConfig.external_data_tools?.length + ? backendModelConfig.external_data_tools.map((item) => ({ + external_data_tool: { + variable: item.variable as string, + label: item.label as string, + enabled: !!item.enabled, + type: item.type as string, + config: item.config, + required: true, + icon: item.icon, + icon_background: item.icon_background, + }, + })) + : []), + ] as unknown as UserInputFormItem[], + backendModelConfig.dataset_query_variable, + ), + }, + prompt_type: backendModelConfig.prompt_type, + chat_prompt_config: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config), + completion_prompt_config: normalizeCompletionPromptConfig( + backendModelConfig.completion_prompt_config, + ), + more_like_this: backendModelConfig.more_like_this ?? { enabled: false }, + opening_statement: backendModelConfig.opening_statement, + suggested_questions: backendModelConfig.suggested_questions ?? [], + sensitive_word_avoidance: backendModelConfig.sensitive_word_avoidance, + speech_to_text: backendModelConfig.speech_to_text, + text_to_speech: backendModelConfig.text_to_speech, + file_upload: backendModelConfig.file_upload ?? null, + suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer ?? { + enabled: false, + }, + retriever_resource: backendModelConfig.retriever_resource, + annotation_reply: backendModelConfig.annotation_reply ?? null, + external_data_tools: backendModelConfig.external_data_tools ?? [], + system_parameters: backendModelConfig.system_parameters, + dataSets: nextDataSets, + agentConfig: + mode === AppModeEnum.AGENT_CHAT + ? { + max_iteration: DEFAULT_AGENT_SETTING.max_iteration, + ...backendModelConfig.agent_mode, + enabled: true, + tools: agentModeTools + .filter((tool) => !tool.dataset) + .map((tool) => { + const toolInCollectionList = collectionList.find( + (collection) => collection.id === tool.provider_id, + ) + return { + ...tool, + isDeleted: + deletedTools?.some( + (deletedTool) => + (deletedTool.provider_id || deletedTool.id) === tool.provider_id && + deletedTool.tool_name === tool.tool_name, + ) ?? false, + notAuthor: toolInCollectionList?.is_team_authorization === false, + ...(tool.provider_type === 'builtin' + ? { + provider_id: correctToolProvider( + tool.provider_name, + !!toolInCollectionList, + ), + provider_name: correctToolProvider( + tool.provider_name, + !!toolInCollectionList, + ), + } + : {}), + } + }) as ModelConfig['agentConfig']['tools'], + strategy: backendModelConfig.agent_mode?.strategy ?? AgentStrategy.react, + } + : DEFAULT_AGENT_SETTING, + } +} + +export function buildPublishedConfig({ + backendModelConfig, + collectionList, + datasetConfigs, + deletedTools, + mode, + nextDataSets, +}: { + backendModelConfig: BackendModelConfig + collectionList: Collection[] + datasetConfigs: DatasetConfigs + deletedTools?: DeletedTool[] + mode: AppModeEnum + nextDataSets: DataSet[] +}): ConfigurationPublishConfig { + return { + modelConfig: buildPublishedModelConfig({ + backendModelConfig, + collectionList, + deletedTools, + mode, + nextDataSets, + }), + completionParams: backendModelConfig.model.completion_params, + promptMode: + backendModelConfig.prompt_type === PromptMode.advanced + ? PromptMode.advanced + : PromptMode.simple, + chatPromptConfig: normalizeChatPromptConfig(backendModelConfig.chat_prompt_config), + completionPromptConfig: normalizeCompletionPromptConfig( + backendModelConfig.completion_prompt_config, + ), + datasetConfigs, + externalDataToolsConfig: backendModelConfig.external_data_tools ?? [], + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/types.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/types.ts new file mode 100644 index 00000000000..6fdd6a6f17f --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/types.ts @@ -0,0 +1,13 @@ +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { DatasetConfigs, ModelConfig, PromptMode } from '@/models/debug' +import type { ModelConfig as BackendModelConfig } from '@/types/app' + +export type ConfigurationPublishConfig = { + modelConfig: ModelConfig + completionParams: FormValue + promptMode: PromptMode + chatPromptConfig: NonNullable + completionPromptConfig: NonNullable + datasetConfigs: DatasetConfigs + externalDataToolsConfig: NonNullable +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts new file mode 100644 index 00000000000..fb6ff9256b8 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-app-context.ts @@ -0,0 +1,66 @@ +import { useMutation, useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useStore as useAppStore } from '@/app/components/app/store' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { usePathname } from '@/next/navigation' +import { updateAppModelConfig } from '@/service/apps' +import { consoleQuery } from '@/service/client' +import { getAppACLCapabilities } from '@/utils/permission' + +export function useConfigurationAppContext() { + const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } = + useAppStore( + useShallow((state) => ({ + appDetail: state.appDetail, + showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal, + setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal, + })), + ) + const pathname = usePathname() + const matched = /\/app\/([^/]+)/.exec(pathname) + const appId = matched?.[1] || '' + const serverLatestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail]) + const appACLCapabilities = useMemo( + () => + getAppACLCapabilities(appDetail?.permission_keys, { + currentUserId, + resourceMaintainer: appDetail?.maintainer, + workspacePermissionKeys, + }), + [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + ) + const { mutateAsync: updateModelConfig } = useMutation({ + mutationFn: (params: Parameters[0]) => + updateAppModelConfig(params), + onSuccess: (_data, _variables, _onMutateResult, context) => + context.client.invalidateQueries({ + queryKey: consoleQuery.apps.byAppId.get.queryKey({ + input: { params: { app_id: appId } }, + }), + }), + }) + + return { + appACLCapabilities, + appDetail, + appId, + configurationReadonly: !appACLCapabilities.canEdit, + currentWorkspace, + isLoadingCurrentWorkspace, + serverLatestPublishedAt, + setShowAppConfigureFeaturesModal, + showAppConfigureFeaturesModal, + updateModelConfig, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-loader.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-loader.ts new file mode 100644 index 00000000000..e68623029ba --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-loader.ts @@ -0,0 +1,48 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { ConfigurationPublishConfig } from './types' +import type { Collection } from '@/app/components/tools/types' +import type { AnnotationReplyConfig } from '@/models/debug' +import type { AppModeEnum } from '@/types/app' +import { useEffect, useRef } from 'react' +import { basePath } from '@/utils/var' +import { loadConfigurationState } from './load' + +type ConfigurationLoaderOperations = { + currentRerankModel?: string + currentRerankProvider?: string + setAnnotationConfig: (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => void + setCollectionList: Dispatch> + setHasFetchedDetail: Dispatch> + setMode: Dispatch> + setPublishedConfig: Dispatch> + syncToPublishedConfig: (config: ConfigurationPublishConfig) => void +} + +export function useConfigurationLoader({ + appId, + ...operations +}: ConfigurationLoaderOperations & { appId: string }) { + const operationsRef = useRef(operations) + operationsRef.current = operations + + useEffect(() => { + const current = operationsRef.current + void (async () => { + const configurationState = await loadConfigurationState({ + appId, + basePath, + currentRerankModel: current.currentRerankModel, + currentRerankProvider: current.currentRerankProvider, + }) + + current.setCollectionList(configurationState.collectionList) + current.setMode(configurationState.mode) + current.syncToPublishedConfig(configurationState.publishedConfig) + if (configurationState.annotationConfig) + current.setAnnotationConfig(configurationState.annotationConfig, true) + + current.setPublishedConfig(configurationState.publishedConfig) + current.setHasFetchedDetail(true) + })() + }, [appId]) +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-publish.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-publish.ts new file mode 100644 index 00000000000..7ff59a334d1 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-configuration-publish.ts @@ -0,0 +1,127 @@ +import type { Dispatch, SetStateAction } from 'react' +import type { SelectorTranslate } from '../../utils' +import type { ConfigurationPublishConfig } from './types' +import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher/types' +import type { Features as FeaturesData } from '@/app/components/base/features/types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { DataSet } from '@/models/datasets' +import type { DatasetConfigs, ModelConfig } from '@/models/debug' +import type { AppModeEnum, ModelConfig as BackendModelConfig, ModelModeType } from '@/types/app' +import { useCallback } from 'react' +import { createPublishHandler } from './publish' + +type UseConfigurationPublishParams = { + appId: string + canReleaseAndVersion: boolean + chatPromptConfig: BackendModelConfig['chat_prompt_config'] + completionParams: FormValue + completionPromptConfig: BackendModelConfig['completion_prompt_config'] + contextVar?: string + contextVarEmpty: boolean + dataSets: DataSet[] + datasetConfigs: DatasetConfigs + externalDataToolsConfig: BackendModelConfig['external_data_tools'] + hasSetBlockStatus: { history: boolean; query: boolean } + isAdvancedMode: boolean + isFunctionCall: boolean + mode: AppModeEnum + modelConfig: ModelConfig + promptEmpty: boolean + promptMode: BackendModelConfig['prompt_type'] + resolvedModelModeType: ModelModeType + setCanReturnToSimpleMode: (value: boolean) => void + setPublishedConfig: Dispatch> + syncToPublishedConfig: (config: ConfigurationPublishConfig) => void + t: SelectorTranslate<'appDebug' | 'common'> + updateModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise +} + +export function useConfigurationPublish({ + appId, + canReleaseAndVersion, + chatPromptConfig, + completionParams, + completionPromptConfig, + contextVar, + contextVarEmpty, + dataSets, + datasetConfigs, + externalDataToolsConfig, + hasSetBlockStatus, + isAdvancedMode, + isFunctionCall, + mode, + modelConfig, + promptEmpty, + promptMode, + resolvedModelModeType, + setCanReturnToSimpleMode, + setPublishedConfig, + syncToPublishedConfig, + t, + updateModelConfig, +}: UseConfigurationPublishParams) { + return useCallback( + async (params?: AppPublisherPublishParams, features?: FeaturesData) => { + if (!canReleaseAndVersion) return + + const modelAndParameter = + params && 'model' in params && 'provider' in params && 'parameters' in params + ? params + : undefined + const handlePublishedConfigChange = (config: ConfigurationPublishConfig) => { + setPublishedConfig(config) + if (modelAndParameter) syncToPublishedConfig(config) + } + const result = await createPublishHandler({ + appId, + chatPromptConfig, + completionParamsState: completionParams, + completionPromptConfig, + contextVar, + contextVarEmpty, + dataSets, + datasetConfigs, + externalDataToolsConfig, + hasSetBlockStatus, + isAdvancedMode, + isFunctionCall, + mode, + modelConfig, + promptEmpty, + promptMode, + resolvedModelModeType, + setCanReturnToSimpleMode, + setPublishedConfig: handlePublishedConfigChange, + t, + })(updateModelConfig, modelAndParameter, features) + + return result + }, + [ + appId, + canReleaseAndVersion, + chatPromptConfig, + completionParams, + completionPromptConfig, + contextVar, + contextVarEmpty, + dataSets, + datasetConfigs, + externalDataToolsConfig, + hasSetBlockStatus, + isAdvancedMode, + isFunctionCall, + mode, + modelConfig, + promptEmpty, + promptMode, + resolvedModelModeType, + setCanReturnToSimpleMode, + setPublishedConfig, + syncToPublishedConfig, + t, + updateModelConfig, + ], + ) +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-dataset-configuration-state.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-dataset-configuration-state.ts new file mode 100644 index 00000000000..c634c083919 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-dataset-configuration-state.ts @@ -0,0 +1,35 @@ +import type { DataSet } from '@/models/datasets' +import type { DatasetConfigs } from '@/models/debug' +import { useCallback, useRef, useState } from 'react' +import { DATASET_DEFAULT } from '@/config' +import { RETRIEVE_TYPE } from '@/types/app' + +export function useDatasetConfigurationState() { + const [datasetConfigs, setDatasetConfigs] = useState({ + retrieval_model: RETRIEVE_TYPE.multiWay, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + top_k: DATASET_DEFAULT.top_k, + score_threshold_enabled: false, + score_threshold: DATASET_DEFAULT.score_threshold, + datasets: { + datasets: [], + }, + }) + const datasetConfigsRef = useRef(datasetConfigs) + const updateDatasetConfigs = useCallback((nextDatasetConfigs: DatasetConfigs) => { + setDatasetConfigs(nextDatasetConfigs) + datasetConfigsRef.current = nextDatasetConfigs + }, []) + const [dataSets, setDataSets] = useState([]) + + return { + dataSets, + datasetConfigs, + datasetConfigsRef, + setDataSets, + setDatasetConfigs: updateDatasetConfigs, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-feature-configuration-state.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-feature-configuration-state.ts new file mode 100644 index 00000000000..acc4e51660a --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-feature-configuration-state.ts @@ -0,0 +1,72 @@ +import type { ExternalDataTool } from '@/models/common' +import type { + AnnotationReplyConfig, + ModerationConfig, + MoreLikeThisConfig, + TextToSpeechConfig, +} from '@/models/debug' +import { useCallback, useState } from 'react' +import { useFormattingChangedDispatcher } from '@/app/components/app/configuration/debug/hooks' +import { ANNOTATION_DEFAULT } from '@/config' + +export function useFeatureConfigurationState() { + const [introduction, setIntroduction] = useState('') + const [suggestedQuestions, setSuggestedQuestions] = useState([]) + const [moreLikeThisConfig, setMoreLikeThisConfig] = useState({ + enabled: false, + }) + const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = + useState({ enabled: false }) + const [speechToTextConfig, setSpeechToTextConfig] = useState({ + enabled: false, + }) + const [textToSpeechConfig, setTextToSpeechConfig] = useState({ + enabled: false, + voice: '', + language: '', + }) + const [citationConfig, setCitationConfig] = useState({ enabled: false }) + const [moderationConfig, setModerationConfig] = useState({ enabled: false }) + const [externalDataToolsConfig, setExternalDataToolsConfig] = useState([]) + const [annotationConfig, setAnnotationConfig] = useState({ + id: '', + enabled: false, + score_threshold: ANNOTATION_DEFAULT.score_threshold, + embedding_model: { + embedding_provider_name: '', + embedding_model_name: '', + }, + }) + const formattingChangedDispatcher = useFormattingChangedDispatcher() + const updateAnnotationConfig = useCallback( + (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => { + setAnnotationConfig(config) + if (!notSetFormatChanged) formattingChangedDispatcher() + }, + [formattingChangedDispatcher], + ) + + return { + annotationConfig, + citationConfig, + externalDataToolsConfig, + formattingChangedDispatcher, + introduction, + moderationConfig, + moreLikeThisConfig, + setAnnotationConfig: updateAnnotationConfig, + setCitationConfig, + setExternalDataToolsConfig, + setIntroduction, + setModerationConfig, + setMoreLikeThisConfig, + setSpeechToTextConfig, + setSuggestedQuestions, + setSuggestedQuestionsAfterAnswerConfig, + setTextToSpeechConfig, + speechToTextConfig, + suggestedQuestions, + suggestedQuestionsAfterAnswerConfig, + textToSpeechConfig, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-model-configuration-state.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-model-configuration-state.ts new file mode 100644 index 00000000000..80665780ec3 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-model-configuration-state.ts @@ -0,0 +1,105 @@ +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelConfig } from '@/models/debug' +import type { VisionSettings } from '@/types/app' +import { useGetState } from 'ahooks' +import { clone } from 'es-toolkit/object' +import { useCallback, useRef, useState } from 'react' +import { + DEFAULT_AGENT_SETTING, + DEFAULT_CHAT_PROMPT_CONFIG, + DEFAULT_COMPLETION_PROMPT_CONFIG, +} from '@/config' +import { ModelModeType, Resolution, TransferMethod } from '@/types/app' + +function createInitialModelConfig(): ModelConfig { + return { + provider: 'langgenius/openai/openai', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.unset, + configs: { + prompt_template: '', + prompt_variables: [], + }, + chat_prompt_config: clone(DEFAULT_CHAT_PROMPT_CONFIG), + completion_prompt_config: clone(DEFAULT_COMPLETION_PROMPT_CONFIG), + more_like_this: null, + opening_statement: '', + suggested_questions: [], + sensitive_word_avoidance: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + suggested_questions_after_answer: null, + retriever_resource: null, + annotation_reply: null, + external_data_tools: [], + system_parameters: { + audio_file_size_limit: 0, + file_size_limit: 0, + image_file_size_limit: 0, + video_file_size_limit: 0, + workflow_file_upload_limit: 0, + }, + dataSets: [], + agentConfig: DEFAULT_AGENT_SETTING, + } +} + +export function useModelConfigurationState({ + formattingChangedDispatcher, +}: { + formattingChangedDispatcher: () => void +}) { + const [completionParams, setCompletionParams] = useState({}) + // oxlint-disable-next-line eslint-react/use-state -- useGetState returns value, setter, and getter. + const tempStopState = useGetState([]) + const setTempStop = tempStopState[1] + const getTempStop = tempStopState[2] + const [modelConfig, setModelConfig] = useState(createInitialModelConfig) + const modelModeTypeRef = useRef(modelConfig.mode) + const [visionConfig, setVisionConfig] = useState({ + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + }) + + const updateCompletionParams = useCallback( + (value: FormValue) => { + const params = { ...value } + if ( + (!params.stop || params.stop.length === 0) && + modelModeTypeRef.current === ModelModeType.completion + ) { + params.stop = getTempStop() + setTempStop([]) + } + setCompletionParams(params) + }, + [getTempStop, setTempStop], + ) + + const updateVisionConfig = useCallback( + (config: VisionSettings, notNoticeFormattingChanged?: boolean) => { + setVisionConfig({ + enabled: config.enabled || false, + number_limits: config.number_limits || 2, + detail: config.detail || Resolution.low, + transfer_methods: config.transfer_methods || [TransferMethod.local_file], + }) + if (!notNoticeFormattingChanged) formattingChangedDispatcher() + }, + [formattingChangedDispatcher], + ) + + return { + completionParams, + modelConfig, + modelModeTypeRef, + setCompletionParams: updateCompletionParams, + setModelConfig, + setTempStop, + setVisionConfig: updateVisionConfig, + visionConfig, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-multiple-model-debug.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-multiple-model-debug.ts new file mode 100644 index 00000000000..8e820b9ee09 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-multiple-model-debug.ts @@ -0,0 +1,44 @@ +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelConfig } from '@/models/debug' +import { useCallback } from 'react' +import { useDebugWithSingleOrMultipleModel } from '@/app/components/app/configuration/debug/hooks' +import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage' + +export function useMultipleModelDebug({ + appId, + completionParams, + modelConfig, +}: { + appId: string + completionParams: FormValue + modelConfig: ModelConfig +}) { + const setDetailSidebarMode = useSetDetailSidebarMode() + const { debugWithMultipleModel, multipleModelConfigs, handleMultipleModelConfigsChange } = + useDebugWithSingleOrMultipleModel(appId) + const enableMultipleModelDebug = useCallback(() => { + handleMultipleModelConfigsChange(true, [ + { + id: `${Date.now()}`, + model: modelConfig.model_id, + provider: modelConfig.provider, + parameters: completionParams, + }, + { id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} }, + ]) + setDetailSidebarMode('collapse') + }, [ + completionParams, + handleMultipleModelConfigsChange, + modelConfig.model_id, + modelConfig.provider, + setDetailSidebarMode, + ]) + + return { + debugWithMultipleModel, + enableMultipleModelDebug, + handleMultipleModelConfigsChange, + multipleModelConfigs, + } +} diff --git a/web/app/components/app/configuration/hooks/configuration-lifecycle/use-published-config-sync.ts b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-published-config-sync.ts new file mode 100644 index 00000000000..dec7a1d3a5e --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-lifecycle/use-published-config-sync.ts @@ -0,0 +1,118 @@ +import type { ConfigurationPublishConfig } from './types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ExternalDataTool } from '@/models/common' +import type { DataSet } from '@/models/datasets' +import type { + DatasetConfigs, + ModelConfig, + ModerationConfig, + MoreLikeThisConfig, + TextToSpeechConfig, +} from '@/models/debug' +import type { VisionSettings } from '@/types/app' +import { useCallback } from 'react' +import { PromptMode } from '@/models/debug' +import { Resolution, TransferMethod } from '@/types/app' + +type UsePublishedConfigSyncParams = { + setCanReturnToSimpleMode: (value: boolean) => void + setChatPromptConfig: (value: ConfigurationPublishConfig['chatPromptConfig']) => void + setCitationConfig: (value: MoreLikeThisConfig) => void + setCompletionParams: (value: FormValue) => void + setCompletionPromptConfig: (value: ConfigurationPublishConfig['completionPromptConfig']) => void + setDataSets: (value: DataSet[]) => void + setDatasetConfigs: (value: DatasetConfigs) => void + setExternalDataToolsConfig: (value: ExternalDataTool[]) => void + setIntroduction: (value: string) => void + setModelConfig: (value: ModelConfig) => void + setModerationConfig: (value: ModerationConfig) => void + setMoreLikeThisConfig: (value: MoreLikeThisConfig) => void + setPromptModeState: (value: PromptMode) => void + setSpeechToTextConfig: (value: MoreLikeThisConfig) => void + setSuggestedQuestions: (value: string[]) => void + setSuggestedQuestionsAfterAnswerConfig: (value: MoreLikeThisConfig) => void + setTextToSpeechConfig: (value: TextToSpeechConfig) => void + setVisionConfig: (value: VisionSettings, notNoticeFormattingChanged?: boolean) => void +} + +export function usePublishedConfigSync({ + setCanReturnToSimpleMode, + setChatPromptConfig, + setCitationConfig, + setCompletionParams, + setCompletionPromptConfig, + setDataSets, + setDatasetConfigs, + setExternalDataToolsConfig, + setIntroduction, + setModelConfig, + setModerationConfig, + setMoreLikeThisConfig, + setPromptModeState, + setSpeechToTextConfig, + setSuggestedQuestions, + setSuggestedQuestionsAfterAnswerConfig, + setTextToSpeechConfig, + setVisionConfig, +}: UsePublishedConfigSyncParams) { + return useCallback( + (publishedConfig: ConfigurationPublishConfig) => { + const publishedModelConfig = publishedConfig.modelConfig + setModelConfig(publishedModelConfig) + setCompletionParams(publishedConfig.completionParams) + setPromptModeState(publishedConfig.promptMode) + setCanReturnToSimpleMode(publishedConfig.promptMode !== PromptMode.advanced) + setChatPromptConfig(publishedConfig.chatPromptConfig) + setCompletionPromptConfig(publishedConfig.completionPromptConfig) + setDataSets(publishedModelConfig.dataSets || []) + setDatasetConfigs(publishedConfig.datasetConfigs) + setExternalDataToolsConfig(publishedConfig.externalDataToolsConfig) + setIntroduction(publishedModelConfig.opening_statement || '') + setSuggestedQuestions(publishedModelConfig.suggested_questions || []) + setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false }) + setSuggestedQuestionsAfterAnswerConfig( + publishedModelConfig.suggested_questions_after_answer || { enabled: false }, + ) + setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false }) + setTextToSpeechConfig( + publishedModelConfig.text_to_speech || { + enabled: false, + voice: '', + language: '', + }, + ) + setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false }) + setModerationConfig(publishedModelConfig.sensitive_word_avoidance || { enabled: false }) + const publishedVisionConfig = publishedModelConfig.file_upload?.image + setVisionConfig( + { + enabled: publishedVisionConfig?.enabled || false, + number_limits: publishedVisionConfig?.number_limits || 2, + detail: publishedVisionConfig?.detail || Resolution.low, + transfer_methods: publishedVisionConfig?.transfer_methods || [TransferMethod.local_file], + }, + true, + ) + }, + [ + setCanReturnToSimpleMode, + setChatPromptConfig, + setCitationConfig, + setCompletionParams, + setCompletionPromptConfig, + setDataSets, + setDatasetConfigs, + setExternalDataToolsConfig, + setIntroduction, + setModelConfig, + setModerationConfig, + setMoreLikeThisConfig, + setPromptModeState, + setSpeechToTextConfig, + setSuggestedQuestions, + setSuggestedQuestionsAfterAnswerConfig, + setTextToSpeechConfig, + setVisionConfig, + ], + ) +} diff --git a/web/app/components/app/configuration/hooks/configuration-view-model.ts b/web/app/components/app/configuration/hooks/configuration-view-model.ts new file mode 100644 index 00000000000..d6e35aa99f1 --- /dev/null +++ b/web/app/components/app/configuration/hooks/configuration-view-model.ts @@ -0,0 +1,52 @@ +import type { ComponentProps } from 'react' +import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' +import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' +import type { + Features as FeaturesData, + OnFeaturesChange, +} from '@/app/components/base/features/types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import type ConfigContext from '@/context/debug-configuration' +import type { DataSet } from '@/models/datasets' +import type { ModelConfig, PromptVariable } from '@/models/debug' + +export type DebugConfigurationValue = ComponentProps['value'] + +export type ConfigurationViewModel = { + appPublisherProps: ComponentProps + contextValue: DebugConfigurationValue + featuresData: FeaturesData + isAgent: boolean + isAdvancedMode: boolean + isMobile: boolean + isShowDebugPanel: boolean + isShowHistoryModal: boolean + isShowSelectDataSet: boolean + modelConfig: ModelConfig + multipleModelConfigs: ModelAndParameter[] + onAutoAddPromptVariable: (variables: PromptVariable[]) => void + onAgentSettingChange: (config: ModelConfig['agentConfig']) => void + onCloseFeaturePanel: () => void + onCloseHistoryModal: () => void + onCloseSelectDataSet: () => void + onCompletionParamsChange: (params: FormValue) => void + onConfirmUseGPT4: () => void + onEnableMultipleModelDebug: () => void + onFeaturesChange: OnFeaturesChange + onHideDebugPanel: () => void + onModelChange: ComponentProps['setModel'] + onMultipleModelConfigsChange: (multiple: boolean, modelConfigs: ModelAndParameter[]) => void + onOpenAccountSettings: () => void + onOpenDebugPanel: () => void + onSaveHistory: ( + data: DebugConfigurationValue['completionPromptConfig']['conversation_histories_role'], + ) => void + onSelectDataSets: (data: DataSet[]) => void + promptVariables: PromptVariable[] + selectedIds: string[] + showAppConfigureFeaturesModal: boolean + showLoading: boolean + showUseGPT4Confirm: boolean + setShowUseGPT4Confirm: (visible: boolean) => void +} diff --git a/web/app/components/app/configuration/hooks/use-configuration-utils.ts b/web/app/components/app/configuration/hooks/use-configuration-utils.ts deleted file mode 100644 index 2bdf2cb85fc..00000000000 --- a/web/app/components/app/configuration/hooks/use-configuration-utils.ts +++ /dev/null @@ -1,770 +0,0 @@ -import type { SelectorTranslate } from '../utils' -import type { Features as FeaturesData } from '@/app/components/base/features/types' -import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type { Collection } from '@/app/components/tools/types' -import type { DataSet } from '@/models/datasets' -import type { - AnnotationReplyConfig, - DatasetConfigs, - ModelConfig, - PromptVariable, -} from '@/models/debug' -import type { - ModelConfig as BackendModelConfig, - UserInputFormItem, - VisionSettings, -} from '@/types/app' -import { clone } from 'es-toolkit/object' -import { produce } from 'immer' -import { toast } from '@/app/components/app/configuration/toast' -import { - getMultipleRetrievalConfig, - getSelectedDatasetsMode, -} from '@/app/components/workflow/nodes/knowledge-retrieval/utils' -import { - DEFAULT_AGENT_SETTING, - DEFAULT_CHAT_PROMPT_CONFIG, - DEFAULT_COMPLETION_PROMPT_CONFIG, -} from '@/config' -import { PromptMode } from '@/models/debug' -import { fetchAppDetailDirect } from '@/service/apps' -import { fetchDatasets } from '@/service/datasets' -import { fetchCollectionList } from '@/service/tools' -import { AgentStrategy, AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app' -import { correctModelProvider, correctToolProvider } from '@/utils' -import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params' -import { - promptVariablesToUserInputsForm, - userInputsFormToPromptVariables, -} from '@/utils/model-config' -import { getStringSelectorTranslate, withCollectionIconBasePath } from '../utils' - -type BackendAgentTool = ModelConfig['agentConfig']['tools'][number] & { - dataset?: { - enabled: boolean - id: string - } - provider_id: string - provider_name: string - provider_type: string - tool_name: string -} - -type DeletedTool = { - id?: string - provider_id?: string - tool_name: string -} - -const buildPublishedModelConfig = ({ - backendModelConfig, - collectionList, - deletedTools, - mode, - nextDataSets, -}: { - backendModelConfig: BackendModelConfig - collectionList: Collection[] - deletedTools?: DeletedTool[] - mode: AppModeEnum - nextDataSets: DataSet[] -}): ModelConfig => { - const model = backendModelConfig.model - const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as BackendAgentTool[] - - return { - provider: correctModelProvider(model.provider), - model_id: model.name, - mode: model.mode, - configs: { - prompt_template: backendModelConfig.pre_prompt || '', - prompt_variables: userInputsFormToPromptVariables( - [ - ...backendModelConfig.user_input_form, - ...(backendModelConfig.external_data_tools?.length - ? backendModelConfig.external_data_tools.map((item) => ({ - external_data_tool: { - variable: item.variable as string, - label: item.label as string, - enabled: !!item.enabled, - type: item.type as string, - config: item.config, - required: true, - icon: item.icon, - icon_background: item.icon_background, - }, - })) - : []), - ] as unknown as UserInputFormItem[], - backendModelConfig.dataset_query_variable, - ), - }, - more_like_this: backendModelConfig.more_like_this ?? { enabled: false }, - opening_statement: backendModelConfig.opening_statement, - suggested_questions: backendModelConfig.suggested_questions ?? [], - sensitive_word_avoidance: backendModelConfig.sensitive_word_avoidance, - speech_to_text: backendModelConfig.speech_to_text, - text_to_speech: backendModelConfig.text_to_speech, - file_upload: backendModelConfig.file_upload ?? null, - suggested_questions_after_answer: backendModelConfig.suggested_questions_after_answer ?? { - enabled: false, - }, - retriever_resource: backendModelConfig.retriever_resource, - annotation_reply: backendModelConfig.annotation_reply ?? null, - external_data_tools: backendModelConfig.external_data_tools ?? [], - system_parameters: backendModelConfig.system_parameters, - dataSets: nextDataSets, - agentConfig: - mode === AppModeEnum.AGENT_CHAT - ? { - max_iteration: DEFAULT_AGENT_SETTING.max_iteration, - ...backendModelConfig.agent_mode, - enabled: true, - tools: agentModeTools - .filter((tool) => !tool.dataset) - .map((tool) => { - const toolInCollectionList = collectionList.find( - (collection) => collection.id === tool.provider_id, - ) - return { - ...tool, - isDeleted: - deletedTools?.some( - (deletedTool) => - (deletedTool.provider_id || deletedTool.id) === tool.provider_id && - deletedTool.tool_name === tool.tool_name, - ) ?? false, - notAuthor: toolInCollectionList?.is_team_authorization === false, - ...(tool.provider_type === 'builtin' - ? { - provider_id: correctToolProvider( - tool.provider_name, - !!toolInCollectionList, - ), - provider_name: correctToolProvider( - tool.provider_name, - !!toolInCollectionList, - ), - } - : {}), - } - }) as ModelConfig['agentConfig']['tools'], - strategy: backendModelConfig.agent_mode?.strategy ?? AgentStrategy.react, - } - : DEFAULT_AGENT_SETTING, - } -} - -export const buildPublishedConfig = ({ - backendModelConfig, - collectionList, - deletedTools, - mode, - nextDataSets, -}: { - backendModelConfig: BackendModelConfig - collectionList: Collection[] - deletedTools?: DeletedTool[] - mode: AppModeEnum - nextDataSets: DataSet[] -}) => ({ - modelConfig: buildPublishedModelConfig({ - backendModelConfig, - collectionList, - deletedTools, - mode, - nextDataSets, - }), - completionParams: backendModelConfig.model.completion_params, -}) - -export const buildConfigurationDatasetConfigs = ({ - backendModelConfig, - currentRerankModel, - currentRerankProvider, - nextDataSets, -}: { - backendModelConfig: BackendModelConfig - currentRerankModel?: string - currentRerankProvider?: string - nextDataSets: DataSet[] -}): DatasetConfigs => { - const retrievalConfig = getMultipleRetrievalConfig( - { - ...backendModelConfig.dataset_configs, - reranking_model: backendModelConfig.dataset_configs.reranking_model && { - provider: backendModelConfig.dataset_configs.reranking_model.reranking_provider_name, - model: backendModelConfig.dataset_configs.reranking_model.reranking_model_name, - }, - }, - nextDataSets, - nextDataSets, - { - provider: currentRerankProvider, - model: currentRerankModel, - }, - ) - - const nextDatasetConfigs = { - ...backendModelConfig.dataset_configs, - ...retrievalConfig, - ...(retrievalConfig.reranking_model - ? { - reranking_model: { - reranking_model_name: retrievalConfig.reranking_model.model, - reranking_provider_name: correctModelProvider(retrievalConfig.reranking_model.provider), - }, - } - : {}), - } as DatasetConfigs - - nextDatasetConfigs.retrieval_model = nextDatasetConfigs.retrieval_model ?? RETRIEVE_TYPE.multiWay - - return nextDatasetConfigs -} - -export const buildPublishBody = ({ - chatPromptConfig, - completionParams, - completionPromptConfig, - contextVar, - dataSets, - datasetConfigs, - externalDataToolsConfig, - features, - isAdvancedMode, - isFunctionCall, - modelConfig, - modelId, - modelProvider, - promptMode, - promptVariables, - promptTemplate, - resolvedModelModeType, -}: { - chatPromptConfig: BackendModelConfig['chat_prompt_config'] - completionParams: FormValue - completionPromptConfig: BackendModelConfig['completion_prompt_config'] - contextVar?: string - dataSets: DataSet[] - datasetConfigs: DatasetConfigs - externalDataToolsConfig: BackendModelConfig['external_data_tools'] - features?: FeaturesData - isAdvancedMode: boolean - isFunctionCall: boolean - modelConfig: ModelConfig - modelId: string - modelProvider: string - promptMode: BackendModelConfig['prompt_type'] - promptVariables: PromptVariable[] - promptTemplate: string - resolvedModelModeType: BackendModelConfig['model']['mode'] -}): BackendModelConfig => { - const postDatasets = dataSets.map(({ id }) => ({ - dataset: { - enabled: true, - id, - }, - })) - - const fileUpload = { ...features?.file } - delete fileUpload?.fileUploadConfig - - return { - pre_prompt: !isAdvancedMode ? promptTemplate : '', - prompt_type: promptMode, - chat_prompt_config: isAdvancedMode ? chatPromptConfig : clone(DEFAULT_CHAT_PROMPT_CONFIG), - completion_prompt_config: isAdvancedMode - ? completionPromptConfig - : clone(DEFAULT_COMPLETION_PROMPT_CONFIG), - user_input_form: promptVariablesToUserInputsForm(promptVariables), - dataset_query_variable: contextVar || '', - more_like_this: features?.moreLikeThis as never, - opening_statement: features?.opening?.enabled ? features.opening?.opening_statement || '' : '', - suggested_questions: features?.opening?.enabled - ? features.opening?.suggested_questions || [] - : [], - sensitive_word_avoidance: features?.moderation as never, - speech_to_text: features?.speech2text as never, - text_to_speech: features?.text2speech as never, - file_upload: fileUpload as never, - suggested_questions_after_answer: features?.suggested as never, - retriever_resource: features?.citation as never, - agent_mode: { - ...modelConfig.agentConfig, - strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react, - }, - external_data_tools: externalDataToolsConfig, - model: { - provider: modelProvider, - name: modelId, - mode: resolvedModelModeType, - completion_params: completionParams as BackendModelConfig['model']['completion_params'], - }, - dataset_configs: { - ...datasetConfigs, - datasets: { - datasets: [...postDatasets], - } as never, - }, - system_parameters: modelConfig.system_parameters, - } -} - -const normalizeAnnotationConfig = (annotationReply?: BackendModelConfig['annotation_reply']) => { - if (!annotationReply) return undefined - - if (!annotationReply.enabled) return annotationReply as AnnotationReplyConfig - - return { - ...annotationReply, - embedding_model: { - ...annotationReply.embedding_model, - embedding_provider_name: correctModelProvider( - annotationReply.embedding_model.embedding_provider_name, - ), - }, - } as AnnotationReplyConfig -} - -export const loadConfigurationState = async ({ - appId, - basePath, - currentRerankModel, - currentRerankProvider, -}: { - appId: string - basePath?: string - currentRerankModel?: string - currentRerankProvider?: string -}) => { - const collectionList = withCollectionIconBasePath(await fetchCollectionList(), basePath) - const response = await fetchAppDetailDirect({ url: '/apps', id: appId }) - const backendModelConfig = response.model_config as BackendModelConfig - const nextPromptMode = - backendModelConfig.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple - - let nextDataSets: DataSet[] = [] - const agentModeTools = (backendModelConfig.agent_mode?.tools ?? []) as Array<{ - dataset?: { enabled: boolean; id: string } - }> - - if (agentModeTools.find((tool) => tool.dataset?.enabled)) - nextDataSets = agentModeTools as unknown as DataSet[] - else if (backendModelConfig.dataset_configs.datasets?.datasets?.length) - nextDataSets = backendModelConfig.dataset_configs.datasets.datasets as unknown as DataSet[] - - if (nextDataSets.length) { - const datasetIds = (nextDataSets as Array) - .map((item) => item.dataset?.id || item.id) - .filter((id): id is string => Boolean(id)) - - const { data } = await fetchDatasets({ - url: '/datasets', - params: { - page: 1, - ids: datasetIds, - }, - }) - nextDataSets = data - } - - return { - annotationConfig: normalizeAnnotationConfig(backendModelConfig.annotation_reply), - backendModelConfig, - canReturnToSimpleMode: nextPromptMode !== PromptMode.advanced, - collectionList, - completionPromptConfig: - backendModelConfig.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG), - datasetConfigs: buildConfigurationDatasetConfigs({ - backendModelConfig, - currentRerankModel, - currentRerankProvider, - nextDataSets, - }), - externalDataToolsConfig: backendModelConfig.external_data_tools ?? [], - mode: response.mode as AppModeEnum, - moreLikeThisConfig: backendModelConfig.more_like_this || { enabled: false }, - nextDataSets, - promptMode: nextPromptMode, - publishedConfig: buildPublishedConfig({ - backendModelConfig, - collectionList, - deletedTools: response.deleted_tools, - mode: response.mode as AppModeEnum, - nextDataSets, - }), - response, - speechToTextConfig: backendModelConfig.speech_to_text || { enabled: false }, - suggestedQuestions: backendModelConfig.suggested_questions || [], - suggestedQuestionsAfterAnswerConfig: backendModelConfig.suggested_questions_after_answer || { - enabled: false, - }, - textToSpeechConfig: backendModelConfig.text_to_speech || { - enabled: false, - voice: '', - language: '', - }, - visionConfig: backendModelConfig.file_upload?.image, - citationConfig: backendModelConfig.retriever_resource || { enabled: false }, - chatPromptConfig: - backendModelConfig.chat_prompt_config && - backendModelConfig.chat_prompt_config.prompt?.length > 0 - ? backendModelConfig.chat_prompt_config - : clone(DEFAULT_CHAT_PROMPT_CONFIG), - introduction: backendModelConfig.opening_statement, - moderationConfig: backendModelConfig.sensitive_word_avoidance, - } -} - -export const createDatasetSelectHandler = - ({ - currentRerankModel, - currentRerankProvider, - dataSets, - datasetConfigs, - datasetConfigsRef, - formattingChangedDispatcher, - hideSelectDataSet, - setDataSets, - setDatasetConfigs, - setRerankSettingModalOpen, - }: { - currentRerankModel?: string - currentRerankProvider?: string - dataSets: DataSet[] - datasetConfigs: DatasetConfigs - datasetConfigsRef: { current: DatasetConfigs } - formattingChangedDispatcher: () => void - hideSelectDataSet: () => void - setDataSets: (data: DataSet[]) => void - setDatasetConfigs: (configs: DatasetConfigs) => void - setRerankSettingModalOpen: (visible: boolean) => void - }) => - (nextDataSets: DataSet[]) => { - if ( - nextDataSets.map((item) => item.id).join(',') === dataSets.map((item) => item.id).join(',') - ) { - hideSelectDataSet() - return - } - - formattingChangedDispatcher() - let mergedDataSets = nextDataSets - - if (nextDataSets.find((item) => !item.name)) { - const hydrated = produce(nextDataSets, (draft) => { - nextDataSets.forEach((item, index) => { - if (!item.name) { - const originalItem = dataSets.find((existing) => existing.id === item.id) - if (originalItem) draft[index] = originalItem - } - }) - }) - setDataSets(hydrated) - mergedDataSets = hydrated - } else { - setDataSets(nextDataSets) - } - - hideSelectDataSet() - const { - allExternal, - allInternal, - mixtureInternalAndExternal, - mixtureHighQualityAndEconomic, - inconsistentEmbeddingModel, - } = getSelectedDatasetsMode(mergedDataSets) - - if ( - (allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel)) || - mixtureInternalAndExternal || - allExternal - ) { - setRerankSettingModalOpen(true) - } - - const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = datasetConfigs - const { top_k, score_threshold, reranking_model, reranking_mode, weights, reranking_enable } = - restConfigs - - const oldRetrievalConfig = { - top_k, - score_threshold, - reranking_model: - reranking_model?.reranking_provider_name && reranking_model?.reranking_model_name - ? { - provider: reranking_model.reranking_provider_name, - model: reranking_model.reranking_model_name, - } - : undefined, - reranking_mode, - weights, - reranking_enable, - } - - const retrievalConfig = getMultipleRetrievalConfig( - oldRetrievalConfig, - mergedDataSets, - dataSets, - { - provider: currentRerankProvider, - model: currentRerankModel, - }, - ) - - setDatasetConfigs({ - ...datasetConfigsRef.current, - ...retrievalConfig, - reranking_model: { - reranking_provider_name: retrievalConfig?.reranking_model?.provider || '', - reranking_model_name: retrievalConfig?.reranking_model?.model || '', - }, - retrieval_model, - score_threshold_enabled, - datasets, - }) - } - -export const createPublishHandler = - ({ - appId, - chatPromptConfig, - citationConfig, - completionParamsState, - completionPromptConfig, - contextVar, - contextVarEmpty, - dataSets, - datasetConfigs, - externalDataToolsConfig, - hasSetBlockStatus, - introduction, - isAdvancedMode, - isFunctionCall, - mode, - modelConfig, - moreLikeThisConfig, - promptEmpty, - promptMode, - resolvedModelModeType, - setCanReturnToSimpleMode, - setPublishedConfig, - speechToTextConfig, - suggestedQuestionsAfterAnswerConfig, - t: rawTranslate, - textToSpeechConfig, - }: { - appId: string - chatPromptConfig: BackendModelConfig['chat_prompt_config'] - citationConfig: ModelConfig['retriever_resource'] - completionParamsState: FormValue - completionPromptConfig: BackendModelConfig['completion_prompt_config'] - contextVar?: string - contextVarEmpty: boolean - dataSets: DataSet[] - datasetConfigs: DatasetConfigs - externalDataToolsConfig: BackendModelConfig['external_data_tools'] - hasSetBlockStatus: { history: boolean; query: boolean } - introduction: string - isAdvancedMode: boolean - isFunctionCall: boolean - mode: AppModeEnum - modelConfig: ModelConfig - moreLikeThisConfig: ModelConfig['more_like_this'] - promptEmpty: boolean - promptMode: BackendModelConfig['prompt_type'] - resolvedModelModeType: ModelModeType - setCanReturnToSimpleMode: (value: boolean) => void - setPublishedConfig: (config: { modelConfig: ModelConfig; completionParams: FormValue }) => void - speechToTextConfig: ModelConfig['speech_to_text'] - suggestedQuestionsAfterAnswerConfig: ModelConfig['suggested_questions_after_answer'] - t: SelectorTranslate<'appDebug' | 'common'> - textToSpeechConfig: ModelConfig['text_to_speech'] - }) => - async ( - updateAppModelConfig: (params: { url: string; body: BackendModelConfig }) => Promise, - modelAndParameter?: { model: string; provider: string; parameters: FormValue }, - features?: FeaturesData, - ) => { - const t = getStringSelectorTranslate(rawTranslate) - const modelId = modelAndParameter?.model || modelConfig.model_id - const promptTemplate = modelConfig.configs.prompt_template - const promptVariables = modelConfig.configs.prompt_variables - - if (promptEmpty) { - toast.error(t(($) => $['otherError.promptNoBeEmpty'], { ns: 'appDebug' })) - return - } - - if ( - isAdvancedMode && - mode !== AppModeEnum.COMPLETION && - resolvedModelModeType === ModelModeType.completion - ) { - if (!hasSetBlockStatus.history) { - toast.error(t(($) => $['otherError.historyNoBeEmpty'], { ns: 'appDebug' })) - return - } - - if (!hasSetBlockStatus.query) { - toast.error(t(($) => $['otherError.queryNoBeEmpty'], { ns: 'appDebug' })) - return - } - } - - if (contextVarEmpty) { - toast.error( - t(($) => $['feature.dataSet.queryVariable.contextVarNotEmpty'], { ns: 'appDebug' }), - ) - return - } - - const body = buildPublishBody({ - chatPromptConfig, - completionParams: modelAndParameter?.parameters || completionParamsState, - completionPromptConfig, - contextVar, - dataSets, - datasetConfigs, - externalDataToolsConfig, - features, - isAdvancedMode, - isFunctionCall, - modelConfig, - modelId, - modelProvider: modelAndParameter?.provider || modelConfig.provider, - promptMode, - promptTemplate, - promptVariables, - resolvedModelModeType, - }) - - await updateAppModelConfig({ url: `/apps/${appId}/model-config`, body }) - - const nextModelConfig = produce(modelConfig, (draft: ModelConfig) => { - draft.opening_statement = introduction - draft.more_like_this = moreLikeThisConfig - draft.suggested_questions_after_answer = suggestedQuestionsAfterAnswerConfig - draft.speech_to_text = speechToTextConfig - draft.text_to_speech = textToSpeechConfig - draft.retriever_resource = citationConfig - draft.dataSets = dataSets - }) - - setPublishedConfig({ - modelConfig: nextModelConfig, - completionParams: completionParamsState, - }) - toast.success(t(($) => $['api.success'], { ns: 'common' })) - setCanReturnToSimpleMode(false) - return true - } - -export const createModelChangeHandler = - ({ - chatPromptLength, - completionParamsState, - completionPromptConfig, - handleSetVisionConfig, - isAdvancedMode, - migrateToDefaultPrompt, - mode, - modelConfig, - resolvedModelModeType, - setCompletionParams, - setModelConfig, - t: rawTranslate, - visionConfig, - }: { - chatPromptLength: number - completionParamsState: FormValue - completionPromptConfig: { - conversation_histories_role: { - assistant_prefix: string - user_prefix: string - } - prompt?: { - text?: string - } - } - handleSetVisionConfig: (config: VisionSettings, notNoticeFormattingChanged?: boolean) => void - isAdvancedMode: boolean - migrateToDefaultPrompt: (force?: boolean, modelModeType?: ModelModeType) => Promise - mode: AppModeEnum - modelConfig: ModelConfig - resolvedModelModeType: ModelModeType - setCompletionParams: (value: FormValue) => void - setModelConfig: (config: ModelConfig) => void - t: SelectorTranslate<'appDebug' | 'common'> - visionConfig: VisionSettings - }) => - async ({ - features = [], - mode: nextModelMode = resolvedModelModeType, - modelId, - provider, - }: { - modelId: string - provider: string - mode?: string - features?: string[] - }) => { - const t = getStringSelectorTranslate(rawTranslate) - if (isAdvancedMode) { - if (nextModelMode === ModelModeType.completion) { - if (mode !== AppModeEnum.COMPLETION) { - if ( - !completionPromptConfig.prompt?.text || - !completionPromptConfig.conversation_histories_role.assistant_prefix || - !completionPromptConfig.conversation_histories_role.user_prefix - ) - await migrateToDefaultPrompt(true, ModelModeType.completion) - } else if (!completionPromptConfig.prompt?.text) { - await migrateToDefaultPrompt(true, ModelModeType.completion) - } - } - - if (nextModelMode === ModelModeType.chat && chatPromptLength === 0) - await migrateToDefaultPrompt(true, ModelModeType.chat) - } - - setModelConfig( - produce(modelConfig, (draft: ModelConfig) => { - draft.provider = provider - draft.model_id = modelId - draft.mode = nextModelMode as ModelModeType - }), - ) - - handleSetVisionConfig( - { - ...visionConfig, - enabled: !!features?.includes('vision'), - }, - true, - ) - - try { - const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams( - provider, - modelId, - completionParamsState, - isAdvancedMode, - ) - - if (Object.keys(removedDetails).length) - toast.warning( - `${t(($) => $['modelProvider.parametersInvalidRemoved'], { ns: 'common' })}: ${Object.entries( - removedDetails, - ) - .map(([key, reason]) => `${key} (${reason})`) - .join(', ')}`, - ) - - setCompletionParams(filtered) - } catch { - toast.error(t(($) => $.error, { ns: 'common' })) - setCompletionParams({}) - } - } diff --git a/web/app/components/app/configuration/hooks/use-configuration.ts b/web/app/components/app/configuration/hooks/use-configuration.ts index 367a3a636af..be2cc6da71c 100644 --- a/web/app/components/app/configuration/hooks/use-configuration.ts +++ b/web/app/components/app/configuration/hooks/use-configuration.ts @@ -1,46 +1,15 @@ 'use client' -import type { ComponentProps } from 'react' -import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher' -import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper' -import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' -import type { - Features as FeaturesData, - OnFeaturesChange, -} from '@/app/components/base/features/types' -import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' -import type ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import type { ConfigurationPublishConfig } from './configuration-lifecycle/types' +import type { ConfigurationViewModel } from './configuration-view-model' +import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { Collection } from '@/app/components/tools/types' -import type ConfigContext from '@/context/debug-configuration' -import type { ExternalDataTool } from '@/models/common' -import type { DataSet } from '@/models/datasets' -import type { - AnnotationReplyConfig, - DatasetConfigs, - Inputs, - ModelConfig, - ModerationConfig, - MoreLikeThisConfig, - PromptConfig, - PromptVariable, - TextToSpeechConfig, -} from '@/models/debug' -import type { VisionSettings } from '@/types/app' -import { useMutation, useSuspenseQuery } from '@tanstack/react-query' -import { useBoolean, useGetState } from 'ahooks' -import { clone } from 'es-toolkit/object' +import type { Inputs, ModelConfig, PromptConfig, PromptVariable } from '@/models/debug' +import { useBoolean } from 'ahooks' import { produce } from 'immer' -import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useShallow } from 'zustand/react/shallow' -import { - useDebugWithSingleOrMultipleModel, - useFormattingChangedDispatcher, -} from '@/app/components/app/configuration/debug/hooks' import useAdvancedPromptConfig from '@/app/components/app/configuration/hooks/use-advanced-prompt-config' -import { useStore as useAppStore } from '@/app/components/app/store' -import { useSetDetailSidebarMode } from '@/app/components/detail-sidebar/storage' import { ModelFeatureEnum, ModelTypeEnum, @@ -53,130 +22,49 @@ import { settingsQueryParamName, settingsQueryParser, } from '@/app/components/header/account-setting/query-params' -import { - ANNOTATION_DEFAULT, - DATASET_DEFAULT, - DEFAULT_AGENT_SETTING, - DEFAULT_CHAT_PROMPT_CONFIG, - DEFAULT_COMPLETION_PROMPT_CONFIG, -} from '@/config' -import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' -import { currentWorkspaceAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PromptMode } from '@/models/debug' -import { usePathname } from '@/next/navigation' -import { updateAppModelConfig } from '@/service/apps' -import { consoleQuery } from '@/service/client' import { useFileUploadConfig } from '@/service/use-common' -import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app' -import { getAppACLCapabilities } from '@/utils/permission' +import { AppModeEnum, ModelModeType } from '@/types/app' import { supportFunctionCall } from '@/utils/tool-call' -import { basePath } from '@/utils/var' import { buildConfigurationFeaturesData, getConfigurationPublishingState } from '../utils' -import { - createDatasetSelectHandler, - createModelChangeHandler, - createPublishHandler, - loadConfigurationState, -} from './use-configuration-utils' - -type PublishConfig = { - modelConfig: ModelConfig - completionParams: FormValue -} - -type DebugConfigurationValue = ComponentProps['value'] - -export type ConfigurationViewModel = { - appPublisherProps: ComponentProps - contextValue: DebugConfigurationValue - featuresData: FeaturesData - isAgent: boolean - isAdvancedMode: boolean - isMobile: boolean - isShowDebugPanel: boolean - isShowHistoryModal: boolean - isShowSelectDataSet: boolean - modelConfig: ModelConfig - multipleModelConfigs: ModelAndParameter[] - onAutoAddPromptVariable: (variables: PromptVariable[]) => void - onAgentSettingChange: (config: ModelConfig['agentConfig']) => void - onCloseFeaturePanel: () => void - onCloseHistoryModal: () => void - onCloseSelectDataSet: () => void - onCompletionParamsChange: (params: FormValue) => void - onConfirmUseGPT4: () => void - onEnableMultipleModelDebug: () => void - onFeaturesChange: OnFeaturesChange - onHideDebugPanel: () => void - onModelChange: ComponentProps['setModel'] - onMultipleModelConfigsChange: (multiple: boolean, modelConfigs: ModelAndParameter[]) => void - onOpenAccountSettings: () => void - onOpenDebugPanel: () => void - onSaveHistory: ( - data: DebugConfigurationValue['completionPromptConfig']['conversation_histories_role'], - ) => void - onSelectDataSets: (data: DataSet[]) => void - promptVariables: PromptVariable[] - selectedIds: string[] - showAppConfigureFeaturesModal: boolean - showLoading: boolean - showUseGPT4Confirm: boolean - setShowUseGPT4Confirm: (visible: boolean) => void -} +import { buildConfigurationContextValue } from './build-configuration-context' +import { useDatasetSelectHandler } from './configuration-lifecycle/dataset' +import { useModelChangeHandler } from './configuration-lifecycle/model' +import { useConfigurationAppContext } from './configuration-lifecycle/use-configuration-app-context' +import { useConfigurationLoader } from './configuration-lifecycle/use-configuration-loader' +import { useConfigurationPublish } from './configuration-lifecycle/use-configuration-publish' +import { useDatasetConfigurationState } from './configuration-lifecycle/use-dataset-configuration-state' +import { useFeatureConfigurationState } from './configuration-lifecycle/use-feature-configuration-state' +import { useModelConfigurationState } from './configuration-lifecycle/use-model-configuration-state' +import { useMultipleModelDebug } from './configuration-lifecycle/use-multiple-model-debug' +import { usePublishedConfigSync } from './configuration-lifecycle/use-published-config-sync' export const useConfiguration = (): ConfigurationViewModel => { const { t } = useTranslation() - const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) - const currentWorkspace = useAtomValue(currentWorkspaceAtom) - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser) - - const { appDetail, showAppConfigureFeaturesModal, setShowAppConfigureFeaturesModal } = - useAppStore( - useShallow((state) => ({ - appDetail: state.appDetail, - showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal, - setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal, - })), - ) - const setDetailSidebarMode = useSetDetailSidebarMode() - - const { data: fileUploadConfigResponse } = useFileUploadConfig() - const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail]) - const appACLCapabilities = useMemo( - () => - getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId, - resourceMaintainer: appDetail?.maintainer, - workspacePermissionKeys, - }), - [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], + const [_settingsDestination, setSettingsDestination] = useQueryState( + settingsQueryParamName, + settingsQueryParser, ) - const configurationReadonly = !appACLCapabilities.canEdit + const { + appACLCapabilities, + appId, + configurationReadonly, + currentWorkspace, + isLoadingCurrentWorkspace, + serverLatestPublishedAt, + setShowAppConfigureFeaturesModal, + showAppConfigureFeaturesModal, + updateModelConfig, + } = useConfigurationAppContext() + const { data: fileUploadConfigResponse } = useFileUploadConfig() const [formattingChanged, setFormattingChanged] = useState(false) const [hasFetchedDetail, setHasFetchedDetail] = useState(false) - const pathname = usePathname() - const matched = /\/app\/([^/]+)/.exec(pathname) - const appId = matched?.[1] || '' - const { mutateAsync: updateModelConfig } = useMutation({ - mutationFn: (params: Parameters[0]) => - updateAppModelConfig(params), - onSuccess: (_data, _variables, _onMutateResult, context) => - context.client.invalidateQueries({ - queryKey: consoleQuery.apps.byAppId.get.queryKey({ - input: { params: { app_id: appId } }, - }), - }), - }) + // oxlint-disable-next-line eslint-react/use-state -- This custom hook returns a state object. + const featureConfiguration = useFeatureConfigurationState() const [mode, setMode] = useState(AppModeEnum.CHAT) - const [publishedConfig, setPublishedConfig] = useState(null) + const [publishedConfig, setPublishedConfig] = useState(null) const [conversationId, setConversationId] = useState('') const media = useBreakpoints() @@ -184,128 +72,49 @@ export const useConfiguration = (): ConfigurationViewModel => { const [isShowDebugPanel, { setTrue: showDebugPanel, setFalse: hideDebugPanel }] = useBoolean(false) - const [introduction, setIntroduction] = useState('') - const [suggestedQuestions, setSuggestedQuestions] = useState([]) + const { + externalDataToolsConfig, + formattingChangedDispatcher, + setAnnotationConfig, + setCitationConfig, + setExternalDataToolsConfig, + setIntroduction, + setModerationConfig, + setMoreLikeThisConfig, + setSpeechToTextConfig, + setSuggestedQuestions, + setSuggestedQuestionsAfterAnswerConfig, + setTextToSpeechConfig, + } = featureConfiguration const [controlClearChatMessage, setControlClearChatMessage] = useState(0) const [prevPromptConfig, setPrevPromptConfig] = useState({ prompt_template: '', prompt_variables: [], }) - const [moreLikeThisConfig, setMoreLikeThisConfig] = useState({ - enabled: false, - }) - const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = - useState({ enabled: false }) - const [speechToTextConfig, setSpeechToTextConfig] = useState({ - enabled: false, - }) - const [textToSpeechConfig, setTextToSpeechConfig] = useState({ - enabled: false, - voice: '', - language: '', - }) - const [citationConfig, setCitationConfig] = useState({ enabled: false }) - const [annotationConfig, doSetAnnotationConfig] = useState({ - id: '', - enabled: false, - score_threshold: ANNOTATION_DEFAULT.score_threshold, - embedding_model: { - embedding_provider_name: '', - embedding_model_name: '', - }, - }) - const formattingChangedDispatcher = useFormattingChangedDispatcher() - const setAnnotationConfig = useCallback( - (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => { - doSetAnnotationConfig(config) - if (!notSetFormatChanged) formattingChangedDispatcher() - }, - [formattingChangedDispatcher], - ) - - const [moderationConfig, setModerationConfig] = useState({ enabled: false }) - const [externalDataToolsConfig, setExternalDataToolsConfig] = useState([]) const [inputs, setInputs] = useState({}) const [query, setQuery] = useState('') - const [completionParamsState, doSetCompletionParams] = useState({}) - const [, setTempStop, getTempStop] = useGetState([]) - const [modelConfig, doSetModelConfig] = useState({ - provider: 'langgenius/openai/openai', - model_id: 'gpt-3.5-turbo', - mode: ModelModeType.unset, - configs: { - prompt_template: '', - prompt_variables: [], - }, - chat_prompt_config: clone(DEFAULT_CHAT_PROMPT_CONFIG), - completion_prompt_config: clone(DEFAULT_COMPLETION_PROMPT_CONFIG), - more_like_this: null, - opening_statement: '', - suggested_questions: [], - sensitive_word_avoidance: null, - speech_to_text: null, - text_to_speech: null, - file_upload: null, - suggested_questions_after_answer: null, - retriever_resource: null, - annotation_reply: null, - external_data_tools: [], - system_parameters: { - audio_file_size_limit: 0, - file_size_limit: 0, - image_file_size_limit: 0, - video_file_size_limit: 0, - workflow_file_upload_limit: 0, - }, - dataSets: [], - agentConfig: DEFAULT_AGENT_SETTING, + // oxlint-disable-next-line eslint-react/use-state -- This custom hook returns a state object. + const modelConfiguration = useModelConfigurationState({ + formattingChangedDispatcher, }) - + const { + completionParams: completionParamsState, + modelConfig, + modelModeTypeRef, + setCompletionParams, + setModelConfig, + setTempStop, + setVisionConfig: handleSetVisionConfig, + visionConfig, + } = modelConfiguration const modelModeType = modelConfig.mode - const modeModeTypeRef = useRef(modelModeType) - - const setCompletionParams = useCallback( - (value: FormValue) => { - const params = { ...value } - if ( - (!params.stop || params.stop.length === 0) && - modeModeTypeRef.current === ModelModeType.completion - ) { - params.stop = getTempStop() - setTempStop([]) - } - doSetCompletionParams(params) - }, - [getTempStop, setTempStop], - ) - - const setModelConfig = useCallback((newModelConfig: ModelConfig) => { - doSetModelConfig(newModelConfig) - }, []) - const isAgent = mode === AppModeEnum.AGENT_CHAT const [collectionList, setCollectionList] = useState([]) - const [datasetConfigs, doSetDatasetConfigs] = useState({ - retrieval_model: RETRIEVE_TYPE.multiWay, - reranking_model: { - reranking_provider_name: '', - reranking_model_name: '', - }, - top_k: DATASET_DEFAULT.top_k, - score_threshold_enabled: false, - score_threshold: DATASET_DEFAULT.score_threshold, - datasets: { - datasets: [], - }, - }) - const datasetConfigsRef = useRef(datasetConfigs) - const setDatasetConfigs = useCallback((newDatasetConfigs: DatasetConfigs) => { - doSetDatasetConfigs(newDatasetConfigs) - datasetConfigsRef.current = newDatasetConfigs - }, []) - - const [dataSets, setDataSets] = useState([]) + // oxlint-disable-next-line eslint-react/use-state -- This custom hook returns a state object. + const datasetConfiguration = useDatasetConfigurationState() + const { dataSets, datasetConfigs, datasetConfigsRef, setDataSets, setDatasetConfigs } = + datasetConfiguration const contextVar = modelConfig.configs.prompt_variables.find((item) => item.is_context_var)?.key const hasSetContextVar = !!contextVar const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] = @@ -319,30 +128,6 @@ export const useConfiguration = (): ConfigurationViewModel => { const { currentModel: currentRerankModel, currentProvider: currentRerankProvider } = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank) - const syncToPublishedConfig = useCallback( - (_publishedConfig: PublishConfig) => { - const publishedModelConfig = _publishedConfig.modelConfig - setModelConfig(publishedModelConfig) - setCompletionParams(_publishedConfig.completionParams) - setDataSets(publishedModelConfig.dataSets || []) - setIntroduction(publishedModelConfig.opening_statement || '') - setMoreLikeThisConfig(publishedModelConfig.more_like_this || { enabled: false }) - setSuggestedQuestionsAfterAnswerConfig( - publishedModelConfig.suggested_questions_after_answer || { enabled: false }, - ) - setSpeechToTextConfig(publishedModelConfig.speech_to_text || { enabled: false }) - setTextToSpeechConfig( - publishedModelConfig.text_to_speech || { - enabled: false, - voice: '', - language: '', - }, - ) - setCitationConfig(publishedModelConfig.retriever_resource || { enabled: false }) - }, - [setCompletionParams, setModelConfig], - ) - const { isAPIKeySet } = useProviderContext() const { currentModel: currModel } = useTextGenerationCurrentProviderAndModelAndModelList({ provider: modelConfig.provider, @@ -358,43 +143,14 @@ export const useConfiguration = (): ConfigurationViewModel => { const isFunctionCall = supportFunctionCall(currModel?.features) useEffect(() => { - modeModeTypeRef.current = resolvedModelModeType - }, [resolvedModelModeType]) + modelModeTypeRef.current = resolvedModelModeType + }, [modelModeTypeRef, resolvedModelModeType]) - const [promptMode, doSetPromptMode] = useState(PromptMode.simple) + const [promptMode, setPromptMode] = useState(PromptMode.simple) const isAdvancedMode = promptMode === PromptMode.advanced const [canReturnToSimpleMode, setCanReturnToSimpleMode] = useState(true) - const [visionConfig, doSetVisionConfig] = useState({ - enabled: false, - number_limits: 2, - detail: Resolution.low, - transfer_methods: [TransferMethod.local_file], - }) - const handleSetVisionConfig = useCallback( - (config: VisionSettings, notNoticeFormattingChanged?: boolean) => { - doSetVisionConfig({ - enabled: config.enabled || false, - number_limits: config.number_limits || 2, - detail: config.detail || Resolution.low, - transfer_methods: config.transfer_methods || [TransferMethod.local_file], - }) - if (!notNoticeFormattingChanged) formattingChangedDispatcher() - }, - [formattingChangedDispatcher], - ) - - const { - chatPromptConfig, - setChatPromptConfig, - completionPromptConfig, - setCompletionPromptConfig, - currentAdvancedPrompt, - setCurrentAdvancedPrompt, - hasSetBlockStatus, - setConversationHistoriesRole, - migrateToDefaultPrompt, - } = useAdvancedPromptConfig({ + const advancedPromptConfiguration = useAdvancedPromptConfig({ appMode: mode, modelName: modelConfig.model_id, promptMode, @@ -408,77 +164,76 @@ export const useConfiguration = (): ConfigurationViewModel => { setCompletionParams, setStop: setTempStop, }) + const { + chatPromptConfig, + setChatPromptConfig, + completionPromptConfig, + setCompletionPromptConfig, + hasSetBlockStatus, + setConversationHistoriesRole, + migrateToDefaultPrompt, + } = advancedPromptConfiguration - const setPromptMode = useCallback( + const syncToPublishedConfig = usePublishedConfigSync({ + setCanReturnToSimpleMode, + setChatPromptConfig, + setCitationConfig, + setCompletionParams, + setCompletionPromptConfig, + setDataSets, + setDatasetConfigs, + setExternalDataToolsConfig, + setIntroduction, + setModelConfig, + setModerationConfig, + setMoreLikeThisConfig, + setPromptModeState: setPromptMode, + setSpeechToTextConfig, + setSuggestedQuestions, + setSuggestedQuestionsAfterAnswerConfig, + setTextToSpeechConfig, + setVisionConfig: handleSetVisionConfig, + }) + + const handlePromptModeChange = useCallback( async (nextMode: PromptMode) => { if (nextMode === PromptMode.advanced) { await migrateToDefaultPrompt() setCanReturnToSimpleMode(true) } - doSetPromptMode(nextMode) + setPromptMode(nextMode) }, [migrateToDefaultPrompt], ) - const handleSelect = useCallback( - createDatasetSelectHandler({ - currentRerankModel: currentRerankModel?.model, - currentRerankProvider: currentRerankProvider?.provider, - dataSets, - datasetConfigs, - datasetConfigsRef, - formattingChangedDispatcher, - hideSelectDataSet, - setDataSets, - setDatasetConfigs, - setRerankSettingModalOpen, - }), - [ - currentRerankModel?.model, - currentRerankProvider?.provider, - dataSets, - datasetConfigs, - datasetConfigsRef, - formattingChangedDispatcher, - hideSelectDataSet, - setDataSets, - setDatasetConfigs, - setRerankSettingModalOpen, - ], - ) + const handleSelect = useDatasetSelectHandler({ + currentRerankModel: currentRerankModel?.model, + currentRerankProvider: currentRerankProvider?.provider, + dataSets, + datasetConfigs, + datasetConfigsRef, + formattingChangedDispatcher, + hideSelectDataSet, + setDataSets, + setDatasetConfigs, + setRerankSettingModalOpen, + }) - const setModel = useCallback( - createModelChangeHandler({ - chatPromptLength: chatPromptConfig.prompt.length, - completionParamsState, - completionPromptConfig, - handleSetVisionConfig, - isAdvancedMode, - migrateToDefaultPrompt, - mode, - modelConfig, - resolvedModelModeType, - setCompletionParams, - setModelConfig, - t, - visionConfig, - }), - [ - chatPromptConfig.prompt.length, - completionParamsState, - completionPromptConfig, - handleSetVisionConfig, - isAdvancedMode, - migrateToDefaultPrompt, - mode, - modelConfig, - resolvedModelModeType, - setCompletionParams, - setModelConfig, - t, - visionConfig, - ], - ) + const setModel = useModelChangeHandler({ + chatPromptLength: chatPromptConfig.prompt.length, + completionParamsState, + completionPromptConfig, + handleSetVisionConfig, + isAdvancedMode, + migrateToDefaultPrompt, + mode, + modelConfig, + resolvedModelModeType, + setCompletionParams, + setModelConfig, + t, + visionConfig, + }) const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision) const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document) @@ -497,7 +252,6 @@ export const useConfiguration = (): ConfigurationViewModel => { }, [formattingChangedDispatcher, setShowAppConfigureFeaturesModal], ) - const handleAddPromptVariable = useCallback( (variables: PromptVariable[]) => { setModelConfig( @@ -509,49 +263,17 @@ export const useConfiguration = (): ConfigurationViewModel => { [modelConfig, setModelConfig], ) - useEffect(() => { - void (async () => { - const configurationState = await loadConfigurationState({ - appId, - basePath, - currentRerankModel: currentRerankModel?.model, - currentRerankProvider: currentRerankProvider?.provider, - }) - - setCollectionList(configurationState.collectionList) - setMode(configurationState.mode) - doSetPromptMode(configurationState.promptMode) - setDataSets(configurationState.nextDataSets) - setIntroduction(configurationState.introduction) - setSuggestedQuestions(configurationState.suggestedQuestions) - setMoreLikeThisConfig(configurationState.moreLikeThisConfig) - setSuggestedQuestionsAfterAnswerConfig(configurationState.suggestedQuestionsAfterAnswerConfig) - setSpeechToTextConfig(configurationState.speechToTextConfig) - setTextToSpeechConfig(configurationState.textToSpeechConfig) - setCitationConfig(configurationState.citationConfig) - setModerationConfig(configurationState.moderationConfig || { enabled: false }) - setExternalDataToolsConfig(configurationState.externalDataToolsConfig) - setDatasetConfigs(configurationState.datasetConfigs) - - if (configurationState.promptMode === PromptMode.advanced) { - setChatPromptConfig(configurationState.chatPromptConfig) - setCompletionPromptConfig(configurationState.completionPromptConfig as never) - setCanReturnToSimpleMode(false) - } else { - setCanReturnToSimpleMode(configurationState.canReturnToSimpleMode) - } - - if (configurationState.annotationConfig) - setAnnotationConfig(configurationState.annotationConfig, true) - - if (configurationState.visionConfig) - handleSetVisionConfig(configurationState.visionConfig, true) - - syncToPublishedConfig(configurationState.publishedConfig as PublishConfig) - setPublishedConfig(configurationState.publishedConfig as PublishConfig) - setHasFetchedDetail(true) - })() - }, [appId]) + useConfigurationLoader({ + appId, + currentRerankModel: currentRerankModel?.model, + currentRerankProvider: currentRerankProvider?.provider, + setAnnotationConfig, + setCollectionList, + setHasFetchedDetail, + setMode, + setPublishedConfig, + syncToPublishedConfig, + }) const { promptEmpty, cannotPublish, contextVarEmpty } = useMemo( () => @@ -579,97 +301,42 @@ export const useConfiguration = (): ConfigurationViewModel => { ], ) - const onPublish = useCallback( - async (params?: AppPublisherPublishParams, features?: FeaturesData) => { - if (!appACLCapabilities.canReleaseAndVersion) return + const onPublish = useConfigurationPublish({ + appId, + canReleaseAndVersion: appACLCapabilities.canReleaseAndVersion, + chatPromptConfig, + completionParams: completionParamsState, + completionPromptConfig, + contextVar, + contextVarEmpty, + dataSets, + datasetConfigs, + externalDataToolsConfig, + hasSetBlockStatus, + isAdvancedMode, + isFunctionCall, + mode, + modelConfig, + promptEmpty, + promptMode, + resolvedModelModeType, + setCanReturnToSimpleMode, + setPublishedConfig, + syncToPublishedConfig, + t, + updateModelConfig, + }) - const modelAndParameter = - params && 'model' in params && 'provider' in params && 'parameters' in params - ? params - : undefined - - return createPublishHandler({ - appId, - chatPromptConfig, - citationConfig, - completionParamsState, - completionPromptConfig, - contextVar, - contextVarEmpty, - dataSets, - datasetConfigs, - externalDataToolsConfig, - hasSetBlockStatus, - introduction, - isAdvancedMode, - isFunctionCall, - mode, - modelConfig, - moreLikeThisConfig, - promptEmpty, - promptMode, - resolvedModelModeType, - setCanReturnToSimpleMode, - setPublishedConfig, - speechToTextConfig, - suggestedQuestionsAfterAnswerConfig, - t, - textToSpeechConfig, - })(updateModelConfig, modelAndParameter, features) - }, - [ - appACLCapabilities.canReleaseAndVersion, - appId, - chatPromptConfig, - citationConfig, - completionParamsState, - completionPromptConfig, - contextVar, - contextVarEmpty, - dataSets, - datasetConfigs, - externalDataToolsConfig, - hasSetBlockStatus, - introduction, - isAdvancedMode, - isFunctionCall, - mode, - modelConfig, - moreLikeThisConfig, - promptEmpty, - promptMode, - resolvedModelModeType, - setCanReturnToSimpleMode, - setPublishedConfig, - speechToTextConfig, - suggestedQuestionsAfterAnswerConfig, - t, - textToSpeechConfig, - updateModelConfig, - ], - ) - - const { debugWithMultipleModel, multipleModelConfigs, handleMultipleModelConfigsChange } = - useDebugWithSingleOrMultipleModel(appId) - - const handleDebugWithMultipleModelChange = useCallback(() => { - handleMultipleModelConfigsChange(true, [ - { - id: `${Date.now()}`, - model: modelConfig.model_id, - provider: modelConfig.provider, - parameters: completionParamsState, - }, - { id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} }, - ]) - setDetailSidebarMode('collapse') - }, [ - completionParamsState, + const { + debugWithMultipleModel, + enableMultipleModelDebug, handleMultipleModelConfigsChange, - modelConfig.model_id, - modelConfig.provider, - setDetailSidebarMode, - ]) + multipleModelConfigs, + } = useMultipleModelDebug({ + appId, + completionParams: completionParamsState, + modelConfig, + }) const onAgentSettingChange = useCallback( (config: ModelConfig['agentConfig']) => { @@ -682,94 +349,65 @@ export const useConfiguration = (): ConfigurationViewModel => { [modelConfig, setModelConfig], ) - const contextValue: DebugConfigurationValue = { - readonly: configurationReadonly, - canTestAndRun: appACLCapabilities.canTestAndRun, - appId, - isAPIKeySet, - isTrailFinished: false, - mode, - modelModeType: resolvedModelModeType, - promptMode, - isAdvancedMode, - isAgent, - isOpenAI: modelConfig.provider === 'langgenius/openai/openai', - isFunctionCall, - collectionList, - setPromptMode, - canReturnToSimpleMode, - setCanReturnToSimpleMode, - chatPromptConfig, - completionPromptConfig, - currentAdvancedPrompt, - setCurrentAdvancedPrompt, - conversationHistoriesRole: completionPromptConfig.conversation_histories_role, - showHistoryModal, - setConversationHistoriesRole, - hasSetBlockStatus, - conversationId, - introduction, - setIntroduction, - suggestedQuestions, - setSuggestedQuestions, - setConversationId, - controlClearChatMessage, - setControlClearChatMessage, - prevPromptConfig, - setPrevPromptConfig, - moreLikeThisConfig, - setMoreLikeThisConfig, - suggestedQuestionsAfterAnswerConfig, - setSuggestedQuestionsAfterAnswerConfig, - speechToTextConfig, - setSpeechToTextConfig, - textToSpeechConfig, - setTextToSpeechConfig, - citationConfig, - setCitationConfig, - annotationConfig, - setAnnotationConfig, - moderationConfig, - setModerationConfig, - externalDataToolsConfig, - setExternalDataToolsConfig, - formattingChanged, - setFormattingChanged, - inputs, - setInputs, - query, - setQuery, - completionParams: completionParamsState, - setCompletionParams, - modelConfig, - setModelConfig, - showSelectDataSet, - dataSets, - setDataSets, - datasetConfigs, - datasetConfigsRef, - setDatasetConfigs, - hasSetContextVar, - isShowVisionConfig, - visionConfig, - setVisionConfig: handleSetVisionConfig, - isAllowVideoUpload, - isShowDocumentConfig, - isShowAudioConfig, - rerankSettingModalOpen, - setRerankSettingModalOpen, - } + const contextValue = buildConfigurationContextValue({ + advancedPrompt: advancedPromptConfiguration, + dataset: datasetConfiguration, + feature: featureConfiguration, + model: modelConfiguration, + base: { + appId, + canReturnToSimpleMode, + canTestAndRun: appACLCapabilities.canTestAndRun, + collectionList, + controlClearChatMessage, + conversationId, + formattingChanged, + hasSetContextVar, + inputs, + isAdvancedMode, + isAgent, + isAllowVideoUpload, + isAPIKeySet, + isFunctionCall, + isOpenAI: modelConfig.provider === 'langgenius/openai/openai', + isShowAudioConfig, + isShowDocumentConfig, + isShowVisionConfig, + isTrailFinished: false, + mode, + modelModeType: resolvedModelModeType, + prevPromptConfig, + promptMode, + query, + readonly: configurationReadonly, + rerankSettingModalOpen, + setCanReturnToSimpleMode, + setControlClearChatMessage, + setConversationId, + setFormattingChanged, + setInputs, + setPrevPromptConfig, + setPromptMode: handlePromptModeChange, + setQuery, + setRerankSettingModalOpen, + showHistoryModal, + showSelectDataSet, + }, + }) return { appPublisherProps: { disabled: !appACLCapabilities.canReleaseAndVersion, publishDisabled: cannotPublish || !appACLCapabilities.canReleaseAndVersion, - publishedAt: (latestPublishedAt || 0) * 1000, + publishedAt: (serverLatestPublishedAt || 0) * 1000, debugWithMultipleModel, multipleModelConfigs, onPublish, - publishedConfig: publishedConfig as PublishConfig, - resetAppConfig: () => publishedConfig && syncToPublishedConfig(publishedConfig), + publishedConfig: publishedConfig as ConfigurationPublishConfig, + resetAppConfig: () => { + if (!publishedConfig) return + syncToPublishedConfig(publishedConfig) + }, }, contextValue, featuresData, @@ -791,7 +429,7 @@ export const useConfiguration = (): ConfigurationViewModel => { setSettingsDestination('provider') setShowUseGPT4Confirm(false) }, - onEnableMultipleModelDebug: handleDebugWithMultipleModelChange, + onEnableMultipleModelDebug: enableMultipleModelDebug, onFeaturesChange: handleFeaturesChange, onHideDebugPanel: hideDebugPanel, onModelChange: setModel, diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx new file mode 100644 index 00000000000..e1020fb06b6 --- /dev/null +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -0,0 +1,1851 @@ +import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppEnvironment, + EnvironmentDeployment, + EnvironmentDeploymentOperation, + GetWorkflowDeploymentOptionsResponse, + WorkflowVersion, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { QueryClient } from '@tanstack/react-query' +import type { ReactElement } from 'react' +import { + DeploymentOperationStatus, + DeploymentOperationType, + DeploymentStatus, + EnvironmentStatus, + EnvVarValueSource, + EnvVarValueType, + OperatorType, + PluginCategory, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { toast } from '@langgenius/dify-ui/toast' +import { act, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { consoleQuery } from '@/service/client' +import { + appWorkflowQueryOptions, + appWorkflowVersionsInfiniteQueryOptions, +} from '@/service/workflow-queries' +import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { AppACLPermission } from '@/utils/permission' +import AppDeploy from '..' +import { EnvironmentTable } from '../environment-table' +import { AppDeployStateBoundary, getEnvironmentDeploymentActions } from '../state' + +const APP_ID = 'app-1' +const ACTIVITY_AT = 1_784_941_200 +const VERSION_DESCRIPTION = + 'Fixed several critical bugs affecting data synchronization and optimized page loading speed. Enhanced system stability and user experience through backend improvements.' +const OPERATOR = { + display_name: 'Evan', + id: 'user-2', + type: OperatorType.OPERATOR_TYPE_ACCOUNT, +} + +function workflowVersion(name: string, id = name.toLowerCase()): WorkflowVersion { + return { + id, + marked_comment: name === 'Sprint-42' ? VERSION_DESCRIPTION : '', + marked_name: name, + version: `2026-07-30.${id}`, + } +} + +const VERSIONS = { + sprint42: workflowVersion('Sprint-42', 'sprint-42'), + version02: workflowVersion('Version-02', 'version-02'), + hotfix: workflowVersion('v0.9-hotfix', 'hotfix'), + beta: workflowVersion('v0.6-beta', 'beta'), + qa: workflowVersion('v0.3-beta', 'qa-version'), +} + +function publishedWorkflowVersion({ + comment = '', + id, + name, + publishedBy = 'Alice', +}: { + comment?: string + id: string + name: string + publishedBy?: string +}): WorkflowResponse { + return { + conversation_variables: [], + created_at: 1_710_000_100, + created_by: { + email: `${publishedBy.toLowerCase()}@example.com`, + id: `user-${publishedBy.toLowerCase()}`, + name: publishedBy, + }, + environment_variables: [], + features: {}, + graph: {}, + hash: `hash-${id}`, + id, + marked_comment: comment, + marked_name: name, + rag_pipeline_variables: [], + tool_published: false, + updated_at: 1_710_000_100, + version: `2026-07-30.${id}`, + } +} + +const PUBLISHED_WORKFLOW_VERSIONS = [ + publishedWorkflowVersion({ + comment: 'Latest production workflow', + id: 'workflow-version-7', + name: 'Release 7', + }), + publishedWorkflowVersion({ + comment: 'Previous production workflow', + id: 'workflow-version-6', + name: 'Release 6', + publishedBy: 'Carol', + }), + publishedWorkflowVersion({ + id: 'sprint-42', + name: 'Sprint-42', + publishedBy: 'Evan', + }), +] + +const SUCCESSFUL_WORKFLOW_DEPLOYMENT_PRECHECK = { + unsupported_nodes: [], +} + +const WORKFLOW_DEPLOYMENT_OPTIONS: GetWorkflowDeploymentOptionsResponse = { + credential_slots: [ + { + candidates: [ + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'enterprise', + display_name: 'Enterprise deployment key', + from_enterprise: true, + provider_id: 'moonshot', + }, + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'development', + display_name: 'Development key', + from_enterprise: false, + provider_id: 'moonshot', + }, + ], + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + last_deployed_credential_id: 'enterprise', + provider_id: 'moonshot', + }, + { + candidates: [ + { + category: PluginCategory.PLUGIN_CATEGORY_TOOL, + credential_id: 'github-oauth', + display_name: 'GitHub OAuth Key', + from_enterprise: false, + provider_id: 'github', + }, + ], + category: PluginCategory.PLUGIN_CATEGORY_TOOL, + last_deployed_credential_id: 'github-oauth', + provider_id: 'github', + }, + ], + environment_variable_slots: [ + { + configured_value: '2', + description: 'Server port', + has_configured_value: true, + has_last_deployed_value: true, + key: 'PORT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER, + }, + { + configured_value: 'sk-123************bc', + description: 'API credential', + has_configured_value: true, + has_last_deployed_value: true, + key: 'API_KEY', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET, + }, + { + description: '', + has_configured_value: false, + has_last_deployed_value: true, + key: 'name', + last_deployed_value: 'environment variable 01', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + }, + ], +} + +function deploymentOperation({ + id, + status = DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, + targetVersion, + type = DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY, +}: { + id: string + status?: EnvironmentDeploymentOperation['status'] + targetVersion?: WorkflowVersion + type?: EnvironmentDeploymentOperation['type'] +}): EnvironmentDeploymentOperation { + return { + activity_at: ACTIVITY_AT, + id, + operator: OPERATOR, + status, + target_version: targetVersion, + type, + } +} + +function environmentDeployment({ + access = { enable_api: true, enable_site: true }, + currentVersion, + id, + latestOperation, + name, + status, + versionsBehind, +}: { + access?: EnvironmentDeployment['access'] + currentVersion?: WorkflowVersion + id: string + latestOperation?: EnvironmentDeploymentOperation + name: string + status: NonNullable['status'] + versionsBehind?: number +}): EnvironmentDeployment { + return { + access, + deployment: { + current_version: currentVersion, + deployed_at: currentVersion ? ACTIVITY_AT : undefined, + deployed_by: currentVersion ? OPERATOR : undefined, + latest_operation: latestOperation, + status, + versions_behind: versionsBehind, + }, + environment: { + description: '', + display_name: name, + id, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + } +} + +const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ + environmentDeployment({ + id: 'staging', + latestOperation: deploymentOperation({ + id: '1', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + targetVersion: VERSIONS.sprint42, + }), + name: 'Staging', + status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + }), + environmentDeployment({ + currentVersion: VERSIONS.sprint42, + id: 'canary', + latestOperation: deploymentOperation({ + id: '2', + targetVersion: VERSIONS.sprint42, + }), + name: 'Canary', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 0, + }), + environmentDeployment({ + currentVersion: VERSIONS.version02, + id: 'pre-release', + latestOperation: deploymentOperation({ + id: '11', + targetVersion: VERSIONS.version02, + }), + name: 'Pre-release', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 1, + }), + environmentDeployment({ + currentVersion: VERSIONS.hotfix, + id: 'prod', + latestOperation: deploymentOperation({ + id: '12', + targetVersion: VERSIONS.hotfix, + }), + name: 'Prod', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 1, + }), + environmentDeployment({ + access: { enable_api: false, enable_site: false }, + currentVersion: VERSIONS.beta, + id: 'eu-prod', + latestOperation: deploymentOperation({ + id: '10', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED, + targetVersion: VERSIONS.sprint42, + }), + name: 'EU-Prod', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 2, + }), + environmentDeployment({ + access: { enable_api: false, enable_site: false }, + currentVersion: VERSIONS.qa, + id: 'qa', + latestOperation: deploymentOperation({ + id: '13', + targetVersion: VERSIONS.qa, + }), + name: 'QA', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 0, + }), + environmentDeployment({ + access: { enable_api: false, enable_site: false }, + currentVersion: VERSIONS.qa, + id: 'sandbox', + latestOperation: deploymentOperation({ + id: '14', + targetVersion: VERSIONS.qa, + }), + name: 'Sandbox', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 0, + }), + environmentDeployment({ + access: { enable_api: false, enable_site: false }, + id: 'preview', + latestOperation: deploymentOperation({ + id: '15', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED, + targetVersion: VERSIONS.sprint42, + }), + name: 'Preview', + status: DeploymentStatus.DEPLOYMENT_STATUS_FAILED, + }), +] + +const ACTION_MATRIX_CASES: Array<{ + actions: Array<{ + disabled: boolean + kind: ReturnType[number]['kind'] + }> + name: string + row: EnvironmentDeployment +}> = [ + { + actions: [ + { disabled: false, kind: 'deployLatest' }, + { disabled: false, kind: 'changeVersion' }, + ], + name: 'undeployed', + row: { + access: { enable_api: false, enable_site: false }, + environment: { + description: '', + display_name: 'Undeployed', + id: 'undeployed', + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + }, + }, + }, + { + actions: [ + { disabled: false, kind: 'changeVersion' }, + { disabled: false, kind: 'redeploy' }, + { disabled: false, kind: 'undeploy' }, + ], + name: 'running the latest version', + row: environmentDeployment({ + currentVersion: VERSIONS.sprint42, + id: 'latest', + name: 'Latest', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 0, + }), + }, + { + actions: [ + { disabled: false, kind: 'deployLatest' }, + { disabled: false, kind: 'changeVersion' }, + { disabled: false, kind: 'redeploy' }, + { disabled: false, kind: 'undeploy' }, + ], + name: 'running behind the latest version', + row: environmentDeployment({ + currentVersion: VERSIONS.version02, + id: 'behind', + name: 'Behind', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 1, + }), + }, + { + actions: [ + { disabled: true, kind: 'changeVersion' }, + { disabled: true, kind: 'redeploy' }, + { disabled: true, kind: 'undeploy' }, + ], + name: 'deploying', + row: environmentDeployment({ + id: 'deploying', + name: 'Deploying', + status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + }), + }, + { + actions: [ + { disabled: true, kind: 'changeVersion' }, + { disabled: true, kind: 'redeploy' }, + { disabled: true, kind: 'undeploy' }, + ], + name: 'undeploying', + row: environmentDeployment({ + currentVersion: VERSIONS.qa, + id: 'undeploying', + name: 'Undeploying', + status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + }), + }, + { + actions: [ + { disabled: false, kind: 'retry' }, + { disabled: false, kind: 'changeVersion' }, + ], + name: 'failed without a previous version', + row: environmentDeployment({ + id: 'failed', + latestOperation: deploymentOperation({ + id: 'failed', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED, + targetVersion: VERSIONS.sprint42, + }), + name: 'Failed', + status: DeploymentStatus.DEPLOYMENT_STATUS_FAILED, + }), + }, + { + actions: [ + { disabled: false, kind: 'retry' }, + { disabled: false, kind: 'changeVersion' }, + { disabled: false, kind: 'undeploy' }, + ], + name: 'running after the latest deployment failed', + row: environmentDeployment({ + currentVersion: VERSIONS.beta, + id: 'running-failed', + latestOperation: deploymentOperation({ + id: 'running-failed', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED, + targetVersion: VERSIONS.sprint42, + }), + name: 'Running failed', + status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + versionsBehind: 2, + }), + }, + { + actions: [ + { disabled: false, kind: 'redeploy' }, + { disabled: false, kind: 'undeploy' }, + ], + name: 'invalid', + row: environmentDeployment({ + currentVersion: VERSIONS.qa, + id: 'invalid', + name: 'Invalid', + status: DeploymentStatus.DEPLOYMENT_STATUS_INVALID, + }), + }, + { + actions: [ + { disabled: false, kind: 'redeploy' }, + { disabled: false, kind: 'undeploy' }, + ], + name: 'unknown', + row: environmentDeployment({ + currentVersion: VERSIONS.qa, + id: 'unknown', + name: 'Unknown', + status: DeploymentStatus.DEPLOYMENT_STATUS_UNSPECIFIED, + }), + }, +] + +const APP_ENVIRONMENTS: AppEnvironment[] = [ + ...APP_ENVIRONMENT_DEPLOYMENTS.map((row) => ({ + description: row.environment.description, + display_name: row.environment.display_name, + id: row.environment.id, + in_use: true, + status: row.environment.status, + })), + ...['Testing', 'Dev', 'Demo', 'US-Prod'].map((name) => ({ + description: '', + display_name: name, + id: name.toLowerCase(), + in_use: false, + status: EnvironmentStatus.ENVIRONMENT_STATUS_READY, + })), +] +const appEnvironmentsQueryOptions = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: APP_ID, + }, + }, + }) +const appEnvironmentDeploymentsQueryOptions = + consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { + params: { + app_id: APP_ID, + }, + }, + }) +const latestPublishedWorkflowQuery = appWorkflowQueryOptions(APP_ID) +const appWorkflowVersionsQuery = appWorkflowVersionsInfiniteQueryOptions(APP_ID) + +function workflowDeploymentPrecheckQueryOptions(workflowId: string) { + return consoleQuery.enterprise.appDeploy.deploymentService.precheckWorkflowDeployment.queryOptions( + { + input: { + params: { + app_id: APP_ID, + workflow_id: workflowId, + }, + }, + retry: false, + }, + ) +} + +function workflowDeploymentOptionsQueryOptions(workflowId: string, environmentId: string) { + return consoleQuery.enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions.queryOptions( + { + input: { + params: { + app_id: APP_ID, + environment_id: environmentId, + workflow_id: workflowId, + }, + }, + retry: false, + }, + ) +} + +function seedWorkflowDeploymentConfigurationQueries(queryClient: QueryClient) { + const workflowIds = new Set([ + ...PUBLISHED_WORKFLOW_VERSIONS.map((workflow) => workflow.id), + ...Object.values(VERSIONS).map((version) => version.id), + ]) + + workflowIds.forEach((workflowId) => { + const precheckQuery = workflowDeploymentPrecheckQueryOptions(workflowId) + queryClient.setQueryDefaults(precheckQuery.queryKey, { staleTime: Infinity }) + queryClient.setQueryData(precheckQuery.queryKey, SUCCESSFUL_WORKFLOW_DEPLOYMENT_PRECHECK) + + APP_ENVIRONMENTS.forEach((environment) => { + const deploymentOptionsQuery = workflowDeploymentOptionsQueryOptions( + workflowId, + environment.id, + ) + queryClient.setQueryDefaults(deploymentOptionsQuery.queryKey, { staleTime: Infinity }) + queryClient.setQueryData(deploymentOptionsQuery.queryKey, WORKFLOW_DEPLOYMENT_OPTIONS) + }) + }) +} + +const mockBuiltInEnvironment = vi.hoisted(() => ({ + appDetail: { + enable_api: false, + enable_site: true, + id: 'app-1', + maintainer: 'user-2', + mode: 'workflow', + }, + mcpServerDetail: { + status: 'active', + }, + publishedWorkflow: { + conversation_variables: [], + created_at: 1_710_000_100, + created_by: { + email: 'alice@example.com', + id: 'user-2', + name: 'Alice', + }, + environment_variables: [], + features: {}, + graph: { + nodes: [{ data: { type: 'start' }, id: 'start' }], + }, + hash: 'hash-workflow-version-7', + id: 'workflow-version-7', + marked_comment: 'Production-ready workflow', + marked_name: 'Release 7', + rag_pipeline_variables: [], + tool_published: false, + updated_at: 1_710_000_200, + updated_by: { + email: 'bob@example.com', + id: 'user-3', + name: 'Bob', + } as { email: string; id: string; name: string } | null, + version: '2026-07-30.1', + }, +})) + +function render( + ui: ReactElement, + { + appEnvironments = APP_ENVIRONMENTS, + environmentDeployments = APP_ENVIRONMENT_DEPLOYMENTS, + publishedWorkflowVersions = PUBLISHED_WORKFLOW_VERSIONS, + }: { + appEnvironments?: AppEnvironment[] + environmentDeployments?: EnvironmentDeployment[] + publishedWorkflowVersions?: WorkflowResponse[] + } = {}, +) { + const queryClient = createConsoleQueryClient() + queryClient.setQueryDefaults(appEnvironmentsQueryOptions.queryKey, { + staleTime: Infinity, + }) + queryClient.setQueryDefaults(appEnvironmentDeploymentsQueryOptions.queryKey, { + staleTime: Infinity, + }) + queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, { + data: appEnvironments, + }) + queryClient.setQueryData(appEnvironmentDeploymentsQueryOptions.queryKey, { + environment_deployments: environmentDeployments, + }) + queryClient.setQueryData( + latestPublishedWorkflowQuery.queryKey, + mockBuiltInEnvironment.publishedWorkflow, + ) + queryClient.setQueryData(appWorkflowVersionsQuery.queryKey, { + pageParams: [1], + pages: [ + { + has_more: false, + items: publishedWorkflowVersions, + limit: 10, + page: 1, + }, + ], + }) + seedWorkflowDeploymentConfigurationQueries(queryClient) + + return renderWithConsoleQuery(ui, { queryClient }) +} + +let appPermissionKeys: string[] = [AppACLPermission.Deploy] +let appDetailAvailable = true +const mockConsoleState = vi.hoisted(() => ({ + workspacePermissionKeys: [] as string[], +})) +const mockDocLink = vi.hoisted(() => vi.fn((path: string) => `https://docs.example.com${path}`)) + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return createReactI18nextMock({ + 'common.operation.cancel': 'Cancel', + 'deployments.deployTab.confirmUndeploy': 'Undeploy', + 'deployments.studio.undeployConfirmDesc': + "The app will stop running in this environment, and all of its access points will become unavailable. The deployed version won't be deleted.", + 'deployments.studio.undeployConfirmTitle': 'Undeploy {{versionName}} from {{envName}}', + 'deployments.deployTab.undeployConfirmWarning': + 'After confirmation, this environment will enter the undeploying state and actions will be temporarily disabled.', + 'deployments.status.RUNTIME_INSTANCE_STATUS_READY': 'Running', + 'deployments.studio.activity.deploySucceeded': 'Deploy {{target}} succeeded', + 'deployments.studio.activity.meta': '{{name}} · {{time}}', + 'deployments.studio.versionValue': 'Version value', + 'deployments.studio.environmentsInUse': '{{used}} of {{total}} environments in use', + 'deployments.studio.environmentVariablesDescription': + "Use the value from the version you're deploying, keep the last deployed value, or enter a custom one.", + 'deployments.studio.updatedAtBy': 'Updated at {{time}} by {{name}}', + 'workflow.common.publishedBy': 'Published {{time}} by {{author}}', + }) +}) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: (time: number) => + time === ACTIVITY_AT * 1000 ? 'activity time' : '17 days ago', + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: (timestamp: number) => + timestamp === mockBuiltInEnvironment.publishedWorkflow.updated_at + ? '03-09 16:03' + : '03-09 16:01', + }), +})) + +vi.mock('@/service/use-tools', () => ({ + useAllBuiltInTools: () => ({ data: [] }), + useAllCustomTools: () => ({ data: [] }), + useAllMCPTools: () => ({ + data: [ + { + icon: '/notion-mcp.svg', + id: 'notion', + name: 'Notion', + plugin_id: 'langgenius/notion', + }, + ], + }), + useAllWorkflowTools: () => ({ data: [] }), + useMCPServerDetail: () => ({ + data: mockBuiltInEnvironment.mcpServerDetail, + }), +})) + +vi.mock('@/app/components/app/store', () => ({ + useStore: (selector: (state: Record) => unknown) => { + const appDetail = appDetailAvailable + ? { + ...mockBuiltInEnvironment.appDetail, + permission_keys: appPermissionKeys, + } + : undefined + + return selector({ appDetail }) + }, +})) + +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + return createPermissionStateModuleMock(() => mockConsoleState) +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => mockDocLink, +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + }, +})) + +describe('AppDeploy', () => { + beforeEach(() => { + vi.clearAllMocks() + appPermissionKeys = [AppACLPermission.Deploy] + appDetailAvailable = true + mockBuiltInEnvironment.appDetail.enable_api = false + mockBuiltInEnvironment.appDetail.enable_site = true + mockBuiltInEnvironment.mcpServerDetail.status = 'active' + mockBuiltInEnvironment.publishedWorkflow.graph.nodes = [ + { data: { type: 'start' }, id: 'start' }, + ] + mockBuiltInEnvironment.publishedWorkflow.updated_by = { + email: 'bob@example.com', + id: 'user-3', + name: 'Bob', + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders the built-in environment and contract deployment list', () => { + render() + + expect( + screen.getByRole('heading', { name: 'deployments.studio.builtInTitle' }), + ).toBeInTheDocument() + expect( + screen.getByRole('heading', { name: 'deployments.studio.environments' }), + ).toBeInTheDocument() + expect(screen.getByRole('cell', { name: /Staging/ })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: /Canary/ })).toBeInTheDocument() + expect(screen.getByRole('cell', { name: /Preview/ })).toBeInTheDocument() + expect(screen.getAllByRole('row')).toHaveLength(9) + expect(screen.getByText('8 of 12 environments in use')).toBeInTheDocument() + }) + + it('links the deploy header to the enterprise deployment documentation', () => { + render() + + expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute( + 'href', + 'https://docs.example.com/use/deploy/overview', + ) + }) + + it.each(ACTION_MATRIX_CASES)( + 'provides the designed row actions when $name', + ({ actions, row }) => { + expect(getEnvironmentDeploymentActions(row)).toEqual(actions) + }, + ) + + it('renders version, status, activity, and access from the deployment contract', () => { + render() + + const canaryRow = within(screen.getByRole('row', { name: /Canary/ })) + expect(canaryRow.getByRole('button', { name: 'Sprint-42' })).toBeInTheDocument() + expect(canaryRow.getByText('Running')).toBeInTheDocument() + expect(canaryRow.getByText('Deploy Sprint-42 succeeded')).toBeInTheDocument() + expect(canaryRow.getByText('Evan · activity time')).toBeInTheDocument() + expect( + canaryRow.getByRole('link', { + name: 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService', + }), + ).toHaveAttribute('href', '/app/app-1/access-point?environment=canary&accessPoint=webApp') + expect( + canaryRow.getByRole('link', { + name: 'agentV2.agentDetail.access.serviceApi.title · agentV2.agentDetail.access.status.inService', + }), + ).toHaveAttribute('href', '/app/app-1/access-point?environment=canary&accessPoint=serviceApi') + }) + + it('renders the built-in version, access points, and publisher from live app data', () => { + render() + + const builtInEnvironment = within( + screen.getByRole('region', { name: 'deployments.studio.builtInTitle' }), + ) + + expect(builtInEnvironment.getByRole('button', { name: 'Release 7' })).toBeInTheDocument() + expect(builtInEnvironment.queryByRole('button', { name: 'Sprint-42' })).not.toBeInTheDocument() + expect( + builtInEnvironment.getByRole('link', { + name: 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.inService', + }), + ).toHaveAttribute('href', '/app/app-1/access-point?environment=built-in&accessPoint=webApp') + expect( + builtInEnvironment.getByRole('button', { + name: 'agentV2.agentDetail.access.serviceApi.title · agentV2.agentDetail.access.status.outOfService', + }), + ).toBeDisabled() + expect( + builtInEnvironment.getByRole('link', { + name: 'MCP · agentV2.agentDetail.access.status.inService', + }), + ).toHaveAttribute('href', '/app/app-1/access-point?environment=built-in&accessPoint=mcp') + expect( + builtInEnvironment.getByRole('button', { + name: 'common.settings.trigger · agentV2.agentDetail.access.status.outOfService', + }), + ).toBeDisabled() + expect(builtInEnvironment.getByText('Updated at 03-09 16:03 by Bob')).toBeInTheDocument() + }) + + it('shows only the trigger access point as active for a published trigger workflow', () => { + mockBuiltInEnvironment.appDetail.enable_api = true + mockBuiltInEnvironment.publishedWorkflow.graph.nodes = [ + { data: { type: 'trigger-webhook' }, id: 'trigger' }, + ] + + render() + + const builtInEnvironment = within( + screen.getByRole('region', { name: 'deployments.studio.builtInTitle' }), + ) + + expect( + builtInEnvironment.getByRole('button', { + name: 'agentV2.agentDetail.access.webApp.title · agentV2.agentDetail.access.status.outOfService', + }), + ).toBeDisabled() + expect( + builtInEnvironment.getByRole('button', { + name: 'agentV2.agentDetail.access.serviceApi.title · agentV2.agentDetail.access.status.outOfService', + }), + ).toBeDisabled() + expect( + builtInEnvironment.getByRole('button', { + name: 'MCP · agentV2.agentDetail.access.status.outOfService', + }), + ).toBeDisabled() + expect( + builtInEnvironment.getByRole('link', { + name: 'common.settings.trigger · agentV2.agentDetail.access.status.inService', + }), + ).toHaveAttribute('href', '/app/app-1/access-point?environment=built-in&accessPoint=trigger') + }) + + it('uses the publisher when the published workflow has no later updater', () => { + mockBuiltInEnvironment.publishedWorkflow.updated_by = null + + render() + + const builtInEnvironment = within( + screen.getByRole('region', { name: 'deployments.studio.builtInTitle' }), + ) + expect(builtInEnvironment.getByText('Updated at 03-09 16:03 by Alice')).toBeInTheDocument() + }) + + it('shows loading while the app detail is unavailable', () => { + appDetailAvailable = false + + render() + + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'common.appMenus.deploy' }), + ).not.toBeInTheDocument() + }) + + it('does not render deployment controls without app deploy ACL permission', () => { + appPermissionKeys = [] + + render() + + expect( + screen.queryByRole('heading', { name: 'common.appMenus.deploy' }), + ).not.toBeInTheDocument() + expect( + screen.queryByRole('heading', { name: 'deployments.studio.environments' }), + ).not.toBeInTheDocument() + }) + + it('opens the deploy menu with undeployed environments', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + + expect(screen.getByText('deployments.card.notDeployed')).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /Testing/ })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /US-Prod/ })).toBeInTheDocument() + expect(screen.queryByRole('menuitem', { name: /Canary/ })).not.toBeInTheDocument() + }) + + it('shows an empty hint when every environment is already deployed', async () => { + const user = userEvent.setup() + render(, { + appEnvironments: APP_ENVIRONMENTS.map((environment) => ({ + ...environment, + in_use: true, + })), + }) + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + + expect(screen.getByRole('status')).toHaveTextContent( + 'deployments.deployDrawer.noNewEnvironmentAvailable', + ) + expect(screen.queryByRole('menuitem')).not.toBeInTheDocument() + }) + + it('opens the selected environment version picker from the deploy menu', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(await screen.findByRole('menuitem', { name: /Dev/ })) + + const dialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + expect(within(dialog).getByText('deployments.studio.chooseVersionToDeploy')).toBeInTheDocument() + expect(within(dialog).getByRole('button', { name: /Release 7/ })).toBeEnabled() + expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeEnabled() + expect(within(dialog).queryByText('deployments.studio.current')).not.toBeInTheDocument() + expect(within(dialog).getByText('Latest production workflow')).toBeInTheDocument() + expect(within(dialog).getByText('Published 17 days ago by Alice')).toBeInTheDocument() + }) + + it('links to the workflow publisher when the version picker has no published versions', async () => { + const user = userEvent.setup() + render(, { publishedWorkflowVersions: [] }) + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(await screen.findByRole('menuitem', { name: /Dev/ })) + + const dialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + expect( + within(dialog).getByText('deployments.studio.accessPoint.noPublishedTitle'), + ).toBeInTheDocument() + expect( + within(dialog).getByRole('link', { + name: 'deployments.studio.accessPoint.goToPublish', + }), + ).toHaveAttribute('href', '/app/app-1/workflow') + }) + + it('continues from version selection to deployment configuration', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(screen.getByRole('menuitem', { name: /Dev/ })) + const versionDialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ })) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + expect(within(configurationDialog).getByText('Release 6')).toBeInTheDocument() + expect(within(configurationDialog).getByText('Dev')).toBeInTheDocument() + expect( + within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }), + ).toHaveTextContent('Enterprise deployment key') + expect( + within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), + ).toBeEnabled() + expect( + within(configurationDialog).getByText( + "Use the value from the version you're deploying, keep the last deployed value, or enter a custom one.", + ), + ).toBeInTheDocument() + + const portSource = within(configurationDialog).getByRole('combobox', { name: /PORT/ }) + expect(portSource).toHaveTextContent('Version value') + const portInput = within(configurationDialog).getByRole('textbox', { name: 'PORT' }) + expect(portInput).toBeDisabled() + expect(portInput).toHaveAttribute('placeholder', '2') + expect(within(configurationDialog).getByRole('textbox', { name: 'API_KEY' })).toHaveAttribute( + 'placeholder', + 'sk-123************bc', + ) + expect(within(configurationDialog).getByRole('textbox', { name: 'name' })).toHaveAttribute( + 'placeholder', + 'environment variable 01', + ) + + await user.click(portSource) + expect( + await screen.findByRole('option', { + name: 'deployments.deployDrawer.envVarSource.literal', + }), + ).toBeInTheDocument() + expect(screen.getByRole('option', { name: 'Version value' })).toBeInTheDocument() + expect( + screen.getByRole('option', { + name: 'deployments.deployDrawer.envVarSource.lastDeployment', + }), + ).toBeInTheDocument() + await user.click( + screen.getByRole('option', { + name: 'deployments.deployDrawer.envVarSource.literal', + }), + ) + + const customPortInput = within(configurationDialog).getByRole('spinbutton', { name: 'PORT' }) + expect(customPortInput).toBeEnabled() + await user.clear(customPortInput) + await user.type(customPortInput, '3000') + expect(customPortInput).toHaveValue(3000) + + await user.click( + within(configurationDialog).getByRole('button', { name: 'common.operation.back' }), + ) + expect( + await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }), + ).toBeInTheDocument() + }) + + it('deploys the selected workflow configuration and refreshes the deployment list', async () => { + const user = userEvent.setup() + const requests: Request[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request.clone()) + + if (request.url.includes('/deployment:deploy')) { + return new Response( + JSON.stringify({ + operation: { + id: 'operation-dev', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY, + }, + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + if (request.url.includes('/workflows/environment-deployments')) { + return new Response( + JSON.stringify({ + environment_deployments: [ + ...APP_ENVIRONMENT_DEPLOYMENTS, + environmentDeployment({ + id: 'dev', + latestOperation: deploymentOperation({ + id: 'operation-dev', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + targetVersion: workflowVersion('Release 6', 'workflow-version-6'), + }), + name: 'Dev', + status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + }), + ], + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + if ( + new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments') + ) { + return new Response( + JSON.stringify({ + data: APP_ENVIRONMENTS.map((environment) => + environment.id === 'dev' ? { ...environment, in_use: true } : environment, + ), + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + render() + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(screen.getByRole('menuitem', { name: /Dev/ })) + const versionDialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ })) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + await user.click(within(configurationDialog).getByRole('combobox', { name: 'Moonshot' })) + await user.click(await screen.findByRole('option', { name: 'Development key' })) + await user.click(within(configurationDialog).getByRole('combobox', { name: /PORT/ })) + await user.click( + await screen.findByRole('option', { + name: 'deployments.deployDrawer.envVarSource.literal', + }), + ) + await user.type(within(configurationDialog).getByRole('spinbutton', { name: 'PORT' }), '3000') + await user.click( + within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), + ) + + expect( + await screen.findByRole('row', { + name: /Dev/, + }), + ).toBeInTheDocument() + expect(screen.getByText('9 of 12 environments in use')).toBeInTheDocument() + expect( + screen.queryByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }), + ).not.toBeInTheDocument() + + const deployRequest = requests.find((request) => request.url.includes('/deployment:deploy')) + if (!deployRequest) throw new Error('Expected the workflow deployment request.') + + expect(deployRequest.method).toBe('POST') + expect(new URL(deployRequest.url).pathname).toBe( + '/console/api/enterprise/app-deploy/apps/app-1/workflows/workflow-version-6/environments/dev/deployment:deploy', + ) + expect(await deployRequest.json()).toEqual({ + credentials: [ + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'development', + provider_id: 'moonshot', + }, + { + category: PluginCategory.PLUGIN_CATEGORY_TOOL, + credential_id: 'github-oauth', + provider_id: 'github', + }, + ], + environment_variables: [ + { + key: 'PORT', + value: '3000', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + { + key: 'API_KEY', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED, + }, + { + key: 'name', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED, + }, + ], + }) + expect( + requests.filter((request) => request.url.includes('/workflows/environment-deployments')), + ).toHaveLength(1) + expect( + requests.filter((request) => + new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments'), + ), + ).toHaveLength(1) + }) + + it('undeploys the current workflow version and refreshes the environment data', async () => { + const user = userEvent.setup() + const requests: Request[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request.clone()) + + if (request.url.includes('/deployment:undeploy')) { + return new Response( + JSON.stringify({ + operation: { + id: 'operation-canary-undeploy', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY, + }, + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + if (request.url.includes('/workflows/environment-deployments')) { + return new Response( + JSON.stringify({ + environment_deployments: APP_ENVIRONMENT_DEPLOYMENTS.map((deployment) => + deployment.environment.id === 'canary' + ? environmentDeployment({ + currentVersion: VERSIONS.sprint42, + id: 'canary', + latestOperation: deploymentOperation({ + id: 'operation-canary-undeploy', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY, + }), + name: 'Canary', + status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + }) + : deployment, + ), + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + if ( + new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments') + ) { + return new Response( + JSON.stringify({ + data: APP_ENVIRONMENTS.map((environment) => + environment.id === 'canary' ? { ...environment, in_use: false } : environment, + ), + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + render() + + const canaryRow = screen.getByRole('row', { name: /Canary/ }) + await user.click( + within(canaryRow).getByRole('button', { + name: 'Canary · deployments.deployTab.moreActions', + }), + ) + await user.click( + within(screen.getByRole('menu')).getByRole('menuitem', { + name: 'deployments.deployTab.undeploy', + }), + ) + const dialog = await screen.findByRole('alertdialog', { + name: 'Undeploy Sprint-42 from Canary', + }) + await user.click(within(dialog).getByRole('button', { name: 'Undeploy' })) + + expect(await screen.findByText('7 of 12 environments in use')).toBeInTheDocument() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + + const undeployRequest = requests.find((request) => request.url.includes('/deployment:undeploy')) + if (!undeployRequest) throw new Error('Expected the workflow undeployment request.') + + expect(undeployRequest.method).toBe('POST') + expect(new URL(undeployRequest.url).pathname).toBe( + '/console/api/enterprise/app-deploy/apps/app-1/workflows/sprint-42/environments/canary/deployment:undeploy', + ) + expect( + requests.filter((request) => request.url.includes('/workflows/environment-deployments')), + ).toHaveLength(1) + expect( + requests.filter((request) => + new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments'), + ), + ).toHaveLength(1) + }) + + it('shows deploy API failures through the fetch toast without an inline configuration error', async () => { + const user = userEvent.setup() + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + + if (request.url.includes('/deployment:deploy')) { + return new Response(JSON.stringify({ message: 'Deployment service unavailable' }), { + headers: { 'Content-Type': 'application/json' }, + status: 502, + }) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + render() + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(screen.getByRole('menuitem', { name: /Dev/ })) + const versionDialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ })) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + await user.click( + within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), + ) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Deployment service unavailable') + }) + expect(configurationDialog).toBeInTheDocument() + expect(within(configurationDialog).queryByRole('alert')).not.toBeInTheDocument() + }) + + it('refreshes all environments after deployment polling finishes', async () => { + const requests: Request[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push(request.clone()) + + if ( + new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments') + ) { + return new Response( + JSON.stringify({ + data: APP_ENVIRONMENTS.map((environment) => + environment.id === 'canary' ? { ...environment, in_use: false } : environment, + ), + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + const view = render(, { + environmentDeployments: [ + environmentDeployment({ + currentVersion: VERSIONS.sprint42, + id: 'canary', + latestOperation: deploymentOperation({ + id: 'operation-canary-undeploy', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY, + }), + name: 'Canary', + status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + }), + ], + }) + expect(screen.getByText('8 of 12 environments in use')).toBeInTheDocument() + + act(() => { + view.queryClient.setQueryData(appEnvironmentDeploymentsQueryOptions.queryKey, { + environment_deployments: [], + }) + }) + + expect(await screen.findByText('7 of 12 environments in use')).toBeInTheDocument() + expect( + requests.filter((request) => + new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments'), + ), + ).toHaveLength(1) + }) + + it('blocks deployment when the selected workflow fails precheck', async () => { + const user = userEvent.setup() + const view = render() + const precheckQuery = workflowDeploymentPrecheckQueryOptions('workflow-version-6') + const deploymentOptionsQuery = workflowDeploymentOptionsQueryOptions( + 'workflow-version-6', + 'dev', + ) + view.queryClient.setQueryData(precheckQuery.queryKey, { + unsupported_nodes: [ + { + id: 'node-1', + title: 'Request approval', + type: 'human-input', + }, + { + id: 'node-2', + provider: { + plugin_id: 'langgenius/notion', + provider_id: 'notion', + provider_name: 'Notion', + provider_type: 'mcp', + }, + title: 'Notion MCP', + type: 'tool', + }, + ], + }) + view.queryClient.removeQueries({ + exact: true, + queryKey: deploymentOptionsQuery.queryKey, + }) + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(screen.getByRole('menuitem', { name: /Dev/ })) + const versionDialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ })) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + const precheckAlert = await within(configurationDialog).findByRole('alert') + expect(precheckAlert).toHaveTextContent('Request approval') + expect(precheckAlert).toHaveTextContent('Notion MCP') + expect(precheckAlert).not.toHaveTextContent('node-1') + const notionNode = within(precheckAlert).getByText('Notion MCP').closest('li') + expect(notionNode?.querySelector('[style]')?.style.backgroundImage).toContain( + '/notion-mcp.svg', + ) + expect( + within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), + ).toBeDisabled() + expect(within(configurationDialog).queryByRole('combobox', { name: 'Moonshot' })).toBeNull() + expect(view.queryClient.getQueryState(deploymentOptionsQuery.queryKey)?.fetchStatus).not.toBe( + 'fetching', + ) + }) + + it('opens the latest version configuration and allows choosing another version', async () => { + const user = userEvent.setup() + render() + + const preReleaseRow = screen.getByRole('row', { name: /Pre-release/ }) + await user.click( + within(preReleaseRow).getByRole('button', { + name: 'deployments.studio.deployLatest', + }), + ) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + expect(within(configurationDialog).getByText('Release 7')).toBeInTheDocument() + expect(within(configurationDialog).getByText('Pre-release')).toBeInTheDocument() + + await user.click( + within(configurationDialog).getByRole('button', { name: 'common.operation.back' }), + ) + + expect( + await screen.findByRole('dialog', { + name: 'deployments.studio.changeVersion · Pre-release', + }), + ).toBeInTheDocument() + }) + + it('opens the failed version configuration for retry without a version-selection step', async () => { + const user = userEvent.setup() + render() + + const previewRow = screen.getByRole('row', { name: /Preview/ }) + await user.click( + within(previewRow).getByRole('button', { + name: 'deployments.studio.retryVersion:{"version":"Sprint-42"}', + }), + ) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + expect(within(configurationDialog).getByText('Sprint-42')).toBeInTheDocument() + expect(within(configurationDialog).getByText('Preview')).toBeInTheDocument() + expect( + within(configurationDialog).queryByRole('button', { name: 'common.operation.back' }), + ).not.toBeInTheDocument() + }) + + it('opens redeploy configuration without a version-selection step', async () => { + const user = userEvent.setup() + render() + + const qaRow = within(screen.getByRole('row', { name: /QA/ })) + await user.click( + qaRow.getByRole('button', { + name: 'QA · deployments.deployTab.moreActions', + }), + ) + await user.click( + within(await screen.findByRole('menu')).getByRole('menuitem', { + name: 'deployments.deployTab.redeploy', + }), + ) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + expect(within(configurationDialog).getByText('v0.3-beta')).toBeInTheDocument() + expect(within(configurationDialog).getByText('QA')).toBeInTheDocument() + expect( + within(configurationDialog).queryByRole('button', { name: 'common.operation.back' }), + ).not.toBeInTheDocument() + }) + + it('opens change version for the selected deployed environment', async () => { + const user = userEvent.setup() + render() + + const canaryRow = within(screen.getByRole('row', { name: /Canary/ })) + await user.click( + canaryRow.getByRole('button', { + name: 'deployments.studio.changeVersion', + }), + ) + + const dialog = await screen.findByRole('dialog', { + name: 'deployments.studio.changeVersion · Canary', + }) + expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeDisabled() + expect(within(dialog).getByText('deployments.studio.current')).toBeInTheDocument() + }) + + it('shows the empty deployment state when there are no environments in use', async () => { + const user = userEvent.setup() + render( + + + , + { + appEnvironments: APP_ENVIRONMENTS.map((environment) => ({ + ...environment, + in_use: false, + })), + environmentDeployments: [], + }, + ) + + expect(screen.getByText('deployments.list.emptyTitle')).toBeInTheDocument() + expect(screen.getByText('deployments.studio.emptyDescription')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument() + + const deployButtons = screen.getAllByRole('button', { + name: 'common.appMenus.deploy', + }) + expect(deployButtons).toHaveLength(2) + await user.click(deployButtons[1]!) + + expect(screen.getByText('deployments.card.notDeployed')).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /Testing/ })).toBeInTheDocument() + }) + + it('shows loading and retries after the environment deployment list fails', async () => { + const user = userEvent.setup() + const deploymentRequests: Request[] = [] + let resolveInitialRequest: ((response: Response) => void) | undefined + vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if ( + !new URL(request.url).pathname.endsWith( + '/enterprise/app-deploy/apps/app-1/workflows/environment-deployments', + ) + ) + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + + deploymentRequests.push(request.clone()) + if (deploymentRequests.length === 1) { + return new Promise((resolve) => { + resolveInitialRequest = resolve + }) + } + + return Promise.resolve( + new Response( + JSON.stringify({ + environment_deployments: APP_ENVIRONMENT_DEPLOYMENTS, + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ), + ) + }) + const queryClient = createConsoleQueryClient() + queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, { + data: APP_ENVIRONMENTS, + }) + + renderWithConsoleQuery( + + + , + { queryClient }, + ) + + expect(screen.getByRole('status', { name: /loading/ })).toBeInTheDocument() + expect(screen.queryByRole('table')).not.toBeInTheDocument() + await waitFor(() => { + expect(deploymentRequests).toHaveLength(1) + }) + + resolveInitialRequest?.( + new Response(JSON.stringify({ message: 'Failed to load deployments' }), { + headers: { 'Content-Type': 'application/json' }, + status: 500, + }), + ) + + const retryButton = await screen.findByRole('button', { + name: 'common.operation.retry', + }) + expect(screen.getByRole('heading', { name: 'common.errorBoundary.title' })).toBeInTheDocument() + expect(screen.getByText('deployments.common.loadFailed')).toBeInTheDocument() + expect(screen.queryByText('deployments.list.emptyTitle')).not.toBeInTheDocument() + expect(screen.queryByRole('table')).not.toBeInTheDocument() + + await user.click(retryButton) + + expect(await screen.findByRole('cell', { name: /Staging/ })).toBeInTheDocument() + expect(deploymentRequests).toHaveLength(2) + }) + + it('shows the environment actions from the design menu', async () => { + const user = userEvent.setup() + render() + + const canaryRow = screen.getByRole('row', { name: /Canary/ }) + await user.click( + within(canaryRow).getByRole('button', { + name: 'Canary · deployments.deployTab.moreActions', + }), + ) + + const menu = await screen.findByRole('menu', { + name: 'Canary · deployments.deployTab.moreActions', + }) + expect( + within(menu).getByRole('menuitem', { name: 'deployments.deployTab.redeploy' }), + ).toBeInTheDocument() + expect( + within(menu).getByRole('menuitem', { name: 'deployments.deployTab.undeploy' }), + ).toBeInTheDocument() + expect( + within(menu).queryByRole('menuitem', { + name: 'deployments.studio.changeVersion', + }), + ).not.toBeInTheDocument() + }) + + it('exposes only the first action and moves every remaining action into More', async () => { + const user = userEvent.setup() + render() + + const preReleaseRow = within(screen.getByRole('row', { name: /Pre-release/ })) + expect( + preReleaseRow.getByRole('button', { + name: 'deployments.studio.deployLatest', + }), + ).toBeInTheDocument() + + await user.click( + preReleaseRow.getByRole('button', { + name: 'Pre-release · deployments.deployTab.moreActions', + }), + ) + + const menu = within( + await screen.findByRole('menu', { + name: 'Pre-release · deployments.deployTab.moreActions', + }), + ) + expect( + menu.getByRole('menuitem', { name: 'deployments.studio.changeVersion' }), + ).toBeInTheDocument() + expect( + menu.getByRole('menuitem', { name: 'deployments.deployTab.redeploy' }), + ).toBeInTheDocument() + expect( + menu.getByRole('menuitem', { name: 'deployments.deployTab.undeploy' }), + ).toBeInTheDocument() + expect( + menu.queryByRole('menuitem', { name: 'deployments.studio.deployLatest' }), + ).not.toBeInTheDocument() + }) + + it('disables every row action while deployment is in progress', async () => { + const user = userEvent.setup() + render() + + const stagingRow = within(screen.getByRole('row', { name: /Staging/ })) + expect( + stagingRow.getByRole('button', { + name: 'deployments.studio.changeVersion', + }), + ).toBeDisabled() + + await user.click( + stagingRow.getByRole('button', { + name: 'Staging · deployments.deployTab.moreActions', + }), + ) + + const menuItems = within(await screen.findByRole('menu')).getAllByRole('menuitem') + expect(menuItems).toHaveLength(2) + for (const item of menuItems) expect(item).toHaveAttribute('aria-disabled', 'true') + }) + + it('asks for confirmation before undeploying an environment', async () => { + const user = userEvent.setup() + const onUndeploy = vi.fn() + render( + + + , + ) + + const canaryRow = screen.getByRole('row', { name: /Canary/ }) + await user.click( + within(canaryRow).getByRole('button', { + name: 'Canary · deployments.deployTab.moreActions', + }), + ) + await user.click( + within(screen.getByRole('menu')).getByRole('menuitem', { + name: 'deployments.deployTab.undeploy', + }), + ) + + const dialog = await screen.findByRole('alertdialog', { + name: 'Undeploy Sprint-42 from Canary', + }) + expect( + within(dialog).getByText( + "The app will stop running in this environment, and all of its access points will become unavailable. The deployed version won't be deleted.", + ), + ).toBeInTheDocument() + expect( + within(dialog).getByText( + 'After confirmation, this environment will enter the undeploying state and actions will be temporarily disabled.', + ), + ).toBeInTheDocument() + expect(onUndeploy).not.toHaveBeenCalled() + + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(onUndeploy).not.toHaveBeenCalled() + }) + + it('undeploys the selected environment after confirmation', async () => { + const user = userEvent.setup() + const onUndeploy = vi.fn() + render( + + + , + ) + + const canaryRow = screen.getByRole('row', { name: /Canary/ }) + await user.click( + within(canaryRow).getByRole('button', { + name: 'Canary · deployments.deployTab.moreActions', + }), + ) + await user.click( + within(screen.getByRole('menu')).getByRole('menuitem', { + name: 'deployments.deployTab.undeploy', + }), + ) + const dialog = await screen.findByRole('alertdialog', { + name: 'Undeploy Sprint-42 from Canary', + }) + + await user.click(within(dialog).getByRole('button', { name: 'Undeploy' })) + + expect(onUndeploy).toHaveBeenCalledOnce() + expect(onUndeploy).toHaveBeenCalledWith( + expect.objectContaining({ + environment: expect.objectContaining({ id: 'canary' }), + }), + ) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('shows the version description from the deployment contract on hover', async () => { + const user = userEvent.setup() + render() + + const canaryRow = screen.getByRole('row', { name: /Canary/ }) + await user.hover(within(canaryRow).getByRole('button', { name: 'Sprint-42' })) + + expect( + await screen.findByText( + 'Fixed several critical bugs affecting data synchronization and optimized page loading speed. Enhanced system stability and user experience through backend improvements.', + ), + ).toBeInTheDocument() + }) + + it('shows the built-in published workflow details on hover', async () => { + const user = userEvent.setup() + render() + + const builtInSection = screen + .getByRole('heading', { name: 'deployments.studio.builtInTitle' }) + .closest('section') + if (!builtInSection) throw new Error('Built-in environment section was not rendered') + await user.hover(within(builtInSection).getByRole('button', { name: 'Release 7' })) + + expect(await screen.findByText('Published 17 days ago by Alice')).toBeInTheDocument() + expect(screen.getByText('Production-ready workflow')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/deploy/__tests__/state.spec.tsx b/web/app/components/app/deploy/__tests__/state.spec.tsx new file mode 100644 index 00000000000..553b365ac79 --- /dev/null +++ b/web/app/components/app/deploy/__tests__/state.spec.tsx @@ -0,0 +1,219 @@ +import type { + WorkflowPaginationResponse, + WorkflowResponse, +} from '@dify/contracts/api/console/apps/types.gen' +import type { ReactElement } from 'react' +import { screen, render as testingLibraryRender } from '@testing-library/react' +import { Provider, useAtomValue } from 'jotai' +import { createQueryAtomTestStore } from '@/test/query-atom' +import { + AppDeployStateBoundary, + appWorkflowVersionsAtom, + latestAppWorkflowVersionAtom, +} from '../state' + +type QueryOptions = { + input: unknown +} + +type InfiniteQueryOptions = { + getNextPageParam: (lastPage: WorkflowPaginationResponse) => number | undefined + initialPageParam: number + input: (pageParam: number) => unknown +} + +const queryOptionsMocks = vi.hoisted(() => ({ + latest: vi.fn(), + versions: vi.fn(), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + byAppId: { + workflows: { + get: { + infiniteOptions: (options: InfiniteQueryOptions) => { + queryOptionsMocks.versions(options) + + return { + ...options, + queryFn: async () => ({ + has_more: false, + items: [], + limit: 10, + page: 1, + }), + queryKey: ['appWorkflowVersions'], + staleTime: Infinity, + } + }, + }, + publish: { + get: { + queryOptions: (options: QueryOptions) => { + queryOptionsMocks.latest(options) + + return { + ...options, + queryFn: async () => undefined, + queryKey: ['latestPublishedWorkflow'], + staleTime: Infinity, + } + }, + }, + }, + }, + }, + }, + }, +})) + +function workflowVersion({ + id, + name, + publishedBy, + version = `2026-07-30.${id}`, + versionNumber, +}: { + id: string + name: string + publishedBy: string + version?: string + versionNumber?: number +}): WorkflowResponse { + return { + conversation_variables: [], + created_at: 1_710_000_100, + created_by: { + email: `${publishedBy.toLowerCase()}@example.com`, + id: `user-${publishedBy.toLowerCase()}`, + name: publishedBy, + }, + environment_variables: [], + features: {}, + graph: {}, + hash: `hash-${id}`, + id, + marked_comment: `${name} notes`, + marked_name: name, + rag_pipeline_variables: [], + tool_published: false, + updated_at: 1_710_000_100, + version, + version_number: versionNumber, + } +} + +function StateConsumer() { + const latestVersion = useAtomValue(latestAppWorkflowVersionAtom) + const versions = useAtomValue(appWorkflowVersionsAtom) + + return ( + <> +
{`Latest: ${latestVersion?.name}`}
+ {versions.map((version) => ( +
+ {`${version.name} · ${version.publishedBy} · ${version.latest ? 'latest' : 'previous'}`} +
+ ))} + + ) +} + +function render(ui: ReactElement) { + const { queryClient, store } = createQueryAtomTestStore() + const latestVersion = workflowVersion({ + id: 'version-3', + name: 'Release 3', + publishedBy: 'Alice', + }) + const previousVersion = workflowVersion({ + id: 'version-2', + name: '', + publishedBy: 'Bob', + versionNumber: 2, + }) + const draftVersion = workflowVersion({ + id: 'draft-version', + name: 'Draft', + publishedBy: 'Alice', + version: 'draft', + }) + + queryClient.setQueryData(['latestPublishedWorkflow'], latestVersion) + queryClient.setQueryData(['appWorkflowVersions'], { + pageParams: [1, 2], + pages: [ + { + has_more: true, + items: [draftVersion, latestVersion], + limit: 2, + page: 1, + }, + { + has_more: false, + items: [previousVersion], + limit: 1, + page: 2, + }, + ], + }) + + return testingLibraryRender({ui}) +} + +describe('app deploy workflow version state', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('keeps published workflow pages without drafts in state and uses a non-paginated latest query', () => { + render( + + + , + ) + + expect(screen.getByText('Latest: Release 3')).toBeInTheDocument() + expect(screen.getByText('Release 3 · Alice · latest')).toBeInTheDocument() + expect(screen.getByText('# 2 · Bob · previous')).toBeInTheDocument() + expect(screen.queryByText(/Draft/)).not.toBeInTheDocument() + + expect(queryOptionsMocks.latest).toHaveBeenCalledWith({ + input: { + params: { + app_id: 'app-1', + }, + }, + }) + + const infiniteOptions = queryOptionsMocks.versions.mock.lastCall?.[0] as InfiniteQueryOptions + expect(infiniteOptions.initialPageParam).toBe(1) + expect(infiniteOptions.input(3)).toEqual({ + params: { + app_id: 'app-1', + }, + query: { + limit: 10, + page: 3, + }, + }) + expect( + infiniteOptions.getNextPageParam({ + has_more: true, + items: [], + limit: 10, + page: 3, + }), + ).toBe(4) + expect( + infiniteOptions.getNextPageParam({ + has_more: false, + items: [], + limit: 10, + page: 3, + }), + ).toBeUndefined() + }) +}) diff --git a/web/app/components/app/deploy/__tests__/version.spec.ts b/web/app/components/app/deploy/__tests__/version.spec.ts new file mode 100644 index 00000000000..f942df6d088 --- /dev/null +++ b/web/app/components/app/deploy/__tests__/version.spec.ts @@ -0,0 +1,36 @@ +import { toDeploymentVersion } from '../version' + +describe('toDeploymentVersion', () => { + it('maps workflow metadata into the shared deployment version shape', () => { + expect( + toDeploymentVersion( + { + created_at: 1_710_000_100, + created_by: { name: 'Ada' }, + environments: [{ name: 'Staging' }], + id: 'workflow-1', + marked_comment: 'Ready to deploy', + version_number: 4, + }, + 'Default', + 'workflow-1', + ), + ).toEqual({ + description: 'Ready to deploy', + id: 'workflow-1', + latest: true, + name: '# 4', + publishedAt: 1_710_000_100_000, + publishedBy: 'Ada', + tags: ['Staging'], + }) + }) + + it('keeps optional deployment metadata absent when the source does not provide it', () => { + expect(toDeploymentVersion({ id: 'workflow-2' }, 'Default')).toEqual({ + description: undefined, + id: 'workflow-2', + name: 'Default', + }) + }) +}) diff --git a/web/app/components/app/deploy/access-point.ts b/web/app/components/app/deploy/access-point.ts new file mode 100644 index 00000000000..406d493e591 --- /dev/null +++ b/web/app/components/app/deploy/access-point.ts @@ -0,0 +1,17 @@ +export type AccessPoint = 'mcp' | 'serviceApi' | 'trigger' | 'webApp' + +export const ACCESS_POINT_ORDER = [ + 'webApp', + 'serviceApi', + 'mcp', + 'trigger', +] as const satisfies readonly AccessPoint[] + +export function getAccessPointHref(appId: string, environmentId: string, accessPoint: AccessPoint) { + const searchParams = new URLSearchParams({ + environment: environmentId, + accessPoint, + }) + + return `/app/${appId}/access-point?${searchParams.toString()}` +} diff --git a/web/app/components/app/deploy/assets/github.png b/web/app/components/app/deploy/assets/github.png new file mode 100644 index 0000000000000000000000000000000000000000..c00ac6ad25f3fa3a58ad40d54bc2b90bdba8fd2a GIT binary patch literal 2961 zcmbuBc{~%0AIG=Zn2k2~mB%f&lqe&{oZ*pUj)-)zG$P0II2sSyoJAs6u9363haAHw zTjjpb6}ef8l&c-rGEt^8$2yPUIK&c&5Z#y?@q2A58&Hq8#Dk=n+iX=1_1!zik29( zV;FEN4}C|{NwEJVt4UL;3p@+`4#R_OdgF>phvSNx=y3%Ng#`s!c9eoAc5W2Av?9Ag z?V{Bp?P{{e6GfHa%hO6!K7NRJ17}>!yp#cg#eQB|9%`WN%6aO z{d&*b)@)Gaoz6Dfvh;{;^6!+FUIvHZN4o6|ZtA*e5pLBUYlf-G7dOq)-*1PYe%6!N zWs*|^9Qs-FLbJ)sQnPNdRFtY=S$Dcu=nwXnv-w2!Gj>@a~qGYo8euIga0Eep;Il zC7jFE`(}$1+pw*Mgt3Q1@iMg`SdAtXO2UX#)Z&TF@VT)JHyE-z$8gWJHL#i^SlGK7 zESgiP4v(dVEzP*miH%5UVL{{{!Xh?+rvWQH2X|z8S0U6VA9qSqBLrtFu-UEDWgp0- zc4a0PmO+W1j3hl)f)KZAfBJVg<(Qf|d8hCetD1GeLIZay1w9c|5$Y+Zxwo7O9GZ#B zEOEBDc-GP#nt2jFJ*RWh@Ut+lp>AlMLaQcNAUScnfVW1V4^5_U|-ywG40=3 z`kcHmG27izzxF)Y)V&Uzn6bPUU_)(s=lP&?LPw3-FGK$aP4$vm1NrrR%2P9mUibcd zb~nfyFcF={lbbE!xD0~p{My{hP@C#*(Tq7CpHz6t?#rRA%GT_yVH7iDqAl^r!r3a&E7{^^@s z4vGExk81hAW^L+7H?wZBVRm%sAWfv8et8FpYMMzUd&E0Ztl4o_$Niu`RAc)i}k zeE&@FmA~%m%!JQMgq=Z*a|!!rEl4f|9Zh|5cN!pG1vvn1<#h+mJv_&9pv_4~avlA> z-yGJQ7izd!u~0Vcy>VyOVXwIJ;f3N<{q>48S5H+Ycs1xbziRKCkcWMewfy|`M_)A( zZ^lfkapoRS$DX#(ONTz+pWBO{$yWr$e%*7mUl`SJ=;#LP!z-Bt5+Jhk-mF3SyO_(3 zEvPpU7qoKP1yB4rUm(_fsV)T(NKYwNcA<0^E+J{&#%wpUniJ+MSAkWNrr*=u0GDR_CPyl;}d6oMfCV;vO(WKZlJq zVeBL>ww-z9z$LPYO7USW6&EG_wa=3Twr`mIq0^WT zxye+3xxOc+Ec!Mfku`(z(T+@b2k8L~^CJ2VT$i32U%l*3;E&^|v>I!zUHZ|{u<-E#AUr9pj^ zBY0By@0@HrgztytIgyF%{1Vx&Ovrn-_oR)3bMM8K&E>-2eKS)6k%KHO7X}zHKkio` z+wvwzJGG-?3$pVpf@T@Ll4+B`RlAxckc%%7`VBM%c6^klarnkS`SxtXvuiW^qv$R7 zaG)nyMpFpP`S7EXN>vxOH?{c&Gttg+Z?h41VSJRBWWoq6-y@cByT05@y_acBXa6o_sL z?YR`l9?sBCqP)I~&C@sJQ4|6?j|42qe1YDpE6e!GajT3~3a_zkQzij_*S2ew2(#iALu{Wu7W@I?Xix220%P<=kfd0B$CFpA@_z?L~Sj8_ovAvIPJSD zb)IJ=h;~zo6La1t*Jr}U`2ppjprU4+&K$Q^lR_yd7NXXMiplb~vqd1925jM&s%zZw zZ{nv;CNqfe}FLu5lzB*4PHUPnhJvR+* zgo=UP{hqaUZf_5uXo$vfmsq2S*NO4gyCCpQ)2zp2S7g{hndI(n{j`3GkFaKh+%V%b zVolqchsALjinMtC!=oj*UI!G*1)bX3|NJO1R*iVeylRVv(;bEMOBacc5F|D#_g~@{ z^O*|a>sllPM)wr>N8js}ROMF2Ag#SCWiUF@pE^klbW;=-ntVUH+JMv}9t3C_zo3Z4 z`*?RnEe1YP#q2Szz2ok;ZBE*9++8cpBSu|4J=5o836@{pvom)P zE?pg~f4fRKQ8juc)c$3ZaG4wvo7qm3oV^WYj`ifr`%i;({vK0j*)uZ2ZTPZ|Ac+xdy`li%n8U9Cm73>P3Lq%T#Rdy7ULXD>D8QF3&shRM zEmeZ=CoH(oVrOOPMg(mVv;ZxH8{h`5K%58tT7`)|0z?)d;K=H~ z#Nav1^6a6hy012-b3?DyB#+kq(T=xkdzv-+4(8uuaRixsp4=lRo=L-kWLz~)`-=

pk5_w7tiOJE^7c$y zYy|Jy-jDCy>MgIuB;K`II=0O)+FD!xd6w|60fY&LBGbgiAHv$L9-1_+ST{-RyJ%wq08`--1z8iW``y397z`{!04`WjD?|9|_){Znk|E8Ac zToZUeWgcO_yExh%@NYk;=gU9fu%mK#Z|}>>aHuA);K73jXVzD*j<~!FRBH#2j20nF z!q3f_w(X6n<_0-4j_bYp~|HmD$fGX4LA-vh`3v-~-~n{ZW=*L2t~1xTlp9t77*rVSgAwso=1Lfdj*u7(=;tn^S>Cmh%|lnHHJ zVb7<${WmJ;JlM|&CqCuo0xVXjM=BjbcM%y*K3HSLr|5ScVt^@!R{b80rG4~WtXe~o zvX_)vm|;m5WjutoX<8AX-Z%u}RD|!FbRdkz4G-nuo#9zg1ZP@Co$ct5vA2RTNUXf0 z`ASj7`-^9f_h1-P99o`_OnVaak~Rz1PrYE9y>9@5=O!dp0zs=6vDw(7(#NyKj~b09 zo^`vCW4C6HoItrUcTGjVAlbr&fLEBPCSle!JzNtNy|Dpfiu8%B{**B0QOy;R-}#R`c2 z0Ddx8g!$ZPA-aBKS8mp0UyeLyaj_&!thzO0gsW=MkhX;`@A?h$>2O@d{ot|s8<&uA z8KK85^>jOD!dEV6s7ZDg9}H|GT;*4wVa}{BU6Qr9((Z^-c>C5MYb9TO zD^@n3oKQg$!Rn-^wa$m+^`~@hT8^4#{ISGf!F^nv?;BQSVET;sj9Hh5k(s>Ov$)`#4Zfl^INjlIT<&30&(@4$fu@pta~wXMo*bh4kpC2#zYI5~P?cla`|XX3-jBX|p3DXJtF z1HGoqw$OgT4fwRt^(0^RmLmIfwO^=i>A=Ot50O9)Qa;->-%>W>(BAv-wFeHZgM)*A z8+M27a08lFTxK-w>_^`e?Hh}`=Q(BDVHHq%rea(;0Ee-U`Fu)c;)kxLN1xYy zwK*C7WE7wFSL%!+O33QIR$spp934086nr^pfd+xr`@xWXpWslPlP2o2ZD)s)=L34} zz1^Bq5vo1ZFlM935E!$&&YBK){r+wChBd<-Jc;Fpn2LG1q907@kh#Trj>4D;2_@BX zZ*p)(SK+Y3?UR(DY=M& zoN7aYqe-L!Gqwx4GYjIN>>a0Z@`iTz?#`dypv3z6dK4HFG#Du?`LE)+F&9g;LgQq3bUxx$Z~o$-t!2>U3ouJ=Kx6yET_`>eY-ka=<{E`*~@QG1sJA4ZRtQL| zJZxkz7|A&KKUe)yP(~^m30?Zx_V-u`=NM-M4k(|N1bzM=Owa$dOFuy?aqyfC2V$4N P)B$$ZPF4);Z;Af{dzq}2 literal 0 HcmV?d00001 diff --git a/web/app/components/app/deploy/assets/slack.png b/web/app/components/app/deploy/assets/slack.png new file mode 100644 index 0000000000000000000000000000000000000000..cf4aa776d2b98024a2b47b56dbd7a557f677cf18 GIT binary patch literal 2792 zcmcJR={pqa8^>p8#u!<~nx*W@ZgdVK*~VaG8)FL-Mr344LxpUk97{N4UkihnERC&b zkY5sGCn+*SWzQMAgfr*&>ih%e#r=G)@Atm0`+oCW&-Y2h*jn&HC7=KRfY%a@!m?TX zPeH)!Gik_Aoel0=XxCr>K+Wo(0+t(OSFufCFxCPIs2!AAVv7@gCN?Gj04T8x`Iuy|_`G14WjXL&n`m5VpWucRZXbF_m(=xbC;l~u4h@|zqh+|7GSK0Hy z-wW3oc9xEemI%vtBd#s)Ut61+3=o^%5I|<06GaM_kWnH~wBvt^EG((;Mf}sRb03qF zht&6sTyjMF4pp%mwB;bUz1|hg0o945K7_K2Px!ZuN_vE^ zK|e7NdQ;sh-mAR(k6f!(VUmpkga@V>+cNtEoQZP$z8k6|StOi6iUKA$VhL z>`;5w01T8f>}axG#{LoMQfJH8*V*u{Y{jT8<}p8P%5byscu~-n8?yg+iJt%avM&Y0 zEjx46(Y|OT%8~!A*)G~lqOIbSNTGPFkDO=81`yRJeKdb~ye+f|GdI-I(O2X;I&v2; zC=hhHW&S!xgO}^(-SzIbEb)$++|)Pgw`0>aIiq%4f}`1eq%o>v=ZKWZfCmnzYeLVg z>#N)Ajf8aPom-40KLwxdI+3>ietUKIUSzR8cFX;w`H;qJ9C=~@0vQ(elV42Gg($82 zp0|Y{&Unv0O2HlW@K7eYgbl!KQy9bY=z;-hbo14&M7!>-RtNF;Ps^kC-p9G^aI1IrCKG%vtZ&K5 zj0iI*1hod zv)3J0TF2c&A8n^ouUi>z{?-$gHdRH(b|1JC!t{tTqmK*TY@Jl9UJ=y3(Cv{a-xkP7 zY#%$?8Pqr7PBU0(X3X=tzg4QO3Qa zi$5O6JAuHeN7Ou$Fy~y{c#zs5Cf@0U^c2bj#N1@mf`crt45S@~M{v#5$ImD4#ft*8lWq zr(B(-PiWE=@++aC{iO+lC$>0mEIyUHcSmCKB~$Oc>Oa0tj6X){nObala1ZYNa%hM9 zZ}uWuiOLuq`T}v>`AxBbgfm+^fr+6*Aq9lyFCKUdTl=$Gn8A_Xpl}eiyxRmgCQ%8>r<_CR}~lvZKOp7qz4aH@j;EF_*t^lWgo#&3AE4A)xJsLT$=-gt!a zM%G&Aeb16iuBOeMdFdiWkmfI8o`@cFU$ujxFZEdTRlHMB372T@PFH*GH_f?h$-PV= zM{-++A)T$$Y}U|W{}U1Y`lBi0Vr$nvIaIx%IZ0=k!`o5(yG$;^q~TSd>0f9TaqI`C zC>%ZVvsbeZdv&Y?;oSeghwna(Mg+@AGpFh~Dw4G%jt=E#&Q zbPK_zKY>tjH30ihGmRder(0esI!o}ijeXG!o7P+6v4<&-2#W?0WP<=amT#a37f4>) z_E#hXOI4pcZf2lM7++`csot7 zj?cZfTWO6$2#3G#GrK4)&xxodvrk@_cSVP3c3?n*oP--3l4xqEwN>K@$_|7lFm$7~ zu(JzxI0UggLR?g-mO+>Ry5^3h_Ahhrg%9ARHcIMrN0^HEm{^mqMePwdrEtE)wPK?S zk*ov)MMWl``RGF^!!(&LaCm1pq@SzQ6PruF?Pvg{LK7a{l=^&T$vm|_f!tC$yNbz~ zp(}z;{@F+=t|hOrAc({4mg(d3$EyQBXRdMSEBen`Q4*=-h6QE@BDalLo2r*FZ!na) z+`nmtx7{~*20e81Xtn)KDlF`hyz=hi|C;^yT7MEc8(T*qqCBQy9tmelG4{OVOSuOtI z+>0n7i)tV3n2hdsP6kGEREo8y4H>;CWGOw;+@%otyU>Q z9QoG7fjgQRS+Mlx4uqO5zC^FeOPnA=%#%)GxsMKYr-=>T_3Kk@} p8JF_s%q6G@r&S_>iT@>zK@RtOif)clqSzl(fTfu&sut;W|8IHe6B_^k literal 0 HcmV?d00001 diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx new file mode 100644 index 00000000000..d04dce259a8 --- /dev/null +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -0,0 +1,114 @@ +'use client' + +import type { WorkflowVersion } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { Node } from '@/app/components/workflow/types' +import { DeploymentStatus as DeploymentStatusEnum } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { useQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' +import useTimestamp from '@/hooks/use-timestamp' +import { useMCPServerDetail } from '@/service/use-tools' +import { appWorkflowQueryOptions } from '@/service/workflow-queries' +import { ACCESS_POINT_ORDER, getAccessPointHref } from '../access-point' +import { AccessPointIcon } from '../shared/access-point-icon' +import { DeploymentStatus } from '../shared/deployment-status' +import { VersionLabel } from '../shared/version-label' + +function Divider() { + return

+} + +export function BuiltInEnvironmentCard() { + const { t } = useTranslation('deployments') + const { formatTime } = useTimestamp() + const appDetail = useAppStore((state) => state.appDetail) + const appId = appDetail?.id ?? '' + const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId || null)) + const { data: mcpServerDetail } = useMCPServerDetail(appId, Boolean(appId)) + const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes) + ? (publishedWorkflow.graph.nodes as Node[]) + : [] + const hasStartNode = publishedNodes.some((node) => node.data.type === BlockEnum.Start) + const hasTriggerNode = publishedNodes.some((node) => isTriggerNode(node.data.type)) + const serviceModeAvailable = Boolean(publishedWorkflow && hasStartNode && !hasTriggerNode) + const activeAccessPoints = { + mcp: serviceModeAvailable && mcpServerDetail?.status === 'active', + serviceApi: serviceModeAvailable && Boolean(appDetail?.enable_api), + trigger: Boolean(publishedWorkflow && hasTriggerNode), + webApp: serviceModeAvailable && Boolean(appDetail?.enable_site), + } + const publishedBy = publishedWorkflow?.created_by?.name ?? appDetail?.author_name ?? '--' + const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy + const publishedVersion: WorkflowVersion | undefined = publishedWorkflow + ? { + created_at: publishedWorkflow.created_at, + created_by: publishedWorkflow.created_by ?? undefined, + id: publishedWorkflow.id, + marked_comment: publishedWorkflow.marked_comment, + marked_name: publishedWorkflow.marked_name, + version: publishedWorkflow.version, + version_number: publishedWorkflow.version_number ?? undefined, + } + : undefined + + return ( +
+
+ {/* Icon */} +
+ +
+ {/* Info */} +
+
+

+ {t(($) => $['studio.builtInTitle'])} +

+

+ {t(($) => $['studio.builtInDescription'])} +

+
+ +
+
+ {t(($) => $['studio.liveVersion'])} +
+ +
+ +
+
+ {t(($) => $['studio.accessPoints'])} +
+
+ {ACCESS_POINT_ORDER.map((accessPoint) => ( + + ))} +
+
+
+
+ {/* Status and updated time */} +
+ +

+ {publishedWorkflow + ? t(($) => $['studio.updatedAtBy'], { + name: updatedBy, + time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'), + }) + : '--'} +

+
+
+ ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx new file mode 100644 index 00000000000..c10c9f02e38 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx @@ -0,0 +1,92 @@ +import type { CredentialSlot } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { API_PREFIX } from '@/config' +import { renderWithConsoleQuery } from '@/test/console/query-data' +import { CredentialField } from '../credential-field' + +const themeState = vi.hoisted(() => ({ + theme: 'light', +})) + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ theme: themeState.theme }), +})) + +const credentialSlot: CredentialSlot = { + candidates: [ + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'credential-1', + display_name: 'Deployment key', + from_enterprise: true, + provider_id: 'langgenius/deepseek', + }, + ], + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + icon: 'deepseek-light.svg', + icon_dark: 'deepseek-dark.svg', + provider_id: 'langgenius/deepseek', +} + +function renderCredentialField() { + return renderWithConsoleQuery( + , + ) +} + +function getDecorativeIcon(container: HTMLElement) { + const icon = container.querySelector('img') + if (!icon) throw new Error('Expected the credential provider icon.') + + return icon +} + +describe('CredentialField', () => { + beforeEach(() => { + themeState.theme = 'light' + }) + + it('resolves the light credential provider icon through the current workspace', () => { + const { container } = renderCredentialField() + + expect(getDecorativeIcon(container)).toHaveAttribute( + 'src', + `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-1&filename=deepseek-light.svg`, + ) + }) + + it('resolves the dark credential provider icon in dark mode', () => { + themeState.theme = 'dark' + const { container } = renderCredentialField() + + expect(getDecorativeIcon(container)).toHaveAttribute( + 'src', + `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=workspace-1&filename=deepseek-dark.svg`, + ) + }) + + it('opens guidance when no credentials are available', async () => { + const user = userEvent.setup() + renderWithConsoleQuery( + , + ) + + const trigger = screen.getByRole('combobox', { name: 'Deepseek' }) + expect(trigger).toBeEnabled() + + await user.click(trigger) + + const listbox = await screen.findByRole('listbox') + expect(within(listbox).getByText('deployments.studio.noCredentialsYet')).toBeInTheDocument() + expect( + within(listbox).getByText('deployments.studio.noCredentialsYetDescription'), + ).toBeInTheDocument() + expect(within(listbox).queryByRole('option')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx new file mode 100644 index 00000000000..8228026c57a --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx @@ -0,0 +1,262 @@ +'use client' + +import type { Dispatch, SetStateAction } from 'react' +import type { DeploymentVersion } from '../../version' +import type { DeploymentDialogRequest } from '../types' +import type { DeploymentConfigurationQueryState } from './use-deployment-configuration-queries' +import type { DeploymentConfigurationValues } from './use-deployment-configuration-values' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { CredentialField } from './credential-field' +import { DeploymentPrecheckAlert } from './deployment-precheck-alert' +import { EnvironmentVariableField } from './environment-variable-field' +import { + credentialSlotKey, + defaultCredentialId, + defaultEnvironmentVariableSelection, +} from './workflow-deployment-input' + +function SectionHeading({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ) +} + +function errorMessage(error: unknown, fallback: string) { + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() + ) { + return error.message.trim() + } + + return fallback +} + +function ConfigurationError({ messages }: { messages: string[] }) { + const { t } = useTranslation('common') + + return ( +
+ +
+

{t(($) => $.error)}

+
    + {messages.map((message) => ( +
  • + {message} +
  • + ))} +
+
+
+ ) +} + +function ConfigurationLoading({ label }: { label: string }) { + return ( +
+ + {label} +
+ ) +} + +export function DeploymentConfigurationContent({ + compact = false, + onValuesChange, + queryState, + request, + values, + version, +}: { + compact?: boolean + onValuesChange: Dispatch> + queryState: DeploymentConfigurationQueryState + request: DeploymentDialogRequest + values: DeploymentConfigurationValues + version: DeploymentVersion +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const horizontalPaddingClassName = compact ? 'px-4' : 'px-6' + const { + deploymentOptions, + deploymentOptionsError, + isLoadingDeploymentOptions, + isPrecheckBlocked, + isPrechecking, + precheck, + precheckError, + } = queryState + const unsupportedNodes = precheck?.unsupported_nodes ?? [] + const showPrecheckAlert = !isPrechecking && !precheckError && isPrecheckBlocked + const showConfiguration = Boolean(deploymentOptions) + const credentialSlots = deploymentOptions?.credential_slots ?? [] + const hasCredentialSlots = credentialSlots.length > 0 + const environmentVariableSlots = deploymentOptions?.environment_variable_slots ?? [] + + return ( + <> +
+
+
+ + {version.name} +
+ +
+ + + {request.environment} + +
+
+
+ +
+ {isPrechecking && ( + $['versions.checkingReleaseContent'])} /> + )} + {!isPrechecking && precheckError && ( +
+ $.error), + ), + ]} + /> +
+ )} + {showPrecheckAlert && ( +
+ +
+ )} + {isLoadingDeploymentOptions && $.loading)} />} + {!isLoadingDeploymentOptions && deploymentOptionsError && ( +
+ $['deployDrawer.bindingOptionsFailed']), + ), + ]} + /> +
+ )} + {showConfiguration && ( + <> + {hasCredentialSlots && ( +
+ $['deployDrawer.runtimeCredentials'])} + description={t(($) => $['deployDrawer.bindingSelectionHint'])} + /> + {credentialSlots.map((slot) => { + const slotKey = credentialSlotKey(slot) + + return ( + + onValuesChange((current) => ({ + ...current, + credentials: { + ...current.credentials, + [slotKey]: value, + }, + })) + } + /> + ) + })} +
+ )} + + {environmentVariableSlots.length > 0 ? ( +
+ $['deployDrawer.envVars'])} + description={t(($) => $['studio.environmentVariablesDescription'])} + /> + {environmentVariableSlots.map((slot) => { + const selection = + values.environmentVariables[slot.key] ?? + defaultEnvironmentVariableSelection(slot) + + return ( + + onValuesChange((current) => ({ + ...current, + environmentVariables: { + ...current.environmentVariables, + [slot.key]: { + ...selection, + source, + }, + }, + })) + } + onCustomValueChange={(customValue) => + onValuesChange((current) => ({ + ...current, + environmentVariables: { + ...current.environmentVariables, + [slot.key]: { + ...selection, + customValue, + }, + }, + })) + } + /> + ) + })} +
+ ) : null} + + )} +
+ + ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx new file mode 100644 index 00000000000..c095eaafb57 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx @@ -0,0 +1,124 @@ +import type { CredentialSlot } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { + Select, + SelectContent, + SelectGroup, + SelectGroupLabel, + SelectItem, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { useTranslation } from 'react-i18next' +import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' + +function providerName(providerId: string) { + const name = providerId.split('/').filter(Boolean).at(-1) ?? providerId + + return name + .split(/[-_]/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +export function CredentialField({ + slot, + value, + onChange, +}: { + slot: CredentialSlot + value?: string + onChange: (value: string) => void +}) { + const { t } = useTranslation('deployments') + const { t: tPlugin } = useTranslation('plugin') + const { getIconUrl } = useGetIcon() + const { theme } = useTheme() + const selectedOption = slot.candidates.find((candidate) => candidate.credential_id === value) + const name = providerName(slot.provider_id) + const iconFileName = + theme === Theme.dark ? slot.icon_dark || slot.icon : slot.icon || slot.icon_dark + const iconSrc = iconFileName ? getIconUrl(iconFileName) : undefined + const category = + slot.category === PluginCategory.PLUGIN_CATEGORY_MODEL + ? tPlugin(($) => $['categorySingle.model']) + : slot.category === PluginCategory.PLUGIN_CATEGORY_TOOL + ? tPlugin(($) => $['categorySingle.tool']) + : '' + + return ( +
+
+ + {iconSrc ? ( + + ) : ( + + )} + + {name} + {category && {category}} +
+ +
+ ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx new file mode 100644 index 00000000000..632cc32c420 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx @@ -0,0 +1,63 @@ +'use client' + +import type { UnsupportedNode } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { Emoji } from '@/app/components/tools/types' +import { useTranslation } from 'react-i18next' +import BlockIcon from '@/app/components/workflow/block-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import { useGetProviderIcon } from './use-provider-icon' + +const WORKFLOW_BLOCK_TYPES = new Set(Object.values(BlockEnum)) + +function isWorkflowBlockType(type: string): type is BlockEnum { + return WORKFLOW_BLOCK_TYPES.has(type) +} + +function UnsupportedNodeIcon({ node, icon }: { node: UnsupportedNode; icon?: string | Emoji }) { + if (!isWorkflowBlockType(node.type)) { + return ( + + + + ) + } + + return +} + +export function DeploymentPrecheckAlert({ nodes }: { nodes: UnsupportedNode[] }) { + const { t } = useTranslation('deployments') + const getProviderIcon = useGetProviderIcon(nodes) + + return ( +
+
+ +
+

{t(($) => $['studio.precheck.title'])}

+

+ {t(($) => $['studio.precheck.description'])} +

+
    + {nodes.map((node) => ( +
  • + + + {node.title} + +
  • + ))} +
+

+ {t(($) => $['studio.precheck.supportMessage'])} +

+
+
+ ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx new file mode 100644 index 00000000000..999b9250753 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx @@ -0,0 +1,129 @@ +'use client' + +import type { + EnvironmentVariableSlot, + EnvVarValueSource, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { + EnvVarValueSource as EnvVarValueSourceEnum, + EnvVarValueType, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { Input } from '@langgenius/dify-ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { useTranslation } from 'react-i18next' + +export function EnvironmentVariableField({ + slot, + source, + customValue, + onSourceChange, + onCustomValueChange, +}: { + slot: EnvironmentVariableSlot + source: EnvVarValueSource + customValue: string + onSourceChange: (source: EnvVarValueSource) => void + onCustomValueChange: (value: string) => void +}) { + const { t } = useTranslation('deployments') + const sourceLabels: Partial> = { + [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED]: t(($) => $['studio.versionValue']), + [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM]: t( + ($) => $['deployDrawer.envVarSource.literal'], + ), + [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]: t( + ($) => $['deployDrawer.envVarSource.lastDeployment'], + ), + } + const availableSources = [ + ...(slot.has_last_deployed_value + ? [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED] + : []), + ...(slot.has_configured_value ? [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED] : []), + EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM, + ] + const valueTypeLabel = + slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER + ? t(($) => $['deployDrawer.envVarType.number']) + : slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET + ? t(($) => $['deployDrawer.envVarType.secret']) + : t(($) => $['deployDrawer.envVarType.string']) + const inputId = `deployment-env-${slot.key}` + const editable = source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM + const inputType = + editable && slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET + ? 'password' + : editable && slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER + ? 'number' + : 'text' + const sourceLabel = sourceLabels[source] ?? t(($) => $['deployDrawer.envVarSource.literal']) + // Secret values arrive masked, so these are safe to show as-is. + const sourceValues: Partial> = { + [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED]: slot.configured_value, + [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]: slot.last_deployed_value, + } + const placeholder = editable ? undefined : (sourceValues[source] ?? sourceLabel) + + return ( +
+
+
+ + + {valueTypeLabel} + {slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET && ( + + )} +
+ +
+ onCustomValueChange(event.target.value)} + /> + {slot.description && ( +

{slot.description}

+ )} +
+ ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx new file mode 100644 index 00000000000..15abbc1c2a5 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx @@ -0,0 +1,139 @@ +'use client' + +import type { DeploymentVersion } from '../../version' +import type { DeploymentDialogRequest } from '../types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { DialogCloseButton, DialogTitle } from '@langgenius/dify-ui/dialog' +import { useTranslation } from 'react-i18next' +import { useDeployWorkflow } from '../../use-deploy-workflow' +import { DeploymentConfigurationContent } from './content' +import { useDeploymentConfigurationQueries } from './use-deployment-configuration-queries' +import { useDeploymentConfigurationValues } from './use-deployment-configuration-values' +import { workflowDeploymentInput } from './workflow-deployment-input' + +export function DeploymentConfiguration({ + appId, + disabled = false, + embedded = false, + invalidateAppEnvironmentsOnSuccess = true, + request, + version, + onBack, + onClose, + onDeploymentStarted, +}: { + appId?: string + disabled?: boolean + embedded?: boolean + invalidateAppEnvironmentsOnSuccess?: boolean + request: DeploymentDialogRequest + version: DeploymentVersion + onBack?: () => void + onClose: () => void + onDeploymentStarted?: (operationId: string) => void +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const [configurationValues, setConfigurationValues] = useDeploymentConfigurationValues() + const queryState = useDeploymentConfigurationQueries({ + appId, + environmentId: request.environmentId, + workflowId: version.id, + }) + const deploymentInput = queryState.deploymentOptions + ? workflowDeploymentInput(queryState.deploymentOptions, configurationValues) + : undefined + const deployMutation = useDeployWorkflow({ + appId, + invalidateAppEnvironmentsOnSuccess, + onSuccess: (response) => { + onDeploymentStarted?.(response.operation.id) + onClose() + }, + }) + const canDeploy = + Boolean(appId) && + !disabled && + queryState.canDeploy && + Boolean(deploymentInput) && + !deployMutation.isPending + + return ( +
{ + event.preventDefault() + if (!appId || !canDeploy || !deploymentInput) return + + deployMutation.mutate({ + body: deploymentInput, + params: { + app_id: appId, + environment_id: request.environmentId, + workflow_id: version.id, + }, + }) + }} + > + {!embedded && ( + $['operation.close'])} + className="top-5 right-5 size-8 rounded-lg" + /> + )} +
+ {onBack && ( + + )} + {embedded ? ( +

+ {t(($) => $['studio.deployConfiguration'])} +

+ ) : ( + + {t(($) => $['studio.deployConfiguration'])} + + )} +
+ + + +
+ + +
+ + ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts new file mode 100644 index 00000000000..7bb52bc8f03 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts @@ -0,0 +1,70 @@ +'use client' + +import { skipToken, useQuery } from '@tanstack/react-query' +import { consoleQuery } from '@/service/client' + +export function useDeploymentConfigurationQueries({ + appId, + environmentId, + workflowId, +}: { + appId?: string + environmentId: string + workflowId: string +}) { + const precheckQuery = useQuery( + consoleQuery.enterprise.appDeploy.deploymentService.precheckWorkflowDeployment.queryOptions({ + input: appId + ? { + params: { + app_id: appId, + workflow_id: workflowId, + }, + } + : skipToken, + retry: false, + }), + ) + const precheck = precheckQuery.data + const precheckPassed = + precheckQuery.isSuccess && !precheckQuery.isFetching && precheck?.unsupported_nodes.length === 0 + + const deploymentOptionsQuery = useQuery( + consoleQuery.enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions.queryOptions({ + input: appId + ? { + params: { + app_id: appId, + environment_id: environmentId, + workflow_id: workflowId, + }, + } + : skipToken, + enabled: precheckPassed, + retry: false, + }), + ) + + const isPrechecking = Boolean(appId) && (precheckQuery.isLoading || precheckQuery.isFetching) + const isLoadingDeploymentOptions = + precheckPassed && (deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching) + const isPrecheckBlocked = + precheckQuery.isSuccess && + !precheckQuery.isFetching && + Boolean(precheck?.unsupported_nodes.length) + const deploymentOptionsReady = + precheckPassed && deploymentOptionsQuery.isSuccess && !deploymentOptionsQuery.isFetching + + return { + canDeploy: deploymentOptionsReady, + deploymentOptions: deploymentOptionsReady ? deploymentOptionsQuery.data : undefined, + deploymentOptionsError: precheckPassed ? deploymentOptionsQuery.error : null, + isLoadingDeploymentOptions, + isPrecheckBlocked, + isPrechecking, + precheck, + precheckError: precheckQuery.error, + } +} + +export type DeploymentConfigurationQueryState = ReturnType diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts new file mode 100644 index 00000000000..0157e1f5907 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts @@ -0,0 +1,23 @@ +'use client' + +import type { EnvVarValueSource } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { useState } from 'react' + +type EnvironmentVariableSelection = { + customValue: string + source: EnvVarValueSource +} + +export type DeploymentConfigurationValues = { + credentials: Record + environmentVariables: Record +} + +export function useDeploymentConfigurationValues() { + const [values, setValues] = useState({ + credentials: {}, + environmentVariables: {}, + }) + + return [values, setValues] as const +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-provider-icon.ts b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-provider-icon.ts new file mode 100644 index 00000000000..dacb65dc440 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-provider-icon.ts @@ -0,0 +1,67 @@ +import type { UnsupportedNode } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { Node } from '@/app/components/workflow/types' +import { useCallback, useMemo } from 'react' +import { transformDataSourceToTool } from '@/app/components/workflow/block-selector/utils' +import { findNodeIcon } from '@/app/components/workflow/hooks/use-tool-icon' +import { BlockEnum } from '@/app/components/workflow/types' +import useTheme from '@/hooks/use-theme' +import { useDataSourceList } from '@/service/use-pipeline' +import { + useAllBuiltInTools, + useAllCustomTools, + useAllMCPTools, + useAllWorkflowTools, +} from '@/service/use-tools' +import { useAllTriggerPlugins } from '@/service/use-triggers' + +const PROVIDER_ICON_BLOCK_TYPES = new Set([ + BlockEnum.DataSource, + BlockEnum.Tool, + BlockEnum.TriggerPlugin, +]) + +const toNodeData = (node: UnsupportedNode): Node['data'] | undefined => { + if (!PROVIDER_ICON_BLOCK_TYPES.has(node.type) || !node.provider) return undefined + + return { + desc: '', + title: node.title, + type: node.type, + ...node.provider, + } as Node['data'] +} + +export const useGetProviderIcon = (nodes: UnsupportedNode[]) => { + const hasToolNode = nodes.some((node) => node.type === BlockEnum.Tool) + const hasDataSourceNode = nodes.some((node) => node.type === BlockEnum.DataSource) + const hasTriggerPluginNode = nodes.some((node) => node.type === BlockEnum.TriggerPlugin) + const { data: buildInTools } = useAllBuiltInTools(hasToolNode) + const { data: customTools } = useAllCustomTools(hasToolNode) + const { data: workflowTools } = useAllWorkflowTools(hasToolNode) + const { data: mcpTools } = useAllMCPTools(hasToolNode) + const { data: dataSources } = useDataSourceList(hasDataSourceNode) + const { data: triggerPlugins } = useAllTriggerPlugins(hasTriggerPluginNode) + const { theme } = useTheme() + const dataSourceList = useMemo(() => dataSources?.map(transformDataSourceToTool), [dataSources]) + + return useCallback( + (node: UnsupportedNode) => { + const data = toNodeData(node) + if (!data) return undefined + + return findNodeIcon({ + data, + collections: { + buildInTools, + customTools, + workflowTools, + mcpTools, + }, + dataSourceList, + triggerPlugins, + theme, + }) + }, + [buildInTools, customTools, dataSourceList, mcpTools, theme, triggerPlugins, workflowTools], + ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts new file mode 100644 index 00000000000..e46ccc8981a --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts @@ -0,0 +1,82 @@ +import type { + CredentialSlot, + EnvironmentVariableSlot, + GetWorkflowDeploymentOptionsResponse, + WorkflowDeploymentInput, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentConfigurationValues } from './use-deployment-configuration-values' +import { EnvVarValueSource as EnvVarValueSourceEnum } from '@dify/contracts/enterprise-app-deploy/types.gen' + +export function credentialSlotKey(slot: CredentialSlot) { + return `${slot.provider_id}:${slot.category}` +} + +export function defaultCredentialId(slot: CredentialSlot) { + if ( + slot.last_deployed_credential_id && + slot.candidates.some( + (candidate) => candidate.credential_id === slot.last_deployed_credential_id, + ) + ) { + return slot.last_deployed_credential_id + } + + return slot.candidates.length === 1 ? slot.candidates[0]?.credential_id : undefined +} + +export function defaultEnvironmentVariableSelection( + slot: EnvironmentVariableSlot, +): DeploymentConfigurationValues['environmentVariables'][string] { + if (slot.has_configured_value) { + return { + customValue: '', + source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED, + } + } + + if (slot.has_last_deployed_value) { + return { + customValue: '', + source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED, + } + } + + return { + customValue: '', + source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM, + } +} + +export function workflowDeploymentInput( + deploymentOptions: GetWorkflowDeploymentOptionsResponse, + values: DeploymentConfigurationValues, +): WorkflowDeploymentInput | undefined { + const credentials: NonNullable = [] + + for (const slot of deploymentOptions.credential_slots) { + const credentialId = values.credentials[credentialSlotKey(slot)] ?? defaultCredentialId(slot) + if (!credentialId) return + + credentials.push({ + category: slot.category, + credential_id: credentialId, + provider_id: slot.provider_id, + }) + } + + return { + credentials, + environment_variables: deploymentOptions.environment_variable_slots.map((slot) => { + const selection = + values.environmentVariables[slot.key] ?? defaultEnvironmentVariableSelection(slot) + + return { + key: slot.key, + value_source: selection.source, + ...(selection.source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM + ? { value: selection.customValue } + : {}), + } + }), + } +} diff --git a/web/app/components/app/deploy/deployment-dialog/index.tsx b/web/app/components/app/deploy/deployment-dialog/index.tsx new file mode 100644 index 00000000000..850d02ea176 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/index.tsx @@ -0,0 +1,62 @@ +'use client' + +import type { DeploymentVersion } from '../version' +import type { DeploymentDialogRequest } from './types' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { useState } from 'react' +import { DeploymentConfiguration } from './deployment-configuration' +import { VersionSelection } from './version-selection' + +type DeploymentDialogProps = { + appId: string + request?: DeploymentDialogRequest + onClose: () => void +} + +function DeploymentDialogSession({ + appId, + request, + onClose, +}: { + appId: string + request: DeploymentDialogRequest + onClose: () => void +}) { + const [selectedVersion, setSelectedVersion] = useState(() => + 'initialVersion' in request ? request.initialVersion : undefined, + ) + + return ( + + {selectedVersion ? ( + setSelectedVersion(undefined)} + onClose={onClose} + /> + ) : ( + + )} + + ) +} + +export function DeploymentDialog({ appId, request, onClose }: DeploymentDialogProps) { + return ( + !open && onClose()}> + {request && ( + + )} + + ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/types.ts b/web/app/components/app/deploy/deployment-dialog/types.ts new file mode 100644 index 00000000000..87c36a34908 --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/types.ts @@ -0,0 +1,18 @@ +import type { DeploymentVersion } from '../version' + +type VersionSelectionRequest = { + currentVersionId?: string + environment: string + environmentId: string + kind: 'changeVersion' | 'deploy' +} + +type ConfigurationRequest = { + currentVersionId?: string + environment: string + environmentId: string + initialVersion: DeploymentVersion + kind: 'deployLatest' | 'redeploy' +} + +export type DeploymentDialogRequest = ConfigurationRequest | VersionSelectionRequest diff --git a/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx b/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx new file mode 100644 index 00000000000..846f2a6de5a --- /dev/null +++ b/web/app/components/app/deploy/deployment-dialog/version-selection/index.tsx @@ -0,0 +1,279 @@ +'use client' + +import type { ReactNode } from 'react' +import type { DeploymentVersion } from '../../version' +import type { DeploymentDialogRequest } from '../types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { DialogCloseButton, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { useAtomValue } from 'jotai' +import { useTranslation } from 'react-i18next' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import Link from '@/next/link' +import { useInfiniteScroll } from '../../hooks/use-infinite-scroll' +import { + appWorkflowVersionsAtom, + appWorkflowVersionsErrorAtom, + appWorkflowVersionsFetchNextPageAtom, + appWorkflowVersionsHasNextPageAtom, + appWorkflowVersionsIsFetchingAtom, + appWorkflowVersionsIsFetchingNextPageAtom, + appWorkflowVersionsIsLoadingAtom, +} from '../../state' + +function VersionBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function VersionTag({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function VersionChoice({ + version, + current, + disabled = false, + onSelect, +}: { + version: DeploymentVersion + current: boolean + disabled?: boolean + onSelect: (version: DeploymentVersion) => void +}) { + const { t } = useTranslation('deployments') + const { t: tWorkflow } = useTranslation('workflow') + const { formatTimeFromNow } = useFormatTimeFromNow() + + return ( + + ) +} + +function VersionList({ + className, + currentVersionId, + disabled = false, + publishHref, + onSelect, +}: { + className?: string + currentVersionId?: string + disabled?: boolean + publishHref?: string + onSelect: (version: DeploymentVersion) => void +}) { + const { t: tCommon } = useTranslation('common') + const { t } = useTranslation('deployments') + const versions = useAtomValue(appWorkflowVersionsAtom) + const versionsError = useAtomValue(appWorkflowVersionsErrorAtom) + const fetchNextPage = useAtomValue(appWorkflowVersionsFetchNextPageAtom) + const hasNextPage = useAtomValue(appWorkflowVersionsHasNextPageAtom) + const isFetching = useAtomValue(appWorkflowVersionsIsFetchingAtom) + const isFetchingNextPage = useAtomValue(appWorkflowVersionsIsFetchingNextPageAtom) + const isLoading = useAtomValue(appWorkflowVersionsIsLoadingAtom) + const { rootRef, sentinelRef } = useInfiniteScroll({ + error: versionsError, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isLoading, + }) + + return ( +
+
+ {versions.map((version) => ( + + ))} +
+ {isLoading && ( +
$.loading)} + className="flex h-20 items-center justify-center" + > + +
+ )} + {!isLoading && versionsError && versions.length === 0 && ( +

+ {tCommon(($) => $.error)} +

+ )} + {!isLoading && !versionsError && versions.length === 0 && ( +
+

+ {t(($) => $['studio.accessPoint.noPublishedTitle'])} +

+ {publishHref && ( + + )} +
+ )} + {isFetchingNextPage && versions.length > 0 && ( +
$.loading)} + className="flex h-8 items-center justify-center" + > + +
+ )} +
+
+ ) +} + +function versionSelectionTitle(request: DeploymentDialogRequest, deployTo: string, change: string) { + return request.kind === 'deploy' ? deployTo : `${change} · ${request.environment}` +} + +export function VersionSelection({ + appId, + request, + onSelect, +}: { + appId: string + request: DeploymentDialogRequest + onSelect: (version: DeploymentVersion) => void +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const title = versionSelectionTitle( + request, + t(($) => $['versions.deployTo'], { name: request.environment }), + t(($) => $['studio.changeVersion']), + ) + + return ( + <> + $['operation.close'])} + className="top-5 right-5 size-8 rounded-lg" + /> +
+ {title} + + {t(($) => $['studio.chooseVersionToDeploy'])} + +
+ + + ) +} + +export function EmbeddedVersionSelection({ + disabled, + request, + onBack, + onSelect, +}: { + disabled: boolean + request: DeploymentDialogRequest + onBack: () => void + onSelect: (version: DeploymentVersion) => void +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const title = versionSelectionTitle( + request, + t(($) => $['versions.deployTo'], { name: request.environment }), + t(($) => $['studio.changeVersion']), + ) + + return ( +
+
+ +

{title}

+

+ {t(($) => $['studio.chooseVersionToDeploy'])} +

+
+ +
+ ) +} diff --git a/web/app/components/app/deploy/environment-deployment-flow/index.tsx b/web/app/components/app/deploy/environment-deployment-flow/index.tsx new file mode 100644 index 00000000000..f8181e81b28 --- /dev/null +++ b/web/app/components/app/deploy/environment-deployment-flow/index.tsx @@ -0,0 +1,102 @@ +'use client' + +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { ReactNode } from 'react' +import type { DeploymentVersion } from '../version' +import { useState } from 'react' +import { DeploymentConfiguration } from '../deployment-dialog/deployment-configuration' +import { EmbeddedVersionSelection } from '../deployment-dialog/version-selection' +import { AppDeployStateBoundary, isEnvironmentDeploymentInProgress } from '../state' + +type EnvironmentDeploymentFlowView = 'configuration' | 'overview' | 'versions' + +type EnvironmentDeploymentFlowActions = { + deploymentActionsDisabled: boolean + deployVersion: (version: DeploymentVersion) => void + showVersionSelection: () => void +} + +type EnvironmentDeploymentFlowProps = { + appId?: string + children: (actions: EnvironmentDeploymentFlowActions) => ReactNode + deployment?: EnvironmentDeployment + disabled?: boolean + environmentId: string + environmentName: string + onDeploymentStarted: (operationId: string) => void +} + +function EnvironmentDeploymentFlowContent({ + appId, + children, + deployment, + disabled = false, + environmentId, + environmentName, + onDeploymentStarted, +}: EnvironmentDeploymentFlowProps) { + const [view, setView] = useState('overview') + const [selectedVersion, setSelectedVersion] = useState() + const currentVersionId = deployment?.deployment?.current_version?.id + const deploymentActionsDisabled = disabled || isEnvironmentDeploymentInProgress(deployment) + const request = { + currentVersionId, + environment: environmentName, + environmentId, + kind: 'deploy' as const, + } + + const showVersionSelection = () => { + if (deploymentActionsDisabled) return + setView('versions') + } + + const deployVersion = (version: DeploymentVersion) => { + if (deploymentActionsDisabled) return + setSelectedVersion(version) + setView('configuration') + } + + if (view === 'configuration' && selectedVersion) { + return ( + setView('overview')} + onDeploymentStarted={onDeploymentStarted} + /> + ) + } + + if (view === 'versions') { + return ( + setView('overview')} + onSelect={deployVersion} + /> + ) + } + + return children({ + deploymentActionsDisabled, + deployVersion, + showVersionSelection, + }) +} + +export function EnvironmentDeploymentFlow(props: EnvironmentDeploymentFlowProps) { + if (!props.appId) return + + return ( + + + + ) +} diff --git a/web/app/components/app/deploy/environment-table/activity-cell.tsx b/web/app/components/app/deploy/environment-table/activity-cell.tsx new file mode 100644 index 00000000000..2469b8fe280 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/activity-cell.tsx @@ -0,0 +1,62 @@ +import type { EnvironmentDeploymentOperation } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { + DeploymentOperationStatus, + DeploymentOperationType, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' + +function activityLabel( + activity: EnvironmentDeploymentOperation, + t: ReturnType>['t'], + defaultVersionName: string, +) { + if (activity.type === DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY) + return t(($) => $['deployTab.undeploy']) + + const target = getWorkflowVersionName(activity.target_version, defaultVersionName) + if (activity.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED) + return t(($) => $['studio.activity.deployFailed'], { target }) + if (activity.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED) + return t(($) => $['studio.activity.deploySucceeded'], { target }) + return t(($) => $['studio.activity.deploy'], { target }) +} + +export function ActivityCell({ activity }: { activity?: EnvironmentDeploymentOperation }) { + const { t } = useTranslation('deployments') + const { t: tWorkflow } = useTranslation('workflow') + const { formatTimeFromNow } = useFormatTimeFromNow() + if (!activity) return -- + + const failed = activity.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED + + return ( +
+
+ {failed && ( + + )} + + {activityLabel( + activity, + t, + tWorkflow(($) => $['versionHistory.defaultName']), + )} + +
+
+ {t(($) => $['studio.activity.meta'], { + name: activity.operator.display_name, + time: formatTimeFromNow(activity.activity_at * 1000), + })} +
+
+ ) +} diff --git a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx new file mode 100644 index 00000000000..475ddae5881 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx @@ -0,0 +1,73 @@ +'use client' + +import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useAtomValue } from 'jotai' +import { useTranslation } from 'react-i18next' +import { undeployedAppEnvironmentsAtom } from '../../state' + +type EnvironmentDeployMenuProps = { + appearance?: 'empty' | 'header' + onSelectEnvironment?: (environment: AppEnvironment) => void +} + +export function EnvironmentDeployMenu({ + appearance = 'header', + onSelectEnvironment, +}: EnvironmentDeployMenuProps) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const undeployedEnvironments = useAtomValue(undeployedAppEnvironmentsAtom) + const isEmptyState = appearance === 'empty' + const label = tCommon(($) => $['appMenus.deploy']) + + return ( + + + + {label} + + + } + /> + + + + {t(($) => $['card.notDeployed'])} + + {undeployedEnvironments.length === 0 && ( +

+ {t(($) => $['deployDrawer.noNewEnvironmentAvailable'])} +

+ )} + {undeployedEnvironments.map((environment) => ( + onSelectEnvironment?.(environment)} + > + + + {environment.display_name} + + + ))} +
+
+
+ ) +} diff --git a/web/app/components/app/deploy/environment-table/empty-state/data.ts b/web/app/components/app/deploy/environment-table/empty-state/data.ts new file mode 100644 index 00000000000..dc1cedc65a9 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/empty-state/data.ts @@ -0,0 +1,119 @@ +export type SkeletonRow = { + accessPointCount: number + activity: string + actor: string + environment: string + environmentWidth?: number + id: string + status: string + version?: string + versionBadge?: string +} + +export const ACCESS_POINT_ICONS = [ + 'i-ri-robot-2-line', + 'i-custom-vender-knowledge-api-aggregate', + 'i-custom-vender-integrations-mcp', + 'i-custom-vender-integrations-trigger', +] + +export const SKELETON_ROWS: SkeletonRow[] = [ + { + accessPointCount: 3, + activity: 'Deploy Sprint-42 succeeded', + actor: 'by Evan · 3d ago', + environment: 'Canary', + id: 'canary-primary', + status: 'Disabled', + version: 'Sprint-42', + versionBadge: 'LATEST', + }, + { + accessPointCount: 2, + activity: 'Deploy #11 succeeded', + actor: 'by Evan · 3d ago', + environment: 'Pre-release', + id: 'pre-release-primary', + status: 'Disabled', + version: 'Version-02', + versionBadge: '1', + }, + { + accessPointCount: 2, + activity: 'Deploy #11 succeeded', + actor: 'by Rhonda · 3d ago', + environment: 'Prod', + id: 'prod', + status: 'Disabled', + version: 'v0.9-hotfix', + versionBadge: '1', + }, + { + accessPointCount: 1, + activity: 'Deploy #10 failed', + actor: 'by Rhonda · 3d ago', + environment: 'EU-Prod', + id: 'eu-prod', + status: 'Disabled', + version: 'v0.6-beta', + versionBadge: '2', + }, + { + accessPointCount: 3, + activity: 'Deploy Sprint-42 succeeded', + actor: 'by Evan · 3d ago', + environment: 'Canary', + id: 'canary-secondary', + status: 'Disabled', + version: 'Sprint-42', + versionBadge: 'LATEST', + }, + { + accessPointCount: 1, + activity: 'Deploy #11 succeeded', + actor: 'by Rhonda · 3d ago', + environment: 'QA', + environmentWidth: 118, + id: 'qa', + status: 'Disabled', + version: 'v0.3-beta', + versionBadge: '1', + }, + { + accessPointCount: 1, + activity: 'Deploy #11 succeeded', + actor: 'by Rhonda · 3d ago', + environment: 'Sandbox', + id: 'sandbox', + status: 'Disabled', + version: 'v0.3-beta', + versionBadge: '1', + }, + { + accessPointCount: 1, + activity: 'Deploy #10 failed', + actor: 'by Rhonda · 3d ago', + environment: 'Preview', + id: 'preview', + status: 'Disabled', + }, + { + accessPointCount: 2, + activity: 'Deploy #11 succeeded', + actor: 'by Evan · 3d ago', + environment: 'Pre-release', + id: 'pre-release-secondary', + status: 'Disabled', + version: 'Version-02', + versionBadge: '1', + }, +] + +export const SKELETON_HEADER_LABELS = [ + 'Environment', + 'Live version', + 'Status', + 'Last activity', + 'Access points', + 'Actions', +] diff --git a/web/app/components/app/deploy/environment-table/empty-state/index.tsx b/web/app/components/app/deploy/environment-table/empty-state/index.tsx new file mode 100644 index 00000000000..40fac6b5cb5 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/empty-state/index.tsx @@ -0,0 +1,58 @@ +'use client' + +import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' +import { EnvironmentDeployMenu } from '../deploy-menu' +import { EmptyTableSkeleton } from './skeleton' + +type EnvironmentTableEmptyProps = + | { + state: 'empty' + onSelectEnvironment?: (environment: AppEnvironment) => void + } + | { + state: 'error' + isRetrying: boolean + onRetry: () => void + } + +export function EnvironmentTableEmpty(props: EnvironmentTableEmptyProps) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const isError = props.state === 'error' + const title = isError ? tCommon(($) => $['errorBoundary.title']) : t(($) => $['list.emptyTitle']) + const description = isError + ? t(($) => $['common.loadFailed']) + : t(($) => $['studio.emptyDescription']) + + return ( +
+ +
+
+
+
+ +
+
+
+

{title}

+

{description}

+
+ {props.state === 'error' ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/web/app/components/app/deploy/environment-table/empty-state/skeleton.tsx b/web/app/components/app/deploy/environment-table/empty-state/skeleton.tsx new file mode 100644 index 00000000000..737a176ad93 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/empty-state/skeleton.tsx @@ -0,0 +1,191 @@ +import type { SkeletonRow } from './data' +import { cn } from '@langgenius/dify-ui/cn' +import { ACCESS_POINT_ICONS, SKELETON_HEADER_LABELS, SKELETON_ROWS } from './data' + +const SKELETON_CLASS_NAME = 'bg-text-quaternary opacity-20' + +function HiddenSkeletonText({ children, className }: { children: string; className: string }) { + return ( + + {children} + + ) +} + +function EmptyTableHeader() { + return ( + + + {SKELETON_HEADER_LABELS.map((label, index) => ( + +
+ + {label} + +
+ + ))} + + + ) +} + +function EnvironmentSkeleton({ name, width }: { name: string; width?: number }) { + return ( +
+ + + + {width ? ( + + ) : ( + + {name} + + )} +
+ ) +} + +function VersionSkeleton({ badge, version }: { badge?: string; version?: string }) { + if (!version) { + return ( +
+ +
+ ) + } + + return ( +
+ + {version} + + {badge && ( + + {badge} + + )} +
+ ) +} + +function StatusSkeleton({ label }: { label: string }) { + return ( +
+ + + + + {label} + +
+ ) +} + +function ActivitySkeleton({ activity, actor }: { activity: string; actor: string }) { + return ( +
+ + {activity} + + + {actor} + +
+ ) +} + +function AccessPointSkeleton({ activeCount }: { activeCount: number }) { + return ( +
+ {ACCESS_POINT_ICONS.map((icon, index) => ( + = activeCount && 'opacity-30', + )} + > + + + ))} +
+ ) +} + +function ActionsSkeleton() { + return ( +
+ + + + + + + + + + + + + +
+ ) +} + +function EmptyTableRow({ row }: { row: SkeletonRow }) { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} + +export function EmptyTableSkeleton() { + return ( +
+ + + + + + + + + + + + {SKELETON_ROWS.map((row) => ( + + ))} + +
+
+
+ ) +} diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx new file mode 100644 index 00000000000..cbee27225ed --- /dev/null +++ b/web/app/components/app/deploy/environment-table/index.tsx @@ -0,0 +1,148 @@ +'use client' + +import type { + AppEnvironment, + EnvironmentDeployment, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { UndeployHandler } from './types' +import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { + appEnvironmentDeploymentsAtom, + appEnvironmentDeploymentsIsErrorAtom, + appEnvironmentDeploymentsIsFetchingAtom, + appEnvironmentDeploymentsIsLoadingAtom, + appEnvironmentDeploymentsRefetchAtom, + appEnvironmentUsageAtom, +} from '../state' +import { EnvironmentDeployMenu } from './deploy-menu' +import { EnvironmentTableEmpty } from './empty-state' +import { EnvironmentRow } from './row' + +type EnvironmentTableProps = { + appId: string + onChangeVersion?: (deployment: EnvironmentDeployment) => void + onDeployLatest?: (deployment: EnvironmentDeployment) => void + onDeployToEnvironment?: (environment: AppEnvironment) => void + onRedeploy?: (deployment: EnvironmentDeployment) => void + onUndeploy?: UndeployHandler +} + +export function EnvironmentTable({ + appId, + onChangeVersion, + onDeployLatest, + onDeployToEnvironment, + onRedeploy, + onUndeploy, +}: EnvironmentTableProps) { + const { t } = useTranslation('deployments') + const deployments = useAtomValue(appEnvironmentDeploymentsAtom) ?? [] + const isLoading = useAtomValue(appEnvironmentDeploymentsIsLoadingAtom) + const isError = useAtomValue(appEnvironmentDeploymentsIsErrorAtom) + const isFetching = useAtomValue(appEnvironmentDeploymentsIsFetchingAtom) + const refetchDeployments = useAtomValue(appEnvironmentDeploymentsRefetchAtom) + const usage = useAtomValue(appEnvironmentUsageAtom) + const used = usage?.used ?? deployments.length + const total = usage?.total ?? deployments.length + const showLoadingState = isLoading && deployments.length === 0 + const showErrorState = isError && deployments.length === 0 + const isRetrying = showErrorState && isFetching + const showEmptyState = !isLoading && !isError && deployments.length === 0 + + return ( +
+
+
+

+ {t(($) => $['studio.environments'])} +

+ + · + + + {t(($) => $['studio.environmentsInUse'], { + total, + used, + })} + +
+ +
+ +
0 ? 'overflow-x-auto' : 'overflow-x-hidden', + )} + > + {showLoadingState ? ( + + ) : showErrorState ? ( + void refetchDeployments()} + /> + ) : showEmptyState ? ( + + ) : ( + + + + + + + + + + + + + + + + + + + + + {deployments.map((row) => ( + + ))} + +
+ {t(($) => $['deployTab.col.environment'])} + + {t(($) => $['studio.liveVersion'])} + + {t(($) => $['deployTab.col.status'])} + + {t(($) => $['studio.lastActivity'])} + + {t(($) => $['studio.accessPoints'])} + + {t(($) => $['deployTab.col.actions'])} +
+ )} +
+
+ ) +} diff --git a/web/app/components/app/deploy/environment-table/row-actions.tsx b/web/app/components/app/deploy/environment-table/row-actions.tsx new file mode 100644 index 00000000000..8f4d8097498 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/row-actions.tsx @@ -0,0 +1,184 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { EnvironmentDeploymentAction } from '../state' +import type { UndeployHandler } from './types' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { Fragment, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' +import { getEnvironmentDeploymentActions } from '../state' +import { UndeployConfirmDialog } from './undeploy-confirm-dialog' + +function rowActionLabel( + action: EnvironmentDeploymentAction, + row: EnvironmentDeployment, + t: ReturnType>['t'], + defaultVersionName: string, +) { + switch (action.kind) { + case 'changeVersion': + return t(($) => $['studio.changeVersion']) + case 'deployLatest': + return t(($) => $['studio.deployLatest']) + case 'redeploy': + return t(($) => $['deployTab.redeploy']) + case 'retry': { + const operation = row.deployment?.latest_operation + const version = getWorkflowVersionName( + operation?.target_version ?? row.deployment?.current_version, + defaultVersionName, + ) + return t(($) => $['studio.retryVersion'], { version }) + } + case 'undeploy': + return t(($) => $['deployTab.undeploy']) + } +} + +const ROW_ACTION_ICON_CLASS_NAMES: Record = { + changeVersion: 'i-ri-repeat-line', + deployLatest: 'i-custom-vender-deploy-rocket', + redeploy: 'i-ri-reset-left-line', + retry: 'i-ri-reset-left-line', + undeploy: 'i-ri-logout-circle-r-line', +} + +export function EnvironmentRowActions({ + row, + onChangeVersion, + onDeployLatest, + onRedeploy, + onUndeploy, +}: { + row: EnvironmentDeployment + onChangeVersion?: (deployment: EnvironmentDeployment) => void + onDeployLatest?: (deployment: EnvironmentDeployment) => void + onRedeploy?: (deployment: EnvironmentDeployment) => void + onUndeploy?: UndeployHandler +}) { + const { t } = useTranslation('deployments') + const { t: tWorkflow } = useTranslation('workflow') + const defaultVersionName = tWorkflow(($) => $['versionHistory.defaultName']) + const currentVersionName = getWorkflowVersionName( + row.deployment?.current_version, + defaultVersionName, + ) + const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) + const [isUndeploying, setIsUndeploying] = useState(false) + const actions = getEnvironmentDeploymentActions(row) + const primaryAction = actions[0] + const moreActions = actions.slice(1) + + const handleAction = useCallback( + (action: EnvironmentDeploymentAction) => { + if (action.disabled) return + + switch (action.kind) { + case 'changeVersion': + onChangeVersion?.(row) + break + case 'deployLatest': + onDeployLatest?.(row) + break + case 'redeploy': + case 'retry': + onRedeploy?.(row) + break + case 'undeploy': + setShowUndeployConfirm(true) + break + } + }, + [onChangeVersion, onDeployLatest, onRedeploy, row], + ) + + const handleUndeploy = useCallback(async () => { + if (isUndeploying) return + + setIsUndeploying(true) + try { + await onUndeploy?.(row) + setShowUndeployConfirm(false) + } catch { + // The request layer reports the error; keep the dialog open so the user can retry. + } finally { + setIsUndeploying(false) + } + }, [isUndeploying, onUndeploy, row]) + + if (!primaryAction) return null + + return ( + <> +
+ + + $['deployTab.moreActions'])}`} + className="w-6 shrink-0 px-0" + > + + + } + /> + + {moreActions.map((action, index) => ( + + {action.kind === 'undeploy' && index > 0 && } + handleAction(action)} + > + + + {rowActionLabel(action, row, t, defaultVersionName)} + + + + ))} + + +
+ + + ) +} diff --git a/web/app/components/app/deploy/environment-table/row.tsx b/web/app/components/app/deploy/environment-table/row.tsx new file mode 100644 index 00000000000..9e652008b67 --- /dev/null +++ b/web/app/components/app/deploy/environment-table/row.tsx @@ -0,0 +1,79 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { AccessPoint } from '../access-point' +import type { UndeployHandler } from './types' +import { ACCESS_POINT_ORDER, getAccessPointHref } from '../access-point' +import { AccessPointIcon } from '../shared/access-point-icon' +import { DeploymentStatus } from '../shared/deployment-status' +import { VersionLabel } from '../shared/version-label' +import { ActivityCell } from './activity-cell' +import { EnvironmentRowActions } from './row-actions' + +export function EnvironmentRow({ + appId, + row, + onChangeVersion, + onDeployLatest, + onRedeploy, + onUndeploy, +}: { + appId: string + row: EnvironmentDeployment + onChangeVersion?: (deployment: EnvironmentDeployment) => void + onDeployLatest?: (deployment: EnvironmentDeployment) => void + onRedeploy?: (deployment: EnvironmentDeployment) => void + onUndeploy?: UndeployHandler +}) { + const isAccessPointActive = (accessPoint: AccessPoint) => { + if (accessPoint === 'webApp') return row.access.enable_site + if (accessPoint === 'serviceApi') return row.access.enable_api + return false + } + + return ( + + +
+ + + + + {row.environment.display_name} + +
+ + + + + + + + + + + +
+ {ACCESS_POINT_ORDER.map((accessPoint) => ( + + ))} +
+ + + + + + ) +} diff --git a/web/app/components/app/deploy/environment-table/types.ts b/web/app/components/app/deploy/environment-table/types.ts new file mode 100644 index 00000000000..2df7dfe882b --- /dev/null +++ b/web/app/components/app/deploy/environment-table/types.ts @@ -0,0 +1,3 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' + +export type UndeployHandler = (deployment: EnvironmentDeployment) => Promise | void diff --git a/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx b/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx new file mode 100644 index 00000000000..375d98c729e --- /dev/null +++ b/web/app/components/app/deploy/environment-table/undeploy-confirm-dialog/index.tsx @@ -0,0 +1,74 @@ +'use client' + +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { useTranslation } from 'react-i18next' + +type UndeployConfirmDialogProps = { + environmentName: string + isPending?: boolean + open: boolean + versionName: string + onConfirm: () => void + onOpenChange: (open: boolean) => void +} + +export function UndeployConfirmDialog({ + environmentName, + isPending = false, + open, + versionName, + onConfirm, + onOpenChange, +}: UndeployConfirmDialogProps) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + + return ( + { + if (!isPending) onOpenChange(nextOpen) + }} + > + +
+ + {t(($) => $['studio.undeployConfirmTitle'], { + envName: environmentName, + versionName, + })} + + } className="flex flex-col gap-2"> +

+ {t(($) => $['studio.undeployConfirmDesc'])} +

+

+ {t(($) => $['deployTab.undeployConfirmWarning'])} +

+
+
+ + + {tCommon(($) => $['operation.cancel'])} + + + {t(($) => $['deployTab.confirmUndeploy'])} + + +
+
+ ) +} diff --git a/web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts b/web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts new file mode 100644 index 00000000000..64f3e533c24 --- /dev/null +++ b/web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts @@ -0,0 +1,149 @@ +import type { InfiniteScrollQuery } from '../use-infinite-scroll' +import { act, render } from '@testing-library/react' +import { createElement } from 'react' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { useInfiniteScroll } from '../use-infinite-scroll' + +let intersectionCallback: IntersectionObserverCallback | undefined +let intersectionOptions: IntersectionObserverInit | undefined +const observe = vi.fn() +const disconnect = vi.fn() +const originalIntersectionObserver = globalThis.IntersectionObserver + +class MockIntersectionObserver implements IntersectionObserver { + readonly root: Element | Document | null + readonly rootMargin: string + readonly scrollMargin = '' + readonly thresholds: ReadonlyArray + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + intersectionCallback = callback + intersectionOptions = options + this.root = options?.root ?? null + this.rootMargin = options?.rootMargin ?? '' + this.thresholds = Array.isArray(options?.threshold) + ? options.threshold + : [options?.threshold ?? 0] + } + + observe = observe + unobserve = vi.fn() + disconnect = disconnect + takeRecords = () => [] +} + +function TestInfiniteScroll({ query }: { query: InfiniteScrollQuery }) { + const { rootRef, sentinelRef } = useInfiniteScroll(query) + + return createElement( + 'div', + { ref: rootRef, 'data-testid': 'scroll-root' }, + createElement('div', { ref: sentinelRef, 'data-testid': 'scroll-sentinel' }), + ) +} + +function createQuery(overrides: Partial = {}): InfiniteScrollQuery { + return { + error: null, + fetchNextPage: vi.fn(() => Promise.resolve()), + hasNextPage: true, + isFetching: false, + isFetchingNextPage: false, + isLoading: false, + ...overrides, + } +} + +function triggerIntersection(isIntersecting: boolean) { + if (!intersectionCallback) + throw new Error('Expected IntersectionObserver callback to be registered') + + intersectionCallback( + [{ isIntersecting } as IntersectionObserverEntry], + {} as IntersectionObserver, + ) +} + +describe('deploy useInfiniteScroll', () => { + beforeEach(() => { + vi.clearAllMocks() + intersectionCallback = undefined + intersectionOptions = undefined + globalThis.IntersectionObserver = + MockIntersectionObserver as unknown as typeof IntersectionObserver + }) + + afterAll(() => { + globalThis.IntersectionObserver = originalIntersectionObserver + }) + + it('should observe the sentinel within the version list', () => { + const view = render(createElement(TestInfiniteScroll, { query: createQuery() })) + const root = view.getByTestId('scroll-root') + const sentinel = view.getByTestId('scroll-sentinel') + + expect(observe).toHaveBeenCalledWith(sentinel) + expect(intersectionOptions).toMatchObject({ + root, + rootMargin: '0px 0px 300px 0px', + threshold: 0, + }) + }) + + it('should fetch the next page when the sentinel intersects', () => { + const query = createQuery() + render(createElement(TestInfiniteScroll, { query })) + + triggerIntersection(true) + + expect(query.fetchNextPage).toHaveBeenCalledOnce() + expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false }) + }) + + it.each([ + ['there is no next page', { hasNextPage: false }], + ['the first page is loading', { isLoading: true }], + ['another request is running', { isFetching: true }], + ['the next page is loading', { isFetchingNextPage: true }], + ['the query has failed', { error: new Error('load failed') }], + ] as const)('should not observe when %s', (_label, overrides) => { + const query = createQuery(overrides) + + render(createElement(TestInfiniteScroll, { query })) + + expect(observe).not.toHaveBeenCalled() + }) + + it('should lock pagination until the current request settles', async () => { + let resolveFetch: (value?: unknown) => void = () => undefined + const fetchNextPage = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve + }), + ) + render( + createElement(TestInfiniteScroll, { + query: createQuery({ fetchNextPage }), + }), + ) + + triggerIntersection(true) + triggerIntersection(true) + + expect(fetchNextPage).toHaveBeenCalledOnce() + + await act(async () => { + resolveFetch() + await Promise.resolve() + }) + }) + + it('should disconnect the observer on unmount', () => { + const view = render(createElement(TestInfiniteScroll, { query: createQuery() })) + + view.unmount() + + expect(disconnect).toHaveBeenCalled() + }) +}) diff --git a/web/app/components/app/deploy/hooks/use-infinite-scroll.ts b/web/app/components/app/deploy/hooks/use-infinite-scroll.ts new file mode 100644 index 00000000000..f66509a3bfe --- /dev/null +++ b/web/app/components/app/deploy/hooks/use-infinite-scroll.ts @@ -0,0 +1,142 @@ +import type { RefCallback } from 'react' +import { useCallback, useEffect, useRef } from 'react' + +type FetchNextPageOptions = { + cancelRefetch?: boolean +} + +export type InfiniteScrollQuery = { + error?: unknown + fetchNextPage: (options?: FetchNextPageOptions) => Promise | unknown + hasNextPage?: boolean + isFetching?: boolean + isFetchingNextPage: boolean + isLoading?: boolean +} + +type UseInfiniteScrollResult = { + rootRef: RefCallback + sentinelRef: RefCallback +} + +type ObservedTarget = { + root: TRoot + sentinel: TTarget +} + +const ROOT_MARGIN = '0px 0px 300px 0px' + +function canFetchNextPage(query: InfiniteScrollQuery) { + return ( + Boolean(query.hasNextPage) && + !query.isLoading && + !query.isFetching && + !query.isFetchingNextPage && + !query.error + ) +} + +export function useInfiniteScroll< + TRoot extends Element = HTMLDivElement, + TTarget extends Element = HTMLDivElement, +>(query: InfiniteScrollQuery): UseInfiniteScrollResult { + const observerRef = useRef(null) + const observedTargetRef = useRef | null>(null) + const rootRef = useRef(null) + const sentinelRef = useRef(null) + const loadingLockRef = useRef(false) + const latestQueryRef = useRef(query) + + latestQueryRef.current = query + + const disconnectObserver = useCallback(() => { + observerRef.current?.disconnect() + observerRef.current = null + observedTargetRef.current = null + }, []) + + const connectObserver = useCallback(() => { + const root = rootRef.current + const sentinel = sentinelRef.current + + if ( + !canFetchNextPage(query) || + !root || + !sentinel || + typeof IntersectionObserver === 'undefined' + ) { + disconnectObserver() + return + } + + const observedTarget = observedTargetRef.current + if ( + observerRef.current && + observedTarget?.root === root && + observedTarget.sentinel === sentinel + ) { + return + } + + disconnectObserver() + + const observer = new IntersectionObserver( + ([entry]) => { + const latestQuery = latestQueryRef.current + + if (!entry?.isIntersecting || !canFetchNextPage(latestQuery) || loadingLockRef.current) { + return + } + + loadingLockRef.current = true + + const releaseLock = () => { + loadingLockRef.current = false + } + + try { + const nextPage = latestQuery.fetchNextPage({ cancelRefetch: false }) + void Promise.resolve(nextPage).then(releaseLock, releaseLock) + } catch { + releaseLock() + } + }, + { + root, + rootMargin: ROOT_MARGIN, + threshold: 0, + }, + ) + + observer.observe(sentinel) + observerRef.current = observer + observedTargetRef.current = { root, sentinel } + }, [disconnectObserver, query]) + + const setRootRef = useCallback( + (node: TRoot | null) => { + rootRef.current = node + connectObserver() + }, + [connectObserver], + ) + + const setSentinelRef = useCallback( + (node: TTarget | null) => { + sentinelRef.current = node + connectObserver() + }, + [connectObserver], + ) + + useEffect(() => { + connectObserver() + + return disconnectObserver + }, [connectObserver, disconnectObserver]) + + return { + rootRef: setRootRef, + sentinelRef: setSentinelRef, + } +} diff --git a/web/app/components/app/deploy/index.tsx b/web/app/components/app/deploy/index.tsx new file mode 100644 index 00000000000..c22034584dd --- /dev/null +++ b/web/app/components/app/deploy/index.tsx @@ -0,0 +1,155 @@ +'use client' + +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentDialogRequest } from './deployment-dialog/types' +import type { DocPathWithoutLang } from '@/types/doc-paths' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import Loading from '@/app/components/base/loading' +import { useDocLink } from '@/context/i18n' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { AppModeEnum } from '@/types/app' +import { getAppACLCapabilities } from '@/utils/permission' +import { BuiltInEnvironmentCard } from './built-in-environment-card' +import { DeploymentDialog } from './deployment-dialog' +import { EnvironmentTable } from './environment-table' +import { AppDeployStateBoundary, latestAppWorkflowVersionAtom } from './state' +import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './use-refresh-app-environments-after-deployment-polling' +import { useUndeployWorkflow } from './use-undeploy-workflow' +import { toDeploymentVersion } from './version' + +function AppDeployContent({ appId }: { appId: string }) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const { t: tWorkflow } = useTranslation('workflow') + const docLink = useDocLink() + // TODO: Replace useDocLink with the EE-specific generator for the versioned + // `en/3.13.x/use/deploy/overview.mdx` URL once it is available. + const deployOverviewDocUrl = docLink('/use/deploy/overview' as DocPathWithoutLang) + const [deploymentRequest, setDeploymentRequest] = useState() + const latestVersion = useAtomValue(latestAppWorkflowVersionAtom) + useRefreshAppEnvironmentsAfterDeploymentPolling(appId) + const undeployWorkflow = useUndeployWorkflow(appId) + + const handleRedeploy = (deployment: EnvironmentDeployment) => { + const deploymentState = deployment.deployment + const version = + deploymentState?.latest_operation?.target_version ?? deploymentState?.current_version + const environment = deployment.environment.display_name + const environmentId = deployment.environment.id + + if (!version) { + setDeploymentRequest({ + environment, + environmentId, + kind: 'changeVersion', + }) + return + } + + setDeploymentRequest({ + currentVersionId: deploymentState?.current_version?.id, + environment, + environmentId, + initialVersion: toDeploymentVersion( + version, + tWorkflow(($) => $['versionHistory.defaultName']), + ), + kind: 'redeploy', + }) + } + + return ( + <> +
+
+
+

+ {tCommon(($) => $['appMenus.deploy'])} +

+
+

+ {t(($) => $['studio.description'])} + + {tCommon(($) => $['operation.learnMore'])} + +

+
+ +
+ + + setDeploymentRequest({ + environment: environment.display_name, + environmentId: environment.id, + kind: 'deploy', + }) + } + onChangeVersion={(deployment) => + setDeploymentRequest({ + currentVersionId: deployment.deployment?.current_version?.id, + environment: deployment.environment.display_name, + environmentId: deployment.environment.id, + kind: 'changeVersion', + }) + } + onDeployLatest={(deployment) => { + if (!latestVersion) return + + setDeploymentRequest({ + currentVersionId: deployment.deployment?.current_version?.id, + environment: deployment.environment.display_name, + environmentId: deployment.environment.id, + initialVersion: latestVersion, + kind: 'deployLatest', + }) + }} + onRedeploy={handleRedeploy} + onUndeploy={undeployWorkflow} + /> +
+
+ setDeploymentRequest(undefined)} + /> + + ) +} + +export default function AppDeploy() { + const appDetail = useAppStore((state) => state.appDetail) + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + + if (!appDetail) return + + const canDeploy = getAppACLCapabilities(appDetail.permission_keys, { + currentUserId, + resourceMaintainer: appDetail.maintainer, + workspacePermissionKeys, + }).canDeploy + + if (appDetail.mode !== AppModeEnum.WORKFLOW || !canDeploy) return null + + return ( + + + + ) +} diff --git a/web/app/components/app/deploy/shared/access-point-icon.tsx b/web/app/components/app/deploy/shared/access-point-icon.tsx new file mode 100644 index 00000000000..734e9b694ee --- /dev/null +++ b/web/app/components/app/deploy/shared/access-point-icon.tsx @@ -0,0 +1,69 @@ +'use client' + +import type { AccessPoint } from '../access-point' +import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' +import Link from '@/next/link' + +const ACCESS_POINT_ICON_CLASS_NAMES: Record = { + mcp: 'i-custom-vender-integrations-mcp', + serviceApi: 'i-custom-vender-knowledge-api-aggregate', + trigger: 'i-custom-vender-integrations-trigger', + webApp: 'i-ri-robot-2-line', +} + +function useAccessPointLabels() { + const { t: tAgent } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + + return { + mcp: 'MCP', + serviceApi: tAgent(($) => $['agentDetail.access.serviceApi.title']), + trigger: tCommon(($) => $['settings.trigger']), + webApp: tAgent(($) => $['agentDetail.access.webApp.title']), + } satisfies Record +} + +export function AccessPointIcon({ + active, + accessPoint, + href, +}: { + active: boolean + accessPoint: AccessPoint + href: string +}) { + const { t } = useTranslation('agentV2') + const labels = useAccessPointLabels() + const status = active + ? t(($) => $['agentDetail.access.status.inService']) + : t(($) => $['agentDetail.access.status.outOfService']) + const label = `${labels[accessPoint]} · ${status}` + const triggerClassName = cn( + 'flex size-5 shrink-0 items-center justify-center rounded-md border border-divider-regular text-text-secondary shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid', + active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed opacity-30', + ) + const icon = ( + + ) + + return ( + + + {icon} + + ) : ( + + ) + } + /> + {label} + + ) +} diff --git a/web/app/components/app/deploy/shared/deployment-status.tsx b/web/app/components/app/deploy/shared/deployment-status.tsx new file mode 100644 index 00000000000..de7cac3dc53 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-status.tsx @@ -0,0 +1,74 @@ +'use client' + +import type { DeploymentStatus as DeploymentStatusValue } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' +import { DeploymentStatus as DeploymentStatusEnum } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { cn } from '@langgenius/dify-ui/cn' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { useTranslation } from 'react-i18next' + +const STATUS_TEXT_CLASS_NAMES: Record = { + [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNSPECIFIED]: 'text-text-tertiary', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYED]: 'text-text-tertiary', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_DEPLOYING]: 'text-util-colors-blue-light-blue-light-600', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING]: 'text-util-colors-green-green-600', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYING]: + 'text-util-colors-blue-light-blue-light-600', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_INVALID]: 'text-util-colors-red-red-600', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_FAILED]: 'text-util-colors-red-red-600', +} + +const STATUS_DOT: Partial> = { + [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNSPECIFIED]: 'disabled', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYED]: 'disabled', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING]: 'success', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_INVALID]: 'error', + [DeploymentStatusEnum.DEPLOYMENT_STATUS_FAILED]: 'error', +} + +function getStatusLabel( + status: DeploymentStatusValue, + t: ReturnType>['t'], +) { + switch (status) { + case DeploymentStatusEnum.DEPLOYMENT_STATUS_DEPLOYING: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_DEPLOYING']) + case DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_READY']) + case DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYING: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING']) + case DeploymentStatusEnum.DEPLOYMENT_STATUS_FAILED: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_FAILED']) + case DeploymentStatusEnum.DEPLOYMENT_STATUS_INVALID: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_INVALID']) + case DeploymentStatusEnum.DEPLOYMENT_STATUS_UNSPECIFIED: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED']) + default: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED']) + } +} + +export function DeploymentStatus({ status }: { status?: DeploymentStatusValue }) { + const { t } = useTranslation('deployments') + const resolvedStatus = status ?? DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYED + const label = getStatusLabel(resolvedStatus, t) + const isInProgress = + resolvedStatus === DeploymentStatusEnum.DEPLOYMENT_STATUS_DEPLOYING || + resolvedStatus === DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYING + + return ( + + {isInProgress ? ( + + ) : ( + + )} + {label} + + ) +} diff --git a/web/app/components/app/deploy/shared/version-label.tsx b/web/app/components/app/deploy/shared/version-label.tsx new file mode 100644 index 00000000000..8eb8559cff3 --- /dev/null +++ b/web/app/components/app/deploy/shared/version-label.tsx @@ -0,0 +1,112 @@ +'use client' + +import type { WorkflowVersion } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' + +export function VersionLabel({ + version, + versionsBehind, + isLatest, +}: { + version?: WorkflowVersion + versionsBehind?: number + isLatest?: boolean +}) { + const { t } = useTranslation('deployments') + const { formatTimeFromNow } = useFormatTimeFromNow() + + if (!version) return -- + + const name = getWorkflowVersionName( + version, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + ) + const description = version.marked_comment + const publishedAt = version.created_at === undefined ? undefined : version.created_at * 1000 + const publishedBy = version.created_by?.name + const latest = isLatest ?? versionsBehind === 0 + const behind = versionsBehind !== undefined && versionsBehind > 0 ? versionsBehind : undefined + const versionsBehindLabel = + behind === undefined + ? '' + : behind === 1 + ? t(($) => $['studio.versionsBehind_one'], { count: behind }) + : t(($) => $['studio.versionsBehind_other'], { count: behind }) + + return ( +
+ + + {name} + + } + /> + +
+ {name} + {publishedAt !== undefined && publishedBy && ( +

+ {t(($) => $['common.publishedBy'], { + ns: 'workflow', + time: formatTimeFromNow(publishedAt), + author: publishedBy, + })} +

+ )} + {description && ( + <> + + + {description} + + + )} +
+
+
+ {latest && ( + + {t(($) => $['overview.chip.latest'])} + + )} + {behind !== undefined && ( + + + + {behind} + + } + /> + {versionsBehindLabel} + + )} +
+ ) +} diff --git a/web/app/components/app/deploy/state.ts b/web/app/components/app/deploy/state.ts new file mode 100644 index 00000000000..114b73e8a31 --- /dev/null +++ b/web/app/components/app/deploy/state.ts @@ -0,0 +1,268 @@ +'use client' + +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { ReactNode } from 'react' +import { + DeploymentOperationStatus, + DeploymentOperationType, + DeploymentStatus, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { skipToken } from '@tanstack/react-query' +import { atom } from 'jotai' +import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' +import { selectAtom, useHydrateAtoms } from 'jotai/utils' +import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' +import { + appWorkflowQueryOptions, + appWorkflowVersionsInfiniteQueryOptions, +} from '@/service/workflow-queries' +import { toDeploymentVersion } from './version' + +const DEPLOYMENT_STATUS_POLLING_INTERVAL = 3000 + +type EnvironmentDeploymentActionKind = + | 'changeVersion' + | 'deployLatest' + | 'redeploy' + | 'retry' + | 'undeploy' + +export type EnvironmentDeploymentAction = { + disabled: boolean + kind: EnvironmentDeploymentActionKind +} + +const appDeployAppIdAtom = atom(null) +const defaultWorkflowVersionNameAtom = atom('') + +export function AppDeployStateBoundary({ + appId, + children, +}: { + appId: string + children: ReactNode +}) { + const { t } = useTranslation('workflow') + + useHydrateAtoms( + [ + [appDeployAppIdAtom, appId], + [defaultWorkflowVersionNameAtom, t(($) => $['versionHistory.defaultName'])], + ] as const, + { + dangerouslyForceHydrate: true, + }, + ) + + return children +} + +const latestPublishedWorkflowQueryAtom = atomWithQuery((get) => { + return appWorkflowQueryOptions(get(appDeployAppIdAtom)) +}) + +const latestPublishedWorkflowAtom = selectAtom( + latestPublishedWorkflowQueryAtom, + (query) => query.data, +) + +export const latestAppWorkflowVersionAtom = atom((get) => { + const workflow = get(latestPublishedWorkflowAtom) + if (!workflow) return + + return toDeploymentVersion(workflow, get(defaultWorkflowVersionNameAtom), workflow.id) +}) + +const appWorkflowVersionsQueryAtom = atomWithInfiniteQuery((get) => { + return appWorkflowVersionsInfiniteQueryOptions(get(appDeployAppIdAtom)) +}) + +const appWorkflowVersionsDataAtom = selectAtom(appWorkflowVersionsQueryAtom, (query) => query.data) + +export const appWorkflowVersionsAtom = atom((get) => { + const latestWorkflowId = get(latestPublishedWorkflowAtom)?.id + const defaultName = get(defaultWorkflowVersionNameAtom) + const pages = get(appWorkflowVersionsDataAtom)?.pages ?? [] + + return pages.flatMap((page) => + page.items + .filter((workflow) => workflow.version !== 'draft') + .map((workflow) => toDeploymentVersion(workflow, defaultName, latestWorkflowId)), + ) +}) + +export const appWorkflowVersionsErrorAtom = selectAtom( + appWorkflowVersionsQueryAtom, + (query) => query.error, +) + +export const appWorkflowVersionsFetchNextPageAtom = selectAtom( + appWorkflowVersionsQueryAtom, + (query) => query.fetchNextPage, +) + +export const appWorkflowVersionsHasNextPageAtom = selectAtom( + appWorkflowVersionsQueryAtom, + (query) => query.hasNextPage, +) + +export const appWorkflowVersionsIsFetchingAtom = selectAtom( + appWorkflowVersionsQueryAtom, + (query) => query.isFetching, +) + +export const appWorkflowVersionsIsFetchingNextPageAtom = selectAtom( + appWorkflowVersionsQueryAtom, + (query) => query.isFetchingNextPage, +) + +export const appWorkflowVersionsIsLoadingAtom = selectAtom( + appWorkflowVersionsQueryAtom, + (query) => query.isLoading, +) + +const appEnvironmentsQueryAtom = atomWithQuery((get) => { + const appId = get(appDeployAppIdAtom) + + return consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: appId + ? { + params: { + app_id: appId, + }, + } + : skipToken, + }) +}) + +const appEnvironmentsAtom = selectAtom(appEnvironmentsQueryAtom, (query) => query.data?.data) + +export const appEnvironmentUsageAtom = atom((get) => { + const environments = get(appEnvironmentsAtom) + if (!environments) return + + return { + total: environments.length, + used: environments.filter((environment) => environment.in_use).length, + } +}) + +export const undeployedAppEnvironmentsAtom = atom( + (get) => get(appEnvironmentsAtom)?.filter((environment) => environment.in_use === false) ?? [], +) + +export function isEnvironmentDeploymentInProgress(deployment?: EnvironmentDeployment) { + const status = deployment?.deployment?.status + + return ( + status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING || + status === DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING + ) +} + +export function hasInProgressEnvironmentDeployments(deployments: EnvironmentDeployment[]) { + return deployments.some(isEnvironmentDeploymentInProgress) +} + +const appEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => { + const appId = get(appDeployAppIdAtom) + + return consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions( + { + input: appId + ? { + params: { + app_id: appId, + }, + } + : skipToken, + refetchInterval: (query) => { + const deployments = query.state.data?.environment_deployments ?? [] + return hasInProgressEnvironmentDeployments(deployments) + ? DEPLOYMENT_STATUS_POLLING_INTERVAL + : false + }, + }, + ) +}) + +export const appEnvironmentDeploymentsAtom = selectAtom( + appEnvironmentDeploymentsQueryAtom, + (query) => query.data?.environment_deployments, +) + +export const appEnvironmentDeploymentsIsLoadingAtom = selectAtom( + appEnvironmentDeploymentsQueryAtom, + (query) => query.isLoading, +) + +export const appEnvironmentDeploymentsIsErrorAtom = selectAtom( + appEnvironmentDeploymentsQueryAtom, + (query) => query.isError, +) + +export const appEnvironmentDeploymentsIsFetchingAtom = selectAtom( + appEnvironmentDeploymentsQueryAtom, + (query) => query.isFetching, +) + +export const appEnvironmentDeploymentsRefetchAtom = selectAtom( + appEnvironmentDeploymentsQueryAtom, + (query) => query.refetch, +) + +function deploymentActions( + kinds: EnvironmentDeploymentActionKind[], + disabled = false, +): EnvironmentDeploymentAction[] { + return kinds.map((kind) => ({ disabled, kind })) +} + +function isLatestDeployOperationFailed(row: EnvironmentDeployment) { + const operation = row.deployment?.latest_operation + + return ( + operation?.type === DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY && + operation.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED + ) +} + +export function getEnvironmentDeploymentActions( + row: EnvironmentDeployment, +): EnvironmentDeploymentAction[] { + const deployment = row.deployment + // Currently, this case may not be possible, but we still handle it to avoid potential errors in the future. + if (!deployment || deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYED) { + return deploymentActions(['deployLatest', 'changeVersion']) + } + + const hasCurrentVersion = Boolean(deployment.current_version) + const hasFailedDeploy = + deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_FAILED || + (deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING && + isLatestDeployOperationFailed(row)) + + if (hasFailedDeploy) { + return deploymentActions( + hasCurrentVersion ? ['retry', 'changeVersion', 'undeploy'] : ['retry', 'changeVersion'], + ) + } + + if ( + deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING || + deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING + ) { + return deploymentActions(['changeVersion', 'redeploy', 'undeploy'], true) + } + + if (deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING) { + if ((deployment.versions_behind ?? 0) > 0) { + return deploymentActions(['deployLatest', 'changeVersion', 'redeploy', 'undeploy']) + } + + return deploymentActions(['changeVersion', 'redeploy', 'undeploy']) + } + + return deploymentActions(['redeploy', 'undeploy']) +} diff --git a/web/app/components/app/deploy/use-deploy-workflow.ts b/web/app/components/app/deploy/use-deploy-workflow.ts new file mode 100644 index 00000000000..9fb87df2e80 --- /dev/null +++ b/web/app/components/app/deploy/use-deploy-workflow.ts @@ -0,0 +1,41 @@ +'use client' + +import type { DeployWorkflowResponse } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { consoleQuery } from '@/service/client' + +export function useDeployWorkflow({ + appId, + invalidateAppEnvironmentsOnSuccess = true, + onSuccess, +}: { + appId?: string + invalidateAppEnvironmentsOnSuccess?: boolean + onSuccess?: (response: DeployWorkflowResponse) => Promise | void +}) { + const queryClient = useQueryClient() + const mutationOptions = + consoleQuery.enterprise.appDeploy.deploymentService.deployWorkflow.mutationOptions() + + return useMutation({ + ...mutationOptions, + onSuccess: async (response, variables, onMutateResult, context) => { + await mutationOptions.onSuccess?.(response, variables, onMutateResult, context) + if (!appId) return + + if (invalidateAppEnvironmentsOnSuccess) { + const appEnvironmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + await queryClient.invalidateQueries({ queryKey: appEnvironmentsQuery.queryKey }) + } + + await onSuccess?.(response) + }, + }) +} diff --git a/web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts b/web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts new file mode 100644 index 00000000000..1a58435ee18 --- /dev/null +++ b/web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts @@ -0,0 +1,54 @@ +'use client' + +import type { ListEnvironmentDeploymentsResponse } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { hashKey, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { consoleQuery } from '@/service/client' +import { hasInProgressEnvironmentDeployments } from './state' + +export function useRefreshAppEnvironmentsAfterDeploymentPolling(appId: string) { + const queryClient = useQueryClient() + + useEffect(() => { + const appEnvironmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + const environmentDeploymentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { + params: { + app_id: appId, + }, + }, + }) + const environmentDeploymentsQueryHash = hashKey(environmentDeploymentsQuery.queryKey) + const isPolling = () => { + const data = queryClient.getQueryData( + environmentDeploymentsQuery.queryKey, + ) + + return hasInProgressEnvironmentDeployments(data?.environment_deployments ?? []) + } + let wasPolling = isPolling() + + return queryClient.getQueryCache().subscribe((event) => { + if ( + event.type !== 'updated' || + event.action.type !== 'success' || + event.query.queryHash !== environmentDeploymentsQueryHash + ) + return + + const isCurrentlyPolling = isPolling() + if (wasPolling && !isCurrentlyPolling) { + void queryClient.invalidateQueries({ queryKey: appEnvironmentsQuery.queryKey }) + } + wasPolling = isCurrentlyPolling + }) + }, [appId, queryClient]) +} diff --git a/web/app/components/app/deploy/use-undeploy-workflow.ts b/web/app/components/app/deploy/use-undeploy-workflow.ts new file mode 100644 index 00000000000..2622202ddb2 --- /dev/null +++ b/web/app/components/app/deploy/use-undeploy-workflow.ts @@ -0,0 +1,28 @@ +'use client' + +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { useMutation } from '@tanstack/react-query' +import { useCallback } from 'react' +import { consoleQuery } from '@/service/client' + +export function useUndeployWorkflow(appId: string) { + const { mutateAsync } = useMutation( + consoleQuery.enterprise.appDeploy.deploymentService.undeployWorkflow.mutationOptions(), + ) + + return useCallback( + (deployment: EnvironmentDeployment) => { + const workflowId = deployment.deployment?.current_version?.id + if (!workflowId) return + + return mutateAsync({ + params: { + app_id: appId, + environment_id: deployment.environment.id, + workflow_id: workflowId, + }, + }).then(() => undefined) + }, + [appId, mutateAsync], + ) +} diff --git a/web/app/components/app/deploy/version.ts b/web/app/components/app/deploy/version.ts new file mode 100644 index 00000000000..875e4fc2ffa --- /dev/null +++ b/web/app/components/app/deploy/version.ts @@ -0,0 +1,41 @@ +import type { WorkflowVersionNameSource } from '@/app/components/workflow/utils/version' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' + +export type DeploymentVersion = { + behind?: number + description?: string + id: string + latest?: boolean + name: string + publishedAt?: number + publishedBy?: string + tags?: string[] +} + +type DeploymentVersionSource = WorkflowVersionNameSource & { + id: string + created_at?: number + created_by?: { name?: string | null } | null + environments?: Array<{ name: string }> + marked_comment?: string | null +} + +export function toDeploymentVersion( + version: DeploymentVersionSource, + defaultName: string, + latestWorkflowId?: string, +): DeploymentVersion { + const deploymentVersion: DeploymentVersion = { + description: version.marked_comment || undefined, + id: version.id, + name: getWorkflowVersionName(version, defaultName), + } + + if (latestWorkflowId !== undefined) deploymentVersion.latest = version.id === latestWorkflowId + if (version.created_at !== undefined) deploymentVersion.publishedAt = version.created_at * 1000 + if (version.created_by?.name) deploymentVersion.publishedBy = version.created_by.name + if ('environments' in version) + deploymentVersion.tags = version.environments?.map((environment) => environment.name) ?? [] + + return deploymentVersion +} diff --git a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx b/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx deleted file mode 100644 index a2f597bdcd8..00000000000 --- a/web/app/components/app/overview/__tests__/app-card-sections.spec.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import type { FormEvent } from 'react' -import type { AppDetailResponse } from '@/models/app' -import { fireEvent, render, screen, within } from '@testing-library/react' -import { InputVarType } from '@/app/components/workflow/types' -import { AccessMode } from '@/models/access-control' -import { withSelectorKey } from '@/test/i18n-mock' -import { AppModeEnum } from '@/types/app' -import { - AppCardAccessControlSection, - AppCardDialogs, - AppCardOperations, - AppCardUrlSection, - createAppCardOperations, - WorkflowLaunchDialog, -} from '../app-card-sections' - -vi.mock('../settings', () => ({ - default: () =>
, -})) - -vi.mock('../embedded', () => ({ - default: () =>
, -})) - -vi.mock('../customize', () => ({ - default: () =>
, -})) - -vi.mock('../../app-access-control', () => { - const MockAccessControl = ({ - onClose, - onConfirm, - }: { - onClose: () => void - onConfirm: () => void - }) => ( -
- - -
- ) - - return { - default: MockAccessControl, - AccessControl: MockAccessControl, - } -}) - -describe('app-card-sections', () => { - const t = withSelectorKey((key: string) => key) - - it('should build operations with the expected disabled state', () => { - const onLaunch = vi.fn() - const operations = createAppCardOperations({ - operationKeys: ['launch', 'settings'], - t: t as never, - runningStatus: false, - triggerModeDisabled: false, - onLaunch, - onEmbedded: vi.fn(), - onCustomize: vi.fn(), - onSettings: vi.fn(), - onDevelop: vi.fn(), - }) - - expect(operations[0]).toMatchObject({ - key: 'launch', - disabled: true, - label: 'overview.appInfo.launch', - }) - expect(operations[1]).toMatchObject({ - key: 'settings', - disabled: false, - label: 'overview.appInfo.settings.entry', - }) - }) - - it('should render the access-control section and call onClick', () => { - const onClick = vi.fn() - render( - , - ) - - fireEvent.click(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)) - - expect( - screen.getByText(/(?:^|\.)accessControlDialog\.accessItems\.specific(?=$|:)/), - ).toBeInTheDocument() - expect(onClick).toHaveBeenCalledTimes(1) - }) - - it('should render operation buttons and execute enabled actions', () => { - const onLaunch = vi.fn() - const onLaunchConfig = vi.fn() - const operations = createAppCardOperations({ - operationKeys: ['launch', 'embedded'], - t: t as never, - runningStatus: true, - triggerModeDisabled: false, - onLaunch, - onEmbedded: vi.fn(), - onCustomize: vi.fn(), - onSettings: vi.fn(), - onDevelop: vi.fn(), - }) - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) - fireEvent.click(screen.getByRole('button', { name: /operation\.config/i })) - - expect(onLaunch).toHaveBeenCalledTimes(1) - expect(onLaunchConfig).toHaveBeenCalledTimes(1) - expect(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)).not.toHaveAttribute( - 'title', - ) - expect( - screen.getByRole('button', { name: /overview\.appInfo\.embedded\.entry/i }), - ).toBeInTheDocument() - }) - - it('should expose native titles only for truncated operation labels', () => { - const operations = createAppCardOperations({ - operationKeys: ['launch', 'embedded', 'customize', 'settings', 'develop'], - t: t as never, - runningStatus: true, - triggerModeDisabled: false, - onLaunch: vi.fn(), - onEmbedded: vi.fn(), - onCustomize: vi.fn(), - onSettings: vi.fn(), - onDevelop: vi.fn(), - }) - - render() - - const untruncatedLabels = [ - 'overview.appInfo.launch', - 'overview.appInfo.embedded.entry', - 'overview.apiInfo.doc', - ] - untruncatedLabels.forEach((label) => { - expect(screen.getByText(label)).toBeInTheDocument() - expect(screen.getByText(label)).not.toHaveAttribute('title') - }) - - const truncatedLabels = ['overview.appInfo.customize.entry', 'overview.appInfo.settings.entry'] - truncatedLabels.forEach((label) => { - expect(screen.getByText(label)).toBeInTheDocument() - expect(screen.getByRole('button', { name: label })).toHaveAttribute('title', label) - }) - }) - - it('should keep customize available for web app cards that are not completion or workflow apps', () => { - const operations = createAppCardOperations({ - operationKeys: ['customize'], - t: t as never, - runningStatus: true, - triggerModeDisabled: false, - onLaunch: vi.fn(), - onEmbedded: vi.fn(), - onCustomize: vi.fn(), - onSettings: vi.fn(), - onDevelop: vi.fn(), - }) - - render() - - expect( - screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.customize\.entry(?=$|:)/ }), - ).toHaveAttribute('title', 'overview.appInfo.customize.entry') - expect(AppModeEnum.CHAT).toBe('chat') - }) - - it('should invoke regenerate dialog callbacks from the url section', () => { - const onRegenerate = vi.fn() - const onHideRegenerateConfirm = vi.fn() - - render( - , - ) - - const dialog = screen.getByRole('alertdialog') - - fireEvent.click(within(dialog).getByRole('button', { name: /operation\.cancel/i })) - expect(onHideRegenerateConfirm).toHaveBeenCalled() - - fireEvent.click(within(dialog).getByRole('button', { name: /operation\.confirm/i })) - expect(onRegenerate).toHaveBeenCalledTimes(1) - }) - - it('should disable all operations when triggerModeDisabled is true', () => { - const operations = createAppCardOperations({ - operationKeys: ['launch', 'settings'], - t: t as never, - runningStatus: true, - triggerModeDisabled: true, - onLaunch: vi.fn(), - onEmbedded: vi.fn(), - onCustomize: vi.fn(), - onSettings: vi.fn(), - onDevelop: vi.fn(), - }) - - expect(operations[0]!.disabled).toBe(true) - expect(operations[1]!.disabled).toBe(true) - }) - - it('should render WorkflowLaunchDialog and submit values', () => { - const onOpenChange = vi.fn() - const onValueChange = vi.fn() - const onSubmit = vi.fn((event: FormEvent) => { - event.preventDefault() - }) - - render( - , - ) - - expect( - screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), - ).toBeInTheDocument() - fireEvent.submit( - screen.getByRole('button', { name: /overview\.appInfo\.launch/i }).closest('form')!, - ) - expect(onSubmit).toHaveBeenCalled() - }) - - it('should return null for WorkflowLaunchDialog when no variables are provided', () => { - const { container } = render( - , - ) - - expect(container).toBeEmptyDOMElement() - }) - - it('should render AppCardDialogs with all modals for web apps', () => { - const appInfo = { - id: 'app-1', - mode: AppModeEnum.CHAT, - enable_site: true, - enable_api: false, - site: { app_base_url: 'https://example.com', access_token: 'token-1' }, - api_base_url: 'https://api.example.com', - } as never - - render( - , - ) - - expect(screen.getByTestId('settings-modal')).toBeInTheDocument() - expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() - expect(screen.getByTestId('customize-modal')).toBeInTheDocument() - expect(screen.getByTestId('access-control')).toBeInTheDocument() - }) - - it('should return null for AppCardDialogs when not an app', () => { - const { container } = render( - , - ) - - expect(container).toBeEmptyDOMElement() - }) -}) diff --git a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts index f33aea01bbc..4f5d168ebe5 100644 --- a/web/app/components/app/overview/__tests__/app-card-utils.spec.ts +++ b/web/app/components/app/overview/__tests__/app-card-utils.spec.ts @@ -1,243 +1,41 @@ -import type { AppDetailResponse } from '@/models/app' -import { BlockEnum, InputVarType } from '@/app/components/workflow/types' -import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' +import { InputVarType } from '@/app/components/workflow/types' import { basePath } from '@/utils/var' import { buildWorkflowLaunchUrl, compressAndEncodeBase64, createWorkflowLaunchInitialValues, - getAppCardDisplayState, - getAppCardOperationKeys, - getAppHiddenLaunchVariables, getEmbeddedIframeSnippet, getEmbeddedScriptSnippet, - getWorkflowHiddenStartVariables, - hasWorkflowStartNode, - isAppAccessConfigured, isWorkflowLaunchInputSupported, } from '../app-card-utils' describe('app-card-utils', () => { - const baseAppInfo = { - id: 'app-1', - mode: AppModeEnum.CHAT, - enable_site: true, - enable_api: false, - access_mode: AccessMode.PUBLIC, - api_base_url: 'https://api.example.com', - site: { - app_base_url: 'https://example.com', - access_token: 'token-1', - }, - } as AppDetailResponse - - it('should detect whether the workflow includes a start node', () => { + it('should create initial values from workflow launch defaults', () => { expect( - hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: BlockEnum.Start } }], + createWorkflowLaunchInitialValues([ + { + variable: 'secret', + label: 'Secret', + type: InputVarType.textInput, + hide: true, + default: 'prefilled', + required: false, }, - }), - ).toBe(true) - - expect( - hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: BlockEnum.Answer } }], + { + variable: 'enabled', + label: 'Enabled', + type: InputVarType.checkbox, + hide: true, + default: true, + required: false, }, - }), - ).toBe(false) - }) - - it('should return hidden workflow start variables and their initial launch values', () => { - const hiddenVariables = getWorkflowHiddenStartVariables({ - graph: { - nodes: [ - { - data: { - type: BlockEnum.Start, - variables: [ - { - variable: 'visible', - label: 'Visible', - type: InputVarType.textInput, - hide: false, - required: false, - }, - { - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - default: 'prefilled', - required: false, - }, - { - variable: 'enabled', - label: 'Enabled', - type: InputVarType.checkbox, - hide: true, - default: true, - required: false, - }, - ], - }, - }, - ], - }, - }) - - expect(hiddenVariables.map((variable) => variable.variable)).toEqual(['secret', 'enabled']) - expect(createWorkflowLaunchInitialValues(hiddenVariables)).toEqual({ + ]), + ).toEqual({ secret: 'prefilled', enabled: true, }) }) - it('should return hidden advanced-chat launch variables from the workflow start node first', () => { - const hiddenVariables = getAppHiddenLaunchVariables({ - appInfo: { - ...baseAppInfo, - mode: AppModeEnum.ADVANCED_CHAT, - model_config: { - user_input_form: [ - { - 'text-input': { - label: 'Visible', - variable: 'visible', - required: true, - max_length: 48, - default: '', - hide: false, - }, - }, - { - checkbox: { - label: 'Hidden Toggle', - variable: 'hidden_toggle', - required: false, - default: true, - hide: true, - }, - }, - ], - }, - } as AppDetailResponse, - currentWorkflow: { - graph: { - nodes: [ - { - data: { - type: BlockEnum.Start, - variables: [ - { - variable: 'start_secret', - label: 'Start Secret', - type: InputVarType.textInput, - hide: true, - default: 'from-start', - required: false, - }, - ], - }, - }, - ], - }, - }, - }) - - expect(hiddenVariables).toEqual([ - expect.objectContaining({ - variable: 'start_secret', - type: InputVarType.textInput, - default: 'from-start', - }), - ]) - }) - - it('should build the display state for a published web app', () => { - const state = getAppCardDisplayState({ - appInfo: baseAppInfo, - cardType: 'webapp', - currentWorkflow: null, - canManageWebApp: true, - canManageApi: true, - }) - - expect(state.isApp).toBe(true) - expect(state.appMode).toBe(AppModeEnum.CHAT) - expect(state.runningStatus).toBe(true) - expect(state.accessibleUrl).toBe(`https://example.com${basePath}/chat/token-1`) - }) - - it('should disable workflow cards without a graph or start node', () => { - const unpublishedState = getAppCardDisplayState({ - appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW }, - cardType: 'webapp', - currentWorkflow: null, - canManageWebApp: true, - canManageApi: true, - }) - expect(unpublishedState.appUnpublished).toBe(true) - expect(unpublishedState.toggleDisabled).toBe(true) - - const missingStartState = getAppCardDisplayState({ - appInfo: { ...baseAppInfo, mode: AppModeEnum.WORKFLOW }, - cardType: 'webapp', - currentWorkflow: { - graph: { - nodes: [{ data: { type: BlockEnum.Answer } }], - }, - }, - canManageWebApp: true, - canManageApi: true, - }) - expect(missingStartState.missingStartNode).toBe(true) - expect(missingStartState.runningStatus).toBe(false) - }) - - it('should require specific access subjects only for the specific access mode', () => { - expect( - isAppAccessConfigured( - { ...baseAppInfo, access_mode: AccessMode.PUBLIC }, - { groups: [], members: [] }, - ), - ).toBe(true) - - expect( - isAppAccessConfigured( - { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - { groups: [], members: [] }, - ), - ).toBe(false) - - expect( - isAppAccessConfigured( - { ...baseAppInfo, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }, - { groups: [{ id: 'group-1' }], members: [] }, - ), - ).toBe(true) - }) - - it('should derive operation keys for api and webapp cards', () => { - expect( - getAppCardOperationKeys({ - cardType: 'api', - appMode: AppModeEnum.COMPLETION, - canManageSettings: true, - }), - ).toEqual(['develop']) - - expect( - getAppCardOperationKeys({ - cardType: 'webapp', - appMode: AppModeEnum.CHAT, - canManageSettings: false, - }), - ).toEqual(['launch', 'embedded', 'customize']) - }) - it('should build a workflow launch URL with serialized parameters', async () => { const url = await buildWorkflowLaunchUrl({ accessibleUrl: 'https://example.com/app/workflow/token-1', diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx deleted file mode 100644 index 29445583212..00000000000 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ /dev/null @@ -1,600 +0,0 @@ -import type { ReactElement } from 'react' -import type { AppDetailResponse } from '@/models/app' -import { fireEvent, screen, waitFor, within } from '@testing-library/react' -import { InputVarType } from '@/app/components/workflow/types' -import { AccessMode } from '@/models/access-control' -import { renderWithConsoleQuery } from '@/test/console/query-data' -import { AppModeEnum } from '@/types/app' -import { AppACLPermission } from '@/utils/permission' -import { basePath } from '@/utils/var' -import AppCard from '../app-card' - -vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path: string) => `https://docs.example.com${path}`, -})) - -const render = (ui: ReactElement) => - renderWithConsoleQuery(ui, { - systemFeatures: { webapp_auth: { enabled: true } }, - }) - -const mockPush = vi.fn() -const mockSetAppDetail = vi.fn() -const mockOnChangeStatus = vi.fn() -const mockOnGenerateCode = vi.fn() -const mockFetchAppDetail = vi.fn() - -let mockWorkflow: { - graph?: { - nodes?: Array<{ data?: { type?: string; variables?: Array> } }> - } -} | null = null -let mockAccessSubjects: { groups?: unknown[]; members?: unknown[] } = { groups: [], members: [] } -let mockAppDetail: AppDetailResponse | undefined - -vi.mock('@/app/components/app/store', () => ({ - useStore: ( - selector: (state: { - appDetail: AppDetailResponse - setAppDetail: typeof mockSetAppDetail - }) => unknown, - ) => - selector({ - appDetail: mockAppDetail as AppDetailResponse, - setAppDetail: mockSetAppDetail, - }), -})) - -vi.mock('@/next/navigation', () => ({ - usePathname: () => '/app/app-1/overview', - useRouter: () => ({ - push: mockPush, - }), -})) - -vi.mock('@/service/use-workflow', () => ({ - useAppWorkflow: () => ({ - data: mockWorkflow, - }), -})) - -vi.mock('@/service/access-control/use-app-access-control', () => ({ - useAppWhiteListSubjects: () => ({ - data: mockAccessSubjects, - }), -})) - -vi.mock('@/service/apps', () => ({ - fetchAppDetail: (...args: unknown[]) => mockFetchAppDetail(...args), -})) - -vi.mock('@/app/components/develop/secret-key/secret-key-button', () => ({ - default: ({ appId }: { appId: string }) =>
{appId}
, -})) - -vi.mock('../settings', () => ({ - default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => - isShow ? ( - - ) : null, -})) - -vi.mock('../embedded', () => ({ - default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => - isShow ? ( - - ) : null, -})) - -vi.mock('../customize', () => ({ - default: ({ isShow, onClose }: { isShow: boolean; onClose: () => void }) => - isShow ? ( - - ) : null, -})) - -vi.mock('../../app-access-control', () => { - const MockAccessControl = ({ - onConfirm, - onClose, - }: { - onConfirm: () => Promise - onClose: () => void - }) => ( -
- - -
- ) - - return { - default: MockAccessControl, - AccessControl: MockAccessControl, - } -}) - -const mockWindowOpen = vi.fn() -Object.defineProperty(window, 'open', { - writable: true, - value: mockWindowOpen, -}) - -describe('AppCard', () => { - const appInfo = { - id: 'app-1', - mode: AppModeEnum.CHAT, - enable_site: true, - enable_api: true, - icon: 'app-icon', - icon_background: '#fff', - api_base_url: 'https://api.example.com', - permission_keys: [AppACLPermission.Edit, AppACLPermission.ReleaseAndVersion], - site: { - app_base_url: 'https://example.com', - access_token: 'access-token', - }, - } as AppDetailResponse - - beforeEach(() => { - vi.clearAllMocks() - mockAppDetail = { - id: 'app-1', - access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - site: { - app_base_url: 'https://example.com', - access_token: 'access-token', - }, - } as AppDetailResponse - mockWorkflow = { - graph: { - nodes: [{ data: { type: 'start' } }], - }, - } - mockAccessSubjects = { - groups: [], - members: [], - } - mockFetchAppDetail.mockResolvedValue({ - id: 'app-1', - access_mode: AccessMode.PUBLIC, - site: { - app_base_url: 'https://example.com', - access_token: 'access-token', - }, - } as AppDetailResponse) - }) - - it('should open the published webapp when launch is clicked', () => { - render() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)) - - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/chat/access-token`, - '_blank', - ) - }) - - it('should expose the web app controls as a named region', () => { - render() - - const webAppCard = screen.getByRole('region', { - name: /(?:^|\.)overview\.appInfo\.title(?=$|:)/, - }) - - expect( - within(webAppCard).getByRole('switch', { - name: /(?:^|\.)overview\.appInfo\.title(?=$|:)/, - }), - ).toBeInTheDocument() - }) - - it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => { - mockWorkflow = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], - }, - }, - ], - }, - } - - render( - , - ) - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)) - - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/workflow/access-token`, - '_blank', - ) - expect( - screen.queryByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), - ).not.toBeInTheDocument() - }) - - it('should collect hidden workflow inputs from the config action before launching the workflow web app', async () => { - mockWorkflow = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { - variable: 'secret', - label: 'Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], - }, - }, - ], - }, - } - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })) - - expect( - screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), - ).toBeInTheDocument() - - fireEvent.change(screen.getByLabelText('Secret'), { - target: { value: 'top-secret' }, - }) - fireEvent.click( - screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), - ) - - await waitFor(() => { - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/workflow/access-token?secret=${encodeURIComponent('top-secret')}`, - '_blank', - ) - }) - }) - - it('should open the chat web app directly when launch is clicked even with hidden inputs', () => { - mockWorkflow = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { - variable: 'chat_secret', - label: 'Chat Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], - }, - }, - ], - }, - } - - render( - , - ) - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)) - - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/chat/access-token`, - '_blank', - ) - expect( - screen.queryByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), - ).not.toBeInTheDocument() - }) - - it('should collect hidden chatflow inputs from the config action before launching the chat web app', async () => { - mockWorkflow = { - graph: { - nodes: [ - { - data: { - type: 'start', - variables: [ - { - variable: 'chat_secret', - label: 'Chat Secret', - type: InputVarType.textInput, - hide: true, - required: true, - default: '', - }, - ], - }, - }, - ], - }, - } - - render( - , - ) - - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.config(?=$|:)/ })) - - expect( - screen.getByText(/(?:^|\.)overview\.appInfo\.workflowLaunchHiddenInputs\.title(?=$|:)/), - ).toBeInTheDocument() - - fireEvent.change(screen.getByLabelText('Chat Secret'), { - target: { value: 'chat-secret' }, - }) - fireEvent.click( - screen.getByRole('button', { name: /(?:^|\.)overview\.appInfo\.launch(?=$|:)/ }), - ) - - await waitFor(() => { - expect(mockWindowOpen).toHaveBeenCalledWith( - `https://example.com${basePath}/chat/access-token?chat_secret=${encodeURIComponent('chat-secret')}`, - '_blank', - ) - }) - }) - - it('should show the access-control not-set badge when specific access has no subjects', () => { - render() - - expect(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)).toBeInTheDocument() - }) - - it('should hide the access-control section when release permission is missing', () => { - render( - , - ) - - expect(screen.queryByText(/(?:^|\.)publishApp\.title(?=$|:)/)).not.toBeInTheDocument() - expect(screen.queryByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)).not.toBeInTheDocument() - }) - - it('should hide the address and operation sections for unpublished workflows', () => { - mockWorkflow = null - - render( - , - ) - - expect( - screen.queryByText(/(?:^|\.)overview\.appInfo\.accessibleAddress(?=$|:)/), - ).not.toBeInTheDocument() - expect(screen.queryByText(/(?:^|\.)overview\.appInfo\.launch(?=$|:)/)).not.toBeInTheDocument() - expect(screen.getByText(/(?:^|\.)overview\.status\.disable(?=$|:)/)).toBeInTheDocument() - }) - - it('should render api operations and navigate to the develop page', () => { - render( - , - ) - - expect(screen.getByTestId('secret-key-button')).toHaveTextContent('app-1') - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.apiInfo\.doc(?=$|:)/)) - - expect(mockPush).toHaveBeenCalledWith('/app/app-1/develop') - }) - - it('should open settings embedded and customize dialogs from webapp operations', () => { - render() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.embedded\.entry(?=$|:)/)) - expect(screen.getByTestId('embedded-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.customize\.entry(?=$|:)/)) - expect(screen.getByTestId('customize-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.settings\.entry(?=$|:)/)) - expect(screen.getByTestId('settings-modal')).toBeInTheDocument() - }) - - it('should refresh app detail after confirming access control changes', async () => { - render( - , - ) - - fireEvent.click(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)) - expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() - - fireEvent.click(screen.getByText('confirm-access-control')) - - await waitFor(() => { - expect(mockFetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) - }) - expect(mockSetAppDetail).toHaveBeenCalledWith( - expect.objectContaining({ - access_mode: AccessMode.PUBLIC, - }), - ) - }) - - it('should surface the learn-more tooltip action for workflows without a start node', () => { - mockWorkflow = { - graph: { - nodes: [{ data: { type: 'llm' } }], - }, - } - - render( - , - ) - - fireEvent.click( - screen.getByRole('button', { - name: /(?:^|\.)overview\.appInfo\.enableTooltip\.description(?=$|:)/, - }), - ) - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.enableTooltip\.learnMore(?=$|:)/)) - - expect(mockWindowOpen).toHaveBeenCalledWith( - 'https://docs.example.com/use-dify/nodes/user-input', - '_blank', - ) - }) - - it('should close the overview dialogs when their child callbacks are invoked', () => { - render() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.embedded\.entry(?=$|:)/)) - fireEvent.click(screen.getByTestId('embedded-modal')) - expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.customize\.entry(?=$|:)/)) - fireEvent.click(screen.getByTestId('customize-modal')) - expect(screen.queryByTestId('customize-modal')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)overview\.appInfo\.settings\.entry(?=$|:)/)) - fireEvent.click(screen.getByTestId('settings-modal')) - expect(screen.queryByTestId('settings-modal')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)) - fireEvent.click(screen.getByText('close-access-control')) - expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() - }) - - it('should report refresh failures from access control updates', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - mockFetchAppDetail.mockRejectedValueOnce(new Error('refresh failed')) - - render() - - fireEvent.click(screen.getByText(/(?:^|\.)publishApp\.notSet(?=$|:)/)) - fireEvent.click(screen.getByText('confirm-access-control')) - - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to fetch app detail:', expect.any(Error)) - }) - - consoleErrorSpy.mockRestore() - }) - - it('should close the regenerate confirmation even when no generator is configured', () => { - const { container } = render() - - const refreshButton = container.querySelector('[class*="refreshIcon"]') - ?.parentElement as HTMLElement - fireEvent.click(refreshButton) - expect( - screen.getByText(/(?:^|\.)overview\.appInfo\.regenerateNotice(?=$|:)/), - ).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) - - expect(mockOnGenerateCode).not.toHaveBeenCalled() - return waitFor(() => { - expect( - screen.queryByText(/(?:^|\.)overview\.appInfo\.regenerateNotice(?=$|:)/), - ).not.toBeInTheDocument() - }) - }) - - it('should regenerate accessible urls when a generator is configured', async () => { - mockOnGenerateCode.mockResolvedValue(undefined) - const { container } = render( - , - ) - - const refreshButton = container.querySelector('[class*="refreshIcon"]') - ?.parentElement as HTMLElement - fireEvent.click(refreshButton) - fireEvent.click(screen.getByRole('button', { name: /(?:^|\.)operation\.confirm(?=$|:)/ })) - - await waitFor(() => { - expect(mockOnGenerateCode).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/web/app/components/app/overview/__tests__/toggle-logic.test.ts b/web/app/components/app/overview/__tests__/toggle-logic.test.ts deleted file mode 100644 index fcb6f52cb9b..00000000000 --- a/web/app/components/app/overview/__tests__/toggle-logic.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' -import { basePath } from '@/utils/var' -import { - getAppCardDisplayState, - getAppCardOperationKeys, - hasWorkflowStartNode, - isAppAccessConfigured, -} from '../app-card-utils' - -describe('app-card-utils', () => { - const baseAppInfo = { - id: 'app-1', - mode: AppModeEnum.WORKFLOW, - enable_site: true, - enable_api: true, - site: { - app_base_url: 'https://example.com', - access_token: 'token-1', - }, - access_mode: AccessMode.PUBLIC, - } as AppDetailResponse & Partial - - describe('hasWorkflowStartNode', () => { - it('should detect a workflow start node', () => { - expect( - hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: 'llm' } }, { data: { type: 'start' } }], - }, - }), - ).toBe(true) - }) - - it('should return false when the workflow has no start node', () => { - expect( - hasWorkflowStartNode({ - graph: { - nodes: [{ data: { type: 'llm' } }], - }, - }), - ).toBe(false) - }) - }) - - describe('getAppCardDisplayState', () => { - it('should disable unpublished workflow apps and mark them as minimal state', () => { - const state = getAppCardDisplayState({ - appInfo: baseAppInfo, - cardType: 'webapp', - currentWorkflow: null, - canManageWebApp: true, - canManageApi: true, - }) - - expect(state.appUnpublished).toBe(true) - expect(state.missingStartNode).toBe(true) - expect(state.toggleDisabled).toBe(true) - expect(state.isMinimalState).toBe(true) - expect(state.runningStatus).toBe(false) - }) - - it('should keep published workflow apps enabled when the user has permissions', () => { - const state = getAppCardDisplayState({ - appInfo: baseAppInfo, - cardType: 'webapp', - currentWorkflow: { - graph: { - nodes: [{ data: { type: 'start' } }], - }, - }, - canManageWebApp: true, - canManageApi: true, - }) - - expect(state.appUnpublished).toBe(false) - expect(state.missingStartNode).toBe(false) - expect(state.toggleDisabled).toBe(false) - expect(state.isMinimalState).toBe(false) - expect(state.accessibleUrl).toBe(`https://example.com${basePath}/workflow/token-1`) - }) - }) - - describe('getAppCardOperationKeys', () => { - it('should include embedded and settings actions for editable chat webapps', () => { - expect( - getAppCardOperationKeys({ - cardType: 'webapp', - appMode: AppModeEnum.CHAT, - canManageSettings: true, - }), - ).toEqual(['launch', 'embedded', 'customize', 'settings']) - }) - - it('should only expose the develop action for api cards', () => { - expect( - getAppCardOperationKeys({ - cardType: 'api', - appMode: AppModeEnum.COMPLETION, - canManageSettings: false, - }), - ).toEqual(['develop']) - }) - }) - - describe('isAppAccessConfigured', () => { - it('should require members or groups for specific access mode', () => { - expect( - isAppAccessConfigured( - { - id: 'app-1', - access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS, - } as unknown as AppDetailResponse, - { groups: [], members: [] }, - ), - ).toBe(false) - }) - - it('should treat non-specific access modes as configured', () => { - expect( - isAppAccessConfigured( - { - id: 'app-1', - access_mode: AccessMode.PUBLIC, - } as unknown as AppDetailResponse, - { groups: [], members: [] }, - ), - ).toBe(true) - }) - }) -}) diff --git a/web/app/components/app/overview/__tests__/trigger-card.spec.tsx b/web/app/components/app/overview/__tests__/trigger-card.spec.tsx deleted file mode 100644 index 7a66e4a479b..00000000000 --- a/web/app/components/app/overview/__tests__/trigger-card.spec.tsx +++ /dev/null @@ -1,415 +0,0 @@ -import type { AppDetailResponse } from '@/models/app' -import { fireEvent, screen, waitFor } from '@testing-library/react' -import { renderWithAccountProfile as render } from '@/test/console/account-profile' -import { AppModeEnum } from '@/types/app' -import { AppACLPermission } from '@/utils/permission' -import TriggerCard from '../trigger-card' - -vi.mock('@/context/i18n', () => ({ - useDocLink: () => (path: string) => `https://docs.example.com${path}`, -})) - -const mockSetTriggerStatus = vi.fn() -const mockSetTriggerStatuses = vi.fn() -vi.mock('@/app/components/workflow/store/trigger-status', () => ({ - useTriggerStatusStore: () => ({ - setTriggerStatus: mockSetTriggerStatus, - setTriggerStatuses: mockSetTriggerStatuses, - }), -})) - -const mockUpdateTriggerStatus = vi.fn() -const mockInvalidateAppTriggers = vi.fn() -let mockTriggers: Array<{ - id: string - node_id: string - title: string - trigger_type: string - status: string - provider_name?: string -}> = [] -let mockIsLoading = false - -vi.mock('@/service/use-tools', () => ({ - useAppTriggers: () => ({ - data: { data: mockTriggers }, - isLoading: mockIsLoading, - }), - useUpdateTriggerStatus: () => ({ - mutateAsync: mockUpdateTriggerStatus, - }), - useInvalidateAppTriggers: () => mockInvalidateAppTriggers, -})) - -vi.mock('@/service/use-triggers', () => ({ - useAllTriggerPlugins: () => ({ - data: [{ id: 'plugin-1', name: 'Test Plugin', icon: 'test-icon' }], - }), -})) - -vi.mock('@/utils', () => ({ - asyncRunSafe: async (promise: Promise) => { - try { - return [null, await promise] - } catch (error) { - return [error] - } - }, - canFindTool: () => false, -})) - -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - return createPermissionStateModuleMock(() => ({ - workspacePermissionKeys: ['app.create_and_management'], - })) -}) - -vi.mock('@/app/components/workflow/block-icon', () => ({ - default: ({ type }: { type: string }) => ( -
- BlockIcon -
- ), -})) - -describe('TriggerCard', () => { - const mockAppInfo = { - id: 'test-app-id', - name: 'Test App', - description: 'Test description', - mode: AppModeEnum.WORKFLOW, - icon_type: 'emoji', - icon: 'test-icon', - icon_background: '#ffffff', - created_at: Date.now(), - updated_at: Date.now(), - enable_site: true, - enable_api: true, - permission_keys: [AppACLPermission.Edit], - } as AppDetailResponse - - const mockOnToggleResult = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - mockTriggers = [] - mockIsLoading = false - mockUpdateTriggerStatus.mockResolvedValue({}) - }) - - describe('Loading State', () => { - it('should render loading skeleton when isLoading is true', () => { - mockIsLoading = true - - const { container } = render( - , - ) - - expect(container.querySelector('.animate-pulse')).toBeInTheDocument() - }) - }) - - describe('Empty State', () => { - it('should show no triggers added message when triggers is empty', () => { - mockTriggers = [] - - render() - - expect( - screen.getByText(/(?:^|\.)overview\.triggerInfo\.noTriggerAdded(?=$|:)/), - ).toBeInTheDocument() - }) - - it('should show trigger status description when no triggers', () => { - mockTriggers = [] - - render() - - expect( - screen.getByText(/(?:^|\.)overview\.triggerInfo\.triggerStatusDescription(?=$|:)/), - ).toBeInTheDocument() - }) - - it('should show learn more link when no triggers', () => { - mockTriggers = [] - - render() - - const learnMoreLink = screen.getByText( - /(?:^|\.)overview\.triggerInfo\.learnAboutTriggers(?=$|:)/, - ) - expect(learnMoreLink).toBeInTheDocument() - expect(learnMoreLink).toHaveAttribute( - 'href', - 'https://docs.example.com/use-dify/nodes/trigger/overview', - ) - }) - }) - - describe('With Triggers', () => { - beforeEach(() => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Webhook Trigger', - trigger_type: 'trigger-webhook', - status: 'enabled', - }, - { - id: 'trigger-2', - node_id: 'node-2', - title: 'Schedule Trigger', - trigger_type: 'trigger-schedule', - status: 'disabled', - }, - ] - }) - - it('should show triggers count message', () => { - render() - - expect(screen.getByText(/overview\.triggerInfo\.triggersAdded.*2/)).toBeInTheDocument() - }) - - it('should render trigger titles', () => { - render() - - expect(screen.getByText('Webhook Trigger')).toBeInTheDocument() - expect(screen.getByText('Schedule Trigger')).toBeInTheDocument() - }) - - it('should show running status for enabled triggers', () => { - render() - - expect(screen.getByText(/(?:^|\.)overview\.status\.running(?=$|:)/)).toBeInTheDocument() - }) - - it('should show disable status for disabled triggers', () => { - render() - - expect(screen.getByText(/(?:^|\.)overview\.status\.disable(?=$|:)/)).toBeInTheDocument() - }) - - it('should render block icons for each trigger', () => { - render() - - const blockIcons = screen.getAllByTestId('block-icon') - expect(blockIcons.length).toBe(2) - }) - - it('should render switches for each trigger', () => { - render() - - const switches = screen.getAllByRole('switch') - expect(switches.length).toBe(2) - }) - }) - - describe('Toggle Trigger', () => { - beforeEach(() => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Test Trigger', - trigger_type: 'trigger-webhook', - status: 'disabled', - }, - ] - }) - - it('should call updateTriggerStatus when toggle is clicked', async () => { - render() - - const switchBtn = screen.getByRole('switch') - fireEvent.click(switchBtn) - - await waitFor(() => { - expect(mockUpdateTriggerStatus).toHaveBeenCalledWith({ - appId: 'test-app-id', - triggerId: 'trigger-1', - enableTrigger: true, - }) - }) - }) - - it('should update trigger status in store optimistically', async () => { - render() - - const switchBtn = screen.getByRole('switch') - fireEvent.click(switchBtn) - - await waitFor(() => { - expect(mockSetTriggerStatus).toHaveBeenCalledWith('node-1', 'enabled') - }) - }) - - it('should invalidate app triggers after successful update', async () => { - render() - - const switchBtn = screen.getByRole('switch') - fireEvent.click(switchBtn) - - await waitFor(() => { - expect(mockInvalidateAppTriggers).toHaveBeenCalledWith('test-app-id') - }) - }) - - it('should call onToggleResult with null on success', async () => { - render() - - const switchBtn = screen.getByRole('switch') - fireEvent.click(switchBtn) - - await waitFor(() => { - expect(mockOnToggleResult).toHaveBeenCalledWith(null) - }) - }) - - it('should rollback status and call onToggleResult with error on failure', async () => { - const error = new Error('Update failed') - mockUpdateTriggerStatus.mockRejectedValueOnce(error) - - render() - - const switchBtn = screen.getByRole('switch') - fireEvent.click(switchBtn) - - await waitFor(() => { - expect(mockSetTriggerStatus).toHaveBeenCalledWith('node-1', 'disabled') - expect(mockOnToggleResult).toHaveBeenCalledWith(error) - }) - }) - }) - - describe('Trigger Types', () => { - it('should render webhook trigger type correctly', () => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Webhook', - trigger_type: 'trigger-webhook', - status: 'enabled', - }, - ] - - render() - - const blockIcon = screen.getByTestId('block-icon') - expect(blockIcon).toHaveAttribute('data-type', 'trigger-webhook') - }) - - it('should render schedule trigger type correctly', () => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Schedule', - trigger_type: 'trigger-schedule', - status: 'enabled', - }, - ] - - render() - - const blockIcon = screen.getByTestId('block-icon') - expect(blockIcon).toHaveAttribute('data-type', 'trigger-schedule') - }) - - it('should render plugin trigger type correctly', () => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Plugin', - trigger_type: 'trigger-plugin', - status: 'enabled', - provider_name: 'plugin-1', - }, - ] - - render() - - const blockIcon = screen.getByTestId('block-icon') - expect(blockIcon).toHaveAttribute('data-type', 'trigger-plugin') - }) - }) - - describe('App ACL Permissions', () => { - it('should render switches for triggers', () => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Test Trigger', - trigger_type: 'trigger-webhook', - status: 'enabled', - }, - ] - - render() - - const switchBtn = screen.getByRole('switch') - expect(switchBtn).toBeInTheDocument() - }) - - it('should disable switches and skip updates without app edit permission', () => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Test Trigger', - trigger_type: 'trigger-webhook', - status: 'enabled', - }, - ] - const appInfoWithoutEditPermission = { - ...mockAppInfo, - permission_keys: [], - } as AppDetailResponse - - render( - , - ) - - const switchBtn = screen.getByRole('switch') - expect(switchBtn).toHaveAttribute('aria-disabled', 'true') - - fireEvent.click(switchBtn) - - expect(mockUpdateTriggerStatus).not.toHaveBeenCalled() - expect(mockSetTriggerStatus).not.toHaveBeenCalled() - }) - }) - - describe('Status Sync', () => { - it('should sync trigger statuses to store when data loads', () => { - mockTriggers = [ - { - id: 'trigger-1', - node_id: 'node-1', - title: 'Test', - trigger_type: 'trigger-webhook', - status: 'enabled', - }, - { - id: 'trigger-2', - node_id: 'node-2', - title: 'Test 2', - trigger_type: 'trigger-schedule', - status: 'disabled', - }, - ] - - render() - - expect(mockSetTriggerStatuses).toHaveBeenCalledWith({ - 'node-1': 'enabled', - 'node-2': 'disabled', - }) - }) - }) -}) diff --git a/web/app/components/app/overview/__tests__/workflow-launch-dialog.spec.tsx b/web/app/components/app/overview/__tests__/workflow-launch-dialog.spec.tsx new file mode 100644 index 00000000000..aeb0b8ed09c --- /dev/null +++ b/web/app/components/app/overview/__tests__/workflow-launch-dialog.spec.tsx @@ -0,0 +1,99 @@ +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { InputVarType } from '@/app/components/workflow/types' +import { render } from '@/test/console/render' +import { WorkflowLaunchDialog } from '../workflow-launch-dialog' + +const hiddenVariables = [ + { + default: 'initial', + hide: true, + label: 'Secret', + required: true, + type: InputVarType.textInput, + variable: 'secret', + }, +] + +describe('WorkflowLaunchDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('opens the workflow with entered hidden values and closes the dialog', async () => { + const user = userEvent.setup() + const onOpenChange = vi.fn() + const openWindow = vi.spyOn(window, 'open').mockReturnValue(null) + + render( + , + ) + + const input = screen.getByLabelText('Secret') + await user.clear(input) + await user.type(input, 'entered value') + await user.click(screen.getByRole('button', { name: /overview\.appInfo\.launch/i })) + + await waitFor(() => { + expect(openWindow).toHaveBeenCalledWith( + 'https://example.test/workflow?secret=entered+value', + '_blank', + ) + }) + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('resets hidden values when the controlled dialog is reopened', async () => { + const user = userEvent.setup() + const { rerender } = render( + , + ) + + const input = screen.getByLabelText('Secret') + await user.clear(input) + await user.type(input, 'changed') + expect(input).toHaveValue('changed') + + rerender( + , + ) + rerender( + , + ) + + expect(screen.getByLabelText('Secret')).toHaveValue('initial') + }) + + it('renders nothing when there are no hidden variables', () => { + const { container } = render( + , + ) + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/app/overview/app-card-sections.tsx b/web/app/components/app/overview/app-card-sections.tsx deleted file mode 100644 index 7664bd0a65e..00000000000 --- a/web/app/components/app/overview/app-card-sections.tsx +++ /dev/null @@ -1,511 +0,0 @@ -/* oxlint-disable react/only-export-components */ -import type { SelectorParam, TFunction } from 'i18next' -import type { ComponentType, FormEvent, ReactNode } from 'react' -import type { - OverviewOperationKey, - WorkflowHiddenStartVariable, - WorkflowLaunchInputValue, -} from './app-card-utils' -import type { ConfigParams } from './settings' -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@langgenius/dify-ui/alert-dialog' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { - RiArrowRightSLine, - RiBookOpenLine, - RiBuildingLine, - RiExternalLinkLine, - RiGlobalLine, - RiLockLine, - RiPaintBrushLine, - RiPaletteLine, - RiSettings2Line, - RiVerifiedBadgeLine, - RiWindowLine, -} from '@remixicon/react' -import { Trans } from 'react-i18next' -import CopyFeedback from '@/app/components/base/copy-feedback' -import Divider from '@/app/components/base/divider' -import ShareQRCode from '@/app/components/base/qrcode' -import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' -import AccessControl from '../app-access-control' -import CustomizeModal from './customize' -import EmbeddedModal from './embedded' -import SettingsModal from './settings' -import style from './style.module.css' -import WorkflowHiddenInputFields from './workflow-hidden-input-fields' - -type AppInfo = AppDetailResponse & Partial - -type OperationIcon = ComponentType<{ className?: string }> - -type AppCardOperation = { - key: OverviewOperationKey - label: string - Icon: OperationIcon - disabled: boolean - onClick: () => void -} - -type LaunchConfigAction = { - label: string - disabled: boolean - onClick: () => void -} - -const OPERATION_ICON_MAP: Record = { - launch: RiExternalLinkLine, - embedded: RiWindowLine, - customize: RiPaintBrushLine, - settings: RiPaletteLine, - develop: RiBookOpenLine, -} - -const ACCESS_MODE_ICON_MAP: Record = { - [AccessMode.ORGANIZATION]: RiBuildingLine, - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: RiLockLine, - [AccessMode.PUBLIC]: RiGlobalLine, - [AccessMode.EXTERNAL_MEMBERS]: RiVerifiedBadgeLine, -} - -const ACCESS_MODE_LABEL_MAP: Record> = { - [AccessMode.ORGANIZATION]: ($) => $['accessControlDialog.accessItems.organization'], - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: ($) => $['accessControlDialog.accessItems.specific'], - [AccessMode.PUBLIC]: ($) => $['accessControlDialog.accessItems.anyone'], - [AccessMode.EXTERNAL_MEMBERS]: ($) => $['accessControlDialog.accessItems.external'], -} - -const MaybeTooltip = ({ - children, - content, - tooltipClassName, - show = true, -}: { - children: ReactNode - content?: ReactNode - tooltipClassName?: string - show?: boolean -}) => { - if (!show || !content) return <>{children} - - return ( - - {children}
} /> - {content} - - ) -} - -export const WorkflowLaunchDialog = ({ - t, - open, - hiddenVariables, - unsupportedVariables, - values, - onOpenChange, - onValueChange, - onSubmit, -}: { - t: TFunction - open: boolean - hiddenVariables: WorkflowHiddenStartVariable[] - unsupportedVariables: WorkflowHiddenStartVariable[] - values: Record - onOpenChange: (open: boolean) => void - onValueChange: (variable: string, value: WorkflowLaunchInputValue) => void - onSubmit: (event: FormEvent) => void -}) => { - if (!hiddenVariables.length && !unsupportedVariables.length) return null - - return ( - - -
- - {t(($) => $['overview.appInfo.workflowLaunchHiddenInputs.title'], { - ns: 'appOverview', - })} - - - $['overview.appInfo.workflowLaunchHiddenInputs.description']} - ns="appOverview" - components={{ bold: }} - /> - -
-
-
- -
-
- - -
-
-
-
- ) -} - -export const createAppCardOperations = ({ - operationKeys, - t, - runningStatus, - triggerModeDisabled, - onLaunch, - onEmbedded, - onCustomize, - onSettings, - onDevelop, -}: { - operationKeys: OverviewOperationKey[] - t: TFunction - runningStatus: boolean - triggerModeDisabled: boolean - onLaunch: () => void - onEmbedded: () => void - onCustomize: () => void - onSettings: () => void - onDevelop: () => void -}): AppCardOperation[] => { - const labelMap: Record = { - launch: t(($) => $['overview.appInfo.launch'], { ns: 'appOverview' }), - embedded: t(($) => $['overview.appInfo.embedded.entry'], { ns: 'appOverview' }), - customize: t(($) => $['overview.appInfo.customize.entry'], { ns: 'appOverview' }), - settings: t(($) => $['overview.appInfo.settings.entry'], { ns: 'appOverview' }), - develop: t(($) => $['overview.apiInfo.doc'], { ns: 'appOverview' }), - } - const onClickMap: Record void> = { - launch: onLaunch, - embedded: onEmbedded, - customize: onCustomize, - settings: onSettings, - develop: onDevelop, - } - - return operationKeys.map((key) => { - const disabled = triggerModeDisabled ? true : key === 'settings' ? false : !runningStatus - return { - key, - label: labelMap[key], - Icon: OPERATION_ICON_MAP[key], - disabled, - onClick: onClickMap[key], - } - }) -} - -export const AppCardUrlSection = ({ - t, - isApp, - accessibleUrl, - showConfirmDelete, - canRegenerateUrl, - genLoading, - onRegenerate, - onShowRegenerateConfirm, - onHideRegenerateConfirm, -}: { - t: TFunction - isApp: boolean - accessibleUrl: string - showConfirmDelete: boolean - canRegenerateUrl: boolean - genLoading: boolean - onRegenerate: () => void - onShowRegenerateConfirm: () => void - onHideRegenerateConfirm: () => void -}) => ( -
-
- {isApp - ? t(($) => $['overview.appInfo.accessibleAddress'], { ns: 'appOverview' }) - : t(($) => $['overview.apiInfo.accessibleAddress'], { ns: 'appOverview' })} -
-
-
-
{accessibleUrl}
-
- - {isApp && } - {isApp && } - !open && onHideRegenerateConfirm()} - > - -
- - {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} - - - {t(($) => $['overview.appInfo.regenerateNotice'], { ns: 'appOverview' })} - -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
-
- {isApp && canRegenerateUrl && ( - $['overview.appInfo.regenerate'], { ns: 'appOverview' }) || ''} - > -
-
-
- - )} -
-
-) - -export const AppCardAccessControlSection = ({ - t, - appDetail, - isAppAccessSet, - onClick, -}: { - t: TFunction - appDetail: AppDetailResponse - isAppAccessSet: boolean - onClick: () => void -}) => { - const Icon = ACCESS_MODE_ICON_MAP[appDetail.access_mode] - const labelSelector = ACCESS_MODE_LABEL_MAP[appDetail.access_mode] - - return ( -
-
- {t(($) => $['publishApp.title'], { ns: 'app' })} -
-
-
- -

{t(labelSelector, { ns: 'app' })}

-
- {!isAppAccessSet && ( -

- {t(($) => $['publishApp.notSet'], { ns: 'app' })} -

- )} -
- -
-
-
- ) -} - -export const AppCardOperations = ({ - t, - operations, - launchConfigAction, -}: { - t: TFunction - operations: AppCardOperation[] - launchConfigAction?: LaunchConfigAction -}) => ( - <> - {operations.map(({ key, label, Icon, disabled, onClick }) => { - const shouldTruncate = key === 'customize' || key === 'settings' - const buttonContent = ( -
- -
- {label} -
-
- ) - - if (key === 'launch' && launchConfigAction) { - return ( -
- $['overview.appInfo.preUseReminder'], { ns: 'appOverview' }) ?? ''} - tooltipClassName="mt-[-8px]" - show={disabled} - > - - - - ) - } - - const actionButton = ( - - ) - - if (disabled) { - return ( - $['overview.appInfo.preUseReminder'], { ns: 'appOverview' }) ?? ''} - tooltipClassName="mt-[-8px]" - > - {actionButton} - - ) - } - - return actionButton - })} - -) - -export const AppCardDialogs = ({ - isApp, - appInfo, - appMode, - showSettingsModal, - showEmbedded, - showCustomizeModal, - showAccessControl, - appDetail, - onCloseSettings, - onCloseEmbedded, - onCloseCustomize, - onCloseAccessControl, - onSaveSiteConfig, - onConfirmAccessControl, - hiddenInputs, -}: { - isApp: boolean - appInfo: AppInfo - appMode: AppModeEnum - showSettingsModal: boolean - showEmbedded: boolean - showCustomizeModal: boolean - showAccessControl: boolean - appDetail: AppDetailResponse | null | undefined - onCloseSettings: () => void - onCloseEmbedded: () => void - onCloseCustomize: () => void - onCloseAccessControl: () => void - onSaveSiteConfig?: (params: ConfigParams) => Promise - onConfirmAccessControl: () => Promise - hiddenInputs?: WorkflowHiddenStartVariable[] -}) => { - if (!isApp) return null - - return ( - <> - - - - {showAccessControl && appDetail && ( - - )} - - ) -} diff --git a/web/app/components/app/overview/app-card-utils.ts b/web/app/components/app/overview/app-card-utils.ts index bf170524d38..94586dbd635 100644 --- a/web/app/components/app/overview/app-card-utils.ts +++ b/web/app/components/app/overview/app-card-utils.ts @@ -1,14 +1,7 @@ import type { InputVar } from '@/app/components/workflow/types' -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { BlockEnum, InputVarType } from '@/app/components/workflow/types' -import { AccessMode } from '@/models/access-control' -import { AppModeEnum } from '@/types/app' +import { InputVarType } from '@/app/components/workflow/types' import { basePath } from '@/utils/var' -type OverviewCardType = 'api' | 'webapp' - -export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop' export type WorkflowLaunchInputValue = string | boolean export type EmbeddedWebAppRoute = 'chatbot' | 'agent' export type WorkflowHiddenStartVariable = Pick< @@ -16,46 +9,6 @@ export type WorkflowHiddenStartVariable = Pick< 'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable' > -type AppInfo = AppDetailResponse & Partial - -type WorkflowLike = - | { - graph?: { - nodes?: Array<{ - data?: { - type?: string - variables?: InputVar[] - } - }> - } - } - | null - | undefined - -type AccessSubjectsLike = - | { - groups?: unknown[] - members?: unknown[] - } - | null - | undefined - -type AppCardDisplayState = { - isApp: boolean - appMode: AppModeEnum - appUnpublished: boolean - missingStartNode: boolean - hasInsufficientPermissions: boolean - toggleDisabled: boolean - runningStatus: boolean - isMinimalState: boolean - accessibleUrl: string -} - -const getCardAppMode = (mode: AppModeEnum) => { - return mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW ? AppModeEnum.CHAT : mode -} - const SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES = new Set([ InputVarType.textInput, InputVarType.paragraph, @@ -81,30 +34,6 @@ const coerceWorkflowLaunchDefaultValue = ( return String(variable.default ?? '') } -export const hasWorkflowStartNode = (currentWorkflow: WorkflowLike) => { - return currentWorkflow?.graph?.nodes?.some((node) => node.data?.type === BlockEnum.Start) ?? false -} - -export const getWorkflowHiddenStartVariables = ( - currentWorkflow: WorkflowLike, -): WorkflowHiddenStartVariable[] => { - const startNode = currentWorkflow?.graph?.nodes?.find( - (node) => node.data?.type === BlockEnum.Start, - ) - return (startNode?.data?.variables ?? []).filter((variable) => variable.hide === true) -} - -export const getAppHiddenLaunchVariables = ({ - appInfo, - currentWorkflow, -}: { - appInfo: AppInfo - currentWorkflow: WorkflowLike -}) => { - if ([AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT].includes(appInfo.mode)) - return getWorkflowHiddenStartVariables(currentWorkflow) -} - export const isWorkflowLaunchInputSupported = (variable: WorkflowHiddenStartVariable) => { return SUPPORTED_WORKFLOW_LAUNCH_INPUT_TYPES.has(variable.type) } @@ -230,78 +159,3 @@ export const compressAndEncodeBase64 = async (input: string) => { const compressedUint8Array = new Uint8Array(await compressedStream) return btoa(String.fromCharCode(...compressedUint8Array)) } - -export const getAppCardDisplayState = ({ - appInfo, - cardType, - currentWorkflow, - canManageWebApp, - canManageApi, - triggerModeDisabled = false, -}: { - appInfo: AppInfo - cardType: OverviewCardType - currentWorkflow: WorkflowLike - canManageWebApp: boolean - canManageApi: boolean - triggerModeDisabled?: boolean -}): AppCardDisplayState => { - const isApp = cardType === 'webapp' - const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW - const appUnpublished = isWorkflowApp && !currentWorkflow?.graph - const missingStartNode = isWorkflowApp && !hasWorkflowStartNode(currentWorkflow) - const hasInsufficientPermissions = isApp ? !canManageWebApp : !canManageApi - const toggleDisabled = - hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled - const runningStatus = - appUnpublished || missingStartNode ? false : isApp ? appInfo.enable_site : appInfo.enable_api - const appMode = getCardAppMode(appInfo.mode) - const appBaseUrl = appInfo.site?.app_base_url ?? '' - const accessToken = appInfo.site?.access_token ?? '' - - return { - isApp, - appMode, - appUnpublished, - missingStartNode, - hasInsufficientPermissions, - toggleDisabled, - runningStatus, - isMinimalState: appUnpublished || missingStartNode, - accessibleUrl: isApp - ? `${appBaseUrl}${basePath}/${appMode}/${accessToken}` - : (appInfo.api_base_url ?? ''), - } -} - -export const isAppAccessConfigured = ( - appDetail: AppDetailResponse | null | undefined, - appAccessSubjects: AccessSubjectsLike, -) => { - if (!appDetail || !appAccessSubjects) return true - - if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS) return true - - return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length) -} - -export const getAppCardOperationKeys = ({ - cardType, - appMode, - canManageSettings, -}: { - cardType: OverviewCardType - appMode: AppModeEnum - canManageSettings: boolean -}): OverviewOperationKey[] => { - if (cardType === 'api') return ['develop'] - - const operationKeys: OverviewOperationKey[] = ['launch'] - if (appMode !== AppModeEnum.COMPLETION && appMode !== AppModeEnum.WORKFLOW) - operationKeys.push('embedded') - - operationKeys.push('customize') - if (canManageSettings) operationKeys.push('settings') - - return operationKeys -} diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx deleted file mode 100644 index ba1e10a0017..00000000000 --- a/web/app/components/app/overview/app-card.tsx +++ /dev/null @@ -1,473 +0,0 @@ -'use client' -import type { WorkflowLaunchInputValue } from './app-card-utils' -import type { ConfigParams } from './settings' -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { Switch } from '@langgenius/dify-ui/switch' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import AppBasic from '@/app/components/app-sidebar/basic' -import { useStore as useAppStore } from '@/app/components/app/store' -import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button' -import { useDocLink } from '@/context/i18n' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { AccessMode } from '@/models/access-control' -import { usePathname, useRouter } from '@/next/navigation' -import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control' -import { fetchAppDetail } from '@/service/apps' -import { useAppWorkflow } from '@/service/use-workflow' -import { AppModeEnum } from '@/types/app' -import { asyncRunSafe } from '@/utils' -import { getAppACLCapabilities } from '@/utils/permission' -import { - AppCardAccessControlSection, - AppCardDialogs, - AppCardOperations, - AppCardUrlSection, - createAppCardOperations, - WorkflowLaunchDialog, -} from './app-card-sections' -import { - buildWorkflowLaunchUrl, - createWorkflowLaunchInitialValues, - getAppCardDisplayState, - getAppCardOperationKeys, - getAppHiddenLaunchVariables, - isAppAccessConfigured, - isWorkflowLaunchInputSupported, -} from './app-card-utils' - -export type IAppCardProps = { - className?: string - appInfo: AppDetailResponse & Partial - isInPanel?: boolean - cardType?: 'api' | 'webapp' - customBgColor?: string - triggerModeDisabled?: boolean - triggerModeMessage?: React.ReactNode - onChangeStatus: (val: boolean) => Promise - onSaveSiteConfig?: (params: ConfigParams) => Promise - onGenerateCode?: () => Promise -} - -function AppCard({ - appInfo, - isInPanel, - cardType = 'webapp', - customBgColor, - triggerModeDisabled = false, - triggerModeMessage = '', - onChangeStatus, - onSaveSiteConfig, - onGenerateCode, - className, -}: IAppCardProps) { - const router = useRouter() - const pathname = usePathname() - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const appACLCapabilities = useMemo( - () => - getAppACLCapabilities(appInfo.permission_keys, { - currentUserId, - resourceMaintainer: appInfo.maintainer, - workspacePermissionKeys, - }), - [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys], - ) - const canEditApp = appACLCapabilities.canEdit - const canManageWebAppAccessControl = appACLCapabilities.canReleaseAndVersion - const shouldFetchWorkflow = - appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT - const { data: currentWorkflow } = useAppWorkflow(shouldFetchWorkflow ? appInfo.id : '') - const docLink = useDocLink() - const appDetail = useAppStore((state) => state.appDetail) - const setAppDetail = useAppStore((state) => state.setAppDetail) - const [showSettingsModal, setShowSettingsModal] = useState(false) - const [showEmbedded, setShowEmbedded] = useState(false) - const [showCustomizeModal, setShowCustomizeModal] = useState(false) - const [genLoading, setGenLoading] = useState(false) - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [showAccessControl, setShowAccessControl] = useState(false) - const [showWorkflowLaunchDialog, setShowWorkflowLaunchDialog] = useState(false) - const [workflowLaunchValues, setWorkflowLaunchValues] = useState< - Record - >({}) - const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: appAccessSubjects } = useAppWhiteListSubjects( - appDetail?.id, - systemFeatures.webapp_auth.enabled && - canManageWebAppAccessControl && - appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, - ) - - const cardState = getAppCardDisplayState({ - appInfo, - cardType, - currentWorkflow, - canManageWebApp: canEditApp, - canManageApi: canEditApp, - triggerModeDisabled, - }) - - const isApp = cardState.isApp - const basicName = isApp - ? t(($) => $['overview.appInfo.title'], { ns: 'appOverview' }) - : t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' }) - - const isAppAccessSet = useMemo( - () => isAppAccessConfigured(appDetail, appAccessSubjects), - [appAccessSubjects, appDetail], - ) - const hiddenLaunchVariables = useMemo( - () => - getAppHiddenLaunchVariables({ - appInfo, - currentWorkflow, - }) || [], - [appInfo, currentWorkflow], - ) - const supportedWorkflowLaunchVariables = useMemo( - () => hiddenLaunchVariables.filter(isWorkflowLaunchInputSupported), - [hiddenLaunchVariables], - ) - const unsupportedWorkflowLaunchVariables = useMemo( - () => hiddenLaunchVariables.filter((variable) => !isWorkflowLaunchInputSupported(variable)), - [hiddenLaunchVariables], - ) - const initialWorkflowLaunchValues = useMemo( - () => createWorkflowLaunchInitialValues(supportedWorkflowLaunchVariables), - [supportedWorkflowLaunchVariables], - ) - - const onGenCode = async () => { - if (!onGenerateCode) return - - setGenLoading(true) - await asyncRunSafe(onGenerateCode()) - setGenLoading(false) - } - - const handleClickAccessControl = useCallback(() => { - if (!appDetail || !canManageWebAppAccessControl) return - - setShowAccessControl(true) - }, [appDetail, canManageWebAppAccessControl]) - - const handleAccessControlUpdate = useCallback(async () => { - if (!appDetail) return - - try { - const res = await fetchAppDetail({ url: '/apps', id: appDetail.id }) - setAppDetail({ ...res }) - setShowAccessControl(false) - } catch (error) { - console.error('Failed to fetch app detail:', error) - } - }, [appDetail, setAppDetail]) - - const operationKeys = useMemo( - () => - getAppCardOperationKeys({ - cardType, - appMode: cardState.appMode, - canManageSettings: canEditApp, - }), - [canEditApp, cardState.appMode, cardType], - ) - - const handleLaunch = useCallback(() => { - window.open(cardState.accessibleUrl, '_blank') - }, [cardState.accessibleUrl]) - - const handleOpenWorkflowLaunchDialog = useCallback(() => { - setWorkflowLaunchValues(initialWorkflowLaunchValues) - setShowWorkflowLaunchDialog(true) - }, [initialWorkflowLaunchValues]) - - const handleWorkflowLaunchValueChange = useCallback( - (variable: string, value: WorkflowLaunchInputValue) => { - setWorkflowLaunchValues((prev) => ({ - ...prev, - [variable]: value, - })) - }, - [], - ) - - const handleWorkflowLaunchConfirm = useCallback( - async (event: React.FormEvent) => { - event.preventDefault() - - const targetUrl = await buildWorkflowLaunchUrl({ - accessibleUrl: cardState.accessibleUrl, - variables: supportedWorkflowLaunchVariables, - values: workflowLaunchValues, - }) - - window.open(targetUrl, '_blank') - setShowWorkflowLaunchDialog(false) - }, - [cardState.accessibleUrl, supportedWorkflowLaunchVariables, workflowLaunchValues], - ) - - const handleOpenCustomize = useCallback(() => { - setShowCustomizeModal(true) - }, []) - - const handleOpenSettings = useCallback(() => { - setShowSettingsModal(true) - }, []) - - const handleOpenEmbedded = useCallback(() => { - setShowEmbedded(true) - }, []) - - const handleOpenDevelop = useCallback(() => { - const pathSegments = pathname.split('/') - pathSegments.pop() - router.push(`${pathSegments.join('/')}/develop`) - }, [pathname, router]) - - const operations = useMemo( - () => - createAppCardOperations({ - operationKeys, - t, - runningStatus: cardState.runningStatus, - triggerModeDisabled, - onLaunch: handleLaunch, - onEmbedded: handleOpenEmbedded, - onCustomize: handleOpenCustomize, - onSettings: handleOpenSettings, - onDevelop: handleOpenDevelop, - }), - [ - cardState.runningStatus, - handleLaunch, - handleOpenCustomize, - handleOpenDevelop, - handleOpenEmbedded, - handleOpenSettings, - operationKeys, - t, - triggerModeDisabled, - ], - ) - - const missingStartNodeContent = - cardState.appUnpublished || cardState.missingStartNode ? ( - <> -
- {t(($) => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} -
- - - ) : ( - '' - ) - - const statusPopoverContent = cardState.toggleDisabled - ? triggerModeDisabled && triggerModeMessage - ? triggerModeMessage - : missingStartNodeContent - : '' - - return ( -
-
- {triggerModeDisabled && - (triggerModeMessage ? ( - - - } - /> - - {triggerModeMessage} - - - ) : ( - - setShowSettingsModal(false)} - onCloseEmbedded={() => setShowEmbedded(false)} - onCloseCustomize={() => setShowCustomizeModal(false)} - onCloseAccessControl={() => setShowAccessControl(false)} - onSaveSiteConfig={onSaveSiteConfig} - onConfirmAccessControl={handleAccessControlUpdate} - hiddenInputs={hiddenLaunchVariables} - /> - -
- ) -} - -export default AppCard diff --git a/web/app/components/app/overview/assets/code-browser.svg b/web/app/components/app/overview/assets/code-browser.svg deleted file mode 100644 index 33434d770e3..00000000000 --- a/web/app/components/app/overview/assets/code-browser.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/app/components/app/overview/assets/refresh-hover.svg b/web/app/components/app/overview/assets/refresh-hover.svg deleted file mode 100644 index b2d281380c1..00000000000 --- a/web/app/components/app/overview/assets/refresh-hover.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/app/components/app/overview/assets/refresh.svg b/web/app/components/app/overview/assets/refresh.svg deleted file mode 100644 index be35d3c994f..00000000000 --- a/web/app/components/app/overview/assets/refresh.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/web/app/components/app/overview/settings/__tests__/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx index 909d69014cd..5298bb0a4a2 100644 --- a/web/app/components/app/overview/settings/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx @@ -117,9 +117,16 @@ const mockAppInfo = { enable_sso: false, } as unknown as AppDetailResponse & Partial -const renderSettingsModal = (appInfo = mockAppInfo) => +const renderSettingsModal = (appInfo = mockAppInfo, canDeploy = false) => render( - , + , ) const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder' @@ -148,6 +155,7 @@ describe('SettingsModal', () => { it('should render the modal with all settings exposed by default', async () => { renderSettingsModal() expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() expect( screen.queryByText('appOverview.overview.appInfo.settings.more.entry'), @@ -165,6 +173,14 @@ describe('SettingsModal', () => { ).toBeInTheDocument() }) + it('should explain that Web app settings apply to every environment when ACL allows deploy', () => { + renderSettingsModal(mockAppInfo, true) + + expect(screen.getByRole('status')).toHaveTextContent( + 'appOverview.overview.appInfo.settings.multiEnvironmentNotice', + ) + }) + it('should notify the user when the name is empty', async () => { renderSettingsModal() const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder') @@ -261,6 +277,7 @@ describe('SettingsModal', () => { const { rerender } = render( { rerender( { rerender( { const { rerender } = render( { rerender( const SettingsModal: FC = ({ isChat, + canDeploy = false, appInfo, isShow = false, onClose, @@ -371,13 +373,39 @@ const SettingsModal: FC = ({
+ {canDeploy && ( +
+
+ +
+ )} {/* form body */} - - - + + + {/* name & icon */}
@@ -738,7 +766,7 @@ const SettingsModal: FC = ({ {/* footer */} -
+
diff --git a/web/app/components/app/overview/style.module.css b/web/app/components/app/overview/style.module.css deleted file mode 100644 index 4e11433048b..00000000000 --- a/web/app/components/app/overview/style.module.css +++ /dev/null @@ -1,32 +0,0 @@ -@reference "../../../styles/globals.css"; - -.generateLogo { - animation: 2s rotate linear infinite; -} - -@keyframes rotate { - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } -} - -.codeBrowserIcon { - @apply h-4 w-4 bg-center bg-no-repeat; - background-image: url(./assets/code-browser.svg); -} - -.refreshIcon { - background-image: url(./assets/refresh.svg); - background-position: center; - background-repeat: no-repeat; -} - -.refreshIcon:hover { - background-image: url(./assets/refresh-hover.svg); - background-position: center; - background-repeat: no-repeat; -} diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx deleted file mode 100644 index 8df6755370b..00000000000 --- a/web/app/components/app/overview/trigger-card.tsx +++ /dev/null @@ -1,237 +0,0 @@ -'use client' - -import type { TriggerWithProvider } from '@/app/components/workflow/block-selector/types' -import type { AppDetailResponse } from '@/models/app' -import type { AppTrigger } from '@/service/use-tools' -import type { AppSSO } from '@/types/app' -import type { I18nKeysByPrefix } from '@/types/i18n' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { Switch } from '@langgenius/dify-ui/switch' -import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import * as React from 'react' -import { useTranslation } from 'react-i18next' -import BlockIcon from '@/app/components/workflow/block-icon' -import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status' -import { BlockEnum } from '@/app/components/workflow/types' -import { useDocLink } from '@/context/i18n' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import Link from '@/next/link' -import { - useAppTriggers, - useInvalidateAppTriggers, - useUpdateTriggerStatus, -} from '@/service/use-tools' -import { useAllTriggerPlugins } from '@/service/use-triggers' -import { canFindTool } from '@/utils' -import { getAppACLCapabilities } from '@/utils/permission' - -type ITriggerCardProps = { - appInfo: AppDetailResponse & Partial - onToggleResult?: (err: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => void -} - -const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: TriggerWithProvider[]) => { - const { trigger_type, status, provider_name } = trigger - - // Get BlockEnum type from trigger_type - let blockType: BlockEnum - switch (trigger_type) { - case 'trigger-webhook': - blockType = BlockEnum.TriggerWebhook - break - case 'trigger-schedule': - blockType = BlockEnum.TriggerSchedule - break - case 'trigger-plugin': - blockType = BlockEnum.TriggerPlugin - break - default: - blockType = BlockEnum.TriggerWebhook - } - - let triggerIcon: string | undefined - if (trigger_type === 'trigger-plugin' && provider_name) { - const targetTriggers = triggerPlugins || [] - const foundTrigger = targetTriggers.find( - (triggerWithProvider) => - canFindTool(triggerWithProvider.id, provider_name) || - triggerWithProvider.id.includes(provider_name) || - triggerWithProvider.name === provider_name, - ) - triggerIcon = typeof foundTrigger?.icon === 'string' ? foundTrigger.icon : undefined - } - - return ( -
- - -
- ) -} - -function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) { - const { t } = useTranslation() - const docLink = useDocLink() - const appId = appInfo.id - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const canEditApp = React.useMemo( - () => - getAppACLCapabilities(appInfo.permission_keys, { - currentUserId, - resourceMaintainer: appInfo.maintainer, - workspacePermissionKeys, - }).canEdit, - [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys], - ) - const { data: triggersResponse, isLoading } = useAppTriggers(appId) - const { mutateAsync: updateTriggerStatus } = useUpdateTriggerStatus() - const invalidateAppTriggers = useInvalidateAppTriggers() - const { data: triggerPlugins } = useAllTriggerPlugins() - - // Zustand store for trigger status sync - const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore() - - const triggers = React.useMemo(() => triggersResponse?.data || [], [triggersResponse?.data]) - const triggerCount = triggers.length - - // Sync trigger statuses to Zustand store when data loads initially or after API calls - React.useEffect(() => { - if (triggers.length > 0) { - const statusMap = triggers.reduce( - (acc, trigger) => { - // Map API status to EntryNodeStatus: only 'enabled' shows green, others show gray - acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' - return acc - }, - {} as Record, - ) - - // Only update if there are actual changes to prevent overriding optimistic updates - setTriggerStatuses(statusMap) - } - }, [triggers, setTriggerStatuses]) - - const onToggleTrigger = async (trigger: AppTrigger, enabled: boolean) => { - if (!canEditApp) return - - try { - // Immediately update Zustand store for real-time UI sync - const newStatus = enabled ? 'enabled' : 'disabled' - setTriggerStatus(trigger.node_id, newStatus) - - await updateTriggerStatus({ - appId, - triggerId: trigger.id, - enableTrigger: enabled, - }) - invalidateAppTriggers(appId) - - // Success toast notification - onToggleResult?.(null) - } catch (error) { - // Rollback Zustand store state on error - const rollbackStatus = enabled ? 'disabled' : 'enabled' - setTriggerStatus(trigger.node_id, rollbackStatus) - - // Error toast notification - onToggleResult?.(error as Error) - } - } - - if (isLoading) { - return ( -
-
-
-
-
-
-
- ) - } - - return ( -
-
-
-
-
-
-
-
-
-
- {triggerCount > 0 - ? t(($) => $['overview.triggerInfo.triggersAdded'], { - ns: 'appOverview', - count: triggerCount, - }) - : t(($) => $['overview.triggerInfo.noTriggerAdded'], { ns: 'appOverview' })} -
-
-
-
-
- - {triggerCount > 0 && ( -
- {triggers.map((trigger) => ( -
-
-
{getTriggerIcon(trigger, triggerPlugins || [])}
-
- {trigger.title} -
-
-
-
- {trigger.status === 'enabled' - ? t(($) => $['overview.status.running'], { ns: 'appOverview' }) - : t(($) => $['overview.status.disable'], { ns: 'appOverview' })} -
-
-
- onToggleTrigger(trigger, enabled)} - disabled={!canEditApp} - /> -
-
- ))} -
- )} - - {triggerCount === 0 && ( -
-
- {t(($) => $['overview.triggerInfo.triggerStatusDescription'], { ns: 'appOverview' })}{' '} - - {t(($) => $['overview.triggerInfo.learnAboutTriggers'], { ns: 'appOverview' })} - -
-
- )} -
-
- ) -} - -export default TriggerCard diff --git a/web/app/components/app/overview/workflow-launch-dialog/index.tsx b/web/app/components/app/overview/workflow-launch-dialog/index.tsx new file mode 100644 index 00000000000..bfc7efccd8e --- /dev/null +++ b/web/app/components/app/overview/workflow-launch-dialog/index.tsx @@ -0,0 +1,110 @@ +'use client' + +import type { FormEvent } from 'react' +import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { + buildWorkflowLaunchUrl, + createWorkflowLaunchInitialValues, + isWorkflowLaunchInputSupported, +} from '../app-card-utils' +import WorkflowHiddenInputFields from '../workflow-hidden-input-fields' + +type WorkflowLaunchFormProps = { + hiddenVariables: WorkflowHiddenStartVariable[] + targetUrl: string + onClose: () => void +} + +function WorkflowLaunchForm({ hiddenVariables, targetUrl, onClose }: WorkflowLaunchFormProps) { + const { t } = useTranslation() + const [values, setValues] = useState>(() => + createWorkflowLaunchInitialValues(hiddenVariables), + ) + + function handleValueChange(variable: string, value: WorkflowLaunchInputValue) { + setValues((currentValues) => ({ + ...currentValues, + [variable]: value, + })) + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + const launchUrl = await buildWorkflowLaunchUrl({ + accessibleUrl: targetUrl, + variables: hiddenVariables, + values, + }) + + window.open(launchUrl, '_blank') + onClose() + } + + return ( + +
+ +
+
+ + +
+ + ) +} + +type WorkflowLaunchDialogProps = { + hiddenVariables: WorkflowHiddenStartVariable[] + open: boolean + targetUrl: string + onOpenChange: (open: boolean) => void +} + +export function WorkflowLaunchDialog({ + hiddenVariables, + open, + targetUrl, + onOpenChange, +}: WorkflowLaunchDialogProps) { + const { t } = useTranslation() + const supportedVariables = hiddenVariables.filter(isWorkflowLaunchInputSupported) + + if (!hiddenVariables.length) return null + + return ( + + +
+ + {t(($) => $['overview.appInfo.workflowLaunchHiddenInputs.title'], { + ns: 'appOverview', + })} + + + $['overview.appInfo.workflowLaunchHiddenInputs.description']} + ns="appOverview" + components={{ bold: }} + /> + +
+ onOpenChange(false)} + /> +
+
+ ) +} diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 495ee13c0c1..f6ad9d6e135 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -377,30 +377,6 @@ vi.mock('@/next/dynamic', () => ({ ) } } - if (fnString.includes('app-access-control')) { - return function MockAccessControl({ - onClose, - onConfirm, - }: { - onClose: () => void - onConfirm: () => void - }) { - return React.createElement( - 'div', - { 'data-testid': 'access-control-modal' }, - React.createElement( - 'button', - { onClick: onClose, 'data-testid': 'close-access-control' }, - 'Close', - ), - React.createElement( - 'button', - { onClick: onConfirm, 'data-testid': 'confirm-access-control' }, - 'Confirm', - ), - ) - } - } return () => null }, })) @@ -678,37 +654,13 @@ describe('AppCard', () => { }) }) - describe('Access Mode Icons', () => { - it('should show public icon on the bottom right of the card', () => { - const publicApp = { ...mockApp, access_mode: AccessMode.PUBLIC } - render() - const icon = screen.getByRole('img', { name: 'app.accessItemsDescription.anyone' }) - expect(icon).toBeInTheDocument() - expect(icon.closest('.right-3.bottom-3')).toBeInTheDocument() - }) + describe('Web app access control entry points', () => { + it('should not render the access mode icon or tooltip trigger', () => { + render() - it('should show lock icon on the bottom right of the card', () => { - const specificApp = { ...mockApp, access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS } - render() - const icon = screen.getByRole('img', { name: 'app.accessItemsDescription.specific' }) - expect(icon).toBeInTheDocument() - expect(icon.closest('.right-3.bottom-3')).toBeInTheDocument() - }) - - it('should show organization icon on the bottom right of the card', () => { - const orgApp = { ...mockApp, access_mode: AccessMode.ORGANIZATION } - render() - const icon = screen.getByRole('img', { name: 'app.accessItemsDescription.organization' }) - expect(icon).toBeInTheDocument() - expect(icon.closest('.right-3.bottom-3')).toBeInTheDocument() - }) - - it('should show external icon on the bottom right of the card', () => { - const externalApp = { ...mockApp, access_mode: AccessMode.EXTERNAL_MEMBERS } - render() - const icon = screen.getByRole('img', { name: 'app.accessItemsDescription.external' }) - expect(icon).toBeInTheDocument() - expect(icon.closest('.right-3.bottom-3')).toBeInTheDocument() + expect( + screen.queryByRole('img', { name: 'app.accessItemsDescription.anyone' }), + ).not.toBeInTheDocument() }) }) @@ -1610,28 +1562,30 @@ describe('AppCard', () => { mockWebappAuthEnabled = true }) - it('should show access control option when webapp_auth is enabled', async () => { + it('should omit web app access control when webapp_auth is enabled', async () => { render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { - expect(screen.getByText('app.accessControl')).toBeInTheDocument() + expect(screen.getByText('app.editApp')).toBeInTheDocument() }) + expect(screen.queryByText('app.accessControl')).not.toBeInTheDocument() }) - it('should show access control option when user has app release and version permission', async () => { + it('should omit web app access control for release-and-version permission', async () => { const appWithReleasePermission = createMockApp({ created_by: 'another-user', maintainer: 'another-user', - permission_keys: [AppACLPermission.ReleaseAndVersion], + permission_keys: [AppACLPermission.ReleaseAndVersion, AppACLPermission.Delete], }) render() fireEvent.click(getOperationsTrigger()) await waitFor(() => { - expect(screen.getByText('app.accessControl')).toBeInTheDocument() + expect(screen.getByText('common.operation.delete')).toBeInTheDocument() }) + expect(screen.queryByText('app.accessControl')).not.toBeInTheDocument() }) it('should show resource access option when user only has app access config permission', async () => { @@ -1684,39 +1638,6 @@ describe('AppCard', () => { expect(mockPush).toHaveBeenCalledWith('/app/test-app-id/access-config') }) - it('should click access control button', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - const accessControlBtn = screen.getByText('app.accessControl') - fireEvent.click(accessControlBtn) - }) - - await waitFor(() => { - expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() - }) - }) - - it('should close access control modal after confirmation', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.accessControl')) - }) - - await waitFor(() => { - expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() - }) - - fireEvent.click(screen.getByTestId('confirm-access-control')) - - await waitFor(() => { - expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() - }) - }) - it('should show open in explore when userCanAccessApp is true', async () => { render() @@ -1750,25 +1671,5 @@ describe('AppCard', () => { message: 'app.notPublishedYet', }) }) - - it('should close access control modal when onClose is called', async () => { - render() - - fireEvent.click(getOperationsTrigger()) - await waitFor(() => { - fireEvent.click(screen.getByText('app.accessControl')) - }) - - await waitFor(() => { - expect(screen.getByTestId('access-control-modal')).toBeInTheDocument() - }) - - // Click close button to trigger onClose - fireEvent.click(screen.getByTestId('close-access-control')) - - await waitFor(() => { - expect(screen.queryByTestId('access-control-modal')).not.toBeInTheDocument() - }) - }) }) }) diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index bd011f0e338..d426c170436 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -414,12 +414,6 @@ vi.mock('../app-card', () => ({ }), ) }, - AppCardActionBar: ({ app }: { app: { id: string } }) => { - return React.createElement('button', { - 'data-testid': `app-card-action-bar-${app.id}`, - type: 'button', - }) - }, default: ({ app }: { app: { id: string; name: string } }) => { return React.createElement( 'div', @@ -429,6 +423,15 @@ vi.mock('../app-card', () => ({ }, })) +vi.mock('../app-card/action-bar', () => ({ + AppCardActionBar: ({ app }: { app: { id: string } }) => { + return React.createElement('button', { + 'data-testid': `app-card-action-bar-${app.id}`, + type: 'button', + }) + }, +})) + vi.mock('../empty', () => ({ default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => { return React.createElement( diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card/action-bar/index.tsx similarity index 53% rename from web/app/components/apps/app-card.tsx rename to web/app/components/apps/app-card/action-bar/index.tsx index 40545bb7e9a..396f668f4d1 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card/action-bar/index.tsx @@ -7,7 +7,6 @@ import type { import type { FormEventHandler, MouseEvent } from 'react' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' -import type { WorkflowOnlineUser } from '@/models/app' import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' import { AlertDialog, @@ -22,8 +21,6 @@ import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { Field, FieldControl, FieldLabel } from '@langgenius/dify-ui/field' @@ -31,14 +28,10 @@ import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { memo, useCallback, useId, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { AppTypeIcon } from '@/app/components/app/type-selector' import { useExportAppDsl, useExportWorkflowAppDsl } from '@/app/components/app/use-export-app-dsl' -import AppIcon from '@/app/components/base/app-icon' import StarIcon from '@/app/components/base/icons/src/vender/Star' -import { UserAvatarList } from '@/app/components/base/user-avatar-list' -import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown, @@ -47,24 +40,17 @@ import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { systemFeaturesQueryOptions } from '@/features/system-features/client' -import { AppCardTags } from '@/features/tag-management/components/app-card-tags' -import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' -import { AccessMode, isAccessMode } from '@/models/access-control' import dynamic from '@/next/dynamic' -import Link from '@/next/link' import { useRouter } from '@/next/navigation' -import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' import { consoleQuery } from '@/service/client' -import { fetchInstalledAppList } from '@/service/explore' import { AppModeEnum } from '@/types/app' -import { getRedirection, getRedirectionPath } from '@/utils/app-redirection' +import { getRedirection } from '@/utils/app-redirection' import { getAppACLCapabilities, hasOnlyAppPreviewPermission, hasPermission, } from '@/utils/permission' -import { formatTime } from '@/utils/time' -import { basePath } from '@/utils/var' +import { AppCardOperationsMenuContent } from '../operations-menu' const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { ssr: false, @@ -81,271 +67,8 @@ const DSLExportConfirmModal = dynamic( ssr: false, }, ) -const AccessControl = dynamic(() => import('@/app/components/app/app-access-control'), { - ssr: false, -}) -const ACCESS_MODE_ICON_CLASS_NAMES: Record = { - [AccessMode.PUBLIC]: 'i-ri-global-line', - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'i-ri-lock-line', - [AccessMode.ORGANIZATION]: 'i-ri-building-line', - [AccessMode.EXTERNAL_MEMBERS]: 'i-ri-verified-badge-line', -} - -const ACCESS_MODE_LABEL_KEYS = { - [AccessMode.PUBLIC]: 'accessItemsDescription.anyone', - [AccessMode.SPECIFIC_GROUPS_MEMBERS]: 'accessItemsDescription.specific', - [AccessMode.ORGANIZATION]: 'accessItemsDescription.organization', - [AccessMode.EXTERNAL_MEMBERS]: 'accessItemsDescription.external', -} as const - -const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.WORKFLOW, -]) const OPERATIONS_MENU_POPUP_CLASS_NAME = 'min-w-[216px]' -const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = [] - -type AppCardProps = { - app: AppPartial - onlineUsers?: WorkflowOnlineUser[] - onOpenTagManagement?: () => void - stepByStepTourActionMenuOpen?: boolean - stepByStepTourCardTarget?: string - stepByStepTourCardHighlightPart?: string - stepByStepTourActionMenuHighlightPart?: string -} - -type AppAccessModeIconProps = { - accessMode?: AccessMode | null -} - -function requiresPublishedWorkflowInExplore(app: AppPartial) { - return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) -} - -function AppAccessModeIcon({ accessMode }: AppAccessModeIconProps) { - const { t } = useTranslation() - - if (!accessMode) return null - - const iconClassName = ACCESS_MODE_ICON_CLASS_NAMES[accessMode] - const labelKey = ACCESS_MODE_LABEL_KEYS[accessMode] - - if (!iconClassName || !labelKey) return null - - const label = t(($) => $[labelKey], { ns: 'app' }) - - return ( -
- - - } - /> - {label} - -
- ) -} - -type AppCardOperationsMenuProps = { - app: AppPartial - shouldShowEditOption: boolean - shouldShowDuplicateOption: boolean - shouldShowExportOption: boolean - shouldShowSwitchOption: boolean - shouldShowOpenInExploreOption: boolean - shouldShowAccessControlOption: boolean - shouldShowAccessConfigOption: boolean - shouldShowDeleteOption: boolean - isExporting: boolean - onEdit: () => void - onDuplicate: () => void - onExport: () => void - onSwitch: () => void - onDelete: () => void - onAccessControl: () => void - onAccessConfig: () => void -} - -function AppCardOperationsMenu({ - app, - shouldShowEditOption, - shouldShowDuplicateOption, - shouldShowExportOption, - shouldShowSwitchOption, - shouldShowOpenInExploreOption, - shouldShowAccessControlOption, - shouldShowAccessConfigOption, - shouldShowDeleteOption, - isExporting, - onEdit, - onDuplicate, - onExport, - onSwitch, - onDelete, - onAccessControl, - onAccessConfig, -}: AppCardOperationsMenuProps) { - const { t } = useTranslation() - const openAsyncWindow = useAsyncWindowOpen() - const hasEditGroup = shouldShowEditOption - const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption - const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption - const hasAccessDeleteGroup = - shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption - - function handleMenuAction(e: MouseEvent, action: () => void) { - e.stopPropagation() - e.preventDefault() - action() - } - - async function handleOpenInstalledApp(e: MouseEvent) { - e.stopPropagation() - e.preventDefault() - if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) { - toast.error(t(($) => $.notPublishedYet, { ns: 'app' })) - return - } - - try { - await openAsyncWindow( - async () => { - const { installed_apps } = await fetchInstalledAppList(app.id) - if (installed_apps?.length > 0) - return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` - throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) - }, - { - onError: (err) => { - toast.error(`${err.message || err}`) - }, - }, - ) - } catch (e: unknown) { - const message = e instanceof Error ? e.message : `${e}` - toast.error(message) - } - } - - return ( - <> - {shouldShowEditOption && ( - handleMenuAction(e, onEdit)}> - - {t(($) => $.editApp, { ns: 'app' })} - - - )} - {hasEditGroup && - (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - - )} - {shouldShowDuplicateOption && ( - handleMenuAction(e, onDuplicate)}> - - {t(($) => $.duplicate, { ns: 'app' })} - - - )} - {shouldShowExportOption && ( - handleMenuAction(e, onExport)} - > - - {t(($) => $.export, { ns: 'app' })} - - - )} - {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( - - )} - {shouldShowSwitchOption && ( - handleMenuAction(e, onSwitch)}> - {t(($) => $.switch, { ns: 'app' })} - - )} - {shouldShowOpenInExploreOption && ( - - - {t(($) => $.openInExplore, { ns: 'app' })} - - - )} - {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } - {shouldShowAccessControlOption && ( - handleMenuAction(e, onAccessControl)} - > - - {t(($) => $.accessControl, { ns: 'app' })} - - - )} - {shouldShowAccessConfigOption && ( - handleMenuAction(e, onAccessConfig)} - > - - {t(($) => $['settings.resourceAccess'], { ns: 'common' })} - - - )} - {(shouldShowAccessControlOption || shouldShowAccessConfigOption) && - shouldShowDeleteOption && } - {shouldShowDeleteOption && ( - handleMenuAction(e, onDelete)} - > - - {t(($) => $['operation.delete'], { ns: 'common' })} - - - )} - - ) -} - -type AppCardOperationsMenuContentProps = Omit< - AppCardOperationsMenuProps, - 'shouldShowOpenInExploreOption' -> - -function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) { - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ - appId: props.app.id, - enabled: systemFeatures.webapp_auth.enabled, - }) - const needsPublishBeforeExplore = - requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id - - const shouldShowOpenInExploreOption = - !props.app.has_draft_trigger && - (needsPublishBeforeExplore || - !systemFeatures.webapp_auth.enabled || - (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) - - return ( - - ) -} type AppCardActionBarProps = { app: AppPartial @@ -386,7 +109,6 @@ export const AppCardActionBar = memo( const [showSwitchModal, setShowSwitchModal] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') - const [showAccessControl, setShowAccessControl] = useState(false) const operationsMenu = useStepByStepTourControlledDropdown({ allowTriggerCloseWhileControlled: false, controlledOpen: stepByStepTourActionMenuOpen, @@ -492,13 +214,6 @@ export const AppCardActionBar = memo( }) }, [setIsOperationsMenuOpen]) - const handleShowAccessControl = useCallback(() => { - setIsOperationsMenuOpen(false) - queueMicrotask(() => { - setShowAccessControl(true) - }) - }, [setIsOperationsMenuOpen]) - const handleOpenAccessConfig = useCallback(() => { setIsOperationsMenuOpen(false) push(`/app/${app.id}/access-config`) @@ -595,10 +310,6 @@ export const AppCardActionBar = memo( if (result?.status === 'confirmation-required') setSecretEnvList(result.secretEnvList) } - const onUpdateAccessControl = useCallback(() => { - setShowAccessControl(false) - }, []) - const handleToggleStar = useCallback( (e: MouseEvent) => { e.stopPropagation() @@ -636,8 +347,6 @@ export const AppCardActionBar = memo( const shouldShowSwitchOption = appACLCapabilities.canEdit && (app.mode === AppModeEnum.COMPLETION || app.mode === AppModeEnum.CHAT) - const shouldShowAccessControlOption = - systemFeatures.webapp_auth.enabled && appACLCapabilities.canReleaseAndVersion const shouldShowAccessConfigOption = appACLCapabilities.canAccessConfig const shouldShowDeleteOption = appACLCapabilities.canDelete const shouldShowOperationsMenu = @@ -645,7 +354,6 @@ export const AppCardActionBar = memo( shouldShowDuplicateOption || shouldShowExportOption || shouldShowSwitchOption || - shouldShowAccessControlOption || shouldShowAccessConfigOption || shouldShowDeleteOption const starActionLabel = app.is_starred @@ -734,7 +442,6 @@ export const AppCardActionBar = memo( shouldShowDuplicateOption={shouldShowDuplicateOption} shouldShowExportOption={shouldShowExportOption} shouldShowSwitchOption={shouldShowSwitchOption} - shouldShowAccessControlOption={shouldShowAccessControlOption} shouldShowAccessConfigOption={shouldShowAccessConfigOption} shouldShowDeleteOption={shouldShowDeleteOption} isExporting={isExporting} @@ -743,7 +450,6 @@ export const AppCardActionBar = memo( onExport={exportCheck} onSwitch={handleShowSwitchModal} onDelete={handleShowDeleteConfirm} - onAccessControl={handleShowAccessControl} onAccessConfig={handleOpenAccessConfig} /> @@ -843,215 +549,7 @@ export const AppCardActionBar = memo( onClose={() => setSecretEnvList([])} /> )} - {showAccessControl && ( - setShowAccessControl(false)} - /> - )} ) }, ) - -export const AppCard = memo( - ({ - app, - onlineUsers = EMPTY_ONLINE_USERS, - onOpenTagManagement, - stepByStepTourActionMenuOpen = false, - stepByStepTourCardTarget, - stepByStepTourCardHighlightPart, - stepByStepTourActionMenuHighlightPart, - }: AppCardProps) => { - const { t } = useTranslation() - const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - const isRbacEnabled = systemFeatures.rbac_enabled - const resourceMaintainer = app.maintainer ?? undefined - const maintainerPermissionOptions = useMemo( - () => ({ - currentUserId, - resourceMaintainer, - workspacePermissionKeys, - isRbacEnabled, - }), - [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], - ) - const appACLCapabilities = useMemo( - () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), - [app.permission_keys, maintainerPermissionOptions], - ) - const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) - const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage') - const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit) - const editTimeText = useMemo(() => { - const timestamp = app.updated_at || app.created_at - if (!timestamp) return '' - - const timeText = formatTime({ - date: timestamp * 1000, - dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, - }) - return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` - }, [app.updated_at, app.created_at, t]) - - const appModeLabel = useMemo(() => { - switch (app.mode) { - case AppModeEnum.CHAT: - return t(($) => $['types.chatbot'], { ns: 'app' }) - case AppModeEnum.ADVANCED_CHAT: - return t(($) => $['types.advanced'], { ns: 'app' }) - case AppModeEnum.AGENT_CHAT: - return t(($) => $['types.agent'], { ns: 'app' }) - case AppModeEnum.COMPLETION: - return t(($) => $['types.completion'], { ns: 'app' }) - case AppModeEnum.WORKFLOW: - return t(($) => $['types.workflow'], { ns: 'app' }) - default: - return app.mode - } - }, [app.mode, t]) - - const onlinePresenceUsers = useMemo(() => { - return onlineUsers - .map((user, index) => { - const id = user.user_id || user.sid || `${app.id}-online-${index}` - const name = user.username || user.user_id || user.sid || `${index + 1}` - return { - id, - name, - avatar_url: user.avatar || null, - } - }) - .filter((user) => Boolean(user.id)) - }, [app.id, onlineUsers]) - const appNameId = useId() - const appDescriptionId = useId() - const appIconType = zIconType.safeParse(app.icon_type).data ?? null - const appHref = getRedirectionPath(app, maintainerPermissionOptions) - const appCardClassName = cn( - 'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out', - isPreviewOnly - ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' - : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', - ) - const showPreviewOnlyAccessWarning = useCallback(() => { - toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) - }, [t]) - const appCardContent = ( - <> -
-
- - -
-
-
-
- {app.name} -
-
-
- {appModeLabel} -
-
- {onlinePresenceUsers.length > 0 && ( -
- -
- )} -
-
-
- {app.description} -
-
-
-
-
- {app.author_name && ( - <> -
{app.author_name}
-
·
- - )} -
{editTimeText}
-
-
- - ) - - return ( -
- {isPreviewOnly ? ( - - ) : ( - - {appCardContent} - - )} - - {!isPreviewOnly && ( - - )} -
- -
-
- ) - }, -) diff --git a/web/app/components/apps/app-card/index.tsx b/web/app/components/apps/app-card/index.tsx new file mode 100644 index 00000000000..8a1760a68f6 --- /dev/null +++ b/web/app/components/apps/app-card/index.tsx @@ -0,0 +1,233 @@ +'use client' + +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' +import type { WorkflowOnlineUser } from '@/models/app' +import { zIconType } from '@dify/contracts/api/console/apps/zod.gen' +import { cn } from '@langgenius/dify-ui/cn' +import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' +import { memo, useCallback, useId, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { AppTypeIcon } from '@/app/components/app/type-selector' +import AppIcon from '@/app/components/base/app-icon' +import { UserAvatarList } from '@/app/components/base/user-avatar-list' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { userProfileQueryOptions } from '@/features/account-profile/client' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { AppCardTags } from '@/features/tag-management/components/app-card-tags' +import Link from '@/next/link' +import { AppModeEnum } from '@/types/app' +import { getRedirectionPath } from '@/utils/app-redirection' +import { + getAppACLCapabilities, + hasOnlyAppPreviewPermission, + hasPermission, +} from '@/utils/permission' +import { formatTime } from '@/utils/time' +import { AppCardActionBar } from './action-bar' + +const EMPTY_ONLINE_USERS: WorkflowOnlineUser[] = [] + +type AppCardProps = { + app: AppPartial + onlineUsers?: WorkflowOnlineUser[] + onOpenTagManagement?: () => void + stepByStepTourActionMenuOpen?: boolean + stepByStepTourCardTarget?: string + stepByStepTourCardHighlightPart?: string + stepByStepTourActionMenuHighlightPart?: string +} + +export const AppCard = memo( + ({ + app, + onlineUsers = EMPTY_ONLINE_USERS, + onOpenTagManagement, + stepByStepTourActionMenuOpen = false, + stepByStepTourCardTarget, + stepByStepTourCardHighlightPart, + stepByStepTourActionMenuHighlightPart, + }: AppCardProps) => { + const { t } = useTranslation() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const { data: currentUserId } = useSuspenseQuery({ + ...userProfileQueryOptions(), + select: (data) => data.profile.id, + }) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + const isRbacEnabled = systemFeatures.rbac_enabled + const resourceMaintainer = app.maintainer ?? undefined + const maintainerPermissionOptions = useMemo( + () => ({ + currentUserId, + resourceMaintainer, + workspacePermissionKeys, + isRbacEnabled, + }), + [currentUserId, isRbacEnabled, resourceMaintainer, workspacePermissionKeys], + ) + const appACLCapabilities = useMemo( + () => getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions), + [app.permission_keys, maintainerPermissionOptions], + ) + const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys) + const canManageAppTags = hasPermission(workspacePermissionKeys, 'app.tag.manage') + const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || appACLCapabilities.canEdit) + const editTimeText = useMemo(() => { + const timestamp = app.updated_at || app.created_at + if (!timestamp) return '' + + const timeText = formatTime({ + date: timestamp * 1000, + dateFormat: `${t(($) => $['segment.dateTimeFormat'], { ns: 'datasetDocuments' })}`, + }) + return `${t(($) => $['segment.editedAt'], { ns: 'datasetDocuments' })} ${timeText}` + }, [app.updated_at, app.created_at, t]) + + const appModeLabel = useMemo(() => { + switch (app.mode) { + case AppModeEnum.CHAT: + return t(($) => $['types.chatbot'], { ns: 'app' }) + case AppModeEnum.ADVANCED_CHAT: + return t(($) => $['types.advanced'], { ns: 'app' }) + case AppModeEnum.AGENT_CHAT: + return t(($) => $['types.agent'], { ns: 'app' }) + case AppModeEnum.COMPLETION: + return t(($) => $['types.completion'], { ns: 'app' }) + case AppModeEnum.WORKFLOW: + return t(($) => $['types.workflow'], { ns: 'app' }) + default: + return app.mode + } + }, [app.mode, t]) + + const onlinePresenceUsers = useMemo(() => { + return onlineUsers + .map((user, index) => { + const id = user.user_id || user.sid || `${app.id}-online-${index}` + const name = user.username || user.user_id || user.sid || `${index + 1}` + return { + id, + name, + avatar_url: user.avatar || null, + } + }) + .filter((user) => Boolean(user.id)) + }, [app.id, onlineUsers]) + const appNameId = useId() + const appDescriptionId = useId() + const appIconType = zIconType.safeParse(app.icon_type).data ?? null + const appHref = getRedirectionPath(app, maintainerPermissionOptions) + const appCardClassName = cn( + 'inline-flex h-full w-full touch-manipulation flex-col overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs outline-hidden transition-shadow duration-200 ease-in-out', + isPreviewOnly + ? 'cursor-not-allowed opacity-60 focus-visible:ring-2 focus-visible:ring-state-accent-solid' + : 'cursor-pointer hover:shadow-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid', + ) + const showPreviewOnlyAccessWarning = useCallback(() => { + toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' })) + }, [t]) + const appCardContent = ( + <> +
+
+ + +
+
+
+
+ {app.name} +
+
+
+ {appModeLabel} +
+
+ {onlinePresenceUsers.length > 0 && ( +
+ +
+ )} +
+
+
+ {app.description} +
+
+
+
+
+ {app.author_name && ( + <> +
{app.author_name}
+
·
+ + )} +
{editTimeText}
+
+
+ + ) + + return ( +
+ {isPreviewOnly ? ( + + ) : ( + + {appCardContent} + + )} + {!isPreviewOnly && ( + + )} +
+ +
+
+ ) + }, +) diff --git a/web/app/components/apps/app-card/operations-menu.tsx b/web/app/components/apps/app-card/operations-menu.tsx new file mode 100644 index 00000000000..714d9dce82e --- /dev/null +++ b/web/app/components/apps/app-card/operations-menu.tsx @@ -0,0 +1,201 @@ +'use client' + +import type { AppPartial } from '@dify/contracts/api/console/apps/types.gen' +import type { MouseEvent } from 'react' +import { DropdownMenuItem, DropdownMenuSeparator } from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' +import { useGetUserCanAccessApp } from '@/service/access-control/use-app-access-control' +import { fetchInstalledAppList } from '@/service/explore' +import { AppModeEnum } from '@/types/app' +import { basePath } from '@/utils/var' + +const APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE = new Set([ + AppModeEnum.ADVANCED_CHAT, + AppModeEnum.WORKFLOW, +]) + +function requiresPublishedWorkflowInExplore(app: AppPartial) { + return APP_MODES_REQUIRING_PUBLISHED_WORKFLOW_IN_EXPLORE.has(app.mode) +} + +type AppCardOperationsMenuProps = { + app: AppPartial + shouldShowEditOption: boolean + shouldShowDuplicateOption: boolean + shouldShowExportOption: boolean + shouldShowSwitchOption: boolean + shouldShowOpenInExploreOption: boolean + shouldShowAccessConfigOption: boolean + shouldShowDeleteOption: boolean + isExporting: boolean + onEdit: () => void + onDuplicate: () => void + onExport: () => void + onSwitch: () => void + onDelete: () => void + onAccessConfig: () => void +} + +function AppCardOperationsMenu({ + app, + shouldShowEditOption, + shouldShowDuplicateOption, + shouldShowExportOption, + shouldShowSwitchOption, + shouldShowOpenInExploreOption, + shouldShowAccessConfigOption, + shouldShowDeleteOption, + isExporting, + onEdit, + onDuplicate, + onExport, + onSwitch, + onDelete, + onAccessConfig, +}: AppCardOperationsMenuProps) { + const { t } = useTranslation() + const openAsyncWindow = useAsyncWindowOpen() + const hasEditGroup = shouldShowEditOption + const hasCreateExportGroup = shouldShowDuplicateOption || shouldShowExportOption + const hasSwitchOrExploreGroup = shouldShowSwitchOption || shouldShowOpenInExploreOption + const hasAccessDeleteGroup = shouldShowAccessConfigOption || shouldShowDeleteOption + + function handleMenuAction(e: MouseEvent, action: () => void) { + e.stopPropagation() + e.preventDefault() + action() + } + + async function handleOpenInstalledApp(e: MouseEvent) { + e.stopPropagation() + e.preventDefault() + if (requiresPublishedWorkflowInExplore(app) && !app.workflow?.id) { + toast.error(t(($) => $.notPublishedYet, { ns: 'app' })) + return + } + + try { + await openAsyncWindow( + async () => { + const { installed_apps } = await fetchInstalledAppList(app.id) + if (installed_apps?.length > 0) + return `${basePath}${buildInstalledAppPath(installed_apps[0]!.id)}` + throw new Error(t(($) => $.notPublishedYet, { ns: 'app' })) + }, + { + onError: (err) => { + toast.error(`${err.message || err}`) + }, + }, + ) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : `${e}` + toast.error(message) + } + } + + return ( + <> + {shouldShowEditOption && ( + handleMenuAction(e, onEdit)}> + + {t(($) => $.editApp, { ns: 'app' })} + + + )} + {hasEditGroup && + (hasCreateExportGroup || hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + + )} + {shouldShowDuplicateOption && ( + handleMenuAction(e, onDuplicate)}> + + {t(($) => $.duplicate, { ns: 'app' })} + + + )} + {shouldShowExportOption && ( + handleMenuAction(e, onExport)} + > + + {t(($) => $.export, { ns: 'app' })} + + + )} + {hasCreateExportGroup && (hasSwitchOrExploreGroup || hasAccessDeleteGroup) && ( + + )} + {shouldShowSwitchOption && ( + handleMenuAction(e, onSwitch)}> + {t(($) => $.switch, { ns: 'app' })} + + )} + {shouldShowOpenInExploreOption && ( + + + {t(($) => $.openInExplore, { ns: 'app' })} + + + )} + {hasSwitchOrExploreGroup && hasAccessDeleteGroup && } + {shouldShowAccessConfigOption && ( + handleMenuAction(e, onAccessConfig)} + > + + {t(($) => $['settings.resourceAccess'], { ns: 'common' })} + + + )} + {shouldShowAccessConfigOption && shouldShowDeleteOption && } + {shouldShowDeleteOption && ( + handleMenuAction(e, onDelete)} + > + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + )} + + ) +} + +type AppCardOperationsMenuContentProps = Omit< + AppCardOperationsMenuProps, + 'shouldShowOpenInExploreOption' +> + +export function AppCardOperationsMenuContent(props: AppCardOperationsMenuContentProps) { + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) + const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ + appId: props.app.id, + enabled: systemFeatures.webapp_auth.enabled, + }) + const needsPublishBeforeExplore = + requiresPublishedWorkflowInExplore(props.app) && !props.app.workflow?.id + + const shouldShowOpenInExploreOption = + !props.app.has_draft_trigger && + (needsPublishBeforeExplore || + !systemFeatures.webapp_auth.enabled || + (!isGettingUserCanAccessApp && Boolean(userCanAccessApp?.result))) + + return ( + + ) +} diff --git a/web/app/components/apps/starred-app-card.tsx b/web/app/components/apps/starred-app-card.tsx index 767abd0477e..5d3e7da6ae6 100644 --- a/web/app/components/apps/starred-app-card.tsx +++ b/web/app/components/apps/starred-app-card.tsx @@ -17,7 +17,7 @@ import Link from '@/next/link' import { getRedirectionPath } from '@/utils/app-redirection' import { hasOnlyAppPreviewPermission } from '@/utils/permission' import { formatTime } from '@/utils/time' -import { AppCardActionBar } from './app-card' +import { AppCardActionBar } from './app-card/action-bar' type StarredAppCardProps = { app: AppPartial diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx index 0a8834988af..d180617cdd9 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx @@ -36,7 +36,7 @@ const AnnotationReply = ({ disabled, onChange }: Props) => { draft.annotationReply = newConfig }) setFeatures(newFeatures) - if (onChange) onChange(newFeatures) + onChange?.() }, [featuresStore, onChange], ) diff --git a/web/app/components/datasets/extra-info/service-api/index.tsx b/web/app/components/datasets/extra-info/service-api/index.tsx index f0cc583dea7..7dd1271949e 100644 --- a/web/app/components/datasets/extra-info/service-api/index.tsx +++ b/web/app/components/datasets/extra-info/service-api/index.tsx @@ -70,6 +70,7 @@ const ServiceApi = ({ apiBaseUrl }: ServiceApiProps) => { isShow={isSecretKeyModalVisible} onClose={handleCloseSecretKeyModal} canManage={canManageSecretKey} + scope={{ type: 'dataset' }} />
) diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx index 5749bfdbe79..0eb189f2dfb 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-button.spec.tsx @@ -1,3 +1,4 @@ +import type { SecretKeyScope } from '../secret-key-modal' import { act, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import SecretKeyButton from '../secret-key-button' @@ -6,17 +7,19 @@ vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ default: ({ isShow, onClose, - appId, canManage, + scope, }: { isShow: boolean onClose: () => void - appId?: string canManage: boolean + scope: SecretKeyScope }) => isShow ? (
- {`Modal for ${appId || 'no-app'}`} + + {`Modal for ${scope.type === 'dataset' ? 'no-app' : scope.appId}`} + {String(canManage)}
- + { + if (!open && !isDeleting) setDeleteKeyId(undefined) + }} + >
@@ -244,10 +197,10 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
- + {t(($) => $['operation.cancel'], { ns: 'common' })} - + {t(($) => $['operation.confirm'], { ns: 'common' })} @@ -255,16 +208,12 @@ const SecretKeyModal = ({ isShow = false, appId, canManage, onClose }: ISecretKe
- {isShow && ( - setIsVisible(false)} - newKey={newKey} - /> - )} + setNewKey(undefined)} + newKey={newKey} + /> ) } - -export default SecretKeyModal diff --git a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx index ab2e1f0a805..da83cfc85c4 100644 --- a/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/permission-set-modal/__tests__/index.spec.tsx @@ -13,6 +13,7 @@ const expectedAppACLPermissionKeys = [ 'app.acl.edit', 'app.acl.import_export_dsl', 'app.acl.delete', + 'app.acl.deploy', 'app.acl.release_and_version', 'app.acl.monitor', 'app.acl.tracing_config', diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 1d57032998d..91698cdbedc 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -714,21 +714,9 @@ describe('MainNav', () => { ).not.toBeInTheDocument() }) - it('renders deployments in primary navigation when app deploy is enabled', () => { + it('hides deployments in primary navigation when app deploy is enabled', () => { renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) - const marketplaceLink = screen.getByRole('link', { name: /common.mainNav.marketplace/ }) - const deploymentsLink = screen.getByRole('link', { name: /common.menus.deployments/ }) - - expect(deploymentsLink).toHaveAttribute('href', '/deployments') - expect(marketplaceLink.compareDocumentPosition(deploymentsLink)).toBe( - Node.DOCUMENT_POSITION_FOLLOWING, - ) - }) - - it('hides deployments in primary navigation when app deploy is disabled', () => { - renderMainNav({ branding: { enabled: false }, enable_app_deploy: false }) - expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument() }) @@ -911,10 +899,9 @@ describe('MainNav', () => { expect( screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), ).toBeInTheDocument() - expect(screen.getByRole('link', { name: /common.menus.deployments/ })).toHaveAttribute( - 'href', - '/deployments', - ) + expect( + screen.queryByRole('link', { name: /common.menus.deployments/ }), + ).not.toBeInTheDocument() }, ) diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index bfd49563a9c..22062086106 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -31,7 +31,6 @@ export type DetailSidebarVisibilityOptions = Pick< const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true const CAN_MANAGE_AGENTS: MainNavRouteVisibility = (options) => options.canManageAgents -const CAN_USE_APP_DEPLOY: MainNavRouteVisibility = (options) => options.canUseAppDeploy function isPathUnderRoute(pathname: string, route: string) { return pathname === route || pathname.startsWith(`${route}/`) @@ -99,15 +98,6 @@ export const MAIN_NAV_ROUTES = [ visibility: VISIBLE_TO_ALL, feature: 'marketplace', }, - { - key: 'deployments', - href: '/deployments', - labelKey: 'menus.deployments', - active: (path: string) => isPathUnderRoute(path, '/deployments'), - icon: 'i-ri-rocket-line', - activeIcon: 'i-ri-rocket-fill', - visibility: CAN_USE_APP_DEPLOY, - }, ] as const satisfies readonly MainNavRouteConfig[] export function isMainNavRouteVisible( diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts index e78a43131a3..38535e5635f 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/hooks/use-app-inputs-form-schema.ts @@ -1,8 +1,10 @@ 'use client' -import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' +import type { + AppDetailWithSite, + WorkflowResponse, +} from '@dify/contracts/api/console/apps/types.gen' import type { FileUpload } from '@/app/components/base/features/types' import type { FileUploadConfigResponse } from '@/models/common' -import type { FetchWorkflowDraftResponse } from '@/types/workflow' import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' @@ -35,6 +37,13 @@ type InputSchemaItem = { [key: string]: unknown } +type WorkflowNodeWithVariables = { + data: { + type: BlockEnum + variables?: Array> + } +} + function isBasicAppMode(mode: string): boolean { return mode !== AppModeEnum.ADVANCED_CHAT && mode !== AppModeEnum.WORKFLOW } @@ -134,12 +143,15 @@ function buildBasicAppSchema( } function buildWorkflowSchema( - workflow: FetchWorkflowDraftResponse, + workflow: Pick, fileUploadConfig?: FileUploadConfigResponse, ): InputSchemaItem[] { - const startNode = workflow.graph?.nodes.find((node) => node.data.type === BlockEnum.Start) as - | { data: { variables: Array> } } - | undefined + const nodes = workflow.graph.nodes + if (!Array.isArray(nodes)) return [] + + const startNode = (nodes as WorkflowNodeWithVariables[]).find( + (node) => node.data.type === BlockEnum.Start, + ) if (!startNode?.data.variables) return [] diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts index b92978860c8..3b6178a1809 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -26,16 +26,6 @@ vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({ }), })) -vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({ - useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise, checkFn: () => boolean) => { - return (...args: unknown[]) => { - if (!checkFn()) { - return fn(...args) - } - } - }, -})) - const mockSyncWorkflowDraft = vi.fn() vi.mock('@/service/workflow', () => ({ syncWorkflowDraft: (params: unknown) => mockSyncWorkflowDraft(params), @@ -230,6 +220,104 @@ describe('useNodesSyncDraft', () => { expect(mockSyncWorkflowDraft).toHaveBeenCalled() }) + it('should capture the graph before a queued sync runs after the canvas is torn down', async () => { + const draftNode = { + id: 'node-1', + data: { type: 'start' }, + position: { x: 0, y: 0 }, + } + const draftEdge = { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + data: { visible: true }, + } + mockGetNodes.mockReturnValue([draftNode]) + mockStoreGetState.mockReturnValue({ + getNodes: mockGetNodes, + edges: [draftEdge], + transform: [10, 20, 1.5], + }) + + const { result } = renderHook(() => useNodesSyncDraft()) + let syncPromise!: ReturnType + + act(() => { + syncPromise = result.current.doSyncWorkflowDraft() + + // Simulate ReactFlow clearing its store immediately after the page starts unmounting. + mockGetNodes.mockReturnValue([]) + mockStoreGetState.mockReturnValue({ + getNodes: mockGetNodes, + edges: [], + transform: [0, 0, 1], + }) + }) + + await act(async () => { + await syncPromise + }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + graph: { + nodes: [draftNode], + edges: [draftEdge], + viewport: { x: 10, y: 20, zoom: 1.5 }, + }, + }), + }), + ) + }) + + it('should use the latest hash when sending a previously captured queued graph', async () => { + const workflowState = { + pipelineId: 'test-pipeline-id', + environmentVariables: [], + syncWorkflowDraftHash: 'initial-hash', + ragPipelineVariables: [], + setSyncWorkflowDraftHash: vi.fn(), + setDraftUpdatedAt: vi.fn(), + } + workflowState.setSyncWorkflowDraftHash.mockImplementation((hash: string) => { + workflowState.syncWorkflowDraftHash = hash + }) + mockWorkflowStoreGetState.mockImplementation(() => workflowState) + mockGetNodes.mockReturnValue([ + { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } }, + ]) + mockSyncWorkflowDraft + .mockResolvedValueOnce({ hash: 'hash-after-first-sync', updated_at: 'first-update' }) + .mockResolvedValueOnce({ hash: 'hash-after-second-sync', updated_at: 'second-update' }) + + const { result } = renderHook(() => useNodesSyncDraft()) + let firstSync!: ReturnType + let secondSync!: ReturnType + + act(() => { + firstSync = result.current.doSyncWorkflowDraft() + secondSync = result.current.doSyncWorkflowDraft() + }) + + await act(async () => { + await Promise.all([firstSync, secondSync]) + }) + + expect(mockSyncWorkflowDraft).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + params: expect.objectContaining({ hash: 'initial-hash' }), + }), + ) + expect(mockSyncWorkflowDraft).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + params: expect.objectContaining({ hash: 'hash-after-first-sync' }), + }), + ) + }) + it('should not include source_workflow_id in sync payloads', async () => { mockGetNodesReadOnly.mockReturnValue(false) mockGetNodes.mockReturnValue([ diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts index 98cb0bbe03c..007740d223a 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-init.spec.ts @@ -156,6 +156,14 @@ describe('usePipelineInit', () => { expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('test-hash') }) }) + + it('should mark workflow data as loaded after a successful fetch', async () => { + renderHook(() => usePipelineInit()) + + await waitFor(() => { + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: true }) + }) + }) }) describe('environment variables handling', () => { diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index 0c52fe4abc3..92cbf061634 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -69,34 +69,56 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { }, [getPostParams, getNodesReadOnly]) const performSync = useCallback( - async (notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback) => { + async ( + baseParams: NonNullable>, + notRefreshWhenSyncError?: boolean, + callback?: SyncDraftCallback, + ) => { if (getNodesReadOnly()) return - const postParams = getPostParams() - if (postParams) { - const { setSyncWorkflowDraftHash, setDraftUpdatedAt } = workflowStore.getState() - try { - const res = await syncWorkflowDraft(postParams) - setSyncWorkflowDraftHash(res.hash) - setDraftUpdatedAt(res.updated_at) - callback?.onSuccess?.() - } catch (error: any) { - if (error && error.json && !error.bodyUsed) { - error.json().then((err: any) => { - if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) - handleRefreshWorkflowDraft() - }) - } - callback?.onError?.() - } finally { - callback?.onSettled?.() + const { setSyncWorkflowDraftHash, setDraftUpdatedAt, syncWorkflowDraftHash } = + workflowStore.getState() + const postParams = { + ...baseParams, + params: { + ...baseParams.params, + hash: syncWorkflowDraftHash, + }, + } + + try { + const res = await syncWorkflowDraft(postParams) + setSyncWorkflowDraftHash(res.hash) + setDraftUpdatedAt(res.updated_at) + callback?.onSuccess?.() + } catch (error: any) { + if (error && error.json && !error.bodyUsed) { + error.json().then((err: any) => { + if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError) + handleRefreshWorkflowDraft() + }) } + callback?.onError?.() + } finally { + callback?.onSettled?.() } }, - [getPostParams, getNodesReadOnly, workflowStore, handleRefreshWorkflowDraft], + [getNodesReadOnly, workflowStore, handleRefreshWorkflowDraft], ) - const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + const queueSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly) + const doSyncWorkflowDraft = useCallback( + (notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback) => { + if (getNodesReadOnly()) return Promise.resolve() + + // Capture before ReactFlow resets its store during route unmount. + const postParams = getPostParams() + if (!postParams) return Promise.resolve() + + return queueSyncWorkflowDraft(postParams, notRefreshWhenSyncError, callback) + }, + [getNodesReadOnly, getPostParams, queueSyncWorkflowDraft], + ) return { doSyncWorkflowDraft, diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts index c7abe70ef49..81bc6dcdedc 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-init.ts @@ -53,6 +53,7 @@ export const usePipelineInit = () => { ) setSyncWorkflowDraftHash(res.hash) setRagPipelineVariables?.(res.rag_pipeline_variables || []) + workflowStore.setState({ isWorkflowDataLoaded: true }) setIsLoading(false) } catch (error: any) { if (error && error.json && !error.bodyUsed && datasetId) { diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index b072831326e..3358f92b285 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -1,4 +1,5 @@ import type { SiteInfo } from '@/models/share' +import type { WebAppAddress } from '@/service/webapp-address' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import MenuDropdown from '../menu-dropdown' @@ -35,6 +36,7 @@ vi.mock('@/next/navigation', () => ({ })) const mockShareCode = 'test-share-code' +const webAppAddress: WebAppAddress = { kind: 'default', code: mockShareCode } vi.mock('@/context/web-app-context', () => ({ useWebAppStore: (selector: (state: Record) => unknown) => { const state = { @@ -50,6 +52,10 @@ vi.mock('@/service/webapp-auth', () => ({ webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args), })) +vi.mock('@/service/webapp-address', () => ({ + resolveWebAppAddress: () => webAppAddress, +})) + afterEach(() => { cleanup() }) @@ -189,7 +195,7 @@ describe('MenuDropdown', () => { }) await waitFor(() => { - expect(mockWebAppLogout).toHaveBeenCalledWith(mockShareCode) + expect(mockWebAppLogout).toHaveBeenCalledWith(webAppAddress) expect(mockReplace).toHaveBeenCalledWith(`/webapp-signin?redirect_url=${mockPathname}`) }) }) diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 0b539e1efdd..6b5ce99595e 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -18,6 +18,7 @@ import ThemeSwitcher from '@/app/components/base/theme-switcher' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter } from '@/next/navigation' +import { resolveWebAppAddress } from '@/service/webapp-address' import { webAppLogout } from '@/service/webapp-auth' import InfoModal from './info-modal' @@ -33,9 +34,8 @@ const MenuDropdown: FC = ({ data, placement, hideLogout }) => { const pathname = usePathname() const { t } = useTranslation() - const shareCode = useWebAppStore((s) => s.shareCode) const handleLogout = async () => { - await webAppLogout(shareCode!) + await webAppLogout(resolveWebAppAddress()) router.replace(`/webapp-signin?redirect_url=${pathname}`) } diff --git a/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts b/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts index 88ed85392a8..2106edb363d 100644 --- a/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts +++ b/web/app/components/snippets/hooks/__tests__/use-snippet-init.spec.ts @@ -178,6 +178,16 @@ describe('useSnippetInit', () => { expect(result.current.data?.draft.graph.viewport).toEqual({ x: 10, y: 20, zoom: 1.2 }) }) + it('should mark workflow data as loaded after the draft request completes', async () => { + const { result } = renderHook(() => useSnippetInit('snippet-1')) + + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) + + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: true }) + }) + it('should not return stale draft data while the draft workflow request is pending', () => { mockFetchSnippetDraftWorkflow.mockReturnValue(new Promise(() => {})) diff --git a/web/app/components/snippets/hooks/use-snippet-init.ts b/web/app/components/snippets/hooks/use-snippet-init.ts index a29ddfd4d5e..c5ff0c99899 100644 --- a/web/app/components/snippets/hooks/use-snippet-init.ts +++ b/web/app/components/snippets/hooks/use-snippet-init.ts @@ -87,6 +87,7 @@ export const useSnippetInit = (snippetId: string) => { setSyncWorkflowDraftHash(response.hash) } + workflowStore.setState({ isWorkflowDataLoaded: true }) setDraftWorkflowState({ snippetId, data: response, diff --git a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx b/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx deleted file mode 100644 index 6b2fc1993ac..00000000000 --- a/web/app/components/tools/mcp/__tests__/mcp-service-card.spec.tsx +++ /dev/null @@ -1,519 +0,0 @@ -import type { ReactNode } from 'react' -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { QueryClient } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import * as React from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { seedSystemFeatures } from '@/test/console/query-data' -import { QueryClientTestProvider } from '@/test/console/query-provider' -import { AppModeEnum } from '@/types/app' -import MCPServiceCard from '../mcp-service-card' - -vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ - default: ({ show, onHide }: { show: boolean; onHide: () => void }) => { - if (!show) return null - return ( -
- -
- ) - }, -})) - -const mockHandleStatusChange = vi.fn().mockResolvedValue({ activated: true }) -const mockHandleServerModalHide = vi.fn().mockReturnValue({ shouldDeactivate: false }) -const mockHandleGenCode = vi.fn() -const mockOpenConfirmDelete = vi.fn() -const mockCloseConfirmDelete = vi.fn() -const mockOpenServerModal = vi.fn() -const mockOnMcpServerUpdate = vi.hoisted(() => vi.fn()) -const mockUnsubscribeMcpServerUpdate = vi.hoisted(() => vi.fn()) -const invalidateMCPServerDetailFns = vi.hoisted(() => [] as Array>) - -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { - onMcpServerUpdate: mockOnMcpServerUpdate, - }, -})) - -vi.mock('@/service/use-tools', () => ({ - useInvalidateMCPServerDetail: () => { - const invalidateFn = vi.fn() - invalidateMCPServerDetailFns.push(invalidateFn) - return invalidateFn - }, -})) - -type MockHookState = { - genLoading: boolean - isLoading: boolean - serverPublished: boolean - serverActivated: boolean - serverURL: string - detail: - | { - id: string - status: string - server_code: string - description: string - parameters: Record - } - | undefined - canManageMCP: boolean - toggleDisabled: boolean - isMinimalState: boolean - appUnpublished: boolean - missingStartNode: boolean - showConfirmDelete: boolean - showMCPServerModal: boolean - latestParams: Array -} - -const createDefaultHookState = (overrides: Partial = {}): MockHookState => ({ - genLoading: false, - isLoading: false, - serverPublished: true, - serverActivated: true, - serverURL: 'https://api.example.com/mcp/server/abc123/mcp', - detail: { - id: 'server-123', - status: 'active', - server_code: 'abc123', - description: 'Test server', - parameters: {}, - }, - canManageMCP: true, - toggleDisabled: false, - isMinimalState: false, - appUnpublished: false, - missingStartNode: false, - showConfirmDelete: false, - showMCPServerModal: false, - latestParams: [], - ...overrides, -}) - -let mockHookState = createDefaultHookState() - -vi.mock('../hooks/use-mcp-service-card', () => ({ - useMCPServiceCardState: () => ({ - ...mockHookState, - handleStatusChange: mockHandleStatusChange, - handleServerModalHide: mockHandleServerModalHide, - handleGenCode: mockHandleGenCode, - openConfirmDelete: mockOpenConfirmDelete, - closeConfirmDelete: mockCloseConfirmDelete, - openServerModal: mockOpenServerModal, - }), -})) - -describe('MCPServiceCard', () => { - const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - seedSystemFeatures(queryClient) - return ({ children }: { children: ReactNode }) => - React.createElement(QueryClientTestProvider, { queryClient }, children) - } - - const createMockAppInfo = ( - mode: AppModeEnum = AppModeEnum.CHAT, - ): AppDetailResponse & Partial => - ({ - id: 'app-123', - name: 'Test App', - mode, - api_base_url: 'https://api.example.com/v1', - }) as AppDetailResponse & Partial - - beforeEach(() => { - mockHookState = createDefaultHookState() - invalidateMCPServerDetailFns.length = 0 - mockHandleStatusChange.mockClear().mockResolvedValue({ activated: true }) - mockHandleServerModalHide.mockClear().mockReturnValue({ shouldDeactivate: false }) - mockHandleGenCode.mockClear() - mockOpenConfirmDelete.mockClear() - mockCloseConfirmDelete.mockClear() - mockOpenServerModal.mockClear() - mockUnsubscribeMcpServerUpdate.mockClear() - mockOnMcpServerUpdate.mockReset().mockReturnValue(mockUnsubscribeMcpServerUpdate) - }) - - describe('Rendering', () => { - it('should render title, status indicator, and switch', () => { - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument() - expect(screen.getByRole('switch')).toBeInTheDocument() - }) - - it('should keep status visible and disable management controls without app edit permission', () => { - mockHookState = createDefaultHookState({ - canManageMCP: false, - toggleDisabled: true, - }) - - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument() - expect(screen.getByText('https://api.example.com/mcp/server/abc123/mcp')).toBeInTheDocument() - - const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('cursor-not-allowed') - - const editButton = screen.getByRole('button', { name: /tools\.mcp\.server\.edit/i }) - expect(editButton).toBeDisabled() - - const regenerateButton = screen.getByRole('button', { - name: /appOverview\.overview\.appInfo\.regenerate/i, - }) - expect(regenerateButton).toBeDisabled() - }) - - it('should render edit button in full state', () => { - render(, { wrapper: createWrapper() }) - - const editBtn = screen.getByRole('button', { name: /tools\.mcp\.server\.edit/i }) - expect(editBtn).toBeInTheDocument() - }) - - it('should return null when isLoading is true', () => { - mockHookState = createDefaultHookState({ isLoading: true }) - - const { container } = render(, { - wrapper: createWrapper(), - }) - expect(container.firstChild).toBeNull() - }) - - it('should render content when isLoading is false', () => { - mockHookState = createDefaultHookState({ isLoading: false }) - - render(, { wrapper: createWrapper() }) - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - }) - }) - - describe('Different App Modes', () => { - it.each([ - AppModeEnum.CHAT, - AppModeEnum.WORKFLOW, - AppModeEnum.ADVANCED_CHAT, - AppModeEnum.COMPLETION, - AppModeEnum.AGENT_CHAT, - ])('should render for %s app mode', (mode) => { - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - expect(screen.getByRole('switch')).toBeInTheDocument() - }) - }) - - describe('Trigger Mode Disabled', () => { - it('should show cursor-not-allowed overlay when triggerModeDisabled is true', () => { - const { container } = render( - , - { wrapper: createWrapper() }, - ) - - const overlay = container.querySelector('.cursor-not-allowed[aria-hidden="true"]') - expect(overlay).toBeInTheDocument() - }) - - it('should not show cursor-not-allowed overlay when triggerModeDisabled is false', () => { - const { container } = render( - , - { wrapper: createWrapper() }, - ) - - const overlay = container.querySelector('.cursor-not-allowed[aria-hidden="true"]') - expect(overlay).toBeNull() - }) - }) - - describe('Switch Toggle', () => { - it('should call handleStatusChange with false when turning off an active server', async () => { - mockHookState = createDefaultHookState({ serverActivated: true }) - mockHandleStatusChange.mockResolvedValue({ activated: false }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByRole('switch')) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(false) - }) - }) - - it('should call handleStatusChange with true when turning on an inactive server', async () => { - mockHookState = createDefaultHookState({ serverActivated: false }) - mockHandleStatusChange.mockResolvedValue({ activated: true }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByRole('switch')) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(true) - }) - }) - - it('should show disabled styling when toggleDisabled is true', () => { - mockHookState = createDefaultHookState({ toggleDisabled: true }) - - render(, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('cursor-not-allowed') - }) - }) - - describe('Server Not Published', () => { - beforeEach(() => { - mockHookState = createDefaultHookState({ - serverPublished: false, - serverActivated: false, - serverURL: '***********', - detail: undefined, - isMinimalState: true, - }) - }) - - it('should render in minimal state without edit button', () => { - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('tools.mcp.server.title')).toBeInTheDocument() - expect( - screen.queryByRole('button', { name: /tools\.mcp\.server\.edit/i }), - ).not.toBeInTheDocument() - }) - - it('should open modal when enabling unpublished server', async () => { - mockHandleStatusChange.mockResolvedValue({ activated: false }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByRole('switch')) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(true) - }) - }) - }) - - describe('Inactive Server', () => { - beforeEach(() => { - mockHookState = createDefaultHookState({ - serverActivated: false, - detail: { - id: 'server-123', - status: 'inactive', - server_code: 'abc123', - description: 'Test server', - parameters: {}, - }, - }) - }) - - it('should show disabled status indicator', () => { - render(, { wrapper: createWrapper() }) - - expect(screen.getByText(/appOverview.overview.status/)).toBeInTheDocument() - }) - - it('should allow toggling switch when server is inactive but published', async () => { - mockHandleStatusChange.mockResolvedValue({ activated: true }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByRole('switch')) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(true) - }) - }) - }) - - describe('Confirm Regenerate Dialog', () => { - it('should call handleGenCode and closeConfirmDelete when confirm is clicked', async () => { - mockHookState = createDefaultHookState({ showConfirmDelete: true }) - - render(, { wrapper: createWrapper() }) - - expect(screen.getByText('appOverview.overview.appInfo.regenerate')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) - - await waitFor(() => { - expect(mockHandleGenCode).toHaveBeenCalled() - expect(mockCloseConfirmDelete).toHaveBeenCalled() - }) - }) - - it('should call closeConfirmDelete when cancel is clicked', async () => { - mockHookState = createDefaultHookState({ showConfirmDelete: true }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - - await waitFor(() => { - expect(mockCloseConfirmDelete).toHaveBeenCalled() - expect(mockHandleGenCode).not.toHaveBeenCalled() - }) - }) - }) - - describe('MCP Server Modal', () => { - it('should render modal when showMCPServerModal is true', () => { - mockHookState = createDefaultHookState({ showMCPServerModal: true }) - - render(, { wrapper: createWrapper() }) - - expect(screen.getByTestId('mcp-server-modal')).toBeInTheDocument() - }) - - it('should call handleServerModalHide when modal is closed', async () => { - mockHookState = createDefaultHookState({ - showMCPServerModal: true, - serverActivated: false, - }) - - render(, { wrapper: createWrapper() }) - - fireEvent.click(screen.getByTestId('close-modal-btn')) - - await waitFor(() => { - expect(mockHandleServerModalHide).toHaveBeenCalled() - }) - }) - - it('should open modal via edit button click', async () => { - render(, { wrapper: createWrapper() }) - - const editBtn = screen.getByRole('button', { name: /tools\.mcp\.server\.edit/i }) - fireEvent.click(editBtn) - - expect(mockOpenServerModal).toHaveBeenCalled() - }) - }) - - describe('Unpublished App', () => { - it('should show minimal state and disabled switch', () => { - mockHookState = createDefaultHookState({ - appUnpublished: true, - isMinimalState: true, - toggleDisabled: true, - }) - - render(, { wrapper: createWrapper() }) - - const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('cursor-not-allowed') - }) - }) - - describe('Workflow Without Start Node', () => { - it('should show minimal state with disabled switch', () => { - mockHookState = createDefaultHookState({ - missingStartNode: true, - isMinimalState: true, - toggleDisabled: true, - }) - - render(, { - wrapper: createWrapper(), - }) - - const switchElement = screen.getByRole('switch') - expect(switchElement.className).toContain('cursor-not-allowed') - }) - }) - - describe('onChangeStatus edge case', () => { - it('should clear pending status when handleStatusChange returns activated: false for an enable action', async () => { - mockHookState = createDefaultHookState({ - serverActivated: false, - serverPublished: false, - }) - mockHandleStatusChange.mockResolvedValue({ activated: false }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByRole('switch')) - - await waitFor(() => { - expect(mockHandleStatusChange).toHaveBeenCalledWith(true) - }) - - expect(screen.getByRole('switch')).toBeInTheDocument() - }) - }) - - describe('onServerModalHide', () => { - it('should call handleServerModalHide with shouldDeactivate: true', async () => { - mockHookState = createDefaultHookState({ - showMCPServerModal: true, - serverActivated: false, - }) - mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: true }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByTestId('close-modal-btn')) - - await waitFor(() => { - expect(mockHandleServerModalHide).toHaveBeenCalled() - }) - }) - - it('should call handleServerModalHide with shouldDeactivate: false', async () => { - mockHookState = createDefaultHookState({ - showMCPServerModal: true, - serverActivated: true, - }) - mockHandleServerModalHide.mockReturnValue({ shouldDeactivate: false }) - - render(, { wrapper: createWrapper() }) - fireEvent.click(screen.getByTestId('close-modal-btn')) - - await waitFor(() => { - expect(mockHandleServerModalHide).toHaveBeenCalled() - }) - }) - }) - - describe('Accessibility', () => { - it('should have an accessible switch element', () => { - render(, { wrapper: createWrapper() }) - - expect(screen.getByRole('switch')).toBeInTheDocument() - }) - }) - - describe('Collaboration Sync', () => { - it('should keep a stable MCP update subscription across rerenders and invalidate with the latest callback', async () => { - let mcpUpdateHandler: ((payload: unknown) => void) | undefined - mockOnMcpServerUpdate.mockImplementation((handler: (payload: unknown) => void) => { - mcpUpdateHandler = handler - return mockUnsubscribeMcpServerUpdate - }) - - const wrapper = createWrapper() - const { rerender } = render(, { wrapper }) - - rerender() - - expect(mockOnMcpServerUpdate).toHaveBeenCalledTimes(1) - const latestInvalidateMCPServerDetail = invalidateMCPServerDetailFns.at(-1) - expect(latestInvalidateMCPServerDetail).toBeDefined() - - mcpUpdateHandler?.({ type: 'mcp_server_update' }) - - invalidateMCPServerDetailFns - .slice(0, -1) - .forEach((invalidate) => expect(invalidate).not.toHaveBeenCalled()) - expect(latestInvalidateMCPServerDetail).toHaveBeenCalledWith('app-123') - }) - }) -}) diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts deleted file mode 100644 index ea43cf7bb8b..00000000000 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-service-card.spec.ts +++ /dev/null @@ -1,460 +0,0 @@ -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { act } from '@testing-library/react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createAccountProfileQueryClient } from '@/test/console/account-profile' -import { createQueryClientWrapper } from '@/test/console/query-client' -import { renderHook } from '@/test/console/render' -import { AppModeEnum } from '@/types/app' -import { AppACLPermission } from '@/utils/permission' -import { useMCPServiceCardState } from '../use-mcp-service-card' - -// Mutable mock data for MCP server detail -let mockMCPServerDetailData: - | { - id: string - status: string - server_code: string - description: string - parameters: Record - } - | undefined = { - id: 'server-123', - status: 'active', - server_code: 'abc123', - description: 'Test server', - parameters: {}, -} -const mockUpdateMCPServer = vi.fn().mockResolvedValue({}) -const mockRefreshMCPServerCode = vi.fn().mockResolvedValue({}) -const mockInvalidateMCPServerDetail = vi.fn() -let mockUseMCPServerDetailAppID = '' -let mockUseMCPServerDetailEnabled: boolean | undefined - -// Mock service hooks -vi.mock('@/context/workspace-state', async () => { - const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') - return createWorkspaceStateModuleMock(() => ({ - currentWorkspace: { id: 'workspace-1' }, - })) -}) - -vi.mock('@/service/use-tools', () => ({ - useUpdateMCPServer: () => ({ - mutateAsync: mockUpdateMCPServer, - }), - useRefreshMCPServerCode: () => ({ - mutateAsync: mockRefreshMCPServerCode, - isPending: false, - }), - useMCPServerDetail: (appID: string, enabled?: boolean) => { - mockUseMCPServerDetailAppID = appID - mockUseMCPServerDetailEnabled = enabled - return { - data: mockMCPServerDetailData, - } - }, - useInvalidateMCPServerDetail: () => mockInvalidateMCPServerDetail, -})) - -// Mock workflow hook -vi.mock('@/service/use-workflow', () => ({ - useAppWorkflow: (appId: string) => ({ - data: appId - ? { - graph: { - nodes: [ - { data: { type: 'start', variables: [{ variable: 'input', label: 'Input' }] } }, - ], - }, - } - : undefined, - }), -})) - -// Mock apps service -vi.mock('@/service/apps', () => ({ - fetchAppDetail: vi.fn().mockResolvedValue({ - model_config: { - updated_at: '2024-01-01', - user_input_form: [], - }, - }), -})) - -vi.mock('@/context/permission-state', async () => { - const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') - - return createPermissionStateModuleMock(() => ({ - workspacePermissionKeys: [], - })) -}) - -describe('useMCPServiceCardState', () => { - const createWrapper = () => { - return createQueryClientWrapper(createAccountProfileQueryClient()) - } - - const createMockAppInfo = ( - mode: AppModeEnum = AppModeEnum.CHAT, - permissionKeys: string[] = [AppACLPermission.Edit], - ): AppDetailResponse & Partial => - ({ - id: 'app-123', - name: 'Test App', - mode, - api_base_url: 'https://api.example.com/v1', - permission_keys: permissionKeys, - }) as AppDetailResponse & Partial - - beforeEach(() => { - vi.clearAllMocks() - // Reset mock data to default (published server) - mockMCPServerDetailData = { - id: 'server-123', - status: 'active', - server_code: 'abc123', - description: 'Test server', - parameters: {}, - } - mockUseMCPServerDetailAppID = '' - mockUseMCPServerDetailEnabled = undefined - }) - - describe('Initialization', () => { - it('should initialize with correct default values for basic app', () => { - const appInfo = createMockAppInfo(AppModeEnum.CHAT) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.serverPublished).toBe(true) - expect(result.current.serverActivated).toBe(true) - expect(result.current.showConfirmDelete).toBe(false) - expect(result.current.showMCPServerModal).toBe(false) - }) - - it('should initialize with correct values for workflow app', () => { - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.isLoading).toBe(false) - }) - - it('should initialize with correct values for advanced chat app', () => { - const appInfo = createMockAppInfo(AppModeEnum.ADVANCED_CHAT) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.isLoading).toBe(false) - }) - }) - - describe('Server URL Generation', () => { - it('should generate correct server URL when published', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp') - }) - }) - - describe('Permission Flags', () => { - it('should expose MCP manage capability from app edit ACL', () => { - const appInfo = createMockAppInfo(AppModeEnum.CHAT, [AppACLPermission.Edit]) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.canManageMCP).toBe(true) - }) - - it('should keep MCP server status readable and disable mutations without app edit ACL', async () => { - const appInfo = createMockAppInfo(AppModeEnum.CHAT, []) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.canManageMCP).toBe(false) - expect(result.current.toggleDisabled).toBe(true) - expect(result.current.serverPublished).toBe(true) - expect(result.current.serverActivated).toBe(true) - expect(result.current.serverURL).toBe('https://api.example.com/mcp/server/abc123/mcp') - expect(mockUseMCPServerDetailAppID).toBe('app-123') - expect(mockUseMCPServerDetailEnabled).toBe(true) - - act(() => { - result.current.openServerModal() - result.current.openConfirmDelete() - }) - expect(result.current.showMCPServerModal).toBe(false) - expect(result.current.showConfirmDelete).toBe(false) - - await act(async () => { - await result.current.handleGenCode() - await result.current.handleStatusChange(true) - }) - expect(mockRefreshMCPServerCode).not.toHaveBeenCalled() - expect(mockUpdateMCPServer).not.toHaveBeenCalled() - }) - - it('should read workflow state without app edit ACL for workflow apps', () => { - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW, []) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.canManageMCP).toBe(false) - expect(result.current.isLoading).toBe(false) - expect(result.current.appUnpublished).toBe(false) - expect(result.current.missingStartNode).toBe(false) - expect(result.current.toggleDisabled).toBe(true) - }) - - it('should have toggleDisabled false when editor has permissions', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - // Toggle is not disabled when user has permissions and app is published - expect(typeof result.current.toggleDisabled).toBe('boolean') - }) - - it('should have toggleDisabled true when triggerModeDisabled is true', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, true), { - wrapper: createWrapper(), - }) - - expect(result.current.toggleDisabled).toBe(true) - }) - }) - - describe('UI State Actions', () => { - it('should open confirm delete modal', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.showConfirmDelete).toBe(false) - - act(() => { - result.current.openConfirmDelete() - }) - - expect(result.current.showConfirmDelete).toBe(true) - }) - - it('should close confirm delete modal', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.openConfirmDelete() - }) - expect(result.current.showConfirmDelete).toBe(true) - - act(() => { - result.current.closeConfirmDelete() - }) - expect(result.current.showConfirmDelete).toBe(false) - }) - - it('should open server modal', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.showMCPServerModal).toBe(false) - - act(() => { - result.current.openServerModal() - }) - - expect(result.current.showMCPServerModal).toBe(true) - }) - - it('should handle server modal hide', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - act(() => { - result.current.openServerModal() - }) - expect(result.current.showMCPServerModal).toBe(true) - - let hideResult: { shouldDeactivate: boolean } | undefined - act(() => { - hideResult = result.current.handleServerModalHide(false) - }) - - expect(result.current.showMCPServerModal).toBe(false) - expect(hideResult?.shouldDeactivate).toBe(true) - }) - - it('should not deactivate when wasActivated is true', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - let hideResult: { shouldDeactivate: boolean } | undefined - act(() => { - hideResult = result.current.handleServerModalHide(true) - }) - - expect(hideResult?.shouldDeactivate).toBe(false) - }) - }) - - describe('Handler Functions', () => { - it('should refresh the MCP server by app ID and invalidate server detail', async () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - await act(async () => { - await result.current.handleGenCode() - }) - - expect(mockRefreshMCPServerCode).toHaveBeenCalledWith('app-123') - expect(mockInvalidateMCPServerDetail).toHaveBeenCalledWith('app-123') - }) - - it('should call invalidateBasicAppConfig', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - // Call the function - should not throw - act(() => { - result.current.invalidateBasicAppConfig() - }) - - // Function should exist and be callable - expect(typeof result.current.invalidateBasicAppConfig).toBe('function') - }) - }) - - describe('Status Change', () => { - it('should return activated state when status change succeeds', async () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - let statusResult: { activated: boolean } | undefined - await act(async () => { - statusResult = await result.current.handleStatusChange(true) - }) - - expect(statusResult?.activated).toBe(true) - }) - - it('should return deactivated state when disabling', async () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - let statusResult: { activated: boolean } | undefined - await act(async () => { - statusResult = await result.current.handleStatusChange(false) - }) - - expect(statusResult?.activated).toBe(false) - }) - }) - - describe('Unpublished Server', () => { - it('should open modal and return not activated when enabling unpublished server', async () => { - // Set mock to return undefined (unpublished server) - mockMCPServerDetailData = undefined - - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - // Verify server is not published - expect(result.current.serverPublished).toBe(false) - - let statusResult: { activated: boolean } | undefined - await act(async () => { - statusResult = await result.current.handleStatusChange(true) - }) - - // Should open modal and return not activated - expect(result.current.showMCPServerModal).toBe(true) - expect(statusResult?.activated).toBe(false) - }) - }) - - describe('Loading States', () => { - it('should have genLoading state', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(typeof result.current.genLoading).toBe('boolean') - }) - - it('should have isLoading state for basic app', () => { - const appInfo = createMockAppInfo(AppModeEnum.CHAT) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - // Basic app doesn't need workflow, so isLoading should be false - expect(result.current.isLoading).toBe(false) - }) - }) - - describe('Detail Data', () => { - it('should return detail data when available', () => { - const appInfo = createMockAppInfo() - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(result.current.detail).toBeDefined() - expect(result.current.detail?.id).toBe('server-123') - expect(result.current.detail?.status).toBe('active') - }) - }) - - describe('Latest Params', () => { - it('should return latestParams for workflow app', () => { - const appInfo = createMockAppInfo(AppModeEnum.WORKFLOW) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(Array.isArray(result.current.latestParams)).toBe(true) - }) - - it('should return latestParams for basic app', () => { - const appInfo = createMockAppInfo(AppModeEnum.CHAT) - const { result } = renderHook(() => useMCPServiceCardState(appInfo, false), { - wrapper: createWrapper(), - }) - - expect(Array.isArray(result.current.latestParams)).toBe(true) - }) - }) -}) diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts deleted file mode 100644 index 59a9c4ba132..00000000000 --- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts +++ /dev/null @@ -1,210 +0,0 @@ -'use client' -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' -import { useCallback, useMemo, useState } from 'react' -import { BlockEnum } from '@/app/components/workflow/types' -import { workspacePermissionKeysAtom } from '@/context/permission-state' -import { userProfileQueryOptions } from '@/features/account-profile/client' -import { fetchAppDetail } from '@/service/apps' -import { - useInvalidateMCPServerDetail, - useMCPServerDetail, - useRefreshMCPServerCode, - useUpdateMCPServer, -} from '@/service/use-tools' -import { useAppWorkflow } from '@/service/use-workflow' -import { AppModeEnum } from '@/types/app' -import { getAppACLCapabilities } from '@/utils/permission' - -const BASIC_APP_CONFIG_KEY = 'basicAppConfig' - -type AppInfo = AppDetailResponse & Partial - -type BasicAppConfig = { - updated_at?: string - user_input_form?: Array> -} - -export const useMCPServiceCardState = (appInfo: AppInfo, triggerModeDisabled: boolean) => { - const appId = appInfo.id - const queryClient = useQueryClient() - - // API hooks - const { mutateAsync: updateMCPServer } = useUpdateMCPServer() - const { mutateAsync: refreshMCPServerCode, isPending: genLoading } = useRefreshMCPServerCode() - const invalidateMCPServerDetail = useInvalidateMCPServerDetail() - const { data: currentUserId } = useSuspenseQuery({ - ...userProfileQueryOptions(), - select: (data) => data.profile.id, - }) - const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) - - const canManageMCP = useMemo( - () => - getAppACLCapabilities(appInfo.permission_keys, { - currentUserId, - resourceMaintainer: appInfo.maintainer, - workspacePermissionKeys, - }).canEdit, - [appInfo.maintainer, appInfo.permission_keys, currentUserId, workspacePermissionKeys], - ) - - // UI state - const [showConfirmDelete, setShowConfirmDelete] = useState(false) - const [showMCPServerModal, setShowMCPServerModal] = useState(false) - - // Derived app type values - const isAdvancedApp = - appInfo?.mode === AppModeEnum.ADVANCED_CHAT || appInfo?.mode === AppModeEnum.WORKFLOW - const isBasicApp = !isAdvancedApp - const isWorkflowApp = appInfo.mode === AppModeEnum.WORKFLOW - - // Workflow data for advanced apps - const { data: currentWorkflow } = useAppWorkflow(isAdvancedApp ? appId : '') - - // Basic app config fetch using React Query - const { data: basicAppConfig = {} } = useQuery({ - queryKey: [BASIC_APP_CONFIG_KEY, appId], - queryFn: async () => { - const res = await fetchAppDetail({ url: '/apps', id: appId }) - return (res?.model_config as BasicAppConfig) || {} - }, - enabled: isBasicApp && !!appId, - }) - - // MCP server detail - const { data: detail } = useMCPServerDetail(appId, !!appId) - const { id, status, server_code } = detail ?? {} - - // Server state - const serverPublished = !!id - const serverActivated = status === 'active' - const serverURL = serverPublished - ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${server_code}/mcp` - : '***********' - - // App state checks - const appUnpublished = isAdvancedApp ? !currentWorkflow?.graph : !basicAppConfig.updated_at - const hasStartNode = currentWorkflow?.graph?.nodes?.some( - (node) => node.data.type === BlockEnum.Start, - ) - const missingStartNode = isWorkflowApp && !hasStartNode - const hasInsufficientPermissions = !canManageMCP - const toggleDisabled = - hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled - const isMinimalState = appUnpublished || missingStartNode - - // Basic app input form - const basicAppInputForm = useMemo(() => { - if (!isBasicApp || !basicAppConfig?.user_input_form) return [] - return (basicAppConfig.user_input_form as Array>).map((item) => { - const type = Object.keys(item)[0] - return { - ...(item[type!] as object), - type: type || 'text-input', - } - }) - }, [basicAppConfig?.user_input_form, isBasicApp]) - - // Latest params for modal - const latestParams = useMemo(() => { - if (isAdvancedApp) { - if (!currentWorkflow?.graph) return [] - type StartNodeData = { type: string; variables?: Array<{ variable: string; label: string }> } - const startNode = currentWorkflow?.graph.nodes.find( - (node) => node.data.type === BlockEnum.Start, - ) as { data: StartNodeData } | undefined - return startNode?.data.variables || [] - } - return basicAppInputForm - }, [currentWorkflow, basicAppInputForm, isAdvancedApp]) - - // Handlers - const handleGenCode = useCallback(async () => { - if (!canManageMCP) return - - await refreshMCPServerCode(appId) - invalidateMCPServerDetail(appId) - }, [canManageMCP, refreshMCPServerCode, invalidateMCPServerDetail, appId]) - - const handleStatusChange = useCallback( - async (state: boolean) => { - if (!canManageMCP) return { activated: false } - - if (state && !serverPublished) { - setShowMCPServerModal(true) - return { activated: false } - } - - await updateMCPServer({ - appID: appId, - id: id || '', - description: detail?.description || '', - parameters: detail?.parameters || {}, - status: state ? 'active' : 'inactive', - }) - invalidateMCPServerDetail(appId) - return { activated: state } - }, - [canManageMCP, serverPublished, updateMCPServer, appId, id, detail, invalidateMCPServerDetail], - ) - - const handleServerModalHide = useCallback((wasActivated: boolean) => { - setShowMCPServerModal(false) - // If server wasn't activated before opening modal, keep it deactivated - return { shouldDeactivate: !wasActivated } - }, []) - - const openConfirmDelete = useCallback(() => { - if (!canManageMCP) return - - setShowConfirmDelete(true) - }, [canManageMCP]) - const closeConfirmDelete = useCallback(() => setShowConfirmDelete(false), []) - const openServerModal = useCallback(() => { - if (!canManageMCP) return - - setShowMCPServerModal(true) - }, [canManageMCP]) - - const invalidateBasicAppConfig = useCallback(() => { - queryClient.invalidateQueries({ queryKey: [BASIC_APP_CONFIG_KEY, appId] }) - }, [queryClient, appId]) - - return { - // Loading states - genLoading, - isLoading: isAdvancedApp ? !currentWorkflow : false, - - // Server state - serverPublished, - serverActivated, - serverURL, - detail, - - // Permission & validation flags - canManageMCP, - toggleDisabled, - isMinimalState, - appUnpublished, - missingStartNode, - - // UI state - showConfirmDelete, - showMCPServerModal, - - // Data - latestParams, - - // Handlers - handleGenCode, - handleStatusChange, - handleServerModalHide, - openConfirmDelete, - closeConfirmDelete, - openServerModal, - invalidateBasicAppConfig, - } -} diff --git a/web/app/components/tools/mcp/mcp-service-card.tsx b/web/app/components/tools/mcp/mcp-service-card.tsx deleted file mode 100644 index 52299de9aad..00000000000 --- a/web/app/components/tools/mcp/mcp-service-card.tsx +++ /dev/null @@ -1,468 +0,0 @@ -'use client' -import type { TFunction } from 'i18next' -import type { FC, ReactNode } from 'react' -import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration' -import type { AppDetailResponse } from '@/models/app' -import type { AppSSO } from '@/types/app' -import { - AlertDialog, - AlertDialogActions, - AlertDialogCancelButton, - AlertDialogConfirmButton, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from '@langgenius/dify-ui/alert-dialog' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { Switch } from '@langgenius/dify-ui/switch' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' -import { useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import CopyFeedback from '@/app/components/base/copy-feedback' -import Divider from '@/app/components/base/divider' -import { Mcp } from '@/app/components/base/icons/src/vender/other' -import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' -import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' -import { useDocLink } from '@/context/i18n' -import { useInvalidateMCPServerDetail } from '@/service/use-tools' -import { useMCPServiceCardState } from './hooks/use-mcp-service-card' - -// Sub-components -type StatusIndicatorProps = { - serverActivated: boolean -} - -const StatusIndicator: FC = ({ serverActivated }) => { - const { t } = useTranslation() - return ( -
- -
- {serverActivated - ? t(($) => $['overview.status.running'], { ns: 'appOverview' }) - : t(($) => $['overview.status.disable'], { ns: 'appOverview' })} -
-
- ) -} - -type ServerURLSectionProps = { - serverURL: string - serverPublished: boolean - canManageMCP: boolean - genLoading: boolean - onRegenerate: () => void -} - -const ServerURLSection: FC = ({ - serverURL, - serverPublished, - canManageMCP, - genLoading, - onRegenerate, -}) => { - const { t } = useTranslation() - return ( -
-
- {t(($) => $['mcp.server.url'], { ns: 'tools' })} -
-
-
-
{serverURL}
-
- {serverPublished && ( - <> - - - - $['overview.appInfo.regenerate'], { ns: 'appOverview' }) || '' - } - disabled={!canManageMCP} - onClick={onRegenerate} - > - - - } - /> - - {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} - - - - )} -
-
- ) -} - -type TriggerModeOverlayProps = { - triggerModeMessage: ReactNode -} - -const TriggerModeOverlay: FC = ({ triggerModeMessage }) => { - if (triggerModeMessage) { - return ( - - - } - /> - - {triggerModeMessage} - - - ) - } - return ( - - ) -} - -// Helper function for tooltip content -type TooltipContentParams = { - toggleDisabled: boolean - appUnpublished: boolean - missingStartNode: boolean - triggerModeMessage: ReactNode - t: TFunction - docLink: ReturnType -} - -function getTooltipContent({ - toggleDisabled, - appUnpublished, - missingStartNode, - triggerModeMessage, - t, - docLink, -}: TooltipContentParams): ReactNode { - if (!toggleDisabled) return '' - - if (appUnpublished) return t(($) => $['mcp.server.publishTip'], { ns: 'tools' }) - - if (missingStartNode) { - return ( - <> -
- {t(($) => $['overview.appInfo.enableTooltip.description'], { ns: 'appOverview' })} -
- - - ) - } - - return triggerModeMessage || '' -} - -// Main component -type IAppCardProps = { - appInfo: AppDetailResponse & Partial - triggerModeDisabled?: boolean - triggerModeMessage?: ReactNode -} - -const MCPServiceCard: FC = ({ - appInfo, - triggerModeDisabled = false, - triggerModeMessage = '', -}) => { - const { t } = useTranslation() - const docLink = useDocLink() - const appId = appInfo.id - const invalidateMCPServerDetail = useInvalidateMCPServerDetail() - const invalidateMCPServerDetailRef = useRef(invalidateMCPServerDetail) - - useEffect(() => { - invalidateMCPServerDetailRef.current = invalidateMCPServerDetail - }, [invalidateMCPServerDetail]) - - const { - genLoading, - isLoading, - serverPublished, - serverActivated, - serverURL, - detail, - canManageMCP, - toggleDisabled, - isMinimalState, - appUnpublished, - missingStartNode, - showConfirmDelete, - showMCPServerModal, - latestParams, - handleGenCode, - handleStatusChange, - handleServerModalHide, - openConfirmDelete, - closeConfirmDelete, - openServerModal, - } = useMCPServiceCardState(appInfo, triggerModeDisabled) - - // Pending status for optimistic updates (null means use server state) - const [pendingStatus, setPendingStatus] = useState(null) - const activated = pendingStatus ?? serverActivated - - const emitMcpServerUpdate = async (data: Record) => { - try { - const { webSocketClient } = - await import('@/app/components/workflow/collaboration/core/websocket-manager') - const socket = webSocketClient.getSocket(appId) - if (!socket) return - - const timestamp = Date.now() - socket.emit('collaboration_event', { - type: 'mcp_server_update', - data: { - ...data, - timestamp, - }, - timestamp, - }) - } catch (error) { - console.error('MCP collaboration event emit failed:', error) - } - } - - const onChangeStatus = async (state: boolean) => { - setPendingStatus(state) - const result = await handleStatusChange(state) - if (!result.activated && state) { - // Server modal was opened instead, clear pending status - setPendingStatus(null) - } - - if (result.activated !== state) return - - // Emit collaboration event to notify other clients of MCP server status change - void emitMcpServerUpdate({ - action: 'statusChanged', - status: state ? 'active' : 'inactive', - }) - } - - const onServerModalHide = () => { - handleServerModalHide(serverActivated) - // Clear pending status when modal closes to sync with server state - setPendingStatus(null) - } - - const onConfirmRegenerate = () => { - closeConfirmDelete() - - void (async () => { - await handleGenCode() - - // Emit collaboration event to notify other clients of MCP server code changes - await emitMcpServerUpdate({ - action: 'codeRegenerated', - }) - })() - } - - // Listen for collaborative MCP server updates from other clients - useEffect(() => { - if (!appId) return - - const unsubscribe = collaborationManager.onMcpServerUpdate((_update: CollaborationUpdate) => { - try { - invalidateMCPServerDetailRef.current(appId) - } catch (error) { - console.error('MCP server update failed:', error) - } - }) - - return unsubscribe - }, [appId]) - - if (isLoading) return null - - const tooltipContent = getTooltipContent({ - toggleDisabled, - appUnpublished, - missingStartNode, - triggerModeMessage, - t, - docLink, - }) - - return ( - <> -
-
- {triggerModeDisabled && } -
-
-
-
- -
-
-
- {t(($) => $['mcp.server.title'], { ns: 'tools' })} -
-
-
- - {toggleDisabled && tooltipContent ? ( - - $['overview.appInfo.enableTooltip.description'], { - ns: 'appOverview', - }) - } - render={ -
- -
- } - /> - - {tooltipContent} - -
- ) : ( - - )} -
- {!isMinimalState && ( - - )} -
- {!isMinimalState && ( -
- -
- )} -
-
- - {showMCPServerModal && ( - - )} - - !open && closeConfirmDelete()}> - -
- - {t(($) => $['overview.appInfo.regenerate'], { ns: 'appOverview' })} - - - {t(($) => $['mcp.server.reGen'], { ns: 'tools' })} - -
- - - {t(($) => $['operation.cancel'], { ns: 'common' })} - - - {t(($) => $['operation.confirm'], { ns: 'common' })} - - -
-
- - ) -} - -export default MCPServiceCard diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx deleted file mode 100644 index 3f71d9926d9..00000000000 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ /dev/null @@ -1,1500 +0,0 @@ -import type { WorkflowToolDrawerPayload } from '../index' -import type { WorkflowToolProviderResponse } from '@/app/components/tools/types' -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { VarType } from '@/app/components/workflow/types' -import WorkflowToolConfigureButton from '../configure-button' -import { WorkflowToolDrawer } from '../index' -import MethodSelector from '../method-selector' - -// Mock Next.js navigation -const mockPush = vi.fn() -vi.mock('@/next/navigation', () => ({ - useRouter: () => ({ - push: mockPush, - replace: vi.fn(), - prefetch: vi.fn(), - }), - usePathname: () => '/app/workflow-app-id', - useSearchParams: () => new URLSearchParams(), - useParams: () => ({}), -})) - -// Mock API services - only mock external services -const mockCreateWorkflowToolProvider = vi.fn() -const mockSaveWorkflowToolProvider = vi.fn() -vi.mock('@/service/tools', () => ({ - createWorkflowToolProvider: (...args: unknown[]) => mockCreateWorkflowToolProvider(...args), - saveWorkflowToolProvider: (...args: unknown[]) => mockSaveWorkflowToolProvider(...args), -})) - -// Mock service hooks -const mockInvalidateAllWorkflowTools = vi.fn() -const mockInvalidateWorkflowToolDetailByAppID = vi.fn() -const mockUseWorkflowToolDetailByAppID = vi.fn() -vi.mock('@/service/use-tools', () => ({ - useInvalidateAllWorkflowTools: () => mockInvalidateAllWorkflowTools, - useInvalidateWorkflowToolDetailByAppID: () => mockInvalidateWorkflowToolDetailByAppID, - useWorkflowToolDetailByAppID: (...args: unknown[]) => mockUseWorkflowToolDetailByAppID(...args), -})) - -// Mock Toast - need to verify notification calls -const mockToastNotify = vi.fn() -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - success: (message: string) => mockToastNotify({ type: 'success', message }), - error: (message: string) => mockToastNotify({ type: 'error', message }), - warning: (message: string) => mockToastNotify({ type: 'warning', message }), - info: (message: string) => mockToastNotify({ type: 'info', message }), - }, -})) - -// Mock useTags hook used by LabelSelector - returns empty tags for testing -vi.mock('@/app/components/plugins/hooks', () => ({ - useTags: () => ({ - tags: [ - { name: 'label1', label: 'Label 1' }, - { name: 'label2', label: 'Label 2' }, - ], - }), -})) - -// Mock AppIcon - simplified for testing -vi.mock('@/app/components/base/app-icon', () => ({ - default: ({ - onClick, - icon, - background, - }: { - onClick?: () => void - icon: string - background: string - }) => ( -
- {icon} -
- ), -})) - -// Mock LabelSelector - simplified for testing -vi.mock('@/app/components/tools/labels/selector', () => ({ - default: ({ value, onChange }: { value: string[]; onChange: (labels: string[]) => void }) => ( -
- {value.join(',')} - -
- ), -})) - -// Test data factories -const createMockEmoji = (overrides = {}) => ({ - content: '🔧', - background: '#ffffff', - ...overrides, -}) - -const createMockWorkflowToolDetail = ( - overrides: Partial = {}, -): WorkflowToolProviderResponse => ({ - workflow_app_id: 'workflow-app-123', - workflow_tool_id: 'workflow-tool-456', - label: 'Test Tool', - name: 'test_tool', - icon: createMockEmoji(), - description: 'A test workflow tool', - synced: true, - tool: { - author: 'test-author', - name: 'test_tool', - label: { en_US: 'Test Tool', zh_Hans: '测试工具' }, - description: { en_US: 'Test description', zh_Hans: '测试描述' }, - labels: ['label1', 'label2'], - parameters: [ - { - name: 'test_var', - label: { en_US: 'Test Variable', zh_Hans: '测试变量' }, - human_description: { en_US: 'A test variable', zh_Hans: '测试变量' }, - type: 'string', - form: 'llm', - llm_description: 'Test variable description', - required: true, - default: '', - }, - ], - output_schema: { - type: 'object', - properties: { - output_var: { - type: 'string', - description: 'Output description', - }, - }, - }, - }, - privacy_policy: 'https://example.com/privacy', - ...overrides, -}) - -const createDefaultConfigureButtonProps = (overrides = {}) => ({ - disabled: false, - published: false, - isLoading: false, - outdated: false, - onConfigure: vi.fn(), - ...overrides, -}) - -const createDefaultDrawerPayload = ( - overrides: Partial = {}, -): WorkflowToolDrawerPayload => ({ - icon: createMockEmoji(), - label: 'Test Tool', - name: 'test_tool', - description: 'Test description', - parameters: [ - { - name: 'param1', - description: 'Parameter 1', - form: 'llm', - required: true, - type: 'string', - }, - ], - outputParameters: [ - { - name: 'output1', - description: 'Output 1', - }, - ], - labels: ['label1'], - privacy_policy: '', - workflow_app_id: 'workflow-app-123', - ...overrides, -}) - -// ============================================================================ -// WorkflowToolConfigureButton Tests -// ============================================================================ -describe('WorkflowToolConfigureButton', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({ - data: enabled ? createMockWorkflowToolDetail() : undefined, - isLoading: false, - })) - }) - - // Rendering Tests (REQUIRED) - describe('Rendering', () => { - it('should render configure required badge when not published', () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: false }) - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('workflow.common.configureRequired'))!.toBeInTheDocument() - }) - - it('should not render configure required badge when published', async () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(screen.queryByText('workflow.common.configureRequired')).not.toBeInTheDocument() - }) - }) - - it('should render disabled state with cursor-not-allowed', () => { - // Arrange - const props = createDefaultConfigureButtonProps({ disabled: true }) - - // Act - render() - - // Assert - const container = document.querySelector('.cursor-not-allowed') - expect(container)!.toBeInTheDocument() - }) - - it('should render disabledReason when provided', () => { - // Arrange - const props = createDefaultConfigureButtonProps({ - disabledReason: 'Please save the workflow first', - }) - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('Please save the workflow first'))!.toBeInTheDocument() - }) - - it('should render loading state when published and fetching details', () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true, isLoading: true }) - - // Act - render() - - // Assert - const loadingElement = document.querySelector('.pt-2') - expect(loadingElement)!.toBeInTheDocument() - }) - - it('should render configure and manage buttons when published', async () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act - render() - - // Assert - await waitFor(() => { - expect(screen.getByText('workflow.common.configure'))!.toBeInTheDocument() - expect(screen.getByText('workflow.common.manageInTools'))!.toBeInTheDocument() - }) - }) - }) - - // Props Testing (REQUIRED) - describe('Props', () => { - it('should handle all required props', () => { - // Arrange - const props = createDefaultConfigureButtonProps() - - // Act & Assert - should not throw - expect(() => render()).not.toThrow() - }) - - it('should render without disabled reason', () => { - // Arrange - const props = createDefaultConfigureButtonProps({ disabledReason: undefined }) - - // Act & Assert - expect(() => render()).not.toThrow() - }) - - it('should handle configured callback props', () => { - // Arrange - const props = createDefaultConfigureButtonProps({ onConfigure: vi.fn() }) - - // Act & Assert - expect(() => render()).not.toThrow() - }) - }) - - // Drawer behavior tests - describe('Drawer Behavior', () => { - it('should request configuration from the unpublished entry point', async () => { - // Arrange - const user = userEvent.setup() - const onConfigure = vi.fn() - const props = createDefaultConfigureButtonProps({ onConfigure }) - - // Act - render() - - // Click to request opening the drawer - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - expect(onConfigure).toHaveBeenCalledTimes(1) - }) - - it('should not request configuration when disabled', async () => { - // Arrange - const user = userEvent.setup() - const onConfigure = vi.fn() - const props = createDefaultConfigureButtonProps({ disabled: true, onConfigure }) - - // Act - render() - - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - expect(onConfigure).not.toHaveBeenCalled() - }) - - it('should request configuration from the published configure button only', async () => { - // Arrange - const user = userEvent.setup() - const onConfigure = vi.fn() - const props = createDefaultConfigureButtonProps({ published: true, onConfigure }) - - // Act - render() - - await waitFor(() => { - expect(screen.getByText('workflow.common.configure'))!.toBeInTheDocument() - }) - - // Click the main area (should not request opening the drawer) - const mainArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(mainArea!) - - expect(onConfigure).not.toHaveBeenCalled() - - // Click configure button - await user.click(screen.getByText('workflow.common.configure')) - - expect(onConfigure).toHaveBeenCalledTimes(1) - }) - }) - - // User Interactions Tests - describe('User Interactions', () => { - it('should navigate to tools page when manage button clicked', async () => { - // Arrange - const user = userEvent.setup() - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act - render() - - await waitFor(() => { - expect(screen.getByText('workflow.common.manageInTools'))!.toBeInTheDocument() - }) - - await user.click(screen.getByText('workflow.common.manageInTools')) - - // Assert - expect(mockPush).toHaveBeenCalledWith('/integrations/tools/workflow') - }) - }) - - // Edge Cases (REQUIRED) - describe('Edge Cases', () => { - it('should keep the configure entry independent from workflow parameter shape', async () => { - // Arrange - const user = userEvent.setup() - const onConfigure = vi.fn() - const props = createDefaultConfigureButtonProps({ onConfigure }) - - // Act - render() - - const triggerArea = screen.getByText('workflow.common.workflowAsTool').closest('.flex') - await user.click(triggerArea!) - - expect(onConfigure).toHaveBeenCalledTimes(1) - }) - }) - - // Accessibility Tests - describe('Accessibility', () => { - it('should have accessible buttons when published', async () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true }) - - // Act - render() - - // Assert - await waitFor(() => { - const buttons = screen.getAllByRole('button') - expect(buttons.length).toBeGreaterThan(0) - }) - }) - - it('should disable configure button when workflow tool is disabled', async () => { - // Arrange - const props = createDefaultConfigureButtonProps({ published: true, disabled: true }) - - // Act - render() - - // Assert - await waitFor(() => { - const configureButton = screen.getByText('workflow.common.configure') - expect(configureButton)!.toBeDisabled() - }) - }) - }) -}) - -// ============================================================================ -// WorkflowToolDrawer Tests -// ============================================================================ -describe('WorkflowToolDrawer', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering Tests (REQUIRED) - describe('Rendering', () => { - it('should render drawer with correct title', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect( - screen.getByRole('heading', { name: 'workflow.common.workflowAsTool' }), - ).toBeInTheDocument() - }) - - it('should render name input field', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect( - screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder'), - )!.toBeInTheDocument() - }) - - it('should render name for tool call input', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect( - screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder'), - )!.toBeInTheDocument() - }) - - it('should render description textarea', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect( - screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder'), - )!.toBeInTheDocument() - }) - - it('should render tool input table', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('tools.createTool.toolInput.title'))!.toBeInTheDocument() - }) - - it('should render tool output table', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('tools.createTool.toolOutput.title'))!.toBeInTheDocument() - }) - - it('should render reserved output parameters', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('text'))!.toBeInTheDocument() - expect(screen.getByText('files'))!.toBeInTheDocument() - expect(screen.getByText('json'))!.toBeInTheDocument() - }) - - it('should render label selector', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByTestId('label-selector'))!.toBeInTheDocument() - }) - - it('should render privacy policy input', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect( - screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder'), - )!.toBeInTheDocument() - }) - - it('should render delete button when editing and onRemove provided', () => { - // Arrange - const props = { - isAdd: false, - payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), - onHide: vi.fn(), - onRemove: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('common.operation.delete'))!.toBeInTheDocument() - }) - - it('should not render delete button when adding', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - onRemove: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() - }) - }) - - // Props Testing (REQUIRED) - describe('Props', () => { - it('should initialize state from payload', () => { - // Arrange - const payload = createDefaultDrawerPayload({ - label: 'Custom Label', - name: 'custom_name', - description: 'Custom description', - }) - const props = { - isAdd: true, - payload, - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByDisplayValue('Custom Label'))!.toBeInTheDocument() - expect(screen.getByDisplayValue('custom_name'))!.toBeInTheDocument() - expect(screen.getByDisplayValue('Custom description'))!.toBeInTheDocument() - }) - - it('should pass labels to label selector', () => { - // Arrange - const payload = createDefaultDrawerPayload({ labels: ['tag1', 'tag2'] }) - const props = { - isAdd: true, - payload, - onHide: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByTestId('label-values'))!.toHaveTextContent('tag1,tag2') - }) - }) - - // State Management Tests - describe('State Management', () => { - it('should update label state on input change', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ label: '' }), - onHide: vi.fn(), - } - - // Act - render() - const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') - await user.type(labelInput, 'New Label') - - // Assert - // Assert - expect(labelInput)!.toHaveValue('New Label') - }) - - it('should update name state on input change', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ name: '' }), - onHide: vi.fn(), - } - - // Act - render() - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'new_name') - - // Assert - // Assert - expect(nameInput)!.toHaveValue('new_name') - }) - - it('should update description state on textarea change', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ description: '' }), - onHide: vi.fn(), - } - - // Act - render() - const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') - await user.type(descInput, 'New description') - - // Assert - // Assert - expect(descInput)!.toHaveValue('New description') - }) - - it('should show emoji picker on icon click', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - const iconButton = screen.getByTestId('app-icon') - await user.click(iconButton) - - // Assert - await waitFor(() => { - expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() - }) - }) - - it('should update emoji on selection', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - // Open emoji picker - const iconButton = screen.getByTestId('app-icon') - await user.click(iconButton) - - await waitFor(() => { - expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() - }) - await user.click(screen.getByRole('button', { name: '#E4FBCC' })) - await user.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) - await waitFor(() => { - expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument() - }) - - // Assert - const updatedIcon = screen.getByTestId('app-icon') - expect(updatedIcon)!.toHaveAttribute('data-icon', '🔧') - expect(updatedIcon)!.toHaveAttribute('data-background', '#E4FBCC') - }) - - it('should close emoji picker on close button', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - } - - // Act - render() - - const iconButton = screen.getByTestId('app-icon') - await user.click(iconButton) - - await waitFor(() => { - expect(screen.getByPlaceholderText('Search emojis...')).toBeInTheDocument() - }) - await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ })) - await waitFor(() => { - expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument() - }) - - expect(screen.queryByPlaceholderText('Search emojis...')).not.toBeInTheDocument() - }) - - it('should update labels when label selector changes', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ labels: ['initial'] }), - onHide: vi.fn(), - } - - // Act - render() - await user.click(screen.getByTestId('add-label')) - - // Assert - // Assert - expect(screen.getByTestId('label-values'))!.toHaveTextContent('initial,new-label') - }) - - it('should update privacy policy on input change', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ privacy_policy: '' }), - onHide: vi.fn(), - } - - // Act - render() - const privacyInput = screen.getByPlaceholderText('tools.createTool.privacyPolicyPlaceholder') - await user.type(privacyInput, 'https://example.com/privacy') - - // Assert - // Assert - expect(privacyInput)!.toHaveValue('https://example.com/privacy') - }) - }) - - // User Interactions Tests - describe('User Interactions', () => { - it('should call onHide when cancel button clicked', async () => { - // Arrange - const user = userEvent.setup() - const onHide = vi.fn() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide, - } - - // Act - render() - await user.click(screen.getByText('common.operation.cancel')) - - // Assert - expect(onHide).toHaveBeenCalledTimes(1) - }) - - it('should call onHide when drawer close button clicked', async () => { - // Arrange - const user = userEvent.setup() - const onHide = vi.fn() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide, - } - - // Act - render() - await user.click(screen.getByRole('button', { name: /Close|operation.close/ })) - - // Assert - expect(onHide).toHaveBeenCalledTimes(1) - }) - - it('should call onRemove when delete button clicked', async () => { - // Arrange - const user = userEvent.setup() - const onRemove = vi.fn() - const props = { - isAdd: false, - payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), - onHide: vi.fn(), - onRemove, - } - - // Act - render() - await user.click(screen.getByText('common.operation.delete')) - - // Assert - expect(onRemove).toHaveBeenCalledTimes(1) - }) - - it('should call onCreate when save clicked in add mode', async () => { - // Arrange - const user = userEvent.setup() - const onCreate = vi.fn() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload(), - onHide: vi.fn(), - onCreate, - } - - // Act - render() - await user.click(screen.getByText('common.operation.save')) - - // Assert - expect(onCreate).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'test_tool', - workflow_app_id: 'workflow-app-123', - }), - ) - }) - - it('should show confirm modal when save clicked in edit mode', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: false, - payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), - onHide: vi.fn(), - onSave: vi.fn(), - } - - // Act - render() - await user.click(screen.getByText('common.operation.save')) - - // Assert - // Assert - expect(screen.getByText('tools.createTool.confirmTitle'))!.toBeInTheDocument() - }) - - it('should call onSave after confirm in edit mode', async () => { - // Arrange - const user = userEvent.setup() - const onSave = vi.fn() - const props = { - isAdd: false, - payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), - onHide: vi.fn(), - onSave, - } - - // Act - render() - await user.click(screen.getByText('common.operation.save')) - await user.click(screen.getByText('common.operation.confirm')) - - // Assert - expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ - workflow_tool_id: 'tool-123', - }), - ) - }) - - it('should update parameter description on input', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ - parameters: [ - { - name: 'param1', - description: '', // Start with empty description - form: 'llm', - required: true, - type: 'string', - }, - ], - }), - onHide: vi.fn(), - } - - // Act - render() - const descInput = screen.getByPlaceholderText( - 'tools.createTool.toolInput.descriptionPlaceholder', - ) - await user.type(descInput, 'New parameter description') - - // Assert - // Assert - expect(descInput)!.toHaveValue('New parameter description') - }) - }) - - // Validation Tests - describe('Validation', () => { - it('should show error when label is empty', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ label: '' }), - onHide: vi.fn(), - onCreate: vi.fn(), - } - - // Act - render() - await user.click(screen.getByText('common.operation.save')) - - // Assert - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: expect.any(String), - }) - }) - - it('should show error when name is empty', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ label: 'Test', name: '' }), - onHide: vi.fn(), - onCreate: vi.fn(), - } - - // Act - render() - await user.click(screen.getByText('common.operation.save')) - - // Assert - expect(mockToastNotify).toHaveBeenCalledWith({ - type: 'error', - message: expect.any(String), - }) - }) - - it('should show validation error for invalid name format', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ name: '' }), - onHide: vi.fn(), - } - - // Act - render() - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'invalid name with spaces') - - // Assert - // Assert - expect(screen.getByText('tools.createTool.nameForToolCallTip'))!.toBeInTheDocument() - }) - - it('should accept valid name format', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ name: '' }), - onHide: vi.fn(), - } - - // Act - render() - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.type(nameInput, 'valid_name_123') - - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - // Assert - expect(screen.queryByText('tools.createTool.nameForToolCallTip')).not.toBeInTheDocument() - }) - }) - - // Edge Cases (REQUIRED) - describe('Edge Cases', () => { - it('should handle empty parameters array', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ parameters: [] }), - onHide: vi.fn(), - } - - // Act & Assert - expect(() => render()).not.toThrow() - }) - - it('should handle empty output parameters', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ outputParameters: [] }), - onHide: vi.fn(), - } - - // Act & Assert - expect(() => render()).not.toThrow() - }) - - it('should handle parameter with __image name specially', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ - parameters: [ - { - name: '__image', - description: 'Image parameter', - form: 'llm', - required: true, - type: 'file', - }, - ], - }), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - __image should show method as text, not selector - // Assert - __image should show method as text, not selector - expect(screen.getByText('tools.createTool.toolInput.methodParameter'))!.toBeInTheDocument() - }) - - it('should show warning for reserved output parameter name collision', () => { - // Arrange - const props = { - isAdd: true, - payload: createDefaultDrawerPayload({ - outputParameters: [ - { - name: 'text', // Collides with reserved - description: 'Custom text output', - type: VarType.string, - }, - ], - }), - onHide: vi.fn(), - } - - // Act - render() - - // Assert - should show both reserved and custom with warning icon - const textElements = screen.getAllByText('text') - expect(textElements.length).toBe(2) - }) - - it('should close confirm modal on close button', async () => { - // Arrange - const user = userEvent.setup() - const props = { - isAdd: false, - payload: createDefaultDrawerPayload({ workflow_tool_id: 'tool-123' }), - onHide: vi.fn(), - onSave: vi.fn(), - } - - // Act - render() - await user.click(screen.getByText('common.operation.save')) - - await waitFor(() => { - expect(screen.getByText('tools.createTool.confirmTitle'))!.toBeInTheDocument() - }) - - // Click cancel in confirm modal - const cancelButtons = screen.getAllByText('common.operation.cancel') - await user.click(cancelButtons[cancelButtons.length - 1]!) - - // Assert - await waitFor(() => { - expect(screen.queryByText('tools.createTool.confirmTitle')).not.toBeInTheDocument() - }) - }) - }) -}) - -// ============================================================================ -// MethodSelector Tests -// ============================================================================ -describe('MethodSelector', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - // Rendering Tests (REQUIRED) - describe('Rendering', () => { - it('should display parameter method text when value is llm', () => { - // Arrange - const props = { - value: 'llm', - onChange: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('tools.createTool.toolInput.methodParameter'))!.toBeInTheDocument() - }) - - it('should display setting method text when value is form', () => { - // Arrange - const props = { - value: 'form', - onChange: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('tools.createTool.toolInput.methodSetting'))!.toBeInTheDocument() - }) - - it('should display setting method text when value is undefined', () => { - // Arrange - const props = { - value: undefined, - onChange: vi.fn(), - } - - // Act - render() - - // Assert - // Assert - expect(screen.getByText('tools.createTool.toolInput.methodSetting'))!.toBeInTheDocument() - }) - }) - - // User Interactions Tests - describe('User Interactions', () => { - it('should open dropdown on trigger click', async () => { - // Arrange - const user = userEvent.setup() - const props = { - value: 'llm', - onChange: vi.fn(), - } - - // Act - render() - await user.click(screen.getByRole('button')) - - // Assert - // Assert - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip')).toBeInTheDocument() - }) - - it('should call onChange with llm when parameter option clicked', async () => { - // Arrange - const user = userEvent.setup() - const onChange = vi.fn() - const props = { - value: 'form', - onChange, - } - - // Act - render() - await user.click(screen.getByRole('button')) - - const paramOption = screen.getAllByText('tools.createTool.toolInput.methodParameter')[0] - await user.click(paramOption!) - - // Assert - expect(onChange).toHaveBeenCalledWith('llm') - }) - - it('should call onChange with form when setting option clicked', async () => { - // Arrange - const user = userEvent.setup() - const onChange = vi.fn() - const props = { - value: 'llm', - onChange, - } - - // Act - render() - await user.click(screen.getByRole('button')) - - const settingOption = screen.getByText('tools.createTool.toolInput.methodSetting') - await user.click(settingOption) - - // Assert - expect(onChange).toHaveBeenCalledWith('form') - }) - - it('should toggle dropdown state on multiple clicks', async () => { - // Arrange - const user = userEvent.setup() - const props = { - value: 'llm', - onChange: vi.fn(), - } - - // Act - render() - const trigger = screen.getByRole('button') - - // First click - open - await user.click(trigger) - expect(screen.getByText('tools.createTool.toolInput.methodParameterTip')).toBeInTheDocument() - - // Second click - close - await user.click(trigger) - expect( - screen.queryByText('tools.createTool.toolInput.methodParameterTip'), - ).not.toBeInTheDocument() - }) - }) - - // Edge Cases (REQUIRED) - describe('Edge Cases', () => { - it('should handle empty string value', () => { - // Arrange - const props = { - value: '', - onChange: vi.fn(), - } - - // Act & Assert - expect(() => render()).not.toThrow() - }) - }) -}) - -// ============================================================================ -// Integration Tests -// ============================================================================ -describe('Integration Tests', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUseWorkflowToolDetailByAppID.mockImplementation((_appId: string, enabled: boolean) => ({ - data: enabled ? createMockWorkflowToolDetail() : undefined, - isLoading: false, - })) - }) - - // Complete workflow: open drawer -> fill form -> save - describe('Complete Workflow', () => { - it('should complete full create workflow', async () => { - // Arrange - const user = userEvent.setup() - const onCreate = vi.fn() - - // Act - render( - , - ) - - // Fill form - const labelInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder') - await user.clear(labelInput) - await user.type(labelInput, 'My Custom Tool') - - const nameInput = screen.getByPlaceholderText('tools.createTool.nameForToolCallPlaceHolder') - await user.clear(nameInput) - await user.type(nameInput, 'my_custom_tool') - - const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') - await user.clear(descInput) - await user.type(descInput, 'A custom tool for testing') - - // Save - await user.click(screen.getByText('common.operation.save')) - - // Assert - await waitFor(() => { - expect(onCreate).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'my_custom_tool', - label: 'My Custom Tool', - description: 'A custom tool for testing', - }), - ) - }) - }) - - it('should complete full update workflow', async () => { - // Arrange - const user = userEvent.setup() - const onSave = vi.fn() - - // Act - render( - , - ) - - // Modify description - const descInput = screen.getByPlaceholderText('tools.createTool.descriptionPlaceholder') - await user.clear(descInput) - await user.type(descInput, 'Updated description') - - // Save - await user.click(screen.getByText('common.operation.save')) - - // Confirm - await waitFor(() => { - expect(screen.getByText('tools.createTool.confirmTitle'))!.toBeInTheDocument() - }) - await user.click(screen.getByText('common.operation.confirm')) - - // Assert - await waitFor(() => { - expect(onSave).toHaveBeenCalledWith( - expect.objectContaining({ - workflow_tool_id: 'workflow-tool-1', - description: 'Updated description', - }), - ) - }) - }) - }) -}) diff --git a/web/app/components/tools/workflow-tool/configure-button.tsx b/web/app/components/tools/workflow-tool/configure-button.tsx deleted file mode 100644 index fbf17bb6424..00000000000 --- a/web/app/components/tools/workflow-tool/configure-button.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { useTranslation } from 'react-i18next' -import Loading from '@/app/components/base/loading' -import { buildIntegrationPath } from '@/app/components/integrations/routes' -import { useRouter } from '@/next/navigation' -import Divider from '../../base/divider' - -type Props = Readonly<{ - disabled: boolean - published: boolean - isLoading: boolean - outdated: boolean - onConfigure: () => void - disabledReason?: string -}> - -const WorkflowToolConfigureButton = ({ - disabled, - published, - isLoading, - outdated, - onConfigure, - disabledReason, -}: Props) => { - const { t } = useTranslation() - const router = useRouter() - - return ( - <> - - {(!published || !isLoading) && ( -
-
{ - if (!disabled && !published) onConfigure() - }} - > - -
$['common.workflowAsTool'], { ns: 'workflow' }) || ''} - className={cn( - 'shrink grow basis-0 truncate system-sm-medium text-text-secondary', - !disabled && !published && 'group-hover:text-text-accent', - )} - > - {t(($) => $['common.workflowAsTool'], { ns: 'workflow' })} -
- {!published && ( - - {t(($) => $['common.configureRequired'], { ns: 'workflow' })} - - )} -
- {disabledReason && ( -
- {disabledReason} -
- )} - {published && ( -
-
- - -
- {outdated && ( -
- {t(($) => $['common.workflowAsToolTip'], { ns: 'workflow' })} -
- )} -
- )} -
- )} - {published && isLoading && ( -
- -
- )} - - ) -} -export default WorkflowToolConfigureButton diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index 889b6a63490..dbc9c7c2e4f 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { ReactElement, ReactNode } from 'react' import { screen, waitFor } from '@testing-library/react' +import { useEffect } from 'react' import { createAccountProfileQueryWrapper } from '@/test/console/account-profile' import { render as renderWithConsoleState } from '@/test/console/render' import { AppACLPermission } from '@/utils/permission' @@ -9,8 +10,13 @@ const mockSetTriggerStatuses = vi.fn() const mockSetInputs = vi.fn() const mockSetShowInputsPanel = vi.fn() const mockSetShowDebugAndPreviewPanel = vi.fn() -const mockWorkflowStoreSetState = vi.fn() +let mockIsWorkflowDataLoaded = true +const mockWorkflowStoreSetState = vi.fn((state: Record) => { + if (typeof state.isWorkflowDataLoaded === 'boolean') + mockIsWorkflowDataLoaded = state.isWorkflowDataLoaded +}) const mockDebouncedCancel = vi.fn() +const mockFinalDraftSync = vi.fn() const mockFetchRunDetail = vi.fn() const mockInitialNodes = vi.fn() const mockInitialEdges = vi.fn() @@ -68,6 +74,7 @@ const render = (ui: ReactElement) => const mockWorkflowStore = { setState: mockWorkflowStoreSetState, getState: () => ({ + isWorkflowDataLoaded: mockIsWorkflowDataLoaded, setInputs: mockSetInputs, setShowInputsPanel: mockSetShowInputsPanel, setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel, @@ -186,7 +193,7 @@ vi.mock('@/app/components/workflow/context', () => ({ })) vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({ - default: ({ + default: function WorkflowMainMock({ nodes, edges, viewport, @@ -194,19 +201,32 @@ vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({ nodes: Array> edges: Array> viewport: Record - }) => ( -
- ), + }) { + useEffect(() => { + return () => { + const { debouncedSyncWorkflowDraft, isWorkflowDataLoaded } = mockWorkflowStore.getState() + if (!isWorkflowDataLoaded) return + + debouncedSyncWorkflowDraft.cancel() + mockFinalDraftSync() + } + }, []) + + return ( +
+ ) + }, })) describe('WorkflowApp', () => { beforeEach(() => { vi.clearAllMocks() + mockIsWorkflowDataLoaded = true appStoreState = { appDetail: { id: 'app-1', @@ -295,7 +315,7 @@ describe('WorkflowApp', () => { expect(mockSetTriggerStatuses).not.toHaveBeenCalled() }) - it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => { + it('should replay workflow inputs from replayRunId', async () => { searchParamsValue = 'run-1' mockFetchRunDetail.mockResolvedValue({ inputs: @@ -318,9 +338,15 @@ describe('WorkflowApp', () => { }) unmount() + }) - expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: false }) - expect(mockDebouncedCancel).toHaveBeenCalled() + it('should keep loaded workflow state available for the canvas unmount sync', () => { + const { unmount } = render() + + unmount() + + expect(mockFinalDraftSync).toHaveBeenCalledTimes(1) + expect(mockDebouncedCancel).toHaveBeenCalledTimes(1) }) it('should skip replay lookups when replayRunId is missing', () => { diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx index 33070c03f3e..1a99810e9b7 100644 --- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx +++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react' -import type { AppPublisherProps } from '@/app/components/app/app-publisher' +import type { AppPublisherProps } from '@/app/components/app/app-publisher/types' import type { App } from '@/types/app' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx index c45248cdbfa..4e10ef912e6 100644 --- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx +++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx @@ -1,4 +1,4 @@ -import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher' +import type { AppPublisherPublishParams } from '@/app/components/app/app-publisher/types' import type { EndNodeType } from '@/app/components/workflow/nodes/end/types' import type { StartNodeType } from '@/app/components/workflow/nodes/start/types' import type { CommonEdgeType, Node } from '@/app/components/workflow/types' diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index 0a3f5c3f38c..758691551d9 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -236,6 +236,57 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => expect(callbacks.onSettled).toHaveBeenCalled() }) + it('should capture the graph before a queued sync runs after the canvas is torn down', async () => { + const draftNode = { + id: 'n1', + position: { x: 0, y: 0 }, + data: { type: BlockEnum.Start, label: 'Start' }, + } + const draftEdge = { + id: 'edge-1', + source: 'n1', + target: 'n2', + data: { stable: 'keep' }, + } + mockGetNodes.mockReturnValue([draftNode]) + reactFlowState = { + ...reactFlowState, + edges: [draftEdge], + transform: [10, 20, 1.5], + } + + const { result } = renderUseNodesSyncDraft() + let syncPromise!: ReturnType + + act(() => { + syncPromise = result.current.doSyncWorkflowDraft(false) + + // Simulate ReactFlow clearing its store immediately after the page starts unmounting. + mockGetNodes.mockReturnValue([]) + reactFlowState = { + ...reactFlowState, + edges: [], + transform: [0, 0, 1], + } + }) + + await act(async () => { + await syncPromise + }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + graph: { + nodes: [draftNode], + edges: [draftEdge], + viewport: { x: 10, y: 20, zoom: 1.5 }, + }, + }), + }), + ) + }) + it('should not include source_workflow_id in draft sync payloads', async () => { const { result } = renderUseNodesSyncDraft() diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts index f73df34a5f9..6b130d10ff4 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts @@ -77,13 +77,19 @@ vi.mock('@/service/use-workflow', () => ({ }, })) +vi.mock('@/service/workflow-queries', () => ({ + appWorkflowQueryOptions: (appId: string) => ({ + queryKey: ['workflow', 'publish', appId], + queryFn: () => mockFetchPublishedWorkflow(`/apps/${appId}/workflows/publish`), + }), +})) + const mockFetchWorkflowDraft = vi.fn() vi.mock('@/service/workflow', () => ({ fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args), syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args), fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args), - fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args), })) const notExistError = () => ({ @@ -351,14 +357,45 @@ describe('useWorkflowInit', () => { expect(result.current.isLoading).toBe(false) }) - it('should fall back to no published user input when preload requests fail', async () => { + it('should keep published metadata when loading node defaults fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse) mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed')) + mockFetchPublishedWorkflow.mockResolvedValue({ + created_at: 99, + graph: { + nodes: [{ id: 'start', data: { type: BlockEnum.Start } }], + edges: [{ source: 'start', target: 'end' }], + }, + }) renderHook(() => useWorkflowInit()) await waitFor(() => { + expect(mockSetPublishedAt).toHaveBeenCalledWith(99) + expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true) + }) + + expect(consoleErrorSpy).toHaveBeenCalled() + consoleErrorSpy.mockRestore() + }) + + it('should keep node defaults when loading published metadata fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse) + mockFetchNodesDefaultConfigs.mockResolvedValue([ + { type: 'start', config: { title: 'Start Config' } }, + ]) + mockFetchPublishedWorkflow.mockRejectedValue(new Error('published workflow failed')) + + renderHook(() => useWorkflowInit()) + + await waitFor(() => { + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ + nodesDefaultConfigs: { + start: { title: 'Start Config' }, + }, + }) expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false) }) diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 3e3313866a2..b36a969481a 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -136,6 +136,7 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { const performLocalSync = useCallback( async ( + baseParams: NonNullable>, notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback, options?: SyncDraftOptions, @@ -147,12 +148,6 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { return null } - const baseParams = getPostParams() - if (!baseParams) { - callback?.onSettled?.() - return null - } - const { setSyncWorkflowDraftHash, setDraftUpdatedAt } = workflowStore.getState() try { @@ -200,13 +195,7 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { callback?.onSettled?.() } }, - [ - workflowStore, - getPostParams, - getNodesReadOnly, - handleRefreshWorkflowDraft, - isCollaborationEnabled, - ], + [workflowStore, getNodesReadOnly, handleRefreshWorkflowDraft, isCollaborationEnabled], ) const doSyncWorkflowDraftLocally = useSerialAsyncCallback(performLocalSync, getNodesReadOnly) @@ -224,8 +213,16 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { !collaborationManager.getIsLeader() && !options?.forceLocal - if (!shouldRequestLeader) - return doSyncWorkflowDraftLocally(notRefreshWhenSyncError, callback, options) + if (!shouldRequestLeader) { + // Capture before ReactFlow resets its store during route unmount. + const baseParams = getPostParams() + if (!baseParams) { + callback?.onSettled?.() + return null + } + + return doSyncWorkflowDraftLocally(baseParams, notRefreshWhenSyncError, callback, options) + } try { const result = await collaborationManager.requestWorkflowSync() @@ -241,7 +238,13 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => { callback?.onSettled?.() } }, - [doSyncWorkflowDraftLocally, getNodesReadOnly, isCollaborationEnabled, workflowStore], + [ + doSyncWorkflowDraftLocally, + getNodesReadOnly, + getPostParams, + isCollaborationEnabled, + workflowStore, + ], ) return { diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 624211adc4d..7ae508e5432 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -1,7 +1,7 @@ import type { Edge, Node } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' import type { FetchWorkflowDraftResponse } from '@/types/workflow' -import { useSuspenseQuery } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useState } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' @@ -10,12 +10,8 @@ import { BlockEnum } from '@/app/components/workflow/types' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useWorkflowConfig } from '@/service/use-workflow' -import { - fetchNodesDefaultConfigs, - fetchPublishedWorkflow, - fetchWorkflowDraft, - syncWorkflowDraft, -} from '@/service/workflow' +import { fetchNodesDefaultConfigs, fetchWorkflowDraft, syncWorkflowDraft } from '@/service/workflow' +import { appWorkflowQueryOptions } from '@/service/workflow-queries' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' import { useWorkflowDraftGraphForCanvas } from './use-workflow-draft-graph-for-canvas' @@ -59,6 +55,7 @@ const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean } export const useWorkflowInit = () => { + const queryClient = useQueryClient() const workflowStore = useWorkflowStore() const { nodes: nodesTemplate, edges: edgesTemplate } = useWorkflowTemplate() const appDetail = useAppStore((state) => state.appDetail)! @@ -196,13 +193,13 @@ export const useWorkflowInit = () => { }, []) const handleFetchPreloadData = useCallback(async () => { - try { - const nodesDefaultConfigsData = await fetchNodesDefaultConfigs( - `/apps/${appDetail?.id}/workflows/default-workflow-block-configs`, - ) - const publishedWorkflow = await fetchPublishedWorkflow( - `/apps/${appDetail?.id}/workflows/publish`, - ) + const [nodesDefaultConfigsResult, publishedWorkflowResult] = await Promise.allSettled([ + fetchNodesDefaultConfigs(`/apps/${appDetail.id}/workflows/default-workflow-block-configs`), + queryClient.fetchQuery(appWorkflowQueryOptions(appDetail.id)), + ]) + + if (nodesDefaultConfigsResult.status === 'fulfilled') { + const nodesDefaultConfigsData = nodesDefaultConfigsResult.value workflowStore.setState({ nodesDefaultConfigs: nodesDefaultConfigsData.reduce( (acc, block) => { @@ -212,16 +209,22 @@ export const useWorkflowInit = () => { {} as Record, ), }) + } else { + console.error(nodesDefaultConfigsResult.reason) + } + + if (publishedWorkflowResult.status === 'fulfilled') { + const publishedWorkflow = publishedWorkflowResult.value workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0) const graph = publishedWorkflow?.graph - workflowStore - .getState() - .setLastPublishedHasUserInput(hasConnectedUserInput(graph?.nodes, graph?.edges)) - } catch (e) { - console.error(e) + const nodes = Array.isArray(graph?.nodes) ? (graph.nodes as Node[]) : undefined + const edges = Array.isArray(graph?.edges) ? (graph.edges as Edge[]) : undefined + workflowStore.getState().setLastPublishedHasUserInput(hasConnectedUserInput(nodes, edges)) + } else { + console.error(publishedWorkflowResult.reason) workflowStore.getState().setLastPublishedHasUserInput(false) } - }, [workflowStore, appDetail]) + }, [workflowStore, appDetail, queryClient]) useEffect(() => { handleFetchPreloadData() diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 29c2b93de9d..47e0b4cd696 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -64,22 +64,6 @@ const WorkflowAppWithAdditionalContext = () => { } }, [triggersResponse?.data, setTriggerStatuses]) - // Cleanup on unmount - useEffect(() => { - return () => { - // Reset the loaded flag when component unmounts - workflowStore.setState({ isWorkflowDataLoaded: false }) - - // Cancel any pending debounced sync operations - const { debouncedSyncWorkflowDraft } = workflowStore.getState() - // The debounced function from lodash has a cancel method - const cancellableSyncWorkflowDraft = debouncedSyncWorkflowDraft as - | { cancel?: () => void } - | undefined - cancellableSyncWorkflowDraft?.cancel?.() - } - }, [workflowStore]) - const nodesData = useMemo(() => { if (data) { const processedNodes = initialNodes(data.graph.nodes, data.graph.edges) diff --git a/web/app/components/workflow/__tests__/index.spec.tsx b/web/app/components/workflow/__tests__/index.spec.tsx index 3f5dc907e8c..41d3eb9d318 100644 --- a/web/app/components/workflow/__tests__/index.spec.tsx +++ b/web/app/components/workflow/__tests__/index.spec.tsx @@ -1,6 +1,7 @@ import type { Edge, Node } from '../types' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useState } from 'react' import { useStoreApi } from 'reactflow' import { WorkflowContextProvider } from '../context' import { useDatasetsDetailStore } from '../datasets-detail-store/store' @@ -68,6 +69,43 @@ const ContextConsumer = () => { ) } +const HistoryAtMountConsumer = () => { + const { store } = useWorkflowHistoryStore() + const [nodeIds] = useState(() => + store + .getState() + .nodes.map((node) => node.id) + .join(','), + ) + + return
{`history-at-mount:${nodeIds}`}
+} + +const WorkflowRemountHarness = () => { + const [useReplacementHistory, setUseReplacementHistory] = useState(false) + const replacementNodes = useReplacementHistory + ? [ + { + ...nodes[0]!, + id: 'node-replacement', + }, + ] + : nodes + + return ( + <> + + + + + + ) +} + describe('WorkflowWithDefaultContext', () => { it('wires the ReactFlow, workflow history, and datasets detail providers around its children', () => { render( @@ -83,6 +121,7 @@ describe('WorkflowWithDefaultContext', () => { it('keeps its children mounted when workflow history advances after initialization', async () => { const user = userEvent.setup() + render( @@ -95,4 +134,20 @@ describe('WorkflowWithDefaultContext', () => { expect(screen.getByText('history:2 datasets:0 reactflow:true')).toBeInTheDocument() }) + + it('initializes new history before mounting children again in the same workflow store', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + expect(screen.getByText('history-at-mount:node-start')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Remount workflow' })) + + expect(screen.getByText('history-at-mount:node-replacement')).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 05403cf4889..43b122c1cf4 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -677,6 +677,25 @@ describe('Workflow edge event wiring', () => { }) }) + it('should cancel the pending debounced save before the final unmount sync', () => { + vi.useFakeTimers() + try { + const pendingSync = vi.fn() + const { store, unmount } = renderSubject({ + initialStoreState: { isWorkflowDataLoaded: true }, + }) + store.getState().debouncedSyncWorkflowDraft(pendingSync) + + unmount() + vi.advanceTimersByTime(5000) + + expect(workflowHookMocks.handleSyncWorkflowDraft).toHaveBeenCalledTimes(1) + expect(pendingSync).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + it('should skip the unmount save before workflow data has loaded', () => { const { unmount } = renderSubject() diff --git a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx index e02950f0e9d..630f665df81 100644 --- a/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-layouts.spec.tsx @@ -185,6 +185,7 @@ const createCurrentVersion = (): NonNullable => ({ tool_published: false, environment_variables: [], version: WorkflowVersion.Latest, + version_number: 5, marked_name: '', marked_comment: '', }) @@ -327,7 +328,7 @@ describe('Header layout components', () => { }) expect(mockEmitRestoreIntent).toHaveBeenCalledWith({ versionId: currentVersion.id, - versionName: currentVersion.marked_name, + versionName: '# 5', initiatorUserId: currentUser.id, initiatorName: currentUser.name, }) diff --git a/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx index f5d138af420..d4faa6e8fc6 100644 --- a/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx +++ b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx @@ -88,6 +88,22 @@ describe('RestoringTitle', () => { expect(container).toHaveTextContent('Alice') }) + it('should render the version number when the marked name is empty', () => { + const currentVersion = createVersion({ + marked_name: '', + version_number: 5, + }) + + const { container } = renderWorkflowComponent(, { + initialStoreState: { + currentVersion, + }, + }) + + expect(container).toHaveTextContent('# 5') + expect(container).not.toHaveTextContent('workflow.versionHistory.defaultName') + }) + it('should render an empty creator name when the version creator name is missing', () => { const currentVersion = createVersion({ created_by: { diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index 61962ff9036..1c8584f8824 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -7,6 +7,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import useTheme from '@/hooks/use-theme' @@ -75,14 +76,17 @@ const HeaderInRestoring = ({ onRestoreSettled }: HeaderInRestoringProps) => { const { collaborationManager } = await import('../collaboration/core/collaboration-manager') collaborationManager.emitRestoreIntent({ versionId: currentVersion.id, - versionName: currentVersion.marked_name, + versionName: getWorkflowVersionName( + currentVersion, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + ), initiatorUserId: userProfile.id, initiatorName: userProfile.name, }) } catch (error) { console.error('Failed to emit restore intent:', error) } - }, [canEmitCollaborationEvents, currentVersion, userProfile.id, userProfile.name]) + }, [canEmitCollaborationEvents, currentVersion, t, userProfile.id, userProfile.name]) const emitRestoreComplete = useCallback( async (success: boolean, errorMessage?: string) => { diff --git a/web/app/components/workflow/header/restoring-title.tsx b/web/app/components/workflow/header/restoring-title.tsx index c23edf989a3..3ead6d63016 100644 --- a/web/app/components/workflow/header/restoring-title.tsx +++ b/web/app/components/workflow/header/restoring-title.tsx @@ -1,5 +1,6 @@ -import { memo, useMemo } from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import useTimestamp from '@/hooks/use-timestamp' import { useStore } from '../store' @@ -15,12 +16,12 @@ const RestoringTitle = () => { ? t(($) => $['common.unpublished'], { ns: 'workflow' }) : t(($) => $['common.published'], { ns: 'workflow' }) - const versionName = useMemo(() => { - if (isDraft) return t(($) => $['versionHistory.currentDraft'], { ns: 'workflow' }) - return ( - currentVersion?.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }) - ) - }, [currentVersion, t, isDraft]) + const versionName = isDraft + ? t(($) => $['versionHistory.currentDraft'], { ns: 'workflow' }) + : getWorkflowVersionName( + currentVersion, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + ) return (
diff --git a/web/app/components/workflow/hooks/use-tool-icon.ts b/web/app/components/workflow/hooks/use-tool-icon.ts index 39e0e317719..fd01ecd0377 100644 --- a/web/app/components/workflow/hooks/use-tool-icon.ts +++ b/web/app/components/workflow/hooks/use-tool-icon.ts @@ -26,7 +26,7 @@ const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource type IconValue = ToolWithProvider['icon'] -type ToolCollections = { +export type ToolIconCollections = { buildInTools?: ToolWithProvider[] customTools?: ToolWithProvider[] workflowTools?: ToolWithProvider[] @@ -60,7 +60,7 @@ const findTriggerPluginIcon = ( const getPrimaryToolCollection = ( providerType: CollectionType | undefined, - collections: ToolCollections, + collections: ToolIconCollections, ) => { switch (providerType) { case CollectionType.custom: @@ -77,7 +77,7 @@ const getPrimaryToolCollection = ( const getCollectionsToSearch = ( providerType: CollectionType | undefined, - collections: ToolCollections, + collections: ToolIconCollections, ) => { return [ getPrimaryToolCollection(providerType, collections), @@ -116,7 +116,7 @@ const findToolNodeIcon = ({ theme, }: { data: ToolNodeType - collections: ToolCollections + collections: ToolIconCollections theme?: string }) => { const matched = findToolInCollections( @@ -136,7 +136,7 @@ const findDataSourceIcon = (data: DataSourceNodeType, dataSourceList?: ToolWithP ?.icon } -const findNodeIcon = ({ +export const findNodeIcon = ({ data, collections, dataSourceList, @@ -144,7 +144,7 @@ const findNodeIcon = ({ theme, }: { data?: Node['data'] - collections: ToolCollections + collections: ToolIconCollections dataSourceList?: ToolWithProvider[] triggerPlugins?: TriggerWithProvider[] theme?: string diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 03fe0024d86..3b21aa05795 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -374,7 +374,10 @@ export const Workflow: FC = memo( }, []) const syncWorkflowDraftOnUnmount = useEffectEvent(() => { - if (!workflowStore.getState().isWorkflowDataLoaded) return + const { debouncedSyncWorkflowDraft, isWorkflowDataLoaded } = workflowStore.getState() + if (!isWorkflowDataLoaded) return + + debouncedSyncWorkflowDraft.cancel?.() if (isCollaborationEnabled && collaborationManager.canUseLocalDraftFallback()) { syncWorkflowDraftWhenPageClose() @@ -850,6 +853,7 @@ const WorkflowHistoryStoreInitializer = ({ children, }: WorkflowWithDefaultContextProps) => { const workflowStore = useWorkflowStore() + const initializedWorkflowHistory = useStore((state) => state.initializedWorkflowHistory) const [initialWorkflowHistory] = useState(() => ({ nodes, edges, @@ -860,11 +864,13 @@ const WorkflowHistoryStoreInitializer = ({ useLayoutEffect(() => { const temporalStore = workflowStore.temporal.getState() temporalStore.pause() - workflowStore.getState().setWorkflowHistory(initialWorkflowHistory) + workflowStore.getState().initializeWorkflowHistory(initialWorkflowHistory) temporalStore.clear() temporalStore.resume() }, [initialWorkflowHistory, workflowStore]) + if (initializedWorkflowHistory !== initialWorkflowHistory) return null + return children } diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/delete-confirm-modal.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/delete-confirm-modal.spec.tsx index 665a662fee6..705c5a1cfac 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/delete-confirm-modal.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/delete-confirm-modal.spec.tsx @@ -55,4 +55,19 @@ describe('DeleteConfirmModal', () => { expect(onClose).toHaveBeenCalled() expect(onDelete).toHaveBeenCalledWith('version-1', expect.anything()) }) + + it('renders the default name for a legacy version without a version number', () => { + render( + , + ) + + expect( + screen.getByText('common.operation.delete workflow.versionHistory.defaultName'), + ).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index 5d737535938..2e9e2cf1876 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -11,14 +11,21 @@ const mockHandleLoadBackupDraft = vi.fn() const mockHandleRefreshWorkflowDraft = vi.fn() const mockHandleExportDSL = vi.fn() const mockRestoreWorkflow = vi.fn() +const mockUpdateWorkflow = vi.fn() +const mockInvalidateAppWorkflow = vi.fn() const mockSetCurrentVersion = vi.fn() const mockSetShowWorkflowVersionHistoryPanel = vi.fn() const mockWorkflowStoreSetState = vi.fn() const mockEmitRestoreIntent = vi.fn() const mockEmitRestoreComplete = vi.fn() const mockEmitWorkflowUpdate = vi.fn() +const mockToast = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), +})) let mockPlanType = Plan.professional let mockEnableBilling = true +let mockPublishedEnvironments: VersionHistory['environments'] const createVersionHistory = (overrides: Partial = {}): VersionHistory => ({ id: 'version-id', @@ -57,6 +64,9 @@ type MockRestoreConfirmModalProps = { versionInfo: VersionHistory onRestore: (item: VersionHistory) => void } +type MockDeleteConfirmModalProps = { + isOpen: boolean +} type MockVersionHistoryItemProps = { item: VersionHistory onClick: (item: VersionHistory) => void @@ -70,12 +80,15 @@ vi.mock('@/context/provider-context', () => ({ }), })) +vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast })) + vi.mock('@/service/use-workflow', () => ({ useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }), + useInvalidateAppWorkflow: () => mockInvalidateAppWorkflow, useInvalidAllLastRun: () => vi.fn(), useResetWorkflowVersionHistory: () => vi.fn(), useRestoreWorkflow: () => ({ mutateAsync: mockRestoreWorkflow }), - useUpdateWorkflow: () => ({ mutateAsync: vi.fn() }), + useUpdateWorkflow: () => ({ mutateAsync: mockUpdateWorkflow }), useWorkflowVersionHistory: () => ({ data: { pages: [ @@ -90,6 +103,7 @@ vi.mock('@/service/use-workflow', () => ({ version: '2024-01-01T00:00:00Z', marked_name: 'v1.0', marked_comment: 'First release', + environments: mockPublishedEnvironments, }), ], }, @@ -117,10 +131,19 @@ vi.mock('../../../hooks/use-workflow-run', () => ({ })) vi.mock('../../../hooks-store', () => ({ - useHooksStore: () => ({ - flowId: 'test-flow-id', - flowType: 'workflow', - }), + useHooksStore: ( + selector: (state: { + accessControl: { canImportExportDSL: boolean } + configsMap: { flowId: string; flowType: string } + }) => unknown, + ) => + selector({ + accessControl: { canImportExportDSL: true }, + configsMap: { + flowId: 'app-1', + flowType: 'appFlow', + }, + }), })) vi.mock('../../../collaboration/core/collaboration-manager', () => ({ @@ -151,7 +174,7 @@ vi.mock('../../../store', () => ({ })) vi.mock('../delete-confirm-modal', () => ({ - default: () => null, + default: ({ isOpen }: MockDeleteConfirmModalProps) => (isOpen ?
confirm delete
: null), })) vi.mock('../restore-confirm-modal', () => ({ @@ -169,7 +192,25 @@ vi.mock('../restore-confirm-modal', () => ({ })) vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({ - default: () => null, + default: ({ + versionInfo, + onPublish, + }: { + versionInfo: VersionHistory + onPublish: (params: { id?: string; title: string; releaseNotes: string }) => Promise + }) => ( + + ), })) vi.mock('../version-history-item', () => ({ @@ -202,6 +243,16 @@ vi.mock('../version-history-item', () => ({ > {`export-${item.id}`} + + )}
@@ -216,9 +267,11 @@ describe('VersionHistoryPanel', () => { beforeEach(() => { vi.clearAllMocks() mockRestoreWorkflow.mockResolvedValue(undefined) + mockUpdateWorkflow.mockResolvedValue(undefined) mockCurrentVersion = null mockPlanType = Plan.professional mockEnableBilling = true + mockPublishedEnvironments = undefined }) describe('Version Click Behavior', () => { @@ -327,6 +380,41 @@ describe('VersionHistoryPanel', () => { expect(mockHandleExportDSL).not.toHaveBeenCalled() }) + it('should block deleting a version deployed to an environment', async () => { + mockPublishedEnvironments = [{ id: 'environment-id', name: 'Production' }] + const { VersionHistoryPanel } = await import('../index') + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + fireEvent.click(screen.getByText('delete-published-version-id')) + + expect(mockToast.error).toHaveBeenCalledWith( + 'workflow.versionHistory.action.deleteDeployedVersionError', + ) + expect(screen.queryByText('confirm delete')).not.toBeInTheDocument() + }) + + it('should allow deleting a version that is not deployed', async () => { + const { VersionHistoryPanel } = await import('../index') + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + fireEvent.click(screen.getByText('delete-published-version-id')) + + expect(screen.getByText('confirm delete')).toBeInTheDocument() + expect(mockToast.error).not.toHaveBeenCalled() + }) + it('should keep restore mode backup state when restore request fails', async () => { const { VersionHistoryPanel } = await import('../index') mockCurrentVersion = createVersionHistory({ @@ -358,4 +446,35 @@ describe('VersionHistoryPanel', () => { expect(mockSetCurrentVersion).not.toHaveBeenCalled() expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() }) + + it('should refresh the published workflow after editing the latest app version', async () => { + mockUpdateWorkflow.mockImplementation( + async ( + _params, + options?: { + onSuccess?: () => void + onSettled?: () => void + }, + ) => { + options?.onSuccess?.() + options?.onSettled?.() + }, + ) + const { VersionHistoryPanel } = await import('../index') + + render( + `/apps/app-1/workflows/${versionId}/restore`} + updateVersionUrl={(versionId) => `/apps/app-1/workflows/${versionId}`} + />, + ) + + fireEvent.click(screen.getByText('edit-published-version-id')) + fireEvent.click(screen.getByRole('button', { name: 'submit version info' })) + + await waitFor(() => { + expect(mockInvalidateAppWorkflow).toHaveBeenCalledWith('app-1') + }) + }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/restore-confirm-modal.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/restore-confirm-modal.spec.tsx index 84448810eed..a08b823b010 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/restore-confirm-modal.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/restore-confirm-modal.spec.tsx @@ -56,4 +56,17 @@ describe('RestoreConfirmModal', () => { expect(onClose).toHaveBeenCalled() expect(onRestore).toHaveBeenCalledWith(versionInfo, expect.anything()) }) + + it('renders the version number when the marked name is empty', () => { + render( + , + ) + + expect(screen.getByText('workflow.common.restore # 5')).toBeInTheDocument() + }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx index d9468b9345c..15df63cef24 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/version-history-item.spec.tsx @@ -83,6 +83,48 @@ describe('VersionHistoryItem', () => { // Published items should expose metadata and the hover context menu. describe('Published Items', () => { + it('should show the version number when the published version has no marked name', () => { + render( + , + ) + + expect(screen.getByText('# 5')).toBeInTheDocument() + expect(screen.queryByText('2024-01-01T00:00:00Z')).not.toBeInTheDocument() + }) + + it('should show the environments deployed from the published version', () => { + render( + , + ) + + expect(screen.getByText('Staging')).toBeInTheDocument() + expect(screen.getByText('Production')).toBeInTheDocument() + }) + it('should open the context menu for a latest named version and forward restore', async () => { const user = userEvent.setup() const handleClickActionMenuItem = vi.fn() @@ -116,6 +158,7 @@ describe('VersionHistoryItem', () => { expect(screen.getByText('workflow.versionHistory.editVersionInfo')).toBeInTheDocument() expect(screen.getByText('app.export')).toBeInTheDocument() expect(screen.getByText('workflow.versionHistory.copyId')).toBeInTheDocument() + expect(screen.getByText('version-1')).toBeInTheDocument() expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument() const restoreItem = screen.getByText('workflow.common.restore').closest('.cursor-pointer') diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx index b27be377ed0..49f43fc52df 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx @@ -43,6 +43,7 @@ describe('ActionMenu', () => { renderActionMenu( { />, ) - await user.click(screen.getByRole('button')) + const trigger = screen.getByRole('button', { name: 'common.operation.more' }) + expect(trigger).not.toHaveAttribute('role') + + await user.click(trigger) await user.click(screen.getByText('workflow.common.restore')) await user.click(screen.getByText('common.operation.delete')) @@ -74,6 +78,7 @@ describe('ActionMenu', () => { renderActionMenu( { it('returns restore, edit, export, copy and delete operations for app workflows', () => { const { result } = renderWorkflowHook(() => useActionMenu({ + workflowId: 'version-1', isNamedVersion: true, canImportExportDSL: true, isShowDelete: false, @@ -31,6 +32,7 @@ describe('useActionMenu', () => { const { result } = renderWorkflowHook( () => useActionMenu({ + workflowId: 'version-1', isNamedVersion: false, canImportExportDSL: true, isShowDelete: true, @@ -57,6 +59,7 @@ describe('useActionMenu', () => { { key: VersionHistoryContextMenuOptions.copyId, name: 'workflow.versionHistory.copyId', + description: 'version-1', }, ]) }) @@ -64,6 +67,7 @@ describe('useActionMenu', () => { it('omits export when import/export DSL permission is missing', () => { const { result } = renderWorkflowHook(() => useActionMenu({ + workflowId: 'version-1', isNamedVersion: true, canImportExportDSL: false, isShowDelete: false, diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx index d110c82287d..3bd4bf90f0c 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx @@ -9,6 +9,7 @@ type ActionMenuItemProps = { item: { key: VersionHistoryContextMenuOptions name: string + description?: string showUpgrade?: boolean } onClick: (operation: VersionHistoryContextMenuOptions) => void @@ -21,6 +22,7 @@ const ActionMenuItem: FC = ({ item, onClick, isDestructive variant={isDestructive ? 'destructive' : 'default'} className={cn( 'justify-between gap-x-3 px-2 py-1.5 whitespace-nowrap', + item.description && 'h-auto py-1', isDestructive && 'data-highlighted:bg-state-destructive-hover', )} onClick={(event) => { @@ -33,11 +35,20 @@ const ActionMenuItem: FC = ({ item, onClick, isDestructive >
- {item.name} +
{item.name}
+ {item.description && ( +
+ {item.description} +
+ )}
{item.showUpgrade && (
diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index 3b2e76c7e98..162d52af7d4 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -6,13 +6,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' -import { RiMoreFill } from '@remixicon/react' import * as React from 'react' +import { useTranslation } from 'react-i18next' import { VersionHistoryContextMenuOptions } from '../../../types' import ActionMenuItem from './action-menu-item' import useActionMenu from './use-action-menu' export type ActionMenuProps = { + workflowId: string isShowDelete: boolean isNamedVersion: boolean canImportExportDSL: boolean @@ -24,21 +25,21 @@ export type ActionMenuProps = { const ActionMenu: FC = (props: ActionMenuProps) => { const { isShowDelete, handleClickActionMenuItem, open, setOpen } = props const { deleteOperation, options } = useActionMenu(props) + const { t } = useTranslation() return ( $['operation.more'], { ns: 'common' })} onClick={(e) => e.stopPropagation()} /> } > - + = (props: ActionMenuProps) => { ))} {isShowDelete && ( <> - + { - const { isNamedVersion, canImportExportDSL } = props + const { workflowId, isNamedVersion, canImportExportDSL } = props const { t } = useTranslation() const pipelineId = useStore((s) => s.pipelineId) const { plan, enableBilling } = useProviderContext() @@ -47,9 +47,10 @@ const useActionMenu = (props: ActionMenuProps) => { { key: VersionHistoryContextMenuOptions.copyId, name: t(($) => $['versionHistory.copyId'], { ns: 'workflow' }), + description: workflowId, }, ] - }, [canImportExportDSL, isNamedVersion, pipelineId, shouldShowUpgrade, shouldShowUpgrade, t]) + }, [canImportExportDSL, isNamedVersion, pipelineId, shouldShowUpgrade, t, workflowId]) return { deleteOperation, diff --git a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx index 1c9672ff58c..4c91e1f8c9b 100644 --- a/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/delete-confirm-modal.tsx @@ -11,6 +11,7 @@ import { } from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' type DeleteConfirmModalProps = { isOpen: boolean @@ -37,7 +38,10 @@ const DeleteConfirmModal: FC = ({
- {`${t(($) => $['operation.delete'], { ns: 'common' })} ${versionInfo.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' })}`} + {`${t(($) => $['operation.delete'], { ns: 'common' })} ${getWorkflowVersionName( + versionInfo, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + )}`} {t(($) => $['versionHistory.deletionTip'], { ns: 'workflow' })} diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index cbc29c38a0e..69500184a72 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -12,16 +12,19 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo import Divider from '@/app/components/base/divider' import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' import { useDeleteWorkflow, useInvalidAllLastRun, + useInvalidateAppWorkflow, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory, } from '@/service/use-workflow' +import { FlowType } from '@/types/common' import { useHooksStore } from '../../hooks-store' import { useDSL } from '../../hooks/use-DSL' import { useWorkflowRefreshDraft } from '../../hooks/use-workflow-refresh-draft' @@ -80,6 +83,7 @@ export const VersionHistoryPanel = ({ const configsMap = useHooksStore((s) => s.configsMap) const canImportExportDSL = useHooksStore((s) => s.accessControl.canImportExportDSL) const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId) + const invalidateAppWorkflow = useInvalidateAppWorkflow() const { deleteAllInspectVars } = workflowStore.getState() const { t } = useTranslation() @@ -137,6 +141,13 @@ export const VersionHistoryPanel = ({ const handleClickActionMenuItem = useCallback( (item: VersionHistory, operation: VersionHistoryContextMenuOptions) => { + if (operation === VersionHistoryContextMenuOptions.delete && item.environments?.length) { + toast.error( + t(($) => $['versionHistory.action.deleteDeployedVersionError'], { ns: 'workflow' }), + ) + return + } + setOperatedItem(item) switch (operation) { case VersionHistoryContextMenuOptions.restore: @@ -190,7 +201,10 @@ export const VersionHistoryPanel = ({ await import('../../collaboration/core/collaboration-manager') collaborationManager.emitRestoreIntent({ versionId: item.id, - versionName: item.marked_name, + versionName: getWorkflowVersionName( + item, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + ), initiatorUserId: userProfile.id, initiatorName: userProfile.name, }) @@ -198,7 +212,7 @@ export const VersionHistoryPanel = ({ console.error('Failed to emit restore intent:', error) } }, - [userProfile.id, userProfile.name], + [t, userProfile.id, userProfile.name], ) const emitRestoreComplete = useCallback( @@ -318,7 +332,12 @@ export const VersionHistoryPanel = ({ onSuccess: () => { setEditModalOpen(false) toast.success(t(($) => $['versionHistory.action.updateSuccess'], { ns: 'workflow' })) - resetWorkflowVersionHistory() + if ( + id === latestVersionId && + configsMap?.flowType === FlowType.appFlow && + configsMap.flowId + ) + invalidateAppWorkflow(configsMap.flowId) }, onError: () => { toast.error(t(($) => $['versionHistory.action.updateFailure'], { ns: 'workflow' })) @@ -329,7 +348,15 @@ export const VersionHistoryPanel = ({ }, ) }, - [t, updateWorkflow, resetWorkflowVersionHistory, updateVersionUrl], + [ + configsMap?.flowId, + configsMap?.flowType, + invalidateAppWorkflow, + latestVersionId, + t, + updateWorkflow, + updateVersionUrl, + ], ) return ( diff --git a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx index 5032f2fc27c..7ed0b6d6fa5 100644 --- a/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx +++ b/web/app/components/workflow/panel/version-history-panel/restore-confirm-modal.tsx @@ -11,6 +11,7 @@ import { } from '@langgenius/dify-ui/alert-dialog' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' type RestoreConfirmModalProps = { isOpen: boolean @@ -37,7 +38,10 @@ const RestoreConfirmModal: FC = ({
- {`${t(($) => $['common.restore'], { ns: 'workflow' })} ${versionInfo.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' })}`} + {`${t(($) => $['common.restore'], { ns: 'workflow' })} ${getWorkflowVersionName( + versionInfo, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + )}`} {t(($) => $['versionHistory.restorationTip'], { ns: 'workflow' })} diff --git a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx index 1d05b884840..18bc56f05fd 100644 --- a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx @@ -5,6 +5,8 @@ import dayjs from 'dayjs' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import Badge from '@/app/components/base/badge/index' +import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' import { WorkflowVersion } from '../../types' import ActionMenu from './action-menu' @@ -53,6 +55,7 @@ const VersionHistoryItem: React.FC = ({ const isSelected = item.version === currentVersion?.version const isDraft = formattedVersion === WorkflowVersion.Draft const isLatest = formattedVersion === WorkflowVersion.Latest + const deployedEnvironments = item.environments || [] useEffect(() => { if (isDraft) onClick(item) @@ -105,7 +108,10 @@ const VersionHistoryItem: React.FC = ({ > {isDraft ? t(($) => $['versionHistory.currentDraft'], { ns: 'workflow' }) - : item.marked_name || t(($) => $['versionHistory.defaultName'], { ns: 'workflow' })} + : getWorkflowVersionName( + item, + t(($) => $['versionHistory.defaultName'], { ns: 'workflow' }), + )}
{isLatest && (
@@ -123,11 +129,25 @@ const VersionHistoryItem: React.FC = ({ {`${formatTime(item.created_at)} · ${item.created_by.name}`}
)} + {!isDraft && deployedEnvironments.length > 0 && ( +
+ {deployedEnvironments.map((environment) => ( + + {environment.name} + + ))} +
+ )}
{/* Action Menu */} {!hideActionMenu && !isDraft && isHovering && (
void setWorkflowHistory: (workflowHistory: WorkflowHistoryState) => void historyShortcutsEnabled: boolean setHistoryShortcutsEnabled: (enabled: boolean) => void @@ -54,6 +56,9 @@ export const createHistorySlice: StateCreator = (set) => ({ workflowHistoryEvent: undefined, workflowHistoryEventMeta: undefined, }, + initializedWorkflowHistory: undefined, + initializeWorkflowHistory: (workflowHistory) => + set(() => ({ workflowHistory, initializedWorkflowHistory: workflowHistory })), setWorkflowHistory: (workflowHistory) => set(() => ({ workflowHistory })), historyShortcutsEnabled: true, setHistoryShortcutsEnabled: (historyShortcutsEnabled) => set(() => ({ historyShortcutsEnabled })), diff --git a/web/app/components/workflow/utils/__tests__/version.spec.ts b/web/app/components/workflow/utils/__tests__/version.spec.ts new file mode 100644 index 00000000000..2975d010dcb --- /dev/null +++ b/web/app/components/workflow/utils/__tests__/version.spec.ts @@ -0,0 +1,39 @@ +import { getWorkflowVersionName } from '../version' + +describe('getWorkflowVersionName', () => { + it('prefers a marked name over the version number', () => { + expect( + getWorkflowVersionName( + { + marked_name: 'Production', + version_number: 7, + }, + 'Untitled Version', + ), + ).toBe('Production') + }) + + it('uses the version number when the marked name is empty', () => { + expect( + getWorkflowVersionName( + { + marked_name: '', + version_number: 7, + }, + 'Untitled Version', + ), + ).toBe('# 7') + }) + + it('uses the default name for legacy versions without a version number', () => { + expect( + getWorkflowVersionName( + { + marked_name: '', + version_number: null, + }, + 'Untitled Version', + ), + ).toBe('Untitled Version') + }) +}) diff --git a/web/app/components/workflow/utils/version.ts b/web/app/components/workflow/utils/version.ts new file mode 100644 index 00000000000..69c4ccb8ec2 --- /dev/null +++ b/web/app/components/workflow/utils/version.ts @@ -0,0 +1,14 @@ +export type WorkflowVersionNameSource = { + marked_name?: string | null + version_number?: number | null +} + +export const getWorkflowVersionName = ( + version: WorkflowVersionNameSource | null | undefined, + defaultName: string, +): string => { + if (version?.marked_name) return version.marked_name + if (version?.version_number) return `# ${version.version_number}` + + return defaultName +} diff --git a/web/context/access-control-store.ts b/web/context/access-control-store.ts deleted file mode 100644 index 49769cd982e..00000000000 --- a/web/context/access-control-store.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control' -import type { App } from '@/types/app' -import { create } from 'zustand' -import { AccessMode } from '@/models/access-control' - -type AccessControlStore = { - appId: App['id'] - setAppId: (appId: App['id']) => void - specificGroups: AccessControlGroup[] - setSpecificGroups: (specificGroups: AccessControlGroup[]) => void - specificMembers: AccessControlAccount[] - setSpecificMembers: (specificMembers: AccessControlAccount[]) => void - currentMenu: AccessMode - setCurrentMenu: (currentMenu: AccessMode) => void - selectedGroupsForBreadcrumb: AccessControlGroup[] - setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb: AccessControlGroup[]) => void -} - -const useAccessControlStore = create((set) => { - return { - appId: '', - setAppId: (appId) => set({ appId }), - specificGroups: [], - setSpecificGroups: (specificGroups) => set({ specificGroups }), - specificMembers: [], - setSpecificMembers: (specificMembers) => set({ specificMembers }), - currentMenu: AccessMode.SPECIFIC_GROUPS_MEMBERS, - setCurrentMenu: (currentMenu) => set({ currentMenu }), - selectedGroupsForBreadcrumb: [], - setSelectedGroupsForBreadcrumb: (selectedGroupsForBreadcrumb) => - set({ selectedGroupsForBreadcrumb }), - } -}) - -export default useAccessControlStore diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx index 2197c025cef..8b224417be6 100644 --- a/web/context/web-app-context.tsx +++ b/web/context/web-app-context.tsx @@ -16,6 +16,7 @@ import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { AccessMode } from '@/models/access-control' import { usePathname, useSearchParams } from '@/next/navigation' import { useGetWebAppAccessModeByCode } from '@/service/use-share' +import { parseWebAppAddress } from '@/service/webapp-address' type WebAppStore = { shareCode: string | null @@ -58,12 +59,10 @@ export const useWebAppStore = create((set) => ({ const getShareCodeFromRedirectUrl = (redirectUrl: string | null): string | null => { const currentOrigin = typeof window === 'undefined' ? undefined : window.location.origin - return resolveWebAppLoginRedirect(redirectUrl, currentOrigin)?.appCode || null + return resolveWebAppLoginRedirect(redirectUrl, currentOrigin)?.address.code || null } const getShareCodeFromPathname = (pathname: string): string | null => { - const code = pathname.split('/').pop() || null - if (code === 'webapp-signin') return null - return code + return parseWebAppAddress(pathname)?.code || null } const WebAppStoreProvider: FC = ({ children }) => { diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx index 2aaff578db4..c54a8b5c104 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx @@ -511,6 +511,23 @@ describe('Agent access surface cards', () => { }) }) + it('should not show the multi-environment settings notice', async () => { + const user = userEvent.setup() + + renderWithQueryClient( + , + ) + + await user.click( + screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.settings' }), + ) + + const dialog = await screen.findByRole('dialog', { + name: 'appOverview.overview.appInfo.settings.title', + }) + expect(within(dialog).queryByRole('status')).not.toBeInTheDocument() + }) + it('should keep embedded disabled until the backing app id and web app token are available', () => { renderWithQueryClient( { ) }) - it('should fall back to develop when RBAC is disabled for an access-config-only app', () => { + it('should fall back to access point when RBAC is disabled for an access-config-only app', () => { renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }), { rbac_enabled: false, }) expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAttribute( 'href', - '/app/app-1/develop', + '/app/app-1/access-point', ) }) diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index 3036678652f..ea4d3e6d606 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "خارج الخدمة", "agentDetail.access.title": "نقطة الوصول", "agentDetail.access.toggleSurface": "تبديل وصول {{name}}", - "agentDetail.access.webApp.accessUrl": "رابط الوصول", + "agentDetail.access.webApp.accessUrl": "عنوان URL لتطبيق الويب", "agentDetail.access.webApp.actions.accessControl": "التحكم في الوصول", "agentDetail.access.webApp.actions.customize": "واجهة أمامية مخصصة", "agentDetail.access.webApp.actions.embedded": "مضمّن", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "تحديث رابط الوصول", "agentDetail.access.webApp.showQrCode": "عرض رمز QR", "agentDetail.access.webApp.ssoEnabled": "SSO ممكّن", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "تطبيق الويب", "agentDetail.access.workflow.description": "يُستخدم كعقدة داخل سير العمل", "agentDetail.access.workflow.empty": "لا توجد مراجع سير عمل بعد.", "agentDetail.access.workflow.loadFailed": "فشل تحميل مراجع سير العمل.", diff --git a/web/i18n/ar-TN/app-api.json b/web/i18n/ar-TN/app-api.json index 5cc331488f9..56a70a6635f 100644 --- a/web/i18n/ar-TN/app-api.json +++ b/web/i18n/ar-TN/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "حذف هذا المفتاح السري؟", "actionMsg.ok": "موافق", "apiKey": "مفتاح API", - "apiKeyModal.apiSecretKey": "مفتاح API السري", + "apiKeyModal.apiSecretKey": "مفتاح API", "apiKeyModal.apiSecretKeyTips": "لمنع إساءة استخدام API، قم بحماية مفتاح API الخاص بك. تجنب استخدامه كنص عادي في كود الواجهة الأمامية. :)", "apiKeyModal.createNewSecretKey": "إنشاء مفتاح سري جديد", "apiKeyModal.created": "تم الإنشاء", diff --git a/web/i18n/ar-TN/app-overview.json b/web/i18n/ar-TN/app-overview.json index 640d0bfe808..39a83ca53ce 100644 --- a/web/i18n/ar-TN/app-overview.json +++ b/web/i18n/ar-TN/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "الذهاب لإعداد مزود النموذج", "apiKeyInfo.tryCloud": "أو جرب النسخة السحابية من Dify مع عرض مجاني", "apiKeyInfo.usedToken": "رمز مستخدم", - "overview.apiInfo.accessibleAddress": "نقطة نهاية واجهة برمجة تطبيقات الخدمة", + "overview.apiInfo.accessibleAddress": "نقطة نهاية API", "overview.apiInfo.doc": "مرجع API", "overview.apiInfo.explanation": "سهلة الدمج في تطبيقك", "overview.apiInfo.title": "واجهة برمجة تطبيقات خدمة الخلفية", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "مسح للمشاركة", "overview.appInfo.qrcode.title": "رمز الاستجابة السريعة للرابط", "overview.appInfo.regenerate": "إعادة إنشاء", - "overview.appInfo.regenerateNotice": "هل تريد إعادة إنشاء عنوان URL العام؟", + "overview.appInfo.regenerateNotice": "هل تريد إعادة إنشاء عنوان URL العام؟ سيتوقف عنوان URL الحالي عن العمل فورًا.", "overview.appInfo.settings.chatColorTheme": "سمة لون الدردشة", "overview.appInfo.settings.chatColorThemeDesc": "تعيين سمة لون روبوت الدردشة", "overview.appInfo.settings.chatColorThemeInverted": "معكوس", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "سياسة الخصوصية", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "أدخل رابط سياسة الخصوصية", "overview.appInfo.settings.more.privacyPolicyTip": "يساعد الزوار على فهم البيانات التي يجمعها التطبيق، راجع سياسة الخصوصية لـ Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "تنطبق إعدادات تطبيق الويب على جميع البيئات.", "overview.appInfo.settings.sso.description": "يُطلب من جميع المستخدمين تسجيل الدخول باستخدام SSO قبل استخدام تطبيق الويب", "overview.appInfo.settings.sso.label": "فرض SSO", "overview.appInfo.settings.sso.title": "تطبيق ويب SSO", @@ -117,10 +118,10 @@ "overview.status.running": "في الخدمة", "overview.title": "نظرة عامة", "overview.triggerInfo.explanation": "إدارة مشغلات سير العمل", - "overview.triggerInfo.learnAboutTriggers": "تعرف على المشغلات", + "overview.triggerInfo.learnAboutTriggers": "تعرّف على المزيد حول المشغلات", "overview.triggerInfo.noTriggerAdded": "لم تتم إضافة أي مشغل", "overview.triggerInfo.title": "المشغلات", - "overview.triggerInfo.triggerStatusDescription": "تظهر حالة عقدة المشغل هنا. (قد تكون موجودة بالفعل في المسودة، وتدخل حيز التنفيذ بعد النشر)", + "overview.triggerInfo.triggerStatusDescription": "تظهر المشغلات هنا بعد نشر الإصدار الذي يحتوي عليها.", "overview.triggerInfo.triggersAdded": "تمت إضافة {{count}} مشغلات", "welcome.enterKeyTip": "أدخل مفتاح OpenAI API الخاص بك أدناه", "welcome.firstStepTip": "للبدء،", diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json index adba07da63e..f0717eaa232 100644 --- a/web/i18n/ar-TN/common.json +++ b/web/i18n/ar-TN/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "توفر ملحقات API إدارة مركزية لواجهة برمجة التطبيقات، مما يبسط التكوين لسهولة الاستخدام عبر تطبيقات Dify.", "apiBasedExtension.type": "النوع", "apiBasedExtensionPage.description": "وسّع قدرات وحدات Dify عبر API — أضف إشرافًا مخصصًا على المحتوى أو أدوات بيانات خارجية إلى تطبيقاتك.", + "appMenus.accessPoint": "نقطة الوصول", "appMenus.annotations": "التعليقات التوضيحية", "appMenus.apiAccess": "وصول API", "appMenus.apiAccessTip": "يمكن الوصول إلى قاعدة المعرفة هذه عبر واجهة برمجة تطبيقات الخدمة", + "appMenus.deploy": "نشر", "appMenus.logs": "السجلات", "appMenus.overview": "المراقبة", "appMenus.promptEng": "تنسيق", diff --git a/web/i18n/ar-TN/deployments.json b/web/i18n/ar-TN/deployments.json index fbf6609f78f..ba01ce8b511 100644 --- a/web/i18n/ar-TN/deployments.json +++ b/web/i18n/ar-TN/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "قيد التشغيل (المزامنة معلقة)", "deployTab.status.runningWithFailed": "قيد التشغيل (فشل آخر نشر)", "deployTab.undeploy": "إلغاء النشر", - "deployTab.undeployConfirmDesc": "سيتوقف وصول المستخدم النهائي فورًا. يمكن إعادة نشر الإصدار لاحقًا.", - "deployTab.undeployConfirmTitle": "إلغاء النشر من {{name}}؟", + "deployTab.undeployConfirmDesc": "ستصبح نقطة نهاية البيئة غير متاحة، لكن لن يتم حذف الإصدار.", + "deployTab.undeployConfirmTitle": "إلغاء نشر {{name}}؟", + "deployTab.undeployConfirmWarning": "بعد التأكيد، ستدخل هذه البيئة حالة إلغاء النشر وسيتم تعطيل الإجراءات مؤقتًا.", "deployTab.viewError": "عرض الخطأ", "detail.mobileTabs": "أقسام النشر", "detail.notFound": "لم يتم العثور على النشر", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "غير منشور", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "جاري إلغاء النشر", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "غير معروف", + "studio.accessPoint.addTriggerNodeDescription": "أضف عقدة Trigger لبدء هذا التطبيق من أحداث خارجية.", + "studio.accessPoint.apiDescription": "استدعِ التطبيق من خدماتك عبر HTTP", + "studio.accessPoint.description": "جميع طرق الوصول إلى هذا التطبيق في البيئة المدمجة وكل بيئة منشورة.", + "studio.accessPoint.embedIntoSite": "تضمين في الموقع", + "studio.accessPoint.goToPublish": "الانتقال إلى النشر", + "studio.accessPoint.mcpDescription": "اعرض التطبيق كأداة للوكلاء وتطبيقات الذكاء الاصطناعي", + "studio.accessPoint.noPublishedDescription": "انشر إصدارًا لإتاحة نقاط الوصول المضمّنة.", + "studio.accessPoint.noPublishedTitle": "لا توجد إصدارات منشورة بعد", + "studio.accessPoint.noTriggerNodes": "لا توجد عقد Trigger في سير العمل هذا", + "studio.accessPoint.notSupported": "غير مدعوم", + "studio.accessPoint.open": "فتح", + "studio.accessPoint.triggerDescription": "شغّل سير العمل وفق جداول زمنية أو أحداث خارجية", + "studio.accessPoint.triggerDisconnected": "غير متصل", + "studio.accessPoint.triggerEnabledCount": "تم تمكين {{enabled}} من {{total}}", + "studio.accessPoint.triggerExclusiveNotice": "يتم إيقاف Web app وAPI وMCP أثناء استخدام سير العمل للمشغلات.", + "studio.accessPoint.triggerMuted": "مكتوم", + "studio.accessPoint.triggerServiceModeUnavailable": "غير متاح أثناء تشغيل Web App أو API أو MCP.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "متاح في البيئة المدمجة فقط.", + "studio.accessPoint.webAppDescription": "شارك التطبيق مع المستخدمين النهائيين كصفحة ويب", + "studio.accessPoints": "نقاط الوصول", + "studio.activity.deploy": "نشر {{target}}", + "studio.activity.deployFailed": "فشل نشر {{target}}", + "studio.activity.deploySucceeded": "تم نشر {{target}} بنجاح", + "studio.activity.meta": "بواسطة {{name}} · {{time}}", + "studio.allVersions": "كل الإصدارات", + "studio.builtInDescription": "يشغّل دائمًا أحدث إصدار منشور", + "studio.builtInTitle": "البيئة المدمجة", + "studio.changeVersion": "تغيير الإصدار", + "studio.chooseVersionToDeploy": "اختر إصدارًا للنشر", + "studio.current": "الحالي", + "studio.deployAnotherVersion": "نشر إصدار آخر", + "studio.deployConfiguration": "إعداد النشر", + "studio.deployLatest": "نشر أحدث إصدار", + "studio.description": "أدِر عمليات نشر هذا التطبيق عبر البيئات. يمكن إنشاء المزيد من البيئات في لوحة معلومات Enterprise.", + "studio.emptyDescription": "انشر إصدارًا منشورًا إلى بيئة، وسيظهر هنا.", + "studio.environmentVariablesDescription": "استخدم القيمة من الإصدار الذي تنشره، أو احتفظ بآخر قيمة تم نشرها، أو أدخل قيمة مخصصة.", + "studio.environments": "البيئات", + "studio.environmentsInUse": "{{used}} من أصل {{total}} بيئة قيد الاستخدام", + "studio.lastActivity": "آخر نشاط", + "studio.liveVersion": "الإصدار النشط", + "studio.moreEnvironments": "المزيد من البيئات", + "studio.noCredentialsYet": "لا توجد بيانات اعتماد بعد", + "studio.noCredentialsYetDescription": "أضف بيانات اعتماد في عمليات التكامل أو إعدادات العقدة", + "studio.precheck.description": "يحتوي على أنواع عقد غير مدعومة بعد:", + "studio.precheck.supportMessage": "سيتوفر دعم أنواع العقد هذه في إصدار مستقبلي.", + "studio.precheck.title": "لا يمكن نشر هذا الإصدار في هذه البيئة", + "studio.publisher.deployingVersion": "جاري النشر: {{version}}", + "studio.publisher.noPublishedDescription": "انشر مسودتك لتتمكن من نشرها هنا.", + "studio.publisher.notDeployedYet": "لم يتم النشر بعد", + "studio.retryVersion": "إعادة محاولة {{version}}", + "studio.undeployConfirmDesc": "سيتوقف التطبيق عن العمل في هذه البيئة، وستصبح جميع نقاط الوصول الخاصة به غير متاحة. لن يتم حذف الإصدار المنشور.", + "studio.undeployConfirmTitle": "إلغاء نشر {{versionName}} من {{envName}}", + "studio.updatedAtBy": "تم التحديث في {{time}} بواسطة {{name}}", + "studio.versionValue": "قيمة الإصدار", + "studio.versionsBehind_one": "متأخر بـ {{count}} إصدار", + "studio.versionsBehind_other": "متأخر بـ {{count}} إصدارات", "tabs.access.description": "إدارة قنوات الوصول وأذونات الوصول.", "tabs.access.name": "الوصول", "tabs.api-tokens.description": "إدارة رموز API واستدعاء المثيلات عبر HTTP.", diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json index 93d4aa39416..e4822af4297 100644 --- a/web/i18n/ar-TN/permission-keys.json +++ b/web/i18n/ar-TN/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "تكوين أذونات الوصول إلى التطبيق", "app.acl.access_config": "تكوين أذونات الوصول إلى التطبيق", "app.acl.delete": "حذف التطبيق", + "app.acl.deploy": "نشر التطبيق", "app.acl.edit": "تعديل التطبيق وتنسيقه", "app.acl.import_export_dsl": "استيراد / تصدير DSL", "app.acl.log_and_annotation": "الوصول إلى السجلات والتعليقات التوضيحية", diff --git a/web/i18n/ar-TN/tools.json b/web/i18n/ar-TN/tools.json index 5e9172b2308..5f6bebe9d21 100644 --- a/web/i18n/ar-TN/tools.json +++ b/web/i18n/ar-TN/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "الغرض من المعلمة والقيود", "mcp.server.modal.parametersTip": "أضف أوصافًا لكل معلمة لمساعدة LLM على فهم الغرض منها والقيود المفروضة عليها.", "mcp.server.publishTip": "التطبيق غير منشور. يرجى نشر التطبيق أولاً.", - "mcp.server.reGen": "هل تريد إعادة إنشاء عنوان URL للخادم؟", + "mcp.server.reGen": "هل تريد إعادة إنشاء عنوان URL للخادم؟ سيتوقف عنوان URL الحالي عن العمل فورًا.", "mcp.server.title": "خادم MCP", "mcp.server.url": "عنوان URL للخادم", "mcp.toolItem.noDescription": "لا يوجد وصف", diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index aefd179a939..40bd1bfa330 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "رد", "common.ImageUploadLegacyTip": "يمكنك الآن إنشاء متغيرات نوع الملف في نموذج البداية. لن ندعم ميزة تحميل الصور في المستقبل. ", "common.accessAPIReference": "الوصول إلى مرجع API", + "common.accessPointDescription": "تهيئة تطبيق الويب وواجهة API وMCP والمشغلات", "common.addBlock": "إضافة عقدة", "common.addDescription": "إضافة وصف...", "common.addFailureBranch": "إضافة فرع فشل", @@ -169,6 +170,7 @@ "common.currentView": "العرض الحالي", "common.currentWorkflow": "سير العمل الحالي", "common.debugAndPreview": "معاينة", + "common.deployDescription": "نشر الإصدارات إلى البيئات", "common.disconnect": "قطع الاتصال", "common.draftSaveFailed": "فشل حفظ المسودة", "common.duplicate": "تكرار", @@ -217,11 +219,15 @@ "common.needConnectTip": "هذه الخطوة غير متصلة بأي شيء", "common.needOutputNode": "يجب إضافة عقدة الإخراج", "common.needStartNode": "يجب إضافة عقدة بدء واحدة على الأقل", + "common.noChanges": "لا توجد تغييرات", "common.noHistory": "لا يوجد سجل", "common.noVar": "لا يوجد متغير", + "common.notPublishedYet": "لم يتم النشر بعد", "common.notRunning": "لم يتم التشغيل بعد", "common.onFailure": "عند الفشل", "common.openInExplore": "فتح في الاستكشاف", + "common.openWebApp": "فتح تطبيق الويب", + "common.openWebAppDescription": "افتح التطبيق المنشور في علامة تبويب جديدة", "common.output": "إخراج", "common.overwriteAndImport": "استبدال واستيراد", "common.parallel": "توازي", @@ -238,10 +244,12 @@ "common.processData": "معالجة البيانات", "common.publish": "نشر", "common.publishToMarketplace": "نشر على Marketplace", + "common.publishToMarketplaceDescription": "شارك هذا التطبيق مع المجتمع", "common.publishToMarketplaceFailed": "فشل النشر على Marketplace", "common.publishUpdate": "نشر التحديث", "common.published": "منشور", "common.publishedAt": "تم النشر في", + "common.publishedBy": "تم النشر {{time}} بواسطة {{author}}", "common.publishingToMarketplace": "جارٍ النشر...", "common.redo": "إعادة", "common.restart": "إعادة تشغيل", @@ -251,6 +259,7 @@ "common.runApp": "تشغيل التطبيق", "common.runHistory": "سجل التشغيل", "common.running": "جارٍ التشغيل", + "common.savedAt": "تم الحفظ {{time}}", "common.scheduleTriggerRunFailed": "فشل تشغيل المشغل المجدول", "common.searchVar": "بحث عن متغير", "common.setVarValuePlaceholder": "تعيين متغير", @@ -265,6 +274,8 @@ "common.tagBound": "عدد التطبيقات التي تستخدم هذه العلامة", "common.undo": "تراجع", "common.unpublished": "غير منشور", + "common.unpublishedChanges": "تغييرات غير منشورة", + "common.upToDate": "محدث", "common.update": "تحديث", "common.variableNamePlaceholder": "اسم المتغير", "common.versionHistory": "سجل الإصدارات", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "فشل تصحيح أخطاء Webhook", "common.webhookDebugRequestFailed": "فشل طلب تصحيح أخطاء Webhook", "common.workflowAsTool": "سير العمل كأداة", + "common.workflowAsToolDescription": "استخدمه كأداة في تطبيقات أخرى", "common.workflowAsToolDisabledHint": "انشر أحدث سير عمل وتأكد من وجود عقدة إدخال مستخدم متصلة قبل تكوينها كأداة.", + "common.workflowAsToolReady": "جاهز", + "common.workflowAsToolReconfigure": "إعادة التهيئة", "common.workflowAsToolTip": "التكوين المطلوب للأداة بعد تحديث سير العمل.", + "common.workflowAsToolUpdateNeeded": "التحديث مطلوب", "common.workflowProcess": "عملية سير العمل", "common.workflowProcessFailed": "فشلت عملية سير العمل", "common.workflowProcessPaused": "تم إيقاف عملية سير العمل مؤقتًا", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "لا توجد متغيرات متاحة", "variableReference.noVarsForOperation": "لا توجد متغيرات متاحة للتعيين مع العملية المحددة.", "versionHistory.action.copyIdSuccess": "تم نسخ المعرف إلى الحافظة", + "versionHistory.action.deleteDeployedVersionError": "تم نشر إصدار سير العمل هذا، أو جارٍ نشره، في بيئة واحدة أو أكثر. ألغِ نشره من جميع البيئات قبل حذف الإصدار.", "versionHistory.action.deleteFailure": "فشل حذف الإصدار", "versionHistory.action.deleteSuccess": "تم حذف الإصدار", "versionHistory.action.restoreFailure": "فشل استعادة الإصدار", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "الخاص بك فقط", "versionHistory.filter.reset": "إعادة تعيين التصفية", "versionHistory.latest": "الأحدث", + "versionHistory.nameIt": "سمّه", "versionHistory.nameThisVersion": "تسمية هذا الإصدار", "versionHistory.releaseNotesPlaceholder": "صف ما تغير", "versionHistory.restorationTip": "بعد استعادة الإصدار، سيتم استبدال المسودة الحالية.", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index 1e04803a18a..40d59b95cf7 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Außer Betrieb", "agentDetail.access.title": "Zugangspunkt", "agentDetail.access.toggleSurface": "Zugriff von {{name}} umschalten", - "agentDetail.access.webApp.accessUrl": "Zugriffs-URL", + "agentDetail.access.webApp.accessUrl": "Webapp-URL", "agentDetail.access.webApp.actions.accessControl": "Zugriffskontrolle", "agentDetail.access.webApp.actions.customize": "Benutzerdefiniertes Frontend", "agentDetail.access.webApp.actions.embedded": "Eingebettet", diff --git a/web/i18n/de-DE/app-api.json b/web/i18n/de-DE/app-api.json index c31b1c3b1e9..f29005ec2be 100644 --- a/web/i18n/de-DE/app-api.json +++ b/web/i18n/de-DE/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Diesen Geheimschlüssel löschen?", "actionMsg.ok": "OK", "apiKey": "API Schlüssel", - "apiKeyModal.apiSecretKey": "API Geheimschlüssel", + "apiKeyModal.apiSecretKey": "API Schlüssel", "apiKeyModal.apiSecretKeyTips": "Um Missbrauch der API zu verhindern, schützen Sie Ihren API Schlüssel. Vermeiden Sie es, ihn als Klartext im Frontend-Code zu verwenden. :)", "apiKeyModal.createNewSecretKey": "Neuen Geheimschlüssel erstellen", "apiKeyModal.created": "ERSTELLT", diff --git a/web/i18n/de-DE/app-overview.json b/web/i18n/de-DE/app-overview.json index ff1878f4ea6..3d207ef2917 100644 --- a/web/i18n/de-DE/app-overview.json +++ b/web/i18n/de-DE/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Zum Einrichten des Modellanbieters gehen", "apiKeyInfo.tryCloud": "Oder probieren Sie die Cloud-Version von Dify mit kostenlosem Angebot aus", "apiKeyInfo.usedToken": "Verwendetes Token", - "overview.apiInfo.accessibleAddress": "Service-API-Endpunkt", + "overview.apiInfo.accessibleAddress": "API-Endpunkt", "overview.apiInfo.doc": "API-Referenz", "overview.apiInfo.explanation": "Einfach in Ihre Anwendung integrierbar", "overview.apiInfo.title": "Backend-Service-API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Teilen Sie die Anwendung per Scan", "overview.appInfo.qrcode.title": "QR-Code zum Teilen", "overview.appInfo.regenerate": "Regenerieren", - "overview.appInfo.regenerateNotice": "Möchten Sie die öffentliche URL neu generieren?", + "overview.appInfo.regenerateNotice": "Möchten Sie die öffentliche URL neu generieren? Die aktuelle URL funktioniert sofort nicht mehr.", "overview.appInfo.settings.chatColorTheme": "Chat-Farbschema", "overview.appInfo.settings.chatColorThemeDesc": "Legen Sie das Farbschema des Chatbots fest", "overview.appInfo.settings.chatColorThemeInverted": "Invertiert", @@ -95,15 +95,16 @@ "overview.appInfo.settings.more.privacyPolicy": "Datenschutzrichtlinie", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Geben Sie den Link zur Datenschutzrichtlinie ein", "overview.appInfo.settings.more.privacyPolicyTip": "Hilft Besuchern zu verstehen, welche Daten die Anwendung sammelt, siehe Difys Datenschutzrichtlinie.", + "overview.appInfo.settings.multiEnvironmentNotice": "Web-App-Einstellungen gelten für alle Umgebungen.", "overview.appInfo.settings.sso.description": "Alle Benutzer müssen sich mit SSO anmelden, bevor sie web app verwenden können", "overview.appInfo.settings.sso.label": "SSO-Authentifizierung", "overview.appInfo.settings.sso.title": "web app SSO", "overview.appInfo.settings.sso.tooltip": "Wenden Sie sich an den Administrator, um web app SSO zu aktivieren", "overview.appInfo.settings.title": "Branding", - "overview.appInfo.settings.webDesc": "web app Beschreibung", + "overview.appInfo.settings.webDesc": "Beschreibung der Web-App", "overview.appInfo.settings.webDescPlaceholder": "Geben Sie die Beschreibung der web app ein", "overview.appInfo.settings.webDescTip": "Dieser Text wird auf der Clientseite angezeigt und bietet grundlegende Anleitungen zur Verwendung der Anwendung", - "overview.appInfo.settings.webName": "web app Name", + "overview.appInfo.settings.webName": "Name der Web-App", "overview.appInfo.settings.workflow.hide": "Verbergen", "overview.appInfo.settings.workflow.show": "Anzeigen", "overview.appInfo.settings.workflow.showDesc": "Ein- oder Ausblenden von Workflow-Details in der web app", @@ -120,7 +121,7 @@ "overview.triggerInfo.learnAboutTriggers": "Erfahren Sie mehr über Auslöser", "overview.triggerInfo.noTriggerAdded": "Kein Auslöser hinzugefügt", "overview.triggerInfo.title": "Auslöser", - "overview.triggerInfo.triggerStatusDescription": "Der Status des Trigger-Knotens wird hier angezeigt. (Kann bereits im Entwurf vorhanden sein, tritt nach der Veröffentlichung in Kraft)", + "overview.triggerInfo.triggerStatusDescription": "Auslöser werden hier angezeigt, nachdem Sie die Version veröffentlicht haben, in der sie enthalten sind.", "overview.triggerInfo.triggersAdded": "{{count}} Auslöser hinzugefügt", "welcome.enterKeyTip": "geben Sie unten Ihren OpenAI-API-Schlüssel ein", "welcome.firstStepTip": "Um zu beginnen,", diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json index 64fa7517aa8..6f970d9f9b4 100644 --- a/web/i18n/de-DE/common.json +++ b/web/i18n/de-DE/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API-Erweiterungen bieten zentralisiertes API-Management und vereinfachen die Konfiguration für eine einfache Verwendung in Difys Anwendungen.", "apiBasedExtension.type": "Typ", "apiBasedExtensionPage.description": "Erweitere die Modulfunktionen von Dify per API — füge deinen Apps benutzerdefinierte Inhaltsmoderation oder externe Datentools hinzu.", + "appMenus.accessPoint": "Zugangspunkt", "appMenus.annotations": "Anmerkungen", "appMenus.apiAccess": "API-Zugriff", "appMenus.apiAccessTip": "Diese Wissensdatenbank ist über die Service-API zugänglich", + "appMenus.deploy": "Bereitstellen", "appMenus.logs": "Baumstämme", "appMenus.overview": "Übersicht", "appMenus.promptEng": "Orchestrieren", diff --git a/web/i18n/de-DE/deployments.json b/web/i18n/de-DE/deployments.json index fe51f9cb5bb..508e597d60a 100644 --- a/web/i18n/de-DE/deployments.json +++ b/web/i18n/de-DE/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Läuft (Synchronisierung ausstehend)", "deployTab.status.runningWithFailed": "Läuft (letzte Bereitstellung fehlgeschlagen)", "deployTab.undeploy": "Bereitstellung aufheben", - "deployTab.undeployConfirmDesc": "Der Endbenutzerzugriff wird sofort beendet. Das Release kann später erneut bereitgestellt werden.", - "deployTab.undeployConfirmTitle": "Bereitstellung aus {{name}} aufheben?", + "deployTab.undeployConfirmDesc": "Der Endpunkt der Umgebung ist danach nicht mehr verfügbar, die Version wird jedoch nicht gelöscht.", + "deployTab.undeployConfirmTitle": "Bereitstellung von {{name}} aufheben?", + "deployTab.undeployConfirmWarning": "Nach der Bestätigung wechselt diese Umgebung in den Status „Bereitstellung wird aufgehoben“ und Aktionen werden vorübergehend deaktiviert.", "deployTab.viewError": "Fehler anzeigen", "detail.mobileTabs": "Bereitstellungsbereiche", "detail.notFound": "Bereitstellung nicht gefunden", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Nicht bereitgestellt", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Bereitstellung wird aufgehoben", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unbekannt", + "studio.accessPoint.addTriggerNodeDescription": "Fügen Sie einen Trigger-Knoten hinzu, um diese App durch externe Ereignisse zu starten.", + "studio.accessPoint.apiDescription": "Rufen Sie die App über HTTP aus Ihren Diensten auf", + "studio.accessPoint.description": "Alle Möglichkeiten, diese App in der integrierten Umgebung und jeder bereitgestellten Umgebung zu erreichen.", + "studio.accessPoint.embedIntoSite": "In Website einbetten", + "studio.accessPoint.goToPublish": "Zur Veröffentlichung", + "studio.accessPoint.mcpDescription": "Stellen Sie die App als Tool für Agenten und KI-Apps bereit", + "studio.accessPoint.noPublishedDescription": "Veröffentlichen Sie eine Version, um integrierte Zugriffspunkte verfügbar zu machen.", + "studio.accessPoint.noPublishedTitle": "Noch keine Version veröffentlicht", + "studio.accessPoint.noTriggerNodes": "Keine Trigger-Knoten in diesem Workflow", + "studio.accessPoint.notSupported": "Nicht unterstützt", + "studio.accessPoint.open": "Öffnen", + "studio.accessPoint.triggerDescription": "Führen Sie den Workflow nach Zeitplänen oder durch externe Ereignisse aus", + "studio.accessPoint.triggerDisconnected": "Getrennt", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} aktiviert", + "studio.accessPoint.triggerExclusiveNotice": "Web-App, API und MCP sind deaktiviert, solange der Workflow Trigger verwendet.", + "studio.accessPoint.triggerMuted": "Stummgeschaltet", + "studio.accessPoint.triggerServiceModeUnavailable": "Nicht verfügbar, solange Web App, API oder MCP in Betrieb ist.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Nur in der integrierten Umgebung verfügbar.", + "studio.accessPoint.webAppDescription": "Teilen Sie die App als Webseite mit Ihren Endnutzern", + "studio.accessPoints": "Zugriffspunkte", + "studio.activity.deploy": "{{target}} bereitstellen", + "studio.activity.deployFailed": "Bereitstellung von {{target}} fehlgeschlagen", + "studio.activity.deploySucceeded": "{{target}} erfolgreich bereitgestellt", + "studio.activity.meta": "von {{name}} · {{time}}", + "studio.allVersions": "Alle Versionen", + "studio.builtInDescription": "Führt immer die neueste veröffentlichte Version aus", + "studio.builtInTitle": "Integrierte Umgebung", + "studio.changeVersion": "Version ändern", + "studio.chooseVersionToDeploy": "Wähle eine Version zum Bereitstellen aus", + "studio.current": "Aktuell", + "studio.deployAnotherVersion": "Andere Version bereitstellen", + "studio.deployConfiguration": "Bereitstellung konfigurieren", + "studio.deployLatest": "Neueste Version bereitstellen", + "studio.description": "Verwalten Sie die Bereitstellungen dieser App in verschiedenen Umgebungen. Weitere Umgebungen können im Enterprise-Dashboard erstellt werden.", + "studio.emptyDescription": "Stellen Sie eine veröffentlichte Version in einer Umgebung bereit, damit sie hier angezeigt wird.", + "studio.environmentVariablesDescription": "Verwende den Wert aus der Version, die du bereitstellst, behalte den zuletzt bereitgestellten Wert bei oder gib einen benutzerdefinierten Wert ein.", + "studio.environments": "Umgebungen", + "studio.environmentsInUse": "{{used}} von {{total}} Umgebungen in Verwendung", + "studio.lastActivity": "Letzte Aktivität", + "studio.liveVersion": "Live-Version", + "studio.moreEnvironments": "Weitere Umgebungen", + "studio.noCredentialsYet": "Noch keine Anmeldedaten", + "studio.noCredentialsYetDescription": "Fügen Sie Anmeldedaten in Integrationen oder den Knoteneinstellungen hinzu", + "studio.precheck.description": "Sie enthält Knotentypen, die noch nicht unterstützt werden:", + "studio.precheck.supportMessage": "Die Unterstützung für diese Knotentypen wird in einer zukünftigen Version verfügbar sein.", + "studio.precheck.title": "Diese Version kann nicht in dieser Umgebung bereitgestellt werden", + "studio.publisher.deployingVersion": "Wird bereitgestellt: {{version}}", + "studio.publisher.noPublishedDescription": "Veröffentlichen Sie Ihren Entwurf, um ihn hier bereitzustellen.", + "studio.publisher.notDeployedYet": "Noch nicht bereitgestellt", + "studio.retryVersion": "{{version}} erneut versuchen", + "studio.undeployConfirmDesc": "Die App wird in dieser Umgebung nicht mehr ausgeführt und alle ihre Zugriffspunkte sind nicht mehr verfügbar. Die bereitgestellte Version wird nicht gelöscht.", + "studio.undeployConfirmTitle": "Bereitstellung von {{versionName}} in {{envName}} aufheben", + "studio.updatedAtBy": "Aktualisiert am {{time}} von {{name}}", + "studio.versionValue": "Versionswert", + "studio.versionsBehind_one": "{{count}} Version zurück", + "studio.versionsBehind_other": "{{count}} Versionen zurück", "tabs.access.description": "Verwalten Sie Zugriffskanäle und Zugriffsberechtigungen.", "tabs.access.name": "Zugriff", "tabs.api-tokens.description": "Verwalten Sie API-Token und rufen Sie Instanzen über HTTP auf.", diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json index 0d65b4fe9d1..5b27d880702 100644 --- a/web/i18n/de-DE/permission-keys.json +++ b/web/i18n/de-DE/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "App-Zugriffsberechtigungen konfigurieren", "app.acl.access_config": "App-Zugriffsberechtigungen konfigurieren", "app.acl.delete": "App löschen", + "app.acl.deploy": "App bereitstellen", "app.acl.edit": "App bearbeiten und orchestrieren", "app.acl.import_export_dsl": "DSL importieren / exportieren", "app.acl.log_and_annotation": "Auf Logs und Annotationen zugreifen", diff --git a/web/i18n/de-DE/tools.json b/web/i18n/de-DE/tools.json index ee9eafe496c..f4dcfd034ca 100644 --- a/web/i18n/de-DE/tools.json +++ b/web/i18n/de-DE/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Zweck und Einschränkungen des Parameters", "mcp.server.modal.parametersTip": "Fügen Sie Beschreibungen für jeden Parameter hinzu, um dem LLM Zweck und Einschränkungen zu verdeutlichen.", "mcp.server.publishTip": "App nicht veröffentlicht. Bitte zuerst die App veröffentlichen.", - "mcp.server.reGen": "Server-URL neu generieren?", + "mcp.server.reGen": "Server-URL neu generieren? Die aktuelle URL funktioniert sofort nicht mehr.", "mcp.server.title": "MCP-Server", "mcp.server.url": "Server-URL", "mcp.toolItem.noDescription": "Keine Beschreibung", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index ef0d408fdfb..0f0856a44a3 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Antworten", "common.ImageUploadLegacyTip": "Sie können jetzt Dateitypvariablen im Startformular erstellen. Wir werden die Funktion zum Hochladen von Bildern in Zukunft nicht mehr unterstützen.", "common.accessAPIReference": "API-Referenz aufrufen", + "common.accessPointDescription": "Web-App, API, MCP und Trigger konfigurieren", "common.addBlock": "Knoten hinzufügen", "common.addDescription": "Beschreibung hinzufügen...", "common.addFailureBranch": "Fail-Branch hinzufügen", @@ -169,6 +170,7 @@ "common.currentView": "Aktuelle Ansicht", "common.currentWorkflow": "Aktueller Arbeitsablauf", "common.debugAndPreview": "Vorschau", + "common.deployDescription": "Versionen in Umgebungen bereitstellen", "common.disconnect": "Trennen", "common.draftSaveFailed": "Entwurf konnte nicht gespeichert werden", "common.duplicate": "Duplizieren", @@ -217,11 +219,15 @@ "common.needConnectTip": "Dieser Schritt ist mit nichts verbunden", "common.needOutputNode": "Der Ausgabeknoten muss hinzugefügt werden", "common.needStartNode": "Es muss mindestens ein Startknoten hinzugefügt werden", + "common.noChanges": "Keine Änderungen", "common.noHistory": "Keine Geschichte", "common.noVar": "Keine Variable", + "common.notPublishedYet": "Noch nicht veröffentlicht", "common.notRunning": "Noch nicht ausgeführt", "common.onFailure": "Bei Ausfall", "common.openInExplore": "In Explore öffnen", + "common.openWebApp": "Web-App öffnen", + "common.openWebAppDescription": "Die veröffentlichte App in einem neuen Tab öffnen", "common.output": "Ausgabe", "common.overwriteAndImport": "Überschreiben und Importieren", "common.parallel": "PARALLEL", @@ -238,10 +244,12 @@ "common.processData": "Daten verarbeiten", "common.publish": "Veröffentlichen", "common.publishToMarketplace": "Im Marketplace veröffentlichen", + "common.publishToMarketplaceDescription": "Diese App mit der Community teilen", "common.publishToMarketplaceFailed": "Veröffentlichung im Marketplace fehlgeschlagen", "common.publishUpdate": "Update veröffentlichen", "common.published": "Veröffentlicht", "common.publishedAt": "Veröffentlicht am", + "common.publishedBy": "Veröffentlicht {{time}} von {{author}}", "common.publishingToMarketplace": "Wird veröffentlicht...", "common.redo": "Wiederholen", "common.restart": "Neustarten", @@ -251,6 +259,7 @@ "common.runApp": "App ausführen", "common.runHistory": "Ausführungsverlauf", "common.running": "Wird ausgeführt", + "common.savedAt": "Gespeichert {{time}}", "common.scheduleTriggerRunFailed": "Geplanter Trigger-Lauf fehlgeschlagen", "common.searchVar": "Variable suchen", "common.setVarValuePlaceholder": "Variable setzen", @@ -265,6 +274,8 @@ "common.tagBound": "Anzahl der Apps, die dieses Tag verwenden", "common.undo": "Rückgängig", "common.unpublished": "Unveröffentlicht", + "common.unpublishedChanges": "Unveröffentlichte Änderungen", + "common.upToDate": "Aktuell", "common.update": "Aktualisieren", "common.variableNamePlaceholder": "Variablenname", "common.versionHistory": "Versionsverlauf", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook-Debugging fehlgeschlagen", "common.webhookDebugRequestFailed": "Webhook-Debugging-Anfrage fehlgeschlagen", "common.workflowAsTool": "Workflow als Tool", + "common.workflowAsToolDescription": "Als Tool in anderen Apps verwenden", "common.workflowAsToolDisabledHint": "Veröffentlichen Sie den neuesten Workflow und stellen Sie sicher, dass ein verbundener User-Input-Knoten vorhanden ist, bevor Sie ihn als Werkzeug konfigurieren.", + "common.workflowAsToolReady": "Bereit", + "common.workflowAsToolReconfigure": "Neu konfigurieren", "common.workflowAsToolTip": "Nach dem Workflow-Update ist eine Neukonfiguration des Tools erforderlich.", + "common.workflowAsToolUpdateNeeded": "Aktualisierung erforderlich", "common.workflowProcess": "Arbeitsablauf", "common.workflowProcessFailed": "Arbeitsablauf fehlgeschlagen", "common.workflowProcessPaused": "Arbeitsablauf pausiert", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Keine verfügbaren Variablen", "variableReference.noVarsForOperation": "Es stehen keine Variablen für die Zuweisung mit der ausgewählten Operation zur Verfügung.", "versionHistory.action.copyIdSuccess": "ID in die Zwischenablage kopiert", + "versionHistory.action.deleteDeployedVersionError": "Diese Workflow-Version ist in einer oder mehreren Umgebungen bereitgestellt oder wird gerade bereitgestellt. Heben Sie die Bereitstellung in allen Umgebungen auf, bevor Sie die Version löschen.", "versionHistory.action.deleteFailure": "Version löschen fehlgeschlagen", "versionHistory.action.deleteSuccess": "Version gelöscht", "versionHistory.action.restoreFailure": "Wiederherstellung der Version fehlgeschlagen", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Nur dein", "versionHistory.filter.reset": "Filter zurücksetzen", "versionHistory.latest": "Neueste", + "versionHistory.nameIt": "Benennen", "versionHistory.nameThisVersion": "Nennen Sie diese Version", "versionHistory.releaseNotesPlaceholder": "Beschreibe, was sich geändert hat.", "versionHistory.restorationTip": "Nach der Wiederherstellung der Version wird der aktuelle Entwurf überschrieben.", diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index 8e39005df11..1d426be74f4 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -8,12 +8,12 @@ "agentDetail.access.serviceApi.actions.apiKey": "API Key", "agentDetail.access.serviceApi.actions.apiReference": "API Reference", "agentDetail.access.serviceApi.endpoint": "Service API Endpoint", - "agentDetail.access.serviceApi.title": "Backend service API", + "agentDetail.access.serviceApi.title": "Backend Service API", "agentDetail.access.status.inService": "In service", "agentDetail.access.status.outOfService": "Out of service", "agentDetail.access.title": "Access Point", "agentDetail.access.toggleSurface": "Toggle {{name}} access", - "agentDetail.access.webApp.accessUrl": "Access URL", + "agentDetail.access.webApp.accessUrl": "Web App URL", "agentDetail.access.webApp.actions.accessControl": "Access Control", "agentDetail.access.webApp.actions.customize": "Custom Frontend", "agentDetail.access.webApp.actions.embedded": "Embedded", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Refresh access URL", "agentDetail.access.webApp.showQrCode": "Show QR code", "agentDetail.access.webApp.ssoEnabled": "SSO Enabled", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Web App", "agentDetail.access.workflow.description": "Used as a node inside the workflow", "agentDetail.access.workflow.empty": "No workflow references yet.", "agentDetail.access.workflow.loadFailed": "Failed to load workflow references.", diff --git a/web/i18n/en-US/app-api.json b/web/i18n/en-US/app-api.json index 6ca527eae5e..a0b45f668ed 100644 --- a/web/i18n/en-US/app-api.json +++ b/web/i18n/en-US/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Delete this secret key?", "actionMsg.ok": "OK", "apiKey": "API Key", - "apiKeyModal.apiSecretKey": "API Secret key", + "apiKeyModal.apiSecretKey": "API Key", "apiKeyModal.apiSecretKeyTips": "To prevent API abuse, protect your API Key. Avoid using it as plain text in front-end code. :)", "apiKeyModal.createNewSecretKey": "Create new Secret key", "apiKeyModal.created": "CREATED", diff --git a/web/i18n/en-US/app-overview.json b/web/i18n/en-US/app-overview.json index d782100b51b..ba9cbc53cc9 100644 --- a/web/i18n/en-US/app-overview.json +++ b/web/i18n/en-US/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Go to setup model provider", "apiKeyInfo.tryCloud": "Or try the cloud version of Dify with free quote", "apiKeyInfo.usedToken": "Used token", - "overview.apiInfo.accessibleAddress": "Service API Endpoint", + "overview.apiInfo.accessibleAddress": "API Endpoint", "overview.apiInfo.doc": "API Reference", "overview.apiInfo.explanation": "Easily integrated into your application", "overview.apiInfo.title": "Backend Service API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Scan To Share", "overview.appInfo.qrcode.title": "Link QR Code", "overview.appInfo.regenerate": "Regenerate", - "overview.appInfo.regenerateNotice": "Do you want to regenerate the public URL?", + "overview.appInfo.regenerateNotice": "Do you want to regenerate the public URL? The current URL will stop working immediately.", "overview.appInfo.settings.chatColorTheme": "Chat color theme", "overview.appInfo.settings.chatColorThemeDesc": "Set the color theme of the chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Inverted", @@ -95,15 +95,16 @@ "overview.appInfo.settings.more.privacyPolicy": "Privacy Policy", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Enter the privacy policy link", "overview.appInfo.settings.more.privacyPolicyTip": "Helps visitors understand the data the application collects, see Dify's Privacy Policy.", + "overview.appInfo.settings.multiEnvironmentNotice": "Web app settings apply to all environments.", "overview.appInfo.settings.sso.description": "All users are required to login with SSO before using web app", "overview.appInfo.settings.sso.label": "SSO Enforcement", "overview.appInfo.settings.sso.title": "web app SSO", "overview.appInfo.settings.sso.tooltip": "Contact the administrator to enable web app SSO", "overview.appInfo.settings.title": "Branding", - "overview.appInfo.settings.webDesc": "web app Description", + "overview.appInfo.settings.webDesc": "Web App Description", "overview.appInfo.settings.webDescPlaceholder": "Enter the description of the web app", "overview.appInfo.settings.webDescTip": "This text will be displayed on the client side, providing basic guidance on how to use the application", - "overview.appInfo.settings.webName": "web app Name", + "overview.appInfo.settings.webName": "Web App Name", "overview.appInfo.settings.workflow.hide": "Hide", "overview.appInfo.settings.workflow.show": "Show", "overview.appInfo.settings.workflow.showDesc": "Show or hide workflow details in web app", @@ -117,10 +118,10 @@ "overview.status.running": "In Service", "overview.title": "Overview", "overview.triggerInfo.explanation": "Workflow trigger management", - "overview.triggerInfo.learnAboutTriggers": "Learn about Triggers", + "overview.triggerInfo.learnAboutTriggers": "Learn more about triggers", "overview.triggerInfo.noTriggerAdded": "No trigger added", "overview.triggerInfo.title": "Triggers", - "overview.triggerInfo.triggerStatusDescription": "Trigger node status appears here. (May already exist in draft, takes effect after publishing)", + "overview.triggerInfo.triggerStatusDescription": "Triggers appear here after you publish the version that contains them.", "overview.triggerInfo.triggersAdded": "{{count}} Triggers added", "welcome.enterKeyTip": "enter your OpenAI API Key below", "welcome.firstStepTip": "To get started,", diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index dd2510038f8..77b5e37dd28 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Custom Endpoints provide centralized API management, simplifying configuration for easy use across Dify's applications.", "apiBasedExtension.type": "Type", "apiBasedExtensionPage.description": "Extend Dify's module capabilities via API — add custom content moderation or external data tools to your apps.", + "appMenus.accessPoint": "Access Point", "appMenus.annotations": "Annotations", "appMenus.apiAccess": "API Access", "appMenus.apiAccessTip": "This knowledge base is accessible via the Service API", + "appMenus.deploy": "Deploy", "appMenus.logs": "Logs", "appMenus.overview": "Monitoring", "appMenus.promptEng": "Orchestrate", @@ -468,7 +470,7 @@ "operation.imageCopied": "Image copied", "operation.imageDownloaded": "Image downloaded", "operation.in": "in", - "operation.learnMore": "Learn More", + "operation.learnMore": "Learn more", "operation.log": "Log", "operation.more": "More", "operation.moreActions": "More actions", diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index b2579fdeaaa..30478b1d8db 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Running (sync pending)", "deployTab.status.runningWithFailed": "Running (last deployment failed)", "deployTab.undeploy": "Undeploy", - "deployTab.undeployConfirmDesc": "End-user access will stop immediately. The release can be redeployed later.", - "deployTab.undeployConfirmTitle": "Undeploy from {{name}}?", + "deployTab.undeployConfirmDesc": "The environment endpoint will become unavailable, but the version will not be deleted.", + "deployTab.undeployConfirmTitle": "Undeploy {{name}}?", + "deployTab.undeployConfirmWarning": "After confirmation, this environment will enter the undeploying state and actions will be temporarily disabled.", "deployTab.viewError": "View error", "detail.mobileTabs": "Deployment sections", "detail.notFound": "Deployment not found", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Not deployed", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Undeploying", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Unknown", + "studio.accessPoint.addTriggerNodeDescription": "Add a trigger node to start this app from external events.", + "studio.accessPoint.apiDescription": "Call the app from your services over HTTP", + "studio.accessPoint.description": "All the ways this app can be reached in Built-in and every deployed environment.", + "studio.accessPoint.embedIntoSite": "Embed Into Site", + "studio.accessPoint.goToPublish": "Go to publish", + "studio.accessPoint.mcpDescription": "Expose the app as a tool for agents and AI apps", + "studio.accessPoint.noPublishedDescription": "Publish your workflow to make it reachable through the web app, API, MCP and triggers.", + "studio.accessPoint.noPublishedTitle": "No published versions yet", + "studio.accessPoint.noTriggerNodes": "No trigger nodes in this workflow", + "studio.accessPoint.notSupported": "Not supported", + "studio.accessPoint.open": "Open", + "studio.accessPoint.triggerDescription": "Run the workflow on schedules or external events", + "studio.accessPoint.triggerDisconnected": "Disconnected", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} enabled", + "studio.accessPoint.triggerExclusiveNotice": "Web app, API, and MCP are turned off while the workflow uses triggers.", + "studio.accessPoint.triggerMuted": "Muted", + "studio.accessPoint.triggerServiceModeUnavailable": "Unavailable while Web app, API or MCP is in service.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Available in the Built-in environment only.", + "studio.accessPoint.webAppDescription": "Share the app with your end users as a web page", + "studio.accessPoints": "Access points", + "studio.activity.deploy": "Deploy {{target}}", + "studio.activity.deployFailed": "Deploy {{target}} failed", + "studio.activity.deploySucceeded": "Deploy {{target}} succeeded", + "studio.activity.meta": "by {{name}} · {{time}}", + "studio.allVersions": "All versions", + "studio.builtInDescription": "Always runs the latest published version", + "studio.builtInTitle": "Built-in Environment", + "studio.changeVersion": "Change version", + "studio.chooseVersionToDeploy": "Choose a version to deploy", + "studio.current": "Current", + "studio.deployAnotherVersion": "Deploy another version", + "studio.deployConfiguration": "Deploy configuration", + "studio.deployLatest": "Deploy latest", + "studio.description": "Manage this app's deployments across environments. More environments can be created in the enterprise Dashboard.", + "studio.emptyDescription": "Deploy a published version to an environment, and it will show up here.", + "studio.environmentVariablesDescription": "Use the value from the version you're deploying, keep the last deployed value, or enter a custom one.", + "studio.environments": "Environments", + "studio.environmentsInUse": "{{used}} of {{total}} environments in use", + "studio.lastActivity": "Last activity", + "studio.liveVersion": "Live version", + "studio.moreEnvironments": "More environments", + "studio.noCredentialsYet": "No credentials yet", + "studio.noCredentialsYetDescription": "Add credentials in Integrations or node settings", + "studio.precheck.description": "It contains node types that are not yet supported:", + "studio.precheck.supportMessage": "Support for these node types is coming in a future release.", + "studio.precheck.title": "This version can't be deployed to this environment", + "studio.publisher.deployingVersion": "Deploying: {{version}}", + "studio.publisher.noPublishedDescription": "Publish your draft to deploy it here.", + "studio.publisher.notDeployedYet": "Not deployed yet", + "studio.retryVersion": "Retry {{version}}", + "studio.undeployConfirmDesc": "The app will stop running in this environment, and all of its access points will become unavailable. The deployed version won't be deleted.", + "studio.undeployConfirmTitle": "Undeploy {{versionName}} from {{envName}}", + "studio.updatedAtBy": "Updated at {{time}} by {{name}}", + "studio.versionValue": "Version value", + "studio.versionsBehind_one": "{{count}} version behind", + "studio.versionsBehind_other": "{{count}} versions behind", "tabs.access.description": "Manage access channels and access permissions.", "tabs.access.name": "Access", "tabs.api-tokens.description": "Manage API Tokens and invoke instances over HTTP.", diff --git a/web/i18n/en-US/permission-keys.json b/web/i18n/en-US/permission-keys.json index c42c65f6b5e..52e657e5e6c 100644 --- a/web/i18n/en-US/permission-keys.json +++ b/web/i18n/en-US/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Configure app access permissions", "app.acl.access_config": "Configure app access permissions", "app.acl.delete": "Delete app", + "app.acl.deploy": "Deploy app", "app.acl.edit": "Edit and orchestrate app", "app.acl.import_export_dsl": "Import / export DSL", "app.acl.log_and_annotation": "Access logs and annotations", diff --git a/web/i18n/en-US/tools.json b/web/i18n/en-US/tools.json index e91e21a3731..d03a74b9209 100644 --- a/web/i18n/en-US/tools.json +++ b/web/i18n/en-US/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Parameter purpose and constraints", "mcp.server.modal.parametersTip": "Add descriptions for each parameter to help the LLM understand their purpose and constraints.", "mcp.server.publishTip": "App not published. Please publish the app first.", - "mcp.server.reGen": "Do you want to regenerator server URL?", + "mcp.server.reGen": "Do you want to regenerate the server URL? The current URL will stop working immediately.", "mcp.server.title": "MCP Server", "mcp.server.url": "Server URL", "mcp.toolItem.noDescription": "No description", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 59ce61a7e88..2f8b3152d8e 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "You can now create file type variables in the start form. We will no longer support the image upload feature in the future. ", "common.accessAPIReference": "Access API Reference", + "common.accessPointDescription": "Configure web app, API, MCP, and triggers", "common.addBlock": "Add Node", "common.addDescription": "Add description...", "common.addFailureBranch": "Add Fail Branch", @@ -161,7 +162,7 @@ "common.clipboardVersionCompatibilityWarning": "This content was copied from a different Dify app version. Some parts may be incompatible.", "common.commentMode": "Comment Mode", "common.configure": "Configure", - "common.configureRequired": "Configure Required", + "common.configureRequired": "Needs Setup", "common.conversationLog": "Conversation Log", "common.copy": "Copy", "common.currentDraft": "Current Draft", @@ -169,6 +170,7 @@ "common.currentView": "Current View", "common.currentWorkflow": "Current Workflow", "common.debugAndPreview": "Preview", + "common.deployDescription": "Push versions to environments", "common.disconnect": "Disconnect", "common.draftSaveFailed": "Draft save failed", "common.duplicate": "Duplicate", @@ -217,11 +219,15 @@ "common.needConnectTip": "This step is not connected to anything", "common.needOutputNode": "The Output node must be added", "common.needStartNode": "At least one start node must be added", + "common.noChanges": "No changes", "common.noHistory": "No History", "common.noVar": "No variable", + "common.notPublishedYet": "Not published yet", "common.notRunning": "Not running yet", "common.onFailure": "On Failure", "common.openInExplore": "Open in Explore", + "common.openWebApp": "Open web app", + "common.openWebAppDescription": "Open the published app in a new tab", "common.output": "Output", "common.overwriteAndImport": "Overwrite and Import", "common.parallel": "PARALLEL", @@ -238,10 +244,12 @@ "common.processData": "Process Data", "common.publish": "Publish", "common.publishToMarketplace": "Publish to Marketplace", + "common.publishToMarketplaceDescription": "List this app as a template", "common.publishToMarketplaceFailed": "Failed to publish to Marketplace", "common.publishUpdate": "Publish Update", "common.published": "Published", "common.publishedAt": "Published", + "common.publishedBy": "Published {{time}} by {{author}}", "common.publishingToMarketplace": "Publishing...", "common.redo": "Redo", "common.restart": "Restart", @@ -251,6 +259,7 @@ "common.runApp": "Run App", "common.runHistory": "Run History", "common.running": "Running", + "common.savedAt": "saved {{time}}", "common.scheduleTriggerRunFailed": "Schedule trigger run failed", "common.searchVar": "Search variable", "common.setVarValuePlaceholder": "Set variable", @@ -265,6 +274,8 @@ "common.tagBound": "Number of apps using this tag", "common.undo": "Undo", "common.unpublished": "Unpublished", + "common.unpublishedChanges": "Unpublished changes", + "common.upToDate": "Up to date", "common.update": "Update", "common.variableNamePlaceholder": "Variable name", "common.versionHistory": "Version History", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook debug failed", "common.webhookDebugRequestFailed": "Webhook debug request failed", "common.workflowAsTool": "Workflow as Tool", + "common.workflowAsToolDescription": "Use as a tool in other apps", "common.workflowAsToolDisabledHint": "Publish the latest workflow and ensure a connected User Input node before configuring it as a tool.", - "common.workflowAsToolTip": "Tool reconfiguration is required after the workflow update.", + "common.workflowAsToolReady": "Ready", + "common.workflowAsToolReconfigure": "Reconfigure", + "common.workflowAsToolTip": "The workflow changed. Reconfigure to apply the updates.", + "common.workflowAsToolUpdateNeeded": "Update Needed", "common.workflowProcess": "Workflow Process", "common.workflowProcessFailed": "Workflow Process failed", "common.workflowProcessPaused": "Workflow Process paused", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "No available variables", "variableReference.noVarsForOperation": "There are no variables available for assignment with the selected operation.", "versionHistory.action.copyIdSuccess": "ID copied to clipboard", + "versionHistory.action.deleteDeployedVersionError": "This workflow version is deployed, or being deployed, to one or more environments. Undeploy it from every environment before deleting the version.", "versionHistory.action.deleteFailure": "Failed to delete version", "versionHistory.action.deleteSuccess": "Version deleted", "versionHistory.action.restoreFailure": "Failed to restore version", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Only yours", "versionHistory.filter.reset": "Reset Filter", "versionHistory.latest": "Latest", + "versionHistory.nameIt": "Name it", "versionHistory.nameThisVersion": "Name this version", "versionHistory.releaseNotesPlaceholder": "Describe what changed", "versionHistory.restorationTip": "After version restoration, the current draft will be overwritten.", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index d2487da5b6d..52b917d48fa 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Fuera de servicio", "agentDetail.access.title": "Punto de acceso", "agentDetail.access.toggleSurface": "Alternar acceso de {{name}}", - "agentDetail.access.webApp.accessUrl": "URL de acceso", + "agentDetail.access.webApp.accessUrl": "URL de la aplicación web", "agentDetail.access.webApp.actions.accessControl": "Control de Acceso", "agentDetail.access.webApp.actions.customize": "Frontend personalizado", "agentDetail.access.webApp.actions.embedded": "Incrustado", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Actualizar URL de acceso", "agentDetail.access.webApp.showQrCode": "Mostrar código QR", "agentDetail.access.webApp.ssoEnabled": "SSO habilitado", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Aplicación web", "agentDetail.access.workflow.description": "Usado como un nodo dentro del flujo de trabajo", "agentDetail.access.workflow.empty": "Aún no hay referencias de flujo de trabajo.", "agentDetail.access.workflow.loadFailed": "Error al cargar las referencias del flujo de trabajo.", diff --git a/web/i18n/es-ES/app-api.json b/web/i18n/es-ES/app-api.json index 07dbfb38902..1cb3682ff97 100644 --- a/web/i18n/es-ES/app-api.json +++ b/web/i18n/es-ES/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "¿Eliminar esta clave secreta?", "actionMsg.ok": "OK", "apiKey": "Clave de API", - "apiKeyModal.apiSecretKey": "Clave secreta de API", + "apiKeyModal.apiSecretKey": "Clave de API", "apiKeyModal.apiSecretKeyTips": "Para evitar el abuso de la API, protege tu clave de API. Evita usarla como texto plano en el código del frontend. :)", "apiKeyModal.createNewSecretKey": "Crear nueva clave secreta", "apiKeyModal.created": "CREADA", diff --git a/web/i18n/es-ES/app-overview.json b/web/i18n/es-ES/app-overview.json index 7485c47e57c..9a5830b122b 100644 --- a/web/i18n/es-ES/app-overview.json +++ b/web/i18n/es-ES/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Ir a configurar proveedor de modelos", "apiKeyInfo.tryCloud": "O prueba la versión en la nube de Dify con una cotización gratuita", "apiKeyInfo.usedToken": "Token utilizados", - "overview.apiInfo.accessibleAddress": "Punto de conexión de la API del servicio", + "overview.apiInfo.accessibleAddress": "Punto final de la API", "overview.apiInfo.doc": "Referencia de la API", "overview.apiInfo.explanation": "Fácilmente integrable en tu aplicación", "overview.apiInfo.title": "API del servicio backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Escanear para compartir la aplicación", "overview.appInfo.qrcode.title": "Código QR para compartir", "overview.appInfo.regenerate": "Regenerar", - "overview.appInfo.regenerateNotice": "¿Deseas regenerar la URL pública?", + "overview.appInfo.regenerateNotice": "¿Deseas regenerar la URL pública? La URL actual dejará de funcionar inmediatamente.", "overview.appInfo.settings.chatColorTheme": "Tema de color del chat", "overview.appInfo.settings.chatColorThemeDesc": "Establece el tema de color del chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Invertido", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Política de privacidad", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Ingresa el enlace de la política de privacidad", "overview.appInfo.settings.more.privacyPolicyTip": "Ayuda a los visitantes a comprender los datos que recopila la aplicación, consulta la Política de privacidad de Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "La configuración de la aplicación web se aplica a todos los entornos.", "overview.appInfo.settings.sso.description": "Todos los usuarios deben iniciar sesión con SSO antes de usar web app", "overview.appInfo.settings.sso.label": "Autenticación SSO", "overview.appInfo.settings.sso.title": "web app SSO", @@ -117,10 +118,10 @@ "overview.status.running": "En servicio", "overview.title": "Resumen", "overview.triggerInfo.explanation": "Gestión de activadores de flujo de trabajo", - "overview.triggerInfo.learnAboutTriggers": "Aprende sobre los desencadenantes", + "overview.triggerInfo.learnAboutTriggers": "Más información sobre los desencadenantes", "overview.triggerInfo.noTriggerAdded": "No se agregó ningún disparador", "overview.triggerInfo.title": "Desencadenantes", - "overview.triggerInfo.triggerStatusDescription": "El estado del nodo de activación aparece aquí. (Puede que ya exista en el borrador, surte efecto después de publicar)", + "overview.triggerInfo.triggerStatusDescription": "Los desencadenantes aparecerán aquí después de que publiques la versión que los contiene.", "overview.triggerInfo.triggersAdded": "Se añadieron los disparadores de {{count}}", "welcome.enterKeyTip": "ingresa tu clave de API de OpenAI a continuación", "welcome.firstStepTip": "Para comenzar,", diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json index f3449ff4715..5a51f7c4158 100644 --- a/web/i18n/es-ES/common.json +++ b/web/i18n/es-ES/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Las extensiones basadas en API proporcionan una gestión centralizada de API, simplificando la configuración para su fácil uso en las aplicaciones de Dify.", "apiBasedExtension.type": "Tipo", "apiBasedExtensionPage.description": "Amplía las capacidades de los módulos de Dify mediante API: añade moderación de contenido personalizada o herramientas de datos externas a tus apps.", + "appMenus.accessPoint": "Punto de acceso", "appMenus.annotations": "Anotaciones", "appMenus.apiAccess": "Acceso API", "appMenus.apiAccessTip": "Esta base de conocimiento es accesible a través de la API de servicio", + "appMenus.deploy": "Desplegar", "appMenus.logs": "Registros", "appMenus.overview": "Monitoreo", "appMenus.promptEng": "Orquestar", diff --git a/web/i18n/es-ES/deployments.json b/web/i18n/es-ES/deployments.json index 1d36be9e0d1..01a2c2f815d 100644 --- a/web/i18n/es-ES/deployments.json +++ b/web/i18n/es-ES/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "En ejecución (sincronización pendiente)", "deployTab.status.runningWithFailed": "En ejecución (último despliegue fallido)", "deployTab.undeploy": "Retirar despliegue", - "deployTab.undeployConfirmDesc": "El acceso de los usuarios finales se detendrá inmediatamente. La versión se puede volver a desplegar más adelante.", - "deployTab.undeployConfirmTitle": "¿Retirar despliegue de {{name}}?", + "deployTab.undeployConfirmDesc": "El endpoint del entorno dejará de estar disponible, pero la versión no se eliminará.", + "deployTab.undeployConfirmTitle": "¿Retirar el despliegue de {{name}}?", + "deployTab.undeployConfirmWarning": "Después de confirmar, este entorno pasará al estado de retirada y las acciones se deshabilitarán temporalmente.", "deployTab.viewError": "Ver error", "detail.mobileTabs": "Secciones del despliegue", "detail.notFound": "Despliegue no encontrado", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "No desplegado", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Retirando despliegue", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Desconocido", + "studio.accessPoint.addTriggerNodeDescription": "Añade un nodo Trigger para iniciar esta aplicación desde eventos externos.", + "studio.accessPoint.apiDescription": "Llama a la aplicación desde tus servicios a través de HTTP", + "studio.accessPoint.description": "Todas las formas de acceder a esta aplicación en el entorno integrado y en cada entorno desplegado.", + "studio.accessPoint.embedIntoSite": "Insertar en el sitio", + "studio.accessPoint.goToPublish": "Ir a publicar", + "studio.accessPoint.mcpDescription": "Expón la aplicación como una herramienta para agentes y aplicaciones de IA", + "studio.accessPoint.noPublishedDescription": "Publica una versión para habilitar los puntos de acceso integrados.", + "studio.accessPoint.noPublishedTitle": "Aún no hay versiones publicadas", + "studio.accessPoint.noTriggerNodes": "No hay nodos Trigger en este flujo de trabajo", + "studio.accessPoint.notSupported": "No compatible", + "studio.accessPoint.open": "Abrir", + "studio.accessPoint.triggerDescription": "Ejecuta el flujo de trabajo según horarios o eventos externos", + "studio.accessPoint.triggerDisconnected": "Desconectado", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} habilitados", + "studio.accessPoint.triggerExclusiveNotice": "La aplicación web, la API y MCP se desactivan mientras el flujo de trabajo utiliza desencadenantes.", + "studio.accessPoint.triggerMuted": "Silenciado", + "studio.accessPoint.triggerServiceModeUnavailable": "No disponible mientras Web App, API o MCP esté en servicio.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Disponible solo en el entorno integrado.", + "studio.accessPoint.webAppDescription": "Comparte la aplicación con tus usuarios finales como una página web", + "studio.accessPoints": "Puntos de acceso", + "studio.activity.deploy": "Desplegar {{target}}", + "studio.activity.deployFailed": "Error al desplegar {{target}}", + "studio.activity.deploySucceeded": "{{target}} se desplegó correctamente", + "studio.activity.meta": "por {{name}} · {{time}}", + "studio.allVersions": "Todas las versiones", + "studio.builtInDescription": "Siempre ejecuta la última versión publicada", + "studio.builtInTitle": "Entorno integrado", + "studio.changeVersion": "Cambiar versión", + "studio.chooseVersionToDeploy": "Elige una versión para desplegar", + "studio.current": "Actual", + "studio.deployAnotherVersion": "Desplegar otra versión", + "studio.deployConfiguration": "Configuración del despliegue", + "studio.deployLatest": "Desplegar la última versión", + "studio.description": "Gestiona los despliegues de esta aplicación en distintos entornos. Se pueden crear más entornos en el Dashboard de Enterprise.", + "studio.emptyDescription": "Despliega una versión publicada en un entorno y aparecerá aquí.", + "studio.environmentVariablesDescription": "Usa el valor de la versión que estás desplegando, conserva el último valor desplegado o introduce uno personalizado.", + "studio.environments": "Entornos", + "studio.environmentsInUse": "{{used}} de {{total}} entornos en uso", + "studio.lastActivity": "Última actividad", + "studio.liveVersion": "Versión activa", + "studio.moreEnvironments": "Más entornos", + "studio.noCredentialsYet": "Aún no hay credenciales", + "studio.noCredentialsYetDescription": "Añade credenciales en Integraciones o en la configuración del nodo", + "studio.precheck.description": "Contiene tipos de nodos que aún no son compatibles:", + "studio.precheck.supportMessage": "La compatibilidad con estos tipos de nodos estará disponible en una versión futura.", + "studio.precheck.title": "Esta versión no se puede implementar en este entorno", + "studio.publisher.deployingVersion": "Desplegando: {{version}}", + "studio.publisher.noPublishedDescription": "Publica tu borrador para desplegarlo aquí.", + "studio.publisher.notDeployedYet": "Aún no se ha desplegado", + "studio.retryVersion": "Reintentar {{version}}", + "studio.undeployConfirmDesc": "La aplicación dejará de ejecutarse en este entorno y todos sus puntos de acceso dejarán de estar disponibles. La versión desplegada no se eliminará.", + "studio.undeployConfirmTitle": "Retirar {{versionName}} de {{envName}}", + "studio.updatedAtBy": "Actualizado el {{time}} por {{name}}", + "studio.versionValue": "Valor de la versión", + "studio.versionsBehind_one": "{{count}} versión por detrás", + "studio.versionsBehind_other": "{{count}} versiones por detrás", "tabs.access.description": "Gestiona los canales y permisos de acceso.", "tabs.access.name": "Acceso", "tabs.api-tokens.description": "Gestiona los API Tokens e invoca instancias mediante HTTP.", diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json index f3e1f4abf78..b47fd3ed44b 100644 --- a/web/i18n/es-ES/permission-keys.json +++ b/web/i18n/es-ES/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Configurar los permisos de acceso de la app", "app.acl.access_config": "Configurar los permisos de acceso de la app", "app.acl.delete": "Eliminar app", + "app.acl.deploy": "Desplegar la app", "app.acl.edit": "Editar y orquestar la app", "app.acl.import_export_dsl": "Importar / exportar DSL", "app.acl.log_and_annotation": "Acceder a logs y anotaciones", diff --git a/web/i18n/es-ES/tools.json b/web/i18n/es-ES/tools.json index c5961b25f6b..0fbc485adb6 100644 --- a/web/i18n/es-ES/tools.json +++ b/web/i18n/es-ES/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Propósito y restricciones del parámetro", "mcp.server.modal.parametersTip": "Añade descripciones de cada parámetro para ayudar al LLM a entender su propósito y restricciones.", "mcp.server.publishTip": "App no publicada. Publícala primero.", - "mcp.server.reGen": "¿Regenerar URL del servidor?", + "mcp.server.reGen": "¿Regenerar la URL del servidor? La URL actual dejará de funcionar inmediatamente.", "mcp.server.title": "Servidor MCP", "mcp.server.url": "URL del servidor", "mcp.toolItem.noDescription": "Sin descripción", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index e0c8a177553..3548d8c71ba 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Responder", "common.ImageUploadLegacyTip": "Ahora puede crear variables de tipo de archivo en el formulario de inicio. Ya no admitiremos la función de carga de imágenes en el futuro.", "common.accessAPIReference": "Acceder a la referencia de la API", + "common.accessPointDescription": "Configurar la aplicación web, la API, MCP y los activadores", "common.addBlock": "Agregar nodo", "common.addDescription": "Agregar descripción...", "common.addFailureBranch": "Agregar rama de error", @@ -169,6 +170,7 @@ "common.currentView": "Vista actual", "common.currentWorkflow": "Flujo de trabajo actual", "common.debugAndPreview": "Vista previa", + "common.deployDescription": "Implementar versiones en entornos", "common.disconnect": "Desconectar", "common.draftSaveFailed": "Error al guardar el borrador", "common.duplicate": "Duplicar", @@ -217,11 +219,15 @@ "common.needConnectTip": "Este paso no está conectado a nada", "common.needOutputNode": "Se debe agregar el nodo de Salida", "common.needStartNode": "Se debe añadir al menos un nodo de inicio", + "common.noChanges": "Sin cambios", "common.noHistory": "Sin historia", "common.noVar": "Sin variable", + "common.notPublishedYet": "Aún no publicado", "common.notRunning": "Aún no se está ejecutando", "common.onFailure": "Sobre el fracaso", "common.openInExplore": "Abrir en Explorar", + "common.openWebApp": "Abrir aplicación web", + "common.openWebAppDescription": "Abrir la aplicación publicada en una pestaña nueva", "common.output": "Salida", "common.overwriteAndImport": "Sobrescribir e importar", "common.parallel": "PARALELO", @@ -238,10 +244,12 @@ "common.processData": "Procesar datos", "common.publish": "Publicar", "common.publishToMarketplace": "Publicar en Marketplace", + "common.publishToMarketplaceDescription": "Compartir esta aplicación con la comunidad", "common.publishToMarketplaceFailed": "Error al publicar en Marketplace", "common.publishUpdate": "Publicar actualización", "common.published": "Publicado", "common.publishedAt": "Publicado el", + "common.publishedBy": "Publicado {{time}} por {{author}}", "common.publishingToMarketplace": "Publicando...", "common.redo": "Rehacer", "common.restart": "Reiniciar", @@ -251,6 +259,7 @@ "common.runApp": "Ejecutar aplicación", "common.runHistory": "Historial de ejecución", "common.running": "Ejecutando", + "common.savedAt": "Guardado {{time}}", "common.scheduleTriggerRunFailed": "Falló la ejecución del disparador programado", "common.searchVar": "Buscar variable", "common.setVarValuePlaceholder": "Establecer variable", @@ -265,6 +274,8 @@ "common.tagBound": "Número de aplicaciones que utilizan esta etiqueta", "common.undo": "Deshacer", "common.unpublished": "No publicado", + "common.unpublishedChanges": "Cambios sin publicar", + "common.upToDate": "Actualizado", "common.update": "Actualizar", "common.variableNamePlaceholder": "Nombre de la variable", "common.versionHistory": "Historial de versiones", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Falló la depuración del Webhook", "common.webhookDebugRequestFailed": "Falló la solicitud de depuración del Webhook", "common.workflowAsTool": "Flujo de trabajo como herramienta", + "common.workflowAsToolDescription": "Usarlo como herramienta en otras aplicaciones", "common.workflowAsToolDisabledHint": "Publica el flujo de trabajo más reciente y asegúrate de que haya un nodo de Entrada de Usuario conectado antes de configurarlo como una herramienta.", + "common.workflowAsToolReady": "Listo", + "common.workflowAsToolReconfigure": "Reconfigurar", "common.workflowAsToolTip": "Se requiere la reconfiguración de la herramienta después de la actualización del flujo de trabajo.", + "common.workflowAsToolUpdateNeeded": "Actualización necesaria", "common.workflowProcess": "Proceso de flujo de trabajo", "common.workflowProcessFailed": "Proceso de flujo de trabajo fallido", "common.workflowProcessPaused": "Proceso de flujo de trabajo pausado", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "No hay variables disponibles", "variableReference.noVarsForOperation": "No hay variables disponibles para la asignación con la operación seleccionada.", "versionHistory.action.copyIdSuccess": "ID copiado en el portapapeles", + "versionHistory.action.deleteDeployedVersionError": "Esta versión del flujo de trabajo está desplegada, o se está desplegando, en uno o más entornos. Retírala de todos los entornos antes de eliminarla.", "versionHistory.action.deleteFailure": "Error al eliminar la versión", "versionHistory.action.deleteSuccess": "Versión eliminada", "versionHistory.action.restoreFailure": "Error al restaurar la versión", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Solo tuyo", "versionHistory.filter.reset": "Restablecer filtro", "versionHistory.latest": "Último", + "versionHistory.nameIt": "Nombrar", "versionHistory.nameThisVersion": "Nombra esta versión", "versionHistory.releaseNotesPlaceholder": "Describe lo que cambió", "versionHistory.restorationTip": "Después de la restauración de la versión, el borrador actual será sobrescrito.", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index d2f0901aa2b..8f8112a1d49 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "خارج از سرویس", "agentDetail.access.title": "نقطه دسترسی", "agentDetail.access.toggleSurface": "تغییر دسترسی {{name}}", - "agentDetail.access.webApp.accessUrl": "URL دسترسی", + "agentDetail.access.webApp.accessUrl": "نشانی URL برنامه وب", "agentDetail.access.webApp.actions.accessControl": "کنترل دسترسی", "agentDetail.access.webApp.actions.customize": "فرانت‌اند سفارشی", "agentDetail.access.webApp.actions.embedded": "تعبیه‌شده", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "تازه‌سازی URL دسترسی", "agentDetail.access.webApp.showQrCode": "نمایش کد QR", "agentDetail.access.webApp.ssoEnabled": "SSO فعال", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "برنامه وب", "agentDetail.access.workflow.description": "به عنوان یک گره در داخل گردش کار استفاده می‌شود", "agentDetail.access.workflow.empty": "هنوز مرجع گردش کاری وجود ندارد.", "agentDetail.access.workflow.loadFailed": "بارگذاری مراجع گردش کار ناموفق بود.", diff --git a/web/i18n/fa-IR/app-api.json b/web/i18n/fa-IR/app-api.json index 35ecfc0a160..f66ac87ab9f 100644 --- a/web/i18n/fa-IR/app-api.json +++ b/web/i18n/fa-IR/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "این کلید مخفی حذف شود؟", "actionMsg.ok": "تایید", "apiKey": "کلید API", - "apiKeyModal.apiSecretKey": "کلید مخفی API", + "apiKeyModal.apiSecretKey": "کلید API", "apiKeyModal.apiSecretKeyTips": "برای جلوگیری از سوء استفاده از API، از کلید API خود محافظت کنید. از استفاده از آن به صورت متن ساده در کد فرانت‌اند خودداری کنید. :)", "apiKeyModal.createNewSecretKey": "ایجاد کلید مخفی جدید", "apiKeyModal.created": "ایجاد شده", diff --git a/web/i18n/fa-IR/app-overview.json b/web/i18n/fa-IR/app-overview.json index 8b7a5c18bc6..fe6cefbcaa9 100644 --- a/web/i18n/fa-IR/app-overview.json +++ b/web/i18n/fa-IR/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "برو به تنظیمات ارائه‌دهنده مدل", "apiKeyInfo.tryCloud": "یا نسخه ابری Dify با سهمیه رایگان را امتحان کنید", "apiKeyInfo.usedToken": "توکن‌های مصرف‌شده", - "overview.apiInfo.accessibleAddress": "نقطه پایانی سرویس API", + "overview.apiInfo.accessibleAddress": "نقطه پایانی API", "overview.apiInfo.doc": "مرجع API", "overview.apiInfo.explanation": "به راحتی در برنامه خود یکپارچه می‌شود", "overview.apiInfo.title": "API سرویس بک‌اند", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "اسکن برای اشتراک‌گذاری", "overview.appInfo.qrcode.title": "کد QR لینک", "overview.appInfo.regenerate": "تولید مجدد", - "overview.appInfo.regenerateNotice": "آیا می‌خواهید آدرس عمومی را دوباره تولید کنید؟", + "overview.appInfo.regenerateNotice": "آیا می‌خواهید آدرس عمومی را دوباره تولید کنید؟ آدرس فعلی بلافاصله از کار خواهد افتاد.", "overview.appInfo.settings.chatColorTheme": "تم رنگی چت", "overview.appInfo.settings.chatColorThemeDesc": "تم رنگی چت‌بات را تنظیم کنید", "overview.appInfo.settings.chatColorThemeInverted": "معکوس", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "سیاست حفظ حریم خصوصی", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "لینک سیاست حفظ حریم خصوصی را وارد کنید", "overview.appInfo.settings.more.privacyPolicyTip": "به بازدیدکنندگان کمک می‌کند تا بفهمند برنامه چه داده‌هایی را جمع‌آوری می‌کند، به سیاست حفظ حریم خصوصی Dify نگاه کنید Privacy Policy.", + "overview.appInfo.settings.multiEnvironmentNotice": "تنظیمات برنامه وب برای همه محیط‌ها اعمال می‌شود.", "overview.appInfo.settings.sso.description": "همه کاربران باید قبل از استفاده از web app با SSO وارد شوند", "overview.appInfo.settings.sso.label": "احراز هویت SSO", "overview.appInfo.settings.sso.title": "web app SSO", @@ -117,10 +118,10 @@ "overview.status.running": "در حال سرویس‌دهی", "overview.title": "نمای کلی", "overview.triggerInfo.explanation": "مدیریت فعال‌سازی جریان کاری", - "overview.triggerInfo.learnAboutTriggers": "با محرک‌ها آشنا شوید", + "overview.triggerInfo.learnAboutTriggers": "درباره محرک‌ها بیشتر بدانید", "overview.triggerInfo.noTriggerAdded": "هیچ ماشه‌ای افزوده نشده است", "overview.triggerInfo.title": "محرک‌ها", - "overview.triggerInfo.triggerStatusDescription": "وضعیت گره تریگر در اینجا نمایش داده می‌شود. (ممکن است قبلاً در پیش‌نویس وجود داشته باشد، پس از انتشار اعمال می‌شود)", + "overview.triggerInfo.triggerStatusDescription": "محرک‌ها پس از انتشار نسخه‌ای که شامل آن‌هاست، در اینجا ظاهر می‌شوند.", "overview.triggerInfo.triggersAdded": "{{count}} محرک‌ها اضافه شدند", "welcome.enterKeyTip": "کلید API خود را در زیر وارد کنید", "welcome.firstStepTip": "برای شروع،", diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json index eddf64c85c0..608402d41f1 100644 --- a/web/i18n/fa-IR/common.json +++ b/web/i18n/fa-IR/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "افزونه‌های مبتنی بر API مدیریت متمرکز API را فراهم می‌کنند و پیکربندی را برای استفاده آسان در برنامه‌های Dify ساده می‌کنند.", "apiBasedExtension.type": "نوع", "apiBasedExtensionPage.description": "قابلیت‌های ماژول‌های Dify را از طریق API گسترش دهید — بازبینی محتوای سفارشی یا ابزارهای داده خارجی را به برنامه‌های خود اضافه کنید.", + "appMenus.accessPoint": "نقطه دسترسی", "appMenus.annotations": "یادداشت‌ها", "appMenus.apiAccess": "دسترسی API", "appMenus.apiAccessTip": "این پایگاه دانش از طریق API سرویس قابل دسترسی است", + "appMenus.deploy": "استقرار", "appMenus.logs": "گزارش‌ها", "appMenus.overview": "نظارت", "appMenus.promptEng": "هماهنگ‌سازی", diff --git a/web/i18n/fa-IR/deployments.json b/web/i18n/fa-IR/deployments.json index fcbf7ec00fe..ca59205e42b 100644 --- a/web/i18n/fa-IR/deployments.json +++ b/web/i18n/fa-IR/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "در حال اجرا (همگام‌سازی در انتظار)", "deployTab.status.runningWithFailed": "در حال اجرا (آخرین استقرار ناموفق بود)", "deployTab.undeploy": "لغو استقرار", - "deployTab.undeployConfirmDesc": "دسترسی کاربر نهایی بلافاصله متوقف می‌شود. نسخه می‌تواند بعداً مجدداً مستقر شود.", - "deployTab.undeployConfirmTitle": "لغو استقرار از {{name}}؟", + "deployTab.undeployConfirmDesc": "نقطه پایانی محیط از دسترس خارج می‌شود، اما نسخه حذف نخواهد شد.", + "deployTab.undeployConfirmTitle": "استقرار {{name}} لغو شود؟", + "deployTab.undeployConfirmWarning": "پس از تأیید، این محیط وارد وضعیت لغو استقرار می‌شود و اقدامات به‌طور موقت غیرفعال خواهند شد.", "deployTab.viewError": "مشاهده خطا", "detail.mobileTabs": "بخش‌های استقرار", "detail.notFound": "استقرار یافت نشد", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "مستقر نشده", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "در حال لغو استقرار", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ناشناخته", + "studio.accessPoint.addTriggerNodeDescription": "برای اجرای این برنامه از رویدادهای خارجی، یک گره Trigger اضافه کنید.", + "studio.accessPoint.apiDescription": "برنامه را از سرویس‌های خود از طریق HTTP فراخوانی کنید", + "studio.accessPoint.description": "همه روش‌های دسترسی به این برنامه در محیط داخلی و هر محیط استقرار‌یافته.", + "studio.accessPoint.embedIntoSite": "جاسازی در سایت", + "studio.accessPoint.goToPublish": "رفتن به انتشار", + "studio.accessPoint.mcpDescription": "برنامه را به‌عنوان ابزاری برای عامل‌ها و برنامه‌های هوش مصنوعی ارائه کنید", + "studio.accessPoint.noPublishedDescription": "برای در دسترس قرار گرفتن نقاط دسترسی داخلی، یک نسخه منتشر کنید.", + "studio.accessPoint.noPublishedTitle": "هنوز نسخه‌ای منتشر نشده است", + "studio.accessPoint.noTriggerNodes": "هیچ گره Trigger در این گردش‌کار وجود ندارد", + "studio.accessPoint.notSupported": "پشتیبانی نمی‌شود", + "studio.accessPoint.open": "باز کردن", + "studio.accessPoint.triggerDescription": "گردش‌کار را طبق زمان‌بندی‌ها یا رویدادهای خارجی اجرا کنید", + "studio.accessPoint.triggerDisconnected": "قطع اتصال", + "studio.accessPoint.triggerEnabledCount": "{{enabled}} از {{total}} فعال", + "studio.accessPoint.triggerExclusiveNotice": "هنگامی که گردش کار از محرک‌ها استفاده می‌کند، Web app، API و MCP غیرفعال می‌شوند.", + "studio.accessPoint.triggerMuted": "بی‌صدا", + "studio.accessPoint.triggerServiceModeUnavailable": "هنگام فعال بودن Web App، API یا MCP در دسترس نیست.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "فقط در محیط داخلی در دسترس است.", + "studio.accessPoint.webAppDescription": "برنامه را به‌صورت یک صفحه وب با کاربران نهایی خود به اشتراک بگذارید", + "studio.accessPoints": "نقاط دسترسی", + "studio.activity.deploy": "استقرار {{target}}", + "studio.activity.deployFailed": "استقرار {{target}} ناموفق بود", + "studio.activity.deploySucceeded": "{{target}} با موفقیت مستقر شد", + "studio.activity.meta": "توسط {{name}} · {{time}}", + "studio.allVersions": "همه نسخه‌ها", + "studio.builtInDescription": "همیشه آخرین نسخه منتشرشده را اجرا می‌کند", + "studio.builtInTitle": "محیط داخلی", + "studio.changeVersion": "تغییر نسخه", + "studio.chooseVersionToDeploy": "نسخه‌ای را برای استقرار انتخاب کنید", + "studio.current": "فعلی", + "studio.deployAnotherVersion": "استقرار نسخه‌ای دیگر", + "studio.deployConfiguration": "پیکربندی استقرار", + "studio.deployLatest": "استقرار آخرین نسخه", + "studio.description": "استقرارهای این برنامه را در محیط‌های مختلف مدیریت کنید. محیط‌های بیشتری را می‌توان در داشبورد Enterprise ایجاد کرد.", + "studio.emptyDescription": "یک نسخهٔ منتشرشده را در یک محیط مستقر کنید تا در اینجا نمایش داده شود.", + "studio.environmentVariablesDescription": "از مقدار نسخه‌ای که مستقر می‌کنید استفاده کنید، آخرین مقدار مستقرشده را نگه دارید یا یک مقدار سفارشی وارد کنید.", + "studio.environments": "محیط‌ها", + "studio.environmentsInUse": "{{used}} از {{total}} محیط در حال استفاده", + "studio.lastActivity": "آخرین فعالیت", + "studio.liveVersion": "نسخه فعال", + "studio.moreEnvironments": "محیط‌های بیشتر", + "studio.noCredentialsYet": "هنوز اعتبارنامه‌ای وجود ندارد", + "studio.noCredentialsYetDescription": "اعتبارنامه‌ها را در بخش یکپارچه‌سازی‌ها یا تنظیمات گره اضافه کنید", + "studio.precheck.description": "این نسخه شامل انواع گره‌ای است که هنوز پشتیبانی نمی‌شوند:", + "studio.precheck.supportMessage": "پشتیبانی از این انواع گره در نسخه‌ای آینده ارائه خواهد شد.", + "studio.precheck.title": "این نسخه را نمی‌توان در این محیط مستقر کرد", + "studio.publisher.deployingVersion": "در حال استقرار: {{version}}", + "studio.publisher.noPublishedDescription": "پیش‌نویس خود را منتشر کنید تا آن را اینجا استقرار دهید.", + "studio.publisher.notDeployedYet": "هنوز مستقر نشده است", + "studio.retryVersion": "تلاش دوباره برای {{version}}", + "studio.undeployConfirmDesc": "این برنامه در این محیط متوقف می‌شود و همه نقاط دسترسی آن از دسترس خارج خواهند شد. نسخه مستقرشده حذف نخواهد شد.", + "studio.undeployConfirmTitle": "لغو استقرار {{versionName}} از {{envName}}", + "studio.updatedAtBy": "در {{time}} توسط {{name}} به‌روزرسانی شد", + "studio.versionValue": "مقدار نسخه", + "studio.versionsBehind_one": "{{count}} نسخه عقب‌تر", + "studio.versionsBehind_other": "{{count}} نسخه عقب‌تر", "tabs.access.description": "مدیریت کانال‌های دسترسی و مجوزهای دسترسی.", "tabs.access.name": "دسترسی", "tabs.api-tokens.description": "مدیریت API Token‌ها و فراخوانی نمونه‌ها از طریق HTTP.", diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json index 89792a325fb..d3d8dcdff81 100644 --- a/web/i18n/fa-IR/permission-keys.json +++ b/web/i18n/fa-IR/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "پیکربندی مجوزهای دسترسی برنامه", "app.acl.access_config": "پیکربندی مجوزهای دسترسی برنامه", "app.acl.delete": "حذف برنامه", + "app.acl.deploy": "استقرار برنامه", "app.acl.edit": "ویرایش و هماهنگ‌سازی برنامه", "app.acl.import_export_dsl": "وارد کردن / صادر کردن DSL", "app.acl.log_and_annotation": "دسترسی به گزارش‌ها و حاشیه‌نویسی‌ها", diff --git a/web/i18n/fa-IR/tools.json b/web/i18n/fa-IR/tools.json index 08bc84d68ea..f09383c28e1 100644 --- a/web/i18n/fa-IR/tools.json +++ b/web/i18n/fa-IR/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "هدف و محدودیت‌های پارامتر", "mcp.server.modal.parametersTip": "برای کمک به LLM در درک هدف و محدودیت‌ها، برای هر پارامتر توضیحات اضافه کنید.", "mcp.server.publishTip": "برنامه منتشر نشده است. لطفاً ابتدا برنامه را منتشر کنید.", - "mcp.server.reGen": "تولید مجدد آدرس سرور؟", + "mcp.server.reGen": "آدرس سرور دوباره تولید شود؟ آدرس فعلی بلافاصله از کار خواهد افتاد.", "mcp.server.title": "سرور MCP", "mcp.server.url": "آدرس سرور", "mcp.toolItem.noDescription": "بدون توضیح", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index 17a0174b620..75a8fd0e79d 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "پاسخ", "common.ImageUploadLegacyTip": "اکنون می‌توانید متغیرهای نوع فایل را در فرم شروع ایجاد کنید. پشتیبانی از ویژگی قدیمی آپلود تصویر به‌زودی متوقف خواهد شد.", "common.accessAPIReference": "دسترسی به مستندات API", + "common.accessPointDescription": "برنامه وب، API، MCP و محرک‌ها را پیکربندی کنید", "common.addBlock": "افزودن گره", "common.addDescription": "افزودن توضیحات...", "common.addFailureBranch": "افزودن شاخه شکست", @@ -169,6 +170,7 @@ "common.currentView": "نمای فعلی", "common.currentWorkflow": "گردش کار فعلی", "common.debugAndPreview": "اشکال‌زدایی و پیش‌نمایش", + "common.deployDescription": "نسخه‌ها را در محیط‌ها مستقر کنید", "common.disconnect": "قطع اتصال", "common.draftSaveFailed": "ذخیره پیش‌نویس ناموفق بود", "common.duplicate": "تکثیر", @@ -217,11 +219,15 @@ "common.needConnectTip": "این مرحله به هیچ گره‌ای متصل نیست", "common.needOutputNode": "باید گره خروجی اضافه شود", "common.needStartNode": "حداقل یک گره شروع باید اضافه شود", + "common.noChanges": "بدون تغییر", "common.noHistory": "بدون تاریخچه", "common.noVar": "بدون متغیر", + "common.notPublishedYet": "هنوز منتشر نشده", "common.notRunning": "هنوز اجرا نشده", "common.onFailure": "در صورت شکست", "common.openInExplore": "باز کردن در کاوش", + "common.openWebApp": "باز کردن برنامه وب", + "common.openWebAppDescription": "برنامه منتشرشده را در یک زبانه جدید باز کنید", "common.output": "خروجی", "common.overwriteAndImport": "بازنویسی و وارد کردن", "common.parallel": "موازی", @@ -238,10 +244,12 @@ "common.processData": "پردازش داده‌ها", "common.publish": "انتشار", "common.publishToMarketplace": "انتشار در Marketplace", + "common.publishToMarketplaceDescription": "این برنامه را با جامعه به اشتراک بگذارید", "common.publishToMarketplaceFailed": "انتشار در Marketplace ناموفق بود", "common.publishUpdate": "انتشار به‌روزرسانی", "common.published": "منتشر شده", "common.publishedAt": "منتشر شده در", + "common.publishedBy": "منتشرشده {{time}} توسط {{author}}", "common.publishingToMarketplace": "در حال انتشار...", "common.redo": "بازانجام", "common.restart": "راه‌اندازی مجدد", @@ -251,6 +259,7 @@ "common.runApp": "اجرای برنامه", "common.runHistory": "تاریخچه اجرا", "common.running": "در حال اجرا", + "common.savedAt": "ذخیره شد {{time}}", "common.scheduleTriggerRunFailed": "اجرای تریگر زمان‌بندی شده ناموفق بود", "common.searchVar": "جستجوی متغیر", "common.setVarValuePlaceholder": "تنظیم متغیر", @@ -265,6 +274,8 @@ "common.tagBound": "تعداد برنامه‌هایی که از این برچسب استفاده می‌کنند", "common.undo": "بازگردانی", "common.unpublished": "منتشر نشده", + "common.unpublishedChanges": "تغییرات منتشرنشده", + "common.upToDate": "به‌روز", "common.update": "به‌روزرسانی", "common.variableNamePlaceholder": "نام متغیر", "common.versionHistory": "تاریخچه نسخه", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "اشکال‌زدایی Webhook ناموفق بود", "common.webhookDebugRequestFailed": "درخواست اشکال‌زدایی Webhook ناموفق بود", "common.workflowAsTool": "گردش کار به عنوان ابزار", + "common.workflowAsToolDescription": "استفاده به‌عنوان ابزار در برنامه‌های دیگر", "common.workflowAsToolDisabledHint": "برای تنظیم به عنوان ابزار، ابتدا گردش کار را منتشر کنید و مطمئن شوید که گره ورودی کاربر متصل است.", + "common.workflowAsToolReady": "آماده", + "common.workflowAsToolReconfigure": "پیکربندی مجدد", "common.workflowAsToolTip": "پس از به‌روزرسانی گردش کار، پیکربندی مجدد ابزار الزامی است.", + "common.workflowAsToolUpdateNeeded": "نیاز به به‌روزرسانی", "common.workflowProcess": "فرآیند گردش کار", "common.workflowProcessFailed": "فرآیند گردش کار ناموفق بود", "common.workflowProcessPaused": "فرآیند گردش کار متوقف شده است", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "هیچ متغیری موجود نیست", "variableReference.noVarsForOperation": "هیچ متغیری برای تخصیص با عملیات انتخاب‌شده موجود نیست.", "versionHistory.action.copyIdSuccess": "شناسه کپی شد", + "versionHistory.action.deleteDeployedVersionError": "این نسخه گردش‌کار در یک یا چند محیط مستقر شده یا در حال استقرار است. پیش از حذف نسخه، استقرار آن را از همه محیط‌ها لغو کنید.", "versionHistory.action.deleteFailure": "حذف نسخه ناموفق بود", "versionHistory.action.deleteSuccess": "نسخه حذف شد", "versionHistory.action.restoreFailure": "بازیابی نسخه ناموفق بود", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "فقط نسخه‌های شما", "versionHistory.filter.reset": "بازنشانی فیلتر", "versionHistory.latest": "آخرین", + "versionHistory.nameIt": "نام‌گذاری", "versionHistory.nameThisVersion": "نام‌گذاری این نسخه", "versionHistory.releaseNotesPlaceholder": "شرح دهید چه چیزی تغییر کرده است", "versionHistory.restorationTip": "پس از بازیابی نسخه، پیش‌نویس فعلی بازنویسی خواهد شد.", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index 79775eac0d5..3ba1caf4aa6 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Hors service", "agentDetail.access.title": "Point d’accès", "agentDetail.access.toggleSurface": "Basculer l’accès de {{name}}", - "agentDetail.access.webApp.accessUrl": "URL d’accès", + "agentDetail.access.webApp.accessUrl": "URL de l’application web", "agentDetail.access.webApp.actions.accessControl": "Contrôle d'accès", "agentDetail.access.webApp.actions.customize": "Frontend personnalisé", "agentDetail.access.webApp.actions.embedded": "Intégré", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Actualiser l’URL d’accès", "agentDetail.access.webApp.showQrCode": "Afficher le code QR", "agentDetail.access.webApp.ssoEnabled": "SSO activé", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Application web", "agentDetail.access.workflow.description": "Utilisé en tant que nœud à l’intérieur du workflow", "agentDetail.access.workflow.empty": "Aucune référence de workflow pour l’instant.", "agentDetail.access.workflow.loadFailed": "Échec du chargement des références de workflow.", diff --git a/web/i18n/fr-FR/app-api.json b/web/i18n/fr-FR/app-api.json index 502dfafb039..bdb5b8aa4de 100644 --- a/web/i18n/fr-FR/app-api.json +++ b/web/i18n/fr-FR/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Supprimer cette clé secrète ?", "actionMsg.ok": "D'accord", "apiKey": "Clé API", - "apiKeyModal.apiSecretKey": "Clé secrète de l'API", + "apiKeyModal.apiSecretKey": "Clé API", "apiKeyModal.apiSecretKeyTips": "Pour prévenir l'abus de l'API, protégez votre clé API. Évitez de l'utiliser comme du texte brut dans le code front-end. :)", "apiKeyModal.createNewSecretKey": "Créer une nouvelle clé secrète", "apiKeyModal.created": "CRÉÉ", diff --git a/web/i18n/fr-FR/app-overview.json b/web/i18n/fr-FR/app-overview.json index 05d8928ef59..a194cb4c15c 100644 --- a/web/i18n/fr-FR/app-overview.json +++ b/web/i18n/fr-FR/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Aller à la configuration du fournisseur de modèle", "apiKeyInfo.tryCloud": "Ou essayez la version cloud de Dify avec un devis gratuit", "apiKeyInfo.usedToken": "Token utilisés", - "overview.apiInfo.accessibleAddress": "Point de terminaison du service API", + "overview.apiInfo.accessibleAddress": "Point de terminaison API", "overview.apiInfo.doc": "Référence de l'API", "overview.apiInfo.explanation": "Facilement intégré dans votre application", "overview.apiInfo.title": "API de service Backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Scanner et partager l'application", "overview.appInfo.qrcode.title": "QR code à partager", "overview.appInfo.regenerate": "Regénérer", - "overview.appInfo.regenerateNotice": "Voulez-vous régénérer l'URL publique ?", + "overview.appInfo.regenerateNotice": "Voulez-vous régénérer l'URL publique ? L'URL actuelle cessera immédiatement de fonctionner.", "overview.appInfo.settings.chatColorTheme": "Thème de couleur du chatbot", "overview.appInfo.settings.chatColorThemeDesc": "Définir le thème de couleur du chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Inversé", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Politique de confidentialité", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Entrez le lien de la politique de confidentialité", "overview.appInfo.settings.more.privacyPolicyTip": "Aide les visiteurs à comprendre les données collectées par l'application, voir la Politique de confidentialité de Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Les paramètres de l’application web s’appliquent à tous les environnements.", "overview.appInfo.settings.sso.description": "Tous les utilisateurs doivent se connecter avec l’authentification unique avant d’utiliser web app", "overview.appInfo.settings.sso.label": "Authentification SSO", "overview.appInfo.settings.sso.title": "web app SSO", @@ -120,7 +121,7 @@ "overview.triggerInfo.learnAboutTriggers": "En savoir plus sur les déclencheurs", "overview.triggerInfo.noTriggerAdded": "Aucun déclencheur ajouté", "overview.triggerInfo.title": "Déclencheurs", - "overview.triggerInfo.triggerStatusDescription": "L'état du nœud de déclenchement apparaît ici. (Peut déjà exister dans le brouillon, prend effet après publication)", + "overview.triggerInfo.triggerStatusDescription": "Les déclencheurs apparaissent ici après la publication de la version qui les contient.", "overview.triggerInfo.triggersAdded": "{{count}} déclencheurs ajoutés", "welcome.enterKeyTip": "saisissez votre clé API OpenAI ci-dessous", "welcome.firstStepTip": "Pour commencer,", diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json index 9abd3723445..359be32b5dd 100644 --- a/web/i18n/fr-FR/common.json +++ b/web/i18n/fr-FR/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Les extensions API fournissent une gestion centralisée des API, simplifiant la configuration pour une utilisation facile à travers les applications de Dify.", "apiBasedExtension.type": "Tapez", "apiBasedExtensionPage.description": "Étendez les capacités des modules de Dify via API — ajoutez une modération de contenu personnalisée ou des outils de données externes à vos apps.", + "appMenus.accessPoint": "Point d’accès", "appMenus.annotations": "Annotations", "appMenus.apiAccess": "Accès API", "appMenus.apiAccessTip": "Cette base de connaissances est accessible via l'API de service", + "appMenus.deploy": "Déployer", "appMenus.logs": "Journaux", "appMenus.overview": "Surveillance", "appMenus.promptEng": "Orchestrer", diff --git a/web/i18n/fr-FR/deployments.json b/web/i18n/fr-FR/deployments.json index 0d3af3d8a2c..e77869103b3 100644 --- a/web/i18n/fr-FR/deployments.json +++ b/web/i18n/fr-FR/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "En cours d'exécution (synchronisation en attente)", "deployTab.status.runningWithFailed": "En cours d'exécution (dernier déploiement en échec)", "deployTab.undeploy": "Annuler le déploiement", - "deployTab.undeployConfirmDesc": "L'accès des utilisateurs finaux s'arrêtera immédiatement. La version pourra être redéployée plus tard.", - "deployTab.undeployConfirmTitle": "Annuler le déploiement depuis {{name}} ?", + "deployTab.undeployConfirmDesc": "Le point de terminaison de l’environnement ne sera plus disponible, mais la version ne sera pas supprimée.", + "deployTab.undeployConfirmTitle": "Annuler le déploiement de {{name}} ?", + "deployTab.undeployConfirmWarning": "Après confirmation, cet environnement passera à l’état d’annulation du déploiement et les actions seront temporairement désactivées.", "deployTab.viewError": "Voir l'erreur", "detail.mobileTabs": "Sections du déploiement", "detail.notFound": "Déploiement introuvable", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Non déployé", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Annulation du déploiement", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Inconnu", + "studio.accessPoint.addTriggerNodeDescription": "Ajoutez un nœud Trigger pour démarrer cette application à partir d’événements externes.", + "studio.accessPoint.apiDescription": "Appelez l’application depuis vos services via HTTP", + "studio.accessPoint.description": "Toutes les façons d’accéder à cette application dans l’environnement intégré et dans chaque environnement déployé.", + "studio.accessPoint.embedIntoSite": "Intégrer au site", + "studio.accessPoint.goToPublish": "Publier", + "studio.accessPoint.mcpDescription": "Exposez l’application comme un outil pour les agents et les applications d’IA", + "studio.accessPoint.noPublishedDescription": "Publiez une version pour rendre les points d’accès intégrés disponibles.", + "studio.accessPoint.noPublishedTitle": "Aucune version publiée pour le moment", + "studio.accessPoint.noTriggerNodes": "Aucun nœud Trigger dans ce workflow", + "studio.accessPoint.notSupported": "Non pris en charge", + "studio.accessPoint.open": "Ouvrir", + "studio.accessPoint.triggerDescription": "Exécutez le workflow selon des planifications ou des événements externes", + "studio.accessPoint.triggerDisconnected": "Déconnecté", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} activés", + "studio.accessPoint.triggerExclusiveNotice": "L’application web, l’API et MCP sont désactivés tant que le workflow utilise des déclencheurs.", + "studio.accessPoint.triggerMuted": "En sourdine", + "studio.accessPoint.triggerServiceModeUnavailable": "Indisponible lorsque Web App, API ou MCP est en service.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Disponible uniquement dans l’environnement intégré.", + "studio.accessPoint.webAppDescription": "Partagez l’application avec vos utilisateurs finaux sous forme de page web", + "studio.accessPoints": "Points d’accès", + "studio.activity.deploy": "Déployer {{target}}", + "studio.activity.deployFailed": "Échec du déploiement de {{target}}", + "studio.activity.deploySucceeded": "{{target}} a été déployé", + "studio.activity.meta": "par {{name}} · {{time}}", + "studio.allVersions": "Toutes les versions", + "studio.builtInDescription": "Exécute toujours la dernière version publiée", + "studio.builtInTitle": "Environnement intégré", + "studio.changeVersion": "Changer de version", + "studio.chooseVersionToDeploy": "Choisissez une version à déployer", + "studio.current": "Actuelle", + "studio.deployAnotherVersion": "Déployer une autre version", + "studio.deployConfiguration": "Configuration du déploiement", + "studio.deployLatest": "Déployer la dernière version", + "studio.description": "Gérez les déploiements de cette application dans différents environnements. Vous pouvez créer d’autres environnements dans le Dashboard Enterprise.", + "studio.emptyDescription": "Déployez une version publiée dans un environnement ; elle apparaîtra ici.", + "studio.environmentVariablesDescription": "Utilisez la valeur de la version que vous déployez, conservez la dernière valeur déployée ou saisissez-en une personnalisée.", + "studio.environments": "Environnements", + "studio.environmentsInUse": "{{used}} environnements sur {{total}} utilisés", + "studio.lastActivity": "Dernière activité", + "studio.liveVersion": "Version active", + "studio.moreEnvironments": "Plus d’environnements", + "studio.noCredentialsYet": "Aucun identifiant pour le moment", + "studio.noCredentialsYetDescription": "Ajoutez des identifiants dans Intégrations ou dans les paramètres du nœud", + "studio.precheck.description": "Elle contient des types de nœuds qui ne sont pas encore pris en charge :", + "studio.precheck.supportMessage": "La prise en charge de ces types de nœuds sera disponible dans une prochaine version.", + "studio.precheck.title": "Cette version ne peut pas être déployée dans cet environnement", + "studio.publisher.deployingVersion": "Déploiement en cours : {{version}}", + "studio.publisher.noPublishedDescription": "Publiez votre brouillon pour le déployer ici.", + "studio.publisher.notDeployedYet": "Pas encore déployé", + "studio.retryVersion": "Réessayer {{version}}", + "studio.undeployConfirmDesc": "L’application cessera de fonctionner dans cet environnement et tous ses points d’accès deviendront indisponibles. La version déployée ne sera pas supprimée.", + "studio.undeployConfirmTitle": "Annuler le déploiement de {{versionName}} dans {{envName}}", + "studio.updatedAtBy": "Mis à jour le {{time}} par {{name}}", + "studio.versionValue": "Valeur de la version", + "studio.versionsBehind_one": "{{count}} version de retard", + "studio.versionsBehind_other": "{{count}} versions de retard", "tabs.access.description": "Gérez les canaux d'accès et les autorisations d'accès.", "tabs.access.name": "Accès", "tabs.api-tokens.description": "Gérez les Tokens API et invoquez les instances via HTTP.", diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json index bd6419a0834..7dbca3d7930 100644 --- a/web/i18n/fr-FR/permission-keys.json +++ b/web/i18n/fr-FR/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Configurer les autorisations d'accès à l'application", "app.acl.access_config": "Configurer les autorisations d'accès à l'application", "app.acl.delete": "Supprimer l'application", + "app.acl.deploy": "Déployer l'application", "app.acl.edit": "Modifier et orchestrer l'application", "app.acl.import_export_dsl": "Importer / exporter le DSL", "app.acl.log_and_annotation": "Accéder aux journaux et annotations", diff --git a/web/i18n/fr-FR/tools.json b/web/i18n/fr-FR/tools.json index 2deae15275d..d97b0353288 100644 --- a/web/i18n/fr-FR/tools.json +++ b/web/i18n/fr-FR/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Objectif et contraintes du paramètre", "mcp.server.modal.parametersTip": "Ajoutez des descriptions pour chaque paramètre afin d'aider le LLM à comprendre leur objectif et leurs contraintes.", "mcp.server.publishTip": "Application non publiée. Merci de publier l'application en premier.", - "mcp.server.reGen": "Voulez-vous régénérer l'URL du serveur?", + "mcp.server.reGen": "Voulez-vous régénérer l'URL du serveur ? L'URL actuelle cessera immédiatement de fonctionner.", "mcp.server.title": "Serveur MCP", "mcp.server.url": "URL du Serveur", "mcp.toolItem.noDescription": "Aucune description", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index 4d0d77e9027..4f6c4424164 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Répondre", "common.ImageUploadLegacyTip": "Vous pouvez désormais créer des variables de type de fichier dans le formulaire de démarrage. À l’avenir, nous ne prendrons plus en charge la fonctionnalité de téléchargement d’images.", "common.accessAPIReference": "Accéder à la référence API", + "common.accessPointDescription": "Configurer l’application web, l’API, MCP et les déclencheurs", "common.addBlock": "Ajouter un nœud", "common.addDescription": "Ajouter une description...", "common.addFailureBranch": "Ajouter une branche d’échec", @@ -169,6 +170,7 @@ "common.currentView": "Vue actuelle", "common.currentWorkflow": "Flux de travail actuel", "common.debugAndPreview": "Aperçu", + "common.deployDescription": "Déployer les versions dans les environnements", "common.disconnect": "Déconnecter", "common.draftSaveFailed": "Échec de l’enregistrement du brouillon", "common.duplicate": "Dupliquer", @@ -217,11 +219,15 @@ "common.needConnectTip": "Cette étape n'est connectée à rien", "common.needOutputNode": "Le nœud de sortie doit être ajouté", "common.needStartNode": "Au moins un nœud de départ doit être ajouté", + "common.noChanges": "Aucune modification", "common.noHistory": "Pas d’histoire", "common.noVar": "Pas de variable", + "common.notPublishedYet": "Pas encore publié", "common.notRunning": "Pas encore en cours d'exécution", "common.onFailure": "Sur l’échec", "common.openInExplore": "Ouvrir dans Explorer", + "common.openWebApp": "Ouvrir l’application web", + "common.openWebAppDescription": "Ouvrir l’application publiée dans un nouvel onglet", "common.output": "Sortie", "common.overwriteAndImport": "Écraser et importer", "common.parallel": "PARALLÈLE", @@ -238,10 +244,12 @@ "common.processData": "Traiter les données", "common.publish": "Publier", "common.publishToMarketplace": "Publier sur le Marketplace", + "common.publishToMarketplaceDescription": "Partager cette application avec la communauté", "common.publishToMarketplaceFailed": "Échec de la publication sur le Marketplace", "common.publishUpdate": "Publier une mise à jour", "common.published": "Publié", "common.publishedAt": "Publié le", + "common.publishedBy": "Publié {{time}} par {{author}}", "common.publishingToMarketplace": "Publication en cours...", "common.redo": "Réexécuter", "common.restart": "Redémarrer", @@ -251,6 +259,7 @@ "common.runApp": "Exécuter l'application", "common.runHistory": "Historique des exécutions", "common.running": "En cours d'exécution", + "common.savedAt": "Enregistré {{time}}", "common.scheduleTriggerRunFailed": "Échec de l'exécution du déclencheur planifié", "common.searchVar": "Rechercher une variable", "common.setVarValuePlaceholder": "Définir la valeur de la variable", @@ -265,6 +274,8 @@ "common.tagBound": "Nombre d'applications utilisant cette étiquette", "common.undo": "Défaire", "common.unpublished": "Non publié", + "common.unpublishedChanges": "Modifications non publiées", + "common.upToDate": "À jour", "common.update": "Mettre à jour", "common.variableNamePlaceholder": "Nom de la variable", "common.versionHistory": "Historique des versions", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Échec du débogage du Webhook", "common.webhookDebugRequestFailed": "Échec de la demande de débogage du Webhook", "common.workflowAsTool": "Flux de travail en tant qu'outil", + "common.workflowAsToolDescription": "L’utiliser comme outil dans d’autres applications", "common.workflowAsToolDisabledHint": "Publiez le dernier flux de travail et assurez-vous qu'un nœud d'entrée utilisateur est connecté avant de le configurer comme outil.", + "common.workflowAsToolReady": "Prêt", + "common.workflowAsToolReconfigure": "Reconfigurer", "common.workflowAsToolTip": "Reconfiguration de l'outil requise après la mise à jour du flux de travail.", + "common.workflowAsToolUpdateNeeded": "Mise à jour requise", "common.workflowProcess": "Processus de flux de travail", "common.workflowProcessFailed": "Processus de flux de travail échoué", "common.workflowProcessPaused": "Processus de flux de travail en pause", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Aucune variable disponible", "variableReference.noVarsForOperation": "Aucune variable n’est disponible pour l’affectation avec l’opération sélectionnée.", "versionHistory.action.copyIdSuccess": "ID copié dans le presse-papiers", + "versionHistory.action.deleteDeployedVersionError": "Cette version du workflow est déployée, ou en cours de déploiement, dans un ou plusieurs environnements. Annulez son déploiement dans tous les environnements avant de la supprimer.", "versionHistory.action.deleteFailure": "Échec de la suppression de la version", "versionHistory.action.deleteSuccess": "Version supprimée", "versionHistory.action.restoreFailure": "Échec de la restauration de la version", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Rien que le tien", "versionHistory.filter.reset": "Réinitialiser le filtre", "versionHistory.latest": "Dernier", + "versionHistory.nameIt": "Nommer", "versionHistory.nameThisVersion": "Nommez cette version", "versionHistory.releaseNotesPlaceholder": "Décrivez ce qui a changé", "versionHistory.restorationTip": "Après la restauration de la version, le brouillon actuel sera écrasé.", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 9a678c70859..b9b96cf6cc6 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "सेवा से बाहर", "agentDetail.access.title": "एक्सेस पॉइंट", "agentDetail.access.toggleSurface": "{{name}} एक्सेस टॉगल करें", - "agentDetail.access.webApp.accessUrl": "एक्सेस URL", + "agentDetail.access.webApp.accessUrl": "वेब ऐप URL", "agentDetail.access.webApp.actions.accessControl": "पहुँच नियंत्रण", "agentDetail.access.webApp.actions.customize": "कस्टम फ्रंटएंड", "agentDetail.access.webApp.actions.embedded": "एम्बेडेड", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "एक्सेस URL रीफ़्रेश करें", "agentDetail.access.webApp.showQrCode": "QR कोड दिखाएँ", "agentDetail.access.webApp.ssoEnabled": "SSO सक्षम", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "वेब ऐप", "agentDetail.access.workflow.description": "वर्कफ़्लो के अंदर एक नोड के रूप में उपयोग किया जाता है", "agentDetail.access.workflow.empty": "अभी तक कोई वर्कफ़्लो संदर्भ नहीं।", "agentDetail.access.workflow.loadFailed": "वर्कफ़्लो संदर्भ लोड करने में विफल।", diff --git a/web/i18n/hi-IN/app-api.json b/web/i18n/hi-IN/app-api.json index f7909f3f28d..584985b18e5 100644 --- a/web/i18n/hi-IN/app-api.json +++ b/web/i18n/hi-IN/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "इस गुप्त कुंजी को हटाएं?", "actionMsg.ok": "ठीक", "apiKey": "एपीआई कुंजी", - "apiKeyModal.apiSecretKey": "एपीआई गुप्त कुंजी", + "apiKeyModal.apiSecretKey": "एपीआई कुंजी", "apiKeyModal.apiSecretKeyTips": "एपीआई का दुरुपयोग रोकने के लिए, अपनी एपीआई कुंजी की सुरक्षा करें। फ्रंट-एंड कोड में इसे सादे पाठ के रूप में उपयोग करने से बचें। :)", "apiKeyModal.createNewSecretKey": "नई गुप्त कुंजी बनाएँ", "apiKeyModal.created": "बनाई गई", diff --git a/web/i18n/hi-IN/app-overview.json b/web/i18n/hi-IN/app-overview.json index 9cc34f09e73..dc9309ae1f4 100644 --- a/web/i18n/hi-IN/app-overview.json +++ b/web/i18n/hi-IN/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "मॉडल प्रदाता सेटअप पर जाएं", "apiKeyInfo.tryCloud": "या मुफ्त कोटा के साथ Dify का क्लाउड संस्करण आज़माएं", "apiKeyInfo.usedToken": "उपयोग किया गया टोकन", - "overview.apiInfo.accessibleAddress": "सेवा एपीआई एंडपॉइंट", + "overview.apiInfo.accessibleAddress": "एपीआई एंडपॉइंट", "overview.apiInfo.doc": "एपीआई संदर्भ", "overview.apiInfo.explanation": "आसानी से अपने एप्लिकेशन में एकीकृत", "overview.apiInfo.title": "बैकएंड सेवा एपीआई", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "एप्लिकेशन शेयर स्कैन करें", "overview.appInfo.qrcode.title": "शेयर करने के लिए क्यूआर कोड", "overview.appInfo.regenerate": "पुनः उत्पन्न करें", - "overview.appInfo.regenerateNotice": "क्या आप सार्वजनिक URL को पुनः उत्पन्न करना चाहते हैं?", + "overview.appInfo.regenerateNotice": "क्या आप सार्वजनिक URL को पुनः उत्पन्न करना चाहते हैं? वर्तमान URL तुरंत काम करना बंद कर देगा।", "overview.appInfo.settings.chatColorTheme": "चैटबॉट का रंग थीम", "overview.appInfo.settings.chatColorThemeDesc": "चैटबॉट का रंग थीम निर्धारित करें", "overview.appInfo.settings.chatColorThemeInverted": "उल्टा", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "गोपनीयता नीति", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "गोपनीयता नीति लिंक दर्ज करें", "overview.appInfo.settings.more.privacyPolicyTip": "आगंतुकों को यह समझने में मदद करता है कि एप्लिकेशन कौन सा डेटा एकत्र करता है, देखें Dify की गोपनीयता नीति।", + "overview.appInfo.settings.multiEnvironmentNotice": "वेब ऐप सेटिंग सभी परिवेशों पर लागू होती हैं।", "overview.appInfo.settings.sso.description": "web app का उपयोग करने से पहले सभी उपयोगकर्ताओं को SSO के साथ लॉगिन करना आवश्यक है", "overview.appInfo.settings.sso.label": "SSO प्रमाणीकरण", "overview.appInfo.settings.sso.title": "वेबएप एसएसओ", @@ -117,10 +118,10 @@ "overview.status.running": "सेवा में", "overview.title": "अवलोकन", "overview.triggerInfo.explanation": "वर्कफ़्लो ट्रिगर प्रबंधन", - "overview.triggerInfo.learnAboutTriggers": "ट्रिगर्स के बारे में जानें", + "overview.triggerInfo.learnAboutTriggers": "ट्रिगर्स के बारे में और जानें", "overview.triggerInfo.noTriggerAdded": "कोई ट्रिगर जोड़ा नहीं गया", "overview.triggerInfo.title": "ट्रिगर्स", - "overview.triggerInfo.triggerStatusDescription": "ट्रिगर नोड की स्थिति यहाँ दिखाई देगी। (शायद पहले से ड्राफ्ट में मौजूद है, प्रकाशित करने के बाद प्रभावी होगी)", + "overview.triggerInfo.triggerStatusDescription": "जिस संस्करण में ट्रिगर शामिल हैं, उसे प्रकाशित करने के बाद वे यहाँ दिखाई देंगे।", "overview.triggerInfo.triggersAdded": "{{count}} ट्रिगर्स जोड़े गए", "welcome.enterKeyTip": "नीचे अपना OpenAI API कुंजी दर्ज करें", "welcome.firstStepTip": "शुरू करने के लिए,", diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json index 0e94bedb305..0637fa5fd73 100644 --- a/web/i18n/hi-IN/common.json +++ b/web/i18n/hi-IN/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "एपीआई एक्सटेंशन केंद्रीकृत एपीआई प्रबंधन प्रदान करते हैं, जो Dify के अनुप्रयोगों में आसान उपयोग के लिए कॉन्फ़िगरेशन को सरल बनाते हैं।", "apiBasedExtension.type": "प्रकार", "apiBasedExtensionPage.description": "API के ज़रिये Dify की मॉड्यूल क्षमताओं को बढ़ाएँ — अपने ऐप्स में कस्टम कंटेंट मॉडरेशन या बाहरी डेटा टूल जोड़ें।", + "appMenus.accessPoint": "एक्सेस पॉइंट", "appMenus.annotations": "एनोटेशन", "appMenus.apiAccess": "API एक्सेस", "appMenus.apiAccessTip": "यह ज्ञान आधार सेवा API के माध्यम से सुलभ है", + "appMenus.deploy": "डिप्लॉय करें", "appMenus.logs": "लॉग्स", "appMenus.overview": "निगरानी", "appMenus.promptEng": "समन्वय करें", diff --git a/web/i18n/hi-IN/deployments.json b/web/i18n/hi-IN/deployments.json index 3fa601df2d5..1a8d43fda14 100644 --- a/web/i18n/hi-IN/deployments.json +++ b/web/i18n/hi-IN/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "चल रहा है (सिंक लंबित)", "deployTab.status.runningWithFailed": "चल रहा है (अंतिम डिप्लॉयमेंट विफल)", "deployTab.undeploy": "अनडिप्लॉय करें", - "deployTab.undeployConfirmDesc": "अंतिम-उपयोगकर्ता एक्सेस तुरंत रुक जाएगा। रिलीज़ को बाद में पुनः डिप्लॉय किया जा सकता है।", - "deployTab.undeployConfirmTitle": "{{name}} से अनडिप्लॉय करें?", + "deployTab.undeployConfirmDesc": "एनवायरनमेंट एंडपॉइंट अनुपलब्ध हो जाएगा, लेकिन वर्शन हटाया नहीं जाएगा।", + "deployTab.undeployConfirmTitle": "{{name}} को अनडिप्लॉय करें?", + "deployTab.undeployConfirmWarning": "पुष्टि के बाद, यह एनवायरनमेंट अनडिप्लॉयिंग स्थिति में चला जाएगा और कार्रवाइयां अस्थायी रूप से अक्षम रहेंगी।", "deployTab.viewError": "त्रुटि देखें", "detail.mobileTabs": "डिप्लॉयमेंट अनुभाग", "detail.notFound": "डिप्लॉयमेंट नहीं मिला", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "डिप्लॉय नहीं किया गया", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "अनडिप्लॉय हो रहा है", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "अज्ञात", + "studio.accessPoint.addTriggerNodeDescription": "बाहरी इवेंट से इस ऐप को शुरू करने के लिए Trigger नोड जोड़ें।", + "studio.accessPoint.apiDescription": "HTTP के ज़रिए अपनी सेवाओं से ऐप को कॉल करें", + "studio.accessPoint.description": "बिल्ट-इन और हर डिप्लॉय किए गए एनवायरनमेंट में इस ऐप तक पहुँचने के सभी तरीके।", + "studio.accessPoint.embedIntoSite": "साइट में एम्बेड करें", + "studio.accessPoint.goToPublish": "प्रकाशित करने जाएँ", + "studio.accessPoint.mcpDescription": "एजेंट और AI ऐप्स के लिए ऐप को एक टूल के रूप में उपलब्ध कराएँ", + "studio.accessPoint.noPublishedDescription": "बिल्ट-इन एक्सेस पॉइंट उपलब्ध कराने के लिए एक संस्करण प्रकाशित करें।", + "studio.accessPoint.noPublishedTitle": "अभी तक कोई संस्करण प्रकाशित नहीं हुआ", + "studio.accessPoint.noTriggerNodes": "इस वर्कफ़्लो में कोई Trigger नोड नहीं है", + "studio.accessPoint.notSupported": "समर्थित नहीं", + "studio.accessPoint.open": "खोलें", + "studio.accessPoint.triggerDescription": "शेड्यूल या बाहरी इवेंट पर वर्कफ़्लो चलाएँ", + "studio.accessPoint.triggerDisconnected": "डिस्कनेक्टेड", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} सक्षम", + "studio.accessPoint.triggerExclusiveNotice": "वर्कफ़्लो में ट्रिगर का इस्तेमाल होने पर Web app, API और MCP बंद रहते हैं।", + "studio.accessPoint.triggerMuted": "म्यूट", + "studio.accessPoint.triggerServiceModeUnavailable": "Web App, API या MCP चालू होने पर उपलब्ध नहीं।", + "studio.accessPoint.unsupportedInDeployedEnvironment": "केवल बिल्ट-इन एनवायरनमेंट में उपलब्ध।", + "studio.accessPoint.webAppDescription": "ऐप को अपने अंतिम उपयोगकर्ताओं के साथ वेब पेज के रूप में साझा करें", + "studio.accessPoints": "एक्सेस पॉइंट", + "studio.activity.deploy": "{{target}} डिप्लॉय करें", + "studio.activity.deployFailed": "{{target}} डिप्लॉय नहीं हो सका", + "studio.activity.deploySucceeded": "{{target}} सफलतापूर्वक डिप्लॉय हुआ", + "studio.activity.meta": "{{name}} द्वारा · {{time}}", + "studio.allVersions": "सभी वर्शन", + "studio.builtInDescription": "हमेशा नवीनतम प्रकाशित वर्शन चलाता है", + "studio.builtInTitle": "बिल्ट-इन एनवायरनमेंट", + "studio.changeVersion": "वर्शन बदलें", + "studio.chooseVersionToDeploy": "डिप्लॉय करने के लिए कोई वर्शन चुनें", + "studio.current": "वर्तमान", + "studio.deployAnotherVersion": "दूसरा वर्शन डिप्लॉय करें", + "studio.deployConfiguration": "डिप्लॉयमेंट कॉन्फ़िगरेशन", + "studio.deployLatest": "नवीनतम वर्शन डिप्लॉय करें", + "studio.description": "अलग-अलग एनवायरनमेंट में इस ऐप के डिप्लॉयमेंट मैनेज करें। Enterprise Dashboard में और एनवायरनमेंट बनाए जा सकते हैं।", + "studio.emptyDescription": "किसी प्रकाशित वर्शन को एक एनवायरनमेंट में डिप्लॉय करें, फिर वह यहां दिखाई देगा।", + "studio.environmentVariablesDescription": "जिस वर्शन को आप डिप्लॉय कर रहे हैं उसका मान इस्तेमाल करें, पिछली बार डिप्लॉय किया गया मान बनाए रखें या कोई कस्टम मान डालें।", + "studio.environments": "एनवायरनमेंट", + "studio.environmentsInUse": "{{total}} में से {{used}} एनवायरनमेंट उपयोग में", + "studio.lastActivity": "पिछली गतिविधि", + "studio.liveVersion": "लाइव वर्शन", + "studio.moreEnvironments": "अधिक एनवायरनमेंट", + "studio.noCredentialsYet": "अभी तक कोई क्रेडेंशियल नहीं", + "studio.noCredentialsYetDescription": "इंटीग्रेशन या नोड सेटिंग में क्रेडेंशियल जोड़ें", + "studio.precheck.description": "इसमें ऐसे नोड प्रकार हैं जो अभी समर्थित नहीं हैं:", + "studio.precheck.supportMessage": "इन नोड प्रकारों के लिए समर्थन भविष्य के रिलीज़ में उपलब्ध होगा।", + "studio.precheck.title": "इस संस्करण को इस परिवेश में डिप्लॉय नहीं किया जा सकता", + "studio.publisher.deployingVersion": "डिप्लॉय हो रहा है: {{version}}", + "studio.publisher.noPublishedDescription": "अपने ड्राफ़्ट को यहाँ डिप्लॉय करने के लिए प्रकाशित करें।", + "studio.publisher.notDeployedYet": "अभी तक डिप्लॉय नहीं किया गया", + "studio.retryVersion": "{{version}} फिर से आज़माएँ", + "studio.undeployConfirmDesc": "ऐप इस एनवायरनमेंट में चलना बंद कर देगा और इसके सभी एक्सेस पॉइंट उपलब्ध नहीं रहेंगे। डिप्लॉय किया गया वर्शन हटाया नहीं जाएगा।", + "studio.undeployConfirmTitle": "{{envName}} से {{versionName}} को अनडिप्लॉय करें", + "studio.updatedAtBy": "{{name}} ने {{time}} पर अपडेट किया", + "studio.versionValue": "वर्शन का मान", + "studio.versionsBehind_one": "{{count}} वर्शन पीछे", + "studio.versionsBehind_other": "{{count}} वर्शन पीछे", "tabs.access.description": "एक्सेस चैनल और एक्सेस अनुमतियाँ प्रबंधित करें।", "tabs.access.name": "एक्सेस", "tabs.api-tokens.description": "API टोकन प्रबंधित करें और HTTP पर इंस्टेंस इनवोक करें।", diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json index 19e879d4491..7bb708c6f93 100644 --- a/web/i18n/hi-IN/permission-keys.json +++ b/web/i18n/hi-IN/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", "app.acl.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", "app.acl.delete": "ऐप हटाएं", + "app.acl.deploy": "ऐप डिप्लॉय करें", "app.acl.edit": "ऐप संपादित और ऑर्केस्ट्रेट करें", "app.acl.import_export_dsl": "DSL आयात / निर्यात करें", "app.acl.log_and_annotation": "लॉग और एनोटेशन तक पहुंच", diff --git a/web/i18n/hi-IN/tools.json b/web/i18n/hi-IN/tools.json index a5ceca37b97..045f9eb2653 100644 --- a/web/i18n/hi-IN/tools.json +++ b/web/i18n/hi-IN/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "पैरामीटर उद्देश्य और बाधाएँ", "mcp.server.modal.parametersTip": "प्रत्येक पैरामीटर के लिए विवरण जोड़ें ताकि LLM को उनके उद्देश्य और बाधाओं को समझने में मदद मिले।", "mcp.server.publishTip": "ऐप प्रकाशित नहीं हुआ। कृपया पहले ऐप प्रकाशित करें।", - "mcp.server.reGen": "सर्वर URL पुनः उत्पन्न करना चाहते हैं?", + "mcp.server.reGen": "क्या आप सर्वर URL पुनः उत्पन्न करना चाहते हैं? वर्तमान URL तुरंत काम करना बंद कर देगा।", "mcp.server.title": "MCP सर्वर", "mcp.server.url": "सर्वर URL", "mcp.toolItem.noDescription": "कोई वर्णन नहीं", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index 9a628515368..8286f325543 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "जवाब दें", "common.ImageUploadLegacyTip": "अब आप प्रारंभ प्रपत्र में फ़ाइल प्रकार चर बना सकते हैं। हम अब भविष्य में छवि अपलोड सुविधा का समर्थन नहीं करेंगे।", "common.accessAPIReference": "एपीआई संदर्भ तक पहुंचें", + "common.accessPointDescription": "वेब ऐप, API, MCP और ट्रिगर कॉन्फ़िगर करें", "common.addBlock": "नोड जोड़ें", "common.addDescription": "विवरण जोड़ें...", "common.addFailureBranch": "असफल शाखा जोड़ें", @@ -169,6 +170,7 @@ "common.currentView": "वर्तमान दृश्य", "common.currentWorkflow": "वर्तमान कार्यप्रवाह", "common.debugAndPreview": "पूर्वावलोकन", + "common.deployDescription": "वर्ज़न को एनवायरनमेंट में डिप्लॉय करें", "common.disconnect": "अलग करना", "common.draftSaveFailed": "ड्राफ़्ट सहेजना विफल रहा", "common.duplicate": "डुप्लिकेट करें", @@ -217,11 +219,15 @@ "common.needConnectTip": "यह चरण किसी से जुड़ा नहीं है", "common.needOutputNode": "आउटपुट नोड जोड़ा जाना चाहिए", "common.needStartNode": "कम से कम एक प्रारंभ नोड जोड़ा जाना चाहिए", + "common.noChanges": "कोई बदलाव नहीं", "common.noHistory": "कोई इतिहास नहीं", "common.noVar": "कोई वेरिएबल नहीं", + "common.notPublishedYet": "अभी तक प्रकाशित नहीं", "common.notRunning": "अभी तक नहीं चल रहा", "common.onFailure": "असफलता पर", "common.openInExplore": "एक्सप्लोर में खोलें", + "common.openWebApp": "वेब ऐप खोलें", + "common.openWebAppDescription": "प्रकाशित ऐप को नए टैब में खोलें", "common.output": "आउटपुट", "common.overwriteAndImport": "अधिलेखित और आयात", "common.parallel": "समानांतर", @@ -238,10 +244,12 @@ "common.processData": "डेटा प्रोसेस करें", "common.publish": "प्रकाशित करें", "common.publishToMarketplace": "Marketplace पर प्रकाशित करें", + "common.publishToMarketplaceDescription": "इस ऐप को समुदाय के साथ साझा करें", "common.publishToMarketplaceFailed": "Marketplace पर प्रकाशित करने में विफल", "common.publishUpdate": "अपडेट प्रकाशित करें", "common.published": "प्रकाशित", "common.publishedAt": "प्रकाशित", + "common.publishedBy": "{{author}} द्वारा {{time}} प्रकाशित", "common.publishingToMarketplace": "प्रकाशित हो रहा है...", "common.redo": "फिर से करें", "common.restart": "पुनः आरंभ करें", @@ -251,6 +259,7 @@ "common.runApp": "ऐप चलाएं", "common.runHistory": "रन इतिहास", "common.running": "चल रहा है", + "common.savedAt": "{{time}} सहेजा गया", "common.scheduleTriggerRunFailed": "शेड्यूल ट्रिगर रन विफल", "common.searchVar": "वेरिएबल खोजें", "common.setVarValuePlaceholder": "वेरिएबल सेट करें", @@ -265,6 +274,8 @@ "common.tagBound": "इस टैग का उपयोग करने वाले ऐप्स की संख्या", "common.undo": "पूर्ववत करें", "common.unpublished": "अप्रकाशित", + "common.unpublishedChanges": "अप्रकाशित परिवर्तन", + "common.upToDate": "अद्यतित", "common.update": "अपडेट करें", "common.variableNamePlaceholder": "वेरिएबल नाम", "common.versionHistory": "संस्करण इतिहास", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook डीबग विफल", "common.webhookDebugRequestFailed": "Webhook डीबग अनुरोध विफल", "common.workflowAsTool": "टूल के रूप में कार्यप्रवाह", + "common.workflowAsToolDescription": "इसे अन्य ऐप्स में टूल के रूप में उपयोग करें", "common.workflowAsToolDisabledHint": "सबसे नया वर्कफ़्लो प्रकाशित करें और इसे टूल के रूप में कॉन्फ़िगर करने से पहले एक कनेक्टेड यूज़र इनपुट नोड सुनिश्चित करें।", + "common.workflowAsToolReady": "तैयार", + "common.workflowAsToolReconfigure": "फिर से कॉन्फ़िगर करें", "common.workflowAsToolTip": "कार्यप्रवाह अपडेट के बाद टूल पुनः कॉन्फ़िगरेशन आवश्यक है।", + "common.workflowAsToolUpdateNeeded": "अपडेट आवश्यक", "common.workflowProcess": "कार्यप्रवाह प्रक्रिया", "common.workflowProcessFailed": "कार्यप्रवाह प्रक्रिया विफल रही", "common.workflowProcessPaused": "कार्यप्रवाह प्रक्रिया रुकी हुई है", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "कोई उपलब्ध चर नहीं", "variableReference.noVarsForOperation": "चयनित कार्रवाई के साथ असाइनमेंट के लिए कोई चर उपलब्ध नहीं हैं.", "versionHistory.action.copyIdSuccess": "आईडी क्लिपबोर्ड पर कॉपी हो गई", + "versionHistory.action.deleteDeployedVersionError": "यह वर्कफ़्लो संस्करण एक या अधिक एनवायरनमेंट में डिप्लॉय है या डिप्लॉय किया जा रहा है। संस्करण हटाने से पहले इसे सभी एनवायरनमेंट से अनडिप्लॉय करें।", "versionHistory.action.deleteFailure": "संस्करण को हटाने में विफल", "versionHistory.action.deleteSuccess": "संस्करण हटाया गया", "versionHistory.action.restoreFailure": "संस्करण को पुनर्स्थापित करने में विफल", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "बस तुम्हारा", "versionHistory.filter.reset": "फिल्टर रीसेट करें", "versionHistory.latest": "लेटेस्ट", + "versionHistory.nameIt": "नाम दें", "versionHistory.nameThisVersion": "इस संस्करण का नाम दें", "versionHistory.releaseNotesPlaceholder": "बताइए कि क्या बदला", "versionHistory.restorationTip": "संस्करण पुनर्स्थापन के बाद, वर्तमान ड्राफ्ट अधिलेखित किया जाएगा।", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index 7958575c827..2c219688596 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Tidak beroperasi", "agentDetail.access.title": "Titik Akses", "agentDetail.access.toggleSurface": "Alihkan akses {{name}}", - "agentDetail.access.webApp.accessUrl": "URL Akses", + "agentDetail.access.webApp.accessUrl": "URL Aplikasi Web", "agentDetail.access.webApp.actions.accessControl": "Kontrol Akses", "agentDetail.access.webApp.actions.customize": "Frontend Kustom", "agentDetail.access.webApp.actions.embedded": "Tertanam", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Segarkan URL akses", "agentDetail.access.webApp.showQrCode": "Tampilkan kode QR", "agentDetail.access.webApp.ssoEnabled": "SSO Diaktifkan", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Aplikasi Web", "agentDetail.access.workflow.description": "Digunakan sebagai simpul di dalam alur kerja", "agentDetail.access.workflow.empty": "Belum ada referensi alur kerja.", "agentDetail.access.workflow.loadFailed": "Gagal memuat referensi alur kerja.", diff --git a/web/i18n/id-ID/app-api.json b/web/i18n/id-ID/app-api.json index 54526082470..e65dfa25ee9 100644 --- a/web/i18n/id-ID/app-api.json +++ b/web/i18n/id-ID/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Hapus kunci rahasia ini?", "actionMsg.ok": "OKE", "apiKey": "Kunci API", - "apiKeyModal.apiSecretKey": "Kunci rahasia API", + "apiKeyModal.apiSecretKey": "Kunci API", "apiKeyModal.apiSecretKeyTips": "Untuk mencegah penyalahgunaan API, lindungi Kunci API Anda. Hindari menggunakannya sebagai teks biasa dalam kode front-end. :)", "apiKeyModal.createNewSecretKey": "Membuat kunci Rahasia baru", "apiKeyModal.created": "DIBUAT", diff --git a/web/i18n/id-ID/app-overview.json b/web/i18n/id-ID/app-overview.json index ce25dad7811..f3159f7799e 100644 --- a/web/i18n/id-ID/app-overview.json +++ b/web/i18n/id-ID/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Buka penyedia model penyiapan", "apiKeyInfo.tryCloud": "Atau coba Dify versi cloud dengan penawaran gratis", "apiKeyInfo.usedToken": "Token bekas", - "overview.apiInfo.accessibleAddress": "Titik Akhir API Layanan", + "overview.apiInfo.accessibleAddress": "Titik Akhir API", "overview.apiInfo.doc": "Referensi API", "overview.apiInfo.explanation": "Mudah diintegrasikan ke dalam aplikasi Anda", "overview.apiInfo.title": "API Layanan Backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Pindai Untuk Berbagi", "overview.appInfo.qrcode.title": "Tautan Kode QR", "overview.appInfo.regenerate": "Regenerasi", - "overview.appInfo.regenerateNotice": "Apakah Anda ingin membuat ulang URL publik?", + "overview.appInfo.regenerateNotice": "Apakah Anda ingin membuat ulang URL publik? URL saat ini akan segera berhenti berfungsi.", "overview.appInfo.settings.chatColorTheme": "Tema warna obrolan", "overview.appInfo.settings.chatColorThemeDesc": "Atur tema warna chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Terbalik", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Kebijakan Privasi", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Masukkan tautan kebijakan privasi", "overview.appInfo.settings.more.privacyPolicyTip": "Membantu pengunjung memahami data yang dikumpulkan aplikasi, lihat Kebijakan Privasi Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Pengaturan aplikasi web berlaku untuk semua lingkungan.", "overview.appInfo.settings.sso.description": "Semua pengguna diharuskan masuk dengan SSO sebelum menggunakan aplikasi web", "overview.appInfo.settings.sso.label": "Penegakan SSO", "overview.appInfo.settings.sso.title": "aplikasi web SSO", @@ -117,10 +118,10 @@ "overview.status.running": "Berjalan", "overview.title": "Ikhtisar", "overview.triggerInfo.explanation": "Manajemen pemicu alur kerja", - "overview.triggerInfo.learnAboutTriggers": "Pelajari tentang Pemicu", + "overview.triggerInfo.learnAboutTriggers": "Pelajari lebih lanjut tentang pemicu", "overview.triggerInfo.noTriggerAdded": "Tidak ada pemicu yang ditambahkan", "overview.triggerInfo.title": "Pemicu", - "overview.triggerInfo.triggerStatusDescription": "Status node pemicu muncul di sini. (Mungkin sudah ada di draf, berlaku setelah dipublikasikan)", + "overview.triggerInfo.triggerStatusDescription": "Pemicu akan muncul di sini setelah Anda menerbitkan versi yang memuatnya.", "overview.triggerInfo.triggersAdded": "{{count}} Pemicu ditambahkan", "welcome.enterKeyTip": "masukkan Kunci API OpenAI Anda di bawah ini", "welcome.firstStepTip": "Untuk memulai,", diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json index 3d499767d77..70d75ce4a80 100644 --- a/web/i18n/id-ID/common.json +++ b/web/i18n/id-ID/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Ekstensi API menyediakan manajemen API terpusat, menyederhanakan konfigurasi agar mudah digunakan di seluruh aplikasi Dify.", "apiBasedExtension.type": "Jenis", "apiBasedExtensionPage.description": "Perluas kemampuan modul Dify melalui API — tambahkan moderasi konten khusus atau alat data eksternal ke aplikasi Anda.", + "appMenus.accessPoint": "Titik Akses", "appMenus.annotations": "Anotasi", "appMenus.apiAccess": "Akses API", "appMenus.apiAccessTip": "Basis pengetahuan ini dapat diakses melalui API layanan", + "appMenus.deploy": "Terapkan", "appMenus.logs": "Log", "appMenus.overview": "Pemantauan", "appMenus.promptEng": "Mengatur", diff --git a/web/i18n/id-ID/deployments.json b/web/i18n/id-ID/deployments.json index af36275b828..cf158ed9882 100644 --- a/web/i18n/id-ID/deployments.json +++ b/web/i18n/id-ID/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Berjalan (sinkronisasi tertunda)", "deployTab.status.runningWithFailed": "Berjalan (penerapan terakhir gagal)", "deployTab.undeploy": "Batalkan Penerapan", - "deployTab.undeployConfirmDesc": "Akses pengguna akhir akan segera berhenti. Rilis dapat diterapkan ulang nanti.", - "deployTab.undeployConfirmTitle": "Batalkan penerapan dari {{name}}?", + "deployTab.undeployConfirmDesc": "Endpoint lingkungan akan menjadi tidak tersedia, tetapi versi tidak akan dihapus.", + "deployTab.undeployConfirmTitle": "Batalkan penerapan {{name}}?", + "deployTab.undeployConfirmWarning": "Setelah dikonfirmasi, lingkungan ini akan masuk ke status pembatalan penerapan dan tindakan akan dinonaktifkan sementara.", "deployTab.viewError": "Lihat kesalahan", "detail.mobileTabs": "Bagian penerapan", "detail.notFound": "Penerapan tidak ditemukan", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Belum diterapkan", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Membatalkan penerapan", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Tidak diketahui", + "studio.accessPoint.addTriggerNodeDescription": "Tambahkan node Trigger untuk memulai aplikasi ini dari peristiwa eksternal.", + "studio.accessPoint.apiDescription": "Panggil aplikasi dari layanan Anda melalui HTTP", + "studio.accessPoint.description": "Semua cara aplikasi ini dapat diakses di lingkungan Bawaan dan setiap lingkungan yang di-deploy.", + "studio.accessPoint.embedIntoSite": "Sematkan ke situs", + "studio.accessPoint.goToPublish": "Buka publikasi", + "studio.accessPoint.mcpDescription": "Ekspos aplikasi sebagai alat untuk agen dan aplikasi AI", + "studio.accessPoint.noPublishedDescription": "Publikasikan versi agar titik akses bawaan tersedia.", + "studio.accessPoint.noPublishedTitle": "Belum ada versi yang dipublikasikan", + "studio.accessPoint.noTriggerNodes": "Tidak ada node Trigger dalam alur kerja ini", + "studio.accessPoint.notSupported": "Tidak didukung", + "studio.accessPoint.open": "Buka", + "studio.accessPoint.triggerDescription": "Jalankan alur kerja berdasarkan jadwal atau peristiwa eksternal", + "studio.accessPoint.triggerDisconnected": "Terputus", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} diaktifkan", + "studio.accessPoint.triggerExclusiveNotice": "Web app, API, dan MCP dinonaktifkan saat alur kerja menggunakan pemicu.", + "studio.accessPoint.triggerMuted": "Dibisukan", + "studio.accessPoint.triggerServiceModeUnavailable": "Tidak tersedia saat Web App, API, atau MCP sedang aktif.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Hanya tersedia di lingkungan Bawaan.", + "studio.accessPoint.webAppDescription": "Bagikan aplikasi kepada pengguna akhir Anda sebagai halaman web", + "studio.accessPoints": "Titik akses", + "studio.activity.deploy": "Deploy {{target}}", + "studio.activity.deployFailed": "Deploy {{target}} gagal", + "studio.activity.deploySucceeded": "{{target}} berhasil di-deploy", + "studio.activity.meta": "oleh {{name}} · {{time}}", + "studio.allVersions": "Semua versi", + "studio.builtInDescription": "Selalu menjalankan versi terbaru yang dipublikasikan", + "studio.builtInTitle": "Lingkungan bawaan", + "studio.changeVersion": "Ubah versi", + "studio.chooseVersionToDeploy": "Pilih versi yang akan diterapkan", + "studio.current": "Saat ini", + "studio.deployAnotherVersion": "Deploy versi lain", + "studio.deployConfiguration": "Konfigurasi penerapan", + "studio.deployLatest": "Deploy versi terbaru", + "studio.description": "Kelola deployment aplikasi ini di berbagai lingkungan. Lingkungan tambahan dapat dibuat di Dashboard Enterprise.", + "studio.emptyDescription": "Deploy versi yang telah dipublikasikan ke lingkungan, lalu versi tersebut akan muncul di sini.", + "studio.environmentVariablesDescription": "Gunakan nilai dari versi yang Anda terapkan, pertahankan nilai terakhir yang diterapkan, atau masukkan nilai kustom.", + "studio.environments": "Lingkungan", + "studio.environmentsInUse": "{{used}} dari {{total}} lingkungan digunakan", + "studio.lastActivity": "Aktivitas terakhir", + "studio.liveVersion": "Versi aktif", + "studio.moreEnvironments": "Lingkungan lainnya", + "studio.noCredentialsYet": "Belum ada kredensial", + "studio.noCredentialsYetDescription": "Tambahkan kredensial di Integrasi atau pengaturan node", + "studio.precheck.description": "Versi ini berisi jenis node yang belum didukung:", + "studio.precheck.supportMessage": "Dukungan untuk jenis node ini akan tersedia dalam rilis mendatang.", + "studio.precheck.title": "Versi ini tidak dapat diterapkan ke lingkungan ini", + "studio.publisher.deployingVersion": "Menerapkan: {{version}}", + "studio.publisher.noPublishedDescription": "Publikasikan draf Anda agar dapat diterapkan di sini.", + "studio.publisher.notDeployedYet": "Belum diterapkan", + "studio.retryVersion": "Coba lagi {{version}}", + "studio.undeployConfirmDesc": "Aplikasi akan berhenti berjalan di lingkungan ini dan semua titik aksesnya tidak akan tersedia. Versi yang di-deploy tidak akan dihapus.", + "studio.undeployConfirmTitle": "Batalkan deployment {{versionName}} dari {{envName}}", + "studio.updatedAtBy": "Diperbarui pada {{time}} oleh {{name}}", + "studio.versionValue": "Nilai versi", + "studio.versionsBehind_one": "Tertinggal {{count}} versi", + "studio.versionsBehind_other": "Tertinggal {{count}} versi", "tabs.access.description": "Kelola saluran akses dan izin akses.", "tabs.access.name": "Akses", "tabs.api-tokens.description": "Kelola API Tokens dan panggil instansi melalui HTTP.", diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json index 450fe588995..d44762b91bb 100644 --- a/web/i18n/id-ID/permission-keys.json +++ b/web/i18n/id-ID/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Konfigurasikan izin akses aplikasi", "app.acl.access_config": "Konfigurasikan izin akses aplikasi", "app.acl.delete": "Hapus aplikasi", + "app.acl.deploy": "Deploy aplikasi", "app.acl.edit": "Edit dan orkestrasikan aplikasi", "app.acl.import_export_dsl": "Impor / ekspor DSL", "app.acl.log_and_annotation": "Akses log dan anotasi", diff --git a/web/i18n/id-ID/tools.json b/web/i18n/id-ID/tools.json index 0d76cf6c770..a3316811ffb 100644 --- a/web/i18n/id-ID/tools.json +++ b/web/i18n/id-ID/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Tujuan dan batasan parameter", "mcp.server.modal.parametersTip": "Tambahkan deskripsi untuk setiap parameter untuk membantu LLM memahami tujuan dan kendala mereka.", "mcp.server.publishTip": "Aplikasi tidak dipublikasikan. Silakan publikasikan aplikasinya terlebih dahulu.", - "mcp.server.reGen": "Apakah Anda ingin meregenerasi URL server?", + "mcp.server.reGen": "Apakah Anda ingin meregenerasi URL server? URL saat ini akan segera berhenti berfungsi.", "mcp.server.title": "MCP Server", "mcp.server.url": "Server URL", "mcp.toolItem.noDescription": "Tanpa deskripsi", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 1def7cfa67f..fbfa2833bba 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Balas", "common.ImageUploadLegacyTip": "Anda sekarang dapat membuat variabel jenis file di formulir awal. Kami tidak akan lagi mendukung fitur unggah gambar di masa mendatang.", "common.accessAPIReference": "Referensi API Akses", + "common.accessPointDescription": "Konfigurasikan aplikasi web, API, MCP, dan pemicu", "common.addBlock": "Tambahkan Node", "common.addDescription": "Tambahkan deskripsi...", "common.addFailureBranch": "Tambahkan Cabang Gagal", @@ -169,6 +170,7 @@ "common.currentView": "Tampilan Saat Ini", "common.currentWorkflow": "Alur Kerja Saat Ini", "common.debugAndPreview": "Pratayang", + "common.deployDescription": "Deploy versi ke lingkungan", "common.disconnect": "Lepaskan", "common.draftSaveFailed": "Gagal menyimpan draf", "common.duplicate": "Duplikat", @@ -217,11 +219,15 @@ "common.needConnectTip": "Langkah ini tidak terhubung ke apa pun", "common.needOutputNode": "Node Output harus ditambahkan", "common.needStartNode": "Setidaknya satu node awal harus ditambahkan", + "common.noChanges": "Tidak ada perubahan", "common.noHistory": "Tidak Ada Sejarah", "common.noVar": "Tidak ada variabel", + "common.notPublishedYet": "Belum diterbitkan", "common.notRunning": "Belum berjalan", "common.onFailure": "Pada Kegagalan", "common.openInExplore": "Buka di Jelajahi", + "common.openWebApp": "Buka aplikasi web", + "common.openWebAppDescription": "Buka aplikasi yang dipublikasikan di tab baru", "common.output": "Hasil", "common.overwriteAndImport": "Menimpa dan Mengimpor", "common.parallel": "SEJAJAR", @@ -238,10 +244,12 @@ "common.processData": "Proses Data", "common.publish": "Menerbitkan", "common.publishToMarketplace": "Publikasikan ke Marketplace", + "common.publishToMarketplaceDescription": "Bagikan aplikasi ini dengan komunitas", "common.publishToMarketplaceFailed": "Gagal mempublikasikan ke Marketplace", "common.publishUpdate": "Publikasikan Pembaruan", "common.published": "Diterbitkan", "common.publishedAt": "Diterbitkan", + "common.publishedBy": "Diterbitkan {{time}} oleh {{author}}", "common.publishingToMarketplace": "Mempublikasikan...", "common.redo": "Ulangi", "common.restart": "Restart", @@ -251,6 +259,7 @@ "common.runApp": "Jalankan Aplikasi", "common.runHistory": "Jalankan Riwayat", "common.running": "Menjalankan", + "common.savedAt": "Disimpan {{time}}", "common.scheduleTriggerRunFailed": "Jalankan trigger terjadwal gagal", "common.searchVar": "Variabel pencarian", "common.setVarValuePlaceholder": "Atur variabel", @@ -265,6 +274,8 @@ "common.tagBound": "Jumlah aplikasi yang menggunakan tag ini", "common.undo": "Urungkan", "common.unpublished": "Tidak diterbitkan", + "common.unpublishedChanges": "Perubahan belum diterbitkan", + "common.upToDate": "Terbaru", "common.update": "Pemutakhiran", "common.variableNamePlaceholder": "Nama variabel", "common.versionHistory": "Riwayat versi", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Debug Webhook gagal", "common.webhookDebugRequestFailed": "Permintaan debug Webhook gagal", "common.workflowAsTool": "Alur Kerja sebagai Alat", + "common.workflowAsToolDescription": "Gunakan sebagai alat di aplikasi lain", "common.workflowAsToolDisabledHint": "Terbitkan alur kerja terbaru dan pastikan ada node Input Pengguna yang terhubung sebelum mengonfigurasinya sebagai alat.", + "common.workflowAsToolReady": "Siap", + "common.workflowAsToolReconfigure": "Konfigurasi ulang", "common.workflowAsToolTip": "Konfigurasi ulang alat diperlukan setelah pembaruan alur kerja.", + "common.workflowAsToolUpdateNeeded": "Perlu diperbarui", "common.workflowProcess": "Proses Alur Kerja", "common.workflowProcessFailed": "Proses Alur Kerja gagal", "common.workflowProcessPaused": "Proses Alur Kerja dijeda", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Tidak ada variabel yang tersedia", "variableReference.noVarsForOperation": "Tidak ada variabel yang tersedia untuk penugasan dengan operasi yang dipilih.", "versionHistory.action.copyIdSuccess": "ID disalin ke clipboard", + "versionHistory.action.deleteDeployedVersionError": "Versi alur kerja ini telah diterapkan, atau sedang diterapkan, ke satu atau beberapa lingkungan. Batalkan penerapan dari semua lingkungan sebelum menghapus versi.", "versionHistory.action.deleteFailure": "Gagal menghapus versi", "versionHistory.action.deleteSuccess": "Versi dihapus", "versionHistory.action.restoreFailure": "Gagal memulihkan versi", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Hanya milik Anda", "versionHistory.filter.reset": "Atur Ulang Filter", "versionHistory.latest": "Terbaru", + "versionHistory.nameIt": "Beri nama", "versionHistory.nameThisVersion": "Beri nama versi ini", "versionHistory.releaseNotesPlaceholder": "Menjelaskan apa yang berubah", "versionHistory.restorationTip": "Setelah pemulihan versi, draf saat ini akan ditimpa.", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index 8d780be61f5..d1a26c96de2 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Fuori servizio", "agentDetail.access.title": "Punto di accesso", "agentDetail.access.toggleSurface": "Attiva/disattiva accesso di {{name}}", - "agentDetail.access.webApp.accessUrl": "URL di accesso", + "agentDetail.access.webApp.accessUrl": "URL dell’app web", "agentDetail.access.webApp.actions.accessControl": "Controllo di accesso", "agentDetail.access.webApp.actions.customize": "Frontend personalizzato", "agentDetail.access.webApp.actions.embedded": "Incorporato", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Aggiorna URL di accesso", "agentDetail.access.webApp.showQrCode": "Mostra codice QR", "agentDetail.access.webApp.ssoEnabled": "SSO abilitato", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "App web", "agentDetail.access.workflow.description": "Usato come nodo all’interno del workflow", "agentDetail.access.workflow.empty": "Nessun riferimento al workflow al momento.", "agentDetail.access.workflow.loadFailed": "Impossibile caricare i riferimenti del workflow.", diff --git a/web/i18n/it-IT/app-api.json b/web/i18n/it-IT/app-api.json index 4c291d96dcb..bdd47f0a17b 100644 --- a/web/i18n/it-IT/app-api.json +++ b/web/i18n/it-IT/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Eliminare questa chiave segreta?", "actionMsg.ok": "OK", "apiKey": "Chiave API", - "apiKeyModal.apiSecretKey": "Chiave segreta API", + "apiKeyModal.apiSecretKey": "Chiave API", "apiKeyModal.apiSecretKeyTips": "Per prevenire l'abuso dell'API, proteggi la tua chiave API. Evita di usarla come testo semplice nel codice front-end. :)", "apiKeyModal.createNewSecretKey": "Crea nuova chiave segreta", "apiKeyModal.created": "CREATA", diff --git a/web/i18n/it-IT/app-overview.json b/web/i18n/it-IT/app-overview.json index a95328b8d71..62f86cf526d 100644 --- a/web/i18n/it-IT/app-overview.json +++ b/web/i18n/it-IT/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Vai a configurare il fornitore di modelli", "apiKeyInfo.tryCloud": "O prova la versione cloud di Dify con quota gratuita", "apiKeyInfo.usedToken": "Token utilizzati", - "overview.apiInfo.accessibleAddress": "Endpoint del servizio API", + "overview.apiInfo.accessibleAddress": "Endpoint API", "overview.apiInfo.doc": "Riferimento API", "overview.apiInfo.explanation": "Facilmente integrabile nella tua applicazione", "overview.apiInfo.title": "API del servizio backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Scansiona Condividi Applicazione", "overview.appInfo.qrcode.title": "Codice QR per condividere", "overview.appInfo.regenerate": "Rigenera", - "overview.appInfo.regenerateNotice": "Vuoi rigenerare l'URL pubblico?", + "overview.appInfo.regenerateNotice": "Vuoi rigenerare l'URL pubblico? L'URL attuale smetterà immediatamente di funzionare.", "overview.appInfo.settings.chatColorTheme": "Tema colore chat", "overview.appInfo.settings.chatColorThemeDesc": "Imposta il tema colore del chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Inverso", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Privacy Policy", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Inserisci il link alla privacy policy", "overview.appInfo.settings.more.privacyPolicyTip": "Aiuta i visitatori a capire i dati raccolti dall'applicazione, vedi la Privacy Policy di Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Le impostazioni dell'app web si applicano a tutti gli ambienti.", "overview.appInfo.settings.sso.description": "Tutti gli utenti devono effettuare l'accesso con SSO prima di utilizzare web app", "overview.appInfo.settings.sso.label": "Autenticazione SSO", "overview.appInfo.settings.sso.title": "web app SSO", @@ -117,10 +118,10 @@ "overview.status.running": "In servizio", "overview.title": "Panoramica", "overview.triggerInfo.explanation": "Gestione dei trigger del flusso di lavoro", - "overview.triggerInfo.learnAboutTriggers": "Scopri i Trigger", + "overview.triggerInfo.learnAboutTriggers": "Scopri di più sui trigger", "overview.triggerInfo.noTriggerAdded": "Nessun trigger aggiunto", "overview.triggerInfo.title": "Inneschi", - "overview.triggerInfo.triggerStatusDescription": "Lo stato del nodo trigger appare qui. (Può già esistere in bozza, prende effetto dopo la pubblicazione)", + "overview.triggerInfo.triggerStatusDescription": "Gli inneschi appariranno qui dopo la pubblicazione della versione che li contiene.", "overview.triggerInfo.triggersAdded": "{{count}} trigger aggiunti", "welcome.enterKeyTip": "inserisci la tua OpenAI API Key qui sotto", "welcome.firstStepTip": "Per iniziare,", diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json index 82639d37e41..260039ae7c4 100644 --- a/web/i18n/it-IT/common.json +++ b/web/i18n/it-IT/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Le estensioni API forniscono una gestione centralizzata delle API, semplificando la configurazione per un facile utilizzo nelle applicazioni di Dify.", "apiBasedExtension.type": "Tipo", "apiBasedExtensionPage.description": "Estendi le capacità dei moduli di Dify tramite API — aggiungi moderazione dei contenuti personalizzata o strumenti dati esterni alle tue app.", + "appMenus.accessPoint": "Punto di accesso", "appMenus.annotations": "Annotazioni", "appMenus.apiAccess": "Accesso API", "appMenus.apiAccessTip": "Questa base di conoscenza è accessibile tramite l'API del servizio", + "appMenus.deploy": "Distribuisci", "appMenus.logs": "Log", "appMenus.overview": "Monitoraggio", "appMenus.promptEng": "Orchestrazione", diff --git a/web/i18n/it-IT/deployments.json b/web/i18n/it-IT/deployments.json index f7122f0539f..15335c7a8ff 100644 --- a/web/i18n/it-IT/deployments.json +++ b/web/i18n/it-IT/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "In esecuzione (sincronizzazione in sospeso)", "deployTab.status.runningWithFailed": "In esecuzione (ultima distribuzione non riuscita)", "deployTab.undeploy": "Annulla distribuzione", - "deployTab.undeployConfirmDesc": "L'accesso degli utenti finali verrà interrotto immediatamente. La release potrà essere ridistribuita successivamente.", - "deployTab.undeployConfirmTitle": "Annullare la distribuzione da {{name}}?", + "deployTab.undeployConfirmDesc": "L'endpoint dell'ambiente non sarà più disponibile, ma la versione non verrà eliminata.", + "deployTab.undeployConfirmTitle": "Annullare la distribuzione di {{name}}?", + "deployTab.undeployConfirmWarning": "Dopo la conferma, questo ambiente entrerà nello stato di annullamento della distribuzione e le azioni saranno temporaneamente disabilitate.", "deployTab.viewError": "Visualizza errore", "detail.mobileTabs": "Sezioni distribuzione", "detail.notFound": "Distribuzione non trovata", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Non distribuito", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Annullamento distribuzione", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Sconosciuto", + "studio.accessPoint.addTriggerNodeDescription": "Aggiungi un nodo Trigger per avviare questa app da eventi esterni.", + "studio.accessPoint.apiDescription": "Chiama l’app dai tuoi servizi tramite HTTP", + "studio.accessPoint.description": "Tutti i modi per raggiungere questa app nell’ambiente integrato e in ogni ambiente distribuito.", + "studio.accessPoint.embedIntoSite": "Incorpora nel sito", + "studio.accessPoint.goToPublish": "Vai alla pubblicazione", + "studio.accessPoint.mcpDescription": "Esponi l’app come strumento per agenti e app di IA", + "studio.accessPoint.noPublishedDescription": "Pubblica una versione per rendere disponibili i punti di accesso integrati.", + "studio.accessPoint.noPublishedTitle": "Nessuna versione pubblicata", + "studio.accessPoint.noTriggerNodes": "Nessun nodo Trigger in questo workflow", + "studio.accessPoint.notSupported": "Non supportato", + "studio.accessPoint.open": "Apri", + "studio.accessPoint.triggerDescription": "Esegui il workflow in base a pianificazioni o eventi esterni", + "studio.accessPoint.triggerDisconnected": "Disconnesso", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} abilitati", + "studio.accessPoint.triggerExclusiveNotice": "L'app web, l'API e MCP sono disattivati mentre il workflow utilizza i trigger.", + "studio.accessPoint.triggerMuted": "Disattivato", + "studio.accessPoint.triggerServiceModeUnavailable": "Non disponibile mentre Web App, API o MCP è in servizio.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Disponibile solo nell’ambiente integrato.", + "studio.accessPoint.webAppDescription": "Condividi l’app con gli utenti finali come pagina web", + "studio.accessPoints": "Punti di accesso", + "studio.activity.deploy": "Distribuisci {{target}}", + "studio.activity.deployFailed": "Distribuzione di {{target}} non riuscita", + "studio.activity.deploySucceeded": "{{target}} distribuito correttamente", + "studio.activity.meta": "da {{name}} · {{time}}", + "studio.allVersions": "Tutte le versioni", + "studio.builtInDescription": "Esegue sempre l'ultima versione pubblicata", + "studio.builtInTitle": "Ambiente integrato", + "studio.changeVersion": "Cambia versione", + "studio.chooseVersionToDeploy": "Scegli una versione da distribuire", + "studio.current": "Corrente", + "studio.deployAnotherVersion": "Distribuisci un’altra versione", + "studio.deployConfiguration": "Configurazione distribuzione", + "studio.deployLatest": "Distribuisci l’ultima versione", + "studio.description": "Gestisci le distribuzioni di questa app nei vari ambienti. È possibile creare altri ambienti nella Dashboard Enterprise.", + "studio.emptyDescription": "Distribuisci una versione pubblicata in un ambiente e verrà visualizzata qui.", + "studio.environmentVariablesDescription": "Usa il valore della versione che stai distribuendo, mantieni l'ultimo valore distribuito o inseriscine uno personalizzato.", + "studio.environments": "Ambienti", + "studio.environmentsInUse": "{{used}} ambienti su {{total}} in uso", + "studio.lastActivity": "Ultima attività", + "studio.liveVersion": "Versione attiva", + "studio.moreEnvironments": "Altri ambienti", + "studio.noCredentialsYet": "Nessuna credenziale per ora", + "studio.noCredentialsYetDescription": "Aggiungi le credenziali in Integrazioni o nelle impostazioni del nodo", + "studio.precheck.description": "Contiene tipi di nodi non ancora supportati:", + "studio.precheck.supportMessage": "Il supporto per questi tipi di nodi sarà disponibile in una versione futura.", + "studio.precheck.title": "Questa versione non può essere distribuita in questo ambiente", + "studio.publisher.deployingVersion": "Distribuzione in corso: {{version}}", + "studio.publisher.noPublishedDescription": "Pubblica la bozza per distribuirla qui.", + "studio.publisher.notDeployedYet": "Non ancora distribuito", + "studio.retryVersion": "Riprova {{version}}", + "studio.undeployConfirmDesc": "L'app smetterà di essere eseguita in questo ambiente e tutti i suoi punti di accesso non saranno più disponibili. La versione distribuita non verrà eliminata.", + "studio.undeployConfirmTitle": "Annulla la distribuzione di {{versionName}} da {{envName}}", + "studio.updatedAtBy": "Aggiornato il {{time}} da {{name}}", + "studio.versionValue": "Valore della versione", + "studio.versionsBehind_one": "{{count}} versione indietro", + "studio.versionsBehind_other": "{{count}} versioni indietro", "tabs.access.description": "Gestisci i canali e le autorizzazioni di accesso.", "tabs.access.name": "Accesso", "tabs.api-tokens.description": "Gestisci API Token e richiama le istanze tramite HTTP.", diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json index 10a9ff715f8..23f7b7d2090 100644 --- a/web/i18n/it-IT/permission-keys.json +++ b/web/i18n/it-IT/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Configura i permessi di accesso all'app", "app.acl.access_config": "Configura i permessi di accesso all'app", "app.acl.delete": "Elimina app", + "app.acl.deploy": "Distribuisci app", "app.acl.edit": "Modifica e orchestra l'app", "app.acl.import_export_dsl": "Importa / esporta DSL", "app.acl.log_and_annotation": "Accedi a log e annotazioni", diff --git a/web/i18n/it-IT/tools.json b/web/i18n/it-IT/tools.json index c297f1c2988..fc54a2f9c71 100644 --- a/web/i18n/it-IT/tools.json +++ b/web/i18n/it-IT/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Scopo e restrizioni del parametro", "mcp.server.modal.parametersTip": "Aggiungi descrizioni per ogni parametro per aiutare il LLM a comprendere il loro scopo e le loro restrizioni.", "mcp.server.publishTip": "App non pubblicata. Pubblica l'app prima.", - "mcp.server.reGen": "Vuoi rigenerare l'URL del server?", + "mcp.server.reGen": "Vuoi rigenerare l'URL del server? L'URL attuale smetterà immediatamente di funzionare.", "mcp.server.title": "Server MCP", "mcp.server.url": "URL del Server", "mcp.toolItem.noDescription": "Nessuna descrizione", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index b8035149dbe..9dee24c6939 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Rispondi", "common.ImageUploadLegacyTip": "Ora è possibile creare variabili di tipo file nel modulo iniziale. In futuro non supporteremo più la funzione di caricamento delle immagini.", "common.accessAPIReference": "Accedi alla Riferimento API", + "common.accessPointDescription": "Configura app web, API, MCP e trigger", "common.addBlock": "Aggiungi nodo", "common.addDescription": "Aggiungi descrizione...", "common.addFailureBranch": "Aggiungi ramo non riuscito", @@ -169,6 +170,7 @@ "common.currentView": "Vista corrente", "common.currentWorkflow": "Flusso di lavoro corrente", "common.debugAndPreview": "Anteprima", + "common.deployDescription": "Distribuisci le versioni negli ambienti", "common.disconnect": "Disconnettere", "common.draftSaveFailed": "Salvataggio della bozza non riuscito", "common.duplicate": "Duplica", @@ -217,11 +219,15 @@ "common.needConnectTip": "Questo passaggio non è collegato a nulla", "common.needOutputNode": "Deve essere aggiunto il nodo di uscita", "common.needStartNode": "Deve essere aggiunto almeno un nodo iniziale", + "common.noChanges": "Nessuna modifica", "common.noHistory": "Nessuna storia", "common.noVar": "Nessuna variabile", + "common.notPublishedYet": "Non ancora pubblicato", "common.notRunning": "Non ancora in esecuzione", "common.onFailure": "In caso di guasto", "common.openInExplore": "Apri in Esplora", + "common.openWebApp": "Apri app web", + "common.openWebAppDescription": "Apri l’app pubblicata in una nuova scheda", "common.output": "Output", "common.overwriteAndImport": "Sovrascrivi e Importa", "common.parallel": "PARALLELO", @@ -238,10 +244,12 @@ "common.processData": "Elabora Dati", "common.publish": "Pubblica", "common.publishToMarketplace": "Pubblica sul Marketplace", + "common.publishToMarketplaceDescription": "Condividi questa app con la community", "common.publishToMarketplaceFailed": "Pubblicazione sul Marketplace non riuscita", "common.publishUpdate": "Pubblica aggiornamento", "common.published": "Pubblicato", "common.publishedAt": "Pubblicato", + "common.publishedBy": "Pubblicato {{time}} da {{author}}", "common.publishingToMarketplace": "Pubblicazione...", "common.redo": "Ripeti", "common.restart": "Riavvia", @@ -251,6 +259,7 @@ "common.runApp": "Esegui App", "common.runHistory": "Cronologia esecuzioni", "common.running": "In esecuzione", + "common.savedAt": "Salvato {{time}}", "common.scheduleTriggerRunFailed": "Esecuzione del trigger pianificato non riuscita", "common.searchVar": "Cerca variabile", "common.setVarValuePlaceholder": "Imposta variabile", @@ -265,6 +274,8 @@ "common.tagBound": "Numero di app che utilizzano questo tag", "common.undo": "Annulla", "common.unpublished": "Non pubblicato", + "common.unpublishedChanges": "Modifiche non pubblicate", + "common.upToDate": "Aggiornato", "common.update": "Aggiorna", "common.variableNamePlaceholder": "Nome variabile", "common.versionHistory": "Cronologia delle versioni", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Debug del Webhook non riuscito", "common.webhookDebugRequestFailed": "Richiesta di debug del Webhook non riuscita", "common.workflowAsTool": "Flusso di lavoro come Strumento", + "common.workflowAsToolDescription": "Usalo come strumento in altre app", "common.workflowAsToolDisabledHint": "Pubblica il flusso di lavoro più recente e assicurati che ci sia un nodo di Input Utente collegato prima di configurarlo come strumento.", + "common.workflowAsToolReady": "Pronto", + "common.workflowAsToolReconfigure": "Riconfigura", "common.workflowAsToolTip": "È richiesta una nuova configurazione dello strumento dopo l'aggiornamento del flusso di lavoro.", + "common.workflowAsToolUpdateNeeded": "Aggiornamento necessario", "common.workflowProcess": "Processo di flusso di lavoro", "common.workflowProcessFailed": "Processo di flusso di lavoro non riuscito", "common.workflowProcessPaused": "Processo di flusso di lavoro in pausa", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Nessuna variabile disponibile", "variableReference.noVarsForOperation": "Non ci sono variabili disponibili per l'assegnazione con l'operazione selezionata.", "versionHistory.action.copyIdSuccess": "ID copiato negli appunti", + "versionHistory.action.deleteDeployedVersionError": "Questa versione del workflow è distribuita, o in fase di distribuzione, in uno o più ambienti. Annulla la distribuzione da tutti gli ambienti prima di eliminare la versione.", "versionHistory.action.deleteFailure": "Impossibile eliminare la versione", "versionHistory.action.deleteSuccess": "Versione eliminata", "versionHistory.action.restoreFailure": "Impossibile ripristinare la versione", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Solo tuo", "versionHistory.filter.reset": "Ripristina filtro", "versionHistory.latest": "Ultimo", + "versionHistory.nameIt": "Assegna un nome", "versionHistory.nameThisVersion": "Chiamare questa versione", "versionHistory.releaseNotesPlaceholder": "Descrivi cosa è cambiato", "versionHistory.restorationTip": "Dopo il ripristino della versione, la bozza attuale verrà sovrascritta.", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index 0dbf82fed90..731af46e81c 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "停止中", "agentDetail.access.title": "アクセスポイント", "agentDetail.access.toggleSurface": "{{name}} のアクセスを切り替え", - "agentDetail.access.webApp.accessUrl": "アクセス URL", + "agentDetail.access.webApp.accessUrl": "Web アプリ URL", "agentDetail.access.webApp.actions.accessControl": "アクセス制御", "agentDetail.access.webApp.actions.customize": "カスタムフロントエンド", "agentDetail.access.webApp.actions.embedded": "埋め込み", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "アクセス URL を更新", "agentDetail.access.webApp.showQrCode": "QR コードを表示", "agentDetail.access.webApp.ssoEnabled": "SSO 有効", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Web アプリ", "agentDetail.access.workflow.description": "ワークフロー内のノードとして使用", "agentDetail.access.workflow.empty": "ワークフロー参照はまだありません。", "agentDetail.access.workflow.loadFailed": "ワークフロー参照の読み込みに失敗しました。", diff --git a/web/i18n/ja-JP/app-api.json b/web/i18n/ja-JP/app-api.json index 11a0c7ef365..e047035fc75 100644 --- a/web/i18n/ja-JP/app-api.json +++ b/web/i18n/ja-JP/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "このシークレットキーを削除しますか?", "actionMsg.ok": "OK", "apiKey": "API キー", - "apiKeyModal.apiSecretKey": "API シークレットキー", + "apiKeyModal.apiSecretKey": "API キー", "apiKeyModal.apiSecretKeyTips": "API の悪用を防ぐために、API キーを保護してください。フロントエンドのコードで平文として使用しないでください。:)", "apiKeyModal.createNewSecretKey": "新しいシークレットキーを作成", "apiKeyModal.created": "作成日時", diff --git a/web/i18n/ja-JP/app-overview.json b/web/i18n/ja-JP/app-overview.json index ee01bac9494..f04c8a30434 100644 --- a/web/i18n/ja-JP/app-overview.json +++ b/web/i18n/ja-JP/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "モデルプロバイダの設定へ", "apiKeyInfo.tryCloud": "または Dify のクラウドバージョンを無料見積もりでお試しください", "apiKeyInfo.usedToken": "使用済みトークン", - "overview.apiInfo.accessibleAddress": "サービス API エンドポイント", + "overview.apiInfo.accessibleAddress": "API エンドポイント", "overview.apiInfo.doc": "API リファレンス", "overview.apiInfo.explanation": "あなたのアプリケーションに簡単に統合できます", "overview.apiInfo.title": "バックエンドサービス API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "アプリケーションの共有をスキャン", "overview.appInfo.qrcode.title": "共有用 QR コード", "overview.appInfo.regenerate": "再生成", - "overview.appInfo.regenerateNotice": "公開 URL を再生成しますか?", + "overview.appInfo.regenerateNotice": "公開 URL を再生成しますか?現在の URL は直ちに使用できなくなります。", "overview.appInfo.settings.chatColorTheme": "チャットボットのカラーテーマ", "overview.appInfo.settings.chatColorThemeDesc": "チャットボットのカラーテーマを設定します", "overview.appInfo.settings.chatColorThemeInverted": "反転", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "プライバシーポリシー", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "プライバシーポリシーリンクを入力してください", "overview.appInfo.settings.more.privacyPolicyTip": "訪問者がアプリケーションが収集するデータを理解し、Dify のプライバシーポリシーを参照できるようにします。", + "overview.appInfo.settings.multiEnvironmentNotice": "Web アプリの設定はすべての環境に適用されます。", "overview.appInfo.settings.sso.description": "すべてのユーザーは、Web アプリを使用する前に SSO でログインする必要があります", "overview.appInfo.settings.sso.label": "SSO 認証", "overview.appInfo.settings.sso.title": "Web アプリの SSO", @@ -117,10 +118,10 @@ "overview.status.running": "稼働中", "overview.title": "概要", "overview.triggerInfo.explanation": "ワークフロートリガー管理", - "overview.triggerInfo.learnAboutTriggers": "トリガーについて学ぶ", + "overview.triggerInfo.learnAboutTriggers": "トリガーについて詳しく見る", "overview.triggerInfo.noTriggerAdded": "トリガーが追加されていません", "overview.triggerInfo.title": "トリガー", - "overview.triggerInfo.triggerStatusDescription": "トリガーノードの状態がここに表示されます。(下書きに既に存在する可能性があり、公開後に有効になります)", + "overview.triggerInfo.triggerStatusDescription": "トリガーを含むバージョンを公開すると、ここに表示されます。", "overview.triggerInfo.triggersAdded": "{{count}} 個のトリガーが追加されました", "welcome.enterKeyTip": "以下に OpenAI API キーを入力してください", "welcome.firstStepTip": "はじめるには、", diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json index 285dab047a7..94a26b2e4fb 100644 --- a/web/i18n/ja-JP/common.json +++ b/web/i18n/ja-JP/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API 拡張機能は、Dify のアプリケーション全体での簡単な使用のための設定を簡素化し、集中的な API 管理を提供します。", "apiBasedExtension.type": "タイプ", "apiBasedExtensionPage.description": "API を通じて Dify のモジュール機能を拡張——カスタムのコンテンツモデレーションや外部データツールをアプリに追加できます。", + "appMenus.accessPoint": "アクセスポイント", "appMenus.annotations": "注釈", "appMenus.apiAccess": "API アクセス", "appMenus.apiAccessTip": "このナレッジベースはサービスAPIを介してアクセス可能です", + "appMenus.deploy": "デプロイ", "appMenus.logs": "ログ", "appMenus.overview": "監視", "appMenus.promptEng": "オーケストレート", diff --git a/web/i18n/ja-JP/deployments.json b/web/i18n/ja-JP/deployments.json index 83fc3a6e394..f937e9b6525 100644 --- a/web/i18n/ja-JP/deployments.json +++ b/web/i18n/ja-JP/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "実行中 (同期保留中)", "deployTab.status.runningWithFailed": "実行中 (前回のデプロイは失敗)", "deployTab.undeploy": "アンデプロイ", - "deployTab.undeployConfirmDesc": "エンドユーザーアクセスはすぐに停止します。リリースは後で再デプロイできます。", - "deployTab.undeployConfirmTitle": "{{name}} からアンデプロイしますか?", + "deployTab.undeployConfirmDesc": "環境のエンドポイントは利用できなくなりますが、バージョンは削除されません。", + "deployTab.undeployConfirmTitle": "{{name}} をアンデプロイしますか?", + "deployTab.undeployConfirmWarning": "確認後、この環境はアンデプロイ中の状態になり、操作は一時的に無効になります。", "deployTab.viewError": "エラーを表示", "detail.mobileTabs": "デプロイセクション", "detail.notFound": "デプロイが見つかりません", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未デプロイ", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "アンデプロイ中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "不明", + "studio.accessPoint.addTriggerNodeDescription": "外部イベントからこのアプリを開始するには Trigger ノードを追加してください。", + "studio.accessPoint.apiDescription": "HTTP 経由でサービスからアプリを呼び出す", + "studio.accessPoint.description": "組み込み環境および各デプロイ環境からこのアプリにアクセスするすべての方法。", + "studio.accessPoint.embedIntoSite": "サイトに埋め込む", + "studio.accessPoint.goToPublish": "公開へ移動", + "studio.accessPoint.mcpDescription": "エージェントや AI アプリ向けのツールとしてアプリを公開", + "studio.accessPoint.noPublishedDescription": "組み込みのアクセスポイントを利用するには、バージョンを公開してください。", + "studio.accessPoint.noPublishedTitle": "公開済みのバージョンはまだありません", + "studio.accessPoint.noTriggerNodes": "このワークフローに Trigger ノードはありません", + "studio.accessPoint.notSupported": "サポート対象外", + "studio.accessPoint.open": "開く", + "studio.accessPoint.triggerDescription": "スケジュールまたは外部イベントでワークフローを実行", + "studio.accessPoint.triggerDisconnected": "切断済み", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} 件が有効", + "studio.accessPoint.triggerExclusiveNotice": "ワークフローでトリガーを使用している間、Web app、API、MCP は無効になります。", + "studio.accessPoint.triggerMuted": "ミュート", + "studio.accessPoint.triggerServiceModeUnavailable": "Web App、API、MCP のいずれかが稼働中は利用できません。", + "studio.accessPoint.unsupportedInDeployedEnvironment": "組み込み環境でのみ利用できます。", + "studio.accessPoint.webAppDescription": "アプリを Web ページとしてエンドユーザーと共有", + "studio.accessPoints": "アクセスポイント", + "studio.activity.deploy": "{{target}} をデプロイ", + "studio.activity.deployFailed": "{{target}} のデプロイに失敗しました", + "studio.activity.deploySucceeded": "{{target}} をデプロイしました", + "studio.activity.meta": "{{name}} · {{time}}", + "studio.allVersions": "すべてのバージョン", + "studio.builtInDescription": "常に最新の公開バージョンを実行します", + "studio.builtInTitle": "組み込み環境", + "studio.changeVersion": "バージョンを変更", + "studio.chooseVersionToDeploy": "デプロイするバージョンを選択", + "studio.current": "現在", + "studio.deployAnotherVersion": "別のバージョンをデプロイ", + "studio.deployConfiguration": "デプロイ設定", + "studio.deployLatest": "最新バージョンをデプロイ", + "studio.description": "各環境におけるこのアプリのデプロイを管理します。Enterprise Dashboard でさらに環境を作成できます。", + "studio.emptyDescription": "公開済みバージョンを環境にデプロイすると、ここに表示されます。", + "studio.environmentVariablesDescription": "デプロイするバージョンの値を使用するか、前回デプロイした値を保持するか、カスタム値を入力します。", + "studio.environments": "環境", + "studio.environmentsInUse": "{{total}} 環境中 {{used}} 環境を使用中", + "studio.lastActivity": "最近のアクティビティ", + "studio.liveVersion": "稼働中のバージョン", + "studio.moreEnvironments": "その他の環境", + "studio.noCredentialsYet": "認証情報はまだありません", + "studio.noCredentialsYetDescription": "連携またはノード設定で認証情報を追加してください", + "studio.precheck.description": "まだサポートされていないノードタイプが含まれています:", + "studio.precheck.supportMessage": "これらのノードタイプは今後のリリースでサポートされる予定です。", + "studio.precheck.title": "このバージョンはこの環境にデプロイできません", + "studio.publisher.deployingVersion": "デプロイ中:{{version}}", + "studio.publisher.noPublishedDescription": "下書きを公開して、ここにデプロイしてください。", + "studio.publisher.notDeployedYet": "まだデプロイされていません", + "studio.retryVersion": "{{version}} を再試行", + "studio.undeployConfirmDesc": "この環境でのアプリの実行が停止し、すべてのアクセスポイントが利用できなくなります。デプロイ済みのバージョンは削除されません。", + "studio.undeployConfirmTitle": "{{envName}} から {{versionName}} をアンデプロイ", + "studio.updatedAtBy": "{{name}} が {{time}} に更新", + "studio.versionValue": "バージョンの値", + "studio.versionsBehind_one": "{{count}} バージョン遅れ", + "studio.versionsBehind_other": "{{count}} バージョン遅れ", "tabs.access.description": "アクセスチャネルとアクセス権限を管理します。", "tabs.access.name": "アクセス", "tabs.api-tokens.description": "API トークンを管理し、HTTP 経由でインスタンスを呼び出します。", diff --git a/web/i18n/ja-JP/permission-keys.json b/web/i18n/ja-JP/permission-keys.json index e1e8119ceef..bc3cb614fa9 100644 --- a/web/i18n/ja-JP/permission-keys.json +++ b/web/i18n/ja-JP/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "アプリアクセス権限を設定", "app.acl.access_config": "アプリアクセス権限を設定", "app.acl.delete": "アプリを削除", + "app.acl.deploy": "アプリをデプロイ", "app.acl.edit": "アプリの編集とオーケストレーション", "app.acl.import_export_dsl": "DSLのインポート / エクスポート", "app.acl.log_and_annotation": "ログと注釈へのアクセス", diff --git a/web/i18n/ja-JP/tools.json b/web/i18n/ja-JP/tools.json index 463cc0f71a4..13d4bbbb51a 100644 --- a/web/i18n/ja-JP/tools.json +++ b/web/i18n/ja-JP/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "パラメータの目的と制約", "mcp.server.modal.parametersTip": "各パラメータの説明を追加して、LLM がその目的と制約を理解できるようにします。", "mcp.server.publishTip": "アプリが公開されていません。まずアプリを公開してください。", - "mcp.server.reGen": "サーバーURL を再生成しますか?", + "mcp.server.reGen": "サーバー URL を再生成しますか?現在の URL は直ちに使用できなくなります。", "mcp.server.title": "MCP サーバー", "mcp.server.url": "サーバーURL", "mcp.toolItem.noDescription": "説明なし", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 6ac2fdd7de7..616f8f1d191 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "返信", "common.ImageUploadLegacyTip": "開始フォームでファイル型変数が作成可能になりました。画像アップロード機能は今後サポート終了となります。", "common.accessAPIReference": "API リファレンス", + "common.accessPointDescription": "Web アプリ、API、MCP、トリガーを設定", "common.addBlock": "ブロックを追加", "common.addDescription": "説明を追加...", "common.addFailureBranch": "失敗ブランチを追加", @@ -169,6 +170,7 @@ "common.currentView": "現在のビュー", "common.currentWorkflow": "現在のワークフロー", "common.debugAndPreview": "プレビュー", + "common.deployDescription": "バージョンを各環境にデプロイ", "common.disconnect": "接続解除", "common.draftSaveFailed": "下書きの保存に失敗しました", "common.duplicate": "複製", @@ -217,11 +219,15 @@ "common.needConnectTip": "接続されていないステップがあります", "common.needOutputNode": "出力ノードを追加する必要があります", "common.needStartNode": "少なくとも1つのスタートノードを追加する必要があります", + "common.noChanges": "変更なし", "common.noHistory": "履歴がありません", "common.noVar": "変数がありません", + "common.notPublishedYet": "まだ公開されていません", "common.notRunning": "まだ実行されていません", "common.onFailure": "失敗時", "common.openInExplore": "探索ページで開く", + "common.openWebApp": "Web アプリを開く", + "common.openWebAppDescription": "公開済みアプリを新しいタブで開く", "common.output": "出力", "common.overwriteAndImport": "上書きしてインポート", "common.parallel": "並列", @@ -238,10 +244,12 @@ "common.processData": "データ処理", "common.publish": "公開する", "common.publishToMarketplace": "マーケットプレイスに公開", + "common.publishToMarketplaceDescription": "アプリをテンプレートとして公開", "common.publishToMarketplaceFailed": "マーケットプレイスへの公開に失敗しました", "common.publishUpdate": "更新を公開", "common.published": "公開済み", "common.publishedAt": "公開日時", + "common.publishedBy": "{{time}}に{{author}}が公開", "common.publishingToMarketplace": "公開中...", "common.redo": "やり直し", "common.restart": "再起動", @@ -251,6 +259,7 @@ "common.runApp": "アプリを実行", "common.runHistory": "実行履歴", "common.running": "実行中", + "common.savedAt": "{{time}} に保存", "common.scheduleTriggerRunFailed": "スケジュールトリガーの実行に失敗しました", "common.searchVar": "変数を検索", "common.setVarValuePlaceholder": "変数値を設定", @@ -265,6 +274,8 @@ "common.tagBound": "このタグを使用しているアプリの数", "common.undo": "元に戻す", "common.unpublished": "未公開", + "common.unpublishedChanges": "未公開の変更", + "common.upToDate": "最新です", "common.update": "更新", "common.variableNamePlaceholder": "変数名を入力", "common.versionHistory": "バージョン履歴", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhookデバッグに失敗しました", "common.webhookDebugRequestFailed": "Webhookデバッグリクエストに失敗しました", "common.workflowAsTool": "ワークフローをツールとして利用", + "common.workflowAsToolDescription": "他のアプリでツールとして利用", "common.workflowAsToolDisabledHint": "最新のワークフローを公開し、接続済みの User Input ノードを用意してからツールとして設定してください。", + "common.workflowAsToolReady": "準備完了", + "common.workflowAsToolReconfigure": "再設定", "common.workflowAsToolTip": "ワークフロー更新後はツールの再設定が必要です", + "common.workflowAsToolUpdateNeeded": "更新が必要", "common.workflowProcess": "ワークフロー処理", "common.workflowProcessFailed": "ワークフロー処理が失敗しました", "common.workflowProcessPaused": "ワークフロー処理は一時停止中です", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "利用可能な変数がありません", "variableReference.noVarsForOperation": "この操作に割り当て可能な変数が存在しません。", "versionHistory.action.copyIdSuccess": "IDがクリップボードにコピーされました", + "versionHistory.action.deleteDeployedVersionError": "このワークフローバージョンは、1つ以上の環境にデプロイ済み、またはデプロイ中です。バージョンを削除する前に、すべての環境からデプロイを解除してください。", "versionHistory.action.deleteFailure": "削除に失敗しました", "versionHistory.action.deleteSuccess": "削除が完了しました", "versionHistory.action.restoreFailure": "復元に失敗しました", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "自分のみ", "versionHistory.filter.reset": "リセット", "versionHistory.latest": "最新版", + "versionHistory.nameIt": "名前を付ける", "versionHistory.nameThisVersion": "バージョン名を付ける", "versionHistory.releaseNotesPlaceholder": "変更内容を入力してください", "versionHistory.restorationTip": "バージョンを復元すると、現在の下書きが上書きされます", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index a6cc0da9cb9..578e7f68e8f 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "서비스 중지됨", "agentDetail.access.title": "액세스 지점", "agentDetail.access.toggleSurface": "{{name}} 액세스 전환", - "agentDetail.access.webApp.accessUrl": "액세스 URL", + "agentDetail.access.webApp.accessUrl": "웹 앱 URL", "agentDetail.access.webApp.actions.accessControl": "접근 제어", "agentDetail.access.webApp.actions.customize": "커스텀 프런트엔드", "agentDetail.access.webApp.actions.embedded": "임베드", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "액세스 URL 새로 고침", "agentDetail.access.webApp.showQrCode": "QR 코드 표시", "agentDetail.access.webApp.ssoEnabled": "SSO 사용 가능", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "웹 앱", "agentDetail.access.workflow.description": "워크플로 내 노드로 사용", "agentDetail.access.workflow.empty": "워크플로 참조가 없습니다.", "agentDetail.access.workflow.loadFailed": "워크플로 참조를 불러오지 못했습니다.", diff --git a/web/i18n/ko-KR/app-api.json b/web/i18n/ko-KR/app-api.json index 86195cec8e6..413c5b72232 100644 --- a/web/i18n/ko-KR/app-api.json +++ b/web/i18n/ko-KR/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "이 비밀 키를 삭제하시겠습니까?", "actionMsg.ok": "확인", "apiKey": "API 키", - "apiKeyModal.apiSecretKey": "API 비밀 키", + "apiKeyModal.apiSecretKey": "API 키", "apiKeyModal.apiSecretKeyTips": "API 키를 보호하여 API 의 남용을 방지하십시오. 프런트엔드 코드에서 평문으로 사용하지 마세요. :)", "apiKeyModal.createNewSecretKey": "새로운 비밀 키 생성", "apiKeyModal.created": "생성 날짜", diff --git a/web/i18n/ko-KR/app-overview.json b/web/i18n/ko-KR/app-overview.json index 2fc91e90579..5d937ae8781 100644 --- a/web/i18n/ko-KR/app-overview.json +++ b/web/i18n/ko-KR/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "모델 제공자 설정으로 이동", "apiKeyInfo.tryCloud": "또는 Dify 의 클라우드 버전을 무료로 체험해보세요", "apiKeyInfo.usedToken": "사용된 토큰", - "overview.apiInfo.accessibleAddress": "서비스 API 엔드포인트", + "overview.apiInfo.accessibleAddress": "API 엔드포인트", "overview.apiInfo.doc": "API 레퍼런스", "overview.apiInfo.explanation": "개발자의 애플리케이션에 쉽게 통합할 수 있습니다", "overview.apiInfo.title": "백엔드 서비스 API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "앱 공유를 스캔하세요", "overview.appInfo.qrcode.title": "공유용 QR 코드", "overview.appInfo.regenerate": "재생성", - "overview.appInfo.regenerateNotice": "공개 URL 을 재생성하시겠습니까?", + "overview.appInfo.regenerateNotice": "공개 URL을 재생성하시겠습니까? 현재 URL은 즉시 작동이 중지됩니다.", "overview.appInfo.settings.chatColorTheme": "챗봇 색상 테마", "overview.appInfo.settings.chatColorThemeDesc": "챗봇의 색상 테마를 설정하세요", "overview.appInfo.settings.chatColorThemeInverted": "반전", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "개인정보 처리방침", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "개인정보 처리방침 링크를 입력하세요", "overview.appInfo.settings.more.privacyPolicyTip": "방문자가 애플리케이션이 수집하는 데이터를 이해하고, Dify 의 개인정보 처리방침을 참조할 수 있도록 합니다.", + "overview.appInfo.settings.multiEnvironmentNotice": "웹 앱 설정은 모든 환경에 적용됩니다.", "overview.appInfo.settings.sso.description": "모든 사용자는 WebApp 을 사용하기 전에 SSO 로 로그인해야 합니다.", "overview.appInfo.settings.sso.label": "SSO 인증", "overview.appInfo.settings.sso.title": "웹앱 SSO", @@ -117,10 +118,10 @@ "overview.status.running": "서비스 중", "overview.title": "개요", "overview.triggerInfo.explanation": "워크플로 트리거 관리", - "overview.triggerInfo.learnAboutTriggers": "트리거에 대해 배우기", + "overview.triggerInfo.learnAboutTriggers": "트리거에 대해 자세히 알아보기", "overview.triggerInfo.noTriggerAdded": "트리거가 추가되지 않았습니다", "overview.triggerInfo.title": "유발 요인", - "overview.triggerInfo.triggerStatusDescription": "트리거 노드 상태가 여기에 표시됩니다. (이미 초안에 존재할 수 있으며, 게시 후에 적용됩니다)", + "overview.triggerInfo.triggerStatusDescription": "트리거는 해당 트리거가 포함된 버전을 게시한 후 여기에 표시됩니다.", "overview.triggerInfo.triggersAdded": "{{count}} 트리거 추가됨", "welcome.enterKeyTip": "아래에 OpenAI API 키를 입력하세요", "welcome.firstStepTip": "시작하려면,", diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json index cb71883adea..d541775d51f 100644 --- a/web/i18n/ko-KR/common.json +++ b/web/i18n/ko-KR/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API 기반 확장은 Dify 애플리케이션 전체에서 간편한 사용을 위한 설정을 단순화하고 집중적인 API 관리를 제공합니다.", "apiBasedExtension.type": "유형", "apiBasedExtensionPage.description": "API를 통해 Dify의 모듈 기능을 확장하고, 앱에 맞춤형 콘텐츠 검수 또는 외부 데이터 도구를 추가하세요.", + "appMenus.accessPoint": "액세스 지점", "appMenus.annotations": "어노테이션", "appMenus.apiAccess": "API 액세스", "appMenus.apiAccessTip": "이 지식 베이스는 서비스 API를 통해 액세스할 수 있습니다", + "appMenus.deploy": "배포", "appMenus.logs": "로그", "appMenus.overview": "모니터링", "appMenus.promptEng": "오케스트레이트", diff --git a/web/i18n/ko-KR/deployments.json b/web/i18n/ko-KR/deployments.json index 731dbdb5154..f36fcb60ba1 100644 --- a/web/i18n/ko-KR/deployments.json +++ b/web/i18n/ko-KR/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "실행 중 (동기화 대기 중)", "deployTab.status.runningWithFailed": "실행 중 (마지막 배포 실패)", "deployTab.undeploy": "배포 취소", - "deployTab.undeployConfirmDesc": "최종 사용자 액세스가 즉시 중지됩니다. 릴리스는 나중에 재배포할 수 있습니다.", - "deployTab.undeployConfirmTitle": "{{name}}에서 배포를 취소하시겠습니까?", + "deployTab.undeployConfirmDesc": "환경 엔드포인트를 사용할 수 없게 되지만 버전은 삭제되지 않습니다.", + "deployTab.undeployConfirmTitle": "{{name}} 배포를 취소하시겠습니까?", + "deployTab.undeployConfirmWarning": "확인 후 이 환경은 배포 취소 중 상태로 전환되며 작업이 일시적으로 비활성화됩니다.", "deployTab.viewError": "오류 보기", "detail.mobileTabs": "배포 섹션", "detail.notFound": "배포를 찾을 수 없음", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "배포되지 않음", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "배포 취소 중", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "알 수 없음", + "studio.accessPoint.addTriggerNodeDescription": "외부 이벤트로 이 앱을 시작하려면 Trigger 노드를 추가하세요.", + "studio.accessPoint.apiDescription": "서비스에서 HTTP로 앱 호출", + "studio.accessPoint.description": "기본 제공 환경과 모든 배포 환경에서 이 앱에 접근하는 모든 방법입니다.", + "studio.accessPoint.embedIntoSite": "사이트에 임베드", + "studio.accessPoint.goToPublish": "게시하러 가기", + "studio.accessPoint.mcpDescription": "에이전트와 AI 앱을 위한 도구로 앱 제공", + "studio.accessPoint.noPublishedDescription": "기본 제공 액세스 포인트를 사용하려면 버전을 게시하세요.", + "studio.accessPoint.noPublishedTitle": "아직 게시된 버전이 없습니다", + "studio.accessPoint.noTriggerNodes": "이 워크플로에 Trigger 노드가 없습니다", + "studio.accessPoint.notSupported": "지원되지 않음", + "studio.accessPoint.open": "열기", + "studio.accessPoint.triggerDescription": "일정 또는 외부 이벤트에 따라 워크플로 실행", + "studio.accessPoint.triggerDisconnected": "연결 끊김", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}}개 활성화", + "studio.accessPoint.triggerExclusiveNotice": "워크플로가 트리거를 사용하는 동안 Web app, API 및 MCP가 비활성화됩니다.", + "studio.accessPoint.triggerMuted": "음소거됨", + "studio.accessPoint.triggerServiceModeUnavailable": "Web App, API 또는 MCP가 서비스 중일 때는 사용할 수 없습니다.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "기본 제공 환경에서만 사용할 수 있습니다.", + "studio.accessPoint.webAppDescription": "최종 사용자에게 웹 페이지로 앱 공유", + "studio.accessPoints": "액세스 포인트", + "studio.activity.deploy": "{{target}} 배포", + "studio.activity.deployFailed": "{{target}} 배포 실패", + "studio.activity.deploySucceeded": "{{target}} 배포 성공", + "studio.activity.meta": "{{name}} · {{time}}", + "studio.allVersions": "모든 버전", + "studio.builtInDescription": "항상 최신 게시 버전을 실행합니다", + "studio.builtInTitle": "기본 제공 환경", + "studio.changeVersion": "버전 변경", + "studio.chooseVersionToDeploy": "배포할 버전을 선택하세요", + "studio.current": "현재", + "studio.deployAnotherVersion": "다른 버전 배포", + "studio.deployConfiguration": "배포 구성", + "studio.deployLatest": "최신 버전 배포", + "studio.description": "여러 환경에서 이 앱의 배포를 관리합니다. Enterprise Dashboard에서 더 많은 환경을 만들 수 있습니다.", + "studio.emptyDescription": "게시된 버전을 환경에 배포하면 여기에 표시됩니다.", + "studio.environmentVariablesDescription": "배포할 버전의 값을 사용하거나, 마지막으로 배포한 값을 유지하거나, 사용자 지정 값을 입력하세요.", + "studio.environments": "환경", + "studio.environmentsInUse": "환경 {{total}}개 중 {{used}}개 사용 중", + "studio.lastActivity": "최근 활동", + "studio.liveVersion": "라이브 버전", + "studio.moreEnvironments": "더 많은 환경", + "studio.noCredentialsYet": "아직 자격 증명이 없습니다", + "studio.noCredentialsYetDescription": "통합 또는 노드 설정에서 자격 증명을 추가하세요", + "studio.precheck.description": "아직 지원되지 않는 노드 유형이 포함되어 있습니다:", + "studio.precheck.supportMessage": "이러한 노드 유형은 향후 릴리스에서 지원될 예정입니다.", + "studio.precheck.title": "이 버전은 이 환경에 배포할 수 없습니다", + "studio.publisher.deployingVersion": "배포 중: {{version}}", + "studio.publisher.noPublishedDescription": "초안을 게시하여 여기에 배포하세요.", + "studio.publisher.notDeployedYet": "아직 배포되지 않음", + "studio.retryVersion": "{{version}} 다시 시도", + "studio.undeployConfirmDesc": "이 환경에서 앱 실행이 중지되고 모든 액세스 포인트를 사용할 수 없게 됩니다. 배포된 버전은 삭제되지 않습니다.", + "studio.undeployConfirmTitle": "{{envName}}에서 {{versionName}} 배포 취소", + "studio.updatedAtBy": "{{name}} 님이 {{time}}에 업데이트", + "studio.versionValue": "버전 값", + "studio.versionsBehind_one": "{{count}}개 버전 뒤처짐", + "studio.versionsBehind_other": "{{count}}개 버전 뒤처짐", "tabs.access.description": "액세스 채널 및 액세스 권한을 관리합니다.", "tabs.access.name": "액세스", "tabs.api-tokens.description": "API 토큰을 관리하고 HTTP를 통해 인스턴스를 호출합니다.", diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json index 07a44cc3675..e5fa343f0a8 100644 --- a/web/i18n/ko-KR/permission-keys.json +++ b/web/i18n/ko-KR/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "앱 접근 권한 구성", "app.acl.access_config": "앱 접근 권한 구성", "app.acl.delete": "앱 삭제", + "app.acl.deploy": "앱 배포", "app.acl.edit": "앱 편집 및 오케스트레이션", "app.acl.import_export_dsl": "DSL 가져오기 / 내보내기", "app.acl.log_and_annotation": "로그 및 주석 접근", diff --git a/web/i18n/ko-KR/tools.json b/web/i18n/ko-KR/tools.json index 52ec64769f3..85831b51635 100644 --- a/web/i18n/ko-KR/tools.json +++ b/web/i18n/ko-KR/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "매개변수의 목적 및 제한 사항", "mcp.server.modal.parametersTip": "각 매개변수의 설명을 추가하여 LLM이 목적과 제한 사항을 이해할 수 있도록 도와주세요.", "mcp.server.publishTip": "앱이 게시되지 않았습니다. 먼저 앱을 게시하십시오.", - "mcp.server.reGen": "서버 URL을 다시 생성하시겠습니까?", + "mcp.server.reGen": "서버 URL을 다시 생성하시겠습니까? 현재 URL은 즉시 작동이 중지됩니다.", "mcp.server.title": "MCP 서버", "mcp.server.url": "서버 URL", "mcp.toolItem.noDescription": "설명 없음", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index 8978cec6fcb..b1455fe8f87 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "답글", "common.ImageUploadLegacyTip": "이제 시작 양식에서 파일 형식 변수를 만들 수 있습니다. 앞으로 이미지 업로드 기능은 더 이상 지원되지 않습니다.", "common.accessAPIReference": "API 참조 접근", + "common.accessPointDescription": "웹 앱, API, MCP 및 트리거를 구성합니다", "common.addBlock": "노드 추가", "common.addDescription": "설명 추가...", "common.addFailureBranch": "실패 분기 추가", @@ -169,6 +170,7 @@ "common.currentView": "현재 보기", "common.currentWorkflow": "현재 워크플로", "common.debugAndPreview": "미리보기", + "common.deployDescription": "버전을 환경에 배포합니다", "common.disconnect": "연결 해제", "common.draftSaveFailed": "초안 저장에 실패했습니다", "common.duplicate": "복제", @@ -217,11 +219,15 @@ "common.needConnectTip": "이 단계는 아무것도 연결되어 있지 않습니다", "common.needOutputNode": "출력 노드를 추가해야 합니다", "common.needStartNode": "적어도 하나의 시작 노드를 추가해야 합니다", + "common.noChanges": "변경 사항 없음", "common.noHistory": "이력 없음", "common.noVar": "변수 없음", + "common.notPublishedYet": "아직 게시되지 않음", "common.notRunning": "아직 실행되지 않음", "common.onFailure": "실패 시", "common.openInExplore": "Explore 에서 열기", + "common.openWebApp": "웹 앱 열기", + "common.openWebAppDescription": "게시된 앱을 새 탭에서 엽니다", "common.output": "출력", "common.overwriteAndImport": "덮어쓰기 및 가져오기", "common.parallel": "병렬", @@ -238,10 +244,12 @@ "common.processData": "데이터 처리", "common.publish": "게시하기", "common.publishToMarketplace": "마켓플레이스에 게시", + "common.publishToMarketplaceDescription": "이 앱을 커뮤니티와 공유합니다", "common.publishToMarketplaceFailed": "마켓플레이스 게시 실패", "common.publishUpdate": "업데이트 게시", "common.published": "게시됨", "common.publishedAt": "발행일", + "common.publishedBy": "{{author}}님이 {{time}}에 게시", "common.publishingToMarketplace": "게시 중...", "common.redo": "다시 실행", "common.restart": "재시작", @@ -251,6 +259,7 @@ "common.runApp": "앱 실행", "common.runHistory": "실행 기록", "common.running": "실행 중", + "common.savedAt": "{{time}}에 저장", "common.scheduleTriggerRunFailed": "예약된 트리거 실행 실패", "common.searchVar": "변수 검색", "common.setVarValuePlaceholder": "변수 값 설정", @@ -265,6 +274,8 @@ "common.tagBound": "이 태그를 사용하는 앱 수", "common.undo": "실행 취소", "common.unpublished": "게시되지 않음", + "common.unpublishedChanges": "게시되지 않은 변경 사항", + "common.upToDate": "최신 상태입니다", "common.update": "업데이트", "common.variableNamePlaceholder": "변수 이름", "common.versionHistory": "버전 기록", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook 디버그 실패", "common.webhookDebugRequestFailed": "Webhook 디버그 요청 실패", "common.workflowAsTool": "도구로서의 워크플로우", + "common.workflowAsToolDescription": "다른 앱에서 도구로 사용합니다", "common.workflowAsToolDisabledHint": "최신 워크플로를 게시하고 도구로 구성하기 전에 연결된 사용자 입력 노드가 있는지 확인하세요.", + "common.workflowAsToolReady": "준비됨", + "common.workflowAsToolReconfigure": "재구성", "common.workflowAsToolTip": "워크플로우 업데이트 후 도구 재구성이 필요합니다.", + "common.workflowAsToolUpdateNeeded": "업데이트 필요", "common.workflowProcess": "워크플로우 과정", "common.workflowProcessFailed": "워크플로우 과정 실패", "common.workflowProcessPaused": "워크플로우 과정 일시 중지됨", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "사용 가능한 변수 없음", "variableReference.noVarsForOperation": "선택한 작업에 할당할 수 있는 변수가 없습니다.", "versionHistory.action.copyIdSuccess": "클립보드에 복사된 ID", + "versionHistory.action.deleteDeployedVersionError": "이 워크플로 버전은 하나 이상의 환경에 배포되었거나 배포 중입니다. 버전을 삭제하기 전에 모든 환경에서 배포를 해제하세요.", "versionHistory.action.deleteFailure": "버전을 삭제하지 못했습니다.", "versionHistory.action.deleteSuccess": "버전 삭제됨", "versionHistory.action.restoreFailure": "버전을 복원하지 못했습니다.", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "내 버전만", "versionHistory.filter.reset": "필터 재설정", "versionHistory.latest": "최신", + "versionHistory.nameIt": "이름 지정", "versionHistory.nameThisVersion": "이름 바꾸기", "versionHistory.releaseNotesPlaceholder": "변경된 내용을 설명하세요.", "versionHistory.restorationTip": "버전 복원 후 현재 초안이 덮어쓰여질 것입니다.", diff --git a/web/i18n/lo-LA/agent-v-2.json b/web/i18n/lo-LA/agent-v-2.json index cb665f69d5b..8bd87f0eadc 100644 --- a/web/i18n/lo-LA/agent-v-2.json +++ b/web/i18n/lo-LA/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "ຢຸດໃຫ້ບໍລິການ", "agentDetail.access.title": "ຈຸດເຂົ້າເຖິງ", "agentDetail.access.toggleSurface": "ເປີດ/ປິດ ການເຂົ້າເຖິງ {{name}}", - "agentDetail.access.webApp.accessUrl": "URL ການເຂົ້າເຖິງ", + "agentDetail.access.webApp.accessUrl": "URL ເວັບແອັບ", "agentDetail.access.webApp.actions.accessControl": "ການຄວບຄຸມການເຂົ້າເຖິງ", "agentDetail.access.webApp.actions.customize": "ປັບແຕ່ງສ່ວນໜ້າເວັບ", "agentDetail.access.webApp.actions.embedded": "ຝັງໄວ້ໃນເວັບໄຊ", diff --git a/web/i18n/lo-LA/app-api.json b/web/i18n/lo-LA/app-api.json index adee2b6d53b..4bf9e712417 100644 --- a/web/i18n/lo-LA/app-api.json +++ b/web/i18n/lo-LA/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "ລຶບລະຫັດລັບນີ້ບໍ?", "actionMsg.ok": "ຕົກລົງ", "apiKey": "API Key", - "apiKeyModal.apiSecretKey": "ລະຫັດລັບ API", + "apiKeyModal.apiSecretKey": "API Key", "apiKeyModal.apiSecretKeyTips": "ເພື່ອປ້ອງກັນການລະເມີດການໃຊ້ API, ຄວນປົກປ້ອງ API Key ຂອງທ່ານ. ຫຼີກເວັ້ນການໃຊ້ໃນຮູບແບບຂໍ້ຄວາມທຳມະດາໃນໂຄ້ດ front-end. :)", "apiKeyModal.createNewSecretKey": "ສ້າງລະຫັດລັບໃໝ່", "apiKeyModal.created": "ສ້າງເມື່ອ", diff --git a/web/i18n/lo-LA/app-overview.json b/web/i18n/lo-LA/app-overview.json index a5bfa7f4cb9..fe43834f322 100644 --- a/web/i18n/lo-LA/app-overview.json +++ b/web/i18n/lo-LA/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "ໄປທີ່ການຕັ້ງຄ່າຜູ້ໃຫ້ບໍລິການໂມເດນ", "apiKeyInfo.tryCloud": "ຫຼື ລອງໃຊ້ເວີຊັນຄລາວຂອງ Dify ພ້ອມໂຄຕ້າຟຣີ", "apiKeyInfo.usedToken": "Token ທີ່ໃຊ້ໄປ", - "overview.apiInfo.accessibleAddress": "Endpoint ຂອງ Service API", + "overview.apiInfo.accessibleAddress": "API Endpoint", "overview.apiInfo.doc": "ເອກະສານອ້າງອີງ API", "overview.apiInfo.explanation": "ລວມເຂົ້າກັບແອັບພລິເຄຊັນຂອງທ່ານໄດ້ຢ່າງງ່າຍດາຍ", "overview.apiInfo.title": "Backend Service API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "ສະແກນເພື່ອແຊຣ໌", "overview.appInfo.qrcode.title": "ລິ້ງ QR Code", "overview.appInfo.regenerate": "ສ້າງໃໝ່", - "overview.appInfo.regenerateNotice": "ທ່ານຕ້ອງການສ້າງ URL ສາທາລະນະໃໝ່ຫຼືບໍ່?", + "overview.appInfo.regenerateNotice": "ທ່ານຕ້ອງການສ້າງ URL ສາທາລະນະໃໝ່ຫຼືບໍ່? URL ປັດຈຸບັນຈະຢຸດໃຊ້ງານທັນທີ.", "overview.appInfo.settings.chatColorTheme": "ທີມສີການສົນທະນາ", "overview.appInfo.settings.chatColorThemeDesc": "ຕັ້ງຄ່າທີມສີຂອງ Chatbot", "overview.appInfo.settings.chatColorThemeInverted": "ສີແບບປີ້ນກັບ (Inverted)", @@ -117,10 +117,10 @@ "overview.status.running": "ກຳລັງໃຫ້ບໍລິການ", "overview.title": "ພາບລວມ", "overview.triggerInfo.explanation": "ການຈັດການຕົວຈຸດຊະນວນ Workflow (Workflow Trigger)", - "overview.triggerInfo.learnAboutTriggers": "ຮຽນຮູ້ກ່ຽວກັບ Triggers", + "overview.triggerInfo.learnAboutTriggers": "ຮຽນຮູ້ເພີ່ມເຕີມກ່ຽວກັບຕົວກະຕຸ້ນ", "overview.triggerInfo.noTriggerAdded": "ຍັງບໍ່ມີການເພີ່ມ Trigger", "overview.triggerInfo.title": "Triggers", - "overview.triggerInfo.triggerStatusDescription": "ສະຖານະຂອງ Trigger Node ຈະປາກົດຢູ່ທີ່ນີ້. (ອາດຈະມີຢູ່ໃນສະບັບຮ່າງແລ້ວ, ເຊິ່ງຈະມີຜົນຫຼັງຈາກການເຜີຍແຜ່)", + "overview.triggerInfo.triggerStatusDescription": "Triggers ຈະປາກົດຢູ່ບ່ອນນີ້ຫຼັງຈາກທ່ານເຜີຍແຜ່ເວີຊັນທີ່ມີພວກມັນ.", "overview.triggerInfo.triggersAdded": "ເພີ່ມແລ້ວ {{count}} Triggers", "welcome.enterKeyTip": "ປ້ອນ OpenAI API Key ຂອງທ່ານຂ້າງລຸ່ມນີ້", "welcome.firstStepTip": "ເພື່ອເລີ່ມຕົ້ນ,", diff --git a/web/i18n/lo-LA/deployments.json b/web/i18n/lo-LA/deployments.json index f6f3ba38492..4605dfc0d91 100644 --- a/web/i18n/lo-LA/deployments.json +++ b/web/i18n/lo-LA/deployments.json @@ -313,6 +313,23 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "ຍັງບໍ່ໄດ້ຕິດຕັ້ງ", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "ກຳລັງຖອນການຕິດຕັ້ງ", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ບໍ່ຮູ້ຈັກ", + "studio.accessPoint.apiDescription": "ເອີ້ນໃຊ້ແອັບຈາກບໍລິການຂອງທ່ານຜ່ານ HTTP", + "studio.accessPoint.description": "ທຸກວິທີທີ່ສາມາດເຂົ້າເຖິງແອັບນີ້ໄດ້ໃນສະພາບແວດລ້ອມ Built-in ແລະທຸກສະພາບແວດລ້ອມທີ່ deploy ແລ້ວ.", + "studio.accessPoint.mcpDescription": "ເປີດໃຊ້ແອັບເປັນເຄື່ອງມືສຳລັບ agent ແລະແອັບ AI", + "studio.accessPoint.triggerDescription": "ເປີດໃຊ້ workflow ຕາມຕາຕະລາງ ຫຼືເຫດການຈາກພາຍນອກ", + "studio.accessPoint.triggerExclusiveNotice": "Web app, API ແລະ MCP ຈະຖືກປິດໃນຂະນະທີ່ Workflow ໃຊ້ຕົວກະຕຸ້ນ.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "ໃຊ້ໄດ້ສະເພາະໃນສະພາບແວດລ້ອມ Built-in ເທົ່ານັ້ນ.", + "studio.accessPoint.webAppDescription": "ແບ່ງປັນແອັບໃຫ້ຜູ້ໃຊ້ປາຍທາງເປັນໜ້າເວັບ", + "studio.builtInDescription": "ໃຊ້ເວີຊັນທີ່ເຜີຍແຜ່ຫຼ້າສຸດສະເໝີ", + "studio.deployAnotherVersion": "ຕິດຕັ້ງລຸ້ນອື່ນ", + "studio.description": "ຈັດການການຕິດຕັ້ງແອັບນີ້ໃນສະພາບແວດລ້ອມຕ່າງໆ. ສາມາດສ້າງສະພາບແວດລ້ອມເພີ່ມເຕີມໄດ້ໃນ Enterprise Dashboard.", + "studio.noCredentialsYet": "ຍັງບໍ່ມີຂໍ້ມູນການເຂົ້າລະບົບ", + "studio.noCredentialsYetDescription": "ເພີ່ມຂໍ້ມູນການເຂົ້າລະບົບໃນ Integrations ຫຼື ການຕັ້ງຄ່າໂນດ", + "studio.precheck.description": "ມັນມີປະເພດໂນດທີ່ຍັງບໍ່ຮອງຮັບ:", + "studio.precheck.supportMessage": "ຈະຮອງຮັບປະເພດໂນດເຫຼົ່ານີ້ໃນລຸ້ນທີ່ຈະມາເຖິງ.", + "studio.precheck.title": "ບໍ່ສາມາດນຳເວີຊັນນີ້ໄປປັບໃຊ້ໃນສະພາບແວດລ້ອມນີ້ໄດ້", + "studio.undeployConfirmDesc": "ແອັບຈະຢຸດເຮັດວຽກໃນສະພາບແວດລ້ອມນີ້ ແລະ ຈຸດເຂົ້າເຖິງທັງໝົດຈະບໍ່ສາມາດໃຊ້ງານໄດ້. ລຸ້ນທີ່ຕິດຕັ້ງແລ້ວຈະບໍ່ຖືກລຶບ.", + "studio.undeployConfirmTitle": "ຖອນການຕິດຕັ້ງ {{versionName}} ອອກຈາກ {{envName}}", "tabs.access.description": "ຈັດການຊ່ອງທາງ ແລະ ສິດການເຂົ້າເຖິງ.", "tabs.access.name": "ການເຂົ້າເຖິງ", "tabs.api-tokens.description": "ຈັດການໂທເຄັນ API ແລະ ເອີ້ນໃຊ້ອິນສະແຕນຊ໌ຜ່ານ HTTP.", diff --git a/web/i18n/lo-LA/tools.json b/web/i18n/lo-LA/tools.json index cf1549a1ef7..472c62f2de2 100644 --- a/web/i18n/lo-LA/tools.json +++ b/web/i18n/lo-LA/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "ຈຸດປະສົງ ແລະ ຂໍ້ຈຳກັດຂອງພາລາມິເຕີ", "mcp.server.modal.parametersTip": "ເພີ່ມຄຳອະທິບາຍສຳລັບແຕ່ລະພາລາມິເຕີເພື່ອຊ່ວຍໃຫ້ LLM ເຂົ້າໃຈຈຸດປະສົງ ແລະ ຂໍ້ຈຳກັດຂອງພວກມັນ.", "mcp.server.publishTip": "ແອັບຍັງບໍ່ທັນໄດ້ເຜີຍແຜ່. ກະລຸນາເຜີຍແຜ່ແອັບກ່ອນ.", - "mcp.server.reGen": "ທ່ານຕ້ອງການສ້າງ URL ເຊີເວີໃໝ່ ຫຼື ບໍ່?", + "mcp.server.reGen": "ທ່ານຕ້ອງການສ້າງ URL ເຊີເວີໃໝ່ ຫຼື ບໍ່? URL ປັດຈຸບັນຈະຢຸດໃຊ້ງານທັນທີ.", "mcp.server.title": "ເຊີເວີ MCP", "mcp.server.url": "URL ຂອງເຊີເວີ", "mcp.toolItem.noDescription": "ບໍ່ມີຄຳອະທິບາຍ", diff --git a/web/i18n/lo-LA/workflow.json b/web/i18n/lo-LA/workflow.json index b1493269782..56d4a70faf9 100644 --- a/web/i18n/lo-LA/workflow.json +++ b/web/i18n/lo-LA/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "ຕອບກັບ", "common.ImageUploadLegacyTip": "ດຽວນີ້ທ່ານສາມາດສ້າງຕົວປ່ຽນປະເພດໄຟລ໌ໃນຟອມເລີ່ມຕົ້ນໄດ້. ພວກເຮົາຈະບໍ່ຮອງຮັບຟີເຈີອັບໂຫຼດຮູບພາບແບບເກົ່າໃນອະນາຄົດ.", "common.accessAPIReference": "ເບິ່ງເອກະສານອ້າງອີງ API", + "common.accessPointDescription": "ຕັ້ງຄ່າເວັບແອັບ, API, MCP ແລະ ຕົວກະຕຸ້ນ", "common.addBlock": "ເພີ່ມໂນດ", "common.addDescription": "ເພີ່ມຄຳອະທິບາຍ...", "common.addFailureBranch": "ເພີ່ມສາຂາທີ່ລົ້ມເຫຼວ", @@ -265,6 +266,7 @@ "common.tagBound": "ຈຳນວນແອັບທີ່ໃຊ້ແທັກນີ້", "common.undo": "ຍົກເລີກ", "common.unpublished": "ຍັງບໍ່ໄດ້ເຜີຍແຜ່", + "common.upToDate": "ເປັນເວີຊັນຫຼ້າສຸດແລ້ວ", "common.update": "ອັບເດດ", "common.variableNamePlaceholder": "ຊື່ຕົວປ່ຽນ", "common.versionHistory": "ປະຫວັດເວີຊັນ", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index 992dccd6220..d0166d5a942 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Buiten gebruik", "agentDetail.access.title": "Toegangspunt", "agentDetail.access.toggleSurface": "Toegang van {{name}} schakelen", - "agentDetail.access.webApp.accessUrl": "Toegangs-URL", + "agentDetail.access.webApp.accessUrl": "Webapp-URL", "agentDetail.access.webApp.actions.accessControl": "Toegangsbeheer", "agentDetail.access.webApp.actions.customize": "Aangepaste frontend", "agentDetail.access.webApp.actions.embedded": "Ingesloten", diff --git a/web/i18n/nl-NL/app-api.json b/web/i18n/nl-NL/app-api.json index 6ca527eae5e..a0b45f668ed 100644 --- a/web/i18n/nl-NL/app-api.json +++ b/web/i18n/nl-NL/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Delete this secret key?", "actionMsg.ok": "OK", "apiKey": "API Key", - "apiKeyModal.apiSecretKey": "API Secret key", + "apiKeyModal.apiSecretKey": "API Key", "apiKeyModal.apiSecretKeyTips": "To prevent API abuse, protect your API Key. Avoid using it as plain text in front-end code. :)", "apiKeyModal.createNewSecretKey": "Create new Secret key", "apiKeyModal.created": "CREATED", diff --git a/web/i18n/nl-NL/app-overview.json b/web/i18n/nl-NL/app-overview.json index 1937e01d9cf..c8a08a42eae 100644 --- a/web/i18n/nl-NL/app-overview.json +++ b/web/i18n/nl-NL/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Go to setup model provider", "apiKeyInfo.tryCloud": "Or try the cloud version of Dify with free quote", "apiKeyInfo.usedToken": "Used token", - "overview.apiInfo.accessibleAddress": "Service API Endpoint", + "overview.apiInfo.accessibleAddress": "API Endpoint", "overview.apiInfo.doc": "API Reference", "overview.apiInfo.explanation": "Easily integrated into your application", "overview.apiInfo.title": "Backend Service API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Scan To Share", "overview.appInfo.qrcode.title": "Link QR Code", "overview.appInfo.regenerate": "Regenerate", - "overview.appInfo.regenerateNotice": "Do you want to regenerate the public URL?", + "overview.appInfo.regenerateNotice": "Wilt u de openbare URL opnieuw genereren? De huidige URL stopt onmiddellijk met werken.", "overview.appInfo.settings.chatColorTheme": "Chat color theme", "overview.appInfo.settings.chatColorThemeDesc": "Set the color theme of the chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Inverted", @@ -95,15 +95,16 @@ "overview.appInfo.settings.more.privacyPolicy": "Privacy Policy", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Enter the privacy policy link", "overview.appInfo.settings.more.privacyPolicyTip": "Helps visitors understand the data the application collects, see Dify's Privacy Policy.", + "overview.appInfo.settings.multiEnvironmentNotice": "Webapp-instellingen zijn van toepassing op alle omgevingen.", "overview.appInfo.settings.sso.description": "All users are required to login with SSO before using web app", "overview.appInfo.settings.sso.label": "SSO Enforcement", "overview.appInfo.settings.sso.title": "web app SSO", "overview.appInfo.settings.sso.tooltip": "Contact the administrator to enable web app SSO", "overview.appInfo.settings.title": "Branding", - "overview.appInfo.settings.webDesc": "web app Description", + "overview.appInfo.settings.webDesc": "Beschrijving van de webapp", "overview.appInfo.settings.webDescPlaceholder": "Enter the description of the web app", "overview.appInfo.settings.webDescTip": "This text will be displayed on the client side, providing basic guidance on how to use the application", - "overview.appInfo.settings.webName": "web app Name", + "overview.appInfo.settings.webName": "Naam van de webapp", "overview.appInfo.settings.workflow.hide": "Hide", "overview.appInfo.settings.workflow.show": "Show", "overview.appInfo.settings.workflow.showDesc": "Show or hide workflow details in web app", @@ -117,10 +118,10 @@ "overview.status.running": "In Service", "overview.title": "Overview", "overview.triggerInfo.explanation": "Workflow trigger management", - "overview.triggerInfo.learnAboutTriggers": "Learn about Triggers", + "overview.triggerInfo.learnAboutTriggers": "Meer informatie over triggers", "overview.triggerInfo.noTriggerAdded": "No trigger added", "overview.triggerInfo.title": "Triggers", - "overview.triggerInfo.triggerStatusDescription": "Trigger node status appears here. (May already exist in draft, takes effect after publishing)", + "overview.triggerInfo.triggerStatusDescription": "Triggers verschijnen hier nadat je de versie hebt gepubliceerd waarin ze staan.", "overview.triggerInfo.triggersAdded": "{{count}} Triggers added", "welcome.enterKeyTip": "enter your OpenAI API Key below", "welcome.firstStepTip": "To get started,", diff --git a/web/i18n/nl-NL/common.json b/web/i18n/nl-NL/common.json index 14ca719cf1d..e23b4966e52 100644 --- a/web/i18n/nl-NL/common.json +++ b/web/i18n/nl-NL/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API extensions provide centralized API management, simplifying configuration for easy use across Dify's applications.", "apiBasedExtension.type": "Type", "apiBasedExtensionPage.description": "Breid de modulemogelijkheden van Dify uit via API — voeg aangepaste contentmoderatie of externe datatools toe aan je apps.", + "appMenus.accessPoint": "Toegangspunt", "appMenus.annotations": "Annotations", "appMenus.apiAccess": "API Access", "appMenus.apiAccessTip": "This knowledge base is accessible via the Service API", + "appMenus.deploy": "Implementeren", "appMenus.logs": "Logs", "appMenus.overview": "Monitoring", "appMenus.promptEng": "Orchestrate", @@ -468,7 +470,7 @@ "operation.imageCopied": "Image copied", "operation.imageDownloaded": "Image downloaded", "operation.in": "in", - "operation.learnMore": "Learn More", + "operation.learnMore": "Meer informatie", "operation.log": "Log", "operation.more": "More", "operation.moreActions": "Meer acties", diff --git a/web/i18n/nl-NL/deployments.json b/web/i18n/nl-NL/deployments.json index 834780872e8..8b41c1da032 100644 --- a/web/i18n/nl-NL/deployments.json +++ b/web/i18n/nl-NL/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Actief (synchronisatie in afwachting)", "deployTab.status.runningWithFailed": "Actief (laatste implementatie mislukt)", "deployTab.undeploy": "Implementatie ongedaan maken", - "deployTab.undeployConfirmDesc": "Eindgebruikertoegang stopt direct. De release kan later opnieuw worden geïmplementeerd.", + "deployTab.undeployConfirmDesc": "Het omgevingseindpunt wordt onbeschikbaar, maar de versie wordt niet verwijderd.", "deployTab.undeployConfirmTitle": "Implementatie van {{name}} ongedaan maken?", + "deployTab.undeployConfirmWarning": "Na bevestiging krijgt deze omgeving de status 'implementatie ongedaan maken' en worden acties tijdelijk uitgeschakeld.", "deployTab.viewError": "Fout bekijken", "detail.mobileTabs": "Implementatiesecties", "detail.notFound": "Implementatie niet gevonden", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Niet geïmplementeerd", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Implementatie ongedaan maken", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Onbekend", + "studio.accessPoint.addTriggerNodeDescription": "Voeg een Trigger-node toe om deze app vanuit externe gebeurtenissen te starten.", + "studio.accessPoint.apiDescription": "Roep de app via HTTP aan vanuit je services", + "studio.accessPoint.description": "Alle manieren waarop deze app bereikbaar is in de ingebouwde omgeving en elke geïmplementeerde omgeving.", + "studio.accessPoint.embedIntoSite": "In site insluiten", + "studio.accessPoint.goToPublish": "Naar publiceren", + "studio.accessPoint.mcpDescription": "Stel de app beschikbaar als tool voor agents en AI-apps", + "studio.accessPoint.noPublishedDescription": "Publiceer een versie om ingebouwde toegangspunten beschikbaar te maken.", + "studio.accessPoint.noPublishedTitle": "Nog geen versies gepubliceerd", + "studio.accessPoint.noTriggerNodes": "Geen Trigger-nodes in deze workflow", + "studio.accessPoint.notSupported": "Niet ondersteund", + "studio.accessPoint.open": "Openen", + "studio.accessPoint.triggerDescription": "Voer de workflow uit volgens schema’s of externe gebeurtenissen", + "studio.accessPoint.triggerDisconnected": "Verbinding verbroken", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} ingeschakeld", + "studio.accessPoint.triggerExclusiveNotice": "Web-app, API en MCP zijn uitgeschakeld zolang de workflow triggers gebruikt.", + "studio.accessPoint.triggerMuted": "Gedempt", + "studio.accessPoint.triggerServiceModeUnavailable": "Niet beschikbaar wanneer Web App, API of MCP in gebruik is.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Alleen beschikbaar in de ingebouwde omgeving.", + "studio.accessPoint.webAppDescription": "Deel de app als webpagina met je eindgebruikers", + "studio.accessPoints": "Toegangspunten", + "studio.activity.deploy": "{{target}} implementeren", + "studio.activity.deployFailed": "Implementatie van {{target}} mislukt", + "studio.activity.deploySucceeded": "{{target}} is geïmplementeerd", + "studio.activity.meta": "door {{name}} · {{time}}", + "studio.allVersions": "Alle versies", + "studio.builtInDescription": "Voert altijd de laatst gepubliceerde versie uit", + "studio.builtInTitle": "Ingebouwde omgeving", + "studio.changeVersion": "Versie wijzigen", + "studio.chooseVersionToDeploy": "Kies een versie om te implementeren", + "studio.current": "Huidig", + "studio.deployAnotherVersion": "Andere versie implementeren", + "studio.deployConfiguration": "Implementatieconfiguratie", + "studio.deployLatest": "Nieuwste versie implementeren", + "studio.description": "Beheer de implementaties van deze app in verschillende omgevingen. Meer omgevingen kunnen worden aangemaakt in het Enterprise-dashboard.", + "studio.emptyDescription": "Implementeer een gepubliceerde versie in een omgeving; deze verschijnt dan hier.", + "studio.environmentVariablesDescription": "Gebruik de waarde uit de versie die je implementeert, behoud de laatst geïmplementeerde waarde of voer een aangepaste waarde in.", + "studio.environments": "Omgevingen", + "studio.environmentsInUse": "{{used}} van {{total}} omgevingen in gebruik", + "studio.lastActivity": "Laatste activiteit", + "studio.liveVersion": "Liveversie", + "studio.moreEnvironments": "Meer omgevingen", + "studio.noCredentialsYet": "Nog geen inloggegevens", + "studio.noCredentialsYetDescription": "Voeg inloggegevens toe in Integraties of de knooppuntinstellingen", + "studio.precheck.description": "Deze versie bevat knooppunttypen die nog niet worden ondersteund:", + "studio.precheck.supportMessage": "Ondersteuning voor deze knooppunttypen komt in een toekomstige release.", + "studio.precheck.title": "Deze versie kan niet in deze omgeving worden geïmplementeerd", + "studio.publisher.deployingVersion": "Implementeren: {{version}}", + "studio.publisher.noPublishedDescription": "Publiceer je concept om het hier te implementeren.", + "studio.publisher.notDeployedYet": "Nog niet geïmplementeerd", + "studio.retryVersion": "{{version}} opnieuw proberen", + "studio.undeployConfirmDesc": "De app wordt niet langer uitgevoerd in deze omgeving en alle toegangspunten worden onbeschikbaar. De geïmplementeerde versie wordt niet verwijderd.", + "studio.undeployConfirmTitle": "Implementatie van {{versionName}} in {{envName}} ongedaan maken", + "studio.updatedAtBy": "Bijgewerkt op {{time}} door {{name}}", + "studio.versionValue": "Versiewaarde", + "studio.versionsBehind_one": "{{count}} versie achter", + "studio.versionsBehind_other": "{{count}} versies achter", "tabs.access.description": "Beheer toegangskanalen en toegangsrechten.", "tabs.access.name": "Toegang", "tabs.api-tokens.description": "Beheer API-tokens en roep instanties aan via HTTP.", diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json index 1b2b567a3f6..628fd064686 100644 --- a/web/i18n/nl-NL/permission-keys.json +++ b/web/i18n/nl-NL/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Toegangsrechten voor app configureren", "app.acl.access_config": "Toegangsrechten voor app configureren", "app.acl.delete": "App verwijderen", + "app.acl.deploy": "App implementeren", "app.acl.edit": "App bewerken en orkestreren", "app.acl.import_export_dsl": "DSL importeren / exporteren", "app.acl.log_and_annotation": "Logs en annotaties openen", diff --git a/web/i18n/nl-NL/tools.json b/web/i18n/nl-NL/tools.json index 6ddd2e0d933..a145b9366cd 100644 --- a/web/i18n/nl-NL/tools.json +++ b/web/i18n/nl-NL/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Parameter purpose and constraints", "mcp.server.modal.parametersTip": "Add descriptions for each parameter to help the LLM understand their purpose and constraints.", "mcp.server.publishTip": "App not published. Please publish the app first.", - "mcp.server.reGen": "Do you want to regenerator server URL?", + "mcp.server.reGen": "Wilt u de server-URL opnieuw genereren? De huidige URL stopt onmiddellijk met werken.", "mcp.server.title": "MCP Server", "mcp.server.url": "Server URL", "mcp.toolItem.noDescription": "No description", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index 67e361d3607..4bc0dce9276 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Antwoord", "common.ImageUploadLegacyTip": "You can now create file type variables in the start form. We will no longer support the image upload feature in the future. ", "common.accessAPIReference": "Access API Reference", + "common.accessPointDescription": "Webapp, API, MCP en triggers configureren", "common.addBlock": "Add Node", "common.addDescription": "Add description...", "common.addFailureBranch": "Add Fail Branch", @@ -169,6 +170,7 @@ "common.currentView": "Current View", "common.currentWorkflow": "Current Workflow", "common.debugAndPreview": "Preview", + "common.deployDescription": "Versies naar omgevingen implementeren", "common.disconnect": "Disconnect", "common.draftSaveFailed": "Opslaan van concept mislukt", "common.duplicate": "Duplicate", @@ -217,11 +219,15 @@ "common.needConnectTip": "This step is not connected to anything", "common.needOutputNode": "The Output node must be added", "common.needStartNode": "At least one start node must be added", + "common.noChanges": "Geen wijzigingen", "common.noHistory": "No History", "common.noVar": "No variable", + "common.notPublishedYet": "Nog niet gepubliceerd", "common.notRunning": "Not running yet", "common.onFailure": "On Failure", "common.openInExplore": "Open in Explore", + "common.openWebApp": "Webapp openen", + "common.openWebAppDescription": "De gepubliceerde app in een nieuw tabblad openen", "common.output": "Output", "common.overwriteAndImport": "Overwrite and Import", "common.parallel": "PARALLEL", @@ -238,10 +244,12 @@ "common.processData": "Process Data", "common.publish": "Publish", "common.publishToMarketplace": "Publiceren op Marketplace", + "common.publishToMarketplaceDescription": "Deze app delen met de community", "common.publishToMarketplaceFailed": "Publiceren op Marketplace mislukt", "common.publishUpdate": "Publish Update", "common.published": "Published", "common.publishedAt": "Published", + "common.publishedBy": "{{time}} gepubliceerd door {{author}}", "common.publishingToMarketplace": "Publiceren...", "common.redo": "Redo", "common.restart": "Restart", @@ -251,6 +259,7 @@ "common.runApp": "Run App", "common.runHistory": "Run History", "common.running": "Running", + "common.savedAt": "Opgeslagen {{time}}", "common.scheduleTriggerRunFailed": "Geplande trigger-uitvoering mislukt", "common.searchVar": "Search variable", "common.setVarValuePlaceholder": "Set variable", @@ -265,6 +274,8 @@ "common.tagBound": "Number of apps using this tag", "common.undo": "Undo", "common.unpublished": "Unpublished", + "common.unpublishedChanges": "Niet-gepubliceerde wijzigingen", + "common.upToDate": "Up-to-date", "common.update": "Update", "common.variableNamePlaceholder": "Variable name", "common.versionHistory": "Version History", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook-debugging mislukt", "common.webhookDebugRequestFailed": "Webhook-debugverzoek mislukt", "common.workflowAsTool": "Workflow as Tool", + "common.workflowAsToolDescription": "Als tool in andere apps gebruiken", "common.workflowAsToolDisabledHint": "Publish the latest workflow and ensure a connected User Input node before configuring it as a tool.", + "common.workflowAsToolReady": "Gereed", + "common.workflowAsToolReconfigure": "Opnieuw configureren", "common.workflowAsToolTip": "Tool reconfiguration is required after the workflow update.", + "common.workflowAsToolUpdateNeeded": "Update vereist", "common.workflowProcess": "Workflow Process", "common.workflowProcessFailed": "Workflowproces mislukt", "common.workflowProcessPaused": "Workflowproces gepauzeerd", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "No available variables", "variableReference.noVarsForOperation": "There are no variables available for assignment with the selected operation.", "versionHistory.action.copyIdSuccess": "ID copied to clipboard", + "versionHistory.action.deleteDeployedVersionError": "Deze workflowversie is geïmplementeerd, of wordt momenteel geïmplementeerd, in een of meer omgevingen. Verwijder de implementatie uit alle omgevingen voordat u de versie verwijdert.", "versionHistory.action.deleteFailure": "Failed to delete version", "versionHistory.action.deleteSuccess": "Version deleted", "versionHistory.action.restoreFailure": "Failed to restore version", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Only yours", "versionHistory.filter.reset": "Reset Filter", "versionHistory.latest": "Latest", + "versionHistory.nameIt": "Naam geven", "versionHistory.nameThisVersion": "Name this version", "versionHistory.releaseNotesPlaceholder": "Describe what changed", "versionHistory.restorationTip": "After version restoration, the current draft will be overwritten.", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index 8100e7b786d..266bbba2868 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Poza użyciem", "agentDetail.access.title": "Punkt dostępu", "agentDetail.access.toggleSurface": "Przełącz dostęp {{name}}", - "agentDetail.access.webApp.accessUrl": "URL dostępu", + "agentDetail.access.webApp.accessUrl": "Adres URL aplikacji webowej", "agentDetail.access.webApp.actions.accessControl": "Kontrola dostępu", "agentDetail.access.webApp.actions.customize": "Niestandardowy frontend", "agentDetail.access.webApp.actions.embedded": "Osadzony", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Odśwież URL dostępu", "agentDetail.access.webApp.showQrCode": "Pokaż kod QR", "agentDetail.access.webApp.ssoEnabled": "SSO włączone", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Aplikacja webowa", "agentDetail.access.workflow.description": "Używany jako węzeł wewnątrz workflow", "agentDetail.access.workflow.empty": "Brak jeszcze odniesień do workflow.", "agentDetail.access.workflow.loadFailed": "Nie udało się załadować odniesień do workflow.", diff --git a/web/i18n/pl-PL/app-api.json b/web/i18n/pl-PL/app-api.json index bd4e25da0ab..a6868166fff 100644 --- a/web/i18n/pl-PL/app-api.json +++ b/web/i18n/pl-PL/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Usunąć ten tajny klucz?", "actionMsg.ok": "OK", "apiKey": "Klucz API", - "apiKeyModal.apiSecretKey": "Tajny klucz API", + "apiKeyModal.apiSecretKey": "Klucz API", "apiKeyModal.apiSecretKeyTips": "Aby zapobiec nadużyciom API, chron swój klucz API. Unikaj używania go jako zwykłego tekstu w kodzie front-end. :)", "apiKeyModal.createNewSecretKey": "Utwórz nowy tajny klucz", "apiKeyModal.created": "UTWORZONY", diff --git a/web/i18n/pl-PL/app-overview.json b/web/i18n/pl-PL/app-overview.json index 1199655ffdc..79bf159a885 100644 --- a/web/i18n/pl-PL/app-overview.json +++ b/web/i18n/pl-PL/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Przejdź do konfiguracji dostawcy modelu", "apiKeyInfo.tryCloud": "Lub wypróbuj wersję chmurową Dify z darmowym limitem", "apiKeyInfo.usedToken": "Zużyty token", - "overview.apiInfo.accessibleAddress": "Punkt końcowy API usługi", + "overview.apiInfo.accessibleAddress": "Koniec API", "overview.apiInfo.doc": "Dokumentacja API", "overview.apiInfo.explanation": "Łatwe do zintegrowania z twoją aplikacją", "overview.apiInfo.title": "API usługi w tle", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Skanuj aplikację udostępniania", "overview.appInfo.qrcode.title": "Kod QR do udostępniania", "overview.appInfo.regenerate": "Wygeneruj ponownie", - "overview.appInfo.regenerateNotice": "Czy chcesz wygenerować ponownie publiczny adres URL?", + "overview.appInfo.regenerateNotice": "Czy chcesz wygenerować ponownie publiczny adres URL? Obecny adres URL natychmiast przestanie działać.", "overview.appInfo.settings.chatColorTheme": "Motyw kolorystyczny czatu", "overview.appInfo.settings.chatColorThemeDesc": "Ustaw motyw kolorystyczny czatu", "overview.appInfo.settings.chatColorThemeInverted": "Odwrócony", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Polityka prywatności", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Wprowadź link do polityki prywatności", "overview.appInfo.settings.more.privacyPolicyTip": "Pomaga odwiedzającym zrozumieć, jakie dane zbiera aplikacja, zobacz Politykę prywatności Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Ustawienia aplikacji internetowej dotyczą wszystkich środowisk.", "overview.appInfo.settings.sso.description": "Wszyscy użytkownicy muszą zalogować się za pomocą logowania jednokrotnego przed użyciem aplikacji internetowej", "overview.appInfo.settings.sso.label": "Uwierzytelnianie logowania jednokrotnego", "overview.appInfo.settings.sso.title": "Logowanie jednokrotne w aplikacji internetowej", @@ -117,10 +118,10 @@ "overview.status.running": "W usłudze", "overview.title": "Przegląd", "overview.triggerInfo.explanation": "Zarządzanie wyzwalaczami przepływu pracy", - "overview.triggerInfo.learnAboutTriggers": "Dowiedz się o wyzwalaczach", + "overview.triggerInfo.learnAboutTriggers": "Dowiedz się więcej o wyzwalaczach", "overview.triggerInfo.noTriggerAdded": "Nie dodano wyzwalacza", "overview.triggerInfo.title": "Czynniki wywołujące", - "overview.triggerInfo.triggerStatusDescription": "Status węzła wyzwalacza pojawia się tutaj. (Może już istnieć w wersji roboczej, obowiązuje po opublikowaniu)", + "overview.triggerInfo.triggerStatusDescription": "Wyzwalacze pojawią się tutaj po opublikowaniu wersji, która je zawiera.", "overview.triggerInfo.triggersAdded": "{{count}} Wyzwalacze dodane", "welcome.enterKeyTip": "wprowadź poniżej swój klucz API OpenAI", "welcome.firstStepTip": "Aby rozpocząć,", diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json index 6ecdd9f68d8..25bd388b8f8 100644 --- a/web/i18n/pl-PL/common.json +++ b/web/i18n/pl-PL/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Rozszerzenia oparte na interfejsie API zapewniają scentralizowane zarządzanie interfejsami API, upraszczając konfigurację dla łatwego użytkowania w aplikacjach Dify.", "apiBasedExtension.type": "Typ", "apiBasedExtensionPage.description": "Rozszerz możliwości modułów Dify przez API — dodaj do aplikacji niestandardową moderację treści lub zewnętrzne narzędzia danych.", + "appMenus.accessPoint": "Punkt dostępu", "appMenus.annotations": "Adnotacje", "appMenus.apiAccess": "Dostęp API", "appMenus.apiAccessTip": "Ta baza wiedzy jest dostępna przez API usługi", + "appMenus.deploy": "Wdróż", "appMenus.logs": "Logi", "appMenus.overview": "Monitorowanie", "appMenus.promptEng": "Orkiestracja", diff --git a/web/i18n/pl-PL/deployments.json b/web/i18n/pl-PL/deployments.json index dffd63ba699..60cc269e37f 100644 --- a/web/i18n/pl-PL/deployments.json +++ b/web/i18n/pl-PL/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Uruchomione (oczekuje synchronizacja)", "deployTab.status.runningWithFailed": "Uruchomione (ostatnie wdrożenie nie powiodło się)", "deployTab.undeploy": "Wycofaj wdrożenie", - "deployTab.undeployConfirmDesc": "Dostęp dla użytkowników końcowych zostanie natychmiast zatrzymany. Wydanie można później ponownie wdrożyć.", - "deployTab.undeployConfirmTitle": "Wycofać wdrożenie z {{name}}?", + "deployTab.undeployConfirmDesc": "Punkt końcowy środowiska stanie się niedostępny, ale wersja nie zostanie usunięta.", + "deployTab.undeployConfirmTitle": "Wycofać wdrożenie {{name}}?", + "deployTab.undeployConfirmWarning": "Po potwierdzeniu to środowisko przejdzie w stan wycofywania wdrożenia, a działania zostaną tymczasowo wyłączone.", "deployTab.viewError": "Zobacz błąd", "detail.mobileTabs": "Sekcje wdrożenia", "detail.notFound": "Nie znaleziono wdrożenia", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Nie wdrożone", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Wycofywanie wdrożenia", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Nieznane", + "studio.accessPoint.addTriggerNodeDescription": "Dodaj węzeł Trigger, aby uruchamiać aplikację ze zdarzeń zewnętrznych.", + "studio.accessPoint.apiDescription": "Wywołuj aplikację z usług przez HTTP", + "studio.accessPoint.description": "Wszystkie sposoby dostępu do tej aplikacji w środowisku wbudowanym i każdym wdrożonym środowisku.", + "studio.accessPoint.embedIntoSite": "Osadź w witrynie", + "studio.accessPoint.goToPublish": "Przejdź do publikowania", + "studio.accessPoint.mcpDescription": "Udostępnij aplikację jako narzędzie dla agentów i aplikacji AI", + "studio.accessPoint.noPublishedDescription": "Opublikuj wersję, aby udostępnić wbudowane punkty dostępu.", + "studio.accessPoint.noPublishedTitle": "Nie opublikowano jeszcze żadnej wersji", + "studio.accessPoint.noTriggerNodes": "Brak węzłów Trigger w tym przepływie", + "studio.accessPoint.notSupported": "Nieobsługiwane", + "studio.accessPoint.open": "Otwórz", + "studio.accessPoint.triggerDescription": "Uruchamiaj przepływ pracy według harmonogramów lub zdarzeń zewnętrznych", + "studio.accessPoint.triggerDisconnected": "Rozłączono", + "studio.accessPoint.triggerEnabledCount": "Włączono {{enabled}}/{{total}}", + "studio.accessPoint.triggerExclusiveNotice": "Aplikacja internetowa, API i MCP są wyłączone, gdy przepływ pracy korzysta z wyzwalaczy.", + "studio.accessPoint.triggerMuted": "Wyciszono", + "studio.accessPoint.triggerServiceModeUnavailable": "Niedostępne, gdy Web App, API lub MCP jest w użyciu.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Dostępne tylko w środowisku wbudowanym.", + "studio.accessPoint.webAppDescription": "Udostępnij aplikację użytkownikom końcowym jako stronę internetową", + "studio.accessPoints": "Punkty dostępu", + "studio.activity.deploy": "Wdróż {{target}}", + "studio.activity.deployFailed": "Nie udało się wdrożyć {{target}}", + "studio.activity.deploySucceeded": "Pomyślnie wdrożono {{target}}", + "studio.activity.meta": "przez {{name}} · {{time}}", + "studio.allVersions": "Wszystkie wersje", + "studio.builtInDescription": "Zawsze uruchamia najnowszą opublikowaną wersję", + "studio.builtInTitle": "Środowisko wbudowane", + "studio.changeVersion": "Zmień wersję", + "studio.chooseVersionToDeploy": "Wybierz wersję do wdrożenia", + "studio.current": "Bieżąca", + "studio.deployAnotherVersion": "Wdróż inną wersję", + "studio.deployConfiguration": "Konfiguracja wdrożenia", + "studio.deployLatest": "Wdróż najnowszą wersję", + "studio.description": "Zarządzaj wdrożeniami tej aplikacji w różnych środowiskach. Więcej środowisk można utworzyć w panelu Enterprise.", + "studio.emptyDescription": "Wdróż opublikowaną wersję w środowisku, a pojawi się tutaj.", + "studio.environmentVariablesDescription": "Użyj wartości z wdrażanej wersji, zachowaj ostatnio wdrożoną wartość lub wprowadź wartość niestandardową.", + "studio.environments": "Środowiska", + "studio.environmentsInUse": "Używane środowiska: {{used}} z {{total}}", + "studio.lastActivity": "Ostatnia aktywność", + "studio.liveVersion": "Wersja aktywna", + "studio.moreEnvironments": "Więcej środowisk", + "studio.noCredentialsYet": "Brak poświadczeń", + "studio.noCredentialsYetDescription": "Dodaj poświadczenia w Integracjach lub ustawieniach węzła", + "studio.precheck.description": "Zawiera ona typy węzłów, które nie są jeszcze obsługiwane:", + "studio.precheck.supportMessage": "Obsługa tych typów węzłów pojawi się w przyszłej wersji.", + "studio.precheck.title": "Nie można wdrożyć tej wersji w tym środowisku", + "studio.publisher.deployingVersion": "Wdrażanie: {{version}}", + "studio.publisher.noPublishedDescription": "Opublikuj wersję roboczą, aby wdrożyć ją tutaj.", + "studio.publisher.notDeployedYet": "Jeszcze nie wdrożono", + "studio.retryVersion": "Ponów {{version}}", + "studio.undeployConfirmDesc": "Aplikacja przestanie działać w tym środowisku, a wszystkie jej punkty dostępu staną się niedostępne. Wdrożona wersja nie zostanie usunięta.", + "studio.undeployConfirmTitle": "Wycofaj wdrożenie {{versionName}} ze środowiska {{envName}}", + "studio.updatedAtBy": "Zaktualizowano {{time}} przez {{name}}", + "studio.versionValue": "Wartość wersji", + "studio.versionsBehind_one": "{{count}} wersja wstecz", + "studio.versionsBehind_other": "{{count}} wersji wstecz", "tabs.access.description": "Zarządzaj kanałami dostępu i uprawnieniami dostępu.", "tabs.access.name": "Dostęp", "tabs.api-tokens.description": "Zarządzaj Tokenami API i wywołuj instancje przez HTTP.", diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json index bf5883470dc..2801ce2650d 100644 --- a/web/i18n/pl-PL/permission-keys.json +++ b/web/i18n/pl-PL/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", "app.acl.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", "app.acl.delete": "Usuń aplikację", + "app.acl.deploy": "Wdróż aplikację", "app.acl.edit": "Edytuj i orkiestruj aplikację", "app.acl.import_export_dsl": "Importuj / eksportuj DSL", "app.acl.log_and_annotation": "Dostęp do logów i adnotacji", diff --git a/web/i18n/pl-PL/tools.json b/web/i18n/pl-PL/tools.json index 24ccaa996b0..4d4f0add225 100644 --- a/web/i18n/pl-PL/tools.json +++ b/web/i18n/pl-PL/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Cel i ograniczenia parametru", "mcp.server.modal.parametersTip": "Dodaj opisy każdego parametru, aby pomóc LLM zrozumieć ich cel i ograniczenia.", "mcp.server.publishTip": "Aplikacja nieopublikowana. Najpierw opublikuj aplikację.", - "mcp.server.reGen": "Wygenerować ponownie URL serwera?", + "mcp.server.reGen": "Wygenerować ponownie URL serwera? Obecny URL natychmiast przestanie działać.", "mcp.server.title": "Serwer MCP", "mcp.server.url": "URL serwera", "mcp.toolItem.noDescription": "Brak opisu", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 4f19a5ba048..92379a1b068 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Teraz można tworzyć zmienne typu pliku w formularzu startowym. W przyszłości nie będziemy już obsługiwać funkcji przesyłania obrazów.", "common.accessAPIReference": "Uzyskaj dostęp do dokumentacji API", + "common.accessPointDescription": "Skonfiguruj aplikację internetową, API, MCP i wyzwalacze", "common.addBlock": "Dodaj węzeł", "common.addDescription": "Dodaj opis...", "common.addFailureBranch": "Dodawanie gałęzi niepowodzenia", @@ -169,6 +170,7 @@ "common.currentView": "Bieżący widok", "common.currentWorkflow": "Bieżący przepływ pracy", "common.debugAndPreview": "Podgląd", + "common.deployDescription": "Wdrażaj wersje w środowiskach", "common.disconnect": "Odłączyć", "common.draftSaveFailed": "Nie udało się zapisać wersji roboczej", "common.duplicate": "Duplikuj", @@ -217,11 +219,15 @@ "common.needConnectTip": "Ten krok nie jest połączony z niczym", "common.needOutputNode": "Należy dodać węzeł wyjściowy", "common.needStartNode": "Należy dodać co najmniej jeden węzeł początkowy", + "common.noChanges": "Brak zmian", "common.noHistory": "Brak historii", "common.noVar": "Brak zmiennej", + "common.notPublishedYet": "Jeszcze nie opublikowano", "common.notRunning": "Jeszcze nie uruchomiono", "common.onFailure": "W przypadku niepowodzenia", "common.openInExplore": "Otwieranie w obszarze Eksploruj", + "common.openWebApp": "Otwórz aplikację internetową", + "common.openWebAppDescription": "Otwórz opublikowaną aplikację w nowej karcie", "common.output": "Wyjście", "common.overwriteAndImport": "Nadpisywanie i importowanie", "common.parallel": "RÓWNOLEGŁY", @@ -238,10 +244,12 @@ "common.processData": "Przetwórz dane", "common.publish": "Opublikuj", "common.publishToMarketplace": "Publikuj na Marketplace", + "common.publishToMarketplaceDescription": "Udostępnij tę aplikację społeczności", "common.publishToMarketplaceFailed": "Nie udało się opublikować na Marketplace", "common.publishUpdate": "Opublikuj aktualizację", "common.published": "Opublikowane", "common.publishedAt": "Opublikowane", + "common.publishedBy": "Opublikowano {{time}} przez {{author}}", "common.publishingToMarketplace": "Publikowanie...", "common.redo": "Ponów", "common.restart": "Uruchom ponownie", @@ -251,6 +259,7 @@ "common.runApp": "Uruchom aplikację", "common.runHistory": "Historia uruchomień", "common.running": "Uruchamianie", + "common.savedAt": "Zapisano {{time}}", "common.scheduleTriggerRunFailed": "Niepowodzenie uruchomienia zaplanowanego wyzwalacza", "common.searchVar": "Szukaj zmiennej", "common.setVarValuePlaceholder": "Ustaw zmienną", @@ -265,6 +274,8 @@ "common.tagBound": "Liczba aplikacji korzystających z tego tagu", "common.undo": "Cofnij", "common.unpublished": "Nieopublikowane", + "common.unpublishedChanges": "Nieopublikowane zmiany", + "common.upToDate": "Aktualne", "common.update": "Aktualizuj", "common.variableNamePlaceholder": "Nazwa zmiennej", "common.versionHistory": "Historia wersji", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Niepowodzenie debugowania Webhook", "common.webhookDebugRequestFailed": "Niepowodzenie żądania debugowania Webhook", "common.workflowAsTool": "Przepływ pracy jako narzędzie", + "common.workflowAsToolDescription": "Używaj jako narzędzia w innych aplikacjach", "common.workflowAsToolDisabledHint": "Opublikuj najnowszy przepływ pracy i upewnij się, że węzeł Wprowadzanie danych użytkownika jest połączony przed skonfigurowaniem go jako narzędzie.", + "common.workflowAsToolReady": "Gotowe", + "common.workflowAsToolReconfigure": "Skonfiguruj ponownie", "common.workflowAsToolTip": "Wymagana rekonfiguracja narzędzia po aktualizacji przepływu pracy.", + "common.workflowAsToolUpdateNeeded": "Wymagana aktualizacja", "common.workflowProcess": "Proces przepływu pracy", "common.workflowProcessFailed": "Proces przepływu pracy nie powiódł się", "common.workflowProcessPaused": "Proces przepływu pracy wstrzymany", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Brak dostępnych zmiennych", "variableReference.noVarsForOperation": "Nie ma dostępnych zmiennych do przypisania do wybranej operacji.", "versionHistory.action.copyIdSuccess": "Identyfikator skopiowany do schowka", + "versionHistory.action.deleteDeployedVersionError": "Ta wersja przepływu pracy jest wdrożona lub właśnie wdrażana w co najmniej jednym środowisku. Wycofaj ją ze wszystkich środowisk przed usunięciem wersji.", "versionHistory.action.deleteFailure": "Nie udało się usunąć wersji", "versionHistory.action.deleteSuccess": "Wersja usunięta", "versionHistory.action.restoreFailure": "Nie udało się przywrócić wersji", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Tylko twój", "versionHistory.filter.reset": "Resetuj filtr", "versionHistory.latest": "Najnowszy", + "versionHistory.nameIt": "Nazwij", "versionHistory.nameThisVersion": "Nazwij tę wersję", "versionHistory.releaseNotesPlaceholder": "Opisz, co się zmieniło", "versionHistory.restorationTip": "Po przywróceniu wersji bieżący szkic zostanie nadpisany.", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index 1d80b31ed70..e7c2bc79a7c 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Fora de serviço", "agentDetail.access.title": "Ponto de acesso", "agentDetail.access.toggleSurface": "Alternar acesso de {{name}}", - "agentDetail.access.webApp.accessUrl": "URL de acesso", + "agentDetail.access.webApp.accessUrl": "URL do aplicativo web", "agentDetail.access.webApp.actions.accessControl": "Controle de Acesso", "agentDetail.access.webApp.actions.customize": "Frontend personalizado", "agentDetail.access.webApp.actions.embedded": "Incorporado", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Atualizar URL de acesso", "agentDetail.access.webApp.showQrCode": "Mostrar QR code", "agentDetail.access.webApp.ssoEnabled": "SSO ativado", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Aplicativo web", "agentDetail.access.workflow.description": "Usado como um nó dentro do workflow", "agentDetail.access.workflow.empty": "Ainda não há referências de workflow.", "agentDetail.access.workflow.loadFailed": "Falha ao carregar as referências de workflow.", diff --git a/web/i18n/pt-BR/app-api.json b/web/i18n/pt-BR/app-api.json index 90301b207d4..6b6e3074cf1 100644 --- a/web/i18n/pt-BR/app-api.json +++ b/web/i18n/pt-BR/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Excluir esta chave secreta?", "actionMsg.ok": "OK", "apiKey": "Chave da API", - "apiKeyModal.apiSecretKey": "Chave Secreta da API", + "apiKeyModal.apiSecretKey": "Chave da API", "apiKeyModal.apiSecretKeyTips": "Para evitar abuso da API, proteja sua Chave da API. Evite usá-la como texto simples no código front-end. :)", "apiKeyModal.createNewSecretKey": "Criar nova Chave Secreta", "apiKeyModal.created": "CRIADA", diff --git a/web/i18n/pt-BR/app-overview.json b/web/i18n/pt-BR/app-overview.json index b6f3ce701db..194112163d0 100644 --- a/web/i18n/pt-BR/app-overview.json +++ b/web/i18n/pt-BR/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Ir para configurar o provedor de modelo", "apiKeyInfo.tryCloud": "Ou experimente a versão em nuvem do Dify com cota gratuita", "apiKeyInfo.usedToken": "Tokens usados", - "overview.apiInfo.accessibleAddress": "Endpoint do Serviço API", + "overview.apiInfo.accessibleAddress": "Endpoint da API", "overview.apiInfo.doc": "Referência da API", "overview.apiInfo.explanation": "Facilmente integrado em sua aplicação", "overview.apiInfo.title": "API de Serviço de Back-end", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Digitalizar e compartilhar o aplicativo", "overview.appInfo.qrcode.title": "Código QR para compartilhar", "overview.appInfo.regenerate": "Regenerar", - "overview.appInfo.regenerateNotice": "Você deseja regenerar a URL pública?", + "overview.appInfo.regenerateNotice": "Você deseja regenerar a URL pública? A URL atual deixará de funcionar imediatamente.", "overview.appInfo.settings.chatColorTheme": "Tema de cor do chatbot", "overview.appInfo.settings.chatColorThemeDesc": "Defina o tema de cor do chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Inve", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Política de Privacidade", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Insira o link da política de privacidade", "overview.appInfo.settings.more.privacyPolicyTip": "Ajuda os visitantes a entender os dados coletados pelo aplicativo, consulte a Política de Privacidade do Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "As configurações do aplicativo web se aplicam a todos os ambientes.", "overview.appInfo.settings.sso.description": "Todos os usuários devem fazer login com SSO antes de usar o web app", "overview.appInfo.settings.sso.label": "Autenticação SSO", "overview.appInfo.settings.sso.title": "web app SSO", @@ -117,10 +118,10 @@ "overview.status.running": "Em serviço", "overview.title": "Visão Geral", "overview.triggerInfo.explanation": "Gerenciamento de gatilho de fluxo de trabalho", - "overview.triggerInfo.learnAboutTriggers": "Saiba mais sobre Gatilhos", + "overview.triggerInfo.learnAboutTriggers": "Saiba mais sobre gatilhos", "overview.triggerInfo.noTriggerAdded": "Nenhum gatilho adicionado", "overview.triggerInfo.title": "Gatilhos", - "overview.triggerInfo.triggerStatusDescription": "O status do nó de gatilho aparece aqui. (Pode já existir no rascunho, entra em vigor após a publicação)", + "overview.triggerInfo.triggerStatusDescription": "Os gatilhos aparecerão aqui depois que você publicar a versão que os contém.", "overview.triggerInfo.triggersAdded": "{{count}} gatilhos adicionados", "welcome.enterKeyTip": "insira sua chave de API OpenAI abaixo", "welcome.firstStepTip": "Para começar,", diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json index ef7bf59de38..c878edbf5ee 100644 --- a/web/i18n/pt-BR/common.json +++ b/web/i18n/pt-BR/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "As extensões de API fornecem gerenciamento centralizado de API, simplificando a configuração para uso fácil em todos os aplicativos da Dify.", "apiBasedExtension.type": "Tipo", "apiBasedExtensionPage.description": "Amplie os recursos dos módulos do Dify via API — adicione moderação de conteúdo personalizada ou ferramentas de dados externas aos seus apps.", + "appMenus.accessPoint": "Ponto de acesso", "appMenus.annotations": "Anotações", "appMenus.apiAccess": "Acesso à API", "appMenus.apiAccessTip": "Esta base de conhecimento é acessível via API de serviço", + "appMenus.deploy": "Implantar", "appMenus.logs": "Logs", "appMenus.overview": "Monitoramento", "appMenus.promptEng": "Orquestrar", @@ -468,7 +470,7 @@ "operation.imageCopied": "Imagem copiada", "operation.imageDownloaded": "Imagem baixada", "operation.in": "em", - "operation.learnMore": "Saiba Mais", + "operation.learnMore": "Saiba mais", "operation.log": "Log", "operation.more": "Mais", "operation.moreActions": "Mais ações", diff --git a/web/i18n/pt-BR/deployments.json b/web/i18n/pt-BR/deployments.json index 12bcb728898..7e0cd9572a2 100644 --- a/web/i18n/pt-BR/deployments.json +++ b/web/i18n/pt-BR/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Em execução (sincronização pendente)", "deployTab.status.runningWithFailed": "Em execução (última implantação falhou)", "deployTab.undeploy": "Desimplantar", - "deployTab.undeployConfirmDesc": "O acesso do usuário final será interrompido imediatamente. A versão pode ser reimplantada posteriormente.", - "deployTab.undeployConfirmTitle": "Desimplantar de {{name}}?", + "deployTab.undeployConfirmDesc": "O endpoint do ambiente ficará indisponível, mas a versão não será excluída.", + "deployTab.undeployConfirmTitle": "Desimplantar {{name}}?", + "deployTab.undeployConfirmWarning": "Após a confirmação, este ambiente entrará no estado de desimplantação e as ações serão temporariamente desabilitadas.", "deployTab.viewError": "Ver erro", "detail.mobileTabs": "Seções da implantação", "detail.notFound": "Implantação não encontrada", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Não implantado", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Desimplantando", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Desconhecido", + "studio.accessPoint.addTriggerNodeDescription": "Adicione um nó Trigger para iniciar este aplicativo a partir de eventos externos.", + "studio.accessPoint.apiDescription": "Chame o aplicativo a partir dos seus serviços via HTTP", + "studio.accessPoint.description": "Todas as formas de acessar este aplicativo no ambiente integrado e em cada ambiente implantado.", + "studio.accessPoint.embedIntoSite": "Incorporar ao site", + "studio.accessPoint.goToPublish": "Ir para publicação", + "studio.accessPoint.mcpDescription": "Exponha o aplicativo como uma ferramenta para agentes e aplicativos de IA", + "studio.accessPoint.noPublishedDescription": "Publique uma versão para disponibilizar os pontos de acesso integrados.", + "studio.accessPoint.noPublishedTitle": "Nenhuma versão publicada ainda", + "studio.accessPoint.noTriggerNodes": "Nenhum nó Trigger neste fluxo de trabalho", + "studio.accessPoint.notSupported": "Não suportado", + "studio.accessPoint.open": "Abrir", + "studio.accessPoint.triggerDescription": "Execute o fluxo de trabalho por agendamentos ou eventos externos", + "studio.accessPoint.triggerDisconnected": "Desconectado", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} ativados", + "studio.accessPoint.triggerExclusiveNotice": "O aplicativo web, a API e o MCP ficam desativados enquanto o fluxo de trabalho usa gatilhos.", + "studio.accessPoint.triggerMuted": "Silenciado", + "studio.accessPoint.triggerServiceModeUnavailable": "Indisponível enquanto Web App, API ou MCP estiver em serviço.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Disponível apenas no ambiente integrado.", + "studio.accessPoint.webAppDescription": "Compartilhe o aplicativo com seus usuários finais como uma página web", + "studio.accessPoints": "Pontos de acesso", + "studio.activity.deploy": "Implantar {{target}}", + "studio.activity.deployFailed": "Falha ao implantar {{target}}", + "studio.activity.deploySucceeded": "{{target}} implantado com sucesso", + "studio.activity.meta": "por {{name}} · {{time}}", + "studio.allVersions": "Todas as versões", + "studio.builtInDescription": "Sempre executa a versão publicada mais recente", + "studio.builtInTitle": "Ambiente integrado", + "studio.changeVersion": "Alterar versão", + "studio.chooseVersionToDeploy": "Escolha uma versão para implantar", + "studio.current": "Atual", + "studio.deployAnotherVersion": "Implantar outra versão", + "studio.deployConfiguration": "Configuração da implantação", + "studio.deployLatest": "Implantar a versão mais recente", + "studio.description": "Gerencie as implantações deste aplicativo em diferentes ambientes. Mais ambientes podem ser criados no Dashboard Enterprise.", + "studio.emptyDescription": "Implante uma versão publicada em um ambiente e ela aparecerá aqui.", + "studio.environmentVariablesDescription": "Use o valor da versão que você está implantando, mantenha o último valor implantado ou insira um valor personalizado.", + "studio.environments": "Ambientes", + "studio.environmentsInUse": "{{used}} de {{total}} ambientes em uso", + "studio.lastActivity": "Última atividade", + "studio.liveVersion": "Versão ativa", + "studio.moreEnvironments": "Mais ambientes", + "studio.noCredentialsYet": "Ainda não há credenciais", + "studio.noCredentialsYetDescription": "Adicione credenciais em Integrações ou nas configurações do nó", + "studio.precheck.description": "Ela contém tipos de nós que ainda não são compatíveis:", + "studio.precheck.supportMessage": "O suporte a esses tipos de nós estará disponível em uma versão futura.", + "studio.precheck.title": "Esta versão não pode ser implantada neste ambiente", + "studio.publisher.deployingVersion": "Implantando: {{version}}", + "studio.publisher.noPublishedDescription": "Publique seu rascunho para implantá-lo aqui.", + "studio.publisher.notDeployedYet": "Ainda não implantado", + "studio.retryVersion": "Tentar {{version}} novamente", + "studio.undeployConfirmDesc": "O aplicativo deixará de ser executado neste ambiente, e todos os seus pontos de acesso ficarão indisponíveis. A versão implantada não será excluída.", + "studio.undeployConfirmTitle": "Desimplantar {{versionName}} de {{envName}}", + "studio.updatedAtBy": "Atualizado em {{time}} por {{name}}", + "studio.versionValue": "Valor da versão", + "studio.versionsBehind_one": "{{count}} versão atrás", + "studio.versionsBehind_other": "{{count}} versões atrás", "tabs.access.description": "Gerencie canais de acesso e permissões de acesso.", "tabs.access.name": "Acesso", "tabs.api-tokens.description": "Gerencie Tokens de API e invoque instâncias por HTTP.", diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json index 9e8580c6284..77f4487ce67 100644 --- a/web/i18n/pt-BR/permission-keys.json +++ b/web/i18n/pt-BR/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Configurar permissões de acesso ao aplicativo", "app.acl.access_config": "Configurar permissões de acesso ao aplicativo", "app.acl.delete": "Excluir aplicativo", + "app.acl.deploy": "Implantar aplicativo", "app.acl.edit": "Editar e orquestrar aplicativo", "app.acl.import_export_dsl": "Importar / exportar DSL", "app.acl.log_and_annotation": "Acessar logs e anotações", diff --git a/web/i18n/pt-BR/tools.json b/web/i18n/pt-BR/tools.json index eff6774c235..2f1afe9bc8a 100644 --- a/web/i18n/pt-BR/tools.json +++ b/web/i18n/pt-BR/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Propósito e restrições do parâmetro", "mcp.server.modal.parametersTip": "Adicione descrições para cada parâmetro para ajudar o LLM a entender seus propósitos e restrições.", "mcp.server.publishTip": "Aplicativo não publicado. Por favor, publique o aplicativo primeiro.", - "mcp.server.reGen": "Você deseja regenerar a URL do servidor?", + "mcp.server.reGen": "Você deseja regenerar a URL do servidor? A URL atual deixará de funcionar imediatamente.", "mcp.server.title": "Servidor MCP", "mcp.server.url": "URL do Servidor", "mcp.toolItem.noDescription": "Sem descrição", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index 639170b4707..5e4aa9c9ea1 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Agora você pode criar variáveis de tipo de arquivo no formulário inicial. Não daremos mais suporte ao recurso de upload de imagens no futuro.", "common.accessAPIReference": "Acessar referência da API", + "common.accessPointDescription": "Configurar aplicativo web, API, MCP e gatilhos", "common.addBlock": "Adicionar Nó", "common.addDescription": "Adicionar descrição...", "common.addFailureBranch": "Adicionar ramificação com falha", @@ -169,6 +170,7 @@ "common.currentView": "Visualização atual", "common.currentWorkflow": "Fluxo de trabalho atual", "common.debugAndPreview": "Visualizar", + "common.deployDescription": "Implantar versões nos ambientes", "common.disconnect": "Desligar", "common.draftSaveFailed": "Falha ao salvar o rascunho", "common.duplicate": "Duplicar", @@ -217,11 +219,15 @@ "common.needConnectTip": "Este passo não está conectado a nada", "common.needOutputNode": "O nó de Saída deve ser adicionado", "common.needStartNode": "Pelo menos um nó inicial deve ser adicionado", + "common.noChanges": "Nenhuma alteração", "common.noHistory": "Sem História", "common.noVar": "Sem variável", + "common.notPublishedYet": "Ainda não publicado", "common.notRunning": "Ainda não está em execução", "common.onFailure": "Em caso de falha", "common.openInExplore": "Abrir no Explore", + "common.openWebApp": "Abrir aplicativo web", + "common.openWebAppDescription": "Abrir o aplicativo publicado em uma nova guia", "common.output": "Saída", "common.overwriteAndImport": "Substituir e importar", "common.parallel": "PARALELO", @@ -238,10 +244,12 @@ "common.processData": "Processar dados", "common.publish": "Publicar", "common.publishToMarketplace": "Publicar no Marketplace", + "common.publishToMarketplaceDescription": "Compartilhar este aplicativo com a comunidade", "common.publishToMarketplaceFailed": "Falha ao publicar no Marketplace", "common.publishUpdate": "Publicar Atualização", "common.published": "Publicado", "common.publishedAt": "Publicado em", + "common.publishedBy": "Publicado {{time}} por {{author}}", "common.publishingToMarketplace": "Publicando...", "common.redo": "Refazer", "common.restart": "Reiniciar", @@ -251,6 +259,7 @@ "common.runApp": "Executar aplicativo", "common.runHistory": "Histórico de execução", "common.running": "Executando", + "common.savedAt": "Salvo {{time}}", "common.scheduleTriggerRunFailed": "Falha na execução do gatilho agendado", "common.searchVar": "Buscar variável", "common.setVarValuePlaceholder": "Definir valor da variável", @@ -265,6 +274,8 @@ "common.tagBound": "Número de aplicativos usando esta tag", "common.undo": "Desfazer", "common.unpublished": "Não publicado", + "common.unpublishedChanges": "Alterações não publicadas", + "common.upToDate": "Atualizado", "common.update": "Atualizar", "common.variableNamePlaceholder": "Nome da variável", "common.versionHistory": "Histórico de Versão", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Falha na depuração do Webhook", "common.webhookDebugRequestFailed": "Falha na solicitação de depuração do Webhook", "common.workflowAsTool": "Fluxo de trabalho como ferramenta", + "common.workflowAsToolDescription": "Usar como ferramenta em outros aplicativos", "common.workflowAsToolDisabledHint": "Publique o fluxo de trabalho mais recente e garanta que haja um nó de Entrada do Usuário conectado antes de configurá-lo como uma ferramenta.", + "common.workflowAsToolReady": "Pronto", + "common.workflowAsToolReconfigure": "Reconfigurar", "common.workflowAsToolTip": "É necessária a reconfiguração da ferramenta após a atualização do fluxo de trabalho.", + "common.workflowAsToolUpdateNeeded": "Atualização necessária", "common.workflowProcess": "Processo de fluxo de trabalho", "common.workflowProcessFailed": "Processo de fluxo de trabalho falhou", "common.workflowProcessPaused": "Processo de fluxo de trabalho pausado", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Nenhuma variável disponível", "variableReference.noVarsForOperation": "Não há variáveis disponíveis para atribuição com a operação selecionada.", "versionHistory.action.copyIdSuccess": "ID copiado para a área de transferência", + "versionHistory.action.deleteDeployedVersionError": "Esta versão do workflow está implantada, ou sendo implantada, em um ou mais ambientes. Remova-a de todos os ambientes antes de excluir a versão.", "versionHistory.action.deleteFailure": "Falha ao deletar versão", "versionHistory.action.deleteSuccess": "Versão excluída", "versionHistory.action.restoreFailure": "Falha ao restaurar versão", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Somente seu", "versionHistory.filter.reset": "Redefinir Filtro", "versionHistory.latest": "Último", + "versionHistory.nameIt": "Dê um nome", "versionHistory.nameThisVersion": "Nomeie esta versão", "versionHistory.releaseNotesPlaceholder": "Descreva o que mudou", "versionHistory.restorationTip": "Após a restauração da versão, o rascunho atual será substituído.", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index f403cf18623..952de591fbb 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Scos din serviciu", "agentDetail.access.title": "Punct de acces", "agentDetail.access.toggleSurface": "Comută accesul pentru {{name}}", - "agentDetail.access.webApp.accessUrl": "URL de acces", + "agentDetail.access.webApp.accessUrl": "URL aplicație web", "agentDetail.access.webApp.actions.accessControl": "Controlul Accesului", "agentDetail.access.webApp.actions.customize": "Frontend personalizat", "agentDetail.access.webApp.actions.embedded": "Încorporat", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Reîmprospătează URL-ul de acces", "agentDetail.access.webApp.showQrCode": "Afișează codul QR", "agentDetail.access.webApp.ssoEnabled": "SSO activat", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Aplicație web", "agentDetail.access.workflow.description": "Utilizat ca nod în cadrul workflow-ului", "agentDetail.access.workflow.empty": "Nu există încă referințe de workflow.", "agentDetail.access.workflow.loadFailed": "Încărcarea referințelor workflow-ului a eșuat.", diff --git a/web/i18n/ro-RO/app-api.json b/web/i18n/ro-RO/app-api.json index 0dc85d30b5f..0df62dd3c58 100644 --- a/web/i18n/ro-RO/app-api.json +++ b/web/i18n/ro-RO/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Ștergeți această cheie secretă?", "actionMsg.ok": "OK", "apiKey": "Cheia API", - "apiKeyModal.apiSecretKey": "Cheia secretă API", + "apiKeyModal.apiSecretKey": "Cheia API", "apiKeyModal.apiSecretKeyTips": "Pentru a preveni abuzul API, protejați-vă Cheia API. Evitați utilizarea ei ca text simplu în codul front-end. :)", "apiKeyModal.createNewSecretKey": "Creează o nouă cheie secretă", "apiKeyModal.created": "CREATĂ", diff --git a/web/i18n/ro-RO/app-overview.json b/web/i18n/ro-RO/app-overview.json index d7576cdb9ff..5b03d6a9b79 100644 --- a/web/i18n/ro-RO/app-overview.json +++ b/web/i18n/ro-RO/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Mergeți la configurarea furnizorului de modele", "apiKeyInfo.tryCloud": "Sau încercați versiunea cloud a Dify cu cotă gratuită", "apiKeyInfo.usedToken": "Token utilizat", - "overview.apiInfo.accessibleAddress": "Punct final API serviciu", + "overview.apiInfo.accessibleAddress": "Endpoint API", "overview.apiInfo.doc": "Referință API", "overview.apiInfo.explanation": "Ușor de integrat în aplicația dvs.", "overview.apiInfo.title": "API serviciu backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Scanați pentru a partaja aplicația", "overview.appInfo.qrcode.title": "Cod QR pentru partajare", "overview.appInfo.regenerate": "Regenerare", - "overview.appInfo.regenerateNotice": "Doriți să regenerați URL-ul public?", + "overview.appInfo.regenerateNotice": "Doriți să regenerați URL-ul public? URL-ul actual va înceta să funcționeze imediat.", "overview.appInfo.settings.chatColorTheme": "Tema de culoare a chatului", "overview.appInfo.settings.chatColorThemeDesc": "Setați tema de culoare a chatbotului", "overview.appInfo.settings.chatColorThemeInverted": "Inversat", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Politica de confidențialitate", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Introduceți link-ul politicii de confidențialitate", "overview.appInfo.settings.more.privacyPolicyTip": "Ajută vizitatorii să înțeleagă datele pe care le colectează aplicația, consultați Politica de confidențialitate a Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Setările aplicației web se aplică tuturor mediilor.", "overview.appInfo.settings.sso.description": "Toți utilizatorii trebuie să se conecteze cu SSO înainte de a utiliza web app", "overview.appInfo.settings.sso.label": "Autentificare SSO", "overview.appInfo.settings.sso.title": "web app SSO", @@ -117,10 +118,10 @@ "overview.status.running": "În service", "overview.title": "Prezentare generală", "overview.triggerInfo.explanation": "Gestionarea declanșatoarelor de flux de lucru", - "overview.triggerInfo.learnAboutTriggers": "Află despre Declanșatoare", + "overview.triggerInfo.learnAboutTriggers": "Află mai multe despre declanșatoare", "overview.triggerInfo.noTriggerAdded": "Niciun declanșator adăugat", "overview.triggerInfo.title": "Declanșatoare", - "overview.triggerInfo.triggerStatusDescription": "Starea nodului declanșator apare aici. (Poate exista deja în draft, devine efectivă după publicare)", + "overview.triggerInfo.triggerStatusDescription": "Declanșatoarele apar aici după ce publicați versiunea care le conține.", "overview.triggerInfo.triggersAdded": "{{count}} Declanșatoare adăugate", "welcome.enterKeyTip": "introduceți cheia API OpenAI mai jos", "welcome.firstStepTip": "Pentru a începe,", diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json index 5ba30d74052..e63da3fa2c8 100644 --- a/web/i18n/ro-RO/common.json +++ b/web/i18n/ro-RO/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Extensiile bazate pe API oferă o gestionare centralizată a API-urilor, simplificând configurația pentru o utilizare ușoară în aplicațiile Dify.", "apiBasedExtension.type": "Tip", "apiBasedExtensionPage.description": "Extinde capabilitățile modulelor Dify prin API — adaugă în aplicații moderare de conținut personalizată sau instrumente externe de date.", + "appMenus.accessPoint": "Punct de acces", "appMenus.annotations": "Anotări", "appMenus.apiAccess": "Acces API", "appMenus.apiAccessTip": "Această bază de cunoștințe este accesibilă prin API-ul serviciului", + "appMenus.deploy": "Implementează", "appMenus.logs": "Jurnale", "appMenus.overview": "Monitorizare", "appMenus.promptEng": "Orchestrare", diff --git a/web/i18n/ro-RO/deployments.json b/web/i18n/ro-RO/deployments.json index e3012e47fee..761887f85d4 100644 --- a/web/i18n/ro-RO/deployments.json +++ b/web/i18n/ro-RO/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Rulează (sincronizare în așteptare)", "deployTab.status.runningWithFailed": "Rulează (ultima implementare a eșuat)", "deployTab.undeploy": "Anulează implementarea", - "deployTab.undeployConfirmDesc": "Accesul utilizatorilor finali se va opri imediat. Versiunea poate fi reimplementată mai târziu.", - "deployTab.undeployConfirmTitle": "Anulezi implementarea din {{name}}?", + "deployTab.undeployConfirmDesc": "Endpointul mediului va deveni indisponibil, dar versiunea nu va fi ștearsă.", + "deployTab.undeployConfirmTitle": "Anulezi implementarea {{name}}?", + "deployTab.undeployConfirmWarning": "După confirmare, acest mediu va intra în starea de anulare a implementării, iar acțiunile vor fi dezactivate temporar.", "deployTab.viewError": "Vezi eroarea", "detail.mobileTabs": "Secțiuni implementare", "detail.notFound": "Implementarea nu a fost găsită", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Neimplementat", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Se anulează implementarea", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Necunoscut", + "studio.accessPoint.addTriggerNodeDescription": "Adaugă un nod Trigger pentru a porni aplicația din evenimente externe.", + "studio.accessPoint.apiDescription": "Apelează aplicația din serviciile tale prin HTTP", + "studio.accessPoint.description": "Toate modalitățile prin care această aplicație poate fi accesată în mediul integrat și în fiecare mediu implementat.", + "studio.accessPoint.embedIntoSite": "Încorporează în site", + "studio.accessPoint.goToPublish": "Mergi la publicare", + "studio.accessPoint.mcpDescription": "Expune aplicația ca instrument pentru agenți și aplicații AI", + "studio.accessPoint.noPublishedDescription": "Publică o versiune pentru a face disponibile punctele de acces încorporate.", + "studio.accessPoint.noPublishedTitle": "Nu există încă versiuni publicate", + "studio.accessPoint.noTriggerNodes": "Nu există noduri Trigger în acest flux", + "studio.accessPoint.notSupported": "Nesuportat", + "studio.accessPoint.open": "Deschide", + "studio.accessPoint.triggerDescription": "Rulează fluxul de lucru după programări sau evenimente externe", + "studio.accessPoint.triggerDisconnected": "Deconectat", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} activate", + "studio.accessPoint.triggerExclusiveNotice": "Aplicația web, API-ul și MCP sunt dezactivate cât timp fluxul de lucru folosește declanșatoare.", + "studio.accessPoint.triggerMuted": "Dezactivat sonor", + "studio.accessPoint.triggerServiceModeUnavailable": "Indisponibil cât timp Web App, API sau MCP este în serviciu.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Disponibil doar în mediul integrat.", + "studio.accessPoint.webAppDescription": "Partajează aplicația cu utilizatorii finali ca pagină web", + "studio.accessPoints": "Puncte de acces", + "studio.activity.deploy": "Implementează {{target}}", + "studio.activity.deployFailed": "Implementarea {{target}} a eșuat", + "studio.activity.deploySucceeded": "{{target}} a fost implementat cu succes", + "studio.activity.meta": "de {{name}} · {{time}}", + "studio.allVersions": "Toate versiunile", + "studio.builtInDescription": "Rulează întotdeauna cea mai recentă versiune publicată", + "studio.builtInTitle": "Mediu integrat", + "studio.changeVersion": "Schimbă versiunea", + "studio.chooseVersionToDeploy": "Alege o versiune de implementat", + "studio.current": "Curent", + "studio.deployAnotherVersion": "Implementează altă versiune", + "studio.deployConfiguration": "Configurarea implementării", + "studio.deployLatest": "Implementează cea mai recentă versiune", + "studio.description": "Gestionează implementările acestei aplicații în diferite medii. Mai multe medii pot fi create în Dashboardul Enterprise.", + "studio.emptyDescription": "Implementează o versiune publicată într-un mediu, iar aceasta va apărea aici.", + "studio.environmentVariablesDescription": "Folosește valoarea din versiunea pe care o implementezi, păstrează ultima valoare implementată sau introdu una personalizată.", + "studio.environments": "Medii", + "studio.environmentsInUse": "{{used}} din {{total}} medii utilizate", + "studio.lastActivity": "Ultima activitate", + "studio.liveVersion": "Versiune activă", + "studio.moreEnvironments": "Mai multe medii", + "studio.noCredentialsYet": "Încă nu există acreditări", + "studio.noCredentialsYetDescription": "Adăugați acreditări în Integrări sau în setările nodului", + "studio.precheck.description": "Conține tipuri de noduri care nu sunt încă acceptate:", + "studio.precheck.supportMessage": "Suportul pentru aceste tipuri de noduri va fi disponibil într-o versiune viitoare.", + "studio.precheck.title": "Această versiune nu poate fi implementată în acest mediu", + "studio.publisher.deployingVersion": "Se implementează: {{version}}", + "studio.publisher.noPublishedDescription": "Publică schița pentru a o implementa aici.", + "studio.publisher.notDeployedYet": "Nu a fost implementat încă", + "studio.retryVersion": "Reîncearcă {{version}}", + "studio.undeployConfirmDesc": "Aplicația nu va mai rula în acest mediu, iar toate punctele sale de acces vor deveni indisponibile. Versiunea implementată nu va fi ștearsă.", + "studio.undeployConfirmTitle": "Anulează implementarea {{versionName}} din {{envName}}", + "studio.updatedAtBy": "Actualizat la {{time}} de {{name}}", + "studio.versionValue": "Valoarea versiunii", + "studio.versionsBehind_one": "{{count}} versiune în urmă", + "studio.versionsBehind_other": "{{count}} versiuni în urmă", "tabs.access.description": "Gestionează canalele de acces și permisiunile de acces.", "tabs.access.name": "Acces", "tabs.api-tokens.description": "Gestionează API Token-urile și apelează instanțele prin HTTP.", diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json index 5a2af95c87f..7c1bc3cd266 100644 --- a/web/i18n/ro-RO/permission-keys.json +++ b/web/i18n/ro-RO/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Configurează permisiunile de acces ale aplicației", "app.acl.access_config": "Configurează permisiunile de acces ale aplicației", "app.acl.delete": "Șterge aplicația", + "app.acl.deploy": "Implementează aplicația", "app.acl.edit": "Editează și orchestrează aplicația", "app.acl.import_export_dsl": "Importă / exportă DSL", "app.acl.log_and_annotation": "Accesează jurnale și adnotări", diff --git a/web/i18n/ro-RO/tools.json b/web/i18n/ro-RO/tools.json index 54ed404b1de..cc54c2ba457 100644 --- a/web/i18n/ro-RO/tools.json +++ b/web/i18n/ro-RO/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Scopul și constrângerile parametrului", "mcp.server.modal.parametersTip": "Adăugați descrieri pentru fiecare parametru pentru a ajuta LLM să înțeleagă scopul și constrângerile.", "mcp.server.publishTip": "Aplicație nepublicată. Publicați aplicația mai întâi.", - "mcp.server.reGen": "Regenerați URL server?", + "mcp.server.reGen": "Doriți să regenerați URL-ul serverului? URL-ul actual va înceta să funcționeze imediat.", "mcp.server.title": "Server MCP", "mcp.server.url": "URL Server", "mcp.toolItem.noDescription": "Fără descriere", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index 62af9c253b9..9ead08090f2 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Acum puteți crea variabile de tip de fișier în formularul de pornire. Nu vom mai accepta funcția de încărcare a imaginilor în viitor.", "common.accessAPIReference": "Accesează referința API", + "common.accessPointDescription": "Configurează aplicația web, API, MCP și declanșatoarele", "common.addBlock": "Adaugă nod", "common.addDescription": "Adaugă descriere...", "common.addFailureBranch": "Adăugare ramură Fail", @@ -169,6 +170,7 @@ "common.currentView": "Vizualizare curentă", "common.currentWorkflow": "Flux de lucru curent", "common.debugAndPreview": "Previzualizare", + "common.deployDescription": "Publică versiunile în medii", "common.disconnect": "Deconecta", "common.draftSaveFailed": "Salvarea schiței a eșuat", "common.duplicate": "Duplică", @@ -217,11 +219,15 @@ "common.needConnectTip": "Acest pas nu este conectat la nimic", "common.needOutputNode": "Nodul de ieșire trebuie adăugat", "common.needStartNode": "Trebuie adăugat cel puțin un nod de start", + "common.noChanges": "Nicio modificare", "common.noHistory": "Fără istorie", "common.noVar": "Fără variabilă", + "common.notPublishedYet": "Nu a fost publicat încă", "common.notRunning": "Încă nu rulează", "common.onFailure": "În caz de eșec", "common.openInExplore": "Deschide în Explorează", + "common.openWebApp": "Deschide aplicația web", + "common.openWebAppDescription": "Deschide aplicația publicată într-o filă nouă", "common.output": "Ieșire", "common.overwriteAndImport": "Suprascriere și import", "common.parallel": "PARALEL", @@ -238,10 +244,12 @@ "common.processData": "Procesează date", "common.publish": "Publică", "common.publishToMarketplace": "Publicați pe Marketplace", + "common.publishToMarketplaceDescription": "Distribuie această aplicație comunității", "common.publishToMarketplaceFailed": "Eroare la publicarea pe Marketplace", "common.publishUpdate": "Publicați actualizarea", "common.published": "Publicat", "common.publishedAt": "Publicat la", + "common.publishedBy": "Publicat {{time}} de {{author}}", "common.publishingToMarketplace": "Se publică...", "common.redo": "Refă", "common.restart": "Repornește", @@ -251,6 +259,7 @@ "common.runApp": "Rulează aplicația", "common.runHistory": "Istoric rulări", "common.running": "Rulând", + "common.savedAt": "Salvat {{time}}", "common.scheduleTriggerRunFailed": "Eșec la rularea declanșatorului programat", "common.searchVar": "Caută variabilă", "common.setVarValuePlaceholder": "Setează valoarea variabilei", @@ -265,6 +274,8 @@ "common.tagBound": "Numărul de aplicații care folosesc acest tag", "common.undo": "Anulează", "common.unpublished": "Nepublicat", + "common.unpublishedChanges": "Modificări nepublicate", + "common.upToDate": "La zi", "common.update": "Actualizează", "common.variableNamePlaceholder": "Nume variabilă", "common.versionHistory": "Istoricul versiunilor", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Eșec la depanarea Webhook", "common.webhookDebugRequestFailed": "Eșec la cererea de depanare Webhook", "common.workflowAsTool": "Flux de lucru ca instrument", + "common.workflowAsToolDescription": "Folosește-l ca instrument în alte aplicații", "common.workflowAsToolDisabledHint": "Publicați fluxul de lucru cel mai recent și asigurați-vă că există un nod de Intrare Utilizator conectat înainte de a-l configura ca instrument.", + "common.workflowAsToolReady": "Gata", + "common.workflowAsToolReconfigure": "Reconfigurează", "common.workflowAsToolTip": "Reconfigurarea instrumentului este necesară după actualizarea fluxului de lucru.", + "common.workflowAsToolUpdateNeeded": "Actualizare necesară", "common.workflowProcess": "Proces de flux de lucru", "common.workflowProcessFailed": "Procesul de flux de lucru a eșuat", "common.workflowProcessPaused": "Procesul de flux de lucru este întrerupt", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Nu există variabile disponibile", "variableReference.noVarsForOperation": "Nu există variabile disponibile pentru atribuire cu operațiunea selectată.", "versionHistory.action.copyIdSuccess": "ID copiat în clipboard", + "versionHistory.action.deleteDeployedVersionError": "Această versiune a fluxului de lucru este implementată sau în curs de implementare în unul sau mai multe medii. Retrageți-o din toate mediile înainte de a șterge versiunea.", "versionHistory.action.deleteFailure": "Ștergerea versiunii a eșuat", "versionHistory.action.deleteSuccess": "Versiune ștearsă", "versionHistory.action.restoreFailure": "Restaurarea versiunii a eșuat", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Numai al tău", "versionHistory.filter.reset": "Resetare filtrare", "versionHistory.latest": "Cea mai recentă", + "versionHistory.nameIt": "Denumește", "versionHistory.nameThisVersion": "Numește această versiune", "versionHistory.releaseNotesPlaceholder": "Descrie ce s-a schimbat", "versionHistory.restorationTip": "După restaurarea versiunii, proiectul actual va fi suprascris.", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index e6b1e45bf6f..c4f374b2611 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Не в работе", "agentDetail.access.title": "Точка доступа", "agentDetail.access.toggleSurface": "Переключить доступ {{name}}", - "agentDetail.access.webApp.accessUrl": "URL доступа", + "agentDetail.access.webApp.accessUrl": "URL веб-приложения", "agentDetail.access.webApp.actions.accessControl": "Управление доступом", "agentDetail.access.webApp.actions.customize": "Пользовательский фронтенд", "agentDetail.access.webApp.actions.embedded": "Встроить", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Обновить URL доступа", "agentDetail.access.webApp.showQrCode": "Показать QR-код", "agentDetail.access.webApp.ssoEnabled": "SSO включено", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Веб-приложение", "agentDetail.access.workflow.description": "Используется как узел внутри рабочего процесса", "agentDetail.access.workflow.empty": "Пока нет ссылок на рабочие процессы.", "agentDetail.access.workflow.loadFailed": "Не удалось загрузить ссылки на рабочие процессы.", diff --git a/web/i18n/ru-RU/app-api.json b/web/i18n/ru-RU/app-api.json index 2a8de7fd894..c6600113005 100644 --- a/web/i18n/ru-RU/app-api.json +++ b/web/i18n/ru-RU/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Удалить этот секретный ключ?", "actionMsg.ok": "ОК", "apiKey": "API Ключ", - "apiKeyModal.apiSecretKey": "Секретный ключ API", + "apiKeyModal.apiSecretKey": "API Ключ", "apiKeyModal.apiSecretKeyTips": "Чтобы предотвратить злоупотребление API, защитите свой API ключ. Избегайте использования его в виде plain-текста во фронтенд-коде. :)", "apiKeyModal.createNewSecretKey": "Создать новый секретный ключ", "apiKeyModal.created": "СОЗДАН", diff --git a/web/i18n/ru-RU/app-overview.json b/web/i18n/ru-RU/app-overview.json index d27cce5bd89..4872abdb10c 100644 --- a/web/i18n/ru-RU/app-overview.json +++ b/web/i18n/ru-RU/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Перейти к настройке поставщика модели", "apiKeyInfo.tryCloud": "Или попробуйте облачную версию Dify с бесплатной квотой", "apiKeyInfo.usedToken": "Использованные токены", - "overview.apiInfo.accessibleAddress": "Конечная точка API сервиса", + "overview.apiInfo.accessibleAddress": "API Endpoint", "overview.apiInfo.doc": "Справочник по API", "overview.apiInfo.explanation": "Легко интегрируется в ваше приложение", "overview.apiInfo.title": "API серверной части", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Сканировать, чтобы поделиться", "overview.appInfo.qrcode.title": "QR-код ссылки", "overview.appInfo.regenerate": "Перегенерировать", - "overview.appInfo.regenerateNotice": "Вы хотите перегенерировать публичный URL?", + "overview.appInfo.regenerateNotice": "Вы хотите перегенерировать публичный URL? Текущий URL немедленно перестанет работать.", "overview.appInfo.settings.chatColorTheme": "Цветовая тема чата", "overview.appInfo.settings.chatColorThemeDesc": "Установите цветовую тему чат-бота", "overview.appInfo.settings.chatColorThemeInverted": "Инвертированные цвета", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Политика конфиденциальности", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Введите ссылку на политику конфиденциальности", "overview.appInfo.settings.more.privacyPolicyTip": "Помогает посетителям понять, какие данные собирает приложение, см. Политику конфиденциальности Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Настройки веб-приложения применяются ко всем средам.", "overview.appInfo.settings.sso.description": "Все пользователи должны войти в систему с помощью SSO перед использованием web app", "overview.appInfo.settings.sso.label": "SSO аутентификация", "overview.appInfo.settings.sso.title": "web app SSO", @@ -117,10 +118,10 @@ "overview.status.running": "В работе", "overview.title": "Обзор", "overview.triggerInfo.explanation": "Управление триггерами рабочего процесса", - "overview.triggerInfo.learnAboutTriggers": "Узнайте о триггерах", + "overview.triggerInfo.learnAboutTriggers": "Узнайте больше о триггерах", "overview.triggerInfo.noTriggerAdded": "Триггер не добавлен", "overview.triggerInfo.title": "Триггеры", - "overview.triggerInfo.triggerStatusDescription": "Статус узла триггера отображается здесь. (Может уже существовать в черновике, вступает в силу после публикации)", + "overview.triggerInfo.triggerStatusDescription": "Триггеры появятся здесь после публикации версии, которая их содержит.", "overview.triggerInfo.triggersAdded": "{{count}} триггеров добавлено", "welcome.enterKeyTip": "введите свой ключ API OpenAI ниже", "welcome.firstStepTip": "Чтобы начать,", diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json index 5dc883b8220..05f36d5ad78 100644 --- a/web/i18n/ru-RU/common.json +++ b/web/i18n/ru-RU/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API-расширения обеспечивают централизованное управление API, упрощая настройку для удобного использования в приложениях Dify.", "apiBasedExtension.type": "Тип", "apiBasedExtensionPage.description": "Расширяйте возможности модулей Dify через API — добавляйте в приложения собственную модерацию контента или внешние инструменты данных.", + "appMenus.accessPoint": "Точка доступа", "appMenus.annotations": "Аннотации", "appMenus.apiAccess": "Доступ к API", "appMenus.apiAccessTip": "Эта база знаний доступна через API сервиса", + "appMenus.deploy": "Развернуть", "appMenus.logs": "Журналы", "appMenus.overview": "Мониторинг", "appMenus.promptEng": "Оркестрация", diff --git a/web/i18n/ru-RU/deployments.json b/web/i18n/ru-RU/deployments.json index 4d3ef8ffb3d..25ac24f2b37 100644 --- a/web/i18n/ru-RU/deployments.json +++ b/web/i18n/ru-RU/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Запущено (ожидается синхронизация)", "deployTab.status.runningWithFailed": "Запущено (последнее развертывание не удалось)", "deployTab.undeploy": "Свернуть развертывание", - "deployTab.undeployConfirmDesc": "Доступ конечных пользователей будет немедленно прекращен. Релиз можно будет повторно развернуть позже.", - "deployTab.undeployConfirmTitle": "Свернуть развертывание из {{name}}?", + "deployTab.undeployConfirmDesc": "Конечная точка среды станет недоступна, но версия не будет удалена.", + "deployTab.undeployConfirmTitle": "Отменить развертывание {{name}}?", + "deployTab.undeployConfirmWarning": "После подтверждения эта среда перейдет в состояние отмены развертывания, а действия будут временно недоступны.", "deployTab.viewError": "Просмотр ошибки", "detail.mobileTabs": "Разделы развертывания", "detail.notFound": "Развертывание не найдено", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Не развернуто", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Свертывание развертывания", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Неизвестно", + "studio.accessPoint.addTriggerNodeDescription": "Добавьте узел Trigger, чтобы запускать приложение по внешним событиям.", + "studio.accessPoint.apiDescription": "Вызывайте приложение из своих сервисов по HTTP", + "studio.accessPoint.description": "Все способы доступа к приложению во встроенной среде и каждой развернутой среде.", + "studio.accessPoint.embedIntoSite": "Встроить на сайт", + "studio.accessPoint.goToPublish": "Перейти к публикации", + "studio.accessPoint.mcpDescription": "Предоставьте приложение как инструмент для агентов и ИИ-приложений", + "studio.accessPoint.noPublishedDescription": "Опубликуйте версию, чтобы встроенные точки доступа стали доступны.", + "studio.accessPoint.noPublishedTitle": "Опубликованных версий пока нет", + "studio.accessPoint.noTriggerNodes": "В этом рабочем процессе нет узлов Trigger", + "studio.accessPoint.notSupported": "Не поддерживается", + "studio.accessPoint.open": "Открыть", + "studio.accessPoint.triggerDescription": "Запускайте рабочий процесс по расписанию или внешним событиям", + "studio.accessPoint.triggerDisconnected": "Отключено", + "studio.accessPoint.triggerEnabledCount": "Включено {{enabled}} из {{total}}", + "studio.accessPoint.triggerExclusiveNotice": "Веб-приложение, API и MCP отключены, пока рабочий процесс использует триггеры.", + "studio.accessPoint.triggerMuted": "Без звука", + "studio.accessPoint.triggerServiceModeUnavailable": "Недоступно, пока работает Web App, API или MCP.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Доступно только во встроенной среде.", + "studio.accessPoint.webAppDescription": "Предоставьте приложение конечным пользователям как веб-страницу", + "studio.accessPoints": "Точки доступа", + "studio.activity.deploy": "Развернуть {{target}}", + "studio.activity.deployFailed": "Не удалось развернуть {{target}}", + "studio.activity.deploySucceeded": "{{target}} успешно развернут", + "studio.activity.meta": "{{name}} · {{time}}", + "studio.allVersions": "Все версии", + "studio.builtInDescription": "Всегда запускает последнюю опубликованную версию", + "studio.builtInTitle": "Встроенная среда", + "studio.changeVersion": "Изменить версию", + "studio.chooseVersionToDeploy": "Выберите версию для развертывания", + "studio.current": "Текущая", + "studio.deployAnotherVersion": "Развернуть другую версию", + "studio.deployConfiguration": "Настройка развертывания", + "studio.deployLatest": "Развернуть последнюю версию", + "studio.description": "Управляйте развертываниями этого приложения в разных средах. Дополнительные среды можно создать в панели управления Enterprise.", + "studio.emptyDescription": "Разверните опубликованную версию в среде, и она появится здесь.", + "studio.environmentVariablesDescription": "Используйте значение из развертываемой версии, сохраните последнее развернутое значение или введите собственное.", + "studio.environments": "Среды", + "studio.environmentsInUse": "Используется сред: {{used}} из {{total}}", + "studio.lastActivity": "Последняя активность", + "studio.liveVersion": "Активная версия", + "studio.moreEnvironments": "Другие среды", + "studio.noCredentialsYet": "Учётных данных пока нет", + "studio.noCredentialsYetDescription": "Добавьте учётные данные в разделе «Интеграции» или в настройках узла", + "studio.precheck.description": "Она содержит типы узлов, которые пока не поддерживаются:", + "studio.precheck.supportMessage": "Поддержка этих типов узлов появится в одном из будущих выпусков.", + "studio.precheck.title": "Эту версию нельзя развернуть в этой среде", + "studio.publisher.deployingVersion": "Развертывание: {{version}}", + "studio.publisher.noPublishedDescription": "Опубликуйте черновик, чтобы развернуть его здесь.", + "studio.publisher.notDeployedYet": "Еще не развернуто", + "studio.retryVersion": "Повторить {{version}}", + "studio.undeployConfirmDesc": "Приложение перестанет работать в этой среде, и все его точки доступа станут недоступны. Развернутая версия не будет удалена.", + "studio.undeployConfirmTitle": "Отменить развертывание {{versionName}} в среде {{envName}}", + "studio.updatedAtBy": "Обновлено {{time}}, {{name}}", + "studio.versionValue": "Значение версии", + "studio.versionsBehind_one": "Отставание на {{count}} версию", + "studio.versionsBehind_other": "Отставание на {{count}} версий", "tabs.access.description": "Управление каналами доступа и правами доступа.", "tabs.access.name": "Доступ", "tabs.api-tokens.description": "Управление API Tokens и вызов экземпляров по HTTP.", diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json index 58d20cee32c..40c8513332f 100644 --- a/web/i18n/ru-RU/permission-keys.json +++ b/web/i18n/ru-RU/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Настройка прав доступа к приложению", "app.acl.access_config": "Настройка прав доступа к приложению", "app.acl.delete": "Удаление приложения", + "app.acl.deploy": "Развертывание приложения", "app.acl.edit": "Редактирование и оркестрация приложения", "app.acl.import_export_dsl": "Импорт / экспорт DSL", "app.acl.log_and_annotation": "Доступ к журналам и аннотациям", diff --git a/web/i18n/ru-RU/tools.json b/web/i18n/ru-RU/tools.json index ff1b7db46ae..041225f7480 100644 --- a/web/i18n/ru-RU/tools.json +++ b/web/i18n/ru-RU/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Назначение и ограничения параметра", "mcp.server.modal.parametersTip": "Добавьте описания для каждого параметра, чтобы помочь LLM понять их назначение и ограничения.", "mcp.server.publishTip": "Приложение не опубликовано. Пожалуйста, сначала опубликуйте приложение.", - "mcp.server.reGen": "Хотите регенерировать URL сервера?", + "mcp.server.reGen": "Хотите регенерировать URL сервера? Текущий URL немедленно перестанет работать.", "mcp.server.title": "MCP Сервер", "mcp.server.url": "URL сервера", "mcp.toolItem.noDescription": "Нет описания", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 0074baca189..b30dea9cffd 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Теперь вы можете создавать переменные типа файла в стартовой форме. В будущем мы больше не будем поддерживать функцию загрузки изображений.", "common.accessAPIReference": "Доступ к справочнику API", + "common.accessPointDescription": "Настроить веб-приложение, API, MCP и триггеры", "common.addBlock": "Добавить узел", "common.addDescription": "Добавить описание...", "common.addFailureBranch": "Добавить ветвь Fail", @@ -169,6 +170,7 @@ "common.currentView": "Текущий вид", "common.currentWorkflow": "Текущий рабочий процесс", "common.debugAndPreview": "Предпросмотр", + "common.deployDescription": "Развернуть версии в средах", "common.disconnect": "Разъединять", "common.draftSaveFailed": "Не удалось сохранить черновик", "common.duplicate": "Дублировать", @@ -217,11 +219,15 @@ "common.needConnectTip": "Этот шаг ни к чему не подключен", "common.needOutputNode": "Необходимо добавить узел вывода", "common.needStartNode": "Необходимо добавить хотя бы один начальный узел", + "common.noChanges": "Нет изменений", "common.noHistory": "Без истории", "common.noVar": "Нет переменной", + "common.notPublishedYet": "Ещё не опубликовано", "common.notRunning": "Еще не запущено", "common.onFailure": "О неудаче", "common.openInExplore": "Открыть в разделе «Обзор»", + "common.openWebApp": "Открыть веб-приложение", + "common.openWebAppDescription": "Открыть опубликованное приложение в новой вкладке", "common.output": "Выход", "common.overwriteAndImport": "Перезаписать и импортировать", "common.parallel": "ПАРАЛЛЕЛЬНЫЙ", @@ -238,10 +244,12 @@ "common.processData": "Обработка данных", "common.publish": "Опубликовать", "common.publishToMarketplace": "Опубликовать в Marketplace", + "common.publishToMarketplaceDescription": "Поделиться приложением с сообществом", "common.publishToMarketplaceFailed": "Не удалось опубликовать в Marketplace", "common.publishUpdate": "Опубликовать обновление", "common.published": "Опубликовано", "common.publishedAt": "Опубликовано", + "common.publishedBy": "Опубликовано {{time}} пользователем {{author}}", "common.publishingToMarketplace": "Публикация...", "common.redo": "Повторить", "common.restart": "Перезапустить", @@ -251,6 +259,7 @@ "common.runApp": "Запустить приложение", "common.runHistory": "История запусков", "common.running": "Выполняется", + "common.savedAt": "Сохранено {{time}}", "common.scheduleTriggerRunFailed": "Не удалось выполнить запланированный триггер", "common.searchVar": "Поиск переменной", "common.setVarValuePlaceholder": "Установить значение переменной", @@ -265,6 +274,8 @@ "common.tagBound": "Количество приложений, использующих этот тег", "common.undo": "Отменить", "common.unpublished": "Не опубликовано", + "common.unpublishedChanges": "Неопубликованные изменения", + "common.upToDate": "Актуально", "common.update": "Обновить", "common.variableNamePlaceholder": "Имя переменной", "common.versionHistory": "История версий", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Ошибка отладки Webhook", "common.webhookDebugRequestFailed": "Ошибка запроса отладки Webhook", "common.workflowAsTool": "Рабочий процесс как инструмент", + "common.workflowAsToolDescription": "Использовать как инструмент в других приложениях", "common.workflowAsToolDisabledHint": "Опубликуйте последний рабочий процесс и убедитесь, что подключен узел ввода пользователя, прежде чем настраивать его как инструмент.", + "common.workflowAsToolReady": "Готово", + "common.workflowAsToolReconfigure": "Перенастроить", "common.workflowAsToolTip": "После обновления рабочего процесса требуется перенастройка инструмента.", + "common.workflowAsToolUpdateNeeded": "Требуется обновление", "common.workflowProcess": "Процесс рабочего процесса", "common.workflowProcessFailed": "Процесс рабочего процесса завершился с ошибкой", "common.workflowProcessPaused": "Процесс рабочего процесса приостановлен", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Нет доступных переменных", "variableReference.noVarsForOperation": "Переменные для присвоения выбранной операции отсутствуют.", "versionHistory.action.copyIdSuccess": "ID скопирован в буфер обмена", + "versionHistory.action.deleteDeployedVersionError": "Эта версия рабочего процесса развернута или развертывается в одной или нескольких средах. Перед удалением версии отмените ее развертывание во всех средах.", "versionHistory.action.deleteFailure": "Не удалось удалить версию", "versionHistory.action.deleteSuccess": "Версия удалена", "versionHistory.action.restoreFailure": "Не удалось восстановить версию", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Только твой", "versionHistory.filter.reset": "Сбросить фильтр", "versionHistory.latest": "Последний", + "versionHistory.nameIt": "Назвать", "versionHistory.nameThisVersion": "Назовите эту версию", "versionHistory.releaseNotesPlaceholder": "Опишите, что изменилось", "versionHistory.restorationTip": "После восстановления версии текущий черновик будет перезаписан.", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index f6261a475e4..dcdc8b13181 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Izven uporabe", "agentDetail.access.title": "Dostopna točka", "agentDetail.access.toggleSurface": "Preklopi dostop {{name}}", - "agentDetail.access.webApp.accessUrl": "URL dostopa", + "agentDetail.access.webApp.accessUrl": "URL spletne aplikacije", "agentDetail.access.webApp.actions.accessControl": "Nadzor dostopa", "agentDetail.access.webApp.actions.customize": "Frontend po meri", "agentDetail.access.webApp.actions.embedded": "Vgrajeno", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Osveži URL dostopa", "agentDetail.access.webApp.showQrCode": "Pokaži QR kodo", "agentDetail.access.webApp.ssoEnabled": "SSO omogočeno", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Spletna aplikacija", "agentDetail.access.workflow.description": "Uporabljeno kot vozlišče znotraj poteka dela", "agentDetail.access.workflow.empty": "Še ni sklicev na potek dela.", "agentDetail.access.workflow.loadFailed": "Sklicev na potek dela ni bilo mogoče naložiti.", diff --git a/web/i18n/sl-SI/app-api.json b/web/i18n/sl-SI/app-api.json index 041cb69f015..0170d667ea3 100644 --- a/web/i18n/sl-SI/app-api.json +++ b/web/i18n/sl-SI/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Izbrisati ta skrivni ključ?", "actionMsg.ok": "V redu", "apiKey": "API Ključ", - "apiKeyModal.apiSecretKey": "API Skrivni ključ", + "apiKeyModal.apiSecretKey": "API Ključ", "apiKeyModal.apiSecretKeyTips": "Da bi preprečili zlorabo API-ja, zaščitite svoj API ključ. Izogibajte se uporabi v navadnem besedilu v sprednji kodi. :)", "apiKeyModal.createNewSecretKey": "Ustvari nov skrivni ključ", "apiKeyModal.created": "USTVARJENO", diff --git a/web/i18n/sl-SI/app-overview.json b/web/i18n/sl-SI/app-overview.json index 9b7ae433a51..146d164011d 100644 --- a/web/i18n/sl-SI/app-overview.json +++ b/web/i18n/sl-SI/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Pojdi na nastavitev ponudnika modela", "apiKeyInfo.tryCloud": "Ali preizkusite oblačno različico Dify s prosto kvoto", "apiKeyInfo.usedToken": "Porabljeni žetoni", - "overview.apiInfo.accessibleAddress": "API končna točka storitve", + "overview.apiInfo.accessibleAddress": "Končna točka API-ja", "overview.apiInfo.doc": "API referenca", "overview.apiInfo.explanation": "Enostavna integracija v vašo aplikacijo", "overview.apiInfo.title": "API storitev v ozadju", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Skeniraj za deljenje", "overview.appInfo.qrcode.title": "Povezava QR koda", "overview.appInfo.regenerate": "Ustvari ponovno", - "overview.appInfo.regenerateNotice": "Ali želite ponovno ustvariti javni URL?", + "overview.appInfo.regenerateNotice": "Ali želite ponovno ustvariti javni URL? Trenutni URL bo takoj prenehal delovati.", "overview.appInfo.settings.chatColorTheme": "Barvna tema klepeta", "overview.appInfo.settings.chatColorThemeDesc": "Nastavite barvno temo klepetalnega bota", "overview.appInfo.settings.chatColorThemeInverted": "Inverzna", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Politika zasebnosti", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Vnesite povezavo do politike zasebnosti", "overview.appInfo.settings.more.privacyPolicyTip": "Pomaga obiskovalcem razumeti, katere podatke aplikacija zbira, glejte politiko zasebnosti Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Nastavitve spletne aplikacije veljajo za vsa okolja.", "overview.appInfo.settings.sso.description": "Vsi uporabniki morajo pred uporabo spletne aplikacije opraviti prijavo preko SSO", "overview.appInfo.settings.sso.label": "SSO avtentikacija", "overview.appInfo.settings.sso.title": "SSO spletne aplikacije", @@ -117,10 +118,10 @@ "overview.status.running": "V storitvi", "overview.title": "Pregled", "overview.triggerInfo.explanation": "Upravljanje sprožilcev poteka dela", - "overview.triggerInfo.learnAboutTriggers": "Spoznajte sprožilce", + "overview.triggerInfo.learnAboutTriggers": "Več o sprožilcih", "overview.triggerInfo.noTriggerAdded": "Sprožilec ni dodan", "overview.triggerInfo.title": "Sprožilci", - "overview.triggerInfo.triggerStatusDescription": "Stanje sprožilnega vozlišča se prikaže tukaj. (Morda že obstaja v osnutku, začne veljati po objavi)", + "overview.triggerInfo.triggerStatusDescription": "Sprožilci se tukaj prikažejo po objavi različice, ki jih vsebuje.", "overview.triggerInfo.triggersAdded": "{{count}} sprožilci dodani", "welcome.enterKeyTip": "vnesete svoj OpenAI API ključ spodaj", "welcome.firstStepTip": "Začnite s tem, da", diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json index 89698c465b3..07d933a7b6a 100644 --- a/web/i18n/sl-SI/common.json +++ b/web/i18n/sl-SI/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Razširitve API zagotavljajo centralizirano upravljanje API, kar poenostavlja konfiguracijo za enostavno uporabo v aplikacijah Dify.", "apiBasedExtension.type": "Vrsta", "apiBasedExtensionPage.description": "Razširite zmožnosti modulov Dify prek API-ja — v aplikacije dodajte prilagojeno moderiranje vsebine ali zunanja podatkovna orodja.", + "appMenus.accessPoint": "Dostopna točka", "appMenus.annotations": "Opombe", "appMenus.apiAccess": "Dostop do API-ja", "appMenus.apiAccessTip": "Ta baza znanja je dostopna prek API storitve", + "appMenus.deploy": "Uvedi", "appMenus.logs": "Dnevniki", "appMenus.overview": "Spremljanje", "appMenus.promptEng": "Orkester", diff --git a/web/i18n/sl-SI/deployments.json b/web/i18n/sl-SI/deployments.json index 8c1ab41b93d..735b9178377 100644 --- a/web/i18n/sl-SI/deployments.json +++ b/web/i18n/sl-SI/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Se izvaja (sinhronizacija v teku)", "deployTab.status.runningWithFailed": "Se izvaja (zadnja uvedba ni uspela)", "deployTab.undeploy": "Razveljavi uvedbo", - "deployTab.undeployConfirmDesc": "Dostop končnih uporabnikov se bo takoj ustavil. Izdajo je mogoče ponovno uvesti kasneje.", - "deployTab.undeployConfirmTitle": "Razveljavite uvedbo iz {{name}}?", + "deployTab.undeployConfirmDesc": "Končna točka okolja ne bo več na voljo, vendar različica ne bo izbrisana.", + "deployTab.undeployConfirmTitle": "Razveljavite uvedbo {{name}}?", + "deployTab.undeployConfirmWarning": "Po potrditvi bo to okolje prešlo v stanje razveljavljanja uvedbe, dejanja pa bodo začasno onemogočena.", "deployTab.viewError": "Oglej si napako", "detail.mobileTabs": "Razdelki uvedbe", "detail.notFound": "Uvedba ni najdena", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Ni uvedeno", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Razveljavljanje uvedbe", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Neznano", + "studio.accessPoint.addTriggerNodeDescription": "Dodajte vozlišče Trigger, da aplikacijo zaženete z zunanjimi dogodki.", + "studio.accessPoint.apiDescription": "Pokličite aplikacijo iz svojih storitev prek HTTP", + "studio.accessPoint.description": "Vsi načini dostopa do te aplikacije v vgrajenem in vsakem uvedenem okolju.", + "studio.accessPoint.embedIntoSite": "Vdelaj v spletno mesto", + "studio.accessPoint.goToPublish": "Pojdi na objavo", + "studio.accessPoint.mcpDescription": "Izpostavite aplikacijo kot orodje za agente in aplikacije umetne inteligence", + "studio.accessPoint.noPublishedDescription": "Objavite različico, da omogočite vgrajene dostopne točke.", + "studio.accessPoint.noPublishedTitle": "Objavljena ni še nobena različica", + "studio.accessPoint.noTriggerNodes": "V tem poteku dela ni vozlišč Trigger", + "studio.accessPoint.notSupported": "Ni podprto", + "studio.accessPoint.open": "Odpri", + "studio.accessPoint.triggerDescription": "Zaženite potek dela po urniku ali ob zunanjih dogodkih", + "studio.accessPoint.triggerDisconnected": "Povezava prekinjena", + "studio.accessPoint.triggerEnabledCount": "Omogočeno {{enabled}}/{{total}}", + "studio.accessPoint.triggerExclusiveNotice": "Spletna aplikacija, API in MCP so izklopljeni, medtem ko potek dela uporablja sprožilce.", + "studio.accessPoint.triggerMuted": "Utišano", + "studio.accessPoint.triggerServiceModeUnavailable": "Ni na voljo, ko je Web App, API ali MCP v uporabi.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Na voljo samo v vgrajenem okolju.", + "studio.accessPoint.webAppDescription": "Delite aplikacijo s končnimi uporabniki kot spletno stran", + "studio.accessPoints": "Dostopne točke", + "studio.activity.deploy": "Namesti {{target}}", + "studio.activity.deployFailed": "Namestitev {{target}} ni uspela", + "studio.activity.deploySucceeded": "{{target}} je bil uspešno nameščen", + "studio.activity.meta": "{{name}} · {{time}}", + "studio.allVersions": "Vse različice", + "studio.builtInDescription": "Vedno izvaja najnovejšo objavljeno različico", + "studio.builtInTitle": "Vgrajeno okolje", + "studio.changeVersion": "Spremeni različico", + "studio.chooseVersionToDeploy": "Izberite različico za uvedbo", + "studio.current": "Trenutna", + "studio.deployAnotherVersion": "Namesti drugo različico", + "studio.deployConfiguration": "Konfiguracija uvedbe", + "studio.deployLatest": "Namesti najnovejšo različico", + "studio.description": "Upravljajte uvedbe te aplikacije v različnih okoljih. Dodatna okolja lahko ustvarite na nadzorni plošči Enterprise.", + "studio.emptyDescription": "Objavljeno različico namestite v okolje in prikazana bo tukaj.", + "studio.environmentVariablesDescription": "Uporabite vrednost iz različice, ki jo uvajate, ohranite zadnjo uvedeno vrednost ali vnesite vrednost po meri.", + "studio.environments": "Okolja", + "studio.environmentsInUse": "V uporabi je {{used}} od {{total}} okolij", + "studio.lastActivity": "Zadnja dejavnost", + "studio.liveVersion": "Aktivna različica", + "studio.moreEnvironments": "Več okolij", + "studio.noCredentialsYet": "Poverilnic še ni", + "studio.noCredentialsYetDescription": "Dodajte poverilnice v Integracijah ali nastavitvah vozlišča", + "studio.precheck.description": "Vsebuje vrste vozlišč, ki še niso podprte:", + "studio.precheck.supportMessage": "Podpora za te vrste vozlišč bo na voljo v prihodnji izdaji.", + "studio.precheck.title": "Te različice ni mogoče uvesti v to okolje", + "studio.publisher.deployingVersion": "Uvajanje: {{version}}", + "studio.publisher.noPublishedDescription": "Objavite osnutek, da ga boste lahko uvedli tukaj.", + "studio.publisher.notDeployedYet": "Še ni uvedeno", + "studio.retryVersion": "Poskusi znova za {{version}}", + "studio.undeployConfirmDesc": "Aplikacija se bo v tem okolju prenehala izvajati, vse njene dostopne točke pa bodo postale nedosegljive. Uvedena različica ne bo izbrisana.", + "studio.undeployConfirmTitle": "Razveljavi uvedbo {{versionName}} iz okolja {{envName}}", + "studio.updatedAtBy": "Posodobljeno ob {{time}}, {{name}}", + "studio.versionValue": "Vrednost različice", + "studio.versionsBehind_one": "{{count}} različica zadaj", + "studio.versionsBehind_other": "{{count}} različic zadaj", "tabs.access.description": "Upravljajte kanale dostopa in dovoljenja za dostop.", "tabs.access.name": "Dostop", "tabs.api-tokens.description": "Upravljajte API žetone in kličite instance preko HTTP.", diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json index 10a915a281f..df83b21f21a 100644 --- a/web/i18n/sl-SI/permission-keys.json +++ b/web/i18n/sl-SI/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", "app.acl.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", "app.acl.delete": "Izbriši aplikacijo", + "app.acl.deploy": "Uvedi aplikacijo", "app.acl.edit": "Uredi in orkestriraj aplikacijo", "app.acl.import_export_dsl": "Uvozi / izvozi DSL", "app.acl.log_and_annotation": "Dostop do dnevnikov in opomb", diff --git a/web/i18n/sl-SI/tools.json b/web/i18n/sl-SI/tools.json index d9875975885..9cb9fb3ea83 100644 --- a/web/i18n/sl-SI/tools.json +++ b/web/i18n/sl-SI/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Namen in omejitve parametra", "mcp.server.modal.parametersTip": "Dodajte opise za vsak parameter, da pomagate LLM razumeti njihov namen in omejitve.", "mcp.server.publishTip": "Aplikacija ni objavljena. Najprej objavite aplikacijo.", - "mcp.server.reGen": "Želite ponovno ustvariti URL strežnika?", + "mcp.server.reGen": "Želite ponovno ustvariti URL strežnika? Trenutni URL bo takoj prenehal delovati.", "mcp.server.title": "Strežnik MCP", "mcp.server.url": "URL strežnika", "mcp.toolItem.noDescription": "Brez opisa", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 89728f9075e..a85dfa7bee9 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Zdaj lahko v začetni obliki ustvarite spremenljivke datotečnega tipa. V prihodnje ne bomo več podpirali funkcije nalaganja slik.", "common.accessAPIReference": "Dostop do referenčnega API-ja", + "common.accessPointDescription": "Nastavi spletno aplikacijo, API, MCP in sprožilce", "common.addBlock": "Dodaj vozlišče", "common.addDescription": "Dodajte opis...", "common.addFailureBranch": "Dodaj neuspešno vejo", @@ -169,6 +170,7 @@ "common.currentView": "Trenutni pogled", "common.currentWorkflow": "Trenutni potek dela", "common.debugAndPreview": "Predogled", + "common.deployDescription": "Razmesti različice v okolja", "common.disconnect": "Odklop", "common.draftSaveFailed": "Shranjevanje osnutka ni uspelo", "common.duplicate": "Podvojiti", @@ -217,11 +219,15 @@ "common.needConnectTip": "Ta korak ni povezan z ničemer.", "common.needOutputNode": "Dodati je treba izhodiščno vozlišče", "common.needStartNode": "Dodati je treba vsaj eno začetno vozlišče", + "common.noChanges": "Brez sprememb", "common.noHistory": "Brez zgodovine", "common.noVar": "Brez spremenljivke", + "common.notPublishedYet": "Še ni objavljeno", "common.notRunning": "Še ne teče", "common.onFailure": "O neuspehu", "common.openInExplore": "Odpri v Raziskovanju", + "common.openWebApp": "Odpri spletno aplikacijo", + "common.openWebAppDescription": "Odpri objavljeno aplikacijo v novem zavihku", "common.output": "Izhod", "common.overwriteAndImport": "Prepiši in uvozi", "common.parallel": "PARALELNO", @@ -238,10 +244,12 @@ "common.processData": "Obdelava podatkov", "common.publish": "Objavi", "common.publishToMarketplace": "Objavi na Marketplace", + "common.publishToMarketplaceDescription": "Deli to aplikacijo s skupnostjo", "common.publishToMarketplaceFailed": "Objava na Marketplace ni uspela", "common.publishUpdate": "Objavi posodobitev", "common.published": "Objavljeno", "common.publishedAt": "Objavljeno", + "common.publishedBy": "Objavljeno {{time}}, objavil/-a {{author}}", "common.publishingToMarketplace": "Objavljanje...", "common.redo": "Ponovno naredi", "common.restart": "Znova zaženi", @@ -251,6 +259,7 @@ "common.runApp": "Zaženi aplikacijo", "common.runHistory": "Zgodovina izvajanja", "common.running": "Tek", + "common.savedAt": "Shranjeno {{time}}", "common.scheduleTriggerRunFailed": "Zagon načrtovanega sprožilnika ni uspel", "common.searchVar": "Iskalna spremenljivka", "common.setVarValuePlaceholder": "Nastavi spremenljivko", @@ -265,6 +274,8 @@ "common.tagBound": "Število aplikacij, ki uporabljajo to oznako", "common.undo": "Razveljavi", "common.unpublished": "Nepublikirano", + "common.unpublishedChanges": "Neobjavljene spremembe", + "common.upToDate": "Posodobljeno", "common.update": "Posodobitev", "common.variableNamePlaceholder": "Ime spremenljivke", "common.versionHistory": "Zgodovina različic", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Razhroščevanje Webhook ni uspelo", "common.webhookDebugRequestFailed": "Zahteva za razhroščevanje Webhook ni uspela", "common.workflowAsTool": "Delovni potek kot orodje", + "common.workflowAsToolDescription": "Uporabi kot orodje v drugih aplikacijah", "common.workflowAsToolDisabledHint": "Objavite najnovejše delovne tokove in zagotovite, da je pred konfiguracijo kot orodje povezan vozel za vnos uporabnika.", + "common.workflowAsToolReady": "Pripravljeno", + "common.workflowAsToolReconfigure": "Ponovno konfiguriraj", "common.workflowAsToolTip": "Zaradi posodobitve delovnega poteka je potrebna ponovna konfiguracija orodja.", + "common.workflowAsToolUpdateNeeded": "Potrebna je posodobitev", "common.workflowProcess": "Delovni postopek", "common.workflowProcessFailed": "Delovni postopek ni uspel", "common.workflowProcessPaused": "Delovni postopek je začasno ustavljen", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Ni razpoložljivih spremenljivk.", "variableReference.noVarsForOperation": "Za izbrano operacijo ni nobenih spremenljivk, ki bi jih bilo mogoče dodeliti.", "versionHistory.action.copyIdSuccess": "ID kopiran v odložišče", + "versionHistory.action.deleteDeployedVersionError": "Ta različica delovnega toka je uvedena ali se uvaja v enem ali več okoljih. Pred brisanjem različice jo odstranite iz vseh okolij.", "versionHistory.action.deleteFailure": "Brisanje različice ni uspelo", "versionHistory.action.deleteSuccess": "Različica izbrisana", "versionHistory.action.restoreFailure": "Obnavljanje različice ni uspelo", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Samo tvoje", "versionHistory.filter.reset": "Ponastavi filter", "versionHistory.latest": "Najnovejši", + "versionHistory.nameIt": "Poimenuj", "versionHistory.nameThisVersion": "Poimenujte to različico", "versionHistory.releaseNotesPlaceholder": "Opisujte, kaj se je spremenilo", "versionHistory.restorationTip": "Po obnovitvi različice bo trenutni osnutek prepisan.", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index 20a4b3c7141..08d7caca13c 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "ไม่พร้อมให้บริการ", "agentDetail.access.title": "จุดเข้าถึง", "agentDetail.access.toggleSurface": "สลับการเข้าถึง {{name}}", - "agentDetail.access.webApp.accessUrl": "URL การเข้าถึง", + "agentDetail.access.webApp.accessUrl": "URL เว็บแอป", "agentDetail.access.webApp.actions.accessControl": "การควบคุมการเข้าถึง", "agentDetail.access.webApp.actions.customize": "ฟรอนต์เอนด์ที่กำหนดเอง", "agentDetail.access.webApp.actions.embedded": "ฝัง", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "รีเฟรช URL การเข้าถึง", "agentDetail.access.webApp.showQrCode": "แสดง QR Code", "agentDetail.access.webApp.ssoEnabled": "เปิดใช้งาน SSO", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "เว็บแอป", "agentDetail.access.workflow.description": "ใช้เป็นโหนดภายในเวิร์กโฟลว์", "agentDetail.access.workflow.empty": "ยังไม่มีการอ้างอิงเวิร์กโฟลว์", "agentDetail.access.workflow.loadFailed": "โหลดการอ้างอิงเวิร์กโฟลว์ไม่สำเร็จ", diff --git a/web/i18n/th-TH/app-api.json b/web/i18n/th-TH/app-api.json index 237da080175..26ffb0ff329 100644 --- a/web/i18n/th-TH/app-api.json +++ b/web/i18n/th-TH/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "ลบคีย์ลับนี้?", "actionMsg.ok": "ตกลง, ได้", "apiKey": "คีย์ API", - "apiKeyModal.apiSecretKey": "คีย์ลับ API", + "apiKeyModal.apiSecretKey": "คีย์ API", "apiKeyModal.apiSecretKeyTips": "เพื่อป้องกันการละเมิด API ให้ปกป้องคีย์ API ของคุณ หลีกเลี่ยงการใช้เป็นข้อความธรรมดาในโค้ดส่วนหน้า :)", "apiKeyModal.createNewSecretKey": "สร้างคีย์ลับใหม่", "apiKeyModal.created": "สร้าง", diff --git a/web/i18n/th-TH/app-overview.json b/web/i18n/th-TH/app-overview.json index 0ead8fd9fa8..53190103d89 100644 --- a/web/i18n/th-TH/app-overview.json +++ b/web/i18n/th-TH/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "ไปที่ผู้ให้บริการโมเดลการตั้งค่า", "apiKeyInfo.tryCloud": "หรือลองใช้ Dify เวอร์ชันคลาวด์พร้อมใบเสนอราคาฟรี", "apiKeyInfo.usedToken": "โทเค็นที่ใช้", - "overview.apiInfo.accessibleAddress": "ปลายทาง API บริการ", + "overview.apiInfo.accessibleAddress": "ปลายทาง API", "overview.apiInfo.doc": "การอ้างอิง API", "overview.apiInfo.explanation": "ผสานรวมเข้ากับแอปพลิเคชันของคุณได้อย่างง่ายดาย", "overview.apiInfo.title": "API บริการแบ็กเอนด์", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "สแกนเพื่อแบ่งปัน", "overview.appInfo.qrcode.title": "เชื่อมโยงรหัส QR", "overview.appInfo.regenerate": "สร้างใหม่", - "overview.appInfo.regenerateNotice": "คุณต้องการสร้าง URL สาธารณะใหม่หรือไม่", + "overview.appInfo.regenerateNotice": "คุณต้องการสร้าง URL สาธารณะใหม่หรือไม่ URL ปัจจุบันจะหยุดทำงานทันที", "overview.appInfo.settings.chatColorTheme": "ธีมสีแชท", "overview.appInfo.settings.chatColorThemeDesc": "กําหนดธีมสีของแชทบอท", "overview.appInfo.settings.chatColorThemeInverted": "คว่ำ", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "นโยบายความเป็นส่วนตัว", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "ป้อนลิงก์นโยบายความเป็นส่วนตัว", "overview.appInfo.settings.more.privacyPolicyTip": "ช่วยให้ผู้เยี่ยมชมเข้าใจข้อมูลที่แอปพลิเคชันรวบรวม โปรดดูนโยบายความเป็นส่วนตัวของ Dify", + "overview.appInfo.settings.multiEnvironmentNotice": "การตั้งค่าเว็บแอปจะมีผลกับทุกสภาพแวดล้อม", "overview.appInfo.settings.sso.description": "ผู้ใช้ทุกคนต้องเข้าสู่ระบบด้วย SSO ก่อนใช้ web app", "overview.appInfo.settings.sso.label": "การรับรองความถูกต้องของ SSO", "overview.appInfo.settings.sso.title": "เว็บแอป SSO", @@ -117,10 +118,10 @@ "overview.status.running": "ให้บริการ", "overview.title": "ภาพรวม", "overview.triggerInfo.explanation": "การจัดการทริกเกอร์เวิร์กโฟลว์", - "overview.triggerInfo.learnAboutTriggers": "เรียนรู้เกี่ยวกับทริกเกอร์", + "overview.triggerInfo.learnAboutTriggers": "เรียนรู้เพิ่มเติมเกี่ยวกับทริกเกอร์", "overview.triggerInfo.noTriggerAdded": "ยังไม่ได้เพิ่มทริกเกอร์", "overview.triggerInfo.title": "สิ่งกระตุ้น", - "overview.triggerInfo.triggerStatusDescription": "สถานะของโหนดตัวเรียกปรากฏที่นี่ (อาจมีอยู่แล้วในร่าง และจะมีผลหลังจากเผยแพร่)", + "overview.triggerInfo.triggerStatusDescription": "ทริกเกอร์จะแสดงที่นี่หลังจากที่คุณเผยแพร่เวอร์ชันที่มีทริกเกอร์เหล่านั้น", "overview.triggerInfo.triggersAdded": "{{count}} ทริกเกอร์ที่เพิ่มแล้ว", "welcome.enterKeyTip": "ป้อนคีย์ OpenAI API ของคุณด้านล่าง", "welcome.firstStepTip": "ในการเริ่มต้น", diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json index 5db695482b7..e417613010e 100644 --- a/web/i18n/th-TH/common.json +++ b/web/i18n/th-TH/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "ส่วนขยาย API ให้การจัดการ API แบบรวมศูนย์ ทําให้การกําหนดค่าง่ายขึ้นเพื่อให้ใช้งานได้ง่ายในแอปพลิเคชันของ Dify", "apiBasedExtension.type": "ประเภท", "apiBasedExtensionPage.description": "ขยายความสามารถของโมดูล Dify ผ่าน API — เพิ่มการตรวจสอบเนื้อหาแบบกำหนดเองหรือเครื่องมือข้อมูลภายนอกให้แอปของคุณ", + "appMenus.accessPoint": "จุดเข้าถึง", "appMenus.annotations": "คำ อธิบาย", "appMenus.apiAccess": "การเข้าถึง API", "appMenus.apiAccessTip": "ฐานความรู้นี้สามารถเข้าถึงได้ผ่าน API บริการ", + "appMenus.deploy": "ปรับใช้", "appMenus.logs": "บันทึก", "appMenus.overview": "ตรวจ สอบ", "appMenus.promptEng": "ออเคสตร้า", diff --git a/web/i18n/th-TH/deployments.json b/web/i18n/th-TH/deployments.json index 876c7ea4bd8..50754e20b56 100644 --- a/web/i18n/th-TH/deployments.json +++ b/web/i18n/th-TH/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "กำลังทำงาน (รอการซิงค์)", "deployTab.status.runningWithFailed": "กำลังทำงาน (การปรับใช้ครั้งล่าสุดล้มเหลว)", "deployTab.undeploy": "ยกเลิกการปรับใช้", - "deployTab.undeployConfirmDesc": "การเข้าถึงของผู้ใช้ปลายทางจะหยุดทันที สามารถปรับใช้รีลีสใหม่ได้ภายหลัง", - "deployTab.undeployConfirmTitle": "ยกเลิกการปรับใช้จาก {{name}}?", + "deployTab.undeployConfirmDesc": "ปลายทางของสภาพแวดล้อมจะไม่พร้อมใช้งาน แต่เวอร์ชันจะไม่ถูกลบ", + "deployTab.undeployConfirmTitle": "ยกเลิกการปรับใช้ {{name}}?", + "deployTab.undeployConfirmWarning": "หลังจากยืนยัน สภาพแวดล้อมนี้จะเข้าสู่สถานะกำลังยกเลิกการปรับใช้ และการดำเนินการต่าง ๆ จะถูกปิดใช้งานชั่วคราว", "deployTab.viewError": "ดูข้อผิดพลาด", "detail.mobileTabs": "ส่วนการปรับใช้", "detail.notFound": "ไม่พบการปรับใช้", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "ยังไม่ได้ปรับใช้", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "กำลังยกเลิกการปรับใช้", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "ไม่ทราบ", + "studio.accessPoint.addTriggerNodeDescription": "เพิ่มโหนด Trigger เพื่อเริ่มแอปนี้จากเหตุการณ์ภายนอก", + "studio.accessPoint.apiDescription": "เรียกใช้แอปจากบริการของคุณผ่าน HTTP", + "studio.accessPoint.description": "ทุกช่องทางที่เข้าถึงแอปนี้ได้ในสภาพแวดล้อม Built-in และทุกสภาพแวดล้อมที่ปรับใช้", + "studio.accessPoint.embedIntoSite": "ฝังในเว็บไซต์", + "studio.accessPoint.goToPublish": "ไปที่การเผยแพร่", + "studio.accessPoint.mcpDescription": "เปิดแอปเป็นเครื่องมือสำหรับเอเจนต์และแอป AI", + "studio.accessPoint.noPublishedDescription": "เผยแพร่เวอร์ชันเพื่อให้จุดเชื่อมต่อในตัวพร้อมใช้งาน", + "studio.accessPoint.noPublishedTitle": "ยังไม่มีเวอร์ชันที่เผยแพร่", + "studio.accessPoint.noTriggerNodes": "ไม่มีโหนด Trigger ในเวิร์กโฟลว์นี้", + "studio.accessPoint.notSupported": "ไม่รองรับ", + "studio.accessPoint.open": "เปิด", + "studio.accessPoint.triggerDescription": "เรียกใช้เวิร์กโฟลว์ตามกำหนดเวลาหรือเหตุการณ์ภายนอก", + "studio.accessPoint.triggerDisconnected": "ตัดการเชื่อมต่อ", + "studio.accessPoint.triggerEnabledCount": "เปิดใช้งาน {{enabled}}/{{total}}", + "studio.accessPoint.triggerExclusiveNotice": "Web app, API และ MCP จะถูกปิดใช้งานขณะที่เวิร์กโฟลว์ใช้ทริกเกอร์", + "studio.accessPoint.triggerMuted": "ปิดเสียง", + "studio.accessPoint.triggerServiceModeUnavailable": "ไม่พร้อมใช้งานขณะที่ Web App, API หรือ MCP กำลังให้บริการ", + "studio.accessPoint.unsupportedInDeployedEnvironment": "ใช้งานได้เฉพาะในสภาพแวดล้อม Built-in เท่านั้น", + "studio.accessPoint.webAppDescription": "แชร์แอปกับผู้ใช้ปลายทางในรูปแบบหน้าเว็บ", + "studio.accessPoints": "จุดเข้าถึง", + "studio.activity.deploy": "ปรับใช้ {{target}}", + "studio.activity.deployFailed": "ปรับใช้ {{target}} ไม่สำเร็จ", + "studio.activity.deploySucceeded": "ปรับใช้ {{target}} สำเร็จ", + "studio.activity.meta": "โดย {{name}} · {{time}}", + "studio.allVersions": "เวอร์ชันทั้งหมด", + "studio.builtInDescription": "เรียกใช้เวอร์ชันที่เผยแพร่ล่าสุดเสมอ", + "studio.builtInTitle": "สภาพแวดล้อมในตัว", + "studio.changeVersion": "เปลี่ยนเวอร์ชัน", + "studio.chooseVersionToDeploy": "เลือกเวอร์ชันที่จะปรับใช้", + "studio.current": "ปัจจุบัน", + "studio.deployAnotherVersion": "ปรับใช้เวอร์ชันอื่น", + "studio.deployConfiguration": "การกำหนดค่าการปรับใช้", + "studio.deployLatest": "ปรับใช้เวอร์ชันล่าสุด", + "studio.description": "จัดการการปรับใช้แอปนี้ในสภาพแวดล้อมต่าง ๆ สามารถสร้างสภาพแวดล้อมเพิ่มเติมได้ใน Enterprise Dashboard", + "studio.emptyDescription": "ปรับใช้เวอร์ชันที่เผยแพร่กับสภาพแวดล้อม แล้วเวอร์ชันนั้นจะแสดงที่นี่", + "studio.environmentVariablesDescription": "ใช้ค่าจากเวอร์ชันที่คุณกำลังปรับใช้ เก็บค่าที่ปรับใช้ล่าสุดไว้ หรือป้อนค่าที่กำหนดเอง", + "studio.environments": "สภาพแวดล้อม", + "studio.environmentsInUse": "ใช้งาน {{used}} จาก {{total}} สภาพแวดล้อม", + "studio.lastActivity": "กิจกรรมล่าสุด", + "studio.liveVersion": "เวอร์ชันที่ใช้งาน", + "studio.moreEnvironments": "สภาพแวดล้อมเพิ่มเติม", + "studio.noCredentialsYet": "ยังไม่มีข้อมูลรับรอง", + "studio.noCredentialsYetDescription": "เพิ่มข้อมูลรับรองในการผสานรวมหรือการตั้งค่าโหนด", + "studio.precheck.description": "เวอร์ชันนี้มีประเภทโหนดที่ยังไม่รองรับ:", + "studio.precheck.supportMessage": "การรองรับประเภทโหนดเหล่านี้จะพร้อมใช้งานในรุ่นถัดไป", + "studio.precheck.title": "ไม่สามารถปรับใช้เวอร์ชันนี้ในสภาพแวดล้อมนี้ได้", + "studio.publisher.deployingVersion": "กำลังปรับใช้: {{version}}", + "studio.publisher.noPublishedDescription": "เผยแพร่ฉบับร่างของคุณเพื่อปรับใช้ที่นี่", + "studio.publisher.notDeployedYet": "ยังไม่ได้ปรับใช้", + "studio.retryVersion": "ลอง {{version}} อีกครั้ง", + "studio.undeployConfirmDesc": "แอปจะหยุดทำงานในสภาพแวดล้อมนี้ และจุดเข้าถึงทั้งหมดจะไม่พร้อมใช้งาน เวอร์ชันที่ปรับใช้จะไม่ถูกลบ", + "studio.undeployConfirmTitle": "ยกเลิกการปรับใช้ {{versionName}} จาก {{envName}}", + "studio.updatedAtBy": "อัปเดตเมื่อ {{time}} โดย {{name}}", + "studio.versionValue": "ค่าของเวอร์ชัน", + "studio.versionsBehind_one": "ล้าหลัง {{count}} เวอร์ชัน", + "studio.versionsBehind_other": "ล้าหลัง {{count}} เวอร์ชัน", "tabs.access.description": "จัดการช่องทางการเข้าถึงและสิทธิ์การเข้าถึง", "tabs.access.name": "การเข้าถึง", "tabs.api-tokens.description": "จัดการ API Tokens และเรียกใช้อินสแตนซ์ผ่าน HTTP", diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json index a63c22b1f4c..a6e41059915 100644 --- a/web/i18n/th-TH/permission-keys.json +++ b/web/i18n/th-TH/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", "app.acl.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", "app.acl.delete": "ลบแอป", + "app.acl.deploy": "ปรับใช้แอป", "app.acl.edit": "แก้ไขและจัดวางแอป", "app.acl.import_export_dsl": "นําเข้า / ส่งออก DSL", "app.acl.log_and_annotation": "เข้าถึงบันทึกและคำอธิบายประกอบ", diff --git a/web/i18n/th-TH/tools.json b/web/i18n/th-TH/tools.json index ef1e60e14d8..42de755b0bd 100644 --- a/web/i18n/th-TH/tools.json +++ b/web/i18n/th-TH/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "วัตถุประสงค์และข้อจำกัดของพารามิเตอร์", "mcp.server.modal.parametersTip": "เพิ่มคำอธิบายสำหรับแต่ละพารามิเตอร์เพื่อช่วยให้ LLM เข้าใจวัตถุประสงค์และข้อจำกัดของมัน", "mcp.server.publishTip": "แอปไม่ถูกเผยแพร่ กรุณาเผยแพร่แอปก่อน", - "mcp.server.reGen": "คุณต้องการสร้าง URL ของเซิร์ฟเวอร์ใหม่หรือไม่?", + "mcp.server.reGen": "คุณต้องการสร้าง URL ของเซิร์ฟเวอร์ใหม่หรือไม่? URL ปัจจุบันจะหยุดทำงานทันที", "mcp.server.title": "เซิร์ฟเวอร์ MCP", "mcp.server.url": "URL ของเซิร์ฟเวอร์", "mcp.toolItem.noDescription": "ไม่มีคำอธิบาย", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index 455370b266a..dd56cae322c 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "ตอนนี้คุณสามารถสร้างตัวแปรชนิดไฟล์ในฟอร์มเริ่มต้นได้แล้ว เราจะไม่รองรับฟีเจอร์การอัปโหลดรูปภาพอีกต่อไปในอนาคต", "common.accessAPIReference": "การอ้างอิง API การเข้าถึง", + "common.accessPointDescription": "กำหนดค่าเว็บแอป, API, MCP และทริกเกอร์", "common.addBlock": "เพิ่มโนด", "common.addDescription": "เพิ่มคําอธิบาย...", "common.addFailureBranch": "เพิ่มสาขา Fail", @@ -169,6 +170,7 @@ "common.currentView": "ปัจจุบัน View", "common.currentWorkflow": "เวิร์กโฟลว์ปัจจุบัน", "common.debugAndPreview": "ดูตัวอย่าง", + "common.deployDescription": "นำเวอร์ชันไปใช้กับสภาพแวดล้อม", "common.disconnect": "ยก เลิก", "common.draftSaveFailed": "บันทึกฉบับร่างไม่สำเร็จ", "common.duplicate": "สำเนา", @@ -217,11 +219,15 @@ "common.needConnectTip": "ขั้นตอนนี้ไม่ได้เชื่อมต่อกับสิ่งใด", "common.needOutputNode": "ต้องเพิ่มโหนดเอาต์พุต", "common.needStartNode": "ต้องเพิ่มโหนดเริ่มต้นอย่างน้อยหนึ่งโหนด", + "common.noChanges": "ไม่มีการเปลี่ยนแปลง", "common.noHistory": "ไม่มีประวัติ", "common.noVar": "ไม่มีตัวแปร", + "common.notPublishedYet": "ยังไม่ได้เผยแพร่", "common.notRunning": "ยังไม่ได้ทํางาน", "common.onFailure": "เมื่อล้มเหลว", "common.openInExplore": "เปิดใน Explore", + "common.openWebApp": "เปิดเว็บแอป", + "common.openWebAppDescription": "เปิดแอปที่เผยแพร่ในแท็บใหม่", "common.output": "ผลิตภัณฑ์", "common.overwriteAndImport": "เขียนทับและนําเข้า", "common.parallel": "ขนาน", @@ -238,10 +244,12 @@ "common.processData": "ประมวลผลข้อมูล", "common.publish": "ตีพิมพ์", "common.publishToMarketplace": "เผยแพร่ไปยัง Marketplace", + "common.publishToMarketplaceDescription": "แชร์แอปนี้กับชุมชน", "common.publishToMarketplaceFailed": "เผยแพร่ไปยัง Marketplace ล้มเหลว", "common.publishUpdate": "เผยแพร่การอัปเดต", "common.published": "เผย แพร่", "common.publishedAt": "เผย แพร่", + "common.publishedBy": "เผยแพร่เมื่อ {{time}} โดย {{author}}", "common.publishingToMarketplace": "กำลังเผยแพร่...", "common.redo": "พร้อม", "common.restart": "เริ่มใหม่", @@ -251,6 +259,7 @@ "common.runApp": "เรียกใช้แอพ", "common.runHistory": "ประวัติการวิ่ง", "common.running": "กำลัง เรียก ใช้", + "common.savedAt": "บันทึกเมื่อ {{time}}", "common.scheduleTriggerRunFailed": "การทำงานของตัวทริกเกอร์ที่กำหนดเวลาล้มเหลว", "common.searchVar": "ตัวแปรการค้นหา", "common.setVarValuePlaceholder": "ตั้งค่าตัวแปร", @@ -265,6 +274,8 @@ "common.tagBound": "จำนวนแอปพลิเคชันที่ใช้แท็กนี้", "common.undo": "แก้", "common.unpublished": "ไม่ได้เผยแพร่", + "common.unpublishedChanges": "การเปลี่ยนแปลงที่ยังไม่ได้เผยแพร่", + "common.upToDate": "เป็นเวอร์ชันล่าสุด", "common.update": "อัพเดต", "common.variableNamePlaceholder": "ชื่อตัวแปร", "common.versionHistory": "ประวัติรุ่น", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "การดีบัก Webhook ล้มเหลว", "common.webhookDebugRequestFailed": "คำขอดีบัก Webhook ล้มเหลว", "common.workflowAsTool": "เวิร์กโฟลว์เป็นเครื่องมือ", + "common.workflowAsToolDescription": "ใช้เป็นเครื่องมือในแอปอื่น", "common.workflowAsToolDisabledHint": "เผยแพร่เวิร์กโฟลว์ล่าสุดและตรวจสอบให้แน่ใจว่ามีโหนดป้อนข้อมูลผู้ใช้เชื่อมต่อก่อนที่จะกำหนดค่าเป็นเครื่องมือ", + "common.workflowAsToolReady": "พร้อม", + "common.workflowAsToolReconfigure": "กำหนดค่าใหม่", "common.workflowAsToolTip": "จําเป็นต้องมีการกําหนดค่าเครื่องมือใหม่หลังจากการอัปเดตเวิร์กโฟลว์", + "common.workflowAsToolUpdateNeeded": "ต้องอัปเดต", "common.workflowProcess": "กระบวนการเวิร์กโฟลว์", "common.workflowProcessFailed": "กระบวนการเวิร์กโฟลว์ล้มเหลว", "common.workflowProcessPaused": "กระบวนการเวิร์กโฟลว์หยุดชั่วคราว", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "ไม่มีตัวแปรที่ใช้ได้", "variableReference.noVarsForOperation": "ไม่มีตัวแปรที่พร้อมใช้งานสําหรับการกําหนดด้วยการดําเนินการที่เลือก", "versionHistory.action.copyIdSuccess": "คัดลอกรหัสไปยังคลิปบอร์ด", + "versionHistory.action.deleteDeployedVersionError": "เวิร์กโฟลว์เวอร์ชันนี้ถูกนำไปใช้งานแล้วหรือกำลังนำไปใช้งานในสภาพแวดล้อมอย่างน้อยหนึ่งแห่ง โปรดยกเลิกการนำไปใช้งานจากทุกสภาพแวดล้อมก่อนลบเวอร์ชัน", "versionHistory.action.deleteFailure": "ลบเวอร์ชันไม่สำเร็จ", "versionHistory.action.deleteSuccess": "เวอร์ชันถูกลบ", "versionHistory.action.restoreFailure": "ไม่สามารถกู้คืนเวอร์ชันได้", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "เพียงของคุณเท่านั้น", "versionHistory.filter.reset": "รีเซ็ตตัวกรอง", "versionHistory.latest": "ล่าสุด", + "versionHistory.nameIt": "ตั้งชื่อ", "versionHistory.nameThisVersion": "ชื่อเวอร์ชันนี้", "versionHistory.releaseNotesPlaceholder": "อธิบายว่าสิ่งที่เปลี่ยนแปลงไปคืออะไร", "versionHistory.restorationTip": "หลังจากการกู้คืนเวอร์ชันแล้ว ร่างปัจจุบันจะถูกเขียนทับ.", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index dfa8d361aab..03a9469ad0d 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Hizmet Dışı", "agentDetail.access.title": "Erişim Noktası", "agentDetail.access.toggleSurface": "{{name}} erişimini değiştir", - "agentDetail.access.webApp.accessUrl": "Erişim URL'si", + "agentDetail.access.webApp.accessUrl": "Web Uygulaması URL'si", "agentDetail.access.webApp.actions.accessControl": "Erişim Kontrolü", "agentDetail.access.webApp.actions.customize": "Özel Ön Yüz", "agentDetail.access.webApp.actions.embedded": "Gömülü", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Erişim URL'sini yenile", "agentDetail.access.webApp.showQrCode": "QR kodunu göster", "agentDetail.access.webApp.ssoEnabled": "SSO Etkin", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Web Uygulaması", "agentDetail.access.workflow.description": "İş akışı içinde bir düğüm olarak kullanılır", "agentDetail.access.workflow.empty": "Henüz iş akışı referansı yok.", "agentDetail.access.workflow.loadFailed": "İş akışı referansları yüklenemedi.", diff --git a/web/i18n/tr-TR/app-api.json b/web/i18n/tr-TR/app-api.json index f9169bb6011..fde3f006ca4 100644 --- a/web/i18n/tr-TR/app-api.json +++ b/web/i18n/tr-TR/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Bu gizli anahtarı silmek istiyor musunuz?", "actionMsg.ok": "Tamam", "apiKey": "API Anahtarı", - "apiKeyModal.apiSecretKey": "API Gizli Anahtar", + "apiKeyModal.apiSecretKey": "API Anahtarı", "apiKeyModal.apiSecretKeyTips": "API suiistimalini önlemek için, API Anahtarınızı koruyunuz. Ön uç kodda düz metin olarak kullanmaktan kaçının. :)", "apiKeyModal.createNewSecretKey": "Yeni Gizli Anahtar Oluştur", "apiKeyModal.created": "OLUŞTURULDU", diff --git a/web/i18n/tr-TR/app-overview.json b/web/i18n/tr-TR/app-overview.json index ed7b8da6743..8246de38d51 100644 --- a/web/i18n/tr-TR/app-overview.json +++ b/web/i18n/tr-TR/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Model sağlayıcısını ayarlamaya git", "apiKeyInfo.tryCloud": "Veya Dify'nin bulut sürümünü ücretsiz kotayla deneyin", "apiKeyInfo.usedToken": "Kullanılan token", - "overview.apiInfo.accessibleAddress": "Servis API Uç Noktası", + "overview.apiInfo.accessibleAddress": "API Uç Noktası", "overview.apiInfo.doc": "API Referansı", "overview.apiInfo.explanation": "Kolayca uygulamanıza entegre edin", "overview.apiInfo.title": "Arka Uç Servis API'si", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Paylaşmak İçin Tara", "overview.appInfo.qrcode.title": "Bağlantı QR Kodu", "overview.appInfo.regenerate": "Yeniden Oluştur", - "overview.appInfo.regenerateNotice": "Genel URL'yi yeniden oluşturmak istiyor musunuz?", + "overview.appInfo.regenerateNotice": "Genel URL'yi yeniden oluşturmak istiyor musunuz? Mevcut URL hemen çalışmayı durduracaktır.", "overview.appInfo.settings.chatColorTheme": "Sohbet renk teması", "overview.appInfo.settings.chatColorThemeDesc": "Sohbet botunun renk temasını ayarlayın", "overview.appInfo.settings.chatColorThemeInverted": "Tersine çevrilmiş", @@ -95,15 +95,16 @@ "overview.appInfo.settings.more.privacyPolicy": "Gizlilik Politikası", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Gizlilik politikası bağlantısını girin", "overview.appInfo.settings.more.privacyPolicyTip": "Ziyaretçilerin uygulamanın topladığı verileri anlamalarına yardımcı olur, Dify'nin Gizlilik Politikası'na bakın.", + "overview.appInfo.settings.multiEnvironmentNotice": "Web uygulaması ayarları tüm ortamlara uygulanır.", "overview.appInfo.settings.sso.description": "Tüm kullanıcıların web app'i kullanmadan önce SSO ile oturum açmaları gerekir", "overview.appInfo.settings.sso.label": "SSO Kimlik Doğrulaması", "overview.appInfo.settings.sso.title": "web app SSO'su", "overview.appInfo.settings.sso.tooltip": "web app SSO'yu etkinleştirmek için yöneticiyle iletişime geçin", "overview.appInfo.settings.title": "Markalama", - "overview.appInfo.settings.webDesc": "web app Açıklaması", + "overview.appInfo.settings.webDesc": "Web uygulaması açıklaması", "overview.appInfo.settings.webDescPlaceholder": "web app'in açıklamasını girin", "overview.appInfo.settings.webDescTip": "Bu metin, uygulamanın nasıl kullanılacağına dair temel açıklamalar sağlar ve istemci tarafında görüntülenir", - "overview.appInfo.settings.webName": "web app İsmi", + "overview.appInfo.settings.webName": "Web uygulaması adı", "overview.appInfo.settings.workflow.hide": "Gizle", "overview.appInfo.settings.workflow.show": "Göster", "overview.appInfo.settings.workflow.showDesc": "web app'te iş akışı ayrıntılarını gösterme veya gizleme", @@ -117,10 +118,10 @@ "overview.status.running": "Çalışıyor", "overview.title": "Genel Bakış", "overview.triggerInfo.explanation": "İş akışı tetikleyici yönetimi", - "overview.triggerInfo.learnAboutTriggers": "Tetikleyiciler hakkında bilgi edinin", + "overview.triggerInfo.learnAboutTriggers": "Tetikleyiciler hakkında daha fazla bilgi edinin", "overview.triggerInfo.noTriggerAdded": "Hiç tetikleyici eklenmedi", "overview.triggerInfo.title": "Tetikleyiciler", - "overview.triggerInfo.triggerStatusDescription": "Tetikleyici düğüm durumu burada görünür. (Zaten taslakta mevcut olabilir, yayımlandıktan sonra geçerli olur)", + "overview.triggerInfo.triggerStatusDescription": "Tetikleyiciler, onları içeren sürümü yayımladıktan sonra burada görünür.", "overview.triggerInfo.triggersAdded": "{{count}} Tetikleyiciler eklendi", "welcome.enterKeyTip": "aşağıya OpenAI API Anahtarınızı girin", "welcome.firstStepTip": "Başlamak için,", diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json index 368afd9f229..041722d078c 100644 --- a/web/i18n/tr-TR/common.json +++ b/web/i18n/tr-TR/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API uzantıları merkezi API yönetimi sağlar, Dify'nin uygulamaları arasında kolay kullanım için yapılandırmayı basitleştirir.", "apiBasedExtension.type": "Tür", "apiBasedExtensionPage.description": "API ile Dify modül yeteneklerini genişletin — uygulamalarınıza özel içerik denetimi veya harici veri araçları ekleyin.", + "appMenus.accessPoint": "Erişim Noktası", "appMenus.annotations": "Ek Açıklamalar", "appMenus.apiAccess": "API Erişimi", "appMenus.apiAccessTip": "Bu bilgi tabanına Servis API üzerinden erişilebilir", + "appMenus.deploy": "Dağıt", "appMenus.logs": "Günlükler", "appMenus.overview": "İzleme", "appMenus.promptEng": "Düzenle", @@ -468,7 +470,7 @@ "operation.imageCopied": "Kopyalanan görüntü", "operation.imageDownloaded": "Resim indirildi", "operation.in": "içinde", - "operation.learnMore": "Daha Fazla Bilgi", + "operation.learnMore": "Daha fazla bilgi", "operation.log": "log", "operation.more": "Daha fazla", "operation.moreActions": "Daha fazla işlem", diff --git a/web/i18n/tr-TR/deployments.json b/web/i18n/tr-TR/deployments.json index 55e41ea759d..67d947a5f24 100644 --- a/web/i18n/tr-TR/deployments.json +++ b/web/i18n/tr-TR/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Çalışıyor (eşitleme bekleniyor)", "deployTab.status.runningWithFailed": "Çalışıyor (son dağıtım başarısız)", "deployTab.undeploy": "Dağıtımı Geri Al", - "deployTab.undeployConfirmDesc": "Son kullanıcı erişimi hemen duracak. Sürüm daha sonra yeniden dağıtılabilir.", - "deployTab.undeployConfirmTitle": "{{name}} ortamından dağıtım geri alınsın mı?", + "deployTab.undeployConfirmDesc": "Ortam uç noktası kullanılamaz hale gelecek, ancak sürüm silinmeyecek.", + "deployTab.undeployConfirmTitle": "{{name}} dağıtımdan kaldırılsın mı?", + "deployTab.undeployConfirmWarning": "Onaydan sonra bu ortam dağıtımdan kaldırılıyor durumuna geçecek ve işlemler geçici olarak devre dışı bırakılacak.", "deployTab.viewError": "Hatayı görüntüle", "detail.mobileTabs": "Dağıtım bölümleri", "detail.notFound": "Dağıtım bulunamadı", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Dağıtılmadı", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Dağıtım geri alınıyor", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Bilinmiyor", + "studio.accessPoint.addTriggerNodeDescription": "Bu uygulamayı dış olaylardan başlatmak için bir Trigger düğümü ekleyin.", + "studio.accessPoint.apiDescription": "Uygulamayı hizmetlerinizden HTTP üzerinden çağırın", + "studio.accessPoint.description": "Bu uygulamaya Yerleşik ortamda ve dağıtılan her ortamda erişmenin tüm yolları.", + "studio.accessPoint.embedIntoSite": "Siteye göm", + "studio.accessPoint.goToPublish": "Yayınlamaya git", + "studio.accessPoint.mcpDescription": "Uygulamayı aracılar ve yapay zekâ uygulamaları için bir araç olarak sunun", + "studio.accessPoint.noPublishedDescription": "Yerleşik erişim noktalarını kullanıma açmak için bir sürüm yayınlayın.", + "studio.accessPoint.noPublishedTitle": "Henüz yayınlanmış sürüm yok", + "studio.accessPoint.noTriggerNodes": "Bu iş akışında Trigger düğümü yok", + "studio.accessPoint.notSupported": "Desteklenmiyor", + "studio.accessPoint.open": "Aç", + "studio.accessPoint.triggerDescription": "İş akışını zamanlamalara veya harici olaylara göre çalıştırın", + "studio.accessPoint.triggerDisconnected": "Bağlantı kesildi", + "studio.accessPoint.triggerEnabledCount": "{{enabled}}/{{total}} etkin", + "studio.accessPoint.triggerExclusiveNotice": "İş akışı tetikleyicileri kullanırken Web app, API ve MCP kapatılır.", + "studio.accessPoint.triggerMuted": "Sessize alındı", + "studio.accessPoint.triggerServiceModeUnavailable": "Web App, API veya MCP hizmetteyken kullanılamaz.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Yalnızca Yerleşik ortamda kullanılabilir.", + "studio.accessPoint.webAppDescription": "Uygulamayı son kullanıcılarınızla web sayfası olarak paylaşın", + "studio.accessPoints": "Erişim noktaları", + "studio.activity.deploy": "{{target}} dağıt", + "studio.activity.deployFailed": "{{target}} dağıtılamadı", + "studio.activity.deploySucceeded": "{{target}} başarıyla dağıtıldı", + "studio.activity.meta": "{{name}} tarafından · {{time}}", + "studio.allVersions": "Tüm sürümler", + "studio.builtInDescription": "Her zaman en son yayımlanan sürümü çalıştırır", + "studio.builtInTitle": "Yerleşik ortam", + "studio.changeVersion": "Sürümü değiştir", + "studio.chooseVersionToDeploy": "Dağıtılacak bir sürüm seçin", + "studio.current": "Geçerli", + "studio.deployAnotherVersion": "Başka bir sürüm dağıt", + "studio.deployConfiguration": "Dağıtım yapılandırması", + "studio.deployLatest": "En son sürümü dağıt", + "studio.description": "Bu uygulamanın farklı ortamlardaki dağıtımlarını yönetin. Enterprise Dashboard'da daha fazla ortam oluşturulabilir.", + "studio.emptyDescription": "Yayınlanmış bir sürümü bir ortama dağıtın; burada görünecektir.", + "studio.environmentVariablesDescription": "Dağıttığınız sürümdeki değeri kullanın, son dağıtılan değeri koruyun veya özel bir değer girin.", + "studio.environments": "Ortamlar", + "studio.environmentsInUse": "{{total}} ortamdan {{used}} tanesi kullanımda", + "studio.lastActivity": "Son etkinlik", + "studio.liveVersion": "Canlı sürüm", + "studio.moreEnvironments": "Daha fazla ortam", + "studio.noCredentialsYet": "Henüz kimlik bilgisi yok", + "studio.noCredentialsYetDescription": "Entegrasyonlar veya düğüm ayarlarından kimlik bilgisi ekleyin", + "studio.precheck.description": "Henüz desteklenmeyen düğüm türleri içeriyor:", + "studio.precheck.supportMessage": "Bu düğüm türleri gelecek bir sürümde desteklenecektir.", + "studio.precheck.title": "Bu sürüm bu ortama dağıtılamaz", + "studio.publisher.deployingVersion": "Dağıtılıyor: {{version}}", + "studio.publisher.noPublishedDescription": "Buraya dağıtmak için taslağınızı yayınlayın.", + "studio.publisher.notDeployedYet": "Henüz dağıtılmadı", + "studio.retryVersion": "{{version}} sürümünü yeniden dene", + "studio.undeployConfirmDesc": "Uygulama bu ortamda çalışmayı durduracak ve tüm erişim noktaları kullanılamaz hale gelecektir. Dağıtılan sürüm silinmeyecektir.", + "studio.undeployConfirmTitle": "{{versionName}} sürümünü {{envName}} ortamından kaldır", + "studio.updatedAtBy": "{{time}} tarihinde {{name}} tarafından güncellendi", + "studio.versionValue": "Sürüm değeri", + "studio.versionsBehind_one": "{{count}} sürüm geride", + "studio.versionsBehind_other": "{{count}} sürüm geride", "tabs.access.description": "Erişim kanallarını ve erişim izinlerini yönetin.", "tabs.access.name": "Erişim", "tabs.api-tokens.description": "API Tokenlerini yönetin ve örnekleri HTTP üzerinden çağırın.", diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json index c710082bb51..a02450808a7 100644 --- a/web/i18n/tr-TR/permission-keys.json +++ b/web/i18n/tr-TR/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Uygulama erişim izinlerini yapılandır", "app.acl.access_config": "Uygulama erişim izinlerini yapılandır", "app.acl.delete": "Uygulamayı sil", + "app.acl.deploy": "Uygulamayı dağıt", "app.acl.edit": "Uygulamayı düzenle ve düzenle", "app.acl.import_export_dsl": "DSL içe / dışa aktar", "app.acl.log_and_annotation": "Günlüklere ve açıklamalara eriş", diff --git a/web/i18n/tr-TR/tools.json b/web/i18n/tr-TR/tools.json index 26252fd66a7..f0202003032 100644 --- a/web/i18n/tr-TR/tools.json +++ b/web/i18n/tr-TR/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Parametre amacı ve kısıtlamaları", "mcp.server.modal.parametersTip": "LLM'nin amaçlarını ve kısıtlamalarını anlamasına yardımcı olmak için her parametreye açıklamalar ekleyin.", "mcp.server.publishTip": "Uygulama yayınlanmadı. Lütfen önce uygulamayı yayınlayın.", - "mcp.server.reGen": "Sunucu URL yeniden oluşturulsun mu?", + "mcp.server.reGen": "Sunucu URL yeniden oluşturulsun mu? Mevcut URL hemen çalışmayı durduracaktır.", "mcp.server.title": "MCP Sunucusu", "mcp.server.url": "Sunucu URL", "mcp.toolItem.noDescription": "Açıklama yok", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 4874d36afb3..9d9ed19353c 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Yanıtla", "common.ImageUploadLegacyTip": "Artık başlangıç formunda dosya türü değişkenleri oluşturabilirsiniz. Gelecekte resim yükleme özelliğini artık desteklemeyeceğiz.", "common.accessAPIReference": "API Referansına Eriş", + "common.accessPointDescription": "Web uygulamasını, API'yi, MCP'yi ve tetikleyicileri yapılandır", "common.addBlock": "Düğüm Ekle", "common.addDescription": "Açıklama ekle...", "common.addFailureBranch": "Başarısız dal ekle", @@ -169,6 +170,7 @@ "common.currentView": "Geçerli Görünüm", "common.currentWorkflow": "Mevcut İş Akışı", "common.debugAndPreview": "Önizleme", + "common.deployDescription": "Sürümleri ortamlara dağıt", "common.disconnect": "Bağlantıyı Kes", "common.draftSaveFailed": "Taslak kaydedilemedi", "common.duplicate": "Çoğalt", @@ -217,11 +219,15 @@ "common.needConnectTip": "Bu adım hiçbir şeye bağlı değil", "common.needOutputNode": "Çıktı düğümü eklenmelidir", "common.needStartNode": "En az bir başlangıç düğümü eklenmelidir", + "common.noChanges": "Değişiklik yok", "common.noHistory": "Tarih Yok", "common.noVar": "Değişken yok", + "common.notPublishedYet": "Henüz yayınlanmadı", "common.notRunning": "Henüz çalıştırılmadı", "common.onFailure": "Başarısızlık Üzerine", "common.openInExplore": "Keşfet'te Aç", + "common.openWebApp": "Web uygulamasını aç", + "common.openWebAppDescription": "Yayınlanan uygulamayı yeni bir sekmede aç", "common.output": "Çıktı", "common.overwriteAndImport": "Üzerine Yaz ve İçe Aktar", "common.parallel": "PARALEL", @@ -238,10 +244,12 @@ "common.processData": "Veriyi İşle", "common.publish": "Yayınla", "common.publishToMarketplace": "Pazar Yeri'nde Yayınla", + "common.publishToMarketplaceDescription": "Bu uygulamayı toplulukla paylaş", "common.publishToMarketplaceFailed": "Pazar Yeri'nde yayınlanamadı", "common.publishUpdate": "Güncellemeyi Yayınla", "common.published": "Yayınlandı", "common.publishedAt": "Yayınlandı", + "common.publishedBy": "{{author}} tarafından {{time}} yayınlandı", "common.publishingToMarketplace": "Yayınlanıyor...", "common.redo": "Yinele", "common.restart": "Yeniden Başlat", @@ -251,6 +259,7 @@ "common.runApp": "Uygulamayı Çalıştır", "common.runHistory": "Çalıştırma Geçmişi", "common.running": "Çalışıyor", + "common.savedAt": "{{time}} kaydedildi", "common.scheduleTriggerRunFailed": "Zamanlanmış tetikleyici çalıştırma başarısız oldu", "common.searchVar": "Değişkeni ara", "common.setVarValuePlaceholder": "Değişkeni ayarla", @@ -265,6 +274,8 @@ "common.tagBound": "Bu etiketi kullanan uygulama sayısı", "common.undo": "Geri Al", "common.unpublished": "Yayınlanmamış", + "common.unpublishedChanges": "Yayınlanmamış değişiklikler", + "common.upToDate": "Güncel", "common.update": "Güncelle", "common.variableNamePlaceholder": "Değişken adı", "common.versionHistory": "Sürüm Geçmişi", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook hata ayıklama başarısız oldu", "common.webhookDebugRequestFailed": "Webhook hata ayıklama isteği başarısız oldu", "common.workflowAsTool": "Araç Olarak İş Akışı", + "common.workflowAsToolDescription": "Diğer uygulamalarda araç olarak kullan", "common.workflowAsToolDisabledHint": "En son iş akışını yayınlayın ve bunu bir araç olarak yapılandırmadan önce bağlı bir Kullanıcı Girdisi düğümünün olduğundan emin olun.", + "common.workflowAsToolReady": "Hazır", + "common.workflowAsToolReconfigure": "Yeniden yapılandır", "common.workflowAsToolTip": "İş Akışı güncellemesinden sonra araç yeniden yapılandırması gereklidir.", + "common.workflowAsToolUpdateNeeded": "Güncelleme gerekli", "common.workflowProcess": "İş Akışı Süreci", "common.workflowProcessFailed": "İş Akışı Süreci başarısız oldu", "common.workflowProcessPaused": "İş Akışı Süreci duraklatıldı", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Kullanılabilir değişken yok", "variableReference.noVarsForOperation": "Seçilen işlemle atanabilecek değişken yok.", "versionHistory.action.copyIdSuccess": "Kimlik panoya kopyalandı", + "versionHistory.action.deleteDeployedVersionError": "Bu iş akışı sürümü bir veya daha fazla ortama dağıtılmıştır ya da dağıtılmaktadır. Sürümü silmeden önce tüm ortamlardaki dağıtımı kaldırın.", "versionHistory.action.deleteFailure": "Versiyonu silme işlemi başarısız oldu", "versionHistory.action.deleteSuccess": "Sürüm silindi", "versionHistory.action.restoreFailure": "Sürümü geri yüklemekte başarısız olundu", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Sadece senin", "versionHistory.filter.reset": "Filtreyi Sıfırla", "versionHistory.latest": "Sonuncu", + "versionHistory.nameIt": "Adlandır", "versionHistory.nameThisVersion": "Bu versiyona isim ver", "versionHistory.releaseNotesPlaceholder": "Değişen şeyleri tanımlayın", "versionHistory.restorationTip": "Sürüm geri yüklemeden sonra, mevcut taslak üzerine yazılacak.", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index facc3cdf404..5eee95124c8 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Не в роботі", "agentDetail.access.title": "Точка доступу", "agentDetail.access.toggleSurface": "Перемкнути доступ {{name}}", - "agentDetail.access.webApp.accessUrl": "URL доступу", + "agentDetail.access.webApp.accessUrl": "URL вебзастосунку", "agentDetail.access.webApp.actions.accessControl": "Контроль доступу", "agentDetail.access.webApp.actions.customize": "Користувацький фронтенд", "agentDetail.access.webApp.actions.embedded": "Вбудувати", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Оновити URL доступу", "agentDetail.access.webApp.showQrCode": "Показати QR-код", "agentDetail.access.webApp.ssoEnabled": "SSO увімкнено", - "agentDetail.access.webApp.title": "Webapp", + "agentDetail.access.webApp.title": "Вебзастосунок", "agentDetail.access.workflow.description": "Використовується як вузол у робочому процесі", "agentDetail.access.workflow.empty": "Поки що немає посилань на робочі процеси.", "agentDetail.access.workflow.loadFailed": "Не вдалося завантажити посилання на робочі процеси.", diff --git a/web/i18n/uk-UA/app-api.json b/web/i18n/uk-UA/app-api.json index 658ea110e0f..a29259269e3 100644 --- a/web/i18n/uk-UA/app-api.json +++ b/web/i18n/uk-UA/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Видалити цей секретний ключ?", "actionMsg.ok": "Гаразд", "apiKey": "Ключ API", - "apiKeyModal.apiSecretKey": "Секретний ключ API", + "apiKeyModal.apiSecretKey": "Ключ API", "apiKeyModal.apiSecretKeyTips": "Щоб запобігти зловживанням API, захистіть свій ключ API. Уникайте його використання як звичайного тексту у front-end коді. :)", "apiKeyModal.createNewSecretKey": "Створити новий секретний ключ", "apiKeyModal.created": "СТВОРЕНО", diff --git a/web/i18n/uk-UA/app-overview.json b/web/i18n/uk-UA/app-overview.json index 4416088cc4c..ff9353d8831 100644 --- a/web/i18n/uk-UA/app-overview.json +++ b/web/i18n/uk-UA/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Перейти до налаштування постачальника моделей", "apiKeyInfo.tryCloud": "Або спробуйте хмарну версію Dify з безкоштовним цитатою", "apiKeyInfo.usedToken": "Використані токени", - "overview.apiInfo.accessibleAddress": "Кінцева точка API сервісу", + "overview.apiInfo.accessibleAddress": "Кінцева точка API", "overview.apiInfo.doc": "Довідка з API", "overview.apiInfo.explanation": "Легко інтегрований у вашу програму", "overview.apiInfo.title": "API сервісу Backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Сканувати та обмінюватися додатком", "overview.appInfo.qrcode.title": "QR-код для обміну", "overview.appInfo.regenerate": "Відновити", - "overview.appInfo.regenerateNotice": "Бажаєте згенерувати новий публічний URL?", + "overview.appInfo.regenerateNotice": "Бажаєте згенерувати новий публічний URL? Поточний URL негайно припинить працювати.", "overview.appInfo.settings.chatColorTheme": "Тема кольору чату", "overview.appInfo.settings.chatColorThemeDesc": "Встановіть тему кольору чат-бота", "overview.appInfo.settings.chatColorThemeInverted": "Інвертовано", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Політика конфіденційності", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Введіть посилання на політику конфіденційності", "overview.appInfo.settings.more.privacyPolicyTip": "Допомагає відвідувачам зрозуміти дані, зібрані додатком, див. Політику конфіденційності Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Налаштування вебзастосунку застосовуються до всіх середовищ.", "overview.appInfo.settings.sso.description": "Усі користувачі повинні увійти в систему за допомогою єдиного входу перед використанням web app", "overview.appInfo.settings.sso.label": "Автентифікація за допомогою єдиного входу", "overview.appInfo.settings.sso.title": "Єдиний вхід для web app", @@ -117,10 +118,10 @@ "overview.status.running": "У роботі", "overview.title": "Огляд", "overview.triggerInfo.explanation": "Управління тригером робочого процесу", - "overview.triggerInfo.learnAboutTriggers": "Дізнайтеся про тригери", + "overview.triggerInfo.learnAboutTriggers": "Дізнайтеся більше про тригери", "overview.triggerInfo.noTriggerAdded": "Триггер не додано", "overview.triggerInfo.title": "Тригери", - "overview.triggerInfo.triggerStatusDescription": "Статус вузла тригера відображається тут. (Може вже існувати в чернетці, набирає чинності після публікації)", + "overview.triggerInfo.triggerStatusDescription": "Тригери з’являться тут після публікації версії, яка їх містить.", "overview.triggerInfo.triggersAdded": "{{count}} тригерів додано", "welcome.enterKeyTip": "введіть свій ключ API OpenAI нижче", "welcome.firstStepTip": "Для початку,", diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json index a02f2eb8e60..bd2c871c927 100644 --- a/web/i18n/uk-UA/common.json +++ b/web/i18n/uk-UA/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "API-розширення забезпечують централізоване керування API, спрощуючи конфігурацію для зручного використання в різних програмах Dify.", "apiBasedExtension.type": "Тип", "apiBasedExtensionPage.description": "Розширюйте можливості модулів Dify через API — додавайте до застосунків власну модерацію контенту або зовнішні інструменти даних.", + "appMenus.accessPoint": "Точка доступу", "appMenus.annotations": "Анотації", "appMenus.apiAccess": "Доступ до API", "appMenus.apiAccessTip": "Ця база знань доступна через API сервісу", + "appMenus.deploy": "Розгорнути", "appMenus.logs": "Журнали", "appMenus.overview": "Моніторинг", "appMenus.promptEng": "Налаштування", diff --git a/web/i18n/uk-UA/deployments.json b/web/i18n/uk-UA/deployments.json index d6d2dddd231..322c3ac9d47 100644 --- a/web/i18n/uk-UA/deployments.json +++ b/web/i18n/uk-UA/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Запущено (очікує синхронізації)", "deployTab.status.runningWithFailed": "Запущено (останнє розгортання не вдалося)", "deployTab.undeploy": "Скасувати розгортання", - "deployTab.undeployConfirmDesc": "Доступ кінцевих користувачів буде негайно припинено. Реліз можна буде перерозгорнути пізніше.", - "deployTab.undeployConfirmTitle": "Скасувати розгортання з {{name}}?", + "deployTab.undeployConfirmDesc": "Кінцева точка середовища стане недоступною, але версію не буде видалено.", + "deployTab.undeployConfirmTitle": "Скасувати розгортання {{name}}?", + "deployTab.undeployConfirmWarning": "Після підтвердження це середовище перейде у стан скасування розгортання, а дії буде тимчасово вимкнено.", "deployTab.viewError": "Переглянути помилку", "detail.mobileTabs": "Розділи розгортання", "detail.notFound": "Розгортання не знайдено", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Не розгорнуто", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Скасування розгортання", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Невідомо", + "studio.accessPoint.addTriggerNodeDescription": "Додайте вузол Trigger, щоб запускати застосунок із зовнішніх подій.", + "studio.accessPoint.apiDescription": "Викликайте застосунок зі своїх сервісів через HTTP", + "studio.accessPoint.description": "Усі способи доступу до застосунку у вбудованому та кожному розгорнутому середовищі.", + "studio.accessPoint.embedIntoSite": "Вбудувати на сайт", + "studio.accessPoint.goToPublish": "Перейти до публікації", + "studio.accessPoint.mcpDescription": "Надайте застосунок як інструмент для агентів і ШІ-застосунків", + "studio.accessPoint.noPublishedDescription": "Опублікуйте версію, щоб вбудовані точки доступу стали доступними.", + "studio.accessPoint.noPublishedTitle": "Опублікованих версій ще немає", + "studio.accessPoint.noTriggerNodes": "У цьому робочому процесі немає вузлів Trigger", + "studio.accessPoint.notSupported": "Не підтримується", + "studio.accessPoint.open": "Відкрити", + "studio.accessPoint.triggerDescription": "Запускайте робочий процес за розкладом або зовнішніми подіями", + "studio.accessPoint.triggerDisconnected": "Від’єднано", + "studio.accessPoint.triggerEnabledCount": "Увімкнено {{enabled}} з {{total}}", + "studio.accessPoint.triggerExclusiveNotice": "Вебзастосунок, API та MCP вимкнені, доки робочий процес використовує тригери.", + "studio.accessPoint.triggerMuted": "Вимкнено звук", + "studio.accessPoint.triggerServiceModeUnavailable": "Недоступно, доки працює Web App, API або MCP.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Доступно лише у вбудованому середовищі.", + "studio.accessPoint.webAppDescription": "Надайте застосунок кінцевим користувачам як вебсторінку", + "studio.accessPoints": "Точки доступу", + "studio.activity.deploy": "Розгорнути {{target}}", + "studio.activity.deployFailed": "Не вдалося розгорнути {{target}}", + "studio.activity.deploySucceeded": "{{target}} успішно розгорнуто", + "studio.activity.meta": "{{name}} · {{time}}", + "studio.allVersions": "Усі версії", + "studio.builtInDescription": "Завжди запускає останню опубліковану версію", + "studio.builtInTitle": "Вбудоване середовище", + "studio.changeVersion": "Змінити версію", + "studio.chooseVersionToDeploy": "Виберіть версію для розгортання", + "studio.current": "Поточна", + "studio.deployAnotherVersion": "Розгорнути іншу версію", + "studio.deployConfiguration": "Налаштування розгортання", + "studio.deployLatest": "Розгорнути останню версію", + "studio.description": "Керуйте розгортаннями цього застосунку в різних середовищах. Додаткові середовища можна створити на панелі керування Enterprise.", + "studio.emptyDescription": "Розгорніть опубліковану версію в середовищі, і вона з’явиться тут.", + "studio.environmentVariablesDescription": "Використайте значення з версії, яку розгортаєте, збережіть останнє розгорнуте значення або введіть власне.", + "studio.environments": "Середовища", + "studio.environmentsInUse": "Використовується середовищ: {{used}} із {{total}}", + "studio.lastActivity": "Остання активність", + "studio.liveVersion": "Активна версія", + "studio.moreEnvironments": "Інші середовища", + "studio.noCredentialsYet": "Облікових даних ще немає", + "studio.noCredentialsYetDescription": "Додайте облікові дані в розділі «Інтеграції» або в налаштуваннях вузла", + "studio.precheck.description": "Вона містить типи вузлів, які ще не підтримуються:", + "studio.precheck.supportMessage": "Підтримка цих типів вузлів з’явиться в одному з майбутніх випусків.", + "studio.precheck.title": "Цю версію не можна розгорнути в цьому середовищі", + "studio.publisher.deployingVersion": "Розгортання: {{version}}", + "studio.publisher.noPublishedDescription": "Опублікуйте чернетку, щоб розгорнути її тут.", + "studio.publisher.notDeployedYet": "Ще не розгорнуто", + "studio.retryVersion": "Повторити {{version}}", + "studio.undeployConfirmDesc": "Застосунок припинить роботу в цьому середовищі, а всі його точки доступу стануть недоступними. Розгорнуту версію не буде видалено.", + "studio.undeployConfirmTitle": "Скасувати розгортання {{versionName}} у середовищі {{envName}}", + "studio.updatedAtBy": "Оновлено {{time}}, {{name}}", + "studio.versionValue": "Значення версії", + "studio.versionsBehind_one": "Відставання на {{count}} версію", + "studio.versionsBehind_other": "Відставання на {{count}} версій", "tabs.access.description": "Керуйте каналами доступу та дозволами доступу.", "tabs.access.name": "Доступ", "tabs.api-tokens.description": "Керуйте API Tokens та викликайте Екземпляри через HTTP.", diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json index 1b2c971758c..3de62f8e280 100644 --- a/web/i18n/uk-UA/permission-keys.json +++ b/web/i18n/uk-UA/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Налаштування дозволів доступу до застосунку", "app.acl.access_config": "Налаштування дозволів доступу до застосунку", "app.acl.delete": "Видалити застосунок", + "app.acl.deploy": "Розгорнути застосунок", "app.acl.edit": "Редагувати та оркеструвати застосунок", "app.acl.import_export_dsl": "Імпорт / експорт DSL", "app.acl.log_and_annotation": "Доступ до журналів і анотацій", diff --git a/web/i18n/uk-UA/tools.json b/web/i18n/uk-UA/tools.json index 7f43e8fb2ec..195c4bb4bbb 100644 --- a/web/i18n/uk-UA/tools.json +++ b/web/i18n/uk-UA/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Призначення та обмеження параметра", "mcp.server.modal.parametersTip": "Додайте описи параметрів, щоб допомогти LLM зрозуміти їх призначення та обмеження.", "mcp.server.publishTip": "Додаток не опубліковано. Спочатку опублікуйте додаток.", - "mcp.server.reGen": "Згенерувати URL сервера знову?", + "mcp.server.reGen": "Згенерувати URL сервера знову? Поточний URL негайно припинить працювати.", "mcp.server.title": "Сервер MCP", "mcp.server.url": "URL сервера", "mcp.toolItem.noDescription": "Немає опису", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 3be1befda57..56fcc292a33 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Тепер ви можете створювати змінні типу файлу у початковій формі. У майбутньому ми більше не підтримуватимемо функцію завантаження зображень.", "common.accessAPIReference": "Доступ до довідника API", + "common.accessPointDescription": "Налаштувати вебзастосунок, API, MCP і тригери", "common.addBlock": "Додати вузол", "common.addDescription": "Додати опис...", "common.addFailureBranch": "Додано гілку помилки", @@ -169,6 +170,7 @@ "common.currentView": "Поточний вигляд", "common.currentWorkflow": "Поточний робочий процес", "common.debugAndPreview": "Попередній перегляд", + "common.deployDescription": "Розгорнути версії в середовищах", "common.disconnect": "Відключити", "common.draftSaveFailed": "Не вдалося зберегти чернетку", "common.duplicate": "Дублювати", @@ -217,11 +219,15 @@ "common.needConnectTip": "Цей крок ні до чого не підключений", "common.needOutputNode": "Необхідно додати вихідний вузол", "common.needStartNode": "Потрібно додати щонайменше один початковий вузол", + "common.noChanges": "Немає змін", "common.noHistory": "Без історії", "common.noVar": "Без змінної", + "common.notPublishedYet": "Ще не опубліковано", "common.notRunning": "Ще не запущено", "common.onFailure": "Про невдачу", "common.openInExplore": "Відкрити в Огляді", + "common.openWebApp": "Відкрити вебзастосунок", + "common.openWebAppDescription": "Відкрити опублікований застосунок у новій вкладці", "common.output": "Вихід", "common.overwriteAndImport": "Перезапис та імпорт", "common.parallel": "ПАРАЛЕЛЬНИЙ", @@ -238,10 +244,12 @@ "common.processData": "Обробити дані", "common.publish": "Опублікувати", "common.publishToMarketplace": "Опублікувати на Marketplace", + "common.publishToMarketplaceDescription": "Поділитися цим застосунком зі спільнотою", "common.publishToMarketplaceFailed": "Не вдалося опублікувати на Marketplace", "common.publishUpdate": "Опублікувати оновлення", "common.published": "Опубліковано", "common.publishedAt": "Опубліковано о", + "common.publishedBy": "Опубліковано {{time}} користувачем {{author}}", "common.publishingToMarketplace": "Публікація...", "common.redo": "Повторити", "common.restart": "Перезапустити", @@ -251,6 +259,7 @@ "common.runApp": "Запустити додаток", "common.runHistory": "Історія запусків", "common.running": "Запущено", + "common.savedAt": "Збережено {{time}}", "common.scheduleTriggerRunFailed": "Помилка виконання запланованого тригера", "common.searchVar": "Пошук змінної", "common.setVarValuePlaceholder": "Встановити значення змінної", @@ -265,6 +274,8 @@ "common.tagBound": "Кількість додатків, що використовують цей тег", "common.undo": "Скасувати", "common.unpublished": "Неопубліковано", + "common.unpublishedChanges": "Неопубліковані зміни", + "common.upToDate": "Актуально", "common.update": "Оновити", "common.variableNamePlaceholder": "Назва змінної", "common.versionHistory": "Історія версій", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Помилка налагодження Webhook", "common.webhookDebugRequestFailed": "Помилка запиту налагодження Webhook", "common.workflowAsTool": "Робочий потік як інструмент", + "common.workflowAsToolDescription": "Використовувати як інструмент в інших застосунках", "common.workflowAsToolDisabledHint": "Опублікуйте останній робочий процес і переконайтеся, що перед налаштуванням його як інструменту є підключений вузол введення користувача.", + "common.workflowAsToolReady": "Готово", + "common.workflowAsToolReconfigure": "Переналаштувати", "common.workflowAsToolTip": "Після оновлення робочого потоку необхідна переконфігурація інструменту.", + "common.workflowAsToolUpdateNeeded": "Потрібне оновлення", "common.workflowProcess": "Процес робочого потоку", "common.workflowProcessFailed": "Процес робочого потоку завершився помилкою", "common.workflowProcessPaused": "Процес робочого потоку призупинено", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Немає доступних змінних", "variableReference.noVarsForOperation": "Немає доступних змінних для призначення з обраною операцією.", "versionHistory.action.copyIdSuccess": "ID скопійовано в буфер обміну", + "versionHistory.action.deleteDeployedVersionError": "Цю версію робочого процесу розгорнуто або зараз розгортають в одному чи кількох середовищах. Перед видаленням версії скасуйте її розгортання в усіх середовищах.", "versionHistory.action.deleteFailure": "Не вдалося видалити версію", "versionHistory.action.deleteSuccess": "Версія видалена", "versionHistory.action.restoreFailure": "Не вдалося відновити версію", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Тільки твоє", "versionHistory.filter.reset": "Скинути фільтр", "versionHistory.latest": "Останні новини", + "versionHistory.nameIt": "Назвати", "versionHistory.nameThisVersion": "Назвіть цю версію", "versionHistory.releaseNotesPlaceholder": "Опишіть, що змінилося", "versionHistory.restorationTip": "Після відновлення версії нинішній проект буде перезаписано.", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index b9b866d7b23..c2496735fe2 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "Ngưng hoạt động", "agentDetail.access.title": "Điểm truy cập", "agentDetail.access.toggleSurface": "Bật/tắt truy cập {{name}}", - "agentDetail.access.webApp.accessUrl": "URL truy cập", + "agentDetail.access.webApp.accessUrl": "URL ứng dụng web", "agentDetail.access.webApp.actions.accessControl": "Kiểm soát truy cập", "agentDetail.access.webApp.actions.customize": "Giao diện tùy chỉnh", "agentDetail.access.webApp.actions.embedded": "Nhúng", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "Làm mới URL truy cập", "agentDetail.access.webApp.showQrCode": "Hiển thị mã QR", "agentDetail.access.webApp.ssoEnabled": "SSO đã bật", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Ứng dụng web", "agentDetail.access.workflow.description": "Được dùng như một nút bên trong quy trình làm việc", "agentDetail.access.workflow.empty": "Chưa có tham chiếu quy trình làm việc nào.", "agentDetail.access.workflow.loadFailed": "Tải tham chiếu quy trình làm việc thất bại.", diff --git a/web/i18n/vi-VN/app-api.json b/web/i18n/vi-VN/app-api.json index 6e217cd0f8d..d9ab05c7727 100644 --- a/web/i18n/vi-VN/app-api.json +++ b/web/i18n/vi-VN/app-api.json @@ -3,7 +3,7 @@ "actionMsg.deleteConfirmTitle": "Xóa khóa bí mật này?", "actionMsg.ok": "OK", "apiKey": "Khóa API", - "apiKeyModal.apiSecretKey": "Khóa bí mật API", + "apiKeyModal.apiSecretKey": "Khóa API", "apiKeyModal.apiSecretKeyTips": "Để ngăn chặn việc lạm dụng API, hãy bảo vệ Khóa API của bạn. Tránh sử dụng nó dưới dạng văn bản thuần trong mã giao diện người dùng.", "apiKeyModal.createNewSecretKey": "Tạo khóa bí mật mới", "apiKeyModal.created": "ĐÃ TẠO", diff --git a/web/i18n/vi-VN/app-overview.json b/web/i18n/vi-VN/app-overview.json index 12514420a6f..b899ee10e31 100644 --- a/web/i18n/vi-VN/app-overview.json +++ b/web/i18n/vi-VN/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "Thiết lập nhà cung cấp mô hình", "apiKeyInfo.tryCloud": "Hoặc dùng thử phiên bản đám mây của Dify với gói miễn phí", "apiKeyInfo.usedToken": "Token đã sử dụng", - "overview.apiInfo.accessibleAddress": "Điểm cuối dịch vụ API", + "overview.apiInfo.accessibleAddress": "Điểm cuối API", "overview.apiInfo.doc": "Tài liệu tham khảo API", "overview.apiInfo.explanation": "Dễ dàng tích hợp vào ứng dụng của bạn", "overview.apiInfo.title": "API dịch vụ backend", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "Quét và chia sẻ ứng dụng", "overview.appInfo.qrcode.title": "Mã QR để chia sẻ", "overview.appInfo.regenerate": "Tạo lại", - "overview.appInfo.regenerateNotice": "Bạn có muốn tạo lại địa chỉ công khai không?", + "overview.appInfo.regenerateNotice": "Bạn có muốn tạo lại địa chỉ công khai không? URL hiện tại sẽ ngừng hoạt động ngay lập tức.", "overview.appInfo.settings.chatColorTheme": "Giao diện màu trò chuyện", "overview.appInfo.settings.chatColorThemeDesc": "Thiết lập giao diện màu của chatbot", "overview.appInfo.settings.chatColorThemeInverted": "Đảo ngược", @@ -95,6 +95,7 @@ "overview.appInfo.settings.more.privacyPolicy": "Chính sách bảo mật", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "Nhập liên kết chính sách bảo mật", "overview.appInfo.settings.more.privacyPolicyTip": "Giúp người dùng hiểu dữ liệu mà ứng dụng thu thập, xem Chính sách bảo mật của Dify.", + "overview.appInfo.settings.multiEnvironmentNotice": "Cài đặt ứng dụng web áp dụng cho tất cả môi trường.", "overview.appInfo.settings.sso.description": "Tất cả người dùng được yêu cầu đăng nhập bằng SSO trước khi sử dụng web app", "overview.appInfo.settings.sso.label": "Xác thực SSO", "overview.appInfo.settings.sso.title": "SSO ứng dụng web", @@ -117,10 +118,10 @@ "overview.status.running": "Đang hoạt động", "overview.title": "Tổng quan", "overview.triggerInfo.explanation": "Quản lý kích hoạt quy trình làm việc", - "overview.triggerInfo.learnAboutTriggers": "Tìm hiểu về Các trình kích hoạt", + "overview.triggerInfo.learnAboutTriggers": "Tìm hiểu thêm về trình kích hoạt", "overview.triggerInfo.noTriggerAdded": "Chưa thêm trình kích hoạt", "overview.triggerInfo.title": "Nguyên nhân kích hoạt", - "overview.triggerInfo.triggerStatusDescription": "Trạng thái nút kích hoạt sẽ hiển thị ở đây. (Có thể đã tồn tại trong bản nháp, có hiệu lực sau khi xuất bản)", + "overview.triggerInfo.triggerStatusDescription": "Các trình kích hoạt sẽ xuất hiện ở đây sau khi bạn xuất bản phiên bản có chứa chúng.", "overview.triggerInfo.triggersAdded": "{{count}} Kích hoạt đã được thêm", "welcome.enterKeyTip": "nhập khóa API OpenAI của bạn bên dưới", "welcome.firstStepTip": "Để bắt đầu,", diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json index 72bdd90f736..30735ea7332 100644 --- a/web/i18n/vi-VN/common.json +++ b/web/i18n/vi-VN/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Các tiện ích API cung cấp quản lý API tập trung, giúp cấu hình dễ dàng sử dụng trên các ứng dụng của Dify.", "apiBasedExtension.type": "Loại", "apiBasedExtensionPage.description": "Mở rộng khả năng mô-đun của Dify qua API — thêm kiểm duyệt nội dung tùy chỉnh hoặc công cụ dữ liệu bên ngoài vào ứng dụng của bạn.", + "appMenus.accessPoint": "Điểm truy cập", "appMenus.annotations": "Chú thích", "appMenus.apiAccess": "Truy cập API", "appMenus.apiAccessTip": "Cơ sở kiến thức này có thể truy cập qua API dịch vụ", + "appMenus.deploy": "Triển khai", "appMenus.logs": "Nhật ký", "appMenus.overview": "Giám sát", "appMenus.promptEng": "Orchestrate", diff --git a/web/i18n/vi-VN/deployments.json b/web/i18n/vi-VN/deployments.json index 23ad7db2a22..c40d22f62c0 100644 --- a/web/i18n/vi-VN/deployments.json +++ b/web/i18n/vi-VN/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "Đang chạy (đang chờ đồng bộ)", "deployTab.status.runningWithFailed": "Đang chạy (triển khai gần nhất thất bại)", "deployTab.undeploy": "Hủy triển khai", - "deployTab.undeployConfirmDesc": "Quyền truy cập của người dùng cuối sẽ bị dừng ngay lập tức. Bản phát hành có thể được triển khai lại sau.", - "deployTab.undeployConfirmTitle": "Hủy triển khai khỏi {{name}}?", + "deployTab.undeployConfirmDesc": "Điểm cuối của môi trường sẽ không còn khả dụng, nhưng phiên bản sẽ không bị xóa.", + "deployTab.undeployConfirmTitle": "Hủy triển khai {{name}}?", + "deployTab.undeployConfirmWarning": "Sau khi xác nhận, môi trường này sẽ chuyển sang trạng thái đang hủy triển khai và các thao tác sẽ tạm thời bị vô hiệu hóa.", "deployTab.viewError": "Xem lỗi", "detail.mobileTabs": "Các mục triển khai", "detail.notFound": "Không tìm thấy triển khai", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "Chưa triển khai", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "Đang hủy triển khai", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "Không xác định", + "studio.accessPoint.addTriggerNodeDescription": "Thêm nút Trigger để khởi chạy ứng dụng này từ các sự kiện bên ngoài.", + "studio.accessPoint.apiDescription": "Gọi ứng dụng từ các dịch vụ của bạn qua HTTP", + "studio.accessPoint.description": "Mọi cách có thể truy cập ứng dụng này trong môi trường tích hợp sẵn và mọi môi trường đã triển khai.", + "studio.accessPoint.embedIntoSite": "Nhúng vào trang web", + "studio.accessPoint.goToPublish": "Đi đến xuất bản", + "studio.accessPoint.mcpDescription": "Cung cấp ứng dụng dưới dạng công cụ cho tác nhân và ứng dụng AI", + "studio.accessPoint.noPublishedDescription": "Xuất bản một phiên bản để sử dụng các điểm truy cập tích hợp.", + "studio.accessPoint.noPublishedTitle": "Chưa có phiên bản nào được xuất bản", + "studio.accessPoint.noTriggerNodes": "Không có nút Trigger trong quy trình này", + "studio.accessPoint.notSupported": "Không được hỗ trợ", + "studio.accessPoint.open": "Mở", + "studio.accessPoint.triggerDescription": "Chạy quy trình làm việc theo lịch hoặc sự kiện bên ngoài", + "studio.accessPoint.triggerDisconnected": "Đã ngắt kết nối", + "studio.accessPoint.triggerEnabledCount": "Đã bật {{enabled}}/{{total}}", + "studio.accessPoint.triggerExclusiveNotice": "Ứng dụng web, API và MCP bị tắt khi quy trình làm việc sử dụng trình kích hoạt.", + "studio.accessPoint.triggerMuted": "Đã tắt tiếng", + "studio.accessPoint.triggerServiceModeUnavailable": "Không khả dụng khi Web App, API hoặc MCP đang hoạt động.", + "studio.accessPoint.unsupportedInDeployedEnvironment": "Chỉ khả dụng trong môi trường tích hợp sẵn.", + "studio.accessPoint.webAppDescription": "Chia sẻ ứng dụng với người dùng cuối dưới dạng trang web", + "studio.accessPoints": "Điểm truy cập", + "studio.activity.deploy": "Triển khai {{target}}", + "studio.activity.deployFailed": "Triển khai {{target}} thất bại", + "studio.activity.deploySucceeded": "Đã triển khai {{target}} thành công", + "studio.activity.meta": "bởi {{name}} · {{time}}", + "studio.allVersions": "Tất cả phiên bản", + "studio.builtInDescription": "Luôn chạy phiên bản được phát hành mới nhất", + "studio.builtInTitle": "Môi trường tích hợp", + "studio.changeVersion": "Đổi phiên bản", + "studio.chooseVersionToDeploy": "Chọn phiên bản để triển khai", + "studio.current": "Hiện tại", + "studio.deployAnotherVersion": "Triển khai phiên bản khác", + "studio.deployConfiguration": "Cấu hình triển khai", + "studio.deployLatest": "Triển khai phiên bản mới nhất", + "studio.description": "Quản lý các bản triển khai của ứng dụng này trên nhiều môi trường. Có thể tạo thêm môi trường trong Dashboard dành cho Enterprise.", + "studio.emptyDescription": "Triển khai một phiên bản đã phát hành tới một môi trường và phiên bản đó sẽ xuất hiện tại đây.", + "studio.environmentVariablesDescription": "Sử dụng giá trị từ phiên bản bạn đang triển khai, giữ lại giá trị đã triển khai gần nhất hoặc nhập một giá trị tùy chỉnh.", + "studio.environments": "Môi trường", + "studio.environmentsInUse": "Đang dùng {{used}} trong {{total}} môi trường", + "studio.lastActivity": "Hoạt động gần nhất", + "studio.liveVersion": "Phiên bản đang chạy", + "studio.moreEnvironments": "Thêm môi trường", + "studio.noCredentialsYet": "Chưa có thông tin xác thực", + "studio.noCredentialsYetDescription": "Thêm thông tin xác thực trong phần Tích hợp hoặc cài đặt nút", + "studio.precheck.description": "Phiên bản này chứa các loại nút chưa được hỗ trợ:", + "studio.precheck.supportMessage": "Các loại nút này sẽ được hỗ trợ trong một bản phát hành trong tương lai.", + "studio.precheck.title": "Không thể triển khai phiên bản này vào môi trường này", + "studio.publisher.deployingVersion": "Đang triển khai: {{version}}", + "studio.publisher.noPublishedDescription": "Xuất bản bản nháp để triển khai tại đây.", + "studio.publisher.notDeployedYet": "Chưa được triển khai", + "studio.retryVersion": "Thử lại {{version}}", + "studio.undeployConfirmDesc": "Ứng dụng sẽ ngừng chạy trong môi trường này và tất cả các điểm truy cập của ứng dụng sẽ không còn khả dụng. Phiên bản đã triển khai sẽ không bị xóa.", + "studio.undeployConfirmTitle": "Hủy triển khai {{versionName}} khỏi {{envName}}", + "studio.updatedAtBy": "Cập nhật lúc {{time}} bởi {{name}}", + "studio.versionValue": "Giá trị phiên bản", + "studio.versionsBehind_one": "Chậm {{count}} phiên bản", + "studio.versionsBehind_other": "Chậm {{count}} phiên bản", "tabs.access.description": "Quản lý các kênh truy cập và quyền truy cập.", "tabs.access.name": "Truy cập", "tabs.api-tokens.description": "Quản lý API Tokens và gọi các phiên bản qua HTTP.", diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json index 072bd090294..006aafaa69c 100644 --- a/web/i18n/vi-VN/permission-keys.json +++ b/web/i18n/vi-VN/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "Cấu hình quyền truy cập ứng dụng", "app.acl.access_config": "Cấu hình quyền truy cập ứng dụng", "app.acl.delete": "Xóa ứng dụng", + "app.acl.deploy": "Triển khai ứng dụng", "app.acl.edit": "Chỉnh sửa và điều phối ứng dụng", "app.acl.import_export_dsl": "Nhập / xuất DSL", "app.acl.log_and_annotation": "Truy cập nhật ký và chú thích", diff --git a/web/i18n/vi-VN/tools.json b/web/i18n/vi-VN/tools.json index a5db4d87ff8..2659332f2e4 100644 --- a/web/i18n/vi-VN/tools.json +++ b/web/i18n/vi-VN/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "Mục đích và ràng buộc của tham số", "mcp.server.modal.parametersTip": "Thêm mô tả cho từng tham số để giúp LLM hiểu mục đích và ràng buộc.", "mcp.server.publishTip": "Ứng dụng chưa xuất bản. Vui lòng xuất bản ứng dụng trước.", - "mcp.server.reGen": "Tạo lại URL máy chủ?", + "mcp.server.reGen": "Tạo lại URL máy chủ? URL hiện tại sẽ ngừng hoạt động ngay lập tức.", "mcp.server.title": "Máy chủ MCP", "mcp.server.url": "URL Máy chủ", "mcp.toolItem.noDescription": "Không có mô tả", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 7419dff0686..0dfe38bc925 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "Reply", "common.ImageUploadLegacyTip": "Bây giờ bạn có thể tạo các biến loại tệp trong biểu mẫu bắt đầu. Chúng tôi sẽ không còn hỗ trợ tính năng tải lên hình ảnh trong tương lai.", "common.accessAPIReference": "Truy cập tài liệu API", + "common.accessPointDescription": "Cấu hình ứng dụng web, API, MCP và trình kích hoạt", "common.addBlock": "Thêm Node", "common.addDescription": "Thêm mô tả...", "common.addFailureBranch": "Thêm nhánh Fail", @@ -169,6 +170,7 @@ "common.currentView": "Hiện tại View", "common.currentWorkflow": "Quy trình làm việc hiện tại", "common.debugAndPreview": "Xem trước", + "common.deployDescription": "Triển khai các phiên bản tới môi trường", "common.disconnect": "Ngắt kết nối", "common.draftSaveFailed": "Lưu bản nháp thất bại", "common.duplicate": "Nhân bản", @@ -217,11 +219,15 @@ "common.needConnectTip": "Bước này không được kết nối với bất kỳ điều gì", "common.needOutputNode": "Phải thêm nút Đầu ra", "common.needStartNode": "Phải thêm ít nhất một nút bắt đầu", + "common.noChanges": "Không có thay đổi", "common.noHistory": "Không có lịch sử", "common.noVar": "Không có biến", + "common.notPublishedYet": "Chưa xuất bản", "common.notRunning": "Chưa chạy", "common.onFailure": "Khi thất bại", "common.openInExplore": "Mở trong Khám phá", + "common.openWebApp": "Mở ứng dụng web", + "common.openWebAppDescription": "Mở ứng dụng đã xuất bản trong tab mới", "common.output": "Đầu ra", "common.overwriteAndImport": "Ghi đè và nhập", "common.parallel": "SONG SONG", @@ -238,10 +244,12 @@ "common.processData": "Xử lý dữ liệu", "common.publish": "Xuất bản", "common.publishToMarketplace": "Xuất bản lên Marketplace", + "common.publishToMarketplaceDescription": "Chia sẻ ứng dụng này với cộng đồng", "common.publishToMarketplaceFailed": "Xuất bản lên Marketplace thất bại", "common.publishUpdate": "Cập nhật xuất bản", "common.published": "Đã xuất bản", "common.publishedAt": "Đã xuất bản lúc", + "common.publishedBy": "Đã xuất bản {{time}} bởi {{author}}", "common.publishingToMarketplace": "Đang xuất bản...", "common.redo": "Làm lại", "common.restart": "Khởi động lại", @@ -251,6 +259,7 @@ "common.runApp": "Chạy ứng dụng", "common.runHistory": "Lịch sử chạy", "common.running": "Đang chạy", + "common.savedAt": "Đã lưu {{time}}", "common.scheduleTriggerRunFailed": "Chạy trigger theo lịch thất bại", "common.searchVar": "Tìm kiếm biến", "common.setVarValuePlaceholder": "Đặt giá trị biến", @@ -265,6 +274,8 @@ "common.tagBound": "Số lượng ứng dụng sử dụng thẻ này", "common.undo": "Hoàn tác", "common.unpublished": "Chưa xuất bản", + "common.unpublishedChanges": "Thay đổi chưa xuất bản", + "common.upToDate": "Đã cập nhật", "common.update": "Cập nhật", "common.variableNamePlaceholder": "Tên biến", "common.versionHistory": "Lịch sử phiên bản", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Gỡ lỗi Webhook thất bại", "common.webhookDebugRequestFailed": "Yêu cầu gỡ lỗi Webhook thất bại", "common.workflowAsTool": "Quy trình làm việc như công cụ", + "common.workflowAsToolDescription": "Sử dụng làm công cụ trong các ứng dụng khác", "common.workflowAsToolDisabledHint": "Xuất bản quy trình làm việc mới nhất và đảm bảo một nút Nhập liệu Người dùng kết nối trước khi cấu hình nó như một công cụ.", + "common.workflowAsToolReady": "Sẵn sàng", + "common.workflowAsToolReconfigure": "Cấu hình lại", "common.workflowAsToolTip": "Cần cấu hình lại công cụ sau khi cập nhật quy trình làm việc.", + "common.workflowAsToolUpdateNeeded": "Cần cập nhật", "common.workflowProcess": "Quy trình làm việc", "common.workflowProcessFailed": "Quy trình làm việc thất bại", "common.workflowProcessPaused": "Quy trình làm việc đã tạm dừng", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "Không có biến khả dụng", "variableReference.noVarsForOperation": "Không có biến nào có sẵn để gán với hoạt động đã chọn.", "versionHistory.action.copyIdSuccess": "ID được sao chép vào khay nhớ tạm", + "versionHistory.action.deleteDeployedVersionError": "Phiên bản quy trình làm việc này đã được triển khai hoặc đang được triển khai tới một hay nhiều môi trường. Hãy hủy triển khai khỏi tất cả môi trường trước khi xóa phiên bản.", "versionHistory.action.deleteFailure": "Xóa phiên bản thất bại", "versionHistory.action.deleteSuccess": "Phiên bản đã bị xóa", "versionHistory.action.restoreFailure": "Không thể khôi phục phiên bản", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "Chỉ của bạn", "versionHistory.filter.reset": "Đặt lại bộ lọc", "versionHistory.latest": "Mới nhất", + "versionHistory.nameIt": "Đặt tên", "versionHistory.nameThisVersion": "Đặt tên cho phiên bản này", "versionHistory.releaseNotesPlaceholder": "Mô tả những gì đã thay đổi", "versionHistory.restorationTip": "Sau khi phục hồi phiên bản, bản nháp hiện tại sẽ bị ghi đè.", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index 40ede87b17a..6dc1a2f081b 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "已停用", "agentDetail.access.title": "访问点", "agentDetail.access.toggleSurface": "切换 {{name}} 访问状态", - "agentDetail.access.webApp.accessUrl": "访问 URL", + "agentDetail.access.webApp.accessUrl": "Web 应用 URL", "agentDetail.access.webApp.actions.accessControl": "访问控制", "agentDetail.access.webApp.actions.customize": "自定义前端", "agentDetail.access.webApp.actions.embedded": "嵌入", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "刷新访问 URL", "agentDetail.access.webApp.showQrCode": "显示二维码", "agentDetail.access.webApp.ssoEnabled": "已启用 SSO", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Web 应用", "agentDetail.access.workflow.description": "作为工作流内的节点使用", "agentDetail.access.workflow.empty": "暂无工作流引用。", "agentDetail.access.workflow.loadFailed": "工作流引用加载失败。", diff --git a/web/i18n/zh-Hans/app-overview.json b/web/i18n/zh-Hans/app-overview.json index 742717e61a4..4b4ec1ca15f 100644 --- a/web/i18n/zh-Hans/app-overview.json +++ b/web/i18n/zh-Hans/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "设置模型提供商", "apiKeyInfo.tryCloud": "或者尝试使用 Dify 的云版本并使用试用配额", "apiKeyInfo.usedToken": "使用 Tokens", - "overview.apiInfo.accessibleAddress": "API 访问凭据", + "overview.apiInfo.accessibleAddress": "API Endpoint", "overview.apiInfo.doc": "查阅 API 文档", "overview.apiInfo.explanation": "可集成至你的应用的后端即服务", "overview.apiInfo.title": "后端服务 API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "扫码分享应用", "overview.appInfo.qrcode.title": "二维码分享", "overview.appInfo.regenerate": "重新生成", - "overview.appInfo.regenerateNotice": "您是否要重新生成公开访问 URL?", + "overview.appInfo.regenerateNotice": "您是否要重新生成公开访问 URL?当前 URL 将立即失效。", "overview.appInfo.settings.chatColorTheme": "聊天颜色主题", "overview.appInfo.settings.chatColorThemeDesc": "设置聊天机器人的颜色主题", "overview.appInfo.settings.chatColorThemeInverted": "反转", @@ -95,15 +95,16 @@ "overview.appInfo.settings.more.privacyPolicy": "隐私政策", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "请输入隐私政策链接", "overview.appInfo.settings.more.privacyPolicyTip": "帮助访问者了解该应用收集的数据,可参考 Dify 的隐私政策。", + "overview.appInfo.settings.multiEnvironmentNotice": "Web App 设置将应用于所有环境。", "overview.appInfo.settings.sso.description": "启用后,所有用户都需要先进行 SSO 认证才能访问", "overview.appInfo.settings.sso.label": "单点登录认证", "overview.appInfo.settings.sso.title": "web app SSO 认证", "overview.appInfo.settings.sso.tooltip": "联系管理员以开启 web app SSO 认证", "overview.appInfo.settings.title": "品牌设置", - "overview.appInfo.settings.webDesc": "web app 描述", + "overview.appInfo.settings.webDesc": "Web App 描述", "overview.appInfo.settings.webDescPlaceholder": "请输入 web app 的描述", "overview.appInfo.settings.webDescTip": "以下文字将展示在客户端中,对应用进行说明和使用上的基本引导", - "overview.appInfo.settings.webName": "web app 名称", + "overview.appInfo.settings.webName": "Web App 名称", "overview.appInfo.settings.workflow.hide": "隐藏", "overview.appInfo.settings.workflow.show": "显示", "overview.appInfo.settings.workflow.showDesc": "在 web app 中展示或者隐藏工作流详情", @@ -117,10 +118,10 @@ "overview.status.running": "运行中", "overview.title": "概览", "overview.triggerInfo.explanation": "工作流触发器管理", - "overview.triggerInfo.learnAboutTriggers": "了解触发器", + "overview.triggerInfo.learnAboutTriggers": "进一步了解触发器", "overview.triggerInfo.noTriggerAdded": "未添加触发器", "overview.triggerInfo.title": "触发器", - "overview.triggerInfo.triggerStatusDescription": "触发器节点状态显示在这里。(草稿中可能已存在,发布后生效)", + "overview.triggerInfo.triggerStatusDescription": "发布包含触发器的版本后,触发器会显示在这里。", "overview.triggerInfo.triggersAdded": "已添加 {{count}} 个触发器", "welcome.enterKeyTip": "请先在下方输入你的 OpenAI API Key", "welcome.firstStepTip": "开始之前,", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index 5ced66f929b..fe70ac5985c 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Custom Endpoints 提供集中式 API 管理,简化配置,方便在 Dify 的应用中使用。", "apiBasedExtension.type": "类型", "apiBasedExtensionPage.description": "通过 API 扩展 Dify 的模块能力——为应用接入自定义内容审核或外部数据工具。", + "appMenus.accessPoint": "访问点", "appMenus.annotations": "标注", "appMenus.apiAccess": "访问 API", "appMenus.apiAccessTip": "此知识库可通过服务 API 访问", + "appMenus.deploy": "部署", "appMenus.logs": "日志", "appMenus.overview": "监测", "appMenus.promptEng": "编排", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index 109e935d4d0..2c8c1ee60e5 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "运行中(待同步)", "deployTab.status.runningWithFailed": "运行中(上次部署失败)", "deployTab.undeploy": "下线", - "deployTab.undeployConfirmDesc": "用户访问将立即停止。该版本之后仍可重新部署。", - "deployTab.undeployConfirmTitle": "从 {{name}} 下线?", + "deployTab.undeployConfirmDesc": "环境端点将无法访问,但版本不会被删除。", + "deployTab.undeployConfirmTitle": "下线 {{name}}?", + "deployTab.undeployConfirmWarning": "确认后,此环境将进入下线中状态,相关操作将暂时禁用。", "deployTab.viewError": "查看错误", "detail.mobileTabs": "部署页面", "detail.notFound": "未找到部署", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未部署", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "下线中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", + "studio.accessPoint.addTriggerNodeDescription": "添加 Trigger 节点,以便通过外部事件启动此应用。", + "studio.accessPoint.apiDescription": "通过 HTTP 从你的服务调用应用", + "studio.accessPoint.description": "应用在内置环境和每个已部署环境中的所有访问方式。", + "studio.accessPoint.embedIntoSite": "嵌入网站", + "studio.accessPoint.goToPublish": "去发布", + "studio.accessPoint.mcpDescription": "将应用作为工具提供给 Agent 和 AI 应用", + "studio.accessPoint.noPublishedDescription": "发布一个版本后即可使用内置访问点。", + "studio.accessPoint.noPublishedTitle": "尚未发布任何版本", + "studio.accessPoint.noTriggerNodes": "此工作流中没有 Trigger 节点", + "studio.accessPoint.notSupported": "不支持", + "studio.accessPoint.open": "打开", + "studio.accessPoint.triggerDescription": "按计划或外部事件运行工作流", + "studio.accessPoint.triggerDisconnected": "连接已断开", + "studio.accessPoint.triggerEnabledCount": "已启用 {{enabled}}/{{total}}", + "studio.accessPoint.triggerExclusiveNotice": "工作流使用触发器时,Web 应用、API 和 MCP 将关闭。", + "studio.accessPoint.triggerMuted": "已静默", + "studio.accessPoint.triggerServiceModeUnavailable": "Web App、API 或 MCP 正在服务时不可用。", + "studio.accessPoint.unsupportedInDeployedEnvironment": "仅在内置环境中可用。", + "studio.accessPoint.webAppDescription": "以网页形式将应用分享给最终用户", + "studio.accessPoints": "访问点", + "studio.activity.deploy": "部署 {{target}}", + "studio.activity.deployFailed": "部署 {{target}} 失败", + "studio.activity.deploySucceeded": "部署 {{target}} 成功", + "studio.activity.meta": "由 {{name}} 操作 · {{time}}", + "studio.allVersions": "全部版本", + "studio.builtInDescription": "始终运行最新发布的版本", + "studio.builtInTitle": "内置环境", + "studio.changeVersion": "更改版本", + "studio.chooseVersionToDeploy": "选择要部署的版本", + "studio.current": "当前", + "studio.deployAnotherVersion": "部署其他版本", + "studio.deployConfiguration": "部署配置", + "studio.deployLatest": "部署最新版本", + "studio.description": "管理此应用在各个环境中的部署。可在企业版 Dashboard 中创建更多环境。", + "studio.emptyDescription": "将已发布版本部署到环境后,它会显示在这里。", + "studio.environmentVariablesDescription": "使用待部署版本中的值、保留上次部署的值,或输入自定义值。", + "studio.environments": "环境", + "studio.environmentsInUse": "已使用 {{used}} / {{total}} 个环境", + "studio.lastActivity": "最近活动", + "studio.liveVersion": "线上版本", + "studio.moreEnvironments": "更多环境", + "studio.noCredentialsYet": "暂无凭据", + "studio.noCredentialsYetDescription": "在集成或节点设置中添加凭据", + "studio.precheck.description": "它包含尚不支持的节点类型:", + "studio.precheck.supportMessage": "未来版本将支持这些节点类型。", + "studio.precheck.title": "此版本无法部署到该环境", + "studio.publisher.deployingVersion": "部署中:{{version}}", + "studio.publisher.noPublishedDescription": "发布草稿后即可在此部署。", + "studio.publisher.notDeployedYet": "尚未部署", + "studio.retryVersion": "重试 {{version}}", + "studio.undeployConfirmDesc": "该应用将停止在此环境中运行,其所有访问点都将无法使用。已部署的版本不会被删除。", + "studio.undeployConfirmTitle": "从 {{envName}} 下线 {{versionName}}", + "studio.updatedAtBy": "{{name}} 于 {{time}} 更新", + "studio.versionValue": "版本值", + "studio.versionsBehind_one": "落后 {{count}} 个版本", + "studio.versionsBehind_other": "落后 {{count}} 个版本", "tabs.access.description": "管理接入渠道与访问权限。", "tabs.access.name": "接入方式", "tabs.api-tokens.description": "管理 API 令牌,通过 HTTP 调用各环境中的实例。", diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json index a8b462e8f44..864941f7f59 100644 --- a/web/i18n/zh-Hans/permission-keys.json +++ b/web/i18n/zh-Hans/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "配置应用访问权限", "app.acl.access_config": "配置应用访问权限", "app.acl.delete": "删除应用", + "app.acl.deploy": "部署应用", "app.acl.edit": "编辑与编排应用", "app.acl.import_export_dsl": "导入 / 导出 DSL", "app.acl.log_and_annotation": "访问日志与标注", diff --git a/web/i18n/zh-Hans/tools.json b/web/i18n/zh-Hans/tools.json index 7a79ca294ce..01a6659d229 100644 --- a/web/i18n/zh-Hans/tools.json +++ b/web/i18n/zh-Hans/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "参数的用途和约束条件", "mcp.server.modal.parametersTip": "为每个参数添加描述,以帮助 LLM 理解其目的和约束条件。", "mcp.server.publishTip": "应用未发布。请先发布应用。", - "mcp.server.reGen": "你想要重新生成服务端点 URL 吗?", + "mcp.server.reGen": "你想要重新生成服务端点 URL 吗?当前 URL 将立即失效。", "mcp.server.title": "MCP 服务", "mcp.server.url": "服务端点 URL", "mcp.toolItem.noDescription": "暂无描述", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 2f7f0f6e15d..636328ef979 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "回复", "common.ImageUploadLegacyTip": "现在可以在 start 表单中创建文件类型变量。未来我们将不继续支持图片上传功能。", "common.accessAPIReference": "访问 API", + "common.accessPointDescription": "配置 Web 应用、API、MCP 和触发器", "common.addBlock": "添加节点", "common.addDescription": "添加描述...", "common.addFailureBranch": "添加异常分支", @@ -169,6 +170,7 @@ "common.currentView": "当前视图", "common.currentWorkflow": "整个工作流", "common.debugAndPreview": "预览", + "common.deployDescription": "将版本部署到各环境", "common.disconnect": "断开连接", "common.draftSaveFailed": "草稿保存失败", "common.duplicate": "复制", @@ -217,11 +219,15 @@ "common.needConnectTip": "此节点尚未连接到其他节点", "common.needOutputNode": "必须添加输出节点", "common.needStartNode": "必须添加至少一个开始节点", + "common.noChanges": "无更改", "common.noHistory": "没有历史版本", "common.noVar": "没有变量", + "common.notPublishedYet": "尚未发布", "common.notRunning": "尚未运行", "common.onFailure": "异常时", "common.openInExplore": "在“探索”中打开", + "common.openWebApp": "打开 Web 应用", + "common.openWebAppDescription": "在新标签页打开已发布的应用", "common.output": "输出", "common.overwriteAndImport": "覆盖并导入", "common.parallel": "并行", @@ -238,10 +244,12 @@ "common.processData": "数据处理", "common.publish": "发布", "common.publishToMarketplace": "发布到 Marketplace", + "common.publishToMarketplaceDescription": "将此应用作为模板上架", "common.publishToMarketplaceFailed": "发布到 Marketplace 失败", "common.publishUpdate": "发布更新", "common.published": "已发布", "common.publishedAt": "发布于", + "common.publishedBy": "{{author}} 发布于 {{time}}", "common.publishingToMarketplace": "发布中...", "common.redo": "重做", "common.restart": "重新开始", @@ -251,6 +259,7 @@ "common.runApp": "运行", "common.runHistory": "运行历史", "common.running": "运行中", + "common.savedAt": "{{time}}已保存", "common.scheduleTriggerRunFailed": "计划触发运行失败", "common.searchVar": "搜索变量", "common.setVarValuePlaceholder": "设置变量值", @@ -265,6 +274,8 @@ "common.tagBound": "使用此标签的应用数量", "common.undo": "撤销", "common.unpublished": "未发布", + "common.unpublishedChanges": "有未发布的更改", + "common.upToDate": "已是最新", "common.update": "更新", "common.variableNamePlaceholder": "变量名", "common.versionHistory": "版本历史", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook 调试失败", "common.webhookDebugRequestFailed": "Webhook 调试请求失败", "common.workflowAsTool": "工作流作为工具", + "common.workflowAsToolDescription": "作为工具供其他应用调用", "common.workflowAsToolDisabledHint": "请先发布最新的工作流,并确保已连接的 User Input 节点后再配置为工具。", + "common.workflowAsToolReady": "已就绪", + "common.workflowAsToolReconfigure": "重新配置", "common.workflowAsToolTip": "工作流更新后需要重新配置工具参数", + "common.workflowAsToolUpdateNeeded": "需要更新", "common.workflowProcess": "工作流", "common.workflowProcessFailed": "工作流运行失败", "common.workflowProcessPaused": "工作流运行已暂停", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "没有可用变量", "variableReference.noVarsForOperation": "当前选择的操作没有可用的变量进行赋值。", "versionHistory.action.copyIdSuccess": "ID 已复制到剪贴板", + "versionHistory.action.deleteDeployedVersionError": "此工作流版本已部署或正在部署到一个或多个环境。请先从所有环境中取消部署,再删除该版本。", "versionHistory.action.deleteFailure": "删除失败", "versionHistory.action.deleteSuccess": "版本已删除", "versionHistory.action.restoreFailure": "回滚失败", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "仅你的", "versionHistory.filter.reset": "重置", "versionHistory.latest": "最新", + "versionHistory.nameIt": "命名", "versionHistory.nameThisVersion": "命名", "versionHistory.releaseNotesPlaceholder": "请描述变更", "versionHistory.restorationTip": "版本回滚后,当前草稿将被覆盖。", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index 6277a431f03..4af091a1e2c 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -13,7 +13,7 @@ "agentDetail.access.status.outOfService": "已停止服務", "agentDetail.access.title": "存取點", "agentDetail.access.toggleSurface": "切換 {{name}} 存取狀態", - "agentDetail.access.webApp.accessUrl": "存取 URL", + "agentDetail.access.webApp.accessUrl": "Web 應用程式 URL", "agentDetail.access.webApp.actions.accessControl": "存取控制", "agentDetail.access.webApp.actions.customize": "自訂前端", "agentDetail.access.webApp.actions.embedded": "嵌入", @@ -22,7 +22,7 @@ "agentDetail.access.webApp.refreshUrl": "重新整理存取 URL", "agentDetail.access.webApp.showQrCode": "顯示 QR 碼", "agentDetail.access.webApp.ssoEnabled": "已啟用 SSO", - "agentDetail.access.webApp.title": "Web app", + "agentDetail.access.webApp.title": "Web 應用程式", "agentDetail.access.workflow.description": "作為工作流程內的節點使用", "agentDetail.access.workflow.empty": "暫無工作流程引用。", "agentDetail.access.workflow.loadFailed": "工作流程引用載入失敗。", diff --git a/web/i18n/zh-Hant/app-overview.json b/web/i18n/zh-Hant/app-overview.json index c0900bb9745..0630a015738 100644 --- a/web/i18n/zh-Hant/app-overview.json +++ b/web/i18n/zh-Hant/app-overview.json @@ -31,7 +31,7 @@ "apiKeyInfo.setAPIBtn": "設定模型提供商", "apiKeyInfo.tryCloud": "或者嘗試使用 Dify 的雲版本並使用試用配額", "apiKeyInfo.usedToken": "使用 Tokens", - "overview.apiInfo.accessibleAddress": "API 訪問憑據", + "overview.apiInfo.accessibleAddress": "API Endpoint", "overview.apiInfo.doc": "查閱 API 文件", "overview.apiInfo.explanation": "可整合至你的應用的後端即服務", "overview.apiInfo.title": "後端服務 API", @@ -71,7 +71,7 @@ "overview.appInfo.qrcode.scan": "掃碼分享應用", "overview.appInfo.qrcode.title": "二維碼分享", "overview.appInfo.regenerate": "重新生成", - "overview.appInfo.regenerateNotice": "您是否要重新生成公開訪問 URL?", + "overview.appInfo.regenerateNotice": "您是否要重新生成公開訪問 URL?目前的 URL 將立即失效。", "overview.appInfo.settings.chatColorTheme": "聊天顏色主題", "overview.appInfo.settings.chatColorThemeDesc": "設定聊天機器人的顏色主題", "overview.appInfo.settings.chatColorThemeInverted": "反轉", @@ -95,15 +95,16 @@ "overview.appInfo.settings.more.privacyPolicy": "隱私政策", "overview.appInfo.settings.more.privacyPolicyPlaceholder": "請輸入隱私政策連結", "overview.appInfo.settings.more.privacyPolicyTip": "幫助訪問者瞭解該應用收集的資料,可參考 Dify 的隱私政策。", + "overview.appInfo.settings.multiEnvironmentNotice": "Web App 設定將套用至所有環境。", "overview.appInfo.settings.sso.description": "所有使用者在使用 web app 之前都需要使用 SSO 登錄", "overview.appInfo.settings.sso.label": "SSO 身份驗證", "overview.appInfo.settings.sso.title": "web app SSO", "overview.appInfo.settings.sso.tooltip": "聯繫管理員以啟用 web app SSO", "overview.appInfo.settings.title": "品牌設定", - "overview.appInfo.settings.webDesc": "web app 描述", + "overview.appInfo.settings.webDesc": "Web App 描述", "overview.appInfo.settings.webDescPlaceholder": "請輸入 web app 的描述", "overview.appInfo.settings.webDescTip": "以下文字將展示在客戶端中,對應用進行說明和使用上的基本引導", - "overview.appInfo.settings.webName": "web app 名稱", + "overview.appInfo.settings.webName": "Web App 名稱", "overview.appInfo.settings.workflow.hide": "隱藏", "overview.appInfo.settings.workflow.show": "展示", "overview.appInfo.settings.workflow.showDesc": "在 web app 中顯示或隱藏工作流詳細資訊", @@ -117,10 +118,10 @@ "overview.status.running": "執行中", "overview.title": "概覽", "overview.triggerInfo.explanation": "工作流程觸發管理", - "overview.triggerInfo.learnAboutTriggers": "了解觸發因素", + "overview.triggerInfo.learnAboutTriggers": "進一步了解觸發器", "overview.triggerInfo.noTriggerAdded": "未添加觸發器", "overview.triggerInfo.title": "觸發因素", - "overview.triggerInfo.triggerStatusDescription": "觸發節點狀態將顯示於此。(可能已存在於草稿中,發布後生效)", + "overview.triggerInfo.triggerStatusDescription": "發布包含觸發器的版本後,觸發器會顯示在這裡。", "overview.triggerInfo.triggersAdded": "{{count}} 已新增觸發器", "welcome.enterKeyTip": "請先在下方輸入你的 OpenAI API Key", "welcome.firstStepTip": "開始之前,", diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json index ada8ea8349f..29e455e99d6 100644 --- a/web/i18n/zh-Hant/common.json +++ b/web/i18n/zh-Hant/common.json @@ -96,9 +96,11 @@ "apiBasedExtension.title": "Custom Endpoints 提供集中式 API 管理,簡化配置,方便在 Dify 的應用中使用。", "apiBasedExtension.type": "型別", "apiBasedExtensionPage.description": "註冊你自己的 API endpoints,讓 Dify 呼叫它們完成內容審核、外部資料等能力。", + "appMenus.accessPoint": "存取點", "appMenus.annotations": "標註", "appMenus.apiAccess": "訪問 API", "appMenus.apiAccessTip": "此知識庫可透過服務 API 訪問", + "appMenus.deploy": "部署", "appMenus.logs": "日誌", "appMenus.overview": "監控", "appMenus.promptEng": "編排", diff --git a/web/i18n/zh-Hant/deployments.json b/web/i18n/zh-Hant/deployments.json index e2c06a3233f..a583e692690 100644 --- a/web/i18n/zh-Hant/deployments.json +++ b/web/i18n/zh-Hant/deployments.json @@ -227,8 +227,9 @@ "deployTab.status.runningOutOfSync": "執行中(待同步)", "deployTab.status.runningWithFailed": "執行中(上次部署失敗)", "deployTab.undeploy": "下線", - "deployTab.undeployConfirmDesc": "終端使用者存取將立即停止。版本之後仍可重新部署。", - "deployTab.undeployConfirmTitle": "從 {{name}} 下線?", + "deployTab.undeployConfirmDesc": "環境端點將無法存取,但版本不會被刪除。", + "deployTab.undeployConfirmTitle": "下線 {{name}}?", + "deployTab.undeployConfirmWarning": "確認後,此環境將進入下線中狀態,相關操作將暫時停用。", "deployTab.viewError": "檢視錯誤", "detail.mobileTabs": "部署頁面", "detail.notFound": "找不到部署", @@ -313,6 +314,62 @@ "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED": "未部署", "status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING": "下線中", "status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED": "未知", + "studio.accessPoint.addTriggerNodeDescription": "新增 Trigger 節點,以便透過外部事件啟動此應用程式。", + "studio.accessPoint.apiDescription": "透過 HTTP 從你的服務呼叫應用程式", + "studio.accessPoint.description": "應用程式在內建環境和每個已部署環境中的所有存取方式。", + "studio.accessPoint.embedIntoSite": "嵌入網站", + "studio.accessPoint.goToPublish": "前往發佈", + "studio.accessPoint.mcpDescription": "將應用程式作為工具提供給 Agent 和 AI 應用程式", + "studio.accessPoint.noPublishedDescription": "發佈版本後即可使用內建存取點。", + "studio.accessPoint.noPublishedTitle": "尚未發佈任何版本", + "studio.accessPoint.noTriggerNodes": "此工作流程中沒有 Trigger 節點", + "studio.accessPoint.notSupported": "不支援", + "studio.accessPoint.open": "開啟", + "studio.accessPoint.triggerDescription": "依排程或外部事件執行工作流程", + "studio.accessPoint.triggerDisconnected": "連線已中斷", + "studio.accessPoint.triggerEnabledCount": "已啟用 {{enabled}}/{{total}}", + "studio.accessPoint.triggerExclusiveNotice": "工作流程使用觸發器時,Web 應用程式、API 和 MCP 將關閉。", + "studio.accessPoint.triggerMuted": "已靜音", + "studio.accessPoint.triggerServiceModeUnavailable": "Web App、API 或 MCP 正在服務時無法使用。", + "studio.accessPoint.unsupportedInDeployedEnvironment": "僅在內建環境中可用。", + "studio.accessPoint.webAppDescription": "以網頁形式將應用程式分享給最終使用者", + "studio.accessPoints": "存取點", + "studio.activity.deploy": "部署 {{target}}", + "studio.activity.deployFailed": "部署 {{target}} 失敗", + "studio.activity.deploySucceeded": "部署 {{target}} 成功", + "studio.activity.meta": "由 {{name}} 操作 · {{time}}", + "studio.allVersions": "所有版本", + "studio.builtInDescription": "始終執行最新發佈的版本", + "studio.builtInTitle": "內建環境", + "studio.changeVersion": "變更版本", + "studio.chooseVersionToDeploy": "選擇要部署的版本", + "studio.current": "目前", + "studio.deployAnotherVersion": "部署其他版本", + "studio.deployConfiguration": "部署設定", + "studio.deployLatest": "部署最新版本", + "studio.description": "管理此應用程式在各個環境中的部署。可在企業版 Dashboard 中建立更多環境。", + "studio.emptyDescription": "將已發布版本部署至環境後,它會顯示在這裡。", + "studio.environmentVariablesDescription": "使用要部署版本中的值、保留上次部署的值,或輸入自訂值。", + "studio.environments": "環境", + "studio.environmentsInUse": "已使用 {{used}} / {{total}} 個環境", + "studio.lastActivity": "最近活動", + "studio.liveVersion": "線上版本", + "studio.moreEnvironments": "更多環境", + "studio.noCredentialsYet": "尚無憑證", + "studio.noCredentialsYetDescription": "在整合或節點設定中新增憑證", + "studio.precheck.description": "它包含尚未支援的節點類型:", + "studio.precheck.supportMessage": "未來版本將支援這些節點類型。", + "studio.precheck.title": "此版本無法部署到此環境", + "studio.publisher.deployingVersion": "部署中:{{version}}", + "studio.publisher.noPublishedDescription": "發佈草稿後即可在此部署。", + "studio.publisher.notDeployedYet": "尚未部署", + "studio.retryVersion": "重試 {{version}}", + "studio.undeployConfirmDesc": "此應用程式將停止在此環境中執行,其所有存取點都將無法使用。已部署的版本不會被刪除。", + "studio.undeployConfirmTitle": "從 {{envName}} 下線 {{versionName}}", + "studio.updatedAtBy": "{{name}} 於 {{time}} 更新", + "studio.versionValue": "版本值", + "studio.versionsBehind_one": "落後 {{count}} 個版本", + "studio.versionsBehind_other": "落後 {{count}} 個版本", "tabs.access.description": "管理存取通道與存取權限。", "tabs.access.name": "存取", "tabs.api-tokens.description": "管理 API Token,並透過 HTTP 呼叫各環境中的實例。", diff --git a/web/i18n/zh-Hant/permission-keys.json b/web/i18n/zh-Hant/permission-keys.json index 4542340a1d5..b367bf03753 100644 --- a/web/i18n/zh-Hant/permission-keys.json +++ b/web/i18n/zh-Hant/permission-keys.json @@ -4,6 +4,7 @@ "app.access_config": "配置應用訪問權限", "app.acl.access_config": "配置應用訪問權限", "app.acl.delete": "刪除應用", + "app.acl.deploy": "部署應用", "app.acl.edit": "編輯與編排應用", "app.acl.import_export_dsl": "匯入 / 匯出 DSL", "app.acl.log_and_annotation": "訪問日誌與標註", diff --git a/web/i18n/zh-Hant/tools.json b/web/i18n/zh-Hant/tools.json index a8f3ac91549..6298f30b22b 100644 --- a/web/i18n/zh-Hant/tools.json +++ b/web/i18n/zh-Hant/tools.json @@ -162,7 +162,7 @@ "mcp.server.modal.parametersPlaceholder": "參數的目的和約束", "mcp.server.modal.parametersTip": "為每個參數新增描述,以幫助 LLM 理解其目的和約束。", "mcp.server.publishTip": "應用程式尚未發布。請先發布應用程式。", - "mcp.server.reGen": "您想要重新生成伺服器 URL 嗎?", + "mcp.server.reGen": "您想要重新生成伺服器 URL 嗎?目前的 URL 將立即失效。", "mcp.server.title": "MCP 伺服器", "mcp.server.url": "伺服器 URL", "mcp.toolItem.noDescription": "無描述", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index d0e8b9cb86c..f51522d3901 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -147,6 +147,7 @@ "comments.reply": "回覆", "common.ImageUploadLegacyTip": "現在,您可以在起始表單中創建檔案類型變數。我們將來不再支持圖片上傳功能。", "common.accessAPIReference": "訪問 API", + "common.accessPointDescription": "設定 Web 應用、API、MCP 和觸發器", "common.addBlock": "新增節點", "common.addDescription": "新增描述...", "common.addFailureBranch": "新增 Fail Branch", @@ -169,6 +170,7 @@ "common.currentView": "當前檢視", "common.currentWorkflow": "當前工作流程", "common.debugAndPreview": "預覽", + "common.deployDescription": "將版本部署至環境", "common.disconnect": "斷開", "common.draftSaveFailed": "草稿儲存失敗", "common.duplicate": "複製", @@ -217,11 +219,15 @@ "common.needConnectTip": "此節點尚未連接到其他節點", "common.needOutputNode": "必須新增輸出節點", "common.needStartNode": "至少必須新增一個起始節點", + "common.noChanges": "沒有變更", "common.noHistory": "無歷史記錄", "common.noVar": "沒有變數", + "common.notPublishedYet": "尚未發佈", "common.notRunning": "尚未運行", "common.onFailure": "失敗時", "common.openInExplore": "在“探索”中打開", + "common.openWebApp": "開啟 Web 應用", + "common.openWebAppDescription": "在新分頁中開啟已發佈的應用", "common.output": "輸出", "common.overwriteAndImport": "覆蓋和導入", "common.parallel": "並行", @@ -238,10 +244,12 @@ "common.processData": "資料處理", "common.publish": "發佈", "common.publishToMarketplace": "發佈到 Marketplace", + "common.publishToMarketplaceDescription": "與社群分享此應用", "common.publishToMarketplaceFailed": "發佈到 Marketplace 失敗", "common.publishUpdate": "發布更新", "common.published": "已發佈", "common.publishedAt": "發佈於", + "common.publishedBy": "{{author}} 發佈於 {{time}}", "common.publishingToMarketplace": "發佈中...", "common.redo": "重做", "common.restart": "重新開始", @@ -251,6 +259,7 @@ "common.runApp": "運行", "common.runHistory": "運行歷史", "common.running": "運行中", + "common.savedAt": "{{time}}已儲存", "common.scheduleTriggerRunFailed": "排程觸發執行失敗", "common.searchVar": "搜索變數", "common.setVarValuePlaceholder": "設置變數值", @@ -265,6 +274,8 @@ "common.tagBound": "使用此標籤的應用程式數量", "common.undo": "復原", "common.unpublished": "未發佈", + "common.unpublishedChanges": "有未發佈的變更", + "common.upToDate": "已是最新", "common.update": "更新", "common.variableNamePlaceholder": "變數名", "common.versionHistory": "版本歷史", @@ -274,8 +285,12 @@ "common.webhookDebugFailed": "Webhook 除錯失敗", "common.webhookDebugRequestFailed": "Webhook 除錯請求失敗", "common.workflowAsTool": "工作流程作為工具", + "common.workflowAsToolDescription": "在其他應用中作為工具使用", "common.workflowAsToolDisabledHint": "發布最新的工作流程,並確保在將其配置為工具之前有一個已連接的使用者輸入節點。", + "common.workflowAsToolReady": "已就緒", + "common.workflowAsToolReconfigure": "重新設定", "common.workflowAsToolTip": "工作流更新後需要重新配置工具參數", + "common.workflowAsToolUpdateNeeded": "需要更新", "common.workflowProcess": "工作流", "common.workflowProcessFailed": "工作流運行失敗", "common.workflowProcessPaused": "工作流運行已暫停", @@ -1295,6 +1310,7 @@ "variableReference.noAvailableVars": "無可用變數", "variableReference.noVarsForOperation": "所選操作沒有可用於賦值的變數。", "versionHistory.action.copyIdSuccess": "ID 已複製到剪貼板", + "versionHistory.action.deleteDeployedVersionError": "此工作流程版本已部署或正在部署至一個或多個環境。請先從所有環境中取消部署,再刪除該版本。", "versionHistory.action.deleteFailure": "無法刪除版本", "versionHistory.action.deleteSuccess": "版本已刪除", "versionHistory.action.restoreFailure": "無法恢復版本", @@ -1317,6 +1333,7 @@ "versionHistory.filter.onlyYours": "只有妳的", "versionHistory.filter.reset": "重置過濾器", "versionHistory.latest": "最新", + "versionHistory.nameIt": "命名", "versionHistory.nameThisVersion": "給這個版本命名", "versionHistory.releaseNotesPlaceholder": "描述發生了什麼變化", "versionHistory.restorationTip": "版本恢復後,當前草稿將被覆蓋。", diff --git a/web/service/__tests__/use-workflow.spec.tsx b/web/service/__tests__/use-workflow.spec.tsx new file mode 100644 index 00000000000..f7ba263a6ad --- /dev/null +++ b/web/service/__tests__/use-workflow.spec.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useUpdateWorkflow } from '../use-workflow' +import { appWorkflowVersionsInfiniteQueryKey } from '../workflow-queries' + +const mockPatch = vi.hoisted(() => vi.fn()) + +vi.mock('../base', () => ({ + del: vi.fn(), + get: vi.fn(), + patch: (...args: unknown[]) => mockPatch(...args), + post: vi.fn(), + put: vi.fn(), +})) + +const createWrapper = (queryClient: QueryClient) => + function Wrapper({ children }: { children: ReactNode }) { + return {children} + } + +describe('useUpdateWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPatch.mockResolvedValue({}) + }) + + it('should invalidate workflow version history after updating version information', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false }, + }, + }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const { result } = renderHook(() => useUpdateWorkflow(), { + wrapper: createWrapper(queryClient), + }) + + await act(async () => { + await result.current.mutateAsync({ + url: '/apps/app-1/workflows/workflow-1', + title: 'Release 1', + releaseNotes: 'Notes', + }) + }) + + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['workflow', 'versionHistory'], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: appWorkflowVersionsInfiniteQueryKey(), + }) + }) +}) diff --git a/web/service/base.spec.ts b/web/service/base.spec.ts index f0427322122..fb014a8ab00 100644 --- a/web/service/base.spec.ts +++ b/web/service/base.spec.ts @@ -1,8 +1,20 @@ import { toast } from '@langgenius/dify-ui/toast' import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { PUBLIC_API_PREFIX } from '@/config' // oxlint-disable-next-line no-restricted-imports -import { del, get, handleStream, patch, post, put, sseGet, ssePost } from './base' +import { + del, + get, + handleStream, + patch, + post, + postPublic, + put, + sseGet, + ssePost, + upload, +} from './base' const refreshAccessTokenOrReLoginMock = vi.hoisted(() => vi.fn()) @@ -595,6 +607,53 @@ describe('ssePost and sseGet', () => { expect(onNotifyError).not.toHaveBeenCalled() expect(toast.error).not.toHaveBeenCalled() }) + + it('uses the environment webapp API for workflow runs and stops', async () => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 204 })) + + await ssePost('/workflows/run', { body: { inputs: {} } }, { isPublicAPI: true }) + await postPublic('/workflows/tasks/task-1/stop') + + expect(fetchSpy.mock.calls[0]![0]).toBe(`${PUBLIC_API_PREFIX}/env/workflow-app/workflows/run`) + const stopRequest = fetchSpy.mock.calls[1]![0] + expect(stopRequest).toBeInstanceOf(Request) + if (!(stopRequest instanceof Request)) throw new TypeError('Expected a request') + expect(stopRequest.url).toBe( + `${PUBLIC_API_PREFIX}/env/workflow-app/workflows/tasks/task-1/stop`, + ) + }) + + it('uses the environment webapp API for local and remote uploads', async () => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + const urls: string[] = [] + const createXhr = () => { + const xhr = { + open: (_method: string, url: string) => urls.push(url), + setRequestHeader: vi.fn(), + send: vi.fn(function (this: { onreadystatechange?: () => void }) { + this.onreadystatechange?.() + }), + status: 201, + response: { id: 'file-1' }, + readyState: 4, + upload: {}, + withCredentials: false, + responseType: '', + } + return xhr as unknown as XMLHttpRequest + } + + await upload({ xhr: createXhr(), data: new FormData() }, true) + await upload({ xhr: createXhr(), data: new FormData() }, true, '/remote-files/upload') + + expect(urls).toEqual([ + `${PUBLIC_API_PREFIX}/env/workflow-app/files/upload`, + `${PUBLIC_API_PREFIX}/env/workflow-app/remote-files/upload`, + ]) + }) }) describe('HTTP methods', () => { diff --git a/web/service/base.ts b/web/service/base.ts index 1a36f364288..199077e450c 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -44,6 +44,7 @@ import { resolveLoginRedirectTarget } from '@/utils/login-redirect' import { basePath } from '@/utils/var' import { base, ContentType, getBaseOptions } from './fetch' import { refreshAccessTokenOrReLogin } from './refresh-token' +import { getWebAppPublicApiPath, resolveWebAppAddress } from './webapp-address' import { getWebAppPassport } from './webapp-auth' const TIME_OUT = 100000 @@ -265,9 +266,14 @@ function requiredWebSSOLogin(message?: string, code?: number) { } function formatURL(url: string, isPublicAPI: boolean) { - const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX + let urlPrefix = API_PREFIX + if (isPublicAPI) urlPrefix = PUBLIC_API_PREFIX if (url.startsWith('http://') || url.startsWith('https://')) return url - const urlWithoutProtocol = url.startsWith('/') ? url : `/${url}` + const urlWithoutProtocol = isPublicAPI + ? getWebAppPublicApiPath(resolveWebAppAddress(), url) + : url.startsWith('/') + ? url + : `/${url}` return `${urlPrefix}${urlWithoutProtocol}` } @@ -500,15 +506,21 @@ export const upload = async ( url?: string, searchParams?: string, ): Promise => { - const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX - const shareCode = globalThis.location.pathname.split('/').slice(-1)[0] + const address = resolveWebAppAddress() + const shareCode = address?.code + const publicApiPrefix = PUBLIC_API_PREFIX + const urlPrefix = isPublicAPI ? publicApiPrefix : API_PREFIX const defaultOptions = { method: 'POST', - url: (url ? `${urlPrefix}${url}` : `${urlPrefix}/files/upload`) + (searchParams || ''), + url: + (url + ? `${urlPrefix}${isPublicAPI ? getWebAppPublicApiPath(address, url) : url}` + : `${urlPrefix}${isPublicAPI ? getWebAppPublicApiPath(address, '/files/upload') : '/files/upload'}`) + + (searchParams || ''), headers: { [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '', - [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), - [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, + [PASSPORT_HEADER_NAME]: getWebAppPassport(address), + [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode || '', }, } const mergedOptions = { @@ -584,7 +596,7 @@ export const ssePost = async ( // No need to get token from localStorage, cookies will be sent automatically const baseOptions = getBaseOptions() - const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]! + const shareCode = resolveWebAppAddress()?.code const options = Object.assign( {}, baseOptions, @@ -593,8 +605,8 @@ export const ssePost = async ( signal: abortController.signal, headers: new Headers({ [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME())! || '', - [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, - [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), + [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode || '', + [PASSPORT_HEADER_NAME]: getWebAppPassport(resolveWebAppAddress()), }), } as RequestInit, fetchOptions, @@ -754,7 +766,7 @@ export const sseGet = async ( const abortController = new AbortController() const baseOptions = getBaseOptions() - const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]! + const shareCode = resolveWebAppAddress()?.code const options = Object.assign( {}, baseOptions, @@ -762,8 +774,8 @@ export const sseGet = async ( signal: abortController.signal, headers: new Headers({ [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME())! || '', - [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode, - [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode!), + [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode || '', + [PASSPORT_HEADER_NAME]: getWebAppPassport(resolveWebAppAddress()), }), } as RequestInit, fetchOptions, diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index ae63878f264..56f8dcbec97 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -1495,6 +1495,142 @@ describe('consoleQuery Web app access mutation defaults', () => { }) }) +// Scenario: legacy App Deploy mutations share cache behavior through oRPC defaults. +describe('consoleQuery App Deploy mutation defaults', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should invalidate the environment key list and API summary after creating or deleting a key', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const params = { + app_id: 'app-1', + environment_id: 'staging', + } + const listQuery = + consoleQuery.enterprise.appDeploy.accessService.listEnvironmentApiKeys.queryOptions({ + input: { params }, + }) + const apiQuery = consoleQuery.enterprise.appDeploy.accessService.getEnvironmentApi.queryOptions( + { + input: { params }, + }, + ) + + const createOptions = + consoleQuery.enterprise.appDeploy.accessService.createEnvironmentApiKey.mutationOptions() + await createOptions.onSuccess?.( + { + created_at: 1, + id: 'key-1', + token: 'secret', + type: 'api', + }, + { params }, + undefined, + createMutationContext(queryClient), + ) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: listQuery.queryKey }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: apiQuery.queryKey }) + + invalidateQueries.mockClear() + const deleteOptions = + consoleQuery.enterprise.appDeploy.accessService.deleteEnvironmentApiKey.mutationOptions() + await deleteOptions.onSuccess?.( + {}, + { + params: { + ...params, + api_key_id: 'key-1', + }, + }, + undefined, + createMutationContext(queryClient), + ) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: listQuery.queryKey }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: apiQuery.queryKey }) + }) + + it('should keep deploy and undeploy cache invalidation aligned', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const params = { + app_id: 'app-1', + environment_id: 'staging', + workflow_id: 'workflow-1', + } + const appEnvironmentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: params.app_id, + }, + }, + }) + const deploymentsQuery = + consoleQuery.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { + params: { + app_id: params.app_id, + }, + }, + }) + const deploymentQuery = + consoleQuery.enterprise.appDeploy.deploymentService.getEnvironmentDeployment.queryOptions({ + input: { + params: { + app_id: params.app_id, + environment_id: params.environment_id, + }, + }, + }) + const response = { + operation: { + id: 'operation-1', + status: 'DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS' as const, + type: 'DEPLOYMENT_OPERATION_TYPE_DEPLOY' as const, + }, + } + + const deployOptions = + consoleQuery.enterprise.appDeploy.deploymentService.deployWorkflow.mutationOptions() + await deployOptions.onSuccess?.( + response, + { body: {}, params }, + undefined, + createMutationContext(queryClient), + ) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: deploymentsQuery.queryKey }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: deploymentQuery.queryKey }) + expect(invalidateQueries).not.toHaveBeenCalledWith({ queryKey: appEnvironmentsQuery.queryKey }) + + invalidateQueries.mockClear() + const undeployOptions = + consoleQuery.enterprise.appDeploy.deploymentService.undeployWorkflow.mutationOptions() + await undeployOptions.onSuccess?.( + { + operation: { + ...response.operation, + type: 'DEPLOYMENT_OPERATION_TYPE_UNDEPLOY', + }, + }, + { params }, + undefined, + createMutationContext(queryClient), + ) + + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: deploymentsQuery.queryKey }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: deploymentQuery.queryKey }) + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: appEnvironmentsQuery.queryKey }) + }) +}) + // Scenario: oRPC mutation defaults own shared tag cache behavior. describe('consoleQuery tag mutation defaults', () => { beforeEach(() => { diff --git a/web/service/client.ts b/web/service/client.ts index 8338063862a..b5c40317145 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -161,6 +161,64 @@ function invalidateQueryKeys(client: QueryClient, queryKeys: QueryKey[]) { return Promise.all(queryKeys.map((queryKey) => client.invalidateQueries({ queryKey }))) } +function invalidateEnvironmentApiKeyQueries( + query: ConsoleQueryUtils, + client: QueryClient, + params: { app_id: string; environment_id: string }, +) { + const environmentParams = { + app_id: params.app_id, + environment_id: params.environment_id, + } + + return invalidateQueryKeys(client, [ + query.enterprise.appDeploy.accessService.listEnvironmentApiKeys.queryOptions({ + input: { params: environmentParams }, + }).queryKey, + query.enterprise.appDeploy.accessService.getEnvironmentApi.queryOptions({ + input: { params: environmentParams }, + }).queryKey, + ]) +} + +function invalidateWorkflowDeploymentQueries( + query: ConsoleQueryUtils, + client: QueryClient, + params: { app_id: string; environment_id: string }, + { appEnvironments }: { appEnvironments: boolean }, +) { + const environmentParams = { + app_id: params.app_id, + environment_id: params.environment_id, + } + const queryKeys: QueryKey[] = [ + query.enterprise.appDeploy.deploymentService.listEnvironmentDeployments.queryOptions({ + input: { + params: { + app_id: params.app_id, + }, + }, + }).queryKey, + query.enterprise.appDeploy.deploymentService.getEnvironmentDeployment.queryOptions({ + input: { params: environmentParams }, + }).queryKey, + ] + + if (appEnvironments) { + queryKeys.push( + query.enterprise.appDeploy.deploymentService.listAppEnvironments.queryOptions({ + input: { + params: { + app_id: params.app_id, + }, + }, + }).queryKey, + ) + } + + return invalidateQueryKeys(client, queryKeys) +} + function appInstanceQueryKey(query: ConsoleQueryUtils, appInstanceId: string) { return query.enterprise.appInstanceService.getAppInstance.key({ type: 'query', @@ -1172,6 +1230,58 @@ export const consoleQuery: RouterUtils = createTanstackQue }, }, enterprise: { + appDeploy: { + accessService: { + createEnvironmentApiKey: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + return invalidateEnvironmentApiKeyQueries( + consoleQuery, + context.client, + variables.params, + ) + }, + }, + }, + deleteEnvironmentApiKey: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + return invalidateEnvironmentApiKeyQueries( + consoleQuery, + context.client, + variables.params, + ) + }, + }, + }, + }, + deploymentService: { + deployWorkflow: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + return invalidateWorkflowDeploymentQueries( + consoleQuery, + context.client, + variables.params, + { appEnvironments: false }, + ) + }, + }, + }, + undeployWorkflow: { + mutationOptions: { + onSuccess: (_data, variables, _result, context) => { + return invalidateWorkflowDeploymentQueries( + consoleQuery, + context.client, + variables.params, + { appEnvironments: true }, + ) + }, + }, + }, + }, + }, webAppAuth: { updateWebAppWhitelistSubjects: { mutationOptions: { diff --git a/web/service/console-router-loader.ts b/web/service/console-router-loader.ts index d4a5891d3c1..7c7ca8ad164 100644 --- a/web/service/console-router-loader.ts +++ b/web/service/console-router-loader.ts @@ -12,8 +12,17 @@ async function loadGeneratedConsoleContract(segment: string) { } async function loadEnterpriseContract(): Promise { - const { contract } = await import('@dify/contracts/enterprise/orpc.gen') - return { enterprise: contract } + const [{ contract: enterpriseContract }, { contract: appDeployContract }] = await Promise.all([ + import('@dify/contracts/enterprise/orpc.gen'), + import('@dify/contracts/enterprise-app-deploy/orpc.gen'), + ]) + + return { + enterprise: { + ...enterpriseContract, + appDeploy: appDeployContract, + }, + } } async function loadKnowledgeFsContract(): Promise { diff --git a/web/service/fetch.spec.ts b/web/service/fetch.spec.ts index ee8dd0b88e3..aa23e81ffd4 100644 --- a/web/service/fetch.spec.ts +++ b/web/service/fetch.spec.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { PUBLIC_API_PREFIX } from '@/config' +// oxlint-disable-next-line no-restricted-imports import { base } from './fetch' vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -13,6 +15,78 @@ const { toast } = await import('@langgenius/dify-ui/toast') describe('base', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') + }) + + describe('Public API routing', () => { + it('should keep ordinary workflow webapps on the Dify public API', async () => { + window.history.replaceState({}, '', '/workflow/legacy-app') + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ result: 'ok' }))) + + await base('/site', {}, { isPublicAPI: true }) + + const [request] = fetchSpy.mock.calls[0]! + expect(request).toBeInstanceOf(Request) + if (!(request instanceof Request)) throw new TypeError('Expected fetch to receive a Request') + expect(request.url).toBe(`${PUBLIC_API_PREFIX}/site`) + expect(request.headers.get('X-App-Code')).toBe('legacy-app') + }) + + it('should send sign in to Dify while opening an environment webapp', async () => { + window.history.replaceState( + {}, + '', + '/webapp-signin?redirect_url=%2Fenv%2Fworkflow%2Fworkflow-app', + ) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ result: 'ok' }))) + + await base('/login', { method: 'POST' }, { isPublicAPI: true }) + + const [request] = fetchSpy.mock.calls[0]! + if (!(request instanceof Request)) throw new TypeError('Expected fetch to receive a Request') + expect(request.url).toBe(`${PUBLIC_API_PREFIX}/login`) + }) + + it.each(['/passport', '/webapp/permission', '/workflows/run', 'parameters', 'meta'])( + 'should route %s to the environment webapp API', + async (path) => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ result: 'ok' }))) + + await base(path, {}, { isPublicAPI: true }) + + const [request] = fetchSpy.mock.calls[0]! + if (!(request instanceof Request)) + throw new TypeError('Expected fetch to receive a Request') + const expectedPath = path.startsWith('/') ? path : `/${path}` + expect(request.url).toBe(`${PUBLIC_API_PREFIX}/env/workflow-app${expectedPath}`) + }, + ) + + it.each([ + '/login/status', + '/email-code-login/validity', + '/forgot-password', + '/forgot-password/validity', + '/enterprise/sso/members/oidc/login', + ])('should keep environment auth path %s on Dify public API', async (path) => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ result: 'ok' }))) + + await base(path, {}, { isPublicAPI: true }) + + const [request] = fetchSpy.mock.calls[0]! + if (!(request instanceof Request)) throw new TypeError('Expected fetch to receive a Request') + expect(request.url).toBe(`${PUBLIC_API_PREFIX}${path}`) + }) }) describe('Error responses', () => { diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 2d5587757bb..958431408d7 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -14,6 +14,7 @@ import { PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME, } from '@/config' +import { getWebAppPublicApiPath, resolveWebAppAddress } from './webapp-address' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' const TIME_OUT = 100000 @@ -85,35 +86,16 @@ const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook } } -const SHARE_ROUTE_DENY_LIST = new Set(['webapp-signin', 'check-code', 'login']) - -const resolveShareCode = () => { - const pathnameSegments = globalThis.location.pathname.split('/').filter(Boolean) - const lastSegment = pathnameSegments.at(-1) || '' - if (lastSegment && !SHARE_ROUTE_DENY_LIST.has(lastSegment)) return lastSegment - - const redirectParam = new URLSearchParams(globalThis.location.search).get('redirect_url') - if (!redirectParam) return '' - try { - const redirectUrl = new URL(decodeURIComponent(redirectParam), globalThis.location.origin) - const redirectSegments = redirectUrl.pathname.split('/').filter(Boolean) - const redirectSegment = redirectSegments.at(-1) || '' - return SHARE_ROUTE_DENY_LIST.has(redirectSegment) ? '' : redirectSegment - } catch { - return '' - } -} - const beforeRequestPublicWithCode: BeforeRequestHook = ({ request }) => { if (!request.headers.has('Authorization')) { const accessToken = getWebAppAccessToken() if (accessToken) request.headers.set('Authorization', `Bearer ${accessToken}`) else request.headers.delete('Authorization') } - const shareCode = resolveShareCode() - if (!shareCode) return - request.headers.set(WEB_APP_SHARE_CODE_HEADER_NAME, shareCode) - request.headers.set(PASSPORT_HEADER_NAME, getWebAppPassport(shareCode)) + const address = resolveWebAppAddress() + if (!address) return + request.headers.set(WEB_APP_SHARE_CODE_HEADER_NAME, address.code) + request.headers.set(PASSPORT_HEADER_NAME, getWebAppPassport(address)) } const baseHooks: Hooks = { @@ -182,7 +164,9 @@ async function base( options.signal = abortController.signal } - const fetchPathname = base + (url.startsWith('/') ? url : `/${url}`) + const fetchPathname = isPublicAPI + ? base + getWebAppPublicApiPath(resolveWebAppAddress(), url) + : base + (url.startsWith('/') ? url : `/${url}`) if (!isMarketplaceAPI) headers.set(CSRF_HEADER_NAME, Cookies.get(CSRF_COOKIE_NAME()) || '') if (deleteContentType) headers.delete('Content-Type') diff --git a/web/service/use-base.ts b/web/service/use-base.ts index 44d270f645e..2fae21f00c6 100644 --- a/web/service/use-base.ts +++ b/web/service/use-base.ts @@ -13,15 +13,3 @@ export const useInvalid = (key?: QueryKey) => { queryClient.invalidateQueries({ queryKey: key }) }, [queryClient, key]) } - -/** - * @deprecated Convenience wrapper scheduled for removal. - * Prefer binding reset in `useMutation` callbacks at the service layer. - */ -export const useReset = (key?: QueryKey) => { - const queryClient = useQueryClient() - return useCallback(() => { - if (!key) return - queryClient.resetQueries({ queryKey: key }) - }, [queryClient, key]) -} diff --git a/web/service/use-share.spec.tsx b/web/service/use-share.spec.tsx index a3b35d6a10f..4533962772a 100644 --- a/web/service/use-share.spec.tsx +++ b/web/service/use-share.spec.tsx @@ -75,6 +75,21 @@ const createConversationData = ( ...overrides, }) +describe('shareQueryKeys', () => { + it('should isolate web app metadata by complete address', () => { + const firstAddress = { kind: 'environment' as const, code: 'webapp' } + const secondAddress = { kind: 'default' as const, code: 'webapp' } + expect(shareQueryKeys.appAccessMode(firstAddress, 'webapp')).not.toEqual( + shareQueryKeys.appAccessMode(secondAddress, 'webapp'), + ) + expect(shareQueryKeys.appInfo(firstAddress)).not.toEqual(shareQueryKeys.appInfo(secondAddress)) + expect(shareQueryKeys.appParams(firstAddress)).not.toEqual( + shareQueryKeys.appParams(secondAddress), + ) + expect(shareQueryKeys.appMeta(firstAddress)).not.toEqual(shareQueryKeys.appMeta(secondAddress)) + }) +}) + // Scenario: share conversation list queries behave consistently with params and enablement. describe('useShareConversations', () => { beforeEach(() => { diff --git a/web/service/use-share.ts b/web/service/use-share.ts index cec2c2f1e50..1578349aab1 100644 --- a/web/service/use-share.ts +++ b/web/service/use-share.ts @@ -1,3 +1,4 @@ +import type { WebAppAddress } from './webapp-address' import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form' import type { AppConversationData, ConversationItem } from '@/models/share' import { useMutation, useQuery } from '@tanstack/react-query' @@ -14,6 +15,7 @@ import { submitHumanInputForm, } from './share' import { useInvalid } from './use-base' +import { resolveWebAppAddress } from './webapp-address' const NAME_SPACE = 'webapp' @@ -44,10 +46,11 @@ type ShareQueryOptions = { } export const shareQueryKeys = { - appAccessMode: (code: string | null) => [NAME_SPACE, 'appAccessMode', code] as const, - appInfo: [NAME_SPACE, 'appInfo'] as const, - appParams: [NAME_SPACE, 'appParams'] as const, - appMeta: [NAME_SPACE, 'appMeta'] as const, + appAccessMode: (address: WebAppAddress | null, code: string | null) => + [NAME_SPACE, 'appAccessMode', address, code] as const, + appInfo: (address: WebAppAddress | null) => [NAME_SPACE, address, 'appInfo'] as const, + appParams: (address: WebAppAddress | null) => [NAME_SPACE, address, 'appParams'] as const, + appMeta: (address: WebAppAddress | null) => [NAME_SPACE, address, 'appMeta'] as const, conversations: [NAME_SPACE, 'conversations'] as const, conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const, @@ -58,8 +61,9 @@ export const shareQueryKeys = { } export const useGetWebAppAccessModeByCode = (code: string | null) => { + const address = resolveWebAppAddress() return useQuery({ - queryKey: shareQueryKeys.appAccessMode(code), + queryKey: shareQueryKeys.appAccessMode(address, code), queryFn: () => getAppAccessModeByAppCode(code!), enabled: !!code, staleTime: 0, // backend change the access mode may cause the logic error. Because /permission API is no cached. @@ -68,8 +72,9 @@ export const useGetWebAppAccessModeByCode = (code: string | null) => { } export const useGetWebAppInfo = () => { + const address = resolveWebAppAddress() return useQuery({ - queryKey: shareQueryKeys.appInfo, + queryKey: shareQueryKeys.appInfo(address), queryFn: () => { return fetchAppInfo() }, @@ -77,8 +82,9 @@ export const useGetWebAppInfo = () => { } export const useGetWebAppParams = () => { + const address = resolveWebAppAddress() return useQuery({ - queryKey: shareQueryKeys.appParams, + queryKey: shareQueryKeys.appParams(address), queryFn: () => { return fetchAppParams(AppSourceType.webApp) }, @@ -86,8 +92,9 @@ export const useGetWebAppParams = () => { } export const useGetWebAppMeta = () => { + const address = resolveWebAppAddress() return useQuery({ - queryKey: shareQueryKeys.appMeta, + queryKey: shareQueryKeys.appMeta(address), queryFn: () => { return fetchAppMeta(AppSourceType.webApp) }, diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts index d75e3d2ed41..4d400bba27f 100644 --- a/web/service/use-workflow.ts +++ b/web/service/use-workflow.ts @@ -3,7 +3,6 @@ import type { FlowType } from '@/types/common' import type { FetchWorkflowDraftPageParams, FetchWorkflowDraftPageResponse, - FetchWorkflowDraftResponse, NodeTracing, PublishWorkflowParams, UpdateWorkflowParams, @@ -13,17 +12,14 @@ import type { } from '@/types/workflow' import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { del, get, patch, post, put } from './base' -import { useInvalid, useReset } from './use-base' +import { useInvalid } from './use-base' import { getFlowPrefix } from './utils' +import { appWorkflowQueryOptions, appWorkflowVersionsInfiniteQueryKey } from './workflow-queries' const NAME_SPACE = 'workflow' export const useAppWorkflow = (appID: string) => { - return useQuery({ - enabled: !!appID, - queryKey: [NAME_SPACE, 'publish', appID], - queryFn: () => get(`/apps/${appID}/workflows/publish`), - }) + return useQuery(appWorkflowQueryOptions(appID)) } const WorkflowRunHistoryKey = [NAME_SPACE, 'runHistory'] @@ -49,8 +45,8 @@ export const useInvalidateWorkflowRunHistory = () => { export const useInvalidateAppWorkflow = () => { const queryClient = useQueryClient() return (appID: string) => { - queryClient.invalidateQueries({ - queryKey: [NAME_SPACE, 'publish', appID], + return queryClient.invalidateQueries({ + queryKey: appWorkflowQueryOptions(appID).queryKey, }) } } @@ -93,10 +89,17 @@ export const useWorkflowVersionHistory = (params: FetchWorkflowDraftPageParams) } export const useResetWorkflowVersionHistory = () => { - return useReset([...WorkflowVersionHistoryKey]) + const queryClient = useQueryClient() + + return () => + Promise.all([ + queryClient.resetQueries({ queryKey: [...WorkflowVersionHistoryKey] }), + queryClient.resetQueries({ queryKey: appWorkflowVersionsInfiniteQueryKey() }), + ]) } export const useUpdateWorkflow = () => { + const queryClient = useQueryClient() return useMutation({ mutationKey: [NAME_SPACE, 'update'], mutationFn: (params: UpdateWorkflowParams) => @@ -106,6 +109,11 @@ export const useUpdateWorkflow = () => { marked_comment: params.releaseNotes, }, }), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: [...WorkflowVersionHistoryKey] }), + queryClient.invalidateQueries({ queryKey: appWorkflowVersionsInfiniteQueryKey() }), + ]), }) } diff --git a/web/service/webapp-address.spec.ts b/web/service/webapp-address.spec.ts new file mode 100644 index 00000000000..f9055705d7b --- /dev/null +++ b/web/service/webapp-address.spec.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getWebAppApiPath, parseWebAppAddress, resolveWebAppAddress } from './webapp-address' + +describe('WebAppAddress', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('parses ordinary and environment workflow addresses without using the app code format', () => { + expect(parseWebAppAddress('/workflow/env-prefix-is-still-ordinary')).toEqual({ + kind: 'default', + code: 'env-prefix-is-still-ordinary', + }) + expect(parseWebAppAddress('/env/workflow/workflow-app')).toEqual({ + kind: 'environment', + code: 'workflow-app', + }) + }) + + it('does not reinterpret an unsupported environment path as an ordinary webapp', () => { + expect(parseWebAppAddress('/env/workflow')).toBeNull() + expect(parseWebAppAddress('/env/not-a-mode/workflow-app')).toBeNull() + }) + + it('accepts every webapp mode under the environment prefix', () => { + expect(parseWebAppAddress('/env/chat/chat-app')).toEqual({ + kind: 'environment', + code: 'chat-app', + }) + }) + + it('builds the environment upload and workflow URLs', () => { + const address = parseWebAppAddress('/env/workflow/workflow-app') + expect(getWebAppApiPath(address, '/files/upload')).toBe('/env/workflow-app/files/upload') + expect(getWebAppApiPath(address, '/workflows/run')).toBe('/env/workflow-app/workflows/run') + expect(getWebAppApiPath(address, '/workflows/tasks/task-1/stop')).toBe( + '/env/workflow-app/workflows/tasks/task-1/stop', + ) + }) + + it('does not resolve a browser address during server rendering', () => { + vi.stubGlobal('location', undefined) + + expect(resolveWebAppAddress()).toBeNull() + }) +}) diff --git a/web/service/webapp-address.ts b/web/service/webapp-address.ts new file mode 100644 index 00000000000..14b4da745f7 --- /dev/null +++ b/web/service/webapp-address.ts @@ -0,0 +1,74 @@ +const WEB_APP_ROUTE_SEGMENTS = new Set(['agent', 'chat', 'chatbot', 'completion', 'workflow']) +const WEB_APP_SIGNIN_SEGMENTS = new Set(['webapp-signin', 'check-code', 'login']) + +export type WebAppAddress = + | { + kind: 'default' + code: string + } + | { + kind: 'environment' + code: string + } + +const normalizePath = (path: string) => (path.startsWith('/') ? path : `/${path}`) + +export const parseWebAppAddress = (pathname: string): WebAppAddress | null => { + const segments = pathname.split('/').filter(Boolean) + if (segments[0] === 'env') { + const [, route, code, ...rest] = segments + if (!route || !code || rest.length > 0 || !WEB_APP_ROUTE_SEGMENTS.has(route)) return null + return { kind: 'environment', code } + } + + const [route, code, ...rest] = segments + if (!route || !code || rest.length > 0 || !WEB_APP_ROUTE_SEGMENTS.has(route)) return null + if (WEB_APP_SIGNIN_SEGMENTS.has(code)) return null + return { kind: 'default', code } +} + +export const resolveWebAppAddress = (): WebAppAddress | null => { + if (typeof globalThis.location === 'undefined') return null + + const currentAddress = parseWebAppAddress(globalThis.location.pathname) + if (currentAddress) return currentAddress + + const redirectParam = new URLSearchParams(globalThis.location.search).get('redirect_url') + if (!redirectParam) return null + + try { + const redirectUrl = new URL(decodeURIComponent(redirectParam), globalThis.location.origin) + return parseWebAppAddress(redirectUrl.pathname) + } catch { + return null + } +} + +export const getWebAppApiPath = (address: WebAppAddress | null, path: string) => { + const normalizedPath = normalizePath(path) + if (!address || address.kind === 'default') return normalizedPath + return `/env/${address.code}${normalizedPath}` +} + +export const getWebAppPassportKey = (address: WebAppAddress) => { + if (address.kind === 'default') return address.code + return `environment:${address.code}` +} + +const isDifyWebAppAuthPath = (path: string) => { + const pathname = normalizePath(path).split('?')[0] || '' + return ( + pathname === '/login' || + pathname === '/logout' || + pathname === '/login/status' || + pathname.startsWith('/email-code-login') || + pathname.startsWith('/forgot-password') || + pathname.startsWith('/enterprise/sso/') + ) +} + +export const getWebAppPublicApiPath = (address: WebAppAddress | null, path: string) => { + if (!address || address.kind === 'default' || isDifyWebAppAuthPath(path)) + return normalizePath(path) + return getWebAppApiPath(address, path) +} diff --git a/web/service/webapp-auth.spec.ts b/web/service/webapp-auth.spec.ts new file mode 100644 index 00000000000..e0ee04897ce --- /dev/null +++ b/web/service/webapp-auth.spec.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AccessMode } from '@/models/access-control' + +const getPublicMock = vi.hoisted(() => vi.fn()) + +vi.mock('./base', () => ({ + getPublic: getPublicMock, + postPublic: vi.fn(), +})) + +const { getWebAppPassport, setWebAppPassport, webAppLoginStatus } = await import('./webapp-auth') + +describe('webAppLoginStatus', () => { + beforeEach(() => { + getPublicMock.mockReset() + getPublicMock.mockResolvedValue({ logged_in: true, app_logged_in: true }) + localStorage.clear() + }) + + it('keeps environment and ordinary passports for the same code separate', () => { + const environment = { kind: 'environment' as const, code: 'workflow-app' } + const ordinary = { kind: 'default' as const, code: 'workflow-app' } + + setWebAppPassport(environment, 'environment-passport') + setWebAppPassport(ordinary, 'ordinary-passport') + + expect(getWebAppPassport(environment)).toBe('environment-passport') + expect(getWebAppPassport(ordinary)).toBe('ordinary-passport') + }) + + it('keeps the ordinary webapp passport storage key unchanged', () => { + const address = { kind: 'default' as const, code: 'workflow-app' } + + setWebAppPassport(address, 'passport') + + expect(localStorage.getItem('passport-workflow-app')).toBe('passport') + }) + + it('does not send an environment code to Dify login status', async () => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + + await webAppLoginStatus('workflow-app', AccessMode.PUBLIC, 'user-1') + + expect(getPublicMock).toHaveBeenCalledWith('/login/status?user_id=user-1') + }) + + it('treats a public environment as logged in before its first passport', async () => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + getPublicMock.mockResolvedValue({ logged_in: false, app_logged_in: false }) + + await expect(webAppLoginStatus('workflow-app', AccessMode.PUBLIC)).resolves.toEqual({ + userLoggedIn: true, + appLoggedIn: false, + }) + }) + + it('trusts the remote login state for an sso verified environment', async () => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + getPublicMock.mockResolvedValue({ logged_in: true, app_logged_in: false }) + + await expect(webAppLoginStatus('workflow-app', AccessMode.EXTERNAL_MEMBERS)).resolves.toEqual({ + userLoggedIn: true, + appLoggedIn: false, + }) + }) + + it('requires a Dify login for a private environment', async () => { + window.history.replaceState({}, '', '/env/workflow/workflow-app') + getPublicMock.mockResolvedValue({ logged_in: false, app_logged_in: false }) + + await expect( + webAppLoginStatus('workflow-app', AccessMode.SPECIFIC_GROUPS_MEMBERS), + ).resolves.toEqual({ + userLoggedIn: false, + appLoggedIn: false, + }) + }) + + it('keeps the app code for ordinary webapps', async () => { + window.history.replaceState({}, '', '/workflow/workflow-app') + + await webAppLoginStatus('workflow-app', AccessMode.PUBLIC) + + expect(getPublicMock).toHaveBeenCalledWith('/login/status?app_code=workflow-app') + }) +}) diff --git a/web/service/webapp-auth.ts b/web/service/webapp-auth.ts index f5d602c7b01..b73bbda6d4f 100644 --- a/web/service/webapp-auth.ts +++ b/web/service/webapp-auth.ts @@ -1,28 +1,33 @@ +import type { WebAppAddress } from './webapp-address' import { ACCESS_TOKEN_LOCAL_STORAGE_NAME, PASSPORT_LOCAL_STORAGE_NAME } from '@/config' +import { AccessMode } from '@/models/access-control' import { getPublic, postPublic } from './base' +import { getWebAppPassportKey, resolveWebAppAddress } from './webapp-address' export function setWebAppAccessToken(token: string) { localStorage.setItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME, token) } -export function setWebAppPassport(shareCode: string, token: string) { - localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode), token) +export function setWebAppPassport(address: WebAppAddress, token: string) { + localStorage.setItem(PASSPORT_LOCAL_STORAGE_NAME(getWebAppPassportKey(address)), token) } export function getWebAppAccessToken() { return localStorage.getItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) || '' } -export function getWebAppPassport(shareCode: string) { - return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) || '' +export function getWebAppPassport(address: WebAppAddress | null) { + if (!address) return '' + return localStorage.getItem(PASSPORT_LOCAL_STORAGE_NAME(getWebAppPassportKey(address))) || '' } function clearWebAppAccessToken() { localStorage.removeItem(ACCESS_TOKEN_LOCAL_STORAGE_NAME) } -function clearWebAppPassport(shareCode: string) { - localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(shareCode)) +function clearWebAppPassport(address: WebAppAddress | null) { + if (!address) return + localStorage.removeItem(PASSPORT_LOCAL_STORAGE_NAME(getWebAppPassportKey(address))) } type isWebAppLogin = { @@ -30,22 +35,29 @@ type isWebAppLogin = { app_logged_in: boolean } -export async function webAppLoginStatus(shareCode: string, userId?: string) { +export async function webAppLoginStatus( + shareCode: string, + accessMode: AccessMode, + userId?: string, +) { // always need to check login to prevent passport from being outdated // check remotely, the access token could be in cookie (enterprise SSO redirected with https) - const params = new URLSearchParams({ app_code: shareCode }) + const address = resolveWebAppAddress() + const params = new URLSearchParams() + if (address?.kind !== 'environment') params.set('app_code', shareCode) if (userId) params.append('user_id', userId) const { logged_in, app_logged_in } = await getPublic( `/login/status?${params.toString()}`, ) return { - userLoggedIn: logged_in, + userLoggedIn: + address?.kind === 'environment' && accessMode === AccessMode.PUBLIC ? true : logged_in, appLoggedIn: app_logged_in, } } -export async function webAppLogout(shareCode: string) { +export async function webAppLogout(address: WebAppAddress | null) { clearWebAppAccessToken() - clearWebAppPassport(shareCode) + clearWebAppPassport(address) await postPublic('/logout') } diff --git a/web/service/workflow-queries.ts b/web/service/workflow-queries.ts new file mode 100644 index 00000000000..5221043c90c --- /dev/null +++ b/web/service/workflow-queries.ts @@ -0,0 +1,38 @@ +import { skipToken } from '@tanstack/react-query' +import { consoleQuery } from './client' + +const WORKFLOW_VERSIONS_PAGE_SIZE = 10 + +export function appWorkflowQueryOptions(appId: string | null | undefined) { + return consoleQuery.apps.byAppId.workflows.publish.get.queryOptions({ + input: appId + ? { + params: { + app_id: appId, + }, + } + : skipToken, + }) +} + +export function appWorkflowVersionsInfiniteQueryOptions(appId: string | null | undefined) { + return consoleQuery.apps.byAppId.workflows.get.infiniteOptions({ + input: appId + ? (pageParam) => ({ + params: { + app_id: appId, + }, + query: { + limit: WORKFLOW_VERSIONS_PAGE_SIZE, + page: Number(pageParam), + }, + }) + : skipToken, + getNextPageParam: (lastPage) => (lastPage.has_more ? lastPage.page + 1 : undefined), + initialPageParam: 1, + }) +} + +export function appWorkflowVersionsInfiniteQueryKey() { + return consoleQuery.apps.byAppId.workflows.get.key({ type: 'infinite' }) +} diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 90d333ed45e..caa029f7db0 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -83,10 +83,6 @@ export const getLoopSingleNodeRunUrl = ( return `${getFlowPrefix(flowType)}/${flowId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/loop/nodes/${nodeId}/run` } -export const fetchPublishedWorkflow = (url: string) => { - return get(url) -} - export const stopWorkflowRun = (url: string) => { return post(url) } diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 5d19452bb27..8aaaa96b655 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -118,6 +118,11 @@ export type NodeTracing = { agentLog?: AgentLogItemWithChildren[] // agent log } +type VersionEnvironment = { + id: string + name: string +} + export type FetchWorkflowDraftResponse = { id: string graph: { @@ -144,8 +149,10 @@ export type FetchWorkflowDraftResponse = { conversation_variables?: ConversationVariable[] rag_pipeline_variables?: RAGPipelineVariables version: string + version_number?: number | null marked_name: string marked_comment: string + environments?: VersionEnvironment[] } export type VersionHistory = FetchWorkflowDraftResponse diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts index a2a6a7ba690..d736ed5a428 100644 --- a/web/utils/app-redirection.spec.ts +++ b/web/utils/app-redirection.spec.ts @@ -10,14 +10,14 @@ describe('app-redirection', () => { /** * Tests getRedirectionPath which determines the correct path based on: * - App ACL layout access permissions - * - App ACL monitor and access config permissions + * - App ACL monitor, deploy, and access config permissions * - App mode (workflow, advanced-chat, chat, completion, agent-chat) */ describe('getRedirectionPath', () => { - it('returns develop path when app ACL cannot access guarded pages', () => { + it('returns access point path when app ACL cannot access guarded pages', () => { const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] } const result = getRedirectionPath(app) - expect(result).toBe('/app/app-123/develop') + expect(result).toBe('/app/app-123/access-point') }) it('returns workflow path for workflow mode when app ACL can access layout', () => { @@ -99,7 +99,7 @@ describe('app-redirection', () => { permission_keys: [AppACLPermission.ViewLayout], } - expect(getRedirectionPath(app1)).toBe('/app/abc-123/develop') + expect(getRedirectionPath(app1)).toBe('/app/abc-123/access-point') expect(getRedirectionPath(app2)).toBe('/app/xyz-789/workflow') }) @@ -125,14 +125,14 @@ describe('app-redirection', () => { expect(getRedirectionPath(app, { isRbacEnabled: true })).toBe('/app/app-123/access-config') }) - it('returns develop path for access config only apps when RBAC is disabled', () => { + it('returns access point path for access config only apps when RBAC is disabled', () => { const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [AppACLPermission.AccessConfig], } - expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/develop') + expect(getRedirectionPath(app, { isRbacEnabled: false })).toBe('/app/app-123/access-point') }) it('returns overview path when app ACL can only monitor the app', () => { @@ -154,6 +154,16 @@ describe('app-redirection', () => { expect(getRedirectionPath(app)).toBe('/app/app-123/logs') }) + + it('returns deploy path when a workflow app ACL can only deploy', () => { + const app = { + id: 'app-123', + mode: AppModeEnum.WORKFLOW, + permission_keys: [AppACLPermission.Deploy], + } + + expect(getRedirectionPath(app)).toBe('/app/app-123/deploy') + }) }) /** @@ -163,13 +173,13 @@ describe('app-redirection', () => { /** * Tests that the redirection function is called with the correct path */ - it('calls redirection function with develop path when app ACL cannot access guarded pages', () => { + it('calls redirection function with access point path when app ACL cannot access guarded pages', () => { const app = { id: 'app-123', mode: AppModeEnum.CHAT, permission_keys: [] } const mockRedirect = vi.fn() getRedirection(app, mockRedirect) - expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/develop') + expect(mockRedirect).toHaveBeenCalledWith('/app/app-123/access-point') expect(mockRedirect).toHaveBeenCalledTimes(1) }) diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index 5856a736cd7..4bdfce9a266 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -31,7 +31,10 @@ export const getRedirectionPath = ( if (appACLCapabilities.canAccessConfig) return `/app/${app.id}/access-config` - return `/app/${app.id}/develop` + if (app.mode === AppModeEnum.WORKFLOW && appACLCapabilities.canDeploy) + return `/app/${app.id}/deploy` + + return `/app/${app.id}/access-point` } export const getRedirection = ( diff --git a/web/utils/permission.spec.ts b/web/utils/permission.spec.ts index 521f4721768..792d6de86aa 100644 --- a/web/utils/permission.spec.ts +++ b/web/utils/permission.spec.ts @@ -41,6 +41,15 @@ describe('permission', () => { expect(capabilities.canTestAndRun).toBe(false) }) + it('keeps deployment permission independent from app publishing and version management', () => { + const deployCapabilities = getAppACLCapabilities([AppACLPermission.Deploy]) + const releaseCapabilities = getAppACLCapabilities([AppACLPermission.ReleaseAndVersion]) + + expect(deployCapabilities.canDeploy).toBe(true) + expect(deployCapabilities.canReleaseAndVersion).toBe(false) + expect(releaseCapabilities.canDeploy).toBe(false) + }) + it('keeps monitor, tracing config, and log/annotation permissions independent', () => { const monitorCapabilities = getAppACLCapabilities([AppACLPermission.Monitor]) const tracingCapabilities = getAppACLCapabilities([AppACLPermission.TracingConfig]) @@ -105,6 +114,7 @@ describe('permission', () => { expect(capabilities.canImportExportDSL).toBe(true) expect(capabilities.canDelete).toBe(true) expect(capabilities.canReleaseAndVersion).toBe(true) + expect(capabilities.canDeploy).toBe(true) expect(capabilities.canMonitor).toBe(true) expect(capabilities.canConfigureTracing).toBe(true) expect(capabilities.canAccessLogAndAnnotation).toBe(true) diff --git a/web/utils/permission.ts b/web/utils/permission.ts index f8467131b17..0c45e84d454 100644 --- a/web/utils/permission.ts +++ b/web/utils/permission.ts @@ -8,6 +8,7 @@ export const AppACLPermission = { ImportExportDSL: 'app.acl.import_export_dsl', Delete: 'app.acl.delete', ReleaseAndVersion: 'app.acl.release_and_version', + Deploy: 'app.acl.deploy', Monitor: 'app.acl.monitor', TracingConfig: 'app.acl.tracing_config', LogAndAnnotation: 'app.acl.log_and_annotation', @@ -45,6 +46,7 @@ type AppACLCapabilities = { canImportExportDSL: boolean canDelete: boolean canReleaseAndVersion: boolean + canDeploy: boolean canMonitor: boolean canConfigureTracing: boolean canAccessLogAndAnnotation: boolean @@ -153,6 +155,11 @@ export const getAppACLCapabilities = ( AppACLPermission.ReleaseAndVersion, hasMaintainerPermissions, ), + canDeploy: hasResourcePermission( + permissionKeys, + AppACLPermission.Deploy, + hasMaintainerPermissions, + ), canMonitor: hasResourcePermission( permissionKeys, AppACLPermission.Monitor,