This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { PlusCircle } from '@/app/components/base/icons/src/vender/solid/general'
import cn from '@/utils/classnames'
type AddModelButtonProps = {
className?: string
onClick: () => void
}
const AddModelButton: FC<AddModelButtonProps> = ({
className,
onClick,
}) => {
const { t } = useTranslation()
return (
<span
className={cn('system-xs-medium flex h-6 shrink-0 cursor-pointer items-center rounded-md px-1.5 text-text-tertiary hover:bg-components-button-ghost-bg-hover hover:text-components-button-ghost-text', className)}
onClick={onClick}
>
<PlusCircle className='mr-1 h-3 w-3' />
{t('common.modelProvider.addModel')}
</span>
)
}
export default AddModelButton

View File

@@ -0,0 +1,64 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLatest } from 'ahooks'
import SimplePieChart from '@/app/components/base/simple-pie-chart'
import Tooltip from '@/app/components/base/tooltip'
export type CooldownTimerProps = {
secondsRemaining?: number
onFinish?: () => void
}
const CooldownTimer = ({ secondsRemaining, onFinish }: CooldownTimerProps) => {
const { t } = useTranslation()
const targetTime = useRef<number>(Date.now())
const [currentTime, setCurrentTime] = useState(targetTime.current)
const displayTime = useMemo(
() => Math.ceil((targetTime.current - currentTime) / 1000),
[currentTime],
)
const countdownTimeout = useRef<number>(undefined)
const clearCountdown = useCallback(() => {
if (countdownTimeout.current) {
window.clearTimeout(countdownTimeout.current)
countdownTimeout.current = undefined
}
}, [])
const onFinishRef = useLatest(onFinish)
const countdown = useCallback(() => {
clearCountdown()
countdownTimeout.current = window.setTimeout(() => {
const now = Date.now()
if (now <= targetTime.current) {
setCurrentTime(Date.now())
countdown()
}
else {
onFinishRef.current?.()
clearCountdown()
}
}, 1000)
}, [clearCountdown, onFinishRef])
useEffect(() => {
const now = Date.now()
targetTime.current = now + (secondsRemaining ?? 0) * 1000
setCurrentTime(now)
countdown()
return clearCountdown
}, [clearCountdown, countdown, secondsRemaining])
return displayTime
? (
<Tooltip popupContent={t('common.modelProvider.apiKeyRateLimit', { seconds: displayTime })}>
<SimplePieChart percentage={Math.round(displayTime / 60 * 100)} className='h-3 w-3' />
</Tooltip>
)
: null
}
export default memo(CooldownTimer)

View File

@@ -0,0 +1,146 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import type {
ModelProvider,
} from '../declarations'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import Indicator from '@/app/components/header/indicator'
import { changeModelProviderPriority } from '@/service/common'
import { useToastContext } from '@/app/components/base/toast'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth'
type CredentialPanelProps = {
provider: ModelProvider
}
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const customConfig = provider.custom_configuration
const systemConfig = provider.system_configuration
const priorityUseType = provider.preferred_provider_type
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const configurateMethods = provider.configurate_methods
const {
hasCredential,
authorized,
authRemoved,
current_credential_name,
notAllowedToUse,
} = useCredentialStatus(provider)
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
body: {
preferred_provider_type: key,
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
updateModelProviders()
configurateMethods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
}
}
const credentialLabel = useMemo(() => {
if (!hasCredential)
return t('common.modelProvider.auth.unAuthorized')
if (authorized)
return current_credential_name
if (authRemoved)
return t('common.modelProvider.auth.authRemoved')
return ''
}, [authorized, authRemoved, current_credential_name, hasCredential])
const color = useMemo(() => {
if (authRemoved || !hasCredential)
return 'red'
if (notAllowedToUse)
return 'gray'
return 'green'
}, [authRemoved, notAllowedToUse, hasCredential])
return (
<>
{
provider.provider_credential_schema && (
<div className={cn(
'relative ml-1 w-[120px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] p-1',
authRemoved && 'border-state-destructive-border bg-state-destructive-hover',
)}>
<div className='system-xs-medium mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary'>
<div
className={cn(
'grow truncate',
authRemoved && 'text-text-destructive',
)}
title={credentialLabel}
>
{credentialLabel}
</div>
<Indicator className='shrink-0' color={color} />
</div>
<div className='flex items-center gap-0.5'>
<ConfigProvider
provider={provider}
/>
{
systemConfig.enabled && isCustomConfigured && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)
}
</div>
{
priorityUseType === PreferredProviderTypeEnum.custom && systemConfig.enabled && (
<PriorityUseTip />
)
}
</div>
)
}
{
systemConfig.enabled && isCustomConfigured && !provider.provider_credential_schema && (
<div className='ml-1'>
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
</div>
)
}
</>
)
}
export default CredentialPanel

View File

@@ -0,0 +1,191 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
RiInformation2Fill,
RiLoader2Line,
} from '@remixicon/react'
import type {
ModelItem,
ModelProvider,
} from '../declarations'
import { ConfigurationMethodEnum } from '../declarations'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
modelTypeFormat,
} from '../utils'
import ProviderIcon from '../provider-icon'
import ModelBadge from '../model-badge'
import CredentialPanel from './credential-panel'
import QuotaPanel from './quota-panel'
import ModelList from './model-list'
import { fetchModelProviderModelList } from '@/service/common'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import cn from '@/utils/classnames'
import {
AddCustomModel,
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
type ProviderAddedCardProps = {
notConfigured?: boolean
provider: ModelProvider
}
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
notConfigured,
provider,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const [fetched, setFetched] = useState(false)
const [loading, setLoading] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [modelList, setModelList] = useState<ModelItem[]>([])
const configurationMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const systemConfig = provider.system_configuration
const hasModelList = fetched && !!modelList.length
const { isCurrentWorkspaceManager } = useAppContext()
const showQuota = systemConfig.enabled && [...MODEL_PROVIDER_QUOTA_GET_PAID].includes(provider.provider) && !IS_CE_EDITION
const showCredential = configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && isCurrentWorkspaceManager
const getModelList = async (providerName: string) => {
if (loading)
return
try {
setLoading(true)
const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${providerName}/models`)
setModelList(modelsData.data)
setCollapsed(false)
setFetched(true)
}
finally {
setLoading(false)
}
}
const handleOpenModelList = () => {
if (fetched) {
setCollapsed(false)
return
}
getModelList(provider.provider)
}
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST && v.payload === provider.provider)
getModelList(v.payload)
})
return (
<div
className={cn(
'mb-2 rounded-xl border-[0.5px] border-divider-regular bg-third-party-model-bg-default shadow-xs',
provider.provider === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
provider.provider === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
)}
>
<div className='flex rounded-t-xl py-2 pl-3 pr-2'>
<div className='grow px-1 pb-0.5 pt-1'>
<ProviderIcon
className='mb-2'
provider={provider}
/>
<div className='flex gap-0.5'>
{
provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))
}
</div>
</div>
{
showQuota && (
<QuotaPanel
provider={provider}
/>
)
}
{
showCredential && (
<CredentialPanel
provider={provider}
/>
)
}
</div>
{
collapsed && (
<div className='system-xs-medium group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary'>
{(showQuota || !notConfigured) && (
<>
<div className='flex h-6 items-center pl-1 pr-1.5 leading-6 group-hover:hidden'>
{
hasModelList
? t('common.modelProvider.modelsNum', { num: modelList.length })
: t('common.modelProvider.showModels')
}
{!loading && <RiArrowRightSLine className='h-4 w-4' />}
</div>
<div
className='hidden h-6 cursor-pointer items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover group-hover:flex'
onClick={handleOpenModelList}
>
{
hasModelList
? t('common.modelProvider.showModelsNum', { num: modelList.length })
: t('common.modelProvider.showModels')
}
{!loading && <RiArrowRightSLine className='h-4 w-4' />}
{
loading && (
<RiLoader2Line className='ml-0.5 h-3 w-3 animate-spin' />
)
}
</div>
</>
)}
{!showQuota && notConfigured && (
<div className='flex h-6 items-center pl-1 pr-1.5'>
<RiInformation2Fill className='mr-1 h-4 w-4 text-text-accent' />
<span className='system-xs-medium text-text-secondary'>{t('common.modelProvider.configureTip')}</span>
</div>
)}
{
configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && isCurrentWorkspaceManager && (
<div className='flex grow justify-end'>
<ManageCustomModelCredentials
provider={provider}
currentCustomConfigurationModelFixedFields={undefined}
/>
<AddCustomModel
provider={provider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
currentCustomConfigurationModelFixedFields={undefined}
/>
</div>
)
}
</div>
)
}
{
!collapsed && (
<ModelList
provider={provider}
models={modelList}
onCollapse={() => setCollapsed(true)}
onChange={(provider: string) => getModelList(provider)}
/>
)
}
</div>
)
}
export default ProviderAddedCard

View File

@@ -0,0 +1,109 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks'
import type { ModelItem, ModelProvider } from '../declarations'
import { ModelStatusEnum } from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import classNames from '@/utils/classnames'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
import { disableModel, enableModel } from '@/service/common'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { ConfigModel } from '../model-auth'
import Badge from '@/app/components/base/badge'
export type ModelListItemProps = {
model: ModelItem
provider: ModelProvider
isConfigurable: boolean
onModifyLoadBalancing?: (model: ModelItem) => void
}
const ModelListItem = ({ model, provider, isConfigurable, onModifyLoadBalancing }: ModelListItemProps) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const { isCurrentWorkspaceManager } = useAppContext()
const toggleModelEnablingStatus = useCallback(async (enabled: boolean) => {
if (enabled)
await enableModel(`/workspaces/current/model-providers/${provider.provider}/models/enable`, { model: model.model, model_type: model.model_type })
else
await disableModel(`/workspaces/current/model-providers/${provider.provider}/models/disable`, { model: model.model, model_type: model.model_type })
}, [model.model, model.model_type, provider.provider])
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })
const onEnablingStateChange = useCallback(async (value: boolean) => {
debouncedToggleModelEnablingStatus(value)
}, [debouncedToggleModelEnablingStatus])
return (
<div
key={`${model.model}-${model.fetch_from}`}
className={classNames(
'group flex h-8 items-center rounded-lg pl-2 pr-2.5',
isConfigurable && 'hover:bg-components-panel-on-panel-item-bg-hover',
model.deprecated && 'opacity-60',
)}
>
<ModelIcon
className='mr-2 shrink-0'
provider={provider}
modelName={model.model}
/>
<ModelName
className='system-md-regular grow text-text-secondary'
modelItem={model}
showModelType
showMode
showContextSize
>
</ModelName>
<div className='flex shrink-0 items-center'>
{modelLoadBalancingEnabled && !model.deprecated && model.load_balancing_enabled && !model.has_invalid_load_balancing_configs && (
<Badge className='mr-1 h-[18px] w-[18px] items-center justify-center border-text-accent-secondary p-0'>
<Balance className='h-3 w-3 text-text-accent-secondary' />
</Badge>
)}
{
(isCurrentWorkspaceManager && (modelLoadBalancingEnabled || plan.type === Plan.sandbox) && !model.deprecated && [ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)) && (
<ConfigModel
onClick={() => onModifyLoadBalancing?.(model)}
loadBalancingEnabled={model.load_balancing_enabled}
loadBalancingInvalid={model.has_invalid_load_balancing_configs}
credentialRemoved={model.status === ModelStatusEnum.credentialRemoved}
/>
)
}
{
model.deprecated
? (
<Tooltip
popupContent={
<span className='font-semibold'>{t('common.modelProvider.modelHasBeenDeprecated')}</span>} offset={{ mainAxis: 4 }
}
>
<Switch defaultValue={false} disabled size='md' />
</Tooltip>
)
: (isCurrentWorkspaceManager && (
<Switch
className='ml-2'
defaultValue={model?.status === ModelStatusEnum.active}
disabled={![ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)}
size='md'
onChange={onEnablingStateChange}
/>
))
}
</div>
</div>
)
}
export default memo(ModelListItem)

View File

@@ -0,0 +1,104 @@
import type { FC } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowRightSLine,
} from '@remixicon/react'
import type {
Credential,
ModelItem,
ModelProvider,
} from '../declarations'
import {
ConfigurationMethodEnum,
} from '../declarations'
// import Tab from './tab'
import ModelListItem from './model-list-item'
import { useModalContextSelector } from '@/context/modal-context'
import { useAppContext } from '@/context/app-context'
import {
AddCustomModel,
ManageCustomModelCredentials,
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
type ModelListProps = {
provider: ModelProvider
models: ModelItem[]
onCollapse: () => void
onChange?: (provider: string) => void
}
const ModelList: FC<ModelListProps> = ({
provider,
models,
onCollapse,
onChange,
}) => {
const { t } = useTranslation()
const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const { isCurrentWorkspaceManager } = useAppContext()
const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel)
const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal)
const onModifyLoadBalancing = useCallback((model: ModelItem, credential?: Credential) => {
setShowModelLoadBalancingModal({
provider,
credential,
configurateMethod: model.fetch_from,
model: model!,
open: !!model,
onClose: () => setShowModelLoadBalancingModal(null),
onSave: onChange,
})
}, [onChange, provider, setShowModelLoadBalancingModal])
return (
<div className='rounded-b-xl px-2 pb-2'>
<div className='rounded-lg bg-components-panel-bg py-1'>
<div className='flex items-center pl-1 pr-[3px]'>
<span className='group mr-2 flex shrink-0 items-center'>
<span className='system-xs-medium inline-flex h-6 items-center pl-1 pr-1.5 text-text-tertiary group-hover:hidden'>
{t('common.modelProvider.modelsNum', { num: models.length })}
<RiArrowRightSLine className='mr-0.5 h-4 w-4 rotate-90' />
</span>
<span
className='system-xs-medium hidden h-6 cursor-pointer items-center rounded-lg bg-state-base-hover pl-1 pr-1.5 text-text-tertiary group-hover:inline-flex'
onClick={() => onCollapse()}
>
{t('common.modelProvider.modelsNum', { num: models.length })}
<RiArrowRightSLine className='mr-0.5 h-4 w-4 rotate-90' />
</span>
</span>
{
isConfigurable && isCurrentWorkspaceManager && (
<div className='flex grow justify-end'>
<ManageCustomModelCredentials
provider={provider}
currentCustomConfigurationModelFixedFields={undefined}
/>
<AddCustomModel
provider={provider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
currentCustomConfigurationModelFixedFields={undefined}
/>
</div>
)
}
</div>
{
models.map(model => (
<ModelListItem
key={`${model.model}-${model.model_type}-${model.fetch_from}`}
{...{
model,
provider,
isConfigurable,
onModifyLoadBalancing,
}}
/>
))
}
</div>
</div>
)
}
export default ModelList

View File

@@ -0,0 +1,285 @@
import type { Dispatch, SetStateAction } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiIndeterminateCircleLine,
} from '@remixicon/react'
import type {
Credential,
CustomConfigurationModelFixedFields,
CustomModelCredential,
ModelCredential,
ModelLoadBalancingConfig,
ModelLoadBalancingConfigEntry,
ModelProvider,
} from '../declarations'
import { ConfigurationMethodEnum } from '../declarations'
import Indicator from '../../../indicator'
import CooldownTimer from './cooldown-timer'
import classNames from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
import Switch from '@/app/components/base/switch'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import s from '@/app/components/custom/style.module.css'
import GridMask from '@/app/components/base/grid-mask'
import { useProviderContextSelector } from '@/context/provider-context'
import { IS_CE_EDITION } from '@/config'
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import Badge from '@/app/components/base/badge/index'
export type ModelLoadBalancingConfigsProps = {
draftConfig?: ModelLoadBalancingConfig
setDraftConfig: Dispatch<SetStateAction<ModelLoadBalancingConfig | undefined>>
provider: ModelProvider
configurationMethod: ConfigurationMethodEnum
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
withSwitch?: boolean
className?: string
modelCredential: ModelCredential
onUpdate?: (payload?: any, formValues?: Record<string, any>) => void
onRemove?: (credentialId: string) => void
model: CustomModelCredential
}
const ModelLoadBalancingConfigs = ({
draftConfig,
setDraftConfig,
provider,
model,
configurationMethod,
currentCustomConfigurationModelFixedFields: _currentCustomConfigurationModelFixedFields,
withSwitch = false,
className,
modelCredential,
onUpdate,
onRemove,
}: ModelLoadBalancingConfigsProps) => {
const { t } = useTranslation()
const providerFormSchemaPredefined = configurationMethod === ConfigurationMethodEnum.predefinedModel
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const updateConfigEntry = useCallback(
(
index: number,
modifier: (entry: ModelLoadBalancingConfigEntry) => ModelLoadBalancingConfigEntry | undefined,
) => {
setDraftConfig((prev) => {
if (!prev)
return prev
const newConfigs = [...prev.configs]
const modifiedConfig = modifier(newConfigs[index])
if (modifiedConfig)
newConfigs[index] = modifiedConfig
else
newConfigs.splice(index, 1)
return {
...prev,
configs: newConfigs,
}
})
},
[setDraftConfig],
)
const addConfigEntry = useCallback((credential: Credential) => {
setDraftConfig((prev: any) => {
if (!prev)
return prev
return {
...prev,
configs: [...prev.configs, {
credential_id: credential.credential_id,
enabled: true,
name: credential.credential_name,
}],
}
})
}, [setDraftConfig])
const toggleModalBalancing = useCallback((enabled: boolean) => {
if ((modelLoadBalancingEnabled || !enabled) && draftConfig) {
setDraftConfig({
...draftConfig,
enabled,
})
}
}, [draftConfig, modelLoadBalancingEnabled, setDraftConfig])
const toggleConfigEntryEnabled = useCallback((index: number, state?: boolean) => {
updateConfigEntry(index, entry => ({
...entry,
enabled: typeof state === 'boolean' ? state : !entry.enabled,
}))
}, [updateConfigEntry])
const clearCountdown = useCallback((index: number) => {
updateConfigEntry(index, ({ ttl: _, ...entry }) => {
return {
...entry,
in_cooldown: false,
}
})
}, [updateConfigEntry])
const validDraftConfigList = useMemo(() => {
if (!draftConfig)
return []
return draftConfig.configs
}, [draftConfig])
const handleUpdate = useCallback((payload?: any, formValues?: Record<string, any>) => {
onUpdate?.(payload, formValues)
}, [onUpdate])
const handleRemove = useCallback((credentialId: string) => {
const index = draftConfig?.configs.findIndex(item => item.credential_id === credentialId && item.name !== '__inherit__')
if (index && index > -1)
updateConfigEntry(index, () => undefined)
onRemove?.(credentialId)
}, [draftConfig?.configs, updateConfigEntry, onRemove])
if (!draftConfig)
return null
return (
<>
<div
className={classNames(
'min-h-16 rounded-xl border bg-components-panel-bg transition-colors',
(withSwitch || !draftConfig.enabled) ? 'border-components-panel-border' : 'border-util-colors-blue-blue-600',
(withSwitch || draftConfig.enabled) ? 'cursor-default' : 'cursor-pointer',
className,
)}
onClick={(!withSwitch && !draftConfig.enabled) ? () => toggleModalBalancing(true) : undefined}
>
<div className='flex select-none items-center gap-2 px-[15px] py-3'>
<div className='flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-util-colors-indigo-indigo-100 bg-util-colors-indigo-indigo-50 text-util-colors-blue-blue-600'>
<Balance className='h-4 w-4' />
</div>
<div className='grow'>
<div className='flex items-center gap-1 text-sm text-text-primary'>
{t('common.modelProvider.loadBalancing')}
<Tooltip
popupContent={t('common.modelProvider.loadBalancingInfo')}
popupClassName='max-w-[300px]'
triggerClassName='w-3 h-3'
/>
</div>
<div className='text-xs text-text-tertiary'>{t('common.modelProvider.loadBalancingDescription')}</div>
</div>
{
withSwitch && (
<Switch
defaultValue={Boolean(draftConfig.enabled)}
size='l'
className='ml-3 justify-self-end'
disabled={!modelLoadBalancingEnabled && !draftConfig.enabled}
onChange={value => toggleModalBalancing(value)}
/>
)
}
</div>
{draftConfig.enabled && (
<div className='flex flex-col gap-1 px-3 pb-3'>
{validDraftConfigList.map((config, index) => {
const isProviderManaged = config.name === '__inherit__'
const credential = modelCredential.available_credentials.find(c => c.credential_id === config.credential_id)
return (
<div key={config.id || index} className='group flex h-10 items-center rounded-lg border border-components-panel-border bg-components-panel-on-panel-item-bg px-3 shadow-xs'>
<div className='flex grow items-center'>
<div className='mr-2 flex h-3 w-3 items-center justify-center'>
{(config.in_cooldown && Boolean(config.ttl))
? (
<CooldownTimer secondsRemaining={config.ttl} onFinish={() => clearCountdown(index)} />
)
: (
<Tooltip popupContent={t('common.modelProvider.apiKeyStatusNormal')}>
<Indicator color={credential?.not_allowed_to_use ? 'gray' : 'green'} />
</Tooltip>
)}
</div>
<div className='mr-1 text-[13px] text-text-secondary'>
{isProviderManaged ? t('common.modelProvider.defaultConfig') : config.name}
</div>
{isProviderManaged && providerFormSchemaPredefined && (
<Badge className='ml-2'>{t('common.modelProvider.providerManaged')}</Badge>
)}
{
credential?.from_enterprise && (
<Badge className='ml-2'>Enterprise</Badge>
)
}
</div>
<div className='flex items-center gap-1'>
{!isProviderManaged && (
<>
<div className='flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
<Tooltip popupContent={t('common.operation.remove')}>
<span
className='flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover'
onClick={() => updateConfigEntry(index, () => undefined)}
>
<RiIndeterminateCircleLine className='h-4 w-4' />
</span>
</Tooltip>
</div>
</>
)}
{
(config.credential_id || config.name === '__inherit__') && (
<>
<span className='mr-2 h-3 border-r border-r-divider-subtle' />
<Switch
defaultValue={credential?.not_allowed_to_use ? false : Boolean(config.enabled)}
size='md'
className='justify-self-end'
onChange={value => toggleConfigEntryEnabled(index, value)}
disabled={credential?.not_allowed_to_use}
/>
</>
)
}
</div>
</div>
)
})}
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={configurationMethod}
modelCredential={modelCredential}
onSelectCredential={addConfigEntry}
onUpdate={handleUpdate}
onRemove={handleRemove}
/>
</div>
)}
{
draftConfig.enabled && validDraftConfigList.length < 2 && (
<div className='flex h-[34px] items-center rounded-b-xl border-t border-t-divider-subtle bg-components-panel-bg px-6 text-xs text-text-secondary'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#f79009]' />
{t('common.modelProvider.loadBalancingLeastKeyWarning')}
</div>
)
}
</div>
{!modelLoadBalancingEnabled && !IS_CE_EDITION && (
<GridMask canvasClassName='!rounded-xl'>
<div className='mt-2 flex h-14 items-center justify-between rounded-xl border-[0.5px] border-components-panel-border px-4 shadow-md'>
<div
className={classNames('text-gradient text-sm font-semibold leading-tight', s.textGradient)}
>
{t('common.modelProvider.upgradeForLoadBalancing')}
</div>
<UpgradeBtn />
</div>
</GridMask>
)}
</>
)
}
export default ModelLoadBalancingConfigs

View File

@@ -0,0 +1,376 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type {
Credential,
CustomConfigurationModelFixedFields,
ModelItem,
ModelLoadBalancingConfig,
ModelLoadBalancingConfigEntry,
ModelProvider,
} from '../declarations'
import {
ConfigurationMethodEnum,
FormTypeEnum,
} from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import ModelLoadBalancingConfigs from './model-load-balancing-configs'
import classNames from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import { useToastContext } from '@/app/components/base/toast'
import { SwitchCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import {
useGetModelCredential,
useUpdateModelLoadBalancingConfig,
} from '@/service/use-models'
import { useAuth } from '../model-auth/hooks/use-auth'
import Confirm from '@/app/components/base/confirm'
import { useRefreshModel } from '../hooks'
export type ModelLoadBalancingModalProps = {
provider: ModelProvider
configurateMethod: ConfigurationMethodEnum
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
model: ModelItem
credential?: Credential
open?: boolean
onClose?: () => void
onSave?: (provider: string) => void
}
// model balancing config modal
const ModelLoadBalancingModal = ({
provider,
configurateMethod,
currentCustomConfigurationModelFixedFields,
model,
credential,
open = false,
onClose,
onSave,
}: ModelLoadBalancingModalProps) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const {
doingAction,
deleteModel,
openConfirmDelete,
closeConfirmDelete,
handleConfirmDelete,
} = useAuth(
provider,
configurateMethod,
currentCustomConfigurationModelFixedFields,
{
isModelCredential: true,
},
)
const [loading, setLoading] = useState(false)
const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel
const configFrom = providerFormSchemaPredefined ? 'predefined-model' : 'custom-model'
const {
isLoading,
data,
refetch,
} = useGetModelCredential(true, provider.provider, credential?.credential_id, model.model, model.model_type, configFrom)
const modelCredential = data
const {
load_balancing,
current_credential_id,
available_credentials,
current_credential_name,
} = modelCredential ?? {}
const originalConfig = load_balancing
const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig>()
const originalConfigMap = useMemo(() => {
if (!originalConfig)
return {}
return originalConfig?.configs.reduce((prev, config) => {
if (config.id)
prev[config.id] = config
return prev
}, {} as Record<string, ModelLoadBalancingConfigEntry>)
}, [originalConfig])
useEffect(() => {
if (originalConfig)
setDraftConfig(originalConfig)
}, [originalConfig])
const toggleModalBalancing = useCallback((enabled: boolean) => {
if (draftConfig) {
setDraftConfig({
...draftConfig,
enabled,
})
}
}, [draftConfig])
const extendedSecretFormSchemas = useMemo(
() => {
if (providerFormSchemaPredefined) {
return provider?.provider_credential_schema?.credential_form_schemas?.filter(
({ type }) => type === FormTypeEnum.secretInput,
) ?? []
}
return provider?.model_credential_schema?.credential_form_schemas?.filter(
({ type }) => type === FormTypeEnum.secretInput,
) ?? []
},
[provider?.model_credential_schema?.credential_form_schemas, provider?.provider_credential_schema?.credential_form_schemas, providerFormSchemaPredefined],
)
const encodeConfigEntrySecretValues = useCallback((entry: ModelLoadBalancingConfigEntry) => {
const result = { ...entry }
extendedSecretFormSchemas.forEach(({ variable }) => {
if (entry.id && result.credentials[variable] === originalConfigMap[entry.id]?.credentials?.[variable])
result.credentials[variable] = '[__HIDDEN__]'
})
return result
}, [extendedSecretFormSchemas, originalConfigMap])
const { mutateAsync: updateModelLoadBalancingConfig } = useUpdateModelLoadBalancingConfig(provider.provider)
const initialCustomModelCredential = useMemo(() => {
if (!current_credential_id)
return undefined
return {
credential_id: current_credential_id,
credential_name: current_credential_name,
}
}, [current_credential_id, current_credential_name])
const [customModelCredential, setCustomModelCredential] = useState<Credential | undefined>(initialCustomModelCredential)
const { handleRefreshModel } = useRefreshModel()
const handleSave = async () => {
try {
setLoading(true)
const res = await updateModelLoadBalancingConfig(
{
credential_id: customModelCredential?.credential_id || current_credential_id,
config_from: configFrom,
model: model.model,
model_type: model.model_type,
load_balancing: {
...draftConfig,
configs: draftConfig!.configs.map(encodeConfigEntrySecretValues),
enabled: Boolean(draftConfig?.enabled),
},
},
)
if (res.result === 'success') {
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
handleRefreshModel(provider, currentCustomConfigurationModelFixedFields, false)
onSave?.(provider.provider)
onClose?.()
}
}
finally {
setLoading(false)
}
}
const handleDeleteModel = useCallback(async () => {
await handleConfirmDelete()
onClose?.()
}, [handleConfirmDelete, onClose])
const handleUpdate = useCallback(async (payload?: any, formValues?: Record<string, any>) => {
const result = await refetch()
const available_credentials = result.data?.available_credentials || []
const credentialName = formValues?.__authorization_name__
const modelCredential = payload?.credential
if (!available_credentials.length) {
onClose?.()
return
}
if (!modelCredential) {
const currentCredential = available_credentials.find(c => c.credential_name === credentialName)
if (currentCredential) {
setDraftConfig((prev: any) => {
if (!prev)
return prev
return {
...prev,
configs: [...prev.configs, {
credential_id: currentCredential.credential_id,
enabled: true,
name: currentCredential.credential_name,
}],
}
})
}
}
else {
setDraftConfig((prev) => {
if (!prev)
return prev
const newConfigs = [...prev.configs]
const prevIndex = newConfigs.findIndex(item => item.credential_id === modelCredential.credential_id && item.name !== '__inherit__')
const newIndex = available_credentials.findIndex(c => c.credential_id === modelCredential.credential_id)
if (newIndex > -1 && prevIndex > -1)
newConfigs[prevIndex].name = available_credentials[newIndex].credential_name || ''
return {
...prev,
configs: newConfigs,
}
})
}
}, [refetch, credential])
const handleUpdateWhenSwitchCredential = useCallback(async () => {
const result = await refetch()
const available_credentials = result.data?.available_credentials || []
if (!available_credentials.length)
onClose?.()
}, [refetch, onClose])
return (
<>
<Modal
isShow={Boolean(model) && open}
onClose={onClose}
className='w-[640px] max-w-none px-8 pt-8'
title={
<div className='pb-3 font-semibold'>
<div className='h-[30px]'>{
draftConfig?.enabled
? t('common.modelProvider.auth.configLoadBalancing')
: t('common.modelProvider.auth.configModel')
}</div>
{Boolean(model) && (
<div className='flex h-5 items-center'>
<ModelIcon
className='mr-2 shrink-0'
provider={provider}
modelName={model!.model}
/>
<ModelName
className='system-md-regular grow text-text-secondary'
modelItem={model!}
showModelType
showMode
showContextSize
/>
</div>
)}
</div>
}
>
{!draftConfig
? <Loading type='area' />
: (
<>
<div className='py-2'>
<div
className={classNames(
'min-h-16 rounded-xl border bg-components-panel-bg transition-colors',
draftConfig.enabled ? 'cursor-pointer border-components-panel-border' : 'cursor-default border-util-colors-blue-blue-600',
)}
onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}
>
<div className='flex select-none items-center gap-2 px-[15px] py-3'>
<div className='flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-components-card-border bg-components-card-bg'>
{Boolean(model) && (
<ModelIcon className='shrink-0' provider={provider} modelName={model!.model} />
)}
</div>
<div className='grow'>
<div className='text-sm text-text-secondary'>{
providerFormSchemaPredefined
? t('common.modelProvider.auth.providerManaged')
: t('common.modelProvider.auth.specifyModelCredential')
}</div>
<div className='text-xs text-text-tertiary'>{
providerFormSchemaPredefined
? t('common.modelProvider.auth.providerManagedTip')
: t('common.modelProvider.auth.specifyModelCredentialTip')
}</div>
</div>
{
!providerFormSchemaPredefined && (
<SwitchCredentialInLoadBalancing
provider={provider}
customModelCredential={customModelCredential ?? initialCustomModelCredential}
setCustomModelCredential={setCustomModelCredential}
model={model}
credentials={available_credentials}
onUpdate={handleUpdateWhenSwitchCredential}
onRemove={handleUpdateWhenSwitchCredential}
/>
)
}
</div>
</div>
{
modelCredential && (
<ModelLoadBalancingConfigs {...{
draftConfig,
setDraftConfig,
provider,
currentCustomConfigurationModelFixedFields: {
__model_name: model.model,
__model_type: model.model_type,
},
configurationMethod: model.fetch_from,
className: 'mt-2',
modelCredential,
onUpdate: handleUpdate,
onRemove: handleUpdateWhenSwitchCredential,
model: {
model: model.model,
model_type: model.model_type,
},
}} />
)
}
</div>
<div className='mt-6 flex items-center justify-between gap-2'>
<div>
{
!providerFormSchemaPredefined && (
<Button
onClick={() => openConfirmDelete(undefined, { model: model.model, model_type: model.model_type })}
className='text-components-button-destructive-secondary-text'
>
{t('common.modelProvider.auth.removeModel')}
</Button>
)
}
</div>
<div className='space-x-2'>
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button
variant='primary'
onClick={handleSave}
disabled={
loading
|| (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2)
|| isLoading
}
>{t('common.operation.save')}</Button>
</div>
</div>
</>
)
}
</Modal >
{
deleteModel && (
<Confirm
isShow
title={t('common.modelProvider.confirmDelete')}
onCancel={closeConfirmDelete}
onConfirm={handleDeleteModel}
isDisabled={doingAction}
/>
)
}
</>
)
}
export default memo(ModelLoadBalancingModal)

View File

@@ -0,0 +1,76 @@
import { Fragment } from 'react'
import type { FC } from 'react'
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react'
import { useTranslation } from 'react-i18next'
import {
RiCheckLine,
RiMoreFill,
} from '@remixicon/react'
import { PreferredProviderTypeEnum } from '../declarations'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
type SelectorProps = {
value?: string
onSelect: (key: PreferredProviderTypeEnum) => void
}
const Selector: FC<SelectorProps> = ({
value,
onSelect,
}) => {
const { t } = useTranslation()
const options = [
{
key: PreferredProviderTypeEnum.custom,
text: t('common.modelProvider.apiKey'),
},
{
key: PreferredProviderTypeEnum.system,
text: t('common.modelProvider.quota'),
},
]
return (
<Popover className='relative'>
<PopoverButton as='div'>
{
({ open }) => (
<Button className={cn(
'h-6 w-6 rounded-md px-0',
open && 'bg-components-button-secondary-bg-hover',
)}>
<RiMoreFill className='h-3 w-3' />
</Button>
)
}
</PopoverButton>
<Transition
as={Fragment}
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<PopoverPanel className='absolute right-0 top-7 z-10 w-[144px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'>
<div className='p-1'>
<div className='px-3 pb-1 pt-2 text-sm font-medium text-text-secondary'>{t('common.modelProvider.card.priorityUse')}</div>
{
options.map(option => (
<PopoverButton as={Fragment} key={option.key}>
<div
className='flex h-9 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-components-panel-on-panel-item-bg-hover'
onClick={() => onSelect(option.key)}
>
<div className='grow'>{option.text}</div>
{value === option.key && <RiCheckLine className='h-4 w-4 text-text-accent' />}
</div>
</PopoverButton>
))
}
</div>
</PopoverPanel>
</Transition>
</Popover>
)
}
export default Selector

View File

@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { ChevronDownDouble } from '@/app/components/base/icons/src/vender/line/arrows'
import Tooltip from '@/app/components/base/tooltip'
const PriorityUseTip = () => {
const { t } = useTranslation()
return (
<Tooltip
popupContent={t('common.modelProvider.priorityUsing') || ''}
>
<div className='absolute -right-[5px] -top-[5px] cursor-pointer rounded-[5px] border-[0.5px] border-components-panel-border-subtle bg-util-colors-indigo-indigo-50 shadow-xs'>
<ChevronDownDouble className='h-3 w-3 rotate-180 text-util-colors-indigo-indigo-600' />
</div>
</Tooltip>
)
}
export default PriorityUseTip

View File

@@ -0,0 +1,66 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import type { ModelProvider } from '../declarations'
import {
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import {
MODEL_PROVIDER_QUOTA_GET_PAID,
} from '../utils'
import PriorityUseTip from './priority-use-tip'
import Tooltip from '@/app/components/base/tooltip'
import { formatNumber } from '@/utils/format'
type QuotaPanelProps = {
provider: ModelProvider
}
const QuotaPanel: FC<QuotaPanelProps> = ({
provider,
}) => {
const { t } = useTranslation()
const customConfig = provider.custom_configuration
const priorityUseType = provider.preferred_provider_type
const systemConfig = provider.system_configuration
const currentQuota = systemConfig.enabled && systemConfig.quota_configurations.find(item => item.quota_type === systemConfig.current_quota_type)
const openaiOrAnthropic = MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider)
return (
<div className='group relative min-w-[112px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] px-3 py-2 shadow-xs'>
<div className='system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary'>
{t('common.modelProvider.quota')}
<Tooltip popupContent={
openaiOrAnthropic
? t('common.modelProvider.card.tip')
: t('common.modelProvider.quotaTip')
}
/>
</div>
{
currentQuota && (
<div className='flex h-4 items-center text-xs text-text-tertiary'>
<span className='system-md-semibold-uppercase mr-0.5 text-text-secondary'>{formatNumber(Math.max((currentQuota?.quota_limit || 0) - (currentQuota?.quota_used || 0), 0))}</span>
{
currentQuota?.quota_unit === QuotaUnitEnum.tokens && 'Tokens'
}
{
currentQuota?.quota_unit === QuotaUnitEnum.times && t('common.modelProvider.callTimes')
}
{
currentQuota?.quota_unit === QuotaUnitEnum.credits && t('common.modelProvider.credits')
}
</div>
)
}
{
priorityUseType === PreferredProviderTypeEnum.system && customConfig.status === CustomConfigurationStatusEnum.active && (
<PriorityUseTip />
)
}
</div>
)
}
export default QuotaPanel