feat(brainSettings): rework knowledge tab (#1534)

Issue: https://github.com/StanGirard/quivr/issues/1435

- feat(knowledgeTab): update structure
- refactor: change AddKnowledge structure
- feat: change AddKnowledge component structure
- feat: rework sources logic
- feat: change knowledge tab upload process
- fix: change knowledge tab fetch, create, update logic
- feat: improve added knowledge ui
- style: improve responsivity

Fix: 
- https://github.com/StanGirard/quivr/issues/1516
- https://github.com/StanGirard/quivr/issues/1336
- https://github.com/StanGirard/quivr/issues/1204




https://github.com/StanGirard/quivr/assets/63923024/f2917bf3-4ff8-42c6-8149-0b36287441b4
This commit is contained in:
Mamadou DICKO 2023-10-31 19:02:26 +01:00 committed by GitHub
parent b94642670c
commit e3925bcbc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 303 additions and 232 deletions

View File

@ -1,52 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AiOutlineLoading3Quarters } from "react-icons/ai";
import { KnowledgeToFeedInput } from "@/lib/components/KnowledgeToFeedInput";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useFeedBrain } from "./hooks/useFeedBrain";
import { useKnowledge } from "./hooks/useKnowledge";
export const AddKnowledge = (): JSX.Element => {
const [shouldDisplayModal, setShouldDisplayModal] = useState(false);
const { currentBrain } = useBrainContext();
const { invalidateKnowledgeDataKey } = useKnowledge({
brainId: currentBrain?.id,
});
const { feedBrain, hasPendingRequests, setHasPendingRequests } = useFeedBrain(
{
dispatchHasPendingRequests: () => setHasPendingRequests(true),
closeFeedInput: () => setShouldDisplayModal(false),
}
);
useEffect(() => {
if (!hasPendingRequests) {
invalidateKnowledgeDataKey();
}
}, [hasPendingRequests, invalidateKnowledgeDataKey]);
return (
<>
<button
className="flex flex-1 items-center justify-center rounded-xl bg-white dark:bg-black border border-black/10 dark:border-white/25 p-2 md:p-6"
onClick={() => setShouldDisplayModal(true)}
>
<div className="flex flex-lin items-center">
<span className="text-2xl md:text-3xl">+</span>
</div>
</button>
{hasPendingRequests && (
<div className="flex mt-1 flex-col md:flex-row 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-2 md:p-6 pl-6">
<AiOutlineLoading3Quarters className="animate-spin text-2xl md:text-3xl self-center" />
</div>
)}
{shouldDisplayModal && (
<KnowledgeToFeedInput feedBrain={() => void feedBrain()} />
)}
</>
);
};

View File

@ -1,31 +0,0 @@
"use client";
import { MdLink } from "react-icons/md";
import { CrawledKnowledge } from "@/lib/types/Knowledge";
import { DeleteKnowledge } from "./DeleteKnowledge";
export const CrawledKnowledgeItem = ({
knowledge,
}: {
knowledge: CrawledKnowledge;
}): JSX.Element => {
return (
<tr key={knowledge.id}>
<td className="px-6 py-4 whitespace-nowrap">
<a href={knowledge.url} target="_blank" rel="noopener noreferrer">
<MdLink size="20" color="gray" />
</a>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
<p className={"max-w-[400px] truncate"}>{knowledge.url}</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<DeleteKnowledge knowledge={knowledge} />
</td>
</tr>
);
};

View File

@ -1,28 +0,0 @@
"use client";
import { UploadedKnowledge } from "@/lib/types/Knowledge";
import { DeleteKnowledge } from "./DeleteKnowledge";
import { DownloadUploadedKnowledge } from "./DownloadUploadedKnowledge";
export const UploadedKnowledgeItem = ({
knowledge,
}: {
knowledge: UploadedKnowledge;
}): JSX.Element => {
return (
<tr key={knowledge.id}>
<td className="px-6 py-4 whitespace-nowrap">
<DownloadUploadedKnowledge knowledge={knowledge} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
<p className={"max-w-[400px] truncate"}>{knowledge.fileName}</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<DeleteKnowledge knowledge={knowledge} />
</td>
</tr>
);
};

View File

@ -1,21 +0,0 @@
"use client";
import { isUploadedKnowledge, Knowledge } from "@/lib/types/Knowledge";
import { CrawledKnowledgeItem } from "./CrawledKnowledgeItem";
import { UploadedKnowledgeItem } from "./UploadedKnowledgeItem";
const KnowledgeItem = ({
knowledge,
}: {
knowledge: Knowledge;
}): JSX.Element => {
return isUploadedKnowledge(knowledge) ? (
<UploadedKnowledgeItem knowledge={knowledge} />
) : (
<CrawledKnowledgeItem knowledge={knowledge} />
);
};
KnowledgeItem.displayName = "KnowledgeItem";
export default KnowledgeItem;

View File

@ -1,23 +0,0 @@
import { Knowledge } from "@/lib/types/Knowledge";
import KnowledgeItem from "./KnowledgeItem";
interface KnowledgeTableProps {
knowledgeList: Knowledge[];
}
export const KnowledgeTable = ({
knowledgeList,
}: KnowledgeTableProps): JSX.Element => {
return (
<div className="w-full">
<table className="min-w-full divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-gray-200">
{knowledgeList.map((knowledge) => (
<KnowledgeItem knowledge={knowledge} key={knowledge.id} />
))}
</tbody>
</table>
</div>
);
};

View File

@ -0,0 +1,24 @@
"use client";
import { AiOutlineLoading3Quarters } from "react-icons/ai";
import { KnowledgeToFeedInput } from "@/lib/components/KnowledgeToFeedInput";
import { useAddKnowledge } from "./hooks/useAddKnowledge";
export const AddKnowledge = (): JSX.Element => {
const { hasPendingRequests, feedBrain } = useAddKnowledge();
return (
<>
{hasPendingRequests && (
<div className="flex mt-1 flex-col md:flex-row 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-2 md:p-6 pl-6">
<AiOutlineLoading3Quarters className="animate-spin text-2xl md:text-3xl self-center" />
</div>
)}
<div className="w-full shadow-md dark:shadow-primary/25 rounded-xl bg-white dark:bg-black border border-black/10 dark:border-white/25 p-4 mt-0 py-10">
<KnowledgeToFeedInput feedBrain={() => void feedBrain()} />
</div>
</>
);
};

View File

@ -0,0 +1,31 @@
import { useEffect } from "react";
import { useUrlBrain } from "@/lib/hooks/useBrainIdFromUrl";
import { useFeedBrain } from "./useFeedBrain";
import { useKnowledge } from "../../../hooks/useKnowledge";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useAddKnowledge = () => {
const { brainId } = useUrlBrain();
const { invalidateKnowledgeDataKey } = useKnowledge({
brainId,
});
const { feedBrain, hasPendingRequests, setHasPendingRequests } = useFeedBrain(
{
dispatchHasPendingRequests: () => setHasPendingRequests(true),
}
);
useEffect(() => {
if (!hasPendingRequests) {
invalidateKnowledgeDataKey();
}
}, [hasPendingRequests, invalidateKnowledgeDataKey]);
return {
feedBrain,
hasPendingRequests,
};
};

View File

@ -2,9 +2,9 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useChatApi } from "@/lib/api/chat/useChatApi";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useKnowledgeToFeedContext } from "@/lib/context/KnowledgeToFeedProvider/hooks/useKnowledgeToFeedContext";
import { useToast } from "@/lib/hooks";
import { useUrlBrain } from "@/lib/hooks/useBrainIdFromUrl";
import { useFeedBrainHandler } from "./useFeedBrainHandler";
@ -18,14 +18,14 @@ export const useFeedBrain = ({
}) => {
const { publish } = useToast();
const { t } = useTranslation(["upload"]);
const { currentBrainId } = useBrainContext();
const { brainId } = useUrlBrain();
const { setKnowledgeToFeed, knowledgeToFeed } = useKnowledgeToFeedContext();
const [hasPendingRequests, setHasPendingRequests] = useState(false);
const { handleFeedBrain } = useFeedBrainHandler();
const { createChat, deleteChat } = useChatApi();
const feedBrain = async (): Promise<void> => {
if (currentBrainId === null) {
if (brainId === undefined) {
publish({
variant: "danger",
text: t("selectBrainFirst"),
@ -51,7 +51,7 @@ export const useFeedBrain = ({
closeFeedInput?.();
setHasPendingRequests(true);
await handleFeedBrain({
brainId: currentBrainId,
brainId,
chatId: currentChatId,
});

View File

@ -0,0 +1,46 @@
import { UUID } from "crypto";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import Spinner from "@/lib/components/ui/Spinner";
import { useAddedKnowledge } from "./hooks/useAddedKnowledge";
import { KnowledgeTable } from "../KnowledgeTable/KnowledgeTable";
type AddedKnowledgeProps = {
brainId: UUID;
};
export const AddedKnowledge = ({
brainId,
}: AddedKnowledgeProps): JSX.Element => {
const { isPending, allKnowledge } = useAddedKnowledge({
brainId,
});
const { t } = useTranslation("explore");
if (isPending) {
return <Spinner />;
}
if (allKnowledge.length === 0) {
return (
<motion.div layout className="w-full max-w-xl flex flex-col gap-5">
<div className="flex flex-col items-center justify-center mt-0 gap-1">
<p className="text-center">{t("empty", { ns: "explore" })}</p>
<p className="text-center">
{t("feed_brain_instructions", { ns: "explore" })}
</p>
</div>
</motion.div>
);
}
return (
<motion.div layout className="w-full flex flex-col gap-5">
<AnimatePresence mode="popLayout">
<KnowledgeTable knowledgeList={allKnowledge} />
</AnimatePresence>
</motion.div>
);
};

View File

@ -0,0 +1,44 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { UUID } from "crypto";
import { getKnowledgeDataKey } from "@/lib/api/knowledge/config";
import { useKnowledgeApi } from "@/lib/api/knowledge/useKnowledgeApi";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useAddedKnowledge = ({ brainId }: { brainId?: UUID }) => {
const queryClient = useQueryClient();
const { getAllKnowledge } = useKnowledgeApi();
const fetchKnowledge = () => {
if (brainId !== undefined) {
return getAllKnowledge({ brainId });
}
};
const { data: allKnowledge, isLoading: isPending } = useQuery({
queryKey: brainId !== undefined ? [getKnowledgeDataKey(brainId)] : [],
queryFn: fetchKnowledge,
enabled: brainId !== undefined,
});
if (brainId === undefined) {
return {
invalidateKnowledgeDataKey: () => void {},
isPending: false,
allKnowledge: [],
};
}
const knowledge_data_key = getKnowledgeDataKey(brainId);
const invalidateKnowledgeDataKey = () => {
void queryClient.invalidateQueries({ queryKey: [knowledge_data_key] });
};
return {
invalidateKnowledgeDataKey,
isPending,
allKnowledge: allKnowledge ?? [],
};
};

View File

@ -0,0 +1,19 @@
import { Knowledge } from "@/lib/types/Knowledge";
import KnowledgeItem from "./components/KnowledgeItem";
interface KnowledgeTableProps {
knowledgeList: Knowledge[];
}
export const KnowledgeTable = ({
knowledgeList,
}: KnowledgeTableProps): JSX.Element => {
return (
<div className="w-full shadow-md dark:shadow-primary/25 rounded-xl bg-white dark:bg-black border border-black/10 dark:border-white/25 mt-0 p-5">
{knowledgeList.map((knowledge) => (
<KnowledgeItem knowledge={knowledge} key={knowledge.id} />
))}
</div>
);
};

View File

@ -0,0 +1,11 @@
"use client";
export const CrawledKnowledgeItem = ({ url }: { url: string }): JSX.Element => {
return (
<a href={url} target="_blank" rel="noopener noreferrer">
<div className="text-sm text-gray-900">
<p className={"max-w-[400px] truncate"}>{url}</p>
</div>
</a>
);
};

View File

@ -3,20 +3,20 @@
import { AiOutlineLoading3Quarters } from "react-icons/ai";
import { MdDelete } from "react-icons/md";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useUrlBrain } from "@/lib/hooks/useBrainIdFromUrl";
import { Knowledge } from "@/lib/types/Knowledge";
import { useKnowledgeItem } from "./useKnowledgeItem";
import { useKnowledgeItem } from "../hooks/useKnowledgeItem";
export const DeleteKnowledge = ({
knowledge,
}: {
knowledge: Knowledge;
}): JSX.Element => {
const { isDeleting, onDeleteKnowledge } = useKnowledgeItem();
const { brain } = useUrlBrain();
const { currentBrain } = useBrainContext();
const canDeleteFile = currentBrain?.role === "Owner";
const canDeleteFile = brain?.role === "Owner";
if (!canDeleteFile) {
return <></>;
@ -25,10 +25,7 @@ export const DeleteKnowledge = ({
return isDeleting ? (
<AiOutlineLoading3Quarters />
) : (
<button
className="text-red-600 hover:text-red-900"
onClick={() => void onDeleteKnowledge(knowledge)}
>
<button onClick={() => void onDeleteKnowledge(knowledge)}>
<MdDelete size="20" />
</button>
);

View File

@ -1,17 +1,20 @@
import axios from "axios";
import { BsFillCloudArrowDownFill } from "react-icons/bs";
import { HiOutlineDownload } from "react-icons/hi";
import { useKnowledgeApi } from "@/lib/api/knowledge/useKnowledgeApi";
import { getFileIcon } from "@/lib/helpers/getFileIcon";
import { UploadedKnowledge } from "@/lib/types/Knowledge";
import { isUploadedKnowledge, Knowledge } from "@/lib/types/Knowledge";
export const DownloadUploadedKnowledge = ({
knowledge,
}: {
knowledge: UploadedKnowledge;
knowledge: Knowledge;
}): JSX.Element => {
const { generateSignedUrlKnowledge } = useKnowledgeApi();
if (!isUploadedKnowledge(knowledge)) {
return <div />;
}
const downloadFile = async () => {
const download_url = await generateSignedUrlKnowledge({
knowledgeId: knowledge.id,
@ -37,12 +40,8 @@ export const DownloadUploadedKnowledge = ({
};
return (
<a
onClick={() => void downloadFile()}
style={{ display: "flex", flexDirection: "column", alignItems: "center" }}
>
{getFileIcon(knowledge.fileName)}
<BsFillCloudArrowDownFill fontSize="small" />
<a onClick={() => void downloadFile()} className="cursor-pointer">
<HiOutlineDownload fontSize="small" size={20} />
</a>
);
};

View File

@ -3,12 +3,12 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useKnowledgeApi } from "@/lib/api/knowledge/useKnowledgeApi";
import { useBrainContext } from "@/lib/context/BrainProvider/hooks/useBrainContext";
import { useToast } from "@/lib/hooks";
import { useUrlBrain } from "@/lib/hooks/useBrainIdFromUrl";
import { Knowledge } from "@/lib/types/Knowledge";
import { useEventTracking } from "@/services/analytics/june/useEventTracking";
import { useKnowledge } from "../hooks/useKnowledge";
import { useKnowledge } from "../../../../../hooks/useKnowledge";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useKnowledgeItem = () => {
@ -16,10 +16,9 @@ export const useKnowledgeItem = () => {
const [isDeleting, setIsDeleting] = useState(false);
const { publish } = useToast();
const { track } = useEventTracking();
const { currentBrain } = useBrainContext();
const { brainId, brain } = useUrlBrain();
const { invalidateKnowledgeDataKey } = useKnowledge({
brainId: currentBrain?.id,
brainId,
});
const { t } = useTranslation(["translation", "explore"]);
@ -30,11 +29,11 @@ export const useKnowledgeItem = () => {
const knowledge_name =
"fileName" in knowledge ? knowledge.fileName : knowledge.url;
try {
if (currentBrain?.id === undefined) {
if (brainId === undefined) {
throw new Error(t("noBrain", { ns: "explore" }));
}
await deleteKnowledge({
brainId: currentBrain.id,
brainId,
knowledgeId: knowledge.id,
});
@ -44,7 +43,7 @@ export const useKnowledgeItem = () => {
variant: "success",
text: t("deleted", {
fileName: knowledge_name,
brain: currentBrain.name,
brain: brain?.name,
ns: "explore",
}),
});

View File

@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { MdLink } from "react-icons/md";
import { getFileIcon } from "@/lib/helpers/getFileIcon";
import { isUploadedKnowledge, Knowledge } from "@/lib/types/Knowledge";
import { CrawledKnowledgeItem } from "./components/CrawledKnowledgeItem";
import { DeleteKnowledge } from "./components/DeleteKnowledge";
import { DownloadUploadedKnowledge } from "./components/DownloadUploadedKnowledge";
const KnowledgeItem = ({
knowledge,
}: {
knowledge: Knowledge;
}): JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="hover:bg-gray-50 rounded-lg flex justify-between w-full py-2 px-1"
>
<div className="text-sm text-gray-900 flex gap-3 items-center">
{isUploadedKnowledge(knowledge) ? (
getFileIcon(knowledge.fileName)
) : (
<MdLink size="20" color="gray" />
)}
{isUploadedKnowledge(knowledge) ? (
<p className={"max-w-[400px] truncate"}>{knowledge.fileName}</p>
) : (
<CrawledKnowledgeItem url={knowledge.url} />
)}
</div>
<div className="flex flex-end items-center">
{isHovered && (
<div className="flex items-center gap-2">
<DownloadUploadedKnowledge knowledge={knowledge} />
<DeleteKnowledge knowledge={knowledge} />
</div>
)}
</div>
</div>
);
};
KnowledgeItem.displayName = "KnowledgeItem";
export default KnowledgeItem;

View File

@ -1,23 +1,18 @@
"use client";
import { UUID } from "crypto";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import Spinner from "@/lib/components/ui/Spinner";
import { Divider } from "@/lib/components/ui/Divider";
import { KnowledgeToFeedProvider } from "@/lib/context";
import { AddKnowledge } from "./AddKnowledge";
import { KnowledgeTable } from "./KnowledgeTable";
import { useKnowledge } from "./hooks/useKnowledge";
import { AddKnowledge } from "./components/AddKnowledge/AddKnowledge";
import { AddedKnowledge } from "./components/AddedKnowledge/AddedKnowledge";
type KnowledgeTabProps = {
brainId: UUID;
};
export const KnowledgeTab = ({ brainId }: KnowledgeTabProps): JSX.Element => {
const { t } = useTranslation(["translation", "explore"]);
const { isPending, allKnowledge } = useKnowledge({
brainId,
});
const { t } = useTranslation(["translation", "explore", "config"]);
return (
<KnowledgeToFeedProvider>
@ -29,25 +24,14 @@ export const KnowledgeTab = ({ brainId }: KnowledgeTabProps): JSX.Element => {
</h1>
<h2 className="opacity-50">{t("subtitle", { ns: "explore" })}</h2>
</div>
<Divider text={t("Upload")} />
<AddKnowledge />
{isPending ? (
<Spinner />
) : (
<motion.div layout className="w-full max-w-xl flex flex-col gap-5">
{allKnowledge.length !== 0 ? (
<AnimatePresence mode="popLayout">
<KnowledgeTable knowledgeList={allKnowledge} />
</AnimatePresence>
) : (
<div className="flex flex-col items-center justify-center mt-10 gap-1">
<p className="text-center">{t("empty", { ns: "explore" })}</p>
<p className="text-center">
{t("feed_brain_instructions", { ns: "explore" })}
</p>
</div>
)}
</motion.div>
)}
<Divider
text={t("knowledge", {
ns: "config",
})}
/>
<AddedKnowledge brainId={brainId} />
</section>
</main>
</KnowledgeToFeedProvider>

View File

@ -40,7 +40,7 @@ export const GeneralInformation = (
return (
<>
<div className="flex flex-row flex-1 justify-between w-full items-end">
<div className="grid grid-cols-1 md:grid-cols-2 justify-between w-full items-end">
<div>
<Field
label={t("brainName", { ns: "brain" })}
@ -54,29 +54,30 @@ export const GeneralInformation = (
</div>
<div className="mt-4">
<div className="flex flex-1 items-center flex-col">
<div className="flex flex-1 items-end flex-col">
{isPublicBrain && !isOwnedByCurrentUser && (
<Chip className="mb-3 bg-primary text-white w-full">
{t("brain:public_brain_label")}
</Chip>
)}
{isDefaultBrain ? (
<div className="border rounded-lg border-dashed border-black dark:border-white bg-white dark:bg-black text-black dark:text-white focus:bg-black dark:focus:bg-white dark dark focus:text-white dark:focus:text-black transition-colors py-2 px-4 shadow-none">
{t("defaultBrain", { ns: "brain" })}
</div>
) : (
hasEditRights && (
<Button
variant={"secondary"}
isLoading={isSettingAsDefault}
onClick={() => void setAsDefaultBrainHandler()}
type="button"
>
{t("setDefaultBrain", { ns: "brain" })}
</Button>
)
)}
<div>
{isDefaultBrain ? (
<div className="border rounded-lg border-dashed border-black dark:border-white bg-white dark:bg-black text-black dark:text-white focus:bg-black dark:focus:bg-white dark dark focus:text-white dark:focus:text-black transition-colors py-2 px-4 shadow-none">
{t("defaultBrain", { ns: "brain" })}
</div>
) : (
hasEditRights && (
<Button
variant={"secondary"}
isLoading={isSettingAsDefault}
onClick={() => void setAsDefaultBrainHandler()}
type="button"
>
{t("setDefaultBrain", { ns: "brain" })}
</Button>
)
)}
</div>
</div>
</div>
</div>

View File

@ -43,6 +43,7 @@ export const useBrainManagementTabs = () => {
const params = useParams();
const { t } = useTranslation(["delete_or_unsubscribe_from_brain"]);
const brainId = params?.brainId as UUID | undefined;
const { hasEditRights, isOwnedByCurrentUser } = getBrainPermissions({
brainId,
userAccessibleBrains: allBrains,

View File

@ -0,0 +1,19 @@
import { UUID } from "crypto";
import { useParams } from "next/navigation";
import { useBrainContext } from "../context/BrainProvider/hooks/useBrainContext";
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const useUrlBrain = () => {
const { allBrains } = useBrainContext();
const params = useParams();
const brainId = params?.brainId as UUID | undefined;
const correspondingBrain = allBrains.find((brain) => brain.id === brainId);
return {
brain: correspondingBrain,
brainId,
};
};