|
|
|
|
| import { Button } from '@/components/ui/button'; |
| import { |
| Command, |
| CommandEmpty, |
| CommandGroup, |
| CommandInput, |
| CommandItem, |
| CommandList, |
| CommandSeparator, |
| } from '@/components/ui/command'; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| DropdownMenuTrigger, |
| } from '@/components/ui/dropdown-menu'; |
| import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; |
| import { |
| InputGroup, |
| InputGroupAddon, |
| InputGroupButton, |
| InputGroupTextarea, |
| } from '@/components/ui/input-group'; |
| import { |
| Select, |
| SelectContent, |
| SelectItem, |
| SelectTrigger, |
| SelectValue, |
| } from '@/components/ui/select'; |
| import { cn } from '@/lib/utils'; |
| import { createLogger } from '@/lib/logger'; |
| import type { ChatStatus, FileUIPart } from 'ai'; |
|
|
| const log = createLogger('PromptInput'); |
| import { |
| CornerDownLeftIcon, |
| ImageIcon, |
| Loader2Icon, |
| MicIcon, |
| PaperclipIcon, |
| PlusIcon, |
| SquareIcon, |
| XIcon, |
| } from 'lucide-react'; |
| import { nanoid } from 'nanoid'; |
| import { |
| type ChangeEvent, |
| type ChangeEventHandler, |
| Children, |
| type ClipboardEventHandler, |
| type ComponentProps, |
| createContext, |
| type FormEvent, |
| type FormEventHandler, |
| Fragment, |
| type HTMLAttributes, |
| type KeyboardEventHandler, |
| type PropsWithChildren, |
| type ReactNode, |
| type RefObject, |
| useCallback, |
| useContext, |
| useEffect, |
| useMemo, |
| useRef, |
| useState, |
| } from 'react'; |
|
|
| |
| |
| |
|
|
| export type AttachmentsContext = { |
| files: (FileUIPart & { id: string })[]; |
| add: (files: File[] | FileList) => void; |
| remove: (id: string) => void; |
| clear: () => void; |
| openFileDialog: () => void; |
| fileInputRef: RefObject<HTMLInputElement | null>; |
| }; |
|
|
| export type TextInputContext = { |
| value: string; |
| setInput: (v: string) => void; |
| clear: () => void; |
| }; |
|
|
| export type PromptInputControllerProps = { |
| textInput: TextInputContext; |
| attachments: AttachmentsContext; |
| |
| __registerFileInput: (ref: RefObject<HTMLInputElement | null>, open: () => void) => void; |
| }; |
|
|
| const PromptInputController = createContext<PromptInputControllerProps | null>(null); |
| const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(null); |
|
|
| export const usePromptInputController = () => { |
| const ctx = useContext(PromptInputController); |
| if (!ctx) { |
| throw new Error( |
| 'Wrap your component inside <PromptInputProvider> to use usePromptInputController().', |
| ); |
| } |
| return ctx; |
| }; |
|
|
| |
| const useOptionalPromptInputController = () => useContext(PromptInputController); |
|
|
| export const useProviderAttachments = () => { |
| const ctx = useContext(ProviderAttachmentsContext); |
| if (!ctx) { |
| throw new Error( |
| 'Wrap your component inside <PromptInputProvider> to use useProviderAttachments().', |
| ); |
| } |
| return ctx; |
| }; |
|
|
| const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); |
|
|
| export type PromptInputProviderProps = PropsWithChildren<{ |
| initialInput?: string; |
| }>; |
|
|
| |
| |
| |
| |
| export function PromptInputProvider({ |
| initialInput: initialTextInput = '', |
| children, |
| }: PromptInputProviderProps) { |
| |
| const [textInput, setTextInput] = useState(initialTextInput); |
| const clearInput = useCallback(() => setTextInput(''), []); |
|
|
| |
| const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]); |
| const fileInputRef = useRef<HTMLInputElement | null>(null); |
| const openRef = useRef<() => void>(() => {}); |
|
|
| const add = useCallback((files: File[] | FileList) => { |
| const incoming = Array.from(files); |
| if (incoming.length === 0) { |
| return; |
| } |
|
|
| setAttachmentFiles((prev) => |
| prev.concat( |
| incoming.map((file) => ({ |
| id: nanoid(), |
| type: 'file' as const, |
| url: URL.createObjectURL(file), |
| mediaType: file.type, |
| filename: file.name, |
| })), |
| ), |
| ); |
| }, []); |
|
|
| const remove = useCallback((id: string) => { |
| setAttachmentFiles((prev) => { |
| const found = prev.find((f) => f.id === id); |
| if (found?.url) { |
| URL.revokeObjectURL(found.url); |
| } |
| return prev.filter((f) => f.id !== id); |
| }); |
| }, []); |
|
|
| const clear = useCallback(() => { |
| setAttachmentFiles((prev) => { |
| for (const f of prev) { |
| if (f.url) { |
| URL.revokeObjectURL(f.url); |
| } |
| } |
| return []; |
| }); |
| }, []); |
|
|
| |
| const attachmentsRef = useRef(attachmentFiles); |
| useEffect(() => { |
| attachmentsRef.current = attachmentFiles; |
| }, [attachmentFiles]); |
|
|
| |
| useEffect(() => { |
| return () => { |
| for (const f of attachmentsRef.current) { |
| if (f.url) { |
| URL.revokeObjectURL(f.url); |
| } |
| } |
| }; |
| }, []); |
|
|
| const openFileDialog = useCallback(() => { |
| openRef.current?.(); |
| }, []); |
|
|
| const attachments = useMemo<AttachmentsContext>( |
| () => ({ |
| files: attachmentFiles, |
| add, |
| remove, |
| clear, |
| openFileDialog, |
| fileInputRef, |
| }), |
| [attachmentFiles, add, remove, clear, openFileDialog], |
| ); |
|
|
| const __registerFileInput = useCallback( |
| (ref: RefObject<HTMLInputElement | null>, open: () => void) => { |
| fileInputRef.current = ref.current; |
| openRef.current = open; |
| }, |
| [], |
| ); |
|
|
| const controller = useMemo<PromptInputControllerProps>( |
| () => ({ |
| textInput: { |
| value: textInput, |
| setInput: setTextInput, |
| clear: clearInput, |
| }, |
| attachments, |
| __registerFileInput, |
| }), |
| [textInput, clearInput, attachments, __registerFileInput], |
| ); |
|
|
| return ( |
| <PromptInputController.Provider value={controller}> |
| <ProviderAttachmentsContext.Provider value={attachments}> |
| {children} |
| </ProviderAttachmentsContext.Provider> |
| </PromptInputController.Provider> |
| ); |
| } |
|
|
| |
| |
| |
|
|
| const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null); |
|
|
| export const usePromptInputAttachments = () => { |
| |
| const provider = useOptionalProviderAttachments(); |
| const local = useContext(LocalAttachmentsContext); |
| const context = provider ?? local; |
| if (!context) { |
| throw new Error( |
| 'usePromptInputAttachments must be used within a PromptInput or PromptInputProvider', |
| ); |
| } |
| return context; |
| }; |
|
|
| export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & { |
| data: FileUIPart & { id: string }; |
| className?: string; |
| }; |
|
|
| export function PromptInputAttachment({ data, className, ...props }: PromptInputAttachmentProps) { |
| const attachments = usePromptInputAttachments(); |
|
|
| const filename = data.filename || ''; |
|
|
| const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file'; |
| const isImage = mediaType === 'image'; |
|
|
| const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment'); |
|
|
| return ( |
| <PromptInputHoverCard> |
| <HoverCardTrigger asChild> |
| <div |
| className={cn( |
| 'group relative flex h-8 cursor-pointer select-none items-center gap-1.5 rounded-md border border-border px-1.5 font-medium text-sm transition-all hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', |
| className, |
| )} |
| key={data.id} |
| {...props} |
| > |
| <div className="relative size-5 shrink-0"> |
| <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0"> |
| {isImage ? ( |
| <img |
| alt={filename || 'attachment'} |
| className="size-5 object-cover" |
| height={20} |
| src={data.url} |
| width={20} |
| /> |
| ) : ( |
| <div className="flex size-5 items-center justify-center text-muted-foreground"> |
| <PaperclipIcon className="size-3" /> |
| </div> |
| )} |
| </div> |
| <Button |
| aria-label="Remove attachment" |
| className="absolute inset-0 size-5 cursor-pointer rounded p-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 [&>svg]:size-2.5" |
| onClick={(e) => { |
| e.stopPropagation(); |
| attachments.remove(data.id); |
| }} |
| type="button" |
| variant="ghost" |
| > |
| <XIcon /> |
| <span className="sr-only">Remove</span> |
| </Button> |
| </div> |
| |
| <span className="flex-1 truncate">{attachmentLabel}</span> |
| </div> |
| </HoverCardTrigger> |
| <PromptInputHoverCardContent className="w-auto p-2"> |
| <div className="w-auto space-y-3"> |
| {isImage && ( |
| <div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border"> |
| <img |
| alt={filename || 'attachment preview'} |
| className="max-h-full max-w-full object-contain" |
| height={384} |
| src={data.url} |
| width={448} |
| /> |
| </div> |
| )} |
| <div className="flex items-center gap-2.5"> |
| <div className="min-w-0 flex-1 space-y-1 px-0.5"> |
| <h4 className="truncate font-semibold text-sm leading-none"> |
| {filename || (isImage ? 'Image' : 'Attachment')} |
| </h4> |
| {data.mediaType && ( |
| <p className="truncate font-mono text-muted-foreground text-xs">{data.mediaType}</p> |
| )} |
| </div> |
| </div> |
| </div> |
| </PromptInputHoverCardContent> |
| </PromptInputHoverCard> |
| ); |
| } |
|
|
| export type PromptInputAttachmentsProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & { |
| children: (attachment: FileUIPart & { id: string }) => ReactNode; |
| }; |
|
|
| export function PromptInputAttachments({ |
| children, |
| className, |
| ...props |
| }: PromptInputAttachmentsProps) { |
| const attachments = usePromptInputAttachments(); |
|
|
| if (!attachments.files.length) { |
| return null; |
| } |
|
|
| return ( |
| <div className={cn('flex flex-wrap items-center gap-2 p-3 w-full', className)} {...props}> |
| {attachments.files.map((file) => ( |
| <Fragment key={file.id}>{children(file)}</Fragment> |
| ))} |
| </div> |
| ); |
| } |
|
|
| export type PromptInputActionAddAttachmentsProps = ComponentProps<typeof DropdownMenuItem> & { |
| label?: string; |
| }; |
|
|
| export const PromptInputActionAddAttachments = ({ |
| label = 'Add photos or files', |
| ...props |
| }: PromptInputActionAddAttachmentsProps) => { |
| const attachments = usePromptInputAttachments(); |
|
|
| return ( |
| <DropdownMenuItem |
| {...props} |
| onSelect={(e) => { |
| e.preventDefault(); |
| attachments.openFileDialog(); |
| }} |
| > |
| <ImageIcon className="mr-2 size-4" /> {label} |
| </DropdownMenuItem> |
| ); |
| }; |
|
|
| export type PromptInputMessage = { |
| text: string; |
| files: FileUIPart[]; |
| }; |
|
|
| export type PromptInputProps = Omit<HTMLAttributes<HTMLFormElement>, 'onSubmit' | 'onError'> & { |
| accept?: string; |
| multiple?: boolean; |
| |
| globalDrop?: boolean; |
| |
| syncHiddenInput?: boolean; |
| |
| maxFiles?: number; |
| maxFileSize?: number; |
| onError?: (err: { code: 'max_files' | 'max_file_size' | 'accept'; message: string }) => void; |
| onSubmit: ( |
| message: PromptInputMessage, |
| event: FormEvent<HTMLFormElement>, |
| ) => void | Promise<void>; |
| }; |
|
|
| export const PromptInput = ({ |
| className, |
| accept, |
| multiple, |
| globalDrop, |
| syncHiddenInput, |
| maxFiles, |
| maxFileSize, |
| onError, |
| onSubmit, |
| children, |
| ...props |
| }: PromptInputProps) => { |
| |
| const controller = useOptionalPromptInputController(); |
| const usingProvider = !!controller; |
|
|
| |
| const inputRef = useRef<HTMLInputElement | null>(null); |
| const formRef = useRef<HTMLFormElement | null>(null); |
|
|
| |
| const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); |
| const files = usingProvider ? controller.attachments.files : items; |
|
|
| |
| const filesRef = useRef(files); |
| useEffect(() => { |
| filesRef.current = files; |
| }, [files]); |
|
|
| const openFileDialogLocal = useCallback(() => { |
| inputRef.current?.click(); |
| }, []); |
|
|
| const matchesAccept = useCallback( |
| (f: File) => { |
| if (!accept || accept.trim() === '') { |
| return true; |
| } |
|
|
| const patterns = accept |
| .split(',') |
| .map((s) => s.trim()) |
| .filter(Boolean); |
|
|
| return patterns.some((pattern) => { |
| if (pattern.endsWith('/*')) { |
| const prefix = pattern.slice(0, -1); |
| return f.type.startsWith(prefix); |
| } |
| return f.type === pattern; |
| }); |
| }, |
| [accept], |
| ); |
|
|
| const addLocal = useCallback( |
| (fileList: File[] | FileList) => { |
| const incoming = Array.from(fileList); |
| const accepted = incoming.filter((f) => matchesAccept(f)); |
| if (incoming.length && accepted.length === 0) { |
| onError?.({ |
| code: 'accept', |
| message: 'No files match the accepted types.', |
| }); |
| return; |
| } |
| const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true); |
| const sized = accepted.filter(withinSize); |
| if (accepted.length > 0 && sized.length === 0) { |
| onError?.({ |
| code: 'max_file_size', |
| message: 'All files exceed the maximum size.', |
| }); |
| return; |
| } |
|
|
| setItems((prev) => { |
| const capacity = |
| typeof maxFiles === 'number' ? Math.max(0, maxFiles - prev.length) : undefined; |
| const capped = typeof capacity === 'number' ? sized.slice(0, capacity) : sized; |
| if (typeof capacity === 'number' && sized.length > capacity) { |
| onError?.({ |
| code: 'max_files', |
| message: 'Too many files. Some were not added.', |
| }); |
| } |
| const next: (FileUIPart & { id: string })[] = []; |
| for (const file of capped) { |
| next.push({ |
| id: nanoid(), |
| type: 'file', |
| url: URL.createObjectURL(file), |
| mediaType: file.type, |
| filename: file.name, |
| }); |
| } |
| return prev.concat(next); |
| }); |
| }, |
| [matchesAccept, maxFiles, maxFileSize, onError], |
| ); |
|
|
| const removeLocal = useCallback( |
| (id: string) => |
| setItems((prev) => { |
| const found = prev.find((file) => file.id === id); |
| if (found?.url) { |
| URL.revokeObjectURL(found.url); |
| } |
| return prev.filter((file) => file.id !== id); |
| }), |
| [], |
| ); |
|
|
| const clearLocal = useCallback( |
| () => |
| setItems((prev) => { |
| for (const file of prev) { |
| if (file.url) { |
| URL.revokeObjectURL(file.url); |
| } |
| } |
| return []; |
| }), |
| [], |
| ); |
|
|
| const add = usingProvider ? controller.attachments.add : addLocal; |
| const remove = usingProvider ? controller.attachments.remove : removeLocal; |
| const clear = usingProvider ? controller.attachments.clear : clearLocal; |
| const openFileDialog = usingProvider |
| ? controller.attachments.openFileDialog |
| : openFileDialogLocal; |
|
|
| |
| useEffect(() => { |
| if (!usingProvider) return; |
| controller.__registerFileInput(inputRef, () => inputRef.current?.click()); |
| }, [usingProvider, controller]); |
|
|
| |
| |
| useEffect(() => { |
| if (syncHiddenInput && inputRef.current && files.length === 0) { |
| inputRef.current.value = ''; |
| } |
| }, [files, syncHiddenInput]); |
|
|
| |
| useEffect(() => { |
| const form = formRef.current; |
| if (!form) return; |
| if (globalDrop) return; |
|
|
| const onDragOver = (e: DragEvent) => { |
| if (e.dataTransfer?.types?.includes('Files')) { |
| e.preventDefault(); |
| } |
| }; |
| const onDrop = (e: DragEvent) => { |
| if (e.dataTransfer?.types?.includes('Files')) { |
| e.preventDefault(); |
| } |
| if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { |
| add(e.dataTransfer.files); |
| } |
| }; |
| form.addEventListener('dragover', onDragOver); |
| form.addEventListener('drop', onDrop); |
| return () => { |
| form.removeEventListener('dragover', onDragOver); |
| form.removeEventListener('drop', onDrop); |
| }; |
| }, [add, globalDrop]); |
|
|
| useEffect(() => { |
| if (!globalDrop) return; |
|
|
| const onDragOver = (e: DragEvent) => { |
| if (e.dataTransfer?.types?.includes('Files')) { |
| e.preventDefault(); |
| } |
| }; |
| const onDrop = (e: DragEvent) => { |
| if (e.dataTransfer?.types?.includes('Files')) { |
| e.preventDefault(); |
| } |
| if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { |
| add(e.dataTransfer.files); |
| } |
| }; |
| document.addEventListener('dragover', onDragOver); |
| document.addEventListener('drop', onDrop); |
| return () => { |
| document.removeEventListener('dragover', onDragOver); |
| document.removeEventListener('drop', onDrop); |
| }; |
| }, [add, globalDrop]); |
|
|
| useEffect( |
| () => () => { |
| if (!usingProvider) { |
| for (const f of filesRef.current) { |
| if (f.url) URL.revokeObjectURL(f.url); |
| } |
| } |
| }, |
|
|
| [usingProvider], |
| ); |
|
|
| const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => { |
| if (event.currentTarget.files) { |
| add(event.currentTarget.files); |
| } |
| |
| event.currentTarget.value = ''; |
| }; |
|
|
| const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => { |
| try { |
| const response = await fetch(url); |
| const blob = await response.blob(); |
| return new Promise((resolve) => { |
| const reader = new FileReader(); |
| reader.onloadend = () => resolve(reader.result as string); |
| reader.onerror = () => resolve(null); |
| reader.readAsDataURL(blob); |
| }); |
| } catch { |
| return null; |
| } |
| }; |
|
|
| const ctx = useMemo<AttachmentsContext>( |
| () => ({ |
| files: files.map((item) => ({ ...item, id: item.id })), |
| add, |
| remove, |
| clear, |
| openFileDialog, |
| fileInputRef: inputRef, |
| }), |
| [files, add, remove, clear, openFileDialog], |
| ); |
|
|
| const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => { |
| event.preventDefault(); |
|
|
| const form = event.currentTarget; |
| const text = usingProvider |
| ? controller.textInput.value |
| : (() => { |
| const formData = new FormData(form); |
| return (formData.get('message') as string) || ''; |
| })(); |
|
|
| |
| |
| if (!usingProvider) { |
| form.reset(); |
| } |
|
|
| |
| Promise.all( |
| files.map(async ({ id: _id, ...item }) => { |
| if (item.url && item.url.startsWith('blob:')) { |
| const dataUrl = await convertBlobUrlToDataUrl(item.url); |
| |
| return { |
| ...item, |
| url: dataUrl ?? item.url, |
| }; |
| } |
| return item; |
| }), |
| ) |
| .then((convertedFiles: FileUIPart[]) => { |
| try { |
| const result = onSubmit({ text, files: convertedFiles }, event); |
|
|
| |
| if (result instanceof Promise) { |
| result |
| .then(() => { |
| clear(); |
| if (usingProvider) { |
| controller.textInput.clear(); |
| } |
| }) |
| .catch(() => { |
| |
| }); |
| } else { |
| |
| clear(); |
| if (usingProvider) { |
| controller.textInput.clear(); |
| } |
| } |
| } catch { |
| |
| } |
| }) |
| .catch(() => { |
| |
| }); |
| }; |
|
|
| |
| const inner = ( |
| <> |
| <input |
| accept={accept} |
| aria-label="Upload files" |
| className="hidden" |
| multiple={multiple} |
| onChange={handleChange} |
| ref={inputRef} |
| title="Upload files" |
| type="file" |
| /> |
| <form className={cn('w-full', className)} onSubmit={handleSubmit} ref={formRef} {...props}> |
| <InputGroup className="overflow-hidden">{children}</InputGroup> |
| </form> |
| </> |
| ); |
|
|
| return usingProvider ? ( |
| inner |
| ) : ( |
| <LocalAttachmentsContext.Provider value={ctx}>{inner}</LocalAttachmentsContext.Provider> |
| ); |
| }; |
|
|
| export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>; |
|
|
| export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => ( |
| <div className={cn('contents', className)} {...props} /> |
| ); |
|
|
| export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>; |
|
|
| export const PromptInputTextarea = ({ |
| onChange, |
| className, |
| placeholder = 'What would you like to know?', |
| ...props |
| }: PromptInputTextareaProps) => { |
| const controller = useOptionalPromptInputController(); |
| const attachments = usePromptInputAttachments(); |
| const [isComposing, setIsComposing] = useState(false); |
|
|
| const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => { |
| if (e.key === 'Enter') { |
| if (isComposing || e.nativeEvent.isComposing) { |
| return; |
| } |
| if (e.shiftKey) { |
| return; |
| } |
| e.preventDefault(); |
|
|
| |
| const form = e.currentTarget.form; |
| const submitButton = form?.querySelector('button[type="submit"]') as HTMLButtonElement | null; |
| if (submitButton?.disabled) { |
| return; |
| } |
|
|
| form?.requestSubmit(); |
| } |
|
|
| |
| if (e.key === 'Backspace' && e.currentTarget.value === '' && attachments.files.length > 0) { |
| e.preventDefault(); |
| const lastAttachment = attachments.files.at(-1); |
| if (lastAttachment) { |
| attachments.remove(lastAttachment.id); |
| } |
| } |
| }; |
|
|
| const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => { |
| const items = event.clipboardData?.items; |
|
|
| if (!items) { |
| return; |
| } |
|
|
| const files: File[] = []; |
|
|
| for (const item of items) { |
| if (item.kind === 'file') { |
| const file = item.getAsFile(); |
| if (file) { |
| files.push(file); |
| } |
| } |
| } |
|
|
| if (files.length > 0) { |
| event.preventDefault(); |
| attachments.add(files); |
| } |
| }; |
|
|
| const controlledProps = controller |
| ? { |
| value: controller.textInput.value, |
| onChange: (e: ChangeEvent<HTMLTextAreaElement>) => { |
| controller.textInput.setInput(e.currentTarget.value); |
| onChange?.(e); |
| }, |
| } |
| : { |
| onChange, |
| }; |
|
|
| return ( |
| <InputGroupTextarea |
| className={cn('field-sizing-content max-h-48 min-h-16', className)} |
| name="message" |
| onCompositionEnd={() => setIsComposing(false)} |
| onCompositionStart={() => setIsComposing(true)} |
| onKeyDown={handleKeyDown} |
| onPaste={handlePaste} |
| placeholder={placeholder} |
| {...props} |
| {...controlledProps} |
| /> |
| ); |
| }; |
|
|
| export type PromptInputHeaderProps = Omit<ComponentProps<typeof InputGroupAddon>, 'align'>; |
|
|
| export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => ( |
| <InputGroupAddon |
| align="block-end" |
| className={cn('order-first flex-wrap gap-1', className)} |
| {...props} |
| /> |
| ); |
|
|
| export type PromptInputFooterProps = Omit<ComponentProps<typeof InputGroupAddon>, 'align'>; |
|
|
| export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => ( |
| <InputGroupAddon |
| align="block-end" |
| className={cn('justify-between gap-1', className)} |
| {...props} |
| /> |
| ); |
|
|
| export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>; |
|
|
| export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => ( |
| <div className={cn('flex items-center gap-1', className)} {...props} /> |
| ); |
|
|
| export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>; |
|
|
| export const PromptInputButton = ({ |
| variant = 'ghost', |
| className, |
| size, |
| ...props |
| }: PromptInputButtonProps) => { |
| const newSize = size ?? (Children.count(props.children) > 1 ? 'sm' : 'icon-sm'); |
|
|
| return ( |
| <InputGroupButton |
| className={cn(className)} |
| size={newSize} |
| type="button" |
| variant={variant} |
| {...props} |
| /> |
| ); |
| }; |
|
|
| export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>; |
| export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ( |
| <DropdownMenu {...props} /> |
| ); |
|
|
| export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; |
|
|
| export const PromptInputActionMenuTrigger = ({ |
| className, |
| children, |
| ...props |
| }: PromptInputActionMenuTriggerProps) => ( |
| <DropdownMenuTrigger asChild> |
| <PromptInputButton className={className} {...props}> |
| {children ?? <PlusIcon className="size-4" />} |
| </PromptInputButton> |
| </DropdownMenuTrigger> |
| ); |
|
|
| export type PromptInputActionMenuContentProps = ComponentProps<typeof DropdownMenuContent>; |
| export const PromptInputActionMenuContent = ({ |
| className, |
| ...props |
| }: PromptInputActionMenuContentProps) => ( |
| <DropdownMenuContent align="start" className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputActionMenuItemProps = ComponentProps<typeof DropdownMenuItem>; |
| export const PromptInputActionMenuItem = ({ |
| className, |
| ...props |
| }: PromptInputActionMenuItemProps) => <DropdownMenuItem className={cn(className)} {...props} />; |
|
|
| |
| |
|
|
| export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & { |
| status?: ChatStatus; |
| }; |
|
|
| export const PromptInputSubmit = ({ |
| className, |
| variant = 'default', |
| size = 'icon-sm', |
| status, |
| children, |
| ...props |
| }: PromptInputSubmitProps) => { |
| let Icon = <CornerDownLeftIcon className="size-4" />; |
|
|
| if (status === 'submitted') { |
| Icon = <Loader2Icon className="size-4 animate-spin" />; |
| } else if (status === 'streaming') { |
| Icon = <SquareIcon className="size-4" />; |
| } else if (status === 'error') { |
| Icon = <XIcon className="size-4" />; |
| } |
|
|
| return ( |
| <InputGroupButton |
| aria-label="Submit" |
| className={cn(className)} |
| size={size} |
| type="submit" |
| variant={variant} |
| {...props} |
| > |
| {children ?? Icon} |
| </InputGroupButton> |
| ); |
| }; |
|
|
| interface SpeechRecognition extends EventTarget { |
| continuous: boolean; |
| interimResults: boolean; |
| lang: string; |
| start(): void; |
| stop(): void; |
| onstart: ((this: SpeechRecognition, ev: Event) => void) | null; |
| onend: ((this: SpeechRecognition, ev: Event) => void) | null; |
| onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; |
| onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null; |
| } |
|
|
| interface SpeechRecognitionEvent extends Event { |
| results: SpeechRecognitionResultList; |
| resultIndex: number; |
| } |
|
|
| type SpeechRecognitionResultList = { |
| readonly length: number; |
| item(index: number): SpeechRecognitionResult; |
| [index: number]: SpeechRecognitionResult; |
| }; |
|
|
| type SpeechRecognitionResult = { |
| readonly length: number; |
| item(index: number): SpeechRecognitionAlternative; |
| [index: number]: SpeechRecognitionAlternative; |
| isFinal: boolean; |
| }; |
|
|
| type SpeechRecognitionAlternative = { |
| script: string; |
| confidence: number; |
| }; |
|
|
| interface SpeechRecognitionErrorEvent extends Event { |
| error: string; |
| } |
|
|
| declare global { |
| interface Window { |
| |
| SpeechRecognition: any; |
| |
| webkitSpeechRecognition: any; |
| } |
| } |
|
|
| export type PromptInputSpeechButtonProps = ComponentProps<typeof PromptInputButton> & { |
| textareaRef?: RefObject<HTMLTextAreaElement | null>; |
| onScriptionChange?: (text: string) => void; |
| }; |
|
|
| export const PromptInputSpeechButton = ({ |
| className, |
| textareaRef, |
| onScriptionChange, |
| ...props |
| }: PromptInputSpeechButtonProps) => { |
| const [isListening, setIsListening] = useState(false); |
| const [recognition, setRecognition] = useState<SpeechRecognition | null>(null); |
| const recognitionRef = useRef<SpeechRecognition | null>(null); |
|
|
| useEffect(() => { |
| if ( |
| typeof window !== 'undefined' && |
| ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) |
| ) { |
| const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; |
| const speechRecognition = new SpeechRecognition(); |
|
|
| speechRecognition.continuous = true; |
| speechRecognition.interimResults = true; |
| speechRecognition.lang = 'en-US'; |
|
|
| speechRecognition.onstart = () => { |
| setIsListening(true); |
| }; |
|
|
| speechRecognition.onend = () => { |
| setIsListening(false); |
| }; |
|
|
| speechRecognition.onresult = (event: SpeechRecognitionEvent) => { |
| let finalScript = ''; |
|
|
| for (let i = event.resultIndex; i < event.results.length; i++) { |
| const result = event.results[i]; |
| if (result.isFinal) { |
| finalScript += result[0]?.script ?? ''; |
| } |
| } |
|
|
| if (finalScript && textareaRef?.current) { |
| const textarea = textareaRef.current; |
| const currentValue = textarea.value; |
| const newValue = currentValue + (currentValue ? ' ' : '') + finalScript; |
|
|
| textarea.value = newValue; |
| textarea.dispatchEvent(new Event('input', { bubbles: true })); |
| onScriptionChange?.(newValue); |
| } |
| }; |
|
|
| speechRecognition.onerror = (event: SpeechRecognitionErrorEvent) => { |
| log.error('Speech recognition error:', event.error); |
| setIsListening(false); |
| }; |
|
|
| recognitionRef.current = speechRecognition; |
| |
| setRecognition(speechRecognition); |
| } |
|
|
| return () => { |
| if (recognitionRef.current) { |
| recognitionRef.current.stop(); |
| } |
| }; |
| }, [textareaRef, onScriptionChange]); |
|
|
| const toggleListening = useCallback(() => { |
| if (!recognition) { |
| return; |
| } |
|
|
| if (isListening) { |
| recognition.stop(); |
| } else { |
| recognition.start(); |
| } |
| }, [recognition, isListening]); |
|
|
| return ( |
| <PromptInputButton |
| className={cn( |
| 'relative transition-all duration-200', |
| isListening && 'animate-pulse bg-accent text-accent-foreground', |
| className, |
| )} |
| disabled={!recognition} |
| onClick={toggleListening} |
| {...props} |
| > |
| <MicIcon className="size-4" /> |
| </PromptInputButton> |
| ); |
| }; |
|
|
| export type PromptInputSelectProps = ComponentProps<typeof Select>; |
|
|
| export const PromptInputSelect = (props: PromptInputSelectProps) => <Select {...props} />; |
|
|
| export type PromptInputSelectTriggerProps = ComponentProps<typeof SelectTrigger>; |
|
|
| export const PromptInputSelectTrigger = ({ |
| className, |
| ...props |
| }: PromptInputSelectTriggerProps) => ( |
| <SelectTrigger |
| className={cn( |
| 'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors', |
| 'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground', |
| className, |
| )} |
| {...props} |
| /> |
| ); |
|
|
| export type PromptInputSelectContentProps = ComponentProps<typeof SelectContent>; |
|
|
| export const PromptInputSelectContent = ({ |
| className, |
| ...props |
| }: PromptInputSelectContentProps) => <SelectContent className={cn(className)} {...props} />; |
|
|
| export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>; |
|
|
| export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => ( |
| <SelectItem className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>; |
|
|
| export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => ( |
| <SelectValue className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>; |
|
|
| export const PromptInputHoverCard = ({ |
| openDelay = 0, |
| closeDelay = 0, |
| ...props |
| }: PromptInputHoverCardProps) => ( |
| <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} /> |
| ); |
|
|
| export type PromptInputHoverCardTriggerProps = ComponentProps<typeof HoverCardTrigger>; |
|
|
| export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => ( |
| <HoverCardTrigger {...props} /> |
| ); |
|
|
| export type PromptInputHoverCardContentProps = ComponentProps<typeof HoverCardContent>; |
|
|
| export const PromptInputHoverCardContent = ({ |
| align = 'start', |
| ...props |
| }: PromptInputHoverCardContentProps) => <HoverCardContent align={align} {...props} />; |
|
|
| export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>; |
|
|
| export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => ( |
| <div className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>; |
|
|
| export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => ( |
| <div className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>; |
|
|
| export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => ( |
| <h3 className={cn('mb-2 px-3 font-medium text-muted-foreground text-xs', className)} {...props} /> |
| ); |
|
|
| export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>; |
|
|
| export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => ( |
| <div className={cn('space-y-1', className)} {...props} /> |
| ); |
|
|
| export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>; |
|
|
| export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => ( |
| <div |
| className={cn('flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent', className)} |
| {...props} |
| /> |
| ); |
|
|
| export type PromptInputCommandProps = ComponentProps<typeof Command>; |
|
|
| export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => ( |
| <Command className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>; |
|
|
| export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => ( |
| <CommandInput className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputCommandListProps = ComponentProps<typeof CommandList>; |
|
|
| export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => ( |
| <CommandList className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>; |
|
|
| export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => ( |
| <CommandEmpty className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>; |
|
|
| export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => ( |
| <CommandGroup className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>; |
|
|
| export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => ( |
| <CommandItem className={cn(className)} {...props} /> |
| ); |
|
|
| export type PromptInputCommandSeparatorProps = ComponentProps<typeof CommandSeparator>; |
|
|
| export const PromptInputCommandSeparator = ({ |
| className, |
| ...props |
| }: PromptInputCommandSeparatorProps) => <CommandSeparator className={cn(className)} {...props} />; |
|
|