@oppulence/design-system 1.0.4 → 1.0.6
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/hooks/use-resize-observer.ts +24 -0
- package/lib/ai.ts +31 -0
- package/package.json +19 -1
- package/src/components/atoms/animated-size-container.tsx +59 -0
- package/src/components/atoms/currency-input.tsx +16 -0
- package/src/components/atoms/icons.tsx +840 -0
- package/src/components/atoms/image.tsx +23 -0
- package/src/components/atoms/index.ts +10 -0
- package/src/components/atoms/loader.tsx +92 -0
- package/src/components/atoms/quantity-input.tsx +103 -0
- package/src/components/atoms/record-button.tsx +178 -0
- package/src/components/atoms/submit-button.tsx +26 -0
- package/src/components/atoms/text-effect.tsx +251 -0
- package/src/components/atoms/text-shimmer.tsx +74 -0
- package/src/components/molecules/actions.tsx +53 -0
- package/src/components/molecules/branch.tsx +192 -0
- package/src/components/molecules/code-block.tsx +151 -0
- package/src/components/molecules/form.tsx +177 -0
- package/src/components/molecules/index.ts +12 -0
- package/src/components/molecules/inline-citation.tsx +295 -0
- package/src/components/molecules/message.tsx +64 -0
- package/src/components/molecules/sources.tsx +116 -0
- package/src/components/molecules/suggestion.tsx +53 -0
- package/src/components/molecules/task.tsx +74 -0
- package/src/components/molecules/time-range-input.tsx +73 -0
- package/src/components/molecules/tool-call-indicator.tsx +42 -0
- package/src/components/molecules/tool.tsx +130 -0
- package/src/components/organisms/combobox-dropdown.tsx +171 -0
- package/src/components/organisms/conversation.tsx +98 -0
- package/src/components/organisms/date-range-picker.tsx +53 -0
- package/src/components/organisms/editor/extentions/bubble-menu/bubble-item.tsx +30 -0
- package/src/components/organisms/editor/extentions/bubble-menu/bubble-menu-button.tsx +27 -0
- package/src/components/organisms/editor/extentions/bubble-menu/index.tsx +63 -0
- package/src/components/organisms/editor/extentions/bubble-menu/link-item.tsx +104 -0
- package/src/components/organisms/editor/extentions/register.ts +22 -0
- package/src/components/organisms/editor/index.tsx +50 -0
- package/src/components/organisms/editor/styles.css +31 -0
- package/src/components/organisms/editor/utils.ts +19 -0
- package/src/components/organisms/index.ts +11 -0
- package/src/components/organisms/multiple-selector.tsx +632 -0
- package/src/components/organisms/prompt-input.tsx +747 -0
- package/src/components/organisms/reasoning.tsx +170 -0
- package/src/components/organisms/response.tsx +121 -0
- package/src/components/organisms/toast-toaster.tsx +84 -0
- package/src/components/organisms/toast.tsx +124 -0
- package/src/components/organisms/use-toast.tsx +206 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ChatStatus, FileUIPart } from "../../../lib/ai";
|
|
4
|
+
import { cn } from "../../../lib/utils";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "./dropdown-menu";
|
|
11
|
+
import {
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
} from "../molecules/select";
|
|
18
|
+
import { PaperclipIcon, PlusIcon, XIcon } from "lucide-react";
|
|
19
|
+
import {
|
|
20
|
+
type ButtonHTMLAttributes,
|
|
21
|
+
type ChangeEventHandler,
|
|
22
|
+
Children,
|
|
23
|
+
type ComponentProps,
|
|
24
|
+
type FormEvent,
|
|
25
|
+
type FormEventHandler,
|
|
26
|
+
Fragment,
|
|
27
|
+
type HTMLAttributes,
|
|
28
|
+
type KeyboardEventHandler,
|
|
29
|
+
type ReactNode,
|
|
30
|
+
type RefObject,
|
|
31
|
+
type TextareaHTMLAttributes,
|
|
32
|
+
createContext,
|
|
33
|
+
useCallback,
|
|
34
|
+
useContext,
|
|
35
|
+
useEffect,
|
|
36
|
+
useLayoutEffect,
|
|
37
|
+
useMemo,
|
|
38
|
+
useRef,
|
|
39
|
+
useState,
|
|
40
|
+
} from "react";
|
|
41
|
+
import { Icons } from "../atoms/icons";
|
|
42
|
+
import { buttonVariants, type ButtonProps } from "../atoms/button";
|
|
43
|
+
|
|
44
|
+
type AttachmentsContext = {
|
|
45
|
+
files: (FileUIPart & { id: string })[];
|
|
46
|
+
add: (files: File[] | FileList) => void;
|
|
47
|
+
remove: (id: string) => void;
|
|
48
|
+
clear: () => void;
|
|
49
|
+
openFileDialog: () => void;
|
|
50
|
+
fileInputRef: RefObject<HTMLInputElement | null>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const AttachmentsContext = createContext<AttachmentsContext | null>(null);
|
|
54
|
+
|
|
55
|
+
export const usePromptInputAttachments = () => {
|
|
56
|
+
const context = useContext(AttachmentsContext);
|
|
57
|
+
|
|
58
|
+
if (!context) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"usePromptInputAttachments must be used within a PromptInput",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return context;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PromptInputAttachmentProps = Omit<
|
|
68
|
+
HTMLAttributes<HTMLDivElement>,
|
|
69
|
+
"className"
|
|
70
|
+
> & {
|
|
71
|
+
data: FileUIPart & { id: string };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function PromptInputAttachment({
|
|
75
|
+
data,
|
|
76
|
+
...props
|
|
77
|
+
}: PromptInputAttachmentProps) {
|
|
78
|
+
const attachments = usePromptInputAttachments();
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
className="group relative h-14 w-14 border"
|
|
83
|
+
key={data.id}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
{data.mediaType?.startsWith("image/") && data.url ? (
|
|
87
|
+
<img
|
|
88
|
+
alt={data.filename || "attachment"}
|
|
89
|
+
className="size-full object-cover"
|
|
90
|
+
height={56}
|
|
91
|
+
src={data.url}
|
|
92
|
+
width={56}
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<div className="flex size-full items-center justify-center text-muted-foreground">
|
|
96
|
+
<PaperclipIcon className="size-4" />
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
<button
|
|
100
|
+
aria-label="Remove attachment"
|
|
101
|
+
className={cn(
|
|
102
|
+
buttonVariants({ variant: "outline", size: "icon-xs" }),
|
|
103
|
+
"-right-1.5 -top-1.5 absolute h-6 w-6 rounded-full opacity-0 group-hover:opacity-100",
|
|
104
|
+
)}
|
|
105
|
+
onClick={() => attachments.remove(data.id)}
|
|
106
|
+
type="button"
|
|
107
|
+
>
|
|
108
|
+
<XIcon className="h-3 w-3" />
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type PromptInputAttachmentsProps = Omit<
|
|
115
|
+
HTMLAttributes<HTMLDivElement>,
|
|
116
|
+
"children" | "className"
|
|
117
|
+
> & {
|
|
118
|
+
children: (attachment: FileUIPart & { id: string }) => ReactNode;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function PromptInputAttachments({
|
|
122
|
+
children,
|
|
123
|
+
...props
|
|
124
|
+
}: PromptInputAttachmentsProps) {
|
|
125
|
+
const attachments = usePromptInputAttachments();
|
|
126
|
+
const [height, setHeight] = useState(0);
|
|
127
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
128
|
+
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
const el = contentRef.current;
|
|
131
|
+
if (!el) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const ro = new ResizeObserver(() => {
|
|
135
|
+
setHeight(el.getBoundingClientRect().height);
|
|
136
|
+
});
|
|
137
|
+
ro.observe(el);
|
|
138
|
+
setHeight(el.getBoundingClientRect().height);
|
|
139
|
+
return () => ro.disconnect();
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
aria-live="polite"
|
|
145
|
+
className="overflow-hidden transition-[height] duration-200 ease-out"
|
|
146
|
+
style={{ height: attachments.files.length ? height : 0 }}
|
|
147
|
+
{...props}
|
|
148
|
+
>
|
|
149
|
+
<div className="flex flex-wrap gap-2 p-3 pt-3" ref={contentRef}>
|
|
150
|
+
{attachments.files.map((file) => (
|
|
151
|
+
<Fragment key={file.id}>{children(file)}</Fragment>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type PromptInputActionAddAttachmentsProps = Omit<
|
|
159
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
160
|
+
"className"
|
|
161
|
+
> & {
|
|
162
|
+
label?: string;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const PromptInputActionAddAttachments = (
|
|
166
|
+
props: PromptInputActionAddAttachmentsProps,
|
|
167
|
+
) => {
|
|
168
|
+
const attachments = usePromptInputAttachments();
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<button
|
|
172
|
+
className={cn(
|
|
173
|
+
buttonVariants({ variant: "ghost", size: "icon-xs" }),
|
|
174
|
+
"size-6 text-muted-foreground",
|
|
175
|
+
)}
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={() => attachments.openFileDialog()}
|
|
178
|
+
{...props}
|
|
179
|
+
>
|
|
180
|
+
<Icons.Add size={16} />
|
|
181
|
+
</button>
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export type PromptInputMessage = {
|
|
186
|
+
text?: string;
|
|
187
|
+
files?: FileUIPart[];
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export type PromptInputProps = Omit<
|
|
191
|
+
HTMLAttributes<HTMLFormElement>,
|
|
192
|
+
"onSubmit" | "className"
|
|
193
|
+
> & {
|
|
194
|
+
accept?: string; // e.g., "image/*" or leave undefined for any
|
|
195
|
+
multiple?: boolean;
|
|
196
|
+
// When true, accepts drops anywhere on document. Default false (opt-in).
|
|
197
|
+
globalDrop?: boolean;
|
|
198
|
+
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
|
|
199
|
+
syncHiddenInput?: boolean;
|
|
200
|
+
// Minimal constraints
|
|
201
|
+
maxFiles?: number;
|
|
202
|
+
maxFileSize?: number; // bytes
|
|
203
|
+
onError?: (err: {
|
|
204
|
+
code: "max_files" | "max_file_size" | "accept";
|
|
205
|
+
message: string;
|
|
206
|
+
}) => void;
|
|
207
|
+
onSubmit: (
|
|
208
|
+
message: PromptInputMessage,
|
|
209
|
+
event: FormEvent<HTMLFormElement>,
|
|
210
|
+
) => void;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const PromptInput = ({
|
|
214
|
+
accept,
|
|
215
|
+
multiple,
|
|
216
|
+
globalDrop,
|
|
217
|
+
syncHiddenInput,
|
|
218
|
+
maxFiles,
|
|
219
|
+
maxFileSize,
|
|
220
|
+
onError,
|
|
221
|
+
onSubmit,
|
|
222
|
+
...props
|
|
223
|
+
}: PromptInputProps) => {
|
|
224
|
+
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
|
225
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
226
|
+
const anchorRef = useRef<HTMLSpanElement>(null);
|
|
227
|
+
const formRef = useRef<HTMLFormElement | null>(null);
|
|
228
|
+
|
|
229
|
+
// Find nearest form to scope drag & drop
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const root = anchorRef.current?.closest("form");
|
|
232
|
+
if (root instanceof HTMLFormElement) {
|
|
233
|
+
formRef.current = root;
|
|
234
|
+
}
|
|
235
|
+
}, []);
|
|
236
|
+
|
|
237
|
+
const openFileDialog = useCallback(() => {
|
|
238
|
+
inputRef.current?.click();
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const matchesAccept = useCallback(
|
|
242
|
+
(f: File) => {
|
|
243
|
+
if (!accept || accept.trim() === "") {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
// Split accept string into individual types
|
|
247
|
+
const acceptTypes = accept.split(",").map((t) => t.trim());
|
|
248
|
+
return acceptTypes.some((type) => {
|
|
249
|
+
if (type.endsWith("/*")) {
|
|
250
|
+
// Handle wildcard types like "image/*" or "application/*"
|
|
251
|
+
const baseType = type.slice(0, -2);
|
|
252
|
+
return f.type.startsWith(`${baseType}/`);
|
|
253
|
+
}
|
|
254
|
+
// Handle specific MIME types like "application/pdf"
|
|
255
|
+
return f.type === type;
|
|
256
|
+
});
|
|
257
|
+
},
|
|
258
|
+
[accept],
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const convertFilesToDataURLs = useCallback(
|
|
262
|
+
(
|
|
263
|
+
files: FileList | File[],
|
|
264
|
+
): Promise<
|
|
265
|
+
{ type: "file"; filename: string; mediaType: string; url: string }[]
|
|
266
|
+
> => {
|
|
267
|
+
return Promise.all(
|
|
268
|
+
Array.from(files).map(
|
|
269
|
+
(file) =>
|
|
270
|
+
new Promise<{
|
|
271
|
+
type: "file";
|
|
272
|
+
filename: string;
|
|
273
|
+
mediaType: string;
|
|
274
|
+
url: string;
|
|
275
|
+
}>((resolve, reject) => {
|
|
276
|
+
const reader = new FileReader();
|
|
277
|
+
reader.onload = () => {
|
|
278
|
+
resolve({
|
|
279
|
+
type: "file",
|
|
280
|
+
filename: file.name,
|
|
281
|
+
mediaType: file.type,
|
|
282
|
+
url: reader.result as string, // Data URL
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
reader.onerror = reject;
|
|
286
|
+
reader.readAsDataURL(file);
|
|
287
|
+
}),
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
},
|
|
291
|
+
[],
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const add = useCallback(
|
|
295
|
+
(files: File[] | FileList) => {
|
|
296
|
+
const incoming = Array.from(files);
|
|
297
|
+
const accepted = incoming.filter((f) => matchesAccept(f));
|
|
298
|
+
if (accepted.length === 0) {
|
|
299
|
+
onError?.({
|
|
300
|
+
code: "accept",
|
|
301
|
+
message: "No files match the accepted types.",
|
|
302
|
+
});
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const withinSize = (f: File) =>
|
|
306
|
+
maxFileSize ? f.size <= maxFileSize : true;
|
|
307
|
+
const sized = accepted.filter(withinSize);
|
|
308
|
+
if (sized.length === 0 && accepted.length > 0) {
|
|
309
|
+
onError?.({
|
|
310
|
+
code: "max_file_size",
|
|
311
|
+
message: "All files exceed the maximum size.",
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
setItems((prev) => {
|
|
316
|
+
const capacity =
|
|
317
|
+
typeof maxFiles === "number"
|
|
318
|
+
? Math.max(0, maxFiles - prev.length)
|
|
319
|
+
: undefined;
|
|
320
|
+
const capped =
|
|
321
|
+
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
|
322
|
+
if (typeof capacity === "number" && sized.length > capacity) {
|
|
323
|
+
onError?.({
|
|
324
|
+
code: "max_files",
|
|
325
|
+
message: "Too many files. Some were not added.",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Create temporary items with blob URLs for immediate UI display
|
|
330
|
+
// Use filename + index as ID
|
|
331
|
+
const tempItems = capped.map((file, index) => {
|
|
332
|
+
const blobUrl = URL.createObjectURL(file);
|
|
333
|
+
return {
|
|
334
|
+
id: `${file.name}-${index}-${Date.now()}`,
|
|
335
|
+
type: "file" as const,
|
|
336
|
+
url: blobUrl,
|
|
337
|
+
mediaType: file.type,
|
|
338
|
+
filename: file.name,
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Convert files to data URLs using FileReader and replace temp items
|
|
343
|
+
convertFilesToDataURLs(capped).then((convertedFiles) => {
|
|
344
|
+
setItems((current) => {
|
|
345
|
+
// Match temp items to converted items by index (since arrays are parallel)
|
|
346
|
+
return current.map((item) => {
|
|
347
|
+
// If this is a temp item (blob URL), find its corresponding converted file
|
|
348
|
+
const itemUrl = item.url;
|
|
349
|
+
if (
|
|
350
|
+
itemUrl &&
|
|
351
|
+
typeof itemUrl === "string" &&
|
|
352
|
+
itemUrl.startsWith("blob:")
|
|
353
|
+
) {
|
|
354
|
+
// Find the temp item by matching ID
|
|
355
|
+
const tempItem = tempItems.find((temp) => temp.id === item.id);
|
|
356
|
+
if (tempItem) {
|
|
357
|
+
const tempIndex = tempItems.indexOf(tempItem);
|
|
358
|
+
if (
|
|
359
|
+
tempIndex >= 0 &&
|
|
360
|
+
tempIndex < convertedFiles.length &&
|
|
361
|
+
itemUrl
|
|
362
|
+
) {
|
|
363
|
+
const converted = convertedFiles[tempIndex];
|
|
364
|
+
if (converted) {
|
|
365
|
+
// Revoke the blob URL
|
|
366
|
+
URL.revokeObjectURL(itemUrl);
|
|
367
|
+
// Return converted item with same id and filename
|
|
368
|
+
return {
|
|
369
|
+
...item,
|
|
370
|
+
url: converted.url, // Data URL
|
|
371
|
+
data: converted.url, // Also store in data field
|
|
372
|
+
filename: converted.filename, // Keep original filename
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return item;
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Return immediately with temporary blob URLs for UI responsiveness
|
|
384
|
+
return prev.concat(tempItems);
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
[matchesAccept, maxFiles, maxFileSize, onError, convertFilesToDataURLs],
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const remove = useCallback((id: string) => {
|
|
391
|
+
setItems((prev) => {
|
|
392
|
+
const found = prev.find((file) => file.id === id);
|
|
393
|
+
// Only revoke blob URLs, not data URLs
|
|
394
|
+
if (found?.url?.startsWith("blob:")) {
|
|
395
|
+
URL.revokeObjectURL(found.url);
|
|
396
|
+
}
|
|
397
|
+
return prev.filter((file) => file.id !== id);
|
|
398
|
+
});
|
|
399
|
+
}, []);
|
|
400
|
+
|
|
401
|
+
const clear = useCallback(() => {
|
|
402
|
+
setItems((prev) => {
|
|
403
|
+
for (const file of prev) {
|
|
404
|
+
// Only revoke blob URLs, not data URLs
|
|
405
|
+
if (file.url?.startsWith("blob:")) {
|
|
406
|
+
URL.revokeObjectURL(file.url);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return [];
|
|
410
|
+
});
|
|
411
|
+
}, []);
|
|
412
|
+
|
|
413
|
+
// Note: File input cannot be programmatically set for security reasons
|
|
414
|
+
// The syncHiddenInput prop is no longer functional
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
if (syncHiddenInput && inputRef.current) {
|
|
417
|
+
// Clear the input when items are cleared
|
|
418
|
+
if (items.length === 0) {
|
|
419
|
+
inputRef.current.value = "";
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}, [items, syncHiddenInput]);
|
|
423
|
+
|
|
424
|
+
// Attach drop handlers on nearest form and document (opt-in)
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
const form = formRef.current;
|
|
427
|
+
if (!form) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const onDragOver = (e: DragEvent) => {
|
|
431
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const onDrop = (e: DragEvent) => {
|
|
436
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
437
|
+
e.preventDefault();
|
|
438
|
+
}
|
|
439
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
440
|
+
add(e.dataTransfer.files);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
form.addEventListener("dragover", onDragOver);
|
|
444
|
+
form.addEventListener("drop", onDrop);
|
|
445
|
+
return () => {
|
|
446
|
+
form.removeEventListener("dragover", onDragOver);
|
|
447
|
+
form.removeEventListener("drop", onDrop);
|
|
448
|
+
};
|
|
449
|
+
}, [add]);
|
|
450
|
+
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (!globalDrop) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const onDragOver = (e: DragEvent) => {
|
|
456
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
457
|
+
e.preventDefault();
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const onDrop = (e: DragEvent) => {
|
|
461
|
+
if (e.dataTransfer?.types?.includes("Files")) {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
}
|
|
464
|
+
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
|
465
|
+
add(e.dataTransfer.files);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
document.addEventListener("dragover", onDragOver);
|
|
469
|
+
document.addEventListener("drop", onDrop);
|
|
470
|
+
return () => {
|
|
471
|
+
document.removeEventListener("dragover", onDragOver);
|
|
472
|
+
document.removeEventListener("drop", onDrop);
|
|
473
|
+
};
|
|
474
|
+
}, [add, globalDrop]);
|
|
475
|
+
|
|
476
|
+
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
|
477
|
+
if (event.currentTarget.files) {
|
|
478
|
+
add(event.currentTarget.files);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
|
483
|
+
event.preventDefault();
|
|
484
|
+
|
|
485
|
+
const files: FileUIPart[] = items.map(({ ...item }) => ({
|
|
486
|
+
...item,
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
const formData = new FormData(event.currentTarget);
|
|
490
|
+
const textValue = formData.get("message");
|
|
491
|
+
const text = typeof textValue === "string" ? textValue : undefined;
|
|
492
|
+
|
|
493
|
+
onSubmit({ text, files }, event);
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const ctx = useMemo<AttachmentsContext>(
|
|
497
|
+
() => ({
|
|
498
|
+
files: items.map((item) => ({ ...item, id: item.id })),
|
|
499
|
+
add,
|
|
500
|
+
remove,
|
|
501
|
+
clear,
|
|
502
|
+
openFileDialog,
|
|
503
|
+
fileInputRef: inputRef,
|
|
504
|
+
}),
|
|
505
|
+
[items, add, remove, clear, openFileDialog],
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<AttachmentsContext.Provider value={ctx}>
|
|
510
|
+
<span aria-hidden="true" className="hidden" ref={anchorRef} />
|
|
511
|
+
<input
|
|
512
|
+
accept={accept}
|
|
513
|
+
className="hidden"
|
|
514
|
+
multiple={multiple}
|
|
515
|
+
onChange={handleChange}
|
|
516
|
+
ref={inputRef}
|
|
517
|
+
type="file"
|
|
518
|
+
/>
|
|
519
|
+
<form
|
|
520
|
+
className="w-full overflow-hidden bg-[#F7F7F7] dark:bg-[#131313]"
|
|
521
|
+
onSubmit={handleSubmit}
|
|
522
|
+
{...props}
|
|
523
|
+
/>
|
|
524
|
+
</AttachmentsContext.Provider>
|
|
525
|
+
);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
export type PromptInputBodyProps = Omit<
|
|
529
|
+
HTMLAttributes<HTMLDivElement>,
|
|
530
|
+
"className"
|
|
531
|
+
>;
|
|
532
|
+
|
|
533
|
+
export const PromptInputBody = ({ ...props }: PromptInputBodyProps) => (
|
|
534
|
+
<div className="flex flex-col" {...props} />
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
export type PromptInputTextareaProps = Omit<
|
|
538
|
+
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
539
|
+
"className"
|
|
540
|
+
>;
|
|
541
|
+
|
|
542
|
+
export const PromptInputTextarea = ({
|
|
543
|
+
onChange,
|
|
544
|
+
placeholder = "Ask anything",
|
|
545
|
+
...props
|
|
546
|
+
}: PromptInputTextareaProps) => {
|
|
547
|
+
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
|
548
|
+
if (e.key === "Enter") {
|
|
549
|
+
// Don't submit if IME composition is in progress
|
|
550
|
+
if (e.nativeEvent.isComposing) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (e.shiftKey) {
|
|
555
|
+
// Allow newline
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Submit on Enter (without Shift)
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
const form = e.currentTarget.form;
|
|
562
|
+
if (form) {
|
|
563
|
+
form.requestSubmit();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
return (
|
|
569
|
+
<textarea
|
|
570
|
+
className={cn(
|
|
571
|
+
"w-full resize-none rounded-none border-none p-3 pt-4 shadow-none outline-none ring-0 text-sm",
|
|
572
|
+
"field-sizing-content bg-transparent dark:bg-transparent placeholder:text-[rgba(102,102,102,0.5)]",
|
|
573
|
+
"max-h-[55px] min-h-[55px]",
|
|
574
|
+
"focus-visible:ring-0",
|
|
575
|
+
)}
|
|
576
|
+
name="message"
|
|
577
|
+
onChange={(e) => {
|
|
578
|
+
onChange?.(e);
|
|
579
|
+
}}
|
|
580
|
+
onKeyDown={handleKeyDown}
|
|
581
|
+
placeholder={placeholder}
|
|
582
|
+
{...props}
|
|
583
|
+
/>
|
|
584
|
+
);
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
export type PromptInputToolbarProps = Omit<
|
|
588
|
+
HTMLAttributes<HTMLDivElement>,
|
|
589
|
+
"className"
|
|
590
|
+
>;
|
|
591
|
+
|
|
592
|
+
export const PromptInputToolbar = ({ ...props }: PromptInputToolbarProps) => (
|
|
593
|
+
<div className="flex items-center justify-between px-3 pb-2" {...props} />
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
export type PromptInputToolsProps = Omit<
|
|
597
|
+
HTMLAttributes<HTMLDivElement>,
|
|
598
|
+
"className"
|
|
599
|
+
>;
|
|
600
|
+
|
|
601
|
+
export const PromptInputTools = ({ ...props }: PromptInputToolsProps) => (
|
|
602
|
+
<div className="flex items-center gap-3.5" {...props} />
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
export type PromptInputButtonProps = Omit<
|
|
606
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
607
|
+
"className"
|
|
608
|
+
> & {
|
|
609
|
+
variant?: ButtonProps["variant"];
|
|
610
|
+
size?: ButtonProps["size"];
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
export const PromptInputButton = ({
|
|
614
|
+
variant = "ghost",
|
|
615
|
+
size,
|
|
616
|
+
...props
|
|
617
|
+
}: PromptInputButtonProps) => {
|
|
618
|
+
const newSize =
|
|
619
|
+
(size ?? Children.count(props.children) > 1) ? "default" : "icon";
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<button
|
|
623
|
+
className={cn(
|
|
624
|
+
buttonVariants({ variant, size: newSize }),
|
|
625
|
+
"shrink-0 gap-1.5",
|
|
626
|
+
variant === "ghost" && "text-muted-foreground",
|
|
627
|
+
newSize === "default" && "px-3",
|
|
628
|
+
)}
|
|
629
|
+
type={props.type ?? "button"}
|
|
630
|
+
{...props}
|
|
631
|
+
/>
|
|
632
|
+
);
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
|
|
636
|
+
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
|
|
637
|
+
<DropdownMenu {...props} />
|
|
638
|
+
);
|
|
639
|
+
|
|
640
|
+
export type PromptInputActionMenuTriggerProps = ComponentProps<
|
|
641
|
+
typeof PromptInputButton
|
|
642
|
+
> & {};
|
|
643
|
+
export const PromptInputActionMenuTrigger = ({
|
|
644
|
+
children,
|
|
645
|
+
...props
|
|
646
|
+
}: PromptInputActionMenuTriggerProps) => (
|
|
647
|
+
<DropdownMenuTrigger render={<PromptInputButton {...props} />}>
|
|
648
|
+
{children ?? <PlusIcon className="size-4" />}
|
|
649
|
+
</DropdownMenuTrigger>
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
export type PromptInputActionMenuContentProps = ComponentProps<
|
|
653
|
+
typeof DropdownMenuContent
|
|
654
|
+
>;
|
|
655
|
+
export const PromptInputActionMenuContent = (
|
|
656
|
+
props: PromptInputActionMenuContentProps,
|
|
657
|
+
) => <DropdownMenuContent align="start" {...props} />;
|
|
658
|
+
|
|
659
|
+
export type PromptInputActionMenuItemProps = ComponentProps<
|
|
660
|
+
typeof DropdownMenuItem
|
|
661
|
+
>;
|
|
662
|
+
export const PromptInputActionMenuItem = (
|
|
663
|
+
props: PromptInputActionMenuItemProps,
|
|
664
|
+
) => <DropdownMenuItem {...props} />;
|
|
665
|
+
|
|
666
|
+
// Note: Actions that perform side-effects (like opening a file dialog)
|
|
667
|
+
// are provided in opt-in modules (e.g., prompt-input-attachments).
|
|
668
|
+
|
|
669
|
+
export type PromptInputSubmitProps = Omit<
|
|
670
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
671
|
+
"className"
|
|
672
|
+
> & {
|
|
673
|
+
variant?: ButtonProps["variant"];
|
|
674
|
+
size?: ButtonProps["size"];
|
|
675
|
+
status?: ChatStatus;
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
export const PromptInputSubmit = ({
|
|
679
|
+
variant = "default",
|
|
680
|
+
size = "icon",
|
|
681
|
+
status,
|
|
682
|
+
children,
|
|
683
|
+
...props
|
|
684
|
+
}: PromptInputSubmitProps) => {
|
|
685
|
+
let Icon = <Icons.ArrowUpward size={16} />;
|
|
686
|
+
|
|
687
|
+
if (status === "streaming") {
|
|
688
|
+
Icon = <Icons.Stop size={16} />;
|
|
689
|
+
} else if (status === "error") {
|
|
690
|
+
Icon = <XIcon className="size-4" />;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Change button type to "button" when streaming to prevent form submission
|
|
694
|
+
// The onClick handler will handle stopping the stream
|
|
695
|
+
const buttonType =
|
|
696
|
+
status === "streaming" || status === "submitted" ? "button" : "submit";
|
|
697
|
+
|
|
698
|
+
return (
|
|
699
|
+
<button
|
|
700
|
+
className={cn(
|
|
701
|
+
buttonVariants({ variant, size }),
|
|
702
|
+
"gap-1.5",
|
|
703
|
+
size === "icon" && "size-8",
|
|
704
|
+
)}
|
|
705
|
+
type={buttonType}
|
|
706
|
+
{...props}
|
|
707
|
+
>
|
|
708
|
+
{children ?? Icon}
|
|
709
|
+
</button>
|
|
710
|
+
);
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
|
|
714
|
+
|
|
715
|
+
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
|
|
716
|
+
<Select {...props} />
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
export type PromptInputModelSelectTriggerProps = ComponentProps<
|
|
720
|
+
typeof SelectTrigger
|
|
721
|
+
>;
|
|
722
|
+
|
|
723
|
+
export const PromptInputModelSelectTrigger = (
|
|
724
|
+
props: PromptInputModelSelectTriggerProps,
|
|
725
|
+
) => <SelectTrigger {...props} />;
|
|
726
|
+
|
|
727
|
+
export type PromptInputModelSelectContentProps = ComponentProps<
|
|
728
|
+
typeof SelectContent
|
|
729
|
+
>;
|
|
730
|
+
|
|
731
|
+
export const PromptInputModelSelectContent = (
|
|
732
|
+
props: PromptInputModelSelectContentProps,
|
|
733
|
+
) => <SelectContent {...props} />;
|
|
734
|
+
|
|
735
|
+
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
|
|
736
|
+
|
|
737
|
+
export const PromptInputModelSelectItem = (
|
|
738
|
+
props: PromptInputModelSelectItemProps,
|
|
739
|
+
) => <SelectItem {...props} />;
|
|
740
|
+
|
|
741
|
+
export type PromptInputModelSelectValueProps = ComponentProps<
|
|
742
|
+
typeof SelectValue
|
|
743
|
+
>;
|
|
744
|
+
|
|
745
|
+
export const PromptInputModelSelectValue = (
|
|
746
|
+
props: PromptInputModelSelectValueProps,
|
|
747
|
+
) => <SelectValue {...props} />;
|