流式 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 会:

  1. 调用 queryMetrics 获取数据
  2. 调用 showChart 展示折线图
  3. 输出文字分析:"上周日活用户增长 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-callcall(加载中)→ result(展示组件)
  • 设计原则:自包含、响应式、有加载/错误态,不要过度使用

下一章讲多模态 AI。