From 5496fc014cff454558495070f2ee384071376b82 Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 19 Jan 2026 18:34:24 +0800 Subject: [PATCH] feat(sandbox): add connect mode selection for E2B provider Add ability to choose between "Managed by Dify" (using system config) and "Bring Your Own API Key" modes when configuring E2B sandbox provider. This allows Cloud users to use Dify's pre-configured credentials or their own E2B account for more control over resources and billing. --- .../sandbox-provider-page/config-modal.tsx | 166 +++++++++++++----- web/i18n/en-US/common.json | 5 + web/i18n/zh-Hans/common.json | 5 + 3 files changed, 133 insertions(+), 43 deletions(-) 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": "撤销",