feat: file upload thumbnail (#5088)
This commit is contained in:
parent
0df92d8bda
commit
484caf04aa
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import TextareaAutosize from 'react-textarea-autosize'
|
import TextareaAutosize from 'react-textarea-autosize'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, toGigabytes } from '@/lib/utils'
|
||||||
import { usePrompt } from '@/hooks/usePrompt'
|
import { usePrompt } from '@/hooks/usePrompt'
|
||||||
import { useThreads } from '@/hooks/useThreads'
|
import { useThreads } from '@/hooks/useThreads'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@ -58,6 +58,15 @@ const ChatInput = ({
|
|||||||
const { selectedModel } = useModelProvider()
|
const { selectedModel } = useModelProvider()
|
||||||
const { sendMessage } = useChat()
|
const { sendMessage } = useChat()
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<
|
||||||
|
Array<{
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
base64: string
|
||||||
|
dataUrl: string
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
|
||||||
const handleSendMesage = (prompt: string) => {
|
const handleSendMesage = (prompt: string) => {
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
@ -137,6 +146,122 @@ const ChatInput = ({
|
|||||||
[abortControllers]
|
[abortControllers]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleAttachmentClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveFile = (indexToRemove: number) => {
|
||||||
|
setUploadedFiles((prev) =>
|
||||||
|
prev.filter((_, index) => index !== indexToRemove)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileTypeFromExtension = (fileName: string): string => {
|
||||||
|
const extension = fileName.toLowerCase().split('.').pop()
|
||||||
|
switch (extension) {
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg':
|
||||||
|
return 'image/jpeg'
|
||||||
|
case 'png':
|
||||||
|
return 'image/png'
|
||||||
|
case 'pdf':
|
||||||
|
return 'application/pdf'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const maxSize = 10 * 1024 * 1024 // 10MB in bytes
|
||||||
|
const newFiles: Array<{
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
base64: string
|
||||||
|
dataUrl: string
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
Array.from(files).forEach((file) => {
|
||||||
|
// Check file size
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
setMessage(`File is too large. Maximum size is 10MB.`)
|
||||||
|
// Reset file input to allow re-uploading
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file type - use extension as fallback if MIME type is incorrect
|
||||||
|
const detectedType = file.type || getFileTypeFromExtension(file.name)
|
||||||
|
const actualType = getFileTypeFromExtension(file.name) || detectedType
|
||||||
|
|
||||||
|
// Check file type
|
||||||
|
const allowedTypes = [
|
||||||
|
'image/jpg',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'application/pdf',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(actualType)) {
|
||||||
|
setMessage(
|
||||||
|
`File is not supported. Only JPEG, JPG, PNG, and PDF files are allowed.`
|
||||||
|
)
|
||||||
|
// Reset file input to allow re-uploading
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result
|
||||||
|
if (typeof result === 'string') {
|
||||||
|
const base64String = result.split(',')[1]
|
||||||
|
const fileData = {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: actualType,
|
||||||
|
base64: base64String,
|
||||||
|
dataUrl: result,
|
||||||
|
}
|
||||||
|
newFiles.push(fileData)
|
||||||
|
// Update state
|
||||||
|
if (
|
||||||
|
newFiles.length ===
|
||||||
|
Array.from(files).filter((f) => {
|
||||||
|
const fType = getFileTypeFromExtension(f.name) || f.type
|
||||||
|
return f.size <= maxSize && allowedTypes.includes(fType)
|
||||||
|
}).length
|
||||||
|
) {
|
||||||
|
setUploadedFiles((prev) => {
|
||||||
|
const updated = [...prev, ...newFiles]
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
// Reset the file input value to allow re-uploading the same file
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
setMessage('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -157,12 +282,61 @@ const ChatInput = ({
|
|||||||
</MovingBorder>
|
</MovingBorder>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-20 px-0 pb-10 border border-main-view-fg/5 rounded-lg text-main-view-fg bg-main-view',
|
'relative z-20 px-0 pb-10 border border-main-view-fg/5 rounded-lg text-main-view-fg bg-main-view',
|
||||||
isFocused && 'ring-1 ring-main-view-fg/10'
|
isFocused && 'ring-1 ring-main-view-fg/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div className="flex gap-3 items-center p-2 pb-0">
|
||||||
|
{uploadedFiles.map((file, index) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'relative border border-main-view-fg/5 rounded-lg',
|
||||||
|
file.type.startsWith('image/') ? 'size-14' : 'h-14 '
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{file.type.startsWith('image/') && (
|
||||||
|
<img
|
||||||
|
className="object-cover w-full h-full rounded-lg"
|
||||||
|
src={file.dataUrl}
|
||||||
|
alt={`${file.name} - ${index}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{file.type === 'application/pdf' && (
|
||||||
|
<div className="bg-main-view-fg/4 h-full rounded-lg p-2 max-w-[400px] pr-4">
|
||||||
|
<div className="flex gap-2 items-center justify-center h-full">
|
||||||
|
<div className="size-10 rounded-md bg-main-view shrink-0 flex items-center justify-center">
|
||||||
|
<span className="uppercase font-bold">
|
||||||
|
{file.name.split('.').pop()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<h6 className="truncate mb-0.5 text-main-view-fg/80">
|
||||||
|
{file.name}
|
||||||
|
</h6>
|
||||||
|
<p className="text-xs text-main-view-fg/70">
|
||||||
|
{toGigabytes(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="absolute -top-1 -right-2.5 bg-destructive size-5 flex rounded-full items-center justify-center cursor-pointer"
|
||||||
|
onClick={() => handleRemoveFile(index)}
|
||||||
|
>
|
||||||
|
<IconX className="text-destructive-fg" size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
disabled={Boolean(streamingContent)}
|
disabled={Boolean(streamingContent)}
|
||||||
@ -215,8 +389,17 @@ const ChatInput = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File attachment - always available */}
|
{/* File attachment - always available */}
|
||||||
<div className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1">
|
<div
|
||||||
|
className="h-6 p-1 flex items-center justify-center rounded-sm hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out gap-1"
|
||||||
|
onClick={handleAttachmentClick}
|
||||||
|
>
|
||||||
<IconPaperclip size={18} className="text-main-view-fg/50" />
|
<IconPaperclip size={18} className="text-main-view-fg/50" />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Microphone - always available - Temp Hide */}
|
{/* Microphone - always available - Temp Hide */}
|
||||||
@ -272,7 +455,9 @@ const ChatInput = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => stopStreaming(currentThreadId ?? streamingContent.thread_id)}
|
onClick={() =>
|
||||||
|
stopStreaming(currentThreadId ?? streamingContent.thread_id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<IconPlayerStopFilled />
|
<IconPlayerStopFilled />
|
||||||
</Button>
|
</Button>
|
||||||
@ -293,7 +478,7 @@ const ChatInput = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{message && !selectedModel && (
|
{message && (
|
||||||
<div className="bg-main-view-fg/2 -mt-0.5 mx-2 pb-2 px-3 pt-1.5 rounded-b-lg text-xs text-destructive transition-all duration-200 ease-in-out">
|
<div className="bg-main-view-fg/2 -mt-0.5 mx-2 pb-2 px-3 pt-1.5 rounded-b-lg text-xs text-destructive transition-all duration-200 ease-in-out">
|
||||||
<div className="flex items-center gap-1 justify-between">
|
<div className="flex items-center gap-1 justify-between">
|
||||||
{message}
|
{message}
|
||||||
@ -301,6 +486,10 @@ const ChatInput = ({
|
|||||||
className="size-3 text-main-view-fg/30 cursor-pointer"
|
className="size-3 text-main-view-fg/30 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
// Reset file input to allow re-uploading the same file
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -182,9 +182,9 @@ export const ThreadContent = memo(
|
|||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
|
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
|
||||||
<IconPencil size={16} />
|
<IconPencil size={16} />
|
||||||
</button>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Edit</p>
|
<p>Edit</p>
|
||||||
@ -331,9 +331,9 @@ export const ThreadContent = memo(
|
|||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
|
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
|
||||||
<IconInfoCircle size={16} />
|
<IconInfoCircle size={16} />
|
||||||
</button>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Metadata</p>
|
<p>Metadata</p>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user