mirror of
https://github.com/StanGirard/quivr.git
synced 2024-12-24 11:52:45 +03:00
feat(chatPage): update ui add new feed component (#1275)
* feat(brains): remove unrelevent buttons on management page * feat: improve feed input ux * feat: update brain knowledge context add knowledge to feed logic * feat: allow to drop files everywhere in the chat section * style: add slide in effect when opening feed card * test(chatPage): fix failing tests
This commit is contained in:
parent
b6f38f7aff
commit
2226eef06b
@ -98,15 +98,16 @@ export const SettingsTab = ({ brainId }: SettingsTabProps): JSX.Element => {
|
||||
{t("defaultBrain", { ns: "brain" })}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
isLoading={isSettingAsDefault}
|
||||
onClick={() => void setAsDefaultBrainHandler()}
|
||||
type="button"
|
||||
disabled={!hasEditRights}
|
||||
>
|
||||
{t("setDefaultBrain", { ns: "brain" })}
|
||||
</Button>
|
||||
hasEditRights && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
isLoading={isSettingAsDefault}
|
||||
onClick={() => void setAsDefaultBrainHandler()}
|
||||
type="button"
|
||||
>
|
||||
{t("setDefaultBrain", { ns: "brain" })}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -187,9 +188,11 @@ export const SettingsTab = ({ brainId }: SettingsTabProps): JSX.Element => {
|
||||
{...register("maxTokens")}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="flex w-full justify-end py-4">
|
||||
<SaveButton disabled={!hasEditRights} handleSubmit={handleSubmit} />
|
||||
</div>
|
||||
{hasEditRights && (
|
||||
<div className="flex w-full justify-end py-4">
|
||||
<SaveButton handleSubmit={handleSubmit} />
|
||||
</div>
|
||||
)}
|
||||
<Divider text={t("customPromptSection", { ns: "config" })} />
|
||||
{hasEditRights && <PublicPrompts onSelect={pickPublicPrompt} />}
|
||||
<Field
|
||||
@ -208,12 +211,14 @@ export const SettingsTab = ({ brainId }: SettingsTabProps): JSX.Element => {
|
||||
disabled={!hasEditRights}
|
||||
{...register("prompt.content")}
|
||||
/>
|
||||
<div className="flex w-full justify-end py-4">
|
||||
<SaveButton disabled={!hasEditRights} handleSubmit={handleSubmit} />
|
||||
</div>
|
||||
{promptId !== "" && (
|
||||
{hasEditRights && (
|
||||
<div className="flex w-full justify-end py-4">
|
||||
<SaveButton handleSubmit={handleSubmit} />
|
||||
</div>
|
||||
)}
|
||||
{hasEditRights && promptId !== "" && (
|
||||
<Button
|
||||
disabled={isUpdating || !hasEditRights}
|
||||
disabled={isUpdating}
|
||||
onClick={() => void removeBrainPrompt()}
|
||||
>
|
||||
{t("removePrompt", { ns: "config" })}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
ChatContextMock,
|
||||
ChatProviderMock,
|
||||
} from "@/lib/context/ChatProvider/mocks/ChatProviderMock";
|
||||
import { KnowledgeProvider } from "@/lib/context/KnowledgeProvider";
|
||||
import {
|
||||
SupabaseContextMock,
|
||||
SupabaseProviderMock,
|
||||
@ -74,15 +75,17 @@ vi.mock("@tanstack/react-query", async () => {
|
||||
describe("Chat page", () => {
|
||||
it("should render chat page correctly", () => {
|
||||
const { getByTestId } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatProviderMock>
|
||||
<SupabaseProviderMock>
|
||||
<BrainProviderMock>
|
||||
<SelectedChatPage />,
|
||||
</BrainProviderMock>
|
||||
</SupabaseProviderMock>
|
||||
</ChatProviderMock>
|
||||
</QueryClientProvider>
|
||||
<KnowledgeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatProviderMock>
|
||||
<SupabaseProviderMock>
|
||||
<BrainProviderMock>
|
||||
<SelectedChatPage />,
|
||||
</BrainProviderMock>
|
||||
</SupabaseProviderMock>
|
||||
</ChatProviderMock>
|
||||
</QueryClientProvider>
|
||||
</KnowledgeProvider>
|
||||
);
|
||||
|
||||
expect(getByTestId("chat-page")).toBeDefined();
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AiOutlineLoading3Quarters } from "react-icons/ai";
|
||||
|
||||
import { ChatInput, KnowledgeToFeed } from "./components";
|
||||
import { useActionBar } from "./hooks/useActionBar";
|
||||
|
||||
type ActionBarProps = {
|
||||
setShouldDisplayUploadCard: (shouldDisplay: boolean) => void;
|
||||
shouldDisplayUploadCard: boolean;
|
||||
@ -29,12 +31,21 @@ export const ActionsBar = ({
|
||||
|
||||
<div>
|
||||
{shouldDisplayUploadCard && (
|
||||
<div className="flex flex-1 overflow-y-auto shadow-md dark:shadow-primary/25 hover:shadow-xl transition-shadow rounded-xl bg-white dark:bg-black border border-black/10 dark:border-white/25 p-4 md:p-6 mt-5">
|
||||
<KnowledgeToFeed
|
||||
closeFeedInput={() => setShouldDisplayUploadCard(false)}
|
||||
dispatchHasPendingRequests={() => setHasPendingRequests(true)}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key="slide"
|
||||
initial={{ y: "100%", opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1, transition: { duration: 0.2 } }}
|
||||
exit={{ y: "100%", opacity: 0 }}
|
||||
>
|
||||
<div className="flex flex-1 overflow-y-auto shadow-md dark:shadow-primary/25 hover:shadow-xl transition-shadow rounded-xl bg-white dark:bg-black border border-black/10 dark:border-white/25 p-4 md:p-6 mt-5">
|
||||
<KnowledgeToFeed
|
||||
closeFeedInput={() => setShouldDisplayUploadCard(false)}
|
||||
dispatchHasPendingRequests={() => setHasPendingRequests(true)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
{!shouldDisplayUploadCard && (
|
||||
<div className="flex mt-1 flex-col w-full shadow-md dark:shadow-primary/25 hover:shadow-xl transition-shadow rounded-xl bg-white dark:bg-black border border-black/10 dark:border-white/25 md:mb-4 lg:mb-[-20px] p-2">
|
||||
|
@ -1,4 +1,3 @@
|
||||
/* eslint-disable */
|
||||
"use client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PiPaperclipFill } from "react-icons/pi";
|
||||
|
@ -49,6 +49,7 @@ export const KnowledgeToFeed = ({
|
||||
onChange={(newSelectedBrainId) =>
|
||||
setCurrentBrainId(newSelectedBrainId)
|
||||
}
|
||||
className="flex flex-row items-center"
|
||||
/>
|
||||
</div>
|
||||
<KnowledgeToFeedInput
|
||||
|
@ -1,8 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useKnowledgeContext } from "@/lib/context/KnowledgeProvider/hooks/useKnowledgeContext";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useSelectedChatPage = () => {
|
||||
const [shouldDisplayUploadCard, setShouldDisplayUploadCard] = useState(false);
|
||||
const { knowledgeToFeed } = useKnowledgeContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (knowledgeToFeed.length > 0 && !shouldDisplayUploadCard) {
|
||||
setShouldDisplayUploadCard(true);
|
||||
}
|
||||
}, [knowledgeToFeed, setShouldDisplayUploadCard]);
|
||||
|
||||
return {
|
||||
shouldDisplayUploadCard,
|
||||
|
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCustomDropzone } from "@/lib/hooks/useDropzone";
|
||||
|
||||
import { ActionsBar } from "./components/ActionsBar";
|
||||
import { ChatDialogueArea } from "./components/ChatDialogueArea/ChatDialogue";
|
||||
import { ChatHeader } from "./components/ChatHeader";
|
||||
@ -8,11 +10,13 @@ import { useSelectedChatPage } from "./hooks/useSelectedChatPage";
|
||||
const SelectedChatPage = (): JSX.Element => {
|
||||
const { setShouldDisplayUploadCard, shouldDisplayUploadCard } =
|
||||
useSelectedChatPage();
|
||||
const { getRootProps } = useCustomDropzone();
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex flex-col w-full h-[calc(100vh-61px)] overflow-hidden"
|
||||
data-testid="chat-page"
|
||||
{...getRootProps()}
|
||||
>
|
||||
<section className="flex flex-col flex-1 items-center w-full h-full overflow-y-auto">
|
||||
<ChatHeader />
|
||||
|
@ -39,8 +39,7 @@ export const useChatNotificationsSync = () => {
|
||||
);
|
||||
|
||||
if (hasAPendingNotification) {
|
||||
//30 seconds
|
||||
return 2_000;
|
||||
return 2_000; // in ms
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { ChatProvider } from "@/lib/context";
|
||||
import { ChatProvider, KnowledgeProvider } from "@/lib/context";
|
||||
import { ChatsProvider } from "@/lib/context/ChatsProvider/chats-provider";
|
||||
import { useSupabase } from "@/lib/context/SupabaseProvider";
|
||||
import { redirectToLogin } from "@/lib/router/redirectToLogin";
|
||||
@ -20,14 +20,16 @@ const Layout = ({ children }: LayoutProps): JSX.Element => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatsProvider>
|
||||
<ChatProvider>
|
||||
<div className="relative h-full w-full flex justify-stretch items-stretch">
|
||||
<ChatsList />
|
||||
{children}
|
||||
</div>
|
||||
</ChatProvider>
|
||||
</ChatsProvider>
|
||||
<KnowledgeProvider>
|
||||
<ChatsProvider>
|
||||
<ChatProvider>
|
||||
<div className="relative h-full w-full flex justify-stretch items-stretch">
|
||||
<ChatsList />
|
||||
{children}
|
||||
</div>
|
||||
</ChatProvider>
|
||||
</ChatsProvider>
|
||||
</KnowledgeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Button from "@/lib/components/ui/Button";
|
||||
import { Divider } from "@/lib/components/ui/Divider";
|
||||
import { useKnowledgeContext } from "@/lib/context/KnowledgeProvider/hooks/useKnowledgeContext";
|
||||
|
||||
import { FeedItems } from "./components";
|
||||
import { Crawler } from "./components/Crawler";
|
||||
import { FileUploader } from "./components/FileUploader";
|
||||
import { useKnowledgeToFeedInput } from "./hooks/useKnowledgeToFeedInput.ts";
|
||||
import { FeedItemUploadType } from "../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
|
||||
type KnowledgeToFeedInputProps = {
|
||||
dispatchHasPendingRequests?: () => void;
|
||||
@ -19,31 +18,33 @@ export const KnowledgeToFeedInput = ({
|
||||
closeFeedInput,
|
||||
}: KnowledgeToFeedInputProps): JSX.Element => {
|
||||
const { t } = useTranslation(["translation", "upload"]);
|
||||
const { addContent, contents, feedBrain, removeContent } =
|
||||
useKnowledgeToFeedInput({
|
||||
dispatchHasPendingRequests,
|
||||
closeFeedInput,
|
||||
});
|
||||
|
||||
const files: File[] = (
|
||||
contents.filter((c) => c.source === "upload") as FeedItemUploadType[]
|
||||
).map((c) => c.file);
|
||||
const { feedBrain } = useKnowledgeToFeedInput({
|
||||
dispatchHasPendingRequests,
|
||||
closeFeedInput,
|
||||
});
|
||||
const { knowledgeToFeed } = useKnowledgeContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileUploader addContent={addContent} files={files} />
|
||||
<Divider text={t("or", { ns: "translation" })} className="m-5" />
|
||||
<Crawler addContent={addContent} />
|
||||
<FeedItems contents={contents} removeContent={removeContent} />
|
||||
<div className="px-20">
|
||||
<div className="flex flex-row gap-10 justify-between items-center mt-5">
|
||||
<FileUploader />
|
||||
<span className="whitespace-nowrap ">
|
||||
{`${t("and", { ns: "translation" })} / ${t("or", {
|
||||
ns: "translation",
|
||||
})}`}
|
||||
</span>
|
||||
<Crawler />
|
||||
</div>
|
||||
<FeedItems />
|
||||
<div className="flex justify-center mt-5">
|
||||
<Button
|
||||
disabled={contents.length === 0}
|
||||
disabled={knowledgeToFeed.length === 0}
|
||||
className="rounded-xl bg-purple-600 border-white"
|
||||
onClick={() => void feedBrain()}
|
||||
>
|
||||
{t("feed_form_submit_button", { ns: "upload" })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -2,20 +2,17 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useKnowledgeContext } from "@/lib/context/KnowledgeProvider/hooks/useKnowledgeContext";
|
||||
import { useSupabase } from "@/lib/context/SupabaseProvider";
|
||||
import { useToast } from "@/lib/hooks";
|
||||
import { redirectToLogin } from "@/lib/router/redirectToLogin";
|
||||
import { useEventTracking } from "@/services/analytics/june/useEventTracking";
|
||||
|
||||
import { FeedItemType } from "../../../../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
import { isValidUrl } from "../helpers/isValidUrl";
|
||||
|
||||
type UseCrawlerProps = {
|
||||
addContent: (content: FeedItemType) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useCrawler = ({ addContent }: UseCrawlerProps) => {
|
||||
export const useCrawler = () => {
|
||||
const { addKnowledgeToFeed } = useKnowledgeContext();
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const { session } = useSupabase();
|
||||
const { publish } = useToast();
|
||||
@ -41,7 +38,7 @@ export const useCrawler = ({ addContent }: UseCrawlerProps) => {
|
||||
return;
|
||||
}
|
||||
void track("URL_CRAWLED");
|
||||
addContent({
|
||||
addKnowledgeToFeed({
|
||||
source: "crawl",
|
||||
url: urlToCrawl,
|
||||
});
|
||||
|
@ -6,16 +6,9 @@ import Button from "@/lib/components/ui/Button";
|
||||
import Field from "@/lib/components/ui/Field";
|
||||
|
||||
import { useCrawler } from "./hooks/useCrawler";
|
||||
import { FeedItemType } from "../../../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
|
||||
type CrawlerProps = {
|
||||
addContent: (content: FeedItemType) => void;
|
||||
};
|
||||
|
||||
export const Crawler = ({ addContent }: CrawlerProps): JSX.Element => {
|
||||
const { urlInputRef, urlToCrawl, handleSubmit, setUrlToCrawl } = useCrawler({
|
||||
addContent,
|
||||
});
|
||||
export const Crawler = (): JSX.Element => {
|
||||
const { urlInputRef, urlToCrawl, handleSubmit, setUrlToCrawl } = useCrawler();
|
||||
const { t } = useTranslation(["translation", "upload"]);
|
||||
|
||||
return (
|
||||
|
@ -1,36 +1,30 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { useKnowledgeContext } from "@/lib/context/KnowledgeProvider/hooks/useKnowledgeContext";
|
||||
|
||||
import { CrawlFeedItem } from "./components/CrawlFeedItem";
|
||||
import { FileFeedItem } from "./components/FileFeedItem/FileFeedItem";
|
||||
import { FeedItemType } from "../../../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
|
||||
type FeedItemsProps = {
|
||||
contents: FeedItemType[];
|
||||
removeContent: (index: number) => void;
|
||||
};
|
||||
|
||||
export const FeedItems = ({
|
||||
contents,
|
||||
removeContent,
|
||||
}: FeedItemsProps): JSX.Element => {
|
||||
if (contents.length === 0) {
|
||||
export const FeedItems = (): JSX.Element => {
|
||||
const { knowledgeToFeed, removeKnowledgeToFeed } = useKnowledgeContext();
|
||||
if (knowledgeToFeed.length === 0) {
|
||||
return <Fragment />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-1 bg-white p-6 overflow-scroll">
|
||||
{contents.map((item, index) =>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-1 bg-white py-6 overflow-scroll">
|
||||
{knowledgeToFeed.map((item, index) =>
|
||||
item.source === "crawl" ? (
|
||||
<CrawlFeedItem
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
onRemove={() => removeContent(index)}
|
||||
onRemove={() => removeKnowledgeToFeed(index)}
|
||||
/>
|
||||
) : (
|
||||
<FileFeedItem
|
||||
key={item.file.name}
|
||||
file={item.file}
|
||||
onRemove={() => removeContent(index)}
|
||||
onRemove={() => removeKnowledgeToFeed(index)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
@ -1,108 +0,0 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import { FileRejection, useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSupabase } from "@/lib/context/SupabaseProvider";
|
||||
import { useToast } from "@/lib/hooks";
|
||||
import { redirectToLogin } from "@/lib/router/redirectToLogin";
|
||||
import { SupportedFileExtensionsWithDot } from "@/lib/types/SupportedFileExtensions";
|
||||
import { useEventTracking } from "@/services/analytics/june/useEventTracking";
|
||||
|
||||
import { FeedItemType } from "../../../../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
|
||||
type UseFileUploaderProps = {
|
||||
addContent: (content: FeedItemType) => void;
|
||||
files: File[];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useFileUploader = ({
|
||||
addContent,
|
||||
files,
|
||||
}: UseFileUploaderProps) => {
|
||||
const { publish } = useToast();
|
||||
const { session } = useSupabase();
|
||||
const { track } = useEventTracking();
|
||||
|
||||
if (session === null) {
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
const { t } = useTranslation(["upload"]);
|
||||
|
||||
const onDrop = (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
if (fileRejections.length > 0) {
|
||||
const firstRejection = fileRejections[0];
|
||||
|
||||
if (firstRejection.errors[0].code === "file-invalid-type") {
|
||||
publish({ variant: "danger", text: t("invalidFileType") });
|
||||
} else {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: t("maxSizeError", { ns: "upload" }),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of acceptedFiles) {
|
||||
const isAlreadyInFiles =
|
||||
files.filter((f) => f.name === file.name && f.size === file.size)
|
||||
.length > 0;
|
||||
if (isAlreadyInFiles) {
|
||||
publish({
|
||||
variant: "warning",
|
||||
text: t("alreadyAdded", { fileName: file.name, ns: "upload" }),
|
||||
});
|
||||
} else {
|
||||
void track("FILE_UPLOADED");
|
||||
addContent({
|
||||
source: "upload",
|
||||
file: file,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const accept: Record<string, SupportedFileExtensionsWithDot[]> = {
|
||||
"text/plain": [".txt"],
|
||||
"text/csv": [".csv"],
|
||||
"text/markdown": [".md", ".markdown"],
|
||||
"audio/x-m4a": [".m4a"],
|
||||
"audio/mpeg": [".mp3", ".mpga", ".mpeg"],
|
||||
"audio/webm": [".webm"],
|
||||
"video/mp4": [".mp4"],
|
||||
"audio/wav": [".wav"],
|
||||
"application/pdf": [".pdf"],
|
||||
"text/html": [".html"],
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
[".pptx"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
|
||||
".docx",
|
||||
],
|
||||
"application/vnd.oasis.opendocument.text": [".odt"],
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
||||
".xlsx",
|
||||
".xls",
|
||||
],
|
||||
"application/epub+zip": [".epub"],
|
||||
"application/x-ipynb+json": [".ipynb"],
|
||||
"text/x-python": [".py"],
|
||||
};
|
||||
|
||||
const { getInputProps, getRootProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
noClick: true,
|
||||
maxSize: 100000000, // 1 MB
|
||||
accept,
|
||||
});
|
||||
|
||||
return {
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
isDragActive,
|
||||
open,
|
||||
};
|
||||
};
|
@ -3,34 +3,26 @@ import { useTranslation } from "react-i18next";
|
||||
import { IoCloudUploadOutline } from "react-icons/io5";
|
||||
|
||||
import Card from "@/lib/components/ui/Card";
|
||||
import { useSupabase } from "@/lib/context/SupabaseProvider";
|
||||
import { useCustomDropzone } from "@/lib/hooks/useDropzone";
|
||||
import { redirectToLogin } from "@/lib/router/redirectToLogin";
|
||||
|
||||
import { useFileUploader } from "./hooks/useFileUploader";
|
||||
import { FeedItemType } from "../../../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
export const FileUploader = (): JSX.Element => {
|
||||
const { session } = useSupabase();
|
||||
const { getInputProps, isDragActive, open } = useCustomDropzone();
|
||||
|
||||
type FileUploaderProps = {
|
||||
addContent: (content: FeedItemType) => void;
|
||||
files: File[];
|
||||
};
|
||||
export const FileUploader = ({
|
||||
addContent,
|
||||
files,
|
||||
}: FileUploaderProps): JSX.Element => {
|
||||
const { getInputProps, getRootProps, isDragActive, open } = useFileUploader({
|
||||
addContent,
|
||||
files,
|
||||
});
|
||||
if (session === null) {
|
||||
redirectToLogin();
|
||||
}
|
||||
|
||||
const { t } = useTranslation(["translation", "upload"]);
|
||||
|
||||
return (
|
||||
<section
|
||||
{...getRootProps()}
|
||||
className="w-full outline-none flex flex-col gap-10 items-center justify-center px-6 py-3"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row max-w-3xl w-full items-center gap-5 mt-5 cursor-pointer">
|
||||
<section className="w-full outline-none flex flex-col gap-10 items-center justify-center px-0">
|
||||
<div className="flex flex-col sm:flex-row max-w-3xl w-full items-center gap-5 cursor-pointer">
|
||||
<div className="flex-1 w-full">
|
||||
<Card
|
||||
className="h-24 flex justify-center items-center"
|
||||
className="h-20 flex justify-center items-center"
|
||||
onClick={open}
|
||||
>
|
||||
<IoCloudUploadOutline className="text-5xl" />
|
||||
|
@ -10,12 +10,12 @@ import { useNotificationApi } from "@/lib/api/notification/useNotificationApi";
|
||||
import { useUploadApi } from "@/lib/api/upload/useUploadApi";
|
||||
import { useChatContext } from "@/lib/context";
|
||||
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
|
||||
import { useKnowledgeContext } from "@/lib/context/KnowledgeProvider/hooks/useKnowledgeContext";
|
||||
import { getAxiosErrorParams } from "@/lib/helpers/getAxiosErrorParams";
|
||||
import { useToast } from "@/lib/hooks";
|
||||
|
||||
import {
|
||||
FeedItemCrawlType,
|
||||
FeedItemType,
|
||||
FeedItemUploadType,
|
||||
} from "../../../../app/chat/[chatId]/components/ActionsBar/types";
|
||||
|
||||
@ -28,7 +28,6 @@ export const useKnowledgeToFeedInput = ({
|
||||
dispatchHasPendingRequests,
|
||||
closeFeedInput,
|
||||
}: UseKnowledgeToFeedInput) => {
|
||||
const [contents, setContents] = useState<FeedItemType[]>([]);
|
||||
const { publish } = useToast();
|
||||
const { uploadFile } = useUploadApi();
|
||||
const { t } = useTranslation(["upload"]);
|
||||
@ -41,14 +40,7 @@ export const useKnowledgeToFeedInput = ({
|
||||
const params = useParams();
|
||||
const chatId = params?.chatId as UUID | undefined;
|
||||
const [hasPendingRequests, setHasPendingRequests] = useState(false);
|
||||
|
||||
const addContent = (content: FeedItemType) => {
|
||||
setContents((prevContents) => [...prevContents, content]);
|
||||
};
|
||||
const removeContent = (index: number) => {
|
||||
setContents((prevContents) => prevContents.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const { setKnowledgeToFeed, knowledgeToFeed } = useKnowledgeContext();
|
||||
const fetchNotifications = async (currentChatId: UUID): Promise<void> => {
|
||||
const fetchedNotifications = await getChatNotifications(currentChatId);
|
||||
setNotifications(fetchedNotifications);
|
||||
@ -127,11 +119,11 @@ export const useKnowledgeToFeedInput = ({
|
||||
);
|
||||
|
||||
const files: File[] = (
|
||||
contents.filter((c) => c.source === "upload") as FeedItemUploadType[]
|
||||
knowledgeToFeed.filter((c) => c.source === "upload") as FeedItemUploadType[]
|
||||
).map((c) => c.file);
|
||||
|
||||
const urls: string[] = (
|
||||
contents.filter((c) => c.source === "crawl") as FeedItemCrawlType[]
|
||||
knowledgeToFeed.filter((c) => c.source === "crawl") as FeedItemCrawlType[]
|
||||
).map((c) => c.url);
|
||||
|
||||
const feedBrain = async (): Promise<void> => {
|
||||
@ -144,7 +136,7 @@ export const useKnowledgeToFeedInput = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (contents.length === 0) {
|
||||
if (knowledgeToFeed.length === 0) {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: t("addFiles"),
|
||||
@ -166,7 +158,7 @@ export const useKnowledgeToFeedInput = ({
|
||||
|
||||
await Promise.all([...uploadPromises, ...crawlPromises]);
|
||||
|
||||
setContents([]);
|
||||
setKnowledgeToFeed([]);
|
||||
|
||||
if (chatId === undefined) {
|
||||
void router.push(`/chat/${currentChatId}`);
|
||||
@ -184,9 +176,6 @@ export const useKnowledgeToFeedInput = ({
|
||||
};
|
||||
|
||||
return {
|
||||
addContent,
|
||||
contents,
|
||||
removeContent,
|
||||
feedBrain,
|
||||
hasPendingRequests,
|
||||
setHasPendingRequests,
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { BsCheckCircleFill } from "react-icons/bs";
|
||||
|
||||
import Popover from "@/lib/components/ui/Popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SelectOptionProps<T> = {
|
||||
label: string;
|
||||
@ -14,6 +15,8 @@ type SelectProps<T> = {
|
||||
onChange: (option: T) => void;
|
||||
label?: string;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
const selectedStyle = "rounded-lg bg-black text-white";
|
||||
@ -24,6 +27,8 @@ export const Select = <T extends string | number>({
|
||||
value,
|
||||
label,
|
||||
readOnly = false,
|
||||
className,
|
||||
emptyLabel,
|
||||
}: SelectProps<T>): JSX.Element => {
|
||||
const selectedValueLabel = options.find(
|
||||
(option) => option.value === value
|
||||
@ -31,11 +36,11 @@ export const Select = <T extends string | number>({
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div>
|
||||
<div className={cn("gap-2", className)}>
|
||||
{label !== undefined && (
|
||||
<label
|
||||
id="listbox-label"
|
||||
className="block text-sm font-medium leading-6 text-gray-900 mb-2"
|
||||
className="block text-sm font-medium leading-6 text-gray-900"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
@ -49,7 +54,7 @@ export const Select = <T extends string | number>({
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<span className="mx-4 block truncate">
|
||||
{selectedValueLabel ?? label ?? "Select"}
|
||||
{selectedValueLabel ?? emptyLabel ?? "-"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@ -59,11 +64,11 @@ export const Select = <T extends string | number>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={cn("gap-2", className)}>
|
||||
{label !== undefined && (
|
||||
<label
|
||||
id="listbox-label"
|
||||
className="block text-sm font-medium leading-6 text-gray-900 mb-2"
|
||||
className="block text-sm font-medium leading-6 text-gray-900"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
@ -78,7 +83,7 @@ export const Select = <T extends string | number>({
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<span className="ml-3 block truncate">
|
||||
{selectedValueLabel ?? label ?? "Select"}
|
||||
{selectedValueLabel ?? emptyLabel ?? "-"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 ml-3 flex items-center pr-2">
|
||||
|
@ -1,14 +1,32 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { FeedItemType } from "@/app/chat/[chatId]/components/ActionsBar/types";
|
||||
|
||||
import { KnowledgeContext } from "../knowledge-provider";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useKnowledgeContext = () => {
|
||||
const context = useContext(KnowledgeContext);
|
||||
|
||||
const addKnowledgeToFeed = (knowledge: FeedItemType) => {
|
||||
context?.setKnowledgeToFeed((prevKnowledge) => [
|
||||
...prevKnowledge,
|
||||
knowledge,
|
||||
]);
|
||||
};
|
||||
|
||||
const removeKnowledgeToFeed = (index: number) => {
|
||||
context?.setKnowledgeToFeed((prevKnowledge) => {
|
||||
const newKnowledge = [...prevKnowledge];
|
||||
newKnowledge.splice(index, 1);
|
||||
|
||||
return newKnowledge;
|
||||
});
|
||||
};
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useKnowledge must be used inside KnowledgeProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
return { ...context, addKnowledgeToFeed, removeKnowledgeToFeed };
|
||||
};
|
||||
|
@ -2,11 +2,14 @@
|
||||
|
||||
import { createContext, useState } from "react";
|
||||
|
||||
import { FeedItemType } from "@/app/chat/[chatId]/components/ActionsBar/types";
|
||||
import { Knowledge } from "@/lib/types/Knowledge";
|
||||
|
||||
type KnowledgeContextType = {
|
||||
allKnowledge: Knowledge[];
|
||||
setAllKnowledge: React.Dispatch<React.SetStateAction<Knowledge[]>>;
|
||||
knowledgeToFeed: FeedItemType[];
|
||||
setKnowledgeToFeed: React.Dispatch<React.SetStateAction<FeedItemType[]>>;
|
||||
};
|
||||
|
||||
export const KnowledgeContext = createContext<KnowledgeContextType | undefined>(
|
||||
@ -19,12 +22,15 @@ export const KnowledgeProvider = ({
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => {
|
||||
const [allKnowledge, setAllKnowledge] = useState<Knowledge[]>([]);
|
||||
const [knowledgeToFeed, setKnowledgeToFeed] = useState<FeedItemType[]>([]);
|
||||
|
||||
return (
|
||||
<KnowledgeContext.Provider
|
||||
value={{
|
||||
allKnowledge,
|
||||
setAllKnowledge,
|
||||
knowledgeToFeed,
|
||||
setKnowledgeToFeed,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
28
frontend/lib/helpers/acceptedFormats.ts
Normal file
28
frontend/lib/helpers/acceptedFormats.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { SupportedFileExtensionsWithDot } from "@/lib/types/SupportedFileExtensions";
|
||||
|
||||
export const acceptedFormats: Record<string, SupportedFileExtensionsWithDot[]> =
|
||||
{
|
||||
"text/plain": [".txt"],
|
||||
"text/csv": [".csv"],
|
||||
"text/markdown": [".md", ".markdown"],
|
||||
"audio/x-m4a": [".m4a"],
|
||||
"audio/mpeg": [".mp3", ".mpga", ".mpeg"],
|
||||
"audio/webm": [".webm"],
|
||||
"video/mp4": [".mp4"],
|
||||
"audio/wav": [".wav"],
|
||||
"application/pdf": [".pdf"],
|
||||
"text/html": [".html"],
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
[".pptx"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
|
||||
".docx",
|
||||
],
|
||||
"application/vnd.oasis.opendocument.text": [".odt"],
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
||||
".xlsx",
|
||||
".xls",
|
||||
],
|
||||
"application/epub+zip": [".epub"],
|
||||
"application/x-ipynb+json": [".ipynb"],
|
||||
"text/x-python": [".py"],
|
||||
};
|
72
frontend/lib/hooks/useDropzone.ts
Normal file
72
frontend/lib/hooks/useDropzone.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { FileRejection, useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FeedItemUploadType } from "@/app/chat/[chatId]/components/ActionsBar/types";
|
||||
import { useEventTracking } from "@/services/analytics/june/useEventTracking";
|
||||
|
||||
import { useToast } from "./useToast";
|
||||
import { useKnowledgeContext } from "../context/KnowledgeProvider/hooks/useKnowledgeContext";
|
||||
import { acceptedFormats } from "../helpers/acceptedFormats";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export const useCustomDropzone = () => {
|
||||
const { knowledgeToFeed, addKnowledgeToFeed } = useKnowledgeContext();
|
||||
|
||||
const files: File[] = (
|
||||
knowledgeToFeed.filter((c) => c.source === "upload") as FeedItemUploadType[]
|
||||
).map((c) => c.file);
|
||||
|
||||
const { publish } = useToast();
|
||||
const { track } = useEventTracking();
|
||||
|
||||
const { t } = useTranslation(["upload"]);
|
||||
|
||||
const onDrop = (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
if (fileRejections.length > 0) {
|
||||
const firstRejection = fileRejections[0];
|
||||
|
||||
if (firstRejection.errors[0].code === "file-invalid-type") {
|
||||
publish({ variant: "danger", text: t("invalidFileType") });
|
||||
} else {
|
||||
publish({
|
||||
variant: "danger",
|
||||
text: t("maxSizeError", { ns: "upload" }),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of acceptedFiles) {
|
||||
const isAlreadyInFiles =
|
||||
files.filter((f) => f.name === file.name && f.size === file.size)
|
||||
.length > 0;
|
||||
if (isAlreadyInFiles) {
|
||||
publish({
|
||||
variant: "warning",
|
||||
text: t("alreadyAdded", { fileName: file.name, ns: "upload" }),
|
||||
});
|
||||
} else {
|
||||
void track("FILE_UPLOADED");
|
||||
addKnowledgeToFeed({
|
||||
source: "upload",
|
||||
file: file,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { getInputProps, getRootProps, isDragActive, open } = useDropzone({
|
||||
onDrop,
|
||||
noClick: true,
|
||||
maxSize: 100000000, // 1 MB
|
||||
accept: acceptedFormats,
|
||||
});
|
||||
|
||||
return {
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
isDragActive,
|
||||
open,
|
||||
};
|
||||
};
|
@ -21,6 +21,7 @@
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"or": "or",
|
||||
"and": "and",
|
||||
"loginButton": "Login",
|
||||
"signUpButton": "Sign up",
|
||||
"logoutButton": "Logout",
|
||||
|
@ -26,6 +26,7 @@
|
||||
"open_source_desc": "La libertad es hermosa, al igual que Quivr. Código abierto y de uso gratuito.",
|
||||
"open_source_title": "Open source",
|
||||
"or": "o",
|
||||
"and": "y",
|
||||
"Owner": "Propietario",
|
||||
"password": "Contraseña",
|
||||
"resetButton": "Restaurar",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"email": "Email",
|
||||
"password": "Mot de passe",
|
||||
"or": "ou",
|
||||
"and": "et",
|
||||
"loginButton": "Connexion",
|
||||
"signUpButton": "S'inscrire",
|
||||
"logoutButton": "Déconnexion",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"email": "Email",
|
||||
"password": "Senha",
|
||||
"or": "ou",
|
||||
"and": "e",
|
||||
"loginButton": "Entrar",
|
||||
"signUpButton": "Cadastre-se",
|
||||
"logoutButton": "Sair",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"email": "Email",
|
||||
"password": "Пароль",
|
||||
"or": "или",
|
||||
"and": "и",
|
||||
"loginButton": "Войти",
|
||||
"signUpButton": "Зарегистрироваться",
|
||||
"logoutButton": "Выйти",
|
||||
|
@ -21,6 +21,7 @@
|
||||
"email": "Email",
|
||||
"password": "密码",
|
||||
"or": "或",
|
||||
"and": "和",
|
||||
"loginButton": "登录",
|
||||
"signUpButton": "注册",
|
||||
"logoutButton": "注销",
|
||||
|
Loading…
Reference in New Issue
Block a user