@copilotkit/react-core 1.55.0-next.8 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +48 -5
  2. package/dist/{copilotkit-DNYSFuz5.mjs → copilotkit-BY5S1-0P.mjs} +2772 -858
  3. package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
  4. package/dist/{copilotkit-Dy5w3qEV.d.mts → copilotkit-BuhSUZHb.d.mts} +230 -17
  5. package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
  6. package/dist/{copilotkit-B3Mb1yVE.cjs → copilotkit-Bz5-ImDl.cjs} +2776 -832
  7. package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
  8. package/dist/{copilotkit-DBzgOMby.d.cts → copilotkit-dwDWYpya.d.cts} +230 -17
  9. package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
  10. package/dist/index.cjs +9 -4
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.mjs +9 -4
  15. package/dist/index.mjs.map +1 -1
  16. package/dist/index.umd.js +1624 -396
  17. package/dist/index.umd.js.map +1 -1
  18. package/dist/v2/index.cjs +13 -1
  19. package/dist/v2/index.css +1 -1
  20. package/dist/v2/index.d.cts +3 -3
  21. package/dist/v2/index.d.mts +3 -3
  22. package/dist/v2/index.mjs +3 -2
  23. package/dist/v2/index.umd.js +2746 -790
  24. package/dist/v2/index.umd.js.map +1 -1
  25. package/package.json +62 -54
  26. package/scripts/scope-preflight.mjs +1 -2
  27. package/src/components/CopilotListeners.tsx +41 -8
  28. package/src/components/copilot-provider/__tests__/copilot-messages-key.test.tsx +92 -0
  29. package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
  30. package/src/components/copilot-provider/copilotkit.tsx +3 -3
  31. package/src/components/toast/toast-provider.tsx +269 -194
  32. package/src/hooks/__tests__/use-copilot-chat-internal-connect.test.tsx +27 -16
  33. package/src/hooks/use-copilot-chat_internal.ts +15 -4
  34. package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
  35. package/src/v2/__tests__/utils/test-helpers.tsx +107 -7
  36. package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
  37. package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
  38. package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
  39. package/src/v2/components/CopilotKitInspector.tsx +2 -0
  40. package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
  41. package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
  42. package/src/v2/components/chat/CopilotChat.tsx +197 -52
  43. package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
  44. package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
  45. package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
  46. package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
  47. package/src/v2/components/chat/CopilotChatMessageView.tsx +260 -151
  48. package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
  49. package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
  50. package/src/v2/components/chat/CopilotChatView.tsx +179 -66
  51. package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
  52. package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
  53. package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
  54. package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
  55. package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
  56. package/src/v2/components/chat/__tests__/CopilotChatToolRendering.e2e.test.tsx +5 -2
  57. package/src/v2/components/chat/__tests__/CopilotChatToolRerenders.e2e.test.tsx +5 -2
  58. package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +60 -3
  59. package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
  60. package/src/v2/components/chat/index.ts +9 -0
  61. package/src/v2/components/chat/scroll-element-context.ts +13 -0
  62. package/src/v2/hooks/__tests__/use-agent-context-timing.e2e.test.tsx +8 -0
  63. package/src/v2/hooks/__tests__/use-agent-thread-isolation.test.tsx +327 -0
  64. package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
  65. package/src/v2/hooks/__tests__/use-agent.e2e.test.tsx +13 -2
  66. package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
  67. package/src/v2/hooks/__tests__/use-frontend-tool.e2e.test.tsx +23 -4
  68. package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
  69. package/src/v2/hooks/index.ts +5 -0
  70. package/src/v2/hooks/use-agent.tsx +220 -15
  71. package/src/v2/hooks/use-attachments.tsx +269 -0
  72. package/src/v2/hooks/use-frontend-tool.tsx +5 -2
  73. package/src/v2/hooks/use-render-activity-message.tsx +9 -2
  74. package/src/v2/hooks/use-render-custom-messages.tsx +6 -1
  75. package/src/v2/hooks/use-threads.tsx +35 -15
  76. package/src/v2/index.ts +5 -1
  77. package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
  78. package/src/v2/lib/__tests__/slots.test.ts +56 -0
  79. package/src/v2/lib/processPartialHtml.ts +45 -0
  80. package/src/v2/lib/slots.tsx +42 -1
  81. package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
  82. package/src/v2/providers/CopilotKitProvider.tsx +268 -32
  83. package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
  84. package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
  85. package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
  86. package/src/v2/providers/index.ts +7 -0
  87. package/src/v2/styles/globals.css +2 -1
  88. package/src/v2/types/index.ts +1 -0
  89. package/src/v2/types/sandbox-function.ts +11 -0
  90. package/dist/copilotkit-B3Mb1yVE.cjs.map +0 -1
  91. package/dist/copilotkit-DBzgOMby.d.cts.map +0 -1
  92. package/dist/copilotkit-DNYSFuz5.mjs.map +0 -1
  93. package/dist/copilotkit-Dy5w3qEV.d.mts.map +0 -1
  94. package/src/v2/components/__tests__/license-warning-banner.test.tsx +0 -46
@@ -0,0 +1,481 @@
1
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
2
+ import { createPortal, flushSync } from "react-dom";
3
+ import type { Attachment } from "@copilotkit/shared";
4
+ import {
5
+ formatFileSize,
6
+ getSourceUrl,
7
+ getDocumentIcon,
8
+ } from "@copilotkit/shared";
9
+ import { Play, X } from "lucide-react";
10
+ import { cn } from "../../lib/utils";
11
+
12
+ interface CopilotChatAttachmentQueueProps {
13
+ attachments: Attachment[];
14
+ onRemoveAttachment: (id: string) => void;
15
+ className?: string;
16
+ }
17
+
18
+ export const CopilotChatAttachmentQueue: React.FC<
19
+ CopilotChatAttachmentQueueProps
20
+ > = ({ attachments, onRemoveAttachment, className }) => {
21
+ if (attachments.length === 0) return null;
22
+
23
+ return (
24
+ <div className={cn("cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2", className)}>
25
+ {attachments.map((attachment) => {
26
+ const isMedia =
27
+ attachment.type === "image" || attachment.type === "video";
28
+ return (
29
+ <div
30
+ key={attachment.id}
31
+ className={cn(
32
+ "cpk:relative cpk:inline-flex cpk:rounded-lg cpk:overflow-hidden cpk:border cpk:border-border",
33
+ isMedia
34
+ ? "cpk:w-[72px] cpk:h-[72px]"
35
+ : attachment.type === "audio"
36
+ ? "cpk:min-w-[200px] cpk:max-w-[280px] cpk:flex-col cpk:p-1 cpk:pr-8"
37
+ : "cpk:p-2 cpk:px-3 cpk:pr-8 cpk:max-w-[240px]",
38
+ )}
39
+ >
40
+ {attachment.status === "uploading" && <UploadingOverlay />}
41
+ <AttachmentPreview attachment={attachment} />
42
+ <button
43
+ onClick={() => onRemoveAttachment(attachment.id)}
44
+ className={cn(
45
+ "cpk:absolute cpk:bg-black/60 cpk:text-white cpk:border-none cpk:rounded-full cpk:w-5 cpk:h-5 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:text-[10px] cpk:z-20",
46
+ isMedia ? "cpk:top-1 cpk:right-1" : "cpk:top-1.5 cpk:right-1.5",
47
+ )}
48
+ aria-label="Remove attachment"
49
+ >
50
+
51
+ </button>
52
+ </div>
53
+ );
54
+ })}
55
+ </div>
56
+ );
57
+ };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Shared
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function UploadingOverlay() {
64
+ return (
65
+ <div className="cpk:absolute cpk:inset-0 cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/40 cpk:z-10">
66
+ <div className="cpk:w-5 cpk:h-5 cpk:border-2 cpk:border-white cpk:border-t-transparent cpk:rounded-full cpk:animate-spin" />
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function AttachmentPreview({ attachment }: { attachment: Attachment }) {
72
+ if (attachment.status === "uploading") {
73
+ return <div className="cpk:w-full cpk:h-full" />;
74
+ }
75
+
76
+ switch (attachment.type) {
77
+ case "image":
78
+ return <ImagePreview attachment={attachment} />;
79
+ case "audio":
80
+ return <AudioPreview attachment={attachment} />;
81
+ case "video":
82
+ return <VideoPreview attachment={attachment} />;
83
+ case "document":
84
+ return <DocumentPreview attachment={attachment} />;
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Lightbox – fullscreen overlay for images and videos (portal to body)
90
+ // Uses the View Transition API when available for a smooth thumbnail-to-
91
+ // fullscreen morph; falls back to a simple opacity fade.
92
+ // ---------------------------------------------------------------------------
93
+
94
+ interface LightboxProps {
95
+ onClose: () => void;
96
+ children: React.ReactNode;
97
+ }
98
+
99
+ function Lightbox({ onClose, children }: LightboxProps) {
100
+ useEffect(() => {
101
+ const handleKey = (e: KeyboardEvent) => {
102
+ if (e.key === "Escape") onClose();
103
+ };
104
+ document.addEventListener("keydown", handleKey);
105
+ return () => document.removeEventListener("keydown", handleKey);
106
+ }, [onClose]);
107
+
108
+ if (typeof document === "undefined") return null;
109
+
110
+ return createPortal(
111
+ <div
112
+ className="cpk:fixed cpk:inset-0 cpk:z-[9999] cpk:flex cpk:items-center cpk:justify-center cpk:bg-black/80 cpk:animate-fade-in"
113
+ onClick={onClose}
114
+ >
115
+ <button
116
+ onClick={onClose}
117
+ className="cpk:absolute cpk:top-4 cpk:right-4 cpk:text-white cpk:bg-white/10 cpk:hover:bg-white/20 cpk:rounded-full cpk:w-10 cpk:h-10 cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer cpk:border-none cpk:z-10"
118
+ aria-label="Close preview"
119
+ >
120
+ <X className="cpk:w-5 cpk:h-5" />
121
+ </button>
122
+
123
+ <div onClick={(e) => e.stopPropagation()}>{children}</div>
124
+ </div>,
125
+ document.body,
126
+ );
127
+ }
128
+
129
+ type DocWithVT = Document & {
130
+ startViewTransition?: (cb: () => void) => { finished: Promise<void> };
131
+ };
132
+
133
+ /**
134
+ * Hook that manages lightbox open/close and uses the View Transition API to
135
+ * morph the thumbnail into fullscreen content.
136
+ *
137
+ * The trick: `view-transition-name` must live on exactly ONE element at a time.
138
+ * - Old state (thumbnail visible): name is on the thumbnail.
139
+ * - New state (lightbox visible): name moves to the lightbox content.
140
+ * `flushSync` ensures React commits the DOM change synchronously inside the
141
+ * `startViewTransition` callback so the API can snapshot old → new correctly.
142
+ */
143
+ function useLightbox() {
144
+ const thumbnailRef = useRef<HTMLElement>(null);
145
+ const [open, setOpen] = useState(false);
146
+ const vtName = useId();
147
+
148
+ const openLightbox = useCallback(() => {
149
+ const thumb = thumbnailRef.current;
150
+ const doc = document as DocWithVT;
151
+
152
+ if (doc.startViewTransition && thumb) {
153
+ // Old snapshot: name on the thumbnail
154
+ thumb.style.viewTransitionName = vtName;
155
+
156
+ doc.startViewTransition(() => {
157
+ // New snapshot: remove from thumb (lightbox content will have it)
158
+ thumb.style.viewTransitionName = "";
159
+ flushSync(() => setOpen(true));
160
+ });
161
+ } else {
162
+ setOpen(true);
163
+ }
164
+ }, []);
165
+
166
+ const closeLightbox = useCallback(() => {
167
+ const thumb = thumbnailRef.current;
168
+ const doc = document as DocWithVT;
169
+
170
+ if (doc.startViewTransition && thumb) {
171
+ const transition = doc.startViewTransition(() => {
172
+ // New snapshot: name back on thumbnail
173
+ flushSync(() => setOpen(false));
174
+ thumb.style.viewTransitionName = vtName;
175
+ });
176
+ // Clean up the name after animation finishes (or fails)
177
+ transition.finished
178
+ .then(() => {
179
+ thumb.style.viewTransitionName = "";
180
+ })
181
+ .catch(() => {
182
+ thumb.style.viewTransitionName = "";
183
+ });
184
+ } else {
185
+ setOpen(false);
186
+ }
187
+ }, []);
188
+
189
+ return {
190
+ thumbnailRef,
191
+ vtName,
192
+ open,
193
+ openLightbox,
194
+ closeLightbox,
195
+ };
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Image
200
+ // ---------------------------------------------------------------------------
201
+
202
+ function ImagePreview({ attachment }: { attachment: Attachment }) {
203
+ const src = getSourceUrl(attachment.source);
204
+ const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
205
+ useLightbox();
206
+
207
+ return (
208
+ <>
209
+ <img
210
+ ref={thumbnailRef as React.Ref<HTMLImageElement>}
211
+ src={src}
212
+ alt={attachment.filename || "Image attachment"}
213
+ className="cpk:w-full cpk:h-full cpk:object-cover cpk:cursor-pointer"
214
+ onClick={openLightbox}
215
+ />
216
+ {open && (
217
+ <Lightbox onClose={closeLightbox}>
218
+ <img
219
+ style={{ viewTransitionName: vtName }}
220
+ src={src}
221
+ alt={attachment.filename || "Image attachment"}
222
+ className="cpk:max-w-[90vw] cpk:max-h-[90vh] cpk:object-contain cpk:rounded-lg"
223
+ />
224
+ </Lightbox>
225
+ )}
226
+ </>
227
+ );
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Audio
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function AudioPreview({ attachment }: { attachment: Attachment }) {
235
+ const src = getSourceUrl(attachment.source);
236
+ return (
237
+ <div className="cpk:flex cpk:flex-col cpk:gap-1 cpk:w-full">
238
+ <audio
239
+ src={src}
240
+ controls
241
+ preload="metadata"
242
+ className="cpk:w-full cpk:h-8"
243
+ />
244
+ {attachment.filename && (
245
+ <span className="cpk:text-xs cpk:font-medium cpk:overflow-hidden cpk:text-ellipsis cpk:whitespace-nowrap">
246
+ {attachment.filename}
247
+ </span>
248
+ )}
249
+ </div>
250
+ );
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Video – thumbnail with play button; click opens lightbox with full controls
255
+ // ---------------------------------------------------------------------------
256
+
257
+ function VideoPreview({ attachment }: { attachment: Attachment }) {
258
+ const src = getSourceUrl(attachment.source);
259
+ const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
260
+ useLightbox();
261
+
262
+ return (
263
+ <>
264
+ <div
265
+ ref={thumbnailRef as React.Ref<HTMLDivElement>}
266
+ className="cpk:w-full cpk:h-full"
267
+ >
268
+ {attachment.thumbnail ? (
269
+ <img
270
+ src={attachment.thumbnail}
271
+ alt={attachment.filename || "Video thumbnail"}
272
+ className="cpk:w-full cpk:h-full cpk:object-cover"
273
+ />
274
+ ) : (
275
+ <video
276
+ src={src}
277
+ preload="metadata"
278
+ muted
279
+ className="cpk:w-full cpk:h-full cpk:object-cover"
280
+ />
281
+ )}
282
+ </div>
283
+ <button
284
+ onClick={openLightbox}
285
+ className="cpk:absolute cpk:inset-0 cpk:flex cpk:items-center cpk:justify-center cpk:z-10 cpk:cursor-pointer cpk:bg-black/20 cpk:border-none cpk:p-0"
286
+ aria-label="Play video"
287
+ >
288
+ <div className="cpk:w-8 cpk:h-8 cpk:rounded-full cpk:bg-black/60 cpk:flex cpk:items-center cpk:justify-center">
289
+ <Play className="cpk:w-4 cpk:h-4 cpk:text-white cpk:ml-0.5" />
290
+ </div>
291
+ </button>
292
+ {open && (
293
+ <Lightbox onClose={closeLightbox}>
294
+ <video
295
+ style={{ viewTransitionName: vtName }}
296
+ src={src}
297
+ controls
298
+ autoPlay
299
+ className="cpk:max-w-[90vw] cpk:max-h-[90vh] cpk:rounded-lg"
300
+ />
301
+ </Lightbox>
302
+ )}
303
+ </>
304
+ );
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Document – click opens lightbox with PDF/text preview or info card
309
+ // ---------------------------------------------------------------------------
310
+
311
+ function isPdf(mimeType: string | undefined): boolean {
312
+ return !!mimeType && mimeType.includes("pdf");
313
+ }
314
+
315
+ function isText(mimeType: string | undefined): boolean {
316
+ return !!mimeType && mimeType.startsWith("text/");
317
+ }
318
+
319
+ function canPreviewInBrowser(mimeType: string | undefined): boolean {
320
+ return isPdf(mimeType) || isText(mimeType);
321
+ }
322
+
323
+ /**
324
+ * Convert a base64-encoded data source to a blob: URL that browsers will
325
+ * render inside an iframe (data: URLs are blocked for PDFs in most browsers).
326
+ */
327
+ function useBlobUrl(attachment: Attachment): string | null {
328
+ const [url, setUrl] = useState<string | null>(null);
329
+
330
+ useEffect(() => {
331
+ if (attachment.source.type !== "data") return;
332
+ try {
333
+ const binary = atob(attachment.source.value);
334
+ const bytes = new Uint8Array(binary.length);
335
+ for (let i = 0; i < binary.length; i++) {
336
+ bytes[i] = binary.charCodeAt(i);
337
+ }
338
+ const blob = new Blob([bytes], {
339
+ type: attachment.source.mimeType || "application/octet-stream",
340
+ });
341
+ const blobUrl = URL.createObjectURL(blob);
342
+ setUrl(blobUrl);
343
+ return () => URL.revokeObjectURL(blobUrl);
344
+ } catch (error) {
345
+ console.error("[CopilotKit] Failed to decode attachment data:", error);
346
+ setUrl(null);
347
+ }
348
+ }, [
349
+ attachment.source.type,
350
+ attachment.source.value,
351
+ attachment.source.mimeType,
352
+ ]);
353
+
354
+ if (attachment.source.type === "url") return attachment.source.value;
355
+ return url;
356
+ }
357
+
358
+ function DocumentLightboxContent({
359
+ attachment,
360
+ vtName,
361
+ }: {
362
+ attachment: Attachment;
363
+ vtName: string;
364
+ }) {
365
+ const mimeType = attachment.source.mimeType;
366
+ const blobUrl = useBlobUrl(attachment);
367
+
368
+ if (isPdf(mimeType)) {
369
+ if (!blobUrl) return null;
370
+ return (
371
+ <iframe
372
+ style={{ viewTransitionName: vtName }}
373
+ src={blobUrl}
374
+ title={attachment.filename || "PDF preview"}
375
+ className="cpk:w-[90vw] cpk:h-[90vh] cpk:max-w-[1000px] cpk:rounded-lg cpk:bg-white"
376
+ />
377
+ );
378
+ }
379
+
380
+ if (isText(mimeType)) {
381
+ // Decode base64 text content for display
382
+ const textContent =
383
+ attachment.source.type === "data"
384
+ ? (() => {
385
+ try {
386
+ return atob(attachment.source.value);
387
+ } catch {
388
+ return attachment.source.value;
389
+ }
390
+ })()
391
+ : null;
392
+
393
+ return (
394
+ <div
395
+ style={{ viewTransitionName: vtName }}
396
+ className="cpk:w-[90vw] cpk:max-w-[800px] cpk:max-h-[90vh] cpk:overflow-auto cpk:rounded-lg cpk:bg-white cpk:dark:bg-gray-900 cpk:p-6"
397
+ >
398
+ {attachment.filename && (
399
+ <div className="cpk:text-sm cpk:font-medium cpk:text-gray-500 cpk:dark:text-gray-400 cpk:mb-4 cpk:pb-2 cpk:border-b cpk:border-gray-200 cpk:dark:border-gray-700">
400
+ {attachment.filename}
401
+ </div>
402
+ )}
403
+ {textContent ? (
404
+ <pre className="cpk:text-sm cpk:whitespace-pre-wrap cpk:break-words cpk:text-gray-800 cpk:dark:text-gray-200 cpk:font-mono cpk:m-0">
405
+ {textContent}
406
+ </pre>
407
+ ) : blobUrl ? (
408
+ <iframe
409
+ src={blobUrl}
410
+ title={attachment.filename || "Text preview"}
411
+ className="cpk:w-full cpk:h-[80vh] cpk:border-none"
412
+ />
413
+ ) : null}
414
+ </div>
415
+ );
416
+ }
417
+
418
+ // Fallback: info card for non-previewable documents
419
+ return (
420
+ <div
421
+ style={{ viewTransitionName: vtName }}
422
+ className="cpk:flex cpk:flex-col cpk:items-center cpk:gap-4 cpk:p-8 cpk:rounded-lg cpk:bg-white cpk:dark:bg-gray-900"
423
+ >
424
+ <div className="cpk:w-16 cpk:h-16 cpk:rounded-xl cpk:bg-primary cpk:text-primary-foreground cpk:flex cpk:items-center cpk:justify-center cpk:text-xl cpk:font-bold">
425
+ {getDocumentIcon(mimeType ?? "")}
426
+ </div>
427
+ <div className="cpk:text-center">
428
+ <div className="cpk:text-base cpk:font-medium cpk:text-gray-800 cpk:dark:text-gray-200">
429
+ {attachment.filename || "Document"}
430
+ </div>
431
+ <div className="cpk:text-sm cpk:text-gray-500 cpk:dark:text-gray-400 cpk:mt-1">
432
+ {mimeType || "Unknown type"}
433
+ {attachment.size != null && ` · ${formatFileSize(attachment.size)}`}
434
+ </div>
435
+ </div>
436
+ <div className="cpk:text-xs cpk:text-gray-400 cpk:dark:text-gray-500">
437
+ No preview available for this file type
438
+ </div>
439
+ </div>
440
+ );
441
+ }
442
+
443
+ function DocumentPreview({ attachment }: { attachment: Attachment }) {
444
+ const { thumbnailRef, vtName, open, openLightbox, closeLightbox } =
445
+ useLightbox();
446
+
447
+ const mimeType = attachment.source.mimeType;
448
+ const previewable = canPreviewInBrowser(mimeType);
449
+
450
+ return (
451
+ <>
452
+ <div
453
+ ref={thumbnailRef as React.Ref<HTMLDivElement>}
454
+ className={cn(
455
+ "cpk:flex cpk:items-center cpk:gap-2",
456
+ previewable && "cpk:cursor-pointer",
457
+ )}
458
+ onClick={previewable ? openLightbox : undefined}
459
+ >
460
+ <div className="cpk:w-8 cpk:h-8 cpk:rounded-md cpk:bg-primary cpk:text-primary-foreground cpk:flex cpk:items-center cpk:justify-center cpk:text-[10px] cpk:font-semibold cpk:shrink-0">
461
+ {getDocumentIcon(mimeType ?? "")}
462
+ </div>
463
+ <div className="cpk:flex cpk:flex-col cpk:min-w-0">
464
+ <span className="cpk:text-xs cpk:font-medium cpk:break-all cpk:leading-tight">
465
+ {attachment.filename || "Document"}
466
+ </span>
467
+ {attachment.size != null && (
468
+ <span className="cpk:text-[11px] cpk:text-muted-foreground">
469
+ {formatFileSize(attachment.size)}
470
+ </span>
471
+ )}
472
+ </div>
473
+ </div>
474
+ {open && (
475
+ <Lightbox onClose={closeLightbox}>
476
+ <DocumentLightboxContent attachment={attachment} vtName={vtName} />
477
+ </Lightbox>
478
+ )}
479
+ </>
480
+ );
481
+ }
@@ -0,0 +1,139 @@
1
+ import React, { memo, useState } from "react";
2
+ import type { InputContentSource } from "@copilotkit/shared";
3
+ import { getSourceUrl, getDocumentIcon } from "@copilotkit/shared";
4
+ import { cn } from "../../lib/utils";
5
+
6
+ interface CopilotChatAttachmentRendererProps {
7
+ type: "image" | "audio" | "video" | "document";
8
+ source: InputContentSource;
9
+ filename?: string;
10
+ className?: string;
11
+ }
12
+
13
+ const ImageAttachment = memo(function ImageAttachment({
14
+ src,
15
+ className,
16
+ }: {
17
+ src: string;
18
+ className?: string;
19
+ }) {
20
+ const [error, setError] = useState(false);
21
+
22
+ if (error) {
23
+ return (
24
+ <div
25
+ className={cn(
26
+ "cpk:flex cpk:flex-col cpk:items-center cpk:justify-center cpk:rounded-lg cpk:bg-muted cpk:p-4 cpk:text-sm cpk:text-muted-foreground",
27
+ className,
28
+ )}
29
+ >
30
+ <span>Failed to load image</span>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <img
37
+ src={src}
38
+ alt="Image attachment"
39
+ className={cn("cpk:max-w-full cpk:h-auto cpk:rounded-lg", className)}
40
+ onError={() => setError(true)}
41
+ />
42
+ );
43
+ });
44
+
45
+ const AudioAttachment = memo(function AudioAttachment({
46
+ src,
47
+ filename,
48
+ className,
49
+ }: {
50
+ src: string;
51
+ filename?: string;
52
+ className?: string;
53
+ }) {
54
+ return (
55
+ <div className={cn("cpk:flex cpk:flex-col cpk:gap-1", className)}>
56
+ <audio
57
+ src={src}
58
+ controls
59
+ preload="metadata"
60
+ className="cpk:max-w-[300px] cpk:w-full cpk:h-10"
61
+ />
62
+ {filename && (
63
+ <span className="cpk:text-xs cpk:text-muted-foreground cpk:truncate cpk:max-w-[300px]">
64
+ {filename}
65
+ </span>
66
+ )}
67
+ </div>
68
+ );
69
+ });
70
+
71
+ const VideoAttachment = memo(function VideoAttachment({
72
+ src,
73
+ className,
74
+ }: {
75
+ src: string;
76
+ className?: string;
77
+ }) {
78
+ return (
79
+ <video
80
+ src={src}
81
+ controls
82
+ preload="metadata"
83
+ className={cn("cpk:max-w-[400px] cpk:w-full cpk:rounded-lg", className)}
84
+ />
85
+ );
86
+ });
87
+
88
+ const DocumentAttachment = memo(function DocumentAttachment({
89
+ source,
90
+ filename,
91
+ className,
92
+ }: {
93
+ source: InputContentSource;
94
+ filename?: string;
95
+ className?: string;
96
+ }) {
97
+ return (
98
+ <div
99
+ className={cn(
100
+ "cpk:inline-flex cpk:items-center cpk:gap-2 cpk:px-3 cpk:py-2 cpk:border cpk:border-border cpk:rounded-lg cpk:bg-muted",
101
+ className,
102
+ )}
103
+ >
104
+ <span className="cpk:text-xs cpk:font-bold cpk:uppercase">
105
+ {getDocumentIcon(source.mimeType ?? "")}
106
+ </span>
107
+ <span className="cpk:text-sm cpk:text-muted-foreground cpk:truncate">
108
+ {filename || source.mimeType || "Unknown type"}
109
+ </span>
110
+ </div>
111
+ );
112
+ });
113
+
114
+ export const CopilotChatAttachmentRenderer: React.FC<
115
+ CopilotChatAttachmentRendererProps
116
+ > = ({ type, source, filename, className }) => {
117
+ const src = getSourceUrl(source);
118
+
119
+ switch (type) {
120
+ case "image":
121
+ return <ImageAttachment src={src} className={className} />;
122
+ case "audio":
123
+ return (
124
+ <AudioAttachment src={src} filename={filename} className={className} />
125
+ );
126
+ case "video":
127
+ return <VideoAttachment src={src} className={className} />;
128
+ case "document":
129
+ return (
130
+ <DocumentAttachment
131
+ source={source}
132
+ filename={filename}
133
+ className={className}
134
+ />
135
+ );
136
+ }
137
+ };
138
+
139
+ export default CopilotChatAttachmentRenderer;