prompt-input.tsx 33 KB

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