Spaces:
No application file
No application file
| 'use client'; | |
| 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'; | |
| // ============================================================================ | |
| // Provider Context & Types | |
| // ============================================================================ | |
| 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; | |
| /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ | |
| __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; | |
| }; | |
| // Optional variants (do NOT throw). Useful for dual-mode components. | |
| 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; | |
| }>; | |
| /** | |
| * Optional global provider that lifts PromptInput state outside of PromptInput. | |
| * If you don't use it, PromptInput stays fully self-managed. | |
| */ | |
| export function PromptInputProvider({ | |
| initialInput: initialTextInput = '', | |
| children, | |
| }: PromptInputProviderProps) { | |
| // ----- textInput state | |
| const [textInput, setTextInput] = useState(initialTextInput); | |
| const clearInput = useCallback(() => setTextInput(''), []); | |
| // ----- attachments state (global when wrapped) | |
| 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 []; | |
| }); | |
| }, []); | |
| // Keep a ref to attachments for cleanup on unmount (avoids stale closure) | |
| const attachmentsRef = useRef(attachmentFiles); | |
| useEffect(() => { | |
| attachmentsRef.current = attachmentFiles; | |
| }, [attachmentFiles]); | |
| // Cleanup blob URLs on unmount to prevent memory leaks | |
| 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> | |
| ); | |
| } | |
| // ============================================================================ | |
| // Component Context & Hooks | |
| // ============================================================================ | |
| const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null); | |
| export const usePromptInputAttachments = () => { | |
| // Dual-mode: prefer provider if present, otherwise use local | |
| 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; // e.g., "image/*" or leave undefined for any | |
| multiple?: boolean; | |
| // When true, accepts drops anywhere on document. Default false (opt-in). | |
| globalDrop?: boolean; | |
| // Render a hidden input with given name and keep it in sync for native form posts. Default false. | |
| syncHiddenInput?: boolean; | |
| // Minimal constraints | |
| maxFiles?: number; | |
| maxFileSize?: number; // bytes | |
| 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) => { | |
| // Try to use a provider controller if present | |
| const controller = useOptionalPromptInputController(); | |
| const usingProvider = !!controller; | |
| // Refs | |
| const inputRef = useRef<HTMLInputElement | null>(null); | |
| const formRef = useRef<HTMLFormElement | null>(null); | |
| // ----- Local attachments (only used when no provider) | |
| const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); | |
| const files = usingProvider ? controller.attachments.files : items; | |
| // Keep a ref to files for cleanup on unmount (avoids stale closure) | |
| 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); // e.g: image/* -> image/ | |
| 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; | |
| // Let provider know about our hidden file input so external menus can call openFileDialog() | |
| useEffect(() => { | |
| if (!usingProvider) return; | |
| controller.__registerFileInput(inputRef, () => inputRef.current?.click()); | |
| }, [usingProvider, controller]); | |
| // Note: File input cannot be programmatically set for security reasons | |
| // The syncHiddenInput prop is no longer functional | |
| useEffect(() => { | |
| if (syncHiddenInput && inputRef.current && files.length === 0) { | |
| inputRef.current.value = ''; | |
| } | |
| }, [files, syncHiddenInput]); | |
| // Attach drop handlers on nearest form and document (opt-in) | |
| useEffect(() => { | |
| const form = formRef.current; | |
| if (!form) return; | |
| if (globalDrop) return; // when global drop is on, let the document-level handler own drops | |
| 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); | |
| } | |
| // Reset input value to allow selecting files that were previously removed | |
| 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) || ''; | |
| })(); | |
| // Reset form immediately after capturing text to avoid race condition | |
| // where user input during async blob conversion would be lost | |
| if (!usingProvider) { | |
| form.reset(); | |
| } | |
| // Convert blob URLs to data URLs asynchronously | |
| Promise.all( | |
| files.map(async ({ id: _id, ...item }) => { | |
| if (item.url && item.url.startsWith('blob:')) { | |
| const dataUrl = await convertBlobUrlToDataUrl(item.url); | |
| // If conversion failed, keep the original blob URL | |
| return { | |
| ...item, | |
| url: dataUrl ?? item.url, | |
| }; | |
| } | |
| return item; | |
| }), | |
| ) | |
| .then((convertedFiles: FileUIPart[]) => { | |
| try { | |
| const result = onSubmit({ text, files: convertedFiles }, event); | |
| // Handle both sync and async onSubmit | |
| if (result instanceof Promise) { | |
| result | |
| .then(() => { | |
| clear(); | |
| if (usingProvider) { | |
| controller.textInput.clear(); | |
| } | |
| }) | |
| .catch(() => { | |
| // Don't clear on error - user may want to retry | |
| }); | |
| } else { | |
| // Sync function completed without throwing, clear attachments | |
| clear(); | |
| if (usingProvider) { | |
| controller.textInput.clear(); | |
| } | |
| } | |
| } catch { | |
| // Don't clear on error - user may want to retry | |
| } | |
| }) | |
| .catch(() => { | |
| // Don't clear on error - user may want to retry | |
| }); | |
| }; | |
| // Render with or without local provider | |
| 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(); | |
| // Check if the submit button is disabled before submitting | |
| const form = e.currentTarget.form; | |
| const submitButton = form?.querySelector('button[type="submit"]') as HTMLButtonElement | null; | |
| if (submitButton?.disabled) { | |
| return; | |
| } | |
| form?.requestSubmit(); | |
| } | |
| // Remove last attachment when Backspace is pressed and textarea is empty | |
| 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} />; | |
| // Note: Actions that perform side-effects (like opening a file dialog) | |
| // are provided in opt-in modules (e.g., prompt-input-attachments). | |
| 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 { | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| SpeechRecognition: any; | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-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; | |
| // eslint-disable-next-line react-hooks/set-state-in-effect -- Initial sync from external API | |
| 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} />; | |