|
|
@@ -0,0 +1,1272 @@
|
|
|
+'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}
|
|
|
+ />
|
|
|
+)
|