feat: add public brain page (#1230)

* feat: add brain library button

* feat(Field): add inputClassName key

* feat: add GET brains/public route

* feat: add brains library layout

* feat: add brain subscriber count
This commit is contained in:
Mamadou DICKO 2023-09-21 09:35:53 +02:00 committed by GitHub
parent f74a1c50c8
commit 8172704b61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 268 additions and 19 deletions

View File

@ -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

View File

@ -2,7 +2,7 @@ from typing import Optional
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 @@ class Brain(Repository):
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 @@ class Brain(Repository):
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"]

View File

@ -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:

View File

@ -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()

View File

@ -4,6 +4,7 @@ from auth import AuthBearer, get_current_user
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 @@ from repository.brain import (
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"]

View File

@ -31,7 +31,7 @@ export const BrainListItem = ({ brain }: BrainsListItemProps): JSX.Element => {
href={`/brains-management/${brain.id}`}
key={brain.id}
>
<div className="flex flex-row flex-1">
<div className="flex flex-row flex-1 w-max">
<div className="flex items-center gap-2">
<FaBrain className="text-xl" />
<p>{brain.name}</p>

View File

@ -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 (
<MotionConfig transition={{ massq: 1, damping: 10 }}>
<motion.div
@ -53,7 +58,18 @@ export const BrainsList = (): JSX.Element => {
))}
</div>
<div className="m-2 mb flex flex-col">
<AddBrainModal />
<Link
href="/brains-management/library"
className="flex flex-row flex-1"
>
<Button
type="button"
className="bg-purple-600 text-white py-2 mb-2 flex flex-row flex-1"
>
{t("brain_library_button_label")}
</Button>
</Link>
<AddBrainModal triggerClassName="border-solid border-2 border-gray-300" />
</div>
</div>
</motion.div>

View File

@ -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,

View File

@ -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 (
<div className="flex justify-center items-center flex-col w-full h-full shadow-md dark:shadow-primary/25 hover:shadow-xl transition-shadow rounded-xl overflow-hidden bg-white dark:bg-black border border-black/10 dark:border-white/25 md:p-5">
<p className="font-bold mb-5 text-xl">{brain.name}</p>
<p className="line-clamp-2 text-center px-5">{brain.description ?? ""}</p>
<Button className="bg-purple-600 text-white p-0 px-3 rounded-xl border-0 w-content mt-3">
{t("public_brain_subscribe_button_label")}
<MdAdd className="text-md" />
</Button>
</div>
);
};

View File

@ -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<Brain[]>(
[]
);
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,
};
};

View File

@ -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 (
<div className="flex flex-1 flex-col items-center m-20 border-2 border-gray-100 border-solid rounded-xl">
<div className="flex">
<Field
value={searchBarText}
onChange={(e) => 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")}
/>
</div>
<div className="flex flex-wrap justify-stretch w-full">
{displayingPublicBrains.map((brain) => (
<div key={brain.id} className="lg:w-1/3 md:w-1/2 w-1 md:p-5">
<PublicBrainItem brain={brain} />
</div>
))}
</div>
</div>
);
};
export default BrainsLibrary;

View File

@ -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`);
});
});

View File

@ -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<void> => {
await axiosInstance.put(`/brains/${brainId}/`, brain);
};
export const getPublicBrains = async (
axiosInstance: AxiosInstance
): Promise<PublicBrain[]> => {
return (await axiosInstance.get<PublicBrain[]>(`/brains/public`)).data;
};

View File

@ -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";

View File

@ -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),
};
};

View File

@ -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 => {
<Button
onClick={() => void 0}
variant={"tertiary"}
className="border-0"
className={cn("border-0", triggerClassName)}
data-testid="add-brain-button"
>
{t("newBrain", { ns: "brain" })}

View File

@ -15,11 +15,20 @@ interface FieldProps
label?: string;
name: string;
icon?: React.ReactNode;
inputClassName?: string;
}
const Field = forwardRef(
(
{ label, className, name, required = false, icon, ...props }: FieldProps,
{
label,
className,
name,
inputClassName,
required = false,
icon,
...props
}: FieldProps,
forwardedRef
) => {
return (
@ -33,7 +42,10 @@ const Field = forwardRef(
<div className="relative">
<input
ref={forwardedRef as RefObject<HTMLInputElement>}
className={`w-full bg-gray-50 dark:bg-gray-900 px-4 py-2 border rounded-md border-black/10 dark:border-white/25`}
className={cn(
`w-full bg-gray-50 dark:bg-gray-900 px-4 py-2 border rounded-md border-black/10 dark:border-white/25`,
inputClassName
)}
name={name}
id={name}
{...props}

View File

@ -33,4 +33,11 @@ export type BackendMinimalBrainForUser = Omit<MinimalBrainForUser, "role"> & {
rights: BrainRoleType;
};
export type PublicBrain = {
id: UUID;
name: string;
description?: string;
number_of_subscribers: number;
};
export type BrainContextType = ReturnType<typeof useBrainProvider>;

View File

@ -33,5 +33,8 @@
"set_brain_status_to_public_modal_title": "Are you sure you want to set this as <span class='text-purple-800'>Public</span>?<br/><br/>",
"set_brain_status_to_public_modal_description": "Every Quivr user will be able to:<br/>- Subscribe to your brain in the 'brains library'.<br/>- Use this brain and check the prompt and model configurations.<br/><br/>They won't have access to your uploaded files and people section.",
"confirm_set_brain_status_to_public": "Yes, set as public",
"cancel_set_brain_status_to_public": "No, keep it private"
"cancel_set_brain_status_to_public": "No, keep it private",
"brain_library_button_label":"Brains library",
"public_brains_search_bar_placeholder":"Search public brains",
"public_brain_subscribe_button_label":"Subscribe"
}

View File

@ -33,5 +33,8 @@
"set_brain_status_to_public_modal_title": "¿Estás seguro de querer establecer esto como <span class='text-purple-800'>Público</span>?<br/><br/>",
"set_brain_status_to_public_modal_description": "Cada usuario de Quivr podrá:<br/>- Suscribirse a tu cerebro en la 'biblioteca de cerebros'.<br/>- Usar este cerebro y comprobar las configuraciones de las indicaciones y el modelo.<br/><br/>No tendrán acceso a tus archivos cargados ni a la sección de personas.",
"confirm_set_brain_status_to_public": "Sí, establecer como público",
"cancel_set_brain_status_to_public": "No, mantenerlo privado"
"cancel_set_brain_status_to_public": "No, mantenerlo privado",
"brain_library_button_label": "Biblioteca de cerebros",
"public_brains_search_bar_placeholder": "Buscar cerebros públicos",
"public_brain_subscribe_button_label": "Suscribirse"
}

View File

@ -33,5 +33,8 @@
"set_brain_status_to_public_modal_title": "Êtes-vous sûr de vouloir définir ceci comme <span class='text-purple-800'>Public</span>?<br/><br/>",
"set_brain_status_to_public_modal_description": "Chaque utilisateur de Quivr pourra :<br/>- S'abonner à votre cerveau dans la 'bibliothèque des cerveaux'.<br/>- Utiliser ce cerveau et vérifier les configurations de prompts et de modèles.<br/><br/>Ils n'auront pas accès à vos fichiers téléchargés et à la section des personnes.",
"confirm_set_brain_status_to_public": "Oui, définir comme public",
"cancel_set_brain_status_to_public": "Non, le garder privé"
"cancel_set_brain_status_to_public": "Non, le garder privé",
"brain_library_button_label": "Bibliothèque des cerveaux",
"public_brains_search_bar_placeholder": "Rechercher des cerveaux publics",
"public_brain_subscribe_button_label": "S'abonner"
}

View File

@ -33,5 +33,8 @@
"set_brain_status_to_public_modal_title": "Tem certeza de que deseja definir isso como <span class='text-purple-800'>Público</span>?<br/><br/>",
"set_brain_status_to_public_modal_description": "Cada usuário do Quivr poderá:<br/>- Se inscrever em seu cérebro na 'biblioteca de cérebros'.<br/>- Usar este cérebro e verificar as configurações de prompts e modelos.<br/><br/>Eles não terão acesso aos seus arquivos enviados e à seção de pessoas.",
"confirm_set_brain_status_to_public": "Sim, definir como público",
"cancel_set_brain_status_to_public": "Não, mantê-lo privado"
"cancel_set_brain_status_to_public": "Não, mantê-lo privado",
"brain_library_button_label": "Biblioteca de cérebros",
"public_brains_search_bar_placeholder": "Pesquisar cérebros públicos",
"public_brain_subscribe_button_label": "Inscrever-se"
}

View File

@ -33,5 +33,8 @@
"set_brain_status_to_public_modal_title": "Вы уверены, что хотите установить это как <span class='text-purple-800'>Публичный</span>?<br/><br/>",
"set_brain_status_to_public_modal_description": "Каждый пользователь Quivr сможет:<br/>- Подписаться на ваш мозг в 'библиотеке мозгов'.<br/>- Использовать этот мозг и проверить настройки подсказок и модели.<br/><br/>У них не будет доступа к вашим загруженным файлам и разделу 'люди'.",
"confirm_set_brain_status_to_public": "Да, установить как публичный",
"cancel_set_brain_status_to_public": "Нет, оставить приватным"
"cancel_set_brain_status_to_public": "Нет, оставить приватным",
"brain_library_button_label": "Библиотека мозгов",
"public_brains_search_bar_placeholder": "Поиск общественных мозгов",
"public_brain_subscribe_button_label": "Подписаться"
}

View File

@ -33,5 +33,8 @@
"set_brain_status_to_public_modal_title": "您确定要将此设置为<span class='text-purple-800'>公共</span>吗?<br/><br/>",
"set_brain_status_to_public_modal_description": "每个 Quivr 用户将能够:<br/>- 在 '大脑库' 中订阅您的大脑。<br/>- 使用此大脑并检查提示和模型配置。<br/><br/>他们将无法访问您上传的文件和人员部分。",
"confirm_set_brain_status_to_public": "是的,设为公共",
"cancel_set_brain_status_to_public": "不,保持私密"
"cancel_set_brain_status_to_public": "不,保持私密",
"brain_library_button_label": "大脑库",
"public_brains_search_bar_placeholder": "搜索公共大脑",
"public_brain_subscribe_button_label": "订阅"
}