Kaynağa Gözat

Feat/apply free quota (#828)

Co-authored-by: Joel <iamjoel007@gmail.com>
zxhlyh 1 yıl önce
ebeveyn
işleme
e5e86fc033

+ 0 - 2
web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx

@@ -1,5 +1,4 @@
 import React from 'react'
-import { EditKeyPopover } from './welcome-banner'
 import ChartView from './chartView'
 import CardView from './cardView'
 import { getLocaleOnServer } from '@/i18n/server'
@@ -21,7 +20,6 @@ const Overview = async ({
       <ApikeyInfoPanel />
       <div className='flex flex-row items-center justify-between mb-4 text-xl text-gray-900'>
         {t('overview.title')}
-        <EditKeyPopover />
       </div>
       <CardView appId={appId} />
       <ChartView appId={appId} />

+ 10 - 1
web/app/(commonLayout)/apps/Apps.tsx

@@ -4,12 +4,13 @@ import { useEffect, useRef } from 'react'
 import useSWRInfinite from 'swr/infinite'
 import { debounce } from 'lodash-es'
 import { useTranslation } from 'react-i18next'
+import { useSearchParams } from 'next/navigation'
 import AppCard from './AppCard'
 import NewAppCard from './NewAppCard'
 import type { AppListResponse } from '@/models/app'
 import { fetchAppList } from '@/service/apps'
 import { useSelector } from '@/context/app-context'
-import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
+import { NEED_REFRESH_APP_LIST_KEY, SPARK_FREE_QUOTA_PENDING } from '@/config'
 
 const getKey = (pageIndex: number, previousPageData: AppListResponse) => {
   if (!pageIndex || previousPageData.has_more)
@@ -23,6 +24,7 @@ const Apps = () => {
   const loadingStateRef = useRef(false)
   const pageContainerRef = useSelector(state => state.pageContainerRef)
   const anchorRef = useRef<HTMLAnchorElement>(null)
+  const searchParams = useSearchParams()
 
   useEffect(() => {
     document.title = `${t('app.title')} -  Dify`
@@ -30,6 +32,13 @@ const Apps = () => {
       localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
       mutate()
     }
+    if (
+      localStorage.getItem(SPARK_FREE_QUOTA_PENDING) !== '1'
+      && searchParams.get('type') === 'provider_apply_callback'
+      && searchParams.get('provider') === 'spark'
+      && searchParams.get('result') === 'success'
+    )
+      localStorage.setItem(SPARK_FREE_QUOTA_PENDING, '1')
   }, [])
 
   useEffect(() => {

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

@@ -3,18 +3,17 @@ import type { FC } from 'react'
 import React, { useState } from 'react'
 import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
-import useSWR from 'swr'
 import Progress from './progress'
 import Button from '@/app/components/base/button'
 import { LinkExternal02, XClose } from '@/app/components/base/icons/src/vender/line/general'
 import AccountSetting from '@/app/components/header/account-setting'
-import { fetchTenantInfo } from '@/service/common'
 import { IS_CE_EDITION } from '@/config'
 import { useProviderContext } from '@/context/provider-context'
+import { formatNumber } from '@/utils/format'
 
 const APIKeyInfoPanel: FC = () => {
   const isCloud = !IS_CE_EDITION
-  const { providers }: any = useProviderContext()
+  const { textGenerationModelList } = useProviderContext()
 
   const { t } = useTranslation()
 
@@ -22,37 +21,42 @@ const APIKeyInfoPanel: FC = () => {
 
   const [isShow, setIsShow] = useState(true)
 
-  const { data: userInfo } = useSWR({ url: '/info' }, fetchTenantInfo)
-  if (!userInfo)
-    return null
+  const hasSetAPIKEY = !!textGenerationModelList?.find(({ model_provider: provider }) => {
+    if (provider.provider_type === 'system' && provider.quota_type === 'paid')
+      return true
+
+    if (provider.provider_type === 'custom')
+      return true
 
-  const hasBindAPI = userInfo?.providers?.find(({ token_is_set }) => token_is_set)
-  if (hasBindAPI)
+    return false
+  })
+  if (hasSetAPIKEY)
     return null
 
   // first show in trail and not used exhausted, else find the exhausted
-  const [used, total, providerName] = (() => {
-    if (!providers || !isCloud)
+  const [used, total, unit, providerName] = (() => {
+    if (!textGenerationModelList || !isCloud)
       return [0, 0, '']
     let used = 0
     let total = 0
+    let unit = 'times'
     let trailProviderName = ''
     let hasFoundNotExhausted = false
-    Object.keys(providers).forEach((providerName) => {
+    textGenerationModelList?.filter(({ model_provider: provider }) => {
+      return provider.quota_type === 'trial'
+    }).forEach(({ model_provider: provider }) => {
       if (hasFoundNotExhausted)
         return
-      providers[providerName].providers.forEach(({ quota_type, quota_limit, quota_used }: any) => {
-        if (quota_type === 'trial') {
-          if (quota_limit !== quota_used)
-            hasFoundNotExhausted = true
-
-          used = quota_used
-          total = quota_limit
-          trailProviderName = providerName
-        }
-      })
+      const { provider_name, quota_used, quota_limit, quota_unit } = provider
+      if (quota_limit !== quota_used)
+        hasFoundNotExhausted = true
+      used = quota_used
+      total = quota_limit
+      unit = quota_unit
+      trailProviderName = provider_name
     })
-    return [used, total, trailProviderName]
+
+    return [used, total, unit, trailProviderName]
   })()
   const usedPercent = Math.round(used / total * 100)
   const exhausted = isCloud && usedPercent === 100
@@ -81,9 +85,9 @@ const APIKeyInfoPanel: FC = () => {
       {isCloud && (
         <div className='my-5'>
           <div className='flex items-center h-5 space-x-2 text-sm text-gray-700 font-medium'>
-            <div>{t('appOverview.apiKeyInfo.callTimes')}</div>
+            <div>{t(`appOverview.apiKeyInfo.${unit === 'times' ? 'callTimes' : 'usedToken'}`)}</div>
             <div>·</div>
-            <div className={cn('font-semibold', exhausted && 'text-[#D92D20]')}>{used}/{total}</div>
+            <div className={cn('font-semibold', exhausted && 'text-[#D92D20]')}>{formatNumber(used)}/{formatNumber(total)}</div>
           </div>
           <Progress className='mt-2' value={usedPercent} />
         </div>

+ 10 - 10
web/app/components/header/account-setting/model-page/configs/spark.tsx

@@ -52,28 +52,28 @@ const config: ProviderConfig = {
       },
       {
         type: 'text',
-        key: 'api_key',
+        key: 'api_secret',
         required: true,
         label: {
-          'en': 'API Key',
-          'zh-Hans': 'API Key',
+          'en': 'API Secret',
+          'zh-Hans': 'API Secret',
         },
         placeholder: {
-          'en': 'Enter your API key here',
-          'zh-Hans': '在此输入您的 API Key',
+          'en': 'Enter your API Secret here',
+          'zh-Hans': '在此输入您的 API Secret',
         },
       },
       {
         type: 'text',
-        key: 'api_secret',
+        key: 'api_key',
         required: true,
         label: {
-          'en': 'API Secret',
-          'zh-Hans': 'API Secret',
+          'en': 'API Key',
+          'zh-Hans': 'API Key',
         },
         placeholder: {
-          'en': 'Enter your API Secret here',
-          'zh-Hans': '在此输入您的 API Secret',
+          'en': 'Enter your API key here',
+          'zh-Hans': '在此输入您的 API Key',
         },
       },
     ],

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

@@ -74,6 +74,10 @@ export type BackendModel = {
   model_provider: {
     provider_name: ProviderEnum
     provider_type: PreferredProviderTypeEnum
+    quota_type: 'trial' | 'paid'
+    quota_unit: 'times' | 'tokens'
+    quota_used: number
+    quota_limit: number
   }
   features: ModelFeature[]
 }

+ 100 - 0
web/app/components/header/account-setting/model-page/model-item/FreeQuota.tsx

@@ -0,0 +1,100 @@
+import { useEffect, useState } from 'react'
+import type { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useContext } from 'use-context-selector'
+import type { ProviderConfigItem, ProviderWithQuota, TypeWithI18N } from '../declarations'
+import { ProviderEnum as ProviderEnumValue } from '../declarations'
+import s from './index.module.css'
+import I18n from '@/context/i18n'
+import Button from '@/app/components/base/button'
+import { submitFreeQuota } from '@/service/common'
+import { SPARK_FREE_QUOTA_PENDING } from '@/config'
+
+const TIP_MAP: { [k: string]: TypeWithI18N } = {
+  [ProviderEnumValue.minimax]: {
+    'en': 'Earn 1 million tokens for free',
+    'zh-Hans': '免费获取 100 万个 token',
+  },
+  [ProviderEnumValue.spark]: {
+    'en': 'Earn 3 million tokens for free',
+    'zh-Hans': '免费获取 300 万个 token',
+  },
+}
+const FREE_QUOTA_TIP = {
+  'en': 'Your 3 million tokens will be credited in 5 minutes.',
+  'zh-Hans': '您的 300 万 token 将在 5 分钟内到账。',
+}
+type FreeQuotaProps = {
+  modelItem: ProviderConfigItem
+  onUpdate: () => void
+  freeProvider?: ProviderWithQuota
+}
+const FreeQuota: FC<FreeQuotaProps> = ({
+  modelItem,
+  onUpdate,
+  freeProvider,
+}) => {
+  const { locale } = useContext(I18n)
+  const { t } = useTranslation()
+  const [loading, setLoading] = useState(false)
+  const [freeQuotaPending, setFreeQuotaPending] = useState(false)
+
+  useEffect(() => {
+    if (
+      modelItem.key === ProviderEnumValue.spark
+      && localStorage.getItem(SPARK_FREE_QUOTA_PENDING) === '1'
+      && freeProvider
+      && !freeProvider.is_valid
+    )
+      setFreeQuotaPending(true)
+  }, [freeProvider, modelItem.key])
+
+  const handleClick = async () => {
+    try {
+      setLoading(true)
+      const res = await submitFreeQuota(`/workspaces/current/model-providers/${modelItem.key}/free-quota-submit`)
+
+      if (res.type === 'redirect' && res.redirect_url)
+        window.location.href = res.redirect_url
+      else if (res.type === 'submit' && res.result === 'success')
+        onUpdate()
+    }
+    finally {
+      setLoading(false)
+    }
+  }
+
+  if (freeQuotaPending) {
+    return (
+      <div className='flex items-center'>
+        ⏳
+        <div className={`${s.vender} ml-1 mr-2 text-xs font-medium text-transparent`}>{FREE_QUOTA_TIP[locale]}</div>
+        <Button
+          className='!px-3 !h-7 !rounded-md !text-xs !font-medium !bg-white !text-gray-700'
+          onClick={onUpdate}
+        >
+          {t('common.operation.reload')}
+        </Button>
+        <div className='mx-2 w-[1px] h-4 bg-black/5' />
+      </div>
+    )
+  }
+
+  return (
+    <div className='flex items-center'>
+      📣
+      <div className={`${s.vender} ml-1 mr-2 text-xs font-medium text-transparent`}>{TIP_MAP[modelItem.key][locale]}</div>
+      <Button
+        type='primary'
+        className='!px-3 !h-7 !rounded-md !text-xs !font-medium'
+        onClick={handleClick}
+        disabled={loading}
+      >
+        {t('common.operation.getForFree')}
+      </Button>
+      <div className='mx-2 w-[1px] h-4 bg-black/5' />
+    </div>
+  )
+}
+
+export default FreeQuota

+ 13 - 0
web/app/components/header/account-setting/model-page/model-item/Setting.tsx

@@ -2,8 +2,10 @@ import type { FC } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useContext } from 'use-context-selector'
 import type { FormValue, Provider, ProviderConfigItem, ProviderWithConfig, ProviderWithQuota } from '../declarations'
+import { ProviderEnum } from '../declarations'
 import Indicator from '../../../indicator'
 import Selector from '../selector'
+import FreeQuota from './FreeQuota'
 import I18n from '@/context/i18n'
 import Button from '@/app/components/base/button'
 import { IS_CE_EDITION } from '@/config'
@@ -13,6 +15,7 @@ type SettingProps = {
   modelItem: ProviderConfigItem
   onOpenModal: (v?: FormValue) => void
   onOperate: (v: Record<string, any>) => void
+  onUpdate: () => void
 }
 
 const Setting: FC<SettingProps> = ({
@@ -20,6 +23,7 @@ const Setting: FC<SettingProps> = ({
   modelItem,
   onOpenModal,
   onOperate,
+  onUpdate,
 }) => {
   const { locale } = useContext(I18n)
   const { t } = useTranslation()
@@ -29,6 +33,15 @@ const Setting: FC<SettingProps> = ({
 
   return (
     <div className='flex items-center'>
+      {
+        (modelItem.key === ProviderEnum.minimax || modelItem.key === ProviderEnum.spark) && systemFree && !systemFree?.is_valid && !IS_CE_EDITION && (
+          <FreeQuota
+            modelItem={modelItem}
+            freeProvider={systemFree}
+            onUpdate={onUpdate}
+          />
+        )
+      }
       {
         modelItem.disable && !IS_CE_EDITION && (
           <div className='flex items-center text-xs text-gray-500'>

+ 2 - 0
web/app/components/header/account-setting/model-page/model-item/index.tsx

@@ -26,6 +26,7 @@ const ModelItem: FC<ModelItemProps> = ({
   modelItem,
   onOpenModal,
   onOperate,
+  onUpdate,
 }) => {
   const { locale } = useContext(I18n)
   const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithModels
@@ -47,6 +48,7 @@ const ModelItem: FC<ModelItemProps> = ({
           modelItem={modelItem}
           onOpenModal={onOpenModal}
           onOperate={onOperate}
+          onUpdate={onUpdate}
         />
       </div>
       {

+ 1 - 0
web/config/index.ts

@@ -120,3 +120,4 @@ export const VAR_ITEM_TEMPLATE = {
 export const appDefaultIconBackground = '#D5F5F6'
 
 export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList'
+export const SPARK_FREE_QUOTA_PENDING = 'sparkFreeQuotaPending'

+ 1 - 0
web/i18n/lang/app-overview.en.ts

@@ -23,6 +23,7 @@ const translation = {
       },
     },
     callTimes: 'Call times',
+    usedToken: 'Used token',
     setAPIBtn: 'Go to setup model provider',
     tryCloud: 'Or try the cloud version of Dify with free quote',
   },

+ 1 - 0
web/i18n/lang/app-overview.zh.ts

@@ -23,6 +23,7 @@ const translation = {
       },
     },
     callTimes: '调用次数',
+    usedToken: '使用 Tokens',
     setAPIBtn: '设置模型提供商',
     tryCloud: '或者尝试使用 Dify 的云版本并使用试用配额',
   },

+ 1 - 0
web/i18n/lang/common.en.ts

@@ -25,6 +25,7 @@ const translation = {
     download: 'Download',
     setup: 'Setup',
     getForFree: 'Get for free',
+    reload: 'Reload',
   },
   placeholder: {
     input: 'Please enter',

+ 1 - 0
web/i18n/lang/common.zh.ts

@@ -25,6 +25,7 @@ const translation = {
     download: '下载',
     setup: '设置',
     getForFree: '免费获取',
+    reload: '刷新',
   },
   placeholder: {
     input: '请输入',

+ 4 - 0
web/service/common.ts

@@ -169,3 +169,7 @@ export const fetchDefaultModal: Fetcher<BackendModel, string> = (url) => {
 export const updateDefaultModel: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => {
   return post(url, { body }) as Promise<CommonResponse>
 }
+
+export const submitFreeQuota: Fetcher<{ type: string; redirect_url?: string; result?: string }, string> = (url) => {
+  return post(url) as Promise<{ type: string; redirect_url?: string; result?: string }>
+}

+ 20 - 17
web/utils/format.ts

@@ -1,33 +1,36 @@
 /*
-* Formats a number with comma separators. 
+* Formats a number with comma separators.
  formatNumber(1234567) will return '1,234,567'
  formatNumber(1234567.89) will return '1,234,567.89'
 */
 export const formatNumber = (num: number | string) => {
-  if (!num) return num;
-  let parts = num.toString().split(".");
-  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
-  return parts.join(".");
+  if (!num)
+    return num
+  const parts = num.toString().split('.')
+  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
+  return parts.join('.')
 }
 
 export const formatFileSize = (num: number) => {
-  if (!num) return num;
-  const units = ['', 'K', 'M', 'G', 'T', 'P'];
-  let index = 0;
+  if (!num)
+    return num
+  const units = ['', 'K', 'M', 'G', 'T', 'P']
+  let index = 0
   while (num >= 1024 && index < units.length) {
-    num = num / 1024;
-    index++;
+    num = num / 1024
+    index++
   }
-  return num.toFixed(2) + `${units[index]}B`;
+  return `${num.toFixed(2)}${units[index]}B`
 }
 
 export const formatTime = (num: number) => {
-  if (!num) return num;
-  const units = ['sec', 'min', 'h'];
-  let index = 0;
+  if (!num)
+    return num
+  const units = ['sec', 'min', 'h']
+  let index = 0
   while (num >= 60 && index < units.length) {
-    num = num / 60;
-    index++;
+    num = num / 60
+    index++
   }
-  return `${num.toFixed(2)} ${units[index]}`;
+  return `${num.toFixed(2)} ${units[index]}`
 }