feat: enable multi-file upload for chat and agent workflows (#12977)

### Closes: #12921 

### What problem does this PR solve?

Previously, multi-file upload was not working correctly across the
application:

- **Chat**: UI displayed "Upload max 5 files" but only the first file
was actually uploaded
- **Agent conversational mode**: Frontend sent multiple files but
backend only processed one
- **Agent task-mode file inputs**: Explicitly limited to single file
only

This PR enables proper multi-file upload support for both chat and agent
workflows, allowing users to upload and process multiple files (up to 5)
as the UI originally suggested.

**Changes:**
- `web/src/pages/next-chats/hooks/use-upload-file.ts`: Process all files
instead of only `files[0]`
- `api/apps/canvas_app.py`: Handle multiple files via
`files.getlist("file")`
- `web/src/pages/agent/debug-content/uploader.tsx`: Allow up to 5 files
with `multiple={true}`
- `agent/component/begin.py` & `fillup.py`: Support file arrays while
maintaining backward compatibility

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
BitToby
2026-02-04 12:03:21 +02:00
committed by GitHub
parent ffdf19b27f
commit 4d4b5a978d
7 changed files with 64 additions and 24 deletions

View File

@ -45,11 +45,14 @@ class Begin(UserFillUp):
if self.check_if_canceled("Begin processing"): if self.check_if_canceled("Begin processing"):
return return
if isinstance(v, dict) and v.get("type", "").lower().find("file") >=0: if isinstance(v, dict) and v.get("type", "").lower().find("file") >= 0:
if v.get("optional") and v.get("value", None) is None: if v.get("optional") and v.get("value", None) is None:
v = None v = None
else: else:
v = FileService.get_files([v["value"]]) file_value = v["value"]
# Support both single file (backward compatibility) and multiple files
files = file_value if isinstance(file_value, list) else [file_value]
v = FileService.get_files(files)
else: else:
v = v.get("value") v = v.get("value")
self.set_output(k, v) self.set_output(k, v)

View File

@ -64,11 +64,14 @@ class UserFillUp(ComponentBase):
for k, v in kwargs.get("inputs", {}).items(): for k, v in kwargs.get("inputs", {}).items():
if self.check_if_canceled("UserFillUp processing"): if self.check_if_canceled("UserFillUp processing"):
return return
if isinstance(v, dict) and v.get("type", "").lower().find("file") >=0: if isinstance(v, dict) and v.get("type", "").lower().find("file") >= 0:
if v.get("optional") and v.get("value", None) is None: if v.get("optional") and v.get("value", None) is None:
v = None v = None
else: else:
v = FileService.get_files([v["value"]]) file_value = v["value"]
# Support both single file (backward compatibility) and multiple files
files = file_value if isinstance(file_value, list) else [file_value]
v = FileService.get_files(files)
else: else:
v = v.get("value") v = v.get("value")
self.set_output(k, v) self.set_output(k, v)

View File

@ -253,9 +253,12 @@ async def upload(canvas_id):
user_id = cvs["user_id"] user_id = cvs["user_id"]
files = await request.files files = await request.files
file = files['file'] if files and files.get("file") else None file_objs = files.getlist("file") if files and files.get("file") else []
try: try:
return get_json_result(data=FileService.upload_info(user_id, file, request.args.get("url"))) if len(file_objs) == 1:
return get_json_result(data=FileService.upload_info(user_id, file_objs[0], request.args.get("url")))
results = [FileService.upload_info(user_id, f) for f in file_objs]
return get_json_result(data=results)
except Exception as e: except Exception as e:
return server_error_response(e) return server_error_response(e)

View File

@ -70,6 +70,8 @@ const DebugContent = ({
value = false; value = false;
} else if (type === BeginQueryType.Integer || type === 'float') { } else if (type === BeginQueryType.Integer || type === 'float') {
fieldSchema = z.coerce.number(); fieldSchema = z.coerce.number();
} else if (type === BeginQueryType.File) {
fieldSchema = z.array(z.record(z.any())).min(1);
} else { } else {
fieldSchema = z.record(z.any()); fieldSchema = z.record(z.any());
} }

View File

@ -19,14 +19,18 @@ import * as React from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
type FileUploadDirectUploadProps = { type FileUploadDirectUploadProps = {
value: Record<string, any>; value: Record<string, any> | Record<string, any>[];
onChange(value: Record<string, any>): void; onChange(value: Record<string, any>[]): void;
}; };
export function FileUploadDirectUpload({ export function FileUploadDirectUpload({
value,
onChange, onChange,
}: FileUploadDirectUploadProps) { }: FileUploadDirectUploadProps) {
const [files, setFiles] = React.useState<File[]>([]); const [files, setFiles] = React.useState<File[]>([]);
const uploadedFilesRef = React.useRef<Record<string, any>[]>(
Array.isArray(value) ? value : value ? [value] : [],
);
const { uploadCanvasFile } = useUploadCanvasFile(); const { uploadCanvasFile } = useUploadCanvasFile();
@ -44,7 +48,11 @@ export function FileUploadDirectUpload({
const ret = await uploadCanvasFile([file]); const ret = await uploadCanvasFile([file]);
if (ret.code === 0) { if (ret.code === 0) {
onSuccess(file); onSuccess(file);
onChange(ret.data); uploadedFilesRef.current = [
...uploadedFilesRef.current,
ret.data,
];
onChange(uploadedFilesRef.current);
} else { } else {
handleError(); handleError();
} }
@ -69,15 +77,31 @@ export function FileUploadDirectUpload({
}); });
}, []); }, []);
const handleFilesChange = React.useCallback(
(newFiles: File[]) => {
// Find removed files and update uploadedFilesRef
const removedFiles = files.filter((f) => !newFiles.includes(f));
if (removedFiles.length > 0) {
const removedIndices = removedFiles.map((f) => files.indexOf(f));
uploadedFilesRef.current = uploadedFilesRef.current.filter(
(_, idx) => !removedIndices.includes(idx),
);
onChange(uploadedFilesRef.current);
}
setFiles(newFiles);
},
[files, onChange],
);
return ( return (
<FileUpload <FileUpload
value={files} value={files}
onValueChange={setFiles} onValueChange={handleFilesChange}
onUpload={onUpload} onUpload={onUpload}
onFileReject={onFileReject} onFileReject={onFileReject}
maxFiles={1} maxFiles={5}
className="w-full" className="w-full"
multiple={false} multiple={true}
> >
<FileUploadDropzone> <FileUploadDropzone>
<div className="flex flex-col items-center gap-1 text-center"> <div className="flex flex-col items-center gap-1 text-center">
@ -86,7 +110,7 @@ export function FileUploadDirectUpload({
</div> </div>
<p className="font-medium text-sm">Drag & drop files here</p> <p className="font-medium text-sm">Drag & drop files here</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Or click to browse (max 1 files) Or click to browse (max 5 files)
</p> </p>
</div> </div>
<FileUploadTrigger asChild> <FileUploadTrigger asChild>

View File

@ -11,7 +11,7 @@ import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const formSchema = z.object({ const formSchema = z.object({
file: z.record(z.any()), file: z.array(z.record(z.any())).min(1),
}); });
export type FormSchemaType = z.infer<typeof formSchema>; export type FormSchemaType = z.infer<typeof formSchema>;
@ -25,7 +25,7 @@ export function UploaderForm({ ok, loading }: UploaderFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const form = useForm<FormSchemaType>({ const form = useForm<FormSchemaType>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: {}, defaultValues: { file: [] },
}); });
return ( return (

View File

@ -29,17 +29,22 @@ export function useUploadFile() {
conversationId?: string, conversationId?: string,
) => { ) => {
if (Array.isArray(files) && files.length) { if (Array.isArray(files) && files.length) {
const file = files[0]; for (const file of files) {
const ret = await uploadAndParseFile({ file, options, conversationId }); const ret = await uploadAndParseFile({
file,
options,
conversationId,
});
if (ret?.code === 0) { if (ret?.code === 0) {
const data = ret.data; const data = ret.data;
setCurrentFiles((list) => [...list, data]); setCurrentFiles((list) => [...list, data]);
setFileMap((map) => { setFileMap((map) => {
map.set(files[0], data); map.set(file, data);
return map; return map;
}); });
} }
} }
}
}, },
[uploadAndParseFile], [uploadAndParseFile],
); );