AI 对话界面设计
问题
如何设计一个高性能的 AI 对话界面?包括消息列表、代码高亮、Markdown 渲染、思考过程展示、文件上传、会话管理、移动端适配、无障碍支持等。
答案
AI 对话界面是 LLM 产品的核心交互载体。与传统 IM 聊天应用不同,AI 对话需要处理流式渲染、多内容类型混排(文本/代码/图片/图表/工具调用)和复杂的中间状态(思考中/工具调用中/生成中)。产品上的差异化体验(如 ChatGPT、Claude、Gemini)往往体现在 UI 细节的打磨上。
- 流式优先:所有 UI 必须支持增量渲染,不能等完整响应后再显示
- 块级内容模型:消息内容不是单一字符串,而是
ContentBlock[]数组 - 状态驱动:消息有完整的生命周期状态机(pending -> streaming -> done/error)
- 性能敏感:历史消息必须 memo 化,流式更新用 RAF 批量合并
一、消息数据模型
消息数据模型的设计直接决定了 UI 层的灵活性。一个好的模型需要支持多模态内容、工具调用链、思考过程、错误状态等复杂场景。
// ===== 内容块类型 =====
/** 纯文本块:支持 Markdown */
interface TextBlock {
type: 'text';
content: string;
}
/** 思考过程块:Claude extended thinking / OpenAI reasoning */
interface ThinkingBlock {
type: 'thinking';
content: string;
isCollapsed: boolean;
/** 思考耗时(ms),用于展示 */
duration?: number;
}
/** 代码块:独立于 Markdown 的代码渲染 */
interface CodeBlock {
type: 'code';
language: string;
content: string;
filename?: string;
}
/** 图片块 */
interface ImageBlock {
type: 'image';
url: string;
alt?: string;
width?: number;
height?: number;
/** 图片来源:用户上传 or AI 生成 */
source?: 'user' | 'generated';
}
/** 工具调用块:Function Calling 的前端表示 */
interface ToolCallBlock {
type: 'tool_call';
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
status: 'calling' | 'done' | 'error';
result?: string;
/** 调用耗时(ms) */
duration?: number;
}
/** 错误块 */
interface ErrorBlock {
type: 'error';
message: string;
code?: string;
retryable?: boolean;
}
/** 文件附件块 */
interface FileBlock {
type: 'file';
name: string;
url: string;
size: number;
mimeType: string;
/** 上传进度 0-100,完成后为 undefined */
progress?: number;
}
/** 引用块:引用外部来源(RAG 场景) */
interface CitationBlock {
type: 'citation';
sources: Array<{
title: string;
url: string;
snippet: string;
}>;
}
/** 联合类型:所有支持的内容块 */
type ContentBlock =
| TextBlock
| ThinkingBlock
| CodeBlock
| ImageBlock
| ToolCallBlock
| ErrorBlock
| FileBlock
| CitationBlock;
// ===== 消息类型 =====
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
status: 'pending' | 'streaming' | 'done' | 'error';
createdAt: number;
updatedAt?: number;
/** 内容块列表:支持多类型混排 */
blocks: ContentBlock[];
/** 父消息 ID:用于消息编辑分支(树状对话结构) */
parentId?: string;
/** 子消息 ID 列表:编辑重发时产生多个分支 */
childrenIds?: string[];
/** 当前展示的子消息索引:用于分支切换 */
activeChildIndex?: number;
/** 元数据 */
model?: string;
usage?: {
inputTokens: number;
outputTokens: number;
/** 缓存命中的 token 数 */
cacheReadTokens?: number;
};
duration?: number;
/** 用户反馈 */
feedback?: 'positive' | 'negative' | null;
feedbackComment?: string;
}
// ===== 会话类型 =====
interface Conversation {
id: string;
title: string;
messages: Message[];
model: string;
systemPrompt?: string;
/** 温度参数 */
temperature?: number;
createdAt: number;
updatedAt: number;
/** 是否已归档 */
archived?: boolean;
/** 是否已固定 */
pinned?: boolean;
/** 标签 */
tags?: string[];
}
ChatGPT 支持"编辑消息"功能:用户可以编辑之前的某条消息重新发送,这会在该消息下创建新的分支。数据结构上通过 parentId / childrenIds / activeChildIndex 实现树状结构,UI 上通过 < > 箭头切换不同分支。
二、消息列表与智能滚动
消息列表是对话 UI 中最核心的组件,也是性能优化的重点区域。智能滚动需要处理多种场景:流式输出时自动跟随、用户查看历史时停止跟随、出现新消息时提示用户。
import { useRef, useEffect, useCallback, useState } from 'react';
import { Message } from '../types/message';
import { MessageBubble } from './MessageBubble';
interface MessageListProps {
messages: Message[];
isStreaming: boolean;
onRetry: (messageId: string) => void;
onEdit: (messageId: string, content: string) => void;
onFeedback: (messageId: string, feedback: 'positive' | 'negative') => void;
onBranchSwitch: (messageId: string, direction: 'prev' | 'next') => void;
}
export function MessageList({
messages,
isStreaming,
onRetry,
onEdit,
onFeedback,
onBranchSwitch,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const isAutoScrollRef = useRef(true);
const [showScrollButton, setShowScrollButton] = useState(false);
// 使用 RAF 节流的滚动到底部
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
if (!bottomRef.current) return;
bottomRef.current.scrollIntoView({ behavior, block: 'end' });
}, []);
// 流式输出期间的滚动跟随
useEffect(() => {
if (!isAutoScrollRef.current) return;
// 流式更新时用 instant 避免动画延迟
scrollToBottom(isStreaming ? 'instant' : 'smooth');
}, [messages, isStreaming, scrollToBottom]);
// 检测用户是否在底部附近(智能滚动核心逻辑)
const handleScroll = useCallback(() => {
if (!listRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceFromBottom < 100;
isAutoScrollRef.current = isNearBottom;
setShowScrollButton(!isNearBottom);
}, []);
// 空状态
if (messages.length === 0) {
return (
<div className="empty-state">
<h2>开始新的对话</h2>
<p>输入你的问题,AI 将为你解答</p>
<div className="suggestion-chips">
{['解释 React Fiber', '写一个 TypeScript 工具类型', '如何优化首屏性能'].map(
(suggestion) => (
<button key={suggestion} className="chip">
{suggestion}
</button>
)
)}
</div>
</div>
);
}
return (
<div className="message-list-wrapper">
<div
ref={listRef}
className="message-list"
onScroll={handleScroll}
role="log"
aria-label="对话消息"
aria-live="polite"
>
{messages.map((msg, index) => (
<MessageBubble
key={msg.id}
message={msg}
isLast={index === messages.length - 1}
isStreaming={isStreaming && index === messages.length - 1}
onRetry={onRetry}
onEdit={onEdit}
onFeedback={onFeedback}
onBranchSwitch={onBranchSwitch}
/>
))}
{/* 锚点元素,用于 scrollIntoView */}
<div ref={bottomRef} />
</div>
{/* 滚动到底部按钮 */}
{showScrollButton && (
<button
className="scroll-to-bottom-btn"
onClick={() => {
isAutoScrollRef.current = true;
scrollToBottom('smooth');
setShowScrollButton(false);
}}
aria-label="滚动到最新消息"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 12L2 6h12L8 12z" />
</svg>
{isStreaming && <span className="new-content-dot" />}
</button>
)}
</div>
);
}
- 不要在流式输出期间用
smooth滚动:smooth 动画有延迟,token 到达速度可能超过滚动速度,导致滚动跟不上。流式期间应使用instant - 不要用
scrollTop = scrollHeight:这会导致每次内容变化时闪烁。用scrollIntoView配合底部锚点元素更稳定 - 阈值不要设太小:
< 10px的判定太敏感,用户轻微触摸就会误判为"离开底部"。建议100px showScrollButton需要用useState:用 ref 存储会导致按钮不显示,因为 ref 变化不会触发重渲染
三、消息气泡与操作栏
每条消息气泡需要渲染多种内容块,并提供复制、重试、反馈等操作。
import { memo, useState, useCallback } from 'react';
import { Message, ContentBlock } from '../types/message';
import { StreamMarkdown } from './StreamMarkdown';
import { ThinkingBlock } from './ThinkingBlock';
import { ToolCallBlock } from './ToolCallBlock';
import { CodeBlockComponent } from './CodeBlock';
import { FilePreview } from './FilePreview';
import { CitationList } from './CitationList';
import { MessageActions } from './MessageActions';
interface MessageBubbleProps {
message: Message;
isLast: boolean;
isStreaming: boolean;
onRetry: (messageId: string) => void;
onEdit: (messageId: string, content: string) => void;
onFeedback: (messageId: string, feedback: 'positive' | 'negative') => void;
onBranchSwitch: (messageId: string, direction: 'prev' | 'next') => void;
}
// memo 是关键:历史消息不应随新 token 到达而重渲染
export const MessageBubble = memo(function MessageBubble({
message,
isLast,
isStreaming,
onRetry,
onEdit,
onFeedback,
onBranchSwitch,
}: MessageBubbleProps) {
const isUser = message.role === 'user';
const [isHovered, setIsHovered] = useState(false);
const [isEditing, setIsEditing] = useState(false);
// 提取纯文本(用于复制)
const getTextContent = useCallback((): string => {
return message.blocks
.filter((b): b is { type: 'text'; content: string } => b.type === 'text')
.map((b) => b.content)
.join('\n\n');
}, [message.blocks]);
return (
<div
className={`message-bubble ${isUser ? 'user' : 'assistant'}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="article"
aria-label={`${isUser ? '你' : 'AI'} 的消息`}
>
{/* 头像 */}
<div className="avatar" aria-hidden="true">
{isUser ? (
<div className="user-avatar">U</div>
) : (
<div className="ai-avatar">AI</div>
)}
</div>
{/* 内容区域 */}
<div className="message-content">
{/* 分支切换器(编辑重发场景) */}
{message.childrenIds && message.childrenIds.length > 1 && (
<div className="branch-switcher">
<button
onClick={() => onBranchSwitch(message.id, 'prev')}
disabled={message.activeChildIndex === 0}
aria-label="上一个分支"
>
<
</button>
<span>
{(message.activeChildIndex ?? 0) + 1} / {message.childrenIds.length}
</span>
<button
onClick={() => onBranchSwitch(message.id, 'next')}
disabled={
message.activeChildIndex === message.childrenIds.length - 1
}
aria-label="下一个分支"
>
>
</button>
</div>
)}
{/* 内容块渲染 */}
{message.blocks.map((block, i) => (
<ContentBlockRenderer
key={`${message.id}-block-${i}`}
block={block}
isStreaming={isStreaming && i === message.blocks.length - 1}
/>
))}
{/* 加载指示器 */}
{message.status === 'pending' && <TypingIndicator />}
{/* 错误状态 + 重试 */}
{message.status === 'error' && (
<div className="error-banner" role="alert">
<span>生成失败</span>
<button onClick={() => onRetry(message.id)}>重试</button>
</div>
)}
{/* 消息操作栏(hover 或 focus 时显示) */}
{message.status === 'done' && (isHovered || isLast) && (
<MessageActions
message={message}
isUser={isUser}
getTextContent={getTextContent}
onRetry={onRetry}
onEdit={(content) => onEdit(message.id, content)}
onFeedback={(fb) => onFeedback(message.id, fb)}
/>
)}
{/* 元信息(token 数、耗时、模型) */}
{message.status === 'done' && !isUser && message.usage && (
<div className="message-meta" aria-label="消息元信息">
{message.model && <span className="model-tag">{message.model}</span>}
<span className="token-count">
{message.usage.outputTokens} tokens
</span>
{message.duration && (
<span className="duration">
{(message.duration / 1000).toFixed(1)}s
</span>
)}
</div>
)}
</div>
</div>
);
});
// ===== 内容块分发渲染器 =====
function ContentBlockRenderer({
block,
isStreaming,
}: {
block: ContentBlock;
isStreaming: boolean;
}) {
switch (block.type) {
case 'text':
return <StreamMarkdown content={block.content} isStreaming={isStreaming} />;
case 'thinking':
return (
<ThinkingBlock
thinking={block.content}
isCollapsed={block.isCollapsed}
duration={block.duration}
/>
);
case 'code':
return (
<CodeBlockComponent
code={block.content}
language={block.language}
filename={block.filename}
/>
);
case 'tool_call':
return <ToolCallBlock tool={block} />;
case 'image':
return (
<figure className="image-block">
<img
src={block.url}
alt={block.alt || '图片'}
loading="lazy"
style={{
maxWidth: block.width ? `${block.width}px` : '100%',
maxHeight: '400px',
objectFit: 'contain',
}}
/>
{block.alt && <figcaption>{block.alt}</figcaption>}
</figure>
);
case 'file':
return <FilePreview file={block} />;
case 'citation':
return <CitationList sources={block.sources} />;
case 'error':
return (
<div className="error-block" role="alert">
{block.message}
{block.code && <code>[{block.code}]</code>}
</div>
);
default:
return null;
}
}
四、消息操作栏(复制/重试/反馈)
每条消息下方的操作栏是 AI 对话产品的重要交互细节,主要包括复制、重新生成、编辑重发和反馈评价。
import { useState, useCallback } from 'react';
import { Message } from '../types/message';
interface MessageActionsProps {
message: Message;
isUser: boolean;
getTextContent: () => string;
onRetry: (messageId: string) => void;
onEdit: (content: string) => void;
onFeedback: (feedback: 'positive' | 'negative') => void;
}
export function MessageActions({
message,
isUser,
getTextContent,
onRetry,
onEdit,
onFeedback,
}: MessageActionsProps) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(getTextContent());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API 可能在 HTTP 或 iframe 中不可用
// 回退到传统方案
const textarea = document.createElement('textarea');
textarea.value = getTextContent();
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [getTextContent]);
return (
<div className="message-actions" role="toolbar" aria-label="消息操作">
{/* 复制按钮 */}
<button
onClick={handleCopy}
title={copied ? '已复制' : '复制消息'}
aria-label={copied ? '已复制' : '复制消息'}
>
{copied ? '✓' : 'Copy'}
</button>
{isUser ? (
// 用户消息:编辑重发
<button
onClick={() => onEdit(getTextContent())}
title="编辑消息"
aria-label="编辑消息"
>
Edit
</button>
) : (
<>
{/* AI 消息:重新生成 */}
<button
onClick={() => onRetry(message.id)}
title="重新生成"
aria-label="重新生成回复"
>
Retry
</button>
{/* 反馈按钮 */}
<button
onClick={() => onFeedback('positive')}
className={message.feedback === 'positive' ? 'active' : ''}
title="有帮助"
aria-label="标记为有帮助"
aria-pressed={message.feedback === 'positive'}
>
+
</button>
<button
onClick={() => onFeedback('negative')}
className={message.feedback === 'negative' ? 'active' : ''}
title="没帮助"
aria-label="标记为没帮助"
aria-pressed={message.feedback === 'negative'}
>
-
</button>
</>
)}
</div>
);
}
用户的 thumbs up/down 反馈是 RLHF(人类反馈强化学习) 的核心数据来源。前端需要收集:
- 反馈类型:positive / negative
- 消息上下文:用户问题 + AI 回答的完整对话
- 可选评语:负面反馈时引导用户说明原因(如"回答不准确"、"代码有错误")
- 模型信息:哪个模型、哪个版本产出的回答
这些数据上报后用于模型微调和产品迭代,详见 AI 应用安全 中的内容安全部分。
五、代码块组件(语法高亮 + 复制)
AI 回答中频繁出现代码块,高质量的代码渲染直接影响开发者用户的体验。
import { useState, useRef, useMemo } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface CodeBlockProps {
code: string;
language: string;
filename?: string;
/** 超过此行数时默认折叠 */
maxCollapsedLines?: number;
}
// 语言显示名映射
const LANGUAGE_LABELS: Record<string, string> = {
ts: 'TypeScript',
tsx: 'TypeScript React',
js: 'JavaScript',
jsx: 'JavaScript React',
py: 'Python',
rb: 'Ruby',
go: 'Go',
rs: 'Rust',
sql: 'SQL',
sh: 'Shell',
bash: 'Bash',
json: 'JSON',
yaml: 'YAML',
yml: 'YAML',
md: 'Markdown',
css: 'CSS',
html: 'HTML',
graphql: 'GraphQL',
dockerfile: 'Dockerfile',
};
export function CodeBlockComponent({
code,
language,
filename,
maxCollapsedLines = 30,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const codeRef = useRef<HTMLDivElement>(null);
const lineCount = useMemo(() => code.split('\n').length, [code]);
const shouldCollapse = lineCount > maxCollapsedLines;
const displayLabel = LANGUAGE_LABELS[language] || language;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
} catch {
/* 回退到 execCommand */
const textarea = document.createElement('textarea');
textarea.value = code;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="code-block-wrapper" ref={codeRef}>
{/* 头部栏 */}
<div className="code-header">
<div className="code-header-left">
<span className="code-language">{displayLabel}</span>
{filename && <span className="code-filename">{filename}</span>}
<span className="code-line-count">{lineCount} lines</span>
</div>
<button
className="copy-btn"
onClick={handleCopy}
aria-label={copied ? '已复制' : '复制代码'}
>
{copied ? '✓ Copied' : 'Copy'}
</button>
</div>
{/* 代码内容 */}
<div
className={`code-content ${shouldCollapse && !isExpanded ? 'collapsed' : ''}`}
style={{
maxHeight: shouldCollapse && !isExpanded ? '400px' : undefined,
overflow: shouldCollapse && !isExpanded ? 'hidden' : undefined,
}}
>
<SyntaxHighlighter
language={language}
style={oneDark}
showLineNumbers
wrapLongLines
customStyle={{ margin: 0, borderRadius: 0 }}
>
{code}
</SyntaxHighlighter>
</div>
{/* 展开/折叠按钮 */}
{shouldCollapse && (
<button
className="expand-toggle"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded
? '收起代码'
: `展开全部(${lineCount} 行)`}
</button>
)}
</div>
);
}
| 库 | 大小 | 语言数 | 适用场景 |
|---|---|---|---|
| Shiki | ~2MB(按需加载) | 200+ | 构建时高亮、SSR、高保真 |
| Prism.js | ~30KB + 语言包 | 300+ | 客户端高亮、轻量场景 |
| highlight.js | ~50KB + 语言包 | 190+ | 通用场景、自动检测语言 |
| react-syntax-highlighter | 包装层 | 依赖底层 | React 项目首选封装 |
推荐:生产级 AI 产品用 Shiki(VS Code 同款引擎,颜色最准),它支持按需加载语言包。如果打包体积敏感则用 Prism。
六、工具调用展示
Function Calling 是 AI Agent 的核心能力。前端需要将工具调用过程可视化,让用户了解 AI 的"思考"和"行动"过程。
import { useState, useMemo } from 'react';
interface ToolCallBlockProps {
tool: {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
status: 'calling' | 'done' | 'error';
result?: string;
duration?: number;
};
}
// 工具图标映射
const TOOL_ICONS: Record<string, string> = {
web_search: 'Search',
code_interpreter: 'Code',
file_read: 'File',
database_query: 'DB',
api_call: 'API',
};
export function ToolCallBlock({ tool }: ToolCallBlockProps) {
const [expanded, setExpanded] = useState(false);
const statusInfo = useMemo(() => {
switch (tool.status) {
case 'calling':
return { label: '调用中...', className: 'calling' };
case 'done':
return {
label: tool.duration ? `完成 (${tool.duration}ms)` : '已完成',
className: 'done',
};
case 'error':
return { label: '调用失败', className: 'error' };
}
}, [tool.status, tool.duration]);
return (
<div
className={`tool-call-block ${statusInfo.className}`}
role="region"
aria-label={`工具调用: ${tool.toolName}`}
>
<button
className="tool-header"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
>
<span className="tool-icon">
{TOOL_ICONS[tool.toolName] || 'Tool'}
</span>
<span className="tool-name">{tool.toolName}</span>
<span className={`tool-status ${statusInfo.className}`}>
{statusInfo.label}
</span>
{/* 调用中的加载动画 */}
{tool.status === 'calling' && (
<span className="loading-spinner" aria-hidden="true" />
)}
<span className="expand-icon" aria-hidden="true">
{expanded ? '\u25BC' : '\u25B6'}
</span>
</button>
{expanded && (
<div className="tool-details">
<div className="tool-section">
<h4>参数</h4>
<pre className="tool-json">
{JSON.stringify(tool.args, null, 2)}
</pre>
</div>
{tool.result && (
<div className="tool-section">
<h4>结果</h4>
<pre className="tool-json">{tool.result}</pre>
</div>
)}
</div>
)}
</div>
);
}
七、打字指示器与加载状态
打字指示器需要在不同阶段给用户准确的反馈,避免"卡住了"的错觉。
import { useEffect, useState } from 'react';
interface TypingIndicatorProps {
/** 当前阶段,用于显示不同文案 */
stage?: 'connecting' | 'thinking' | 'generating' | 'tool_calling';
/** 工具名称(tool_calling 阶段) */
toolName?: string;
}
const STAGE_LABELS: Record<string, string> = {
connecting: '连接中',
thinking: '思考中',
generating: '生成中',
tool_calling: '调用工具',
};
export function TypingIndicator({
stage = 'thinking',
toolName,
}: TypingIndicatorProps) {
// 超时检测:超过 30s 显示提示
const [isTimeout, setIsTimeout] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setIsTimeout(true), 30000);
return () => clearTimeout(timer);
}, []);
const label =
stage === 'tool_calling' && toolName
? `正在调用 ${toolName}`
: STAGE_LABELS[stage];
return (
<div className="typing-indicator" role="status" aria-label={label}>
<div className="typing-dots" aria-hidden="true">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<span className="typing-label">{label}</span>
{isTimeout && (
<span className="timeout-hint">
响应时间较长,请耐心等待...
</span>
)}
</div>
);
}
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
color: var(--text-secondary);
}
.typing-dots {
display: flex;
gap: 4px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
/* 三个点依次跳动 */
animation: bounce 1.4s infinite both;
}
.dot:nth-child(2) {
animation-delay: 0.16s;
}
.dot:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-6px);
}
}
八、输入框与文件上传
输入框是用户与 AI 交互的入口,需要支持自适应高度、文件上传、粘贴图片、快捷键等。
import { useState, useRef, useCallback, KeyboardEvent, DragEvent, ClipboardEvent } from 'react';
interface Attachment {
id: string;
file: File;
preview?: string; // 图片预览 URL
status: 'uploading' | 'done' | 'error';
progress: number;
}
interface ChatInputProps {
onSend: (content: string, attachments?: File[]) => void;
isStreaming: boolean;
onStop: () => void;
placeholder?: string;
/** 允许上传的文件类型 */
acceptTypes?: string;
/** 最大文件大小(字节) */
maxFileSize?: number;
}
export function ChatInput({
onSend,
isStreaming,
onStop,
placeholder,
acceptTypes = 'image/*,.pdf,.txt,.md,.csv,.json',
maxFileSize = 10 * 1024 * 1024, // 10MB
}: ChatInputProps) {
const [input, setInput] = useState('');
const [attachments, setAttachments] = useState<Attachment[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 自适应高度:根据内容自动调整,最大不超过 200px
const adjustHeight = useCallback(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';
}, []);
// 发送消息
const handleSend = useCallback(() => {
const content = input.trim();
if ((!content && attachments.length === 0) || isStreaming) return;
const files = attachments
.filter((a) => a.status === 'done')
.map((a) => a.file);
onSend(content, files.length > 0 ? files : undefined);
setInput('');
setAttachments([]);
// 重置高度
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
}, [input, attachments, isStreaming, onSend]);
// 快捷键处理
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
// 处理文件选择
const handleFiles = useCallback(
(files: FileList | File[]) => {
const newAttachments: Attachment[] = Array.from(files)
.filter((file) => {
if (file.size > maxFileSize) {
alert(`文件 ${file.name} 超过大小限制(${maxFileSize / 1024 / 1024}MB)`);
return false;
}
return true;
})
.map((file) => ({
id: crypto.randomUUID(),
file,
preview: file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined,
status: 'done' as const,
progress: 100,
}));
setAttachments((prev) => [...prev, ...newAttachments]);
},
[maxFileSize]
);
// 粘贴图片
const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(e.clipboardData.items);
const imageItems = items.filter((item) => item.type.startsWith('image/'));
if (imageItems.length > 0) {
e.preventDefault();
const files = imageItems
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
handleFiles(files);
}
};
// 拖拽上传
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
};
// 移除附件
const removeAttachment = (id: string) => {
setAttachments((prev) => {
const attachment = prev.find((a) => a.id === id);
if (attachment?.preview) {
URL.revokeObjectURL(attachment.preview);
}
return prev.filter((a) => a.id !== id);
});
};
return (
<div
className="chat-input-container"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{/* 附件预览区 */}
{attachments.length > 0 && (
<div className="attachments-preview">
{attachments.map((att) => (
<div key={att.id} className="attachment-item">
{att.preview ? (
<img src={att.preview} alt={att.file.name} />
) : (
<div className="file-icon">{att.file.name.split('.').pop()}</div>
)}
<span className="file-name">{att.file.name}</span>
<button
onClick={() => removeAttachment(att.id)}
aria-label={`移除 ${att.file.name}`}
>
x
</button>
</div>
))}
</div>
)}
{/* 输入区 */}
<div className="input-row">
{/* 文件上传按钮 */}
<button
className="upload-btn"
onClick={() => fileInputRef.current?.click()}
title="上传文件"
aria-label="上传文件"
disabled={isStreaming}
>
+
</button>
<input
ref={fileInputRef}
type="file"
accept={acceptTypes}
multiple
hidden
onChange={(e) => {
if (e.target.files) handleFiles(e.target.files);
e.target.value = '';
}}
/>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
adjustHeight();
}}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder || '输入消息,Enter 发送,Shift+Enter 换行...'}
rows={1}
disabled={isStreaming}
aria-label="消息输入框"
/>
<div className="input-actions">
{isStreaming ? (
<button className="stop-btn" onClick={onStop} aria-label="停止生成">
Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() && attachments.length === 0}
aria-label="发送消息"
>
Send
</button>
)}
</div>
</div>
</div>
);
}
中文、日文等使用输入法(IME)的语言中,按下 Enter 键可能触发输入法的选词确认,而非发送消息。务必检查 e.nativeEvent.isComposing:
// 正确做法:IME 输入中不触发发送
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
不加这个判断的话,中文用户输入法选字时会误触发发送。
九、会话管理
会话管理包括创建、删除、重命名、搜索、归档等操作,是 AI 对话产品的基础功能。
import { useState, useCallback, useMemo } from 'react';
interface UseConversationOptions {
/** 持久化存储 key */
storageKey?: string;
/** 最大会话数(超过时自动归档旧会话) */
maxConversations?: number;
}
export function useConversation(options: UseConversationOptions = {}) {
const { storageKey = 'conversations', maxConversations = 100 } = options;
const [conversations, setConversations] = useState<Conversation[]>(() => {
// 从 localStorage 恢复
try {
const saved = localStorage.getItem(storageKey);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
const [activeId, setActiveId] = useState<string | null>(null);
// 持久化到 localStorage
const persist = useCallback(
(convs: Conversation[]) => {
try {
localStorage.setItem(storageKey, JSON.stringify(convs));
} catch {
// localStorage 满了,清理最旧的归档会话
const archived = convs.filter((c) => c.archived);
if (archived.length > 0) {
const cleaned = convs.filter((c) => !c.archived);
localStorage.setItem(storageKey, JSON.stringify(cleaned));
}
}
},
[storageKey],
);
// 创建新会话
const createConversation = useCallback(
(model: string = 'gpt-4o') => {
const newConv: Conversation = {
id: crypto.randomUUID(),
title: '新对话',
messages: [],
model,
createdAt: Date.now(),
updatedAt: Date.now(),
};
setConversations((prev) => {
const next = [newConv, ...prev];
// 超过上限时自动归档
if (next.length > maxConversations) {
next[next.length - 1].archived = true;
}
persist(next);
return next;
});
setActiveId(newConv.id);
return newConv.id;
},
[maxConversations, persist],
);
// 删除会话
const deleteConversation = useCallback(
(id: string) => {
setConversations((prev) => {
const next = prev.filter((c) => c.id !== id);
persist(next);
return next;
});
if (activeId === id) setActiveId(null);
},
[activeId, persist],
);
// 自动生成标题(第一条 AI 回复完成后调用)
const autoTitle = useCallback(
async (convId: string, firstMessage: string) => {
try {
const title = await generateTitle(firstMessage);
setConversations((prev) => {
const next = prev.map((c) =>
c.id === convId ? { ...c, title, updatedAt: Date.now() } : c,
);
persist(next);
return next;
});
} catch {
// 生成失败则截取前 20 字作为标题
const fallbackTitle = firstMessage.slice(0, 20) + '...';
setConversations((prev) => {
const next = prev.map((c) =>
c.id === convId ? { ...c, title: fallbackTitle } : c,
);
persist(next);
return next;
});
}
},
[persist],
);
// 重命名会话
const renameConversation = useCallback(
(id: string, title: string) => {
setConversations((prev) => {
const next = prev.map((c) =>
c.id === id ? { ...c, title, updatedAt: Date.now() } : c,
);
persist(next);
return next;
});
},
[persist],
);
// 搜索会话(标题 + 消息内容)
const searchConversations = useCallback(
(query: string): Conversation[] => {
if (!query.trim()) return conversations;
const lowerQuery = query.toLowerCase();
return conversations.filter((conv) => {
// 搜索标题
if (conv.title.toLowerCase().includes(lowerQuery)) return true;
// 搜索消息内容
return conv.messages.some((msg) =>
msg.blocks.some(
(block) =>
'content' in block &&
typeof block.content === 'string' &&
block.content.toLowerCase().includes(lowerQuery),
),
);
});
},
[conversations],
);
// 固定/取消固定
const togglePin = useCallback(
(id: string) => {
setConversations((prev) => {
const next = prev.map((c) =>
c.id === id ? { ...c, pinned: !c.pinned } : c,
);
persist(next);
return next;
});
},
[persist],
);
// 排序后的会话列表:固定在前 -> 按更新时间排序
const sortedConversations = useMemo(() => {
return [...conversations]
.filter((c) => !c.archived)
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return b.updatedAt - a.updatedAt;
});
}, [conversations]);
const activeConversation = useMemo(
() => conversations.find((c) => c.id === activeId) ?? null,
[conversations, activeId],
);
return {
conversations: sortedConversations,
activeId,
activeConversation,
setActiveId,
createConversation,
deleteConversation,
renameConversation,
autoTitle,
searchConversations,
togglePin,
};
}
// 使用 LLM 生成简短标题
async function generateTitle(message: string): Promise<string> {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [
{
role: 'system',
content:
'用5-10个字概括用户的问题,作为对话标题。只输出标题,不要引号或其他内容。',
},
{ role: 'user', content: message },
],
max_tokens: 30,
// 用轻量模型降低成本
model: 'gpt-4o-mini',
}),
});
if (!response.ok) throw new Error('Failed to generate title');
const data = await response.json();
return data.content || message.slice(0, 20);
}
十、移动端适配
AI 对话界面在移动端面临屏幕空间有限、虚拟键盘弹出、触摸交互等挑战。
import { useEffect, useState, useCallback } from 'react';
/**
* 处理移动端虚拟键盘弹出/收起时的布局调整
* 核心问题:iOS 上虚拟键盘弹出不会改变 viewport 高度(使用 Visual Viewport API)
*/
export function useMobileKeyboard() {
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
useEffect(() => {
// 优先使用 Visual Viewport API(现代浏览器)
const viewport = window.visualViewport;
if (!viewport) return;
const handleResize = () => {
// 键盘高度 = window 高度 - 可视区域高度
const newKeyboardHeight = window.innerHeight - viewport.height;
const isOpen = newKeyboardHeight > 100; // 阈值,避免地址栏收缩误判
setKeyboardHeight(isOpen ? newKeyboardHeight : 0);
setIsKeyboardOpen(isOpen);
// 动态调整 CSS 变量
document.documentElement.style.setProperty(
'--keyboard-height',
`${isOpen ? newKeyboardHeight : 0}px`,
);
};
viewport.addEventListener('resize', handleResize);
return () => viewport.removeEventListener('resize', handleResize);
}, []);
return { keyboardHeight, isKeyboardOpen };
}
/* 移动端对话界面核心布局 */
.chat-container {
display: flex;
flex-direction: column;
/* 使用 dvh 代替 vh 处理移动端地址栏 */
height: 100dvh;
/* 或使用 CSS 变量应对虚拟键盘 */
height: calc(100vh - var(--keyboard-height, 0px));
}
.message-list {
flex: 1;
overflow-y: auto;
/* iOS 惯性滚动 */
-webkit-overflow-scrolling: touch;
/* 防止 pull-to-refresh 干扰滚动 */
overscroll-behavior: contain;
}
.chat-input-container {
flex-shrink: 0;
/* 安全区域适配(iPhone 底部横条) */
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid var(--border-color);
background: var(--bg-primary);
}
/* 侧边栏在移动端变为抽屉 */
@media (max-width: 768px) {
.sidebar {
position: fixed;
inset: 0;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 99;
}
/* 移动端代码块横向滚动 */
.code-block-wrapper {
max-width: calc(100vw - 48px);
overflow-x: auto;
}
/* 移动端隐藏 hover 操作栏,改为长按触发 */
.message-actions {
display: none;
}
.message-bubble.action-menu-open .message-actions {
display: flex;
}
}
| 问题 | 原因 | 解决方案 |
|---|---|---|
| iOS 键盘弹出后页面布局错乱 | iOS 不缩小 viewport | 使用 visualViewport API 动态调整 |
| 滚动到底部后页面被拉起 | 橡皮筋效果 + overscroll | overscroll-behavior: contain |
| 100vh 包含地址栏高度 | 移动端 vh 单位问题 | 使用 100dvh 或 window.innerHeight |
| 长按选择文本触发系统菜单 | 默认浏览器行为 | 操作按钮区域添加 user-select: none |
| 代码块无法横向滚动 | 内容溢出 | 限制 max-width 并设置 overflow-x: auto |
十一、无障碍(Accessibility)
AI 对话界面的无障碍支持不仅是法律要求(ADA/WCAG),也是企业级产品的必备能力。
import { useRef, useEffect, useCallback } from 'react';
/**
* 无障碍增强 Hook
* 提供键盘导航、屏幕阅读器支持、焦点管理
*/
export function useAccessibleChat(messagesCount: number) {
const announcerRef = useRef<HTMLDivElement>(null);
// 使用 ARIA live region 通知屏幕阅读器新消息
const announceMessage = useCallback((text: string) => {
if (announcerRef.current) {
announcerRef.current.textContent = text;
// 清空后重新赋值,确保相同内容也会被朗读
setTimeout(() => {
if (announcerRef.current) {
announcerRef.current.textContent = '';
}
}, 1000);
}
}, []);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl/Cmd + / 聚焦输入框
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
document.querySelector<HTMLTextAreaElement>('.chat-input textarea')?.focus();
}
// Escape 停止生成(如果正在流式输出)
if (e.key === 'Escape') {
document.querySelector<HTMLButtonElement>('.stop-btn')?.click();
}
// Ctrl/Cmd + Shift + C 复制最后一条 AI 回复
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') {
e.preventDefault();
const lastCopyBtn = document.querySelector<HTMLButtonElement>(
'.assistant:last-of-type .copy-btn'
);
lastCopyBtn?.click();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// ARIA live region(隐藏的通知区域)
const LiveRegion = () => (
<div
ref={announcerRef}
role="status"
aria-live="polite"
aria-atomic="true"
// 视觉隐藏但屏幕阅读器可读
style={{
position: 'absolute',
width: '1px',
height: '1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
}}
/>
);
return { announceMessage, LiveRegion };
}
ARIA 角色与属性:
- 消息列表使用
role="log",表示按时间顺序排列的内容 - 每条消息使用
role="article" - 操作栏使用
role="toolbar" - 新消息区域使用
aria-live="polite"(不打断用户当前操作) - 加载指示器使用
role="status"
键盘导航:
Tab/Shift+Tab在消息间导航Enter或Space展开/折叠思考过程和工具调用Ctrl/Cmd + /快速聚焦输入框Escape停止生成
屏幕阅读器:
- 新 AI 回复完成后通过 live region 通知
- 工具调用状态变化(调用中 -> 完成)要通知
- 代码块标注语言信息,如 "TypeScript 代码块,共 20 行"
- 图片提供有意义的 alt 文本
十二、性能优化策略
import { useRef, useCallback, useEffect } from 'react';
/**
* 流式 token 缓冲 Hook
* 将高频 token 到达合并为每帧一次 UI 更新
*
* 问题:LLM 每秒可产出 30-100 个 token,每个 token 触发一次 setState:
* 1. 每个 token → 一次 setState → 一次 React reconciliation → 一次 DOM 更新
* 2. Markdown 解析器对整段内容重新解析(内容越长越慢)
* 3. DOM 更新导致消息高度变化 → 触发滚动位置重算 → 浏览器 reflow
* 4. 自动滚动逻辑执行 scrollIntoView → 又一次 reflow
* 这些操作会导致 React 渲染压力过大,帧率下降。而且以上一个循环在 16ms(60fps)内完不成,帧就丢了。多帧连续丢,用户看到的就是文字跳动、滚动卡顿、布局闪烁。
*
*
* 方案:用 RAF 收集一帧内的所有 token,批量更新一次
*/
export function useStreamBuffer(onFlush: (buffered: string) => void) {
const bufferRef = useRef('');
const rafIdRef = useRef<number | null>(null);
const appendToken = useCallback(
(token: string) => {
bufferRef.current += token;
// 如果没有 pending 的 RAF,安排一个
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(() => {
// 刷新缓冲区到 UI
onFlush(bufferRef.current);
bufferRef.current = '';
rafIdRef.current = null;
});
}
},
[onFlush],
);
// 流结束时,刷新剩余缓冲
const flush = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
if (bufferRef.current) {
onFlush(bufferRef.current);
bufferRef.current = '';
}
}, [onFlush]);
// 清理
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
return { appendToken, flush };
}
| 优化手段 | 原理 | 收益 |
|---|---|---|
React.memo | 历史消息不随新 token 重渲染 | 减少 90%+ 不必要渲染 |
requestAnimationFrame 缓冲 | 每帧只更新一次 UI | 帧率从 15fps 提升到 60fps |
| Markdown 解析缓存 | done 状态的消息只解析一次 | 滚动时无重复计算 |
| 虚拟列表 | 超长对话只渲染可视区域 | 1000+ 条消息仍流畅 |
| 代码高亮 Web Worker | 语法分析在后台线程 | 不阻塞主线程渲染 |
IndexedDB 替代 localStorage | 大数据异步存储 | 避免 localStorage 5MB 限制和同步阻塞 |
| 图片懒加载 | loading="lazy" + IntersectionObserver | 减少初始加载带宽 |
十三、消息列表虚拟滚动
AI 对话场景下的虚拟滚动比普通列表复杂得多:每条消息高度不固定(代码块、图片、工具调用等)、流式渲染中高度持续变化、需要底部对齐(新消息从底部出现)。推荐使用 react-virtuoso 而非 react-window。
import { useRef, useCallback } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Message } from '../types/message';
import { MessageBubble } from './MessageBubble';
interface VirtualMessageListProps {
messages: Message[];
isStreaming: boolean;
onRetry: (messageId: string) => void;
onEdit: (messageId: string, content: string) => void;
onFeedback: (messageId: string, feedback: 'positive' | 'negative') => void;
onBranchSwitch: (messageId: string, direction: 'prev' | 'next') => void;
}
export function VirtualMessageList({
messages,
isStreaming,
onRetry,
onEdit,
onFeedback,
onBranchSwitch,
}: VirtualMessageListProps) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
/**
* followOutput 控制自动滚动:
* - 'smooth':用户在底部时,新内容出现自动平滑滚动
* - false:用户在上方浏览历史时,不打断
*
* react-virtuoso 内部会判断用户是否在底部,
* atBottomStateChange 回调可获取当前状态
*/
const followOutput = useCallback(
(isAtBottom: boolean) => {
// 流式输出时在底部则跟随,否则不跟随
if (isStreaming && isAtBottom) return 'smooth';
// 非流式(用户刚发消息)也跟随
if (!isStreaming) return 'auto';
return false;
},
[isStreaming]
);
return (
<Virtuoso
ref={virtuosoRef}
data={messages}
// 初始定位到底部(打开已有会话时)
initialTopMostItemIndex={messages.length - 1}
// 自动跟随新输出
followOutput={followOutput}
// 底部对齐:消息少时从底部开始排列(类似 IM)
alignToBottom
// 预渲染区域:上下各多渲染 300px,减少滚动时白屏
overscan={300}
// 渲染单条消息
itemContent={(index) => {
const msg = messages[index];
return (
<MessageBubble
key={msg.id}
message={msg}
isLast={index === messages.length - 1}
isStreaming={isStreaming && index === messages.length - 1}
onRetry={onRetry}
onEdit={onEdit}
onFeedback={onFeedback}
onBranchSwitch={onBranchSwitch}
/>
);
}}
// 滚动到底部按钮
atBottomStateChange={(atBottom) => {
// 可以在这里控制"滚动到底部"按钮的显隐
// setShowScrollButton(!atBottom);
}}
// 无障碍
role="log"
aria-label="对话消息"
/>
);
}
| 对比项 | react-window | react-virtuoso |
|---|---|---|
| 动态高度 | 需要手动测量 + VariableSizeList | 自动测量,开箱即用 |
| 底部对齐 | 不支持,需要自己 hack | alignToBottom 属性原生支持 |
| 自动跟随新内容 | 不支持 | followOutput 属性原生支持 |
| 流式内容高度变化 | 高度缓存失效需手动 resetAfterIndex | 自动检测高度变化并重新布局 |
| 反向滚动(加载历史) | 需要大量手动处理 | firstItemIndex 支持向上追加 |
| 包体积 | ~6KB gzipped | ~15KB gzipped |
总结:react-window 适合高度固定或变化不频繁的列表(如商品列表);AI 对话这种动态高度 + 底部对齐 + 流式跟随的场景,react-virtuoso 是更合适的选择。
-
流式消息高度持续增长:最后一条消息在流式输出期间高度不断变化,虚拟列表需要频繁重新计算布局。react-virtuoso 内部用
ResizeObserver监听每个 item 的高度变化,自动处理。但如果用 react-window,需要在每次 token 到达后手动调用resetAfterIndex(lastIndex) -
Markdown 渲染导致高度突变:一段普通文本突然变成代码块或表格,高度可能瞬间翻倍。需要确保虚拟列表能平滑处理这种突变,否则滚动位置会跳动
-
图片加载后高度变化:图片从占位符变为实际图片时高度改变。建议图片块预设
aspect-ratio或固定高度占位,避免布局抖动 -
启用阈值:不要一开始就用虚拟列表。消息少于 50 条时虚拟列表的开销(测量、计算)反而高于直接渲染。建议设置阈值:
// 消息少时直接渲染,多时切换为虚拟列表
{messages.length > 50 ? (
<VirtualMessageList messages={messages} ... />
) : (
<SimpleMessageList messages={messages} ... />
)}
- 加载历史消息(向上滚动加载):用户滚动到顶部时需要加载更早的消息。react-virtuoso 通过
firstItemIndex属性支持向上追加数据而不跳动滚动位置:
// 假设总共有 1000 条消息,当前加载了最新 100 条
const [firstItemIndex, setFirstItemIndex] = useState(900);
const [messages, setMessages] = useState<Message[]>(latestMessages);
const loadMore = useCallback(async () => {
const olderMessages = await fetchOlderMessages(firstItemIndex);
setMessages(prev => [...olderMessages, ...prev]);
setFirstItemIndex(prev => prev - olderMessages.length);
}, [firstItemIndex]);
<Virtuoso
firstItemIndex={firstItemIndex}
data={messages}
startReached={loadMore} // 滚动到顶部时触发
// ...
/>
十四、异常处理与容错
AI 对话中的异常场景远比普通 Web 应用复杂——流式连接可能在任意时刻中断,LLM 服务不稳定,用户行为不可预测。健壮的异常处理是生产级 AI 产品的标配。
1. 流式中断恢复(页面关闭 / 网络断开)
流式渲染到一半时用户关闭页面或网络断开,下次打开时内容丢失。核心思路:边收边存,不要等流结束才持久化。
import Dexie from 'dexie';
// IndexedDB 数据库定义
const db = new Dexie('ChatDB');
db.version(1).stores({
messages: 'id, conversationId, status, updatedAt',
});
/**
* 流式持久化 Hook
* 在流式接收过程中定期将内容写入 IndexedDB
*/
export function useStreamPersistence() {
const bufferRef = useRef('');
const timerRef = useRef<ReturnType<typeof setInterval>>();
// 开始流式接收时,启动定时持久化
const startPersistence = useCallback((messageId: string) => {
// 每 500ms 写入一次 IndexedDB(节流,避免频繁 IO)
timerRef.current = setInterval(() => {
if (bufferRef.current) {
db.table('messages').update(messageId, {
content: bufferRef.current,
status: 'streaming',
updatedAt: Date.now(),
});
}
}, 500);
}, []);
// 接收 token 时更新缓冲
const appendToken = useCallback((token: string) => {
bufferRef.current += token;
}, []);
// 流结束时,最后写入一次并标记完成
const finishPersistence = useCallback(async (messageId: string) => {
if (timerRef.current) clearInterval(timerRef.current);
await db.table('messages').update(messageId, {
content: bufferRef.current,
status: 'done',
updatedAt: Date.now(),
});
bufferRef.current = '';
}, []);
// 页面关闭前的兜底写入
useEffect(() => {
const handleBeforeUnload = () => {
// beforeunload 中不能用异步操作
// 退回 localStorage 做最后一次同步写入
if (bufferRef.current) {
localStorage.setItem(
'stream_recovery',
JSON.stringify({
content: bufferRef.current,
timestamp: Date.now(),
}),
);
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
return { startPersistence, appendToken, finishPersistence };
}
恢复策略:下次打开页面时检查是否有未完成的消息。
/**
* 页面打开时恢复中断的流式消息
*/
export function useStreamRecovery() {
useEffect(() => {
async function recover() {
// 1. 检查 IndexedDB 中是否有 streaming 状态的消息
const interrupted = await db
.table('messages')
.where('status')
.equals('streaming')
.toArray();
// 2. 检查 localStorage 兜底数据
const recovery = localStorage.getItem('stream_recovery');
if (recovery) {
localStorage.removeItem('stream_recovery');
// 合并到 IndexedDB
}
if (interrupted.length === 0) return;
// 3. 对每条中断消息进行处理
for (const msg of interrupted) {
// 标记为 interrupted,展示已有内容
await db.table('messages').update(msg.id, {
status: 'interrupted',
});
}
}
recover();
}, []);
}
UI 层处理:
interface InterruptedMessageProps {
message: Message;
onContinue: (messageId: string) => void;
onRegenerate: (messageId: string) => void;
}
/**
* 中断消息展示组件
* 显示已接收的部分内容 + 操作按钮
*/
export function InterruptedMessage({
message,
onContinue,
onRegenerate,
}: InterruptedMessageProps) {
return (
<div className="interrupted-message">
{/* 渲染已有的部分内容 */}
<div className="partial-content">
<StreamMarkdown content={message.content} isStreaming={false} />
</div>
{/* 中断提示 + 操作按钮 */}
<div className="interruption-banner" role="alert">
<span>回答未完成(网络中断或页面关闭)</span>
<div className="interruption-actions">
{/* highlight-start */}
{/* 继续生成:将已有内容作为 assistant prefill 继续请求 */}
<button onClick={() => onContinue(message.id)}>
继续生成
</button>
{/* 重新生成:丢弃已有内容,重新请求 */}
<button onClick={() => onRegenerate(message.id)}>
重新生成
</button>
{/* highlight-end */}
</div>
</div>
</div>
);
}
将已有的 partial content 作为 assistant 消息的一部分发送给 API,让模型接着输出:
async function continueGeneration(
conversationId: string,
messageId: string,
partialContent: string,
) {
const messages = await buildMessageHistory(conversationId);
// 最后一条 assistant 消息替换为已有的部分内容
messages.push({ role: 'assistant', content: partialContent });
// 请求时带上 continue 标记
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
messages,
// 部分模型支持 continue 参数
// Anthropic Claude: 直接在 messages 末尾放 assistant 消息即可
}),
});
// 流式追加到已有内容后面
handleStream(response, messageId, partialContent);
}
注意:不是所有模型都支持 assistant prefill 续写。OpenAI 模型可能会"重新回答"而不是接着说。Anthropic Claude 对 assistant prefill 支持较好。如果模型不支持,退回"重新生成"即可。
2. 网络断开与 SSE 连接中断
/**
* 网络感知的流式请求
* 处理网络断开、SSE 超时、连接异常
*/
export function useNetworkAwareStream() {
const abortRef = useRef<AbortController>();
const retryCountRef = useRef(0);
const MAX_RETRIES = 3;
const startStream = useCallback(
async (
url: string,
body: unknown,
onToken: (token: string) => void,
onError: (error: StreamError) => void,
onDone: () => void,
) => {
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,
});
// HTTP 层错误处理
if (!response.ok) {
const status = response.status;
if (status === 429) {
// 限流:从 Retry-After 头获取等待时间
const retryAfter = parseInt(
response.headers.get('Retry-After') || '60',
);
onError({ type: 'rate_limit', retryAfter });
return;
}
if (status === 413) {
onError({
type: 'context_too_long',
message: '上下文超出模型限制',
});
return;
}
if (status >= 500) {
onError({ type: 'server_error', message: '服务暂时不可用' });
return;
}
onError({ type: 'unknown', message: `请求失败: ${status}` });
return;
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
// 解析 SSE 格式
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
onDone();
return;
}
try {
const parsed = JSON.parse(data);
onToken(parsed.content || '');
} catch {
// 非 JSON 数据,可能是心跳或其他信号
}
}
}
}
onDone();
retryCountRef.current = 0;
} catch (error) {
if ((error as Error).name === 'AbortError') {
// 用户主动取消,不重试
return;
}
// 网络错误自动重试(指数退避)
if (retryCountRef.current < MAX_RETRIES) {
retryCountRef.current++;
const delay = Math.min(1000 * 2 ** retryCountRef.current, 10000);
onError({
type: 'network',
message: `网络异常,${delay / 1000}s 后重试 (${retryCountRef.current}/${MAX_RETRIES})`,
retrying: true,
});
await new Promise((resolve) => setTimeout(resolve, delay));
// 递归重试
return startStream(url, body, onToken, onError, onDone);
}
onError({
type: 'network',
message: '网络连接失败,请检查网络后重试',
retrying: false,
});
}
},
[],
);
const stopStream = useCallback(() => {
abortRef.current?.abort();
}, []);
return { startStream, stopStream };
}
// 错误类型定义
interface StreamError {
type:
| 'network'
| 'rate_limit'
| 'context_too_long'
| 'server_error'
| 'content_filtered'
| 'token_limit'
| 'unknown';
message?: string;
retryAfter?: number;
retrying?: boolean;
}
3. 其他常见异常场景
/**
* AI 对话中的各种异常处理策略
*/
// ===== 1. 上下文窗口溢出 =====
// 对话太长超出模型 token 限制时,需要截断或总结历史消息
function handleContextOverflow(
messages: Message[],
maxTokens: number,
): Message[] {
let totalTokens = 0;
const kept: Message[] = [];
// 从最新消息向前保留,直到接近限制
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = estimateTokens(messages[i]);
if (totalTokens + msgTokens > maxTokens * 0.8) break; // 留 20% 给回复
kept.unshift(messages[i]);
totalTokens += msgTokens;
}
// 如果截断了太多,在开头插入系统摘要
if (kept.length < messages.length) {
const truncated = messages.slice(0, messages.length - kept.length);
const summary: Message = {
id: 'summary',
role: 'system',
status: 'done',
createdAt: Date.now(),
blocks: [
{
type: 'text',
content: `[以下是之前 ${truncated.length} 条消息的摘要:${summarize(truncated)}]`,
},
],
};
kept.unshift(summary);
}
return kept;
}
// ===== 2. Token 超限截断 =====
// 模型输出达到 max_tokens 时被截断,回复不完整
function handleTokenLimitTruncation(message: Message): Message {
// 检测是否被截断(finish_reason === 'length')
return {
...message,
status: 'done',
blocks: [
...message.blocks,
{
type: 'error',
message: '回答因长度限制被截断',
retryable: true, // 允许"继续生成"
},
],
};
}
// ===== 3. 内容安全过滤 =====
// 模型拒绝回答或回答被内容审核过滤
function handleContentFiltered(filterReason?: string): Message {
return {
id: crypto.randomUUID(),
role: 'assistant',
status: 'error',
createdAt: Date.now(),
blocks: [
{
type: 'error',
message: filterReason || '该内容无法生成,请调整你的问题后重试',
code: 'CONTENT_FILTERED',
retryable: false,
},
],
};
}
// ===== 4. 快速连续发送(防抖) =====
// 用户连续快速点击发送按钮
function createSendGuard() {
let lastSendTime = 0;
const MIN_INTERVAL = 1000; // 最少间隔 1 秒
return function canSend(): boolean {
const now = Date.now();
if (now - lastSendTime < MIN_INTERVAL) {
return false; // 防抖,忽略
}
lastSendTime = now;
return true;
};
}
// ===== 5. 流式中途发新消息 =====
// 用户在 AI 还在回答时发送新消息
function handleSendWhileStreaming(
stopCurrentStream: () => void,
currentMessage: Message,
): Message {
// 停止当前流
stopCurrentStream();
// 将当前未完成的消息标记为 done(保留已有内容)
return {
...currentMessage,
status: 'done',
blocks: [
...currentMessage.blocks,
{
type: 'error',
message: '(回答被中断)',
retryable: true,
},
],
};
}
// ===== 6. 用户主动停止生成 =====
// 点击 Stop 按钮取消流式输出
function handleUserStop(
abortController: AbortController,
currentMessage: Message,
): Message {
abortController.abort();
// 保留已有内容,标记为完成
return {
...currentMessage,
status: 'done', // 不是 error,已有内容是有效的
};
}
// 估算 token 数(粗略:英文 1 word ≈ 1.3 token,中文 1 字 ≈ 2 token)
function estimateTokens(message: Message): number {
const text = message.blocks
.filter((b): b is { type: 'text'; content: string } => b.type === 'text')
.map((b) => b.content)
.join('');
// 粗略估算,实际应使用 tiktoken 等库
return Math.ceil(text.length * 1.5);
}
4. 异常状态的 UI 展示
import { StreamError } from '../utils/error-handlers';
interface ErrorBannerProps {
error: StreamError;
onRetry: () => void;
onDismiss: () => void;
}
/**
* 根据不同错误类型展示对应的 UI
*/
export function ErrorBanner({ error, onRetry, onDismiss }: ErrorBannerProps) {
// 不同错误类型的展示配置
const config: Record<StreamError['type'], {
icon: string;
title: string;
showRetry: boolean;
}> = {
network: {
icon: 'wifi-off',
title: '网络连接异常',
showRetry: true,
},
rate_limit: {
icon: 'clock',
title: `请求过于频繁,请 ${error.retryAfter}s 后重试`,
showRetry: true,
},
context_too_long: {
icon: 'file-text',
title: '对话太长,已超出模型上下文限制',
showRetry: false, // 重试没用,需要开新对话或清理历史
},
server_error: {
icon: 'server',
title: '服务暂时不可用,请稍后重试',
showRetry: true,
},
content_filtered: {
icon: 'shield',
title: '内容被安全策略过滤',
showRetry: false,
},
token_limit: {
icon: 'scissors',
title: '回答因长度限制被截断',
showRetry: true, // 可以"继续生成"
},
unknown: {
icon: 'alert',
title: error.message || '发生未知错误',
showRetry: true,
},
};
const { icon, title, showRetry } = config[error.type];
return (
<div className="error-banner" role="alert">
<span className="error-icon">{icon}</span>
<span className="error-title">{title}</span>
{/* 重试中的提示 */}
{error.retrying && (
<span className="retry-hint">正在重试...</span>
)}
<div className="error-actions">
{showRetry && !error.retrying && (
<button onClick={onRetry}>
{error.type === 'token_limit' ? '继续生成' : '重试'}
</button>
)}
{error.type === 'context_too_long' && (
<button onClick={() => {/* 开新对话 */}}>
开始新对话
</button>
)}
<button onClick={onDismiss}>关闭</button>
</div>
</div>
);
}
- 永远不丢数据:流式内容边收边存(IndexedDB),页面关闭前兜底写入(localStorage / beforeunload)
- 给用户选择权:中断后不要自动重试整个请求,而是展示已有内容 + "继续生成" / "重新生成"按钮
- 区分可重试和不可重试:网络错误、服务端 500 可以重试;内容过滤、上下文溢出重试没用
- 优雅降级:
- IndexedDB 不可用 → 退回 localStorage
- beforeunload 中不能用异步 API → 用同步 localStorage
- 模型不支持 prefill 续写 → 退回重新生成
- 避免重复请求:用户快速点击、网络抖动触发重试时,用 AbortController 取消上一个请求
常见面试问题
Q1: AI 对话列表如何做性能优化?
答案:
AI 对话列表的性能瓶颈集中在流式渲染期间的高频更新和长对话的大量 DOM 节点。优化策略分为三层:
第一层:减少渲染次数
- React.memo:历史消息(
status === 'done')用 memo 包裹,只有正在流式输出的最后一条消息需要频繁更新。这一步就能减少 90% 以上的不必要渲染 - RAF 批量更新:LLM 每秒产出 30-100 个 token,每个 token 一次 setState 会压垮 React。用
requestAnimationFrame将一帧内的所有 token 合并为一次更新
第二层:减少计算量
- Markdown 缓存:已完成的消息只解析一次 Markdown,将 React 元素结果缓存起来。用
useMemo依赖[content, status],status 为 done 后 content 不变,缓存命中 - 代码高亮 Worker 化:使用 Shiki 时可在 Web Worker 中做语法分析,不阻塞主线程
第三层:减少 DOM 数量
- 虚拟列表:消息超过 50-100 条时启用虚拟滚动,推荐
react-virtuoso(支持动态高度和底部对齐) - 图片懒加载:
loading="lazy"配合 IntersectionObserver
// RAF 批量更新示例
const bufferRef = useRef('');
const rafRef = useRef<number>();
function onToken(token: string) {
bufferRef.current += token;
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setContent((prev) => prev + bufferRef.current);
bufferRef.current = '';
rafRef.current = undefined;
});
}
}
Q2: 如何实现智能自动滚动?详细说明判断逻辑。
答案:
智能自动滚动的核心逻辑是判断用户意图:如果用户在查看最新内容(底部),则自动跟随新内容滚动;如果用户在翻看历史消息,则停止自动滚动。
判断用户是否在底部:
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = listRef.current!;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
// 阈值 100px:避免小幅度触摸误判
isAutoScrollRef.current = distanceFromBottom < 100;
};
不同场景的滚动行为:
| 场景 | 行为 | 滚动方式 |
|---|---|---|
| 流式输出中,用户在底部 | 自动跟随 | scrollIntoView({ behavior: 'instant' }) |
| 流式输出中,用户在上方 | 不滚动,显示"新消息"按钮 | 无 |
| 用户发送新消息 | 强制滚到底部 | scrollIntoView({ behavior: 'smooth' }) |
| 点击"新消息"按钮 | 滚到底部并恢复自动跟随 | scrollIntoView({ behavior: 'smooth' }) |
关键细节:
- 流式期间用
instant而非smooth:smooth 动画有延迟,token 到达速度快时滚动会"追不上" - 使用
useRef存储isAutoScroll而非useState:避免 onScroll 高频触发 setState showScrollButton用useState(需要触发 UI 更新)
Q3: 如何处理 AI 回复中的代码块渲染?
答案:
AI 回复中的代码块渲染需要解决以下问题:
1. 流式渲染中的未闭合代码块
在流式输出过程中,AI 可能正在输出一个代码块但还未写完闭合标记。需要在渲染时检测并补全:
function preprocessStreamingMarkdown(content: string): string {
// 统计未闭合的代码块围栏
const fenceMatches = content.match(/```/g);
const fenceCount = fenceMatches?.length ?? 0;
// 奇数个围栏说明有未闭合的代码块,补全
if (fenceCount % 2 !== 0) {
return content + '\n```';
}
return content;
}
2. 语法高亮方案选择
- Shiki:VS Code 同款引擎,颜色准确度最高,支持 Web Worker,适合生产级产品
- Prism.js:轻量,适合体积敏感场景
- react-syntax-highlighter:Prism/Highlight.js 的 React 封装,开箱即用
3. 代码块功能清单
| 功能 | 实现方式 |
|---|---|
| 语言标签 | 解析围栏后的语言标识(如 typescript) |
| 复制按钮 | navigator.clipboard.writeText() + 回退方案 |
| 行号 | showLineNumbers 属性 |
| 文件名 | 解析 title="filename.ts" 元数据 |
| 长代码折叠 | 超过 30 行默认折叠,提供展开按钮 |
| 横向滚动 | overflow-x: auto,移动端限制 max-width |
Q4: 对话界面需要哪些无障碍(Accessibility)支持?
答案:
ARIA 角色和属性:
- 消息列表:
role="log"+aria-label="对话消息"— 表示按时间顺序排列的内容区域 - 单条消息:
role="article"— 独立的内容单元 - 操作栏:
role="toolbar"— 一组相关操作按钮 - 加载指示:
role="status"— 非关键状态更新 - 新消息通知:
aria-live="polite"— 不打断用户当前操作
键盘导航:
| 快捷键 | 功能 |
|---|---|
Tab / Shift+Tab | 在消息和交互元素间导航 |
Enter / Space | 展开/折叠代码块、工具调用 |
Ctrl/Cmd + / | 聚焦输入框 |
Escape | 停止生成、关闭弹窗 |
Ctrl/Cmd + Shift + C | 复制最后一条 AI 回复 |
屏幕阅读器:
- 使用隐藏的
aria-liveregion 通知新消息到达 - 工具调用状态变化("调用中" -> "已完成")要通知
- 代码块标注
aria-label="TypeScript 代码,共 20 行" - 所有图标按钮提供
aria-label
Q5: 如何设计多模态消息的数据模型?
答案:
采用块级内容模型(Block-based Content Model):每条消息包含一个 ContentBlock[] 数组,而不是单一字符串。每个 block 有独立的 type 字段,由对应的渲染组件处理。
// 一条完整的 AI 回复示例
const message: Message = {
id: 'msg_1',
role: 'assistant',
status: 'done',
createdAt: Date.now(),
blocks: [
// 1. 思考过程(可折叠)
{
type: 'thinking',
content: '用户在问 React 性能优化...',
isCollapsed: true,
},
// 2. 文本回答(Markdown)
{ type: 'text', content: '以下是 React 性能优化的三种方法:' },
// 3. 代码示例
{
type: 'code',
language: 'tsx',
content: 'const MemoComp = React.memo(...)',
filename: 'App.tsx',
},
// 4. 工具调用(搜索了文档)
{
type: 'tool_call',
toolCallId: 'tc_1',
toolName: 'web_search',
args: { query: 'React.memo' },
status: 'done',
result: '...',
},
// 5. 图片
{
type: 'image',
url: '/diagrams/perf.png',
alt: '性能对比图',
source: 'generated',
},
// 6. 引用来源(RAG)
{
type: 'citation',
sources: [
{ title: 'React Docs', url: 'https://react.dev', snippet: '...' },
],
},
],
};
块级模型的优势:
- 类型安全:TypeScript 可辨识联合类型,每个 block 有明确的字段定义
- 渲染解耦:每种 block 由独立组件渲染,互不影响
- 流式友好:新 token 到达时只需 append 到最后一个 block,或创建新 block
- 可扩展:新增内容类型(如音频、表格、图表)只需添加新的 block type
详见 多模态交互 中的多模态输入处理。
Q6: 如何实现文件和图片上传?
答案:
AI 对话中的文件上传需要支持三种输入方式:
1. 按钮选择文件
<input type="file" accept="image/*,.pdf,.txt,.md" multiple onChange={handleFileChange} />
2. 粘贴图片(Ctrl+V)
const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(e.clipboardData.items);
const imageItems = items.filter((item) => item.type.startsWith('image/'));
if (imageItems.length > 0) {
e.preventDefault();
const files = imageItems
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
handleFiles(files);
}
};
3. 拖拽上传(Drag & Drop)
const handleDrop = (e: DragEvent) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
};
图片预览:使用 URL.createObjectURL() 生成本地预览 URL,组件卸载时用 URL.revokeObjectURL() 释放内存。
发送到 API:图片需要编码为 Base64 或上传到 OSS 获取 URL,然后通过 LLM 多模态 API 的 content 数组发送。
文件大小限制:前端校验文件大小,超过限制时提示用户。不同模型对图片有不同的 token 计费方式(如 GPT-4o 按分辨率计算 token)。
Q7: 如何实现消息的编辑重发和分支切换?
答案:
编辑重发是 ChatGPT 的特色功能:用户可以修改之前发送的消息重新提交,AI 会基于修改后的上下文重新生成回答。
数据结构设计(树状对话):
// 每条消息记录父子关系
interface Message {
id: string;
parentId?: string; // 父消息 ID
childrenIds?: string[]; // 子消息 ID 列表(编辑重发产生分支)
activeChildIndex?: number; // 当前展示哪个分支
// ...
}
流程:
- 用户点击编辑按钮,进入编辑模式
- 修改消息内容后提交
- 在原消息下创建新分支(新的 childrenId)
- 向 API 发送截断到编辑点的消息历史 + 新消息
- UI 上显示
< 1/2 >分支切换器
关键细节:发送给 API 的 messages 数组需要从根消息沿 parentId 向上回溯,构建正确的对话路径,而不是简单截取。
Q8: 如何实现打字指示器(Typing Indicator)?有哪些不同的阶段?
答案:
打字指示器需要在不同阶段给用户准确的反馈,避免用户认为应用卡住了。
阶段设计:
| 阶段 | 触发时机 | 显示内容 |
|---|---|---|
connecting | 发送请求,等待首字节 | "连接中..." |
thinking | 模型推理中(Claude thinking 模式) | "思考中..." |
tool_calling | 模型调用工具 | "正在搜索..." / "正在执行代码..." |
generating | 流式 token 开始到达 | 显示实际内容(不再需要指示器) |
超时提示:如果某个阶段超过 30 秒没有进展,显示"响应时间较长,请耐心等待..."。超过 60 秒可提供"取消"按钮。
CSS 动画:三个圆点依次弹跳,使用 animation-delay 实现错位效果。纯 CSS 实现不依赖 JavaScript timer。
Q9: 会话管理(侧边栏)如何设计?自动生成标题如何实现?
答案:
会话列表功能:
- 创建新会话、删除会话、重命名
- 搜索会话(搜索标题和消息内容)
- 固定/取消固定(pinned 会话置顶)
- 归档(超过上限的旧会话自动归档)
- 按更新时间排序
自动生成标题:用户发送第一条消息后,用轻量模型(如 gpt-4o-mini)生成 5-10 字的标题:
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
messages: [
{ role: 'system', content: '用5-10个字概括用户的问题。只输出标题。' },
{ role: 'user', content: firstMessage },
],
max_tokens: 30,
model: 'gpt-4o-mini', // 用便宜模型降低成本
}),
});
存储策略:
- 少量会话(< 50):
localStorage足够 - 大量会话:
IndexedDB(异步、无容量限制) - 生产级:后端数据库 + API,前端只缓存最近的会话
Q10: 流式 Markdown 渲染有什么特殊处理?
答案:
流式 Markdown 渲染的核心问题是内容不完整——AI 正在输出的内容可能处于未闭合状态。
需要处理的场景:
| 未闭合语法 | 示例 | 处理方式 |
|---|---|---|
| 代码块 | ```ts\nconst | 检测奇数个 ```,追加闭合 |
| 粗体 | **部分文 | 追加 ** 闭合 |
| 行内代码 | `code | 追加 ` 闭合 |
| 链接 | [text](url | 追加 ) 闭合 |
| 列表 | 空行后的 - | 等待更多内容,不急于渲染 |
function closeOpenMarkdownSyntax(content: string): string {
let result = content;
// 处理未闭合的代码块
const fences = result.match(/```/g);
if (fences && fences.length % 2 !== 0) {
result += '\n```';
}
// 处理未闭合的粗体
const bolds = result.match(/\*\*/g);
if (bolds && bolds.length % 2 !== 0) {
result += '**';
}
return result;
}
性能优化:
- 增量解析:使用支持增量解析的 Markdown 库(如
markdown-it配合手动增量) - 缓存已完成部分:将已完成段落的渲染结果缓存为 React 元素
- RAF 节流:与 token 缓冲配合,每帧只重新解析一次
详见 流式渲染与 SSE 中的 Markdown 流式渲染部分。
Q11: 消息反馈(thumbs up/down)的前端实现有哪些细节?
答案:
UI 交互:
- 在每条 AI 回复的操作栏显示
+/-按钮 - 点击后高亮选中状态(
aria-pressed="true") - 再次点击取消反馈
- 点击
-后弹出可选的反馈原因("不准确"、"有害内容"、"代码有错误"等)
数据上报:
interface FeedbackPayload {
messageId: string;
conversationId: string;
feedback: 'positive' | 'negative';
comment?: string;
// 完整上下文用于模型改进
context: {
userMessage: string;
assistantMessage: string;
model: string;
temperature: number;
};
}
async function submitFeedback(payload: FeedbackPayload): Promise<void> {
await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
乐观更新:点击反馈后立即更新 UI 状态,不等服务端响应。如果请求失败,回滚状态并提示用户。
Q12: 移动端对话 UI 有哪些特殊适配?
答案:
布局问题:
| 问题 | 解决方案 |
|---|---|
100vh 包含地址栏 | 使用 100dvh(Dynamic Viewport Height) |
| iOS 虚拟键盘弹出不改变 viewport | 使用 window.visualViewport API 监听 |
| iPhone 底部安全区域 | padding-bottom: env(safe-area-inset-bottom) |
| 滚动穿透(弹窗下方可滚动) | 弹窗打开时 body { overflow: hidden } |
交互差异:
- 操作栏触发方式:桌面端 hover 显示,移动端改为长按触发
- 侧边栏:桌面端固定显示,移动端变为滑出抽屉(transform + transition)
- 代码块:桌面端自适应宽度,移动端需要横向滚动(
overflow-x: auto) - 输入框:移动端可考虑增加语音输入按钮
虚拟键盘处理:
// 使用 Visual Viewport API 获取准确的可视区域
const viewport = window.visualViewport!;
viewport.addEventListener('resize', () => {
const keyboardHeight = window.innerHeight - viewport.height;
document.documentElement.style.setProperty(
'--keyboard-height',
`${keyboardHeight}px`,
);
});
// CSS 中使用
// height: calc(100vh - var(--keyboard-height, 0px));
Q13: 如何从零搭建一个完整的 AI 对话应用?技术选型是什么?
答案:
技术栈推荐:
| 层次 | 选择 | 理由 |
|---|---|---|
| 框架 | Next.js (App Router) | SSR + API Routes + 流式支持 |
| AI SDK | Vercel AI SDK | useChat Hook 开箱即用 |
| UI 组件 | shadcn/ui + Tailwind | 高度可定制、无运行时 |
| Markdown | react-markdown + remark-gfm | 支持 GFM 表格、任务列表 |
| 代码高亮 | Shiki | VS Code 引擎、颜色准确 |
| 状态管理 | Zustand | 轻量、支持持久化中间件 |
| 存储 | IndexedDB (Dexie.js) | 大容量异步存储 |
核心流程:
使用 Vercel AI SDK 的极简实现:
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
});
return result.toDataStreamResponse();
}
'use client';
import { useChat } from '@ai-sdk/react';
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat();
return (
<div>
{messages.map(m => (
<div key={m.id}>{m.role}: {m.content}</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
{isLoading ? <button onClick={stop}>Stop</button> : <button type="submit">Send</button>}
</form>
</div>
);
}
详见 AI SDK 与框架 中 Vercel AI SDK 的完整用法。
Q14: 思考过程(Thinking/Reasoning)如何展示?
答案:
Claude 的 Extended Thinking 和 OpenAI 的 Reasoning(o1/o3 系列)会在最终回答前输出思考过程。前端展示需要:
1. 数据模型:将 thinking 作为独立的 ContentBlock
{ type: 'thinking', content: '让我分析一下这个问题...', isCollapsed: true, duration: 3200 }
2. UI 设计:
- 默认折叠,显示"思考了 3.2 秒"
- 点击展开查看完整思考内容
- 用视觉差异区分思考和正式回答(如浅色背景、斜体)
- 流式输出期间,思考内容实时展示(通常没有 Markdown 格式,纯文本即可)
3. 流式处理:
// SSE 事件类型区分
// Claude: content_block_start -> type: "thinking"
// OpenAI: 通过 reasoning_content 字段
function handleStreamEvent(event: StreamEvent) {
if (
event.type === 'content_block_start' &&
event.content_block.type === 'thinking'
) {
// 开始新的思考块
addBlock({ type: 'thinking', content: '', isCollapsed: false });
} else if (
event.type === 'content_block_delta' &&
event.delta.type === 'thinking_delta'
) {
// 追加思考内容
appendToLastBlock(event.delta.thinking);
} else if (event.type === 'content_block_stop') {
// 思考结束,自动折叠
updateLastBlock({ isCollapsed: true, duration: elapsed });
}
}
Q15: 对话导出和分享功能如何实现?
答案:
导出格式:
| 格式 | 实现方式 | 适用场景 |
|---|---|---|
| Markdown | 遍历消息块拼接文本 | 技术文档、笔记 |
| JSON | JSON.stringify(conversation) | 数据备份、迁移 |
| 图片 | html2canvas 截图 | 社交分享 |
| 服务端 Puppeteer 渲染 | 正式文档 |
分享链接:
- 将对话数据存储到服务端(或生成唯一 hash)
- 生成形如
https://app.com/share/abc123的链接 - 访问链接时加载只读版对话界面
- 注意隐私:分享前确认用户意图,提供"匿名化"选项(移除个人信息)
async function exportAsMarkdown(conversation: Conversation): Promise<string> {
let md = `# ${conversation.title}\n\n`;
md += `> 模型: ${conversation.model} | 时间: ${new Date(conversation.createdAt).toLocaleString()}\n\n`;
for (const msg of conversation.messages) {
const role = msg.role === 'user' ? '**You**' : '**AI**';
md += `### ${role}\n\n`;
for (const block of msg.blocks) {
switch (block.type) {
case 'text':
md += block.content + '\n\n';
break;
case 'code':
md += `\`\`\`${block.language}\n${block.content}\n\`\`\`\n\n`;
break;
case 'thinking':
md += `<details><summary>思考过程</summary>\n\n${block.content}\n\n</details>\n\n`;
break;
}
}
md += '---\n\n';
}
return md;
}
Q16: 流式渲染到一半关掉页面,下次打开内容丢失怎么处理?
答案:
核心思路是边收边存,不要等流结束才持久化。分三层防护:
第一层:客户端增量持久化
流式接收过程中,用定时器(500ms 间隔)将当前内容写入 IndexedDB,消息状态标记为 streaming:
// 每 500ms 将已收到的内容写入 IndexedDB
const timer = setInterval(() => {
db.messages.update(messageId, {
content: currentBuffer,
status: 'streaming',
updatedAt: Date.now(),
});
}, 500);
第二层:页面关闭前兜底
beforeunload 事件中不能用异步 API(IndexedDB 是异步的),退回 localStorage 做最后一次同步写入:
window.addEventListener('beforeunload', () => {
if (buffer) {
localStorage.setItem(
'stream_recovery',
JSON.stringify({
messageId,
content: buffer,
timestamp: Date.now(),
}),
);
}
});
第三层:服务端持久化(生产级推荐)
BFF 层在转发 LLM 响应时同步落库。客户端重连后从服务端恢复,不依赖本地存储:
Client ← SSE ← BFF ← LLM
↓
DB(边收边写,标记 status)
恢复流程:打开页面时检查 IndexedDB 中 status === 'streaming' 的消息,展示已有内容 + "回答未完成"提示,提供两个按钮:
- 继续生成:将已有内容作为 assistant prefill 发送给 API,让模型接着输出(Anthropic Claude 对此支持较好)
- 重新生成:丢弃已有内容,重新请求
Q17: AI 对话中有哪些常见的异常场景?分别怎么处理?
答案:
AI 对话的异常场景分三类:连接层异常、业务层异常、用户操作异常。
连接层异常:
| 异常 | 检测方式 | 处理策略 |
|---|---|---|
| 网络断开 | fetch 抛出 TypeError | 指数退避重试(最多 3 次),展示"网络异常"提示 |
| SSE 连接中断 | reader.read() 提前返回 done | 检查消息是否完整,不完整则走中断恢复流程 |
| 请求超时 | AbortController + setTimeout | 超时后中断请求,展示已有内容 + 重试按钮 |
| 429 限流 | response.status === 429 | 读取 Retry-After 头,显示倒计时后自动重试 |
业务层异常:
| 异常 | 检测方式 | 处理策略 |
|---|---|---|
| 上下文窗口溢出 | 413 或 API 返回 context_length_exceeded | 自动截断历史消息或生成摘要,提示用户可开新对话 |
| Token 超限截断 | finish_reason === 'length' | 显示已有内容 + "回答被截断"提示 + "继续生成"按钮 |
| 内容安全过滤 | finish_reason === 'content_filter' | 提示"该内容无法生成",不可重试(重试结果一样) |
| 模型服务 500 | response.status >= 500 | 自动重试 1-2 次,失败后显示"服务暂时不可用" |
用户操作异常:
| 异常 | 处理策略 |
|---|---|
| 流式中途发新消息 | 中止当前流,保留已有内容标记为 done,然后发送新消息 |
| 快速连续点击发送 | 防抖(1s 间隔),忽略重复请求 |
| 用户主动停止生成 | AbortController.abort(),保留已有内容标记为 done(不是 error) |
| 页面切到后台 | visibilitychange 监听,流式接收继续但暂停 UI 更新,切回前台时一次性刷新 |
错误 UI 设计原则:
- 区分可重试和不可重试:网络错误显示"重试"按钮,内容过滤不显示
- 不丢用户已有数据:任何异常都保留已接收内容
- 给用户选择权:中断后提供"继续生成"和"重新生成"两个选项
Q18: AI 对话中的虚拟滚动和普通列表的虚拟滚动有什么区别?
答案:
AI 对话的虚拟滚动有四个特殊难点,是普通列表(如商品列表)不会遇到的:
1. 每条消息高度不固定且动态变化
普通列表的 item 高度通常在渲染后就稳定了。但 AI 消息在流式输出期间高度持续增长,图片加载后高度突变,代码块展开/折叠时高度改变。虚拟列表需要频繁重新计算布局。
2. 需要底部对齐
普通列表从顶部开始排列,AI 对话需要消息从底部向上排列(消息少时底部对齐),类似 IM 聊天。react-window 不支持底部对齐,需要大量 hack。
3. 需要自动跟随新内容
流式输出时如果用户在底部,需要自动跟随滚动;用户在上方浏览历史时,不能打断。这需要虚拟列表和智能滚动逻辑深度配合。
4. 需要向上加载历史(反向滚动)
用户滚动到顶部时需要加载更早的消息,且加载后滚动位置不能跳动。
方案对比:
| 对比项 | react-window | react-virtuoso |
|---|---|---|
| 动态高度 | VariableSizeList + 手动测量 + resetAfterIndex | 自动测量,开箱即用 |
| 底部对齐 | 不支持 | alignToBottom 原生支持 |
| 自动跟随 | 需自己实现 | followOutput 原生支持 |
| 反向加载 | 需大量手动处理 | firstItemIndex + startReached |
| 包体积 | ~6KB gzipped | ~15KB gzipped |
启用阈值建议:消息少于 50 条时直接渲染(虚拟列表的测量开销反而更高),超过 50 条再切换为虚拟列表:
{messages.length > 50 ? (
<VirtualMessageList messages={messages} {...props} />
) : (
<SimpleMessageList messages={messages} {...props} />
)}
Q19: AI 对话中调用工具时,前端、后端和 LLM 之间的交互流程是怎样的?
答案:
工具调用(Function Calling / Tool Use)是 AI Agent 的核心能力。关键点:LLM 不会自己调用 API,它只输出一个结构化的"调用指令",实际执行由后端完成。
完整流程(以用户问"今天北京天气怎么样"为例):
第一步:后端把工具定义发给 LLM
LLM 根据用户问题和 tools 列表,自主决定是否需要调用工具、调用哪个、传什么参数:
// 后端发给 LLM 的请求
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
messages: [{ role: 'user', content: '今天北京天气怎么样' }],
tools: [
{
name: 'get_weather',
description: '查询指定城市的天气',
input_schema: {
type: 'object',
properties: {
city: { type: 'string', description: '城市名' },
},
required: ['city'],
},
},
],
});
第二步:LLM 返回工具调用指令(不是文本)
// LLM 返回的不是文本,而是 tool_use 对象
{
type: "tool_use",
id: "call_abc123",
name: "get_weather", // 要调用的工具名
input: { city: "beijing" } // 参数
}
第三步:后端执行工具,结果发回 LLM,循环直到 LLM 给出文本回答
一次对话可能需要多轮工具调用(如"对比北京和上海天气"需要调两次),后端用循环处理:
async function chat(userMessage: string, tools: Tool[]) {
const messages = [{ role: 'user', content: userMessage }];
while (true) {
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
messages,
tools,
});
messages.push({ role: 'assistant', content: response.content });
// 检查是否有工具调用
const toolCalls = response.content.filter(
(block) => block.type === 'tool_use'
);
if (toolCalls.length === 0) {
return response; // 没有工具调用,LLM 直接给出文本,结束
}
// 执行所有工具,收集结果
const toolResults = await Promise.all(
toolCalls.map(async (tc) => ({
type: 'tool_result' as const,
tool_use_id: tc.id,
content: JSON.stringify(await executeTool(tc.name, tc.input)),
}))
);
// 结果发回 LLM,继续下一轮
messages.push({ role: 'user', content: toolResults });
}
}
前端收到的 SSE 事件流及对应 UI 状态:
| SSE 事件 | 前端 UI 展示 |
|---|---|
thinking_delta | 灰色折叠区展示思考过程 |
tool_call_start | 显示"正在查询天气..." + loading 动画 |
tool_call_delta | 可选:实时展示工具参数(JSON 片段拼接) |
tool_call_end | 工具参数完整,等待执行结果 |
tool_result | 显示执行结果摘要,可展开看详细 JSON |
text_delta | 流式渲染 Markdown 文本(最终回答) |
done | 消息完成,显示操作栏(复制/重试/反馈) |
详见 Function Calling 与 AI Agent 中工具调用的完整实现。
相关链接
- 流式渲染与 SSE - 流式数据传输与 Markdown 渲染
- Function Calling 与 AI Agent - 工具调用的完整实现
- AI 应用性能优化 - 渲染性能与缓存策略
- 多模态交互 - 图片/语音/视频的输入输出
- AI SDK 与框架 - Vercel AI SDK、LangChain.js
- AI 生成 UI - Generative UI 与动态组件
- AI 应用安全 - Prompt 注入防护与内容安全
- 长列表优化 - 虚拟滚动技术
- React 性能优化 - React.memo、useMemo、useCallback
- MDN: Visual Viewport API
- MDN: Clipboard API
- WAI-ARIA: Log Role
- react-virtuoso - 支持动态高度的虚拟列表
- Shiki - VS Code 语法高亮引擎