From b771462cd5f7b362a8d436bd8724936df063b06c Mon Sep 17 00:00:00 2001 From: aadamgough Date: Tue, 9 Dec 2025 19:45:16 -0800 Subject: [PATCH 1/8] added teams download and chat download file --- .../message/components/file-download.tsx | 146 ++++++++++++++++++ .../app/chat/components/message/message.tsx | 26 +++- apps/sim/app/chat/hooks/use-chat-streaming.ts | 79 +++++++++- apps/sim/blocks/blocks/microsoft_teams.ts | 16 ++ .../sim/tools/microsoft_teams/read_channel.ts | 14 +- apps/sim/tools/microsoft_teams/read_chat.ts | 14 +- apps/sim/tools/microsoft_teams/utils.ts | 109 +++++++++++++ 7 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 apps/sim/app/chat/components/message/components/file-download.tsx diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx new file mode 100644 index 00000000000..b56e857fc67 --- /dev/null +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -0,0 +1,146 @@ +'use client' + +import { useState } from 'react' +import { ArrowDown, Loader2, Music } from 'lucide-react' +import { Button, Tooltip } 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 +} + +/** + * Format file size in human-readable format + */ +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]}` +} + +/** + * Check if file is an audio type + */ +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) + ) +} + +/** + * Check if file is an image type + */ +function isImageFile(mimeType: string): boolean { + return mimeType.startsWith('image/') +} + +/** + * File download component for the deployed chat interface. + * Renders a clickable file card that opens the file in a new tab. + */ +export function ChatFileDownload({ file }: ChatFileDownloadProps) { + const [isDownloading, setIsDownloading] = useState(false) + + const handleDownload = () => { + if (isDownloading) return + + setIsDownloading(true) + + try { + logger.info(`Initiating download for file: ${file.name}`) + + if (file.key.startsWith('url/')) { + if (file.url) { + window.open(file.url, '_blank') + logger.info(`Opened URL-type file directly: ${file.url}`) + return + } + throw new Error('URL is required for URL-type files') + } + + if (file.url) { + window.open(file.url, '_blank') + logger.info(`Opened file via presigned URL: ${file.name}`) + } else { + const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}` + window.open(serveUrl, '_blank') + logger.info(`Opened file via serve endpoint: ${serveUrl}`) + } + } 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 + } + if (isImageFile(file.type)) { + const ImageIcon = DefaultFileIcon + return + } + const DocumentIcon = getDocumentIcon(file.type, file.name) + return + } + + return ( + + + + + + + {file.name} + + + + ) +} diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 3955285e620..f012e679ce7 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -3,6 +3,7 @@ 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 } from '@/app/chat/components/message/components/file-download' import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer' export interface ChatAttachment { @@ -13,6 +14,19 @@ export interface ChatAttachment { size?: number } +/** + * File object returned from workflow execution (e.g., from download file tools) + */ +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 @@ -21,6 +35,7 @@ export interface ChatMessage { isInitialMessage?: boolean isStreaming?: boolean attachments?: ChatAttachment[] + files?: ChatFile[] } function EnhancedMarkdownRenderer({ content }: { content: string }) { @@ -177,6 +192,14 @@ export const ClientChatMessage = memo( )} + {/* File downloads - displayed after content */} + {message.files && message.files.length > 0 && ( +
+ {message.files.map((file) => ( + + ))} +
+ )} {message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
{/* Copy Button - Only show when not streaming */} @@ -221,7 +244,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 ) } ) diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index 52cc2903ca5..3004002884c 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -1,12 +1,58 @@ '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') +/** + * Recursively extracts file objects from any data structure + */ +function extractFilesFromData( + data: any, + files: ChatFile[] = [], + seenIds = new Set() +): ChatFile[] { + if (!data || typeof data !== 'object') { + return files + } + + // Check if this object is a file + 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 + } + + // Recursively check arrays + if (Array.isArray(data)) { + for (const item of data) { + extractFilesFromData(item, files, seenIds) + } + return files + } + + // Recursively check object properties + for (const value of Object.values(data)) { + extractFilesFromData(value, files, seenIds) + } + + return files +} + export interface VoiceSettings { isVoiceEnabled: boolean voiceId: string @@ -185,12 +231,18 @@ 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 } + // Check if this is a file object - don't format as JSON + if (isUserFile(value)) { + return null + } + if (typeof value === 'string') { return value } @@ -235,6 +287,28 @@ export function useChatStreaming() { if (!blockOutputs) continue const value = getOutputValue(blockOutputs, config.path) + + // Check if the output is a file object + 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 + } + + // Also extract files from nested structures + const nestedFiles = extractFilesFromData(value) + if (nestedFiles.length > 0) { + extractedFiles = [...extractedFiles, ...nestedFiles] + continue + } + const formatted = formatValue(value) if (formatted) { formattedOutputs.push(formatted) @@ -267,7 +341,7 @@ export function useChatStreaming() { } } - if (!finalContent) { + if (!finalContent && extractedFiles.length === 0) { if (finalData.error) { if (typeof finalData.error === 'string') { finalContent = finalData.error @@ -291,6 +365,7 @@ export function useChatStreaming() { ...msg, isStreaming: false, content: finalContent ?? msg.content, + files: extractedFiles.length > 0 ? extractedFiles : undefined, } : msg ) diff --git a/apps/sim/blocks/blocks/microsoft_teams.ts b/apps/sim/blocks/blocks/microsoft_teams.ts index e74b3f32f9a..498d2ae6c01 100644 --- a/apps/sim/blocks/blocks/microsoft_teams.ts +++ b/apps/sim/blocks/blocks/microsoft_teams.ts @@ -228,6 +228,12 @@ export const MicrosoftTeamsBlock: BlockConfig = { }, required: true, }, + { + id: 'includeAttachments', + title: 'Include Attachments', + type: 'switch', + condition: { field: 'operation', value: ['read_chat', 'read_channel'] }, + }, // File upload (basic mode) { id: 'attachmentFiles', @@ -320,6 +326,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { files, messageId, reactionType, + includeAttachments, ...rest } = params @@ -332,6 +339,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { credential, } + if ((operation === 'read_chat' || operation === 'read_channel') && includeAttachments) { + baseParams.includeAttachments = true + } + // Add files if provided const fileParam = attachmentFiles || files if (fileParam && (operation === 'write_chat' || operation === 'write_channel')) { @@ -437,6 +448,10 @@ export const MicrosoftTeamsBlock: BlockConfig = { description: 'Message content. Mention users with userName', }, reactionType: { type: 'string', description: 'Emoji reaction (e.g., ❤️, 👍, 😊)' }, + includeAttachments: { + type: 'boolean', + description: 'Download and include message attachments', + }, attachmentFiles: { type: 'json', description: 'Files to attach (UI upload)' }, files: { type: 'array', description: 'Files to attach (UserFile array)' }, }, @@ -447,6 +462,7 @@ export const MicrosoftTeamsBlock: BlockConfig = { messages: { type: 'json', description: 'Array of message objects' }, totalAttachments: { type: 'number', description: 'Total number of attachments' }, attachmentTypes: { type: 'json', description: 'Array of attachment content types' }, + attachments: { type: 'array', description: 'Downloaded message attachments' }, updatedContent: { type: 'boolean', description: 'Whether content was successfully updated/sent', diff --git a/apps/sim/tools/microsoft_teams/read_channel.ts b/apps/sim/tools/microsoft_teams/read_channel.ts index 881706ca4df..1fbca3f5152 100644 --- a/apps/sim/tools/microsoft_teams/read_channel.ts +++ b/apps/sim/tools/microsoft_teams/read_channel.ts @@ -4,6 +4,7 @@ import type { MicrosoftTeamsToolParams, } from '@/tools/microsoft_teams/types' import { + downloadAllReferenceAttachments, extractMessageAttachments, fetchHostedContentsForChannelMessage, } from '@/tools/microsoft_teams/utils' @@ -123,7 +124,7 @@ export const readChannelTool: ToolConfig { + const { accessToken, attachment } = params + + if (attachment.contentType !== 'reference') { + return null + } + + const contentUrl = attachment.contentUrl + if (!contentUrl) { + logger.warn('Reference attachment has no contentUrl', { attachmentId: attachment.id }) + return null + } + + try { + const encodedUrl = Buffer.from(contentUrl) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + const shareId = `u!${encodedUrl}` + + const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem` + const metadataRes = await fetch(metadataUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!metadataRes.ok) { + const errorData = await metadataRes.json().catch(() => ({})) + logger.error('Failed to get driveItem metadata via shares API', { + status: metadataRes.status, + error: errorData, + attachmentName: attachment.name, + }) + return null + } + + const driveItem = await metadataRes.json() + const mimeType = driveItem.file?.mimeType || 'application/octet-stream' + const fileName = attachment.name || driveItem.name || 'attachment' + + const downloadUrl = `https://graph.microsoft.com/v1.0/shares/${shareId}/driveItem/content` + const downloadRes = await fetch(downloadUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + + if (!downloadRes.ok) { + logger.error('Failed to download file content', { + status: downloadRes.status, + fileName, + }) + return null + } + + const arrayBuffer = await downloadRes.arrayBuffer() + const base64Data = Buffer.from(arrayBuffer).toString('base64') + + logger.info('Successfully downloaded reference attachment', { + fileName, + size: arrayBuffer.byteLength, + }) + + return { + name: fileName, + mimeType, + data: base64Data, + } + } catch (error) { + logger.error('Error downloading reference attachment:', { + error, + attachmentId: attachment.id, + attachmentName: attachment.name, + }) + return null + } +} + +export async function downloadAllReferenceAttachments(params: { + accessToken: string + attachments: MicrosoftTeamsAttachment[] +}): Promise { + const { accessToken, attachments } = params + const results: ToolFileData[] = [] + + const referenceAttachments = attachments.filter((att) => att.contentType === 'reference') + + if (referenceAttachments.length === 0) { + return results + } + + logger.info(`Downloading ${referenceAttachments.length} reference attachment(s)`) + + for (const attachment of referenceAttachments) { + const file = await downloadReferenceAttachment({ accessToken, attachment }) + if (file) { + results.push(file) + } + } + + return results +} + function parseMentions(content: string): ParsedMention[] { const mentions: ParsedMention[] = [] const mentionRegex = /([^<]+)<\/at>/gi From 8ad9d319458c171a65c494bf4695d05ed4e109aa Mon Sep 17 00:00:00 2001 From: aadamgough Date: Tue, 9 Dec 2025 19:51:33 -0800 Subject: [PATCH 2/8] Removed comments --- .../components/message/components/file-download.tsx | 13 ------------- apps/sim/app/chat/components/message/message.tsx | 4 ---- apps/sim/app/chat/hooks/use-chat-streaming.ts | 6 ------ apps/sim/tools/microsoft_teams/read_channel.ts | 12 ------------ apps/sim/tools/microsoft_teams/read_chat.ts | 9 --------- 5 files changed, 44 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx index b56e857fc67..fa9aa6b890b 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -13,9 +13,6 @@ interface ChatFileDownloadProps { file: ChatFile } -/** - * Format file size in human-readable format - */ function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 @@ -24,9 +21,6 @@ function formatFileSize(bytes: number): string { return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}` } -/** - * Check if file is an audio type - */ function isAudioFile(mimeType: string, filename: string): boolean { const audioMimeTypes = [ 'audio/mpeg', @@ -46,17 +40,10 @@ function isAudioFile(mimeType: string, filename: string): boolean { ) } -/** - * Check if file is an image type - */ function isImageFile(mimeType: string): boolean { return mimeType.startsWith('image/') } -/** - * File download component for the deployed chat interface. - * Renders a clickable file card that opens the file in a new tab. - */ export function ChatFileDownload({ file }: ChatFileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index f012e679ce7..3b95b464fc5 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -14,9 +14,6 @@ export interface ChatAttachment { size?: number } -/** - * File object returned from workflow execution (e.g., from download file tools) - */ export interface ChatFile { id: string name: string @@ -192,7 +189,6 @@ export const ClientChatMessage = memo( )}
- {/* File downloads - displayed after content */} {message.files && message.files.length > 0 && (
{message.files.map((file) => ( diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index 3004002884c..e53b13189dd 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -8,9 +8,6 @@ import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants' const logger = createLogger('UseChatStreaming') -/** - * Recursively extracts file objects from any data structure - */ function extractFilesFromData( data: any, files: ChatFile[] = [], @@ -20,7 +17,6 @@ function extractFilesFromData( return files } - // Check if this object is a file if (isUserFile(data)) { if (!seenIds.has(data.id)) { seenIds.add(data.id) @@ -37,7 +33,6 @@ function extractFilesFromData( return files } - // Recursively check arrays if (Array.isArray(data)) { for (const item of data) { extractFilesFromData(item, files, seenIds) @@ -45,7 +40,6 @@ function extractFilesFromData( return files } - // Recursively check object properties for (const value of Object.values(data)) { extractFilesFromData(value, files, seenIds) } diff --git a/apps/sim/tools/microsoft_teams/read_channel.ts b/apps/sim/tools/microsoft_teams/read_channel.ts index 1fbca3f5152..f12ad7aabad 100644 --- a/apps/sim/tools/microsoft_teams/read_channel.ts +++ b/apps/sim/tools/microsoft_teams/read_channel.ts @@ -63,18 +63,15 @@ export const readChannelTool: ToolConfig { - // Validate access token if (!params.accessToken) { throw new Error('Access token is required') } @@ -88,7 +85,6 @@ export const readChannelTool: ToolConfig { const data = await response.json() - // Microsoft Graph API returns messages in a 'value' array const messages = data.value || [] if (messages.length === 0) { @@ -108,7 +104,6 @@ export const readChannelTool: ToolConfig { try { @@ -124,7 +119,6 @@ export const readChannelTool: ToolConfig { const sender = message.sender @@ -189,7 +180,6 @@ export const readChannelTool: ToolConfig msg.attachments || []) const attachmentTypes: string[] = [] const seenTypes = new Set() @@ -205,7 +195,6 @@ export const readChannelTool: ToolConfig m.uploadedFiles || []) return { diff --git a/apps/sim/tools/microsoft_teams/read_chat.ts b/apps/sim/tools/microsoft_teams/read_chat.ts index 328a02f1ded..4542b95da4d 100644 --- a/apps/sim/tools/microsoft_teams/read_chat.ts +++ b/apps/sim/tools/microsoft_teams/read_chat.ts @@ -44,17 +44,14 @@ export const readChatTool: ToolConfig { - // Ensure chatId is valid const chatId = params.chatId?.trim() if (!chatId) { throw new Error('Chat ID is required') } - // Fetch the most recent messages from the chat return `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?$top=50&$orderby=createdDateTime desc` }, method: 'GET', headers: (params) => { - // Validate access token if (!params.accessToken) { throw new Error('Access token is required') } @@ -68,7 +65,6 @@ export const readChatTool: ToolConfig { const data = await response.json() - // Microsoft Graph API returns messages in a 'value' array const messages = data.value || [] if (messages.length === 0) { @@ -87,20 +83,16 @@ export const readChatTool: ToolConfig { const content = message.body?.content || 'No content' const messageId = message.id - // Extract attachments without any content processing const attachments = extractMessageAttachments(message) - // Optionally fetch and upload hosted contents and reference attachments let uploaded: any[] = [] if (params?.includeAttachments && params.accessToken && params.chatId && messageId) { try { - // Fetch hosted contents (inline images, etc.) const hostedContents = await fetchHostedContentsForChatMessage({ accessToken: params.accessToken, chatId: params.chatId, @@ -108,7 +100,6 @@ export const readChatTool: ToolConfig Date: Tue, 9 Dec 2025 19:58:14 -0800 Subject: [PATCH 3/8] removed comments --- apps/sim/app/chat/hooks/use-chat-streaming.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index e53b13189dd..5c24a885907 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -232,7 +232,6 @@ export function useChatStreaming() { return null } - // Check if this is a file object - don't format as JSON if (isUserFile(value)) { return null } @@ -282,7 +281,6 @@ export function useChatStreaming() { const value = getOutputValue(blockOutputs, config.path) - // Check if the output is a file object if (isUserFile(value)) { extractedFiles.push({ id: value.id, @@ -296,7 +294,6 @@ export function useChatStreaming() { continue } - // Also extract files from nested structures const nestedFiles = extractFilesFromData(value) if (nestedFiles.length > 0) { extractedFiles = [...extractedFiles, ...nestedFiles] From 674a88c65a15ee3df7c2454cb667c5a2fa710bb6 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Tue, 9 Dec 2025 21:36:25 -0800 Subject: [PATCH 4/8] component structure and download all --- .../message/components/file-download.tsx | 177 ++++++++++++------ .../app/chat/components/message/message.tsx | 9 +- 2 files changed, 130 insertions(+), 56 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx index fa9aa6b890b..9db19b683aa 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -1,8 +1,8 @@ 'use client' import { useState } from 'react' -import { ArrowDown, Loader2, Music } from 'lucide-react' -import { Button, Tooltip } from '@/components/emcn' +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' @@ -13,6 +13,10 @@ interface ChatFileDownloadProps { file: ChatFile } +interface ChatFileDownloadAllProps { + files: ChatFile[] +} + function formatFileSize(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 @@ -44,36 +48,59 @@ function isImageFile(mimeType: string): boolean { return mimeType.startsWith('image/') } +/** + * Gets the download URL for a file + */ +function getFileUrl(file: ChatFile): string { + if (file.key.startsWith('url/') && file.url) { + return file.url + } + if (file.url) { + return file.url + } + return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}` +} + +async function triggerDownload(url: string, filename: string): Promise { + 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) + + // Clean up the blob URL to free memory + URL.revokeObjectURL(blobUrl) + logger.info(`Downloaded: ${filename}`) +} + +/** + * Single file download card component + */ export function ChatFileDownload({ file }: ChatFileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) + const [isHovered, setIsHovered] = useState(false) - const handleDownload = () => { + const handleDownload = async () => { if (isDownloading) return setIsDownloading(true) try { logger.info(`Initiating download for file: ${file.name}`) - - if (file.key.startsWith('url/')) { - if (file.url) { - window.open(file.url, '_blank') - logger.info(`Opened URL-type file directly: ${file.url}`) - return - } - throw new Error('URL is required for URL-type files') - } - - if (file.url) { - window.open(file.url, '_blank') - logger.info(`Opened file via presigned URL: ${file.name}`) - } else { - const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}` - window.open(serveUrl, '_blank') - logger.info(`Opened file via serve endpoint: ${serveUrl}`) - } + const url = getFileUrl(file) + await triggerDownload(url, file.name) } catch (error) { logger.error(`Failed to download file ${file.name}:`, error) + // Fallback: open in new tab if blob download fails if (file.url) { window.open(file.url, '_blank') } @@ -95,39 +122,79 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { } return ( - - - - - - - {file.name} - - - + + ) +} + +/** + * Download all files button - triggers download for each file + */ +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}`) + + // Small delay between downloads to avoid overwhelming the browser + 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 ( + ) } diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 3b95b464fc5..7a8f4546d4e 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -3,7 +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 } from '@/app/chat/components/message/components/file-download' +import { + ChatFileDownload, + ChatFileDownloadAll, +} from '@/app/chat/components/message/components/file-download' import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer' export interface ChatAttachment { @@ -226,6 +229,10 @@ export const ClientChatMessage = memo( )} + {/* Download All Button - Only show when there are files */} + {!message.isStreaming && message.files && ( + + )}
)} From 2406a1402701f3c158f0cccdf03d977e0ae9f019 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Tue, 9 Dec 2025 21:41:14 -0800 Subject: [PATCH 5/8] removed comments --- .../components/message/components/file-download.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx index 9db19b683aa..3f894bf64ac 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -48,9 +48,6 @@ function isImageFile(mimeType: string): boolean { return mimeType.startsWith('image/') } -/** - * Gets the download URL for a file - */ function getFileUrl(file: ChatFile): string { if (file.key.startsWith('url/') && file.url) { return file.url @@ -77,14 +74,10 @@ async function triggerDownload(url: string, filename: string): Promise { link.click() document.body.removeChild(link) - // Clean up the blob URL to free memory URL.revokeObjectURL(blobUrl) logger.info(`Downloaded: ${filename}`) } -/** - * Single file download card component - */ export function ChatFileDownload({ file }: ChatFileDownloadProps) { const [isDownloading, setIsDownloading] = useState(false) const [isHovered, setIsHovered] = useState(false) @@ -100,7 +93,6 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { await triggerDownload(url, file.name) } catch (error) { logger.error(`Failed to download file ${file.name}:`, error) - // Fallback: open in new tab if blob download fails if (file.url) { window.open(file.url, '_blank') } @@ -148,9 +140,6 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) { ) } -/** - * Download all files button - triggers download for each file - */ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) { const [isDownloading, setIsDownloading] = useState(false) @@ -171,7 +160,6 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) { await triggerDownload(url, file.name) logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`) - // Small delay between downloads to avoid overwhelming the browser if (i < files.length - 1) { await new Promise((resolve) => setTimeout(resolve, 150)) } From 91d31ba8e09df38276a112a351ce41f8578b5515 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 9 Dec 2025 21:47:35 -0800 Subject: [PATCH 6/8] cleanup code --- .../app/chat/components/message/components/file-download.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx index 3f894bf64ac..6d047ca10aa 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -49,9 +49,6 @@ function isImageFile(mimeType: string): boolean { } function getFileUrl(file: ChatFile): string { - if (file.key.startsWith('url/') && file.url) { - return file.url - } if (file.url) { return file.url } From 3d4ca163bb636491b9cb11e27d6c5b72e8d3c016 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 9 Dec 2025 22:00:01 -0800 Subject: [PATCH 7/8] fix empty files case --- apps/sim/app/chat/hooks/use-chat-streaming.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index 5c24a885907..95113634d6b 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -236,6 +236,10 @@ export function useChatStreaming() { return null } + if (Array.isArray(value) && value.length === 0) { + return null + } + if (typeof value === 'string') { return value } From 6c05af40114ff8a56a200e23917a4bbab59a9f02 Mon Sep 17 00:00:00 2001 From: aadamgough Date: Tue, 9 Dec 2025 22:01:41 -0800 Subject: [PATCH 8/8] small fix --- .../app/chat/components/message/components/file-download.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx index 6d047ca10aa..7be5237b165 100644 --- a/apps/sim/app/chat/components/message/components/file-download.tsx +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -49,9 +49,6 @@ function isImageFile(mimeType: string): boolean { } function getFileUrl(file: ChatFile): string { - if (file.url) { - return file.url - } return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}` }