瀏覽代碼

feat: model load balancing (#4926)

Nite Knite 10 月之前
父節點
當前提交
37f292ea91
共有 58 個文件被更改,包括 1896 次插入304 次删除
  1. 1 1
      web/app/components/app/configuration/config/assistant-type-picker/index.tsx
  2. 3 3
      web/app/components/app/configuration/debug/index.tsx
  3. 4 4
      web/app/components/app/configuration/index.tsx
  4. 2 2
      web/app/components/app/overview/apikey-info-panel/index.tsx
  5. 3 5
      web/app/components/app/overview/appCard.tsx
  6. 3 3
      web/app/components/base/button/index.css
  7. 11 11
      web/app/components/base/button/index.tsx
  8. 1 1
      web/app/components/base/chat/chat/answer/workflow-process.tsx
  9. 3 0
      web/app/components/base/icons/assets/vender/line/financeAndECommerce/balance.svg
  10. 29 0
      web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.json
  11. 16 0
      web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.tsx
  12. 1 0
      web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts
  13. 2 3
      web/app/components/base/image-uploader/image-list.tsx
  14. 1 1
      web/app/components/base/image-uploader/image-preview.tsx
  15. 7 0
      web/app/components/base/modal/index.css
  16. 10 5
      web/app/components/base/modal/index.tsx
  17. 4 0
      web/app/components/base/simple-pie-chart/index.module.css
  18. 66 0
      web/app/components/base/simple-pie-chart/index.tsx
  19. 5 3
      web/app/components/base/switch/index.tsx
  20. 1 0
      web/app/components/billing/type.ts
  21. 3 3
      web/app/components/custom/custom-app-header-brand/index.tsx
  22. 3 3
      web/app/components/custom/custom-web-app-brand/index.tsx
  23. 1 1
      web/app/components/datasets/create/index.tsx
  24. 8 8
      web/app/components/datasets/create/step-two/index.tsx
  25. 1 1
      web/app/components/datasets/documents/detail/settings/index.tsx
  26. 26 4
      web/app/components/header/account-setting/model-provider-page/declarations.ts
  27. 29 16
      web/app/components/header/account-setting/model-provider-page/hooks.ts
  28. 14 14
      web/app/components/header/account-setting/model-provider-page/index.tsx
  29. 5 5
      web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx
  30. 98 33
      web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx
  31. 344 0
      web/app/components/header/account-setting/model-provider-page/model-modal/model-load-balancing-entry-modal.tsx
  32. 10 7
      web/app/components/header/account-setting/model-provider-page/model-name/index.tsx
  33. 2 2
      web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx
  34. 2 2
      web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx
  35. 64 0
      web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx
  36. 2 2
      web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx
  37. 10 9
      web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx
  38. 119 0
      web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx
  39. 33 57
      web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx
  40. 269 0
      web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx
  41. 190 0
      web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx
  42. 1 1
      web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx
  43. 4 5
      web/app/components/header/account-setting/model-provider-page/provider-card/index.tsx
  44. 45 1
      web/app/components/header/account-setting/model-provider-page/utils.ts
  45. 220 0
      web/app/components/tools/tool-list/index.tsx
  46. 2 2
      web/app/components/workflow/block-icon.tsx
  47. 2 2
      web/app/components/workflow/header/checklist.tsx
  48. 1 1
      web/app/components/workflow/operator/index.tsx
  49. 1 1
      web/app/components/workflow/panel/chat-record/index.tsx
  50. 2 2
      web/app/components/workflow/panel/debug-and-preview/index.tsx
  51. 2 1
      web/app/styles/globals.css
  52. 3 3
      web/context/debug-configuration.ts
  53. 77 26
      web/context/modal-context.tsx
  54. 42 29
      web/context/provider-context.tsx
  55. 16 0
      web/i18n/en-US/common.ts
  56. 15 0
      web/i18n/zh-Hans/common.ts
  57. 28 2
      web/service/common.ts
  58. 29 19
      web/tailwind.config.js

+ 1 - 1
web/app/components/app/configuration/config/assistant-type-picker/index.tsx

@@ -123,7 +123,7 @@ const AssistantTypePicker: FC<Props> = ({
           </div>
         </PortalToFollowElemTrigger>
         <PortalToFollowElemContent style={{ zIndex: 1000 }}>
-          <div className='relative left-0.5 p-6 bg-white border border-black/[0.08] shadow-lg rounded-xl w-[480px]'>
+          <div className='relative left-0.5 p-6 bg-white border border-black/8 shadow-lg rounded-xl w-[480px]'>
             <div className='mb-2 leading-5 text-sm font-semibold text-gray-900'>{t('appDebug.assistantType.name')}</div>
             <SelectItem
               Icon={BubbleText}

+ 3 - 3
web/app/components/app/configuration/debug/index.tsx

@@ -41,7 +41,7 @@ import PromptLogModal from '@/app/components/base/prompt-log-modal'
 import { useStore as useAppStore } from '@/app/components/app/store'
 
 type IDebug = {
-  hasSetAPIKEY: boolean
+  isAPIKeySet: boolean
   onSetting: () => void
   inputs: Inputs
   modelParameterParams: Pick<ModelParameterModalProps, 'setModel' | 'onCompletionParamsChange'>
@@ -51,7 +51,7 @@ type IDebug = {
 }
 
 const Debug: FC<IDebug> = ({
-  hasSetAPIKEY = true,
+  isAPIKeySet = true,
   onSetting,
   inputs,
   modelParameterParams,
@@ -503,7 +503,7 @@ const Debug: FC<IDebug> = ({
           onCancel={handleCancel}
         />
       )}
-      {!hasSetAPIKEY && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
+      {!isAPIKeySet && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
     </>
   )
 }

+ 4 - 4
web/app/components/app/configuration/index.tsx

@@ -255,7 +255,7 @@ const Configuration: FC = () => {
     })
   }
 
-  const { hasSettedApiKey } = useProviderContext()
+  const { isAPIKeySet } = useProviderContext()
   const {
     currentModel: currModel,
     textGenerationModelList,
@@ -678,7 +678,7 @@ const Configuration: FC = () => {
   return (
     <ConfigContext.Provider value={{
       appId,
-      hasSetAPIKEY: hasSettedApiKey,
+      isAPIKeySet,
       isTrailFinished: false,
       mode,
       modelModeType,
@@ -818,7 +818,7 @@ const Configuration: FC = () => {
             {!isMobile && <div className="relative flex flex-col w-1/2 h-full overflow-y-auto grow " style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
               <div className='flex flex-col h-0 border-t border-l grow rounded-tl-2xl bg-gray-50 '>
                 <Debug
-                  hasSetAPIKEY={hasSettedApiKey}
+                  isAPIKeySet={isAPIKeySet}
                   onSetting={() => setShowAccountSettingModal({ payload: 'provider' })}
                   inputs={inputs}
                   modelParameterParams={{
@@ -881,7 +881,7 @@ const Configuration: FC = () => {
         {isMobile && (
           <Drawer showClose isOpen={isShowDebugPanel} onClose={hideDebugPanel} mask footer={null} panelClassname='!bg-gray-50'>
             <Debug
-              hasSetAPIKEY={hasSettedApiKey}
+              isAPIKeySet={isAPIKeySet}
               onSetting={() => setShowAccountSettingModal({ payload: 'provider' })}
               inputs={inputs}
               modelParameterParams={{

+ 2 - 2
web/app/components/app/overview/apikey-info-panel/index.tsx

@@ -12,14 +12,14 @@ import { useModalContext } from '@/context/modal-context'
 const APIKeyInfoPanel: FC = () => {
   const isCloud = !IS_CE_EDITION
 
-  const { hasSettedApiKey } = useProviderContext()
+  const { isAPIKeySet } = useProviderContext()
   const { setShowAccountSettingModal } = useModalContext()
 
   const { t } = useTranslation()
 
   const [isShow, setIsShow] = useState(true)
 
-  if (hasSettedApiKey)
+  if (isAPIKeySet)
     return null
 
   if (!(isShow))

+ 3 - 5
web/app/components/app/overview/appCard.tsx

@@ -132,8 +132,7 @@ function AppCard({
 
   return (
     <div
-      className={`shadow-xs border-[0.5px] rounded-lg border-gray-200 ${
-        className ?? ''
+      className={`shadow-xs border-[0.5px] rounded-lg border-gray-200 ${className ?? ''
       }`}
     >
       <div className={`px-6 py-5 ${customBgColor ?? bgColor} rounded-lg`}>
@@ -165,7 +164,7 @@ function AppCard({
                 ? t('appOverview.overview.appInfo.accessibleAddress')
                 : t('appOverview.overview.apiInfo.accessibleAddress')}
             </div>
-            <div className="w-full h-9 pl-2 pr-0.5 py-0.5 bg-black bg-opacity-[0.02] rounded-lg border border-black border-opacity-5 justify-start items-center inline-flex">
+            <div className="w-full h-9 pl-2 pr-0.5 py-0.5 bg-black bg-opacity-2 rounded-lg border border-black border-opacity-5 justify-start items-center inline-flex">
               <div className="h-4 px-2 justify-start items-start gap-2 flex flex-1 min-w-0">
                 <div className="text-gray-700 text-xs font-medium text-ellipsis overflow-hidden whitespace-nowrap">
                   {isApp ? appUrl : apiUrl}
@@ -203,8 +202,7 @@ function AppCard({
                     onClick={() => setShowConfirmDelete(true)}
                   >
                     <div
-                      className={`w-full h-full ${style.refreshIcon} ${
-                        genLoading ? style.generateLogo : ''
+                      className={`w-full h-full ${style.refreshIcon} ${genLoading ? style.generateLogo : ''
                       }`}
                     ></div>
                   </div>

+ 3 - 3
web/app/components/base/button/index.css

@@ -3,10 +3,10 @@
 @layer components {
   .btn {
     @apply inline-flex justify-center items-center content-center h-9 leading-5 rounded-lg px-4 py-2 text-base cursor-pointer whitespace-nowrap;
-  }
+  };
 
   .btn-default {
-    @apply border-solid border border-gray-200 cursor-pointer text-gray-500 hover:bg-white hover:shadow-sm hover:border-gray-300;
+    @apply border-solid border border-gray-200 cursor-pointer text-gray-700 hover:bg-white hover:shadow-sm hover:border-gray-300;
   }
 
   .btn-default-disabled {
@@ -28,4 +28,4 @@
   .btn-warning-disabled {
     @apply bg-red-600/75 cursor-not-allowed text-white;
   }
-}
+}

+ 11 - 11
web/app/components/base/button/index.tsx

@@ -1,16 +1,16 @@
-import type { FC, MouseEventHandler } from 'react'
-import React from 'react'
+import type { FC, MouseEventHandler, PropsWithChildren } from 'react'
+import React, { memo } from 'react'
+import classNames from 'classnames'
 import Spinner from '../spinner'
 
-export type IButtonProps = {
+export type IButtonProps = PropsWithChildren<{
   type?: string
   className?: string
   disabled?: boolean
   loading?: boolean
   tabIndex?: number
-  children: React.ReactNode
   onClick?: MouseEventHandler<HTMLDivElement>
-}
+}>
 
 const Button: FC<IButtonProps> = ({
   type,
@@ -21,22 +21,22 @@ const Button: FC<IButtonProps> = ({
   loading = false,
   tabIndex,
 }) => {
-  let style = 'cursor-pointer'
+  let typeClassNames = 'cursor-pointer'
   switch (type) {
     case 'primary':
-      style = (disabled || loading) ? 'btn-primary-disabled' : 'btn-primary'
+      typeClassNames = (disabled || loading) ? 'btn-primary-disabled' : 'btn-primary'
       break
     case 'warning':
-      style = (disabled || loading) ? 'btn-warning-disabled' : 'btn-warning'
+      typeClassNames = (disabled || loading) ? 'btn-warning-disabled' : 'btn-warning'
       break
     default:
-      style = disabled ? 'btn-default-disabled' : 'btn-default'
+      typeClassNames = disabled ? 'btn-default-disabled' : 'btn-default'
       break
   }
 
   return (
     <div
-      className={`btn ${style} ${className && className}`}
+      className={classNames('btn', typeClassNames, className)}
       tabIndex={tabIndex}
       onClick={disabled ? undefined : onClick}
     >
@@ -47,4 +47,4 @@ const Button: FC<IButtonProps> = ({
   )
 }
 
-export default React.memo(Button)
+export default memo(Button)

+ 1 - 1
web/app/components/base/chat/chat/answer/workflow-process.tsx

@@ -65,7 +65,7 @@ const WorkflowProcessItem = ({
   return (
     <div
       className={cn(
-        'mb-2 rounded-xl border-[0.5px] border-black/[0.08]',
+        'mb-2 rounded-xl border-[0.5px] border-black/8',
         collapse ? 'py-[7px]' : hideInfo ? 'pt-2 pb-1' : 'py-2',
         collapse && (!grayBg ? 'bg-white' : 'bg-gray-50'),
         hideInfo ? 'mx-[-8px] px-1' : 'w-full px-3',

文件差異過大導致無法顯示
+ 3 - 0
web/app/components/base/icons/assets/vender/line/financeAndECommerce/balance.svg


文件差異過大導致無法顯示
+ 29 - 0
web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.json


+ 16 - 0
web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Balance.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Balance'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/financeAndECommerce/index.ts

@@ -1,3 +1,4 @@
+export { default as Balance } from './Balance'
 export { default as CoinsStacked01 } from './CoinsStacked01'
 export { default as GoldCoin } from './GoldCoin'
 export { default as ReceiptList } from './ReceiptList'

+ 2 - 3
web/app/components/base/image-uploader/image-list.tsx

@@ -77,8 +77,7 @@ const ImageList: FC<ImageListProps> = ({
             <div
               className={`
                   absolute inset-0 flex items-center justify-center rounded-lg z-[1] border
-                  ${
-            item.progress === -1
+                  ${item.progress === -1
               ? 'bg-[#FEF0C7] border-[#DC6803]'
               : 'bg-black/[0.16] border-transparent'
             }
@@ -120,7 +119,7 @@ const ImageList: FC<ImageListProps> = ({
               type="button"
               className={cn(
                 'absolute z-10 -top-[9px] -right-[9px] items-center justify-center w-[18px] h-[18px]',
-                'bg-white hover:bg-gray-50 border-[0.5px] border-black/[0.02] rounded-2xl shadow-lg',
+                'bg-white hover:bg-gray-50 border-[0.5px] border-black/2 rounded-2xl shadow-lg',
                 item.progress === -1 ? 'flex' : 'hidden group-hover:flex',
               )}
               onClick={() => onRemove && onRemove(item._id)}

+ 1 - 1
web/app/components/base/image-uploader/image-preview.tsx

@@ -18,7 +18,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
         className='max-w-full max-h-full'
       />
       <div
-        className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
+        className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer'
         onClick={onCancel}
       >
         <XClose className='w-4 h-4 text-white' />

+ 7 - 0
web/app/components/base/modal/index.css

@@ -0,0 +1,7 @@
+.modal-dialog {
+  @apply relative z-10;
+}
+
+.modal-panel {
+  @apply w-full max-w-md transform rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all;
+}

+ 10 - 5
web/app/components/base/modal/index.tsx

@@ -1,16 +1,17 @@
 import { Dialog, Transition } from '@headlessui/react'
 import { Fragment } from 'react'
 import { XMarkIcon } from '@heroicons/react/24/outline'
+import classNames from 'classnames'
 // https://headlessui.com/react/dialog
 
 type IModal = {
   className?: string
   wrapperClassName?: string
   isShow: boolean
-  onClose: () => void
+  onClose?: () => void
   title?: React.ReactNode
   description?: React.ReactNode
-  children: React.ReactNode
+  children?: React.ReactNode
   closable?: boolean
   overflowVisible?: boolean
 }
@@ -19,7 +20,7 @@ export default function Modal({
   className,
   wrapperClassName,
   isShow,
-  onClose,
+  onClose = () => { },
   title,
   description,
   children,
@@ -28,7 +29,7 @@ export default function Modal({
 }: IModal) {
   return (
     <Transition appear show={isShow} as={Fragment}>
-      <Dialog as="div" className={`relative z-30 ${wrapperClassName}`} onClose={onClose}>
+      <Dialog as="div" className={classNames('modal-dialog', wrapperClassName)} onClose={onClose}>
         <Transition.Child
           as={Fragment}
           enter="ease-out duration-300"
@@ -58,7 +59,11 @@ export default function Modal({
               leaveFrom="opacity-100 scale-100"
               leaveTo="opacity-0 scale-95"
             >
-              <Dialog.Panel className={`w-full max-w-md transform ${overflowVisible ? 'overflow-visible' : 'overflow-hidden'} rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all ${className}`}>
+              <Dialog.Panel className={classNames(
+                'modal-panel',
+                overflowVisible ? 'overflow-visible' : 'overflow-hidden',
+                className,
+              )}>
                 {title && <Dialog.Title
                   as="h3"
                   className="text-lg font-medium leading-6 text-gray-900"

+ 4 - 0
web/app/components/base/simple-pie-chart/index.module.css

@@ -0,0 +1,4 @@
+.simplePieChart {
+  border-radius: 50%;
+  box-shadow: 0 0 5px -3px rgb(from var(--simple-pie-chart-color) r g b / 0.1), 0.5px 0.5px 3px 0 rgb(from var(--simple-pie-chart-color) r g b / 0.3);
+}

+ 66 - 0
web/app/components/base/simple-pie-chart/index.tsx

@@ -0,0 +1,66 @@
+import type { CSSProperties } from 'react'
+import { memo, useMemo } from 'react'
+import ReactECharts from 'echarts-for-react'
+import type { EChartsOption } from 'echarts'
+import classNames from 'classnames'
+import style from './index.module.css'
+
+export type SimplePieChartProps = {
+  percentage?: number
+  fill?: string
+  stroke?: string
+  size?: number
+  className?: string
+}
+
+const SimplePieChart = ({ percentage = 80, fill = '#fdb022', stroke = '#f79009', size = 12, className }: SimplePieChartProps) => {
+  const option: EChartsOption = useMemo(() => ({
+    series: [
+      {
+        type: 'pie',
+        radius: ['83%', '100%'],
+        animation: false,
+        data: [
+          { value: 100, itemStyle: { color: stroke } },
+        ],
+        emphasis: {
+          disabled: true,
+        },
+        labelLine: {
+          show: false,
+        },
+        cursor: 'default',
+      },
+      {
+        type: 'pie',
+        radius: '83%',
+        animationDuration: 600,
+        data: [
+          { value: percentage, itemStyle: { color: fill } },
+          { value: 100 - percentage, itemStyle: { color: '#fff' } },
+        ],
+        emphasis: {
+          disabled: true,
+        },
+        labelLine: {
+          show: false,
+        },
+        cursor: 'default',
+      },
+    ],
+  }), [stroke, fill, percentage])
+
+  return (
+    <ReactECharts
+      option={option}
+      className={classNames(style.simplePieChart, className)}
+      style={{
+        '--simple-pie-chart-color': fill,
+        'width': size,
+        'height': size,
+      } as CSSProperties}
+    />
+  )
+}
+
+export default memo(SimplePieChart)

+ 5 - 3
web/app/components/base/switch/index.tsx

@@ -4,13 +4,14 @@ import classNames from 'classnames'
 import { Switch as OriginalSwitch } from '@headlessui/react'
 
 type SwitchProps = {
-  onChange: (value: boolean) => void
+  onChange?: (value: boolean) => void
   size?: 'sm' | 'md' | 'lg' | 'l'
   defaultValue?: boolean
   disabled?: boolean
+  className?: string
 }
 
-const Switch = ({ onChange, size = 'lg', defaultValue = false, disabled = false }: SwitchProps) => {
+const Switch = ({ onChange, size = 'lg', defaultValue = false, disabled = false, className }: SwitchProps) => {
   const [enabled, setEnabled] = useState(defaultValue)
   useEffect(() => {
     setEnabled(defaultValue)
@@ -42,13 +43,14 @@ const Switch = ({ onChange, size = 'lg', defaultValue = false, disabled = false
         if (disabled)
           return
         setEnabled(checked)
-        onChange(checked)
+        onChange?.(checked)
       }}
       className={classNames(
         wrapStyle[size],
         enabled ? 'bg-blue-600' : 'bg-gray-200',
         'relative inline-flex  flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out',
         disabled ? '!opacity-50 !cursor-not-allowed' : '',
+        className,
       )}
     >
       <span

+ 1 - 0
web/app/components/billing/type.ts

@@ -65,6 +65,7 @@ export type CurrentPlanInfoBackend = {
   }
   docs_processing: DocumentProcessingPriority
   can_replace_logo: boolean
+  model_load_balancing_enabled: boolean
 }
 
 export type SubscriptionItem = {

+ 3 - 3
web/app/components/custom/custom-app-header-brand/index.tsx

@@ -14,7 +14,7 @@ const CustomAppHeaderBrand = () => {
   return (
     <div className='py-3'>
       <div className='mb-2 text-sm font-medium text-gray-900'>{t('custom.app.title')}</div>
-      <div className='relative mb-4 rounded-xl bg-gray-100 border-[0.5px] border-black/[0.08] shadow-xs'>
+      <div className='relative mb-4 rounded-xl bg-gray-100 border-[0.5px] border-black/8 shadow-xs'>
         <div className={`${s.mask} absolute inset-0 rounded-xl`}></div>
         <div className='flex items-center pl-5 h-14 rounded-t-xl'>
           <div className='relative flex items-center mr-[199px] w-[120px] h-10 bg-[rgba(217,45,32,0.12)]'>
@@ -43,7 +43,7 @@ const CustomAppHeaderBrand = () => {
       <div className='flex items-center mb-2'>
         <Button
           className={`
-            !h-8 !px-3 bg-white !text-[13px] 
+            !h-8 !px-3 bg-white !text-[13px]
             ${plan.type === Plan.sandbox ? 'opacity-40' : ''}
           `}
           disabled={plan.type === Plan.sandbox}
@@ -54,7 +54,7 @@ const CustomAppHeaderBrand = () => {
         <div className='mx-2 h-5 w-[1px] bg-black/5'></div>
         <Button
           className={`
-            !h-8 !px-3 bg-white !text-[13px] 
+            !h-8 !px-3 bg-white !text-[13px]
             ${plan.type === Plan.sandbox ? 'opacity-40' : ''}
           `}
           disabled={plan.type === Plan.sandbox}

+ 3 - 3
web/app/components/custom/custom-web-app-brand/index.tsx

@@ -106,7 +106,7 @@ const CustomWebAppBrand = () => {
   return (
     <div className='py-4'>
       <div className='mb-2 text-sm font-medium text-gray-900'>{t('custom.webapp.title')}</div>
-      <div className='relative mb-4 pl-4 pb-6 pr-[119px] rounded-xl border-[0.5px] border-black/[0.08] shadow-xs bg-gray-50 overflow-hidden'>
+      <div className='relative mb-4 pl-4 pb-6 pr-[119px] rounded-xl border-[0.5px] border-black/8 shadow-xs bg-gray-50 overflow-hidden'>
         <div className={`${s.mask} absolute top-0 left-0 w-full -bottom-2 z-10`}></div>
         <div className='flex items-center -mt-2 mb-4 p-6 bg-white rounded-xl'>
           <div className='flex items-center px-4 w-[125px] h-9 rounded-lg bg-primary-600 border-[0.5px] border-primary-700 shadow-xs'>
@@ -152,7 +152,7 @@ const CustomWebAppBrand = () => {
             !uploading && (
               <Button
                 className={`
-                  relative mr-2 !h-8 !px-3 bg-white !text-[13px] 
+                  relative mr-2 !h-8 !px-3 bg-white !text-[13px]
                   ${uploadDisabled ? 'opacity-40' : ''}
                 `}
                 disabled={uploadDisabled}
@@ -212,7 +212,7 @@ const CustomWebAppBrand = () => {
           <div className='mr-2 h-5 w-[1px] bg-black/5'></div>
           <Button
             className={`
-              !h-8 !px-3 bg-white !text-[13px] 
+              !h-8 !px-3 bg-white !text-[13px]
               ${(uploadDisabled || (!webappLogo && !webappBrandRemoved)) ? 'opacity-40' : ''}
             `}
             disabled={uploadDisabled || (!webappLogo && !webappBrandRemoved)}

+ 1 - 1
web/app/components/datasets/create/index.tsx

@@ -123,7 +123,7 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
           onStepChange={nextStep}
         />}
         {(step === 2 && (!datasetId || (datasetId && !!detail))) && <StepTwo
-          hasSetAPIKEY={!!embeddingsDefaultModel}
+          isAPIKeySet={!!embeddingsDefaultModel}
           onSetting={() => setShowAccountSettingModal({ payload: 'provider' })}
           indexingType={detail?.indexing_technique}
           datasetId={datasetId}

+ 8 - 8
web/app/components/datasets/create/step-two/index.tsx

@@ -49,7 +49,7 @@ type ValueOf<T> = T[keyof T]
 type StepTwoProps = {
   isSetting?: boolean
   documentDetail?: FullDocumentDetail
-  hasSetAPIKEY: boolean
+  isAPIKeySet: boolean
   onSetting: () => void
   datasetId?: string
   indexingType?: ValueOf<IndexingType>
@@ -75,7 +75,7 @@ enum IndexingType {
 const StepTwo = ({
   isSetting,
   documentDetail,
-  hasSetAPIKEY,
+  isAPIKeySet,
   onSetting,
   datasetId,
   indexingType,
@@ -107,7 +107,7 @@ const StepTwo = ({
   const hasSetIndexType = !!indexingType
   const [indexType, setIndexType] = useState<ValueOf<IndexingType>>(
     (indexingType
-      || hasSetAPIKEY)
+      || isAPIKeySet)
       ? IndexingType.QUALIFIED
       : IndexingType.ECONOMICAL,
   )
@@ -480,8 +480,8 @@ const StepTwo = ({
       setIndexType(indexingType as IndexingType)
 
     else
-      setIndexType(hasSetAPIKEY ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL)
-  }, [hasSetAPIKEY, indexingType, datasetId])
+      setIndexType(isAPIKeySet ? IndexingType.QUALIFIED : IndexingType.ECONOMICAL)
+  }, [isAPIKeySet, indexingType, datasetId])
 
   useEffect(() => {
     if (segmentationType === SegmentType.AUTO) {
@@ -636,13 +636,13 @@ const StepTwo = ({
                   className={cn(
                     s.radioItem,
                     s.indexItem,
-                    !hasSetAPIKEY && s.disabled,
+                    !isAPIKeySet && s.disabled,
                     !hasSetIndexType && indexType === IndexingType.QUALIFIED && s.active,
                     hasSetIndexType && s.disabled,
                     hasSetIndexType && '!w-full',
                   )}
                   onClick={() => {
-                    if (hasSetAPIKEY)
+                    if (isAPIKeySet)
                       setIndexType(IndexingType.QUALIFIED)
                   }}
                 >
@@ -665,7 +665,7 @@ const StepTwo = ({
                         )
                     }
                   </div>
-                  {!hasSetAPIKEY && (
+                  {!isAPIKeySet && (
                     <div className={s.warningTip}>
                       <span>{t('datasetCreation.stepTwo.warning')}&nbsp;</span>
                       <span className={s.click} onClick={onSetting}>{t('datasetCreation.stepTwo.click')}</span>

+ 1 - 1
web/app/components/datasets/documents/detail/settings/index.tsx

@@ -68,7 +68,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
         {!documentDetail && <Loading type='app' />}
         {dataset && documentDetail && (
           <StepTwo
-            hasSetAPIKEY={!!embeddingsDefaultModel}
+            isAPIKeySet={!!embeddingsDefaultModel}
             onSetting={showSetAPIKey}
             datasetId={datasetId}
             dataSourceType={documentDetail.data_source_type}

+ 26 - 4
web/app/components/header/account-setting/model-provider-page/declarations.ts

@@ -39,7 +39,7 @@ export const MODEL_TYPE_TEXT = {
   [ModelTypeEnum.tts]: 'TTS',
 }
 
-export enum ConfigurateMethodEnum {
+export enum ConfigurationMethodEnum {
   predefinedModel = 'predefined-model',
   customizableModel = 'customizable-model',
   fetchFromRemote = 'fetch-from-remote',
@@ -64,6 +64,7 @@ export enum ModelStatusEnum {
   noConfigure = 'no-configure',
   quotaExceeded = 'quota-exceeded',
   noPermission = 'no-permission',
+  disabled = 'disabled',
 }
 
 export const MODEL_STATUS_TEXT: { [k: string]: TypeWithI18N } = {
@@ -114,9 +115,10 @@ export type ModelItem = {
   label: TypeWithI18N
   model_type: ModelTypeEnum
   features?: ModelFeatureEnum[]
-  fetch_from: ConfigurateMethodEnum
+  fetch_from: ConfigurationMethodEnum
   status: ModelStatusEnum
   model_properties: Record<string, string | number>
+  load_balancing_enabled: boolean
   deprecated?: boolean
 }
 
@@ -158,7 +160,7 @@ export type ModelProvider = {
   icon_large: TypeWithI18N
   background?: string
   supported_model_types: ModelTypeEnum[]
-  configurate_methods: ConfigurateMethodEnum[]
+  configurate_methods: ConfigurationMethodEnum[]
   provider_credential_schema: {
     credential_form_schemas: CredentialFormSchema[]
   }
@@ -204,7 +206,7 @@ export type DefaultModel = {
   model: string
 }
 
-export type CustomConfigrationModelFixedFields = {
+export type CustomConfigurationModelFixedFields = {
   __model_name: string
   __model_type: ModelTypeEnum
 }
@@ -223,3 +225,23 @@ export type ModelParameterRule = {
   options?: string[]
   tagPlaceholder?: TypeWithI18N
 }
+
+export type ModelLoadBalancingConfigEntry = {
+  /** model balancing config entry id */
+  id?: string
+  /** is config entry enabled */
+  enabled?: boolean
+  /** config entry name */
+  name: string
+  /** model balancing credential */
+  credentials: Record<string, string | undefined | boolean>
+  /** is config entry currently removed from Round-robin queue */
+  in_cooldown?: boolean
+  /** cooldown time (in seconds) */
+  ttl?: number
+}
+
+export type ModelLoadBalancingConfig = {
+  enabled: boolean
+  configs: ModelLoadBalancingConfigEntry[]
+}

+ 29 - 16
web/app/components/header/account-setting/model-provider-page/hooks.ts

@@ -7,14 +7,14 @@ import {
 import useSWR, { useSWRConfig } from 'swr'
 import { useContext } from 'use-context-selector'
 import type {
-  CustomConfigrationModelFixedFields,
+  CustomConfigurationModelFixedFields,
   DefaultModel,
   DefaultModelResponse,
   Model,
   ModelTypeEnum,
 } from './declarations'
 import {
-  ConfigurateMethodEnum,
+  ConfigurationMethodEnum,
   ModelStatusEnum,
 } from './declarations'
 import I18n from '@/context/i18n'
@@ -61,42 +61,55 @@ export const useLanguage = () => {
   return locale.replace('-', '_')
 }
 
-export const useProviderCrenditialsFormSchemasValue = (
+export const useProviderCredentialsAndLoadBalancing = (
   provider: string,
-  configurateMethod: ConfigurateMethodEnum,
+  configurationMethod: ConfigurationMethodEnum,
   configured?: boolean,
-  currentCustomConfigrationModelFixedFields?: CustomConfigrationModelFixedFields,
+  currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
 ) => {
-  const { data: predefinedFormSchemasValue } = useSWR(
-    (configurateMethod === ConfigurateMethodEnum.predefinedModel && configured)
+  const { data: predefinedFormSchemasValue, mutate: mutatePredefined } = useSWR(
+    (configurationMethod === ConfigurationMethodEnum.predefinedModel && configured)
       ? `/workspaces/current/model-providers/${provider}/credentials`
       : null,
     fetchModelProviderCredentials,
   )
-  const { data: customFormSchemasValue } = useSWR(
-    (configurateMethod === ConfigurateMethodEnum.customizableModel && currentCustomConfigrationModelFixedFields)
-      ? `/workspaces/current/model-providers/${provider}/models/credentials?model=${currentCustomConfigrationModelFixedFields?.__model_name}&model_type=${currentCustomConfigrationModelFixedFields?.__model_type}`
+  const { data: customFormSchemasValue, mutate: mutateCustomized } = useSWR(
+    (configurationMethod === ConfigurationMethodEnum.customizableModel && currentCustomConfigurationModelFixedFields)
+      ? `/workspaces/current/model-providers/${provider}/models/credentials?model=${currentCustomConfigurationModelFixedFields?.__model_name}&model_type=${currentCustomConfigurationModelFixedFields?.__model_type}`
       : null,
     fetchModelProviderCredentials,
   )
 
-  const value = useMemo(() => {
-    return configurateMethod === ConfigurateMethodEnum.predefinedModel
+  const credentials = useMemo(() => {
+    return configurationMethod === ConfigurationMethodEnum.predefinedModel
       ? predefinedFormSchemasValue?.credentials
       : customFormSchemasValue?.credentials
         ? {
           ...customFormSchemasValue?.credentials,
-          ...currentCustomConfigrationModelFixedFields,
+          ...currentCustomConfigurationModelFixedFields,
         }
         : undefined
   }, [
-    configurateMethod,
-    currentCustomConfigrationModelFixedFields,
+    configurationMethod,
+    currentCustomConfigurationModelFixedFields,
     customFormSchemasValue?.credentials,
     predefinedFormSchemasValue?.credentials,
   ])
 
-  return value
+  const mutate = useMemo(() => () => {
+    mutatePredefined()
+    mutateCustomized()
+  }, [mutateCustomized, mutatePredefined])
+
+  return {
+    credentials,
+    loadBalancing: (configurationMethod === ConfigurationMethodEnum.predefinedModel
+      ? predefinedFormSchemasValue
+      : customFormSchemasValue
+    )?.load_balancing,
+    mutate,
+  }
+  // as ([Record<string, string | boolean | undefined> | undefined, ModelLoadBalancingConfig | undefined])
 }
 
 export const useModelList = (type: ModelTypeEnum) => {

+ 14 - 14
web/app/components/header/account-setting/model-provider-page/index.tsx

@@ -4,11 +4,11 @@ import SystemModelSelector from './system-model-selector'
 import ProviderAddedCard, { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
 import ProviderCard from './provider-card'
 import type {
-  CustomConfigrationModelFixedFields,
+  CustomConfigurationModelFixedFields,
   ModelProvider,
 } from './declarations'
 import {
-  ConfigurateMethodEnum,
+  ConfigurationMethodEnum,
   CustomConfigurationStatusEnum,
   ModelTypeEnum,
 } from './declarations'
@@ -19,7 +19,7 @@ import {
 } from './hooks'
 import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
 import { useProviderContext } from '@/context/provider-context'
-import { useModalContext } from '@/context/modal-context'
+import { useModalContextSelector } from '@/context/modal-context'
 import { useEventEmitterContextContext } from '@/context/event-emitter'
 
 const ModelProviderPage = () => {
@@ -33,7 +33,7 @@ const ModelProviderPage = () => {
   const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
   const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
   const { modelProviders: providers } = useProviderContext()
-  const { setShowModelModal } = useModalContext()
+  const setShowModelModal = useModalContextSelector(state => state.setShowModelModal)
   const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
   const [configedProviders, notConfigedProviders] = useMemo(() => {
     const configedProviders: ModelProvider[] = []
@@ -57,32 +57,32 @@ const ModelProviderPage = () => {
 
   const handleOpenModal = (
     provider: ModelProvider,
-    configurateMethod: ConfigurateMethodEnum,
-    customConfigrationModelFixedFields?: CustomConfigrationModelFixedFields,
+    configurateMethod: ConfigurationMethodEnum,
+    CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
   ) => {
     setShowModelModal({
       payload: {
         currentProvider: provider,
-        currentConfigurateMethod: configurateMethod,
-        currentCustomConfigrationModelFixedFields: customConfigrationModelFixedFields,
+        currentConfigurationMethod: configurateMethod,
+        currentCustomConfigurationModelFixedFields: CustomConfigurationModelFixedFields,
       },
       onSaveCallback: () => {
         updateModelProviders()
 
-        if (configurateMethod === ConfigurateMethodEnum.predefinedModel) {
+        if (configurateMethod === ConfigurationMethodEnum.predefinedModel) {
           provider.supported_model_types.forEach((type) => {
             updateModelList(type)
           })
         }
 
-        if (configurateMethod === ConfigurateMethodEnum.customizableModel && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
+        if (configurateMethod === ConfigurationMethodEnum.customizableModel && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
           eventEmitter?.emit({
             type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
             payload: provider.provider,
           } as any)
 
-          if (customConfigrationModelFixedFields?.__model_type)
-            updateModelList(customConfigrationModelFixedFields?.__model_type)
+          if (CustomConfigurationModelFixedFields?.__model_type)
+            updateModelList(CustomConfigurationModelFixedFields?.__model_type)
         }
       },
     })
@@ -117,7 +117,7 @@ const ModelProviderPage = () => {
                 <ProviderAddedCard
                   key={provider.provider}
                   provider={provider}
-                  onOpenModal={(configurateMethod: ConfigurateMethodEnum, currentCustomConfigrationModelFixedFields?: CustomConfigrationModelFixedFields) => handleOpenModal(provider, configurateMethod, currentCustomConfigrationModelFixedFields)}
+                  onOpenModal={(configurateMethod: ConfigurationMethodEnum, currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => handleOpenModal(provider, configurateMethod, currentCustomConfigurationModelFixedFields)}
                 />
               ))
             }
@@ -137,7 +137,7 @@ const ModelProviderPage = () => {
                   <ProviderCard
                     key={provider.provider}
                     provider={provider}
-                    onOpenModal={(configurateMethod: ConfigurateMethodEnum) => handleOpenModal(provider, configurateMethod)}
+                    onOpenModal={(configurateMethod: ConfigurationMethodEnum) => handleOpenModal(provider, configurateMethod)}
                   />
                 ))
               }

+ 5 - 5
web/app/components/header/account-setting/model-provider-page/model-badge/index.tsx

@@ -1,3 +1,4 @@
+import classNames from 'classnames'
 import type { FC, ReactNode } from 'react'
 
 type ModelBadgeProps = {
@@ -9,11 +10,10 @@ const ModelBadge: FC<ModelBadgeProps> = ({
   children,
 }) => {
   return (
-    <div className={`
-      flex items-center px-1 h-[18px] rounded-[5px] border border-black/[0.08] bg-white/[0.48]
-      text-[10px] font-medium text-gray-500
-      ${className}
-    `}>
+    <div className={classNames(
+      'flex items-center px-1 h-[18px] rounded-[5px] border border-black/8 bg-white/[0.48] text-[10px] font-medium text-gray-500 cursor-default',
+      className,
+    )}>
       {children}
     </div>
   )

+ 98 - 33
web/app/components/header/account-setting/model-provider-page/model-modal/index.tsx

@@ -11,12 +11,14 @@ import type {
   CredentialFormSchema,
   CredentialFormSchemaRadio,
   CredentialFormSchemaSelect,
-  CustomConfigrationModelFixedFields,
+  CustomConfigurationModelFixedFields,
   FormValue,
+  ModelLoadBalancingConfig,
+  ModelLoadBalancingConfigEntry,
   ModelProvider,
 } from '../declarations'
 import {
-  ConfigurateMethodEnum,
+  ConfigurationMethodEnum,
   CustomConfigurationStatusEnum,
   FormTypeEnum,
 } from '../declarations'
@@ -28,11 +30,12 @@ import {
 } from '../utils'
 import {
   useLanguage,
-  useProviderCrenditialsFormSchemasValue,
+  useProviderCredentialsAndLoadBalancing,
 } from '../hooks'
 import ProviderIcon from '../provider-icon'
 import { useValidate } from '../../key-validator/hooks'
 import { ValidatedStatus } from '../../key-validator/declarations'
+import ModelLoadBalancingConfigs from '../provider-added-card/model-load-balancing-configs'
 import Form from './Form'
 import Button from '@/app/components/base/button'
 import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
@@ -47,8 +50,8 @@ import ConfirmCommon from '@/app/components/base/confirm/common'
 
 type ModelModalProps = {
   provider: ModelProvider
-  configurateMethod: ConfigurateMethodEnum
-  currentCustomConfigrationModelFixedFields?: CustomConfigrationModelFixedFields
+  configurateMethod: ConfigurationMethodEnum
+  currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
   onCancel: () => void
   onSave: () => void
 }
@@ -56,16 +59,20 @@ type ModelModalProps = {
 const ModelModal: FC<ModelModalProps> = ({
   provider,
   configurateMethod,
-  currentCustomConfigrationModelFixedFields,
+  currentCustomConfigurationModelFixedFields,
   onCancel,
   onSave,
 }) => {
-  const providerFormSchemaPredefined = configurateMethod === ConfigurateMethodEnum.predefinedModel
-  const formSchemasValue = useProviderCrenditialsFormSchemasValue(
+  const providerFormSchemaPredefined = configurateMethod === ConfigurationMethodEnum.predefinedModel
+  const {
+    credentials: formSchemasValue,
+    loadBalancing: originalConfig,
+    mutate,
+  } = useProviderCredentialsAndLoadBalancing(
     provider.provider,
     configurateMethod,
     providerFormSchemaPredefined && provider.custom_configuration.status === CustomConfigurationStatusEnum.active,
-    currentCustomConfigrationModelFixedFields,
+    currentCustomConfigurationModelFixedFields,
   )
   const isEditMode = !!formSchemasValue
   const { t } = useTranslation()
@@ -73,13 +80,29 @@ const ModelModal: FC<ModelModalProps> = ({
   const language = useLanguage()
   const [loading, setLoading] = useState(false)
   const [showConfirm, setShowConfirm] = useState(false)
+
+  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 && !draftConfig)
+      setDraftConfig(originalConfig)
+  }, [draftConfig, originalConfig])
+
   const formSchemas = useMemo(() => {
     return providerFormSchemaPredefined
       ? provider.provider_credential_schema.credential_form_schemas
       : [
         genModelTypeFormSchema(provider.supported_model_types),
         genModelNameFormSchema(provider.model_credential_schema?.model),
-        ...provider.model_credential_schema.credential_form_schemas,
+        ...(draftConfig?.enabled ? [] : provider.model_credential_schema.credential_form_schemas),
       ]
   }, [
     providerFormSchemaPredefined,
@@ -87,15 +110,14 @@ const ModelModal: FC<ModelModalProps> = ({
     provider.supported_model_types,
     provider.model_credential_schema?.credential_form_schemas,
     provider.model_credential_schema?.model,
+    draftConfig?.enabled,
   ])
   const [
     requiredFormSchemas,
-    secretFormSchemas,
     defaultFormSchemaValue,
     showOnVariableMap,
   ] = useMemo(() => {
     const requiredFormSchemas: CredentialFormSchema[] = []
-    const secretFormSchemas: CredentialFormSchema[] = []
     const defaultFormSchemaValue: Record<string, string | number> = {}
     const showOnVariableMap: Record<string, string[]> = {}
 
@@ -103,9 +125,6 @@ const ModelModal: FC<ModelModalProps> = ({
       if (formSchema.required)
         requiredFormSchemas.push(formSchema)
 
-      if (formSchema.type === FormTypeEnum.secretInput)
-        secretFormSchemas.push(formSchema)
-
       if (formSchema.default)
         defaultFormSchemaValue[formSchema.variable] = formSchema.default
 
@@ -136,22 +155,21 @@ const ModelModal: FC<ModelModalProps> = ({
 
     return [
       requiredFormSchemas,
-      secretFormSchemas,
       defaultFormSchemaValue,
       showOnVariableMap,
     ]
   }, [formSchemas])
-  const initialFormSchemasValue = useMemo(() => {
+  const initialFormSchemasValue: Record<string, string | number> = useMemo(() => {
     return {
       ...defaultFormSchemaValue,
       ...formSchemasValue,
-    }
+    } as unknown as Record<string, string | number>
   }, [formSchemasValue, defaultFormSchemaValue])
   const [value, setValue] = useState(initialFormSchemasValue)
   useEffect(() => {
     setValue(initialFormSchemasValue)
   }, [initialFormSchemasValue])
-  const [validate, validating, validatedStatusState] = useValidate(value)
+  const [_, validating, validatedStatusState] = useValidate(value)
   const filteredRequiredFormSchemas = requiredFormSchemas.filter((requiredFormSchema) => {
     if (requiredFormSchema.show_on.length && requiredFormSchema.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
       return true
@@ -161,32 +179,63 @@ const ModelModal: FC<ModelModalProps> = ({
 
     return false
   })
-  const getSecretValues = useCallback((v: FormValue) => {
-    return secretFormSchemas.reduce((prev, next) => {
-      if (v[next.variable] === initialFormSchemasValue[next.variable])
-        prev[next.variable] = '[__HIDDEN__]'
-
-      return prev
-    }, {} as Record<string, string>)
-  }, [initialFormSchemasValue, secretFormSchemas])
 
   const handleValueChange = (v: FormValue) => {
     setValue(v)
   }
+
+  const extendedSecretFormSchemas = useMemo(
+    () =>
+      (providerFormSchemaPredefined
+        ? provider.provider_credential_schema.credential_form_schemas
+        : [
+          genModelTypeFormSchema(provider.supported_model_types),
+          genModelNameFormSchema(provider.model_credential_schema?.model),
+          ...provider.model_credential_schema.credential_form_schemas,
+        ]).filter(({ type }) => type === FormTypeEnum.secretInput),
+    [
+      provider.model_credential_schema?.credential_form_schemas,
+      provider.model_credential_schema?.model,
+      provider.provider_credential_schema?.credential_form_schemas,
+      provider.supported_model_types,
+      providerFormSchemaPredefined,
+    ],
+  )
+
+  const encodeSecretValues = useCallback((v: FormValue) => {
+    const result = { ...v }
+    extendedSecretFormSchemas.forEach(({ variable }) => {
+      if (result[variable] === formSchemasValue?.[variable])
+        result[variable] = '[__HIDDEN__]'
+    })
+    return result
+  }, [extendedSecretFormSchemas, formSchemasValue])
+
+  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 handleSave = async () => {
     try {
       setLoading(true)
-
       const res = await saveCredentials(
         providerFormSchemaPredefined,
         provider.provider,
+        encodeSecretValues(value),
         {
-          ...value,
-          ...getSecretValues(value),
+          ...draftConfig,
+          enabled: Boolean(draftConfig?.enabled),
+          configs: draftConfig?.configs.map(encodeConfigEntrySecretValues) || [],
         },
       )
       if (res.result === 'success') {
         notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
+        mutate()
         onSave()
         onCancel()
       }
@@ -207,6 +256,7 @@ const ModelModal: FC<ModelModalProps> = ({
       )
       if (res.result === 'success') {
         notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
+        mutate()
         onSave()
         onCancel()
       }
@@ -217,7 +267,7 @@ const ModelModal: FC<ModelModalProps> = ({
   }
 
   const renderTitlePrefix = () => {
-    const prefix = configurateMethod === ConfigurateMethodEnum.customizableModel ? t('common.operation.add') : t('common.operation.setup')
+    const prefix = configurateMethod === ConfigurationMethodEnum.customizableModel ? t('common.operation.add') : t('common.operation.setup')
 
     return `${prefix} ${provider.label[language] || provider.label.en_US}`
   }
@@ -232,6 +282,7 @@ const ModelModal: FC<ModelModalProps> = ({
                 <div className='text-xl font-semibold text-gray-900'>{renderTitlePrefix()}</div>
                 <ProviderIcon provider={provider} />
               </div>
+
               <Form
                 value={value}
                 onChange={handleValueChange}
@@ -241,7 +292,17 @@ const ModelModal: FC<ModelModalProps> = ({
                 showOnVariableMap={showOnVariableMap}
                 isEditMode={isEditMode}
               />
-              <div className='sticky bottom-0 flex justify-between items-center py-6 flex-wrap gap-y-2 bg-white'>
+
+              <div className='mt-1 mb-4 border-t-[0.5px] border-t-gray-100' />
+              <ModelLoadBalancingConfigs withSwitch {...{
+                draftConfig,
+                setDraftConfig,
+                provider,
+                currentCustomConfigurationModelFixedFields,
+                configurationMethod: configurateMethod,
+              }} />
+
+              <div className='sticky bottom-0 flex justify-between items-center mt-2 -mx-2 pt-4 px-2 pb-6 flex-wrap gap-y-2 bg-white z-10'>
                 {
                   (provider.help && (provider.help.title || provider.help.url))
                     ? (
@@ -278,7 +339,11 @@ const ModelModal: FC<ModelModalProps> = ({
                     className='h-9 text-sm font-medium'
                     type='primary'
                     onClick={handleSave}
-                    disabled={loading || filteredRequiredFormSchemas.some(item => value[item.variable] === undefined)}
+                    disabled={
+                      loading
+                      || filteredRequiredFormSchemas.some(item => value[item.variable] === undefined)
+                      || (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2)
+                    }
                   >
                     {t('common.operation.save')}
                   </Button>

+ 344 - 0
web/app/components/header/account-setting/model-provider-page/model-modal/model-load-balancing-entry-modal.tsx

@@ -0,0 +1,344 @@
+import type { FC } from 'react'
+import {
+  memo,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import type {
+  CredentialFormSchema,
+  CredentialFormSchemaRadio,
+  CredentialFormSchemaSelect,
+  CredentialFormSchemaTextInput,
+  CustomConfigurationModelFixedFields,
+  FormValue,
+  ModelLoadBalancingConfigEntry,
+  ModelProvider,
+} from '../declarations'
+import {
+  ConfigurationMethodEnum,
+  FormTypeEnum,
+} from '../declarations'
+
+import {
+  useLanguage,
+} from '../hooks'
+import { useValidate } from '../../key-validator/hooks'
+import { ValidatedStatus } from '../../key-validator/declarations'
+import { validateLoadBalancingCredentials } from '../utils'
+import Form from './Form'
+import Button from '@/app/components/base/button'
+import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
+import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
+import { AlertCircle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+} from '@/app/components/base/portal-to-follow-elem'
+import { useToastContext } from '@/app/components/base/toast'
+import ConfirmCommon from '@/app/components/base/confirm/common'
+
+type ModelModalProps = {
+  provider: ModelProvider
+  configurationMethod: ConfigurationMethodEnum
+  currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
+  entry?: ModelLoadBalancingConfigEntry
+  onCancel: () => void
+  onSave: (entry: ModelLoadBalancingConfigEntry) => void
+  onRemove: () => void
+}
+
+const ModelLoadBalancingEntryModal: FC<ModelModalProps> = ({
+  provider,
+  configurationMethod,
+  currentCustomConfigurationModelFixedFields,
+  entry,
+  onCancel,
+  onSave,
+  onRemove,
+}) => {
+  const providerFormSchemaPredefined = configurationMethod === ConfigurationMethodEnum.predefinedModel
+  // const { credentials: formSchemasValue } = useProviderCredentialsAndLoadBalancing(
+  //   provider.provider,
+  //   configurationMethod,
+  //   providerFormSchemaPredefined && provider.custom_configuration.status === CustomConfigurationStatusEnum.active,
+  //   currentCustomConfigurationModelFixedFields,
+  // )
+  const isEditMode = !!entry
+  const { t } = useTranslation()
+  const { notify } = useToastContext()
+  const language = useLanguage()
+  const [loading, setLoading] = useState(false)
+  const [showConfirm, setShowConfirm] = useState(false)
+  const formSchemas = useMemo(() => {
+    return [
+      {
+        type: FormTypeEnum.textInput,
+        label: {
+          en_US: 'Config Name',
+          zh_Hans: '配置名称',
+        },
+        variable: 'name',
+        required: true,
+        show_on: [],
+        placeholder: {
+          en_US: 'Enter your Config Name here',
+          zh_Hans: '输入配置名称',
+        },
+      } as CredentialFormSchemaTextInput,
+      ...(
+        providerFormSchemaPredefined
+          ? provider.provider_credential_schema.credential_form_schemas
+          : provider.model_credential_schema.credential_form_schemas
+      ),
+    ]
+  }, [
+    providerFormSchemaPredefined,
+    provider.provider_credential_schema?.credential_form_schemas,
+    provider.model_credential_schema?.credential_form_schemas,
+  ])
+
+  const [
+    requiredFormSchemas,
+    secretFormSchemas,
+    defaultFormSchemaValue,
+    showOnVariableMap,
+  ] = useMemo(() => {
+    const requiredFormSchemas: CredentialFormSchema[] = []
+    const secretFormSchemas: CredentialFormSchema[] = []
+    const defaultFormSchemaValue: Record<string, string | number> = {}
+    const showOnVariableMap: Record<string, string[]> = {}
+
+    formSchemas.forEach((formSchema) => {
+      if (formSchema.required)
+        requiredFormSchemas.push(formSchema)
+
+      if (formSchema.type === FormTypeEnum.secretInput)
+        secretFormSchemas.push(formSchema)
+
+      if (formSchema.default)
+        defaultFormSchemaValue[formSchema.variable] = formSchema.default
+
+      if (formSchema.show_on.length) {
+        formSchema.show_on.forEach((showOnItem) => {
+          if (!showOnVariableMap[showOnItem.variable])
+            showOnVariableMap[showOnItem.variable] = []
+
+          if (!showOnVariableMap[showOnItem.variable].includes(formSchema.variable))
+            showOnVariableMap[showOnItem.variable].push(formSchema.variable)
+        })
+      }
+
+      if (formSchema.type === FormTypeEnum.select || formSchema.type === FormTypeEnum.radio) {
+        (formSchema as (CredentialFormSchemaRadio | CredentialFormSchemaSelect)).options.forEach((option) => {
+          if (option.show_on.length) {
+            option.show_on.forEach((showOnItem) => {
+              if (!showOnVariableMap[showOnItem.variable])
+                showOnVariableMap[showOnItem.variable] = []
+
+              if (!showOnVariableMap[showOnItem.variable].includes(formSchema.variable))
+                showOnVariableMap[showOnItem.variable].push(formSchema.variable)
+            })
+          }
+        })
+      }
+    })
+
+    return [
+      requiredFormSchemas,
+      secretFormSchemas,
+      defaultFormSchemaValue,
+      showOnVariableMap,
+    ]
+  }, [formSchemas])
+  const [initialValue, setInitialValue] = useState<ModelLoadBalancingConfigEntry['credentials']>()
+  useEffect(() => {
+    if (entry && !initialValue) {
+      setInitialValue({
+        ...defaultFormSchemaValue,
+        ...entry.credentials,
+        id: entry.id,
+        name: entry.name,
+      } as Record<string, string | undefined | boolean>)
+    }
+  }, [entry, defaultFormSchemaValue, initialValue])
+  const formSchemasValue = useMemo(() => ({
+    ...currentCustomConfigurationModelFixedFields,
+    ...initialValue,
+  }), [currentCustomConfigurationModelFixedFields, initialValue])
+  const initialFormSchemasValue: Record<string, string | number> = useMemo(() => {
+    return {
+      ...defaultFormSchemaValue,
+      ...formSchemasValue,
+    } as Record<string, string | number>
+  }, [formSchemasValue, defaultFormSchemaValue])
+  const [value, setValue] = useState(initialFormSchemasValue)
+  useEffect(() => {
+    setValue(initialFormSchemasValue)
+  }, [initialFormSchemasValue])
+  const [_, validating, validatedStatusState] = useValidate(value)
+  const filteredRequiredFormSchemas = requiredFormSchemas.filter((requiredFormSchema) => {
+    if (requiredFormSchema.show_on.length && requiredFormSchema.show_on.every(showOnItem => value[showOnItem.variable] === showOnItem.value))
+      return true
+
+    if (!requiredFormSchema.show_on.length)
+      return true
+
+    return false
+  })
+  const getSecretValues = useCallback((v: FormValue) => {
+    return secretFormSchemas.reduce((prev, next) => {
+      if (v[next.variable] === initialFormSchemasValue[next.variable])
+        prev[next.variable] = '[__HIDDEN__]'
+
+      return prev
+    }, {} as Record<string, string>)
+  }, [initialFormSchemasValue, secretFormSchemas])
+
+  // const handleValueChange = ({ __model_type, __model_name, ...v }: FormValue) => {
+  const handleValueChange = (v: FormValue) => {
+    setValue(v)
+  }
+  const handleSave = async () => {
+    try {
+      setLoading(true)
+
+      const res = await validateLoadBalancingCredentials(
+        providerFormSchemaPredefined,
+        provider.provider,
+        {
+          ...value,
+          ...getSecretValues(value),
+        },
+      )
+      if (res.status === ValidatedStatus.Success) {
+        // notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
+        const { __model_type, __model_name, name, ...credentials } = value
+        onSave({
+          ...(entry || {}),
+          name: name as string,
+          credentials: credentials as Record<string, string | boolean | undefined>,
+        })
+        //   onCancel()
+      }
+      else {
+        notify({ type: 'error', message: res.message || '' })
+      }
+    }
+    finally {
+      setLoading(false)
+    }
+  }
+
+  const handleRemove = () => {
+    onRemove?.()
+  }
+
+  return (
+    <PortalToFollowElem open>
+      <PortalToFollowElemContent className='w-full h-full z-[60]'>
+        <div className='fixed inset-0 flex items-center justify-center bg-black/[.25]'>
+          <div className='mx-2 w-[640px] max-h-[calc(100vh-120px)] bg-white shadow-xl rounded-2xl overflow-y-auto'>
+            <div className='px-8 pt-8'>
+              <div className='flex justify-between items-center mb-2'>
+                <div className='text-xl font-semibold text-gray-900'>{t(isEditMode ? 'common.modelProvider.editConfig' : 'common.modelProvider.addConfig')}</div>
+              </div>
+              <Form
+                value={value}
+                onChange={handleValueChange}
+                formSchemas={formSchemas}
+                validating={validating}
+                validatedSuccess={validatedStatusState.status === ValidatedStatus.Success}
+                showOnVariableMap={showOnVariableMap}
+                isEditMode={isEditMode}
+              />
+              <div className='sticky bottom-0 flex justify-between items-center py-6 flex-wrap gap-y-2 bg-white'>
+                {
+                  (provider.help && (provider.help.title || provider.help.url))
+                    ? (
+                      <a
+                        href={provider.help?.url[language] || provider.help?.url.en_US}
+                        target='_blank' rel='noopener noreferrer'
+                        className='inline-flex items-center text-xs text-primary-600'
+                        onClick={e => !provider.help.url && e.preventDefault()}
+                      >
+                        {provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
+                        <LinkExternal02 className='ml-1 w-3 h-3' />
+                      </a>
+                    )
+                    : <div />
+                }
+                <div>
+                  {
+                    isEditMode && (
+                      <Button
+                        className='mr-2 h-9 text-sm font-medium text-[#D92D20]'
+                        onClick={() => setShowConfirm(true)}
+                      >
+                        {t('common.operation.remove')}
+                      </Button>
+                    )
+                  }
+                  <Button
+                    className='mr-2 h-9 text-sm font-medium text-gray-700'
+                    onClick={onCancel}
+                  >
+                    {t('common.operation.cancel')}
+                  </Button>
+                  <Button
+                    className='h-9 text-sm font-medium'
+                    type='primary'
+                    onClick={handleSave}
+                    disabled={loading || filteredRequiredFormSchemas.some(item => value[item.variable] === undefined)}
+                  >
+                    {t('common.operation.save')}
+                  </Button>
+                </div>
+              </div>
+            </div>
+            <div className='border-t-[0.5px] border-t-black/5'>
+              {
+                (validatedStatusState.status === ValidatedStatus.Error && validatedStatusState.message)
+                  ? (
+                    <div className='flex px-[10px] py-3 bg-[#FEF3F2] text-xs text-[#D92D20]'>
+                      <AlertCircle className='mt-[1px] mr-2 w-[14px] h-[14px]' />
+                      {validatedStatusState.message}
+                    </div>
+                  )
+                  : (
+                    <div className='flex justify-center items-center py-3 bg-gray-50 text-xs text-gray-500'>
+                      <Lock01 className='mr-1 w-3 h-3 text-gray-500' />
+                      {t('common.modelProvider.encrypted.front')}
+                      <a
+                        className='text-primary-600 mx-1'
+                        target='_blank' rel='noopener noreferrer'
+                        href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
+                      >
+                        PKCS1_OAEP
+                      </a>
+                      {t('common.modelProvider.encrypted.back')}
+                    </div>
+                  )
+              }
+            </div>
+          </div>
+          {
+            showConfirm && (
+              <ConfirmCommon
+                title={t('common.modelProvider.confirmDelete')}
+                isShow={showConfirm}
+                onCancel={() => setShowConfirm(false)}
+                onConfirm={handleRemove}
+                confirmWrapperClassName='z-[70]'
+              />
+            )
+          }
+        </div>
+      </PortalToFollowElemContent>
+    </PortalToFollowElem>
+  )
+}
+
+export default memo(ModelLoadBalancingEntryModal)

+ 10 - 7
web/app/components/header/account-setting/model-provider-page/model-name/index.tsx

@@ -1,4 +1,5 @@
-import type { FC } from 'react'
+import type { FC, PropsWithChildren } from 'react'
+import classNames from 'classnames'
 import {
   modelTypeFormat,
   sizeFormat,
@@ -8,7 +9,7 @@ import type { ModelItem } from '../declarations'
 import ModelBadge from '../model-badge'
 import FeatureIcon from '../model-selector/feature-icon'
 
-type ModelNameProps = {
+type ModelNameProps = PropsWithChildren<{
   modelItem: ModelItem
   className?: string
   showModelType?: boolean
@@ -18,7 +19,7 @@ type ModelNameProps = {
   showFeatures?: boolean
   featuresClassName?: string
   showContextSize?: boolean
-}
+}>
 const ModelName: FC<ModelNameProps> = ({
   modelItem,
   className,
@@ -29,6 +30,7 @@ const ModelName: FC<ModelNameProps> = ({
   showFeatures,
   featuresClassName,
   showContextSize,
+  children,
 }) => {
   const language = useLanguage()
 
@@ -42,21 +44,21 @@ const ModelName: FC<ModelNameProps> = ({
       `}
     >
       <div
-        className='mr-1 truncate'
+        className='truncate'
         title={modelItem.label[language] || modelItem.label.en_US}
       >
         {modelItem.label[language] || modelItem.label.en_US}
       </div>
       {
         showModelType && modelItem.model_type && (
-          <ModelBadge className={`mr-0.5 ${modelTypeClassName}`}>
+          <ModelBadge className={classNames('ml-1', modelTypeClassName)}>
             {modelTypeFormat(modelItem.model_type)}
           </ModelBadge>
         )
       }
       {
         modelItem.model_properties.mode && showMode && (
-          <ModelBadge className={`mr-0.5 ${modeClassName}`}>
+          <ModelBadge className={classNames('ml-1', modeClassName)}>
             {(modelItem.model_properties.mode as string).toLocaleUpperCase()}
           </ModelBadge>
         )
@@ -72,11 +74,12 @@ const ModelName: FC<ModelNameProps> = ({
       }
       {
         showContextSize && modelItem.model_properties.context_size && (
-          <ModelBadge>
+          <ModelBadge className='ml-1'>
             {sizeFormat(modelItem.model_properties.context_size as number)}
           </ModelBadge>
         )
       }
+      {children}
     </div>
   )
 }

+ 2 - 2
web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx

@@ -86,7 +86,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
   isInWorkflow,
 }) => {
   const { t } = useTranslation()
-  const { hasSettedApiKey } = useProviderContext()
+  const { isAPIKeySet } = useProviderContext()
   const [open, setOpen] = useState(false)
   const { data: parameterRulesData, isLoading } = useSWR((provider && modelId) ? `/workspaces/current/model-providers/${provider}/models/parameter-rules?model=${modelId}` : null, fetchModelParameterRules)
   const {
@@ -99,7 +99,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
 
   const hasDeprecated = !currentProvider || !currentModel
   const modelDisabled = currentModel?.status !== ModelStatusEnum.active
-  const disabled = !hasSettedApiKey || hasDeprecated || modelDisabled
+  const disabled = !isAPIKeySet || hasDeprecated || modelDisabled
 
   const parameterRules: ModelParameterRule[] = useMemo(() => {
     return parameterRulesData?.data || []

+ 2 - 2
web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx

@@ -13,7 +13,7 @@ import {
 import ModelIcon from '../model-icon'
 import ModelName from '../model-name'
 import {
-  ConfigurateMethodEnum,
+  ConfigurationMethodEnum,
   MODEL_STATUS_TEXT,
   ModelStatusEnum,
 } from '../declarations'
@@ -49,7 +49,7 @@ const PopupItem: FC<PopupItemProps> = ({
     setShowModelModal({
       payload: {
         currentProvider,
-        currentConfigurateMethod: ConfigurateMethodEnum.predefinedModel,
+        currentConfigurationMethod: ConfigurationMethodEnum.predefinedModel,
       },
       onSaveCallback: () => {
         updateModelProviders()

+ 64 - 0
web/app/components/header/account-setting/model-provider-page/provider-added-card/cooldown-timer.tsx

@@ -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 TooltipPlus from '@/app/components/base/tooltip-plus'
+
+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<NodeJS.Timeout>()
+  const clearCountdown = useCallback(() => {
+    if (countdownTimeout.current) {
+      clearTimeout(countdownTimeout.current)
+      countdownTimeout.current = undefined
+    }
+  }, [])
+
+  const onFinishRef = useLatest(onFinish)
+
+  const countdown = useCallback(() => {
+    clearCountdown()
+    countdownTimeout.current = 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
+    ? (
+      <TooltipPlus popupContent={t('common.modelProvider.apiKeyRateLimit', { seconds: displayTime })}>
+        <SimplePieChart percentage={Math.round(displayTime / 60 * 100)} className='w-3 h-3' />
+      </TooltipPlus>
+    )
+    : null
+}
+
+export default memo(CooldownTimer)

+ 2 - 2
web/app/components/header/account-setting/model-provider-page/provider-added-card/credential-panel.tsx

@@ -2,7 +2,7 @@ import type { FC } from 'react'
 import { useTranslation } from 'react-i18next'
 import type { ModelProvider } from '../declarations'
 import {
-  ConfigurateMethodEnum,
+  ConfigurationMethodEnum,
   CustomConfigurationStatusEnum,
   PreferredProviderTypeEnum,
 } from '../declarations'
@@ -51,7 +51,7 @@ const CredentialPanel: FC<CredentialPanelProps> = ({
       updateModelProviders()
 
       configurateMethods.forEach((method) => {
-        if (method === ConfigurateMethodEnum.predefinedModel)
+        if (method === ConfigurationMethodEnum.predefinedModel)
           provider.supported_model_types.forEach(modelType => updateModelList(modelType))
       })
 

+ 10 - 9
web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx

@@ -2,11 +2,11 @@ import type { FC } from 'react'
 import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import type {
-  CustomConfigrationModelFixedFields,
+  CustomConfigurationModelFixedFields,
   ModelItem,
   ModelProvider,
 } from '../declarations'
-import { ConfigurateMethodEnum } from '../declarations'
+import { ConfigurationMethodEnum } from '../declarations'
 import {
   DEFAULT_BACKGROUND_COLOR,
   MODEL_PROVIDER_QUOTA_GET_PAID,
@@ -27,7 +27,7 @@ import { IS_CE_EDITION } from '@/config'
 export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
 type ProviderAddedCardProps = {
   provider: ModelProvider
-  onOpenModal: (configurateMethod: ConfigurateMethodEnum, currentCustomConfigrationModelFixedFields?: CustomConfigrationModelFixedFields) => void
+  onOpenModal: (configurationMethod: ConfigurationMethodEnum, currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => void
 }
 const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
   provider,
@@ -39,7 +39,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
   const [loading, setLoading] = useState(false)
   const [collapsed, setCollapsed] = useState(true)
   const [modelList, setModelList] = useState<ModelItem[]>([])
-  const configurateMethods = provider.configurate_methods.filter(method => method !== ConfigurateMethodEnum.fetchFromRemote)
+  const configurationMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
   const systemConfig = provider.system_configuration
   const hasModelList = fetched && !!modelList.length
   const showQuota = systemConfig.enabled && [...MODEL_PROVIDER_QUOTA_GET_PAID].includes(provider.provider) && !IS_CE_EDITION
@@ -101,9 +101,9 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
           )
         }
         {
-          configurateMethods.includes(ConfigurateMethodEnum.predefinedModel) && (
+          configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && (
             <CredentialPanel
-              onSetup={() => onOpenModal(ConfigurateMethodEnum.predefinedModel)}
+              onSetup={() => onOpenModal(ConfigurationMethodEnum.predefinedModel)}
               provider={provider}
             />
           )
@@ -136,9 +136,9 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
               }
             </div>
             {
-              configurateMethods.includes(ConfigurateMethodEnum.customizableModel) && (
+              configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && (
                 <AddModelButton
-                  onClick={() => onOpenModal(ConfigurateMethodEnum.customizableModel)}
+                  onClick={() => onOpenModal(ConfigurationMethodEnum.customizableModel)}
                   className='hidden group-hover:flex group-hover:text-primary-600'
                 />
               )
@@ -152,7 +152,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
             provider={provider}
             models={modelList}
             onCollapse={() => setCollapsed(true)}
-            onConfig={currentCustomConfigrationModelFixedFields => onOpenModal(ConfigurateMethodEnum.customizableModel, currentCustomConfigrationModelFixedFields)}
+            onConfig={currentCustomConfigurationModelFixedFields => onOpenModal(ConfigurationMethodEnum.customizableModel, currentCustomConfigurationModelFixedFields)}
+            onChange={(provider: string) => getModelList(provider)}
           />
         )
       }

+ 119 - 0
web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx

@@ -0,0 +1,119 @@
+import { memo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
+import { useDebounceFn } from 'ahooks'
+import type { CustomConfigurationModelFixedFields, ModelItem, ModelProvider } from '../declarations'
+import { ConfigurationMethodEnum, ModelStatusEnum } from '../declarations'
+import ModelBadge from '../model-badge'
+import ModelIcon from '../model-icon'
+import ModelName from '../model-name'
+import Button from '@/app/components/base/button'
+import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
+import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
+import Switch from '@/app/components/base/switch'
+import TooltipPlus from '@/app/components/base/tooltip-plus'
+import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
+import { disableModel, enableModel } from '@/service/common'
+import { Plan } from '@/app/components/billing/type'
+
+export type ModelListItemProps = {
+  model: ModelItem
+  provider: ModelProvider
+  isConfigurable: boolean
+  onConfig: (currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => void
+  onModifyLoadBalancing?: (model: ModelItem) => void
+}
+
+const ModelListItem = ({ model, provider, isConfigurable, onConfig, onModifyLoadBalancing }: ModelListItemProps) => {
+  const { t } = useTranslation()
+  const { plan } = useProviderContext()
+  const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
+
+  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}
+      className={classNames(
+        'group flex items-center pl-2 pr-2.5 h-8 rounded-lg',
+        isConfigurable && 'hover:bg-gray-50',
+        model.deprecated && 'opacity-60',
+      )}
+    >
+      <ModelIcon
+        className='shrink-0 mr-2'
+        provider={provider}
+        modelName={model.model}
+      />
+      <ModelName
+        className='grow text-sm font-normal text-gray-900'
+        modelItem={model}
+        showModelType
+        showMode
+        showContextSize
+      >
+        {modelLoadBalancingEnabled && !model.deprecated && model.load_balancing_enabled && (
+          <ModelBadge className='ml-1 uppercase text-indigo-600 border-indigo-300'>
+            <Balance className='w-3 h-3 mr-0.5' />
+            {t('common.modelProvider.loadBalancingHeadline')}
+          </ModelBadge>
+        )}
+      </ModelName>
+      <div className='shrink-0 flex items-center'>
+        {
+          model.fetch_from === ConfigurationMethodEnum.customizableModel
+            ? (
+              <Button
+                className='hidden group-hover:flex py-0 h-7 text-xs font-medium text-gray-700'
+                onClick={() => onConfig({ __model_name: model.model, __model_type: model.model_type })}
+              >
+                <Settings01 className='mr-[5px] w-3.5 h-3.5' />
+                {t('common.modelProvider.config')}
+              </Button>
+            )
+            : ((modelLoadBalancingEnabled || plan.type === Plan.sandbox) && !model.deprecated && [ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status))
+              ? (
+                <Button
+                  className='opacity-0 group-hover:opacity-100 px-3 h-[28px] text-xs text-gray-700 rounded-md transition-opacity'
+                  onClick={() => onModifyLoadBalancing?.(model)}
+                >
+                  <Balance className='mr-1 w-[14px] h-[14px]' />
+                  {t('common.modelProvider.configLoadBalancing')}
+                </Button>
+              )
+              : null
+        }
+        {
+          model.deprecated
+            ? (
+              <TooltipPlus popupContent={<span className='font-semibold'>{t('common.modelProvider.modelHasBeenDeprecated')}</span>} offset={{ mainAxis: 4 }}>
+                <Switch defaultValue={false} disabled size='md' />
+              </TooltipPlus>
+            )
+            : (
+              <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)

+ 33 - 57
web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx

@@ -1,41 +1,48 @@
 import type { FC } from 'react'
+import { useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import type {
-  CustomConfigrationModelFixedFields,
+  CustomConfigurationModelFixedFields,
   ModelItem,
   ModelProvider,
 } from '../declarations'
 import {
-  ConfigurateMethodEnum,
-  ModelStatusEnum,
+  ConfigurationMethodEnum,
 } from '../declarations'
-import { useLanguage } from '../hooks'
-import ModelIcon from '../model-icon'
-import ModelName from '../model-name'
 // import Tab from './tab'
 import AddModelButton from './add-model-button'
-import Indicator from '@/app/components/header/indicator'
-import { Settings01 } from '@/app/components/base/icons/src/vender/line/general'
+import ModelListItem from './model-list-item'
 import { ChevronDownDouble } from '@/app/components/base/icons/src/vender/line/arrows'
-import Button from '@/app/components/base/button'
+import { useModalContextSelector } from '@/context/modal-context'
 
 type ModelListProps = {
   provider: ModelProvider
   models: ModelItem[]
   onCollapse: () => void
-  onConfig: (currentCustomConfigrationModelFixedFields?: CustomConfigrationModelFixedFields) => void
+  onConfig: (currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields) => void
+  onChange?: (provider: string) => void
 }
 const ModelList: FC<ModelListProps> = ({
   provider,
   models,
   onCollapse,
   onConfig,
+  onChange,
 }) => {
   const { t } = useTranslation()
-  const language = useLanguage()
-  const configurateMethods = provider.configurate_methods.filter(method => method !== ConfigurateMethodEnum.fetchFromRemote)
-  const canCustomConfig = configurateMethods.includes(ConfigurateMethodEnum.customizableModel)
-  // const canSystemConfig = configurateMethods.includes(ConfigurateMethodEnum.predefinedModel)
+  const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
+  const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel)
+
+  const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal)
+  const onModifyLoadBalancing = useCallback((model: ModelItem) => {
+    setShowModelLoadBalancingModal({
+      provider,
+      model: model!,
+      open: !!model,
+      onClose: () => setShowModelLoadBalancingModal(null),
+      onSave: onChange,
+    })
+  }, [onChange, provider, setShowModelLoadBalancingModal])
 
   return (
     <div className='px-2 pb-2 rounded-b-xl'>
@@ -46,10 +53,7 @@ const ModelList: FC<ModelListProps> = ({
               {t('common.modelProvider.modelsNum', { num: models.length })}
             </span>
             <span
-              className={`
-                hidden group-hover:inline-flex items-center pl-1 pr-1.5 h-6 bg-gray-50 
-                text-xs font-medium text-gray-500 cursor-pointer rounded-lg
-              `}
+              className='hidden group-hover:inline-flex items-center pl-1 pr-1.5 h-6 text-xs font-medium text-gray-500 bg-gray-50 cursor-pointer rounded-lg'
               onClick={() => onCollapse()}
             >
               <ChevronDownDouble className='mr-0.5 w-3 h-3 rotate-180' />
@@ -57,14 +61,14 @@ const ModelList: FC<ModelListProps> = ({
             </span>
           </span>
           {/* {
-            canCustomConfig && canSystemConfig && (
+            isConfigurable && canSystemConfig && (
               <span className='flex items-center'>
                 <Tab active='all' onSelect={() => {}} />
               </span>
             )
           } */}
           {
-            canCustomConfig && (
+            isConfigurable && (
               <div className='grow flex justify-end'>
                 <AddModelButton onClick={() => onConfig()} />
               </div>
@@ -73,44 +77,16 @@ const ModelList: FC<ModelListProps> = ({
         </div>
         {
           models.map(model => (
-            <div
+            <ModelListItem
               key={model.model}
-              className={`
-                group flex items-center pl-2 pr-2.5 h-8 rounded-lg
-                ${canCustomConfig && 'hover:bg-gray-50'}
-                ${model.deprecated && 'opacity-60'}
-              `}
-            >
-              <ModelIcon
-                className='shrink-0 mr-2'
-                provider={provider}
-                modelName={model.model}
-              />
-              <ModelName
-                className='grow text-sm font-normal text-gray-900'
-                modelItem={model}
-                showModelType
-                showMode
-                showContextSize
-              />
-              <div className='shrink-0 flex items-center'>
-                {
-                  model.fetch_from === ConfigurateMethodEnum.customizableModel && (
-                    <Button
-                      className='hidden group-hover:flex py-0 h-7 text-xs font-medium text-gray-700'
-                      onClick={() => onConfig({ __model_name: model.model, __model_type: model.model_type })}
-                    >
-                      <Settings01 className='mr-[5px] w-3.5 h-3.5' />
-                      {t('common.modelProvider.config')}
-                    </Button>
-                  )
-                }
-                <Indicator
-                  className='ml-2.5'
-                  color={model.status === ModelStatusEnum.active ? 'green' : 'gray'}
-                />
-              </div>
-            </div>
+              {...{
+                model,
+                provider,
+                isConfigurable,
+                onConfig,
+                onModifyLoadBalancing,
+              }}
+            />
           ))
         }
       </div>

+ 269 - 0
web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx

@@ -0,0 +1,269 @@
+import classNames from 'classnames'
+import type { Dispatch, SetStateAction } from 'react'
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import type { ConfigurationMethodEnum, CustomConfigurationModelFixedFields, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations'
+import Indicator from '../../../indicator'
+import CooldownTimer from './cooldown-timer'
+import TooltipPlus from '@/app/components/base/tooltip-plus'
+import Switch from '@/app/components/base/switch'
+import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
+import { Edit02, HelpCircle, Plus02, Trash03 } from '@/app/components/base/icons/src/vender/line/general'
+import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
+import { useModalContextSelector } from '@/context/modal-context'
+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'
+
+export type ModelLoadBalancingConfigsProps = {
+  draftConfig?: ModelLoadBalancingConfig
+  setDraftConfig: Dispatch<SetStateAction<ModelLoadBalancingConfig | undefined>>
+  provider: ModelProvider
+  configurationMethod: ConfigurationMethodEnum
+  currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
+  withSwitch?: boolean
+  className?: string
+}
+
+const ModelLoadBalancingConfigs = ({
+  draftConfig,
+  setDraftConfig,
+  provider,
+  configurationMethod,
+  currentCustomConfigurationModelFixedFields,
+  withSwitch = false,
+  className,
+}: ModelLoadBalancingConfigsProps) => {
+  const { t } = useTranslation()
+  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 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 setShowModelLoadBalancingEntryModal = useModalContextSelector(state => state.setShowModelLoadBalancingEntryModal)
+
+  const toggleEntryModal = useCallback((index?: number, entry?: ModelLoadBalancingConfigEntry) => {
+    setShowModelLoadBalancingEntryModal({
+      payload: {
+        currentProvider: provider,
+        currentConfigurationMethod: configurationMethod,
+        currentCustomConfigurationModelFixedFields,
+        entry,
+        index,
+      },
+      onSaveCallback: ({ entry: result }) => {
+        if (entry) {
+          // edit
+          setDraftConfig(prev => ({
+            ...prev,
+            enabled: !!prev?.enabled,
+            configs: prev?.configs.map((config, i) => i === index ? result! : config) || [],
+          }))
+        }
+        else {
+          // add
+          setDraftConfig(prev => ({
+            ...prev,
+            enabled: !!prev?.enabled,
+            configs: (prev?.configs || []).concat([{ ...result!, enabled: true }]),
+          }))
+        }
+      },
+      onRemoveCallback: ({ index }) => {
+        if (index !== undefined && (draftConfig?.configs?.length ?? 0) > index) {
+          setDraftConfig(prev => ({
+            ...prev,
+            enabled: !!prev?.enabled,
+            configs: prev?.configs.filter((_, i) => i !== index) || [],
+          }))
+        }
+      },
+    })
+  }, [
+    configurationMethod,
+    currentCustomConfigurationModelFixedFields,
+    draftConfig?.configs?.length,
+    provider,
+    setDraftConfig,
+    setShowModelLoadBalancingEntryModal,
+  ])
+
+  const clearCountdown = useCallback((index: number) => {
+    updateConfigEntry(index, ({ ttl: _, ...entry }) => {
+      return {
+        ...entry,
+        in_cooldown: false,
+      }
+    })
+  }, [updateConfigEntry])
+
+  if (!draftConfig)
+    return null
+
+  return (
+    <>
+      <div
+        className={classNames(
+          'min-h-16 bg-gray-50 border rounded-xl transition-colors',
+          (withSwitch || !draftConfig.enabled) ? 'border-gray-200' : 'border-primary-400',
+          (withSwitch || draftConfig.enabled) ? 'cursor-default' : 'cursor-pointer',
+          className,
+        )}
+        onClick={(!withSwitch && !draftConfig.enabled) ? () => toggleModalBalancing(true) : undefined}
+      >
+        <div className='flex items-center px-[15px] py-3 gap-2 select-none'>
+          <div className='grow-0 shrink-0 flex items-center justify-center w-8 h-8 text-primary-600 bg-indigo-50 border border-indigo-100 rounded-lg'>
+            <Balance className='w-4 h-4' />
+          </div>
+          <div className='grow'>
+            <div className='flex items-center gap-1 text-sm'>
+              {t('common.modelProvider.loadBalancing')}
+              <TooltipPlus popupContent={t('common.modelProvider.loadBalancingInfo')} popupClassName='max-w-[300px]'>
+                <HelpCircle className='w-3 h-3 text-gray-400' />
+              </TooltipPlus>
+            </div>
+            <div className='text-xs text-gray-500'>{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'>
+            {draftConfig.configs.map((config, index) => {
+              const isProviderManaged = config.name === '__inherit__'
+              return (
+                <div key={config.id || index} className='group flex items-center px-3 h-10 bg-white border border-gray-200 rounded-lg shadow-xs'>
+                  <div className='grow flex items-center'>
+                    <div className='flex items-center justify-center mr-2 w-3 h-3'>
+                      {(config.in_cooldown && Boolean(config.ttl))
+                        ? (
+                          <CooldownTimer secondsRemaining={config.ttl} onFinish={() => clearCountdown(index)} />
+                        )
+                        : (
+                          <TooltipPlus popupContent={t('common.modelProvider.apiKeyStatusNormal')}>
+                            <Indicator color='green' />
+                          </TooltipPlus>
+                        )}
+                    </div>
+                    <div className='text-[13px] mr-1'>
+                      {isProviderManaged ? t('common.modelProvider.defaultConfig') : config.name}
+                    </div>
+                    {isProviderManaged && (
+                      <span className='px-1 text-2xs uppercase text-gray-500 border border-black/8 rounded-[5px]'>{t('common.modelProvider.providerManaged')}</span>
+                    )}
+                  </div>
+                  <div className='flex items-center gap-1'>
+                    {!isProviderManaged && (
+                      <>
+                        <div className='flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100'>
+                          <span
+                            className='flex items-center justify-center w-8 h-8 text-gray-500 bg-white rounded-lg transition-colors cursor-pointer hover:bg-black/5'
+                            onClick={() => toggleEntryModal(index, config)}
+                          >
+                            <Edit02 className='w-4 h-4' />
+                          </span>
+                          <span
+                            className='flex items-center justify-center w-8 h-8 text-gray-500 bg-white rounded-lg transition-colors cursor-pointer hover:bg-black/5'
+                            onClick={() => updateConfigEntry(index, () => undefined)}
+                          >
+                            <Trash03 className='w-4 h-4' />
+                          </span>
+                          <span className='mr-2 h-3 border-r border-r-gray-100' />
+                        </div>
+                      </>
+                    )}
+                    <Switch
+                      defaultValue={Boolean(config.enabled)}
+                      size='md'
+                      className='justify-self-end'
+                      onChange={value => toggleConfigEntryEnabled(index, value)}
+                    />
+                  </div>
+                </div>
+              )
+            })}
+
+            <div
+              className='flex items-center px-3 mt-1 h-8 text-[13px] font-medium text-primary-600'
+              onClick={() => toggleEntryModal()}
+            >
+              <div className='flex items-center cursor-pointer'>
+                <Plus02 className='mr-2 w-3 h-3' />{t('common.modelProvider.addConfig')}
+              </div>
+            </div>
+          </div>
+        )}
+        {
+          draftConfig.enabled && draftConfig.configs.length < 2 && (
+            <div className='flex items-center px-6 h-[34px] text-xs text-gray-700 bg-black/2 border-t border-t-black/5'>
+              <AlertTriangle className='mr-1 w-3 h-3 text-[#f79009]' />
+              {t('common.modelProvider.loadBalancingLeastKeyWarning')}
+            </div>
+          )
+        }
+      </div>
+
+      {!modelLoadBalancingEnabled && !IS_CE_EDITION && (
+        <GridMask canvasClassName='!rounded-xl'>
+          <div className='flex items-center justify-between mt-2 px-4 h-14 border-[0.5px] border-gray-200 rounded-xl shadow-md'>
+            <div
+              className={classNames('text-sm font-semibold leading-tight text-gradient', s.textGradient)}
+            >
+              {t('common.modelProvider.upgradeForLoadBalancing')}
+            </div>
+            <UpgradeBtn />
+          </div>
+        </GridMask>
+      )}
+    </>
+  )
+}
+
+export default ModelLoadBalancingConfigs

+ 190 - 0
web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal.tsx

@@ -0,0 +1,190 @@
+import { memo, useCallback, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import classNames from 'classnames'
+import useSWR from 'swr'
+import type { ModelItem, ModelLoadBalancingConfig, ModelLoadBalancingConfigEntry, ModelProvider } from '../declarations'
+import { FormTypeEnum } from '../declarations'
+import ModelIcon from '../model-icon'
+import ModelName from '../model-name'
+import { savePredefinedLoadBalancingConfig } from '../utils'
+import ModelLoadBalancingConfigs from './model-load-balancing-configs'
+import Modal from '@/app/components/base/modal'
+import Button from '@/app/components/base/button'
+import { fetchModelLoadBalancingConfig } from '@/service/common'
+import Loading from '@/app/components/base/loading'
+import { useToastContext } from '@/app/components/base/toast'
+
+export type ModelLoadBalancingModalProps = {
+  provider: ModelProvider
+  model: ModelItem
+  open?: boolean
+  onClose?: () => void
+  onSave?: (provider: string) => void
+}
+
+// model balancing config modal
+const ModelLoadBalancingModal = ({ provider, model, open = false, onClose, onSave }: ModelLoadBalancingModalProps) => {
+  const { t } = useTranslation()
+  const { notify } = useToastContext()
+
+  const [loading, setLoading] = useState(false)
+
+  const { data, mutate } = useSWR(
+    `/workspaces/current/model-providers/${provider.provider}/models/credentials?model=${model.model}&model_type=${model.model_type}`,
+    fetchModelLoadBalancingConfig,
+  )
+
+  const originalConfig = data?.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(
+    () => provider.provider_credential_schema.credential_form_schemas.filter(
+      ({ type }) => type === FormTypeEnum.secretInput,
+    ),
+    [provider.provider_credential_schema.credential_form_schemas],
+  )
+
+  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 handleSave = async () => {
+    try {
+      setLoading(true)
+      const res = await savePredefinedLoadBalancingConfig(
+        provider.provider,
+        ({
+          ...(data?.credentials ?? {}),
+          __model_type: model.model_type,
+          __model_name: model.model,
+        }),
+        {
+          ...draftConfig,
+          enabled: Boolean(draftConfig?.enabled),
+          configs: draftConfig!.configs.map(encodeConfigEntrySecretValues),
+        },
+      )
+      if (res.result === 'success') {
+        notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
+        mutate()
+        onSave?.(provider.provider)
+        onClose?.()
+      }
+    }
+    finally {
+      setLoading(false)
+    }
+  }
+
+  return (
+    <Modal
+      isShow={Boolean(model) && open}
+      onClose={onClose}
+      wrapperClassName='!z-30'
+      className='max-w-none pt-8 px-8 w-[640px]'
+      title={
+        <div className='pb-3 font-semibold'>
+          <div className='h-[30px]'>{t('common.modelProvider.configLoadBalancing')}</div>
+          {Boolean(model) && (
+            <div className='flex items-center h-5'>
+              <ModelIcon
+                className='shrink-0 mr-2'
+                provider={provider}
+                modelName={model!.model}
+              />
+              <ModelName
+                className='grow text-sm font-normal text-gray-900'
+                modelItem={model!}
+                showModelType
+                showMode
+                showContextSize
+              />
+            </div>
+          )}
+        </div>
+      }
+    >
+      {!draftConfig
+        ? <Loading type='area' />
+        : (
+          <>
+            <div className='py-2'>
+              <div
+                className={classNames(
+                  'min-h-16 bg-gray-50 border rounded-xl transition-colors',
+                  draftConfig.enabled ? 'border-gray-200 cursor-pointer' : 'border-primary-400 cursor-default',
+                )}
+                onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}
+              >
+                <div className='flex items-center px-[15px] py-3 gap-2 select-none'>
+                  <div className='grow-0 shrink-0 flex items-center justify-center w-8 h-8 bg-white border rounded-lg'>
+                    {Boolean(model) && (
+                      <ModelIcon className='shrink-0' provider={provider} modelName={model!.model} />
+                    )}
+                  </div>
+                  <div className='grow'>
+                    <div className='text-sm'>{t('common.modelProvider.providerManaged')}</div>
+                    <div className='text-xs text-gray-500'>{t('common.modelProvider.providerManagedDescription')}</div>
+                  </div>
+                </div>
+              </div>
+
+              <ModelLoadBalancingConfigs {...{
+                draftConfig,
+                setDraftConfig,
+                provider,
+                currentCustomConfigurationModelFixedFields: {
+                  __model_name: model.model,
+                  __model_type: model.model_type,
+                },
+                configurationMethod: model.fetch_from,
+                className: 'mt-2',
+              }} />
+            </div>
+
+            <div className='flex items-center justify-end gap-2 mt-6'>
+              <Button onClick={onClose}>{t('common.operation.cancel')}</Button>
+              <Button
+                type='primary'
+                onClick={handleSave}
+                disabled={
+                  loading
+                  || (draftConfig?.enabled && (draftConfig?.configs.filter(config => config.enabled).length ?? 0) < 2)
+                }
+              >{t('common.operation.save')}</Button>
+            </div>
+          </>
+        )
+      }
+    </Modal >
+  )
+}
+
+export default memo(ModelLoadBalancingModal)

+ 1 - 1
web/app/components/header/account-setting/model-provider-page/provider-added-card/priority-selector.tsx

@@ -18,7 +18,7 @@ const Selector: FC<SelectorProps> = ({
   const options = [
     {
       key: PreferredProviderTypeEnum.custom,
-      text: 'API',
+      text: t('common.modelProvider.apiKey'),
     },
     {
       key: PreferredProviderTypeEnum.system,

+ 4 - 5
web/app/components/header/account-setting/model-provider-page/provider-card/index.tsx

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
 import type {
   ModelProvider,
 } from '../declarations'
-import { ConfigurateMethodEnum } from '../declarations'
+import { ConfigurationMethodEnum } from '../declarations'
 import {
   DEFAULT_BACKGROUND_COLOR,
   modelTypeFormat,
@@ -19,7 +19,7 @@ import Button from '@/app/components/base/button'
 
 type ProviderCardProps = {
   provider: ModelProvider
-  onOpenModal: (configurateMethod: ConfigurateMethodEnum) => void
+  onOpenModal: (configurateMethod: ConfigurationMethodEnum) => void
 }
 
 const ProviderCard: FC<ProviderCardProps> = ({
@@ -28,8 +28,7 @@ const ProviderCard: FC<ProviderCardProps> = ({
 }) => {
   const { t } = useTranslation()
   const language = useLanguage()
-
-  const configurateMethods = provider.configurate_methods.filter(method => method !== ConfigurateMethodEnum.fetchFromRemote)
+  const configurateMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
 
   return (
     <div
@@ -59,7 +58,7 @@ const ProviderCard: FC<ProviderCardProps> = ({
         <div className={`hidden group-hover:grid grid-cols-${configurateMethods.length} gap-1`}>
           {
             configurateMethods.map((method) => {
-              if (method === ConfigurateMethodEnum.predefinedModel) {
+              if (method === ConfigurationMethodEnum.predefinedModel) {
                 return (
                   <Button
                     key={method}

+ 45 - 1
web/app/components/header/account-setting/model-provider-page/utils.ts

@@ -3,8 +3,10 @@ import type {
   CredentialFormSchemaRadio,
   CredentialFormSchemaTextInput,
   FormValue,
+  ModelLoadBalancingConfig,
 } from './declarations'
 import {
+  ConfigurationMethodEnum,
   FormTypeEnum,
   MODEL_TYPE_TEXT,
   ModelTypeEnum,
@@ -12,6 +14,7 @@ import {
 import {
   deleteModelProvider,
   setModelProvider,
+  validateModelLoadBalancingCredentials,
   validateModelProvider,
 } from '@/service/common'
 
@@ -53,12 +56,38 @@ export const validateCredentials = async (predefined: boolean, provider: string,
   }
 }
 
-export const saveCredentials = async (predefined: boolean, provider: string, v: FormValue) => {
+export const validateLoadBalancingCredentials = async (predefined: boolean, provider: string, v: FormValue): Promise<{
+  status: ValidatedStatus
+  message?: string
+}> => {
+  const { __model_name, __model_type, ...credentials } = v
+  try {
+    const res = await validateModelLoadBalancingCredentials({
+      url: `/workspaces/current/model-providers/${provider}/models/load-balancing-configs/credentials-validate`,
+      body: {
+        model: __model_name,
+        model_type: __model_type,
+        credentials,
+      },
+    })
+    if (res.result === 'success')
+      return Promise.resolve({ status: ValidatedStatus.Success })
+    else
+      return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
+  }
+  catch (e: any) {
+    return Promise.resolve({ status: ValidatedStatus.Error, message: e.message })
+  }
+}
+
+export const saveCredentials = async (predefined: boolean, provider: string, v: FormValue, loadBalancing?: ModelLoadBalancingConfig) => {
   let body, url
 
   if (predefined) {
     body = {
+      config_from: ConfigurationMethodEnum.predefinedModel,
       credentials: v,
+      load_balancing: loadBalancing,
     }
     url = `/workspaces/current/model-providers/${provider}`
   }
@@ -68,6 +97,7 @@ export const saveCredentials = async (predefined: boolean, provider: string, v:
       model: __model_name,
       model_type: __model_type,
       credentials,
+      load_balancing: loadBalancing,
     }
     url = `/workspaces/current/model-providers/${provider}/models`
   }
@@ -75,6 +105,20 @@ export const saveCredentials = async (predefined: boolean, provider: string, v:
   return setModelProvider({ url, body })
 }
 
+export const savePredefinedLoadBalancingConfig = async (provider: string, v: FormValue, loadBalancing?: ModelLoadBalancingConfig) => {
+  const { __model_name, __model_type, ...credentials } = v
+  const body = {
+    config_from: ConfigurationMethodEnum.predefinedModel,
+    model: __model_name,
+    model_type: __model_type,
+    credentials,
+    load_balancing: loadBalancing,
+  }
+  const url = `/workspaces/current/model-providers/${provider}/models`
+
+  return setModelProvider({ url, body })
+}
+
 export const removeCredentials = async (predefined: boolean, provider: string, v: FormValue) => {
   let url = ''
   let body

+ 220 - 0
web/app/components/tools/tool-list/index.tsx

@@ -0,0 +1,220 @@
+'use client'
+import type { FC } from 'react'
+import React, { useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
+import { AuthHeaderPrefix, AuthType, CollectionType, LOC } from '../types'
+import type { Collection, CustomCollectionBackend, Tool } from '../types'
+import Loading from '../../base/loading'
+import { ArrowNarrowRight } from '../../base/icons/src/vender/line/arrows'
+import Toast from '../../base/toast'
+import { ConfigurationMethodEnum } from '../../header/account-setting/model-provider-page/declarations'
+import Header from './header'
+import Item from './item'
+import AppIcon from '@/app/components/base/app-icon'
+import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
+import { fetchCustomCollection, removeBuiltInToolCredential, removeCustomCollection, updateBuiltInToolCredential, updateCustomCollection } from '@/service/tools'
+import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
+import type { AgentTool } from '@/types/app'
+import { MAX_TOOLS_NUM } from '@/config'
+import { useModalContext } from '@/context/modal-context'
+import { useProviderContext } from '@/context/provider-context'
+
+type Props = {
+  collection: Collection | null
+  list: Tool[]
+  // onToolListChange: () => void // custom tools change
+  loc: LOC
+  addedTools?: AgentTool[]
+  onAddTool?: (collection: Collection, payload: Tool) => void
+  onRefreshData: () => void
+  onCollectionRemoved: () => void
+  isLoading: boolean
+}
+
+const ToolList: FC<Props> = ({
+  collection,
+  list,
+  loc,
+  addedTools,
+  onAddTool,
+  onRefreshData,
+  onCollectionRemoved,
+  isLoading,
+}) => {
+  const { t } = useTranslation()
+  const isInToolsPage = loc === LOC.tools
+  const isBuiltIn = collection?.type === CollectionType.builtIn
+  const isModel = collection?.type === CollectionType.model
+  const needAuth = collection?.allow_delete
+
+  const { setShowModelModal } = useModalContext()
+  const [showSettingAuth, setShowSettingAuth] = useState(false)
+  const { modelProviders: providers } = useProviderContext()
+  const showSettingAuthModal = () => {
+    if (isModel) {
+      const provider = providers.find(item => item.provider === collection?.id)
+      if (provider) {
+        setShowModelModal({
+          payload: {
+            currentProvider: provider,
+            currentConfigurationMethod: ConfigurationMethodEnum.predefinedModel,
+            currentCustomConfigurationModelFixedFields: undefined,
+          },
+          onSaveCallback: () => {
+            onRefreshData()
+          },
+        })
+      }
+    }
+    else {
+      setShowSettingAuth(true)
+    }
+  }
+
+  const [customCollection, setCustomCollection] = useState<CustomCollectionBackend | null>(null)
+  useEffect(() => {
+    if (!collection)
+      return
+    (async () => {
+      if (collection.type === CollectionType.custom) {
+        const res = await fetchCustomCollection(collection.name)
+        if (res.credentials.auth_type === AuthType.apiKey && !res.credentials.api_key_header_prefix) {
+          if (res.credentials.api_key_value)
+            res.credentials.api_key_header_prefix = AuthHeaderPrefix.custom
+        }
+        setCustomCollection({
+          ...res,
+          provider: collection.name,
+        })
+      }
+    })()
+  }, [collection])
+  const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
+
+  const doUpdateCustomToolCollection = async (data: CustomCollectionBackend) => {
+    await updateCustomCollection(data)
+    onRefreshData()
+    Toast.notify({
+      type: 'success',
+      message: t('common.api.actionSuccess'),
+    })
+    setIsShowEditCustomCollectionModal(false)
+  }
+
+  const doRemoveCustomToolCollection = async () => {
+    await removeCustomCollection(collection?.name as string)
+    onCollectionRemoved()
+    Toast.notify({
+      type: 'success',
+      message: t('common.api.actionSuccess'),
+    })
+    setIsShowEditCustomCollectionModal(false)
+  }
+
+  if (!collection || isLoading)
+    return <Loading type='app' />
+
+  const icon = <>{typeof collection.icon === 'string'
+    ? (
+      <div
+        className='p-2 bg-cover bg-center border border-gray-100 rounded-lg'
+      >
+        <div className='w-6 h-6 bg-center bg-contain rounded-md'
+          style={{
+            backgroundImage: `url(${collection.icon})`,
+          }}
+        ></div>
+      </div>
+    )
+    : (
+      <AppIcon
+        size='large'
+        icon={collection.icon.content}
+        background={collection.icon.background}
+      />
+    )}
+  </>
+
+  return (
+    <div className='flex flex-col h-full pb-4'>
+      <Header
+        icon={icon}
+        collection={collection}
+        loc={loc}
+        onShowAuth={() => showSettingAuthModal()}
+        onShowEditCustomCollection={() => setIsShowEditCustomCollectionModal(true)}
+      />
+      <div className={cn(isInToolsPage ? 'px-6 pt-4' : 'px-4 pt-3')}>
+        <div className='flex items-center h-[4.5] space-x-2  text-xs font-medium text-gray-500'>
+          <div className=''>{t('tools.includeToolNum', {
+            num: list.length,
+          })}</div>
+          {needAuth && (isBuiltIn || isModel) && !collection.is_team_authorization && (
+            <>
+              <div>·</div>
+              <div
+                className='flex items-center text-[#155EEF] cursor-pointer'
+                onClick={() => showSettingAuthModal()}
+              >
+                <div>{t('tools.auth.setup')}</div>
+                <ArrowNarrowRight className='ml-0.5 w-3 h-3' />
+              </div>
+            </>
+          )}
+        </div>
+      </div>
+      <div className={cn(isInToolsPage ? 'px-6' : 'px-4', 'grow h-0 pt-2 overflow-y-auto')}>
+        {/* list */}
+        <div className={cn(isInToolsPage ? 'grid-cols-3 gap-4' : 'grid-cols-1 gap-2', 'grid')}>
+          {list.map(item => (
+            <Item
+              key={item.name}
+              icon={icon}
+              payload={item}
+              collection={collection}
+              isInToolsPage={isInToolsPage}
+              isToolNumMax={(addedTools?.length || 0) >= MAX_TOOLS_NUM}
+              added={!!addedTools?.find(v => v.provider_id === collection.id && v.provider_type === collection.type && v.tool_name === item.name)}
+              onAdd={!isInToolsPage ? tool => onAddTool?.(collection as Collection, tool) : undefined}
+            />
+          ))}
+        </div>
+      </div>
+      {showSettingAuth && (
+        <ConfigCredential
+          collection={collection}
+          onCancel={() => setShowSettingAuth(false)}
+          onSaved={async (value) => {
+            await updateBuiltInToolCredential(collection.name, value)
+            Toast.notify({
+              type: 'success',
+              message: t('common.api.actionSuccess'),
+            })
+            await onRefreshData()
+            setShowSettingAuth(false)
+          }}
+          onRemove={async () => {
+            await removeBuiltInToolCredential(collection.name)
+            Toast.notify({
+              type: 'success',
+              message: t('common.api.actionSuccess'),
+            })
+            await onRefreshData()
+            setShowSettingAuth(false)
+          }}
+        />
+      )}
+
+      {isShowEditCollectionToolModal && (
+        <EditCustomToolModal
+          payload={customCollection}
+          onHide={() => setIsShowEditCustomCollectionModal(false)}
+          onEdit={doUpdateCustomToolCollection}
+          onRemove={doRemoveCustomToolCollection}
+        />
+      )}
+    </div>
+  )
+}
+export default React.memo(ToolList)

+ 2 - 2
web/app/components/workflow/block-icon.tsx

@@ -72,8 +72,8 @@ const BlockIcon: FC<BlockIconProps> = ({
 }) => {
   return (
     <div className={`
-      flex items-center justify-center border-[0.5px] border-white/[0.02] text-white
-      ${ICON_CONTAINER_CLASSNAME_SIZE_MAP[size]} 
+      flex items-center justify-center border-[0.5px] border-white/2 text-white
+      ${ICON_CONTAINER_CLASSNAME_SIZE_MAP[size]}
       ${ICON_CONTAINER_BG_COLOR_MAP[type]}
       ${toolIcon && '!shadow-none'}
       ${className}

+ 2 - 2
web/app/components/workflow/header/checklist.tsx

@@ -61,7 +61,7 @@ const WorkflowChecklist = ({
         >
           <div
             className={`
-              group flex items-center justify-center w-full h-full rounded-md cursor-pointer 
+              group flex items-center justify-center w-full h-full rounded-md cursor-pointer
               hover:bg-primary-50
               ${open && 'bg-primary-50'}
             `}
@@ -122,7 +122,7 @@ const WorkflowChecklist = ({
                             />
                             {node.title}
                           </div>
-                          <div className='border-t-[0.5px] border-t-black/[0.02]'>
+                          <div className='border-t-[0.5px] border-t-black/2'>
                             {
                               node.unConnected && (
                                 <div className='px-3 py-2 bg-gray-25 rounded-b-lg'>

+ 1 - 1
web/app/components/workflow/operator/index.tsx

@@ -11,7 +11,7 @@ const Operator = () => {
           width: 102,
           height: 72,
         }}
-        className='!absolute !left-4 !bottom-14 z-[9] !m-0 !w-[102px] !h-[72px] !border-[0.5px] !border-black/[0.08] !rounded-lg !shadow-lg'
+        className='!absolute !left-4 !bottom-14 z-[9] !m-0 !w-[102px] !h-[72px] !border-[0.5px] !border-black/8 !rounded-lg !shadow-lg'
       />
       <div className='flex items-center mt-1 gap-2 absolute left-4 bottom-4 z-[9]'>
         <ZoomInOut />

+ 1 - 1
web/app/components/workflow/panel/chat-record/index.tsx

@@ -71,7 +71,7 @@ const ChatRecord = () => {
   return (
     <div
       className={`
-        flex flex-col w-[400px] rounded-l-2xl h-full border border-black/[0.02] shadow-xl
+        flex flex-col w-[400px] rounded-l-2xl h-full border border-black/2 shadow-xl
       `}
       style={{
         background: 'linear-gradient(156deg, rgba(242, 244, 247, 0.80) 0%, rgba(242, 244, 247, 0.00) 99.43%), var(--white, #FFF)',

+ 2 - 2
web/app/components/workflow/panel/debug-and-preview/index.tsx

@@ -20,7 +20,7 @@ export type ChatWrapperRefType = {
 }
 const DebugAndPreview = () => {
   const { t } = useTranslation()
-  const chatRef = useRef({ handleRestart: () => {} })
+  const chatRef = useRef({ handleRestart: () => { } })
   const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
   const { handleNodeCancelRunningStatus } = useNodesInteractions()
   const { handleEdgeCancelRunningStatus } = useEdgesInteractions()
@@ -40,7 +40,7 @@ const DebugAndPreview = () => {
   return (
     <div
       className={cn(
-        'flex flex-col w-[400px] rounded-l-2xl h-full border border-black/[0.02]',
+        'flex flex-col w-[400px] rounded-l-2xl h-full border border-black/2',
       )}
       style={{
         background: 'linear-gradient(156deg, rgba(242, 244, 247, 0.80) 0%, rgba(242, 244, 247, 0.00) 99.43%), var(--white, #FFF)',

+ 2 - 1
web/app/styles/globals.css

@@ -147,4 +147,5 @@ button:focus-within {
   bottom: 0;
 }
 
-@import '../components/base/button/index.css';
+@import '../components/base/button/index.css';
+@import '../components/base/modal/index.css';

+ 3 - 3
web/context/debug-configuration.ts

@@ -28,7 +28,7 @@ import type { Collection } from '@/app/components/tools/types'
 
 type IDebugConfiguration = {
   appId: string
-  hasSetAPIKEY: boolean
+  isAPIKeySet: boolean
   isTrailFinished: boolean
   mode: string
   modelModeType: ModelModeType
@@ -101,7 +101,7 @@ type IDebugConfiguration = {
 
 const DebugConfigurationContext = createContext<IDebugConfiguration>({
   appId: '',
-  hasSetAPIKEY: false,
+  isAPIKeySet: false,
   isTrailFinished: false,
   mode: '',
   modelModeType: ModelModeType.chat,
@@ -134,7 +134,7 @@ const DebugConfigurationContext = createContext<IDebugConfiguration>({
   introduction: '',
   setIntroduction: () => { },
   suggestedQuestions: [],
-  setSuggestedQuestions: () => {},
+  setSuggestedQuestions: () => { },
   controlClearChatMessage: 0,
   setControlClearChatMessage: () => { },
   prevPromptConfig: {

+ 77 - 26
web/context/modal-context.tsx

@@ -2,7 +2,7 @@
 
 import type { Dispatch, SetStateAction } from 'react'
 import { useCallback, useState } from 'react'
-import { createContext, useContext } from 'use-context-selector'
+import { createContext, useContext, useContextSelector } from 'use-context-selector'
 import { useRouter, useSearchParams } from 'next/navigation'
 import AccountSetting from '@/app/components/header/account-setting'
 import ApiBasedExtensionModal from '@/app/components/header/account-setting/api-based-extension-page/modal'
@@ -11,8 +11,9 @@ import ExternalDataToolModal from '@/app/components/app/configuration/tools/exte
 import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
 import ModelModal from '@/app/components/header/account-setting/model-provider-page/model-modal'
 import type {
-  ConfigurateMethodEnum,
-  CustomConfigrationModelFixedFields,
+  ConfigurationMethodEnum,
+  CustomConfigurationModelFixedFields,
+  ModelLoadBalancingConfigEntry,
   ModelProvider,
 } from '@/app/components/header/account-setting/model-provider-page/declarations'
 
@@ -22,20 +23,28 @@ import type {
   ApiBasedExtension,
   ExternalDataTool,
 } from '@/models/common'
+import ModelLoadBalancingEntryModal from '@/app/components/header/account-setting/model-provider-page/model-modal/model-load-balancing-entry-modal'
+import type { ModelLoadBalancingModalProps } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal'
+import ModelLoadBalancingModal from '@/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-modal'
 
 export type ModalState<T> = {
   payload: T
   onCancelCallback?: () => void
   onSaveCallback?: (newPayload: T) => void
+  onRemoveCallback?: (newPayload: T) => void
   onValidateBeforeSaveCallback?: (newPayload: T) => boolean
 }
 
 export type ModelModalType = {
   currentProvider: ModelProvider
-  currentConfigurateMethod: ConfigurateMethodEnum
-  currentCustomConfigrationModelFixedFields?: CustomConfigrationModelFixedFields
+  currentConfigurationMethod: ConfigurationMethodEnum
+  currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields
 }
-const ModalContext = createContext<{
+export type LoadBalancingEntryModalType = ModelModalType & {
+  entry?: ModelLoadBalancingConfigEntry
+  index?: number
+}
+export type ModalContextState = {
   setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<string> | null>>
   setShowApiBasedExtensionModal: Dispatch<SetStateAction<ModalState<ApiBasedExtension> | null>>
   setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>>
@@ -43,18 +52,29 @@ const ModalContext = createContext<{
   setShowPricingModal: () => void
   setShowAnnotationFullModal: () => void
   setShowModelModal: Dispatch<SetStateAction<ModalState<ModelModalType> | null>>
-}>({
-      setShowAccountSettingModal: () => { },
-      setShowApiBasedExtensionModal: () => { },
-      setShowModerationSettingModal: () => { },
-      setShowExternalDataToolModal: () => { },
-      setShowPricingModal: () => { },
-      setShowAnnotationFullModal: () => { },
-      setShowModelModal: () => { },
-    })
+  setShowModelLoadBalancingModal: Dispatch<SetStateAction<ModelLoadBalancingModalProps | null>>
+  setShowModelLoadBalancingEntryModal: Dispatch<SetStateAction<ModalState<LoadBalancingEntryModalType> | null>>
+}
+const ModalContext = createContext<ModalContextState>({
+  setShowAccountSettingModal: () => { },
+  setShowApiBasedExtensionModal: () => { },
+  setShowModerationSettingModal: () => { },
+  setShowExternalDataToolModal: () => { },
+  setShowPricingModal: () => { },
+  setShowAnnotationFullModal: () => { },
+  setShowModelModal: () => { },
+  setShowModelLoadBalancingModal: () => { },
+  setShowModelLoadBalancingEntryModal: () => { },
+})
 
 export const useModalContext = () => useContext(ModalContext)
 
+// Adding a dangling comma to avoid the generic parsing issue in tsx, see:
+// https://github.com/microsoft/TypeScript/issues/15713
+// eslint-disable-next-line @typescript-eslint/comma-dangle
+export const useModalContextSelector = <T,>(selector: (state: ModalContextState) => T): T =>
+  useContextSelector(ModalContext, selector)
+
 type ModalContextProviderProps = {
   children: React.ReactNode
 }
@@ -66,34 +86,32 @@ export const ModalContextProvider = ({
   const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
   const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null)
   const [showModelModal, setShowModelModal] = useState<ModalState<ModelModalType> | null>(null)
+  const [showModelLoadBalancingModal, setShowModelLoadBalancingModal] = useState<ModelLoadBalancingModalProps | null>(null)
+  const [showModelLoadBalancingEntryModal, setShowModelLoadBalancingEntryModal] = useState<ModalState<LoadBalancingEntryModalType> | null>(null)
   const searchParams = useSearchParams()
   const router = useRouter()
   const [showPricingModal, setShowPricingModal] = useState(searchParams.get('show-pricing') === '1')
   const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
   const handleCancelAccountSettingModal = () => {
     setShowAccountSettingModal(null)
-
     if (showAccountSettingModal?.onCancelCallback)
       showAccountSettingModal?.onCancelCallback()
   }
 
   const handleCancelModerationSettingModal = () => {
     setShowModerationSettingModal(null)
-
     if (showModerationSettingModal?.onCancelCallback)
       showModerationSettingModal.onCancelCallback()
   }
 
   const handleCancelExternalDataToolModal = () => {
     setShowExternalDataToolModal(null)
-
     if (showExternalDataToolModal?.onCancelCallback)
       showExternalDataToolModal.onCancelCallback()
   }
 
   const handleCancelModelModal = useCallback(() => {
     setShowModelModal(null)
-
     if (showModelModal?.onCancelCallback)
       showModelModal.onCancelCallback()
   }, [showModelModal])
@@ -101,35 +119,48 @@ export const ModalContextProvider = ({
   const handleSaveModelModal = useCallback(() => {
     if (showModelModal?.onSaveCallback)
       showModelModal.onSaveCallback(showModelModal.payload)
-
     setShowModelModal(null)
   }, [showModelModal])
 
+  const handleCancelModelLoadBalancingEntryModal = useCallback(() => {
+    showModelLoadBalancingEntryModal?.onCancelCallback?.()
+    setShowModelLoadBalancingEntryModal(null)
+  }, [showModelLoadBalancingEntryModal])
+
+  const handleSaveModelLoadBalancingEntryModal = useCallback((entry: ModelLoadBalancingConfigEntry) => {
+    showModelLoadBalancingEntryModal?.onSaveCallback?.({
+      ...showModelLoadBalancingEntryModal.payload,
+      entry,
+    })
+    setShowModelLoadBalancingEntryModal(null)
+  }, [showModelLoadBalancingEntryModal])
+
+  const handleRemoveModelLoadBalancingEntry = useCallback(() => {
+    showModelLoadBalancingEntryModal?.onRemoveCallback?.(showModelLoadBalancingEntryModal.payload)
+    setShowModelLoadBalancingEntryModal(null)
+  }, [showModelLoadBalancingEntryModal])
+
   const handleSaveApiBasedExtension = (newApiBasedExtension: ApiBasedExtension) => {
     if (showApiBasedExtensionModal?.onSaveCallback)
       showApiBasedExtensionModal.onSaveCallback(newApiBasedExtension)
-
     setShowApiBasedExtensionModal(null)
   }
 
   const handleSaveModeration = (newModerationConfig: ModerationConfig) => {
     if (showModerationSettingModal?.onSaveCallback)
       showModerationSettingModal.onSaveCallback(newModerationConfig)
-
     setShowModerationSettingModal(null)
   }
 
   const handleSaveExternalDataTool = (newExternalDataTool: ExternalDataTool) => {
     if (showExternalDataToolModal?.onSaveCallback)
       showExternalDataToolModal.onSaveCallback(newExternalDataTool)
-
     setShowExternalDataToolModal(null)
   }
 
   const handleValidateBeforeSaveExternalDataTool = (newExternalDataTool: ExternalDataTool) => {
     if (showExternalDataToolModal?.onValidateBeforeSaveCallback)
       return showExternalDataToolModal?.onValidateBeforeSaveCallback(newExternalDataTool)
-
     return true
   }
 
@@ -142,6 +173,8 @@ export const ModalContextProvider = ({
       setShowPricingModal: () => setShowPricingModal(true),
       setShowAnnotationFullModal: () => setShowAnnotationFullModal(true),
       setShowModelModal,
+      setShowModelLoadBalancingModal,
+      setShowModelLoadBalancingEntryModal,
     }}>
       <>
         {children}
@@ -205,13 +238,31 @@ export const ModalContextProvider = ({
           !!showModelModal && (
             <ModelModal
               provider={showModelModal.payload.currentProvider}
-              configurateMethod={showModelModal.payload.currentConfigurateMethod}
-              currentCustomConfigrationModelFixedFields={showModelModal.payload.currentCustomConfigrationModelFixedFields}
+              configurateMethod={showModelModal.payload.currentConfigurationMethod}
+              currentCustomConfigurationModelFixedFields={showModelModal.payload.currentCustomConfigurationModelFixedFields}
               onCancel={handleCancelModelModal}
               onSave={handleSaveModelModal}
             />
           )
         }
+        {
+          Boolean(showModelLoadBalancingModal) && (
+            <ModelLoadBalancingModal {...showModelLoadBalancingModal!} />
+          )
+        }
+        {
+          !!showModelLoadBalancingEntryModal && (
+            <ModelLoadBalancingEntryModal
+              provider={showModelLoadBalancingEntryModal.payload.currentProvider}
+              configurationMethod={showModelLoadBalancingEntryModal.payload.currentConfigurationMethod}
+              currentCustomConfigurationModelFixedFields={showModelLoadBalancingEntryModal.payload.currentCustomConfigurationModelFixedFields}
+              entry={showModelLoadBalancingEntryModal.payload.entry}
+              onCancel={handleCancelModelLoadBalancingEntryModal}
+              onSave={handleSaveModelLoadBalancingEntryModal}
+              onRemove={handleRemoveModelLoadBalancingEntry}
+            />
+          )
+        }
       </>
     </ModalContext.Provider>
   )

+ 42 - 29
web/context/provider-context.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { createContext, useContext } from 'use-context-selector'
+import { createContext, useContext, useContextSelector } from 'use-context-selector'
 import useSWR from 'swr'
 import { useEffect, useState } from 'react'
 import {
@@ -19,11 +19,11 @@ import { fetchCurrentPlanInfo } from '@/service/billing'
 import { parseCurrentPlan } from '@/app/components/billing/utils'
 import { defaultPlan } from '@/app/components/billing/config'
 
-const ProviderContext = createContext<{
+type ProviderContextState = {
   modelProviders: ModelProvider[]
   textGenerationModelList: Model[]
   supportRetrievalMethods: RETRIEVE_METHOD[]
-  hasSettedApiKey: boolean
+  isAPIKeySet: boolean
   plan: {
     type: Plan
     usage: UsagePlanInfo
@@ -33,34 +33,43 @@ const ProviderContext = createContext<{
   enableBilling: boolean
   onPlanInfoChanged: () => void
   enableReplaceWebAppLogo: boolean
-}>({
-      modelProviders: [],
-      textGenerationModelList: [],
-      supportRetrievalMethods: [],
-      hasSettedApiKey: true,
-      plan: {
-        type: Plan.sandbox,
-        usage: {
-          vectorSpace: 32,
-          buildApps: 12,
-          teamMembers: 1,
-          annotatedResponse: 1,
-        },
-        total: {
-          vectorSpace: 200,
-          buildApps: 50,
-          teamMembers: 1,
-          annotatedResponse: 10,
-        },
-      },
-      isFetchedPlan: false,
-      enableBilling: false,
-      onPlanInfoChanged: () => { },
-      enableReplaceWebAppLogo: false,
-    })
+  modelLoadBalancingEnabled: boolean
+}
+const ProviderContext = createContext<ProviderContextState>({
+  modelProviders: [],
+  textGenerationModelList: [],
+  supportRetrievalMethods: [],
+  isAPIKeySet: true,
+  plan: {
+    type: Plan.sandbox,
+    usage: {
+      vectorSpace: 32,
+      buildApps: 12,
+      teamMembers: 1,
+      annotatedResponse: 1,
+    },
+    total: {
+      vectorSpace: 200,
+      buildApps: 50,
+      teamMembers: 1,
+      annotatedResponse: 10,
+    },
+  },
+  isFetchedPlan: false,
+  enableBilling: false,
+  onPlanInfoChanged: () => { },
+  enableReplaceWebAppLogo: false,
+  modelLoadBalancingEnabled: false,
+})
 
 export const useProviderContext = () => useContext(ProviderContext)
 
+// Adding a dangling comma to avoid the generic parsing issue in tsx, see:
+// https://github.com/microsoft/TypeScript/issues/15713
+// eslint-disable-next-line @typescript-eslint/comma-dangle
+export const useProviderContextSelector = <T,>(selector: (state: ProviderContextState) => T): T =>
+  useContextSelector(ProviderContext, selector)
+
 type ProviderContextProviderProps = {
   children: React.ReactNode
 }
@@ -76,6 +85,7 @@ export const ProviderContextProvider = ({
   const [isFetchedPlan, setIsFetchedPlan] = useState(false)
   const [enableBilling, setEnableBilling] = useState(true)
   const [enableReplaceWebAppLogo, setEnableReplaceWebAppLogo] = useState(false)
+  const [modelLoadBalancingEnabled, setModelLoadBalancingEnabled] = useState(false)
 
   const fetchPlan = async () => {
     const data = await fetchCurrentPlanInfo()
@@ -86,6 +96,8 @@ export const ProviderContextProvider = ({
       setPlan(parseCurrentPlan(data))
       setIsFetchedPlan(true)
     }
+    if (data.model_load_balancing_enabled)
+      setModelLoadBalancingEnabled(true)
   }
   useEffect(() => {
     fetchPlan()
@@ -95,13 +107,14 @@ export const ProviderContextProvider = ({
     <ProviderContext.Provider value={{
       modelProviders: providersData?.data || [],
       textGenerationModelList: textGenerationModelList?.data || [],
-      hasSettedApiKey: !!textGenerationModelList?.data.some(model => model.status === ModelStatusEnum.active),
+      isAPIKeySet: !!textGenerationModelList?.data.some(model => model.status === ModelStatusEnum.active),
       supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
       plan,
       isFetchedPlan,
       enableBilling,
       onPlanInfoChanged: fetchPlan,
       enableReplaceWebAppLogo,
+      modelLoadBalancingEnabled,
     }}>
       {children}
     </ProviderContext.Provider>

+ 16 - 0
web/i18n/en-US/common.ts

@@ -278,6 +278,7 @@ const translation = {
       key: 'Rerank Model',
       tip: 'Rerank model will reorder the candidate document list based on the semantic match with  user query, improving the results of semantic ranking',
     },
+    apiKey: 'API-KEY',
     quota: 'Quota',
     searchModel: 'Search model',
     noModelFound: 'No model found for {{model}}',
@@ -334,6 +335,21 @@ const translation = {
     quotaTip: 'Remaining available free tokens',
     loadPresets: 'Load Presents',
     parameters: 'PARAMETERS',
+    loadBalancing: 'Load balancing',
+    loadBalancingDescription: 'Reduce pressure with multiple sets of credentials.',
+    loadBalancingHeadline: 'Load Balancing',
+    configLoadBalancing: 'Config Load Balancing',
+    modelHasBeenDeprecated: 'This model has been deprecated',
+    providerManaged: 'Provider managed',
+    providerManagedDescription: 'Use the single set of credentials provided by the model provider.',
+    defaultConfig: 'Default Config',
+    apiKeyStatusNormal: 'APIKey status is normal',
+    apiKeyRateLimit: 'Rate limit was reached, available after {{seconds}}s',
+    addConfig: 'Add Config',
+    editConfig: 'Edit Config',
+    loadBalancingLeastKeyWarning: 'To enable load balancing at least 2 keys must be enabled.',
+    loadBalancingInfo: 'By default, load balancing uses the Round-robin strategy. If rate limiting is triggered, a 1-minute cooldown period will be applied.',
+    upgradeForLoadBalancing: 'Upgrade your plan to enable Load Balancing.',
   },
   dataSource: {
     add: 'Add a data source',

+ 15 - 0
web/i18n/zh-Hans/common.ts

@@ -334,6 +334,21 @@ const translation = {
     quotaTip: '剩余免费额度',
     loadPresets: '加载预设',
     parameters: '参数',
+    loadBalancing: '负载均衡',
+    loadBalancingDescription: '为了减轻单组凭据的压力,您可以为模型调用配置多组凭据。',
+    loadBalancingHeadline: '负载均衡',
+    configLoadBalancing: '设置负载均衡',
+    modelHasBeenDeprecated: '该模型已废弃',
+    providerManaged: '由模型供应商管理',
+    providerManagedDescription: '使用模型供应商提供的单组凭据',
+    defaultConfig: '默认配置',
+    apiKeyStatusNormal: 'API Key 正常',
+    apiKeyRateLimit: '已达频率上限,{{seconds}}秒后恢复',
+    addConfig: '增加配置',
+    editConfig: '修改配置',
+    loadBalancingLeastKeyWarning: '至少启用 2 个 Key 以使用负载均衡',
+    loadBalancingInfo: '默认情况下,负载平衡使用 Round-robin 策略。如果触发速率限制,将应用 1 分钟的冷却时间',
+    upgradeForLoadBalancing: '升级以解锁负载均衡功能',
   },
   dataSource: {
     add: '添加数据源',

+ 28 - 2
web/service/common.ts

@@ -30,8 +30,10 @@ import type {
   DefaultModelResponse,
   Model,
   ModelItem,
+  ModelLoadBalancingConfig,
   ModelParameterRule,
   ModelProvider,
+  ModelTypeEnum,
 } from '@/app/components/header/account-setting/model-provider-page/declarations'
 import type { RETRIEVE_METHOD } from '@/types/app'
 import type { SystemFeatures } from '@/types/feature'
@@ -166,8 +168,22 @@ export const fetchModelProviders: Fetcher<{ data: ModelProvider[] }, string> = (
   return get<{ data: ModelProvider[] }>(url)
 }
 
-export const fetchModelProviderCredentials: Fetcher<{ credentials?: Record<string, string | undefined | boolean> }, string> = (url) => {
-  return get<{ credentials?: Record<string, string | undefined | boolean> }>(url)
+export type ModelProviderCredentials = {
+  credentials?: Record<string, string | undefined | boolean>
+  load_balancing: ModelLoadBalancingConfig
+}
+export const fetchModelProviderCredentials: Fetcher<ModelProviderCredentials, string> = (url) => {
+  return get<ModelProviderCredentials>(url)
+}
+
+export const fetchModelLoadBalancingConfig: Fetcher<{
+  credentials?: Record<string, string | undefined | boolean>
+  load_balancing: ModelLoadBalancingConfig
+}, string> = (url) => {
+  return get<{
+    credentials?: Record<string, string | undefined | boolean>
+    load_balancing: ModelLoadBalancingConfig
+  }>(url)
 }
 
 export const fetchModelProviderModelList: Fetcher<{ data: ModelItem[] }, string> = (url) => {
@@ -182,6 +198,10 @@ export const validateModelProvider: Fetcher<ValidateOpenAIKeyResponse, { url: st
   return post<ValidateOpenAIKeyResponse>(url, { body })
 }
 
+export const validateModelLoadBalancingCredentials: Fetcher<ValidateOpenAIKeyResponse, { url: string; body: any }> = ({ url, body }) => {
+  return post<ValidateOpenAIKeyResponse>(url, { body })
+}
+
 export const setModelProvider: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => {
   return post<CommonResponse>(url, { body })
 }
@@ -272,3 +292,9 @@ export const fetchSupportRetrievalMethods: Fetcher<RetrievalMethodsRes, string>
 export const getSystemFeatures = () => {
   return get<SystemFeatures>('/system-features')
 }
+
+export const enableModel = (url: string, body: { model: string; model_type: ModelTypeEnum }) =>
+  patch<CommonResponse>(url, { body })
+
+export const disableModel = (url: string, body: { model: string; model_type: ModelTypeEnum }) =>
+  patch<CommonResponse>(url, { body })

+ 29 - 19
web/tailwind.config.js

@@ -9,27 +9,30 @@ module.exports = {
     extend: {
       colors: {
         gray: {
-          25: '#FCFCFD',
-          50: '#F9FAFB',
-          100: '#F3F4F6',
-          200: '#E5E7EB',
-          300: '#D1D5DB',
-          400: '#9CA3AF',
-          500: '#6B7280',
-          700: '#374151',
-          800: '#1F2A37',
-          900: '#111928',
+          25: '#fcfcfd',
+          50: '#f9fafb',
+          100: '#f2f4f7',
+          200: '#eaecf0',
+          300: '#d0d5dd',
+          400: '#98a2b3',
+          500: '#667085',
+          700: '#475467',
+          600: '#344054',
+          800: '#1d2939',
+          900: '#101828',
         },
         primary: {
-          25: '#F5F8FF',
-          50: '#EBF5FF',
-          100: '#E1EFFE',
-          200: '#C3DDFD',
-          300: '#A4CAFE',
-          400: '#528BFF',
-          500: '#2970FF',
-          600: '#1C64F2',
-          700: '#1A56DB',
+          25: '#f5f8ff',
+          50: '#eff4ff',
+          100: '#d1e0ff',
+          200: '#b2ccff',
+          300: '#84adff',
+          400: '#528bff',
+          500: '#2970ff',
+          600: '#155eef',
+          700: '#004eeb',
+          800: '#0040c1',
+          900: '#00359e',
         },
         blue: {
           500: '#E1EFFE',
@@ -75,6 +78,13 @@ module.exports = {
         '2xl': '0px 24px 48px -12px rgba(16, 24, 40, 0.18)',
         '3xl': '0px 32px 64px -12px rgba(16, 24, 40, 0.14)',
       },
+      opacity: {
+        2: '0.02',
+        8: '0.08',
+      },
+      fontSize: {
+        '2xs': '0.625rem',
+      },
     },
   },
   plugins: [