← 返回
IT技术

如何使用 Vercel AI SDK 和 Shadcn/ui 构建 AI 聊天应用界面

✍️ zhirenhun 📅 2026/9/12 👁 13 阅读 ⏱ 36 分钟
如何使用 Vercel AI SDK 和 Shadcn/ui 构建 AI 聊天应用界面

如今你打开的几乎所有 AI 产品都呈现相同的界面:底部有一个消息列表、一个文本框,以及逐 token 流式出现的文字。看起来简单,但要做好并不容易。

你需要管理流式状态、部分 token、工具调用、重试、Markdown 渲染、滚动位置以及十几个细微的 UX 细节……同时还要保持界面的可访问性和高速响应。如果使用了错误的工具,你会花更多时间在修复状态错误上,而不是构建实际产品。

在本教程中,你将使用两种天生相配的工具构建一个真实的 AI 聊天界面:Vercel AI SDK 负责流式处理和模型逻辑,而 shadcn/ui 负责界面本身。

学完后,你将拥有一个可运行的聊天界面,能够流式返回结果、渲染 Markdown,并且看起来像是可以直接交付的产品。

你还将学习如何通过 MCP 服务器进一步提升 UI 开发速度,以及如果想直接跳过搭建过程,可以在哪里获取一个生产就绪的聊天模板。

目录

先决条件

您需要:

您将构建的内容

您将构建一个具有以下功能的 Next.js 聊天应用:

让我们从一个空文件夹开始,逐步构建出您愿意向队友展示的内容。

步骤 1:搭建 Next.js 应用

创建一个启用 TypeScript 和 Tailwind 的新 Next.js 项目:

npx create-next-app@latest ai-chat-app --typescript --tailwind --eslint --app
cd ai-chat-app

保持 CLI 询问的其他选项的默认值。您将几乎完全在 app 目录中工作。

步骤 2:安装 Vercel AI SDK

The Vercel AI SDK 负责这里的主要工作。它提供了一个统一的 API,用于调用不同的模型提供商、流式传输文本和结构化数据以及处理工具调用,这样您在切换模型时就不必重新编写聊天逻辑。

安装核心包、React 绑定以及一个 OpenAI 兼容的提供商:

npm install ai @ai-sdk/react @ai-sdk/openai-compatible

@ai-sdk/openai-compatible 值得特别提一下:与其为每个提供商安装独立的软件包,不如只需更换基础 URL,即可指向任何遵循 OpenAI 风格 API 的提供商(包括 OpenAI 本身、Gemini、Groq 以及大量自托管方案)。结合 AI_PROVIDER 环境变量,您可以在不触碰路由处理程序的情况下实现提供商切换——这正是您在下一步将要构建的模式。

步骤 3:为什么 shadcn/ui 与 AI 聊天界面如此契合

在编写任何 UI 代码之前,先了解为何众多 AI 聊天产品倾向于使用 shadcn/ui,而非传统组件库,是很有价值的。

大多数组件库会直接给你一个编译好的包,并通过 props 隐藏内部实现。这对设置页面来说已经足够好,但对聊天界面则不然。在聊天场景中,你需要精确控制消息气泡在流式输出时的动画、“思考中”指示器的行为,以及工具调用与纯文本的渲染方式不同。

shadcn/ui 采取了另一种方式:无需安装软件包,而是将组件的实际源码复制到你的项目中。你完全拥有它,无需与抽象层作斗争来适应你的用例,也不必等待维护者暴露你所需的那个 prop。这种所有权模式正是聊天界面所需要的,因为几乎没有两款 AI 产品在消息、推理或工具输出的渲染方式上完全相同。

这也是为什么围绕它形成了一个完整的生态系统。如果你想要超出默认注册表的更多生产就绪的区块和模板——包括仪表盘、营销区块以及完整的聊天界面——位于 Shadcn Space 的 shadcn/ui 社区中心值得收藏。在本教程后续你会再次回到这里。

步骤 4:在你的项目中设置 shadcn/ui

由于你已经在步骤 1 中拥有了一个 Next.js 项目,因此可以直接在此项目上应用 Shadcn Space 预设:

npx shadcn@latest apply --preset b0

这会在你的现有项目中创建 components.json、Tailwind 配置以及 lib/utils.ts。接着,拉取聊天界面需要的组件:

npx shadcn@latest add button input textarea scroll-area avatar separator

每次调用 add 会把真实可读的组件源码复制到 components/ui/ 目录,随时可以像项目中的其他文件一样导入和编辑,之后无需再与已编译的包打交道。

如果你不想从单独的原始组件手动拼装消息列表和作曲器,Shadcn Space 还提供了现成的 AI 聊天块,你可以直接添加:

npx shadcn@latest add @shadcn-space/ai-chat-01

npx shadcn@latest add @shadcn-space/ai-chat-03

ai-chat-01 提供了带有欢迎屏幕、建议提示、可滚动消息线程以及带有附件和模型选择器的作曲器的对话界面。

AI Chat 01 实时预览:

ai-chat-03 提供了带有可折叠侧边栏、固定和最近聊天、搜索以及顶部栏的周围应用程序外壳。

AI Chat 03 实时预览:

您可以单独安装任意一个块,或者如果想要完整的聊天布局而不必从头构建周围界面,则可以同时安装两者。

两个块均为付费功能。如果您希望为聊天界面获得免费侧边栏,则标准 shadcn sidebar-07 块是一个轻量级且免费的替代方案:

npx shadcn@latest add sidebar-07

步骤 5:构建流式 API 路由

创建 app/api/chat/route.ts。此服务器端部分负责与模型通信,并将响应流式传输回浏览器。

// app/api/chat/route.ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { convertToModelMessages, streamText, type UIMessage } from "ai";

const PROVIDERS: Record = {
  openai: {
    baseURL: "https://api.openai.com/v1",
    model: "gpt-4o-mini",
  },
  gemini: {
    baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
    model: "gemini-2.5-flash",
  },
  groq: {
    baseURL: "https://api.groq.com/openai/v1",
    model: "llama-3.3-70b-versatile",
  },
};

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const providerName =
    process.env.AI_PROVIDER?.trim().toLowerCase() ?? "openai";

  const { baseURL, model } =
    PROVIDERS[providerName] ?? PROVIDERS.openai;

  const provider = createOpenAICompatible({
    name: providerName,
    baseURL: process.env.AI_BASE_URL ?? baseURL,
    apiKey: process.env.AI_API_KEY,
  });

  const result = streamText({
    model: provider(process.env.AI_MODEL ?? model),
    system: "You are a concise, helpful assistant.",
    messages: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

以下几点值得注意:

.env 中设置您的提供程序和密钥:

# .env
AI_PROVIDER=openai
AI_API_KEY=

如果你还没有密钥,也可以先构建 UI。只需让此路由返回一个预设的流式响应,直到你准备好连接真正的提供者。下面的客户端代码不关心流来自哪里。

步骤 6:使用 useChat 连接客户端

现在,让我们构建聊天界面。创建一个 components/chat.tsx 文件:

// components/chat.tsx
"use client";

import { useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";

export function Chat() {
  const [input, setInput] = useState("");

  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: "/api/chat" }),
  });

  const isLoading =
    status === "submitted" || status === "streaming";

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    if (!input.trim()) return;

    sendMessage({ text: input });
    setInput("");
  };

  return (
    
{messages.map((message) => (
{message.role !== "user" && ( AI )}
{message.parts.map((part, i) => part.type === "text" ? ( {part.text} ) : null )}
))}