feat(chat): add brain selection through mention input (#969)

* feat(chat): add brain selection through mention input

* feat: detect mention deletion from editor

* feat: improve ux

* chore: improve dx

* feat: update translations

* feat: improve mention popover ui

* fix: update failing tests

* feat: add mentions suggestion popover

* feat: update translations

* feat: remove add new brain button
This commit is contained in:
Mamadou DICKO 2023-08-22 10:05:52 +02:00 committed by GitHub
parent 20d5294795
commit 8e94f22782
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1169 additions and 555 deletions

View File

@ -1,4 +1,4 @@
import { ChatInput } from "../ChatInput";
import { ChatInput } from "./components";
export const ActionsBar = (): JSX.Element => {
return (

View File

@ -0,0 +1,23 @@
"use client";
import { MentionInput } from "./components";
type ChatBarProps = {
onSubmit: () => void;
setMessage: (text: string) => void;
message: string;
};
export const ChatBar = ({
onSubmit,
setMessage,
message,
}: ChatBarProps): JSX.Element => {
return (
<MentionInput
message={message}
setMessage={setMessage}
onSubmit={onSubmit}
/>
);
};

View File

@ -3,18 +3,16 @@ import { MdRemoveCircleOutline } from "react-icons/md";
type MentionItemProps = {
text: string;
onRemove: () => void;
prefix?: string;
};
export const MentionItem = ({
export const BrainMentionItem = ({
text,
prefix = "",
onRemove,
}: MentionItemProps): JSX.Element => {
return (
<div className="relative">
<div className="relative inline-block w-fit-content">
<div className="flex items-center bg-gray-300 mr-2 text-gray-600 rounded-2xl py-1 px-2">
<span className="flex-grow">{`${prefix}${text}`}</span>
<span className="flex-grow">@{text}</span>
<MdRemoveCircleOutline
className="cursor-pointer absolute top-[-10px] right-[5px]"
onClick={onRemove}

View File

@ -0,0 +1,64 @@
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 { SuggestionRow } from "./components/SuggestionRow";
import { SuggestionsContainer } from "./components/SuggestionsContainer";
import { useMentionInput } from "./hooks/useMentionInput";
type MentionInputProps = {
onSubmit: () => void;
setMessage: (text: string) => void;
message: string;
};
export const MentionInput = ({
onSubmit,
setMessage,
message,
}: MentionInputProps): ReactElement => {
const {
mentionInputRef,
MentionSuggestions,
keyBindingFn,
editorState,
onOpenChange,
onSearchChange,
open,
plugins,
suggestions,
onAddMention,
handleEditorChange,
} = useMentionInput({
message,
onSubmit,
setMessage,
});
const { t } = useTranslation(["chat"]);
return (
<div className="w-full" data-testid="chat-input">
<Editor
editorKey={"editor"}
editorState={editorState}
onChange={handleEditorChange}
plugins={plugins}
ref={mentionInputRef}
placeholder={t("actions_bar_placeholder")}
keyBindingFn={keyBindingFn}
/>
<MentionSuggestions
open={open}
onOpenChange={onOpenChange}
suggestions={suggestions}
onSearchChange={onSearchChange}
popoverContainer={SuggestionsContainer}
onAddMention={onAddMention}
entryComponent={SuggestionRow}
/>
</div>
);
};

View File

@ -0,0 +1,22 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
export const AddNewBrainButton = (): JSX.Element => {
const { t } = useTranslation(["chat"]);
return (
<Link
href={"/brains-management"}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<Button className="px-5 py-2 text-sm" variant={"tertiary"}>
{t("new_brain")}
</Button>
</Link>
);
};

View File

@ -0,0 +1,23 @@
type BrainSuggestionProps = {
content: string;
id: string;
};
export const BrainSuggestion = ({
content,
id,
}: BrainSuggestionProps): JSX.Element => {
//TODO: use this id for ShareBrain component
console.log({ id });
return (
<div className="relative flex group items-center">
<div
className={
"flex flex-1 items-center gap-2 w-full text-left px-5 py-2 text-sm leading-5 text-gray-900 dark:text-gray-300 group-hover:bg-gray-100 dark:group-hover:bg-gray-700 group-focus:bg-gray-100 dark:group-focus:bg-gray-700 group-focus:outline-none transition-colors"
}
>
<span className="flex-1">{content}</span>
</div>
</div>
);
};

View File

@ -0,0 +1,14 @@
import { EntryComponentProps } from "@draft-js-plugins/mention/lib/MentionSuggestions/Entry/Entry";
import { BrainSuggestion } from "./BrainSuggestion";
export const SuggestionRow = ({
mention,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
className,
...otherProps
}: EntryComponentProps): JSX.Element => (
<div {...otherProps}>
<BrainSuggestion id={mention.id as string} content={mention.name} />
</div>
);

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 SuggestionsContainer = ({
children,
...popoverProps
}: PopoverProps): JSX.Element => (
<Popover {...popoverProps}>
<div
style={{
maxWidth: "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,59 @@
import createMentionPlugin from "@draft-js-plugins/mention";
import { useMemo } from "react";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { BrainMentionItem } from "../../../BrainMentionItem";
interface MentionPluginProps {
removeMention: (entityKeyToRemove: string) => void;
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMentionPlugin = (props: MentionPluginProps) => {
const { removeMention } = props;
const { setCurrentBrainId } = useBrainContext();
const { MentionSuggestions, plugins } = useMemo(() => {
const mentionPlugin = createMentionPlugin({
mentionComponent: ({ entityKey, mention: { name } }) => (
<BrainMentionItem
text={name}
onRemove={() => {
setCurrentBrainId(null);
removeMention(entityKey);
}}
/>
),
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",
marginBottom: "5",
};
},
},
],
},
});
const { MentionSuggestions: LegacyMentionSuggestions } = mentionPlugin;
const legacyPlugins = [mentionPlugin];
return {
plugins: legacyPlugins,
MentionSuggestions: LegacyMentionSuggestions,
};
}, []);
return { MentionSuggestions, plugins };
};

View File

@ -0,0 +1,98 @@
import { EditorState } from "draft-js";
import { useEffect, useState } from "react";
import { MentionTriggerType } from "@/app/chat/[chatId]/components/ActionsBar/types";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { MentionInputMentionsType, TriggerMap } from "../../../../types";
import { mapMinimalBrainToMentionData } from "../../utils/mapMinimalBrainToMentionData";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMentionState = () => {
const { allBrains } = useBrainContext();
const [editorState, legacySetEditorState] = useState(() =>
EditorState.createEmpty()
);
const [mentionItems, setMentionItems] = useState<MentionInputMentionsType>({
"@": allBrains.map((brain) => ({ ...brain, value: brain.name })),
});
const [suggestions, setSuggestions] = useState(
mapMinimalBrainToMentionData(mentionItems["@"])
);
const setEditorState = (newState: EditorState) => {
const currentSelection = newState.getSelection();
const stateWithContentAndSelection = EditorState.forceSelection(
newState,
currentSelection
);
legacySetEditorState(stateWithContentAndSelection);
};
const getEditorCurrentMentions = (): TriggerMap[] => {
const contentState = editorState.getCurrentContent();
const plainText = contentState.getPlainText();
const mentionTriggers = Object.keys(mentionItems);
const mentionTexts: TriggerMap[] = [];
mentionTriggers.forEach((trigger) => {
if (trigger === "@") {
mentionItems["@"].forEach((item) => {
const mentionText = `${trigger}${item.name}`;
if (plainText.includes(mentionText)) {
mentionTexts.push({
trigger: trigger as MentionTriggerType,
content: item.name,
});
}
});
}
});
return mentionTexts;
};
const getEditorTextWithoutMentions = (
editorCurrentState: EditorState
): string => {
const contentState = editorCurrentState.getCurrentContent();
let plainText = contentState.getPlainText();
Object.keys(mentionItems).forEach((trigger) => {
if (trigger === "@") {
mentionItems[trigger].forEach((item) => {
const regex = new RegExp(`${trigger}${item.name}`, "g");
plainText = plainText.replace(regex, "");
});
}
});
return plainText;
};
useEffect(() => {
setMentionItems({
...mentionItems,
"@": [
...allBrains.map((brain) => ({
...brain,
value: brain.name,
})),
],
});
}, [allBrains]);
return {
editorState,
setEditorState,
mentionItems,
setSuggestions,
setMentionItems,
suggestions,
getEditorCurrentMentions,
getEditorTextWithoutMentions,
};
};

View File

@ -0,0 +1,60 @@
import { addMention, MentionData } from "@draft-js-plugins/mention";
import { EditorState } from "draft-js";
import { MentionTriggerType } from "@/app/chat/[chatId]/components/ActionsBar/types";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
type MentionUtilsProps = {
editorState: EditorState;
setEditorState: (editorState: EditorState) => void;
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMentionUtils = (props: MentionUtilsProps) => {
const { editorState, setEditorState } = props;
const { setCurrentBrainId } = useBrainContext();
const removeMention = (entityKeyToRemove: string): void => {
const contentState = editorState.getCurrentContent();
const entity = contentState.getEntity(entityKeyToRemove);
if (entity.getType() === "mention") {
const newContentState = contentState.replaceEntityData(
entityKeyToRemove,
{}
);
const newEditorState = EditorState.push(
editorState,
newContentState,
"apply-entity"
);
setEditorState(newEditorState);
setCurrentBrainId(null);
}
};
const insertMention = (
mention: MentionData,
trigger: MentionTriggerType,
customEditorState?: EditorState
): EditorState => {
const editorStateWithMention = addMention(
customEditorState ?? editorState,
mention,
trigger,
trigger,
"MUTABLE"
);
setEditorState(editorStateWithMention);
return editorStateWithMention;
};
return {
removeMention,
insertMention,
};
};

View File

@ -0,0 +1,200 @@
/* 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 { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useMentionPlugin } from "./helpers/MentionPlugin";
import { useMentionState } from "./helpers/MentionState";
import { useMentionUtils } from "./helpers/MentionUtils";
import { mapMinimalBrainToMentionData } from "../utils/mapMinimalBrainToMentionData";
import "@draft-js-plugins/mention/lib/plugin.css";
import "draft-js/dist/Draft.css";
type UseMentionInputProps = {
message: string;
onSubmit: () => void;
setMessage: (text: string) => void;
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useMentionInput = ({
message,
onSubmit,
setMessage,
}: UseMentionInputProps) => {
const { allBrains, currentBrainId, setCurrentBrainId } = useBrainContext();
const {
editorState,
setEditorState,
setMentionItems,
mentionItems,
setSuggestions,
suggestions,
getEditorCurrentMentions,
getEditorTextWithoutMentions,
} = useMentionState();
const { removeMention, insertMention } = useMentionUtils({
editorState,
setEditorState,
});
const { MentionSuggestions, plugins } = useMentionPlugin({
removeMention,
});
const mentionInputRef = useRef<Editor>(null);
const [selectedBrainAddedOnload, setSelectedBrainAddedOnload] =
useState(false);
const [open, setOpen] = useState(false);
const onOpenChange = useCallback((_open: boolean) => {
setOpen(_open);
}, []);
const onAddMention = (mention: MentionData) => {
setCurrentBrainId(mention.id as UUID);
};
const onSearchChange = ({
trigger,
value,
}: {
trigger: string;
value: string;
}) => {
if (currentBrainId !== null) {
setSuggestions([]);
return;
}
setSuggestions(
defaultSuggestionsFilter(
value,
mapMinimalBrainToMentionData(mentionItems["@"]),
trigger
)
);
};
const insertCurrentBrainAsMention = (): void => {
const mention = mentionItems["@"].find(
(item) => item.id === currentBrainId
);
if (mention !== undefined) {
insertMention(mention, "@");
}
};
const resetEditorContent = () => {
const currentMentions = getEditorCurrentMentions();
let newEditorState = EditorState.createEmpty();
currentMentions.forEach((mention) => {
if (mention.trigger === "@") {
const correspondingMention = mentionItems["@"].find(
(item) => item.name === mention.content
);
if (correspondingMention !== undefined) {
newEditorState = insertMention(
correspondingMention,
mention.trigger,
newEditorState
);
}
}
});
setEditorState(newEditorState);
};
const keyBindingFn = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
onSubmit();
return "submit";
}
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
return undefined;
}
return getDefaultKeyBinding(e);
};
const handleEditorChange = (newEditorState: EditorState) => {
setEditorState(newEditorState);
const currentMessage = getEditorTextWithoutMentions(newEditorState);
setMessage(currentMessage);
};
useEffect(() => {
if (message === "") {
resetEditorContent();
}
}, [message]);
useEffect(() => {
setSuggestions(mapMinimalBrainToMentionData(mentionItems["@"]));
}, [mentionItems]);
useEffect(() => {
setMentionItems({
...mentionItems,
"@": [
...allBrains.map((brain) => ({
...brain,
value: brain.name,
})),
],
});
}, [allBrains]);
useEffect(() => {
if (
selectedBrainAddedOnload ||
mentionItems["@"].length === 0 ||
currentBrainId === null
) {
return;
}
insertCurrentBrainAsMention();
setSelectedBrainAddedOnload(true);
}, [currentBrainId, mentionItems]);
useEffect(() => {
const contentState = editorState.getCurrentContent();
const plainText = contentState.getPlainText();
if (plainText === "" && currentBrainId !== null) {
insertCurrentBrainAsMention();
}
}, [editorState]);
return {
mentionInputRef,
plugins,
MentionSuggestions,
onOpenChange,
onSearchChange,
open,
suggestions,
onAddMention,
editorState,
insertCurrentBrainAsMention,
handleEditorChange,
keyBindingFn,
};
};

View File

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

View File

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

View File

@ -0,0 +1,10 @@
import { useFeature } from "@growthbook/growthbook-react";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useChatBar = () => {
const shouldUseNewUX = useFeature("new-ux").on;
return {
shouldUseNewUX,
};
};

View File

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

View File

@ -0,0 +1,11 @@
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
import { MentionTriggerType } from "../../../../types";
export type MentionInputMentionsType = {
"@": MinimalBrainForUser[];
};
export type TriggerMap = {
trigger: MentionTriggerType;
content: string;
};

View File

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

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { useChat } from "../../../hooks/useChat";
import { useChat } from "@/app/chat/[chatId]/hooks/useChat";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useChatInput = () => {
@ -8,9 +8,6 @@ export const useChatInput = () => {
const { addQuestion, generatingAnswer, chatId } = useChat();
const submitQuestion = () => {
if (message.length === 0) {
return;
}
if (!generatingAnswer) {
void addQuestion(message, () => setMessage(""));
}

View File

@ -0,0 +1,51 @@
"use client";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
import { ChatBar } from "./components/ChatBar/ChatBar";
import { ConfigModal } from "./components/ConfigModal";
import { MicButton } from "./components/MicButton/MicButton";
import { useChatInput } from "./hooks/useChatInput";
export const ChatInput = (): JSX.Element => {
const { setMessage, submitQuestion, chatId, generatingAnswer, message } =
useChatInput();
const { t } = useTranslation(["chat"]);
return (
<form
data-testid="chat-input-form"
onSubmit={(e) => {
e.preventDefault();
submitQuestion();
}}
className="sticky flex items-star bottom-0 bg-white dark:bg-black w-full flex justify-center gap-2 z-20"
>
<div className="flex flex-1 flex-col items-center">
<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">
<MicButton setMessage={setMessage} />
<ConfigModal chatId={chatId} />
</div>
</div>
</form>
);
};

View File

@ -1,21 +0,0 @@
type StyleMentionsInputProps = {
value: string;
onChange: (value: string) => void;
placeholder: string;
};
export const MentionsInput = ({
onChange,
placeholder,
value,
}: StyleMentionsInputProps): JSX.Element => {
return (
<input
autoFocus
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
value={value}
className="focus:outline-none focus:border-none"
/>
);
};

View File

@ -1,2 +1 @@
export * from "./MentionItem";
export * from "./MentionsInput";
export * from "./ChatInput";

View File

@ -1,15 +0,0 @@
import { useState } from "react";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useActionsBar = () => {
const [value, setValue] = useState("");
const handleChange = (newPlainTextValue: string) => {
setValue(newPlainTextValue);
};
return {
handleChange,
value,
};
};

View File

@ -1 +1,3 @@
export type MentionType = { id: string; display: string };
export type MentionTriggerType = "@" | "#";

View File

@ -1,148 +0,0 @@
/* eslint-disable max-lines */
import { fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BrainProvider } from "@/lib/context";
import { BrainConfigProvider } from "@/lib/context/BrainConfigProvider";
import { ChatInput } from "../index";
const addQuestionMock = vi.fn((...params: unknown[]) => ({ params }));
vi.mock("@/lib/hooks", async () => {
const actual = await vi.importActual<typeof import("@/lib/hooks")>(
"@/lib/hooks"
);
return {
...actual,
useAxios: () => ({
axiosInstance: {
get: vi.fn(() => ({
data: {},
})),
},
}),
};
});
vi.mock("@/app/chat/[chatId]/hooks/useChat", () => ({
useChat: () => ({
addQuestion: (...params: unknown[]) => addQuestionMock(...params),
generatingAnswer: false,
}),
}));
const mockUseSupabase = vi.fn(() => ({
session: {},
}));
vi.mock("@/lib/context/SupabaseProvider", () => ({
useSupabase: () => mockUseSupabase(),
}));
describe("ChatInput", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("should render correctly", () => {
// Rendering the ChatInput component
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInputForm = getByTestId("chat-input-form");
expect(chatInputForm).toBeDefined();
const chatInput = getByTestId("chat-input");
expect(chatInput).toBeDefined();
const submitButton = getByTestId("submit-button");
expect(submitButton).toBeDefined();
const micButton = getByTestId("mic-button");
expect(micButton).toBeDefined();
});
it("should not call addQuestion on form submit when message is empty", () => {
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInputForm = getByTestId("chat-input-form");
fireEvent.submit(chatInputForm);
// Asserting that the addQuestion function was called with the expected arguments
expect(addQuestionMock).not.toHaveBeenCalled();
});
it("should call addQuestion once on form submit when message is not empty", () => {
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInput = getByTestId("chat-input");
fireEvent.change(chatInput, { target: { value: "Test question" } });
const chatInputForm = getByTestId("chat-input-form");
fireEvent.submit(chatInputForm);
// Asserting that the addQuestion function was called with the expected arguments
expect(addQuestionMock).toHaveBeenCalledTimes(1);
expect(addQuestionMock).toHaveBeenCalledWith(
"Test question",
expect.any(Function)
);
});
it('should submit a question when "Enter" key is pressed without shift', () => {
// Mocking the addQuestion function
// Rendering the ChatInput component with the mock function
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const chatInput = getByTestId("chat-input");
fireEvent.change(chatInput, { target: { value: "Another test question" } });
fireEvent.keyDown(chatInput, { key: "Enter", shiftKey: false });
// Asserting that the addQuestion function was called with the expected arguments
expect(addQuestionMock).toHaveBeenCalledTimes(1);
expect(addQuestionMock).toHaveBeenCalledWith(
"Another test question",
expect.any(Function)
);
});
it('should not submit a question when "Enter" key is pressed with shift', () => {
const { getByTestId } = render(
<BrainConfigProvider>
<BrainProvider>
<ChatInput />
</BrainProvider>
</BrainConfigProvider>
);
const inputElement = getByTestId("chat-input");
fireEvent.change(inputElement, { target: { value: "Test question" } });
fireEvent.keyDown(inputElement, { key: "Enter", shiftKey: true });
expect(addQuestionMock).not.toHaveBeenCalled();
});
});

View File

@ -1,73 +0,0 @@
"use client";
import { useFeature } from "@growthbook/growthbook-react";
import { useTranslation } from "react-i18next";
import Button from "@/lib/components/ui/Button";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { ConfigModal } from "./components/ConfigModal";
import { MicButton } from "./components/MicButton/MicButton";
import { useChatInput } from "./hooks/useChatInput";
import { MentionItem } from "../ActionsBar/components";
export const ChatInput = (): JSX.Element => {
const { message, setMessage, submitQuestion, chatId, generatingAnswer } =
useChatInput();
const { t } = useTranslation(["chat"]);
const { currentBrain, setCurrentBrainId } = useBrainContext();
const shouldUseNewUX = useFeature("new-ux").on;
return (
<form
data-testid="chat-input-form"
onSubmit={(e) => {
e.preventDefault();
submitQuestion();
}}
className="sticky flex items-star bottom-0 bg-white dark:bg-black w-full flex justify-center gap-2 z-20"
>
{currentBrain !== undefined && (
<MentionItem
text={currentBrain.name}
onRemove={() => setCurrentBrainId(null)}
prefix="@"
/>
)}
<textarea
autoFocus
value={message}
required
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // Prevents the newline from being entered in the textarea
submitQuestion();
}
}}
className="w-full p-2 pt-0 dark:border-gray-500 outline-none rounded dark:bg-gray-800 focus:outline-none focus:border-none"
placeholder={
shouldUseNewUX
? t("actions_bar_placeholder")
: t("begin_conversation_placeholder")
}
data-testid="chat-input"
rows={1}
/>
<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">
<MicButton setMessage={setMessage} />
<ConfigModal chatId={chatId} />
</div>
</form>
);
};

View File

@ -33,10 +33,7 @@ export const ChatMessage = React.forwardRef(
isUserSpeaker ? "items-end" : "items-start"
);
const markdownClasses = cn(
"prose",
"dark:prose-invert"
);
const markdownClasses = cn("prose", "dark:prose-invert");
return (
<div className={containerWrapperClasses}>

View File

@ -1,2 +1 @@
export * from "./ChatInput";
export * from "./ChatMessages";

View File

@ -1,18 +1,18 @@
/* eslint-disable max-lines */
import { AxiosError } from 'axios';
import { useParams } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AxiosError } from "axios";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { getChatConfigFromLocalStorage } from '@/lib/api/chat/chat.local';
import { useChatApi } from '@/lib/api/chat/useChatApi';
import { useBrainContext } from '@/lib/context/BrainProvider/hooks/useBrainContext';
import { useChatContext } from '@/lib/context/ChatProvider/hooks/useChatContext';
import { useToast } from '@/lib/hooks';
import { useEventTracking } from '@/services/analytics/useEventTracking';
import { getChatConfigFromLocalStorage } from "@/lib/api/chat/chat.local";
import { useChatApi } from "@/lib/api/chat/useChatApi";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useChatContext } from "@/lib/context/ChatProvider/hooks/useChatContext";
import { useToast } from "@/lib/hooks";
import { useEventTracking } from "@/services/analytics/useEventTracking";
import { useQuestion } from './useQuestion';
import { ChatQuestion } from '../types';
import { useQuestion } from "./useQuestion";
import { ChatQuestion } from "../types";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useChat = () => {
@ -32,6 +32,15 @@ export const useChat = () => {
const { t } = useTranslation(["chat"]);
const addQuestion = async (question: string, callback?: () => void) => {
if (question === "") {
publish({
variant: "danger",
text: t("ask"),
});
return;
}
try {
setGeneratingAnswer(true);
@ -39,14 +48,14 @@ export const useChat = () => {
//if chatId is not set, create a new chat. Chat name is from the first question
if (currentChatId === undefined) {
const chatName = question.split(' ').slice(0, 3).join(' ');
const chatName = question.split(" ").slice(0, 3).join(" ");
const chat = await createChat(chatName);
currentChatId = chat.chat_id;
setChatId(currentChatId);
//TODO: update chat list here
}
void track('QUESTION_ASKED');
void track("QUESTION_ASKED");
const chatConfig = getChatConfigFromLocalStorage(currentChatId);
const chatQuestion: ChatQuestion = {
@ -65,16 +74,16 @@ export const useChat = () => {
if ((error as AxiosError).response?.status === 429) {
publish({
variant: 'danger',
text: t('limit_reached', { ns: 'chat' }),
variant: "danger",
text: t("limit_reached", { ns: "chat" }),
});
return;
}
publish({
variant: 'danger',
text: t('error_occurred', { ns: 'chat' }),
variant: "danger",
text: t("error_occurred", { ns: "chat" }),
});
} finally {
setGeneratingAnswer(false);

View File

@ -1,12 +1,9 @@
/* eslint-disable max-lines */
import axios from "axios";
import { useTranslation } from "react-i18next";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useChatContext } from "@/lib/context/ChatProvider/hooks/useChatContext";
import { useFetch } from "@/lib/hooks";
import { useToast } from "@/lib/hooks/useToast";
import { useFetch, useToast } from "@/lib/hooks";
import { ChatHistory, ChatQuestion } from "../types";

View File

@ -1,15 +1,13 @@
/* eslint-disable max-lines */
import { useTranslation } from 'react-i18next'
import { useTranslation } from "react-i18next";
import { availableRoles } from "@/lib/components/ShareBrain/types";
import Field from "@/lib/components/ui/Field";
import { Select } from "@/lib/components/ui/Select";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { RemoveAccessIcon } from "./components/RemoveAccessIcon";
import { useBrainUser } from "./hooks/useBrainUser";
import { availableRoles } from "../../../NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/types";
import { BrainRoleType } from "../../../NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
type BrainUserProps = {
email: string;
role: BrainRoleType;
@ -54,7 +52,7 @@ export const BrainUser = ({
name="email"
required
type="email"
placeholder= {t('email')}
placeholder={t("email")}
value={email}
data-testid="role-assignation-email-input"
readOnly

View File

@ -1,6 +1,5 @@
/* eslint-disable max-lines */
import { useEffect, useState } from "react";
import { useTranslation } from 'react-i18next'
import { useTranslation } from "react-i18next";
import { Subscription } from "@/lib/api/brain/brain";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
@ -15,7 +14,7 @@ export const useBrainUsers = (brainId: string) => {
const { publish } = useToast();
const { getBrainUsers } = useBrainApi();
const { session } = useSupabase();
const { t } = useTranslation(['brain']);
const { t } = useTranslation(["brain"]);
const fetchBrainUsers = async () => {
// Optimistic update
@ -26,7 +25,7 @@ export const useBrainUsers = (brainId: string) => {
} catch {
publish({
variant: "danger",
text: t('errorFetchingBrainUsers',{ns:'brain'})
text: t("errorFetchingBrainUsers", { ns: "brain" }),
});
} finally {
setFetchingBrainUsers(false);

View File

@ -1,6 +1,7 @@
import { ShareBrain } from "@/lib/components/ShareBrain";
import { MinimalBrainForUser } from "@/lib/context/BrainProvider/types";
import { DeleteBrain, ShareBrain } from "./components";
import { DeleteBrain } from "./components";
import { BrainRoleType } from "./types";
type BrainActionsProps = {

View File

@ -1,2 +1 @@
export * from "./DeleteBrain";
export * from "./ShareBrain";

View File

@ -1,5 +1,6 @@
export const roles = ["Viewer", "Editor", "Owner"] as const;
//TODO: move these types to a shared place
export type BrainRoleType = (typeof roles)[number];
export type BrainRoleAssignation = {

View File

@ -49,7 +49,7 @@ export const ShareBrain = ({
</Button>
}
CloseTrigger={<div />}
title={t("shareBrain", { brain: name, ns: "brain" })}
title={t("shareBrain", { name, ns: "brain" })}
isOpen={isShareModalOpen}
setOpen={setIsShareModalOpen}
>

View File

@ -1,4 +1,4 @@
import { BrainRoleType } from "../../../types";
import { BrainRoleType } from "../../NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
export type SelectOptionsProps = {
label: string;

View File

@ -1,4 +1,4 @@
import { BrainRoleAssignation } from "../../../types";
import { BrainRoleAssignation } from "../../NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
export const generateBrainAssignation = (): BrainRoleAssignation => {
return {

View File

@ -6,11 +6,11 @@ import Field from "@/lib/components/ui/Field";
import { Select } from "@/lib/components/ui/Select";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { userRoleToAssignableRoles } from "./NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/types";
import {
BrainRoleAssignation,
BrainRoleType,
} from "./NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
import { userRoleToAssignableRoles } from "./ShareBrain/types";
type UserToInviteProps = {
onChange: (newRole: BrainRoleAssignation) => void;
@ -43,10 +43,10 @@ export const UserToInvite = ({
}
const assignableRoles = userRoleToAssignableRoles[currentBrain.role];
const translatedOptions = assignableRoles.map(role => ({
const translatedOptions = assignableRoles.map((role) => ({
value: role.value,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
label: t(role.value)
label: t(role.value),
}));
return (

View File

@ -1,17 +1,17 @@
/* eslint-disable max-lines */
import axios, { AxiosResponse } from "axios";
import { useState } from "react";
import { useTranslation } from 'react-i18next';
import { useTranslation } from "react-i18next";
import { Subscription } from "@/lib/api/brain/brain";
import { useBrainApi } from "@/lib/api/brain/useBrainApi";
import { useToast } from "@/lib/hooks";
import { generateBrainAssignation } from "../components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/components/ShareBrain/utils/generateBrainAssignation";
import {
BrainRoleAssignation,
BrainRoleType,
} from "../components/NavBar/components/NavItems/components/BrainsDropDown/components/BrainActions/types";
import { generateBrainAssignation } from "../components/ShareBrain/utils/generateBrainAssignation";
import { useBrainContext } from "../context/BrainProvider/hooks/useBrainContext";
const requiredAccessToShareBrain: BrainRoleType[] = ["Owner", "Editor"];
@ -23,7 +23,7 @@ export const useShareBrain = (brainId: string) => {
const [roleAssignations, setRoleAssignation] = useState<
BrainRoleAssignation[]
>([generateBrainAssignation()]);
const { t } = useTranslation(['brain']);
const { t } = useTranslation(["brain"]);
const baseUrl = window.location.origin;
const brainShareLink = `${baseUrl}/invitation/${brainId}`;
@ -40,7 +40,7 @@ export const useShareBrain = (brainId: string) => {
await navigator.clipboard.writeText(brainShareLink);
publish({
variant: "success",
text: t('copiedToClipboard',{ns: 'brain'}),
text: t("copiedToClipboard", { ns: "brain" }),
});
};
@ -104,7 +104,7 @@ export const useShareBrain = (brainId: string) => {
} else {
publish({
variant: "danger",
text: t("errorSendingInvitation")
text: t("errorSendingInvitation"),
});
}
} finally {

View File

@ -16,6 +16,8 @@
"format-fix": "prettier --write ."
},
"dependencies": {
"@draft-js-plugins/editor": "^4.1.4",
"@draft-js-plugins/mention": "^5.2.2",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@growthbook/growthbook-react": "^0.17.0",
@ -33,6 +35,7 @@
"@supabase/auth-ui-shared": "^0.1.6",
"@supabase/supabase-js": "^2.22.0",
"@types/dom-speech-recognition": "^0.0.1",
"@types/draft-js": "^0.11.12",
"@types/node": "20.1.7",
"@types/react": "18",
"@types/react-dom": "18",
@ -44,6 +47,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"date-fns": "^2.30.0",
"draft-js": "^0.11.7",
"encoding": "^0.1.13",
"eslint": "^8.41.0",
"eslint-config-next": "13.4.2",

View File

@ -8,16 +8,15 @@
"error_occurred": "Error occurred while getting answer",
"noCurrentBrain": "No current brain",
"errorParsingData": "Error parsing data",
"resposeBodyNull": "Responde body is null",
"resposeBodyNull": "Response body is null",
"tooManyRequests": "You have exceeded the number of requests per day. To continue chatting, please enter an OpenAI API key in your profile or in used brain.",
"receivedResponse": "Received response. Starting to handle stream...",
"errorCallingAPI": "Error calling the API",
"ask": "Ask a question, or describe a task.",
"begin_conversation_placeholder": "Begin conversation here...",
"thinking": "Thinking...",
"chat": "Chat",
"errorFetching": "Error occurred while fetching your chats",
"chatDeleted": "Chat sucessfully deleted. Id: {{id}}",
"chatDeleted": "Chat successfully deleted. Id: {{id}}",
"errorDeleting": "Error deleting chat: {{error}}",
"chatNameUpdated": "Chat name updated",
"shortcut_select_brain": "@: Select a brain to talk",
@ -33,5 +32,6 @@
"empty_brain_title_prefix": "Upload files in a",
"empty_brain_title_suffix": "and chat with them",
"actions_bar_placeholder": "Ask a question to @brains or /files and choose your #prompt",
"missing_brain": "Please select a brain to chat with"
"missing_brain": "Please select a brain to chat with",
"new_brain": "Create new brain"
}

View File

@ -1,7 +1,6 @@
{
"actions_bar_placeholder": "Haz una pregunta a @brains o /files y elige tu #indicación",
"actions_bar_placeholder": "Haz una pregunta a @brains",
"ask": "Has una pregunta o describe un tarea.",
"begin_conversation_placeholder": "Inicia la conversación aquí...",
"brain": "cerebro",
"brains": "cerebros",
"chat": "Conversar",
@ -34,5 +33,6 @@
"subtitle": "Habla con un modelo de lenguaje acerca de tus datos subidos",
"thinking": "Pensando...",
"title": "Conversa con {{brain}}",
"missing_brain": "No hay cerebro seleccionado"
"missing_brain": "No hay cerebro seleccionado",
"new_brain": "Crear nuevo cerebro"
}

View File

@ -1,7 +1,6 @@
{
"actions_bar_placeholder": "Posez une question à @brains ou /files et choisissez votre #invite",
"actions_bar_placeholder": "Posez une question à @brains",
"ask": "Posez une question ou décrivez une tâche.",
"begin_conversation_placeholder": "Commencez la conversation ici...",
"brain": "cerveau",
"brains": "cerveaux",
"chat": "Chat",
@ -34,5 +33,6 @@
"subtitle": "Parlez à un modèle linguistique de vos données téléchargées",
"thinking": "Réflexion...",
"title": "Discuter avec {{brain}}",
"missing_brain": "Veuillez selectionner un cerveau pour discuter"
"missing_brain": "Veuillez selectionner un cerveau pour discuter",
"new_brain": "Créer un nouveau cerveau"
}

View File

@ -1,7 +1,6 @@
{
"actions_bar_placeholder": "Faça uma pergunta para @cérebros ou /arquivos e escolha o seu #prompt",
"ask": "Faça uma pergunta ou descreva uma tarefa.",
"begin_conversation_placeholder": "Inicie a conversa aqui...",
"brain": "cérebro",
"brains": "cérebros",
"chat": "Conversa",
@ -34,5 +33,6 @@
"subtitle": "Converse com um modelo de linguagem sobre seus dados enviados",
"thinking": "Pensando...",
"title": "Converse com {{brain}}",
"missing_brain": "Cérebro não encontrado"
"missing_brain": "Cérebro não encontrado",
"new_brain": "Criar novo cérebro"
}

View File

@ -1,7 +1,6 @@
{
"actions_bar_placeholder": "Задайте вопрос @brains или /files и выберите ваш #prompt",
"ask": "Задайте вопрос или опишите задачу.",
"begin_conversation_placeholder": "Начните беседу здесь...",
"brain": "мозг",
"brains": "мозги",
"chat": "Чат",
@ -34,5 +33,6 @@
"subtitle": "Общайтесь с языковой моделью о ваших загруженных данных",
"thinking": "Думаю...",
"title": "Чат с {{brain}}",
"missing_brain": "Мозг не найден"
"missing_brain": "Мозг не найден",
"new_brain": "Создать новый мозг"
}

File diff suppressed because it is too large Load Diff