Skip to content

Commit c37d409

Browse files
committed
feat(react-core): add attachments prop, upload flow, drag-and-drop to v2 CopilotChat
Wire multimodal attachment support into the v2 CopilotChat component: - Add AttachmentsConfig prop to CopilotChat for enabling file attachments - Implement processFiles with accept filter, size validation, placeholder/ready lifecycle - Support custom onUpload handlers and default base64 encoding - Build InputContent[] when attachments accompany a message - Add drag-and-drop handlers with visual feedback (dashed outline) - Add clipboard paste handler for file items - Render CopilotChatAttachmentQueue between scroll view and input - Forward onAddFile through CopilotChatView to CopilotChatInput's AddMenuButton - Omit internal attachment state props from CopilotChatProps public API
1 parent ae1dc0c commit c37d409

2 files changed

Lines changed: 299 additions & 9 deletions

File tree

packages/react-core/src/v2/components/chat/CopilotChat.tsx

Lines changed: 259 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
1526
import { 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";
1734
import { merge } from "ts-deepmerge";
1835
import {
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

Comments
 (0)