Files
simstudioai--sim/apps/sim/app/(interfaces)/chat/components/message/components/file-download.tsx
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

186 lines
5.2 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Button, cn, Download, Loader } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import { Music } from 'lucide-react'
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
import type { ChatFile } from '@/app/(interfaces)/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 && isSafeHttpUrl(file.url)) {
window.open(file.url, '_blank', 'noopener,noreferrer')
}
} finally {
setIsDownloading(false)
}
}
const renderIcon = () => {
if (isAudioFile(file.type, file.name)) {
return <Music className='size-4 text-purple-500' />
}
if (isImageFile(file.type)) {
const ImageIcon = DefaultFileIcon
return <ImageIcon className='size-5' />
}
const DocumentIcon = getDocumentIcon(file.type, file.name)
return <DocumentIcon className='size-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 size-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-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
</div>
<div className='flex-shrink-0'>
{isDownloading ? (
<Loader className='size-3.5' animate />
) : (
<Download
className={cn('size-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 sleep(150)
}
} catch (error) {
logger.error(`Failed to download file ${file.name}:`, error)
}
}
} finally {
setIsDownloading(false)
}
}
return (
<Button
variant='ghost-secondary'
onClick={handleDownloadAll}
disabled={isDownloading}
className='p-0'
>
{isDownloading ? (
<Loader className='size-3' animate />
) : (
<Download className='size-3' strokeWidth={2} />
)}
</Button>
)
}