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 0000000000..7be5237b16 --- /dev/null +++ b/apps/sim/app/chat/components/message/components/file-download.tsx @@ -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 { + 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 + } + if (isImageFile(file.type)) { + const ImageIcon = DefaultFileIcon + return + } + const DocumentIcon = getDocumentIcon(file.type, file.name) + return + } + + return ( + + ) +} + +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 ( + + ) +} diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx index 3955285e62..7a8f4546d4 100644 --- a/apps/sim/app/chat/components/message/message.tsx +++ b/apps/sim/app/chat/components/message/message.tsx @@ -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 { @@ -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 @@ -21,6 +35,7 @@ export interface ChatMessage { isInitialMessage?: boolean isStreaming?: boolean attachments?: ChatAttachment[] + files?: ChatFile[] } function EnhancedMarkdownRenderer({ content }: { content: string }) { @@ -177,6 +192,13 @@ export const ClientChatMessage = memo( )} + {message.files && message.files.length > 0 && ( +
+ {message.files.map((file) => ( + + ))} +
+ )} {message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
{/* Copy Button - Only show when not streaming */} @@ -207,6 +229,10 @@ export const ClientChatMessage = memo( )} + {/* Download All Button - Only show when there are files */} + {!message.isStreaming && message.files && ( + + )}
)} @@ -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 ) } ) diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index 52cc2903ca..95113634d6 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -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() +): 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 @@ -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 } @@ -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) @@ -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 @@ -291,6 +360,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 e74b3f32f9..498d2ae6c0 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 881706ca4d..f12ad7aaba 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' @@ -62,18 +63,15 @@ export const readChannelTool: ToolConfig { - // Validate access token if (!params.accessToken) { throw new Error('Access token is required') } @@ -87,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) { @@ -107,7 +104,6 @@ export const readChannelTool: ToolConfig { try { @@ -123,7 +119,6 @@ export const readChannelTool: ToolConfig { const sender = message.sender @@ -179,7 +180,6 @@ export const readChannelTool: ToolConfig msg.attachments || []) const attachmentTypes: string[] = [] const seenTypes = new Set() @@ -195,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 5e3b7fe398..4542b95da4 100644 --- a/apps/sim/tools/microsoft_teams/read_chat.ts +++ b/apps/sim/tools/microsoft_teams/read_chat.ts @@ -3,6 +3,7 @@ import type { MicrosoftTeamsToolParams, } from '@/tools/microsoft_teams/types' import { + downloadAllReferenceAttachments, extractMessageAttachments, fetchHostedContentsForChatMessage, } from '@/tools/microsoft_teams/utils' @@ -43,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') } @@ -67,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) { @@ -86,24 +83,28 @@ 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 let uploaded: any[] = [] if (params?.includeAttachments && params.accessToken && params.chatId && messageId) { try { - uploaded = await fetchHostedContentsForChatMessage({ + const hostedContents = await fetchHostedContentsForChatMessage({ accessToken: params.accessToken, chatId: params.chatId, messageId, }) + uploaded.push(...hostedContents) + + const referenceFiles = await downloadAllReferenceAttachments({ + accessToken: params.accessToken, + attachments, + }) + uploaded.push(...referenceFiles) } catch (_e) { uploaded = [] } diff --git a/apps/sim/tools/microsoft_teams/utils.ts b/apps/sim/tools/microsoft_teams/utils.ts index f7b5ebc1f9..de1db0e91a 100644 --- a/apps/sim/tools/microsoft_teams/utils.ts +++ b/apps/sim/tools/microsoft_teams/utils.ts @@ -132,6 +132,115 @@ export async function fetchHostedContentsForChannelMessage(params: { } } +/** + * Download a reference-type attachment (SharePoint/OneDrive file) from Teams. + * These are files shared in Teams that are stored in SharePoint/OneDrive. + * + */ +export async function downloadReferenceAttachment(params: { + accessToken: string + attachment: MicrosoftTeamsAttachment +}): Promise { + 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