设计智能体对话界面:消息气泡、打字指示器与时间戳
今天这篇文章,我就把消息气泡、打字指示器、时间戳以及围绕它们的所有细节,一次性讲透。包含完整的React + TypeScript + TailwindCSS代码,你可以直接复制粘贴到项目里。
一、对话界面的基础构成
一个智能体对话界面,表面上看就是左边AI气泡、右边用户气泡、底部输入框。但实际需要包含这些核心部分:
- 消息气泡:区分用户和AI、显示头像、支持富文本(Markdown)、展示消息状态(发送中、已发送、失败)
- 打字指示器:AI思考时显示的动态效果(三个点跳动),配合流式输出
- 时间戳:告知用户每条消息的发送时间(或相对时间),辅助理解上下文
三者配合流式输出、滚动管理、键盘交互,才能形成完整的用户体验。
下面这张草图展示了典型布局:
二、消息气泡:角色、状态与样式
2.1 角色区分的基本样式
最常见的模式:用户消息靠右,背景色深(如蓝色);AI消息靠左,背景色浅(如灰色)。用TailwindCSS的条件类名实现非常干净:
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}> <div className={` max-w-[70%] rounded-2xl px-4 py-2 ${isUser ? 'bg-blue-500 text-white rounded-br-none' : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none' } `}> {content} </div> </div>关键点:去掉右下圆角(rounded-br-none)或左下圆角(rounded-bl-none),制造出气泡尾巴的视觉感,就像真实聊天气泡那样指向发言者。
2.2 头像与元信息
头像可以增强辨识度,也会占用空间。我建议在桌面端显示头像,移动端可省略或用小图标代替。
<div className="flex items-start gap-2"> {!isUser && <Avatar src={aiAvatar} className="w-6 h-6" />} <div className="flex-1"> <div className="text-xs text-gray-500 mb-1">{isUser ? '我' : 'AI助手'}</div> <div className="...气泡样式...">{content}</div> </div> {isUser && <Avatar src={userAvatar} className="w-6 h-6" />} </div>2.3 消息状态(发送中、成功、失败)
用户消息在发送后需要立即显示在界面上,同时显示一个“发送中”的指示器。成功后变为对勾,失败则显示重试按钮。
type MessageStatus = 'pending' | 'sent' | 'error'; // 在气泡右下角添加状态图标 {status === 'pending' && <Loader2 className="w-3 h-3 ml-1 animate-spin" />} {status === 'sent' && <Check className="w-3 h-3 ml-1 text-green-500" />} {status === 'error' && <AlertCircle className="w-3 h-3 ml-1 text-red-500 cursor-pointer" onClick={retry} />}AI消息不需要发送状态,但流式输出过程中,可以显示“正在输入”效果(见第三节)。
2.4 富文本与代码块
AI的回答常常包含列表、代码块、表格等。直接显示纯文本会丢失格式。推荐使用react-markdown+remark-gfm+rehype-highlight。
npminstallreact-markdown remark-gfm rehype-highlight然后:
import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeHighlight from 'rehype-highlight'; import 'highlight.js/styles/github-dark.css'; <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}> {content} </ReactMarkdown>注意:流式输出时,Markdown 语法可能不完整(例如只收到了## 标,还没收到题),react-markdown依然会尽力渲染,但偶尔会出现闪烁。解决办法是等到收到一个完整的句子(以句号、换行等结尾)时才触发渲染,或者使用useDeferredValue降低渲染频率。
2.5 长文本处理
用户消息可能粘贴大段文字。需要用whitespace-pre-wrap保留换行和空格,并设置break-words防止溢出:
<div className="whitespace-pre-wrap break-words"> {content} </div>三、打字指示器:给用户“它在思考”的安全感
3.1 基础样式:三个点跳动
最经典的效果是一个气泡里面三个点依次上下跳动。用TailwindCSS动画实现:
const TypingIndicator = () => { return ( <div className="flex justify-start mb-4"> <div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-none px-4 py-3"> <div className="flex space-x-1"> <span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> <span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> <span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> </div> </div> </div> ); };在tailwind.config.js中添加动画定义:
extend:{animation:{bounce:'bounce 1s infinite',},keyframes:{bounce:{'0%, 60%, 100%':{transform:'translateY(0)'},'30%':{transform:'translateY(-10px)'},},},}3.2 与流式输出的配合
打字指示器应该在用户发送消息后、收到第一个流式chunk之前显示。一旦收到第一个chunk,指示器消失,开始逐字显示AI回答。
const [isAiTyping, setIsAiTyping] = useState(false); const sendMessage = async (text) => { setMessages(prev => [...prev, userMessage]); setIsAiTyping(true); await streamAIResponse(text, (chunk) => { if (isAiTyping) setIsAiTyping(false); // 追加chunk到消息内容... }); };3.3 延迟消失与超时保护
有时AI生成第一个token很慢(网络延迟或模型推理慢),用户可能以为卡死了。设定一个超时(比如10秒),若未收到任何chunk,则显示“生成超时,请重试”的提示,并隐藏打字指示器。
useEffect(() => { if (isAiTyping) { const timer = setTimeout(() => { setIsAiTyping(false); setError('响应超时,请稍后重试'); }, 10000); return () => clearTimeout(timer); } }, [isAiTyping]);四、时间戳与分组策略
4.1 显示绝对时间还是相对时间?
- 绝对时间:
2026-05-20 14:32:09,精确但占空间,适合正式场景或需要导出记录。 - 相对时间:
刚刚、5分钟前、昨天 14:32,更符合移动端习惯。
折中方案:默认显示相对时间,鼠标悬停(桌面)或长按(移动)时显示完整时间戳。实现可使用date-fns:
import{formatDistanceToNowStrict,format}from'date-fns';import{zhCN}from'date-fns/locale';constformatMessageTime=(timestamp:number)=>{constnow=Date.now();constdiff=now-timestamp;if(diff<60*1000)return'刚刚';if(diff<24*3600*1000){returnformatDistanceToNowStrict(timestamp,{locale:zhCN,addSuffix:true});}returnformat(timestamp,'MM-dd HH:mm');};4.2 时间戳放在哪里?
常见位置:气泡下方,右对齐(用户消息)或左对齐(AI消息)。使用小字号、灰色字体。
<div className={`text-xs ${isUser ? 'text-right' : 'text-left'} mt-1 opacity-50`}> {formatMessageTime(timestamp)} </div>4.3 合并连续消息
如果同一用户在短时间内连续发送多条消息,通常会将头像和时间戳隐藏,只显示气泡堆叠,避免冗余。判断逻辑:前一条消息与当前消息同一角色,且时间差小于2分钟。
const shouldShowAvatar = (index: number, messages: Message[]) => { if (index === 0) return true; const prev = messages[index - 1]; const curr = messages[index]; if (prev.role !== curr.role) return true; if (curr.timestamp - prev.timestamp > 120000) return true; // 2分钟 return false; };对于隐藏头像的情况,渲染时条件性渲染头像区域,并将气泡圆角调整为对称圆角(避免尾巴),时间戳只显示在第一条消息上。
五、滚动行为与自动定位
5.1 新消息自动滚动到底部
使用scrollIntoView配合useRef。
const messagesEndRef = useRef<HTMLDivElement>(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, isAiTyping]); // 依赖消息列表和打字指示器状态5.2 用户滚动时禁用自动滚动
如果用户主动向上滚动查看历史,应该暂时禁用自动滚动,直到用户再次滚动到底部或点击“回到底部”按钮。
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; setIsUserScrolledUp(!isAtBottom); }; // 在滚动容器上监听 <div className="overflow-y-auto" onScroll={handleScroll}> {messages.map(...)} </div> // 自动滚动时检查 useEffect(() => { if (!isUserScrolledUp) { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } }, [messages]);5.3 “新消息”提示按钮
当用户上滚且新消息到达时,显示一个悬浮按钮,点击后滚动到底部。
{isUserScrolledUp && unreadCount > 0 && ( <button onClick={() => { setIsUserScrolledUp(false); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }} className="absolute bottom-20 right-4 bg-blue-500 text-white rounded-full p-2 shadow-lg" > <ChevronDown className="w-4 h-4" /> <span className="ml-1 text-xs">{unreadCount}条新消息</span> </button> )}六、完整代码示例
下面是一个完整的ChatInterface.tsx组件,整合了上述所有功能。它依赖useStreamingChatHook(可参考本专栏流式输出文章),以及shadcn/ui的 Avatar 组件(可选)。
// components/ChatInterface.tsx import { useState, useRef, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { formatDistanceToNowStrict, format } from 'date-fns'; import { zhCN } from 'date-fns/locale'; import { Loader2, Check, AlertCircle, ChevronDown } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { useStreamingChat } from '@/hooks/useStreamingChat'; interface Message { id: string; role: 'user' | 'assistant'; content: string; timestamp: number; status?: 'pending' | 'sent' | 'error'; } export function ChatInterface() { const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(''); const [isUserScrolledUp, setIsUserScrolledUp] = useState(false); const { sendMessage, isStreaming, currentAnswer } = useStreamingChat(); const messagesEndRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null); const [showTyping, setShowTyping] = useState(false); // 处理流式输出:将 currentAnswer 实时更新到最后一条 AI 消息 useEffect(() => { if (isStreaming) { setShowTyping(true); setMessages(prev => { const last = prev[prev.length - 1]; if (last?.role === 'assistant' && last.status !== 'pending') { // 更新已存在的 AI 消息 return prev.map((msg, idx) => idx === prev.length - 1 ? { ...msg, content: currentAnswer } : msg ); } else { // 创建新的 AI 消息 return [...prev, { id: Date.now().toString(), role: 'assistant', content: currentAnswer, timestamp: Date.now(), }]; } }); } else { setShowTyping(false); } }, [currentAnswer, isStreaming]); const handleSend = async () => { if (!input.trim() || isStreaming) return; const userMessage: Message = { id: Date.now().toString(), role: 'user', content: input, timestamp: Date.now(), status: 'pending', }; setMessages(prev => [...prev, userMessage]); setInput(''); try { await sendMessage(input); setMessages(prev => prev.map(msg => msg.id === userMessage.id ? { ...msg, status: 'sent' } : msg ) ); } catch (error) { setMessages(prev => prev.map(msg => msg.id === userMessage.id ? { ...msg, status: 'error' } : msg ) ); } }; const formatMessageTime = (timestamp: number) => { const now = Date.now(); const diff = now - timestamp; if (diff < 60 * 1000) return '刚刚'; if (diff < 24 * 3600 * 1000) { return formatDistanceToNowStrict(timestamp, { locale: zhCN, addSuffix: true }); } return format(timestamp, 'MM-dd HH:mm'); }; const shouldShowAvatar = (index: number) => { if (index === 0) return true; const prev = messages[index - 1]; const curr = messages[index]; if (prev.role !== curr.role) return true; if (curr.timestamp - prev.timestamp > 120000) return true; return false; }; const handleScroll = () => { if (!scrollContainerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const atBottom = scrollHeight - scrollTop - clientHeight < 50; setIsUserScrolledUp(!atBottom); }; // 自动滚动(仅在用户未上滚时) useEffect(() => { if (!isUserScrolledUp && messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages, isUserScrolledUp, showTyping]); return ( <div className="flex flex-col h-screen bg-white dark:bg-gray-900"> {/* 消息列表区域 */} <div ref={scrollContainerRef} onScroll={handleScroll} className="flex-1 overflow-y-auto p-4 space-y-4" > {messages.map((msg, idx) => { const isUser = msg.role === 'user'; const showAvatar = shouldShowAvatar(idx); return ( <div key={msg.id} className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}> {!isUser && showAvatar && ( <Avatar className="w-8 h-8 mr-2 mt-1"> <AvatarImage src="/ai-avatar.png" /> <AvatarFallback>AI</AvatarFallback> </Avatar> )} <div className={`max-w-[70%] ${!isUser && !showAvatar ? 'ml-10' : ''}`}> {showAvatar && ( <div className="text-xs text-gray-500 mb-1 ml-1"> {isUser ? '我' : 'AI助手'} </div> )} <div className={` rounded-2xl px-4 py-2 ${isUser ? 'bg-blue-500 text-white rounded-br-none' : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none' } `}> {isUser ? ( <div className="whitespace-pre-wrap break-words">{msg.content}</div> ) : ( <ReactMarkdown remarkPlugins={[remarkGfm]}> {msg.content} </ReactMarkdown> )} </div> <div className={`text-xs opacity-50 mt-1 ${isUser ? 'text-right' : 'text-left'}`}> {formatMessageTime(msg.timestamp)} {isUser && msg.status === 'pending' && ( <Loader2 className="inline w-3 h-3 ml-1 animate-spin" /> )} {isUser && msg.status === 'sent' && ( <Check className="inline w-3 h-3 ml-1 text-green-500" /> )} {isUser && msg.status === 'error' && ( <AlertCircle className="inline w-3 h-3 ml-1 text-red-500 cursor-pointer" /> )} </div> </div> {isUser && showAvatar && ( <Avatar className="w-8 h-8 ml-2 mt-1"> <AvatarImage src="/user-avatar.png" /> <AvatarFallback>我</AvatarFallback> </Avatar> )} </div> ); })} {showTyping && !isStreaming && ( <div className="flex justify-start"> <div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-none px-4 py-3"> <div className="flex space-x-1"> <span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} /> <span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} /> <span className="w-2 h-2 bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} /> </div> </div> </div> )} <div ref={messagesEndRef} /> </div> {/* 新消息提示按钮 */} {isUserScrolledUp && messages.length > 0 && ( <button onClick={() => { setIsUserScrolledUp(false); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }} className="absolute bottom-20 right-4 bg-blue-500 text-white rounded-full p-2 shadow-lg flex items-center gap-1" > <ChevronDown className="w-4 h-4" /> <span className="text-xs">新消息</span> </button> )} {/* 输入区域 */} <div className="border-t dark:border-gray-800 p-4 bg-white dark:bg-gray-900"> <form onSubmit={(e) => { e.preventDefault(); handleSend(); }} className="flex gap-2"> <input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder="输入消息..." disabled={isStreaming} className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:border-gray-700" /> <button type="submit" disabled={isStreaming || !input.trim()} className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 disabled:opacity-50" > 发送 </button> </form> </div> </div> ); }七、性能优化与移动端适配
7.1 Markdown 渲染性能
流式输出时,频繁重新渲染整个ReactMarkdown组件可能引起卡顿。优化方法:
- 使用
useDeferredValue降低渲染优先级 - 仅在收到完整句子(遇到句号、换行等)时更新消息内容
- 分离消息列表和输入区域,减少不必要的重绘
7.2 移动端触摸体验
- 打字指示器的动画在低端手机上可能掉帧,可以改为 CSS
opacity渐变而非transform。 - 滚动容器需要设置
-webkit-overflow-scrolling: touch实现惯性滚动。 - 输入框在移动端应自动聚焦,但注意不要遮挡键盘。
7.3 虚拟滚动
如果消息数量超过200条,建议使用react-window或@tanstack/react-virtual实现虚拟滚动,仅渲染可视区域内的消息,大幅提升性能。
八、总结
智能体对话界面看起来简单,细节却非常琐碎。从消息气泡的角色区分、状态显示,到打字指示器的动画时机,再到时间戳的格式与合并,每一步都会影响用户体验。本文给出的代码示例涵盖了生产级所需的大部分功能,你只需根据自己的设计稿调整样式即可。
