From b8d6cef77ed052cf45cb0bc21b8d3f99b1707720 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 5 Aug 2025 12:37:06 -0700 Subject: [PATCH 01/10] Connects to s3 --- apps/sim/app/api/files/presigned/route.ts | 76 +++++- .../components/user-input/user-input.tsx | 231 +++++++++++++++--- apps/sim/lib/env.ts | 2 + apps/sim/lib/environment.ts | 2 +- apps/sim/lib/uploads/setup.server.ts | 6 + apps/sim/lib/uploads/setup.ts | 12 + 6 files changed, 287 insertions(+), 42 deletions(-) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 30c33215446..ac640bb68e6 100644 --- a/apps/sim/app/api/files/presigned/route.ts +++ b/apps/sim/app/api/files/presigned/route.ts @@ -9,9 +9,11 @@ import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3/s3-cl import { BLOB_CHAT_CONFIG, BLOB_CONFIG, + BLOB_COPILOT_CONFIG, BLOB_KB_CONFIG, S3_CHAT_CONFIG, S3_CONFIG, + S3_COPILOT_CONFIG, S3_KB_CONFIG, } from '@/lib/uploads/setup' import { createErrorResponse, createOptionsResponse } from '@/app/api/files/utils' @@ -22,9 +24,11 @@ interface PresignedUrlRequest { fileName: string contentType: string fileSize: number + userId?: string + chatId?: string } -type UploadType = 'general' | 'knowledge-base' | 'chat' +type UploadType = 'general' | 'knowledge-base' | 'chat' | 'copilot' class PresignedUrlError extends Error { constructor( @@ -58,7 +62,7 @@ export async function POST(request: NextRequest) { throw new ValidationError('Invalid JSON in request body') } - const { fileName, contentType, fileSize } = data + const { fileName, contentType, fileSize, userId, chatId } = data if (!fileName?.trim()) { throw new ValidationError('fileName is required and cannot be empty') @@ -83,7 +87,19 @@ export async function POST(request: NextRequest) { ? 'knowledge-base' : uploadTypeParam === 'chat' ? 'chat' - : 'general' + : uploadTypeParam === 'copilot' + ? 'copilot' + : 'general' + + // Validate copilot-specific requirements + if (uploadType === 'copilot') { + if (!userId?.trim()) { + throw new ValidationError('userId is required for copilot uploads') + } + if (!chatId?.trim()) { + throw new ValidationError('chatId is required for copilot uploads') + } + } if (!isUsingCloudStorage()) { throw new StorageConfigError( @@ -96,9 +112,9 @@ export async function POST(request: NextRequest) { switch (storageProvider) { case 's3': - return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType) + return await handleS3PresignedUrl(fileName, contentType, fileSize, uploadType, userId, chatId) case 'blob': - return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType) + return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType, userId, chatId) default: throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`) } @@ -126,7 +142,9 @@ async function handleS3PresignedUrl( fileName: string, contentType: string, fileSize: number, - uploadType: UploadType + uploadType: UploadType, + userId?: string, + chatId?: string ) { try { const config = @@ -134,15 +152,26 @@ async function handleS3PresignedUrl( ? S3_KB_CONFIG : uploadType === 'chat' ? S3_CHAT_CONFIG - : S3_CONFIG + : uploadType === 'copilot' + ? S3_COPILOT_CONFIG + : S3_CONFIG if (!config.bucket || !config.region) { throw new StorageConfigError(`S3 configuration missing for ${uploadType} uploads`) } const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') - const prefix = uploadType === 'knowledge-base' ? 'kb/' : uploadType === 'chat' ? 'chat/' : '' - const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}` + + let prefix = '' + if (uploadType === 'knowledge-base') { + prefix = 'kb/' + } else if (uploadType === 'chat') { + prefix = 'chat/' + } else if (uploadType === 'copilot') { + prefix = `${userId}/${chatId}/` + } + + const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}` const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName) @@ -155,6 +184,10 @@ async function handleS3PresignedUrl( metadata.purpose = 'knowledge-base' } else if (uploadType === 'chat') { metadata.purpose = 'chat' + } else if (uploadType === 'copilot') { + metadata.purpose = 'copilot' + metadata.userId = userId || '' + metadata.chatId = chatId || '' } const command = new PutObjectCommand({ @@ -210,7 +243,9 @@ async function handleBlobPresignedUrl( fileName: string, contentType: string, fileSize: number, - uploadType: UploadType + uploadType: UploadType, + userId?: string, + chatId?: string ) { try { const config = @@ -218,7 +253,9 @@ async function handleBlobPresignedUrl( ? BLOB_KB_CONFIG : uploadType === 'chat' ? BLOB_CHAT_CONFIG - : BLOB_CONFIG + : uploadType === 'copilot' + ? BLOB_COPILOT_CONFIG + : BLOB_CONFIG if ( !config.accountName || @@ -229,8 +266,17 @@ async function handleBlobPresignedUrl( } const safeFileName = fileName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9.-]/g, '_') - const prefix = uploadType === 'knowledge-base' ? 'kb/' : uploadType === 'chat' ? 'chat/' : '' - const uniqueKey = `${prefix}${Date.now()}-${uuidv4()}-${safeFileName}` + + let prefix = '' + if (uploadType === 'knowledge-base') { + prefix = 'kb/' + } else if (uploadType === 'chat') { + prefix = 'chat/' + } else if (uploadType === 'copilot') { + prefix = `${userId}/${chatId}/` + } + + const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}` const blobServiceClient = getBlobServiceClient() const containerClient = blobServiceClient.getContainerClient(config.containerName) @@ -282,6 +328,10 @@ async function handleBlobPresignedUrl( uploadHeaders['x-ms-meta-purpose'] = 'knowledge-base' } else if (uploadType === 'chat') { uploadHeaders['x-ms-meta-purpose'] = 'chat' + } else if (uploadType === 'copilot') { + uploadHeaders['x-ms-meta-purpose'] = 'copilot' + uploadHeaders['x-ms-meta-userid'] = encodeURIComponent(userId || '') + uploadHeaders['x-ms-meta-chatid'] = encodeURIComponent(chatId || '') } return NextResponse.json({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx index 1ffa1f397aa..829f17c81e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx @@ -8,10 +8,12 @@ import { useRef, useState, } from 'react' -import { ArrowUp, Loader2, MessageCircle, Package, X } from 'lucide-react' +import { ArrowUp, Loader2, MessageCircle, Package, Paperclip, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' +import { useSession } from '@/lib/auth-client' +import { useCopilotStore } from '@/stores/copilot/store' interface UserInputProps { onSubmit: (message: string) => void @@ -27,6 +29,15 @@ interface UserInputProps { onChange?: (value: string) => void // Callback when value changes } +interface AttachedFile { + id: string + name: string + size: number + type: string + path: string + uploading?: boolean +} + interface UserInputRef { focus: () => void } @@ -49,7 +60,12 @@ const UserInput = forwardRef( ref ) => { const [internalMessage, setInternalMessage] = useState('') + const [attachedFiles, setAttachedFiles] = useState([]) const textareaRef = useRef(null) + const fileInputRef = useRef(null) + + const { data: session } = useSession() + const { currentChat, workflowId } = useCopilotStore() // Expose focus method to parent useImperativeHandle( @@ -111,6 +127,111 @@ const UserInput = forwardRef( } } + const handleFileSelect = () => { + fileInputRef.current?.click() + } + + const handleFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files + if (!files || files.length === 0) return + + const file = files[0] + const userId = session?.user?.id + const chatId = currentChat?.id + + if (!userId || !chatId) { + console.error('User ID or Chat ID not available for file upload') + return + } + + // Create a temporary file entry with uploading state + const tempFile: AttachedFile = { + id: crypto.randomUUID(), + name: file.name, + size: file.size, + type: file.type, + path: '', + uploading: true, + } + + setAttachedFiles(prev => [...prev, tempFile]) + + try { + // Request presigned URL + const presignedResponse = await fetch('/api/files/presigned?type=copilot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileName: file.name, + contentType: file.type, + fileSize: file.size, + userId, + chatId, + }), + }) + + if (!presignedResponse.ok) { + throw new Error('Failed to get presigned URL') + } + + const presignedData = await presignedResponse.json() + + // Upload file to S3 + console.log('Uploading to S3:', presignedData.presignedUrl) + const uploadResponse = await fetch(presignedData.presignedUrl, { + method: 'PUT', + headers: { + 'Content-Type': file.type, + }, + body: file, + }) + + console.log('S3 Upload response status:', uploadResponse.status) + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text() + console.error('S3 Upload failed:', errorText) + throw new Error(`Failed to upload file: ${uploadResponse.status} ${errorText}`) + } + + // Update file entry with success + setAttachedFiles(prev => + prev.map(f => + f.id === tempFile.id + ? { + ...f, + path: presignedData.fileInfo.path, + uploading: false, + } + : f + ) + ) + } catch (error) { + console.error('File upload failed:', error) + // Remove failed upload + setAttachedFiles(prev => prev.filter(f => f.id !== tempFile.id)) + } + + // Clear the input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const removeFile = (fileId: string) => { + setAttachedFiles(prev => prev.filter(f => f.id !== fileId)) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] + } + const canSubmit = message.trim().length > 0 && !disabled && !isLoading const showAbortButton = isLoading && onAbort @@ -131,6 +252,36 @@ const UserInput = forwardRef( return (
+ {/* Attached Files Display */} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file) => ( +
+ + {file.name} + + {formatFileSize(file.size)} + + {file.uploading ? ( + + ) : ( + + )} +
+ ))} +
+ )} + {/* Textarea Field */}