diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 9cb72864b3..23c7dc9992 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -32,6 +32,7 @@ import { ErrorMessageBanner } from "./error-message-banner"; import { shouldRenderEvent } from "./event-content-helpers/should-render-event"; import { useUploadFiles } from "#/hooks/mutation/use-upload-files"; import { useConfig } from "#/hooks/query/use-config"; +import { validateFiles } from "#/utils/file-validation"; function getEntryPoint( hasRepository: boolean | null, @@ -92,9 +93,12 @@ export function ChatInterface() { const handleSendMessage = async ( content: string, - images: File[], - files: File[], + originalImages: File[], + originalFiles: File[], ) => { + // Create mutable copies of the arrays + const images = [...originalImages]; + const files = [...originalFiles]; if (events.length === 0) { posthog.capture("initial_query_submitted", { entry_point: getEntryPoint( @@ -110,6 +114,16 @@ export function ChatInterface() { current_message_length: content.length, }); } + + // Validate file sizes before any processing + const allFiles = [...images, ...files]; + const validation = validateFiles(allFiles); + + if (!validation.isValid) { + displayErrorToast(`Error: ${validation.errorMessage}`); + return; // Stop processing if validation fails + } + const promises = images.map((image) => convertImageToBase64(image)); const imageUrls = await Promise.all(promises); diff --git a/frontend/src/components/features/chat/interactive-chat-box.tsx b/frontend/src/components/features/chat/interactive-chat-box.tsx index d9428f4347..e99d20d04d 100644 --- a/frontend/src/components/features/chat/interactive-chat-box.tsx +++ b/frontend/src/components/features/chat/interactive-chat-box.tsx @@ -5,6 +5,8 @@ import { ImageCarousel } from "../images/image-carousel"; import { UploadImageInput } from "../images/upload-image-input"; import { FileList } from "../files/file-list"; import { isFileImage } from "#/utils/is-file-image"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { validateFiles } from "#/utils/file-validation"; interface InteractiveChatBoxProps { isDisabled?: boolean; @@ -27,14 +29,20 @@ export function InteractiveChatBox({ const [files, setFiles] = React.useState([]); const handleUpload = (selectedFiles: File[]) => { - setFiles((prevFiles) => [ - ...prevFiles, - ...selectedFiles.filter((f) => !isFileImage(f)), - ]); - setImages((prevImages) => [ - ...prevImages, - ...selectedFiles.filter((f) => isFileImage(f)), - ]); + // Validate files before adding them + const validation = validateFiles(selectedFiles, [...images, ...files]); + + if (!validation.isValid) { + displayErrorToast(`Error: ${validation.errorMessage}`); + return; // Don't add any files if validation fails + } + + // Filter valid files by type + const validFiles = selectedFiles.filter((f) => !isFileImage(f)); + const validImages = selectedFiles.filter((f) => isFileImage(f)); + + setFiles((prevFiles) => [...prevFiles, ...validFiles]); + setImages((prevImages) => [...prevImages, ...validImages]); }; const removeElementByIndex = (array: Array, index: number) => { diff --git a/frontend/src/utils/file-validation.ts b/frontend/src/utils/file-validation.ts new file mode 100644 index 0000000000..490912d506 --- /dev/null +++ b/frontend/src/utils/file-validation.ts @@ -0,0 +1,70 @@ +export const MAX_FILE_SIZE = 3 * 1024 * 1024; // 3MB maximum file size +export const MAX_TOTAL_SIZE = 3 * 1024 * 1024; // 3MB maximum total size for all files combined + +export interface FileValidationResult { + isValid: boolean; + errorMessage?: string; + oversizedFiles?: string[]; +} + +/** + * Validates individual file sizes + */ +export function validateIndividualFileSizes( + files: File[], +): FileValidationResult { + const oversizedFiles = files.filter((file) => file.size > MAX_FILE_SIZE); + + if (oversizedFiles.length > 0) { + const fileNames = oversizedFiles.map((f) => f.name); + return { + isValid: false, + errorMessage: `Files exceeding 3MB are not allowed: ${fileNames.join(", ")}`, + oversizedFiles: fileNames, + }; + } + + return { isValid: true }; +} + +/** + * Validates total file size including existing files + */ +export function validateTotalFileSize( + newFiles: File[], + existingFiles: File[] = [], +): FileValidationResult { + const currentTotalSize = existingFiles.reduce( + (sum, file) => sum + file.size, + 0, + ); + const newFilesSize = newFiles.reduce((sum, file) => sum + file.size, 0); + const totalSize = currentTotalSize + newFilesSize; + + if (totalSize > MAX_TOTAL_SIZE) { + const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(1); + return { + isValid: false, + errorMessage: `Total file size would be ${totalSizeMB}MB, exceeding the 3MB limit. Please select fewer or smaller files.`, + }; + } + + return { isValid: true }; +} + +/** + * Validates both individual and total file sizes + */ +export function validateFiles( + newFiles: File[], + existingFiles: File[] = [], +): FileValidationResult { + // First check individual file sizes + const individualValidation = validateIndividualFileSizes(newFiles); + if (!individualValidation.isValid) { + return individualValidation; + } + + // Then check total size + return validateTotalFileSize(newFiles, existingFiles); +} diff --git a/openhands/server/shared.py b/openhands/server/shared.py index b62157983c..715cd0e630 100644 --- a/openhands/server/shared.py +++ b/openhands/server/shared.py @@ -43,7 +43,11 @@ if redis_host: sio = socketio.AsyncServer( - async_mode='asgi', cors_allowed_origins='*', client_manager=client_manager + async_mode='asgi', + cors_allowed_origins='*', + client_manager=client_manager, + # Increase buffer size to 4MB (to handle 3MB files with base64 overhead) + max_http_buffer_size=4 * 1024 * 1024, ) MonitoringListenerImpl = get_impl(