feat: add FeedBrainInput component (#1101)

* feat: add FeedBrainInput component

* feat: add upload button

* feat: update translations add feed_brain_placeholder

* refactor(uploadPage): add config.ts

* feat(lib): add MentionInput

* feat: add <BrainSelector/> component

* feat: update FeedBrainInput
This commit is contained in:
Mamadou DICKO 2023-09-04 15:27:06 +02:00 committed by GitHub
parent 500f27f81c
commit 14e44ac6ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 572 additions and 29 deletions

View File

@ -0,0 +1,30 @@
"use client";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { BrainSelector } from "./components";
import { useFeedBrainInput } from "./hooks/useFeedBrainInput";
import { MentionItem } from "../ChatBar/components/MentionItem";
export const FeedBrainInput = (): JSX.Element => {
const { currentBrain, setCurrentBrainId } = useBrainContext();
useFeedBrainInput();
return (
<div className="flex flex-row flex-1 w-full item-start">
{currentBrain !== undefined && (
<MentionItem
text={currentBrain.name}
onRemove={() => {
setCurrentBrainId(null);
}}
trigger={"@"}
/>
)}
<div className="flex-1">
<BrainSelector />
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
import Editor from "@draft-js-plugins/editor";
import { ReactElement } from "react";
import { useTranslation } from "react-i18next";
import "@draft-js-plugins/mention/lib/plugin.css";
import "draft-js/dist/Draft.css";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { BrainSuggestionsContainer } from "./components";
import { SuggestionRow } from "./components/SuggestionRow";
import { useBrainSelector } from "./hooks/useBrainSelector";
export const BrainSelector = (): ReactElement => {
const {
mentionInputRef,
MentionSuggestions,
keyBindingFn,
editorState,
onAddMention,
setOpen,
onSearchChange,
open,
plugins,
suggestions,
handleEditorChange,
} = useBrainSelector();
const { currentBrainId } = useBrainContext();
const { t } = useTranslation(["chat"]);
const hasBrainSelected = currentBrainId !== null;
return (
<div className="w-full" data-testid="chat-input">
<Editor
editorKey={"editor"}
editorState={editorState}
onChange={handleEditorChange}
plugins={plugins}
ref={mentionInputRef}
placeholder={hasBrainSelected ? "" : t("feed_brain_placeholder")}
keyBindingFn={keyBindingFn}
readOnly={hasBrainSelected}
/>
<div
style={{
// `open` should be directly passed to the MentionSuggestions component.
// However, it is not working as expected since we are not able to click on button in custom suggestion renderer.
// So, we are using this hack to make it work.
opacity: open ? 1 : 0,
}}
>
<MentionSuggestions
open
onOpenChange={setOpen}
suggestions={suggestions}
onSearchChange={onSearchChange}
popoverContainer={BrainSuggestionsContainer}
entryComponent={SuggestionRow}
renderEmptyPopup
onAddMention={onAddMention}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,8 @@
type BrainSuggestionProps = {
content: string;
};
export const BrainSuggestion = ({
content,
}: BrainSuggestionProps): JSX.Element => {
return <span>{content}</span>;
};

View File

@ -0,0 +1,18 @@
import { Popover } from "@draft-js-plugins/mention";
import { PopoverProps } from "@draft-js-plugins/mention/lib/MentionSuggestions/Popover";
export const BrainSuggestionsContainer = ({
children,
...popoverProps
}: PopoverProps): JSX.Element => (
<Popover {...popoverProps}>
<div
style={{
width: "max-content",
}}
className="bg-white dark:bg-black border border-black/10 dark:border-white/25 rounded-md shadow-md overflow-y-auto"
>
{children}
</div>
</Popover>
);

View File

@ -0,0 +1,16 @@
import { EntryComponentProps } from "@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry";
import { BrainSuggestion } from "./BrainSuggestion";
export const SuggestionRow = ({
mention,
...otherProps
}: EntryComponentProps): JSX.Element => {
return (
<div {...otherProps}>
<div className="relative flex group px-4">
<BrainSuggestion content={mention.name} />
</div>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./BrainSuggestion";
export * from "./BrainSuggestionsContainer";
export * from "./SuggestionRow";

View File

@ -0,0 +1,44 @@
import createMentionPlugin from "@draft-js-plugins/mention";
import { useMemo } from "react";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMentionPlugin = () => {
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin({
mentionComponent: () => <span />,
mentionTrigger: ["@"],
mentionPrefix: "@#",
popperOptions: {
placement: "top-end",
modifiers: [
{
name: "customStyle", // Custom modifier for applying styles
enabled: true,
phase: "beforeWrite",
fn: ({ state }) => {
state.styles.popper = {
...state.styles.popper,
minWidth: "auto",
backgroundColor: "transparent",
padding: "0",
// We are adding a bottom margin to the suggestions container since it is overlapping with mention remove icon
// Please, do not remove!
marginBottom: "20px",
};
},
},
],
},
});
const { MentionSuggestions: LegacyMentionSuggestions } = mentionPlugin;
const legacyPlugins = [mentionPlugin];
return {
plugins: legacyPlugins,
MentionSuggestions: LegacyMentionSuggestions,
};
}, []);
return { MentionSuggestions, plugins };
};

View File

@ -0,0 +1,44 @@
/* eslint-disable max-lines */
import { MentionData } from "@draft-js-plugins/mention";
import { EditorState } from "draft-js";
import { useEffect, useMemo, useState } from "react";
import { requiredRolesForUpload } from "@/app/upload/config";
import { mapMinimalBrainToMentionData } from "@/lib/components/MentionInput";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { MentionInputMentionsType } from "../../types";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMentionState = () => {
const { allBrains } = useBrainContext();
const brainsWithUploadRights = useMemo(
() =>
allBrains.filter((brain) => requiredRolesForUpload.includes(brain.role)),
[allBrains]
);
const [editorState, setEditorState] = useState(EditorState.createEmpty());
const [mentionItems, setMentionItems] = useState<MentionInputMentionsType>({
"@": brainsWithUploadRights.map(mapMinimalBrainToMentionData),
});
const [suggestions, setSuggestions] = useState<MentionData[]>([]);
useEffect(() => {
setMentionItems({
"@": brainsWithUploadRights.map(mapMinimalBrainToMentionData),
});
}, [brainsWithUploadRights]);
return {
editorState,
setEditorState,
mentionItems,
setSuggestions,
setMentionItems,
suggestions,
};
};

View File

@ -0,0 +1,146 @@
/* eslint-disable max-lines */
import Editor from "@draft-js-plugins/editor";
import {
defaultSuggestionsFilter,
MentionData,
} from "@draft-js-plugins/mention";
import { UUID } from "crypto";
import { EditorState, getDefaultKeyBinding } from "draft-js";
import { useCallback, useEffect, useRef, useState } from "react";
import { mentionTriggers } from "@/app/chat/[chatId]/components/ActionsBar/types";
import { getEditorText } from "@/lib/components/MentionInput/utils/getEditorText";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import "@draft-js-plugins/mention/lib/plugin.css";
import "draft-js/dist/Draft.css";
import { useMentionPlugin } from "./helpers/MentionPlugin";
import { useMentionState } from "./helpers/MentionState";
import { MentionTriggerType } from "../types";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useBrainSelector = () => {
const {
currentBrainId,
currentPromptId,
setCurrentBrainId,
setCurrentPromptId,
} = useBrainContext();
const {
editorState,
setEditorState,
mentionItems,
setSuggestions,
suggestions,
} = useMentionState();
const { MentionSuggestions, plugins } = useMentionPlugin();
const [currentTrigger, setCurrentTrigger] = useState<MentionTriggerType>("@");
const mentionInputRef = useRef<Editor>(null);
const [open, setOpen] = useState(false);
const onSearchChange = useCallback(
({ trigger, value }: { trigger: MentionTriggerType; value: string }) => {
setCurrentTrigger(trigger);
if (currentBrainId !== null) {
setSuggestions([]);
return;
}
if (value === "") {
setSuggestions(mentionItems["@"]);
return;
}
setSuggestions(defaultSuggestionsFilter(value, mentionItems, trigger));
},
[currentBrainId, mentionItems, setSuggestions]
);
const onAddMention = useCallback(
(mention: MentionData) => {
if (mention.trigger === "@") {
setCurrentBrainId(mention.id as UUID);
}
},
[setCurrentBrainId]
);
useEffect(() => {
// Reset editor state when there is no brain selected in order to show placeholder
if (currentBrainId === null) {
setEditorState(EditorState.createEmpty());
}
}, [currentBrainId, setEditorState]);
const keyBindingFn = useCallback(
// eslint-disable-next-line complexity
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (mentionTriggers.includes(e.key as MentionTriggerType)) {
setOpen(true);
return getDefaultKeyBinding(e);
}
if (e.key === "Backspace" || e.key === "Delete") {
const editorContent = getEditorText(editorState);
if (editorContent !== "") {
return getDefaultKeyBinding(e);
}
if (currentPromptId !== null) {
setCurrentPromptId(null);
return "backspace";
}
if (currentBrainId !== null) {
setCurrentBrainId(null);
return "backspace";
}
return "backspace";
}
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
return undefined;
}
return getDefaultKeyBinding(e);
},
[
currentBrainId,
currentPromptId,
editorState,
setCurrentBrainId,
setCurrentPromptId,
]
);
const handleEditorChange = useCallback(
(newEditorState: EditorState) => {
setEditorState(newEditorState);
},
[setEditorState]
);
return {
mentionInputRef,
plugins,
MentionSuggestions,
onSearchChange,
open,
suggestions,
editorState,
handleEditorChange,
keyBindingFn,
currentTrigger,
setOpen,
onAddMention,
};
};

View File

@ -0,0 +1,8 @@
import { MentionData } from "@draft-js-plugins/mention";
export const mentionTriggers = ["@"] as const;
export type MentionTriggerType = (typeof mentionTriggers)[number];
export type MentionInputMentionsType = Record<
MentionTriggerType,
MentionData[]
>;

View File

@ -0,0 +1,17 @@
import { useEffect } from "react";
import { requiredRolesForUpload } from "@/app/upload/config";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useFeedBrainInput = () => {
const { currentBrain, setCurrentBrainId } = useBrainContext();
useEffect(() => {
if (
currentBrain !== undefined &&
!requiredRolesForUpload.includes(currentBrain.role)
) {
setCurrentBrainId(null);
}
}, [currentBrain, setCurrentBrainId]);
};

View File

@ -0,0 +1 @@
export * from "./FeedBrainInput";

View File

@ -0,0 +1,8 @@
import { MentionData } from "@draft-js-plugins/mention";
import { MentionTriggerType } from "../../../../types";
export type MentionInputMentionsType = Record<
MentionTriggerType,
MentionData[]
>;

View File

@ -6,6 +6,7 @@ import { useChat } from "@/app/chat/[chatId]/hooks/useChat";
export const useChatInput = () => {
const [message, setMessage] = useState<string>("");
const { addQuestion, generatingAnswer, chatId } = useChat();
const [isUploading, setIsUploading] = useState(false);
const submitQuestion = () => {
if (!generatingAnswer) {
@ -19,5 +20,7 @@ export const useChatInput = () => {
submitQuestion,
generatingAnswer,
chatId,
isUploading,
setIsUploading,
};
};

View File

@ -1,16 +1,29 @@
"use client";
import { useFeature } from "@growthbook/growthbook-react";
import { useTranslation } from "react-i18next";
import { MdAddCircle, MdSend } from "react-icons/md";
import Button from "@/lib/components/ui/Button";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { ChatBar } from "./components/ChatBar/ChatBar";
import { ConfigModal } from "./components/ConfigModal";
import { FeedBrainInput } from "./components/FeedBrainInput";
import { useChatInput } from "./hooks/useChatInput";
export const ChatInput = (): JSX.Element => {
const { setMessage, submitQuestion, chatId, generatingAnswer, message } =
useChatInput();
const {
setMessage,
submitQuestion,
chatId,
generatingAnswer,
message,
isUploading,
setIsUploading,
} = useChatInput();
const { t } = useTranslation(["chat"]);
const shouldDisplayUploadButton = useFeature("ux-upload").on;
const { currentBrainId } = useBrainContext();
return (
<form
@ -21,28 +34,60 @@ export const ChatInput = (): JSX.Element => {
}}
className="sticky flex items-star bottom-0 bg-white dark:bg-black w-full flex justify-center gap-2 z-20"
>
{!isUploading && shouldDisplayUploadButton && (
<div className="flex items-start">
<Button
className="p-0"
variant={"tertiary"}
data-testid="upload-button"
type="button"
onClick={() => setIsUploading(true)}
>
<MdAddCircle className="text-3xl" />
</Button>
</div>
)}
<div className="flex flex-1 flex-col items-center">
<ChatBar
message={message}
setMessage={setMessage}
onSubmit={submitQuestion}
/>
{isUploading ? (
<FeedBrainInput />
) : (
<ChatBar
message={message}
setMessage={setMessage}
onSubmit={submitQuestion}
/>
)}
</div>
<div className="flex flex-row items-end">
<Button
className="px-3 py-2 sm:px-4 sm:py-2"
type="submit"
isLoading={generatingAnswer}
data-testid="submit-button"
>
{generatingAnswer
? t("thinking", { ns: "chat" })
: t("chat", { ns: "chat" })}
</Button>
<div className="flex items-center">
<ConfigModal chatId={chatId} />
</div>
{isUploading ? (
<div className="flex items-center">
<Button
disabled={currentBrainId === null}
variant="tertiary"
onClick={() => setIsUploading(false)}
>
<MdSend className="text-3xl transform -rotate-90" />
</Button>
</div>
) : (
<>
<Button
className="px-3 py-2 sm:px-4 sm:py-2"
type="submit"
isLoading={generatingAnswer}
data-testid="submit-button"
>
{generatingAnswer
? t("thinking", { ns: "chat" })
: t("chat", { ns: "chat" })}
</Button>
<div className="flex items-center">
<ConfigModal chatId={chatId} />
</div>
</>
)}
</div>
</form>
);

View File

@ -0,0 +1,3 @@
import { BrainRoleType } from "@/lib/components/BrainUsers/types";
export const requiredRolesForUpload: BrainRoleType[] = ["Editor", "Owner"];

View File

@ -2,7 +2,6 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { BrainRoleType } from "@/lib/components/BrainUsers/types";
import Button from "@/lib/components/ui/Button";
import { Divider } from "@/lib/components/ui/Divider";
import PageHeading from "@/lib/components/ui/PageHeading";
@ -12,8 +11,7 @@ import { redirectToLogin } from "@/lib/router/redirectToLogin";
import { Crawler } from "./components/Crawler";
import { FileUploader } from "./components/FileUploader";
const requiredRolesForUpload: BrainRoleType[] = ["Editor", "Owner"];
import { requiredRolesForUpload } from "./config";
const UploadPage = (): JSX.Element => {
const { currentBrain } = useBrainContext();

View File

@ -0,0 +1 @@
export * from "./utils";

View File

@ -0,0 +1,21 @@
import { MentionData } from "@draft-js-plugins/mention";
import { EditorState } from "draft-js";
import { isMention } from "./isMention";
export const getEditorMentions = (editorState: EditorState): MentionData[] => {
const contentState = editorState.getCurrentContent();
const entities = contentState.getAllEntities();
const mentions: MentionData[] = [];
entities.forEach((contentBlock) => {
if (isMention(contentBlock?.getType())) {
mentions.push(
(contentBlock?.getData() as { mention: MentionData }).mention
);
}
});
return mentions;
};

View File

@ -0,0 +1,22 @@
import { MentionData } from "@draft-js-plugins/mention";
import { EditorState } from "draft-js";
export const getEditorText = (editorState: EditorState): string => {
const mentions: string[] = [];
const editorEntities = editorState.getCurrentContent().getAllEntities();
editorEntities.forEach((entity) => {
const entityData = entity?.getData() as { mention?: MentionData };
if (entityData.mention !== undefined) {
mentions.push(entityData.mention.name);
}
});
let content = editorState.getCurrentContent().getPlainText();
for (const mention of mentions) {
content = content.replace(`@#${mention}`, "");
}
return content.trim();
};

View File

@ -0,0 +1,3 @@
export * from "./isMention";
export * from "./mapMinimalBrainToMentionData";
export * from "./mapPromptToMentionData";

View File

@ -0,0 +1,9 @@
import { mentionTriggers } from "@/app/chat/[chatId]/components/ActionsBar/types";
const mentionsTags = [
"mention",
...mentionTriggers.map((trigger) => `${trigger}mention`),
];
export const isMention = (type?: string): boolean =>
type !== undefined && mentionsTags.includes(type);

View File

@ -0,0 +1,11 @@
import { MentionData } from "@draft-js-plugins/mention";
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
export const mapMinimalBrainToMentionData = (
brain: MinimalBrainForUser
): MentionData => ({
name: brain.name,
id: brain.id as string,
trigger: "@",
});

View File

@ -0,0 +1,9 @@
import { MentionData } from "@draft-js-plugins/mention";
import { Prompt } from "@/lib/types/Prompt";
export const mapPromptToMentionData = (prompt: Prompt): MentionData => ({
name: prompt.title,
id: prompt.id,
trigger: "#",
});

View File

@ -33,5 +33,6 @@
"empty_brain_title_suffix": "and chat with them",
"actions_bar_placeholder": "Ask a question to a @brain and choose your #prompt",
"missing_brain": "Please select a brain to chat with",
"new_prompt": "Create new prompt"
"new_prompt": "Create new prompt",
"feed_brain_placeholder":"Choose which @brain you want to feed with these files"
}

View File

@ -34,5 +34,6 @@
"thinking": "Pensando...",
"title": "Conversa con {{brain}}",
"missing_brain": "No hay cerebro seleccionado",
"new_prompt": "Crear nueva instrucción"
"new_prompt": "Crear nueva instrucción",
"feed_brain_placeholder" : "Elige cuál @cerebro quieres alimentar con estos archivos"
}

View File

@ -34,5 +34,6 @@
"thinking": "Réflexion...",
"title": "Discuter avec {{brain}}",
"missing_brain": "Veuillez selectionner un cerveau pour discuter",
"new_prompt": "Créer un nouveau prompt"
"new_prompt": "Créer un nouveau prompt",
"feed_brain_placeholder" : "Choisissez le @cerveau que vous souhaitez nourrir avec ces fichiers"
}

View File

@ -34,5 +34,6 @@
"thinking": "Pensando...",
"title": "Converse com {{brain}}",
"missing_brain": "Cérebro não encontrado",
"new_prompt": "Criar novo prompt"
"new_prompt": "Criar novo prompt",
"feed_brain_placeholder" : "Escolha qual @cérebro você deseja alimentar com esses arquivos"
}

View File

@ -34,5 +34,6 @@
"thinking": "Думаю...",
"title": "Чат с {{brain}}",
"missing_brain": "Мозг не найден",
"new_prompt": "Создать новый запрос"
"new_prompt": "Создать новый запрос",
"feed_brain_placeholder" : "Выберите, какой @мозг вы хотите питать этими файлами"
}

View File

@ -34,5 +34,6 @@
"empty_brain_title_suffix": "和他们聊天",
"actions_bar_placeholder": "向@大脑提问,选择您的#提示",
"missing_brain": "请选择一个大脑进行聊天",
"new_prompt": "新提示"
"new_prompt": "新提示",
"feed_brain_placeholder" : "选择要用这些文件喂养的 @大脑"
}