s06 - 对话界面
前五章,你的 Agent 跑在终端里。能调工具,能多步推理,但只有一个黑底白字的窗口。 这一章的目标:给它一个网页界面,让别人也能用。
这一章要解决什么问题
终端对程序员来说够用。但终端不是产品。
如果你把 agent.py 发给一个朋友,他大概率会问:"怎么运行?""装什么软件?""这黑框框怎么用?"
你需要一个网页。打开浏览器,看到对话框,打字,拿回复。不用装任何东西。
这就是这一章要做的事情:用 Next.js 搭一个最简的聊天界面,背后接上 DeepSeek。
架构长什么样
先看整体结构,三件事:
浏览器(前端)
↓ POST /api/chat
Next.js 服务端(API Route)
↓ 调用 DeepSeek API
模型返回回复
↓
浏览器渲染回复前端只负责显示和收集输入。API Route 负责转发请求给模型。两边各干各的。
为什么不让前端直接调 DeepSeek?因为 API key 不能暴露在浏览器里。任何人打开开发者工具都能看到你的 key。放在服务端,key 藏在 .env.local 里,不会发到浏览器。
代码
完整代码在 code/s06/。我们逐个看。
依赖
package.json 里四个核心依赖:
next— 框架react/react-dom— UI 库openai— 调模型的 SDK(跟 s01 的 Python 版同一个 API 格式)
开发依赖加了 tailwindcss 来写样式,以及 TypeScript 类型。
安装:
cd code/s06
npm install启动
export DEEPSEEK_API_KEY="sk-xxxxxxxx"
npm run dev浏览器打开 http://localhost:3000,你会看到一个空白的对话页面。
前端:page.tsx
这是整个聊天界面。拆开看关键部分。
消息状态
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);三个状态:
messages— 对话历史,每条消息有role("user" 或 "assistant")和contentinput— 输入框里正在打的字loading— 模型是否在思考(用来禁用发送按钮,显示"思考中...")
发送消息
async function handleSend() {
const text = input.trim();
if (!text || loading) return;
const userMessage: Message = { role: "user", content: text };
const updated = [...messages, userMessage];
setMessages(updated);
setInput("");
setLoading(true);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: updated }),
});
const data = await res.json();
setMessages([...updated, { role: "assistant", content: data.reply }]);
} catch {
setMessages([
...updated,
{ role: "assistant", content: "出错了,请重试。" },
]);
} finally {
setLoading(false);
}
}做了这些事:
- 把用户输入加到
messages里 - 清空输入框,设 loading 为 true
fetch("/api/chat")— 发 POST 请求到后端 API- 拿到回复,加到
messages里 - loading 设回 false
注意 fetch 的 body 里传了完整的 messages 列表。这就是 s01 讲的多轮对话基础——每条历史都发给模型,它才知道之前聊了什么。
渲染消息
{messages.map((msg, i) => (
<div
key={i}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${
msg.role === "user"
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
{msg.content}
</div>
</div>
))}用户消息靠右、深色背景。模型回复靠左、浅色背景。用 Tailwind 的类名控制样式,不用写一行 CSS。
输入栏
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="输入消息..."
/>输入框绑定 input 状态。按 Enter 发送。旁边有个发送按钮,点击也能发。
后端:api/chat/route.ts
const client = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});跟 s01 的 Python 代码几乎一模一样。换个语言,API 格式完全相同。
export async function POST(req: NextRequest) {
const { messages } = await req.json();
const completion = await client.chat.completions.create({
model: "deepseek-chat",
messages: [
{ role: "system", content: "你是一个有帮助的助手。" },
...messages,
],
});
const reply = completion.choices[0].message.content ?? "";
return NextResponse.json({ reply });
}接收前端发来的 messages,加上一条 system 消息,调 DeepSeek,把回复返回。
整个文件只有 20 行。这就是 Next.js API Route 的好处——一个文件就是一个接口。
配置文件
tailwind.config.ts 不需要——Tailwind v4 用 app/globals.css 里的 @import "tailwindcss" 就够了。
tsconfig.json 是 TypeScript 配置,基本不用动。postcss.config.mjs 是 Tailwind 需要的 PostCSS 插件配置。
发生了什么
从用户点"发送"到看到回复,数据走了这么一条路:
1. 用户按 Enter
2. handleSend() 把消息加到 messages 状态
3. fetch POST 到 /api/chat,body 里带着完整对话历史
4. Next.js API Route 收到请求
5. OpenAI SDK 调用 DeepSeek API
6. DeepSeek 返回回复
7. API Route 把回复返回给前端
8. 前端把回复加到 messages 状态
9. React 重新渲染,用户看到新消息没有 WebSocket,没有轮询,就是一次 HTTP 请求。简单够用。
试着改改
1. 改 system 消息
在 api/chat/route.ts 里,把 system 消息改成别的:
{ role: "system", content: "你是一个只会用文言文回答问题的助手。" }刷新页面,发一条消息看看。
2. 加一个"清空对话"按钮
在 page.tsx 的输入栏旁边加一个按钮:
<button onClick={() => setMessages([])} className="text-gray-400 text-sm">
清空
</button>点一下,messages 变空数组,页面回到初始状态。
3. 显示消息条数
在消息列表上方加一行:
<p className="text-xs text-gray-400">{messages.length} 条消息</p>观察每次发送后数字怎么变。
4. 改输入框为 textarea
把 <input> 换成 <textarea>,支持多行输入:
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="输入消息... (Shift+Enter 换行)"
rows={1}
className="flex-1 border border-gray-300 rounded-2xl px-4 py-2 text-sm outline-none resize-none"
/>Enter 发送,Shift+Enter 换行。
教学边界
这一章只做一件事:用网页界面替代终端,让对话能通过浏览器进行。
不涉及:
- 流式输出(模型回复还是一次性出现,不是逐字显示)
- 消息持久化(刷新页面,对话就没了)
- 用户认证(任何人都能用)
- 工具调用(前端还没接上 s05 的 Agent 逻辑)
- 部署(只在本地跑)
这些都在后面的章节里。现在你只需要记住一件事:
前端收集消息发给后端,后端转发给模型,模型回复返回前端。三层结构,各管各的。
一句话记住
前端是壳,API Route 是管道,模型是核心。三件事分开做,才能独立替换任何一层。