AI应用前端开发与React+LLM集成完全教程
从架构设计到生产部署,系统掌握AI应用前端开发的核心技术与最佳实践。
目录
- AI应用前端架构设计
- 流式输出渲染:SSE与WebSocket
- Markdown渲染与代码高亮
- 对话界面UI组件
- 文件上传与多模态输入
- 状态管理:Zustand与Redux
- Vercel AI SDK使用
- 性能优化
- 与后端API集成
- 部署方案
- 完整实战案例
- 最佳实践总结
1. AI应用前端架构设计
1.1 AI应用的特殊性
与传统Web应用相比,AI应用的前端面临几个独特挑战:
| 维度 | 传统应用 | AI应用 |
|---|---|---|
| 响应模式 | 同步请求/响应 | 流式输出(SSE/WS) |
| 响应时间 | 毫秒级 | 秒到分钟级 |
| 内容格式 | JSON/HTML | Markdown/代码/多模态 |
| 交互模式 | 表单/点击 | 对话/多轮交互 |
| 状态复杂度 | 相对简单 | 上下文、历史、Token管理 |
1.2 推荐技术栈
┌─────────────────────────────────────────────┐
│ AI应用前端技术栈 │
├─────────────────────────────────────────────┤
│ 框架层 │ React 18+ / Next.js 14+ │
│ 状态层 │ Zustand / Jotai / Redux Toolkit │
│ UI层 │ Tailwind CSS + shadcn/ui │
│ 流式层 │ Vercel AI SDK / EventSource │
│ 渲染层 │ react-markdown + rehype-highlight │
│ 网络层 │ fetch / axios / SWR / TanStack │
│ 部署层 │ Vercel / Cloudflare / Docker │
└─────────────────────────────────────────────┘
1.3 项目结构
ai-chat-app/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── api/
│ │ └── chat/
│ │ └── route.ts # API路由(代理LLM请求)
│ ├── components/
│ │ ├── chat/
│ │ │ ├── ChatWindow.tsx # 对话窗口主组件
│ │ │ ├── MessageList.tsx # 消息列表
│ │ │ ├── MessageBubble.tsx # 单条消息气泡
│ │ │ ├── ChatInput.tsx # 输入框组件
│ │ │ ├── StreamingText.tsx # 流式文本渲染
│ │ │ └── CodeBlock.tsx # 代码块组件
│ │ ├── ui/ # 通用UI组件
│ │ └── layout/ # 布局组件
│ ├── hooks/
│ │ ├── useChat.ts # 对话核心Hook
│ │ ├── useStream.ts # 流式输出Hook
│ │ └── useTokenCount.ts # Token计数Hook
│ ├── stores/
│ │ ├── chatStore.ts # 对话状态
│ │ └── settingsStore.ts # 设置状态
│ ├── lib/
│ │ ├── api.ts # API客户端
│ │ ├── markdown.ts # Markdown处理
│ │ └── token.ts # Token工具
│ └── types/
│ └── chat.ts # 类型定义
├── public/
├── package.json
└── tailwind.config.ts
1.4 核心类型定义
// types/chat.ts
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
status: 'sending' | 'streaming' | 'complete' | 'error';
tokenUsage?: {
prompt: number;
completion: number;
};
attachments?: Attachment[];
}
export interface Attachment {
type: 'image' | 'file' | 'audio';
name: string;
url: string;
mimeType: string;
size: number;
}
export interface Conversation {
id: string;
title: string;
messages: Message[];
model: string;
createdAt: number;
updatedAt: number;
totalTokens: number;
}
export interface StreamEvent {
type: 'text' | 'error' | 'done' | 'usage';
content?: string;
error?: string;
usage?: { prompt: number; completion: number };
}
export interface ChatConfig {
model: string;
temperature: number;
maxTokens: number;
systemPrompt: string;
}
2. 流式输出渲染:SSE与WebSocket
2.1 为什么需要流式输出
LLM生成文本是逐Token进行的,完整响应可能需要5-30秒。如果等到全部生成完再显示,用户体验极差。流式输出让用户看到"打字机效果",大幅降低感知等待时间。
两种主流方案对比:
| 特性 | SSE (Server-Sent Events) | WebSocket |
|---|---|---|
| 方向 | 单向(服务端→客户端) | 双向 |
| 协议 | HTTP | WS |
| 复杂度 | 低 | 中 |
| 自动重连 | 内置支持 | 需手动实现 |
| 适用场景 | LLM流式输出 | 实时协作、聊天室 |
| 浏览器支持 | EventSource API | WebSocket API |
LLM场景推荐SSE:因为数据流是单向的(服务端生成→客户端展示),SSE更简单可靠。
2.2 后端SSE实现(Next.js API Route)
// app/api/chat/route.ts
import { OpenAI } from 'openai';
const openai = new OpenAI();
export async function POST(req: Request) {
const { messages, model = 'gpt-4o', maxTokens = 4096 } = await req.json();
// 创建流式响应
const stream = await openai.chat.completions.create({
model,
messages,
max_tokens: maxTokens,
stream: true,
});
// 将OpenAI流转换为ReadableStream
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
// SSE格式:data: {json}\n\n
const data = JSON.stringify({ type: 'text', content });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
}
}
// 发送完成信号
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
);
} catch (error) {
const errData = JSON.stringify({
type: 'error',
error: String(error)
});
controller.enqueue(encoder.encode(`data: ${errData}\n\n`));
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
2.3 前端SSE消费
// hooks/useStream.ts
import { useState, useCallback, useRef } from 'react';
import type { StreamEvent } from '@/types/chat';
interface UseStreamOptions {
onText?: (text: string) => void;
onDone?: () => void;
onError?: (error: string) => void;
}
export function useStream(options: UseStreamOptions = {}) {
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const startStream = useCallback(async (
url: string,
body: Record<string, unknown>
) => {
setIsStreaming(true);
setError(null);
abortRef.current = new AbortController();
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: abortRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按SSE格式解析
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 保留未完成的行
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event: StreamEvent = JSON.parse(line.slice(6));
switch (event.type) {
case 'text':
options.onText?.(event.content!);
break;
case 'done':
options.onDone?.();
break;
case 'error':
setError(event.error!);
options.onError?.(event.error!);
break;
}
} catch {
// 忽略解析错误
}
}
}
}
} catch (err: any) {
if (err.name !== 'AbortError') {
const msg = String(err);
setError(msg);
options.onError?.(msg);
}
} finally {
setIsStreaming(false);
}
}, [options]);
const stopStream = useCallback(() => {
abortRef.current?.abort();
}, []);
return { startStream, stopStream, isStreaming, error };
}
2.4 WebSocket方案(适用于实时协作场景)
// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
export function useWebSocket(url: string) {
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const connect = useCallback(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
console.log('[WS] Connected');
};
ws.onclose = () => {
setIsConnected(false);
// 自动重连
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = (err) => {
console.error('[WS] Error:', err);
};
wsRef.current = ws;
}, [url]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimer.current);
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
const onMessage = useCallback((handler: (data: unknown) => void) => {
if (!wsRef.current) return;
wsRef.current.onmessage = (event) => {
try {
handler(JSON.parse(event.data));
} catch {
handler(event.data);
}
};
}, []);
return { send, onMessage, isConnected };
}
3. Markdown渲染与代码高亮
3.1 核心依赖
npm install react-markdown remark-gfm rehype-highlight rehype-raw
3.2 Markdown渲染组件
// components/chat/MarkdownRenderer.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import { CodeBlock } from './CodeBlock';
interface Props {
content: string;
}
export function MarkdownRenderer({ content }: Props) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeRaw]}
components={{
// 自定义代码块渲染
code({ node, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
if (isInline) {
return (
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono text-red-600" {...props}>
{children}
</code>
);
}
return (
<CodeBlock
language={match?.[1] || 'text'}
value={String(children).replace(/\n$/, '')}
/>
);
},
// 自定义表格样式
table({ children }) {
return (
<div className="overflow-x-auto my-4">
<table className="min-w-full border-collapse border border-gray-200">
{children}
</table>
</div>
);
},
// 自定义链接(新窗口打开)
a({ href, children }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
);
},
}}
>
{content}
</ReactMarkdown>
);
}
3.3 代码块组件(带复制和语言标签)
// components/chat/CodeBlock.tsx
import { useState, useCallback } from 'react';
interface Props {
language: string;
value: string;
}
export function CodeBlock({ language, value }: Props) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [value]);
return (
<div className="relative group my-4 rounded-lg overflow-hidden border border-gray-200">
{/* 语言标签和复制按钮 */}
<div className="flex items-center justify-between px-4 py-2 bg-gray-800 text-gray-300 text-xs">
<span className="font-mono">{language}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-700
transition-colors opacity-0 group-hover:opacity-100"
>
{copied ? (
<>
<CheckIcon /> 已复制
</>
) : (
<>
<CopyIcon /> 复制
</>
)}
</button>
</div>
{/* 代码内容 */}
<pre className="p-4 bg-gray-900 overflow-x-auto">
<code className={`language-${language} text-sm leading-relaxed`}>
{value}
</code>
</pre>
</div>
);
}
function CopyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
);
}
function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
3.4 流式Markdown渲染
在流式输出过程中,Markdown可能不完整(例如代码块没有闭合),需要特殊处理:
// components/chat/StreamingMarkdown.tsx
import { useMemo } from 'react';
import { MarkdownRenderer } from './MarkdownRenderer';
interface Props {
content: string;
isStreaming: boolean;
}
export function StreamingMarkdown({ content, isStreaming }: Props) {
// 流式输出时,补全未闭合的Markdown结构
const safeContent = useMemo(() => {
if (!isStreaming) return content;
let text = content;
// 补全未闭合的代码块
const codeBlockCount = (text.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) {
text += '\n```';
}
// 补全未闭合的行内代码
const inlineCodeCount = (text.match(/(?<!`)`(?!`)/g) || []).length;
if (inlineCodeCount % 2 !== 0) {
text += '`';
}
// 补全未闭合的粗体
const boldCount = (text.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) {
text += '**';
}
return text;
}, [content, isStreaming]);
return (
<div className="prose prose-sm max-w-none">
<MarkdownRenderer content={safeContent} />
{isStreaming && (
<span className="inline-block w-2 h-4 bg-gray-900 animate-pulse ml-0.5" />
)}
</div>
);
}
4. 对话界面UI组件
4.1 消息气泡组件
// components/chat/MessageBubble.tsx
import { StreamingMarkdown } from './StreamingMarkdown';
import type { Message } from '@/types/chat';
import { formatDistanceToNow } from 'date-fns';
interface Props {
message: Message;
isStreaming?: boolean;
}
export function MessageBubble({ message, isStreaming }: Props) {
const isUser = message.role === 'user';
return (
<div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
{/* 头像 */}
<div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm
${isUser ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
{isUser ? '👤' : '🤖'}
</div>
{/* 消息内容 */}
<div className={`flex flex-col max-w-[80%] ${isUser ? 'items-end' : 'items-start'}`}>
<div className={`rounded-2xl px-4 py-2.5 ${
isUser
? 'bg-blue-600 text-white rounded-tr-sm'
: 'bg-white border border-gray-200 rounded-tl-sm shadow-sm'
}`}>
{isUser ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
) : (
<StreamingMarkdown
content={message.content}
isStreaming={isStreaming || message.status === 'streaming'}
/>
)}
</div>
{/* 时间和状态 */}
<div className="flex items-center gap-2 mt-1 px-1">
<span className="text-xs text-gray-400">
{formatDistanceToNow(message.timestamp, { addSuffix: true })}
</span>
{message.status === 'error' && (
<span className="text-xs text-red-500">发送失败</span>
)}
{message.tokenUsage && (
<span className="text-xs text-gray-400">
{message.tokenUsage.completion} tokens
</span>
)}
</div>
</div>
</div>
);
}
4.2 对话输入框
// components/chat/ChatInput.tsx
import { useState, useRef, useCallback, useEffect } from 'react';
interface Props {
onSend: (message: string) => void;
onStop?: () => void;
isLoading: boolean;
disabled?: boolean;
placeholder?: string;
}
export function ChatInput({
onSend, onStop, isLoading, disabled, placeholder = '输入消息...'
}: Props) {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 自动调整高度
useEffect(() => {
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}
}, [input]);
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setInput('');
// 重置高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [input, disabled, onSend]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
return (
<div className="border-t border-gray-200 bg-white p-4">
<div className="flex items-end gap-3 max-w-3xl mx-auto">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-xl border border-gray-300 px-4 py-3
pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500
focus:border-transparent disabled:opacity-50"
/>
</div>
{isLoading ? (
<button
onClick={onStop}
className="flex-shrink-0 w-10 h-10 rounded-xl bg-red-500 text-white
flex items-center justify-center hover:bg-red-600 transition-colors"
>
<StopIcon />
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || disabled}
className="flex-shrink-0 w-10 h-10 rounded-xl bg-blue-600 text-white
flex items-center justify-center hover:bg-blue-700
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<SendIcon />
</button>
)}
</div>
<p className="text-center text-xs text-gray-400 mt-2">
Shift + Enter 换行 · Enter 发送
</p>
</div>
);
}
function SendIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
);
}
function StopIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);
}
4.3 对话窗口主组件
// components/chat/ChatWindow.tsx
import { useRef, useEffect } from 'react';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { useChat } from '@/hooks/useChat';
export function ChatWindow() {
const { messages, sendMessage, stopGeneration, isStreaming } = useChat();
const listRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
useEffect(() => {
const el = listRef.current;
if (el) {
el.scrollTop = el.scrollHeight;
}
}, [messages]);
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* 头部 */}
<header className="border-b border-gray-200 bg-white px-6 py-3">
<h1 className="text-lg font-semibold">AI 助手</h1>
<p className="text-sm text-gray-500">
{messages.length} 条消息
</p>
</header>
{/* 消息列表 */}
<div ref={listRef} className="flex-1 overflow-y-auto px-4 py-6">
<div className="max-w-3xl mx-auto space-y-6">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<div className="text-5xl mb-4">🤖</div>
<p className="text-lg">有什么可以帮助你的?</p>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
isStreaming={msg.status === 'streaming'}
/>
))}
</div>
</div>
{/* 输入框 */}
<ChatInput
onSend={sendMessage}
onStop={stopGeneration}
isLoading={isStreaming}
/>
</div>
);
}
5. 文件上传与多模态输入
5.1 图片上传与预览
// components/chat/ImageUpload.tsx
import { useState, useRef, useCallback } from 'react';
interface Props {
onUpload: (files: File[]) => void;
maxFiles?: number;
maxSize?: number; // MB
}
export function ImageUpload({ onUpload, maxFiles = 5, maxSize = 10 }: Props) {
const [previews, setPreviews] = useState<{ file: File; url: string }[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
const validFiles: File[] = [];
const newPreviews: { file: File; url: string }[] = [];
Array.from(files).forEach((file) => {
// 检查文件大小
if (file.size > maxSize * 1024 * 1024) {
alert(`文件 ${file.name} 超过 ${maxSize}MB 限制`);
return;
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
alert(`文件 ${file.name} 不是图片`);
return;
}
validFiles.push(file);
newPreviews.push({
file,
url: URL.createObjectURL(file),
});
});
setPreviews((prev) => [...prev, ...newPreviews].slice(0, maxFiles));
onUpload(validFiles);
}, [maxFiles, maxSize, onUpload]);
const removePreview = useCallback((index: number) => {
setPreviews((prev) => {
URL.revokeObjectURL(prev[index].url);
return prev.filter((_, i) => i !== index);
});
}, []);
return (
<div className="space-y-2">
{/* 预览区 */}
{previews.length > 0 && (
<div className="flex gap-2 flex-wrap">
{previews.map((preview, i) => (
<div key={i} className="relative group">
<img
src={preview.url}
alt=""
className="w-16 h-16 object-cover rounded-lg border"
/>
<button
onClick={() => removePreview(i)}
className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white
rounded-full text-xs flex items-center justify-center
opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
)}
{/* 上传按钮 */}
<button
onClick={() => inputRef.current?.click()}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
>
<ImageIcon />
<span>上传图片</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
);
}
function ImageIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
}
5.2 多模态消息发送
// 发送包含图片的消息
async function sendMultimodalMessage(
text: string,
images: File[]
): Promise<void> {
// 方案1:先上传图片获取URL,再发送消息
const imageUrls = await Promise.all(
images.map(async (file) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
const { url } = await res.json();
return url;
})
);
// 构造多模态消息(OpenAI格式)
const content = [
{ type: 'text', text },
...imageUrls.map((url) => ({
type: 'image_url' as const,
image_url: { url, detail: 'auto' as const },
})),
];
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{ role: 'user', content }
],
}),
});
}
5.3 文件拖拽上传
// hooks/useDragDrop.ts
import { useState, useCallback, DragEvent } from 'react';
export function useDragDrop(onDrop: (files: File[]) => void) {
const [isDragging, setIsDragging] = useState(false);
const handleDragEnter = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
// 只在真正离开容器时取消
if (e.currentTarget === e.target) {
setIsDragging(false);
}
}, []);
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onDrop(files);
}
}, [onDrop]);
return {
isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
};
}
6. 状态管理:Zustand与Redux
6.1 为什么选择Zustand
对于AI应用的状态管理,Zustand比Redux Toolkit更轻量、更灵活:
| 特性 | Zustand | Redux Toolkit |
|---|---|---|
| 模板代码 | 极少 | 中等 |
| 学习曲线 | 低 | 中 |
| 包大小 | ~1KB | ~11KB |
| TypeScript | 原生支持 | 需要配置 |
| 中间件 | 简洁 | 生态丰富 |
| DevTools | 支持 | 原生支持 |
6.2 对话状态Store
// stores/chatStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { Message, Conversation, ChatConfig } from '@/types/chat';
interface ChatState {
// 当前对话
conversations: Conversation[];
activeConversationId: string | null;
config: ChatConfig;
// 操作
createConversation: () => string;
addMessage: (conversationId: string, message: Message) => void;
updateMessage: (conversationId: string, messageId: string, content: string) => void;
setMessageStatus: (conversationId: string, messageId: string, status: Message['status']) => void;
deleteConversation: (id: string) => void;
setActiveConversation: (id: string) => void;
updateConfig: (config: Partial<ChatConfig>) => void;
// 派生数据
getActiveConversation: () => Conversation | null;
getActiveMessages: () => Message[];
}
export const useChatStore = create<ChatState>()(
persist(
immer((set, get) => ({
conversations: [],
activeConversationId: null,
config: {
model: 'gpt-4o',
temperature: 0.7,
maxTokens: 4096,
systemPrompt: '你是一个有帮助的AI助手。',
},
createConversation: () => {
const id = crypto.randomUUID();
const conversation: Conversation = {
id,
title: '新对话',
messages: [],
model: get().config.model,
createdAt: Date.now(),
updatedAt: Date.now(),
totalTokens: 0,
};
set((state) => {
state.conversations.unshift(conversation);
state.activeConversationId = id;
});
return id;
},
addMessage: (conversationId, message) => {
set((state) => {
const conv = state.conversations.find((c) => c.id === conversationId);
if (conv) {
conv.messages.push(message);
conv.updatedAt = Date.now();
// 自动更新标题(取第一条用户消息)
if (conv.messages.length === 1 && message.role === 'user') {
conv.title = message.content.slice(0, 30) +
(message.content.length > 30 ? '...' : '');
}
}
});
},
updateMessage: (conversationId, messageId, content) => {
set((state) => {
const conv = state.conversations.find((c) => c.id === conversationId);
if (conv) {
const msg = conv.messages.find((m) => m.id === messageId);
if (msg) {
msg.content = content;
}
}
});
},
setMessageStatus: (conversationId, messageId, status) => {
set((state) => {
const conv = state.conversations.find((c) => c.id === conversationId);
if (conv) {
const msg = conv.messages.find((m) => m.id === messageId);
if (msg) {
msg.status = status;
}
}
});
},
deleteConversation: (id) => {
set((state) => {
state.conversations = state.conversations.filter((c) => c.id !== id);
if (state.activeConversationId === id) {
state.activeConversationId = state.conversations[0]?.id || null;
}
});
},
setActiveConversation: (id) => {
set({ activeConversationId: id });
},
updateConfig: (config) => {
set((state) => {
Object.assign(state.config, config);
});
},
getActiveConversation: () => {
const state = get();
return state.conversations.find((c) => c.id === state.activeConversationId) || null;
},
getActiveMessages: () => {
const conv = get().getActiveConversation();
return conv?.messages || [];
},
})),
{
name: 'chat-storage',
// 只持久化对话数据,不持久化临时状态
partialize: (state) => ({
conversations: state.conversations.slice(0, 50), // 最多保留50个对话
activeConversationId: state.activeConversationId,
config: state.config,
}),
}
)
);
6.3 核心Chat Hook
// hooks/useChat.ts
import { useCallback, useRef } from 'react';
import { useChatStore } from '@/stores/chatStore';
import { useStream } from './useStream';
export function useChat() {
const store = useChatStore();
const activeConv = store.getActiveConversation();
const assistantMsgIdRef = useRef<string | null>(null);
const { startStream, stopStream, isStreaming } = useStream({
onText: (text) => {
if (assistantMsgIdRef.current && store.activeConversationId) {
// 追加流式文本
const conv = store.getActiveConversation();
const msg = conv?.messages.find((m) => m.id === assistantMsgIdRef.current);
if (msg) {
store.updateMessage(
store.activeConversationId,
assistantMsgIdRef.current,
msg.content + text
);
}
}
},
onDone: () => {
if (assistantMsgIdRef.current && store.activeConversationId) {
store.setMessageStatus(
store.activeConversationId,
assistantMsgIdRef.current,
'complete'
);
}
assistantMsgIdRef.current = null;
},
onError: (error) => {
if (assistantMsgIdRef.current && store.activeConversationId) {
store.setMessageStatus(
store.activeConversationId,
assistantMsgIdRef.current,
'error'
);
}
console.error('Stream error:', error);
},
});
const sendMessage = useCallback(async (content: string) => {
// 确保有活跃对话
let convId = store.activeConversationId;
if (!convId) {
convId = store.createConversation();
}
// 添加用户消息
const userMsg = {
id: crypto.randomUUID(),
role: 'user' as const,
content,
timestamp: Date.now(),
status: 'complete' as const,
};
store.addMessage(convId, userMsg);
// 添加助手消息占位
const assistantMsg = {
id: crypto.randomUUID(),
role: 'assistant' as const,
content: '',
timestamp: Date.now(),
status: 'streaming' as const,
};
assistantMsgIdRef.current = assistantMsg.id;
store.addMessage(convId, assistantMsg);
// 获取完整消息历史用于请求
const conv = store.getActiveConversation();
const messages = [
{ role: 'system' as const, content: store.config.systemPrompt },
...(conv?.messages
.filter((m) => m.status !== 'error')
.map((m) => ({ role: m.role, content: m.content })) || []),
];
// 开始流式请求
await startStream('/api/chat', {
messages,
model: store.config.model,
maxTokens: store.config.maxTokens,
});
}, [store, startStream]);
const stopGeneration = useCallback(() => {
stopStream();
if (assistantMsgIdRef.current && store.activeConversationId) {
store.setMessageStatus(
store.activeConversationId,
assistantMsgIdRef.current,
'complete'
);
}
}, [stopStream, store]);
return {
messages: store.getActiveMessages(),
sendMessage,
stopGeneration,
isStreaming,
conversations: store.conversations,
activeConversationId: store.activeConversationId,
config: store.config,
createConversation: store.createConversation,
setActiveConversation: store.setActiveConversation,
deleteConversation: store.deleteConversation,
updateConfig: store.updateConfig,
};
}
7. Vercel AI SDK使用
7.1 Vercel AI SDK简介
Vercel AI SDK(ai包)是目前React + LLM集成的最佳开发体验方案,提供了:
useChatHook:一行代码实现完整的对话功能useCompletionHook:文本补全场景- 流式渲染内置支持
- 多模型适配(OpenAI、Anthropic、Google等)
7.2 后端集成
// app/api/chat/route.ts (使用Vercel AI SDK)
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
system: '你是一个有帮助的AI助手。',
messages,
maxTokens: 4096,
temperature: 0.7,
});
return result.toDataStreamResponse();
}
7.3 前端使用useChat
// app/page.tsx
'use client';
import { useChat } from 'ai/react';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { ChatInput } from '@/components/chat/ChatInput';
export default function ChatPage() {
const {
messages,
input,
handleInputChange,
handleSubmit,
isLoading,
stop,
error,
reload,
} = useChat({
api: '/api/chat',
onError: (err) => {
console.error('Chat error:', err);
},
onFinish: (message) => {
console.log('Finished:', message.usage);
},
});
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={{
id: msg.id,
role: msg.role as 'user' | 'assistant',
content: msg.content,
timestamp: Date.now(),
status: isLoading && msg.id === messages[messages.length - 1]?.id
? 'streaming' : 'complete',
}}
/>
))}
</div>
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="flex gap-2 max-w-3xl mx-auto">
<input
value={input}
onChange={handleInputChange}
placeholder="输入消息..."
className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{isLoading ? (
<button type="button" onClick={stop}
className="px-4 py-2 bg-red-500 text-white rounded-lg">
停止
</button>
) : (
<button type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-lg">
发送
</button>
)}
</div>
</form>
</div>
);
}
7.4 useCompletion(文本补全场景)
// 适用于非对话场景,如文本续写、代码补全
'use client';
import { useCompletion } from 'ai/react';
export function TextCompleter() {
const { completion, input, handleInputChange, handleSubmit, isLoading } =
useCompletion({
api: '/api/completion',
});
return (
<div>
<form onSubmit={handleSubmit}>
<textarea value={input} onChange={handleInputChange} />
<button type="submit" disabled={isLoading}>生成</button>
</form>
{completion && (
<div className="mt-4 p-4 bg-gray-50 rounded">
<p>{completion}</p>
</div>
)}
</div>
);
}
8. 性能优化
8.1 消息列表虚拟化
当对话消息很多时,需要虚拟化渲染以保持流畅:
npm install @tanstack/react-virtual
// components/chat/VirtualizedMessageList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { MessageBubble } from './MessageBubble';
import type { Message } from '@/types/chat';
interface Props {
messages: Message[];
}
export function VirtualizedMessageList({ messages }: Props) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // 估计每条消息高度
overscan: 5, // 预渲染5条额外消息
});
return (
<div ref={parentRef} className="flex-1 overflow-y-auto">
<div
style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
>
<MessageBubble message={messages[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
8.2 防抖与节流
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 在流式输出中使用节流避免过于频繁的渲染
// hooks/useThrottledValue.ts
import { useState, useEffect, useRef } from 'react';
export function useThrottledValue<T>(value: T, interval: number = 50): T {
const [throttled, setThrottled] = useState(value);
const lastUpdate = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const now = Date.now();
const remaining = interval - (now - lastUpdate.current);
if (remaining <= 0) {
lastUpdate.current = now;
setThrottled(value);
} else {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
lastUpdate.current = Date.now();
setThrottled(value);
}, remaining);
}
return () => clearTimeout(timerRef.current);
}, [value, interval]);
return throttled;
}
8.3 代码分割与懒加载
// 动态导入重型Markdown组件
import dynamic from 'next/dynamic';
const MarkdownRenderer = dynamic(
() => import('@/components/chat/MarkdownRenderer').then(mod => mod.MarkdownRenderer),
{
loading: () => <div className="animate-pulse bg-gray-100 h-20 rounded" />,
ssr: false, // Markdown渲染不需要SSR
}
);
// 代码高亮样式按需加载
import { useEffect } from 'react';
export function useHighlightTheme(theme: string = 'github-dark') {
useEffect(() => {
import(`highlight.js/styles/${theme}.css`);
}, [theme]);
}
8.4 缓存策略
// 使用SWR缓存对话历史
import useSWR from 'swr';
const fetcher = (url: string) => fetch(url).then(r => r.json());
export function useConversationHistory(conversationId: string | null) {
return useSWR(
conversationId ? `/api/conversations/${conversationId}` : null,
fetcher,
{
revalidateOnFocus: false, // 切换标签不重新获取
revalidateOnReconnect: false, // 断网重连不重新获取
dedupingInterval: 60000, // 1分钟内去重
}
);
}
9. 与后端API集成
9.1 API客户端封装
// lib/api.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';
interface RequestOptions {
method?: string;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
class APIClient {
private baseURL: string;
private token: string | null = null;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
setToken(token: string) {
this.token = token;
}
async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {}, signal } = options;
const response = await fetch(`${this.baseURL}${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
signal,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new APIError(response.status, error.message || '请求失败');
}
return response.json();
}
// 流式请求
async *stream(endpoint: string, body: unknown): AsyncGenerator<string> {
const response = await fetch(`${this.baseURL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new APIError(response.status, '流式请求失败');
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6));
if (event.type === 'text' && event.content) {
yield event.content;
}
} catch {
// 忽略解析错误
}
}
}
}
}
}
class APIError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'APIError';
}
}
export const api = new APIClient(BASE_URL);
9.2 错误处理与重试
// lib/retry.ts
interface RetryOptions {
maxRetries?: number;
delay?: number;
backoff?: number;
retryOn?: (error: Error) => boolean;
}
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const {
maxRetries = 3,
delay = 1000,
backoff = 2,
retryOn = defaultRetryCondition
} = options;
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries || !retryOn(lastError)) {
throw lastError;
}
// 指数退避
const waitTime = delay * Math.pow(backoff, attempt);
console.log(`重试 ${attempt + 1}/${maxRetries},等待 ${waitTime}ms...`);
await new Promise(r => setTimeout(r, waitTime));
}
}
throw lastError!;
}
function defaultRetryCondition(error: Error): boolean {
// 网络错误或5xx错误可以重试
if (error instanceof APIError) {
return error.status >= 500 || error.status === 429;
}
return error.message.includes('fetch') || error.message.includes('network');
}
// 使用示例
const response = await withRetry(
() => api.request('/api/chat', { method: 'POST', body: { messages } }),
{ maxRetries: 2, delay: 1000 }
);
9.3 认证集成
// hooks/useAuth.ts
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { api } from '@/lib/api';
interface AuthState {
user: { id: string; email: string; name: string } | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthState['user']>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 检查已保存的Token
const token = localStorage.getItem('auth_token');
if (token) {
api.setToken(token);
api.request('/api/me')
.then(setUser)
.catch(() => localStorage.removeItem('auth_token'))
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
const login = async (email: string, password: string) => {
const { token, user } = await api.request<{ token: string; user: AuthState['user'] }>(
'/api/auth/login',
{ method: 'POST', body: { email, password } }
);
localStorage.setItem('auth_token', token);
api.setToken(token);
setUser(user);
};
const logout = () => {
localStorage.removeItem('auth_token');
api.setToken('');
setUser(null);
};
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be inside AuthProvider');
return ctx;
}
10. 部署方案
10.1 Vercel部署(推荐)
// vercel.json
{
"framework": "nextjs",
"regions": ["iad1"],
"functions": {
"app/api/chat/route.ts": {
"maxDuration": 60,
"memory": 1024
}
}
}
# 部署命令
vercel --prod
# 环境变量设置
vercel env add OPENAI_API_KEY production
Vercel部署优势:
- 零配置Next.js支持
- 边缘函数(Edge Runtime)低延迟
- 自动HTTPS和CDN
- 与AI SDK深度集成
10.2 Docker部署
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# 复制构建产物
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
ai-chat:
build: .
ports:
- "3000:3000"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- NEXT_PUBLIC_API_URL=${API_URL}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
10.3 Cloudflare Pages部署
# 使用OpenNext适配器
npm install -g opennext
opennext build
# 将输出部署到Cloudflare Pages
10.4 部署检查清单
- 环境变量已配置(API Key等敏感信息)
- API路由设置了合理的超时时间
- 静态资源已配置CDN缓存
- 错误监控已接入(Sentry等)
- 日志收集已配置
- 健康检查端点已实现
- 限流策略已配置
- CORS策略已设置
11. 完整实战案例
11.1 项目初始化
# 创建Next.js项目
npx create-next-app@latest ai-chat --typescript --tailwind --app --src-dir
cd ai-chat
# 安装依赖
npm install ai @ai-sdk/openai zustand immer
npm install react-markdown remark-gfm rehype-highlight rehype-raw
npm install @tanstack/react-virtual date-fns nanoid
npm install -D @types/node
11.2 完整应用入口
// src/app/page.tsx
'use client';
import { ChatWindow } from '@/components/chat/ChatWindow';
import { Sidebar } from '@/components/layout/Sidebar';
import { useState } from 'react';
export default function Home() {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="flex h-screen bg-gray-50">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<main className="flex-1">
<ChatWindow />
</main>
</div>
);
}
11.3 侧边栏组件
// src/components/layout/Sidebar.tsx
import { useChatStore } from '@/stores/chatStore';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
interface Props {
isOpen: boolean;
onToggle: () => void;
}
export function Sidebar({ isOpen, onToggle }: Props) {
const { conversations, activeConversationId, createConversation,
setActiveConversation, deleteConversation } = useChatStore();
return (
<aside className={`${isOpen ? 'w-64' : 'w-0'} transition-all duration-300
border-r border-gray-200 bg-white flex flex-col overflow-hidden`}>
{/* 新建对话 */}
<div className="p-3 border-b">
<button
onClick={createConversation}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg
bg-blue-600 text-white hover:bg-blue-700 transition-colors text-sm"
>
<PlusIcon />
新对话
</button>
</div>
{/* 对话列表 */}
<div className="flex-1 overflow-y-auto">
{conversations.map((conv) => (
<div
key={conv.id}
onClick={() => setActiveConversation(conv.id)}
className={`group flex items-center gap-2 px-3 py-2.5 cursor-pointer
hover:bg-gray-50 transition-colors ${
conv.id === activeConversationId ? 'bg-blue-50 border-r-2 border-blue-600' : ''
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{conv.title}</p>
<p className="text-xs text-gray-400">
{formatDistanceToNow(conv.updatedAt, { addSuffix: true, locale: zhCN })}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="opacity-0 group-hover:opacity-100 text-gray-400
hover:text-red-500 transition-all"
>
<TrashIcon />
</button>
</div>
))}
</div>
{/* 折叠按钮 */}
<button
onClick={onToggle}
className="p-3 border-t text-gray-400 hover:text-gray-600"
>
{isOpen ? '◀ 收起' : '▶'}
</button>
</aside>
);
}
function PlusIcon() {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>;
}
function TrashIcon() {
return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>;
}
12. 最佳实践总结
12.1 架构原则
- API路由代理:永远不要在前端直接调用LLM API,通过后端API Route代理,保护API Key
- 流式优先:所有LLM交互默认使用流式输出
- 乐观更新:用户消息立即显示,不必等待服务端确认
- 状态持久化:使用localStorage或IndexedDB持久化对话历史
12.2 用户体验要点
- 流式输出使用打字机光标效果
- 支持停止生成(AbortController)
- 代码块带复制按钮和语言标签
- 输入框支持Shift+Enter换行
- 自动滚动到最新消息(但用户上滚时不强制拉回)
- 加载状态有明确的视觉反馈
- 错误状态有重试按钮
- 长消息支持折叠/展开
12.3 性能清单
- 消息列表虚拟化(>50条消息时)
- 流式输出节流(50-100ms间隔更新UI)
- Markdown组件懒加载
- 图片懒加载和压缩
- 代码高亮按需加载语言包
- 对话历史分页加载
12.4 安全要点
- API Key只在服务端使用
- 输入内容做XSS过滤
- Markdown渲染配置白名单标签
- 文件上传限制类型和大小
- 请求频率限制
- 用户认证和会话隔离
本教程覆盖了AI应用前端开发的完整技术栈。从流式输出到状态管理,从UI组件到生产部署,这些知识和代码可以直接用于构建高质量的AI对话应用。关键是:始终以用户体验为核心,让AI的能力通过精心设计的前端界面得到最好的呈现。