feat(frontend): show remaining credits (#2495)

# Description

Please include a summary of the changes and the related issue. Please
also include relevant motivation and context.

## Checklist before requesting a review

Please delete options that are not relevant.

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented hard-to-understand areas
- [ ] I have ideally added tests that prove my fix is effective or that
my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged

## Screenshots (if appropriate):
This commit is contained in:
Antoine Dewez 2024-04-27 13:44:48 +02:00 committed by GitHub
parent 6cde04b65f
commit e7ce2fa54b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 120 additions and 7 deletions

View File

@ -98,7 +98,7 @@ def update_user_usage(usage: UserUsage, user_settings, cost: int = 100):
if int(montly_usage + cost) > int(monthly_chat_credit): if int(montly_usage + cost) > int(monthly_chat_credit):
raise HTTPException( raise HTTPException(
status_code=429, # pyright: ignore reportPrivateUsage=none status_code=429, # pyright: ignore reportPrivateUsage=none
detail=f"You have reached your monthly chat limit of {monthly_chat_credit} requests per months. Please upgrade your plan to increase your daily chat limit.", detail=f"You have reached your monthly chat limit of {monthly_chat_credit} requests per months. Please upgrade your plan to increase your monthly chat limit.",
) )
else: else:
usage.handle_increment_user_request_count(date, cost) usage.handle_increment_user_request_count(date, cost)

View File

@ -78,3 +78,16 @@ def get_user_identity_route(
Get user identity. Get user identity.
""" """
return user_repository.get_user_identity(current_user.id) return user_repository.get_user_identity(current_user.id)
@user_router.get(
"/user/credits",
dependencies=[Depends(AuthBearer())],
tags=["User"],
)
def get_user_credits(
current_user: UserIdentity = Depends(get_current_user),
) -> int:
"""
Get user remaining credits.
"""
return user_repository.get_user_credits(current_user.id)

View File

@ -1,6 +1,8 @@
import time
from models.settings import get_supabase_client from models.settings import get_supabase_client
from modules.user.entity.user_identity import UserIdentity from modules.user.entity.user_identity import UserIdentity
from modules.user.repository.users_interface import UsersInterface from modules.user.repository.users_interface import UsersInterface
from modules.user.service import user_usage
class Users(UsersInterface): class Users(UsersInterface):
@ -73,3 +75,11 @@ class Users(UsersInterface):
"get_user_email_by_user_id", {"user_id": str(user_id)} "get_user_email_by_user_id", {"user_id": str(user_id)}
).execute() ).execute()
return response.data[0]["email"] return response.data[0]["email"]
def get_user_credits(self, user_id):
user_usage_instance = user_usage.UserUsage(id=user_id)
user_monthly_usage = user_usage_instance.get_user_monthly_usage(time.strftime("%Y%m%d"))
monthly_chat_credit = self.db.from_("user_settings").select("monthly_chat_credit").filter("user_id", "eq", str(user_id)).execute().data[0]["monthly_chat_credit"]
return monthly_chat_credit - user_monthly_usage

View File

@ -44,3 +44,10 @@ class UsersInterface(ABC):
Get the user email by user id Get the user email by user id
""" """
pass pass
@abstractmethod
def get_user_credits(self, user_id: UUID) -> int:
"""
Get user remaining credits
"""
pass

View File

@ -54,7 +54,7 @@ class UserUsage(UserIdentity):
def get_user_monthly_usage(self, date): def get_user_monthly_usage(self, date):
""" """
Fetch the user daily usage from the database Fetch the user monthly usage from the database
""" """
posthog = PostHogSettings() posthog = PostHogSettings()
request = self.supabase_db.get_user_requests_count_for_month(self.id, date) request = self.supabase_db.get_user_requests_count_for_month(self.id, date)

View File

@ -7,9 +7,11 @@ import { useTranslation } from "react-i18next";
import { CHATS_DATA_KEY } from "@/lib/api/chat/config"; import { CHATS_DATA_KEY } from "@/lib/api/chat/config";
import { useChatApi } from "@/lib/api/chat/useChatApi"; import { useChatApi } from "@/lib/api/chat/useChatApi";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { useChatContext } from "@/lib/context"; import { useChatContext } from "@/lib/context";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext"; import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useSearchModalContext } from "@/lib/context/SearchModalProvider/hooks/useSearchModalContext"; import { useSearchModalContext } from "@/lib/context/SearchModalProvider/hooks/useSearchModalContext";
import { useUserSettingsContext } from "@/lib/context/UserSettingsProvider/hooks/useUserSettingsContext";
import { getChatNameFromQuestion } from "@/lib/helpers/getChatNameFromQuestion"; import { getChatNameFromQuestion } from "@/lib/helpers/getChatNameFromQuestion";
import { useToast } from "@/lib/hooks"; import { useToast } from "@/lib/hooks";
import { useOnboarding } from "@/lib/hooks/useOnboarding"; import { useOnboarding } from "@/lib/hooks/useOnboarding";
@ -42,6 +44,8 @@ export const useChat = () => {
chatConfig: { model, maxTokens, temperature }, chatConfig: { model, maxTokens, temperature },
} = useLocalStorageChatConfig(); } = useLocalStorageChatConfig();
const { isVisible } = useSearchModalContext(); const { isVisible } = useSearchModalContext();
const { getUserCredits } = useUserApi();
const { setRemainingCredits } = useUserSettingsContext();
const { addStreamQuestion } = useQuestion(); const { addStreamQuestion } = useQuestion();
const { t } = useTranslation(["chat"]); const { t } = useTranslation(["chat"]);
@ -95,6 +99,10 @@ export const useChat = () => {
callback?.(); callback?.();
await addStreamQuestion(currentChatId, chatQuestion); await addStreamQuestion(currentChatId, chatQuestion);
void (async () => {
const res = await getUserCredits();
setRemainingCredits(res);
})();
} catch (error) { } catch (error) {
console.error({ error }); console.error({ error });

View File

@ -82,6 +82,22 @@
top: 0; top: 0;
left: 0; left: 0;
.main_message_wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.main_message {
display: flex;
flex-direction: column;
.bolder {
font-weight: bold;
}
}
}
.first_brain_button { .first_brain_button {
position: absolute; position: absolute;
right: Spacings.$spacing07; right: Spacings.$spacing07;

View File

@ -86,12 +86,22 @@ const Search = (): JSX.Element => {
!userIdentityData?.onboarded && !userIdentityData?.onboarded &&
!!isUserDataFetched && ( !!isUserDataFetched && (
<div className={styles.onboarding_overlay}> <div className={styles.onboarding_overlay}>
<div className={styles.first_brain_button}> <div className={styles.main_message_wrapper}>
<MessageInfoBox type="tutorial"> <MessageInfoBox type="tutorial">
<span> <div className={styles.main_message}>
Press the following button to create your first brain <span>Welcome {userIdentityData?.username}!</span>
</span> <span>
We will guide you through your quivr adventure and the
creation of your first brain.
</span>
<span className={styles.bolder}>
First, Press the Create Brain button on the top right corner
to create your first brain.
</span>
</div>
</MessageInfoBox> </MessageInfoBox>
</div>
<div className={styles.first_brain_button}>
<QuivrButton <QuivrButton
iconName="brain" iconName="brain"
label="Create Brain" label="Create Brain"

View File

@ -2,6 +2,7 @@ import { useAxios } from "@/lib/hooks";
import { import {
getUser, getUser,
getUserCredits,
getUserIdentity, getUserIdentity,
updateUserIdentity, updateUserIdentity,
UserIdentityUpdatableProperties, UserIdentityUpdatableProperties,
@ -17,5 +18,6 @@ export const useUserApi = () => {
) => updateUserIdentity(userIdentityUpdatableProperties, axiosInstance), ) => updateUserIdentity(userIdentityUpdatableProperties, axiosInstance),
getUserIdentity: async () => getUserIdentity(axiosInstance), getUserIdentity: async () => getUserIdentity(axiosInstance),
getUser: async () => getUser(axiosInstance), getUser: async () => getUser(axiosInstance),
getUserCredits: async () => getUserCredits(axiosInstance),
}; };
}; };

View File

@ -53,3 +53,7 @@ export const getUserIdentity = async (
export const getUser = async ( export const getUser = async (
axiosInstance: AxiosInstance axiosInstance: AxiosInstance
): Promise<UserStats> => (await axiosInstance.get<UserStats>("/user")).data; ): Promise<UserStats> => (await axiosInstance.get<UserStats>("/user")).data;
export const getUserCredits = async (
axiosInstance: AxiosInstance
): Promise<number> => (await axiosInstance.get<number>("/user/credits")).data;

View File

@ -30,7 +30,18 @@
.buttons_wrapper { .buttons_wrapper {
display: flex; display: flex;
gap: Spacings.$spacing03; gap: Spacings.$spacing04;
align-items: center; align-items: center;
.credits {
display: flex;
align-items: center;
gap: Spacings.$spacing02;
.number {
font-size: Typography.$tiny;
color: var(--gold);
}
}
} }
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { useMenuContext } from "@/lib/context/MenuProvider/hooks/useMenuContext"; import { useMenuContext } from "@/lib/context/MenuProvider/hooks/useMenuContext";
import { useUserSettingsContext } from "@/lib/context/UserSettingsProvider/hooks/useUserSettingsContext"; import { useUserSettingsContext } from "@/lib/context/UserSettingsProvider/hooks/useUserSettingsContext";
import { ButtonType } from "@/lib/types/QuivrButton"; import { ButtonType } from "@/lib/types/QuivrButton";
@ -23,6 +24,8 @@ export const PageHeader = ({
const { isOpened } = useMenuContext(); const { isOpened } = useMenuContext();
const { isDarkMode, setIsDarkMode } = useUserSettingsContext(); const { isDarkMode, setIsDarkMode } = useUserSettingsContext();
const [lightModeIconName, setLightModeIconName] = useState("sun"); const [lightModeIconName, setLightModeIconName] = useState("sun");
const { remainingCredits, setRemainingCredits } = useUserSettingsContext();
const { getUserCredits } = useUserApi();
const toggleTheme = () => { const toggleTheme = () => {
setIsDarkMode(!isDarkMode); setIsDarkMode(!isDarkMode);
@ -32,6 +35,13 @@ export const PageHeader = ({
setLightModeIconName(isDarkMode ? "sun" : "moon"); setLightModeIconName(isDarkMode ? "sun" : "moon");
}, [isDarkMode]); }, [isDarkMode]);
useEffect(() => {
void (async () => {
const res = await getUserCredits();
setRemainingCredits(res);
})();
}, []);
return ( return (
<div className={styles.page_header_wrapper}> <div className={styles.page_header_wrapper}>
<div className={`${styles.left} ${!isOpened ? styles.menu_closed : ""}`}> <div className={`${styles.left} ${!isOpened ? styles.menu_closed : ""}`}>
@ -49,6 +59,12 @@ export const PageHeader = ({
hidden={button.hidden} hidden={button.hidden}
/> />
))} ))}
{remainingCredits !== null && (
<div className={styles.credits}>
<span className={styles.number}>{remainingCredits}</span>
<Icon name="coin" color="gold" size="normal"></Icon>
</div>
)}
<Icon <Icon
name={lightModeIconName} name={lightModeIconName}
color="black" color="black"

View File

@ -1,10 +1,13 @@
import { createContext, useEffect, useState } from "react"; import { createContext, useEffect, useState } from "react";
import { useUserApi } from "@/lib/api/user/useUserApi";
import { parseBoolean } from "@/lib/helpers/parseBoolean"; import { parseBoolean } from "@/lib/helpers/parseBoolean";
type UserSettingsContextType = { type UserSettingsContextType = {
isDarkMode: boolean; isDarkMode: boolean;
setIsDarkMode: React.Dispatch<React.SetStateAction<boolean>>; setIsDarkMode: React.Dispatch<React.SetStateAction<boolean>>;
remainingCredits: number | null;
setRemainingCredits: React.Dispatch<React.SetStateAction<number | null>>;
}; };
export const UserSettingsContext = createContext< export const UserSettingsContext = createContext<
@ -16,6 +19,8 @@ export const UserSettingsProvider = ({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}): JSX.Element => { }): JSX.Element => {
const { getUserCredits } = useUserApi();
const [remainingCredits, setRemainingCredits] = useState<number | null>(null);
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => { const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return true; return true;
@ -24,6 +29,13 @@ export const UserSettingsProvider = ({
return true; return true;
}); });
useEffect(() => {
void (async () => {
const res = await getUserCredits();
setRemainingCredits(res);
})();
}, []);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const prefersDarkMode = window.matchMedia( const prefersDarkMode = window.matchMedia(
@ -66,6 +78,8 @@ export const UserSettingsProvider = ({
value={{ value={{
isDarkMode, isDarkMode,
setIsDarkMode, setIsDarkMode,
remainingCredits,
setRemainingCredits,
}} }}
> >
{children} {children}

View File

@ -1,4 +1,5 @@
import { AiOutlineLoading3Quarters } from "react-icons/ai"; import { AiOutlineLoading3Quarters } from "react-icons/ai";
import { BiCoin } from "react-icons/bi";
import { import {
BsArrowRightShort, BsArrowRightShort,
BsChatLeftText, BsChatLeftText,
@ -90,6 +91,7 @@ export const iconList: { [name: string]: IconType } = {
chevronLeft: LuChevronLeft, chevronLeft: LuChevronLeft,
chevronRight: LuChevronRight, chevronRight: LuChevronRight,
close: IoMdClose, close: IoMdClose,
coin: BiCoin,
copy: LuCopy, copy: LuCopy,
custom: MdDashboardCustomize, custom: MdDashboardCustomize,
delete: MdDeleteOutline, delete: MdDeleteOutline,