@copilotkit/react-core 1.55.0-next.9 → 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.
- package/CHANGELOG.md +36 -6
- package/dist/{copilotkit-DeOzjPsb.mjs → copilotkit-BY5S1-0P.mjs} +2402 -552
- package/dist/copilotkit-BY5S1-0P.mjs.map +1 -0
- package/dist/{copilotkit-BqcyhQjT.d.mts → copilotkit-BuhSUZHb.d.mts} +228 -17
- package/dist/copilotkit-BuhSUZHb.d.mts.map +1 -0
- package/dist/{copilotkit-BDNjFNmk.cjs → copilotkit-Bz5-ImDl.cjs} +2421 -541
- package/dist/copilotkit-Bz5-ImDl.cjs.map +1 -0
- package/dist/{copilotkit-l-IBF4Xp.d.cts → copilotkit-dwDWYpya.d.cts} +228 -17
- package/dist/copilotkit-dwDWYpya.d.cts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.umd.js +1400 -238
- package/dist/index.umd.js.map +1 -1
- package/dist/v2/index.cjs +13 -1
- package/dist/v2/index.css +1 -1
- package/dist/v2/index.d.cts +3 -3
- package/dist/v2/index.d.mts +3 -3
- package/dist/v2/index.mjs +3 -2
- package/dist/v2/index.umd.js +2442 -552
- package/dist/v2/index.umd.js.map +1 -1
- package/package.json +62 -54
- package/scripts/scope-preflight.mjs +1 -2
- package/src/components/CopilotListeners.tsx +41 -8
- package/src/components/copilot-provider/copilotkit-props.tsx +4 -2
- package/src/components/toast/toast-provider.tsx +269 -194
- package/src/v2/__tests__/A2UIMessageRenderer.test.tsx +86 -22
- package/src/v2/__tests__/utils/test-helpers.tsx +67 -0
- package/src/v2/a2ui/A2UICatalogContext.tsx +79 -0
- package/src/v2/a2ui/A2UIMessageRenderer.tsx +125 -37
- package/src/v2/a2ui/A2UIToolCallRenderer.tsx +290 -0
- package/src/v2/components/CopilotKitInspector.tsx +2 -0
- package/src/v2/components/OpenGenerativeUIRenderer.tsx +598 -0
- package/src/v2/components/__tests__/OpenGenerativeUIRenderer.test.tsx +665 -0
- package/src/v2/components/chat/CopilotChat.tsx +193 -50
- package/src/v2/components/chat/CopilotChatAssistantMessage.tsx +17 -2
- package/src/v2/components/chat/CopilotChatAttachmentQueue.tsx +481 -0
- package/src/v2/components/chat/CopilotChatAttachmentRenderer.tsx +139 -0
- package/src/v2/components/chat/CopilotChatInput.tsx +146 -77
- package/src/v2/components/chat/CopilotChatMessageView.tsx +253 -149
- package/src/v2/components/chat/CopilotChatSuggestionView.tsx +1 -0
- package/src/v2/components/chat/CopilotChatUserMessage.tsx +54 -0
- package/src/v2/components/chat/CopilotChatView.tsx +179 -66
- package/src/v2/components/chat/__tests__/CopilotChat.attachments.test.tsx +168 -0
- package/src/v2/components/chat/__tests__/CopilotChatActivityRendering.e2e.test.tsx +63 -2
- package/src/v2/components/chat/__tests__/CopilotChatInput.test.tsx +544 -1
- package/src/v2/components/chat/__tests__/CopilotChatPerf.e2e.test.tsx +268 -0
- package/src/v2/components/chat/__tests__/CopilotChatPropsRerender.e2e.test.tsx +249 -0
- package/src/v2/components/chat/__tests__/MCPAppsActivityRenderer.e2e.test.tsx +43 -2
- package/src/v2/components/chat/__tests__/copilot-chat-throttle.test.tsx +138 -0
- package/src/v2/components/chat/index.ts +9 -0
- package/src/v2/components/chat/scroll-element-context.ts +13 -0
- package/src/v2/hooks/__tests__/use-agent-throttle.test.tsx +1003 -0
- package/src/v2/hooks/__tests__/use-attachments.test.tsx +169 -0
- package/src/v2/hooks/__tests__/use-threads.test.tsx +54 -0
- package/src/v2/hooks/index.ts +5 -0
- package/src/v2/hooks/use-agent.tsx +95 -10
- package/src/v2/hooks/use-attachments.tsx +269 -0
- package/src/v2/hooks/use-frontend-tool.tsx +5 -2
- package/src/v2/hooks/use-render-activity-message.tsx +9 -2
- package/src/v2/hooks/use-threads.tsx +35 -15
- package/src/v2/index.ts +5 -1
- package/src/v2/lib/__tests__/processPartialHtml.test.ts +112 -0
- package/src/v2/lib/__tests__/slots.test.ts +56 -0
- package/src/v2/lib/processPartialHtml.ts +45 -0
- package/src/v2/lib/slots.tsx +42 -1
- package/src/v2/providers/CopilotChatConfigurationProvider.tsx +9 -3
- package/src/v2/providers/CopilotKitProvider.tsx +268 -32
- package/src/v2/providers/SandboxFunctionsContext.ts +10 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.sandboxFunctions.test.tsx +198 -0
- package/src/v2/providers/__tests__/CopilotKitProvider.test.tsx +71 -0
- package/src/v2/providers/index.ts +7 -0
- package/src/v2/styles/globals.css +2 -1
- package/src/v2/types/index.ts +1 -0
- package/src/v2/types/sandbox-function.ts +11 -0
- package/dist/copilotkit-BDNjFNmk.cjs.map +0 -1
- package/dist/copilotkit-BqcyhQjT.d.mts.map +0 -1
- package/dist/copilotkit-DeOzjPsb.mjs.map +0 -1
- package/dist/copilotkit-l-IBF4Xp.d.cts.map +0 -1
- 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;
|