Next.js + OpenAI API 跑通一个带流式输出的聊天机器人

开发一个带流式输出(Streaming)的聊天机器人是目前 AI 应用的基础。为了让你能循序渐进地掌握,我将这个过程拆解为 10 个篇章。

这个系列会使用 Next.js (App Router) + OpenAI API + Tailwind CSS,侧重于实战,去掉废话。


🚀 教程大纲:Next.js + OpenAI 流式聊天机器人

第一篇:环境搭建与项目初始化

  • 核心内容:安装 Next.js 最新版,配置项目结构。
  • 重点 :选择 App Router 模式,安装 lucide-react(图标库)和 clsx(类名处理)。
  • 目标:跑通一个空的 Next.js 页面。

第二篇:获取并配置 OpenAI API Key

  • 核心内容 :如何获取 API Key,并在 .env.local 中安全地配置环境变量。
  • 重点 :区分环境变量的客户端与服务端访问权限(NEXT_PUBLIC_ 的使用边界)。

第三篇:基础 UI 设计:聊天窗口与输入框

  • 核心内容:使用 Tailwind CSS 绘制一个典型的聊天界面。
  • 重点:响应式布局、固定底部的输入框、可滚动的消息区域。

第四篇:状态管理:处理消息列表

  • 核心内容 :使用 useState 管理对话数组(Role: user/assistant, Content)。
  • 重点:定义消息对象的 TypeScript 类型,编写更新消息列表的逻辑。

第五篇:服务端:创建 Edge Runtime API 接口

  • 核心内容 :在 app/api/chat/route.ts 编写后端逻辑。
  • 重点Edge Runtime 是流式输出的关键,学习如何调用 OpenAI SDK 并开启 stream: true

第六篇:前端逻辑:实现 Fetch 流式读取

  • 核心内容 :编写前端 fetch 请求,处理 ReadableStream
  • 重点 :如何通过 TextDecoder 逐块读取数据并实时更新 UI,而不是等待全部返回。

第七篇:Markdown 渲染与代码高亮

  • 核心内容 :集成 react-markdown
  • 重点 :让机器人输出的代码块带高亮,支持表格和数学公式渲染(使用 remark-gfm)。

第八篇:UI 体验优化:自动滚动与加载态

  • 核心内容:解决"内容过长需要手动滚动"的问题。
  • 重点 :使用 useRefuseEffect 实现新消息自动探底,增加打字机样式的 Loading 动画。

第九篇:进阶:多轮对话与上下文管理

  • 核心内容:如何让机器人"记住"上一句话。
  • 重点:管理 Token 长度,裁剪过长的历史记录,确保不会超出模型窗口限制。

第十篇:部署与线上环境优化

  • 核心内容:发布到 Vercel。
  • 重点:配置线上环境变量,解决 Vercel 免费版超时限制(Function Timeout)对流式传输的影响。

第一篇:环境搭建与项目初始化

我们直接从最基础的开始。

1. 创建项目

打开终端,运行以下命令:

bash 复制代码
npx create-next-app@latest my-ai-chatbot

选项建议:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • src/ directory: Yes
  • App Router: Yes
  • Import alias: Yes (@/*)

2. 安装必要依赖

进入项目文件夹,安装后面要用到的工具库:

bash 复制代码
cd my-ai-chatbot
npm install openai lucide-react clsx tailwind-merge
  • openai: 官方 SDK。
  • lucide-react: 图标库。
  • clsx & tailwind-merge: 方便我们更灵活地写 Tailwind 类名。

3. 清理代码

删除 src/app/page.tsx 里的默认内容,改成一个简单的容器,确保项目能跑起来:

tsx 复制代码
export default function Page() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <h1 className="text-2xl font-bold">我的 AI 聊天机器人</h1>
    </main>
  )
}

运行 npm run dev,浏览器访问 localhost:3000,看到文字即代表环境 OK。


第二篇:获取并配置 OpenAI API Key

1. 获取 OpenAI API Key

如果你还没有 Key,需要去 OpenAI 官网(platform.openai.com)申请。

  • 登录后,找到 API Keys 栏目。
  • 点击 Create new secret key
  • 注意:Key 只会出现一次,请务必找个地方存好。

2. 安全地存储环境变量

在 Next.js 中,我们绝不能把 API Key 直接写在代码里,否则上传到 GitHub 后别人就能盗刷你的余额。

在项目根目录下创建一个名为 .env.local 的文件:

text 复制代码
OPENAI_API_KEY=你的sk-xxxxxx键值

关键知识点:

  • 在 Next.js 中,默认情况下,环境变量只能在服务端(Server Side)访问。
  • 只要变量名不加 NEXT_PUBLIC_ 前缀,它就不会被暴露给浏览器,这是安全的。

3. 创建 OpenAI 客户端实例

为了方便复用,我们单独封装一个配置文件。在 src/ 目录下(或者根目录)新建 lib/openai.ts

typescript 复制代码
import OpenAI from 'openai';

// 检查环境变量是否存在
if (!process.env.OPENAI_API_KEY) {
  throw new Error('Missing OpenAI API Key in environment variables');
}

export const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

4. 验证配置

我们可以写一个简单的临时脚本测试一下 Key 是否有效。在 src/app/api/test/route.ts 新建一个接口:

typescript 复制代码
import { openai } from '@/lib/openai';
import { NextResponse } from 'next/server';

export async function GET() {
  try {
    const response = await openai.models.list();
    return NextResponse.json({ data: response.data });
  } catch (error) {
    return NextResponse.json({ error: 'Key 无效或网络异常' }, { status: 500 });
  }
}

访问 http://localhost:3000/api/test,如果能看到一串模型列表,说明配置成功。


第三篇:基础 UI 设计:聊天窗口与输入框

有了后端配置,现在我们来搭个"壳子"。

1. 修改全局样式

src/app/globals.css 中,清理掉多余的背景色设置,保持页面清爽:

css 复制代码
@tailwind base;
@tailwind components;
@tailwind utilities;

:root {
  --foreground-rgb: 0, 0, 0;
  --background-start-rgb: 255, 255, 255;
  --background-end-rgb: 255, 255, 255;
}

body {
  color: rgb(var(--foreground-rgb));
  background: white;
}

2. 编写页面结构

修改 src/app/page.tsx。我们需要一个顶部的标题栏、中间的对话展示区和底部的固定输入框。

tsx 复制代码
import { Send, User, Bot } from "lucide-react";

export default function ChatPage() {
  return (
    <div className="flex flex-col h-screen bg-gray-50">
      {/* 顶部标题 */}
      <header className="p-4 border-b bg-white flex justify-between items-center">
        <h1 className="text-xl font-semibold">AI Assistant</h1>
      </header>

      {/* 消息展示区域 */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {/* 用户消息示例 */}
        <div className="flex justify-end">
          <div className="bg-blue-600 text-white p-3 rounded-lg max-w-[80%]">
            你好,帮我写个代码。
          </div>
        </div>

        {/* 机器人消息示例 */}
        <div className="flex justify-start">
          <div className="bg-white border p-3 rounded-lg max-w-[80%] shadow-sm text-gray-800">
            没问题,你想写什么样的代码?
          </div>
        </div>
      </div>

      {/* 底部输入框 */}
      <footer className="p-4 bg-white border-t">
        <div className="max-w-4xl mx-auto flex gap-2">
          <input
            type="text"
            placeholder="输入你的问题..."
            className="flex-1 p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button className="bg-blue-600 text-white p-2 rounded-md hover:bg-blue-700 transition">
            <Send size={20} />
          </button>
        </div>
      </footer>
    </div>
  );
}

3. 为什么这么写?

  • flex-col h-screen: 强制页面撑满屏幕高度,不出现多余滚动条。
  • flex-1 overflow-y-auto: 让中间的消息区占据所有剩余空间,内容多了会自动出现内部滚动。
  • max-w-[80%]: 防止消息太长直接铺满全屏,保持对话的呼吸感。

第四篇:状态管理:处理消息列表

在聊天应用里,核心数据就是一张"对话列表"。每一条消息都有个身份(是你说还是 AI 说)和内容。

1. 定义消息类型

src/app/page.tsx 顶部,先定义好消息的结构。

typescript 复制代码
type Message = {
  role: 'user' | 'assistant';
  content: string;
};

2. 初始化状态

我们需要两个主要状态:messages(存放所有对话)和 input(当前的输入框内容)。

tsx 复制代码
"use client"; // 别忘了加上这个,因为我们要用到 hooks
import { useState } from "react";
import { Send } from "lucide-react";

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  // 发送消息的方法(下一篇填充逻辑)
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isLoading) return;
    
    // 逻辑占位...
  };

  return (
    // ... UI 代码 ...
  );
}

3. 让 UI 动态渲染

把之前写死的 div 替换成 messages.map

tsx 复制代码
<div className="flex-1 overflow-y-auto p-4 space-y-4">
  {messages.map((m, index) => (
    <div key={index} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
      <div className={`p-3 rounded-lg max-w-[80%] ${
        m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white border text-gray-800'
      }`}>
        {m.content}
      </div>
    </div>
  ))}
  {isLoading && <div className="text-gray-400 text-sm">AI 正在思考...</div>}
</div>

第五篇:服务端:创建 Edge Runtime API 接口

Next.js 的路由处理程序(Route Handlers)非常适合做中转。我们要用 Edge Runtime,因为它对流式传输支持更好,响应也更快。

新建 src/app/api/chat/route.ts

typescript 复制代码
import { openai } from '@/lib/openai';

export const runtime = 'edge'; // 必须指定为 edge 运行环境

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

  const response = await openai.chat.completions.create({
    model: 'gpt-3.5-turbo', // 或者 gpt-4-turbo
    stream: true,           // 开启流式输出的核心
    messages,
  });

  // 将 OpenAI 的流转化为前端能读取的 ReadableStream
  const stream = new ReadableStream({
    async start(controller) {
      for await (const chunk of response) {
        const content = chunk.choices[0]?.delta?.content || "";
        if (content) {
          controller.enqueue(new TextEncoder().encode(content));
        }
      }
      controller.close();
    },
  });

  return new Response(stream);
}

关键点:

  • stream: true: 告诉 OpenAI 不要等全写完再发,而是一边写一边发。
  • ReadableStream: 这是一个标准 Web API,允许我们把数据像自来水一样源源不断地推向前端。

第六篇:前端逻辑:实现 Fetch 流式读取

这是最"黑科技"的部分。我们要像接水管一样,把后端的流数据一点点接住。

回到 src/app/page.tsx,完善 handleSubmit 函数:

tsx 复制代码
const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!input.trim() || isLoading) return;

  const userMessage: Message = { role: 'user', content: input };
  setMessages((prev) => [...prev, userMessage]);
  setInput("");
  setIsLoading(true);

  // 预备一条空的 AI 消息,用于承接流式内容
  setMessages((prev) => [...prev, { role: 'assistant', content: "" }]);

  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ messages: [...messages, userMessage] }),
    });

    if (!response.body) return;

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let lastContent = "";

    // 循环读取流
    while (!done) {
      const { value, done: doneReading } = await reader.read();
      done = doneReading;
      const chunkValue = decoder.decode(value);
      lastContent += chunkValue;

      // 实时更新最后一条(即 AI 消息)的内容
      setMessages((prev) => {
        const newMsgs = [...prev];
        newMsgs[newMsgs.length - 1].content = lastContent;
        return newMsgs;
      });
    }
  } catch (error) {
    console.error("读取失败", error);
  } finally {
    setIsLoading(false);
  }
};

这几步发生了什么?

  1. getReader(): 拿到了数据的"读取器"。
  2. while 循环: 只要数据没发完,就一直读。
  3. TextDecoder: 电脑看的是二进制,我们要把它转成人类能看的文字。
  4. 状态更新 : 每次读到一点点新字,我们就用 lastContent 替换列表里最后那条消息的内容。用户就会看到字是一个个蹦出来的。

第七篇:Markdown 渲染与代码高亮

AI 的输出通常是 Markdown 格式。我们需要让它像 GitHub 或 ChatGPT 那样,能漂亮地展示代码块、表格和加粗文字。

1. 安装渲染库

我们需要 react-markdown 处理文档,remark-gfm 处理 GitHub 风格的表格/列表,以及 react-syntax-highlighter 处理代码高亮。

bash 复制代码
npm install react-markdown remark-gfm react-syntax-highlighter
npm install -D @types/react-syntax-highlighter

2. 封装 Markdown 组件

src/components/Markdown.tsx(如果没有文件夹就建一个)中编写:

tsx 复制代码
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';

export function Markdown({ content }: { content: string }) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        code({ node, inline, className, children, ...props }: any) {
          const match = /language-(\w+)/.exec(className || '');
          return !inline && match ? (
            <SyntaxHighlighter
              style={oneLight}
              language={match[1]}
              PreTag="div"
              {...props}
            >
              {String(children).replace(/\n$/, '')}
            </SyntaxHighlighter>
          ) : (
            <code className={className} {...props}>
              {children}
            </code>
          );
        },
      }}
      className="prose prose-sm max-w-none break-words"
    >
      {content}
    </ReactMarkdown>
  );
}

3. 应用到页面

回到 page.tsx,把之前渲染 {m.content} 的地方换成 <Markdown content={m.content} />


第八篇:UI 体验优化:自动滚动与加载态

当 AI 输出长文本时,用户必须手动往下划才能看到新字,这体验太差了。我们需要实现"自动探底"。

1. 使用 useRef 锚定底部

page.tsx 中增加一个隐藏的 div 作为滚动锚点:

tsx 复制代码
import { useEffect, useRef } from "react";

// ... 在组件内部 ...
const messagesEndRef = useRef<HTMLDivElement>(null);

const scrollToBottom = () => {
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};

// 只要消息列表变动,就滚动到底部
useEffect(() => {
  scrollToBottom();
}, [messages]);

2. 放置锚点

messages.map 循环的最下方放置这个 ref

tsx 复制代码
<div className="flex-1 overflow-y-auto p-4 space-y-4">
  {messages.map((m, i) => ( /* ... */ ))}
  <div ref={messagesEndRef} /> {/* 自动滚动目标 */}
</div>

第九篇:进阶:多轮对话与上下文管理

现在的机器人是"鱼的记忆",问它第二句时,它不记得第一句说了什么。我们要把历史记录传给 API。

1. 修改前端发送逻辑

handleSubmit 中,确保我们把整个 messages 数组(加上当前新消息)都发过去:

tsx 复制代码
// page.tsx
const response = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ 
    messages: [...messages, userMessage] // 发送完整历史
  }),
});

2. 后端 Token 裁剪(防爆单)

如果对话了几百轮,Token 会非常贵且可能超出模型上限。我们需要在 api/chat/route.ts 中做个简单的截断:

typescript 复制代码
// api/chat/route.ts
const { messages } = await req.json();

// 只保留最近的 10 条对话,避免上下文过长
const limitedMessages = messages.slice(-10);

const response = await openai.chat.completions.create({
  model: 'gpt-3.5-turbo',
  stream: true,
  messages: limitedMessages,
});

第十篇:部署与线上环境优化

最后一步,我们要把这个机器人发布到公网。

1. 部署到 Vercel

  1. 把代码推送到 GitHub。
  2. 在 Vercel 官网关联这个仓库。
  3. 关键 :在 Vercel 的项目设置中,找到 Environment Variables ,添加你在 .env.local 里的 OPENAI_API_KEY

2. 解决超时问题

Vercel 的免费版 Serverless Function 有 10 秒超时限制。如果 AI 思考太久,请求会被强行中断。

  • 对策 1 :我们在第五篇使用了 runtime = 'edge',Edge Function 的超时限制更宽松。

  • 对策 2 :在 route.ts 中显式声明最大时长(需配合 Vercel 特定配置):

    typescript 复制代码
    export const maxDuration = 30; // 仅限 Pro 计划以上,免费版需优化流速

3. 恭喜!

你现在拥有了一个完整的、带流式输出、支持代码高亮、具备上下文记忆的 AI 聊天机器人。


相关推荐
lsx2024062 小时前
MySQL 删除数据表
开发语言
前端程序猿i2 小时前
纯JS 导出 Excel 工具
开发语言·javascript·excel
沐知全栈开发2 小时前
XML Schema 复合类型 - 仅含元素
开发语言
weixin_408099672 小时前
跨境电商OCR:3秒识别多语言商品标签
开发语言·图像处理·人工智能·后端·ocr·api·文字识别ocr
小樱花的樱花2 小时前
C++引用:高效编程的技巧
开发语言·数据结构·c++·算法
南境十里·墨染春水2 小时前
C++笔记 继承中重载规则 公有私有继承的区别(面向对象)
开发语言·c++·笔记
遇见你...2 小时前
B03 SpringMVC拦截器
java·开发语言
沉鱼.442 小时前
进制转换题
开发语言·c++·算法
淼淼7633 小时前
QT仪表盘
开发语言·qt