diff --git a/backend/models/brain_entity.py b/backend/models/brain_entity.py index f98e0b87d7b2..a101af601076 100644 --- a/backend/models/brain_entity.py +++ b/backend/models/brain_entity.py @@ -33,3 +33,10 @@ class MinimalBrainEntity(BaseModel): name: str rights: RoleEnum status: str + + +class PublicBrain(BaseModel): + id: UUID + name: str + description: Optional[str] + number_of_subscribers: int = 0 diff --git a/backend/models/databases/supabase/brains.py b/backend/models/databases/supabase/brains.py index 32af7249a4e0..5389d0848dff 100644 --- a/backend/models/databases/supabase/brains.py +++ b/backend/models/databases/supabase/brains.py @@ -2,7 +2,7 @@ from uuid import UUID from logger import get_logger -from models.brain_entity import BrainEntity, MinimalBrainEntity +from models.brain_entity import BrainEntity, MinimalBrainEntity, PublicBrain from models.databases.repository import Repository from pydantic import BaseModel @@ -75,6 +75,24 @@ def get_user_brains(self, user_id) -> list[MinimalBrainEntity]: user_brains[-1].rights = item["rights"] return user_brains + def get_public_brains(self) -> list[PublicBrain]: + response = ( + self.db.from_("brains") + .select("id:brain_id, name, description") + .filter("status", "eq", "public") + .execute() + ) + public_brains: list[PublicBrain] = [] + for item in response.data: + brain = PublicBrain( + id=item["id"], + name=item["name"], + description=item["description"], + ) + brain.number_of_subscribers = self.get_brain_subscribers_count(brain.id) + public_brains.append(brain) + return public_brains + def get_brain_for_user(self, user_id, brain_id) -> MinimalBrainEntity | None: response = ( self.db.from_("brains_users") @@ -274,3 +292,16 @@ def get_brain_by_id(self, brain_id: UUID) -> BrainEntity | None: return None return BrainEntity(**response[0]) + + def get_brain_subscribers_count(self, brain_id: UUID) -> int: + response = ( + self.db.from_("brains_users") + .select( + "count", + ) + .filter("brain_id", "eq", str(brain_id)) + .execute() + ).data + if len(response) == 0: + raise ValueError(f"Brain with id {brain_id} does not exist.") + return response[0]["count"] diff --git a/backend/repository/brain/create_brain.py b/backend/repository/brain/create_brain.py index 84d7665aba27..e722dcdcc68e 100644 --- a/backend/repository/brain/create_brain.py +++ b/backend/repository/brain/create_brain.py @@ -1,5 +1,5 @@ -from models.databases.supabase.brains import CreateBrainProperties from models import BrainEntity, get_supabase_db +from models.databases.supabase.brains import CreateBrainProperties def create_brain(brain: CreateBrainProperties) -> BrainEntity: diff --git a/backend/repository/brain/get_public_brains.py b/backend/repository/brain/get_public_brains.py new file mode 100644 index 000000000000..2f93f5bed093 --- /dev/null +++ b/backend/repository/brain/get_public_brains.py @@ -0,0 +1,7 @@ +from models import get_supabase_db +from models.brain_entity import PublicBrain + + +def get_public_brains() -> list[PublicBrain]: + supabase_db = get_supabase_db() + return supabase_db.get_public_brains() diff --git a/backend/routes/brain_routes.py b/backend/routes/brain_routes.py index 24d7dc374938..fbe627af08a6 100644 --- a/backend/routes/brain_routes.py +++ b/backend/routes/brain_routes.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from logger import get_logger from models import UserIdentity, UserUsage +from models.brain_entity import PublicBrain from models.databases.supabase.brains import ( BrainQuestionRequest, BrainUpdatableProperties, @@ -20,6 +21,7 @@ set_as_default_brain_for_user, update_brain_by_id, ) +from repository.brain.get_public_brains import get_public_brains from repository.prompt import delete_prompt_by_id, get_prompt_by_id from routes.authorizations.brain_authorization import has_brain_authorization @@ -48,6 +50,16 @@ async def brain_endpoint( return {"brains": brains} +@brain_router.get( + "/brains/public", dependencies=[Depends(AuthBearer())], tags=["Brain"] +) +async def public_brains_endpoint() -> list[PublicBrain]: + """ + Retrieve all Quivr public brains + """ + return get_public_brains() + + # get default brain @brain_router.get( "/brains/default/", dependencies=[Depends(AuthBearer())], tags=["Brain"] diff --git a/frontend/app/brains-management/[brainId]/components/BrainListItem/BrainListItem.tsx b/frontend/app/brains-management/[brainId]/components/BrainListItem/BrainListItem.tsx index 60d2494e22cf..b1f4c527a9cd 100644 --- a/frontend/app/brains-management/[brainId]/components/BrainListItem/BrainListItem.tsx +++ b/frontend/app/brains-management/[brainId]/components/BrainListItem/BrainListItem.tsx @@ -31,7 +31,7 @@ export const BrainListItem = ({ brain }: BrainsListItemProps): JSX.Element => { href={`/brains-management/${brain.id}`} key={brain.id} > -
+

{brain.name}

diff --git a/frontend/app/brains-management/[brainId]/components/BrainsList.tsx b/frontend/app/brains-management/[brainId]/components/BrainsList.tsx index 66fbaad12af6..8f4baf8beacd 100644 --- a/frontend/app/brains-management/[brainId]/components/BrainsList.tsx +++ b/frontend/app/brains-management/[brainId]/components/BrainsList.tsx @@ -1,8 +1,11 @@ "use client"; import { motion, MotionConfig } from "framer-motion"; +import Link from "next/link"; +import { useTranslation } from "react-i18next"; import { MdChevronRight } from "react-icons/md"; import { AddBrainModal } from "@/lib/components/AddBrainModal/AddBrainModal"; +import Button from "@/lib/components/ui/Button"; import { cn } from "@/lib/utils"; import { BrainListItem } from "./BrainListItem"; @@ -13,6 +16,8 @@ export const BrainsList = (): JSX.Element => { const { open, setOpen, searchQuery, setSearchQuery, brains } = useBrainsList(); + const { t } = useTranslation("brain"); + return ( { ))}
- + + + +
diff --git a/frontend/app/brains-management/[brainId]/hooks/useBrainsList.ts b/frontend/app/brains-management/[brainId]/hooks/useBrainsList.ts index 9e84e7a33e07..57d697886c95 100644 --- a/frontend/app/brains-management/[brainId]/hooks/useBrainsList.ts +++ b/frontend/app/brains-management/[brainId]/hooks/useBrainsList.ts @@ -36,10 +36,14 @@ export const useBrainsList = () => { return; } - if (currentBrainId !== null) { + if ( + currentBrainId !== null && + pathname !== null && + !pathname.includes("library") + ) { redirect(`/brains-management/${currentBrainId}`); } - }, [currentBrainId]); + }, [brainId, currentBrainId, pathname]); return { open, diff --git a/frontend/app/brains-management/library/components/PublicBrainItem.tsx b/frontend/app/brains-management/library/components/PublicBrainItem.tsx new file mode 100644 index 000000000000..c0e708364726 --- /dev/null +++ b/frontend/app/brains-management/library/components/PublicBrainItem.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from "react-i18next"; +import { MdAdd } from "react-icons/md"; + +import Button from "@/lib/components/ui/Button"; +import { Brain } from "@/lib/context/BrainProvider/types"; + +type PublicBrainItemProps = { + brain: Brain; +}; + +export const PublicBrainItem = ({ + brain, +}: PublicBrainItemProps): JSX.Element => { + const { t } = useTranslation("brain"); + + return ( +
+

{brain.name}

+

{brain.description ?? ""}

+ +
+ ); +}; diff --git a/frontend/app/brains-management/library/hooks/useBrainsLibrary.tsx b/frontend/app/brains-management/library/hooks/useBrainsLibrary.tsx new file mode 100644 index 000000000000..4ffddf43420d --- /dev/null +++ b/frontend/app/brains-management/library/hooks/useBrainsLibrary.tsx @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import { PUBLIC_BRAINS_KEY } from "@/lib/api/brain/config"; +import { useBrainApi } from "@/lib/api/brain/useBrainApi"; +import { Brain } from "@/lib/context/BrainProvider/types"; + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export const useBrainsLibrary = () => { + const [searchBarText, setSearchBarText] = useState(""); + const { getPublicBrains } = useBrainApi(); + const { data: publicBrains = [] } = useQuery({ + queryKey: [PUBLIC_BRAINS_KEY], + queryFn: getPublicBrains, + }); + + const [displayingPublicBrains, setDisplayingPublicBrains] = useState( + [] + ); + + useEffect(() => { + setDisplayingPublicBrains(publicBrains); + }, [publicBrains]); + + useEffect(() => { + if (searchBarText === "") { + setDisplayingPublicBrains(publicBrains); + + return; + } + setDisplayingPublicBrains( + publicBrains.filter((brain) => + brain.name.toLowerCase().includes(searchBarText.toLowerCase()) + ) + ); + }, [publicBrains, searchBarText]); + + return { + displayingPublicBrains, + searchBarText, + setSearchBarText, + }; +}; diff --git a/frontend/app/brains-management/library/page.tsx b/frontend/app/brains-management/library/page.tsx new file mode 100644 index 000000000000..f4d72de6dc48 --- /dev/null +++ b/frontend/app/brains-management/library/page.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useTranslation } from "react-i18next"; + +import Field from "@/lib/components/ui/Field"; + +import { PublicBrainItem } from "./components/PublicBrainItem"; +import { useBrainsLibrary } from "./hooks/useBrainsLibrary"; + +const BrainsLibrary = (): JSX.Element => { + const { displayingPublicBrains, searchBarText, setSearchBarText } = + useBrainsLibrary(); + const { t } = useTranslation("brain"); + + return ( +
+
+ setSearchBarText(e.target.value)} + name="search" + inputClassName="w-max lg:min-w-[300px] md:min-w-[200px] min-w-[100px] mt-10 rounded-3xl bg-white" + placeholder={t("public_brains_search_bar_placeholder")} + /> +
+ +
+ {displayingPublicBrains.map((brain) => ( +
+ +
+ ))} +
+
+ ); +}; + +export default BrainsLibrary; diff --git a/frontend/lib/api/brain/__tests__/useBrainApi.test.ts b/frontend/lib/api/brain/__tests__/useBrainApi.test.ts index 4d37e79919be..24662d317ade 100644 --- a/frontend/lib/api/brain/__tests__/useBrainApi.test.ts +++ b/frontend/lib/api/brain/__tests__/useBrainApi.test.ts @@ -226,4 +226,14 @@ describe("useBrainApi", () => { expect(axiosPutMock).toHaveBeenCalledTimes(1); expect(axiosPutMock).toHaveBeenCalledWith(`/brains/${brainId}/`, brain); }); + it("should call getPublicBrains with correct parameters", async () => { + const { + result: { + current: { getPublicBrains }, + }, + } = renderHook(() => useBrainApi()); + await getPublicBrains(); + expect(axiosGetMock).toHaveBeenCalledTimes(1); + expect(axiosGetMock).toHaveBeenCalledWith(`/brains/public`); + }); }); diff --git a/frontend/lib/api/brain/brain.ts b/frontend/lib/api/brain/brain.ts index 211d605ceeb4..46b06bd7822a 100644 --- a/frontend/lib/api/brain/brain.ts +++ b/frontend/lib/api/brain/brain.ts @@ -6,6 +6,7 @@ import { BackendMinimalBrainForUser, Brain, MinimalBrainForUser, + PublicBrain, } from "@/lib/context/BrainProvider/types"; import { Document } from "@/lib/types/Document"; @@ -136,3 +137,9 @@ export const updateBrain = async ( ): Promise => { await axiosInstance.put(`/brains/${brainId}/`, brain); }; + +export const getPublicBrains = async ( + axiosInstance: AxiosInstance +): Promise => { + return (await axiosInstance.get(`/brains/public`)).data; +}; diff --git a/frontend/lib/api/brain/config.ts b/frontend/lib/api/brain/config.ts index 32f7aedb78e0..d3da5381c874 100644 --- a/frontend/lib/api/brain/config.ts +++ b/frontend/lib/api/brain/config.ts @@ -1,7 +1,9 @@ -const brainDataKey = "quivr-brains"; +const BRAIN_DATA_KEY = "quivr-brains"; export const getBrainDataKey = (brainId: string): string => - `${brainDataKey}-${brainId}`; + `${BRAIN_DATA_KEY}-${brainId}`; export const getBrainKnowledgeDataKey = (brainId: string): string => - `${brainDataKey}-${brainId}-knowledge`; + `${BRAIN_DATA_KEY}-${brainId}-knowledge`; + +export const PUBLIC_BRAINS_KEY = "quivr-public-brains"; diff --git a/frontend/lib/api/brain/useBrainApi.ts b/frontend/lib/api/brain/useBrainApi.ts index ad4a1bbc62fa..1a2b09497b6c 100644 --- a/frontend/lib/api/brain/useBrainApi.ts +++ b/frontend/lib/api/brain/useBrainApi.ts @@ -9,6 +9,7 @@ import { getBrains, getBrainUsers, getDefaultBrain, + getPublicBrains, setAsDefaultBrain, Subscription, updateBrain, @@ -48,5 +49,6 @@ export const useBrainApi = () => { setAsDefaultBrain(brainId, axiosInstance), updateBrain: async (brainId: string, brain: UpdateBrainInput) => updateBrain(brainId, brain, axiosInstance), + getPublicBrains: async () => getPublicBrains(axiosInstance), }; }; diff --git a/frontend/lib/components/AddBrainModal/AddBrainModal.tsx b/frontend/lib/components/AddBrainModal/AddBrainModal.tsx index 2d2c33256b37..e985ac68d9e8 100644 --- a/frontend/lib/components/AddBrainModal/AddBrainModal.tsx +++ b/frontend/lib/components/AddBrainModal/AddBrainModal.tsx @@ -9,6 +9,7 @@ import Button from "@/lib/components/ui/Button"; import Field from "@/lib/components/ui/Field"; import { Modal } from "@/lib/components/ui/Modal"; import { defineMaxTokens } from "@/lib/helpers/defineMaxTokens"; +import { cn } from "@/lib/utils"; import { PublicAccessConfirmationModal } from "./components/PublicAccessConfirmationModal"; import { useAddBrainModal } from "./hooks/useAddBrainModal"; @@ -16,7 +17,13 @@ import { Divider } from "../ui/Divider"; import { Radio } from "../ui/Radio"; import { TextArea } from "../ui/TextArea"; -export const AddBrainModal = (): JSX.Element => { +type AddBrainModalProps = { + triggerClassName?: string; +}; + +export const AddBrainModal = ({ + triggerClassName, +}: AddBrainModalProps): JSX.Element => { const { t } = useTranslation(["translation", "brain", "config"]); const { handleSubmit, @@ -43,7 +50,7 @@ export const AddBrainModal = (): JSX.Element => {