前端转 AI 第 44 天:前端集成实战 - 用 React 给 Agent 做个正式界面
昨日回顾
Day 43 把 43 天所学整合成了一个完整的 Agent 后端。
但后端再牛,用户也得有个界面用。
今天发挥你的老本行------写前端。
Day 44 目标:用 React + Vite 搭一个正式的聊天界面,把 Day 43 的 Agent 包装成"能用的产品"。
一、为什么前端是"后端 Agent 的放大器"
1. 后端能跑 ≠ 用户能用
昨天的 Agent 只有 curl 能调。
用户要的是什么?
- 一个输入框
- 一个对话区
- "正在输入"的反馈
- 能点停止
- 能看到历史
- 能管理会话
这就是"AI 产品"和"AI API"的差距。
2. 前端是"AI 体验"的关键
同样一个 Agent,前端做得好坏,感受天差地别:
| 体验点 | 差的前端 | 好的前端 |
|---|---|---|
| 回答显示 | 等 5 秒一次弹出 | 打字机流式 |
| 等待反馈 | 白屏 / 没反应 | "思考中..." + 动画 |
| 工具调用 | 无感知 | "正在查询天气..." |
| 出错 | 控制台报错 | 友好提示 + 重试按钮 |
| 长对话 | 卡顿 | 虚拟滚动 + 分页 |
| 会话管理 | 单次用完 | 列表 + 切换 + 删除 |
ChatGPT 不是因为它最强才火的------是它的"体验"最好。
3. 前端视角:这是"产品思维"
做过前端的你,一直在做这件事:
- 后端返回 JSON → 前端要"设计成界面"
- 后端返回错误 → 前端要"翻译成用户看得懂的"
- 后端慢 → 前端要"用 loading / skeleton 撑住"
AI 产品也一样------ 前端的体验决定了用户是不是"愿意用"。
而且这次你是"全栈"------ 后端也是自己写的,前端可以按自己的设计来。
二、项目技术选型
1. 技术栈
不折腾,选最稳的:
| 层 | 选型 | 理由 |
|---|---|---|
| 构建 | Vite | 快,生态好 |
| 框架 | React 18 | 熟 |
| 语言 | TypeScript | 类型安全 |
| UI | Tailwind CSS | 快,不引组件库 |
| 状态 | Zustand | 轻,够用 |
| HTTP | fetch | 原生,SSE 好处理 |
| 路由 | React Router | 会话切换 |
| 图标 | lucide-react | 好看、轻 |
为什么不用 Next.js / Nuxt?
AI 聊天界面是"客户端 SPA"------不需要 SSR / SEO。Vite 够用。
2. 项目结构
python
agent-web/
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
└── src/
├── main.tsx
├── App.tsx
├── api/
│ └── client.ts # API 封装
├── store/
│ └── chat.ts # Zustand store
├── hooks/
│ ├── useStreamChat.ts # 流式 hook
│ └── useSessions.ts # 会话 hook
├── components/
│ ├── ChatLayout.tsx # 整体布局
│ ├── Sidebar.tsx # 侧边栏(会话列表)
│ ├── MessageList.tsx # 消息列表
│ ├── MessageItem.tsx # 单条消息
│ ├── ToolCallCard.tsx # 工具调用展示
│ ├── InputBox.tsx # 输入框
│ └── CostBadge.tsx # 成本显示
├── types/
│ └── index.ts
└── utils/
└── format.ts
3. 初始化
bash
cd day44
npm create vite@latest agent-web -- --template react-ts
cd agent-web
npm install
npm install zustand lucide-react react-router-dom
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.config.js:
js
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { extend: {} },
plugins: [],
};
src/index.css:
css
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #root {
height: 100%;
margin: 0;
}
三、类型定义与 API 封装
1. 类型
src/types/index.ts:
typescript
export type Role = "user" | "assistant" | "system" | "tool";
export interface Message {
id: string;
role: Role;
content: string;
timestamp: number;
// 工具相关
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: string;
toolStatus?: "running" | "done" | "error";
// 流式状态
streaming?: boolean;
}
export interface Session {
id: string;
title: string;
updatedAt: number;
messageCount: number;
}
// SSE 事件类型
export type StreamEvent =
| { type: "meta"; session_id: string }
| { type: "delta"; content: string }
| { type: "tool_call"; name: string; args: Record<string, unknown> }
| { type: "tool_result"; name: string; content: string }
| { type: "done"; message_count: number }
| { type: "error"; message: string };
export interface CostStats {
total_calls: number;
total_cost_yuan: number;
avg_cost_yuan: number;
models_used: string[];
}
2. API 封装
src/api/client.ts:
typescript
const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8000";
const API_KEY = import.meta.env.VITE_API_KEY || "test-key";
async function request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
...options.headers,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(error.detail || `HTTP ${res.status}`);
}
return res.json();
}
// ============ 非流式对话 ============
export interface ChatRequest {
message: string;
session_id?: string;
}
export interface ChatResponse {
session_id: string;
reply: string;
message_count: number;
}
export async function sendMessage(req: ChatRequest): Promise<ChatResponse> {
return request("/api/chat", {
method: "POST",
body: JSON.stringify(req),
});
}
// ============ 流式对话 ============
export async function* streamMessage(
req: ChatRequest
): AsyncGenerator<import("../types").StreamEvent> {
const res = await fetch(`${API_BASE}/api/chat/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
},
body: JSON.stringify(req),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
if (!part.trim()) continue;
const line = part.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const data = JSON.parse(line.slice(6));
yield data as import("../types").StreamEvent;
}
}
} finally {
reader.releaseLock();
}
}
// ============ 会话管理 ============
export async function getSessionMessages(sessionId: string) {
return request<{ session_id: string; messages: any[] }>(
`/api/sessions/${sessionId}`
);
}
export async function deleteSession(sessionId: string) {
return request<{ ok: boolean }>(`/api/sessions/${sessionId}`, {
method: "DELETE",
});
}
// ============ 成本统计 ============
export async function getCostStats() {
return request<import("../types").CostStats>("/api/stats/cost");
}
3. 环境变量
.env:
ini
VITE_API_BASE=http://localhost:8000
VITE_API_KEY=test-key
注意:
VITE_前缀的变量会被打进前端产物 ------所以VITE_API_KEY不是"真"密钥。生产环境应该用登录 / Cookie 认证,不是把密钥写前端。这里为了简化。
四、核心 Hook:流式对话
src/hooks/useStreamChat.ts:
typescript
import { useRef, useCallback } from "react";
import { streamMessage } from "../api/client";
import { useChatStore } from "../store/chat";
import type { StreamEvent } from "../types";
export function useStreamChat() {
const abortRef = useRef<AbortController | null>(null);
const {
sessionId,
addMessage,
appendToLastMessage,
updateToolCall,
setSessionId,
setStreaming,
setError,
} = useChatStore();
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStreaming(false);
}, [setStreaming]);
const send = useCallback(
async (text: string) => {
if (!text.trim()) return;
setError(null);
// 1. 加用户消息
addMessage({ role: "user", content: text });
// 2. 加一个空的 assistant 消息(流式填充)
addMessage({ role: "assistant", content: "", streaming: true });
setStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const stream = streamMessage({
message: text,
session_id: sessionId || undefined,
});
for await (const event of stream) {
handleEvent(event);
}
} catch (err: any) {
if (err.name === "AbortError") {
appendToLastMessage("\n\n_[已停止]_");
} else {
setError(err.message || "请求失败");
appendToLastMessage(`\n\n❌ 出错了:${err.message}`);
}
} finally {
setStreaming(false);
abortRef.current = null;
}
},
[sessionId, addMessage, appendToLastMessage, setSessionId, setStreaming, setError]
);
const handleEvent = (event: StreamEvent) => {
switch (event.type) {
case "meta":
setSessionId(event.session_id);
break;
case "delta":
appendToLastMessage(event.content);
break;
case "tool_call":
updateToolCall(event.name, event.args, "running");
break;
case "tool_result":
updateToolCall(event.name, undefined, "done", event.content);
break;
case "done":
// 结束时标记最后一条消息
setStreaming(false);
break;
case "error":
setError(event.message);
appendToLastMessage(`\n\n❌ ${event.message}`);
break;
}
};
return { send, stop };
}
关键设计:
AbortController支持"停止生成"- 按
type分支处理 SSE 事件 appendToLastMessage保证流式字符追加到同一条消息上
五、状态管理:Zustand
src/store/chat.ts:
typescript
import { create } from "zustand";
import type { Message, Session } from "../types";
interface ChatStore {
// 状态
sessionId: string | null;
messages: Message[];
sessions: Session[];
streaming: boolean;
error: string | null;
// 操作
setSessionId: (id: string | null) => void;
addMessage: (msg: Partial<Message>) => string;
appendToLastMessage: (text: string) => void;
updateToolCall: (
name: string,
args: Record<string, unknown> | undefined,
status: "running" | "done" | "error",
result?: string
) => void;
setStreaming: (v: boolean) => void;
setError: (e: string | null) => void;
clearMessages: () => void;
setSessions: (sessions: Session[]) => void;
newSession: () => void;
}
function genId() {
return Math.random().toString(36).slice(2, 10);
}
export const useChatStore = create<ChatStore>((set, get) => ({
sessionId: null,
messages: [],
sessions: [],
streaming: false,
error: null,
setSessionId: (id) => set({ sessionId: id }),
addMessage: (msg) => {
const id = genId();
const message: Message = {
id,
role: msg.role || "user",
content: msg.content || "",
timestamp: Date.now(),
streaming: msg.streaming,
toolName: msg.toolName,
toolArgs: msg.toolArgs,
toolResult: msg.toolResult,
toolStatus: msg.toolStatus,
};
set((state) => ({ messages: [...state.messages, message] }));
return id;
},
appendToLastMessage: (text) => {
set((state) => {
const messages = [...state.messages];
const last = messages[messages.length - 1];
if (last && last.role === "assistant") {
messages[messages.length - 1] = { ...last, content: last.content + text };
}
return { messages };
});
},
updateToolCall: (name, args, status, result) => {
set((state) => {
// 在最后一条 assistant 消息里追加 / 更新一个工具调用
const messages = [...state.messages];
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return { messages };
if (status === "running") {
// 加新的一条"工具调用"消息
messages.push({
id: genId(),
role: "tool",
content: "",
timestamp: Date.now(),
toolName: name,
toolArgs: args,
toolStatus: "running",
});
} else {
// 找最近的同名工具消息,更新
for (let i = messages.length - 1; i >= 0; i--) {
if (
messages[i].role === "tool" &&
messages[i].toolName === name &&
messages[i].toolStatus === "running"
) {
messages[i] = {
...messages[i],
toolStatus: status,
toolResult: result,
};
break;
}
}
}
return { messages };
});
},
setStreaming: (v) => set({ streaming: v }),
setError: (e) => set({ error: e }),
clearMessages: () => set({ messages: [] }),
setSessions: (sessions) => set({ sessions }),
newSession: () => {
set({ sessionId: null, messages: [], error: null });
},
}));
为什么用 Zustand?
- 轻:几行代码搞定,不像 Redux 一堆样板
- 好测:store 就是普通函数
- TS 友好:类型推断完美
前端同学会觉得很舒服。
六、组件实现
1. 整体布局
src/components/ChatLayout.tsx:
tsx
import { Sidebar } from "./Sidebar";
import { MessageList } from "./MessageList";
import { InputBox } from "./InputBox";
import { CostBadge } from "./CostBadge";
export function ChatLayout() {
return (
<div className="flex h-full bg-gray-50">
{/* 侧边栏 */}
<Sidebar />
{/* 主区域 */}
<div className="flex-1 flex flex-col">
{/* 顶部栏 */}
<header className="h-14 px-6 flex items-center justify-between border-b bg-white">
<h1 className="text-lg font-semibold">AI Agent</h1>
<CostBadge />
</header>
{/* 消息列表 */}
<div className="flex-1 overflow-hidden">
<MessageList />
</div>
{/* 输入框 */}
<div className="border-t bg-white">
<InputBox />
</div>
</div>
</div>
);
}
2. 侧边栏(会话列表)
src/components/Sidebar.tsx:
tsx
import { Plus, MessageSquare, Trash2 } from "lucide-react";
import { useChatStore } from "../store/chat";
import { deleteSession, getSessionMessages } from "../api/client";
export function Sidebar() {
const {
sessions,
sessionId,
setSessions,
setSessionId,
newSession,
clearMessages,
addMessage,
} = useChatStore();
const handleNew = () => {
newSession();
};
const handleSelect = async (id: string) => {
if (id === sessionId) return;
try {
const data = await getSessionMessages(id);
clearMessages();
setSessionId(id);
data.messages.forEach((m: any) => {
addMessage({ role: m.role, content: m.content });
});
} catch (e) {
console.error(e);
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("确定删除这个会话?")) return;
try {
await deleteSession(id);
setSessions(sessions.filter((s) => s.id !== id));
if (sessions.length === 1) newSession();
} catch (e) {
console.error(e);
}
};
return (
<aside className="w-64 bg-gray-900 text-gray-100 flex flex-col">
{/* 新建按钮 */}
<button
onClick={handleNew}
className="m-3 px-3 py-2 rounded border border-gray-700 hover:bg-gray-800 flex items-center gap-2 text-sm"
>
<Plus size={16} />
新建对话
</button>
{/* 会话列表 */}
<div className="flex-1 overflow-y-auto px-2">
{sessions.length === 0 ? (
<div className="text-xs text-gray-500 p-4 text-center">
还没有会话
</div>
) : (
sessions.map((s) => (
<div
key={s.id}
onClick={() => handleSelect(s.id)}
className={`group px-3 py-2 rounded mb-1 cursor-pointer flex items-center gap-2 ${
s.id === sessionId ? "bg-gray-700" : "hover:bg-gray-800"
}`}
>
<MessageSquare size={14} className="flex-shrink-0" />
<span className="flex-1 text-sm truncate">{s.title || "未命名"}</span>
<button
onClick={(e) => handleDelete(s.id, e)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-400"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
</aside>
);
}
3. 消息列表
src/components/MessageList.tsx:
tsx
import { useEffect, useRef } from "react";
import { useChatStore } from "../store/chat";
import { MessageItem } from "./MessageItem";
export function MessageList() {
const messages = useChatStore((s) => s.messages);
const bottomRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
if (messages.length === 0) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-400">
<h2 className="text-2xl font-semibold mb-2">开始对话</h2>
<p className="text-sm">试试问:"北京天气怎么样?"</p>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto px-6 py-4">
<div className="max-w-3xl mx-auto space-y-4">
{messages.map((m) => (
<MessageItem key={m.id} message={m} />
))}
<div ref={bottomRef} />
</div>
</div>
);
}
4. 单条消息
src/components/MessageItem.tsx:
tsx
import type { Message } from "../types";
import { ToolCallCard } from "./ToolCallCard";
export function MessageItem({ message }: { message: Message }) {
// 工具调用
if (message.role === "tool") {
return <ToolCallCard message={message} />;
}
const isUser = message.role === "user";
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
isUser
? "bg-blue-600 text-white"
: "bg-white border border-gray-200 text-gray-900"
}`}
>
{!isUser && (
<div className="text-xs font-semibold text-gray-500 mb-1">AI</div>
)}
<div className="whitespace-pre-wrap break-words">
{message.content}
{message.streaming && (
<span className="inline-block w-2 h-4 bg-gray-400 ml-1 animate-pulse" />
)}
</div>
</div>
</div>
);
}
5. 工具调用卡片
src/components/ToolCallCard.tsx:
tsx
import { Loader2, CheckCircle2, XCircle, Wrench } from "lucide-react";
import type { Message } from "../types";
export function ToolCallCard({ message }: { message: Message }) {
const { toolName, toolArgs, toolStatus, toolResult } = message;
const icon = {
running: <Loader2 size={14} className="animate-spin text-blue-500" />,
done: <CheckCircle2 size={14} className="text-green-500" />,
error: <XCircle size={14} className="text-red-500" />,
}[toolStatus || "running"];
const label = {
running: "正在调用",
done: "已完成",
error: "失败",
}[toolStatus || "running"];
return (
<div className="flex justify-start">
<div className="max-w-[80%] bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 text-sm">
<div className="flex items-center gap-2 text-blue-700">
<Wrench size={14} />
<span className="font-medium">调用工具</span>
<code className="bg-white px-1.5 py-0.5 rounded text-xs">{toolName}</code>
{icon}
<span className="text-xs text-blue-500">{label}</span>
</div>
{toolArgs && (
<div className="mt-2 text-xs text-gray-600">
<span className="text-gray-400">参数:</span>
<code className="bg-white px-1 py-0.5 rounded">
{JSON.stringify(toolArgs)}
</code>
</div>
)}
{toolResult && (
<div className="mt-2 text-xs text-gray-600 border-t border-blue-100 pt-2">
<span className="text-gray-400">结果:</span>
<span className="break-all">{toolResult.slice(0, 200)}</span>
</div>
)}
</div>
</div>
);
}
用户能"看到"AI 在做什么------不再是黑盒。
6. 输入框
src/components/InputBox.tsx:
tsx
import { useState, useRef, useEffect } from "react";
import { Send, Square } from "lucide-react";
import { useChatStore } from "../store/chat";
import { useStreamChat } from "../hooks/useStreamChat";
export function InputBox() {
const [text, setText] = useState("");
const streaming = useChatStore((s) => s.streaming);
const { send, stop } = useStreamChat();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = () => {
if (streaming || !text.trim()) return;
send(text);
setText("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
// 自动调整高度
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, [text]);
return (
<div className="max-w-3xl mx-auto p-4">
<div className="flex items-end gap-2 border rounded-xl bg-white shadow-sm focus-within:border-blue-400 transition">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"
className="flex-1 resize-none px-4 py-3 outline-none bg-transparent"
rows={1}
disabled={streaming}
/>
{streaming ? (
<button
onClick={stop}
className="m-2 p-2 bg-gray-200 hover:bg-gray-300 rounded-lg"
title="停止生成"
>
<Square size={18} />
</button>
) : (
<button
onClick={handleSubmit}
disabled={!text.trim()}
className="m-2 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
title="发送"
>
<Send size={18} />
</button>
)}
</div>
<div className="mt-2 text-xs text-gray-400 text-center">
AI 也会犯错,请核实重要信息
</div>
</div>
);
}
细节:
textarea自适应高度Enter发送,Shift+Enter换行- 生成中显示"停止"按钮
- 禁用状态有明显反馈
7. 成本显示
src/components/CostBadge.tsx:
tsx
import { useEffect, useState } from "react";
import { DollarSign } from "lucide-react";
import { getCostStats } from "../api/client";
import type { CostStats } from "../types";
import { useChatStore } from "../store/chat";
export function CostBadge() {
const [stats, setStats] = useState<CostStats | null>(null);
const streaming = useChatStore((s) => s.streaming);
useEffect(() => {
getCostStats().then(setStats).catch(() => {});
}, [streaming]); // 每次对话完成刷新
if (!stats || stats.total_calls === 0) return null;
return (
<div className="flex items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<DollarSign size={14} />
<span>累计 ¥{stats.total_cost_yuan.toFixed(4)}</span>
</div>
<div className="text-gray-300">|</div>
<div>{stats.total_calls} 次调用</div>
</div>
);
}
让用户"感知成本"------这是个很酷的功能,也是"AI 产品"的特色。
七、入口与路由
src/App.tsx:
tsx
import { useEffect } from "react";
import { ChatLayout } from "./components/ChatLayout";
import { useChatStore } from "./store/chat";
import { getCostStats } from "./api/client";
function App() {
const setSessions = useChatStore((s) => s.setSessions);
useEffect(() => {
// 加载会话列表(这里简化:实际应该有后端接口)
setSessions([]);
}, [setSessions]);
return <ChatLayout />;
}
export default App;
src/main.tsx:
tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
vite.config.ts:
typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// 开发时不需要跨域
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});
开发时用 Vite 代理------避免了跨域问题,生产环境用 Nginx。
八、启动测试
1. 启动后端
bash
cd day44/ai-agent # Day 43 的项目
uvicorn ai_agent.api.app:app --reload
2. 启动前端
bash
cd day44/agent-web
npm run dev
**浏览器打开 http://localhost:5173**:
arduino
┌────────────────────────────────────────────┐
│ AI Agent ¥0.0012 │
├─────────┬──────────────────────────────────┤
│ │ │
│ + 新建 │ 开始对话 │
│ │ │
│ 会话 1 │ 试试问:"北京天气怎么样?" │
│ 会话 2 │ │
│ 会话 3 │ │
│ │ │
│ ├──────────────────────────────────┤
│ │ ┌──────────────────────────┐ │
│ │ │ 输入消息... 📤 │ │
│ │ └──────────────────────────┘ │
└─────────┴──────────────────────────────────┘
3. 测试流式对话
输入:"北京天气怎么样?顺便算一下 25 的平方"
实时效果:
css
你:北京天气怎么样?顺便算一下 25 的平方
🔧 调用工具 get_weather {city: "北京"} ✅ 已完成
结果:{"city":"北京","temp":18,"condition":"晴"}
🔧 调用工具 calculator {expression: "25**2"} ✅ 已完成
结果:{"expression":"25**2","result":625}
AI:北京今天晴,18°C。25 的平方是 625。
用户能"看到"AI 做了什么,这是最有魅力的体验。
4. 测试停止
发一条复杂请求,生成中点击"停止"按钮:
效果:
css
AI:这是一段很长的回答... [已停止]
AbortController 会中断请求。
5. 测试历史会话
刷新页面,点击侧边栏的会话------消息恢复。
(这个功能需要后端实现 /api/sessions 列表接口,这里简化了,实际项目要加上。)
九、部署:前端 + 后端一体
1. 构建前端
bash
cd day44/agent-web
npm run build
# 产物:dist/
dist/ 是纯静态文件。
2. 用 Nginx 一起部署
nginx/conf.d/agent.conf:
nginx
server {
listen 443 ssl;
http2 on;
server_name agent.your-domain.com;
ssl_certificate /etc/nginx/certs/live/agent.your-domain.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/agent.your-domain.com/privkey.pem;
# 前端静态文件
root /var/www/agent;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 反向代理
location /api/ {
proxy_pass http://api_backend;
# 流式关键
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 流式请求可能很久
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
一个域名,前后端全搞定。
3. Docker 化(可选)
agent-web/Dockerfile:
dockerfile
# ============ Stage 1: 构建 ============
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ============ Stage 2: Nginx ============
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
agent-web/nginx.conf:
nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
}
}
加到 Day 30 的 docker-compose.prod.yml 里:
yaml
services:
web:
build: ./agent-web
container_name: agent-web
ports:
- "80:80"
depends_on:
- api
restart: always
一键部署前后端。
十、前端视角:这次你终于"用上了"老本行
44 天来,你一直在"学后端"。
今天,你终于回到了"舒适区"------ 写前端。
而且这次是"更有意思的前端":
| 传统前端 | AI 前端 |
|---|---|
| 展示表格 | 展示"AI 思考过程" |
| 表单校验 | 流式打字机 |
| WebSocket 聊天 | SSE 流式 |
| Loading 转圈 | "思考中..."动画 |
| 分页 | 会话列表 |
| 图表 | 成本可视化 |
"AI 前端"是前端的一个新方向------ 它需要的不只是"技术",更是"体验设计"。
你有"用户体验"的直觉------这正是"纯后端"工程师缺乏的。
从今天起,你不只是"前端",还是"AI 产品的界面设计师"。
十一、今日踩坑
EventSource不支持 POST → 用fetch手动处理 SSETextDecoder没加{ stream: true }→ 中文跨 chunk 时乱码buffer.split("\n\n")最后一段残留 → 要pop()保留不完整的部分VITE_API_KEY是"公开的" → 生产不能用密钥,要用登录认证AbortController忘了abort()→ 停止按钮失效textarea高度不自动调整 → 长文本溢出,加useEffect调整setMessages直接 push 后不更新 → Zustand 要创建新数组- 流式回答的
streaming光标不消失 →done时记得清掉 getSessionMessages类型不对 → 后端返回的messages是any[],要做类型转换- Vite 代理没配
/api→ 开发时跨域 - Nginx 没配
proxy_buffering off→ 流式失效(最重要) tool_call和tool_result重复渲染 → 要区分"新建"和"更新"sessionId跨请求丢失 → 用useRef或 store 保证useEffect依赖数组漏字段 → ESLint 警告,要加上- 前端打包体积太大 → 用
lucide-react按需引入,别 import 全部
十二、今日总结
今天核心收获三件事:
- 完整的 AI 前端:流式对话 + 工具可视化 + 会话管理 + 成本显示
- SSE 前端处理模式 :
fetch+ReadableStream+TextDecoder+ 按\n\n分割 - AI 前端的独特之处:不只是"展示数据",是"展示过程"(思考 / 工具调用 / 流式)
一个感受:
43 天你一直在学"AI 后端",今天你终于"用了自己的老本行"。
而且发现------前端经验在 AI 时代更值钱了:
- 流式输出的体验设计 → 前端最懂
- 复杂状态管理 → 前端最熟
- 实时交互 → 前端本行
- 视觉呈现 → 前端强项
AI 产品最缺的就是"体验"------很多 AI 产品后端很强,前端像 demo。
你能做"好看、好用、好体验"的 AI 产品------这就是你的差异化。
"前端转 AI"不是"放弃前端",是"把前端能力用到 AI 上"。
Day 44 完成度:✅
明日预告(Day 45)
- 阶段复盘 :
- Day 31-44 完整回顾
- 从"LLM 应用"到"AI 产品"的跨越
- 完整项目的简历包装
- RAG 阶段预告:让 Agent "读懂文档"
最后
如果这篇对你有用,点个赞让我知道有人在看。
评论区互动: 你的前端界面长什么样?跑起来了吗?欢迎截图分享(或者描述一下)。
有问题评论区见,我们第 45 天继续。
100 天,第 45 天见。
附录:系列目录
第一阶段(已完成 ✅)
| 阶段 | 天数 | 内容 |
|---|---|---|
| Python 基础 | 01-15 | 语法 + 命令行助手 |
| 后端工程化 | 16-30 | 数据库/测试/FastAPI/Docker/上线 |
| LLM 应用 | 31-42 | 调用/流式/工具/结构化/成本/多模型 |
进行中 🎯
| 阶段 | 天数 | 内容 |
|---|---|---|
| LLM 应用 | 43-45 | 综合项目 + 前端集成 + 复盘 |
| RAG | 46-60 | 向量数据库 + 知识库 |
| Agent | 61-80 | LangChain / ReAct / 工具调用 |
| 完整项目 | 81-100 | 上线一个真实 Agent 产品 |
44 天已过,56 天继续。
一起从切图仔变成 Agent 开发者。