11import React , { useState , useRef , useEffect } from "react" ;
2- import { ArrowUp , Loader2 , Paperclip , X , AlertCircle , FileText } from "lucide-react" ;
2+ import { ArrowUp , Loader2 , Paperclip , X , AlertCircle , FileText , RefreshCw } from "lucide-react" ;
33import ModelSelector from "@/components/ModelSelector" ;
44import { useIsMobile } from "@/hooks" ;
55import { modelsList } from "@app/shared/src/models" ;
66import { useAuth } from "@clerk/clerk-react" ;
77import { useParams , useNavigate } from "react-router-dom" ;
88import type { UploadedFile } from "@app/shared/src/types" ;
99import { uploadToCloudinary } from "@/utils/cloudinary" ;
10+ import { toast } from "sonner" ;
1011
1112type Attachment = {
1213 id : string ;
@@ -26,6 +27,19 @@ type Props = {
2627 startRagPolling : ( ) => void ;
2728} ;
2829
30+ const SUPPORTED_FORMATS = [
31+ "application/pdf" ,
32+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ,
33+ "application/vnd.openxmlformats-officedocument.presentationml.presentation" ,
34+ "text/csv" ,
35+ "text/plain" ,
36+ "image/jpeg" ,
37+ "image/png" ,
38+ "image/webp" ,
39+ ] ;
40+
41+ const SUPPORTED_EXTENSIONS_DISPLAY = ".pdf, .docx, .pptx, .csv, .txt, .jpg, .jpeg, .png, .webp" ;
42+
2943export default function InputContainer ( { sendMessage, isGenerating, selectedModel, onStop, onModelChange, autoFocus, isRagProcessing, startRagPolling } : Props ) {
3044 const [ message , setMessage ] = useState ( "" ) ;
3145 const { getToken } = useAuth ( ) ;
@@ -40,62 +54,102 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
4054 const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
4155 const isMobile = useIsMobile ( ) ;
4256
57+ const uploadSingleFile = async ( attachment : Attachment ) => {
58+ try {
59+ setAttachments ( ( prev ) =>
60+ prev . map ( ( item ) => ( item . id === attachment . id ? { ...item , status : "uploading" } : item ) )
61+ ) ;
62+
63+ const data = await uploadToCloudinary ( { file : attachment . file , getToken } ) ;
64+
65+ let newChatId = undefined ;
66+ if ( ! data . fileType . includes ( "image" ) ) {
67+ const token = await getToken ( ) ;
68+ const res = await fetch ( `${ import . meta. env . VITE_BACKEND_URL } /api/upload/rag` , {
69+ method : 'POST' ,
70+ headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ token } ` } ,
71+ body : JSON . stringify ( {
72+ fileUrl : data . fileUrl ,
73+ fileName : data . fileName ,
74+ fileType : data . fileType ,
75+ chatId : chatId || undefined
76+ } )
77+ } ) ;
78+ const apiData = await res . json ( ) ;
79+ if ( apiData . success ) {
80+ startRagPolling ( ) ;
81+ if ( apiData . chatId && ! chatId ) {
82+ newChatId = apiData . chatId ;
83+ }
84+ }
85+ }
86+
87+ setAttachments ( ( prev ) =>
88+ prev . map ( ( item ) => ( item . id === attachment . id ? { ...item , status : "success" , uploadData : data } : item ) )
89+ ) ;
90+
91+ if ( newChatId ) {
92+ navigate ( `/c/${ newChatId } ` , { replace : true } ) ;
93+ }
94+ } catch {
95+ setAttachments ( ( prev ) =>
96+ prev . map ( ( item ) => ( item . id === attachment . id ? { ...item , status : "error" } : item ) )
97+ ) ;
98+ }
99+ } ;
100+
43101 const processFiles = async ( files : File [ ] ) => {
44102 if ( ! files . length ) return ;
45103
46- const newAttachments : Attachment [ ] = files . map ( ( file ) => ( {
104+ const currentCount = attachments . length ;
105+ const remainingSlots = 4 - currentCount ;
106+
107+ if ( remainingSlots <= 0 ) {
108+ toast . error ( "Maximum 4 files allowed per message" ) ;
109+ return ;
110+ }
111+
112+ const validFiles = files . filter ( file => {
113+ const isSupported = SUPPORTED_FORMATS . includes ( file . type ) ||
114+ file . name . toLowerCase ( ) . endsWith ( '.txt' ) ||
115+ file . name . toLowerCase ( ) . endsWith ( '.csv' ) ||
116+ file . name . toLowerCase ( ) . endsWith ( '.pdf' ) ||
117+ file . name . toLowerCase ( ) . endsWith ( '.docx' ) ||
118+ file . name . toLowerCase ( ) . endsWith ( '.pptx' ) ;
119+
120+ if ( ! isSupported ) {
121+ toast . error ( `Unsupported file type: ${ file . name } ` ) ;
122+ }
123+ return isSupported ;
124+ } ) ;
125+
126+ if ( validFiles . length === 0 ) return ;
127+
128+ let filesToUpload = validFiles ;
129+ if ( validFiles . length > remainingSlots ) {
130+ toast . warning ( `Only ${ remainingSlots } more file${ remainingSlots > 1 ? 's' : '' } can be added (4 file limit)` ) ;
131+ filesToUpload = validFiles . slice ( 0 , remainingSlots ) ;
132+ }
133+
134+ const newAttachments : Attachment [ ] = filesToUpload . map ( ( file ) => ( {
47135 id : crypto . randomUUID ( ) ,
48136 file,
49137 status : "uploading" ,
50138 } ) ) ;
51139
52140 setAttachments ( ( prev ) => [ ...prev , ...newAttachments ] ) ;
53141
54- const uploadPromises = newAttachments . map ( async ( attachment ) => {
55- try {
56- const data = await uploadToCloudinary ( { file : attachment . file , getToken } ) ;
57-
58- let newChatId = undefined ;
59- if ( ! data . fileType . includes ( "image" ) ) {
60- const token = await getToken ( ) ;
61- const res = await fetch ( `${ import . meta. env . VITE_BACKEND_URL } /api/upload/rag` , {
62- method : 'POST' ,
63- headers : { 'Content-Type' : 'application/json' , 'Authorization' : `Bearer ${ token } ` } ,
64- body : JSON . stringify ( {
65- fileUrl : data . fileUrl ,
66- fileName : data . fileName ,
67- fileType : data . fileType ,
68- chatId : chatId || undefined
69- } )
70- } ) ;
71- const apiData = await res . json ( ) ;
72- if ( apiData . success ) {
73- startRagPolling ( ) ;
74- if ( apiData . chatId && ! chatId ) {
75- newChatId = apiData . chatId ;
76- }
77- }
78- }
79-
80- setAttachments ( ( prev ) =>
81- prev . map ( ( item ) => ( item . id === attachment . id ? { ...item , status : "success" , uploadData : data } : item ) )
82- ) ;
83-
84- if ( newChatId ) {
85- navigate ( `/c/${ newChatId } ` , { replace : true } ) ;
86- }
87- } catch {
88- setAttachments ( ( prev ) =>
89- prev . map ( ( item ) => ( item . id === attachment . id ? { ...item , status : "error" } : item ) )
90- ) ;
91- }
92- } ) ;
142+ const uploadPromises = newAttachments . map ( ( attachment ) => uploadSingleFile ( attachment ) ) ;
93143
94144 await Promise . allSettled ( uploadPromises ) ;
95145
96146 if ( fileInputRef . current ) fileInputRef . current . value = "" ;
97147 } ;
98148
149+ const handleRetryUpload = ( attachment : Attachment ) => {
150+ uploadSingleFile ( attachment ) ;
151+ } ;
152+
99153 const handleFileChange = async ( e : React . ChangeEvent < HTMLInputElement > ) => {
100154 if ( e . target . files && e . target . files . length > 0 ) {
101155 await processFiles ( Array . from ( e . target . files ) ) ;
@@ -231,7 +285,7 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
231285 const isBusy = isGenerating || isGlobalUploading ;
232286
233287 return (
234- < div className = "w-full flex justify-center items-center px-4 pb-6 sm:pb- 4 bg-transparent relative" >
288+ < div className = "w-full flex justify-center items-center px-4 pb-4 bg-transparent relative" >
235289 { isDragging && (
236290 < div className = "fixed inset-0 z-99 flex items-center justify-center bg-black/40 backdrop-blur-sm pointer-events-none transition-all duration-200" >
237291 < div className = "absolute inset-4 sm:inset-6 md:inset-8 border-4 border-dashed border-white/60 rounded-3xl z-0" />
@@ -255,9 +309,19 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
255309 < img
256310 src = { URL . createObjectURL ( att . file ) }
257311 alt = "Preview"
258- className = { `h-16 w-auto rounded-lg object-contain border border-gray-100 transition-all ${ att . status === "uploading" ? "opacity-50 blur-[1px]" : ""
259- } `}
312+ className = { `h-16 w-auto rounded-lg object-contain border border-gray-100 transition-all ${ att . status === "uploading" ? "opacity-50 blur-[1px]" : "opacity-100 "
313+ } ${ att . status === "error" ? "border-red-400 bg-red-50" : "" } `}
260314 />
315+ { att . status === "error" && (
316+ < button
317+ type = "button"
318+ onClick = { ( ) => handleRetryUpload ( att ) }
319+ className = "absolute inset-0 m-auto h-8 w-8 bg-black/60 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg z-20"
320+ title = "Retry upload"
321+ >
322+ < RefreshCw size = { 16 } />
323+ </ button >
324+ ) }
261325 < button
262326 type = "button"
263327 onClick = { ( ) => handleRemoveAttachment ( att . id ) }
@@ -268,10 +332,20 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
268332 </ div >
269333 ) : (
270334 < div className = "relative inline-flex items-center gap-2 bg-gray-50 px-3 py-2 rounded-lg border border-gray-100 w-fit max-w-full" >
271- < div className = "bg-white p-1.5 rounded-md border border-gray-200 shadow-sm" >
272- < FileText size = { 16 } className = "text-primary" />
335+ < div className = { `bg-white p-1.5 rounded-md border shadow-sm transition-colors ${ att . status === "error" ? "border-red-200 bg-red-50" : "border-gray-200" } ` } >
336+ { att . status === "error" ? (
337+ < RefreshCw
338+ size = { 16 }
339+ className = "text-red-500 cursor-pointer hover:rotate-180 transition-transform duration-500"
340+ onClick = { ( ) => handleRetryUpload ( att ) }
341+ />
342+ ) : (
343+ < FileText size = { 16 } className = "text-primary" />
344+ ) }
273345 </ div >
274- < span className = "truncate text-xs text-primary max-w-24 font-medium" > { att . file . name } </ span >
346+ < span className = { `truncate text-xs max-w-24 font-medium ${ att . status === "error" ? "text-red-600" : "text-primary" } ` } >
347+ { att . file . name }
348+ </ span >
275349 < button
276350 type = "button"
277351 onClick = { ( ) => handleRemoveAttachment ( att . id ) }
@@ -317,7 +391,6 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
317391 onChange = { ( e ) => setMessage ( e . target . value ) }
318392 onKeyDown = { handleKeyDown }
319393 onPaste = { handlePaste }
320- disabled = { isBusy }
321394 placeholder = "Ask Anything"
322395 rows = { 1 }
323396 className = "w-full custom-scroll custom-scroll-xs py-2 px-1.5 text-primary text-sm focus:outline-none resize-none overflow-y-auto max-h-28 transition-all duration-200 ease-in-out"
@@ -329,7 +402,8 @@ export default function InputContainer({ sendMessage, isGenerating, selectedMode
329402 type = "file"
330403 multiple
331404 ref = { fileInputRef }
332- max = { 5 }
405+ max = { 4 }
406+ accept = { SUPPORTED_EXTENSIONS_DISPLAY }
333407 onChange = { handleFileChange }
334408 className = "hidden"
335409 id = "file-upload"
0 commit comments