|
|
'use client'; |
|
|
|
|
|
import React, { forwardRef, useEffect } from 'react'; |
|
|
import { Button } from '@/components/ui/button'; |
|
|
import { Paperclip, Loader2 } from 'lucide-react'; |
|
|
import { toast } from 'sonner'; |
|
|
import { createClient } from '@/lib/supabase/client'; |
|
|
import { useQueryClient } from '@tanstack/react-query'; |
|
|
import { fileQueryKeys } from '@/hooks/react-query/files/use-file-queries'; |
|
|
import { |
|
|
Tooltip, |
|
|
TooltipContent, |
|
|
TooltipProvider, |
|
|
TooltipTrigger, |
|
|
} from '@/components/ui/tooltip'; |
|
|
import { UploadedFile } from './chat-input'; |
|
|
import { normalizeFilenameToNFC } from '@/lib/utils/unicode'; |
|
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_BACKEND_URL || ''; |
|
|
|
|
|
const handleLocalFiles = ( |
|
|
files: File[], |
|
|
setPendingFiles: React.Dispatch<React.SetStateAction<File[]>>, |
|
|
setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFile[]>>, |
|
|
) => { |
|
|
const filteredFiles = files.filter((file) => { |
|
|
if (file.size > 50 * 1024 * 1024) { |
|
|
toast.error(`File size exceeds 50MB limit: ${file.name}`); |
|
|
return false; |
|
|
} |
|
|
return true; |
|
|
}); |
|
|
|
|
|
setPendingFiles((prevFiles) => [...prevFiles, ...filteredFiles]); |
|
|
|
|
|
const newUploadedFiles: UploadedFile[] = filteredFiles.map((file) => { |
|
|
|
|
|
const normalizedName = normalizeFilenameToNFC(file.name); |
|
|
|
|
|
return { |
|
|
name: normalizedName, |
|
|
path: `/workspace/${normalizedName}`, |
|
|
size: file.size, |
|
|
type: file.type || 'application/octet-stream', |
|
|
localUrl: URL.createObjectURL(file) |
|
|
}; |
|
|
}); |
|
|
|
|
|
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]); |
|
|
filteredFiles.forEach((file) => { |
|
|
const normalizedName = normalizeFilenameToNFC(file.name); |
|
|
toast.success(`File attached: ${normalizedName}`); |
|
|
}); |
|
|
}; |
|
|
|
|
|
const uploadFiles = async ( |
|
|
files: File[], |
|
|
sandboxId: string, |
|
|
setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFile[]>>, |
|
|
setIsUploading: React.Dispatch<React.SetStateAction<boolean>>, |
|
|
messages: any[] = [], |
|
|
queryClient?: any, |
|
|
) => { |
|
|
try { |
|
|
setIsUploading(true); |
|
|
|
|
|
const newUploadedFiles: UploadedFile[] = []; |
|
|
|
|
|
for (const file of files) { |
|
|
if (file.size > 50 * 1024 * 1024) { |
|
|
toast.error(`File size exceeds 50MB limit: ${file.name}`); |
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
const normalizedName = normalizeFilenameToNFC(file.name); |
|
|
const uploadPath = `/workspace/${normalizedName}`; |
|
|
|
|
|
|
|
|
const isFileInChat = messages.some(message => { |
|
|
const content = typeof message.content === 'string' ? message.content : ''; |
|
|
return content.includes(`[Uploaded File: ${uploadPath}]`); |
|
|
}); |
|
|
|
|
|
const formData = new FormData(); |
|
|
|
|
|
|
|
|
formData.append('file', file, normalizedName); |
|
|
formData.append('path', uploadPath); |
|
|
|
|
|
const supabase = createClient(); |
|
|
const { |
|
|
data: { session }, |
|
|
} = await supabase.auth.getSession(); |
|
|
|
|
|
if (!session?.access_token) { |
|
|
throw new Error('No access token available'); |
|
|
} |
|
|
|
|
|
const response = await fetch(`${API_URL}/sandboxes/${sandboxId}/files`, { |
|
|
method: 'POST', |
|
|
headers: { |
|
|
Authorization: `Bearer ${session.access_token}`, |
|
|
}, |
|
|
body: formData, |
|
|
}); |
|
|
|
|
|
if (!response.ok) { |
|
|
throw new Error(`Upload failed: ${response.statusText}`); |
|
|
} |
|
|
|
|
|
|
|
|
if (isFileInChat && queryClient) { |
|
|
console.log(`Invalidating cache for existing file: ${uploadPath}`); |
|
|
|
|
|
|
|
|
['text', 'blob', 'json'].forEach(contentType => { |
|
|
const queryKey = fileQueryKeys.content(sandboxId, uploadPath, contentType); |
|
|
queryClient.removeQueries({ queryKey }); |
|
|
}); |
|
|
|
|
|
|
|
|
const directoryPath = uploadPath.substring(0, uploadPath.lastIndexOf('/')); |
|
|
queryClient.invalidateQueries({ |
|
|
queryKey: fileQueryKeys.directory(sandboxId, directoryPath), |
|
|
}); |
|
|
} |
|
|
|
|
|
newUploadedFiles.push({ |
|
|
name: normalizedName, |
|
|
path: uploadPath, |
|
|
size: file.size, |
|
|
type: file.type || 'application/octet-stream', |
|
|
}); |
|
|
|
|
|
toast.success(`File uploaded: ${normalizedName}`); |
|
|
} |
|
|
|
|
|
setUploadedFiles((prev) => [...prev, ...newUploadedFiles]); |
|
|
} catch (error) { |
|
|
console.error('File upload failed:', error); |
|
|
toast.error( |
|
|
typeof error === 'string' |
|
|
? error |
|
|
: error instanceof Error |
|
|
? error.message |
|
|
: 'Failed to upload file', |
|
|
); |
|
|
} finally { |
|
|
setIsUploading(false); |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleFiles = async ( |
|
|
files: File[], |
|
|
sandboxId: string | undefined, |
|
|
setPendingFiles: React.Dispatch<React.SetStateAction<File[]>>, |
|
|
setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFile[]>>, |
|
|
setIsUploading: React.Dispatch<React.SetStateAction<boolean>>, |
|
|
messages: any[] = [], |
|
|
queryClient?: any, |
|
|
) => { |
|
|
if (sandboxId) { |
|
|
|
|
|
await uploadFiles(files, sandboxId, setUploadedFiles, setIsUploading, messages, queryClient); |
|
|
} else { |
|
|
|
|
|
handleLocalFiles(files, setPendingFiles, setUploadedFiles); |
|
|
} |
|
|
}; |
|
|
|
|
|
interface FileUploadHandlerProps { |
|
|
loading: boolean; |
|
|
disabled: boolean; |
|
|
isAgentRunning: boolean; |
|
|
isUploading: boolean; |
|
|
sandboxId?: string; |
|
|
setPendingFiles: React.Dispatch<React.SetStateAction<File[]>>; |
|
|
setUploadedFiles: React.Dispatch<React.SetStateAction<UploadedFile[]>>; |
|
|
setIsUploading: React.Dispatch<React.SetStateAction<boolean>>; |
|
|
messages?: any[]; |
|
|
isLoggedIn?: boolean; |
|
|
} |
|
|
|
|
|
export const FileUploadHandler = forwardRef< |
|
|
HTMLInputElement, |
|
|
FileUploadHandlerProps |
|
|
>( |
|
|
( |
|
|
{ |
|
|
loading, |
|
|
disabled, |
|
|
isAgentRunning, |
|
|
isUploading, |
|
|
sandboxId, |
|
|
setPendingFiles, |
|
|
setUploadedFiles, |
|
|
setIsUploading, |
|
|
messages = [], |
|
|
isLoggedIn = true, |
|
|
}, |
|
|
ref, |
|
|
) => { |
|
|
const queryClient = useQueryClient(); |
|
|
|
|
|
useEffect(() => { |
|
|
return () => { |
|
|
|
|
|
setUploadedFiles(prev => { |
|
|
prev.forEach(file => { |
|
|
if (file.localUrl) { |
|
|
URL.revokeObjectURL(file.localUrl); |
|
|
} |
|
|
}); |
|
|
return prev; |
|
|
}); |
|
|
}; |
|
|
}, []); |
|
|
|
|
|
const handleFileUpload = () => { |
|
|
if (ref && 'current' in ref && ref.current) { |
|
|
ref.current.click(); |
|
|
} |
|
|
}; |
|
|
|
|
|
const processFileUpload = async ( |
|
|
event: React.ChangeEvent<HTMLInputElement>, |
|
|
) => { |
|
|
if (!event.target.files || event.target.files.length === 0) return; |
|
|
|
|
|
const files = Array.from(event.target.files); |
|
|
|
|
|
handleFiles( |
|
|
files, |
|
|
sandboxId, |
|
|
setPendingFiles, |
|
|
setUploadedFiles, |
|
|
setIsUploading, |
|
|
messages, |
|
|
queryClient, |
|
|
); |
|
|
|
|
|
event.target.value = ''; |
|
|
}; |
|
|
|
|
|
return ( |
|
|
<> |
|
|
<TooltipProvider> |
|
|
<Tooltip> |
|
|
<TooltipTrigger asChild> |
|
|
<span className="inline-block"> |
|
|
<Button |
|
|
type="button" |
|
|
onClick={handleFileUpload} |
|
|
variant="outline" |
|
|
size="sm" |
|
|
className="h-8 px-3 py-2 bg-transparent border border-border rounded-xl text-muted-foreground hover:text-foreground hover:bg-accent/50 flex items-center gap-2" |
|
|
disabled={ |
|
|
!isLoggedIn || loading || (disabled && !isAgentRunning) || isUploading |
|
|
} |
|
|
> |
|
|
{isUploading ? ( |
|
|
<Loader2 className="h-4 w-4 animate-spin" /> |
|
|
) : ( |
|
|
<Paperclip className="h-4 w-4" /> |
|
|
)} |
|
|
<span className="text-sm">Attach</span> |
|
|
</Button> |
|
|
</span> |
|
|
</TooltipTrigger> |
|
|
<TooltipContent side="top"> |
|
|
<p>{isLoggedIn ? 'Attach files' : 'Please login to attach files'}</p> |
|
|
</TooltipContent> |
|
|
</Tooltip> |
|
|
</TooltipProvider> |
|
|
|
|
|
<input |
|
|
type="file" |
|
|
ref={ref} |
|
|
className="hidden" |
|
|
onChange={processFileUpload} |
|
|
multiple |
|
|
/> |
|
|
</> |
|
|
); |
|
|
}, |
|
|
); |
|
|
|
|
|
FileUploadHandler.displayName = 'FileUploadHandler'; |
|
|
export { handleFiles, handleLocalFiles, uploadFiles }; |
|
|
|