Skip to content

Commit cb6e763

Browse files
authored
improvement(chat): add the ability to download files from the deployed chat (#2280)
* added teams download and chat download file * Removed comments * removed comments * component structure and download all * removed comments * cleanup code * fix empty files case * small fix
1 parent d06b360 commit cb6e763

File tree

7 files changed

+425
-22
lines changed

7 files changed

+425
-22
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { ArrowDown, Download, Loader2, Music } from 'lucide-react'
5+
import { Button } from '@/components/emcn'
6+
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
7+
import { createLogger } from '@/lib/logs/console/logger'
8+
import type { ChatFile } from '@/app/chat/components/message/message'
9+
10+
const logger = createLogger('ChatFileDownload')
11+
12+
interface ChatFileDownloadProps {
13+
file: ChatFile
14+
}
15+
16+
interface ChatFileDownloadAllProps {
17+
files: ChatFile[]
18+
}
19+
20+
function formatFileSize(bytes: number): string {
21+
if (bytes === 0) return '0 B'
22+
const k = 1024
23+
const sizes = ['B', 'KB', 'MB', 'GB']
24+
const i = Math.floor(Math.log(bytes) / Math.log(k))
25+
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
26+
}
27+
28+
function isAudioFile(mimeType: string, filename: string): boolean {
29+
const audioMimeTypes = [
30+
'audio/mpeg',
31+
'audio/wav',
32+
'audio/mp3',
33+
'audio/ogg',
34+
'audio/webm',
35+
'audio/aac',
36+
'audio/flac',
37+
]
38+
const audioExtensions = ['mp3', 'wav', 'ogg', 'webm', 'aac', 'flac', 'm4a']
39+
const extension = filename.split('.').pop()?.toLowerCase()
40+
41+
return (
42+
audioMimeTypes.some((t) => mimeType.includes(t)) ||
43+
(extension ? audioExtensions.includes(extension) : false)
44+
)
45+
}
46+
47+
function isImageFile(mimeType: string): boolean {
48+
return mimeType.startsWith('image/')
49+
}
50+
51+
function getFileUrl(file: ChatFile): string {
52+
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
53+
}
54+
55+
async function triggerDownload(url: string, filename: string): Promise<void> {
56+
const response = await fetch(url)
57+
if (!response.ok) {
58+
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
59+
}
60+
61+
const blob = await response.blob()
62+
const blobUrl = URL.createObjectURL(blob)
63+
64+
const link = document.createElement('a')
65+
link.href = blobUrl
66+
link.download = filename
67+
document.body.appendChild(link)
68+
link.click()
69+
document.body.removeChild(link)
70+
71+
URL.revokeObjectURL(blobUrl)
72+
logger.info(`Downloaded: ${filename}`)
73+
}
74+
75+
export function ChatFileDownload({ file }: ChatFileDownloadProps) {
76+
const [isDownloading, setIsDownloading] = useState(false)
77+
const [isHovered, setIsHovered] = useState(false)
78+
79+
const handleDownload = async () => {
80+
if (isDownloading) return
81+
82+
setIsDownloading(true)
83+
84+
try {
85+
logger.info(`Initiating download for file: ${file.name}`)
86+
const url = getFileUrl(file)
87+
await triggerDownload(url, file.name)
88+
} catch (error) {
89+
logger.error(`Failed to download file ${file.name}:`, error)
90+
if (file.url) {
91+
window.open(file.url, '_blank')
92+
}
93+
} finally {
94+
setIsDownloading(false)
95+
}
96+
}
97+
98+
const renderIcon = () => {
99+
if (isAudioFile(file.type, file.name)) {
100+
return <Music className='h-4 w-4 text-purple-500' />
101+
}
102+
if (isImageFile(file.type)) {
103+
const ImageIcon = DefaultFileIcon
104+
return <ImageIcon className='h-5 w-5' />
105+
}
106+
const DocumentIcon = getDocumentIcon(file.type, file.name)
107+
return <DocumentIcon className='h-5 w-5' />
108+
}
109+
110+
return (
111+
<Button
112+
variant='default'
113+
onClick={handleDownload}
114+
onMouseEnter={() => setIsHovered(true)}
115+
onMouseLeave={() => setIsHovered(false)}
116+
disabled={isDownloading}
117+
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
118+
>
119+
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
120+
<div className='min-w-0 flex-1 text-left'>
121+
<div className='w-[100px] truncate text-xs'>{file.name}</div>
122+
<div className='text-[10px] text-[var(--text-muted)]'>{formatFileSize(file.size)}</div>
123+
</div>
124+
<div className='flex-shrink-0'>
125+
{isDownloading ? (
126+
<Loader2 className='h-3.5 w-3.5 animate-spin' />
127+
) : (
128+
<ArrowDown
129+
className={`h-3.5 w-3.5 transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
130+
/>
131+
)}
132+
</div>
133+
</Button>
134+
)
135+
}
136+
137+
export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
138+
const [isDownloading, setIsDownloading] = useState(false)
139+
140+
if (!files || files.length === 0) return null
141+
142+
const handleDownloadAll = async () => {
143+
if (isDownloading) return
144+
145+
setIsDownloading(true)
146+
147+
try {
148+
logger.info(`Initiating download for ${files.length} files`)
149+
150+
for (let i = 0; i < files.length; i++) {
151+
const file = files[i]
152+
try {
153+
const url = getFileUrl(file)
154+
await triggerDownload(url, file.name)
155+
logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`)
156+
157+
if (i < files.length - 1) {
158+
await new Promise((resolve) => setTimeout(resolve, 150))
159+
}
160+
} catch (error) {
161+
logger.error(`Failed to download file ${file.name}:`, error)
162+
}
163+
}
164+
} finally {
165+
setIsDownloading(false)
166+
}
167+
}
168+
169+
return (
170+
<button
171+
onClick={handleDownloadAll}
172+
disabled={isDownloading}
173+
className='text-muted-foreground transition-colors hover:bg-muted disabled:opacity-50'
174+
>
175+
{isDownloading ? (
176+
<Loader2 className='h-3 w-3 animate-spin' strokeWidth={2} />
177+
) : (
178+
<Download className='h-3 w-3' strokeWidth={2} />
179+
)}
180+
</button>
181+
)
182+
}

apps/sim/app/chat/components/message/message.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
import { memo, useMemo, useState } from 'react'
44
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
55
import { Tooltip } from '@/components/emcn'
6+
import {
7+
ChatFileDownload,
8+
ChatFileDownloadAll,
9+
} from '@/app/chat/components/message/components/file-download'
610
import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer'
711

812
export interface ChatAttachment {
@@ -13,6 +17,16 @@ export interface ChatAttachment {
1317
size?: number
1418
}
1519

20+
export interface ChatFile {
21+
id: string
22+
name: string
23+
url: string
24+
key: string
25+
size: number
26+
type: string
27+
context?: string
28+
}
29+
1630
export interface ChatMessage {
1731
id: string
1832
content: string | Record<string, unknown>
@@ -21,6 +35,7 @@ export interface ChatMessage {
2135
isInitialMessage?: boolean
2236
isStreaming?: boolean
2337
attachments?: ChatAttachment[]
38+
files?: ChatFile[]
2439
}
2540

2641
function EnhancedMarkdownRenderer({ content }: { content: string }) {
@@ -177,6 +192,13 @@ export const ClientChatMessage = memo(
177192
)}
178193
</div>
179194
</div>
195+
{message.files && message.files.length > 0 && (
196+
<div className='flex flex-wrap gap-2'>
197+
{message.files.map((file) => (
198+
<ChatFileDownload key={file.id} file={file} />
199+
))}
200+
</div>
201+
)}
180202
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
181203
<div className='flex items-center justify-start space-x-2'>
182204
{/* Copy Button - Only show when not streaming */}
@@ -207,6 +229,10 @@ export const ClientChatMessage = memo(
207229
</Tooltip.Content>
208230
</Tooltip.Root>
209231
)}
232+
{/* Download All Button - Only show when there are files */}
233+
{!message.isStreaming && message.files && (
234+
<ChatFileDownloadAll files={message.files} />
235+
)}
210236
</div>
211237
)}
212238
</div>
@@ -221,7 +247,8 @@ export const ClientChatMessage = memo(
221247
prevProps.message.id === nextProps.message.id &&
222248
prevProps.message.content === nextProps.message.content &&
223249
prevProps.message.isStreaming === nextProps.message.isStreaming &&
224-
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage
250+
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
251+
prevProps.message.files?.length === nextProps.message.files?.length
225252
)
226253
}
227254
)

apps/sim/app/chat/hooks/use-chat-streaming.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,52 @@
11
'use client'
22

33
import { useRef, useState } from 'react'
4+
import { isUserFile } from '@/lib/core/utils/display-filters'
45
import { createLogger } from '@/lib/logs/console/logger'
5-
import type { ChatMessage } from '@/app/chat/components/message/message'
6+
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
67
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'
78

89
const logger = createLogger('UseChatStreaming')
910

11+
function extractFilesFromData(
12+
data: any,
13+
files: ChatFile[] = [],
14+
seenIds = new Set<string>()
15+
): ChatFile[] {
16+
if (!data || typeof data !== 'object') {
17+
return files
18+
}
19+
20+
if (isUserFile(data)) {
21+
if (!seenIds.has(data.id)) {
22+
seenIds.add(data.id)
23+
files.push({
24+
id: data.id,
25+
name: data.name,
26+
url: data.url,
27+
key: data.key,
28+
size: data.size,
29+
type: data.type,
30+
context: data.context,
31+
})
32+
}
33+
return files
34+
}
35+
36+
if (Array.isArray(data)) {
37+
for (const item of data) {
38+
extractFilesFromData(item, files, seenIds)
39+
}
40+
return files
41+
}
42+
43+
for (const value of Object.values(data)) {
44+
extractFilesFromData(value, files, seenIds)
45+
}
46+
47+
return files
48+
}
49+
1050
export interface VoiceSettings {
1151
isVoiceEnabled: boolean
1252
voiceId: string
@@ -185,12 +225,21 @@ export function useChatStreaming() {
185225

186226
const outputConfigs = streamingOptions?.outputConfigs
187227
const formattedOutputs: string[] = []
228+
let extractedFiles: ChatFile[] = []
188229

189230
const formatValue = (value: any): string | null => {
190231
if (value === null || value === undefined) {
191232
return null
192233
}
193234

235+
if (isUserFile(value)) {
236+
return null
237+
}
238+
239+
if (Array.isArray(value) && value.length === 0) {
240+
return null
241+
}
242+
194243
if (typeof value === 'string') {
195244
return value
196245
}
@@ -235,6 +284,26 @@ export function useChatStreaming() {
235284
if (!blockOutputs) continue
236285

237286
const value = getOutputValue(blockOutputs, config.path)
287+
288+
if (isUserFile(value)) {
289+
extractedFiles.push({
290+
id: value.id,
291+
name: value.name,
292+
url: value.url,
293+
key: value.key,
294+
size: value.size,
295+
type: value.type,
296+
context: value.context,
297+
})
298+
continue
299+
}
300+
301+
const nestedFiles = extractFilesFromData(value)
302+
if (nestedFiles.length > 0) {
303+
extractedFiles = [...extractedFiles, ...nestedFiles]
304+
continue
305+
}
306+
238307
const formatted = formatValue(value)
239308
if (formatted) {
240309
formattedOutputs.push(formatted)
@@ -267,7 +336,7 @@ export function useChatStreaming() {
267336
}
268337
}
269338

270-
if (!finalContent) {
339+
if (!finalContent && extractedFiles.length === 0) {
271340
if (finalData.error) {
272341
if (typeof finalData.error === 'string') {
273342
finalContent = finalData.error
@@ -291,6 +360,7 @@ export function useChatStreaming() {
291360
...msg,
292361
isStreaming: false,
293362
content: finalContent ?? msg.content,
363+
files: extractedFiles.length > 0 ? extractedFiles : undefined,
294364
}
295365
: msg
296366
)

0 commit comments

Comments
 (0)