chatbot-ui/components/chat/chat-files-display.tsx

284 lines
9.0 KiB
TypeScript

import { ChatbotUIContext } from "@/context/context"
import { getFileFromStorage } from "@/db/storage/files"
import useHotkey from "@/lib/hooks/use-hotkey"
import { cn } from "@/lib/utils"
import { ChatFile, MessageImage } from "@/types"
import {
IconCircleFilled,
IconFileFilled,
IconFileTypeCsv,
IconFileTypeDocx,
IconFileTypePdf,
IconFileTypeTxt,
IconJson,
IconLoader2,
IconMarkdown,
IconX
} from "@tabler/icons-react"
import Image from "next/image"
import { FC, useContext, useState } from "react"
import { Button } from "../ui/button"
import { FilePreview } from "../ui/file-preview"
import { WithTooltip } from "../ui/with-tooltip"
import { ChatRetrievalSettings } from "./chat-retrieval-settings"
interface ChatFilesDisplayProps {}
export const ChatFilesDisplay: FC<ChatFilesDisplayProps> = ({}) => {
useHotkey("f", () => setShowFilesDisplay(prev => !prev))
useHotkey("e", () => setUseRetrieval(prev => !prev))
const {
files,
newMessageImages,
setNewMessageImages,
newMessageFiles,
setNewMessageFiles,
setShowFilesDisplay,
showFilesDisplay,
chatFiles,
chatImages,
setChatImages,
setChatFiles,
setUseRetrieval
} = useContext(ChatbotUIContext)
const [selectedFile, setSelectedFile] = useState<ChatFile | null>(null)
const [selectedImage, setSelectedImage] = useState<MessageImage | null>(null)
const [showPreview, setShowPreview] = useState(false)
const messageImages = [
...newMessageImages.filter(
image =>
!chatImages.some(chatImage => chatImage.messageId === image.messageId)
)
]
const combinedChatFiles = [
...newMessageFiles.filter(
file => !chatFiles.some(chatFile => chatFile.id === file.id)
),
...chatFiles
]
const combinedMessageFiles = [...messageImages, ...combinedChatFiles]
const getLinkAndView = async (file: ChatFile) => {
const fileRecord = files.find(f => f.id === file.id)
if (!fileRecord) return
const link = await getFileFromStorage(fileRecord.file_path)
window.open(link, "_blank")
}
return showFilesDisplay && combinedMessageFiles.length > 0 ? (
<>
{showPreview && selectedImage && (
<FilePreview
type="image"
item={selectedImage}
isOpen={showPreview}
onOpenChange={(isOpen: boolean) => {
setShowPreview(isOpen)
setSelectedImage(null)
}}
/>
)}
{showPreview && selectedFile && (
<FilePreview
type="file"
item={selectedFile}
isOpen={showPreview}
onOpenChange={(isOpen: boolean) => {
setShowPreview(isOpen)
setSelectedFile(null)
}}
/>
)}
<div className="space-y-2">
<div className="flex w-full items-center justify-center">
<Button
className="flex h-[32px] w-[140px] space-x-2"
onClick={() => setShowFilesDisplay(false)}
>
<RetrievalToggle />
<div>Hide files</div>
<div onClick={e => e.stopPropagation()}>
<ChatRetrievalSettings />
</div>
</Button>
</div>
<div className="overflow-auto">
<div className="flex gap-2 overflow-auto pt-2">
{messageImages.map((image, index) => (
<div
key={index}
className="relative flex h-[64px] cursor-pointer items-center space-x-4 rounded-xl hover:opacity-50"
>
<Image
className="rounded"
// Force the image to be 56px by 56px
style={{
minWidth: "56px",
minHeight: "56px",
maxHeight: "56px",
maxWidth: "56px"
}}
src={image.base64} // Preview images will always be base64
alt="File image"
width={56}
height={56}
onClick={() => {
setSelectedImage(image)
setShowPreview(true)
}}
/>
<IconX
className="bg-muted-foreground border-primary absolute right-[-6px] top-[-2px] flex size-5 cursor-pointer items-center justify-center rounded-full border-DEFAULT text-[10px] hover:border-red-500 hover:bg-white hover:text-red-500"
onClick={e => {
e.stopPropagation()
setNewMessageImages(
newMessageImages.filter(
f => f.messageId !== image.messageId
)
)
setChatImages(
chatImages.filter(f => f.messageId !== image.messageId)
)
}}
/>
</div>
))}
{combinedChatFiles.map((file, index) =>
file.id === "loading" ? (
<div
key={index}
className="relative flex h-[64px] items-center space-x-4 rounded-xl border-2 px-4 py-3"
>
<div className="rounded bg-blue-500 p-2">
<IconLoader2 className="animate-spin" />
</div>
<div className="truncate text-sm">
<div className="truncate">{file.name}</div>
<div className="truncate opacity-50">{file.type}</div>
</div>
</div>
) : (
<div
key={file.id}
className="relative flex h-[64px] cursor-pointer items-center space-x-4 rounded-xl border-2 px-4 py-3 hover:opacity-50"
onClick={() => getLinkAndView(file)}
>
<div className="rounded bg-blue-500 p-2">
{(() => {
let fileExtension = file.type.includes("/")
? file.type.split("/")[1]
: file.type
switch (fileExtension) {
case "pdf":
return <IconFileTypePdf />
case "markdown":
return <IconMarkdown />
case "txt":
return <IconFileTypeTxt />
case "json":
return <IconJson />
case "csv":
return <IconFileTypeCsv />
case "docx":
return <IconFileTypeDocx />
default:
return <IconFileFilled />
}
})()}
</div>
<div className="truncate text-sm">
<div className="truncate">{file.name}</div>
</div>
<IconX
className="bg-muted-foreground border-primary absolute right-[-6px] top-[-6px] flex size-5 cursor-pointer items-center justify-center rounded-full border-DEFAULT text-[10px] hover:border-red-500 hover:bg-white hover:text-red-500"
onClick={e => {
e.stopPropagation()
setNewMessageFiles(
newMessageFiles.filter(f => f.id !== file.id)
)
setChatFiles(chatFiles.filter(f => f.id !== file.id))
}}
/>
</div>
)
)}
</div>
</div>
</div>
</>
) : (
combinedMessageFiles.length > 0 && (
<div className="flex w-full items-center justify-center space-x-2">
<Button
className="flex h-[32px] w-[140px] space-x-2"
onClick={() => setShowFilesDisplay(true)}
>
<RetrievalToggle />
<div>
{" "}
View {combinedMessageFiles.length} file
{combinedMessageFiles.length > 1 ? "s" : ""}
</div>
<div onClick={e => e.stopPropagation()}>
<ChatRetrievalSettings />
</div>
</Button>
</div>
)
)
}
const RetrievalToggle = ({}) => {
const { useRetrieval, setUseRetrieval } = useContext(ChatbotUIContext)
return (
<div className="flex items-center">
<WithTooltip
delayDuration={0}
side="top"
display={
<div>
{useRetrieval
? "File retrieval is enabled on the selected files for this message. Click the indicator to disable."
: "Click the indicator to enable file retrieval for this message."}
</div>
}
trigger={
<IconCircleFilled
className={cn(
"p-1",
useRetrieval ? "text-green-500" : "text-red-500",
useRetrieval ? "hover:text-green-200" : "hover:text-red-200"
)}
size={24}
onClick={e => {
e.stopPropagation()
setUseRetrieval(prev => !prev)
}}
/>
}
/>
</div>
)
}