diff --git a/apps/sim/app/api/copilot/chat/file-utils.ts b/apps/sim/app/api/copilot/chat/file-utils.ts new file mode 100644 index 00000000000..48b81bafa6c --- /dev/null +++ b/apps/sim/app/api/copilot/chat/file-utils.ts @@ -0,0 +1,132 @@ +export interface FileAttachment { + id: string + s3_key: string + filename: string + media_type: string + size: number +} + +export interface AnthropicMessageContent { + type: 'text' | 'image' | 'document' + text?: string + source?: { + type: 'base64' + media_type: string + data: string + } +} + +/** + * Mapping of MIME types to Anthropic content types + */ +export const MIME_TYPE_MAPPING: Record = { + // Images + 'image/jpeg': 'image', + 'image/jpg': 'image', + 'image/png': 'image', + 'image/gif': 'image', + 'image/webp': 'image', + 'image/svg+xml': 'image', + + // Documents + 'application/pdf': 'document', + 'text/plain': 'document', + 'text/csv': 'document', + 'application/json': 'document', + 'application/xml': 'document', + 'text/xml': 'document', + 'text/html': 'document', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document', // .docx + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', // .xlsx + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', // .pptx + 'application/msword': 'document', // .doc + 'application/vnd.ms-excel': 'document', // .xls + 'application/vnd.ms-powerpoint': 'document', // .ppt + 'text/markdown': 'document', + 'application/rtf': 'document', +} + +/** + * Get the Anthropic content type for a given MIME type + */ +export function getAnthropicContentType(mimeType: string): 'image' | 'document' | null { + return MIME_TYPE_MAPPING[mimeType.toLowerCase()] || null +} + +/** + * Check if a MIME type is supported by Anthropic + */ +export function isSupportedFileType(mimeType: string): boolean { + return mimeType.toLowerCase() in MIME_TYPE_MAPPING +} + +/** + * Convert a file buffer to base64 + */ +export function bufferToBase64(buffer: Buffer): string { + return buffer.toString('base64') +} + +/** + * Create Anthropic message content from file data + */ +export function createAnthropicFileContent( + fileBuffer: Buffer, + mimeType: string +): AnthropicMessageContent | null { + const contentType = getAnthropicContentType(mimeType) + if (!contentType) { + return null + } + + return { + type: contentType, + source: { + type: 'base64', + media_type: mimeType, + data: bufferToBase64(fileBuffer), + }, + } +} + +/** + * Extract file extension from filename + */ +export function getFileExtension(filename: string): string { + const lastDot = filename.lastIndexOf('.') + return lastDot !== -1 ? filename.slice(lastDot + 1).toLowerCase() : '' +} + +/** + * Get MIME type from file extension (fallback if not provided) + */ +export function getMimeTypeFromExtension(extension: string): string { + const extensionMimeMap: Record = { + // Images + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + + // Documents + pdf: 'application/pdf', + txt: 'text/plain', + csv: 'text/csv', + json: 'application/json', + xml: 'application/xml', + html: 'text/html', + htm: 'text/html', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + doc: 'application/msword', + xls: 'application/vnd.ms-excel', + ppt: 'application/vnd.ms-powerpoint', + md: 'text/markdown', + rtf: 'application/rtf', + } + + return extensionMimeMap[extension.toLowerCase()] || 'application/octet-stream' +} diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 6b5bfad14d0..9f042f855ed 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -13,12 +13,25 @@ import { getCopilotModel } from '@/lib/copilot/config' import { TITLE_GENERATION_SYSTEM_PROMPT, TITLE_GENERATION_USER_PROMPT } from '@/lib/copilot/prompts' import { env } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' +import { downloadFile } from '@/lib/uploads' +import { downloadFromS3WithConfig } from '@/lib/uploads/s3/s3-client' +import { S3_COPILOT_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup' import { db } from '@/db' import { copilotChats } from '@/db/schema' import { executeProviderRequest } from '@/providers' +import { createAnthropicFileContent, isSupportedFileType } from './file-utils' const logger = createLogger('CopilotChatAPI') +// Schema for file attachments +const FileAttachmentSchema = z.object({ + id: z.string(), + s3_key: z.string(), + filename: z.string(), + media_type: z.string(), + size: z.number(), +}) + // Schema for chat messages const ChatMessageSchema = z.object({ message: z.string().min(1, 'Message is required'), @@ -29,6 +42,7 @@ const ChatMessageSchema = z.object({ createNewChat: z.boolean().optional().default(false), stream: z.boolean().optional().default(true), implicitFeedback: z.string().optional(), + fileAttachments: z.array(FileAttachmentSchema).optional(), }) // Sim Agent API configuration @@ -145,6 +159,7 @@ export async function POST(req: NextRequest) { createNewChat, stream, implicitFeedback, + fileAttachments, } = ChatMessageSchema.parse(body) logger.info(`[${tracker.requestId}] Processing copilot chat request`, { @@ -195,15 +210,91 @@ export async function POST(req: NextRequest) { } } + // Process file attachments if present + const processedFileContents: any[] = [] + if (fileAttachments && fileAttachments.length > 0) { + logger.info(`[${tracker.requestId}] Processing ${fileAttachments.length} file attachments`) + + for (const attachment of fileAttachments) { + try { + // Check if file type is supported + if (!isSupportedFileType(attachment.media_type)) { + logger.warn(`[${tracker.requestId}] Unsupported file type: ${attachment.media_type}`) + continue + } + + // Download file from S3 + logger.info(`[${tracker.requestId}] Downloading file: ${attachment.s3_key}`) + let fileBuffer: Buffer + if (USE_S3_STORAGE) { + fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG) + } else { + // Fallback to generic downloadFile for other storage providers + fileBuffer = await downloadFile(attachment.s3_key) + } + + // Convert to Anthropic format + const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type) + if (fileContent) { + processedFileContents.push(fileContent) + logger.info( + `[${tracker.requestId}] Processed file: ${attachment.filename} (${attachment.media_type})` + ) + } + } catch (error) { + logger.error( + `[${tracker.requestId}] Failed to process file ${attachment.filename}:`, + error + ) + // Continue processing other files + } + } + } + // Build messages array for sim agent with conversation history const messages = [] - // Add conversation history + // Add conversation history (need to rebuild these with file support if they had attachments) for (const msg of conversationHistory) { - messages.push({ - role: msg.role, - content: msg.content, - }) + if (msg.fileAttachments && msg.fileAttachments.length > 0) { + // This is a message with file attachments - rebuild with content array + const content: any[] = [{ type: 'text', text: msg.content }] + + // Process file attachments for historical messages + for (const attachment of msg.fileAttachments) { + try { + if (isSupportedFileType(attachment.media_type)) { + let fileBuffer: Buffer + if (USE_S3_STORAGE) { + fileBuffer = await downloadFromS3WithConfig(attachment.s3_key, S3_COPILOT_CONFIG) + } else { + // Fallback to generic downloadFile for other storage providers + fileBuffer = await downloadFile(attachment.s3_key) + } + const fileContent = createAnthropicFileContent(fileBuffer, attachment.media_type) + if (fileContent) { + content.push(fileContent) + } + } + } catch (error) { + logger.error( + `[${tracker.requestId}] Failed to process historical file ${attachment.filename}:`, + error + ) + } + } + + messages.push({ + role: msg.role, + content, + }) + } else { + // Regular text-only message + messages.push({ + role: msg.role, + content: msg.content, + }) + } } // Add implicit feedback if provided @@ -214,11 +305,27 @@ export async function POST(req: NextRequest) { }) } - // Add current user message - messages.push({ - role: 'user', - content: message, - }) + // Add current user message with file attachments + if (processedFileContents.length > 0) { + // Message with files - use content array format + const content: any[] = [{ type: 'text', text: message }] + + // Add file contents + for (const fileContent of processedFileContents) { + content.push(fileContent) + } + + messages.push({ + role: 'user', + content, + }) + } else { + // Text-only message + messages.push({ + role: 'user', + content: message, + }) + } // Start title generation in parallel if this is a new chat with first message if (actualChatId && !currentChat?.title && conversationHistory.length === 0) { @@ -270,6 +377,7 @@ export async function POST(req: NextRequest) { role: 'user', content: message, timestamp: new Date().toISOString(), + ...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }), } // Create a pass-through stream that captures the response @@ -590,6 +698,7 @@ export async function POST(req: NextRequest) { role: 'user', content: message, timestamp: new Date().toISOString(), + ...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }), } const assistantMessage = { diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts index c7af5952516..598679e560c 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -24,6 +24,17 @@ const UpdateMessagesSchema = z.object({ timestamp: z.string(), toolCalls: z.array(z.any()).optional(), contentBlocks: z.array(z.any()).optional(), + fileAttachments: z + .array( + z.object({ + id: z.string(), + s3_key: z.string(), + filename: z.string(), + media_type: z.string(), + size: z.number(), + }) + ) + .optional(), }) ), }) diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts index 30c33215446..a343d3a1eb3 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,16 @@ 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 (!isUsingCloudStorage()) { throw new StorageConfigError( @@ -96,9 +109,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) case 'blob': - return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType) + return await handleBlobPresignedUrl(fileName, contentType, fileSize, uploadType, userId) default: throw new StorageConfigError(`Unknown storage provider: ${storageProvider}`) } @@ -126,7 +139,8 @@ async function handleS3PresignedUrl( fileName: string, contentType: string, fileSize: number, - uploadType: UploadType + uploadType: UploadType, + userId?: string ) { try { const config = @@ -134,15 +148,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}/` + } + + const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}` const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName) @@ -155,6 +180,9 @@ async function handleS3PresignedUrl( metadata.purpose = 'knowledge-base' } else if (uploadType === 'chat') { metadata.purpose = 'chat' + } else if (uploadType === 'copilot') { + metadata.purpose = 'copilot' + metadata.userId = userId || '' } const command = new PutObjectCommand({ @@ -210,7 +238,8 @@ async function handleBlobPresignedUrl( fileName: string, contentType: string, fileSize: number, - uploadType: UploadType + uploadType: UploadType, + userId?: string ) { try { const config = @@ -218,7 +247,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 +260,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}/` + } + + const uniqueKey = `${prefix}${uuidv4()}-${safeFileName}` const blobServiceClient = getBlobServiceClient() const containerClient = blobServiceClient.getContainerClient(config.containerName) @@ -282,6 +322,9 @@ 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 || '') } return NextResponse.json({ diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 810bd58e108..c0c8973e0b2 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -58,7 +58,11 @@ export async function GET( if (isUsingCloudStorage() || isCloudPath) { // Extract the actual key (remove 's3/' or 'blob/' prefix if present) const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath - return await handleCloudProxy(cloudKey) + + // Get bucket type from query parameter + const bucketType = request.nextUrl.searchParams.get('bucket') + + return await handleCloudProxy(cloudKey, bucketType) } // Use local handler for local files @@ -152,12 +156,37 @@ async function downloadKBFile(cloudKey: string): Promise { /** * Proxy cloud file through our server */ -async function handleCloudProxy(cloudKey: string): Promise { +async function handleCloudProxy( + cloudKey: string, + bucketType?: string | null +): Promise { try { // Check if this is a KB file (starts with 'kb/') const isKBFile = cloudKey.startsWith('kb/') - const fileBuffer = isKBFile ? await downloadKBFile(cloudKey) : await downloadFile(cloudKey) + let fileBuffer: Buffer + + if (isKBFile) { + fileBuffer = await downloadKBFile(cloudKey) + } else if (bucketType === 'copilot') { + // Download from copilot-specific bucket + const storageProvider = getStorageProvider() + + if (storageProvider === 's3') { + const { downloadFromS3WithConfig } = await import('@/lib/uploads/s3/s3-client') + const { S3_COPILOT_CONFIG } = await import('@/lib/uploads/setup') + fileBuffer = await downloadFromS3WithConfig(cloudKey, S3_COPILOT_CONFIG) + } else if (storageProvider === 'blob') { + // For Azure Blob, use the default downloadFile for now + // TODO: Add downloadFromBlobWithConfig when needed + fileBuffer = await downloadFile(cloudKey) + } else { + fileBuffer = await downloadFile(cloudKey) + } + } else { + // Default bucket + fileBuffer = await downloadFile(cloudKey) + } // Extract the original filename from the key (last part after last /) const originalFilename = cloudKey.split('/').pop() || 'download' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx index bae6d96a348..e6e73b5afa2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/copilot-message.tsx @@ -1,7 +1,17 @@ 'use client' import { type FC, memo, useEffect, useMemo, useRef, useState } from 'react' -import { Check, Clipboard, Loader2, RotateCcw, ThumbsDown, ThumbsUp, X } from 'lucide-react' +import { + Check, + Clipboard, + FileText, + Image, + Loader2, + RotateCcw, + ThumbsDown, + ThumbsUp, + X, +} from 'lucide-react' import { InlineToolCall } from '@/lib/copilot/tools/inline-tool-call' import { createLogger } from '@/lib/logs/console/logger' import { usePreviewStore } from '@/stores/copilot/preview-store' @@ -38,6 +48,107 @@ const StreamingIndicator = memo(() => ( StreamingIndicator.displayName = 'StreamingIndicator' +// File attachment display component +interface FileAttachmentDisplayProps { + fileAttachments: any[] +} + +const FileAttachmentDisplay = memo(({ fileAttachments }: FileAttachmentDisplayProps) => { + // Cache for file URLs to avoid re-fetching on every render + const [fileUrls, setFileUrls] = useState>({}) + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}` + } + + const getFileIcon = (mediaType: string) => { + if (mediaType.startsWith('image/')) { + return + } + if (mediaType.includes('pdf')) { + return + } + if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) { + return + } + return + } + + const getFileUrl = (file: any) => { + const cacheKey = file.s3_key + if (fileUrls[cacheKey]) { + return fileUrls[cacheKey] + } + + // Generate URL only once and cache it + const url = `/api/files/serve/s3/${encodeURIComponent(file.s3_key)}?bucket=copilot` + setFileUrls((prev) => ({ ...prev, [cacheKey]: url })) + return url + } + + const handleFileClick = (file: any) => { + // Use cached URL or generate it + const serveUrl = getFileUrl(file) + + // Open the file in a new tab + window.open(serveUrl, '_blank') + } + + const isImageFile = (mediaType: string) => { + return mediaType.startsWith('image/') + } + + return ( + <> + {fileAttachments.map((file) => ( +
handleFileClick(file)} + title={`${file.filename} (${formatFileSize(file.size)})`} + > + {isImageFile(file.media_type) ? ( + // For images, show actual thumbnail + {file.filename} { + // If image fails to load, replace with icon + const target = e.target as HTMLImageElement + target.style.display = 'none' + const parent = target.parentElement + if (parent) { + const iconContainer = document.createElement('div') + iconContainer.className = + 'flex items-center justify-center w-full h-full bg-background/50' + iconContainer.innerHTML = + '' + parent.appendChild(iconContainer) + } + }} + /> + ) : ( + // For other files, show icon centered +
+ {getFileIcon(file.media_type)} +
+ )} + + {/* Hover overlay effect */} +
+
+ ))} + + ) +}) + +FileAttachmentDisplay.displayName = 'FileAttachmentDisplay' + // Smooth streaming text component with typewriter effect interface SmoothStreamingTextProps { content: string @@ -481,8 +592,18 @@ const CopilotMessage: FC = memo( if (isUser) { return (
+ {/* File attachments displayed above the message, completely separate from message box width */} + {message.fileAttachments && message.fileAttachments.length > 0 && ( +
+
+ +
+
+ )} +
+ {/* Message content in purple box */}
= memo(
+ + {/* Checkpoints below message */} {hasCheckpoints && (
{showRestoreConfirmation ? ( 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..b4e1dd12a26 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,13 +8,43 @@ import { useRef, useState, } from 'react' -import { ArrowUp, Loader2, MessageCircle, Package, X } from 'lucide-react' +import { + ArrowUp, + FileText, + Image, + Loader2, + MessageCircle, + Package, + Paperclip, + X, +} from 'lucide-react' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' +import { useSession } from '@/lib/auth-client' import { cn } from '@/lib/utils' +import { useCopilotStore } from '@/stores/copilot/store' + +export interface MessageFileAttachment { + id: string + s3_key: string + filename: string + media_type: string + size: number +} + +interface AttachedFile { + id: string + name: string + size: number + type: string + path: string + key?: string // Add key field to store the actual S3 key + uploading: boolean + previewUrl?: string // For local preview of images before upload +} interface UserInputProps { - onSubmit: (message: string) => void + onSubmit: (message: string, fileAttachments?: MessageFileAttachment[]) => void onAbort?: () => void disabled?: boolean isLoading?: boolean @@ -49,7 +79,15 @@ const UserInput = forwardRef( ref ) => { const [internalMessage, setInternalMessage] = useState('') + const [attachedFiles, setAttachedFiles] = useState([]) + // Drag and drop state + const [isDragging, setIsDragging] = useState(false) + const [dragCounter, setDragCounter] = useState(0) const textareaRef = useRef(null) + const fileInputRef = useRef(null) + + const { data: session } = useSession() + const { currentChat, workflowId } = useCopilotStore() // Expose focus method to parent useImperativeHandle( @@ -76,17 +114,190 @@ const UserInput = forwardRef( } }, [message]) + // Cleanup preview URLs on unmount + useEffect(() => { + return () => { + attachedFiles.forEach((f) => { + if (f.previewUrl) { + URL.revokeObjectURL(f.previewUrl) + } + }) + } + }, []) + + // Drag and drop handlers + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragCounter((prev) => { + const newCount = prev + 1 + if (newCount === 1) { + setIsDragging(true) + } + return newCount + }) + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragCounter((prev) => { + const newCount = prev - 1 + if (newCount === 0) { + setIsDragging(false) + } + return newCount + }) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + // Add visual feedback for valid drop zone + e.dataTransfer.dropEffect = 'copy' + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + setDragCounter(0) + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + await processFiles(e.dataTransfer.files) + } + } + + // Process dropped or selected files + const processFiles = async (fileList: FileList) => { + const userId = session?.user?.id + + if (!userId) { + console.error('User ID not available for file upload') + return + } + + // Process files one by one + for (const file of Array.from(fileList)) { + // Create a preview URL for images + let previewUrl: string | undefined + if (file.type.startsWith('image/')) { + previewUrl = URL.createObjectURL(file) + } + + // 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, + previewUrl, + } + + 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, + }), + }) + + 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, + key: presignedData.fileInfo.key, // Store the actual S3 key + uploading: false, + } + : f + ) + ) + } catch (error) { + console.error('File upload failed:', error) + // Remove failed upload + setAttachedFiles((prev) => prev.filter((f) => f.id !== tempFile.id)) + } + } + } + const handleSubmit = () => { const trimmedMessage = message.trim() if (!trimmedMessage || disabled || isLoading) return - onSubmit(trimmedMessage) - // Clear the message after submit + // Check for failed uploads and show user feedback + const failedUploads = attachedFiles.filter((f) => !f.uploading && !f.key) + if (failedUploads.length > 0) { + console.error( + 'Some files failed to upload:', + failedUploads.map((f) => f.name) + ) + } + + // Convert attached files to the format expected by the API + const fileAttachments = attachedFiles + .filter((f) => !f.uploading && f.key) // Only include successfully uploaded files with keys + .map((f) => ({ + id: f.id, + s3_key: f.key!, // Use the actual S3 key stored from the upload response + filename: f.name, + media_type: f.type, + size: f.size, + })) + + onSubmit(trimmedMessage, fileAttachments) + + // Clean up preview URLs before clearing + attachedFiles.forEach((f) => { + if (f.previewUrl) { + URL.revokeObjectURL(f.previewUrl) + } + }) + + // Clear the message and files after submit if (controlledValue !== undefined) { onControlledChange?.('') } else { setInternalMessage('') } + setAttachedFiles([]) } const handleAbort = () => { @@ -111,6 +322,67 @@ 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 + + await processFiles(files) + + // Clear the input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const removeFile = (fileId: string) => { + // Clean up preview URL if it exists + const file = attachedFiles.find((f) => f.id === fileId) + if (file?.previewUrl) { + URL.revokeObjectURL(file.previewUrl) + } + setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId)) + } + + const handleFileClick = (file: AttachedFile) => { + // If file has been uploaded and has an S3 key, open the S3 URL + if (file.key) { + const serveUrl = `/api/files/serve/s3/${encodeURIComponent(file.key)}?bucket=copilot` + window.open(serveUrl, '_blank') + } else if (file.previewUrl) { + // If file hasn't been uploaded yet but has a preview URL, open that + window.open(file.previewUrl, '_blank') + } + } + + 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 / k ** i) * 100) / 100} ${sizes[i]}` + } + + const isImageFile = (type: string) => { + return type.startsWith('image/') + } + + const getFileIcon = (mediaType: string) => { + if (mediaType.startsWith('image/')) { + return + } + if (mediaType.includes('pdf')) { + return + } + if (mediaType.includes('text') || mediaType.includes('json') || mediaType.includes('xml')) { + return + } + return + } + const canSubmit = message.trim().length > 0 && !disabled && !isLoading const showAbortButton = isLoading && onAbort @@ -130,23 +402,93 @@ const UserInput = forwardRef( return (
-
+
+ {/* Attached Files Display with Thumbnails */} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file) => ( +
handleFileClick(file)} + > + {isImageFile(file.type) && file.previewUrl ? ( + // For images, show actual thumbnail + {file.name} + ) : isImageFile(file.type) && file.key ? ( + // For uploaded images without preview URL, use S3 URL + {file.name} + ) : ( + // For other files, show icon centered +
+ {getFileIcon(file.type)} +
+ )} + + {/* Loading overlay */} + {file.uploading && ( +
+ +
+ )} + + {/* Remove button */} + {!file.uploading && ( + + )} + + {/* Hover overlay effect */} +
+
+ ))} +
+ )} + {/* Textarea Field */}