diff --git a/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx b/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx index 35ffed5260..99dcbeec51 100644 --- a/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx +++ b/web/app/components/header/account-setting/sandbox-provider-page/config-modal.tsx @@ -3,29 +3,66 @@ import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' import type { SandboxProvider } from '@/types/sandbox-provider' import { RiExternalLinkLine, RiLock2Fill } from '@remixicon/react' -import { memo, useCallback, useMemo, useRef } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { BaseForm } from '@/app/components/base/form/components/base' import { FormTypeEnum } from '@/app/components/base/form/types' import Modal from '@/app/components/base/modal' +import RadioUI from '@/app/components/base/radio/ui' import { useToastContext } from '@/app/components/base/toast' import { useDeleteSandboxProviderConfig, useSaveSandboxProviderConfig, } from '@/service/use-sandbox-provider' +import { cn } from '@/utils/classnames' import { PROVIDER_DOC_LINKS, PROVIDER_LABEL_KEYS, SANDBOX_FIELD_CONFIGS } from './constants' import ProviderIcon from './provider-icon' +type ConfigMode = 'managed' | 'byok' + +// Providers that support mode selection (must have system config available) +const PROVIDERS_WITH_MODE_SELECTION: readonly string[] = ['e2b'] + +type ModeOptionProps = { + isSelected: boolean + isDisabled?: boolean + title: string + description: string + onClick: () => void +} + +function ModeOption({ isSelected, isDisabled = false, title, description, onClick }: ModeOptionProps) { + return ( +
!isDisabled && onClick()} + > +
+ +
+
+ {title} + {description} +
+
+ ) +} + type ConfigModalProps = { provider: SandboxProvider onClose: () => void } -const ConfigModal = ({ - provider, - onClose, -}: ConfigModalProps) => { +function ConfigModal({ provider, onClose }: ConfigModalProps) { const { t } = useTranslation() const { notify } = useToastContext() const formRef = useRef(null) @@ -33,6 +70,21 @@ const ConfigModal = ({ const { mutateAsync: saveConfig, isPending: isSaving } = useSaveSandboxProviderConfig() const { mutateAsync: deleteConfig, isPending: isDeleting } = useDeleteSandboxProviderConfig() + // Determine if mode selection should be shown (for providers that support it) + const shouldShowModeSelection = PROVIDERS_WITH_MODE_SELECTION.includes(provider.provider_type) + + // Managed mode is only available when system has configured this provider + const isManagedModeAvailable = provider.is_system_configured + + // Determine default mode based on configuration state + const defaultMode: ConfigMode = provider.is_tenant_configured + ? 'byok' + : provider.is_system_configured + ? 'managed' + : 'byok' + + const [configMode, setConfigMode] = useState(defaultMode) + const formSchemas: FormSchema[] = useMemo(() => { return provider.config_schema.map((schema) => { const fieldConfig = SANDBOX_FIELD_CONFIGS[schema.name as keyof typeof SANDBOX_FIELD_CONFIGS] @@ -50,6 +102,23 @@ const ConfigModal = ({ }, [provider.config_schema, provider.config, t]) const handleSave = useCallback(async () => { + // For managed mode, save empty config to use system defaults + if (shouldShowModeSelection && configMode === 'managed') { + try { + await saveConfig({ + providerType: provider.provider_type, + config: {}, + }) + notify({ type: 'success', message: t('api.saved', { ns: 'common' }) }) + onClose() + } + catch { + // Error toast is handled by fetch layer + } + return + } + + // For BYOK mode, validate and save user-provided config const formValues = formRef.current?.getFormValues({ needTransformWhenSecretFieldIsPristine: true, }) @@ -68,7 +137,7 @@ const ConfigModal = ({ catch { // Error toast is handled by fetch layer } - }, [saveConfig, provider.provider_type, notify, t, onClose]) + }, [shouldShowModeSelection, configMode, saveConfig, provider.provider_type, notify, t, onClose]) const handleRevoke = useCallback(async () => { try { @@ -81,35 +150,61 @@ const ConfigModal = ({ } }, [deleteConfig, provider.provider_type, notify, t, onClose]) - const isConfigured = provider.is_tenant_configured const docLink = PROVIDER_DOC_LINKS[provider.provider_type] + const providerLabelKey = PROVIDER_LABEL_KEYS[provider.provider_type as keyof typeof PROVIDER_LABEL_KEYS] ?? 'sandboxProvider.e2b.label' + const providerLabel = t(providerLabelKey, { ns: 'common' }) + + // Only show revoke button when in BYOK mode and tenant has custom config + const showRevokeButton = provider.is_tenant_configured && (!shouldShowModeSelection || configMode === 'byok') + const isActionDisabled = isSaving || isDeleting + const showByokForm = !shouldShowModeSelection || configMode === 'byok' return ( - - {/* Custom Header: Title + Subtitle with 8px gap */} + + {/* Header */}

{t('sandboxProvider.configModal.title', { ns: 'common' })}

- - {t(PROVIDER_LABEL_KEYS[provider.provider_type as keyof typeof PROVIDER_LABEL_KEYS] ?? 'sandboxProvider.e2b.label', { ns: 'common' })} - + {providerLabel}
- + {/* Mode Selection */} + {shouldShowModeSelection && ( +
+ +
+ setConfigMode('managed')} + /> + setConfigMode('byok')} + /> +
+
+ )} + + {/* Form fields (hidden when managed mode is selected) */} + {showByokForm && ( + + )} {/* Footer Actions */}
@@ -121,36 +216,21 @@ const ConfigModal = ({ rel="noopener noreferrer" className="system-xs-regular inline-flex items-center gap-1 text-text-accent hover:underline" > - {t('sandboxProvider.configModal.readDocLink', { ns: 'common', provider: t(PROVIDER_LABEL_KEYS[provider.provider_type as keyof typeof PROVIDER_LABEL_KEYS] ?? 'sandboxProvider.e2b.label', { ns: 'common' }) })} + {t('sandboxProvider.configModal.readDocLink', { ns: 'common', provider: providerLabel })} )}
- {isConfigured && ( - )} - -
diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json index 462e07e25c..a519728b31 100644 --- a/web/i18n/en-US/common.json +++ b/web/i18n/en-US/common.json @@ -564,8 +564,11 @@ "sandboxProvider.configModal.apiKeyPlaceholder": "Enter your API key", "sandboxProvider.configModal.baseWorkingPath": "Base Working Path", "sandboxProvider.configModal.baseWorkingPathPlaceholder": "/tmp/sandbox", + "sandboxProvider.configModal.bringYourOwnKey": "Bring Your Own E2B API Key", + "sandboxProvider.configModal.bringYourOwnKeyDesc": "Connect using your own E2B account. No usage limits from Dify, with full control over resources and billing.", "sandboxProvider.configModal.cancel": "Cancel", "sandboxProvider.configModal.confirm": "Confirm", + "sandboxProvider.configModal.connectionMode": "Connect Mode", "sandboxProvider.configModal.dockerImage": "Docker Image", "sandboxProvider.configModal.dockerImagePlaceholder": "ubuntu:latest", "sandboxProvider.configModal.dockerSock": "Docker Socket", @@ -574,6 +577,8 @@ "sandboxProvider.configModal.e2bApiUrlPlaceholder": "https://api.e2b.app", "sandboxProvider.configModal.e2bTemplate": "E2B Template", "sandboxProvider.configModal.e2bTemplatePlaceholder": "code-interpreter-v1", + "sandboxProvider.configModal.managedByDify": "Managed by Dify", + "sandboxProvider.configModal.managedByDifyDesc": "Ready to use instantly. Managed and billed by Dify, with standard usage limits. Best for testing, demos, and light workloads.", "sandboxProvider.configModal.readDoc": "Read Documentation", "sandboxProvider.configModal.readDocLink": "Read {{provider}} Docs", "sandboxProvider.configModal.revoke": "Revoke", diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json index baa09f8bba..8c75e2d658 100644 --- a/web/i18n/zh-Hans/common.json +++ b/web/i18n/zh-Hans/common.json @@ -564,8 +564,11 @@ "sandboxProvider.configModal.apiKeyPlaceholder": "输入您的 API Key", "sandboxProvider.configModal.baseWorkingPath": "基础工作路径", "sandboxProvider.configModal.baseWorkingPathPlaceholder": "/tmp/sandbox", + "sandboxProvider.configModal.bringYourOwnKey": "使用自己的 E2B API Key", + "sandboxProvider.configModal.bringYourOwnKeyDesc": "使用您自己的 E2B 账户连接。无 Dify 使用限制,完全控制资源和计费。", "sandboxProvider.configModal.cancel": "取消", "sandboxProvider.configModal.confirm": "确认", + "sandboxProvider.configModal.connectionMode": "连接模式", "sandboxProvider.configModal.dockerImage": "Docker 镜像", "sandboxProvider.configModal.dockerImagePlaceholder": "ubuntu:latest", "sandboxProvider.configModal.dockerSock": "Docker Socket", @@ -574,6 +577,8 @@ "sandboxProvider.configModal.e2bApiUrlPlaceholder": "https://api.e2b.app", "sandboxProvider.configModal.e2bTemplate": "E2B 模板", "sandboxProvider.configModal.e2bTemplatePlaceholder": "code-interpreter-v1", + "sandboxProvider.configModal.managedByDify": "由 Dify 托管", + "sandboxProvider.configModal.managedByDifyDesc": "开箱即用。由 Dify 管理和计费,有标准使用限制。适合测试、演示和轻量工作负载。", "sandboxProvider.configModal.readDoc": "查看文档", "sandboxProvider.configModal.readDocLink": "查看 {{provider}} 文档", "sandboxProvider.configModal.revoke": "撤销",