跳转到主要内容
src/components/ai/ 是一组扁平的 19 个组件。它们在 shadcn/ui 之上封了一层 AI 工作站特有的模式:成本徽章、token 柱、流式光标、生成卡、图片画廊。每个都是展示型: 无全局 state、无隐藏副作用、不带 Suspense。即拿即用,想换就 copy-paste 改。

前置条件

  • shadcn/ui 已经接好(脚手架自带)。
  • Tailwind 类名已被你的主题识别(@/lib/utilscn)。
  • 看一遍 src/components/ai/index.ts 的导出清单。

API 参考

展示型(6 个)

<CostBadge microCents={1234} compact?={true} /> —— 把 micro-cents 整数渲染成金额。 不到 1¢ 显示 4 位小数,≥ 1¢ 显示 2 位。
<CostBadge microCents={result.usage.costMicroCents} />
<TokenMeter inputTokens={120} outputTokens={480} total?={4096} /> —— 输入/输出 token 的堆叠柱状图。传 total 可以显示上下文窗口占用率。 <LatencyPill totalMs={840} ttftMs?={120} /> —— 流式调用的 total + TTFT 胶囊条。 <ProviderPill provider="openai" model?="gpt-4o-mini" /> —— 带颜色的 provider 徽章,每个 AIProviderName 一种颜色。 <CreditsBadge label?="credits" hideWhileLoading?={true} /> —— 顶栏积分徽章, 内部包了 useCredits()。匿名用户什么都不渲染。 <StreamingText text={text} isStreaming?={true} /> —— whitespace-pre-wrap, streaming 时尾巴有闪烁光标。
<StreamingText text={text} isStreaming={isStreaming} />

聊天(2 个)

<ChatBubble author="user" trailing?={<CopyButton text=".." />}>...</ChatBubble> —— 单条消息气泡。author'system' | 'user' | 'assistant',user 靠右对齐。
<ChatBubble author="assistant" trailing={<CopyButton text={msg} iconOnly />}>
  {msg}
</ChatBubble>
<ChatList trigger={messages.length}>...</ChatList> —— 自动滚动容器。聪明:用户 本来就贴底才会贴底跟。把每次 scroll 之间会变的东西塞 trigger

容器(8 个)

<GenerationCard title?="GPT-4o-mini" actions?={...} footer?={...}>...</GenerationCard> —— 任何 AI 结果的卡片外壳:标题行 + 内容 + 元数据 footer。
<GenerationCard
  title="gpt-4o-mini"
  actions={<CopyButton text={text} iconOnly />}
  footer={<><CostBadge microCents={cost} /><LatencyPill totalMs={ms} /></>}
>
  <StreamingText text={text} isStreaming={isStreaming} />
</GenerationCard>
<ImageGallery images={[{ url }]} aspect?="square" onClick?={...} /> —— 响应式 1/2/3 列网格,hover 出现下载按钮。 <EmptyState icon?={Icon} title="..." description?="..." action?={...} /> —— 虚线边框占位,用在「还没数据」的位置。 <ErrorState title?="..." description?="..." onRetry?={...} action?={...} /> —— 带 destructive 色调的错误卡,可选重试按钮。 <TaskProgress status={state.status} progress?={0.4} label?="Rendering" /> —— 进度条 + 标签,绑定 useTaskTaskStatus。不传 progress 时是不确定模式。 <HistoryRow row={row} /> —— ai_call 历史列表里的一行。row 用的是 useHistoryAICallHistoryRow 形状。 <InsufficientCreditsBanner needed={50} balance={3} topUpHref?="/pricing" /> —— 琥珀色横幅,带「充值」CTA。在 chat() 返回 INSUFFICIENT_CREDITS 时渲染。
if (isInsufficientCredits(r)) return <InsufficientCreditsBanner needed={r.needed} balance={r.balance} />;

输入型(4 个)

<ModelPicker models={[{ id, label, hint? }]} value={id} onChange={fn} /> —— 模型选择下拉。hint 显示在 label 下面(用来标注成本/延迟)。
<ModelPicker
  models={[
    { id: 'gpt-4o-mini', label: 'GPT-4o mini', hint: '快 · 便宜' },
    { id: 'gpt-4o', label: 'GPT-4o', hint: '旗舰' },
  ]}
  value={model}
  onChange={setModel}
/>
<PromptVariableForm variables={spec} values={vals} onChange={setVals} /> —— 按 {{variable}} 自动生成带 label 的输入框。配 usePrompt(slug).prompt.latestVersion.variables 一起用。 <RegenerateButton onClick={fn} isLoading?={true} iconOnly?={true} /> —— 放在 GenerationCard.actions 里。loading 时图标变 spinner。 <CopyButton text="..." iconOnly?={true} resetMs?={1500} /> —— 复制到剪贴板, 1.5 秒内显示对勾。老浏览器自动 fallback 到 document.execCommand('copy')

验证生效

  1. 在任意客户端组件 import { CostBadge } from '@/components/ai';,渲染 <CostBadge microCents={42_000_000} />,应该显示 $42.00
  2. <TokenMeter inputTokens={100} outputTokens={300} /> —— 柱条看起来约 1/4 vs 3/4。
  3. 顶栏挂上 <CreditsBadge />。登录、发一次付费调用,调完手动 refetch() 后数字更新。
  4. 看 demo 页面 —— chat-demoimage-demodocument-demo —— 看这些组件在真实流程 里是怎么组合的。

常见坑

  • Server component 引入 client 组件。 CreditsBadgeCopyButtonModelPickerChatListRegenerateButtonPromptVariableForm 都是 'use client'。 server component 引它们会构建失败 —— 包一层,或者把父组件升成 client。
  • 把 cents 当 micro-cents 传。 <CostBadge /> 要的是 micro-cents(百分之一美分)。 100 美分换算成 micro-cents 是 10_000_000。如果数字小了 10⁴ 倍,那就是单位错了。
  • <TokenMeter total={0} /> 除零有保护(safeDenom = 1),但柱条会撑满 100%+。 要么传真实 total,要么干脆别传。
  • <ChatList> 没传 trigger 新消息进来不会自动滚。请传 trigger={[messages.length, streamingText]}
  • 改了一个忘了它的兄弟。 这些组件共享视觉节奏(一样的 padding、字号)。如果你 fork <CostBadge /> 加个图标,记得同步审查 <LatencyPill><ProviderPill>, 否则一行就对不齐了。

官方文档