mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
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.
This commit is contained in:
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-xl border p-3',
|
||||
isDisabled && 'cursor-not-allowed opacity-50',
|
||||
!isDisabled && 'cursor-pointer',
|
||||
isSelected
|
||||
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
|
||||
: 'border-components-option-card-option-border bg-components-option-card-option-bg',
|
||||
!isDisabled && !isSelected && 'hover:bg-components-option-card-option-bg-hover',
|
||||
)}
|
||||
onClick={() => !isDisabled && onClick()}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<RadioUI isChecked={isSelected} disabled={isDisabled} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="system-sm-semibold text-text-primary">{title}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<FormRefObject>(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<ConfigMode>(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 (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={onClose}
|
||||
closable
|
||||
className="w-[480px]"
|
||||
>
|
||||
{/* Custom Header: Title + Subtitle with 8px gap */}
|
||||
<Modal isShow onClose={onClose} closable className="w-[480px]">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex flex-col gap-2">
|
||||
<h3 className="title-2xl-semi-bold text-text-primary">
|
||||
{t('sandboxProvider.configModal.title', { ns: 'common' })}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon providerType={provider.provider_type} size="sm" withBorder />
|
||||
<span className="system-md-regular text-text-secondary">
|
||||
{t(PROVIDER_LABEL_KEYS[provider.provider_type as keyof typeof PROVIDER_LABEL_KEYS] ?? 'sandboxProvider.e2b.label', { ns: 'common' })}
|
||||
</span>
|
||||
<span className="system-md-regular text-text-secondary">{providerLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={formRef}
|
||||
labelClassName="system-sm-medium mb-1 flex items-center gap-1 text-text-secondary"
|
||||
formClassName="space-y-4"
|
||||
/>
|
||||
{/* Mode Selection */}
|
||||
{shouldShowModeSelection && (
|
||||
<div className="mb-4 flex flex-col gap-1">
|
||||
<label className="system-sm-medium text-text-secondary">
|
||||
{t('sandboxProvider.configModal.connectionMode', { ns: 'common' })}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<ModeOption
|
||||
isSelected={configMode === 'managed'}
|
||||
isDisabled={!isManagedModeAvailable}
|
||||
title={t('sandboxProvider.configModal.managedByDify', { ns: 'common' })}
|
||||
description={t('sandboxProvider.configModal.managedByDifyDesc', { ns: 'common' })}
|
||||
onClick={() => setConfigMode('managed')}
|
||||
/>
|
||||
<ModeOption
|
||||
isSelected={configMode === 'byok'}
|
||||
title={t('sandboxProvider.configModal.bringYourOwnKey', { ns: 'common' })}
|
||||
description={t('sandboxProvider.configModal.bringYourOwnKeyDesc', { ns: 'common' })}
|
||||
onClick={() => setConfigMode('byok')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form fields (hidden when managed mode is selected) */}
|
||||
{showByokForm && (
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={formRef}
|
||||
labelClassName="system-sm-medium mb-1 flex items-center gap-1 text-text-secondary"
|
||||
formClassName="space-y-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
@ -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 })}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured && (
|
||||
<Button
|
||||
variant="warning"
|
||||
size="medium"
|
||||
onClick={handleRevoke}
|
||||
disabled={isDeleting || isSaving}
|
||||
>
|
||||
{showRevokeButton && (
|
||||
<Button variant="warning" size="medium" onClick={handleRevoke} disabled={isActionDisabled}>
|
||||
{t('sandboxProvider.configModal.revoke', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onClose}
|
||||
disabled={isSaving || isDeleting}
|
||||
>
|
||||
<Button variant="secondary" size="medium" onClick={onClose} disabled={isActionDisabled}>
|
||||
{t('sandboxProvider.configModal.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || isDeleting}
|
||||
>
|
||||
<Button variant="primary" size="medium" onClick={handleSave} disabled={isActionDisabled}>
|
||||
{t('sandboxProvider.configModal.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "撤销",
|
||||
|
||||
Reference in New Issue
Block a user