AI 聊天界面设计
问题
如何设计一个生产级的 AI 聊天界面?消息数据模型、自动滚动、代码块渲染有哪些注意点?
答案
一、消息数据模型
interface Message {
id: string;
role: "user" | "assistant" | "system";
content: string;
// 元信息
createdAt: Date;
model?: string; // 使用的模型
// 流式状态
status?: "streaming" | "complete" | "error";
// 工具调用
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
// 多模态
attachments?: Attachment[]; // 图片、文件
// Thinking(推理过程展示)
thinking?: string;
}
interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
interface Attachment {
type: "image" | "file";
url: string;
name: string;
mimeType: string;
}
二、消息列表与自动滚动
function MessageList({ messages }: { messages: Message[] }) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// 检测用户是否手动上滚
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setAutoScroll(isAtBottom);
};
// 只在自动滚动开启时滚动到底部
useEffect(() => {
if (autoScroll) {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, autoScroll]);
return (
<div ref={containerRef} onScroll={handleScroll} className="overflow-y-auto">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</div>
);
}
自动滚动的关键
- 用户正在阅读历史消息(手动上滚)时不要自动滚动
- AI 正在流式输出且用户在底部时自动滚动
- 检测方法:
scrollHeight - scrollTop - clientHeight < threshold
三、代码块渲染
import { memo } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
// 代码块组件(带复制按钮)
const CodeBlock = memo(({ 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">
{/* 语言标签 */}
<div className="flex justify-between bg-zinc-800 px-4 py-2 text-xs text-zinc-400">
<span>{language}</span>
<button onClick={handleCopy}>
{copied ? "已复制 ✓" : "复制"}
</button>
</div>
{/* 代码高亮 */}
<SyntaxHighlighter language={language} style={oneDark}>
{code}
</SyntaxHighlighter>
</div>
);
});
四、输入框设计
function ChatInput({ onSend, isLoading }: 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 handleSubmit = () => {
if (!input.trim() || isLoading) return;
onSend(input.trim());
setInput("");
};
// Enter 发送,Shift+Enter 换行
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="flex items-end gap-2 border rounded-lg p-2">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息..."
rows={1}
className="flex-1 resize-none"
/>
<button onClick={handleSubmit} disabled={isLoading || !input.trim()}>
发送
</button>
</div>
);
}
五、Thinking 展示
展示 AI 的推理过程(如 Claude 的 Extended Thinking):
function ThinkingBlock({ thinking }: { thinking: string }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border-l-2 border-purple-500 pl-3 my-2">
<button onClick={() => setExpanded(!expanded)} className="text-sm text-purple-600">
{expanded ? "▼" : "▶"} 思考过程
</button>
{expanded && (
<div className="text-sm text-gray-500 mt-1 whitespace-pre-wrap">
{thinking}
</div>
)}
</div>
);
}
常见面试问题
Q1: AI 聊天界面的自动滚动有哪些坑?
答案:
- 用户手动上滚查看历史时不应自动滚动(通过检测滚动位置判断)
- 流式输出时每次内容更新都触发滚动,需要用
requestAnimationFrame节流 - 图片等异步加载完成后容器高度变化,需要重新计算是否在底部
- 使用
scroll-behavior: smooth可能导致高频更新时滚动卡顿
Q2: 如何处理流式渲染中的 Markdown 闪烁?
答案:
- 问题:每次追加文本后 re-render,react-markdown 重新解析导致闪烁
- 方案 1:使用
React.memo+ key 策略避免不必要的重渲染 - 方案 2:将完整消息和流式消息分开渲染组件
- 方案 3:使用增量 Markdown 解析器(如
marked的 streaming 模式)