wui
组件

AI 输入框 AI Prompt

专为现代 AI 对话与智能体(Agent)场景设计的组合式输入框,支持 @ 提及、模型切换、文件附件、引用上下文与思考强度调节。

第三方依赖 · class-variance-authority第三方依赖 · lucide-react第三方依赖 · motion

基础用法

最常用的 AI 输入框用法。包含自增高输入区、模型快速选择器、附件上传入口与带加载状态的发送按钮:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/ai-prompt
安装基础依赖与动效库
pnpm add radix-ui motion class-variance-authority lucide-react clsx tailwind-merge
复制组件源码到 components/ui/ai-prompt.tsx
components/ui/ai-prompt.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type HTMLMotionProps,
} from "motion/react"
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

      // Measure the natural height, then restore the previous height before
      // applying the new one so the CSS height transition has a start value.
      const previous = textarea.style.height
      textarea.style.height = "auto"
      const scrollHeight = textarea.scrollHeight
      const next = `${Math.min(scrollHeight, maxHeight)}px`
      textarea.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden"
      if (previous && previous !== next) {
        textarea.style.height = previous
        void textarea.offsetHeight
      }
      textarea.style.height = next
    },
    [autoResize, maxHeight]
  )

  // Tracks the value already measured in onChange, so the follow-up layout
  // effect does not re-measure and cancel the running height transition.
  const measuredValue = React.useRef<string | null>(null)

  React.useLayoutEffect(() => {
    const textarea = textareaRef.current
    if (textarea && textarea.value === measuredValue.current) return
    resize(textarea)
  }, [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 transition-[height] duration-150 ease-out placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none",
        className
      )}
      style={{ ...style, maxHeight }}
      onChange={(event) => {
        onChange?.(event)
        if (!event.defaultPrevented && mention) {
          mention.updateValue(
            event.currentTarget.value,
            event.currentTarget.selectionStart
          )
        }
        measuredValue.current = event.currentTarget.value
        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()
  const reduceMotion = useReducedMotion()
  const open = !!mention?.open
  const activeIndex = mention?.activeIndex ?? 0
  const menuId = mention?.menuId

  React.useEffect(() => {
    if (!open || !menuId) return
    document
      .getElementById(`${menuId}-option-${activeIndex}`)
      ?.scrollIntoView({ block: "nearest" })
  }, [activeIndex, menuId, open])

  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 origin-bottom overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-[0.98] motion-safe:slide-in-from-bottom-1",
        className
      )}
      {...props}
    >
      {mention.filteredItems.length ? (
        mention.filteredItems.map((item, index) => (
          <button
            key={item.id}
            id={`${mention.menuId}-option-${index}`}
            type="button"
            role="option"
            tabIndex={-1}
            aria-selected={index === mention.activeIndex}
            data-slot="ai-prompt-mention-item"
            data-active={index === mention.activeIndex}
            className="relative isolate flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm outline-none transition-colors data-[active=true]:text-accent-foreground"
            onMouseEnter={() => mention.setActiveIndex(index)}
            onMouseDown={(event) => event.preventDefault()}
            onClick={() => mention.selectItem(item)}
          >
            {index === mention.activeIndex ? (
              <motion.span
                aria-hidden
                layoutId={`${mention.menuId}-highlight`}
                className="absolute inset-0 z-[-1] rounded-md bg-accent"
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
                }
              />
            ) : null}
            {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>
  )
}

/** Lays out attachment chips and animates keyed chips in and out. */
function AiPromptAttachments({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-prompt-attachments"
      className={cn("relative flex min-w-0 flex-wrap items-center gap-2", className)}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {children}
      </AnimatePresence>
    </div>
  )
}

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) {
  const reduceMotion = useReducedMotion()

  return (
    <motion.div
      layout={!reduceMotion}
      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
      )}
      initial={
        reduceMotion ? false : { opacity: 0, scale: 0.9, filter: "blur(3px)" }
      }
      animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
      exit={
        reduceMotion
          ? { opacity: 0, transition: { duration: 0 } }
          : { opacity: 0, scale: 0.9, filter: "blur(3px)" }
      }
      transition={
        reduceMotion
          ? { duration: 0 }
          : { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
      }
      {...(props as HTMLMotionProps<"div">)}
    >
      <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}
    </motion.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 reduceMotion = useReducedMotion()
  const busy = status === "submitted" || status === "streaming"
  const icon =
    status === "submitted" ? (
      <LoaderCircleIcon className="motion-safe:animate-spin" />
    ) : status === "streaming" ? (
      <SquareIcon className="size-3 fill-current" />
    ) : (
      <SendHorizontalIcon size={18} />
    )

  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 transition-[color,background-color,border-color,box-shadow,opacity,scale] active:scale-95 motion-reduce:active:scale-100",
        className
      )}
      {...props}
    >
      {children ?? (
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={status === "error" ? "idle" : status}
            className="flex items-center justify-center"
            initial={
              reduceMotion
                ? false
                : { opacity: 0, scale: 0.4, rotate: -45, filter: "blur(2px)" }
            }
            animate={{ opacity: 1, scale: 1, rotate: 0, filter: "blur(0px)" }}
            exit={
              reduceMotion
                ? undefined
                : { opacity: 0, scale: 0.4, rotate: 45, filter: "blur(2px)" }
            }
            transition={
              reduceMotion
                ? { duration: 0 }
                : { type: "spring", stiffness: 520, damping: 32, mass: 0.6 }
            }
          >
            {icon}
          </motion.span>
        </AnimatePresence>
      )}
    </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,
}

属性 Props

AiPrompt (根组件)

AiPrompt 本质为一个语义化 <form> 容器,支持以下配置属性:

属性类型默认值说明
size"default" | "compact""default"输入框的排版密度与圆角尺寸规范。
onSubmit(event: React.FormEvent<HTMLFormElement>) => void—表单提交时的回调函数,通常在此处发送模型请求。
classNamestring—应用于外层表单容器的额外 CSS 类名。

AiPromptTextarea

AiPromptTextarea 为自适应高度的文本输入域,支持以下属性:

属性类型默认值说明
autoResizebooleantrue是否随输入内容行数自动增高。
maxHeightnumber160自动增高的最大高度像素值(px),超过后内部启用滚动。
valuestring—受控模式下的输入文本值。
onChange(event: React.ChangeEvent<HTMLTextAreaElement>) => void—输入文本变化时的回调函数。
placeholderstring—输入框占位引导提示文本。

AiPromptMention (提及上下文容器)

属性类型默认值说明
valuestring—受控的完整文本输入内容。
onValueChange(value: string) => void—文本变动(包含普通输入和提及插入)时的统一更新回调。
itemsAiPromptMentionItem[]—可供提及的候选对象列表,包含 id、label、description 等。
triggerstring"@"触发提及下拉菜单的引导字符。
onMentionSelect(item: AiPromptMentionItem) => void—用户选中某一项提及对象并插入到输入框后的回调函数。

AiPromptSubmit (发送/停止按钮)

属性类型默认值说明
status"idle" | "submitted" | "streaming" | "error""idle"当前请求生命周期状态。在 `submitted` 状态下展示加载动画,在 `streaming` 状态下展示方形停止生成图标;状态切换时图标以旋转缩放的方式过渡。
disabledbooleanfalse是否禁用提交按钮交互。

AiPromptAction (能力开关按钮)

属性类型默认值说明
activebooleanfalse能力是否处于激活开启状态(如联网搜索已开启)。
iconReact.ReactNode—按钮前置图标。
labelReact.ReactNode—悬停展开或激活时展示的文本标签。

事件 Events

属性类型默认值说明
onSubmit(event: React.FormEvent<HTMLFormElement>) => void—用户点击发送按钮或在输入框中按下 Enter 键时触发。
onValueChange(value: string) => void—由 `AiPromptMention` 统一派发的文本变化事件。
onMentionSelect(item: AiPromptMentionItem) => void—在提及菜单中选择特定候选项后触发,参数为被选中的目标对象。
onFilesChange(files: File[]) => void—通过 `AiPromptAttachmentButton` 选择本地文件后触发。

使用场景与设计规范

AiPrompt 专用于 AI 对话、Copilot 侧边栏和智能体交互场景,它负责组织输入区层级、上下文载入与操作入口:

  • 何时使用:
    • 聊天机器人与 Copilot 对话交互输入区;
    • 包含多模态文件上传、联网搜索、深度推理强度调节等复杂上下文配置的输入场景;
    • 需要 @ 提及项目文件、数据库表或团队成员的协作工作流。
  • 何时不应使用:
    • 常规单行全局搜索框(请使用 Command 或 Input);
    • 表单中的纯文本备注(请使用 Textarea);
    • 社区评论发布框(不需要模型与推理选项配置)。
  • 设计最佳实践:
    • 回车键快捷交互:遵循“Enter 发送、Shift + Enter 换行”的行业统一交互约定;
    • 加载与打断保护:在模型流式生成期间,发送按钮应自动切换为“停止生成”按钮,允许用户随时打断冗长回答;
    • 上下文明确可视:被引用的代码片段或上传的文件应放置在 AiPromptHeader 独立卡片中,避免直接堆砌在 placeholder 中。

场景示例

提及成员与引用上下文

通过将输入区域嵌套在 AiPromptMention 中,支持输入 @ 触发候选项实时联想与键盘导航:

Loading…

高级参数与多模态附件

组合引用预览 (AiPromptQuote)、文件附件列表 (AiPromptAttachments)、级联思考强度选择器 (AiPromptCapabilitySelector) 以及厂商模型切换器。AiPromptAttachments 会为带有稳定 key 的附件播放进出场动画,其余附件平滑补位:

Loading…

紧凑输入条

在侧边栏或轻量 Copilot 窗口中使用 compact 尺寸节省垂直空间。发送后按钮依次过渡为加载、停止,再回到发送:

Loading…

无障碍与交互 Accessibility

  • ARIA 角色与语义:
    • AiPrompt 根元素渲染为标准 <form>,便于辅助设备识别提交语义;
    • 当提及菜单展开时,输入框自动挂载 role="combobox"、aria-autocomplete="list" 以及 aria-expanded="true";
    • 提交按钮会依据 status 动态更新 aria-label(如“发送消息”或“停止生成”)。
  • 键盘导航交互:
    • Enter:直接触发表单提交;在提及菜单展开时则选中当前高亮项。
    • Shift + Enter:在文本域中插入换行符,不会触发误提交。
    • ↑ / ↓:在提及弹层中循环切换高亮项。
    • Escape:关闭当前打开的提及菜单或模型选择弹层。
  • 动效降级:
    • 按钮文字展开与图标过渡遵循 prefers-reduced-motion 系统偏好,开启“减少动态效果”时将立即切换状态。