流式 UI 与生成式界面
传统 AI 聊天只输出文本。Generative UI 更进一步——AI 不仅回复文本,还能在对话中嵌入交互式 React 组件:天气卡片、股票图表、预订表单、数据看板。这一章讲清如何用 AI SDK 的
streamUI和工具调用实现这种新范式。
1. 从文本到组件
1.1 传统方式的局限
传统 AI 回复:"上海今天 28°C,多云,湿度 65%"——纯文本,用户需要自己解读。
Generative UI 回复:一个可交互的天气卡片组件——温度图标、天气动画、未来几天预报。用户体验完全不同。
这背后是一个重要的范式转变:传统前端是开发者决定展示什么组件,而 Generative UI 是 AI 决定展示什么组件。AI 根据用户意图选择最合适的 UI 表现形式——数据查询结果用图表,预约请求用表单,天气查询用卡片。前端只需要提前准备好组件库,AI 来“组装”它们。
1.2 实现思路
核心思路:AI 调用工具 → 工具返回数据 → 前端根据工具名渲染对应的 React 组件。
这个思路巧妙地复用了上一章的工具调用机制——工具本来是用来获取外部数据的,现在把工具返回的数据直接作为 UI 组件的 props。AI 并不“知道”自己在生成 UI,它只是调用了一个工具。是前端的渲染层把工具返回值变成了可视化组件。
上一章展示的基础版用 switch 匹配工具名。本章讲更可扩展的方案——用注册表模式。
2. 基于 Tool Result 的 Generative UI
2.1 定义工具与组件的映射
// lib/ui-tools.ts — 每个工具对应一个 UI 组件
import { tool } from 'ai'
import { z } from 'zod'
export const showWeather = tool({
description: '展示天气信息卡片,当用户询问天气时调用',
parameters: z.object({
city: z.string(),
}),
execute: async ({ city }) => {
const weather = await fetchWeather(city)
return {
city,
temperature: weather.temp,
condition: weather.condition,
icon: weather.icon,
forecast: weather.forecast, // 未来 3 天
}
},
})
export const showStockChart = tool({
description: '展示股票走势图,当用户询问股票价格或走势时调用',
parameters: z.object({
symbol: z.string().describe('股票代码,如 AAPL、TSLA'),
period: z.enum(['1d', '1w', '1m', '3m', '1y']).default('1m'),
}),
execute: async ({ symbol, period }) => {
const data = await fetchStockData(symbol, period)
return {
symbol,
name: data.name,
currentPrice: data.price,
change: data.change,
changePercent: data.changePercent,
chartData: data.history, // { date, price }[]
}
},
})
export const showBookingForm = tool({
description: '展示预订表单,当用户想要预约会议或服务时调用',
parameters: z.object({
type: z.enum(['meeting', 'demo', 'consultation']),
suggestedDate: z.string().optional(),
}),
// 没有 execute → 客户端工具,表单提交由前端处理
})2.2 UI 组件库
为每个工具创建对应的卡片组件:
// components/ai-cards/weather-card.tsx
'use client'
interface WeatherData {
city: string
temperature: number
condition: string
icon: string
forecast: { day: string; high: number; low: number }[]
}
export function WeatherCard({ data }: { data: WeatherData }) {
return (
<div className="bg-gradient-to-br from-blue-400 to-blue-600 text-white rounded-2xl p-6 max-w-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">{data.city}</h3>
<p className="text-4xl font-bold mt-1">{data.temperature}°C</p>
<p className="text-blue-100 mt-1">{data.condition}</p>
</div>
<img src={data.icon} alt={data.condition} className="w-16 h-16" />
</div>
<div className="mt-4 flex gap-4 border-t border-blue-300/30 pt-4">
{data.forecast.map((day) => (
<div key={day.day} className="text-center text-sm">
<p className="text-blue-100">{day.day}</p>
<p className="font-medium">{day.high}° / {day.low}°</p>
</div>
))}
</div>
</div>
)
}// components/ai-cards/stock-chart.tsx
'use client'
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
interface StockData {
symbol: string
name: string
currentPrice: number
change: number
changePercent: number
chartData: { date: string; price: number }[]
}
export function StockChart({ data }: { data: StockData }) {
const isPositive = data.change >= 0
return (
<div className="border rounded-2xl p-6 max-w-md">
<div className="flex items-center justify-between">
<div>
<h3 className="font-bold text-lg">{data.symbol}</h3>
<p className="text-sm text-gray-500">{data.name}</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold">${data.currentPrice}</p>
<p className={`text-sm ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
{isPositive ? '+' : ''}{data.change} ({data.changePercent}%)
</p>
</div>
</div>
<div className="mt-4 h-40">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.chartData}>
<XAxis dataKey="date" hide />
<YAxis hide domain={['auto', 'auto']} />
<Tooltip />
<Line
type="monotone"
dataKey="price"
stroke={isPositive ? '#22c55e' : '#ef4444'}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}2.3 消息渲染器
核心架构:用 TOOL_COMPONENTS 注册表把工具名映射到 React 组件。这比 switch/case 更可扩展——新增工具只需在注册表里加一行,不用修改渲染逻辑。这也是“开放-封闭原则”的体现:对扩展开放,对修改封闭。
统一的消息渲染组件——根据工具名分发到对应卡片:
// components/chat-message.tsx
'use client'
import { Message } from 'ai'
import { Markdown } from './markdown'
import { WeatherCard } from './ai-cards/weather-card'
import { StockChart } from './ai-cards/stock-chart'
import { BookingForm } from './ai-cards/booking-form'
const TOOL_COMPONENTS: Record<string, React.ComponentType<{ data: any }>> = {
showWeather: WeatherCard,
showStockChart: StockChart,
showBookingForm: BookingForm,
}
export function ChatMessage({ message }: { message: Message }) {
return (
<div className="space-y-3">
{/* 文本部分 */}
{message.content && <Markdown content={message.content} />}
{/* 工具调用渲染 */}
{message.toolInvocations?.map((invocation) => {
const Component = TOOL_COMPONENTS[invocation.toolName]
if (invocation.state === 'call') {
return (
<div key={invocation.toolCallId} className="flex items-center gap-2 text-gray-500">
<span className="animate-spin">⏳</span>
<span className="text-sm">正在获取数据...</span>
</div>
)
}
if (invocation.state === 'result' && Component) {
return <Component key={invocation.toolCallId} data={invocation.result} />
}
// 未知工具 → 显示原始 JSON
if (invocation.state === 'result') {
return (
<pre key={invocation.toolCallId} className="text-xs bg-gray-50 p-3 rounded-lg overflow-auto">
{JSON.stringify(invocation.result, null, 2)}
</pre>
)
}
return null
})}
</div>
)
}这样 AI 可以在一条回复中同时包含文本和多个交互组件——比如回答"对比一下苹果和特斯拉的股价"时,AI 输出一段文本分析 + 两个 StockChart 组件。
3. 客户端交互工具
有些"组件"需要用户交互——填写表单、选择选项、确认操作。这些是客户端工具,和上一章的确认型工具类似,它们没有 execute 函数。
运作原理:服务端定义工具但不提供执行函数 → AI 仍然会生成工具调用 → AI SDK 发现没有 execute,把工具调用发到客户端 → 客户端的 onToolCall 回调拦截 → 返回一个 Promise,这个 Promise 会一直挂起,直到用户完成交互(提交表单或取消)后 resolve → 结果回传给 AI,对话继续。
这个模式的精妙之处:AI 的对话流会“暂停”等待用户交互,用户提交后自动恢复。对用户来说,就像 AI “弹出了一个表单”,填完后 AI 继续回复。
// 服务端:不提供 execute
const showBookingForm = tool({
description: '展示预订表单让用户选择时间',
parameters: z.object({
type: z.enum(['meeting', 'demo']),
suggestedDate: z.string().optional(),
}),
})// 客户端:用 onToolCall 拦截并处理
const { messages } = useChat({
onToolCall: async ({ toolCall }) => {
if (toolCall.toolName === 'showBookingForm') {
// 返回一个 Promise,等用户填完表单再 resolve
return new Promise((resolve) => {
setActiveForm({
type: toolCall.args.type,
suggestedDate: toolCall.args.suggestedDate,
onSubmit: (formData) => {
resolve({
booked: true,
date: formData.date,
time: formData.time,
notes: formData.notes,
})
setActiveForm(null)
},
onCancel: () => {
resolve({ booked: false, reason: '用户取消了预订' })
setActiveForm(null)
},
})
})
}
},
})用户填写表单后,结果返回给 AI,AI 继续对话:"好的,已为您预订了 3 月 15 日下午 2 点的会议。"
4. 流式组件状态
工具调用有三个状态,形成一个清晰的状态机:
partial-call → call → result
(AI 正在生成参数) (参数完整,正在执行) (执行完成,有结果)
- partial-call:AI 还在流式生成工具参数(尤其是参数复杂时)。此时可以展示 Skeleton 占位符
- call:参数已完整,
execute正在运行(可能是网络请求)。展示带上下文的加载提示(“正在查询上海天气...”) - result:执行完成,渲染最终组件
利用这三个状态可以实现平滑的加载体验:
function ToolInvocationRenderer({ invocation }: { invocation: ToolInvocation }) {
switch (invocation.state) {
case 'partial-call':
// AI 还在生成工具参数(流式)
return <ToolSkeleton toolName={invocation.toolName} />
case 'call':
// 参数完整,正在执行
return <ToolLoading toolName={invocation.toolName} args={invocation.args} />
case 'result':
// 执行完成,渲染结果
const Component = TOOL_COMPONENTS[invocation.toolName]
return Component ? <Component data={invocation.result} /> : null
}
}
function ToolLoading({ toolName, args }: { toolName: string; args: any }) {
const labels: Record<string, string> = {
showWeather: `正在查询 ${args.city} 天气...`,
showStockChart: `正在加载 ${args.symbol} 股票数据...`,
searchKnowledge: '正在搜索知识库...',
}
return (
<div className="flex items-center gap-3 p-4 bg-gray-50 rounded-xl animate-pulse">
<div className="w-8 h-8 bg-gray-200 rounded-lg" />
<span className="text-sm text-gray-500">{labels[toolName] || '处理中...'}</span>
</div>
)
}5. 实战:AI 数据助手
把前面的所有概念组合起来:工具调用(第 35 章)+ 组件渲染 + Agent 多步推理 = 一个能“看懂数据”的 AI 助手。架构思路:
queryMetrics工具负责从数据库查数据(纯数据获取)showChart/showTable工具负责展示结果(它们的execute直接返回参数,因为数据就是展示用的)maxSteps: 8允许 AI 多次查询和展示,完成复杂的分析任务
// app/api/data-assistant/route.ts
import { streamText, tool } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
export async function POST(request: Request) {
const { messages } = await request.json()
const { workspace } = await requireWorkspaceAccess()
const result = streamText({
model: openai('gpt-4o'),
system: `你是一个数据分析助手。使用提供的工具查询数据并生成可视化图表。
始终先查询数据,再用图表展示。给出简洁的数据解读。`,
messages,
tools: {
queryMetrics: tool({
description: '查询业务指标数据',
parameters: z.object({
metrics: z.array(z.enum([
'daily_active_users', 'new_signups', 'revenue',
'token_usage', 'api_calls', 'error_rate',
])),
startDate: z.string().describe('开始日期 YYYY-MM-DD'),
endDate: z.string().describe('结束日期 YYYY-MM-DD'),
granularity: z.enum(['hourly', 'daily', 'weekly', 'monthly']),
}),
execute: async ({ metrics, startDate, endDate, granularity }) => {
return await getMetricsData(workspace.id, { metrics, startDate, endDate, granularity })
},
}),
showChart: tool({
description: '展示数据图表',
parameters: z.object({
type: z.enum(['line', 'bar', 'pie', 'area']),
title: z.string(),
data: z.array(z.object({
label: z.string(),
value: z.number(),
})),
xAxisLabel: z.string().optional(),
yAxisLabel: z.string().optional(),
}),
execute: async (params) => params, // 直接返回,前端渲染
}),
showTable: tool({
description: '展示数据表格',
parameters: z.object({
title: z.string(),
columns: z.array(z.string()),
rows: z.array(z.array(z.union([z.string(), z.number()]))),
}),
execute: async (params) => params,
}),
},
maxSteps: 8,
})
return result.toDataStreamResponse()
}用户说"帮我看看上周的用户增长和收入趋势"时,AI 会:
- 调用
queryMetrics获取数据 - 调用
showChart展示折线图 - 输出文字分析:"上周日活用户增长 12%,收入环比增长 8%,其中周三达到峰值..."
6. 设计原则
6.1 什么时候用 Generative UI
- ✅ 数据可视化:图表、表格、指标卡片
- ✅ 交互操作:预订表单、确认对话框、选择器
- ✅ 富媒体:图片画廊、地图、视频播放器
- ❌ 简单文本回复:不需要为"好的"做一个卡片
- ❌ 所有内容:过度使用卡片会让对话变得臃肿
6.2 组件设计规范
- 自包含:组件能独立渲染,不依赖外部状态
- 响应式:适应对话区域的宽度(通常 max-w-md)
- 加载态:工具执行时展示 Skeleton / 动画
- 错误态:工具执行失败时优雅降级
- 可访问性:键盘导航、屏幕阅读器支持
6.3 性能考虑
// 懒加载重型组件(图表库很大)
const StockChart = dynamic(() => import('./ai-cards/stock-chart'), {
loading: () => <div className="h-40 bg-gray-100 animate-pulse rounded-xl" />,
})本章小结
- Generative UI:AI 不只输出文本,还能在对话中嵌入交互式 React 组件
- 实现方式:工具调用返回数据 → 前端根据工具名渲染对应组件
- 工具组件映射:
TOOL_COMPONENTS注册表 + 统一的消息渲染器 - 客户端工具:不提供
execute,用onToolCall拦截,支持表单交互 - 流式状态:
partial-call→call(加载中)→result(展示组件) - 设计原则:自包含、响应式、有加载/错误态,不要过度使用
下一章讲多模态 AI。