diff --git a/api/server/controllers/Balance.js b/api/server/controllers/Balance.js index 729afc7684..0361045c72 100644 --- a/api/server/controllers/Balance.js +++ b/api/server/controllers/Balance.js @@ -1,9 +1,24 @@ const Balance = require('~/models/Balance'); async function balanceController(req, res) { - const { tokenCredits: balance = '' } = - (await Balance.findOne({ user: req.user.id }, 'tokenCredits').lean()) ?? {}; - res.status(200).send('' + balance); + const balanceData = await Balance.findOne( + { user: req.user.id }, + '-_id tokenCredits autoRefillEnabled refillIntervalValue refillIntervalUnit lastRefill refillAmount', + ).lean(); + + if (!balanceData) { + return res.status(404).json({ error: 'Balance not found' }); + } + + // If auto-refill is not enabled, remove auto-refill related fields from the response + if (!balanceData.autoRefillEnabled) { + delete balanceData.refillIntervalValue; + delete balanceData.refillIntervalUnit; + delete balanceData.lastRefill; + delete balanceData.refillAmount; + } + + res.status(200).json(balanceData); } module.exports = balanceController; diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 1e2aa9b3d9..d017d6625c 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -16,7 +16,6 @@ type TLoginFormProps = { const LoginForm: React.FC = ({ onSubmit, startupConfig, error, setError }) => { const localize = useLocalize(); const { theme } = useContext(ThemeContext); - const { register, getValues, diff --git a/client/src/components/Nav/AccountSettings.tsx b/client/src/components/Nav/AccountSettings.tsx index 7d4e6576c2..921d4b0356 100644 --- a/client/src/components/Nav/AccountSettings.tsx +++ b/client/src/components/Nav/AccountSettings.tsx @@ -75,12 +75,10 @@ function AccountSettings() { {user?.email ?? localize('com_nav_user')} - {startupConfig?.balance?.enabled === true && - balanceQuery.data != null && - !isNaN(parseFloat(balanceQuery.data)) && ( + {startupConfig?.balance?.enabled === true && balanceQuery.data != null && ( <>
- {localize('com_nav_balance')}: {parseFloat(balanceQuery.data).toFixed(2)} + {localize('com_nav_balance')}: {balanceQuery.data.tokenCredits.toFixed(2)}
diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index 8ecae5a572..7bc1a8e75f 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -1,28 +1,31 @@ import React, { useState, useRef } from 'react'; import * as Tabs from '@radix-ui/react-tabs'; -import { MessageSquare, Command } from 'lucide-react'; +import { MessageSquare, Command, DollarSign } from 'lucide-react'; import { SettingsTabValues } from 'librechat-data-provider'; +import { useGetStartupConfig } from '~/data-provider'; import type { TDialogProps } from '~/common'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { GearIcon, DataIcon, SpeechIcon, UserIcon, ExperimentIcon } from '~/components/svg'; -import { General, Chat, Speech, Beta, Commands, Data, Account } from './SettingsTabs'; +import { General, Chat, Speech, Beta, Commands, Data, Account, Balance } from './SettingsTabs'; import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks'; import { cn } from '~/utils'; export default function Settings({ open, onOpenChange }: TDialogProps) { const isSmallScreen = useMediaQuery('(max-width: 767px)'); + const { data: startupConfig } = useGetStartupConfig(); const localize = useLocalize(); const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL); const tabRefs = useRef({}); const handleKeyDown = (event: React.KeyboardEvent) => { - const tabs = [ + const tabs: SettingsTabValues[] = [ SettingsTabValues.GENERAL, SettingsTabValues.CHAT, SettingsTabValues.BETA, SettingsTabValues.COMMANDS, SettingsTabValues.SPEECH, SettingsTabValues.DATA, + ...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []), SettingsTabValues.ACCOUNT, ]; const currentIndex = tabs.indexOf(activeTab); @@ -82,6 +85,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { icon: , label: 'com_nav_setting_data', }, + ...(startupConfig?.balance?.enabled + ? [ + { + value: SettingsTabValues.BALANCE, + icon: , + label: 'com_nav_setting_balance' as TranslationKeys, + }, + ] + : ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])), { value: SettingsTabValues.ACCOUNT, icon: , @@ -204,6 +216,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { + {startupConfig?.balance?.enabled && ( + + + + )} diff --git a/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx b/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx new file mode 100644 index 0000000000..f34e2bed23 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Balance/AutoRefillSettings.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { TranslationKeys, useLocalize } from '~/hooks'; +import { Label } from '~/components'; +import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings'; + +interface AutoRefillSettingsProps { + lastRefill: Date; + refillAmount: number; + refillIntervalUnit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; + refillIntervalValue: number; +} + +/** + * Adds a time interval to a given date. + * @param {Date} date - The starting date. + * @param {number} value - The numeric value of the interval. + * @param {'seconds'|'minutes'|'hours'|'days'|'weeks'|'months'} unit - The unit of time. + * @returns {Date} A new Date representing the starting date plus the interval. + */ +const addIntervalToDate = ( + date: Date, + value: number, + unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months', +): Date => { + const result = new Date(date); + switch (unit) { + case 'seconds': + result.setSeconds(result.getSeconds() + value); + break; + case 'minutes': + result.setMinutes(result.getMinutes() + value); + break; + case 'hours': + result.setHours(result.getHours() + value); + break; + case 'days': + result.setDate(result.getDate() + value); + break; + case 'weeks': + result.setDate(result.getDate() + value * 7); + break; + case 'months': + result.setMonth(result.getMonth() + value); + break; + default: + break; + } + return result; +}; + +const AutoRefillSettings: React.FC = ({ + lastRefill, + refillAmount, + refillIntervalUnit, + refillIntervalValue, +}) => { + const localize = useLocalize(); + + const lastRefillDate = lastRefill ? new Date(lastRefill) : null; + const nextRefill = lastRefillDate + ? addIntervalToDate(lastRefillDate, refillIntervalValue, refillIntervalUnit) + : null; + + // Return the localized unit based on singular/plural values + const getLocalizedIntervalUnit = ( + value: number, + unit: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months', + ): string => { + let key: TranslationKeys; + switch (unit) { + case 'seconds': + key = value === 1 ? 'com_nav_balance_second' : 'com_nav_balance_seconds'; + break; + case 'minutes': + key = value === 1 ? 'com_nav_balance_minute' : 'com_nav_balance_minutes'; + break; + case 'hours': + key = value === 1 ? 'com_nav_balance_hour' : 'com_nav_balance_hours'; + break; + case 'days': + key = value === 1 ? 'com_nav_balance_day' : 'com_nav_balance_days'; + break; + case 'weeks': + key = value === 1 ? 'com_nav_balance_week' : 'com_nav_balance_weeks'; + break; + case 'months': + key = value === 1 ? 'com_nav_balance_month' : 'com_nav_balance_months'; + break; + default: + key = 'com_nav_balance_seconds'; + } + return localize(key); + }; + + return ( +
+

{localize('com_nav_balance_auto_refill_settings')}

+
+ {localize('com_nav_balance_last_refill')} + {lastRefillDate ? lastRefillDate.toLocaleString() : '-'} +
+
+ {localize('com_nav_balance_refill_amount')} + {refillAmount !== undefined ? refillAmount : '-'} +
+
+ {localize('com_nav_balance_interval')} + + {localize('com_nav_balance_every')} {refillIntervalValue}{' '} + {getLocalizedIntervalUnit(refillIntervalValue, refillIntervalUnit)} + +
+
+ {/* Left Section: Label */} +
+ + +
+ + {/* Right Section: tokenCredits Value */} + + {nextRefill ? nextRefill.toLocaleString() : '-'} + +
+
+ ); +}; + +export default AutoRefillSettings; diff --git a/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx b/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx new file mode 100644 index 0000000000..18b4368e0b --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Balance/Balance.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { useGetStartupConfig, useGetUserBalance } from '~/data-provider'; +import { useAuthContext, useLocalize } from '~/hooks'; +import TokenCreditsItem from './TokenCreditsItem'; +import AutoRefillSettings from './AutoRefillSettings'; + +function Balance() { + const localize = useLocalize(); + const { isAuthenticated } = useAuthContext(); + const { data: startupConfig } = useGetStartupConfig(); + + const balanceQuery = useGetUserBalance({ + enabled: !!isAuthenticated && !!startupConfig?.balance?.enabled, + }); + const balanceData = balanceQuery.data; + + // Pull out all the fields we need, with safe defaults + const { + tokenCredits = 0, + autoRefillEnabled = false, + lastRefill, + refillAmount, + refillIntervalUnit, + refillIntervalValue, + } = balanceData ?? {}; + + // Check that all auto-refill props are present + const hasValidRefillSettings = + lastRefill !== undefined && + refillAmount !== undefined && + refillIntervalUnit !== undefined && + refillIntervalValue !== undefined; + + return ( +
+ {/* Token credits display */} + + + {/* Auto-refill display */} + {autoRefillEnabled ? ( + hasValidRefillSettings ? ( + + ) : ( +
+ {localize('com_nav_balance_auto_refill_error')} +
+ ) + ) : ( +
+ {localize('com_nav_balance_auto_refill_disabled')} +
+ )} +
+ ); +} + +export default React.memo(Balance); diff --git a/client/src/components/Nav/SettingsTabs/Balance/TokenCreditsItem.tsx b/client/src/components/Nav/SettingsTabs/Balance/TokenCreditsItem.tsx new file mode 100644 index 0000000000..3488872a34 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Balance/TokenCreditsItem.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useLocalize } from '~/hooks'; +import { Label } from '~/components'; +import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings'; + +interface TokenCreditsItemProps { + tokenCredits?: number; +} + +const TokenCreditsItem: React.FC = ({ tokenCredits }) => { + const localize = useLocalize(); + + return ( +
+ {/* Left Section: Label */} +
+ + +
+ + {/* Right Section: tokenCredits Value */} + + {tokenCredits !== undefined ? tokenCredits.toFixed(2) : '0.00'} + +
+ ); +}; + +export default TokenCreditsItem; diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 59d28f7675..380d9a7a6d 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -5,4 +5,5 @@ export { default as Beta } from './Beta/Beta'; export { default as Commands } from './Commands/Commands'; export { RevokeKeysButton } from './Data/RevokeKeysButton'; export { default as Account } from './Account/Account'; +export { default as Balance } from './Balance/Balance'; export { default as Speech } from './Speech/Speech'; diff --git a/client/src/data-provider/Misc/queries.ts b/client/src/data-provider/Misc/queries.ts index b5127068f4..112b36a190 100644 --- a/client/src/data-provider/Misc/queries.ts +++ b/client/src/data-provider/Misc/queries.ts @@ -19,10 +19,10 @@ export const useGetBannerQuery = ( }; export const useGetUserBalance = ( - config?: UseQueryOptions, -): QueryObserverResult => { + config?: UseQueryOptions, +): QueryObserverResult => { const queriesEnabled = useRecoilValue(store.queriesEnabled); - return useQuery([QueryKeys.balance], () => dataService.getUserBalance(), { + return useQuery([QueryKeys.balance], () => dataService.getUserBalance(), { refetchOnWindowFocus: true, refetchOnReconnect: true, refetchOnMount: true, diff --git a/client/src/hooks/useLocalize.ts b/client/src/hooks/useLocalize.ts index bae7480e7c..6b574d25b1 100644 --- a/client/src/hooks/useLocalize.ts +++ b/client/src/hooks/useLocalize.ts @@ -18,4 +18,4 @@ export default function useLocalize() { }, [lang, i18n]); return (phraseKey: TranslationKeys, options?: TOptions) => t(phraseKey, options); -} \ No newline at end of file +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 12e66f5630..924e7e070e 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -303,6 +303,27 @@ "com_nav_auto_transcribe_audio": "Auto transcribe audio", "com_nav_automatic_playback": "Autoplay Latest Message", "com_nav_balance": "Balance", + "com_nav_balance_auto_refill_settings": "Auto-Refill Settings", + "com_nav_balance_auto_refill_disabled": "Auto-Refill is disabled.", + "com_nav_balance_auto_refill_error": "Error loading auto-refill settings.", + "com_nav_balance_last_refill": "Last Refill:", + "com_nav_balance_refill_amount": "Refill Amount:", + "com_nav_balance_interval": "Interval:", + "com_nav_balance_next_refill": "Next Refill:", + "com_nav_balance_next_refill_info": "The next refill will occur automatically only when both conditions are met: the designated time interval has passed since the last refill, and sending a prompt would cause your balance to drop below zero.", + "com_nav_balance_every": "Every", + "com_nav_balance_second": "second", + "com_nav_balance_seconds": "seconds", + "com_nav_balance_minute": "minute", + "com_nav_balance_minutes": "minutes", + "com_nav_balance_hour": "hour", + "com_nav_balance_hours": "hours", + "com_nav_balance_day": "day", + "com_nav_balance_days": "days", + "com_nav_balance_week": "week", + "com_nav_balance_weeks": "weeks", + "com_nav_balance_month": "month", + "com_nav_balance_months": "months", "com_nav_browser": "Browser", "com_nav_center_chat_input": "Center Chat Input on Welcome Screen", "com_nav_change_picture": "Change picture", @@ -416,6 +437,7 @@ "com_nav_search_placeholder": "Search messages", "com_nav_send_message": "Send message", "com_nav_setting_account": "Account", + "com_nav_setting_balance": "Balance", "com_nav_setting_beta": "Beta features", "com_nav_setting_chat": "Chat", "com_nav_setting_data": "Data controls", @@ -926,4 +948,4 @@ "com_ui_zoom": "Zoom", "com_user_message": "You", "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." -} \ No newline at end of file +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 0da6c61835..005b50add6 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1276,6 +1276,10 @@ export enum SettingsTabValues { * Tab for Data Controls */ DATA = 'data', + /** + * Tab for Balance Settings + */ + BALANCE = 'balance', /** * Tab for Account Settings */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 737b252215..8494ce4e95 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -93,7 +93,7 @@ export function getUser(): Promise { return request.get(endpoints.user()); } -export function getUserBalance(): Promise { +export function getUserBalance(): Promise { return request.get(endpoints.balance()); } diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 1967b0a1f8..dff8e904cb 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -546,3 +546,13 @@ export type TAcceptTermsResponse = { }; export type TBannerResponse = TBanner | null; + +export type TBalanceResponse = { + tokenCredits: number; + // Automatic refill settings + autoRefillEnabled: boolean; + refillIntervalValue?: number; + refillIntervalUnit?: 'seconds' | 'minutes' | 'hours' | 'days' | 'weeks' | 'months'; + lastRefill?: Date; + refillAmount?: number; +};