prompt-input.tsx 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267
  1. 'use client';
  2. import { Button } from '@/components/ui/button';
  3. import {
  4. Command,
  5. CommandEmpty,
  6. CommandGroup,
  7. CommandInput,
  8. CommandItem,
  9. CommandList,
  10. CommandSeparator,
  11. } from '@/components/ui/command';
  12. import {
  13. DropdownMenu,
  14. DropdownMenuContent,
  15. DropdownMenuItem,
  16. DropdownMenuTrigger,
  17. } from '@/components/ui/dropdown-menu';
  18. import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
  19. import {
  20. InputGroup,
  21. InputGroupAddon,
  22. InputGroupButton,
  23. InputGroupTextarea,
  24. } from '@/components/ui/input-group';
  25. import {
  26. Select,
  27. SelectContent,
  28. SelectItem,
  29. SelectTrigger,
  30. SelectValue,
  31. } from '@/components/ui/select';
  32. import { cn } from '@/lib/utils';
  33. import { createLogger } from '@/lib/logger';
  34. import type { ChatStatus, FileUIPart } from 'ai';
  35. const log = createLogger('PromptInput');
  36. import {
  37. CornerDownLeftIcon,
  38. ImageIcon,
  39. Loader2Icon,
  40. MicIcon,
  41. PaperclipIcon,
  42. PlusIcon,
  43. SquareIcon,
  44. XIcon,
  45. } from 'lucide-react';
  46. import { nanoid } from 'nanoid';
  47. import {
  48. type ChangeEvent,
  49. type ChangeEventHandler,
  50. Children,
  51. type ClipboardEventHandler,
  52. type ComponentProps,
  53. createContext,
  54. type FormEvent,
  55. type FormEventHandler,
  56. Fragment,
  57. type HTMLAttributes,
  58. type KeyboardEventHandler,
  59. type PropsWithChildren,
  60. type ReactNode,
  61. type RefObject,
  62. useCallback,
  63. useContext,
  64. useEffect,
  65. useMemo,
  66. useRef,
  67. useState,
  68. } from 'react';
  69. // ============================================================================
  70. // Provider Context & Types
  71. // ============================================================================
  72. export type AttachmentsContext = {
  73. files: (FileUIPart & { id: string })[];
  74. add: (files: File[] | FileList) => void;
  75. remove: (id: string) => void;
  76. clear: () => void;
  77. openFileDialog: () => void;
  78. fileInputRef: RefObject<HTMLInputElement | null>;
  79. };
  80. export type TextInputContext = {
  81. value: string;
  82. setInput: (v: string) => void;
  83. clear: () => void;
  84. };
  85. export type PromptInputControllerProps = {
  86. textInput: TextInputContext;
  87. attachments: AttachmentsContext;
  88. /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
  89. __registerFileInput: (ref: RefObject<HTMLInputElement | null>, open: () => void) => void;
  90. };
  91. const PromptInputController = createContext<PromptInputControllerProps | null>(null);
  92. const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(null);
  93. export const usePromptInputController = () => {
  94. const ctx = useContext(PromptInputController);
  95. if (!ctx) {
  96. throw new Error(
  97. 'Wrap your component inside <PromptInputProvider> to use usePromptInputController().',
  98. );
  99. }
  100. return ctx;
  101. };
  102. // Optional variants (do NOT throw). Useful for dual-mode components.
  103. const useOptionalPromptInputController = () => useContext(PromptInputController);
  104. export const useProviderAttachments = () => {
  105. const ctx = useContext(ProviderAttachmentsContext);
  106. if (!ctx) {
  107. throw new Error(
  108. 'Wrap your component inside <PromptInputProvider> to use useProviderAttachments().',
  109. );
  110. }
  111. return ctx;
  112. };
  113. const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext);
  114. export type PromptInputProviderProps = PropsWithChildren<{
  115. initialInput?: string;
  116. }>;
  117. /**
  118. * Optional global provider that lifts PromptInput state outside of PromptInput.
  119. * If you don't use it, PromptInput stays fully self-managed.
  120. */
  121. export function PromptInputProvider({
  122. initialInput: initialTextInput = '',
  123. children,
  124. }: PromptInputProviderProps) {
  125. // ----- textInput state
  126. const [textInput, setTextInput] = useState(initialTextInput);
  127. const clearInput = useCallback(() => setTextInput(''), []);
  128. // ----- attachments state (global when wrapped)
  129. const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]);
  130. const fileInputRef = useRef<HTMLInputElement | null>(null);
  131. const openRef = useRef<() => void>(() => {});
  132. const add = useCallback((files: File[] | FileList) => {
  133. const incoming = Array.from(files);
  134. if (incoming.length === 0) {
  135. return;
  136. }
  137. setAttachmentFiles((prev) =>
  138. prev.concat(
  139. incoming.map((file) => ({
  140. id: nanoid(),
  141. type: 'file' as const,
  142. url: URL.createObjectURL(file),
  143. mediaType: file.type,
  144. filename: file.name,
  145. })),
  146. ),
  147. );
  148. }, []);
  149. const remove = useCallback((id: string) => {
  150. setAttachmentFiles((prev) => {
  151. const found = prev.find((f) => f.id === id);
  152. if (found?.url) {
  153. URL.revokeObjectURL(found.url);
  154. }
  155. return prev.filter((f) => f.id !== id);
  156. });
  157. }, []);
  158. const clear = useCallback(() => {
  159. setAttachmentFiles((prev) => {
  160. for (const f of prev) {
  161. if (f.url) {
  162. URL.revokeObjectURL(f.url);
  163. }
  164. }
  165. return [];
  166. });
  167. }, []);
  168. // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
  169. const attachmentsRef = useRef(attachmentFiles);
  170. useEffect(() => {
  171. attachmentsRef.current = attachmentFiles;
  172. }, [attachmentFiles]);
  173. // Cleanup blob URLs on unmount to prevent memory leaks
  174. useEffect(() => {
  175. return () => {
  176. for (const f of attachmentsRef.current) {
  177. if (f.url) {
  178. URL.revokeObjectURL(f.url);
  179. }
  180. }
  181. };
  182. }, []);
  183. const openFileDialog = useCallback(() => {
  184. openRef.current?.();
  185. }, []);
  186. const attachments = useMemo<AttachmentsContext>(
  187. () => ({
  188. files: attachmentFiles,
  189. add,
  190. remove,
  191. clear,
  192. openFileDialog,
  193. fileInputRef,
  194. }),
  195. [attachmentFiles, add, remove, clear, openFileDialog],
  196. );
  197. const __registerFileInput = useCallback(
  198. (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
  199. fileInputRef.current = ref.current;
  200. openRef.current = open;
  201. },
  202. [],
  203. );
  204. const controller = useMemo<PromptInputControllerProps>(
  205. () => ({
  206. textInput: {
  207. value: textInput,
  208. setInput: setTextInput,
  209. clear: clearInput,
  210. },
  211. attachments,
  212. __registerFileInput,
  213. }),
  214. [textInput, clearInput, attachments, __registerFileInput],
  215. );
  216. return (
  217. <PromptInputController.Provider value={controller}>
  218. <ProviderAttachmentsContext.Provider value={attachments}>
  219. {children}
  220. </ProviderAttachmentsContext.Provider>
  221. </PromptInputController.Provider>
  222. );
  223. }
  224. // ============================================================================
  225. // Component Context & Hooks
  226. // ============================================================================
  227. const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
  228. export const usePromptInputAttachments = () => {
  229. // Dual-mode: prefer provider if present, otherwise use local
  230. const provider = useOptionalProviderAttachments();
  231. const local = useContext(LocalAttachmentsContext);
  232. const context = provider ?? local;
  233. if (!context) {
  234. throw new Error(
  235. 'usePromptInputAttachments must be used within a PromptInput or PromptInputProvider',
  236. );
  237. }
  238. return context;
  239. };
  240. export type PromptInputAttachmentProps = HTMLAttributes<HTMLDivElement> & {
  241. data: FileUIPart & { id: string };
  242. className?: string;
  243. };
  244. export function PromptInputAttachment({ data, className, ...props }: PromptInputAttachmentProps) {
  245. const attachments = usePromptInputAttachments();
  246. const filename = data.filename || '';
  247. const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file';
  248. const isImage = mediaType === 'image';
  249. const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment');
  250. return (
  251. <PromptInputHoverCard>
  252. <HoverCardTrigger asChild>
  253. <div
  254. className={cn(
  255. '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',
  256. className,
  257. )}
  258. key={data.id}
  259. {...props}
  260. >
  261. <div className="relative size-5 shrink-0">
  262. <div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background transition-opacity group-hover:opacity-0">
  263. {isImage ? (
  264. <img
  265. alt={filename || 'attachment'}
  266. className="size-5 object-cover"
  267. height={20}
  268. src={data.url}
  269. width={20}
  270. />
  271. ) : (
  272. <div className="flex size-5 items-center justify-center text-muted-foreground">
  273. <PaperclipIcon className="size-3" />
  274. </div>
  275. )}
  276. </div>
  277. <Button
  278. aria-label="Remove attachment"
  279. 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"
  280. onClick={(e) => {
  281. e.stopPropagation();
  282. attachments.remove(data.id);
  283. }}
  284. type="button"
  285. variant="ghost"
  286. >
  287. <XIcon />
  288. <span className="sr-only">Remove</span>
  289. </Button>
  290. </div>
  291. <span className="flex-1 truncate">{attachmentLabel}</span>
  292. </div>
  293. </HoverCardTrigger>
  294. <PromptInputHoverCardContent className="w-auto p-2">
  295. <div className="w-auto space-y-3">
  296. {isImage && (
  297. <div className="flex max-h-96 w-96 items-center justify-center overflow-hidden rounded-md border">
  298. <img
  299. alt={filename || 'attachment preview'}
  300. className="max-h-full max-w-full object-contain"
  301. height={384}
  302. src={data.url}
  303. width={448}
  304. />
  305. </div>
  306. )}
  307. <div className="flex items-center gap-2.5">
  308. <div className="min-w-0 flex-1 space-y-1 px-0.5">
  309. <h4 className="truncate font-semibold text-sm leading-none">
  310. {filename || (isImage ? 'Image' : 'Attachment')}
  311. </h4>
  312. {data.mediaType && (
  313. <p className="truncate font-mono text-muted-foreground text-xs">{data.mediaType}</p>
  314. )}
  315. </div>
  316. </div>
  317. </div>
  318. </PromptInputHoverCardContent>
  319. </PromptInputHoverCard>
  320. );
  321. }
  322. export type PromptInputAttachmentsProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
  323. children: (attachment: FileUIPart & { id: string }) => ReactNode;
  324. };
  325. export function PromptInputAttachments({
  326. children,
  327. className,
  328. ...props
  329. }: PromptInputAttachmentsProps) {
  330. const attachments = usePromptInputAttachments();
  331. if (!attachments.files.length) {
  332. return null;
  333. }
  334. return (
  335. <div className={cn('flex flex-wrap items-center gap-2 p-3 w-full', className)} {...props}>
  336. {attachments.files.map((file) => (
  337. <Fragment key={file.id}>{children(file)}</Fragment>
  338. ))}
  339. </div>
  340. );
  341. }
  342. export type PromptInputActionAddAttachmentsProps = ComponentProps<typeof DropdownMenuItem> & {
  343. label?: string;
  344. };
  345. export const PromptInputActionAddAttachments = ({
  346. label = 'Add photos or files',
  347. ...props
  348. }: PromptInputActionAddAttachmentsProps) => {
  349. const attachments = usePromptInputAttachments();
  350. return (
  351. <DropdownMenuItem
  352. {...props}
  353. onSelect={(e) => {
  354. e.preventDefault();
  355. attachments.openFileDialog();
  356. }}
  357. >
  358. <ImageIcon className="mr-2 size-4" /> {label}
  359. </DropdownMenuItem>
  360. );
  361. };
  362. export type PromptInputMessage = {
  363. text: string;
  364. files: FileUIPart[];
  365. };
  366. export type PromptInputProps = Omit<HTMLAttributes<HTMLFormElement>, 'onSubmit' | 'onError'> & {
  367. accept?: string; // e.g., "image/*" or leave undefined for any
  368. multiple?: boolean;
  369. // When true, accepts drops anywhere on document. Default false (opt-in).
  370. globalDrop?: boolean;
  371. // Render a hidden input with given name and keep it in sync for native form posts. Default false.
  372. syncHiddenInput?: boolean;
  373. // Minimal constraints
  374. maxFiles?: number;
  375. maxFileSize?: number; // bytes
  376. onError?: (err: { code: 'max_files' | 'max_file_size' | 'accept'; message: string }) => void;
  377. onSubmit: (
  378. message: PromptInputMessage,
  379. event: FormEvent<HTMLFormElement>,
  380. ) => void | Promise<void>;
  381. };
  382. export const PromptInput = ({
  383. className,
  384. accept,
  385. multiple,
  386. globalDrop,
  387. syncHiddenInput,
  388. maxFiles,
  389. maxFileSize,
  390. onError,
  391. onSubmit,
  392. children,
  393. ...props
  394. }: PromptInputProps) => {
  395. // Try to use a provider controller if present
  396. const controller = useOptionalPromptInputController();
  397. const usingProvider = !!controller;
  398. // Refs
  399. const inputRef = useRef<HTMLInputElement | null>(null);
  400. const formRef = useRef<HTMLFormElement | null>(null);
  401. // ----- Local attachments (only used when no provider)
  402. const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
  403. const files = usingProvider ? controller.attachments.files : items;
  404. // Keep a ref to files for cleanup on unmount (avoids stale closure)
  405. const filesRef = useRef(files);
  406. useEffect(() => {
  407. filesRef.current = files;
  408. }, [files]);
  409. const openFileDialogLocal = useCallback(() => {
  410. inputRef.current?.click();
  411. }, []);
  412. const matchesAccept = useCallback(
  413. (f: File) => {
  414. if (!accept || accept.trim() === '') {
  415. return true;
  416. }
  417. const patterns = accept
  418. .split(',')
  419. .map((s) => s.trim())
  420. .filter(Boolean);
  421. return patterns.some((pattern) => {
  422. if (pattern.endsWith('/*')) {
  423. const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
  424. return f.type.startsWith(prefix);
  425. }
  426. return f.type === pattern;
  427. });
  428. },
  429. [accept],
  430. );
  431. const addLocal = useCallback(
  432. (fileList: File[] | FileList) => {
  433. const incoming = Array.from(fileList);
  434. const accepted = incoming.filter((f) => matchesAccept(f));
  435. if (incoming.length && accepted.length === 0) {
  436. onError?.({
  437. code: 'accept',
  438. message: 'No files match the accepted types.',
  439. });
  440. return;
  441. }
  442. const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true);
  443. const sized = accepted.filter(withinSize);
  444. if (accepted.length > 0 && sized.length === 0) {
  445. onError?.({
  446. code: 'max_file_size',
  447. message: 'All files exceed the maximum size.',
  448. });
  449. return;
  450. }
  451. setItems((prev) => {
  452. const capacity =
  453. typeof maxFiles === 'number' ? Math.max(0, maxFiles - prev.length) : undefined;
  454. const capped = typeof capacity === 'number' ? sized.slice(0, capacity) : sized;
  455. if (typeof capacity === 'number' && sized.length > capacity) {
  456. onError?.({
  457. code: 'max_files',
  458. message: 'Too many files. Some were not added.',
  459. });
  460. }
  461. const next: (FileUIPart & { id: string })[] = [];
  462. for (const file of capped) {
  463. next.push({
  464. id: nanoid(),
  465. type: 'file',
  466. url: URL.createObjectURL(file),
  467. mediaType: file.type,
  468. filename: file.name,
  469. });
  470. }
  471. return prev.concat(next);
  472. });
  473. },
  474. [matchesAccept, maxFiles, maxFileSize, onError],
  475. );
  476. const removeLocal = useCallback(
  477. (id: string) =>
  478. setItems((prev) => {
  479. const found = prev.find((file) => file.id === id);
  480. if (found?.url) {
  481. URL.revokeObjectURL(found.url);
  482. }
  483. return prev.filter((file) => file.id !== id);
  484. }),
  485. [],
  486. );
  487. const clearLocal = useCallback(
  488. () =>
  489. setItems((prev) => {
  490. for (const file of prev) {
  491. if (file.url) {
  492. URL.revokeObjectURL(file.url);
  493. }
  494. }
  495. return [];
  496. }),
  497. [],
  498. );
  499. const add = usingProvider ? controller.attachments.add : addLocal;
  500. const remove = usingProvider ? controller.attachments.remove : removeLocal;
  501. const clear = usingProvider ? controller.attachments.clear : clearLocal;
  502. const openFileDialog = usingProvider
  503. ? controller.attachments.openFileDialog
  504. : openFileDialogLocal;
  505. // Let provider know about our hidden file input so external menus can call openFileDialog()
  506. useEffect(() => {
  507. if (!usingProvider) return;
  508. controller.__registerFileInput(inputRef, () => inputRef.current?.click());
  509. }, [usingProvider, controller]);
  510. // Note: File input cannot be programmatically set for security reasons
  511. // The syncHiddenInput prop is no longer functional
  512. useEffect(() => {
  513. if (syncHiddenInput && inputRef.current && files.length === 0) {
  514. inputRef.current.value = '';
  515. }
  516. }, [files, syncHiddenInput]);
  517. // Attach drop handlers on nearest form and document (opt-in)
  518. useEffect(() => {
  519. const form = formRef.current;
  520. if (!form) return;
  521. if (globalDrop) return; // when global drop is on, let the document-level handler own drops
  522. const onDragOver = (e: DragEvent) => {
  523. if (e.dataTransfer?.types?.includes('Files')) {
  524. e.preventDefault();
  525. }
  526. };
  527. const onDrop = (e: DragEvent) => {
  528. if (e.dataTransfer?.types?.includes('Files')) {
  529. e.preventDefault();
  530. }
  531. if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
  532. add(e.dataTransfer.files);
  533. }
  534. };
  535. form.addEventListener('dragover', onDragOver);
  536. form.addEventListener('drop', onDrop);
  537. return () => {
  538. form.removeEventListener('dragover', onDragOver);
  539. form.removeEventListener('drop', onDrop);
  540. };
  541. }, [add, globalDrop]);
  542. useEffect(() => {
  543. if (!globalDrop) return;
  544. const onDragOver = (e: DragEvent) => {
  545. if (e.dataTransfer?.types?.includes('Files')) {
  546. e.preventDefault();
  547. }
  548. };
  549. const onDrop = (e: DragEvent) => {
  550. if (e.dataTransfer?.types?.includes('Files')) {
  551. e.preventDefault();
  552. }
  553. if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
  554. add(e.dataTransfer.files);
  555. }
  556. };
  557. document.addEventListener('dragover', onDragOver);
  558. document.addEventListener('drop', onDrop);
  559. return () => {
  560. document.removeEventListener('dragover', onDragOver);
  561. document.removeEventListener('drop', onDrop);
  562. };
  563. }, [add, globalDrop]);
  564. useEffect(
  565. () => () => {
  566. if (!usingProvider) {
  567. for (const f of filesRef.current) {
  568. if (f.url) URL.revokeObjectURL(f.url);
  569. }
  570. }
  571. },
  572. [usingProvider],
  573. );
  574. const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
  575. if (event.currentTarget.files) {
  576. add(event.currentTarget.files);
  577. }
  578. // Reset input value to allow selecting files that were previously removed
  579. event.currentTarget.value = '';
  580. };
  581. const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
  582. try {
  583. const response = await fetch(url);
  584. const blob = await response.blob();
  585. return new Promise((resolve) => {
  586. const reader = new FileReader();
  587. reader.onloadend = () => resolve(reader.result as string);
  588. reader.onerror = () => resolve(null);
  589. reader.readAsDataURL(blob);
  590. });
  591. } catch {
  592. return null;
  593. }
  594. };
  595. const ctx = useMemo<AttachmentsContext>(
  596. () => ({
  597. files: files.map((item) => ({ ...item, id: item.id })),
  598. add,
  599. remove,
  600. clear,
  601. openFileDialog,
  602. fileInputRef: inputRef,
  603. }),
  604. [files, add, remove, clear, openFileDialog],
  605. );
  606. const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
  607. event.preventDefault();
  608. const form = event.currentTarget;
  609. const text = usingProvider
  610. ? controller.textInput.value
  611. : (() => {
  612. const formData = new FormData(form);
  613. return (formData.get('message') as string) || '';
  614. })();
  615. // Reset form immediately after capturing text to avoid race condition
  616. // where user input during async blob conversion would be lost
  617. if (!usingProvider) {
  618. form.reset();
  619. }
  620. // Convert blob URLs to data URLs asynchronously
  621. Promise.all(
  622. files.map(async ({ id: _id, ...item }) => {
  623. if (item.url && item.url.startsWith('blob:')) {
  624. const dataUrl = await convertBlobUrlToDataUrl(item.url);
  625. // If conversion failed, keep the original blob URL
  626. return {
  627. ...item,
  628. url: dataUrl ?? item.url,
  629. };
  630. }
  631. return item;
  632. }),
  633. )
  634. .then((convertedFiles: FileUIPart[]) => {
  635. try {
  636. const result = onSubmit({ text, files: convertedFiles }, event);
  637. // Handle both sync and async onSubmit
  638. if (result instanceof Promise) {
  639. result
  640. .then(() => {
  641. clear();
  642. if (usingProvider) {
  643. controller.textInput.clear();
  644. }
  645. })
  646. .catch(() => {
  647. // Don't clear on error - user may want to retry
  648. });
  649. } else {
  650. // Sync function completed without throwing, clear attachments
  651. clear();
  652. if (usingProvider) {
  653. controller.textInput.clear();
  654. }
  655. }
  656. } catch {
  657. // Don't clear on error - user may want to retry
  658. }
  659. })
  660. .catch(() => {
  661. // Don't clear on error - user may want to retry
  662. });
  663. };
  664. // Render with or without local provider
  665. const inner = (
  666. <>
  667. <input
  668. accept={accept}
  669. aria-label="Upload files"
  670. className="hidden"
  671. multiple={multiple}
  672. onChange={handleChange}
  673. ref={inputRef}
  674. title="Upload files"
  675. type="file"
  676. />
  677. <form className={cn('w-full', className)} onSubmit={handleSubmit} ref={formRef} {...props}>
  678. <InputGroup className="overflow-hidden">{children}</InputGroup>
  679. </form>
  680. </>
  681. );
  682. return usingProvider ? (
  683. inner
  684. ) : (
  685. <LocalAttachmentsContext.Provider value={ctx}>{inner}</LocalAttachmentsContext.Provider>
  686. );
  687. };
  688. export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
  689. export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
  690. <div className={cn('contents', className)} {...props} />
  691. );
  692. export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>;
  693. export const PromptInputTextarea = ({
  694. onChange,
  695. className,
  696. placeholder = 'What would you like to know?',
  697. ...props
  698. }: PromptInputTextareaProps) => {
  699. const controller = useOptionalPromptInputController();
  700. const attachments = usePromptInputAttachments();
  701. const [isComposing, setIsComposing] = useState(false);
  702. const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
  703. if (e.key === 'Enter') {
  704. if (isComposing || e.nativeEvent.isComposing) {
  705. return;
  706. }
  707. if (e.shiftKey) {
  708. return;
  709. }
  710. e.preventDefault();
  711. // Check if the submit button is disabled before submitting
  712. const form = e.currentTarget.form;
  713. const submitButton = form?.querySelector('button[type="submit"]') as HTMLButtonElement | null;
  714. if (submitButton?.disabled) {
  715. return;
  716. }
  717. form?.requestSubmit();
  718. }
  719. // Remove last attachment when Backspace is pressed and textarea is empty
  720. if (e.key === 'Backspace' && e.currentTarget.value === '' && attachments.files.length > 0) {
  721. e.preventDefault();
  722. const lastAttachment = attachments.files.at(-1);
  723. if (lastAttachment) {
  724. attachments.remove(lastAttachment.id);
  725. }
  726. }
  727. };
  728. const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
  729. const items = event.clipboardData?.items;
  730. if (!items) {
  731. return;
  732. }
  733. const files: File[] = [];
  734. for (const item of items) {
  735. if (item.kind === 'file') {
  736. const file = item.getAsFile();
  737. if (file) {
  738. files.push(file);
  739. }
  740. }
  741. }
  742. if (files.length > 0) {
  743. event.preventDefault();
  744. attachments.add(files);
  745. }
  746. };
  747. const controlledProps = controller
  748. ? {
  749. value: controller.textInput.value,
  750. onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
  751. controller.textInput.setInput(e.currentTarget.value);
  752. onChange?.(e);
  753. },
  754. }
  755. : {
  756. onChange,
  757. };
  758. return (
  759. <InputGroupTextarea
  760. className={cn('field-sizing-content max-h-48 min-h-16', className)}
  761. name="message"
  762. onCompositionEnd={() => setIsComposing(false)}
  763. onCompositionStart={() => setIsComposing(true)}
  764. onKeyDown={handleKeyDown}
  765. onPaste={handlePaste}
  766. placeholder={placeholder}
  767. {...props}
  768. {...controlledProps}
  769. />
  770. );
  771. };
  772. export type PromptInputHeaderProps = Omit<ComponentProps<typeof InputGroupAddon>, 'align'>;
  773. export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => (
  774. <InputGroupAddon
  775. align="block-end"
  776. className={cn('order-first flex-wrap gap-1', className)}
  777. {...props}
  778. />
  779. );
  780. export type PromptInputFooterProps = Omit<ComponentProps<typeof InputGroupAddon>, 'align'>;
  781. export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => (
  782. <InputGroupAddon
  783. align="block-end"
  784. className={cn('justify-between gap-1', className)}
  785. {...props}
  786. />
  787. );
  788. export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
  789. export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
  790. <div className={cn('flex items-center gap-1', className)} {...props} />
  791. );
  792. export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>;
  793. export const PromptInputButton = ({
  794. variant = 'ghost',
  795. className,
  796. size,
  797. ...props
  798. }: PromptInputButtonProps) => {
  799. const newSize = size ?? (Children.count(props.children) > 1 ? 'sm' : 'icon-sm');
  800. return (
  801. <InputGroupButton
  802. className={cn(className)}
  803. size={newSize}
  804. type="button"
  805. variant={variant}
  806. {...props}
  807. />
  808. );
  809. };
  810. export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
  811. export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
  812. <DropdownMenu {...props} />
  813. );
  814. export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
  815. export const PromptInputActionMenuTrigger = ({
  816. className,
  817. children,
  818. ...props
  819. }: PromptInputActionMenuTriggerProps) => (
  820. <DropdownMenuTrigger asChild>
  821. <PromptInputButton className={className} {...props}>
  822. {children ?? <PlusIcon className="size-4" />}
  823. </PromptInputButton>
  824. </DropdownMenuTrigger>
  825. );
  826. export type PromptInputActionMenuContentProps = ComponentProps<typeof DropdownMenuContent>;
  827. export const PromptInputActionMenuContent = ({
  828. className,
  829. ...props
  830. }: PromptInputActionMenuContentProps) => (
  831. <DropdownMenuContent align="start" className={cn(className)} {...props} />
  832. );
  833. export type PromptInputActionMenuItemProps = ComponentProps<typeof DropdownMenuItem>;
  834. export const PromptInputActionMenuItem = ({
  835. className,
  836. ...props
  837. }: PromptInputActionMenuItemProps) => <DropdownMenuItem className={cn(className)} {...props} />;
  838. // Note: Actions that perform side-effects (like opening a file dialog)
  839. // are provided in opt-in modules (e.g., prompt-input-attachments).
  840. export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
  841. status?: ChatStatus;
  842. };
  843. export const PromptInputSubmit = ({
  844. className,
  845. variant = 'default',
  846. size = 'icon-sm',
  847. status,
  848. children,
  849. ...props
  850. }: PromptInputSubmitProps) => {
  851. let Icon = <CornerDownLeftIcon className="size-4" />;
  852. if (status === 'submitted') {
  853. Icon = <Loader2Icon className="size-4 animate-spin" />;
  854. } else if (status === 'streaming') {
  855. Icon = <SquareIcon className="size-4" />;
  856. } else if (status === 'error') {
  857. Icon = <XIcon className="size-4" />;
  858. }
  859. return (
  860. <InputGroupButton
  861. aria-label="Submit"
  862. className={cn(className)}
  863. size={size}
  864. type="submit"
  865. variant={variant}
  866. {...props}
  867. >
  868. {children ?? Icon}
  869. </InputGroupButton>
  870. );
  871. };
  872. interface SpeechRecognition extends EventTarget {
  873. continuous: boolean;
  874. interimResults: boolean;
  875. lang: string;
  876. start(): void;
  877. stop(): void;
  878. onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
  879. onend: ((this: SpeechRecognition, ev: Event) => void) | null;
  880. onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
  881. onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;
  882. }
  883. interface SpeechRecognitionEvent extends Event {
  884. results: SpeechRecognitionResultList;
  885. resultIndex: number;
  886. }
  887. type SpeechRecognitionResultList = {
  888. readonly length: number;
  889. item(index: number): SpeechRecognitionResult;
  890. [index: number]: SpeechRecognitionResult;
  891. };
  892. type SpeechRecognitionResult = {
  893. readonly length: number;
  894. item(index: number): SpeechRecognitionAlternative;
  895. [index: number]: SpeechRecognitionAlternative;
  896. isFinal: boolean;
  897. };
  898. type SpeechRecognitionAlternative = {
  899. script: string;
  900. confidence: number;
  901. };
  902. interface SpeechRecognitionErrorEvent extends Event {
  903. error: string;
  904. }
  905. declare global {
  906. interface Window {
  907. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  908. SpeechRecognition: any;
  909. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  910. webkitSpeechRecognition: any;
  911. }
  912. }
  913. export type PromptInputSpeechButtonProps = ComponentProps<typeof PromptInputButton> & {
  914. textareaRef?: RefObject<HTMLTextAreaElement | null>;
  915. onScriptionChange?: (text: string) => void;
  916. };
  917. export const PromptInputSpeechButton = ({
  918. className,
  919. textareaRef,
  920. onScriptionChange,
  921. ...props
  922. }: PromptInputSpeechButtonProps) => {
  923. const [isListening, setIsListening] = useState(false);
  924. const [recognition, setRecognition] = useState<SpeechRecognition | null>(null);
  925. const recognitionRef = useRef<SpeechRecognition | null>(null);
  926. useEffect(() => {
  927. if (
  928. typeof window !== 'undefined' &&
  929. ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
  930. ) {
  931. const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  932. const speechRecognition = new SpeechRecognition();
  933. speechRecognition.continuous = true;
  934. speechRecognition.interimResults = true;
  935. speechRecognition.lang = 'en-US';
  936. speechRecognition.onstart = () => {
  937. setIsListening(true);
  938. };
  939. speechRecognition.onend = () => {
  940. setIsListening(false);
  941. };
  942. speechRecognition.onresult = (event: SpeechRecognitionEvent) => {
  943. let finalScript = '';
  944. for (let i = event.resultIndex; i < event.results.length; i++) {
  945. const result = event.results[i];
  946. if (result.isFinal) {
  947. finalScript += result[0]?.script ?? '';
  948. }
  949. }
  950. if (finalScript && textareaRef?.current) {
  951. const textarea = textareaRef.current;
  952. const currentValue = textarea.value;
  953. const newValue = currentValue + (currentValue ? ' ' : '') + finalScript;
  954. textarea.value = newValue;
  955. textarea.dispatchEvent(new Event('input', { bubbles: true }));
  956. onScriptionChange?.(newValue);
  957. }
  958. };
  959. speechRecognition.onerror = (event: SpeechRecognitionErrorEvent) => {
  960. log.error('Speech recognition error:', event.error);
  961. setIsListening(false);
  962. };
  963. recognitionRef.current = speechRecognition;
  964. // eslint-disable-next-line react-hooks/set-state-in-effect -- Initial sync from external API
  965. setRecognition(speechRecognition);
  966. }
  967. return () => {
  968. if (recognitionRef.current) {
  969. recognitionRef.current.stop();
  970. }
  971. };
  972. }, [textareaRef, onScriptionChange]);
  973. const toggleListening = useCallback(() => {
  974. if (!recognition) {
  975. return;
  976. }
  977. if (isListening) {
  978. recognition.stop();
  979. } else {
  980. recognition.start();
  981. }
  982. }, [recognition, isListening]);
  983. return (
  984. <PromptInputButton
  985. className={cn(
  986. 'relative transition-all duration-200',
  987. isListening && 'animate-pulse bg-accent text-accent-foreground',
  988. className,
  989. )}
  990. disabled={!recognition}
  991. onClick={toggleListening}
  992. {...props}
  993. >
  994. <MicIcon className="size-4" />
  995. </PromptInputButton>
  996. );
  997. };
  998. export type PromptInputSelectProps = ComponentProps<typeof Select>;
  999. export const PromptInputSelect = (props: PromptInputSelectProps) => <Select {...props} />;
  1000. export type PromptInputSelectTriggerProps = ComponentProps<typeof SelectTrigger>;
  1001. export const PromptInputSelectTrigger = ({
  1002. className,
  1003. ...props
  1004. }: PromptInputSelectTriggerProps) => (
  1005. <SelectTrigger
  1006. className={cn(
  1007. 'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
  1008. 'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground',
  1009. className,
  1010. )}
  1011. {...props}
  1012. />
  1013. );
  1014. export type PromptInputSelectContentProps = ComponentProps<typeof SelectContent>;
  1015. export const PromptInputSelectContent = ({
  1016. className,
  1017. ...props
  1018. }: PromptInputSelectContentProps) => <SelectContent className={cn(className)} {...props} />;
  1019. export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
  1020. export const PromptInputSelectItem = ({ className, ...props }: PromptInputSelectItemProps) => (
  1021. <SelectItem className={cn(className)} {...props} />
  1022. );
  1023. export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
  1024. export const PromptInputSelectValue = ({ className, ...props }: PromptInputSelectValueProps) => (
  1025. <SelectValue className={cn(className)} {...props} />
  1026. );
  1027. export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
  1028. export const PromptInputHoverCard = ({
  1029. openDelay = 0,
  1030. closeDelay = 0,
  1031. ...props
  1032. }: PromptInputHoverCardProps) => (
  1033. <HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
  1034. );
  1035. export type PromptInputHoverCardTriggerProps = ComponentProps<typeof HoverCardTrigger>;
  1036. export const PromptInputHoverCardTrigger = (props: PromptInputHoverCardTriggerProps) => (
  1037. <HoverCardTrigger {...props} />
  1038. );
  1039. export type PromptInputHoverCardContentProps = ComponentProps<typeof HoverCardContent>;
  1040. export const PromptInputHoverCardContent = ({
  1041. align = 'start',
  1042. ...props
  1043. }: PromptInputHoverCardContentProps) => <HoverCardContent align={align} {...props} />;
  1044. export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
  1045. export const PromptInputTabsList = ({ className, ...props }: PromptInputTabsListProps) => (
  1046. <div className={cn(className)} {...props} />
  1047. );
  1048. export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
  1049. export const PromptInputTab = ({ className, ...props }: PromptInputTabProps) => (
  1050. <div className={cn(className)} {...props} />
  1051. );
  1052. export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
  1053. export const PromptInputTabLabel = ({ className, ...props }: PromptInputTabLabelProps) => (
  1054. <h3 className={cn('mb-2 px-3 font-medium text-muted-foreground text-xs', className)} {...props} />
  1055. );
  1056. export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
  1057. export const PromptInputTabBody = ({ className, ...props }: PromptInputTabBodyProps) => (
  1058. <div className={cn('space-y-1', className)} {...props} />
  1059. );
  1060. export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
  1061. export const PromptInputTabItem = ({ className, ...props }: PromptInputTabItemProps) => (
  1062. <div
  1063. className={cn('flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent', className)}
  1064. {...props}
  1065. />
  1066. );
  1067. export type PromptInputCommandProps = ComponentProps<typeof Command>;
  1068. export const PromptInputCommand = ({ className, ...props }: PromptInputCommandProps) => (
  1069. <Command className={cn(className)} {...props} />
  1070. );
  1071. export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
  1072. export const PromptInputCommandInput = ({ className, ...props }: PromptInputCommandInputProps) => (
  1073. <CommandInput className={cn(className)} {...props} />
  1074. );
  1075. export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
  1076. export const PromptInputCommandList = ({ className, ...props }: PromptInputCommandListProps) => (
  1077. <CommandList className={cn(className)} {...props} />
  1078. );
  1079. export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
  1080. export const PromptInputCommandEmpty = ({ className, ...props }: PromptInputCommandEmptyProps) => (
  1081. <CommandEmpty className={cn(className)} {...props} />
  1082. );
  1083. export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
  1084. export const PromptInputCommandGroup = ({ className, ...props }: PromptInputCommandGroupProps) => (
  1085. <CommandGroup className={cn(className)} {...props} />
  1086. );
  1087. export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
  1088. export const PromptInputCommandItem = ({ className, ...props }: PromptInputCommandItemProps) => (
  1089. <CommandItem className={cn(className)} {...props} />
  1090. );
  1091. export type PromptInputCommandSeparatorProps = ComponentProps<typeof CommandSeparator>;
  1092. export const PromptInputCommandSeparator = ({
  1093. className,
  1094. ...props
  1095. }: PromptInputCommandSeparatorProps) => <CommandSeparator className={cn(className)} {...props} />;