| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272 |
- 'use client'
- import { Button } from '../ui/button'
- import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- } from '../ui/dropdown-menu'
- import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card'
- import {
- InputGroup,
- InputGroupAddon,
- InputGroupButton,
- InputGroupTextarea,
- } from '../ui/input-group'
- import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- } from '../ui/select'
- import { cn } from '../../utils'
- import type { ChatStatus, FileUIPart } from 'ai'
- import {
- ImageIcon,
- Loader2Icon,
- MicIcon,
- PaperclipIcon,
- PlusIcon,
- SendIcon,
- 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 [attachements, setAttachements] = useState<
- (FileUIPart & { id: string })[]
- >([])
- const fileInputRef = useRef<HTMLInputElement | null>(null)
- const openRef = useRef<() => void>(() => {})
- const add = useCallback((files: File[] | FileList) => {
- const incoming = Array.from(files)
- if (incoming.length === 0) return
- setAttachements(prev =>
- prev.concat(
- incoming.map(file => ({
- id: nanoid(),
- type: 'file' as const,
- url: URL.createObjectURL(file),
- mediaType: file.type,
- filename: file.name,
- }))
- )
- )
- }, [])
- const remove = useCallback((id: string) => {
- setAttachements(prev => {
- const found = prev.find(f => f.id === id)
- if (found?.url) URL.revokeObjectURL(found.url)
- return prev.filter(f => f.id !== id)
- })
- }, [])
- const clear = useCallback(() => {
- setAttachements(prev => {
- for (const f of prev) if (f.url) URL.revokeObjectURL(f.url)
- return []
- })
- }, [])
- const openFileDialog = useCallback(() => {
- openRef.current?.()
- }, [])
- const attachments = useMemo<AttachmentsContext>(
- () => ({
- files: attachements,
- add,
- remove,
- clear,
- openFileDialog,
- fileInputRef,
- }),
- [attachements, add, remove, clear, openFileDialog]
- )
- const __registerFileInput = useCallback(
- (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
- fileInputRef.current = ref.current
- openRef.current = open
- },
- []
- )
- const controller = useMemo<PromptInputControllerProps>(
- () => ({
- textInput: {
- value: textInput,
- setInput: setTextInput,
- clear: clearInput,
- },
- attachments,
- __registerFileInput,
- }),
- [textInput, clearInput, attachments, __registerFileInput]
- )
- return (
- <PromptInputController.Provider value={controller}>
- <ProviderAttachmentsContext.Provider value={attachments}>
- {children}
- </ProviderAttachmentsContext.Provider>
- </PromptInputController.Provider>
- )
- }
- // ============================================================================
- // 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-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',
- 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,
- }: PromptInputAttachmentsProps) {
- const attachments = usePromptInputAttachments()
- if (!attachments.files.length) {
- return null
- }
- return attachments.files.map(file => (
- <Fragment key={file.id}>{children(file)}</Fragment>
- ))
- }
- 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 anchorRef = useRef<HTMLSpanElement>(null)
- const formRef = useRef<HTMLFormElement | null>(null)
- // Find nearest form to scope drag & drop
- useEffect(() => {
- const root = anchorRef.current?.closest('form')
- if (root instanceof HTMLFormElement) {
- formRef.current = root
- }
- }, [])
- // ----- Local attachments (only used when no provider)
- const [items, setItems] = useState<(FileUIPart & { id: string })[]>([])
- const files = usingProvider ? controller.attachments.files : items
- const openFileDialogLocal = useCallback(() => {
- inputRef.current?.click()
- }, [])
- const matchesAccept = useCallback(
- (f: File) => {
- if (!accept || accept.trim() === '') {
- return true
- }
- if (accept.includes('image/*')) {
- return f.type.startsWith('image/')
- }
- // NOTE: keep simple; expand as needed
- return true
- },
- [accept]
- )
- const addLocal = useCallback(
- (fileList: File[] | FileList) => {
- const incoming = Array.from(fileList)
- const accepted = incoming.filter(f => matchesAccept(f))
- if (incoming.length && accepted.length === 0) {
- onError?.({
- code: 'accept',
- message: 'No files match the accepted types.',
- })
- return
- }
- const withinSize = (f: File) =>
- maxFileSize ? f.size <= maxFileSize : true
- const sized = accepted.filter(withinSize)
- if (accepted.length > 0 && sized.length === 0) {
- onError?.({
- code: 'max_file_size',
- message: 'All files exceed the maximum size.',
- })
- return
- }
- setItems(prev => {
- const capacity =
- typeof maxFiles === 'number'
- ? Math.max(0, maxFiles - prev.length)
- : undefined
- const capped =
- typeof capacity === 'number' ? sized.slice(0, capacity) : sized
- if (typeof capacity === 'number' && sized.length > capacity) {
- onError?.({
- code: 'max_files',
- message: 'Too many files. Some were not added.',
- })
- }
- const next: (FileUIPart & { id: string })[] = []
- for (const file of capped) {
- next.push({
- id: nanoid(),
- type: 'file',
- url: URL.createObjectURL(file),
- mediaType: file.type,
- filename: file.name,
- })
- }
- return prev.concat(next)
- })
- },
- [matchesAccept, maxFiles, maxFileSize, onError]
- )
- const add = usingProvider
- ? (files: File[] | FileList) => controller.attachments.add(files)
- : addLocal
- const remove = usingProvider
- ? (id: string) => controller.attachments.remove(id)
- : (id: string) =>
- setItems(prev => {
- const found = prev.find(file => file.id === id)
- if (found?.url) {
- URL.revokeObjectURL(found.url)
- }
- return prev.filter(file => file.id !== id)
- })
- const clear = usingProvider
- ? () => controller.attachments.clear()
- : () =>
- setItems(prev => {
- for (const file of prev) {
- if (file.url) {
- URL.revokeObjectURL(file.url)
- }
- }
- return []
- })
- const openFileDialog = usingProvider
- ? () => controller.attachments.openFileDialog()
- : openFileDialogLocal
- // 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
- const onDragOver = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes('Files')) {
- e.preventDefault()
- }
- }
- const onDrop = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes('Files')) {
- e.preventDefault()
- }
- if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
- add(e.dataTransfer.files)
- }
- }
- form.addEventListener('dragover', onDragOver)
- form.addEventListener('drop', onDrop)
- return () => {
- form.removeEventListener('dragover', onDragOver)
- form.removeEventListener('drop', onDrop)
- }
- }, [add])
- useEffect(() => {
- if (!globalDrop) return
- const onDragOver = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes('Files')) {
- e.preventDefault()
- }
- }
- const onDrop = (e: DragEvent) => {
- if (e.dataTransfer?.types?.includes('Files')) {
- e.preventDefault()
- }
- if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
- add(e.dataTransfer.files)
- }
- }
- document.addEventListener('dragover', onDragOver)
- document.addEventListener('drop', onDrop)
- return () => {
- document.removeEventListener('dragover', onDragOver)
- document.removeEventListener('drop', onDrop)
- }
- }, [add, globalDrop])
- useEffect(
- () => () => {
- if (!usingProvider) {
- for (const f of files) {
- if (f.url) URL.revokeObjectURL(f.url)
- }
- }
- },
- [usingProvider, files]
- )
- const handleChange: ChangeEventHandler<HTMLInputElement> = event => {
- if (event.currentTarget.files) {
- add(event.currentTarget.files)
- }
- }
- const convertBlobUrlToDataUrl = async (url: string): Promise<string> => {
- const response = await fetch(url)
- const blob = await response.blob()
- return await new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onloadend = () => resolve(reader.result as string)
- reader.onerror = reject
- reader.readAsDataURL(blob)
- })
- }
- const ctx = useMemo<AttachmentsContext>(
- () => ({
- files: files.map(item => ({ ...item, id: item.id })),
- add,
- remove,
- clear,
- openFileDialog,
- fileInputRef: inputRef,
- }),
- [files, add, remove, clear, openFileDialog]
- )
- const handleSubmit: FormEventHandler<HTMLFormElement> = event => {
- event.preventDefault()
- const form = event.currentTarget
- const text = usingProvider
- ? controller.textInput.value
- : (() => {
- const formData = new FormData(form)
- return (formData.get('message') as string) || ''
- })()
- // 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, ...item }) => {
- if (item.url && item.url.startsWith('blob:')) {
- return {
- ...item,
- url: await convertBlobUrlToDataUrl(item.url),
- }
- }
- return item
- })
- ).then((convertedFiles: FileUIPart[]) => {
- try {
- const result = onSubmit({ text, files: convertedFiles }, event)
- // 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 (error) {
- // Don't clear on error - user may want to retry
- }
- })
- }
- // Render with or without local provider
- const inner = (
- <>
- <span aria-hidden="true" className="hidden" ref={anchorRef} />
- <input
- accept={accept}
- aria-label="Upload files"
- className="hidden"
- multiple={multiple}
- onChange={handleChange}
- ref={inputRef}
- title="Upload files"
- type="file"
- />
- <form
- className={cn('w-full', className)}
- onSubmit={handleSubmit}
- {...props}
- >
- <InputGroup>{children}</InputGroup>
- </form>
- </>
- )
- return usingProvider ? (
- inner
- ) : (
- <LocalAttachmentsContext.Provider value={ctx}>
- {inner}
- </LocalAttachmentsContext.Provider>
- )
- }
- export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>
- export const PromptInputBody = ({
- className,
- ...props
- }: PromptInputBodyProps) => (
- <div className={cn('contents', className)} {...props} />
- )
- export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>
- export const PromptInputTextarea = ({
- onChange,
- className,
- placeholder = 'What would you like to do?',
- ...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()
- e.currentTarget.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 = <SendIcon className="size-4" />
- if (status === 'submitted') {
- Icon = <Loader2Icon className="size-4 animate-spin" />
- } else if (status === 'streaming') {
- Icon = <SquareIcon className="size-4" />
- } else if (status === 'error') {
- Icon = <XIcon className="size-4" />
- }
- return (
- <InputGroupButton
- aria-label="Submit"
- className={cn(className)}
- size={size}
- type="submit"
- variant={variant}
- {...props}
- >
- {children ?? Icon}
- </InputGroupButton>
- )
- }
- interface SpeechRecognition extends EventTarget {
- continuous: boolean
- interimResults: boolean
- lang: string
- start(): void
- stop(): void
- onstart: ((this: SpeechRecognition, ev: Event) => any) | null
- onend: ((this: SpeechRecognition, ev: Event) => any) | null
- onresult:
- | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
- | null
- onerror:
- | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
- | null
- }
- interface SpeechRecognitionEvent extends Event {
- results: SpeechRecognitionResultList
- }
- type SpeechRecognitionResultList = {
- readonly length: number
- item(index: number): SpeechRecognitionResult
- [index: number]: SpeechRecognitionResult
- }
- type SpeechRecognitionResult = {
- readonly length: number
- item(index: number): SpeechRecognitionAlternative
- [index: number]: SpeechRecognitionAlternative
- isFinal: boolean
- }
- type SpeechRecognitionAlternative = {
- transcript: string
- confidence: number
- }
- interface SpeechRecognitionErrorEvent extends Event {
- error: string
- }
- declare global {
- // eslint-disable-next-line no-unused-vars
- interface Window {
- SpeechRecognition: {
- new (): SpeechRecognition
- }
- webkitSpeechRecognition: {
- new (): SpeechRecognition
- }
- }
- }
- export type PromptInputSpeechButtonProps = ComponentProps<
- typeof PromptInputButton
- > & {
- textareaRef?: RefObject<HTMLTextAreaElement | null>
- onTranscriptionChange?: (text: string) => void
- }
- export const PromptInputSpeechButton = ({
- className,
- textareaRef,
- onTranscriptionChange,
- ...props
- }: PromptInputSpeechButtonProps) => {
- const [isListening, setIsListening] = useState(false)
- const [recognition, setRecognition] = useState<SpeechRecognition | null>(null)
- const recognitionRef = useRef<SpeechRecognition | null>(null)
- useEffect(() => {
- if (
- typeof window !== 'undefined' &&
- ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)
- ) {
- const SpeechRecognition =
- window.SpeechRecognition || window.webkitSpeechRecognition
- const speechRecognition = new SpeechRecognition()
- speechRecognition.continuous = true
- speechRecognition.interimResults = true
- speechRecognition.lang = 'en-US'
- speechRecognition.onstart = () => {
- setIsListening(true)
- }
- speechRecognition.onend = () => {
- setIsListening(false)
- }
- speechRecognition.onresult = event => {
- let finalTranscript = ''
- const results = Array.from(event.results)
- for (const result of results) {
- if (result.isFinal) {
- finalTranscript += result[0]?.transcript ?? ''
- }
- }
- if (finalTranscript && textareaRef?.current) {
- const textarea = textareaRef.current
- const currentValue = textarea.value
- const newValue =
- currentValue + (currentValue ? ' ' : '') + finalTranscript
- textarea.value = newValue
- textarea.dispatchEvent(new Event('input', { bubbles: true }))
- onTranscriptionChange?.(newValue)
- }
- }
- speechRecognition.onerror = event => {
- console.error('Speech recognition error:', event.error)
- setIsListening(false)
- }
- recognitionRef.current = speechRecognition
- setRecognition(speechRecognition)
- }
- return () => {
- if (recognitionRef.current) {
- recognitionRef.current.stop()
- }
- }
- }, [textareaRef, onTranscriptionChange])
- const toggleListening = useCallback(() => {
- if (!recognition) {
- return
- }
- if (isListening) {
- recognition.stop()
- } else {
- recognition.start()
- }
- }, [recognition, isListening])
- return (
- <PromptInputButton
- className={cn(
- 'relative transition-all duration-200',
- isListening && 'animate-pulse bg-accent text-accent-foreground',
- className
- )}
- disabled={!recognition}
- onClick={toggleListening}
- {...props}
- >
- <MicIcon className="size-4" />
- </PromptInputButton>
- )
- }
- export type PromptInputModelSelectProps = ComponentProps<typeof Select>
- export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
- <Select {...props} />
- )
- export type PromptInputModelSelectTriggerProps = ComponentProps<
- typeof SelectTrigger
- >
- export const PromptInputModelSelectTrigger = ({
- className,
- ...props
- }: PromptInputModelSelectTriggerProps) => (
- <SelectTrigger
- className={cn(
- 'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
- 'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
- className
- )}
- {...props}
- />
- )
- export type PromptInputModelSelectContentProps = ComponentProps<
- typeof SelectContent
- >
- export const PromptInputModelSelectContent = ({
- className,
- ...props
- }: PromptInputModelSelectContentProps) => (
- <SelectContent className={cn(className)} {...props} />
- )
- export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>
- export const PromptInputModelSelectItem = ({
- className,
- ...props
- }: PromptInputModelSelectItemProps) => (
- <SelectItem className={cn(className)} {...props} />
- )
- export type PromptInputModelSelectValueProps = ComponentProps<
- typeof SelectValue
- >
- export const PromptInputModelSelectValue = ({
- className,
- ...props
- }: PromptInputModelSelectValueProps) => (
- <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}
- />
- )
|