mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
feat: sandbox provider configuration
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
export const ACCOUNT_SETTING_MODAL_ACTION = 'showSettings'
|
||||
|
||||
export const ACCOUNT_SETTING_TAB = {
|
||||
SANDBOX_PROVIDER: 'sandbox-provider',
|
||||
PROVIDER: 'provider',
|
||||
MEMBERS: 'members',
|
||||
BILLING: 'billing',
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
RiBox3Fill,
|
||||
RiBox3Line,
|
||||
RiBrain2Fill,
|
||||
RiBrain2Line,
|
||||
RiCloseLine,
|
||||
@ -36,6 +38,7 @@ import DataSourcePage from './data-source-page-new'
|
||||
import LanguagePage from './language-page'
|
||||
import MembersPage from './members-page'
|
||||
import ModelProviderPage from './model-provider-page'
|
||||
import SandboxProviderPage from './sandbox-provider-page'
|
||||
|
||||
const iconClassName = `
|
||||
w-5 h-5 mr-2
|
||||
@ -79,6 +82,12 @@ export default function AccountSetting({
|
||||
icon: <RiBrain2Line className={iconClassName} />,
|
||||
activeIcon: <RiBrain2Fill className={iconClassName} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER,
|
||||
name: t('settings.sandboxProvider', { ns: 'common' }),
|
||||
icon: <RiBox3Line className={iconClassName} />,
|
||||
activeIcon: <RiBox3Fill className={iconClassName} />,
|
||||
},
|
||||
{
|
||||
key: ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
name: t('settings.members', { ns: 'common' }),
|
||||
@ -239,6 +248,7 @@ export default function AccountSetting({
|
||||
</div>
|
||||
<div className="px-4 pt-2 sm:px-8">
|
||||
{activeMenu === 'provider' && <ModelProviderPage searchText={searchValue} />}
|
||||
{activeMenu === 'sandbox-provider' && <SandboxProviderPage />}
|
||||
{activeMenu === 'members' && <MembersPage />}
|
||||
{activeMenu === 'billing' && <BillingPage />}
|
||||
{activeMenu === 'data-source' && <DataSourcePage />}
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import type { FormRefObject, FormSchema } from '@/app/components/base/form/types'
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import { memo, useCallback, useMemo, useRef } 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 { useToastContext } from '@/app/components/base/toast'
|
||||
import {
|
||||
useDeleteSandboxProviderConfig,
|
||||
useInvalidSandboxProviderList,
|
||||
useSaveSandboxProviderConfig,
|
||||
} from '@/service/use-sandbox-provider'
|
||||
import { PROVIDER_DOC_LINKS, SANDBOX_FIELD_CONFIGS } from './constants'
|
||||
|
||||
type ConfigModalProps = {
|
||||
provider: SandboxProvider
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ConfigModal = ({
|
||||
provider,
|
||||
onClose,
|
||||
}: ConfigModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const invalidateList = useInvalidSandboxProviderList()
|
||||
const formRef = useRef<FormRefObject>(null)
|
||||
|
||||
const { mutateAsync: saveConfig, isPending: isSaving } = useSaveSandboxProviderConfig()
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting } = useDeleteSandboxProviderConfig()
|
||||
|
||||
const formSchemas: FormSchema[] = useMemo(() => {
|
||||
return provider.config_schema.map((schema) => {
|
||||
const fieldConfig = SANDBOX_FIELD_CONFIGS[schema.name as keyof typeof SANDBOX_FIELD_CONFIGS]
|
||||
const fallbackType = schema.type === 'secret' ? FormTypeEnum.secretInput : FormTypeEnum.textInput
|
||||
|
||||
return {
|
||||
name: schema.name,
|
||||
label: fieldConfig ? t(fieldConfig.labelKey, { ns: 'common' }) : schema.name,
|
||||
placeholder: fieldConfig ? t(fieldConfig.placeholderKey, { ns: 'common' }) : '',
|
||||
type: fieldConfig?.type ?? fallbackType,
|
||||
required: schema.name === 'api_key',
|
||||
default: provider.config[schema.name] || '',
|
||||
}
|
||||
})
|
||||
}, [provider.config_schema, provider.config, t])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const formValues = formRef.current?.getFormValues({
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
})
|
||||
|
||||
if (!formValues?.isCheckValidated)
|
||||
return
|
||||
|
||||
try {
|
||||
await saveConfig({
|
||||
providerType: provider.provider_type,
|
||||
config: formValues.values,
|
||||
})
|
||||
await invalidateList()
|
||||
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
// Error toast is handled by fetch layer
|
||||
}
|
||||
}, [saveConfig, provider.provider_type, invalidateList, notify, t, onClose])
|
||||
|
||||
const handleRevoke = useCallback(async () => {
|
||||
try {
|
||||
await deleteConfig(provider.provider_type)
|
||||
await invalidateList()
|
||||
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
// Error toast is handled by fetch layer
|
||||
}
|
||||
}, [deleteConfig, provider.provider_type, invalidateList, notify, t, onClose])
|
||||
|
||||
const isConfigured = provider.is_tenant_configured
|
||||
const docLink = PROVIDER_DOC_LINKS[provider.provider_type]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={onClose}
|
||||
title={t('sandboxProvider.configModal.title', { ns: 'common', provider: provider.label })}
|
||||
closable
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="mt-4">
|
||||
<BaseForm
|
||||
formSchemas={formSchemas}
|
||||
ref={formRef}
|
||||
labelClassName="system-sm-semibold 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">
|
||||
<div>
|
||||
{docLink && (
|
||||
<a
|
||||
href={docLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="system-sm-medium inline-flex items-center gap-1 text-text-accent hover:underline"
|
||||
>
|
||||
{t('sandboxProvider.configModal.readDoc', { ns: 'common' })}
|
||||
<RiExternalLinkLine className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConfigured && (
|
||||
<Button
|
||||
variant="warning"
|
||||
size="medium"
|
||||
onClick={handleRevoke}
|
||||
disabled={isDeleting || isSaving}
|
||||
>
|
||||
{t('sandboxProvider.configModal.revoke', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || isDeleting}
|
||||
>
|
||||
{t('sandboxProvider.configModal.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConfigModal)
|
||||
@ -0,0 +1,40 @@
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
|
||||
export const SANDBOX_FIELD_CONFIGS = {
|
||||
api_key: {
|
||||
labelKey: 'sandboxProvider.configModal.apiKey',
|
||||
placeholderKey: 'sandboxProvider.configModal.apiKeyPlaceholder',
|
||||
type: FormTypeEnum.secretInput,
|
||||
},
|
||||
e2b_api_url: {
|
||||
labelKey: 'sandboxProvider.configModal.e2bApiUrl',
|
||||
placeholderKey: 'sandboxProvider.configModal.e2bApiUrlPlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
e2b_default_template: {
|
||||
labelKey: 'sandboxProvider.configModal.e2bTemplate',
|
||||
placeholderKey: 'sandboxProvider.configModal.e2bTemplatePlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
docker_sock: {
|
||||
labelKey: 'sandboxProvider.configModal.dockerSock',
|
||||
placeholderKey: 'sandboxProvider.configModal.dockerSockPlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
docker_image: {
|
||||
labelKey: 'sandboxProvider.configModal.dockerImage',
|
||||
placeholderKey: 'sandboxProvider.configModal.dockerImagePlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
base_working_path: {
|
||||
labelKey: 'sandboxProvider.configModal.baseWorkingPath',
|
||||
placeholderKey: 'sandboxProvider.configModal.baseWorkingPathPlaceholder',
|
||||
type: FormTypeEnum.textInput,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PROVIDER_DOC_LINKS: Record<string, string> = {
|
||||
e2b: 'https://e2b.dev/docs',
|
||||
docker: 'https://docs.docker.com/',
|
||||
local: '',
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGetSandboxProviderList } from '@/service/use-sandbox-provider'
|
||||
import ConfigModal from './config-modal'
|
||||
import ProviderCard from './provider-card'
|
||||
import SwitchModal from './switch-modal'
|
||||
|
||||
const SandboxProviderPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceOwner } = useAppContext()
|
||||
const { data: providers, isLoading } = useGetSandboxProviderList()
|
||||
|
||||
const [configModalProvider, setConfigModalProvider] = useState<SandboxProvider | null>(null)
|
||||
const [switchModalProvider, setSwitchModalProvider] = useState<SandboxProvider | null>(null)
|
||||
|
||||
const currentProvider = providers?.find(p => p.is_active)
|
||||
const otherProviders = providers?.filter(p => !p.is_active) || []
|
||||
|
||||
const handleConfig = (provider: SandboxProvider) => {
|
||||
setConfigModalProvider(provider)
|
||||
}
|
||||
|
||||
const handleEnable = (provider: SandboxProvider) => {
|
||||
setSwitchModalProvider(provider)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<div className="system-sm-regular text-text-tertiary">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Current Provider Section */}
|
||||
{currentProvider && (
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-2 text-text-secondary">
|
||||
{t('sandboxProvider.currentProvider', { ns: 'common' })}
|
||||
</div>
|
||||
<ProviderCard
|
||||
provider={currentProvider}
|
||||
isCurrent
|
||||
onConfig={() => handleConfig(currentProvider)}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Providers Section */}
|
||||
{otherProviders.length > 0 && (
|
||||
<div>
|
||||
<div className="system-sm-semibold mb-2 text-text-secondary">
|
||||
{t('sandboxProvider.otherProvider', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{otherProviders.map(provider => (
|
||||
<ProviderCard
|
||||
key={provider.provider_type}
|
||||
provider={provider}
|
||||
onConfig={() => handleConfig(provider)}
|
||||
onEnable={() => handleEnable(provider)}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrentWorkspaceOwner && (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('sandboxProvider.noPermission', { ns: 'common' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config Modal */}
|
||||
{configModalProvider && (
|
||||
<ConfigModal
|
||||
provider={configModalProvider}
|
||||
onClose={() => setConfigModalProvider(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Switch Modal */}
|
||||
{switchModalProvider && (
|
||||
<SwitchModal
|
||||
provider={switchModalProvider}
|
||||
onClose={() => setSwitchModalProvider(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SandboxProviderPage)
|
||||
@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ProviderCardProps = {
|
||||
provider: SandboxProvider
|
||||
isCurrent?: boolean
|
||||
onConfig: () => void
|
||||
onEnable?: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, string> = {
|
||||
e2b: '/sandbox-providers/e2b.svg',
|
||||
docker: '/sandbox-providers/docker.svg',
|
||||
local: '/sandbox-providers/local.svg',
|
||||
}
|
||||
|
||||
const ProviderIcon = ({ providerType }: { providerType: string }) => {
|
||||
const iconSrc = PROVIDER_ICONS[providerType] || PROVIDER_ICONS.e2b
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconSrc}
|
||||
alt={`${providerType} icon`}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ProviderCard = ({
|
||||
provider,
|
||||
isCurrent = false,
|
||||
onConfig,
|
||||
onEnable,
|
||||
disabled = false,
|
||||
}: ProviderCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isConfigured = provider.is_tenant_configured || provider.is_system_configured
|
||||
const showEnableButton = !isCurrent && isConfigured && onEnable
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between rounded-xl p-4',
|
||||
isCurrent ? 'bg-background-section' : 'bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{/* Icon */}
|
||||
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-divider-subtle bg-background-default-subtle">
|
||||
<ProviderIcon providerType={provider.provider_type} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-md-semibold text-text-primary">
|
||||
{provider.label}
|
||||
</span>
|
||||
{provider.is_system_configured && (
|
||||
<span className="system-2xs-medium-uppercase rounded border border-divider-regular px-1.5 py-0.5 text-text-tertiary">
|
||||
{t('sandboxProvider.managedBySaas', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{provider.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: Connected Badge + Actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{isConfigured && (
|
||||
<span className="system-xs-medium flex items-center gap-1 rounded-md bg-util-colors-green-green-50 px-1.5 py-0.5 text-util-colors-green-green-600">
|
||||
<Indicator color="green" />
|
||||
{t('sandboxProvider.connected', { ns: 'common' })}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onConfig}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('sandboxProvider.config', { ns: 'common' })}
|
||||
</Button>
|
||||
{showEnableButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={onEnable}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('sandboxProvider.enable', { ns: 'common' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProviderCard)
|
||||
@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import type { SandboxProvider } from '@/service/use-sandbox-provider'
|
||||
import { RiAlertLine } from '@remixicon/react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import {
|
||||
useActivateSandboxProvider,
|
||||
useInvalidSandboxProviderList,
|
||||
} from '@/service/use-sandbox-provider'
|
||||
|
||||
type SwitchModalProps = {
|
||||
provider: SandboxProvider
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SwitchModal = ({
|
||||
provider,
|
||||
onClose,
|
||||
}: SwitchModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const invalidateList = useInvalidSandboxProviderList()
|
||||
|
||||
const { mutateAsync: activateProvider, isPending } = useActivateSandboxProvider()
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
try {
|
||||
await activateProvider(provider.provider_type)
|
||||
await invalidateList()
|
||||
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
|
||||
onClose()
|
||||
}
|
||||
catch {
|
||||
// Error toast is handled by fetch layer
|
||||
}
|
||||
}, [activateProvider, provider.provider_type, invalidateList, notify, t, onClose])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={onClose}
|
||||
title={t('sandboxProvider.switchModal.title', { ns: 'common' })}
|
||||
closable
|
||||
className="w-[480px]"
|
||||
>
|
||||
<div className="mt-4">
|
||||
{/* Warning Section */}
|
||||
<div className="flex gap-3 rounded-xl bg-state-warning-hover p-3">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-state-warning-solid">
|
||||
<RiAlertLine className="h-3 w-3 text-text-warning-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('sandboxProvider.switchModal.warning', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="system-xs-regular mt-1 text-text-secondary">
|
||||
{t('sandboxProvider.switchModal.warningDesc', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Text */}
|
||||
<div className="system-sm-regular mt-4 text-text-secondary">
|
||||
{t('sandboxProvider.switchModal.confirmText', { ns: 'common', provider: provider.label })}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('sandboxProvider.switchModal.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
onClick={handleConfirm}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('sandboxProvider.switchModal.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SwitchModal)
|
||||
Reference in New Issue
Block a user