组件
AI 模型选择器 AI Model Selector
专为大语言模型切换、推理特性与能力标签展示,以及上下文 Token / Context Window 消耗进度指示设计的下拉选择器。
基础用法
包含模型厂商图标、能力徽章(如 Reasoning、Fast、200k)、分组归类与 Token 窗口配额进度的模型下拉选择器:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/ai-model-selector安装基础依赖与动效库
pnpm add radix-ui class-variance-authority lucide-react clsx tailwind-merge复制组件源码到
components/ui/ai-model-selector.tsx"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
ChevronDownIcon,
CpuIcon,
SparklesIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
const glideSpring = {
type: "spring",
stiffness: 520,
damping: 38,
mass: 0.7,
} as const
type AiModelListContextValue = {
layoutId: string
highlighted: string | null
setHighlighted: (id: string | null) => void
}
const AiModelListContext = React.createContext<AiModelListContextValue | null>(
null
)
/* -------------------------------------------------------------------------- */
/* AiModelSelector */
/* -------------------------------------------------------------------------- */
export interface AiModelSelectorProps
extends React.ComponentProps<typeof PopoverPrimitive.Root> {
/** 默认是否展开模型选择弹层(非受控)。 */
defaultOpen?: boolean
/** 是否展开模型选择弹层(受控)。 */
open?: boolean
/** 弹层展开或关闭状态改变时的回调函数。 */
onOpenChange?: (open: boolean) => void
/** 是否以模态方式呈现。 @default false */
modal?: boolean
}
/** 专用于大模型切换与参数配置的下拉选择器。 */
function AiModelSelector({ children, ...props }: AiModelSelectorProps) {
return <PopoverPrimitive.Root {...props}>{children}</PopoverPrimitive.Root>
}
/* -------------------------------------------------------------------------- */
/* AiModelSelectorTrigger */
/* -------------------------------------------------------------------------- */
const aiModelSelectorTriggerVariants = cva(
"group inline-flex cursor-pointer select-none items-center justify-between gap-2 rounded-md border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground outline-none transition-colors hover:bg-muted/50 focus-visible:ring-[3px] focus-visible:ring-ring/35 data-[state=open]:bg-muted/50",
{
variants: {
variant: {
default: "shadow-xs",
ghost: "border-transparent bg-transparent hover:bg-muted data-[state=open]:bg-muted",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface AiModelSelectorTriggerProps
extends React.ComponentProps<typeof PopoverPrimitive.Trigger>,
VariantProps<typeof aiModelSelectorTriggerVariants> {
/** Optional icon rendered on the left of the model name. */
icon?: React.ReactNode
}
function AiModelSelectorTrigger({
className,
variant,
icon,
children,
...props
}: AiModelSelectorTriggerProps) {
const reduceMotion = useReducedMotion()
const label = typeof children === "string" ? children : undefined
return (
<PopoverPrimitive.Trigger
data-slot="ai-model-selector-trigger"
className={cn(aiModelSelectorTriggerVariants({ variant }), className)}
{...props}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="flex size-3.5 shrink-0 items-center justify-center [&_svg]:size-3.5">
{icon ?? <SparklesIcon className="text-muted-foreground" />}
</span>
<span className="relative min-w-0 truncate">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={label ?? "custom"}
className="block truncate"
initial={reduceMotion ? false : { opacity: 0, y: 6, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? undefined : { opacity: 0, y: -6, filter: "blur(2px)" }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
}
>
{children}
</motion.span>
</AnimatePresence>
</span>
</span>
<ChevronDownIcon className="size-3 shrink-0 text-muted-foreground transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[state=open]:rotate-180 motion-reduce:transition-none" />
</PopoverPrimitive.Trigger>
)
}
/* -------------------------------------------------------------------------- */
/* AiModelSelectorContent */
/* -------------------------------------------------------------------------- */
export interface AiModelSelectorContentProps
extends React.ComponentProps<typeof PopoverPrimitive.Content> {}
function AiModelSelectorContent({
className,
align = "start",
sideOffset = 6,
children,
onKeyDown,
...props
}: AiModelSelectorContentProps) {
const layoutId = React.useId()
const [highlighted, setHighlighted] = React.useState<string | null>(null)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="ai-model-selector-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 max-w-[calc(100vw-2rem)] origin-(--radix-popover-content-transform-origin) rounded-lg border bg-popover p-1 text-popover-foreground shadow-md outline-none duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1",
className
)}
onKeyDown={(event) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'[data-slot="ai-model-item"]:not(:disabled)'
)
)
if (!items.length) return
event.preventDefault()
const index = items.indexOf(document.activeElement as HTMLButtonElement)
const next =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: index < 0
? 0
: (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) %
items.length
items[next]?.focus()
}}
onPointerLeave={() => setHighlighted(null)}
{...props}
>
<AiModelListContext.Provider
value={{ layoutId, highlighted, setHighlighted }}
>
{children}
</AiModelListContext.Provider>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
)
}
/* -------------------------------------------------------------------------- */
/* AiModelGroup */
/* -------------------------------------------------------------------------- */
export interface AiModelGroupProps extends React.ComponentProps<"div"> {
/** Group title label. */
heading?: React.ReactNode
}
function AiModelGroup({
className,
heading,
children,
...props
}: AiModelGroupProps) {
const headingId = React.useId()
return (
<div
role="group"
aria-labelledby={heading ? headingId : undefined}
data-slot="ai-model-group"
className={cn("flex flex-col py-1", className)}
{...props}
>
{heading && (
<div
id={headingId}
className="px-2 pb-1 pt-0.5 text-[11px] font-medium text-muted-foreground"
>
{heading}
</div>
)}
<div className="flex flex-col gap-px">{children}</div>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* AiModelItem */
/* -------------------------------------------------------------------------- */
export interface AiModelItemProps
extends Omit<React.ComponentProps<"button">, "name"> {
/** 模型名称或标题。 */
name: React.ReactNode
/** 模型的简短描述或特性。 */
description?: React.ReactNode
/** 当前模型是否处于选中状态。 @default false */
selected?: boolean
/** 模型厂商或类型专属图标。 */
icon?: React.ReactNode
/** 模型能力或参数标签(如 Vision、Reasoning)。 */
badge?: React.ReactNode
}
function AiModelItem({
className,
name,
description,
selected = false,
icon,
badge,
onPointerEnter,
onFocus,
...props
}: AiModelItemProps) {
const id = React.useId()
const list = React.useContext(AiModelListContext)
const reduceMotion = useReducedMotion()
// The highlight follows the pointer/focus and settles back on the selection.
const highlighted = list
? (list.highlighted ?? (selected ? id : null)) === id
: selected
return (
<button
type="button"
data-slot="ai-model-item"
data-selected={selected ? "true" : "false"}
data-highlighted={highlighted ? "true" : undefined}
aria-current={selected ? "true" : undefined}
className={cn(
"group relative isolate flex w-full cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left text-xs outline-none transition-colors disabled:pointer-events-none disabled:opacity-50",
!list && "hover:bg-muted",
!list && selected && "bg-muted",
className
)}
onPointerEnter={(event) => {
list?.setHighlighted(id)
onPointerEnter?.(event)
}}
onFocus={(event) => {
list?.setHighlighted(id)
onFocus?.(event)
}}
{...props}
>
{list && highlighted ? (
<motion.span
aria-hidden
layoutId={`${list.layoutId}-highlight`}
className="absolute inset-0 z-[-1] rounded-md bg-muted"
transition={reduceMotion ? { duration: 0 } : glideSpring}
/>
) : null}
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors group-data-[highlighted=true]:text-foreground [&_svg]:size-3.5">
{icon ?? <CpuIcon />}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium text-foreground">{name}</span>
{badge}
</div>
{description && (
<p className="mt-0.5 line-clamp-1 text-[11px] font-normal text-muted-foreground">
{description}
</p>
)}
</div>
<span className="mt-0.5 flex size-3.5 shrink-0 items-center justify-center">
<AnimatePresence initial={false}>
{selected ? (
<motion.svg
key="check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="size-3.5 text-foreground"
initial={reduceMotion ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? undefined : { opacity: 0, scale: 0.6 }}
transition={reduceMotion ? { duration: 0 } : glideSpring}
>
<motion.path
d="M20 6 9 17l-5-5"
initial={reduceMotion ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.28, ease: [0.22, 1, 0.36, 1], delay: 0.05 }
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</span>
</button>
)
}
/* -------------------------------------------------------------------------- */
/* AiTokenUsage */
/* -------------------------------------------------------------------------- */
export interface AiTokenUsageProps extends React.ComponentProps<"div"> {
/** Used token count. */
used: number
/** Maximum context window limit. */
limit: number
/** Label describing the quota. @default "Context Window" */
label?: string
/** Whether to show percentage string. @default true */
showPercentage?: boolean
}
function formatTokens(num: number) {
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}k`
return num.toString()
}
function AiTokenUsage({
className,
used,
limit,
label = "Context Window",
showPercentage = true,
...props
}: AiTokenUsageProps) {
const reduceMotion = useReducedMotion()
const ratio = limit > 0 ? Math.min(1, Math.max(0, used / limit)) : 0
const percentage = Math.round(ratio * 100)
const statusColor =
percentage >= 95
? "bg-destructive"
: percentage >= 80
? "bg-warning"
: "bg-primary"
return (
<div
data-slot="ai-token-usage"
className={cn("mt-1 flex flex-col gap-1.5 border-t px-2 pb-1.5 pt-2.5 text-xs", className)}
{...props}
>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>{label}</span>
<span className="font-mono tabular-nums">
{formatTokens(used)} / {formatTokens(limit)}
{showPercentage && ` · ${percentage}%`}
</span>
</div>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={limit}
aria-valuenow={used}
className="h-1 w-full overflow-hidden rounded-full bg-muted"
>
<motion.div
className={cn("h-full origin-left rounded-full transition-colors duration-300", statusColor)}
initial={reduceMotion ? false : { scaleX: 0 }}
animate={{ scaleX: ratio }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 180, damping: 26, delay: 0.08 }
}
/>
</div>
</div>
)
}
export {
AiModelGroup,
AiModelItem,
AiModelSelector,
AiModelSelectorContent,
AiModelSelectorTrigger,
AiTokenUsage,
aiModelSelectorTriggerVariants,
}
属性 Props
AiModelSelector (根选择器容器)
底层基于 Radix Popover 封装:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| open | boolean | — | 受控模式下弹层展开状态。 |
| defaultOpen | boolean | false | 非受控模式下初始展开状态。 |
| onOpenChange | (open: boolean) => void | — | 弹层展开或关闭状态改变时的回调函数。 |
| modal | boolean | false | 是否以模态方式呈现。为 true 时阻止与外部页面元素交互。 |
AiModelSelectorTrigger (触发按钮)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | "default" | "ghost" | "default" | 外观变体。"default" 为带微边框与阴影的标准按钮;"ghost" 为适用于工具栏的透明背景。 |
| icon | React.ReactNode | — | 模型前置图标(如厂商 Logo 或闪电图标)。 |
AiModelGroup (模型分类分组)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| heading | React.ReactNode | — | 分组的标题文案(如「Flagship / 深度推理」)。 |
AiModelItem (单个模型选项)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| name | React.ReactNode | — | 模型的展示名称(如 `Claude 3.7 Sonnet`)。 |
| description | React.ReactNode | — | 模型的简要特性或适用场景说明。 |
| selected | boolean | false | 当前模型是否处于选中状态。选中时右侧展示高亮勾选图标。 |
| icon | React.ReactNode | — | 模型专属图标。 |
| badge | React.ReactNode | — | 模型能力徽章(如 `<Badge>Vision</Badge>` 或 `<Badge>200k</Badge>`)。 |
AiTokenUsage (上下文窗口用量进度)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| used | number | — | 当前对话已消耗的 Token 总量。 |
| limit | number | — | 当前模型支持的最大上下文窗口(Context Window)限制。 |
| label | string | "Context Window" | 配额指示器的标签文案。 |
| showPercentage | boolean | true | 是否在右侧数字旁展示百分比(如 `(38%)`)。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onOpenChange | (open: boolean) => void | — | 模型选择器下拉面板展开或关闭时触发。 |
| onClick | (event: React.MouseEvent<HTMLButtonElement>) => void | — | 点击具体的 `AiModelItem` 时触发,通常在此切换当前激活的模型 ID。 |
使用场景与设计规范
AiModelSelector 帮助用户根据任务复杂度(如纯文本快速总结 vs 多模态深度推演)选择合适的大模型,并实时掌握上下文窗口容量:
- 何时使用:
- 支持多模型路由的 AI 平台、开发平台或 Copilot 工具栏;
- 需要告知用户当前所选模型的上下文窗口限制(如 128k、200k、1M tokens);
- 需要在推理旗舰(Flagship)与高速轻量(Fast)模型之间建立清晰层级。
- 何时不应使用:
- 系统底层固定单一模型、不向终端用户暴露切换入口的应用;
- 常规表单下拉项(请使用通用
Select)。
- 设计最佳实践:
- 清晰的能力徽章:为具备长思维链的模型添加
Reasoning标签,为高并发低延迟模型添加Fast标签; - 告警阈值指示:
AiTokenUsage会根据已用比例自动切换色相(正常为 Primary、超 80% 为 Warning、超 95% 为 Destructive); - 紧凑集成:可直接置于
AiPromptFooter或AiChatPromptTools工具栏中。
- 清晰的能力徽章:为具备长思维链的模型添加
场景示例
旗舰推理与极速轻量模型分级配置
按上下文窗口分级展示模型:当前会话超出窗口的模型自动禁用,底部用量条随所选模型变化:
Loading…
无障碍与交互 Accessibility
- Popover 语义支持:
- 触发器挂载
aria-haspopup="dialog"与aria-expanded,并关联下拉浮层的id; - 选中的
AiModelItem带有data-selected="true"与视觉勾选指示。
- 触发器挂载
- 键盘导航支持:
- Tab:按顺序聚焦模型选项;
- Enter / Space:选中目标模型并关闭弹层;
- Escape:快速退出选择面板。
- 高对比度焦点环:
- 触发器与所有模型子选项在键盘聚焦时均具备清晰的高对比度外边框。