Louis 5ca3069fa1
fix: image upload button and drag event are not enabled (#2248)
* fix: image upload button and drag event are not enabled

* fix: add tooltips for unsupported model
2024-03-06 09:09:38 +07:00

344 lines
12 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef, useState } from 'react'
import { InferenceEvent, MessageStatus, events } from '@janhq/core'
import {
Textarea,
Button,
Tooltip,
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from '@janhq/uikit'
import { useAtom, useAtomValue } from 'jotai'
import {
FileTextIcon,
ImageIcon,
StopCircle,
PaperclipIcon,
} from 'lucide-react'
import { twMerge } from 'tailwind-merge'
import { currentPromptAtom, fileUploadAtom } from '@/containers/Providers/Jotai'
import { useActiveModel } from '@/hooks/useActiveModel'
import { useClickOutside } from '@/hooks/useClickOutside'
import useSendChatMessage from '@/hooks/useSendChatMessage'
import FileUploadPreview from '../FileUploadPreview'
import ImageUploadPreview from '../ImageUploadPreview'
import { experimentalFeatureEnabledAtom } from '@/helpers/atoms/AppConfig.atom'
import { getCurrentChatMessagesAtom } from '@/helpers/atoms/ChatMessage.atom'
import {
activeThreadAtom,
getActiveThreadIdAtom,
isGeneratingResponseAtom,
threadStatesAtom,
waitingToSendMessage,
} from '@/helpers/atoms/Thread.atom'
const ChatInput: React.FC = () => {
const activeThread = useAtomValue(activeThreadAtom)
const { stateModel } = useActiveModel()
const messages = useAtomValue(getCurrentChatMessagesAtom)
const [currentPrompt, setCurrentPrompt] = useAtom(currentPromptAtom)
const { sendChatMessage } = useSendChatMessage()
const activeThreadId = useAtomValue(getActiveThreadIdAtom)
const [isWaitingToSend, setIsWaitingToSend] = useAtom(waitingToSendMessage)
const [fileUpload, setFileUpload] = useAtom(fileUploadAtom)
const [showAttacmentMenus, setShowAttacmentMenus] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const experimentalFeature = useAtomValue(experimentalFeatureEnabledAtom)
const isGeneratingResponse = useAtomValue(isGeneratingResponseAtom)
const threadStates = useAtomValue(threadStatesAtom)
const isStreamingResponse = Object.values(threadStates).some(
(threadState) => threadState.waitingForResponse
)
const onPromptChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCurrentPrompt(e.target.value)
}
const refAttachmentMenus = useClickOutside(() => setShowAttacmentMenus(false))
useEffect(() => {
if (isWaitingToSend && activeThreadId) {
setIsWaitingToSend(false)
sendChatMessage(currentPrompt)
}
}, [
activeThreadId,
isWaitingToSend,
currentPrompt,
setIsWaitingToSend,
sendChatMessage,
])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.focus()
}
}, [activeThreadId])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = '40px'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
}
}, [currentPrompt])
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (messages[messages.length - 1]?.status !== MessageStatus.Pending)
sendChatMessage(currentPrompt)
else onStopInferenceClick()
}
}
const onStopInferenceClick = async () => {
events.emit(InferenceEvent.OnInferenceStopped, {})
}
/**
* Handles the change event of the extension file input element by setting the file name state.
* Its to be used to display the extension file name of the selected file.
* @param event - The change event object.
*/
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setFileUpload([{ file: file, type: 'pdf' }])
}
const handleImageChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setFileUpload([{ file: file, type: 'image' }])
}
const renderPreview = (fileUpload: any) => {
if (fileUpload.length > 0) {
if (fileUpload[0].type === 'image') {
return <ImageUploadPreview file={fileUpload[0].file} />
} else {
return <FileUploadPreview />
}
}
}
return (
<div className="mx-auto flex w-full flex-shrink-0 items-end justify-center space-x-4 px-8 py-4">
<div className="relative flex w-full flex-col">
{renderPreview(fileUpload)}
<Textarea
className={twMerge(
'max-h-[400px] resize-none pr-20',
fileUpload.length && 'rounded-t-none'
)}
style={{ height: '40px' }}
ref={textareaRef}
onKeyDown={onKeyDown}
placeholder="Enter your message..."
disabled={stateModel.loading || !activeThread}
value={currentPrompt}
onChange={onPromptChange}
/>
{experimentalFeature && (
<Tooltip>
<TooltipTrigger asChild>
<PaperclipIcon
size={20}
className="absolute bottom-2 right-4 cursor-pointer text-muted-foreground"
onClick={(e) => {
if (
fileUpload.length > 0 ||
(activeThread?.assistants[0].tools &&
!activeThread?.assistants[0].tools[0]?.enabled &&
!activeThread?.assistants[0].model.settings.vision_model)
) {
e.stopPropagation()
} else {
setShowAttacmentMenus(!showAttacmentMenus)
}
}}
/>
</TooltipTrigger>
<TooltipPortal>
{fileUpload.length > 0 ||
(activeThread?.assistants[0].tools &&
!activeThread?.assistants[0].tools[0]?.enabled &&
!activeThread?.assistants[0].model.settings.vision_model && (
<TooltipContent side="top" className="max-w-[154px] px-3">
{fileUpload.length !== 0 && (
<span>
Currently, we only support 1 attachment at the same
time.
</span>
)}
{activeThread?.assistants[0].tools &&
activeThread?.assistants[0].tools[0]?.enabled ===
false && (
<span>
Turn on Retrieval in Assistant Settings to use this
feature.
</span>
)}
<TooltipArrow />
</TooltipContent>
))}
</TooltipPortal>
</Tooltip>
)}
{showAttacmentMenus && (
<div
ref={refAttachmentMenus}
className="absolute bottom-10 right-0 z-30 w-36 cursor-pointer rounded-lg border border-border bg-background py-1 shadow"
>
<ul>
<Tooltip>
<TooltipTrigger asChild>
<li
className={twMerge(
'flex w-full items-center space-x-2 px-4 py-2 text-muted-foreground hover:bg-secondary',
activeThread?.assistants[0].model.settings.vision_model
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
)}
onClick={() => {
if (
activeThread?.assistants[0].model.settings.vision_model
) {
imageInputRef.current?.click()
setShowAttacmentMenus(false)
}
}}
>
<ImageIcon size={16} />
<span className="font-medium">Image</span>
</li>
</TooltipTrigger>
<TooltipPortal>
{!activeThread?.assistants[0].model.settings.vision_model && (
<TooltipContent side="top" className="max-w-[154px] px-3">
<span>This feature only supports multimodal models.</span>
<TooltipArrow />
</TooltipContent>
)}
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<li
className={twMerge(
'flex w-full cursor-pointer items-center space-x-2 px-4 py-2 text-muted-foreground hover:bg-secondary',
activeThread?.assistants[0].model.settings.vision_model &&
activeThread?.assistants[0].model.settings
.text_model === false
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer'
)}
onClick={() => {
if (
!activeThread?.assistants[0].model.settings
.vision_model ||
activeThread?.assistants[0].model.settings
.text_model !== false
) {
fileInputRef.current?.click()
setShowAttacmentMenus(false)
}
}}
>
<FileTextIcon size={16} />
<span className="font-medium">Document</span>
</li>
</TooltipTrigger>
<TooltipPortal>
{(!activeThread?.assistants[0].tools ||
!activeThread?.assistants[0].tools[0]?.enabled ||
activeThread?.assistants[0].model.settings.text_model ===
false) && (
<TooltipContent side="top" className="max-w-[154px] px-3">
{activeThread?.assistants[0].model.settings.text_model ===
false ? (
<span>
This model does not support text-based retrieval.
</span>
) : (
<span>
Turn on Retrieval in Assistant Settings to use this
feature.
</span>
)}
<TooltipArrow />
</TooltipContent>
)}
</TooltipPortal>
</Tooltip>
</ul>
</div>
)}
</div>
<input
type="file"
className="hidden"
ref={imageInputRef}
value=""
onChange={handleImageChange}
accept="image/png, image/jpeg, image/jpg"
/>
<input
type="file"
className="hidden"
ref={fileInputRef}
value=""
onChange={handleFileChange}
accept="application/pdf"
/>
{messages[messages.length - 1]?.status !== MessageStatus.Pending &&
!isGeneratingResponse &&
!isStreamingResponse ? (
<Button
size="lg"
disabled={
stateModel.loading ||
!activeThread ||
currentPrompt.trim().length === 0
}
themes="primary"
className="min-w-[100px]"
onClick={() => sendChatMessage(currentPrompt)}
>
Send
</Button>
) : (
<Button
size="lg"
themes="danger"
onClick={onStopInferenceClick}
className="min-w-[100px]"
>
<StopCircle size={24} />
</Button>
)}
</div>
)
}
export default ChatInput