From 3789d2eb0326a26d3fc40effb311b5f0839f5e73 Mon Sep 17 00:00:00 2001 From: Heisenberg Vader <23ucs596@lnmiit.ac.in> Date: Tue, 4 Nov 2025 04:42:35 +0530 Subject: [PATCH] Updated the technique for handling multiple file uploads from the user (#2126) * Fixed multiple file uploads to be sent through a single request to backend for further processing and storing * Fixed multiple file uploads to be sent through a single request to backend for further processing and storing * Fixed multiple file uploads to be sent through a single request to backend for further processing and storing * Made duplicate multiple keyword fixes * Added back drag and drop functionality and it keeps the multiple file uploads --- application/api/user/attachments/routes.py | 112 ++++++--- frontend/src/components/MessageInput.tsx | 263 +++++++++++++++++++-- 2 files changed, 324 insertions(+), 51 deletions(-) diff --git a/application/api/user/attachments/routes.py b/application/api/user/attachments/routes.py index cd67f1ba..3935821d 100644 --- a/application/api/user/attachments/routes.py +++ b/application/api/user/attachments/routes.py @@ -25,7 +25,7 @@ class StoreAttachment(Resource): api.model( "AttachmentModel", { - "file": fields.Raw(required=True, description="File to upload"), + "file": fields.Raw(required=True, description="File(s) to upload"), "api_key": fields.String( required=False, description="API key (optional)" ), @@ -33,18 +33,24 @@ class StoreAttachment(Resource): ) ) @api.doc( - description="Stores a single attachment without vectorization or training. Supports user or API key authentication." + description="Stores one or multiple attachments without vectorization or training. Supports user or API key authentication." ) def post(self): decoded_token = getattr(request, "decoded_token", None) api_key = request.form.get("api_key") or request.args.get("api_key") - file = request.files.get("file") - - if not file or file.filename == "": + + files = request.files.getlist("file") + if not files: + single_file = request.files.get("file") + if single_file: + files = [single_file] + + if not files or all(f.filename == "" for f in files): return make_response( - jsonify({"status": "error", "message": "Missing file"}), + jsonify({"status": "error", "message": "Missing file(s)"}), 400, ) + user = None if decoded_token: user = safe_filename(decoded_token.get("sub")) @@ -59,32 +65,74 @@ class StoreAttachment(Resource): return make_response( jsonify({"success": False, "message": "Authentication required"}), 401 ) + try: - attachment_id = ObjectId() - original_filename = safe_filename(os.path.basename(file.filename)) - relative_path = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{str(attachment_id)}/{original_filename}" + tasks = [] + errors = [] + original_file_count = len(files) + + for idx, file in enumerate(files): + try: + attachment_id = ObjectId() + original_filename = safe_filename(os.path.basename(file.filename)) + relative_path = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{str(attachment_id)}/{original_filename}" - metadata = storage.save_file(file, relative_path) - - file_info = { - "filename": original_filename, - "attachment_id": str(attachment_id), - "path": relative_path, - "metadata": metadata, - } - - task = store_attachment.delay(file_info, user) - - return make_response( - jsonify( - { - "success": True, - "task_id": task.id, - "message": "File uploaded successfully. Processing started.", + metadata = storage.save_file(file, relative_path) + file_info = { + "filename": original_filename, + "attachment_id": str(attachment_id), + "path": relative_path, + "metadata": metadata, } - ), - 200, - ) + + task = store_attachment.delay(file_info, user) + tasks.append({ + "task_id": task.id, + "filename": original_filename, + "attachment_id": str(attachment_id), + }) + except Exception as file_err: + current_app.logger.error(f"Error processing file {idx} ({file.filename}): {file_err}", exc_info=True) + errors.append({ + "filename": file.filename, + "error": str(file_err) + }) + + if not tasks: + error_msg = "No valid files to upload" + if errors: + error_msg += f". Errors: {errors}" + return make_response( + jsonify({"status": "error", "message": error_msg, "errors": errors}), + 400, + ) + + if original_file_count == 1 and len(tasks) == 1: + current_app.logger.info("Returning single task_id response") + return make_response( + jsonify( + { + "success": True, + "task_id": tasks[0]["task_id"], + "message": "File uploaded successfully. Processing started.", + } + ), + 200, + ) + else: + response_data = { + "success": True, + "tasks": tasks, + "message": f"{len(tasks)} file(s) uploaded successfully. Processing started.", + } + if errors: + response_data["errors"] = errors + response_data["message"] += f" {len(errors)} file(s) failed." + + return make_response( + jsonify(response_data), + 200, + ) except Exception as err: current_app.logger.error(f"Error storing attachment: {err}", exc_info=True) return make_response(jsonify({"success": False, "error": str(err)}), 400) @@ -130,15 +178,11 @@ class TextToSpeech(Resource): @api.expect(tts_model) @api.doc(description="Synthesize audio speech from text") def post(self): - from application.utils import clean_text_for_tts - data = request.get_json() text = data["text"] - cleaned_text = clean_text_for_tts(text) - try: tts_instance = TTSCreator.create_tts(settings.TTS_PROVIDER) - audio_base64, detected_language = tts_instance.text_to_speech(cleaned_text) + audio_base64, detected_language = tts_instance.text_to_speech(text) return make_response( jsonify( { diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index f3eefb6d..4e494959 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -19,8 +19,8 @@ import { removeAttachment, selectAttachments, updateAttachment, + reorderAttachments, } from '../upload/uploadSlice'; -import { reorderAttachments } from '../upload/uploadSlice'; import { ActiveState } from '../models/misc'; import { @@ -77,7 +77,7 @@ export default function MessageInput({ (browserOS === 'mac' && event.metaKey && event.key === 'k') ) { event.preventDefault(); - setIsSourcesPopupOpen(!isSourcesPopupOpen); + setIsSourcesPopupOpen((s) => !s); } }; @@ -89,8 +89,198 @@ export default function MessageInput({ const uploadFiles = useCallback( (files: File[]) => { + if (!files || files.length === 0) return; + const apiHost = import.meta.env.VITE_API_HOST; + if (files.length > 1) { + const formData = new FormData(); + const indexToUiId: Record = {}; + + files.forEach((file, i) => { + formData.append('file', file); + const uiId = crypto.randomUUID(); + indexToUiId[i] = uiId; + dispatch( + addAttachment({ + id: uiId, + fileName: file.name, + progress: 0, + status: 'uploading' as const, + taskId: '', + }), + ); + }); + + const xhr = new XMLHttpRequest(); + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); + Object.values(indexToUiId).forEach((uiId) => + dispatch( + updateAttachment({ + id: uiId, + updates: { progress }, + }), + ), + ); + } + }); + + xhr.onload = () => { + const status = xhr.status; + if (status === 200) { + try { + const response = JSON.parse(xhr.responseText); + + if (Array.isArray(response?.tasks)) { + const tasks = response.tasks as Array<{ + task_id?: string; + filename?: string; + attachment_id?: string; + path?: string; + }>; + + tasks.forEach((t, idx) => { + const uiId = indexToUiId[idx]; + if (!uiId) return; + if (t?.task_id) { + dispatch( + updateAttachment({ + id: uiId, + updates: { + taskId: t.task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } else { + dispatch( + updateAttachment({ + id: uiId, + updates: { status: 'failed' }, + }), + ); + } + }); + + if (tasks.length < files.length) { + for (let i = tasks.length; i < files.length; i++) { + const uiId = indexToUiId[i]; + if (uiId) { + dispatch( + updateAttachment({ + id: uiId, + updates: { status: 'failed' }, + }), + ); + } + } + } + } else if (response?.task_id) { + if (files.length === 1) { + const uiId = indexToUiId[0]; + if (uiId) { + dispatch( + updateAttachment({ + id: uiId, + updates: { + taskId: response.task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } + } else { + console.warn( + 'Server returned a single task_id for multiple files. Update backend to return tasks[].', + ); + const firstUi = indexToUiId[0]; + if (firstUi) { + dispatch( + updateAttachment({ + id: firstUi, + updates: { + taskId: response.task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } + for (let i = 1; i < files.length; i++) { + const uiId = indexToUiId[i]; + if (uiId) { + dispatch( + updateAttachment({ + id: uiId, + updates: { status: 'failed' }, + }), + ); + } + } + } + } else { + console.error('Unexpected upload response shape', response); + Object.values(indexToUiId).forEach((id) => + dispatch( + updateAttachment({ + id, + updates: { status: 'failed' }, + }), + ), + ); + } + } catch (err) { + console.error( + 'Failed to parse upload response', + err, + xhr.responseText, + ); + Object.values(indexToUiId).forEach((id) => + dispatch( + updateAttachment({ + id, + updates: { status: 'failed' }, + }), + ), + ); + } + } else { + console.error('Upload failed', status, xhr.responseText); + Object.values(indexToUiId).forEach((id) => + dispatch( + updateAttachment({ + id, + updates: { status: 'failed' }, + }), + ), + ); + } + }; + + xhr.onerror = () => { + console.error('Upload network error'); + Object.values(indexToUiId).forEach((id) => + dispatch( + updateAttachment({ + id, + updates: { status: 'failed' }, + }), + ), + ); + }; + + xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.send(formData); + return; + } + + // Single-file path: upload each file individually (original repo behavior) files.forEach((file) => { const formData = new FormData(); formData.append('file', file); @@ -121,16 +311,54 @@ export default function MessageInput({ xhr.onload = () => { if (xhr.status === 200) { - const response = JSON.parse(xhr.responseText); - if (response.task_id) { + try { + const response = JSON.parse(xhr.responseText); + if (response.task_id) { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { + taskId: response.task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } else { + // If backend returned tasks[] for single-file, handle gracefully: + if ( + Array.isArray(response?.tasks) && + response.tasks[0]?.task_id + ) { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { + taskId: response.tasks[0].task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } else { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { status: 'failed' }, + }), + ); + } + } + } catch (err) { + console.error( + 'Failed to parse upload response', + err, + xhr.responseText, + ); dispatch( updateAttachment({ id: uniqueId, - updates: { - taskId: response.task_id, - status: 'processing', - progress: 10, - }, + updates: { status: 'failed' }, }), ); } @@ -154,7 +382,7 @@ export default function MessageInput({ }; xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); - xhr.setRequestHeader('Authorization', `Bearer ${token}`); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.send(formData); }); }, @@ -163,15 +391,13 @@ export default function MessageInput({ const handleFileAttachment = (e: React.ChangeEvent) => { if (!e.target.files || e.target.files.length === 0) return; - const files = Array.from(e.target.files); uploadFiles(files); - // clear input so same file can be selected again e.target.value = ''; }; - // Drag and drop handler + // Drag & drop via react-dropzone const onDrop = useCallback( (acceptedFiles: File[]) => { uploadFiles(acceptedFiles); @@ -321,11 +547,8 @@ export default function MessageInput({ handleAbort(); }; - // Drag state for reordering const [draggingId, setDraggingId] = useState(null); - // no preview object URLs to revoke (preview removed per reviewer request) - const findIndexById = (id: string) => attachments.findIndex((a) => a.id === id); @@ -359,7 +582,9 @@ export default function MessageInput({ return (
+ {/* react-dropzone input (for drag/drop) */} +
{attachments.map((attachment) => { @@ -374,7 +599,11 @@ export default function MessageInput({ attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100' - } ${draggingId === attachment.id ? 'ring-dashed opacity-60 ring-2 ring-purple-200' : ''}`} + } ${ + draggingId === attachment.id + ? 'ring-dashed opacity-60 ring-2 ring-purple-200' + : '' + }`} title={attachment.fileName} >