Co-authored-by: Joel <iamjoel007@gmail.com>tags/0.3.13
| import React from 'react' | import React from 'react' | ||||
| import { EditKeyPopover } from './welcome-banner' | |||||
| import ChartView from './chartView' | import ChartView from './chartView' | ||||
| import CardView from './cardView' | import CardView from './cardView' | ||||
| import { getLocaleOnServer } from '@/i18n/server' | import { getLocaleOnServer } from '@/i18n/server' | ||||
| <ApikeyInfoPanel /> | <ApikeyInfoPanel /> | ||||
| <div className='flex flex-row items-center justify-between mb-4 text-xl text-gray-900'> | <div className='flex flex-row items-center justify-between mb-4 text-xl text-gray-900'> | ||||
| {t('overview.title')} | {t('overview.title')} | ||||
| <EditKeyPopover /> | |||||
| </div> | </div> | ||||
| <CardView appId={appId} /> | <CardView appId={appId} /> | ||||
| <ChartView appId={appId} /> | <ChartView appId={appId} /> |
| import useSWRInfinite from 'swr/infinite' | import useSWRInfinite from 'swr/infinite' | ||||
| import { debounce } from 'lodash-es' | import { debounce } from 'lodash-es' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useSearchParams } from 'next/navigation' | |||||
| import AppCard from './AppCard' | import AppCard from './AppCard' | ||||
| import NewAppCard from './NewAppCard' | import NewAppCard from './NewAppCard' | ||||
| import type { AppListResponse } from '@/models/app' | import type { AppListResponse } from '@/models/app' | ||||
| import { fetchAppList } from '@/service/apps' | import { fetchAppList } from '@/service/apps' | ||||
| import { useSelector } from '@/context/app-context' | 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) => { | const getKey = (pageIndex: number, previousPageData: AppListResponse) => { | ||||
| if (!pageIndex || previousPageData.has_more) | if (!pageIndex || previousPageData.has_more) | ||||
| const loadingStateRef = useRef(false) | const loadingStateRef = useRef(false) | ||||
| const pageContainerRef = useSelector(state => state.pageContainerRef) | const pageContainerRef = useSelector(state => state.pageContainerRef) | ||||
| const anchorRef = useRef<HTMLAnchorElement>(null) | const anchorRef = useRef<HTMLAnchorElement>(null) | ||||
| const searchParams = useSearchParams() | |||||
| useEffect(() => { | useEffect(() => { | ||||
| document.title = `${t('app.title')} - Dify` | document.title = `${t('app.title')} - Dify` | ||||
| localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) | localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) | ||||
| mutate() | 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(() => { | useEffect(() => { |
| import React, { useState } from 'react' | import React, { useState } from 'react' | ||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import cn from 'classnames' | import cn from 'classnames' | ||||
| import useSWR from 'swr' | |||||
| import Progress from './progress' | import Progress from './progress' | ||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import { LinkExternal02, XClose } from '@/app/components/base/icons/src/vender/line/general' | import { LinkExternal02, XClose } from '@/app/components/base/icons/src/vender/line/general' | ||||
| import AccountSetting from '@/app/components/header/account-setting' | import AccountSetting from '@/app/components/header/account-setting' | ||||
| import { fetchTenantInfo } from '@/service/common' | |||||
| import { IS_CE_EDITION } from '@/config' | import { IS_CE_EDITION } from '@/config' | ||||
| import { useProviderContext } from '@/context/provider-context' | import { useProviderContext } from '@/context/provider-context' | ||||
| import { formatNumber } from '@/utils/format' | |||||
| const APIKeyInfoPanel: FC = () => { | const APIKeyInfoPanel: FC = () => { | ||||
| const isCloud = !IS_CE_EDITION | const isCloud = !IS_CE_EDITION | ||||
| const { providers }: any = useProviderContext() | |||||
| const { textGenerationModelList } = useProviderContext() | |||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const [isShow, setIsShow] = useState(true) | 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 | return null | ||||
| // first show in trail and not used exhausted, else find the exhausted | // 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, ''] | return [0, 0, ''] | ||||
| let used = 0 | let used = 0 | ||||
| let total = 0 | let total = 0 | ||||
| let unit = 'times' | |||||
| let trailProviderName = '' | let trailProviderName = '' | ||||
| let hasFoundNotExhausted = false | let hasFoundNotExhausted = false | ||||
| Object.keys(providers).forEach((providerName) => { | |||||
| textGenerationModelList?.filter(({ model_provider: provider }) => { | |||||
| return provider.quota_type === 'trial' | |||||
| }).forEach(({ model_provider: provider }) => { | |||||
| if (hasFoundNotExhausted) | if (hasFoundNotExhausted) | ||||
| return | 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 usedPercent = Math.round(used / total * 100) | ||||
| const exhausted = isCloud && usedPercent === 100 | const exhausted = isCloud && usedPercent === 100 | ||||
| {isCloud && ( | {isCloud && ( | ||||
| <div className='my-5'> | <div className='my-5'> | ||||
| <div className='flex items-center h-5 space-x-2 text-sm text-gray-700 font-medium'> | <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>·</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> | </div> | ||||
| <Progress className='mt-2' value={usedPercent} /> | <Progress className='mt-2' value={usedPercent} /> | ||||
| </div> | </div> |
| }, | }, | ||||
| { | { | ||||
| type: 'text', | type: 'text', | ||||
| key: 'api_key', | |||||
| key: 'api_secret', | |||||
| required: true, | required: true, | ||||
| label: { | label: { | ||||
| 'en': 'API Key', | |||||
| 'zh-Hans': 'API Key', | |||||
| 'en': 'API Secret', | |||||
| 'zh-Hans': 'API Secret', | |||||
| }, | }, | ||||
| placeholder: { | placeholder: { | ||||
| 'en': 'Enter your API key here', | |||||
| 'zh-Hans': '在此输入您的 API Key', | |||||
| 'en': 'Enter your API Secret here', | |||||
| 'zh-Hans': '在此输入您的 API Secret', | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| type: 'text', | type: 'text', | ||||
| key: 'api_secret', | |||||
| key: 'api_key', | |||||
| required: true, | required: true, | ||||
| label: { | label: { | ||||
| 'en': 'API Secret', | |||||
| 'zh-Hans': 'API Secret', | |||||
| 'en': 'API Key', | |||||
| 'zh-Hans': 'API Key', | |||||
| }, | }, | ||||
| placeholder: { | placeholder: { | ||||
| 'en': 'Enter your API Secret here', | |||||
| 'zh-Hans': '在此输入您的 API Secret', | |||||
| 'en': 'Enter your API key here', | |||||
| 'zh-Hans': '在此输入您的 API Key', | |||||
| }, | }, | ||||
| }, | }, | ||||
| ], | ], |
| model_provider: { | model_provider: { | ||||
| provider_name: ProviderEnum | provider_name: ProviderEnum | ||||
| provider_type: PreferredProviderTypeEnum | provider_type: PreferredProviderTypeEnum | ||||
| quota_type: 'trial' | 'paid' | |||||
| quota_unit: 'times' | 'tokens' | |||||
| quota_used: number | |||||
| quota_limit: number | |||||
| } | } | ||||
| features: ModelFeature[] | features: ModelFeature[] | ||||
| } | } |
| 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 |
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||
| import { useContext } from 'use-context-selector' | import { useContext } from 'use-context-selector' | ||||
| import type { FormValue, Provider, ProviderConfigItem, ProviderWithConfig, ProviderWithQuota } from '../declarations' | import type { FormValue, Provider, ProviderConfigItem, ProviderWithConfig, ProviderWithQuota } from '../declarations' | ||||
| import { ProviderEnum } from '../declarations' | |||||
| import Indicator from '../../../indicator' | import Indicator from '../../../indicator' | ||||
| import Selector from '../selector' | import Selector from '../selector' | ||||
| import FreeQuota from './FreeQuota' | |||||
| import I18n from '@/context/i18n' | import I18n from '@/context/i18n' | ||||
| import Button from '@/app/components/base/button' | import Button from '@/app/components/base/button' | ||||
| import { IS_CE_EDITION } from '@/config' | import { IS_CE_EDITION } from '@/config' | ||||
| modelItem: ProviderConfigItem | modelItem: ProviderConfigItem | ||||
| onOpenModal: (v?: FormValue) => void | onOpenModal: (v?: FormValue) => void | ||||
| onOperate: (v: Record<string, any>) => void | onOperate: (v: Record<string, any>) => void | ||||
| onUpdate: () => void | |||||
| } | } | ||||
| const Setting: FC<SettingProps> = ({ | const Setting: FC<SettingProps> = ({ | ||||
| modelItem, | modelItem, | ||||
| onOpenModal, | onOpenModal, | ||||
| onOperate, | onOperate, | ||||
| onUpdate, | |||||
| }) => { | }) => { | ||||
| const { locale } = useContext(I18n) | const { locale } = useContext(I18n) | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| return ( | return ( | ||||
| <div className='flex items-center'> | <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 && ( | modelItem.disable && !IS_CE_EDITION && ( | ||||
| <div className='flex items-center text-xs text-gray-500'> | <div className='flex items-center text-xs text-gray-500'> |
| modelItem, | modelItem, | ||||
| onOpenModal, | onOpenModal, | ||||
| onOperate, | onOperate, | ||||
| onUpdate, | |||||
| }) => { | }) => { | ||||
| const { locale } = useContext(I18n) | const { locale } = useContext(I18n) | ||||
| const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithModels | const custom = currentProvider?.providers.find(p => p.provider_type === 'custom') as ProviderWithModels | ||||
| modelItem={modelItem} | modelItem={modelItem} | ||||
| onOpenModal={onOpenModal} | onOpenModal={onOpenModal} | ||||
| onOperate={onOperate} | onOperate={onOperate} | ||||
| onUpdate={onUpdate} | |||||
| /> | /> | ||||
| </div> | </div> | ||||
| { | { |
| export const appDefaultIconBackground = '#D5F5F6' | export const appDefaultIconBackground = '#D5F5F6' | ||||
| export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList' | export const NEED_REFRESH_APP_LIST_KEY = 'needRefreshAppList' | ||||
| export const SPARK_FREE_QUOTA_PENDING = 'sparkFreeQuotaPending' |
| }, | }, | ||||
| }, | }, | ||||
| callTimes: 'Call times', | callTimes: 'Call times', | ||||
| usedToken: 'Used token', | |||||
| setAPIBtn: 'Go to setup model provider', | setAPIBtn: 'Go to setup model provider', | ||||
| tryCloud: 'Or try the cloud version of Dify with free quote', | tryCloud: 'Or try the cloud version of Dify with free quote', | ||||
| }, | }, |
| }, | }, | ||||
| }, | }, | ||||
| callTimes: '调用次数', | callTimes: '调用次数', | ||||
| usedToken: '使用 Tokens', | |||||
| setAPIBtn: '设置模型提供商', | setAPIBtn: '设置模型提供商', | ||||
| tryCloud: '或者尝试使用 Dify 的云版本并使用试用配额', | tryCloud: '或者尝试使用 Dify 的云版本并使用试用配额', | ||||
| }, | }, |
| download: 'Download', | download: 'Download', | ||||
| setup: 'Setup', | setup: 'Setup', | ||||
| getForFree: 'Get for free', | getForFree: 'Get for free', | ||||
| reload: 'Reload', | |||||
| }, | }, | ||||
| placeholder: { | placeholder: { | ||||
| input: 'Please enter', | input: 'Please enter', |
| download: '下载', | download: '下载', | ||||
| setup: '设置', | setup: '设置', | ||||
| getForFree: '免费获取', | getForFree: '免费获取', | ||||
| reload: '刷新', | |||||
| }, | }, | ||||
| placeholder: { | placeholder: { | ||||
| input: '请输入', | input: '请输入', |
| export const updateDefaultModel: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => { | export const updateDefaultModel: Fetcher<CommonResponse, { url: string; body: any }> = ({ url, body }) => { | ||||
| return post(url, { body }) as Promise<CommonResponse> | 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 }> | |||||
| } |
| /* | /* | ||||
| * Formats a number with comma separators. | |||||
| * Formats a number with comma separators. | |||||
| formatNumber(1234567) will return '1,234,567' | formatNumber(1234567) will return '1,234,567' | ||||
| formatNumber(1234567.89) will return '1,234,567.89' | formatNumber(1234567.89) will return '1,234,567.89' | ||||
| */ | */ | ||||
| export const formatNumber = (num: number | string) => { | 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) => { | 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) { | 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) => { | 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) { | 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]}` | |||||
| } | } |