跳到主要内容

AI 对话界面设计

问题

如何设计一个高性能的 AI 对话界面?包括消息列表、代码高亮、Markdown 渲染、思考过程展示、文件上传、会话管理、移动端适配、无障碍支持等。

答案

AI 对话界面是 LLM 产品的核心交互载体。与传统 IM 聊天应用不同,AI 对话需要处理流式渲染多内容类型混排(文本/代码/图片/图表/工具调用)和复杂的中间状态(思考中/工具调用中/生成中)。产品上的差异化体验(如 ChatGPT、Claude、Gemini)往往体现在 UI 细节的打磨上。

核心设计原则
  1. 流式优先:所有 UI 必须支持增量渲染,不能等完整响应后再显示
  2. 块级内容模型:消息内容不是单一字符串,而是 ContentBlock[] 数组
  3. 状态驱动:消息有完整的生命周期状态机(pending -> streaming -> done/error)
  4. 性能敏感:历史消息必须 memo 化,流式更新用 RAF 批量合并

一、消息数据模型

消息数据模型的设计直接决定了 UI 层的灵活性。一个好的模型需要支持多模态内容、工具调用链、思考过程、错误状态等复杂场景。

types/message.ts
// ===== 内容块类型 =====

/** 纯文本块:支持 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 中最核心的组件,也是性能优化的重点区域。智能滚动需要处理多种场景:流式输出时自动跟随、用户查看历史时停止跟随、出现新消息时提示用户。

components/MessageList.tsx
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>
);
}
自动滚动的常见陷阱
  1. 不要在流式输出期间用 smooth 滚动:smooth 动画有延迟,token 到达速度可能超过滚动速度,导致滚动跟不上。流式期间应使用 instant
  2. 不要用 scrollTop = scrollHeight:这会导致每次内容变化时闪烁。用 scrollIntoView 配合底部锚点元素更稳定
  3. 阈值不要设太小< 10px 的判定太敏感,用户轻微触摸就会误判为"离开底部"。建议 100px
  4. showScrollButton 需要用 useState:用 ref 存储会导致按钮不显示,因为 ref 变化不会触发重渲染

三、消息气泡与操作栏

每条消息气泡需要渲染多种内容块,并提供复制、重试、反馈等操作。

components/MessageBubble.tsx
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="上一个分支"
>
&lt;
</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="下一个分支"
>
&gt;
</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 对话产品的重要交互细节,主要包括复制、重新生成、编辑重发和反馈评价。

components/MessageActions.tsx
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(人类反馈强化学习) 的核心数据来源。前端需要收集:

  1. 反馈类型:positive / negative
  2. 消息上下文:用户问题 + AI 回答的完整对话
  3. 可选评语:负面反馈时引导用户说明原因(如"回答不准确"、"代码有错误")
  4. 模型信息:哪个模型、哪个版本产出的回答

这些数据上报后用于模型微调和产品迭代,详见 AI 应用安全 中的内容安全部分。

五、代码块组件(语法高亮 + 复制)

AI 回答中频繁出现代码块,高质量的代码渲染直接影响开发者用户的体验。

components/CodeBlock.tsx
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 的"思考"和"行动"过程。

components/ToolCallBlock.tsx
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>
);
}

七、打字指示器与加载状态

打字指示器需要在不同阶段给用户准确的反馈,避免"卡住了"的错觉。

components/TypingIndicator.tsx
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>
);
}
styles/typing-indicator.css
.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 交互的入口,需要支持自适应高度、文件上传、粘贴图片、快捷键等。

components/ChatInput.tsx
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 对话产品的基础功能。

hooks/useConversation.ts
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 对话界面在移动端面临屏幕空间有限、虚拟键盘弹出、触摸交互等挑战。

hooks/useMobileKeyboard.ts
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 };
}
styles/mobile-chat.css
/* 移动端对话界面核心布局 */
.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 动态调整
滚动到底部后页面被拉起橡皮筋效果 + overscrolloverscroll-behavior: contain
100vh 包含地址栏高度移动端 vh 单位问题使用 100dvhwindow.innerHeight
长按选择文本触发系统菜单默认浏览器行为操作按钮区域添加 user-select: none
代码块无法横向滚动内容溢出限制 max-width 并设置 overflow-x: auto

十一、无障碍(Accessibility)

AI 对话界面的无障碍支持不仅是法律要求(ADA/WCAG),也是企业级产品的必备能力。

components/AccessibleChat.tsx
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 };
}
对话 UI 的无障碍检查清单

ARIA 角色与属性

  • 消息列表使用 role="log",表示按时间顺序排列的内容
  • 每条消息使用 role="article"
  • 操作栏使用 role="toolbar"
  • 新消息区域使用 aria-live="polite"(不打断用户当前操作)
  • 加载指示器使用 role="status"

键盘导航

  • Tab / Shift+Tab 在消息间导航
  • EnterSpace 展开/折叠思考过程和工具调用
  • Ctrl/Cmd + / 快速聚焦输入框
  • Escape 停止生成

屏幕阅读器

  • 新 AI 回复完成后通过 live region 通知
  • 工具调用状态变化(调用中 -> 完成)要通知
  • 代码块标注语言信息,如 "TypeScript 代码块,共 20 行"
  • 图片提供有意义的 alt 文本

十二、性能优化策略

hooks/useStreamBuffer.ts
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,它原生支持:

  • 动态高度自动测量
  • 底部对齐(新消息出现在底部)
  • followOutput 属性实现自动滚动跟随

详见 长列表优化AI 应用性能优化

十三、消息列表虚拟滚动

AI 对话场景下的虚拟滚动比普通列表复杂得多:每条消息高度不固定(代码块、图片、工具调用等)、流式渲染中高度持续变化、需要底部对齐(新消息从底部出现)。推荐使用 react-virtuoso 而非 react-window

components/VirtualMessageList.tsx
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-virtuoso 而不是 react-window?
对比项react-windowreact-virtuoso
动态高度需要手动测量 + VariableSizeList自动测量,开箱即用
底部对齐不支持,需要自己 hackalignToBottom 属性原生支持
自动跟随新内容不支持followOutput 属性原生支持
流式内容高度变化高度缓存失效需手动 resetAfterIndex自动检测高度变化并重新布局
反向滚动(加载历史)需要大量手动处理firstItemIndex 支持向上追加
包体积~6KB gzipped~15KB gzipped

总结react-window 适合高度固定或变化不频繁的列表(如商品列表);AI 对话这种动态高度 + 底部对齐 + 流式跟随的场景,react-virtuoso 是更合适的选择。

虚拟滚动在 AI 对话中的坑
  1. 流式消息高度持续增长:最后一条消息在流式输出期间高度不断变化,虚拟列表需要频繁重新计算布局。react-virtuoso 内部用 ResizeObserver 监听每个 item 的高度变化,自动处理。但如果用 react-window,需要在每次 token 到达后手动调用 resetAfterIndex(lastIndex)

  2. Markdown 渲染导致高度突变:一段普通文本突然变成代码块或表格,高度可能瞬间翻倍。需要确保虚拟列表能平滑处理这种突变,否则滚动位置会跳动

  3. 图片加载后高度变化:图片从占位符变为实际图片时高度改变。建议图片块预设 aspect-ratio 或固定高度占位,避免布局抖动

  4. 启用阈值:不要一开始就用虚拟列表。消息少于 50 条时虚拟列表的开销(测量、计算)反而高于直接渲染。建议设置阈值:

// 消息少时直接渲染,多时切换为虚拟列表
{messages.length > 50 ? (
<VirtualMessageList messages={messages} ... />
) : (
<SimpleMessageList messages={messages} ... />
)}
  1. 加载历史消息(向上滚动加载):用户滚动到顶部时需要加载更早的消息。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. 流式中断恢复(页面关闭 / 网络断开)

流式渲染到一半时用户关闭页面或网络断开,下次打开时内容丢失。核心思路:边收边存,不要等流结束才持久化。

hooks/useStreamPersistence.ts
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 };
}

恢复策略:下次打开页面时检查是否有未完成的消息。

hooks/useStreamRecovery.ts
/**
* 页面打开时恢复中断的流式消息
*/
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 层处理

components/InterruptedMessage.tsx
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 连接中断

hooks/useNetworkAwareStream.ts
/**
* 网络感知的流式请求
* 处理网络断开、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. 其他常见异常场景

utils/error-handlers.ts
/**
* 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 展示

components/ErrorBanner.tsx
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>
);
}
异常处理的核心原则
  1. 永远不丢数据:流式内容边收边存(IndexedDB),页面关闭前兜底写入(localStorage / beforeunload)
  2. 给用户选择权:中断后不要自动重试整个请求,而是展示已有内容 + "继续生成" / "重新生成"按钮
  3. 区分可重试和不可重试:网络错误、服务端 500 可以重试;内容过滤、上下文溢出重试没用
  4. 优雅降级
    • IndexedDB 不可用 → 退回 localStorage
    • beforeunload 中不能用异步 API → 用同步 localStorage
    • 模型不支持 prefill 续写 → 退回重新生成
  5. 避免重复请求:用户快速点击、网络抖动触发重试时,用 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
  • showScrollButtonuseState(需要触发 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-live region 通知新消息到达
  • 工具调用状态变化("调用中" -> "已完成")要通知
  • 代码块标注 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: '...' },
],
},
],
};

块级模型的优势

  1. 类型安全:TypeScript 可辨识联合类型,每个 block 有明确的字段定义
  2. 渲染解耦:每种 block 由独立组件渲染,互不影响
  3. 流式友好:新 token 到达时只需 append 到最后一个 block,或创建新 block
  4. 可扩展:新增内容类型(如音频、表格、图表)只需添加新的 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; // 当前展示哪个分支
// ...
}

流程

  1. 用户点击编辑按钮,进入编辑模式
  2. 修改消息内容后提交
  3. 在原消息下创建新分支(新的 childrenId)
  4. 向 API 发送截断到编辑点的消息历史 + 新消息
  5. 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 SDKVercel AI SDKuseChat Hook 开箱即用
UI 组件shadcn/ui + Tailwind高度可定制、无运行时
Markdownreact-markdown + remark-gfm支持 GFM 表格、任务列表
代码高亮ShikiVS Code 引擎、颜色准确
状态管理Zustand轻量、支持持久化中间件
存储IndexedDB (Dexie.js)大容量异步存储

核心流程

使用 Vercel AI SDK 的极简实现

app/api/chat/route.ts
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();
}
app/page.tsx
'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遍历消息块拼接文本技术文档、笔记
JSONJSON.stringify(conversation)数据备份、迁移
图片html2canvas 截图社交分享
PDF服务端 Puppeteer 渲染正式文档

分享链接

  1. 将对话数据存储到服务端(或生成唯一 hash)
  2. 生成形如 https://app.com/share/abc123 的链接
  3. 访问链接时加载只读版对话界面
  4. 注意隐私:分享前确认用户意图,提供"匿名化"选项(移除个人信息)
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'提示"该内容无法生成",不可重试(重试结果一样)
模型服务 500response.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-windowreact-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 中工具调用的完整实现。

相关链接