Skip to content

Commit 679cb02

Browse files
committed
Enhance file upload and download functionality to support multiple files. Updated serializers to handle multiple presigned URLs and S3 paths. Modified frontend components to accommodate new file handling format, including validation for maximum file uploads. Improved error handling during file uploads.
1 parent af7f031 commit 679cb02

File tree

6 files changed

+229
-91
lines changed

6 files changed

+229
-91
lines changed

back/back/apps/broker/serializers/messages/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,17 @@ class MessageStackSerializer(serializers.Serializer):
202202

203203
def validate(self, attrs):
204204
# If it is a file for download and doesn't have a url then we need to return a url to the file so the fsm can download it
205-
if attrs['type'] == 'file_uploaded' and not attrs.get('payload', {}).get('url') and attrs.get('payload', {}).get('s3_path'):
205+
if attrs['type'] == 'file_uploaded':
206206
storage = select_private_storage()
207207
if not isinstance(storage, PrivateMediaLocalStorage):
208-
attrs['payload']['url'] = storage.generate_presigned_url_get(attrs.get('payload', {}).get('s3_path'), expires_in=3600)
208+
payload = attrs.get('payload', {})
209+
210+
# Handle new multiple files format
211+
if payload.get('files'):
212+
for file_data in payload['files']:
213+
if file_data.get('s3_path') and not file_data.get('url'):
214+
file_data['url'] = storage.generate_presigned_url_get(file_data['s3_path'], expires_in=3600)
215+
209216
return attrs
210217

211218

back/back/apps/broker/serializers/rpc.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import mimetypes
22
import time
3+
from datetime import datetime
4+
import uuid
35

46
from rest_framework import serializers
57

@@ -70,17 +72,32 @@ def validate(self, attrs):
7072
for ndx, element in enumerate(attrs.get("stack", [])):
7173
if element["type"] == "file_upload":
7274
storage = select_private_storage()
75+
max_files = element["payload"].get("max_files", 1)
7376

7477
for file_extension in element["payload"]["files"].keys():
7578

7679
# Generate presigned URL if using S3
7780
if not isinstance(storage, PrivateMediaLocalStorage):
78-
s3_path = f"uploads/{attrs['ctx']['conversation_id']}/{int(time.time())}"
79-
80-
# We receive the file extension from the client, but we need to add the placeholder to the file extension to be able to guess the content type
81-
content_type = mimetypes.guess_type(f'placeholder.{file_extension}')[0]
82-
attrs['stack'][ndx]['payload']["files"][file_extension]['presigned_url'] = storage.generate_presigned_url_put(s3_path, content_type=content_type)
83-
attrs['stack'][ndx]['payload']["files"][file_extension]['s3_path'] = s3_path
81+
date_partition = datetime.utcnow().strftime('%Y/%m/%d')
82+
83+
# Generate multiple presigned URLs based on max_files
84+
presigned_urls = []
85+
s3_paths = []
86+
87+
for file_index in range(max_files):
88+
# Create unique path for each potential file upload
89+
unique_id = uuid.uuid4()
90+
s3_path = f"uploads/{date_partition}/{attrs['ctx']['conversation_id']}/{unique_id}"
91+
92+
# We receive the file extension from the client, but we need to add the placeholder to the file extension to be able to guess the content type
93+
content_type = mimetypes.guess_type(f'placeholder.{file_extension}')[0]
94+
presigned_url = storage.generate_presigned_url_put(s3_path, content_type=content_type)
95+
96+
presigned_urls.append(presigned_url)
97+
s3_paths.append(s3_path)
98+
99+
attrs['stack'][ndx]['payload']["files"][file_extension]['presigned_urls'] = presigned_urls
100+
attrs['stack'][ndx]['payload']["files"][file_extension]['s3_paths'] = s3_paths
84101
attrs['stack'][ndx]['payload']["files"][file_extension]['content_type'] = content_type
85102

86103
return super().validate(attrs)
@@ -131,7 +148,7 @@ class RPCLLMRequestSerializer(serializers.Serializer):
131148
conversation_id = serializers.CharField()
132149
bot_channel_name = serializers.CharField()
133150
messages = serializers.ListField(child=serializers.DictField(), allow_empty=True, required=False, allow_null=True)
134-
temperature = serializers.FloatField(default=0.7, required=False)
151+
temperature = serializers.FloatField(default=0.7, required=False, allow_null=True)
135152
max_tokens = serializers.IntegerField(default=1024, required=False)
136153
seed = serializers.IntegerField(default=42, required=False)
137154
thinking = ThinkingField(default=None, required=False, allow_null=True)

sdk/chatfaq_sdk/layers/__init__.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,21 +84,25 @@ def __init__(
8484
content,
8585
file_extensions=[],
8686
max_size=0,
87+
max_files=1,
8788
*args,
8889
**kwargs,
8990
):
9091
"""
9192
:param file_extensions: A list of file extensions to request. For example: ["pdf", "xml"]
9293
:param max_size: The maximum size of the file to request in bytes. For example: 50 * 1024 * 1024 (50MB)
94+
:param max_files: The maximum number of files that can be uploaded. For example: 5
9395
"""
9496
super().__init__(*args, **kwargs)
9597
self.content = content
9698
self.file_extensions = file_extensions
9799
self.max_size = max_size
100+
self.max_files = max_files
98101

99102
async def build_payloads(self, ctx, data):
100103
_payload = {
101104
"content": self.content,
105+
"max_files": self.max_files,
102106
"files": {
103107
file_extension: {
104108
"max_size": self.max_size,
@@ -112,33 +116,34 @@ async def build_payloads(self, ctx, data):
112116

113117
class FileDownload(Layer):
114118
"""
115-
A message layer that includes a file download.
119+
A message layer that includes file downloads (single or multiple).
116120
"""
117121
_type = "file_download"
118122

119123
def __init__(
120124
self,
121125
content: str,
122-
file_name: str,
123-
file_url: str,
126+
files=None,
124127
*args,
125128
**kwargs,
126129
):
127130
"""
128-
:param file_name: The name of the file. For example: "report.pdf"
129-
:param file_url: The URL of the file where the user can download it or visualize it. For example: "https://example.com/report.pdf"
131+
:param files: List of files to download. Each file should be a dict with 'name' and 'url' keys.
132+
For example: [{"name": "report.pdf", "url": "https://example.com/report.pdf"}]
130133
"""
131134
super().__init__(*args, **kwargs)
132135
self.content = content
133-
self.file_name = file_name
134-
self.file_url = file_url
136+
137+
if files:
138+
self.files = files
139+
else:
140+
raise ValueError("'files' parameter must be provided")
135141

136142
async def build_payloads(self, ctx, data):
137143
payload = {
138144
"payload": {
139-
"content": self.content,
140-
"name": self.file_name,
141-
"url": self.file_url,
145+
"content": self.content,
146+
"files": self.files,
142147
}
143148
}
144149
yield [payload], True

sdk/examples/file_example/fsm_definition.py

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,51 @@
11
from chatfaq_sdk import ChatFAQSDK
22
from chatfaq_sdk.fsm import FSMDefinition, State, Transition
3-
from chatfaq_sdk.layers import Message, FileDownload, FileUpload
3+
from chatfaq_sdk.layers import FileDownload, FileUpload, Message
44

55

66
async def send_greeting(sdk: ChatFAQSDK, ctx: dict):
7-
yield FileUpload(content="Please upload a file", file_extensions=["pdf", "xml"], max_size=50*1024*1024)
7+
yield FileUpload(
8+
content="Please upload up to 5 files (PDF or XML)",
9+
file_extensions=["pdf", "xml"],
10+
max_size=50*1024*1024,
11+
max_files=5
12+
)
813

914

1015
async def send_answer(sdk: ChatFAQSDK, ctx: dict):
11-
# Last message
12-
file_name = ctx['conv_mml'][-1]['stack'][0]['payload']['name']
13-
file_url = ctx['conv_mml'][-1]['stack'][0]['payload']['url']
14-
print("file_name: ", file_name)
15-
print("file_url: ", file_url)
16-
17-
########################################
18-
# Do some processing with the file here
19-
new_file_name = "processed_" + file_name
20-
new_file_url = file_url
21-
########################################
22-
23-
yield FileDownload(content="Here is the processed file", file_url=new_file_url, file_name=new_file_name)
16+
# Last message payload
17+
payload = ctx['conv_mml'][-1]['stack'][0]['payload']
18+
processed_files = []
19+
20+
# Handle new multiple files format
21+
if 'files' in payload:
22+
print("Processing multiple files:")
23+
for file_data in payload['files']:
24+
file_name = file_data['name']
25+
file_url = file_data['url']
26+
print(f"- file_name: {file_name}")
27+
print(f"- file_url: {file_url}")
28+
29+
########################################
30+
# Do some processing with each file here
31+
new_file_name = "processed_" + file_name
32+
new_file_url = file_url # In reality, you'd process and upload to new location
33+
########################################
34+
35+
processed_files.append({
36+
"name": new_file_name,
37+
"url": new_file_url
38+
})
39+
else:
40+
print("No files found in payload")
41+
yield Message(content="No files were uploaded to process.")
42+
return
43+
44+
# Send back the processed files
45+
yield FileDownload(
46+
content=f"Here {'are' if len(processed_files) > 1 else 'is'} the processed file{'s' if len(processed_files) > 1 else ''}:",
47+
files=processed_files
48+
)
2449

2550

2651
greeting_state = State(name="Greeting", events=[send_greeting], initial=True)

widget/components/chat/msgs/pieces/AttachmentMsgPiece.vue

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
<template>
22
<div class="file-download-wrapper">
3-
<span>{{ props.data.content }}</span>
4-
<div class="file-download" :class="{ 'dark-mode': store.darkMode }">
3+
<span v-if="props.data.content">{{ props.data.content }}</span>
4+
5+
<!-- Handle multiple files (new format) -->
6+
<div v-if="props.data.files" class="files-container">
7+
<div
8+
v-for="(file, index) in props.data.files"
9+
:key="index"
10+
class="file-download"
11+
:class="{ 'dark-mode': store.darkMode }"
12+
>
13+
<div class="file-icon-wrapper" :class="{ 'dark-mode': store.darkMode }">
14+
<File class="file-icon" :class="{ 'dark-mode': store.darkMode }"/>
15+
</div>
16+
<div class="file-info">
17+
<span class="file-name">{{ file.name }}</span>
18+
<span v-if="file.name" class="file-type">{{ getFileTypeFromName(file.name) }}</span>
19+
</div>
20+
<div class="file-download-icon" v-if="file.url" @click="downloadFile(file.url)">
21+
<Download class="download-icon" :class="{ 'dark-mode': store.darkMode }"/>
22+
</div>
23+
</div>
24+
</div>
25+
26+
<!-- Handle single file (legacy format) -->
27+
<div
28+
v-else-if="props.data.name"
29+
class="file-download"
30+
:class="{ 'dark-mode': store.darkMode }"
31+
>
532
<div class="file-icon-wrapper" :class="{ 'dark-mode': store.darkMode }">
633
<File class="file-icon" :class="{ 'dark-mode': store.darkMode }"/>
734
</div>
835
<div class="file-info">
936
<span class="file-name">{{ props.data.name }}</span>
1037
<span v-if="props.data.name" class="file-type">{{ getFileTypeFromName(props.data.name) }}</span>
1138
</div>
12-
<div class="file-download-icon" v-if="props.data.url" @click="downloadFile">
39+
<div class="file-download-icon" v-if="props.data.url" @click="downloadFile(props.data.url)">
1340
<Download class="download-icon" :class="{ 'dark-mode': store.darkMode }"/>
1441
</div>
1542
</div>
@@ -30,8 +57,8 @@ const props = defineProps({
3057
}
3158
});
3259
33-
function downloadFile() {
34-
window.open(props.data.url, '_blank');
60+
function downloadFile(url) {
61+
window.open(url, '_blank');
3562
}
3663
3764
function getFileTypeFromName(url) {
@@ -44,6 +71,12 @@ function getFileTypeFromName(url) {
4471

4572
<style scoped lang="scss">
4673
.file-download-wrapper {
74+
.files-container {
75+
display: flex;
76+
flex-direction: column;
77+
gap: 8px;
78+
}
79+
4780
.file-download {
4881
background-color: #FFFFFF;
4982
display: flex;

0 commit comments

Comments
 (0)