mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Co-authored-by: Alf Eaton <alf.eaton@overleaf.com> GitOrigin-RevId: 79bb329932b1e6fcc88f648bca9cc4bee215cd41
1273 lines
33 KiB
TypeScript
1273 lines
33 KiB
TypeScript
'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}
|
|
/>
|
|
)
|