| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267 |
- '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} />;
|