feat(frontend): brain Catalogue (#2303)

# 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-03-06 11:17:46 -08:00 committed by GitHub
parent c19f443e7f
commit 4efa346a66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 295 additions and 278 deletions

View File

@ -10,6 +10,15 @@ class IntegrationType(str, Enum):
SYNC = "sync"
DOC = "doc"
class IntegrationBrainTag(str, Enum):
NEW = "new"
RECOMMENDED = "recommended"
MOST_POPULAR = "most_popular"
PREMIUM = "premium"
COMING_SOON = "coming_soon"
COMMUNITY = "community"
DEPRECATED = "deprecated"
class IntegrationDescriptionEntity(BaseModel):
id: UUID
@ -17,8 +26,11 @@ class IntegrationDescriptionEntity(BaseModel):
integration_logo_url: Optional[str] = None
connection_settings: Optional[dict] = None
integration_type: IntegrationType
tags: Optional[list[IntegrationBrainTag]] = []
information: Optional[str] = None
description: str
max_files: int
allow_model_change: bool
class IntegrationEntity(BaseModel):

View File

@ -59,7 +59,7 @@ export const SettingsTabContent = ({
<div className={styles.general_information}>
<GeneralInformation hasEditRights={hasEditRights} />
</div>
{brain?.brain_type === "doc" && (
{brain?.integration_description?.allow_model_change && (
<div className={styles.model_information}>
<ModelSelection
accessibleModels={accessibleModels}

View File

@ -53,7 +53,7 @@ export type ApiBrainDefinition = {
export type IntegrationSettings = {
integration_id?: string;
settings?: { [x: string]: object | undefined };
settings?: { [x: string]: string | undefined };
};
export type CreateBrainInput = {
@ -71,14 +71,26 @@ export type CreateBrainInput = {
integration?: IntegrationSettings;
};
enum IntegrationBrainTag {
NEW = "new",
RECOMMENDED = "recommended",
MOST_POPULAR = "most_popular",
PREMIUM = "premium",
COMING_SOON = "coming_soon",
COMMUNITY = "community",
DEPRECATED = "deprecated",
}
export type IntegrationBrains = {
id: UUID;
integration_name: string;
integration_logo_url: string;
connections_settings: Record<string, unknown>;
connection_settings: string;
integration_type: "custom" | "sync";
description: string;
max_files: number;
tags: IntegrationBrainTag[];
information: string;
};
export type UpdateBrainInput = Partial<CreateBrainInput>;

View File

@ -14,4 +14,5 @@ export const mapBackendMinimalBrainToMinimalBrain = (
description: backendMinimalBrain.description,
integration_logo_url: backendMinimalBrain.integration_logo_url,
max_files: backendMinimalBrain.max_files,
allow_model_change: backendMinimalBrain.allow_model_change,
});

View File

@ -19,7 +19,7 @@ export const AddBrainModal = (): JSX.Element => {
const {
isBrainCreationModalOpened,
setIsBrainCreationModalOpened,
setCurrentIntegrationBrain,
setCurrentSelectedBrain,
} = useBrainCreationContext();
const defaultValues: CreateBrainProps = {
@ -33,7 +33,7 @@ export const AddBrainModal = (): JSX.Element => {
});
useEffect(() => {
setCurrentIntegrationBrain(undefined);
setCurrentSelectedBrain(undefined);
}, [isBrainCreationModalOpened]);
return (

View File

@ -7,8 +7,8 @@ interface BrainCreationContextProps {
setIsBrainCreationModalOpened: React.Dispatch<React.SetStateAction<boolean>>;
creating: boolean;
setCreating: React.Dispatch<React.SetStateAction<boolean>>;
currentIntegrationBrain: IntegrationBrains | undefined;
setCurrentIntegrationBrain: React.Dispatch<
currentSelectedBrain: IntegrationBrains | undefined;
setCurrentSelectedBrain: React.Dispatch<
React.SetStateAction<IntegrationBrains | undefined>
>;
}
@ -24,7 +24,7 @@ export const BrainCreationProvider = ({
}): JSX.Element => {
const [isBrainCreationModalOpened, setIsBrainCreationModalOpened] =
useState<boolean>(false);
const [currentIntegrationBrain, setCurrentIntegrationBrain] =
const [currentSelectedBrain, setCurrentSelectedBrain] =
useState<IntegrationBrains>();
const [creating, setCreating] = useState<boolean>(false);
@ -35,8 +35,8 @@ export const BrainCreationProvider = ({
setIsBrainCreationModalOpened,
creating,
setCreating,
currentIntegrationBrain,
setCurrentIntegrationBrain,
currentSelectedBrain,
setCurrentSelectedBrain,
}}
>
{children}

View File

@ -0,0 +1,60 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.cards_wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
.title {
@include Typography.H2;
}
.brains_grid {
display: flex;
gap: Spacings.$spacing03;
flex-wrap: wrap;
.brain_card_container {
display: flex;
flex-direction: column;
gap: Spacings.$spacing02;
.tag_wrapper {
height: 2rem;
}
.brain_card_wrapper {
display: flex;
flex-direction: column;
align-items: center;
border-radius: Radius.$normal;
gap: Spacings.$spacing03;
padding: Spacings.$spacing04;
width: fit-content;
cursor: pointer;
width: 120px;
.brain_title {
font-size: Typography.$medium;
font-weight: 500;
}
&:hover,
&.selected {
border-color: Colors.$primary;
background-color: Colors.$primary-lightest;
.brain_title {
color: Colors.$primary;
}
}
}
}
}
}

View File

@ -1,38 +1,49 @@
import { capitalCase } from "change-case";
import Image from "next/image";
import { IntegrationBrains } from "@/lib/api/brain/types";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import { Tag } from "@/lib/components/ui/Tag/Tag";
import Tooltip from "@/lib/components/ui/Tooltip/Tooltip";
import styles from "./CustomBrainList.module.scss";
import styles from "./BrainCatalogue.module.scss";
import { useBrainCreationContext } from "../../../brainCreation-provider";
export const CustomBrainList = ({
customBrainList,
export const BrainCatalogue = ({
brains,
next,
}: {
customBrainList: IntegrationBrains[];
brains: IntegrationBrains[];
next: () => void;
}): JSX.Element => {
const { setCurrentIntegrationBrain, currentIntegrationBrain } =
const { setCurrentSelectedBrain, currentSelectedBrain } =
useBrainCreationContext();
return (
<div className={styles.cards_wrapper}>
<MessageInfoBox type="info">
More custom brains are coming!
<span>
A Brain is a specialized AI tool designed to interact with specific
use cases or data sources.
</span>
</MessageInfoBox>
<span className={styles.title}>Choose a custom brain</span>
<div>
{customBrainList.map((brain) => {
<span className={styles.title}>Choose a brain type</span>
<div className={styles.brains_grid}>
{brains.map((brain) => {
return (
<div
key={brain.id}
onClick={() => setCurrentIntegrationBrain(brain)}
className={styles.brain_card_container}
onClick={() => {
next();
setCurrentSelectedBrain(brain);
}}
>
<Tooltip tooltip={brain.description}>
<div
className={`${styles.brain_card_wrapper} ${
currentIntegrationBrain === brain ? styles.selected : ""
currentSelectedBrain === brain ? styles.selected : ""
}`}
>
<Image
@ -44,6 +55,11 @@ export const CustomBrainList = ({
<span className={styles.brain_title}>
{brain.integration_name}
</span>
<div className={styles.tag_wrapper}>
{brain.tags[0] && (
<Tag color="primary" name={capitalCase(brain.tags[0])} />
)}
</div>
</div>
</Tooltip>
</div>

View File

@ -1,48 +0,0 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.brain_type_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
padding: Spacings.$spacing05;
border-radius: Radius.$big;
cursor: pointer;
border: 1px solid Colors.$lightest-black;
&.disabled {
pointer-events: none;
background-color: Colors.$lightest-grey;
opacity: 0.6;
}
.first_line_wrapper {
display: flex;
gap: Spacings.$spacing03;
align-items: center;
.name {
@include Typography.H3;
}
}
.description {
color: Colors.$dark-grey;
}
&:hover,
&.selected {
border-color: Colors.$primary;
background-color: Colors.$primary-lightest;
.name {
color: Colors.$primary;
}
.description {
color: Colors.$black;
}
}
}

View File

@ -1,41 +0,0 @@
import { useState } from "react";
import { BrainType } from "@/lib/components/AddBrainModal/types/types";
import Icon from "@/lib/components/ui/Icon/Icon";
import styles from "./BrainTypeSelection.module.scss";
export const BrainTypeSelection = ({
brainType,
onClick,
selected,
}: {
brainType: BrainType;
onClick: () => void;
selected: boolean;
}): JSX.Element => {
const [isHovered, setIsHovered] = useState<boolean>(false);
return (
<div
className={`
${styles.brain_type_wrapper}
${brainType.disabled && styles.disabled}
${selected && styles.selected}
`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
<div className={styles.first_line_wrapper}>
<Icon
name={brainType.iconName}
size="normal"
color={isHovered || selected ? "primary" : "black"}
/>
<span className={styles.name}>{brainType.name}</span>
</div>
<span className={styles.description}>{brainType.description}</span>
</div>
);
};

View File

@ -1,66 +1,34 @@
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { IntegrationBrains } from "@/lib/api/brain/types";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { BrainType } from "@/lib/components/AddBrainModal/types/types";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { BrainTypeSelection } from "./BrainTypeSelection/BrainTypeSelection";
import { BrainCatalogue } from "./BrainCatalogue/BrainCatalogue";
import styles from "./BrainTypeSelectionStep.module.scss";
import { CustomBrainList } from "./CustomBrainList/CustomBrainList";
import { useBrainCreationContext } from "../../brainCreation-provider";
import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
import { CreateBrainProps } from "../../types/types";
export const BrainTypeSelectionStep = (): JSX.Element => {
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const [customBrainsCatalogueOpened, setCustomBrainsCatalogueOpened] =
useState<boolean>(false);
const [customBrains, setCustomBrains] = useState<IntegrationBrains[]>([]);
const [brains, setBrains] = useState<IntegrationBrains[]>([]);
const { goToNextStep, currentStepIndex } = useBrainCreationSteps();
const { getIntegrationBrains } = useBrainApi();
const { currentIntegrationBrain } = useBrainCreationContext();
const { setValue } = useFormContext<CreateBrainProps>();
useEffect(() => {
getIntegrationBrains()
.then((response) => {
setCustomBrains(
response.filter((brain) => brain.integration_type === "custom")
);
setBrains(response);
})
.catch((error) => {
console.error(error);
});
setValue("name", "");
setValue("description", "");
}, []);
const brainTypes: BrainType[] = [
{
name: "Core Brain",
description: "Upload documents or website links to feed your brain.",
iconName: "feed",
},
{
name: "Custom Brain",
description:
"Explore your databases, converse with your APIs, and much more!",
iconName: "custom",
onClick: () => {
setCustomBrainsCatalogueOpened(true);
},
},
{
name: "Sync Brain - Coming soon!",
description:
"Connect to your tools and applications to interact with your data.",
iconName: "software",
disabled: true,
},
];
const next = (): void => {
goToNextStep();
};
if (currentStepIndex !== 0) {
return <></>;
}
@ -68,54 +36,7 @@ export const BrainTypeSelectionStep = (): JSX.Element => {
return (
<div className={styles.brain_types_wrapper}>
<div className={styles.main_wrapper}>
{customBrainsCatalogueOpened ? (
<CustomBrainList customBrainList={customBrains} />
) : (
<>
<span className={styles.title}>Choose a type of brain</span>
{brainTypes.map((brainType, index) => (
<div key={index}>
<BrainTypeSelection
brainType={brainType}
selected={index === selectedIndex}
onClick={() => {
setSelectedIndex(index);
if (brainType.onClick) {
brainType.onClick();
}
}}
/>
</div>
))}
</>
)}
</div>
<div
className={`${styles.buttons_wrapper} ${
customBrainsCatalogueOpened ? styles.two_buttons : ""
}`}
>
{customBrainsCatalogueOpened && (
<QuivrButton
label="Type of brain"
iconName="chevronLeft"
color="primary"
onClick={() => {
setCustomBrainsCatalogueOpened(false);
setSelectedIndex(-1);
}}
/>
)}
<QuivrButton
label="Next Step"
iconName="chevronRight"
color="primary"
onClick={() => next()}
disabled={
selectedIndex === -1 ||
(!!customBrainsCatalogueOpened && !currentIntegrationBrain)
}
/>
<BrainCatalogue brains={brains} next={goToNextStep} />
</div>
</div>
);

View File

@ -1,44 +0,0 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/ScreenSizes.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.cards_wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
.title {
@include Typography.H2;
}
.brain_card_wrapper {
display: flex;
flex-direction: column;
align-items: center;
border-radius: Radius.$normal;
gap: Spacings.$spacing03;
padding: Spacings.$spacing04;
border: 1px solid Colors.$lightest-black;
width: fit-content;
cursor: pointer;
width: 100px;
.brain_title {
font-size: Typography.$small;
}
&:hover,
&.selected {
border-color: Colors.$primary;
background-color: Colors.$primary-lightest;
.brain_title {
color: Colors.$primary;
}
}
}
}

View File

@ -12,6 +12,12 @@
@include Typography.H2;
}
.settings_wrapper {
display: flex;
flex-direction: column;
gap: Spacings.$spacing05;
}
.message_info_box_wrapper {
align-self: center;
display: flex;

View File

@ -1,6 +1,10 @@
import { capitalCase } from "change-case";
import { useEffect, useState } from "react";
import { KnowledgeToFeed } from "@/app/chat/[chatId]/components/ActionsBar/components";
import { MessageInfoBox } from "@/lib/components/ui/MessageInfoBox/MessageInfoBox";
import QuivrButton from "@/lib/components/ui/QuivrButton/QuivrButton";
import { TextInput } from "@/lib/components/ui/TextInput/TextInput";
import styles from "./CreateBrainStep.module.scss";
import { useBrainCreationApi } from "./hooks/useBrainCreationApi";
@ -10,9 +14,29 @@ import { useBrainCreationSteps } from "../../hooks/useBrainCreationSteps";
export const CreateBrainStep = (): JSX.Element => {
const { currentStepIndex, goToPreviousStep } = useBrainCreationSteps();
const { createBrain } = useBrainCreationApi();
const { creating, setCreating, currentIntegrationBrain } =
const { createBrain, fields, setFields } = useBrainCreationApi();
const { creating, setCreating, currentSelectedBrain } =
useBrainCreationContext();
const [createBrainStepIndex, setCreateBrainStepIndex] = useState<number>(0);
useEffect(() => {
if (currentSelectedBrain?.connection_settings) {
const newFields = Object.entries(
currentSelectedBrain.connection_settings
).map(([key, type]) => {
return { name: key, type, value: "" };
});
setFields(newFields);
}
setCreateBrainStepIndex(Number(!currentSelectedBrain?.connection_settings));
}, [currentSelectedBrain?.connection_settings]);
const handleInputChange = (name: string, value: string) => {
setFields(
fields.map((field) => (field.name === name ? { ...field, value } : field))
);
};
const previous = (): void => {
goToPreviousStep();
@ -29,28 +53,48 @@ export const CreateBrainStep = (): JSX.Element => {
return (
<div className={styles.brain_knowledge_wrapper}>
{!currentIntegrationBrain ? (
{!createBrainStepIndex && (
<div className={styles.settings_wrapper}>
<MessageInfoBox type="warning">
{currentSelectedBrain?.information}
</MessageInfoBox>
{fields.map(({ name, value }) => (
<TextInput
key={name}
inputValue={value}
setInputValue={(inputValue) =>
handleInputChange(name, inputValue)
}
label={capitalCase(name)}
/>
))}
</div>
)}
{!!currentSelectedBrain?.max_files && !!createBrainStepIndex && (
<div>
<span className={styles.title}>Feed your brain</span>
<KnowledgeToFeed hideBrainSelector={true} />
</div>
) : (
<div className={styles.message_info_box_wrapper}>
<MessageInfoBox type="info">
<div className={styles.message_content}>
Click on
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={feed}
isLoading={creating}
/>
to finish your brain creation.
</div>
</MessageInfoBox>
</div>
)}
{!currentSelectedBrain?.max_files &&
!currentSelectedBrain?.connection_settings && (
<div className={styles.message_info_box_wrapper}>
<MessageInfoBox type="info">
<div className={styles.message_content}>
Click on
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={feed}
isLoading={creating}
/>
to finish your brain creation.
</div>
</MessageInfoBox>
</div>
)}
<div className={styles.buttons_wrapper}>
<QuivrButton
label="Previous step"
@ -58,13 +102,24 @@ export const CreateBrainStep = (): JSX.Element => {
iconName="chevronLeft"
onClick={previous}
/>
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={feed}
isLoading={creating}
/>
{(!currentSelectedBrain?.max_files && !createBrainStepIndex) ||
createBrainStepIndex ? (
<QuivrButton
label="Create"
color="primary"
iconName="add"
onClick={feed}
isLoading={creating}
/>
) : (
<QuivrButton
label="Feed your brain"
color="primary"
iconName="add"
onClick={() => setCreateBrainStepIndex(1)}
isLoading={creating}
/>
)}
</div>
</div>
);

View File

@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UUID } from "crypto";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -24,11 +25,11 @@ export const useBrainCreationApi = () => {
const { setKnowledgeToFeed } = useKnowledgeToFeedContext();
const { createBrain: createBrainApi, setCurrentBrainId } = useBrainContext();
const { crawlWebsiteHandler, uploadFileHandler } = useKnowledgeToFeedInput();
const {
setIsBrainCreationModalOpened,
setCreating,
currentIntegrationBrain,
} = useBrainCreationContext();
const { setIsBrainCreationModalOpened, setCreating, currentSelectedBrain } =
useBrainCreationContext();
const [fields, setFields] = useState<
{ name: string; type: string; value: string }[]
>([]);
const handleFeedBrain = async (brainId: UUID): Promise<void> => {
const uploadPromises = files.map((file) =>
@ -44,15 +45,19 @@ export const useBrainCreationApi = () => {
const { name, description } = getValues();
let integrationSettings: IntegrationSettings | undefined = undefined;
if (currentIntegrationBrain) {
if (currentSelectedBrain) {
integrationSettings = {
integration_id: currentIntegrationBrain.id,
settings: {},
integration_id: currentSelectedBrain.id,
settings: fields.reduce((acc, field) => {
acc[field.name] = field.value;
return acc;
}, {} as { [key: string]: string }),
};
}
const createdBrainId = await createBrainApi({
brain_type: currentIntegrationBrain ? "integration" : "doc",
brain_type: currentSelectedBrain ? "integration" : "doc",
name,
description,
integration: integrationSettings,
@ -98,5 +103,7 @@ export const useBrainCreationApi = () => {
return {
createBrain: mutate,
isBrainCreationPending,
fields,
setFields,
};
};

View File

@ -1,3 +1,4 @@
import { useEffect } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -6,9 +7,12 @@ import {
Step,
} from "@/lib/components/AddBrainModal/types/types";
import { useBrainCreationContext } from "../brainCreation-provider";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useBrainCreationSteps = () => {
const { t } = useTranslation("brain");
const { isBrainCreationModalOpened } = useBrainCreationContext();
const steps: Step[] = [
{
@ -30,6 +34,10 @@ export const useBrainCreationSteps = () => {
(step) => step.value === currentStep
);
useEffect(() => {
goToFirstStep();
}, [isBrainCreationModalOpened]);
const goToNextStep = () => {
if (currentStepIndex === -1 || currentStepIndex === steps.length - 1) {
return;
@ -48,6 +56,10 @@ export const useBrainCreationSteps = () => {
return setValue("brainCreationStep", previousStep.value);
};
const goToFirstStep = () => {
return setValue("brainCreationStep", steps[0].value);
};
return {
currentStep,
steps,

View File

@ -0,0 +1,20 @@
@use "@/styles/Colors.module.scss";
@use "@/styles/Radius.module.scss";
@use "@/styles/Spacings.module.scss";
@use "@/styles/Typography.module.scss";
.tag_wrapper {
padding: Spacings.$spacing01;
padding-inline: Spacings.$spacing03;
border-radius: Radius.$big;
width: fit-content;
font-size: Typography.$tiny;
&.primary {
background-color: Colors.$primary-light;
}
&.gold {
background-color: Colors.$gold;
}
}

View File

@ -0,0 +1,16 @@
import { Color } from "@/lib/types/Colors";
import styles from "./Tag.module.scss";
interface TagProps {
name: string;
color: Color;
}
export const Tag = (props: TagProps): JSX.Element => {
return (
<div className={`${styles.tag_wrapper} ${styles[props.color]} `}>
{props.name}
</div>
);
};

View File

@ -18,6 +18,7 @@ export type IntegrationDescription = {
integration_name: string;
integration_type: "custom" | "sync";
max_files: number;
allow_model_change: boolean;
};
export type Brain = {
@ -45,6 +46,7 @@ export type MinimalBrainForUser = {
description: string;
integration_logo_url?: string;
max_files: number;
allow_model_change: boolean;
};
//TODO: rename rights to role in Backend and use MinimalBrainForUser instead of BackendMinimalBrainForUser

View File

@ -0,0 +1,7 @@
create type "public"."brain_tags" as enum ('new', 'recommended', 'most_popular', 'premium', 'coming_soon', 'community', 'deprecated');
alter table "public"."integrations" add column "information" text;
alter table "public"."integrations" add column "tags" brain_tags[];

View File

@ -0,0 +1,3 @@
alter table "public"."integrations" add column "allow_model_change" boolean not null default true;