组件
AI 输入框 AI Prompt
支持提及、模型选择、附件、引用和可选能力的组合式 AI 输入框。
基础示例
Loading…
pnpm dlx wui@latest add @wui/ai-prompt"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import {
LoaderCircleIcon,
PlusIcon,
QuoteIcon,
SparklesIcon,
SquareIcon,
XIcon,
} from "lucide-react"
import {
BrandAnthropicIcon,
BrandOpenaiIcon,
BrandXaiIcon,
FileDescriptionIcon,
SendHorizontalIcon,
} from "@/registry/icons/animated"
import type { AnimatedIconHandle } from "@/registry/icons/animated/types"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Cascader,
type CascaderProps,
} from "@/components/ui/cascader"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export type AiPromptStatus = "idle" | "submitted" | "streaming" | "error"
export type AiModelProvider = "openai" | "anthropic" | "xai"
export interface AiPromptMentionItem {
/** Stable option identifier. */
id: string
/** Visible name inserted after the trigger. */
label: string
/** Optional secondary line in the mention menu. */
description?: string
/** Custom value inserted into the textarea. Defaults to label. */
value?: string
/** Additional terms included in local filtering. */
keywords?: string[]
/** Avatar or icon displayed before the label. */
avatar?: React.ReactNode
}
type MentionRange = {
start: number
end: number
query: string
}
type AiPromptMentionContextValue = {
activeIndex: number
filteredItems: AiPromptMentionItem[]
menuId: string
open: boolean
textareaRef: React.RefObject<HTMLTextAreaElement | null>
value: string
setActiveIndex: React.Dispatch<React.SetStateAction<number>>
handleKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
selectItem: (item: AiPromptMentionItem) => void
updateValue: (value: string, caret: number) => void
}
const AiPromptMentionContext =
React.createContext<AiPromptMentionContextValue | null>(null)
const aiPromptVariants = cva(
"relative isolate w-full border bg-background text-foreground outline-none transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/15",
{
variants: {
size: {
compact:
"rounded-md [&_[data-slot=ai-prompt-textarea]]:min-h-8",
default:
"rounded-lg [&_[data-slot=ai-prompt-textarea]]:min-h-10",
},
},
defaultVariants: {
size: "default",
},
}
)
function useAiPromptMention() {
return React.useContext(AiPromptMentionContext)
}
function findMentionRange(
value: string,
caret: number,
trigger: string
): MentionRange | null {
const beforeCaret = value.slice(0, caret)
const start = beforeCaret.lastIndexOf(trigger)
if (start < 0) return null
const precedingCharacter = beforeCaret[start - 1]
const hasBoundary =
start === 0 ||
/[\s([{"',。!?、]/.test(precedingCharacter)
if (!hasBoundary) return null
const query = beforeCaret.slice(start + trigger.length)
if (!query || !/\s/.test(query)) {
return query.includes(trigger) ? null : { start, end: caret, query }
}
return null
}
function setReactRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
if (typeof ref === "function") ref(value)
else if (ref) ref.current = value
}
export interface AiPromptMentionProps {
/** Full textarea value controlled by the provider. */
value: string
/** Called for normal typing and mention insertion. */
onValueChange: (value: string) => void
/** Available mention targets. */
items: AiPromptMentionItem[]
/** Character that opens the mention menu. @default "@" */
trigger?: string
/** Called after a target is inserted. */
onMentionSelect?: (item: AiPromptMentionItem) => void
children: React.ReactNode
}
/** Coordinates mention detection, keyboard navigation, and insertion. */
function AiPromptMention({
value,
onValueChange,
items,
trigger = "@",
onMentionSelect,
children,
}: AiPromptMentionProps) {
const menuId = React.useId()
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const [range, setRange] = React.useState<MentionRange | null>(null)
const [activeIndex, setActiveIndex] = React.useState(0)
const filteredItems = React.useMemo(() => {
if (!range) return []
const query = range.query.trim().toLocaleLowerCase()
if (!query) return items
return items.filter((item) =>
[item.label, item.description, ...(item.keywords ?? [])]
.filter(Boolean)
.some((term) => term!.toLocaleLowerCase().includes(query))
)
}, [items, range])
React.useEffect(() => {
setActiveIndex(0)
}, [range?.query])
const selectItem = React.useCallback(
(item: AiPromptMentionItem) => {
if (!range) return
const insertion = `${trigger}${item.value ?? item.label} `
const nextValue =
value.slice(0, range.start) + insertion + value.slice(range.end)
const nextCaret = range.start + insertion.length
onValueChange(nextValue)
onMentionSelect?.(item)
setRange(null)
requestAnimationFrame(() => {
textareaRef.current?.focus()
textareaRef.current?.setSelectionRange(nextCaret, nextCaret)
})
},
[onMentionSelect, onValueChange, range, trigger, value]
)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (!range) return
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((current) =>
filteredItems.length ? (current + 1) % filteredItems.length : 0
)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((current) =>
filteredItems.length
? (current - 1 + filteredItems.length) % filteredItems.length
: 0
)
} else if (
(event.key === "Enter" || event.key === "Tab") &&
filteredItems[activeIndex]
) {
event.preventDefault()
selectItem(filteredItems[activeIndex])
} else if (event.key === "Escape") {
event.preventDefault()
setRange(null)
}
},
[activeIndex, filteredItems, range, selectItem]
)
const updateValue = React.useCallback(
(nextValue: string, caret: number) => {
onValueChange(nextValue)
setRange(findMentionRange(nextValue, caret, trigger))
},
[onValueChange, trigger]
)
return (
<AiPromptMentionContext.Provider
value={{
activeIndex,
filteredItems,
menuId,
open: range !== null,
textareaRef,
value,
setActiveIndex,
handleKeyDown,
selectItem,
updateValue,
}}
>
{children}
</AiPromptMentionContext.Provider>
)
}
export interface AiPromptProps extends React.ComponentProps<"form"> {
/** Composer density. @default "default" */
size?: "compact" | "default"
}
/** A standalone, composable prompt composer for AI products. */
function AiPrompt({
className,
size = "default",
onSubmit,
...props
}: AiPromptProps) {
return (
<form
data-slot="ai-prompt"
data-size={size}
className={cn(aiPromptVariants({ size }), className)}
onSubmit={(event) => {
if (!onSubmit) event.preventDefault()
onSubmit?.(event)
}}
{...props}
/>
)
}
function AiPromptHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-prompt-header"
className={cn(
"flex min-h-10 flex-nowrap items-center gap-2 overflow-hidden border-b px-3 py-1.5",
className
)}
{...props}
/>
)
}
function AiPromptContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-prompt-content"
className={cn("relative", className)}
{...props}
/>
)
}
export interface AiPromptTextareaProps
extends React.ComponentProps<"textarea"> {
/** Grow with the content until maxHeight is reached. @default true */
autoResize?: boolean
/** Maximum auto-resized height in pixels. @default 160 */
maxHeight?: number
}
function AiPromptTextarea({
className,
ref,
value,
onChange,
onKeyDown,
autoResize = true,
maxHeight = 160,
style,
...props
}: AiPromptTextareaProps) {
const mention = useAiPromptMention()
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
const resize = React.useCallback(
(textarea: HTMLTextAreaElement | null) => {
if (!textarea || !autoResize) return
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden"
},
[autoResize, maxHeight]
)
React.useLayoutEffect(() => {
resize(textareaRef.current)
}, [mention?.value, resize, value])
return (
<textarea
ref={(node) => {
textareaRef.current = node
setReactRef(ref, node)
if (mention) mention.textareaRef.current = node
}}
data-slot="ai-prompt-textarea"
rows={1}
value={value ?? mention?.value}
role={mention ? "combobox" : undefined}
aria-autocomplete={mention ? "list" : undefined}
aria-controls={mention?.menuId}
aria-expanded={mention?.open}
aria-activedescendant={
mention?.open && mention.filteredItems[mention.activeIndex]
? `${mention.menuId}-option-${mention.activeIndex}`
: undefined
}
className={cn(
"block w-full resize-none overflow-y-hidden bg-transparent px-2 py-2 text-[15px] leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
style={{ ...style, maxHeight }}
onChange={(event) => {
onChange?.(event)
if (!event.defaultPrevented && mention) {
mention.updateValue(
event.currentTarget.value,
event.currentTarget.selectionStart
)
}
resize(event.currentTarget)
}}
onKeyDown={(event) => {
mention?.handleKeyDown(event)
onKeyDown?.(event)
}}
{...props}
/>
)
}
export interface AiPromptMentionMenuProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Message shown when the current query has no matches. */
emptyMessage?: React.ReactNode
}
function AiPromptMentionMenu({
className,
emptyMessage = "没有匹配的提及对象",
...props
}: AiPromptMentionMenuProps) {
const mention = useAiPromptMention()
if (!mention?.open) return null
return (
<div
id={mention.menuId}
data-slot="ai-prompt-mention-menu"
role="listbox"
className={cn(
"absolute inset-x-3 bottom-[calc(100%+0.5rem)] z-30 max-h-64 overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
className
)}
{...props}
>
{mention.filteredItems.length ? (
mention.filteredItems.map((item, index) => (
<button
key={item.id}
id={`${mention.menuId}-option-${index}`}
type="button"
role="option"
aria-selected={index === mention.activeIndex}
data-slot="ai-prompt-mention-item"
data-active={index === mention.activeIndex}
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
onMouseEnter={() => mention.setActiveIndex(index)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => mention.selectItem(item)}
>
{item.avatar ? (
<span className="shrink-0">{item.avatar}</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{item.label}</span>
{item.description ? (
<span className="block truncate text-xs text-muted-foreground">
{item.description}
</span>
) : null}
</span>
</button>
))
) : (
<div className="px-3 py-5 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)}
</div>
)
}
function AiPromptFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-prompt-footer"
className={cn(
"flex min-h-12 items-end gap-2 px-2.5 py-1.5",
className
)}
{...props}
/>
)
}
function AiPromptTools({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-prompt-tools"
className={cn(
"flex shrink-0 items-center gap-1.5",
className
)}
{...props}
/>
)
}
export interface AiPromptActionProps
extends React.ComponentProps<typeof Button> {
/** Persistent toggle state for optional prompt capabilities. */
active?: boolean
/** Leading icon, including an animated icon from the collection. */
icon?: React.ReactNode
/** Text revealed on hover, focus, or while active. */
label?: React.ReactNode
}
function AiPromptAction({
className,
variant = "ghost",
size = "sm",
active = false,
icon,
label,
children,
...props
}: AiPromptActionProps) {
return (
<Button
type="button"
data-slot="ai-prompt-action"
data-active={active}
aria-pressed={active || undefined}
variant={variant}
size={size}
className={cn(
"group h-9 gap-0 rounded-md border border-transparent px-2.5 text-muted-foreground hover:border-border hover:bg-muted/60 data-[active=true]:border-border data-[active=true]:bg-muted data-[active=true]:text-foreground",
className
)}
{...props}
>
{icon ? <span className="flex size-4 shrink-0 items-center justify-center">{icon}</span> : null}
{label ? (
<span className="ml-0 max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-[max-width,margin,opacity] duration-300 ease-out group-hover:ml-1.5 group-hover:max-w-32 group-hover:opacity-100 group-focus-visible:ml-1.5 group-focus-visible:max-w-32 group-focus-visible:opacity-100 group-data-[active=true]:ml-1.5 group-data-[active=true]:max-w-32 group-data-[active=true]:opacity-100 motion-reduce:transition-none">
{label}
</span>
) : (
children
)}
</Button>
)
}
export interface AiPromptCapabilitySelectorProps
extends Omit<CascaderProps, "renderValue"> {
/** Leading icon shown while the text label is collapsed. */
icon?: React.ReactNode
}
/** A compact hierarchical picker for modes with nested effort levels. */
function AiPromptCapabilitySelector({
className,
contentClassName,
separator = "·",
icon,
...props
}: AiPromptCapabilitySelectorProps) {
return (
<Cascader
data-slot="ai-prompt-capability-selector"
separator={separator}
className={cn(
"h-9 w-auto min-w-0 gap-1.5 rounded-md border-transparent bg-transparent px-2.5 text-muted-foreground shadow-none hover:border-border hover:bg-muted/60",
className
)}
contentClassName={cn("rounded-lg shadow-md", contentClassName)}
renderValue={(selectedOptions) => (
<span className="flex min-w-0 items-center">
{icon ? (
<span className="flex size-4 shrink-0 items-center justify-center">
{icon}
</span>
) : null}
<span
className={cn(
"flex min-w-0 items-center whitespace-nowrap",
icon &&
"ml-0 max-w-0 overflow-hidden opacity-0 transition-[max-width,margin,opacity] duration-300 ease-out group-hover:ml-1.5 group-hover:max-w-40 group-hover:opacity-100 group-focus-visible:ml-1.5 group-focus-visible:max-w-40 group-focus-visible:opacity-100 motion-reduce:transition-none"
)}
>
{selectedOptions.map((option, index) => (
<React.Fragment key={option.value}>
{index ? (
<span className="mx-1 text-muted-foreground/60">
{separator}
</span>
) : null}
<span>{option.label}</span>
</React.Fragment>
))}
</span>
</span>
)}
{...props}
/>
)
}
export interface AiPromptAttachmentButtonProps
extends Omit<
React.ComponentProps<"input">,
"children" | "onChange" | "type" | "value"
> {
/** Called whenever files are selected. */
onFilesChange?: (files: File[]) => void
/** Visible trigger content. */
children?: React.ReactNode
}
function AiPromptAttachmentButton({
className,
multiple = false,
disabled,
onFilesChange,
children,
...props
}: AiPromptAttachmentButtonProps) {
return (
<label
data-slot="ai-prompt-attachment-button"
aria-disabled={disabled}
className={cn(
buttonVariants({ variant: "outline", size: "icon" }),
"size-9 cursor-pointer rounded-full bg-background text-muted-foreground shadow-none aria-disabled:pointer-events-none aria-disabled:opacity-50",
className
)}
>
<input
type="file"
multiple={multiple}
disabled={disabled}
className="sr-only"
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? [])
onFilesChange?.(multiple ? files : files.slice(0, 1))
event.currentTarget.value = ""
}}
{...props}
/>
{children ?? (
<PlusIcon />
)}
</label>
)
}
function AiPromptAttachments({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-prompt-attachments"
className={cn("flex min-w-0 flex-wrap items-center gap-2", className)}
{...props}
/>
)
}
export interface AiPromptAttachmentProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** File name shown in the attachment chip. */
name: string
/** Optional file size, type, or upload state. */
metadata?: React.ReactNode
/** Called by the remove button. Omit to render a read-only attachment. */
onRemove?: () => void
/** Custom leading preview or file icon. */
preview?: React.ReactNode
}
function AiPromptAttachment({
className,
name,
metadata,
onRemove,
preview,
...props
}: AiPromptAttachmentProps) {
return (
<div
data-slot="ai-prompt-attachment"
className={cn(
"flex h-7 min-w-0 max-w-72 items-center gap-1.5 rounded-md px-1.5 transition-colors hover:bg-muted/60",
className
)}
{...props}
>
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{preview ?? <FileDescriptionIcon size={16} />}
</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{name}
</span>
{metadata ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{metadata}
</span>
) : null}
{onRemove ? (
<button
type="button"
aria-label={`移除 ${name}`}
className="flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
onClick={onRemove}
>
<XIcon className="size-3.5" />
</button>
) : null}
</div>
)
}
export interface AiPromptQuoteProps extends React.ComponentProps<"blockquote"> {
/** Optional source label shown before the quoted content. */
label?: React.ReactNode
/** Called by the remove button. */
onRemove?: () => void
}
function AiPromptQuote({
className,
label,
onRemove,
children,
...props
}: AiPromptQuoteProps) {
return (
<blockquote
data-slot="ai-prompt-quote"
className={cn(
"flex h-7 min-w-0 flex-1 items-center gap-2 text-sm",
className
)}
{...props}
>
<QuoteIcon className="size-4 shrink-0 text-muted-foreground" />
{label ? (
<span className="shrink-0 text-xs font-medium text-foreground">
{label}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{children}
</span>
{onRemove ? (
<button
type="button"
aria-label="移除引用"
className="flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
onClick={onRemove}
>
<XIcon className="size-3.5" />
</button>
) : null}
</blockquote>
)
}
function AiModelSelector(
props: React.ComponentProps<typeof Select>
) {
return <Select {...props} />
}
function AiModelSelectorValue(
props: React.ComponentProps<typeof SelectValue>
) {
return <SelectValue {...props} />
}
export interface AiModelProviderIconProps
extends React.ComponentProps<"span"> {
/** AI model provider represented by the brand mark. */
provider: AiModelProvider
}
function AiModelProviderIcon({
className,
provider,
onMouseEnter,
onMouseLeave,
...props
}: AiModelProviderIconProps) {
const iconRef = React.useRef<AnimatedIconHandle>(null)
const ProviderIcon =
provider === "openai"
? BrandOpenaiIcon
: provider === "anthropic"
? BrandAnthropicIcon
: BrandXaiIcon
return (
<span
data-slot="ai-model-provider-icon"
data-provider={provider}
aria-hidden="true"
className={cn("flex size-8 shrink-0 items-center justify-center text-foreground", className)}
onMouseEnter={(event) => {
iconRef.current?.startAnimation()
onMouseEnter?.(event)
}}
onMouseLeave={(event) => {
iconRef.current?.stopAnimation()
onMouseLeave?.(event)
}}
{...props}
>
<ProviderIcon ref={iconRef} size={20} className="shrink-0" />
</span>
)
}
export interface AiModelSelectorTriggerProps
extends React.ComponentProps<typeof SelectTrigger> {
/** Provider used to render the leading brand icon. */
provider?: AiModelProvider
/** Primary selected model label. */
model?: React.ReactNode
/** Secondary mode or capability label. */
description?: React.ReactNode
/** Custom icon that replaces the provider mark. */
icon?: React.ReactNode
}
function AiModelSelectorTrigger({
className,
children,
size = "sm",
provider,
model,
description,
icon,
...props
}: AiModelSelectorTriggerProps) {
return (
<SelectTrigger
data-slot="ai-model-selector-trigger"
size={size}
className={cn(
"h-9 min-h-0 min-w-0 gap-1.5 border-transparent bg-transparent px-2 py-0 text-foreground shadow-none hover:bg-muted/50 focus-visible:ring-2",
className
)}
{...props}
>
{icon ??
(provider ? (
<AiModelProviderIcon
provider={provider}
className="size-5"
/>
) : (
<SparklesIcon className="size-4 text-muted-foreground" />
))}
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium">
{model ?? children}
</span>
</SelectTrigger>
)
}
function AiModelSelectorContent({
className,
...props
}: React.ComponentProps<typeof SelectContent>) {
return (
<SelectContent
data-slot="ai-model-selector-content"
className={cn("min-w-64", className)}
{...props}
/>
)
}
function AiModelSelectorGroup(
props: React.ComponentProps<typeof SelectGroup>
) {
return <SelectGroup data-slot="ai-model-selector-group" {...props} />
}
function AiModelSelectorLabel({
className,
...props
}: React.ComponentProps<typeof SelectLabel>) {
return (
<SelectLabel
data-slot="ai-model-selector-label"
className={cn(
"px-3 pb-1 pt-2 text-[11px] font-medium normal-case tracking-normal",
className
)}
{...props}
/>
)
}
function AiModelSelectorSeparator({
className,
...props
}: React.ComponentProps<typeof SelectSeparator>) {
return (
<SelectSeparator
data-slot="ai-model-selector-separator"
className={cn("my-1", className)}
{...props}
/>
)
}
export interface AiModelSelectorItemProps
extends React.ComponentProps<typeof SelectItem> {
/** Supporting capability or cost note. */
description?: React.ReactNode
/** Provider used to render the model's brand icon. */
provider?: AiModelProvider
/** Right-aligned context, speed, or plan metadata. */
meta?: React.ReactNode
/** Custom icon that replaces the provider mark. */
icon?: React.ReactNode
}
function AiModelSelectorItem({
className,
description,
provider,
meta,
icon,
children,
...props
}: AiModelSelectorItemProps) {
return (
<SelectItem
data-slot="ai-model-selector-item"
className={cn("min-h-10 items-center py-1.5 pr-3", className)}
{...props}
>
<span className="flex min-w-0 items-center gap-2.5">
{icon ??
(provider ? (
<AiModelProviderIcon provider={provider} className="size-7" />
) : null)}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{children}</span>
{description ? (
<span className="mt-0.5 block truncate text-xs font-normal text-muted-foreground">
{description}
</span>
) : null}
</span>
{meta ? (
<span className="shrink-0 text-[11px] font-normal text-muted-foreground">
{meta}
</span>
) : null}
</span>
</SelectItem>
)
}
export interface AiPromptSubmitProps
extends React.ComponentProps<typeof Button> {
/** Current request lifecycle. @default "idle" */
status?: AiPromptStatus
}
function AiPromptSubmit({
className,
status = "idle",
children,
...props
}: AiPromptSubmitProps) {
const busy = status === "submitted" || status === "streaming"
return (
<Button
type={busy ? "button" : "submit"}
size="icon"
aria-label={busy ? "停止生成" : "发送消息"}
data-slot="ai-prompt-submit"
data-status={status}
className={cn("size-10 rounded-full", className)}
{...props}
>
{children ??
(status === "submitted" ? (
<LoaderCircleIcon className="motion-safe:animate-spin" />
) : status === "streaming" ? (
<SquareIcon className="size-3 fill-current" />
) : (
<SendHorizontalIcon size={18} />
))}
</Button>
)
}
export {
AiModelProviderIcon,
AiModelSelector,
AiModelSelectorContent,
AiModelSelectorGroup,
AiModelSelectorItem,
AiModelSelectorLabel,
AiModelSelectorSeparator,
AiModelSelectorTrigger,
AiModelSelectorValue,
AiPrompt,
AiPromptAction,
AiPromptCapabilitySelector,
AiPromptAttachment,
AiPromptAttachmentButton,
AiPromptAttachments,
AiPromptContent,
AiPromptFooter,
AiPromptHeader,
AiPromptMention,
AiPromptMentionMenu,
AiPromptQuote,
AiPromptSubmit,
AiPromptTextarea,
AiPromptTools,
aiPromptVariants,
}
组件作用
AiPrompt 是可独立放在页面、侧栏或 AiChat 底部的提示输入框。它负责输入区结构、提及选择、模型入口、附件展示和发送状态,不绑定具体 AI SDK、上传接口或模型列表。
普通搜索框、评论输入和传统表单不应使用 AiPrompt。只有输入会触发模型能力,且确实需要模型、上下文或工具选项时,才使用这些附加部件。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "compact" | "default" | default | Composer density. |
事件
AiPrompt透传表单onSubmit,业务层负责发送请求。AiPromptTextarea默认随内容自动增高,到达maxHeight后切换为内部滚动;放在AiPromptMention中时,Provider 通过onValueChange统一处理普通输入和提及插入。AiPromptMention onMentionSelect在候选项插入后触发;菜单支持方向键、Enter、Tab 与 Escape。AiPromptAttachmentButton onFilesChange返回本地File[],不预设上传请求。AiModelSelector onValueChange返回模型值;简单布尔能力使用AiPromptAction,存在模式与强度层级的能力使用AiPromptCapabilitySelector。
扩展使用
提及与引用上下文
Loading…
将输入框包在 AiPromptMention 中即可启用 @ 检测、过滤、键盘导航与插入。引用内容放在 AiPromptHeader,与正文保持明确层级,不把引用拼进 placeholder。
模型、附件与可选能力
Loading…
模型选择器复用通用 Select,通过 provider 自动显示 OpenAI、Anthropic 或 xAI 厂商标识,并以主模型名、当前模式和能力说明建立两级信息层次。已选文件使用 AiPromptAttachment 呈现;联网搜索等布尔能力使用 AiPromptAction active,深度思考则通过复用 Cascader 的 AiPromptCapabilitySelector 继续选择 effort。