Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions apps/sim/app/chat/components/message/components/file-download.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
'use client'

import { useState } from 'react'
import { ArrowDown, Download, Loader2, Music } from 'lucide-react'
import { Button } from '@/components/emcn'
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChatFile } from '@/app/chat/components/message/message'

const logger = createLogger('ChatFileDownload')

interface ChatFileDownloadProps {
file: ChatFile
}

interface ChatFileDownloadAllProps {
files: ChatFile[]
}

function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}

function isAudioFile(mimeType: string, filename: string): boolean {
const audioMimeTypes = [
'audio/mpeg',
'audio/wav',
'audio/mp3',
'audio/ogg',
'audio/webm',
'audio/aac',
'audio/flac',
]
const audioExtensions = ['mp3', 'wav', 'ogg', 'webm', 'aac', 'flac', 'm4a']
const extension = filename.split('.').pop()?.toLowerCase()

return (
audioMimeTypes.some((t) => mimeType.includes(t)) ||
(extension ? audioExtensions.includes(extension) : false)
)
}

function isImageFile(mimeType: string): boolean {
return mimeType.startsWith('image/')
}

function getFileUrl(file: ChatFile): string {
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
}

async function triggerDownload(url: string, filename: string): Promise<void> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
}

const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)

const link = document.createElement('a')
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)

URL.revokeObjectURL(blobUrl)
logger.info(`Downloaded: ${filename}`)
}

export function ChatFileDownload({ file }: ChatFileDownloadProps) {
const [isDownloading, setIsDownloading] = useState(false)
const [isHovered, setIsHovered] = useState(false)

const handleDownload = async () => {
if (isDownloading) return

setIsDownloading(true)

try {
logger.info(`Initiating download for file: ${file.name}`)
const url = getFileUrl(file)
await triggerDownload(url, file.name)
} catch (error) {
logger.error(`Failed to download file ${file.name}:`, error)
if (file.url) {
window.open(file.url, '_blank')
}
} finally {
setIsDownloading(false)
}
}

const renderIcon = () => {
if (isAudioFile(file.type, file.name)) {
return <Music className='h-4 w-4 text-purple-500' />
}
if (isImageFile(file.type)) {
const ImageIcon = DefaultFileIcon
return <ImageIcon className='h-5 w-5' />
}
const DocumentIcon = getDocumentIcon(file.type, file.name)
return <DocumentIcon className='h-5 w-5' />
}

return (
<Button
variant='default'
onClick={handleDownload}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
disabled={isDownloading}
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
<div className='min-w-0 flex-1 text-left'>
<div className='w-[100px] truncate text-xs'>{file.name}</div>
<div className='text-[10px] text-[var(--text-muted)]'>{formatFileSize(file.size)}</div>
</div>
<div className='flex-shrink-0'>
{isDownloading ? (
<Loader2 className='h-3.5 w-3.5 animate-spin' />
) : (
<ArrowDown
className={`h-3.5 w-3.5 transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
/>
)}
</div>
</Button>
)
}

export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
const [isDownloading, setIsDownloading] = useState(false)

if (!files || files.length === 0) return null

const handleDownloadAll = async () => {
if (isDownloading) return

setIsDownloading(true)

try {
logger.info(`Initiating download for ${files.length} files`)

for (let i = 0; i < files.length; i++) {
const file = files[i]
try {
const url = getFileUrl(file)
await triggerDownload(url, file.name)
logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`)

if (i < files.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 150))
}
} catch (error) {
logger.error(`Failed to download file ${file.name}:`, error)
}
}
} finally {
setIsDownloading(false)
}
}

return (
<button
onClick={handleDownloadAll}
disabled={isDownloading}
className='text-muted-foreground transition-colors hover:bg-muted disabled:opacity-50'
>
{isDownloading ? (
<Loader2 className='h-3 w-3 animate-spin' strokeWidth={2} />
) : (
<Download className='h-3 w-3' strokeWidth={2} />
)}
</button>
)
}
29 changes: 28 additions & 1 deletion apps/sim/app/chat/components/message/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { memo, useMemo, useState } from 'react'
import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import {
ChatFileDownload,
ChatFileDownloadAll,
} from '@/app/chat/components/message/components/file-download'
import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer'

export interface ChatAttachment {
Expand All @@ -13,6 +17,16 @@ export interface ChatAttachment {
size?: number
}

export interface ChatFile {
id: string
name: string
url: string
key: string
size: number
type: string
context?: string
}

export interface ChatMessage {
id: string
content: string | Record<string, unknown>
Expand All @@ -21,6 +35,7 @@ export interface ChatMessage {
isInitialMessage?: boolean
isStreaming?: boolean
attachments?: ChatAttachment[]
files?: ChatFile[]
}

function EnhancedMarkdownRenderer({ content }: { content: string }) {
Expand Down Expand Up @@ -177,6 +192,13 @@ export const ClientChatMessage = memo(
)}
</div>
</div>
{message.files && message.files.length > 0 && (
<div className='flex flex-wrap gap-2'>
{message.files.map((file) => (
<ChatFileDownload key={file.id} file={file} />
))}
</div>
)}
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
<div className='flex items-center justify-start space-x-2'>
{/* Copy Button - Only show when not streaming */}
Expand Down Expand Up @@ -207,6 +229,10 @@ export const ClientChatMessage = memo(
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Download All Button - Only show when there are files */}
{!message.isStreaming && message.files && (
<ChatFileDownloadAll files={message.files} />
)}
</div>
)}
</div>
Expand All @@ -221,7 +247,8 @@ export const ClientChatMessage = memo(
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.isStreaming === nextProps.message.isStreaming &&
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
prevProps.message.files?.length === nextProps.message.files?.length
)
}
)
74 changes: 72 additions & 2 deletions apps/sim/app/chat/hooks/use-chat-streaming.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,52 @@
'use client'

import { useRef, useState } from 'react'
import { isUserFile } from '@/lib/core/utils/display-filters'
import { createLogger } from '@/lib/logs/console/logger'
import type { ChatMessage } from '@/app/chat/components/message/message'
import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message'
import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants'

const logger = createLogger('UseChatStreaming')

function extractFilesFromData(
data: any,
files: ChatFile[] = [],
seenIds = new Set<string>()
): ChatFile[] {
if (!data || typeof data !== 'object') {
return files
}

if (isUserFile(data)) {
if (!seenIds.has(data.id)) {
seenIds.add(data.id)
files.push({
id: data.id,
name: data.name,
url: data.url,
key: data.key,
size: data.size,
type: data.type,
context: data.context,
})
}
return files
}

if (Array.isArray(data)) {
for (const item of data) {
extractFilesFromData(item, files, seenIds)
}
return files
}

for (const value of Object.values(data)) {
extractFilesFromData(value, files, seenIds)
}

return files
}

export interface VoiceSettings {
isVoiceEnabled: boolean
voiceId: string
Expand Down Expand Up @@ -185,12 +225,21 @@ export function useChatStreaming() {

const outputConfigs = streamingOptions?.outputConfigs
const formattedOutputs: string[] = []
let extractedFiles: ChatFile[] = []

const formatValue = (value: any): string | null => {
if (value === null || value === undefined) {
return null
}

if (isUserFile(value)) {
return null
}

if (Array.isArray(value) && value.length === 0) {
return null
}

if (typeof value === 'string') {
return value
}
Expand Down Expand Up @@ -235,6 +284,26 @@ export function useChatStreaming() {
if (!blockOutputs) continue

const value = getOutputValue(blockOutputs, config.path)

if (isUserFile(value)) {
extractedFiles.push({
id: value.id,
name: value.name,
url: value.url,
key: value.key,
size: value.size,
type: value.type,
context: value.context,
})
continue
}

const nestedFiles = extractFilesFromData(value)
if (nestedFiles.length > 0) {
extractedFiles = [...extractedFiles, ...nestedFiles]
continue
}

const formatted = formatValue(value)
if (formatted) {
formattedOutputs.push(formatted)
Expand Down Expand Up @@ -267,7 +336,7 @@ export function useChatStreaming() {
}
}

if (!finalContent) {
if (!finalContent && extractedFiles.length === 0) {
if (finalData.error) {
if (typeof finalData.error === 'string') {
finalContent = finalData.error
Expand All @@ -291,6 +360,7 @@ export function useChatStreaming() {
...msg,
isStreaming: false,
content: finalContent ?? msg.content,
files: extractedFiles.length > 0 ? extractedFiles : undefined,
}
: msg
)
Expand Down
Loading