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