AI大模型应用前端UI设计完全教程

教程简介

本教程全面讲解AI大模型应用前端UI设计的核心技术与最佳实践,涵盖对话式UI设计模式、SSE/WebSocket流式输出渲染、Markdown实时渲染、多模态输入组件、对话历史管理、AI思考过程可视化、错误处理与加载状态、响应式适配等核心内容,提供完整的Next.js AI聊天应用实战案例。

AI大模型应用前端UI设计完全教程

1. AI应用前端设计原则与趋势

AI应用的前端设计与传统Web应用存在本质差异。传统应用的核心是"人操作界面",而AI应用的核心是"人与AI对话"。这种交互范式的转变,要求前端设计遵循以下原则:

渐进式披露(Progressive Disclosure):不要一次性展示所有功能。AI的能力边界模糊,用户需要逐步探索。首次进入时只展示核心对话入口,高级功能(如模型选择、参数调节、知识库管理)折叠到侧边栏或设置面板中。

流式优先(Streaming First):用户发出请求后,AI的响应是逐步生成的。前端必须从架构层面支持流式渲染,而非等待完整响应后再展示。这不仅降低感知延迟,还让用户有时间阅读和思考。

容错设计(Graceful Degradation):AI可能返回错误、产生幻觉、或超出预期。前端需要提供"重新生成"、"编辑后重新发送"、"反馈(👍👎)"等机制,让用户能优雅地纠正AI的行为。

上下文感知(Context Awareness):对话是有状态的。前端需要维护完整的对话上下文,支持上下文窗口管理、历史消息引用、以及会话级别的配置持久化。

2024-2025年的主要设计趋势包括:

  • 侧边栏 + 主区域布局:左侧会话列表,右侧对话区域,已成为事实标准(ChatGPT、Claude、Gemini均采用此模式)
  • 多模态融合输入:文本、图片、文件、语音混合输入
  • AI思考过程透明化:展示推理链(Chain of Thought)、工具调用过程
  • 嵌入式AI(Embedded AI):AI能力嵌入到现有产品中,而非独立应用

2. 对话式UI设计模式

对话式UI是AI应用的核心交互模式。一个完整的对话消息组件需要处理多种内容类型:

// 消息类型定义
interface Message {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: number;
  status: 'sending' | 'streaming' | 'complete' | 'error';
  attachments?: Attachment[];
  thinkingProcess?: string;    // AI思考过程
  tokenUsage?: {               // Token用量
    prompt: number;
    completion: number;
  };
  feedback?: 'positive' | 'negative' | null;
}

interface Attachment {
  type: 'image' | 'file' | 'audio';
  url: string;
  name: string;
  size: number;
}

消息气泡组件的设计要点:

import { memo } from 'react';

const MessageBubble = memo(function MessageBubble({ message }: { message: Message }) {
  const isUser = message.role === 'user';

  return (
    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
      {!isUser && (
        <div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center mr-3 flex-shrink-0">
          <SparklesIcon className="w-4 h-4 text-white" />
        </div>
      )}

      <div className={`max-w-[80%] ${isUser ? 'order-first' : ''}`}>
        {/* 思考过程折叠区 */}
        {message.thinkingProcess && (
          <ThinkingBlock content={message.thinkingProcess} />
        )}

        {/* 主内容区 */}
        <div className={`rounded-2xl px-4 py-3 ${
          isUser
            ? 'bg-blue-600 text-white rounded-br-md'
            : 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-md'
        }`}>
          {message.status === 'streaming' ? (
            <StreamingContent content={message.content} />
          ) : (
            <MarkdownRenderer content={message.content} />
          )}
        </div>

        {/* 附件预览 */}
        {message.attachments?.map(att => (
          <AttachmentPreview key={att.url} attachment={att} />
        ))}

        {/* 消息操作栏 */}
        {!isUser && message.status === 'complete' && (
          <MessageActions message={message} />
        )}
      </div>
    </div>
  );
});

自动滚动是对话UI的关键体验细节。新消息到来时应自动滚动到底部,但用户手动向上滚动后不应强制拉回:

function useAutoScroll(deps: any[]) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [isAtBottom, setIsAtBottom] = useState(true);

  const handleScroll = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    const threshold = 100;
    setIsAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < threshold);
  }, []);

  useEffect(() => {
    if (isAtBottom) {
      scrollRef.current?.scrollTo({
        top: scrollRef.current.scrollHeight,
        behavior: 'smooth',
      });
    }
  }, deps);

  return { scrollRef, handleScroll, isAtBottom };
}

3. 流式输出渲染(SSE/WebSocket)

流式输出是AI应用前端的核心技术挑战。主流方案有两种:Server-Sent Events(SSE)和WebSocket。对于单向的AI响应流,SSE是更简洁的选择。

SSE方案实现

// 流式请求封装
async function* streamChat(messages: Message[], signal?: AbortSignal) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages, stream: true }),
    signal,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = line.slice(6);
          if (data === '[DONE]') return;
          try {
            yield JSON.parse(data);
          } catch {
            // 跳过格式错误的行
          }
        }
      }
    }
  } finally {
    reader.releaseLock();
  }
}

React Hook封装

function useStreamChat() {
  const [isStreaming, setIsStreaming] = useState(false);
  const [content, setContent] = useState('');
  const abortRef = useRef<AbortController | null>(null);

  const startStream = useCallback(async (messages: Message[]) => {
    abortRef.current?.abort();
    abortRef.current = new AbortController();
    setIsStreaming(true);
    setContent('');

    try {
      for await (const chunk of streamChat(messages, abortRef.current.signal)) {
        if (chunk.choices?.[0]?.delta?.content) {
          setContent(prev => prev + chunk.choices[0].delta.content);
        }
      }
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') {
        // 用户主动取消
      } else {
        throw err;
      }
    } finally {
      setIsStreaming(false);
    }
  }, []);

  const stopStream = useCallback(() => {
    abortRef.current?.abort();
  }, []);

  return { content, isStreaming, startStream, stopStream };
}

打字机效果与防闪烁

流式渲染时,逐字出现的打字机效果能显著提升体验。但需要平衡渲染频率,避免过快更新导致的性能问题:

function StreamingContent({ content }: { content: string }) {
  const [displayContent, setDisplayContent] = useState('');
  const queueRef = useRef<string>('');
  const rafRef = useRef<number>();

  useEffect(() => {
    queueRef.current = content;

    const flush = () => {
      const target = queueRef.current;
      if (displayContent.length < target.length) {
        // 每帧最多追加3个字符,保持流畅感
        const nextLen = Math.min(displayContent.length + 3, target.length);
        setDisplayContent(target.slice(0, nextLen));
        rafRef.current = requestAnimationFrame(flush);
      }
    };

    rafRef.current = requestAnimationFrame(flush);
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [content]);

  return <MarkdownRenderer content={displayContent} />;
}

4. Markdown实时渲染与代码高亮

AI响应通常包含丰富的Markdown格式:标题、列表、代码块、表格、LaTeX公式等。实时渲染需要处理"不完整Markdown"的情况——流式过程中代码块可能只有开始标记而没有结束标记。

import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';

function MarkdownRenderer({ content }: { content: string }) {
  // 预处理:补全未闭合的代码块
  const processedContent = preprocessMarkdown(content);

  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm, remarkMath]}
      rehypePlugins={[rehypeKatex]}
      components={{
        code({ node, inline, className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || '');
          const codeString = String(children).replace(/\n$/, '');

          if (!inline && match) {
            return <CodeBlock language={match[1]} code={codeString} />;
          }

          return (
            <code className="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm" {...props}>
              {children}
            </code>
          );
        },
        table({ children }) {
          return (
            <div className="overflow-x-auto my-2">
              <table className="min-w-full border-collapse border border-gray-300 dark:border-gray-600">
                {children}
              </table>
            </div>
          );
        },
      }}
    >
      {processedContent}
    </ReactMarkdown>
  );
}

// 预处理不完整的Markdown
function preprocessMarkdown(content: string): string {
  // 统计未闭合的代码块
  const codeFences = content.match(/```/g);
  if (codeFences && codeFences.length % 2 !== 0) {
    // 有未闭合的代码块,添加结束标记
    return content + '\n```';
  }
  return content;
}

代码块组件需要支持复制功能和语言标识:

function CodeBlock({ language, code }: { language: string; code: string }) {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    await navigator.clipboard.writeText(code);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  };

  return (
    <div className="relative group my-3 rounded-lg overflow-hidden">
      <div className="flex items-center justify-between bg-gray-800 px-4 py-2 text-xs text-gray-400">
        <span>{language}</span>
        <button
          onClick={handleCopy}
          className="flex items-center gap-1 hover:text-white transition-colors"
        >
          {copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
          {copied ? '已复制' : '复制'}
        </button>
      </div>
      <SyntaxHighlighter
        language={language}
        style={oneDark}
        customStyle={{ margin: 0, borderRadius: '0 0 8px 8px', fontSize: '14px' }}
        showLineNumbers={code.split('\n').length > 5}
      >
        {code}
      </SyntaxHighlighter>
    </div>
  );
}

5. 多模态输入组件(图片/文件/语音)

现代AI应用需要支持多种输入方式。一个多模态输入组件应当统一管理文本、图片、文件和语音输入:

function MultiModalInput({ onSend }: { onSend: (data: InputData) => void }) {
  const [text, setText] = useState('');
  const [attachments, setAttachments] = useState<File[]>([]);
  const [isRecording, setIsRecording] = useState(false);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  // 自动调整文本框高度
  const adjustHeight = useCallback(() => {
    const el = textareaRef.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
  }, []);

  // 拖拽上传
  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    const files = Array.from(e.dataTransfer.files);
    setAttachments(prev => [...prev, ...files]);
  }, []);

  // 粘贴图片
  const handlePaste = useCallback((e: React.ClipboardEvent) => {
    const items = Array.from(e.clipboardData.items);
    for (const item of items) {
      if (item.type.startsWith('image/')) {
        const file = item.getAsFile();
        if (file) setAttachments(prev => [...prev, file]);
      }
    }
  }, []);

  // 发送消息
  const handleSend = () => {
    if (!text.trim() && attachments.length === 0) return;
    onSend({ text: text.trim(), attachments });
    setText('');
    setAttachments([]);
    textareaRef.current?.focus();
  };

  // Ctrl+Enter / Enter 发送
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
      {/* 附件预览区 */}
      {attachments.length > 0 && (
        <div className="flex gap-2 mb-3 overflow-x-auto pb-2">
          {attachments.map((file, i) => (
            <AttachmentChip
              key={i}
              file={file}
              onRemove={() => setAttachments(prev => prev.filter((_, j) => j !== i))}
            />
          ))}
        </div>
      )}

      <div className="flex items-end gap-2">
        <div className="flex-1 relative">
          <textarea
            ref={textareaRef}
            value={text}
            onChange={e => { setText(e.target.value); adjustHeight(); }}
            onKeyDown={handleKeyDown}
            onPaste={handlePaste}
            onDrop={handleDrop}
            onDragOver={e => e.preventDefault()}
            placeholder="输入消息... (Shift+Enter 换行)"
            rows={1}
            className="w-full resize-none rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 px-4 py-3 pr-24 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-white"
          />

          {/* 输入区工具栏 */}
          <div className="absolute right-2 bottom-2 flex items-center gap-1">
            <ToolButton icon={<ImageIcon />} onClick={() => fileInputRef.current?.click()} tooltip="上传图片" />
            <ToolButton icon={<MicIcon />} onClick={() => setIsRecording(!isRecording)} tooltip="语音输入" active={isRecording} />
            <ToolButton icon={<PaperclipIcon />} onClick={() => fileInputRef.current?.click()} tooltip="上传文件" />
          </div>
        </div>

        <button
          onClick={handleSend}
          disabled={!text.trim() && attachments.length === 0}
          className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          <SendIcon className="w-5 h-5" />
        </button>
      </div>

      <input
        ref={fileInputRef}
        type="file"
        multiple
        accept="image/*,.pdf,.doc,.docx,.txt,.csv,.json"
        className="hidden"
        onChange={e => {
          const files = Array.from(e.target.files || []);
          setAttachments(prev => [...prev, ...files]);
          e.target.value = '';
        }}
      />
    </div>
  );
}

语音输入需要调用浏览器的MediaRecorder API:

function useVoiceInput() {
  const [isRecording, setIsRecording] = useState(false);
  const [transcript, setTranscript] = useState('');
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
  const chunksRef = useRef<Blob[]>([]);

  const startRecording = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mediaRecorder = new MediaRecorder(stream, {
        mimeType: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
          ? 'audio/webm;codecs=opus'
          : 'audio/webm',
      });

      chunksRef.current = [];

      mediaRecorder.ondataavailable = (e) => {
        if (e.data.size > 0) chunksRef.current.push(e.data);
      };

      mediaRecorder.onstop = async () => {
        const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
        // 发送到后端进行语音识别
        const result = await transcribeAudio(blob);
        setTranscript(result);
        stream.getTracks().forEach(t => t.stop());
      };

      mediaRecorder.start(100); // 每100ms收集一次数据
      mediaRecorderRef.current = mediaRecorder;
      setIsRecording(true);
    } catch (err) {
      console.error('无法访问麦克风:', err);
    }
  };

  const stopRecording = () => {
    mediaRecorderRef.current?.stop();
    setIsRecording(false);
  };

  return { isRecording, transcript, startRecording, stopRecording };
}

async function transcribeAudio(blob: Blob): Promise<string> {
  const formData = new FormData();
  formData.append('audio', blob, 'recording.webm');
  const res = await fetch('/api/transcribe', { method: 'POST', body: formData });
  const data = await res.json();
  return data.text;
}

6. 对话历史管理与搜索

会话管理是AI应用的基础功能。需要支持会话列表、创建、删除、重命名、以及全文搜索:

// 会话状态管理(Zustand示例)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface Conversation {
  id: string;
  title: string;
  messages: Message[];
  createdAt: number;
  updatedAt: number;
  model: string;
  systemPrompt?: string;
}

interface ChatStore {
  conversations: Conversation[];
  activeId: string | null;
  searchQuery: string;

  // 操作
  createConversation: () => string;
  deleteConversation: (id: string) => void;
  renameConversation: (id: string, title: string) => void;
  setActiveId: (id: string) => void;
  addMessage: (conversationId: string, message: Message) => void;
  updateMessage: (conversationId: string, messageId: string, content: string) => void;
  setSearchQuery: (query: string) => void;

  // 计算
  activeConversation: () => Conversation | undefined;
  filteredConversations: () => Conversation[];
}

const useChatStore = create<ChatStore>()(
  persist(
    (set, get) => ({
      conversations: [],
      activeId: null,
      searchQuery: '',

      createConversation: () => {
        const id = crypto.randomUUID();
        const conv: Conversation = {
          id,
          title: '新对话',
          messages: [],
          createdAt: Date.now(),
          updatedAt: Date.now(),
          model: 'gpt-4',
        };
        set(state => ({
          conversations: [conv, ...state.conversations],
          activeId: id,
        }));
        return id;
      },

      deleteConversation: (id) => {
        set(state => ({
          conversations: state.conversations.filter(c => c.id !== id),
          activeId: state.activeId === id ? state.conversations[0]?.id || null : state.activeId,
        }));
      },

      addMessage: (conversationId, message) => {
        set(state => ({
          conversations: state.conversations.map(c => {
            if (c.id !== conversationId) return c;
            const messages = [...c.messages, message];
            // 自动用第一条用户消息作为标题
            const title = c.title === '新对话' && message.role === 'user'
              ? message.content.slice(0, 30) + (message.content.length > 30 ? '...' : '')
              : c.title;
            return { ...c, messages, title, updatedAt: Date.now() };
          }),
        }));
      },

      activeConversation: () => {
        const { conversations, activeId } = get();
        return conversations.find(c => c.id === activeId);
      },

      filteredConversations: () => {
        const { conversations, searchQuery } = get();
        if (!searchQuery.trim()) return conversations;
        const q = searchQuery.toLowerCase();
        return conversations.filter(c =>
          c.title.toLowerCase().includes(q) ||
          c.messages.some(m => m.content.toLowerCase().includes(q))
        );
      },
    }),
    {
      name: 'ai-chat-storage',
      partialize: (state) => ({
        conversations: state.conversations.slice(0, 100), // 最多持久化100个会话
        activeId: state.activeId,
      }),
    }
  )
);

侧边栏会话列表组件:

function ConversationSidebar() {
  const {
    activeId, searchQuery, setSearchQuery,
    createConversation, deleteConversation, setActiveId,
    filteredConversations,
  } = useChatStore();

  const conversations = filteredConversations();

  return (
    <aside className="w-72 h-screen flex flex-col border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
      {/* 头部 */}
      <div className="p-3">
        <button
          onClick={createConversation}
          className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors text-sm"
        >
          <PlusIcon className="w-4 h-4" />
          新对话
        </button>
      </div>

      {/* 搜索框 */}
      <div className="px-3 mb-2">
        <input
          type="text"
          value={searchQuery}
          onChange={e => setSearchQuery(e.target.value)}
          placeholder="搜索对话..."
          className="w-full px-3 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:outline-none focus:ring-1 focus:ring-blue-500"
        />
      </div>

      {/* 会话列表 */}
      <div className="flex-1 overflow-y-auto px-2">
        {conversations.map(conv => (
          <ConversationItem
            key={conv.id}
            conversation={conv}
            isActive={conv.id === activeId}
            onClick={() => setActiveId(conv.id)}
            onDelete={() => deleteConversation(conv.id)}
          />
        ))}
      </div>
    </aside>
  );
}

7. AI思考过程可视化

许多大模型支持展示思考过程(如Claude的extended thinking、OpenAI的reasoning tokens)。将思考过程可视化,能增加用户对AI的信任感:

function ThinkingBlock({ content }: { content: string }) {
  const [expanded, setExpanded] = useState(false);
  const [isThinking, setIsThinking] = useState(true);

  // 如果content还在变化,说明AI仍在思考
  useEffect(() => {
    const timer = setTimeout(() => setIsThinking(false), 500);
    return () => clearTimeout(timer);
  }, [content]);

  return (
    <div className="mb-2">
      <button
        onClick={() => setExpanded(!expanded)}
        className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
      >
        {isThinking ? (
          <Spinner className="w-3.5 h-3.5 animate-spin" />
        ) : (
          <ChevronIcon className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`} />
        )}
        <span>{isThinking ? '正在思考...' : `思考过程 (${estimateReadTime(content)})`}</span>
      </button>

      <AnimatePresence>
        {expanded && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: 'auto', opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="overflow-hidden"
          >
            <div className="mt-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-gray-600 dark:text-gray-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
              {content}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

工具调用(Function Calling)的可视化:

function ToolCallBlock({ toolCall }: { toolCall: ToolCall }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div className="my-2 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
      <button
        onClick={() => setExpanded(!expanded)}
        className="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-sm hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors"
      >
        <WrenchIcon className="w-4 h-4 text-blue-500" />
        <span className="font-medium">{toolCall.name}</span>
        {toolCall.status === 'running' && <Spinner className="w-3.5 h-3.5 animate-spin ml-auto" />}
        {toolCall.status === 'done' && <CheckIcon className="w-3.5 h-3.5 text-green-500 ml-auto" />}
        {toolCall.status === 'error' && <XIcon className="w-3.5 h-3.5 text-red-500 ml-auto" />}
      </button>

      {expanded && (
        <div className="p-3 text-sm">
          <div className="mb-2">
            <span className="text-xs text-gray-500 uppercase tracking-wide">参数</span>
            <pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto">
              {JSON.stringify(toolCall.arguments, null, 2)}
            </pre>
          </div>
          {toolCall.result && (
            <div>
              <span className="text-xs text-gray-500 uppercase tracking-wide">结果</span>
              <pre className="mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto max-h-40">
                {typeof toolCall.result === 'string' ? toolCall.result : JSON.stringify(toolCall.result, null, 2)}
              </pre>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

8. 错误处理与加载状态设计

AI应用的错误类型多样,需要分层处理:

// 错误分类与处理策略
type ErrorType = 'network' | 'rate_limit' | 'context_length' | 'content_filter' | 'server' | 'unknown';

function classifyError(error: Error): ErrorType {
  const message = error.message.toLowerCase();
  if (message.includes('network') || message.includes('fetch')) return 'network';
  if (message.includes('rate') || message.includes('429')) return 'rate_limit';
  if (message.includes('context') || message.includes('token')) return 'context_length';
  if (message.includes('content') || message.includes('filter')) return 'content_filter';
  if (message.includes('500') || message.includes('502') || message.includes('503')) return 'server';
  return 'unknown';
}

function getErrorInfo(type: ErrorType) {
  const errorMap: Record<ErrorType, { title: string; message: string; action: string; retryable: boolean }> = {
    network: {
      title: '网络连接失败',
      message: '请检查网络连接后重试',
      action: '重试',
      retryable: true,
    },
    rate_limit: {
      title: '请求过于频繁',
      message: '已达到速率限制,请稍后再试',
      action: '等待后重试',
      retryable: true,
    },
    context_length: {
      title: '上下文过长',
      message: '对话历史超出模型限制,建议开启新对话或精简内容',
      action: '新建对话',
      retryable: false,
    },
    content_filter: {
      title: '内容被过滤',
      message: '输入或输出内容触发了安全过滤器',
      action: '修改后重试',
      retryable: false,
    },
    server: {
      title: '服务暂时不可用',
      message: '服务器繁忙,请稍后重试',
      action: '重试',
      retryable: true,
    },
    unknown: {
      title: '发生未知错误',
      message: '请重试或联系支持团队',
      action: '重试',
      retryable: true,
    },
  };
  return errorMap[type];
}

function ErrorBanner({ error, onRetry, onNewChat }: {
  error: Error;
  onRetry: () => void;
  onNewChat: () => void;
}) {
  const errorType = classifyError(error);
  const info = getErrorInfo(errorType);

  return (
    <motion.div
      initial={{ opacity: 0, y: 10 }}
      animate={{ opacity: 1, y: 0 }}
      className="mx-auto max-w-2xl p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
    >
      <div className="flex items-start gap-3">
        <AlertIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
        <div className="flex-1">
          <h3 className="font-medium text-red-800 dark:text-red-200">{info.title}</h3>
          <p className="text-sm text-red-600 dark:text-red-300 mt-1">{info.message}</p>
          <div className="mt-3 flex gap-2">
            {info.retryable && (
              <button onClick={onRetry} className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
                {info.action}
              </button>
            )}
            <button onClick={onNewChat} className="px-3 py-1.5 text-sm border border-red-300 dark:border-red-600 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/40 transition-colors">
              新对话
            </button>
          </div>
        </div>
      </div>
    </motion.div>
  );
}

加载骨架屏:

function MessageSkeleton() {
  return (
    <div className="flex gap-3 p-4 animate-pulse">
      <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700" />
      <div className="flex-1 space-y-3">
        <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4" />
        <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2" />
        <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
      </div>
    </div>
  );
}

9. 响应式与移动端适配

AI应用在移动端的体验需要特别关注。输入框需要处理虚拟键盘弹出、侧边栏需要改为抽屉式:

function ResponsiveLayout({ children }: { children: React.ReactNode }) {
  const [sidebarOpen, setSidebarOpen] = useState(false);
  const isMobile = useMediaQuery('(max-width: 768px)');

  return (
    <div className="flex h-screen overflow-hidden">
      {/* 桌面端:固定侧边栏 */}
      {!isMobile && <ConversationSidebar />}

      {/* 移动端:抽屉式侧边栏 */}
      {isMobile && (
        <AnimatePresence>
          {sidebarOpen && (
            <>
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="fixed inset-0 bg-black/50 z-40"
                onClick={() => setSidebarOpen(false)}
              />
              <motion.div
                initial={{ x: '-100%' }}
                animate={{ x: 0 }}
                exit={{ x: '-100%' }}
                transition={{ type: 'spring', damping: 25, stiffness: 300 }}
                className="fixed left-0 top-0 bottom-0 z-50 w-72"
              >
                <ConversationSidebar onClose={() => setSidebarOpen(false)} />
              </motion.div>
            </>
          )}
        </AnimatePresence>
      )}

      <main className="flex-1 flex flex-col min-w-0">
        {/* 移动端顶栏 */}
        {isMobile && (
          <header className="flex items-center gap-3 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
            <button onClick={() => setSidebarOpen(true)} className="p-1">
              <MenuIcon className="w-5 h-5" />
            </button>
            <h1 className="font-medium truncate">AI 助手</h1>
          </header>
        )}
        {children}
      </main>
    </div>
  );
}

// 移动端输入框适配
function MobileInputAdapt() {
  // iOS虚拟键盘弹出时,fixed定位的输入框需要特殊处理
  return (
    <div className="sticky bottom-0 bg-white dark:bg-gray-900 pb-[env(safe-area-inset-bottom)]">
      <MultiModalInput onSend={handleSend} />
    </div>
  );
}

10. 实战案例:用Next.js构建AI聊天应用

将以上所有模块整合成一个完整的Next.js应用:

// app/page.tsx - 主页面
import { ChatLayout } from '@/components/ChatLayout';
import { ConversationSidebar } from '@/components/ConversationSidebar';
import { ChatArea } from '@/components/ChatArea';

export default function Home() {
  return (
    <ChatLayout>
      <ConversationSidebar />
      <ChatArea />
    </ChatLayout>
  );
}
// app/api/chat/route.ts - API路由(流式响应)
import { openai } from '@/lib/openai';

export async function POST(req: Request) {
  const { messages, model, systemPrompt } = await req.json();

  const stream = await openai.chat.completions.create({
    model: model || 'gpt-4',
    messages: [
      { role: 'system', content: systemPrompt || 'You are a helpful assistant.' },
      ...messages,
    ],
    stream: true,
  });

  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      for await (const chunk of stream) {
        const data = `data: ${JSON.stringify(chunk)}\n\n`;
        controller.enqueue(encoder.encode(data));
      }
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    },
  });

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}
// components/ChatArea.tsx - 核心对话区域
'use client';

import { useChatStore } from '@/store/chat';
import { MessageBubble } from './MessageBubble';
import { MultiModalInput } from './MultiModalInput';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { useStreamChat } from '@/hooks/useStreamChat';

export function ChatArea() {
  const conversation = useChatStore(s => s.activeConversation());
  const addMessage = useChatStore(s => s.addMessage);
  const updateMessage = useChatStore(s => s.updateMessage);
  const { content, isStreaming, startStream, stopStream } = useStreamChat();
  const { scrollRef, handleScroll, isAtBottom } = useAutoScroll([content]);

  const handleSend = async (data: InputData) => {
    const userMsg: Message = {
      id: crypto.randomUUID(),
      role: 'user',
      content: data.text,
      timestamp: Date.now(),
      status: 'complete',
      attachments: data.attachments,
    };

    const assistantMsg: Message = {
      id: crypto.randomUUID(),
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      status: 'streaming',
    };

    addMessage(conversation!.id, userMsg);
    addMessage(conversation!.id, assistantMsg);

    try {
      await startStream([...conversation!.messages, userMsg]);
      updateMessage(conversation!.id, assistantMsg.id, content);
    } catch (err) {
      updateMessage(conversation!.id, assistantMsg.id, '');
      // 错误处理...
    }
  };

  return (
    <div className="flex flex-col h-full">
      <div
        ref={scrollRef}
        onScroll={handleScroll}
        className="flex-1 overflow-y-auto px-4 py-6 max-w-3xl mx-auto w-full"
      >
        {conversation?.messages.map(msg => (
          <MessageBubble key={msg.id} message={msg} />
        ))}
        {isStreaming && <MessageBubble message={{ ...currentMessage, content }} />}
      </div>

      <MultiModalInput onSend={handleSend} />
    </div>
  );
}

11. 性能优化与用户体验

消息列表虚拟化

当对话历史很长时(数百条消息),需要使用虚拟列表避免DOM节点过多:

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedMessageList({ messages }: { messages: Message[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 120, // 预估每条消息高度
    overscan: 5,
  });

  return (
    <div ref={parentRef} className="flex-1 overflow-auto">
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <MessageBubble message={messages[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

防抖与节流

// 搜索防抖
const debouncedSearch = useDebouncedCallback((query: string) => {
  setSearchQuery(query);
}, 300);

// 流式内容更新节流
function useThrottledValue<T>(value: T, interval: number): T {
  const [throttled, setThrottled] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setThrottled(value), interval);
    return () => clearTimeout(timer);
  }, [value, interval]);

  return throttled;
}

按需加载与代码分割

// 动态导入重量级组件
const MarkdownRenderer = dynamic(() => import('./MarkdownRenderer'), {
  loading: () => <div className="animate-pulse h-20 bg-gray-100 dark:bg-gray-800 rounded" />,
  ssr: false,
});

const SyntaxHighlighter = dynamic(() => import('react-syntax-highlighter'), {
  ssr: false,
});

离线支持与本地缓存

// 使用IndexedDB缓存大量对话历史
import { openDB } from 'idb';

const dbPromise = openDB('ai-chat', 1, {
  upgrade(db) {
    const store = db.createObjectStore('conversations', { keyPath: 'id' });
    store.createIndex('updatedAt', 'updatedAt');
  },
});

async function saveConversationToDB(conv: Conversation) {
  const db = await dbPromise;
  await db.put('conversations', conv);
}

async function loadConversationsFromDB(): Promise<Conversation[]> {
  const db = await dbPromise;
  return db.getAllFromIndex('conversations', 'updatedAt');
}

通过以上各模块的组合,可以构建出一个功能完整、体验流畅的AI对话应用前端。核心要点是:流式优先、渐进式披露、优雅降级、以及对移动端的充分适配。在实际项目中,还需要根据具体的模型API和业务需求进行调整,但整体架构和设计模式是通用的。

内容声明

本文内容为AI技术学习教程,仅供学习参考。如涉及技术问题,欢迎通过 xurj005@163.com 与我们交流。

目录