1
- import { cn } from '../lib/utils'
2
- import type { Message } from '../mcp/client'
3
- import { formatTimestamp } from '../lib/utils'
4
- import { Bot , Copy } from 'lucide-react'
5
- import { MarkdownContent } from './MarkdownContent'
6
-
1
+ import { Bot , Copy , Download } from 'lucide-react'
7
2
import { toast } from 'sonner'
8
- import { copyToClipboard } from '../lib/utils/clipboard'
3
+ import { useState } from 'react'
4
+ import { cn } from '@/lib/utils'
5
+ import type { Message } from '@/mcp/client'
6
+ import { formatTimestamp } from '@/lib/utils'
7
+ import { MarkdownContent } from '@/components/MarkdownContent'
8
+ import { copyToClipboard } from '@/lib/utils/clipboard'
9
+ import { MessageAvatar } from './MessageAvatar'
10
+ import {
11
+ isImageFile ,
12
+ createAnnotatedFileUrl ,
13
+ } from '@/lib/utils/code-interpreter'
9
14
10
15
export interface BotMessageProps {
11
16
message : Message
12
- isLoading ?: boolean
17
+ fileAnnotations ?: Array < {
18
+ type : string
19
+ container_id : string
20
+ file_id : string
21
+ filename : string
22
+ } >
13
23
}
14
24
15
- export function BotMessage ( { message, isLoading } : BotMessageProps ) {
25
+ export function BotMessage ( { message, fileAnnotations = [ ] } : BotMessageProps ) {
26
+ const [ imageErrors , setImageErrors ] = useState < Set < string > > ( new Set ( ) )
27
+
16
28
const handleCopy = async ( ) => {
17
- const copied = await copyToClipboard ( message . content )
29
+ const copied = await copyToClipboard ( processedContent )
18
30
19
31
if ( copied ) {
20
32
toast . success ( 'Copied message to clipboard' )
21
33
}
22
34
}
23
35
36
+ const handleImageError = ( fileId : string ) => {
37
+ setImageErrors ( ( prev ) => new Set ( prev ) . add ( fileId ) )
38
+ }
39
+
40
+ // Use message content directly; sandbox URL replacement is not needed
41
+ const processedContent = message . content
42
+
43
+ // Separate image and non-image files
44
+ const imageFiles = fileAnnotations . filter ( ( annotation ) =>
45
+ isImageFile ( annotation . filename ) ,
46
+ )
47
+ const otherFiles = fileAnnotations . filter (
48
+ ( annotation ) => ! isImageFile ( annotation . filename ) ,
49
+ )
50
+
24
51
return (
25
52
< div
26
53
className = { cn (
27
54
'flex w-full max-w-full gap-2 py-2 animate-in fade-in' ,
28
55
'justify-start' ,
29
56
) }
30
57
>
31
- < div
32
- className = { cn (
33
- 'flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100' ,
34
- isLoading && 'animate-[pulse_1.5s_ease-in-out_infinite] opacity-80' ,
35
- ) }
36
- >
37
- < Bot className = "h-5 w-5" />
38
- </ div >
58
+ < MessageAvatar icon = { < Bot className = "h-5 w-5" /> } variant = "default" />
39
59
< div
40
60
className = { cn (
41
61
'group grid gap-1 space-y-1 items-start' ,
@@ -50,6 +70,90 @@ export function BotMessage({ message, isLoading }: BotMessageProps) {
50
70
< div data-raw-markdown = { message . content } >
51
71
< MarkdownContent content = { message . content } />
52
72
</ div >
73
+
74
+ { /* Image Gallery */ }
75
+ { imageFiles . length > 0 && (
76
+ < div className = "mt-4 space-y-3" >
77
+ { imageFiles . map ( ( annotation ) => {
78
+ const hasError = imageErrors . has ( annotation . file_id )
79
+ const imageUrl = createAnnotatedFileUrl ( annotation )
80
+
81
+ // Generate accessible alt text based on filename
82
+ const getAltText = ( filename : string ) => {
83
+ const name = filename . replace ( / \. [ ^ / . ] + $ / , '' ) // Remove extension
84
+ return `Generated visualization: ${ name . replace ( / _ / g, ' ' ) } `
85
+ }
86
+
87
+ return (
88
+ < div key = { annotation . file_id } className = "relative group" >
89
+ { hasError ? (
90
+ < div className = "flex items-center justify-center p-4 bg-gray-50 dark:bg-gray-900 rounded border border-dashed border-gray-300 dark:border-gray-600" >
91
+ < div className = "text-center" >
92
+ < p className = "text-sm text-gray-500 dark:text-gray-400" >
93
+ Failed to load image
94
+ </ p >
95
+ < a
96
+ href = { createAnnotatedFileUrl ( annotation ) }
97
+ download = { annotation . filename }
98
+ className = "mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
99
+ >
100
+ Download file instead
101
+ </ a >
102
+ </ div >
103
+ </ div >
104
+ ) : (
105
+ < >
106
+ < img
107
+ src = { imageUrl }
108
+ alt = { getAltText ( annotation . filename ) }
109
+ className = "max-w-full h-auto rounded border border-gray-200 dark:border-gray-700 cursor-pointer"
110
+ onError = { ( ) => handleImageError ( annotation . file_id ) }
111
+ onClick = { ( ) => window . open ( imageUrl , '_blank' ) }
112
+ />
113
+ < a
114
+ href = { createAnnotatedFileUrl ( annotation ) }
115
+ download = { annotation . filename }
116
+ className = "absolute top-2 right-2 p-2 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-full shadow-sm opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white dark:hover:bg-gray-800"
117
+ title = "Download image"
118
+ >
119
+ < Download className = "h-4 w-4 text-gray-600 dark:text-gray-300" />
120
+ </ a >
121
+ </ >
122
+ ) }
123
+ </ div >
124
+ )
125
+ } ) }
126
+ </ div >
127
+ ) }
128
+
129
+ { /* Other Files */ }
130
+ { otherFiles . length > 0 && (
131
+ < div className = "mt-4 space-y-2" >
132
+ < p className = "text-xs text-gray-500 dark:text-gray-400 font-medium" >
133
+ Attachments:
134
+ </ p >
135
+ < div className = "space-y-1" >
136
+ { otherFiles . map ( ( annotation ) => (
137
+ < div
138
+ key = { annotation . file_id }
139
+ className = "flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700"
140
+ >
141
+ < span className = "text-sm text-gray-700 dark:text-gray-300 truncate" >
142
+ { annotation . filename }
143
+ </ span >
144
+ < a
145
+ href = { createAnnotatedFileUrl ( annotation ) }
146
+ download = { annotation . filename }
147
+ className = "flex items-center gap-1 px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
148
+ >
149
+ < Download className = "h-3 w-3" />
150
+ Download
151
+ </ a >
152
+ </ div >
153
+ ) ) }
154
+ </ div >
155
+ </ div >
156
+ ) }
53
157
</ div >
54
158
< div className = "flex gap-1 items-center text-xs text-gray-500 dark:text-gray-400 space-x-1" >
55
159
< time dateTime = { message . timestamp . toISOString ( ) } >
0 commit comments