使用 Vercel AI SDK 和 shadcn/ui 构建 AI 聊天应用界面
如今随便打开一个 AI 产品,看到的都是同样的界面:一列消息、底部的输入框,还有逐 token 流式输出的文字。看起来简单,但做好并不容易。
你需要处理流式状态、不完整的 token、tool 调用、重试、markdown 渲染、滚动位置,还有一堆细节体验问题……同时还得保证界面可访问且流畅。工具选不对,你会发现时间都耗在修状态 bug 上,而不是做自己的产品。
本教程将带你用两个天生一对的工具搭建一个真实的 AI 聊天界面:Vercel AI SDK 负责流式传输和模型逻辑,shadcn/ui 负责界面本身。
读完本文,你将得到一个能正常工作的聊天界面:支持流式响应、markdown 渲染,而且成品拿来就能上线。
你还会学到如何借助 MCP server 进一步加速 UI 开发,以及如果想完全跳过搭建步骤,去哪里找现成的生产级聊天模板。
目录
前置条件
你需要:
Node.js 18 或更高版本
对 React 和 Next.js 有基本了解,特别是 App Router
来自 OpenAI、Anthropic 或 Google 等 LLM 提供商的 API 密钥。即使没有密钥也可以跟着做,稍后会详细说明。
你将构建的内容
我们将构建一个基于 Next.js 的聊天应用,包含以下功能:
一个流式 API 路由,用于调用 LLM 提供商
使用
useChat构建的客户端聊天界面消息气泡、自动扩展输入框和可滚动的对话记录,均使用 shadcn/ui 组件进行样式设计
一个简单的工具调用,让模型不仅能对话,还能执行更多操作
一种回退状态,即使未添加 API 密钥也能正常工作,允许你先构建 UI,之后再连接模型
让我们从一个空文件夹开始,逐步构建出一个足以向同事展示的应用。
第一步:搭建 Next.js 应用骨架
创建一个新的 Next.js 项目,启用 TypeScript 和 Tailwind:
npx create-next-app@latest ai-chat-app --typescript --tailwind --eslint --app
cd ai-chat-app
对于 CLI 询问的其他选项,保持默认设置即可。你几乎将在 app 目录内完成所有工作。
第二步:安装 Vercel AI SDK
Vercel AI SDK 在这里承担了主要工作。它提供了一致 API 来调用不同的模型提供商,支持流式传输文本和结构化数据,并处理工具调用,这样在切换模型时就不必重写聊天逻辑。
安装核心包、React 绑定以及 OpenAI 兼容提供商:
npm install ai @ai-sdk/react @ai-sdk/openai-compatible
特别要提一下 @ai-sdk/openai-compatible:你无需为每个提供商单独安装不同的包,只需切换 base URL,就能指向任何支持 OpenAI 风格 API 的提供商(如 OpenAI 本身、Gemini、Groq,以及许多自托管环境)。配合 AI_PROVIDER 环境变量,你甚至不用修改路由处理器就能切换提供商,这正是下一步要实现的模式。
步骤 3:为什么 shadcn/ui 特别适合 AI 聊天界面
在编写 UI 代码之前,不妨先了解一下,为什么许多 AI 聊天产品选择 shadcn/ui 而非传统组件库。
大多数组件库提供一个编译后的包,并通过 props 隐藏内部实现。这在设置页面中没问题,但用在聊天界面上效果很差。聊天场景需要精细控制消息气泡在流式输出时的动画、「思考中」指示器的状态,以及工具调用与普通文本的不同渲染方式。
shadcn/ui 采取了不同的方法:不安装包,而是直接将组件源码复制到你的项目中。你完全拥有这份代码,不必与抽象层搏斗来适应你的需求,也不用等维护者为你暴露某个特定 prop。这种所有权模式正是聊天界面所需要的,因为几乎没有两个 AI 产品会以完全相同的方式渲染消息、推理过程或工具输出。
这也是围绕它形成完整生态系统的原因。如果你想在默认注册表之外获得更多生产级区块和模板(包括仪表盘、营销章节和完整的聊天 UI),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 chat blocks,可以直接添加:
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 实时预览:
这两个 block 可以单独安装,也可以一起安装,这样就能直接获得完整的聊天布局,无需从零搭建界面。
两个 block 都是付费的。如果你想给聊天界面配一个免费的侧边栏,标准 shadcn 的 sidebar-07 block 是一个轻量的免费替代方案:
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<string, { baseURL: string; model: string }> = {
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();
}
几个要点值得关注:
createOpenAICompatible返回一个兼容任何 OpenAI 风格 API 的 Provider 实例。只需切换AI_PROVIDER环境变量为openai、gemini或groq,路由处理逻辑无需任何改动。convertToModelMessages用于转换消息格式,将客户端发送的 UI 消息格式转为模型服务方所需的格式。streamText启动模型生成流程,返回一个可直接转发给客户端的流。toUIMessageStreamResponse()将该流封装为响应,客户端的useChatHook 可据此逐 Token 解析消费。
在 .env 中配置 Provider 和密钥:
# .env
AI_PROVIDER=openai
AI_API_KEY=
如果尚未获取密钥,仍可先构建 UI。此时可让路由返回预设的流式响应,待准备就绪后再接入真实 Provider。下方客户端代码不关心流的来源。
第 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 (
<div className="flex h-screen flex-col">
<ScrollArea className="flex-1 p-4">
<div className="mx-auto flex max-w-2xl flex-col gap-4">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex gap-3",
message.role === "user" && "justify-end"
)}
>
{message.role !== "user" && (
<Avatar className="h-8 w-8">
<AvatarFallback>AI</AvatarFallback>
</Avatar>
)}
<div
className={cn(
"max-w-[75%] rounded-2xl px-4 py-2 text-sm",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
)}
>
{message.parts.map((part, i) =>
part.type === "text" ? (
<span key={i}>{part.text}</span>
) : null
)}
</div>
</div>
))}
</div>
</ScrollArea>
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="mx-auto flex max-w-2xl items-end gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="向助手发送消息..."
className="min-h-11 flex-1 resize-none"
disabled={isLoading}
/>
<Button
type="submit"
disabled={isLoading || !input.trim()}
>
发送
</Button>
</div>
</form>
</div>
);
}
把 <Chat /> 组件放入 app/page.tsx,然后运行 npm run dev。现在你就拥有了一个可以流式输出的聊天界面。模型返回的每条消息都会逐字显示,而不是整段一次性输出;同时 status 状态让你能在响应处理期间干净地禁用输入框。
注意 useChat 在这里默默承担了大量工作:它管理消息列表,处理数据块到达时的流式重组,并维护 submitted、streaming 和 ready 的生命周期,省去你手动跟踪这些状态的麻烦。
实时预览:
Step 7: Let the Model Call Tools
只能聊天的输入框功能有限。AI SDK 允许模型调用代码中的实际函数,并通过 JSON schema 描述允许传入的参数。
在 Step 5 的 provider 配置旁,往路由处理程序里加上这段代码:
// app/api/chat/route.ts
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import {
convertToModelMessages,
jsonSchema,
streamText,
tool,
type UIMessage,
} from "ai";
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const provider = createOpenAICompatible({
name: "openai",
baseURL: "https://api.openai.com/v1",
apiKey: process.env.AI_API_KEY,
});
const result = streamText({
model: provider("gpt-4o-mini"),
messages: convertToModelMessages(messages),
tools: {
getWeather: tool({
description: "Get the current weather for a city",
inputSchema: jsonSchema<{ city: string }>({
type: "object",
properties: {
city: {
type: "string",
description: "The city to get the weather for",
},
},
required: ["city"],
}),
execute: async ({ city }) => {
// Call a real weather API here in production
return {
city,
temperature: 22,
condition: "clear",
};
},
}),
},
});
return result.toUIMessageStreamResponse();
}
模型决定何时调用 getWeather,SDK 将该调用路由到你的 execute 函数,结果随后作为消息片段流式传回对话中。除了需要检查 part.type 以区分工具部分和普通文本并做不同渲染外,客户端无需额外处理逻辑。
第 8 步:无后端原型 UI
你会经常遇到这样一个问题:在后端或 API 密钥就绪之前,就希望完善聊天界面,包括间距、动画以及推理块的折叠效果。每次微调像素都需针对实时模型重建 UI,过程既缓慢又耗费 token。
这正是 shadcn AI SDK helper 包解决的问题。它允许你脚本化一个假对话,并通过你已在使用的同一个 useChat hook 将其流式传输,全程无需服务器、模型或 API 密钥:
npm install @shadcn/helpers
import { createChat } from "@shadcn/helpers";
import { useChat } from "@ai-sdk/react";
const chat = createChat()
.user("What changed in the last release?")
.assistant("The release added keyboard shortcuts and faster search.");
function ChatPreview() {
const { messages } = useChat({
messages: chat.get(0),
transport: chat.transport(),
});
// render `messages` exactly like you would with a real backend
}
它支持 AI SDK 理解的每一种部分类型,包括推理、工具调用、文件和来源,且每次流式传输都是确定性的。这也使其在编写可复现的演示或 UI 测试时真正有用。
你可以用这种方式构建和完善整个界面,然后在后端就绪时立即切换为真实的 /api/chat 路由。
将你的聊天应用打造为完整产品
聊天窗口很少单独发布。一旦你的聊天功能可用,通常还需要一个侧边栏来显示历史对话、一个设置面板用于模型选择,以及一个管理视图来查看所有用户的使用情况。这与流式文本传输是不同的问题。它属于应用外壳和数据表格的范畴。
与其自己手写这套外壳,大多数团队会选择现成的后台布局。一个做好的 shadcn dashboard(比如 Shadcn Space 提供的那款)自带内部工具所需的布局、数据表格、图表和导航模式,你不用为了让聊天功能有个落脚点,就重新造一遍侧边栏和设置页。
如果你连聊天界面本身也不想写,这也是完全可行的。有些团队会直接从一个现成的 shadcn AI chat app 模板起步,它已经内置了会话侧边栏、markdown 和代码渲染、工具调用可视化等功能。文末我们会再聊到这个。
完整聊天应用在线预览:
用 MCP Server 加速 shadcn 开发
不管你怎么搭建聊天 UI,都有比从文档里复制粘贴更快的组件引入方式:用 MCP(Model Context Protocol)server 让你的 AI 编程助手直接访问组件仓库。
Shadcn MCP server 能把 Claude Code、Cursor、Windsurf 这类工具直接连到 Shadcn Space 组件目录。你不用自己去翻文档、粘贴安装命令,只需要告诉助手你需要什么,比如"加一个带头像和时间戳的消息气泡组件",它就会拉取真实、最新的组件定义,而不是凭过时的训练数据瞎猜。
在 Claude Code 里只需一行命令即可完成配置:
claude mcp add shadcnspace-mcp -- npx -y shadcnspace-mcp@latest
其他编辑器只需把同样的命令写进各自的 MCP 配置文件,比如 Cursor 的 .cursor/mcp.json。MCP server 入门指南详细介绍了各支持编辑器的配置方法,Shadcn MCP 页面则说明了它连接后具体能搜索、安装和生成哪些内容。
如果比起阅读教程,你更倾向于观看视频,这里有一段简短的演示视频,完整覆盖了上述所有步骤。
上线前需要处理的几个事项
上述教程版本刻意保持极简。在将其推向生产环境之前,请补充以下内容:
为
/api/chat路由添加速率限制。没有限制的聊天端点是产生巨额模型账单的最快途径。实现中止处理,允许用户在流式传输过程中停止响应。通过
useChat的stop()函数即可开箱即用。为聊天组件添加错误边界,因为流中断或服务商故障不应导致整个页面崩溃。
添加身份验证,如果需要将响应或对话历史限定在特定用户范围内。
这些都不是什么高深技术。它们就是应用于任何 API 路由的标准生产级配置。聊天端点容易让人忽略这些基础项,因为在开发阶段一切看起来都格外顺滑。
使用现成模板跳过样板代码
上述步骤能让你拥有一个真正可用的聊天界面,但这仅是教程版本。生产级聊天产品通常还需要会话侧边栏、项目分组、Markdown 和带语法高亮的代码块、推理面板、语音输入以及用于切换模型的设置界面。从零构建所有这些功能本身就是一项耗时数周的工作。
如果你不想自己搭建这套外壳,建议看看 Shadcn Space 上的shadcn AI 聊天应用模板。它基于本文介绍的技术栈(Next.js、Vercel AI SDK 和 shadcn ui)构建,但已预集成侧边栏、推理和工具调用 UI、文件附件以及多服务商模型切换功能。只需设置 AI_PROVIDER 和 AI_API_KEY,即可通过完善的界面与真实模型进行对话。
你可以访问在线演示,查看侧边栏、流式传输和工具调用的具体表现,再决定是自行开发还是基于模板启动。
总结
现在你已经拥有一个可以流式输出真实回复、调用工具的聊天界面,所有组件均由你自主掌握,可自由编辑。对于 AI 产品而言,这一点比听起来更重要。Vercel AI SDK 负责处理复杂的流式传输和模型逻辑,而 shadcn/ui 则让你完全掌控这些逻辑在屏幕上的视觉呈现。
接下来,自然的步骤是接入真实的服务提供商、添加上述生产环境所需的基础功能,并决定是继续扩展自定义 UI,还是依赖现成的模板来加速周边产品的构建。无论选择哪条路径,由于你现在已透彻理解底层运行机制,实施起来都会轻松许多。
资源
本文撰写过程中得到了 Ashutosh Rada(高级前端开发)的协助。在 LinkedIn 上连接。


