Calcium-Ion 8b2b03d276
feat(web/default): unified UI overhaul — Base UI migration, theme presets, rankings dashboard, and table toolbar refactor (#4633)
* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges

Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage.

Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx.

Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark).

Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi.

* 🎨 fix(web): align segmented controls with theme radius tokens

- Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart
  toolbars with radius-md so the active state follows --radius when users
  change Radius in Theme Settings.
- Use nested radii consistent with TabsList/TabsTrigger: outer
  rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px))
  so the track and active thumb stay concentric at small scales (e.g.
  0.3rem) instead of a squared “focus” block inside a rounded shell.
- Apply the same pattern to pricing SegmentedControl and the segmented
  groups in consumption-distribution-chart, model-charts, and user-charts.

Verified: bun run typecheck (web/default)

*  feat(pricing): enrich model details with uptime sparkline and API documentation

Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with
per-day tooltips, surface it in a status row under quick stats and in the
per-group performance table, and extend mock data so uptime series are stable
and optionally scoped by group.

Introduce an API tab with Shiki-highlighted code samples (cURL, Python,
TypeScript, JavaScript), endpoint-type switching, authentication guidance, a
supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer
vendor, tokenizer, license, and data-retention hints for a provider & data
privacy card on the Overview tab (capabilities/modalities stay with model
identity; rate limits stay with the API tab).

Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi.

* 🏆 feat(rankings): add comprehensive rankings dashboard

Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending.

Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales.

* fix(theme): correct theme preset selection state

- update Base UI Radio selectors to use data-checked/data-unchecked states.
- fix unchecked theme options still showing selected indicators.
- isolate the default theme preview tokens to prevent preset changes from leaking into it.

* fix(setup): correct usage mode radio state

- use Base UI data-checked/data-unchecked states for RadioGroup styling.
- hide radio indicators when options are unchecked to avoid setup page display issues.
- drive usage mode card and icon selection styles from Base UI state.

* fix(auth): submit sign-in and sign-up forms

* 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization

Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`)
and reinstall full component registry with `--overwrite`, including Hugeicons-backed
widgets and newly added registry components.

- Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies,
  preset UI from config drawer); rely on official semantic CSS tokens + light/dark only.
- Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and
  `@theme inline` mappings (plus skeleton token vars for existing shimmer usage).
- Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`;
  clarify scroll-lock override comment.

Application compatibility:
- Keep minimal shims where app code diverged from official APIs (popover collision props,
  combobox legacy `options` callers, Spinner prop typing).
- Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI
  semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.)

Tooling / docs / build:
- Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`.
- Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack.
- Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.)

Verified: `bun run typecheck` passes.

Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere;
not addressed in this change.

* 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters

- Refactor DataTableToolbar to a single wrapping flex row with a
  right-aligned action cluster (Reset / Search / View / Expand) for a
  cleaner Ant Design Pro–style filter bar; remove the dedicated stats row
  and the toolbar `stats` prop.
- Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive-
  data visibility toggle into the page header via CommonLogsHeaderActions
  and SectionPageLayout.Actions so the toolbar stays focused on filters.
- Slim CommonLogsFilterBar props (no stats / preActions eye control).
- Improve CompactDateTimeRangePicker: show minute-precision labels on the
  trigger (seconds omitted; aligns with datetime-local inputs); widen the
  trigger on sm+ breakpoints so the full range is visible without truncation;
  apply the same width in task logs filters.
- Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon.
- Earlier layout tweak: extra top padding on SectionPageLayout scroll
  content so control focus rings are not clipped by overflow.

* feat(web/default): Base UI migration and component foundation

Migrate from Radix UI to Base UI, rewrite core UI primitives,
update dependencies (recharts, date-fns, next-themes), add
shadcn agent skill documentation, and refresh AI element components.

This is the foundational work from the v2/localmain lineage that
was not covered by the individual feature commits above.

---------

Co-authored-by: t0ng7u <dev@aiass.cc>
Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
2026-05-06 12:39:36 +08:00

1389 lines
36 KiB
TypeScript
Vendored

/* eslint-disable react-refresh/only-export-components */
'use client'
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'
import type { ChatStatus, FileUIPart } from 'ai'
import {
ImageIcon,
Loader2Icon,
MicIcon,
PaperclipIcon,
PlusIcon,
SendIcon,
SquareIcon,
XIcon,
} from 'lucide-react'
import { nanoid } from 'nanoid'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
// ============================================================================
// 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 { t } = useTranslation()
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>
<PromptInputHoverCardTrigger
render={
<div
className={cn(
'group border-border hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 relative flex h-8 cursor-default items-center gap-1.5 rounded-md border px-1.5 text-sm font-medium transition-all select-none',
className
)}
key={data.id}
{...props}
/>
}
>
<div className='relative size-5 shrink-0'>
<div className='bg-background absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded 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='text-muted-foreground flex size-5 items-center justify-center'>
<PaperclipIcon className='size-3' />
</div>
)}
</div>
<Button
aria-label={t('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'>{t('Remove')}</span>
</Button>
</div>
<span className='flex-1 truncate'>{attachmentLabel}</span>
</PromptInputHoverCardTrigger>
<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 text-sm leading-none font-semibold'>
{filename || (isImage ? 'Image' : 'Attachment')}
</h4>
{data.mediaType && (
<p className='text-muted-foreground truncate font-mono 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,
...props
}: PromptInputActionAddAttachmentsProps) => {
const { t } = useTranslation()
const resolvedLabel = label ?? t('Add photos or files')
const attachments = usePromptInputAttachments()
return (
<DropdownMenuItem
{...props}
onSelect={(e) => {
e.preventDefault()
attachments.openFileDialog()
}}
>
<ImageIcon className='mr-2 size-4' /> {resolvedLabel}
</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>
/**
* Optional className applied to the inner InputGroup wrapper
* (useful for customizing rounded corners, e.g., rounded-[20px]).
*/
groupClassName?: string
}
export const PromptInput = ({
className,
groupClassName,
accept,
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
const { t } = useTranslation()
// 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: t('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: t('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: t('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, t]
)
const add = useMemo(
() =>
controller
? (files: File[] | FileList) => controller.attachments.add(files)
: addLocal,
[controller, addLocal]
)
const remove = useMemo(
() =>
controller
? (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)
}),
[controller]
)
const clear = useMemo(
() =>
controller
? () => controller.attachments.clear()
: () =>
setItems((prev) => {
for (const file of prev) {
if (file.url) {
URL.revokeObjectURL(file.url)
}
}
return []
}),
[controller]
)
const openFileDialog = useMemo(
() =>
controller
? () => controller.attachments.openFileDialog()
: openFileDialogLocal,
[controller, 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 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={t('Upload files')}
className='hidden'
multiple={multiple}
onChange={handleChange}
ref={inputRef}
title={t('Upload files')}
type='file'
/>
<form
className={cn('w-full', className)}
onSubmit={handleSubmit}
{...props}
>
<InputGroup className={groupClassName}>{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,
...props
}: PromptInputTextareaProps) => {
const { t } = useTranslation()
const controller = useOptionalPromptInputController()
const attachments = usePromptInputAttachments()
const resolvedPlaceholder = placeholder ?? t('What would you like to know?')
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.length > 0
? attachments.files[attachments.files.length - 1]
: undefined
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={resolvedPlaceholder}
{...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
render={<PromptInputButton className={className} {...props} />}
>
{children ?? <PlusIcon className='size-4' />}
</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) => {
const { t } = useTranslation()
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={t('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) => void) | null
onend: ((this: SpeechRecognition, ev: Event) => void) | null
onresult:
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)
| null
onerror:
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)
| 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 {
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) => {
// eslint-disable-next-line no-console
console.error('Speech recognition error:', event.error)
setIsListening(false)
}
recognitionRef.current = speechRecognition
// eslint-disable-next-line react-hooks/set-state-in-effect
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 && 'bg-accent text-accent-foreground animate-pulse',
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,
size = 'sm',
...props
}: PromptInputModelSelectTriggerProps) => (
<SelectTrigger
className={cn(
'text-muted-foreground border-none bg-transparent font-medium shadow-none transition-colors',
'hover:bg-accent hover:text-foreground [&[aria-expanded="true"]]:bg-accent [&[aria-expanded="true"]]:text-foreground',
className
)}
size={size}
{...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 = (props: PromptInputHoverCardProps) => (
<HoverCard {...props} />
)
export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>
export const PromptInputHoverCardTrigger = ({
delay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardTriggerProps) => (
<HoverCardTrigger delay={delay} closeDelay={closeDelay} {...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(
'text-muted-foreground mb-2 px-3 text-xs font-medium',
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(
'hover:bg-accent flex items-center gap-2 px-3 py-2 text-xs',
className
)}
{...props}
/>
)
export type PromptInputCommandProps = ComponentProps<typeof Command>
export const PromptInputCommand = ({
className,
...props
}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>
export const PromptInputCommandInput = ({
className,
...props
}: PromptInputCommandInputProps) => (
<CommandInput className={cn(className)} {...props} />
)
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>
export const PromptInputCommandList = ({
className,
...props
}: PromptInputCommandListProps) => (
<CommandList className={cn(className)} {...props} />
)
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>
export const PromptInputCommandEmpty = ({
className,
...props
}: PromptInputCommandEmptyProps) => (
<CommandEmpty className={cn(className)} {...props} />
)
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>
export const PromptInputCommandGroup = ({
className,
...props
}: PromptInputCommandGroupProps) => (
<CommandGroup className={cn(className)} {...props} />
)
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>
export const PromptInputCommandItem = ({
className,
...props
}: PromptInputCommandItemProps) => (
<CommandItem className={cn(className)} {...props} />
)
export type PromptInputCommandSeparatorProps = ComponentProps<
typeof CommandSeparator
>
export const PromptInputCommandSeparator = ({
className,
...props
}: PromptInputCommandSeparatorProps) => (
<CommandSeparator className={cn(className)} {...props} />
)