Bläddra i källkod

feat: model load balancing (#4926)

Nite Knite 11 månader sedan
förälder
incheckning
37f292ea91
58 ändrade filer med 1896 tillägg och 304 borttagningar
  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

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 3V20M12 20H6.99999M12 20H17M2.99999 6H7.52785C7.83834 6 8.14457 5.92771 8.42228 5.78885L9.5777 5.21115C9.85541 5.07229 10.1616 5 10.4721 5H13.5279C13.8384 5 14.1446 5.07229 14.4223 5.21115L15.5777 5.78885C15.8554 5.92771 16.1616 6 16.4721 6H21M5.49999 6L3.02043 13.4387C2.71807 14.3458 3.08918 15.3834 4.0053 15.657C5.0117 15.9577 5.98828 15.9577 6.99468 15.657C7.9108 15.3834 8.28191 14.3457 7.97955 13.4387L5.49999 6ZM18.5 6L16.0204 13.4387C15.7181 14.3458 16.0892 15.3834 17.0053 15.657C18.0117 15.9577 18.9883 15.9577 19.9947 15.657C20.9108 15.3834 21.2819 14.3457 20.9796 13.4387L18.5 6Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 29 - 0
web/app/components/base/icons/src/vender/line/financeAndECommerce/Balance.json

@@ -0,0 +1,29 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M12 3V20M12 20H6.99999M12 20H17M2.99999 6H7.52785C7.83834 6 8.14457 5.92771 8.42228 5.78885L9.5777 5.21115C9.85541 5.07229 10.1616 5 10.4721 5H13.5279C13.8384 5 14.1446 5.07229 14.4223 5.21115L15.5777 5.78885C15.8554 5.92771 16.1616 6 16.4721 6H21M5.49999 6L3.02043 13.4387C2.71807 14.3458 3.08918 15.3834 4.0053 15.657C5.0117 15.9577 5.98828 15.9577 6.99468 15.657C7.9108 15.3834 8.28191 14.3457 7.97955 13.4387L5.49999 6ZM18.5 6L16.0204 13.4387C15.7181 14.3458 16.0892 15.3834 17.0053 15.657C18.0117 15.9577 18.9883 15.9577 19.9947 15.657C20.9108 15.3834 21.2819 14.3457 20.9796 13.4387L18.5 6Z",
+					"stroke": "currentColor",
+					"stroke-width": "2",
+					"stroke-linecap": "round",
+					"stroke-linejoin": "round"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Balance"
+}

+ 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: [