Co-authored-by: Joel <iamjoel007@gmail.com>tags/0.3.13
| @@ -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} /> | |||
| @@ -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(() => { | |||
| @@ -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> | |||
| @@ -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', | |||
| }, | |||
| }, | |||
| ], | |||
| @@ -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[] | |||
| } | |||
| @@ -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 | |||
| @@ -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'> | |||
| @@ -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> | |||
| { | |||
| @@ -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' | |||
| @@ -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', | |||
| }, | |||
| @@ -23,6 +23,7 @@ const translation = { | |||
| }, | |||
| }, | |||
| callTimes: '调用次数', | |||
| usedToken: '使用 Tokens', | |||
| setAPIBtn: '设置模型提供商', | |||
| tryCloud: '或者尝试使用 Dify 的云版本并使用试用配额', | |||
| }, | |||
| @@ -25,6 +25,7 @@ const translation = { | |||
| download: 'Download', | |||
| setup: 'Setup', | |||
| getForFree: 'Get for free', | |||
| reload: 'Reload', | |||
| }, | |||
| placeholder: { | |||
| input: 'Please enter', | |||
| @@ -25,6 +25,7 @@ const translation = { | |||
| download: '下载', | |||
| setup: '设置', | |||
| getForFree: '免费获取', | |||
| reload: '刷新', | |||
| }, | |||
| placeholder: { | |||
| input: '请输入', | |||
| @@ -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 }> | |||
| } | |||
| @@ -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]}` | |||
| } | |||