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
This commit is contained in:
Heisenberg Vader
2025-11-04 04:42:35 +05:30
committed by GitHub
parent d54469532e
commit 3789d2eb03
2 changed files with 324 additions and 51 deletions

View File

@@ -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(
{

View File

@@ -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<number, string> = {};
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<HTMLInputElement>) => {
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<string | null>(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 (
<div {...getRootProps()} className="flex w-full flex-col">
{/* react-dropzone input (for drag/drop) */}
<input {...getInputProps()} />
<div className="border-dark-gray bg-lotion dark:border-grey relative flex w-full flex-col rounded-[23px] border dark:bg-transparent">
<div className="flex flex-wrap gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
{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}
>
<div className="bg-purple-30 mr-2 flex h-8 w-8 items-center justify-center rounded-md p-1">