AI大模型应用前端UI设计完全教程
1. AI应用前端设计原则与趋势
AI应用的前端设计与传统Web应用存在本质差异。传统应用的核心是"人操作界面",而AI应用的核心是"人与AI对话"。这种交互范式的转变,要求前端设计遵循以下原则:
渐进式披露(Progressive Disclosure):不要一次性展示所有功能。AI的能力边界模糊,用户需要逐步探索。首次进入时只展示核心对话入口,高级功能(如模型选择、参数调节、知识库管理)折叠到侧边栏或设置面板中。
流式优先(Streaming First):用户发出请求后,AI的响应是逐步生成的。前端必须从架构层面支持流式渲染,而非等待完整响应后再展示。这不仅降低感知延迟,还让用户有时间阅读和思考。
容错设计(Graceful Degradation):AI可能返回错误、产生幻觉、或超出预期。前端需要提供"重新生成"、"编辑后重新发送"、"反馈(👍👎)"等机制,让用户能优雅地纠正AI的行为。
上下文感知(Context Awareness):对话是有状态的。前端需要维护完整的对话上下文,支持上下文窗口管理、历史消息引用、以及会话级别的配置持久化。
2024-2025年的主要设计趋势包括:
- 侧边栏 + 主区域布局:左侧会话列表,右侧对话区域,已成为事实标准(ChatGPT、Claude、Gemini均采用此模式)
- 多模态融合输入:文本、图片、文件、语音混合输入
- AI思考过程透明化:展示推理链(Chain of Thought)、工具调用过程
- 嵌入式AI(Embedded AI):AI能力嵌入到现有产品中,而非独立应用
2. 对话式UI设计模式
对话式UI是AI应用的核心交互模式。一个完整的对话消息组件需要处理多种内容类型:
// 消息类型定义
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
status: 'sending' | 'streaming' | 'complete' | 'error';
attachments?: Attachment[];
thinkingProcess?: string; // AI思考过程
tokenUsage?: { // Token用量
prompt: number;
completion: number;
};
feedback?: 'positive' | 'negative' | null;
}
interface Attachment {
type: 'image' | 'file' | 'audio';
url: string;
name: string;
size: number;
}
消息气泡组件的设计要点:
import { memo } from 'react';
const MessageBubble = memo(function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === 'user';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
{!isUser && (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center mr-3 flex-shrink-0">
<SparklesIcon className="w-4 h-4 text-white" />
</div>
)}
<div className={`max-w-[80%] ${isUser ? 'order-first' : ''}`}>
{/* 思考过程折叠区 */}
{message.thinkingProcess && (
<ThinkingBlock content={message.thinkingProcess} />
)}
{/* 主内容区 */}
<div className={`rounded-2xl px-4 py-3 ${
isUser
? 'bg-blue-600 text-white rounded-br-md'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-md'
}`}>
{message.status === 'streaming' ? (
<StreamingContent content={message.content} />
) : (
<MarkdownRenderer content={message.content} />
)}
</div>
{/* 附件预览 */}
{message.attachments?.map(att => (
<AttachmentPreview key={att.url} attachment={att} />
))}
{/* 消息操作栏 */}
{!isUser && message.status === 'complete' && (
<MessageActions message={message} />
)}
</div>
</div>
);
});
自动滚动是对话UI的关键体验细节。新消息到来时应自动滚动到底部,但用户手动向上滚动后不应强制拉回:
function useAutoScroll(deps: any[]) {
const scrollRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const threshold = 100;
setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < threshold);
}, []);
useEffect(() => {
if (isAtBottom) {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
});
}
}, deps);
return { scrollRef, handleScroll, isAtBottom };
}
3. 流式输出渲染(SSE/WebSocket)
流式输出是AI应用前端的核心技术挑战。主流方案有两种:Server-Sent Events(SSE)和WebSocket。对于单向的AI响应流,SSE是更简洁的选择。
SSE方案实现
// 流式请求封装
async function* streamChat(messages: Message[], signal?: AbortSignal) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true }),
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const reader = response.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 lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
yield JSON.parse(data);
} catch {
// 跳过格式错误的行
}
}
}
}
} finally {
reader.releaseLock();
}
}
React Hook封装
function useStreamChat() {
const [isStreaming, setIsStreaming] = useState(false);
const [content, setContent] = useState('');
const abortRef = useRef<AbortController | null>(null);
const startStream = useCallback(async (messages: Message[]) => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setIsStreaming(true);
setContent('');
try {
for await (const chunk of streamChat(messages, abortRef.current.signal)) {
if (chunk.choices?.[0]?.delta?.content) {
setContent(prev => prev + chunk.choices[0].delta.content);
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// 用户主动取消
} else {
throw err;
}
} finally {
setIsStreaming(false);
}
}, []);
const stopStream = useCallback(() => {
abortRef.current?.abort();
}, []);
return { content, isStreaming, startStream, stopStream };
}
打字机效果与防闪烁
流式渲染时,逐字出现的打字机效果能显著提升体验。但需要平衡渲染频率,避免过快更新导致的性能问题:
function StreamingContent({ content }: { content: string }) {
const [displayContent, setDisplayContent] = useState('');
const queueRef = useRef<string>('');
const rafRef = useRef<number>();
useEffect(() => {
queueRef.current = content;
const flush = () => {
const target = queueRef.current;
if (displayContent.length < target.length) {
// 每帧最多追加3个字符,保持流畅感
const nextLen = Math.min(displayContent.length + 3, target.length);
setDisplayContent(target.slice(0, nextLen));
rafRef.current = requestAnimationFrame(flush);
}
};
rafRef.current = requestAnimationFrame(flush);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [content]);
return <MarkdownRenderer content={displayContent} />;
}
4. Markdown实时渲染与代码高亮
AI响应通常包含丰富的Markdown格式:标题、列表、代码块、表格、LaTeX公式等。实时渲染需要处理"不完整Markdown"的情况——流式过程中代码块可能只有开始标记而没有结束标记。
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
function MarkdownRenderer({ content }: { content: string }) {
// 预处理:补全未闭合的代码块
const processedContent = preprocessMarkdown(content);
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const codeString = String(children).replace(/\n$/, '');
if (!inline && match) {
return <CodeBlock language={match[1]} code={codeString} />;
}
return (
<code className="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm" {...props}>
{children}
</code>
);
},
table({ children }) {
return (
<div className="overflow-x-auto my-2">
<table className="min-w-full border-collapse border border-gray-300 dark:border-gray-600">
{children}
</table>
</div>
);
},
}}
>
{processedContent}
</ReactMarkdown>
);
}
// 预处理不完整的Markdown
function preprocessMarkdown(content: string): string {
// 统计未闭合的代码块
const codeFences = content.match(/```/g);
if (codeFences && codeFences.length % 2 !== 0) {
// 有未闭合的代码块,添加结束标记
return content + '\n```';
}
return content;
}
代码块组件需要支持复制功能和语言标识:
function CodeBlock({ language, code }: { language: string; code: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group my-3 rounded-lg overflow-hidden">
<div className="flex items-center justify-between bg-gray-800 px-4 py-2 text-xs text-gray-400">
<span>{language}</span>
<button
onClick={handleCopy}
className="flex items-center gap-1 hover:text-white transition-colors"
>
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
</div>
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{ margin: 0, borderRadius: '0 0 8px 8px', fontSize: '14px' }}
showLineNumbers={code.split('\n').length > 5}
>
{code}
</SyntaxHighlighter>
</div>
);
}
5. 多模态输入组件(图片/文件/语音)
现代AI应用需要支持多种输入方式。一个多模态输入组件应当统一管理文本、图片、文件和语音输入:
function MultiModalInput({ onSend }: { onSend: (data: InputData) => void }) {
const [text, setText] = useState('');
const [attachments, setAttachments] = useState<File[]>([]);
const [isRecording, setIsRecording] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 自动调整文本框高度
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}, []);
// 拖拽上传
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
setAttachments(prev => [...prev, ...files]);
}, []);
// 粘贴图片
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) setAttachments(prev => [...prev, file]);
}
}
}, []);
// 发送消息
const handleSend = () => {
if (!text.trim() && attachments.length === 0) return;
onSend({ text: text.trim(), attachments });
setText('');
setAttachments([]);
textareaRef.current?.focus();
};
// Ctrl+Enter / Enter 发送
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
{/* 附件预览区 */}
{attachments.length > 0 && (
<div className="flex gap-2 mb-3 overflow-x-auto pb-2">
{attachments.map((file, i) => (
<AttachmentChip
key={i}
file={file}
onRemove={() => setAttachments(prev => prev.filter((_, j) => j !== i))}
/>
))}
</div>
)}
<div className="flex items-end gap-2">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={text}
onChange={e => { setText(e.target.value); adjustHeight(); }}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={e => e.preventDefault()}
placeholder="输入消息... (Shift+Enter 换行)"
rows={1}
className="w-full resize-none rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-4 py-3 pr-24 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-white"
/>
{/* 输入区工具栏 */}
<div className="absolute right-2 bottom-2 flex items-center gap-1">
<ToolButton icon={<ImageIcon />} onClick={() => fileInputRef.current?.click()} tooltip="上传图片" />
<ToolButton icon={<MicIcon />} onClick={() => setIsRecording(!isRecording)} tooltip="语音输入" active={isRecording} />
<ToolButton icon={<PaperclipIcon />} onClick={() => fileInputRef.current?.click()} tooltip="上传文件" />
</div>
</div>
<button
onClick={handleSend}
disabled={!text.trim() && attachments.length === 0}
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<SendIcon className="w-5 h-5" />
</button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt,.csv,.json"
className="hidden"
onChange={e => {
const files = Array.from(e.target.files || []);
setAttachments(prev => [...prev, ...files]);
e.target.value = '';
}}
/>
</div>
);
}
语音输入需要调用浏览器的MediaRecorder API:
function useVoiceInput() {
const [isRecording, setIsRecording] = useState(false);
const [transcript, setTranscript] = useState('');
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, {
mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm',
});
chunksRef.current = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mediaRecorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
// 发送到后端进行语音识别
const result = await transcribeAudio(blob);
setTranscript(result);
stream.getTracks().forEach(t => t.stop());
};
mediaRecorder.start(100); // 每100ms收集一次数据
mediaRecorderRef.current = mediaRecorder;
setIsRecording(true);
} catch (err) {
console.error('无法访问麦克风:', err);
}
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setIsRecording(false);
};
return { isRecording, transcript, startRecording, stopRecording };
}
async function transcribeAudio(blob: Blob): Promise<string> {
const formData = new FormData();
formData.append('audio', blob, 'recording.webm');
const res = await fetch('/api/transcribe', { method: 'POST', body: formData });
const data = await res.json();
return data.text;
}
6. 对话历史管理与搜索
会话管理是AI应用的基础功能。需要支持会话列表、创建、删除、重命名、以及全文搜索:
// 会话状态管理(Zustand示例)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Conversation {
id: string;
title: string;
messages: Message[];
createdAt: number;
updatedAt: number;
model: string;
systemPrompt?: string;
}
interface ChatStore {
conversations: Conversation[];
activeId: string | null;
searchQuery: string;
// 操作
createConversation: () => string;
deleteConversation: (id: string) => void;
renameConversation: (id: string, title: string) => void;
setActiveId: (id: string) => void;
addMessage: (conversationId: string, message: Message) => void;
updateMessage: (conversationId: string, messageId: string, content: string) => void;
setSearchQuery: (query: string) => void;
// 计算
activeConversation: () => Conversation | undefined;
filteredConversations: () => Conversation[];
}
const useChatStore = create<ChatStore>()(
persist(
(set, get) => ({
conversations: [],
activeId: null,
searchQuery: '',
createConversation: () => {
const id = crypto.randomUUID();
const conv: Conversation = {
id,
title: '新对话',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
model: 'gpt-4',
};
set(state => ({
conversations: [conv, ...state.conversations],
activeId: id,
}));
return id;
},
deleteConversation: (id) => {
set(state => ({
conversations: state.conversations.filter(c => c.id !== id),
activeId: state.activeId === id ? state.conversations[0]?.id || null : state.activeId,
}));
},
addMessage: (conversationId, message) => {
set(state => ({
conversations: state.conversations.map(c => {
if (c.id !== conversationId) return c;
const messages = [...c.messages, message];
// 自动用第一条用户消息作为标题
const title = c.title === '新对话' && message.role === 'user'
? message.content.slice(0, 30) + (message.content.length > 30 ? '...' : '')
: c.title;
return { ...c, messages, title, updatedAt: Date.now() };
}),
}));
},
activeConversation: () => {
const { conversations, activeId } = get();
return conversations.find(c => c.id === activeId);
},
filteredConversations: () => {
const { conversations, searchQuery } = get();
if (!searchQuery.trim()) return conversations;
const q = searchQuery.toLowerCase();
return conversations.filter(c =>
c.title.toLowerCase().includes(q) ||
c.messages.some(m => m.content.toLowerCase().includes(q))
);
},
}),
{
name: 'ai-chat-storage',
partialize: (state) => ({
conversations: state.conversations.slice(0, 100), // 最多持久化100个会话
activeId: state.activeId,
}),
}
)
);
侧边栏会话列表组件:
function ConversationSidebar() {
const {
activeId, searchQuery, setSearchQuery,
createConversation, deleteConversation, setActiveId,
filteredConversations,
} = useChatStore();
const conversations = filteredConversations();
return (
<aside className="w-72 h-screen flex flex-col border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
{/* 头部 */}
<div className="p-3">
<button
onClick={createConversation}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm"
>
<PlusIcon className="w-4 h-4" />
新对话
</button>
</div>
{/* 搜索框 */}
<div className="px-3 mb-2">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="搜索对话..."
className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
{/* 会话列表 */}
<div className="flex-1 overflow-y-auto px-2">
{conversations.map(conv => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeId}
onClick={() => setActiveId(conv.id)}
onDelete={() => deleteConversation(conv.id)}
/>
))}
</div>
</aside>
);
}
7. AI思考过程可视化
许多大模型支持展示思考过程(如Claude的extended thinking、OpenAI的reasoning tokens)。将思考过程可视化,能增加用户对AI的信任感:
function ThinkingBlock({ content }: { content: string }) {
const [expanded, setExpanded] = useState(false);
const [isThinking, setIsThinking] = useState(true);
// 如果content还在变化,说明AI仍在思考
useEffect(() => {
const timer = setTimeout(() => setIsThinking(false), 500);
return () => clearTimeout(timer);
}, [content]);
return (
<div className="mb-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
>
{isThinking ? (
<Spinner className="w-3.5 h-3.5 animate-spin" />
) : (
<ChevronIcon className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
)}
<span>{isThinking ? '正在思考...' : `思考过程 (${estimateReadTime(content)})`}</span>
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-gray-600 dark:text-gray-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
{content}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
工具调用(Function Calling)的可视化:
function ToolCallBlock({ toolCall }: { toolCall: ToolCall }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="my-2 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-sm hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
>
<WrenchIcon className="w-4 h-4 text-blue-500" />
<span className="font-medium">{toolCall.name}</span>
{toolCall.status === 'running' && <Spinner className="w-3.5 h-3.5 animate-spin ml-auto" />}
{toolCall.status === 'done' && <CheckIcon className="w-3.5 h-3.5 text-green-500 ml-auto" />}
{toolCall.status === 'error' && <XIcon className="w-3.5 h-3.5 text-red-500 ml-auto" />}
</button>
{expanded && (
<div className="p-3 text-sm">
<div className="mb-2">
<span className="text-xs text-gray-500 uppercase tracking-wide">参数</span>
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto">
{JSON.stringify(toolCall.arguments, null, 2)}
</pre>
</div>
{toolCall.result && (
<div>
<span className="text-xs text-gray-500 uppercase tracking-wide">结果</span>
<pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto max-h-40">
{typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}
8. 错误处理与加载状态设计
AI应用的错误类型多样,需要分层处理:
// 错误分类与处理策略
type ErrorType = 'network' | 'rate_limit' | 'context_length' | 'content_filter' | 'server' | 'unknown';
function classifyError(error: Error): ErrorType {
const message = error.message.toLowerCase();
if (message.includes('network') || message.includes('fetch')) return 'network';
if (message.includes('rate') || message.includes('429')) return 'rate_limit';
if (message.includes('context') || message.includes('token')) return 'context_length';
if (message.includes('content') || message.includes('filter')) return 'content_filter';
if (message.includes('500') || message.includes('502') || message.includes('503')) return 'server';
return 'unknown';
}
function getErrorInfo(type: ErrorType) {
const errorMap: Record<ErrorType, { title: string; message: string; action: string; retryable: boolean }> = {
network: {
title: '网络连接失败',
message: '请检查网络连接后重试',
action: '重试',
retryable: true,
},
rate_limit: {
title: '请求过于频繁',
message: '已达到速率限制,请稍后再试',
action: '等待后重试',
retryable: true,
},
context_length: {
title: '上下文过长',
message: '对话历史超出模型限制,建议开启新对话或精简内容',
action: '新建对话',
retryable: false,
},
content_filter: {
title: '内容被过滤',
message: '输入或输出内容触发了安全过滤器',
action: '修改后重试',
retryable: false,
},
server: {
title: '服务暂时不可用',
message: '服务器繁忙,请稍后重试',
action: '重试',
retryable: true,
},
unknown: {
title: '发生未知错误',
message: '请重试或联系支持团队',
action: '重试',
retryable: true,
},
};
return errorMap[type];
}
function ErrorBanner({ error, onRetry, onNewChat }: {
error: Error;
onRetry: () => void;
onNewChat: () => void;
}) {
const errorType = classifyError(error);
const info = getErrorInfo(errorType);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mx-auto max-w-2xl p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
>
<div className="flex items-start gap-3">
<AlertIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-medium text-red-800 dark:text-red-200">{info.title}</h3>
<p className="text-sm text-red-600 dark:text-red-300 mt-1">{info.message}</p>
<div className="mt-3 flex gap-2">
{info.retryable && (
<button onClick={onRetry} className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
{info.action}
</button>
)}
<button onClick={onNewChat} className="px-3 py-1.5 text-sm border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors">
新对话
</button>
</div>
</div>
</div>
</motion.div>
);
}
加载骨架屏:
function MessageSkeleton() {
return (
<div className="flex gap-3 p-4 animate-pulse">
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex-1 space-y-3">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
</div>
</div>
);
}
9. 响应式与移动端适配
AI应用在移动端的体验需要特别关注。输入框需要处理虚拟键盘弹出、侧边栏需要改为抽屉式:
function ResponsiveLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const isMobile = useMediaQuery('(max-width: 768px)');
return (
<div className="flex h-screen overflow-hidden">
{/* 桌面端:固定侧边栏 */}
{!isMobile && <ConversationSidebar />}
{/* 移动端:抽屉式侧边栏 */}
{isMobile && (
<AnimatePresence>
{sidebarOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={() => setSidebarOpen(false)}
/>
<motion.div
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 bottom-0 z-50 w-72"
>
<ConversationSidebar onClose={() => setSidebarOpen(false)} />
</motion.div>
</>
)}
</AnimatePresence>
)}
<main className="flex-1 flex flex-col min-w-0">
{/* 移动端顶栏 */}
{isMobile && (
<header className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<button onClick={() => setSidebarOpen(true)} className="p-1">
<MenuIcon className="w-5 h-5" />
</button>
<h1 className="font-medium truncate">AI 助手</h1>
</header>
)}
{children}
</main>
</div>
);
}
// 移动端输入框适配
function MobileInputAdapt() {
// iOS虚拟键盘弹出时,fixed定位的输入框需要特殊处理
return (
<div className="sticky bottom-0 bg-white dark:bg-gray-900 pb-[env(safe-area-inset-bottom)]">
<MultiModalInput onSend={handleSend} />
</div>
);
}
10. 实战案例:用Next.js构建AI聊天应用
将以上所有模块整合成一个完整的Next.js应用:
// app/page.tsx - 主页面
import { ChatLayout } from '@/components/ChatLayout';
import { ConversationSidebar } from '@/components/ConversationSidebar';
import { ChatArea } from '@/components/ChatArea';
export default function Home() {
return (
<ChatLayout>
<ConversationSidebar />
<ChatArea />
</ChatLayout>
);
}
// app/api/chat/route.ts - API路由(流式响应)
import { openai } from '@/lib/openai';
export async function POST(req: Request) {
const { messages, model, systemPrompt } = await req.json();
const stream = await openai.chat.completions.create({
model: model || 'gpt-4',
messages: [
{ role: 'system', content: systemPrompt || 'You are a helpful assistant.' },
...messages,
],
stream: true,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const data = `data: ${JSON.stringify(chunk)}\n\n`;
controller.enqueue(encoder.encode(data));
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
// components/ChatArea.tsx - 核心对话区域
'use client';
import { useChatStore } from '@/store/chat';
import { MessageBubble } from './MessageBubble';
import { MultiModalInput } from './MultiModalInput';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { useStreamChat } from '@/hooks/useStreamChat';
export function ChatArea() {
const conversation = useChatStore(s => s.activeConversation());
const addMessage = useChatStore(s => s.addMessage);
const updateMessage = useChatStore(s => s.updateMessage);
const { content, isStreaming, startStream, stopStream } = useStreamChat();
const { scrollRef, handleScroll, isAtBottom } = useAutoScroll([content]);
const handleSend = async (data: InputData) => {
const userMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: data.text,
timestamp: Date.now(),
status: 'complete',
attachments: data.attachments,
};
const assistantMsg: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: Date.now(),
status: 'streaming',
};
addMessage(conversation!.id, userMsg);
addMessage(conversation!.id, assistantMsg);
try {
await startStream([...conversation!.messages, userMsg]);
updateMessage(conversation!.id, assistantMsg.id, content);
} catch (err) {
updateMessage(conversation!.id, assistantMsg.id, '');
// 错误处理...
}
};
return (
<div className="flex flex-col h-full">
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-6 max-w-3xl mx-auto w-full"
>
{conversation?.messages.map(msg => (
<MessageBubble key={msg.id} message={msg} />
))}
{isStreaming && <MessageBubble message={{ ...currentMessage, content }} />}
</div>
<MultiModalInput onSend={handleSend} />
</div>
);
}
11. 性能优化与用户体验
消息列表虚拟化
当对话历史很长时(数百条消息),需要使用虚拟列表避免DOM节点过多:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedMessageList({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120, // 预估每条消息高度
overscan: 5,
});
return (
<div ref={parentRef} className="flex-1 overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<MessageBubble message={messages[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
防抖与节流
// 搜索防抖
const debouncedSearch = useDebouncedCallback((query: string) => {
setSearchQuery(query);
}, 300);
// 流式内容更新节流
function useThrottledValue<T>(value: T, interval: number): T {
const [throttled, setThrottled] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setThrottled(value), interval);
return () => clearTimeout(timer);
}, [value, interval]);
return throttled;
}
按需加载与代码分割
// 动态导入重量级组件
const MarkdownRenderer = dynamic(() => import('./MarkdownRenderer'), {
loading: () => <div className="animate-pulse h-20 bg-gray-100 dark:bg-gray-800 rounded" />,
ssr: false,
});
const SyntaxHighlighter = dynamic(() => import('react-syntax-highlighter'), {
ssr: false,
});
离线支持与本地缓存
// 使用IndexedDB缓存大量对话历史
import { openDB } from 'idb';
const dbPromise = openDB('ai-chat', 1, {
upgrade(db) {
const store = db.createObjectStore('conversations', { keyPath: 'id' });
store.createIndex('updatedAt', 'updatedAt');
},
});
async function saveConversationToDB(conv: Conversation) {
const db = await dbPromise;
await db.put('conversations', conv);
}
async function loadConversationsFromDB(): Promise<Conversation[]> {
const db = await dbPromise;
return db.getAllFromIndex('conversations', 'updatedAt');
}
通过以上各模块的组合,可以构建出一个功能完整、体验流畅的AI对话应用前端。核心要点是:流式优先、渐进式披露、优雅降级、以及对移动端的充分适配。在实际项目中,还需要根据具体的模型API和业务需求进行调整,但整体架构和设计模式是通用的。