| 'use client';
|
|
|
| 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';
|
| import type { ChatStatus, FileUIPart } from 'ai';
|
| import { CornerDownLeftIcon, ImageIcon, Loader2Icon, MicIcon, PaperclipIcon, PlusIcon, SquareIcon, XIcon } from 'lucide-react';
|
| import { nanoid } from 'nanoid';
|
| import { cn } from '@/lib/utils';
|
| 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';
|
|
|
|
|
|
|
|
|
|
|
| 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 [attachements, setAttachements] = 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;
|
| }
|
|
|
| setAttachements((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) => {
|
| setAttachements((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(() => {
|
| setAttachements((prev) => {
|
| for (const f of prev) {
|
| if (f.url) {
|
| URL.revokeObjectURL(f.url);
|
| }
|
| }
|
| return [];
|
| });
|
| }, []);
|
|
|
| const openFileDialog = useCallback(() => {
|
| openRef.current?.();
|
| }, []);
|
|
|
| const attachments = useMemo<AttachmentsContext>(
|
| () => ({
|
| files: attachements,
|
| add,
|
| remove,
|
| clear,
|
| openFileDialog,
|
| fileInputRef,
|
| }),
|
| [attachements, 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 border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-default items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none',
|
| className
|
| )}
|
| key={data.id}
|
| {...props}
|
| >
|
| <div className='relative size-5 shrink-0'>
|
| <div className='bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded 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='text-muted-foreground flex size-5 items-center justify-center'>
|
| <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 text-sm leading-none font-semibold'>{filename || (isImage ? 'Image' : 'Attachment')}</h4>
|
| {data.mediaType && <p className='text-muted-foreground truncate font-mono 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', 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 anchorRef = useRef<HTMLSpanElement>(null);
|
| const formRef = useRef<HTMLFormElement | null>(null);
|
|
|
|
|
| useEffect(() => {
|
| const root = anchorRef.current?.closest('form');
|
| if (root instanceof HTMLFormElement) {
|
| formRef.current = root;
|
| }
|
| }, []);
|
|
|
|
|
| const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
|
| const files = usingProvider ? controller.attachments.files : items;
|
|
|
| const openFileDialogLocal = useCallback(() => {
|
| inputRef.current?.click();
|
| }, []);
|
|
|
| const matchesAccept = useCallback(
|
| (f: File) => {
|
| if (!accept || accept.trim() === '') {
|
| return true;
|
| }
|
| if (accept.includes('image/*')) {
|
| return f.type.startsWith('image/');
|
| }
|
|
|
| return true;
|
| },
|
| [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 add = usingProvider ? (files: File[] | FileList) => controller.attachments.add(files) : addLocal;
|
|
|
| const remove = usingProvider
|
| ? (id: string) => controller.attachments.remove(id)
|
| : (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 clear = usingProvider
|
| ? () => controller.attachments.clear()
|
| : () =>
|
| setItems((prev) => {
|
| for (const file of prev) {
|
| if (file.url) {
|
| URL.revokeObjectURL(file.url);
|
| }
|
| }
|
| return [];
|
| });
|
|
|
| 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;
|
|
|
| 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]);
|
|
|
| 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 files) {
|
| if (f.url) URL.revokeObjectURL(f.url);
|
| }
|
| }
|
| },
|
| [usingProvider, files]
|
| );
|
|
|
| const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
| if (event.currentTarget.files) {
|
| add(event.currentTarget.files);
|
| }
|
| };
|
|
|
| const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {
|
| const response = await fetch(url);
|
| const blob = await response.blob();
|
| return new Promise((resolve, reject) => {
|
| const reader = new FileReader();
|
| reader.onloadend = () => resolve(reader.result as string);
|
| reader.onerror = reject;
|
| reader.readAsDataURL(blob);
|
| });
|
| };
|
|
|
| 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, ...item }) => {
|
| if (item.url && item.url.startsWith('blob:')) {
|
| return {
|
| ...item,
|
| url: await convertBlobUrlToDataUrl(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 (error) {
|
|
|
| }
|
| });
|
| };
|
|
|
|
|
| const inner = (
|
| <>
|
| <span aria-hidden='true' className='hidden' ref={anchorRef} />
|
| <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} {...props}>
|
| <InputGroup>{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) => any) | null;
|
| onend: ((this: SpeechRecognition, ev: Event) => any) | null;
|
| onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any) | null;
|
| onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any) | null;
|
| }
|
|
|
| interface SpeechRecognitionEvent extends Event {
|
| results: SpeechRecognitionResultList;
|
| }
|
|
|
| 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 = {
|
| transcript: string;
|
| confidence: number;
|
| };
|
|
|
| interface SpeechRecognitionErrorEvent extends Event {
|
| error: string;
|
| }
|
|
|
| declare global {
|
| interface Window {
|
| SpeechRecognition: {
|
| new (): SpeechRecognition;
|
| };
|
| webkitSpeechRecognition: {
|
| new (): SpeechRecognition;
|
| };
|
| }
|
| }
|
|
|
| export type PromptInputSpeechButtonProps = ComponentProps<typeof PromptInputButton> & {
|
| textareaRef?: RefObject<HTMLTextAreaElement | null>;
|
| onTranscriptionChange?: (text: string) => void;
|
| };
|
|
|
| export const PromptInputSpeechButton = ({ className, textareaRef, onTranscriptionChange, ...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) => {
|
| let finalTranscript = '';
|
|
|
| const results = Array.from(event.results);
|
|
|
| for (const result of results) {
|
| if (result.isFinal) {
|
| finalTranscript += result[0]?.transcript ?? '';
|
| }
|
| }
|
|
|
| if (finalTranscript && textareaRef?.current) {
|
| const textarea = textareaRef.current;
|
| const currentValue = textarea.value;
|
| const newValue = currentValue + (currentValue ? ' ' : '') + finalTranscript;
|
|
|
| textarea.value = newValue;
|
| textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
| onTranscriptionChange?.(newValue);
|
| }
|
| };
|
|
|
| speechRecognition.onerror = (event) => {
|
| setIsListening(false);
|
| };
|
|
|
| recognitionRef.current = speechRecognition;
|
| setRecognition(speechRecognition);
|
| }
|
|
|
| return () => {
|
| if (recognitionRef.current) {
|
| recognitionRef.current.stop();
|
| }
|
| };
|
| }, [textareaRef, onTranscriptionChange]);
|
|
|
| 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 && 'bg-accent text-accent-foreground animate-pulse', 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(
|
| 'text-muted-foreground border-none bg-transparent font-medium 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('text-muted-foreground mb-2 px-3 text-xs font-medium', 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('hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs', 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} />
|
| );
|
|
|