@@ -11,9 +11,26 @@ import {
1111 DEFAULT_AGENT_ID ,
1212 randomUUID ,
1313 TranscriptionErrorCode ,
14+ getModalityFromMimeType ,
15+ exceedsMaxSize ,
16+ readFileAsBase64 ,
17+ generateVideoThumbnail ,
18+ matchesAcceptFilter ,
19+ formatFileSize ,
20+ } from "@copilotkit/shared" ;
21+ import type {
22+ Attachment ,
23+ AttachmentsConfig ,
24+ InputContent ,
1425} from "@copilotkit/shared" ;
1526import { Suggestion , CopilotKitCoreErrorCode } from "@copilotkit/core" ;
16- import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
27+ import React , {
28+ useCallback ,
29+ useEffect ,
30+ useMemo ,
31+ useRef ,
32+ useState ,
33+ } from "react" ;
1734import { merge } from "ts-deepmerge" ;
1835import {
1936 useCopilotKit ,
@@ -34,12 +51,22 @@ export type CopilotChatProps = Omit<
3451 | "suggestions"
3552 | "suggestionLoadingIndexes"
3653 | "onSelectSuggestion"
54+ // Attachment state props — managed internally based on `attachments` config
55+ | "attachments"
56+ | "onRemoveAttachment"
57+ | "onAddFile"
58+ | "dragOver"
59+ | "onDragOver"
60+ | "onDragLeave"
61+ | "onDrop"
3762> & {
3863 agentId ?: string ;
3964 threadId ?: string ;
4065 labels ?: Partial < CopilotChatLabels > ;
4166 chatView ?: SlotValue < typeof CopilotChatView > ;
4267 isModalDefaultOpen ?: boolean ;
68+ /** Enable multimodal file attachments (images, audio, video, documents). */
69+ attachments ?: AttachmentsConfig ;
4370 /**
4471 * Error handler scoped to this chat's agent. Fires in addition to the
4572 * provider-level onError (does not suppress it). Receives only errors
@@ -57,6 +84,7 @@ export function CopilotChat({
5784 labels,
5885 chatView,
5986 isModalDefaultOpen,
87+ attachments : attachmentsConfig ,
6088 onError,
6189 ...props
6290} : CopilotChatProps ) {
@@ -130,6 +158,20 @@ export function CopilotChat({
130158 ) ;
131159 const [ isTranscribing , setIsTranscribing ] = useState ( false ) ;
132160
161+ // Attachment state
162+ const attachmentsEnabled = attachmentsConfig ?. enabled ?? false ;
163+ const attachmentsAccept = attachmentsConfig ?. accept ?? "*/*" ;
164+ const attachmentsMaxSize = attachmentsConfig ?. maxSize ?? 20 * 1024 * 1024 ;
165+
166+ const [ selectedAttachments , setSelectedAttachments ] = useState < Attachment [ ] > (
167+ [ ] ,
168+ ) ;
169+ const [ dragOver , setDragOver ] = useState ( false ) ;
170+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
171+ const processFilesRef = useRef < ( files : File [ ] ) => Promise < void > > (
172+ async ( ) => { } ,
173+ ) ;
174+
133175 // Check if transcription is enabled
134176 const isTranscriptionEnabled = copilotkit . audioFileTranscriptionEnabled ;
135177
@@ -184,13 +226,202 @@ export function CopilotChat({
184226 // eslint-disable-next-line react-hooks/exhaustive-deps
185227 } , [ resolvedThreadId , agent , resolvedAgentId ] ) ;
186228
229+ // --- Attachment logic ---
230+
231+ const processFiles = async ( files : File [ ] ) => {
232+ const validFiles = files . filter ( ( file ) =>
233+ matchesAcceptFilter ( file , attachmentsAccept ) ,
234+ ) ;
235+ const rejectedCount = files . length - validFiles . length ;
236+ if ( rejectedCount > 0 ) {
237+ console . error (
238+ `[CopilotKit] ${ rejectedCount } file(s) not accepted. Supported types: ${ attachmentsAccept } ` ,
239+ ) ;
240+ }
241+
242+ for ( const file of validFiles ) {
243+ if ( exceedsMaxSize ( file , attachmentsMaxSize ) ) {
244+ console . error (
245+ `[CopilotKit] File "${ file . name } " exceeds the maximum size of ${ formatFileSize ( attachmentsMaxSize ) } ` ,
246+ ) ;
247+ continue ;
248+ }
249+
250+ const modality = getModalityFromMimeType ( file . type ) ;
251+ const placeholderId = randomUUID ( ) ;
252+ const placeholder : Attachment = {
253+ id : placeholderId ,
254+ type : modality ,
255+ source : { type : "data" , value : "" , mimeType : file . type } ,
256+ filename : file . name ,
257+ size : file . size ,
258+ status : "uploading" ,
259+ } ;
260+
261+ setSelectedAttachments ( ( prev ) => [ ...prev , placeholder ] ) ;
262+
263+ try {
264+ let source : Attachment [ "source" ] ;
265+
266+ if ( attachmentsConfig ?. onUpload ) {
267+ const result = await attachmentsConfig . onUpload ( file ) ;
268+ if ( "data" in result ) {
269+ source = {
270+ type : "data" ,
271+ value : result . data ,
272+ mimeType : result . mimeType ,
273+ } ;
274+ } else {
275+ source = {
276+ type : "url" ,
277+ value : result . url ,
278+ mimeType : result . mimeType ?? file . type ,
279+ } ;
280+ }
281+ } else {
282+ const base64 = await readFileAsBase64 ( file ) ;
283+ source = { type : "data" , value : base64 , mimeType : file . type } ;
284+ }
285+
286+ let thumbnail : string | undefined ;
287+ if ( modality === "video" ) {
288+ thumbnail = await generateVideoThumbnail ( file ) ;
289+ }
290+
291+ setSelectedAttachments ( ( prev ) =>
292+ prev . map ( ( att ) =>
293+ att . id === placeholderId
294+ ? { ...att , source, status : "ready" as const , thumbnail }
295+ : att ,
296+ ) ,
297+ ) ;
298+ } catch ( error ) {
299+ setSelectedAttachments ( ( prev ) =>
300+ prev . filter ( ( att ) => att . id !== placeholderId ) ,
301+ ) ;
302+ console . error ( `[CopilotKit] Failed to upload "${ file . name } ":` , error ) ;
303+ }
304+ }
305+ } ;
306+ processFilesRef . current = processFiles ;
307+
308+ const handleFileUpload = async (
309+ e : React . ChangeEvent < HTMLInputElement > ,
310+ ) => {
311+ if ( ! e . target . files ?. length ) return ;
312+ try {
313+ await processFiles ( Array . from ( e . target . files ) ) ;
314+ } catch ( error ) {
315+ console . error ( "[CopilotKit] Upload error:" , error ) ;
316+ }
317+ } ;
318+
319+ // Drag-and-drop handlers
320+ const handleDragOver = ( e : React . DragEvent ) => {
321+ if ( ! attachmentsEnabled ) return ;
322+ e . preventDefault ( ) ;
323+ e . stopPropagation ( ) ;
324+ setDragOver ( true ) ;
325+ } ;
326+
327+ const handleDragLeave = ( e : React . DragEvent ) => {
328+ e . preventDefault ( ) ;
329+ e . stopPropagation ( ) ;
330+ setDragOver ( false ) ;
331+ } ;
332+
333+ const handleDrop = async ( e : React . DragEvent ) => {
334+ e . preventDefault ( ) ;
335+ e . stopPropagation ( ) ;
336+ setDragOver ( false ) ;
337+ if ( ! attachmentsEnabled ) return ;
338+
339+ const files = Array . from ( e . dataTransfer . files ) ;
340+ if ( files . length > 0 ) {
341+ try {
342+ await processFiles ( files ) ;
343+ } catch ( error ) {
344+ console . error ( "[CopilotKit] Drop error:" , error ) ;
345+ }
346+ }
347+ } ;
348+
349+ // Clipboard paste handler
350+ useEffect ( ( ) => {
351+ if ( ! attachmentsEnabled ) return ;
352+
353+ const handlePaste = async ( e : ClipboardEvent ) => {
354+ const items = Array . from ( e . clipboardData ?. items || [ ] ) ;
355+ const fileItems = items . filter (
356+ ( item ) =>
357+ item . kind === "file" &&
358+ item . getAsFile ( ) !== null &&
359+ matchesAcceptFilter ( item . getAsFile ( ) ! , attachmentsAccept ) ,
360+ ) ;
361+
362+ if ( fileItems . length === 0 ) return ;
363+ e . preventDefault ( ) ;
364+
365+ const files = fileItems
366+ . map ( ( item ) => item . getAsFile ( ) )
367+ . filter ( ( f ) : f is File => f !== null ) ;
368+
369+ try {
370+ await processFilesRef . current ( files ) ;
371+ } catch ( error ) {
372+ console . error ( "[CopilotKit] Paste error:" , error ) ;
373+ }
374+ } ;
375+
376+ document . addEventListener ( "paste" , handlePaste ) ;
377+ return ( ) => document . removeEventListener ( "paste" , handlePaste ) ;
378+ } , [ attachmentsEnabled , attachmentsAccept ] ) ;
379+
380+ // --- End attachment logic ---
381+
187382 const onSubmitInput = useCallback (
188383 async ( value : string ) => {
189- agent . addMessage ( {
190- id : randomUUID ( ) ,
191- role : "user" ,
192- content : value ,
193- } ) ;
384+ // Block if uploads in progress
385+ const hasUploading = selectedAttachments . some (
386+ ( a ) => a . status === "uploading" ,
387+ ) ;
388+ if ( hasUploading ) {
389+ console . error (
390+ "[CopilotKit] Cannot send while attachments are uploading" ,
391+ ) ;
392+ return ;
393+ }
394+
395+ const readyAttachments = selectedAttachments . filter (
396+ ( a ) => a . status === "ready" ,
397+ ) ;
398+ setSelectedAttachments ( [ ] ) ;
399+ if ( fileInputRef . current ) fileInputRef . current . value = "" ;
400+
401+ if ( readyAttachments . length > 0 ) {
402+ const contentParts : InputContent [ ] = [ ] ;
403+ if ( value . trim ( ) ) {
404+ contentParts . push ( { type : "text" , text : value } ) ;
405+ }
406+ for ( const att of readyAttachments ) {
407+ contentParts . push ( {
408+ type : att . type ,
409+ source : att . source ,
410+ } as InputContent ) ;
411+ }
412+ agent . addMessage ( {
413+ id : randomUUID ( ) ,
414+ role : "user" ,
415+ content : contentParts ,
416+ } ) ;
417+ } else {
418+ agent . addMessage ( {
419+ id : randomUUID ( ) ,
420+ role : "user" ,
421+ content : value ,
422+ } ) ;
423+ }
424+
194425 // Clear input after submitting
195426 setInputValue ( "" ) ;
196427 try {
@@ -201,7 +432,7 @@ export function CopilotChat({
201432 } ,
202433 // copilotkit is intentionally excluded — it is a stable ref that never changes.
203434 // eslint-disable-next-line react-hooks/exhaustive-deps
204- [ agent ] ,
435+ [ agent , selectedAttachments ] ,
205436 ) ;
206437
207438 const handleSelectSuggestion = useCallback (
@@ -390,6 +621,17 @@ export function CopilotChat({
390621 onFinishTranscribeWithAudio : showTranscription
391622 ? handleFinishTranscribeWithAudio
392623 : undefined ,
624+ // Attachment props
625+ attachments : selectedAttachments ,
626+ onRemoveAttachment : ( id : string ) =>
627+ setSelectedAttachments ( ( prev ) => prev . filter ( ( a ) => a . id !== id ) ) ,
628+ onAddFile : attachmentsEnabled
629+ ? ( ) => fileInputRef . current ?. click ( )
630+ : undefined ,
631+ dragOver,
632+ onDragOver : handleDragOver ,
633+ onDragLeave : handleDragLeave ,
634+ onDrop : handleDrop ,
393635 } ) as CopilotChatViewProps ;
394636
395637 // Always create a provider with merged values
@@ -403,6 +645,16 @@ export function CopilotChat({
403645 labels = { labels }
404646 isModalDefaultOpen = { isModalDefaultOpen }
405647 >
648+ { attachmentsEnabled && (
649+ < input
650+ type = "file"
651+ multiple
652+ ref = { fileInputRef }
653+ onChange = { handleFileUpload }
654+ accept = { attachmentsAccept }
655+ style = { { display : "none" } }
656+ />
657+ ) }
406658 { ! isChatLicensed && < InlineFeatureWarning featureName = "Chat" /> }
407659 { transcriptionError && (
408660 < div
0 commit comments