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
This commit is contained in:
Adam Gough
2025-12-09 22:08:03 -08:00
committed by GitHub
parent d06b360b1d
commit cb6e763714
7 changed files with 425 additions and 22 deletions

View File

@@ -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>
)
}

View File

@@ -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<string, unknown>
@@ -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(
)}
</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 */}
@@ -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>
@@ -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
)
}
)

View File

@@ -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
@@ -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
)

View File

@@ -228,6 +228,12 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
},
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<MicrosoftTeamsResponse> = {
files,
messageId,
reactionType,
includeAttachments,
...rest
} = params
@@ -332,6 +339,10 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
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<MicrosoftTeamsResponse> = {
description: 'Message content. Mention users with <at>userName</at>',
},
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<MicrosoftTeamsResponse> = {
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',

View File

@@ -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<MicrosoftTeamsToolParams, MicrosoftTeam
throw new Error('Channel ID is required')
}
// URL encode the IDs to handle special characters
const encodedTeamId = encodeURIComponent(teamId)
const encodedChannelId = encodeURIComponent(channelId)
// Fetch the most recent messages from the channel
const url = `https://graph.microsoft.com/v1.0/teams/${encodedTeamId}/channels/${encodedChannelId}/messages`
return url
},
method: 'GET',
headers: (params) => {
// Validate access token
if (!params.accessToken) {
throw new Error('Access token is required')
}
@@ -87,7 +85,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
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<MicrosoftTeamsToolParams, MicrosoftTeam
}
}
// Process messages with attachments
const processedMessages = await Promise.all(
messages.map(async (message: any, index: number) => {
try {
@@ -123,7 +119,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
sender = 'System'
}
// Optionally fetch and upload hosted contents
let uploaded: any[] = []
if (
params?.includeAttachments &&
@@ -133,12 +128,19 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
messageId
) {
try {
uploaded = await fetchHostedContentsForChannelMessage({
const hostedContents = await fetchHostedContentsForChannelMessage({
accessToken: params.accessToken,
teamId: params.teamId,
channelId: params.channelId,
messageId,
})
uploaded.push(...hostedContents)
const referenceFiles = await downloadAllReferenceAttachments({
accessToken: params.accessToken,
attachments,
})
uploaded.push(...referenceFiles)
} catch (_e) {
uploaded = []
}
@@ -167,7 +169,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
})
)
// Format the messages into a readable text (no attachment info in content)
const formattedMessages = processedMessages
.map((message: any) => {
const sender = message.sender
@@ -179,7 +180,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
})
.join('\n\n')
// Calculate attachment statistics
const allAttachments = processedMessages.flatMap((msg: any) => msg.attachments || [])
const attachmentTypes: string[] = []
const seenTypes = new Set<string>()
@@ -195,7 +195,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
}
})
// Create document metadata
const metadata = {
teamId: messages[0]?.channelIdentity?.teamId || params?.teamId || '',
channelId: messages[0]?.channelIdentity?.channelId || params?.channelId || '',
@@ -205,7 +204,6 @@ export const readChannelTool: ToolConfig<MicrosoftTeamsToolParams, MicrosoftTeam
messages: processedMessages,
}
// Flatten uploaded files across all messages for convenience
const flattenedUploads = processedMessages.flatMap((m: any) => m.uploadedFiles || [])
return {

View File

@@ -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<MicrosoftTeamsToolParams, MicrosoftTeamsRe
request: {
url: (params) => {
// 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<MicrosoftTeamsToolParams, MicrosoftTeamsRe
transformResponse: async (response: Response, params?: MicrosoftTeamsToolParams) => {
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<MicrosoftTeamsToolParams, MicrosoftTeamsRe
}
}
// Process messages with attachments
const processedMessages = await Promise.all(
messages.map(async (message: any) => {
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 = []
}

View File

@@ -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<ToolFileData | null> {
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<ToolFileData[]> {
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>([^<]+)<\/at>/gi