当前位置: 首页 > news >正文

设计智能体对话界面:消息气泡、打字指示器与时间戳

今天这篇文章,我就把消息气泡、打字指示器、时间戳以及围绕它们的所有细节,一次性讲透。包含完整的React + TypeScript + TailwindCSS代码,你可以直接复制粘贴到项目里。

一、对话界面的基础构成

一个智能体对话界面,表面上看就是左边AI气泡、右边用户气泡、底部输入框。但实际需要包含这些核心部分:

  1. 消息气泡:区分用户和AI、显示头像、支持富文本(Markdown)、展示消息状态(发送中、已发送、失败)
  2. 打字指示器:AI思考时显示的动态效果(三个点跳动),配合流式输出
  3. 时间戳:告知用户每条消息的发送时间(或相对时间),辅助理解上下文

三者配合流式输出、滚动管理、键盘交互,才能形成完整的用户体验。

下面这张草图展示了典型布局:

二、消息气泡:角色、状态与样式

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 移动端触摸体验

  • 打字指示器的动画在低端手机上可能掉帧,可以改为 CSSopacity渐变而非transform
  • 滚动容器需要设置-webkit-overflow-scrolling: touch实现惯性滚动。
  • 输入框在移动端应自动聚焦,但注意不要遮挡键盘。

7.3 虚拟滚动

如果消息数量超过200条,建议使用react-window@tanstack/react-virtual实现虚拟滚动,仅渲染可视区域内的消息,大幅提升性能。

八、总结

智能体对话界面看起来简单,细节却非常琐碎。从消息气泡的角色区分、状态显示,到打字指示器的动画时机,再到时间戳的格式与合并,每一步都会影响用户体验。本文给出的代码示例涵盖了生产级所需的大部分功能,你只需根据自己的设计稿调整样式即可。

    http://www.cnnetsun.cn/news/2483889.html

    相关文章:

  • HFSS仿真微带线损耗翻车?可能是这3个细节没做好(附PCB导入避坑指南)
  • NY378固态MT29F32T08GSLBHL8-24QA:B
  • JavaSE-14
  • 硬核实战:调用Gemini多模态管道,直击办公中的图表解析、发票识别与自动化脚本生成(国内镜像免费方案)
  • LabVIEW实战:生产者-消费者与状态机模式在测控系统中的应用
  • 2026硕士论文AIGC检测多少算合格?各校红线汇总,附降AI攻略
  • 从VIO到全局定位:深入剖析Maplab框架中的ROVIOLI前端工作原理与调优
  • Hermes Agent Memory 记忆系统详解:为什么它能“越用越懂你”?
  • 智慧铁路要素数据集 铁路场景多传感器数据序列 轨道分割数据集 轨道点云数据集 铁路红外人员与铁路设施与环境元素识别数据集第10130期
  • MetaTube插件JAV影片元数据刮削失败的终极解决方案
  • 免费降AI率工具靠谱吗?2026本科论文知网AI率从37%降到8%
  • 如何彻底解决Cursor AI试用限制:开源技术方案深度解析
  • 用MC1496芯片手把手搭建DSB调制电路:从原理图到实测波形(附Multisim仿真文件)
  • 12.5 通配符的使用
  • 从卡尔曼滤波到Mamba:状态空间模型(SSM)的‘前世今生’与技术演进图谱
  • CAXA 孔/轴
  • 安全开发自查清单:从Pikachu靶场的CSRF漏洞,反推你的Web应用该怎么防
  • AI科技热点日报 | AI Tech Daily | 2026年5月20日 May 20, 2026
  • 企业级Agent落地,你绕不开的 4 个工程问题
  • Java 程序员第 22 阶段:Function Call 工具调用实战,Java 封装大模型外部能力
  • 投稿前利用GPT-5.5给论文做一次深度校对,投稿命中率翻倍!
  • Windows 10/11 下保姆级教程:用 Python 3.10 和 Fast DDS 2.10.0 跑通你的第一个 DDS 通信
  • 不只是安装器:深度体验GDebi,看它如何优雅管理Ubuntu下的DEB包依赖
  • 收藏必备!VSCode 超详细入门教程 从安装到精通
  • 从AngularJS到jQuery:盘点那些年我们绕过的前端框架XSS(含实战Payload)
  • 微信消息撤回已成往事:3分钟解锁永久防撤回功能
  • 【Ansible 入门实战】三种变量详解
  • 告别“氛围编程”混乱!Kiro、Spec Kit等工具助力规范驱动开发
  • 谷歌 I/O 大会宣布:Android Studio 集成 AI agent,Android CLI 1.0 助力应用开发加速
  • 面向企业安全运营的网络钓鱼暴露面收敛技术与实践研究