AI应用前端开发与React+LLM集成完全教程

教程简介

零基础AI应用前端开发与React+LLM集成完全教程,涵盖AI应用前端架构设计、流式输出渲染(SSE/WebSocket)、Markdown与代码高亮、对话界面UI组件、文件上传与多模态输入、状态管理(Zustand/Redux)、Vercel AI SDK使用、性能优化、与后端API集成、部署方案等核心技能,适合前端开发者系统学习。

AI应用前端开发与React+LLM集成完全教程

从架构设计到生产部署,系统掌握AI应用前端开发的核心技术与最佳实践。


目录

  1. AI应用前端架构设计
  2. 流式输出渲染:SSE与WebSocket
  3. Markdown渲染与代码高亮
  4. 对话界面UI组件
  5. 文件上传与多模态输入
  6. 状态管理:Zustand与Redux
  7. Vercel AI SDK使用
  8. 性能优化
  9. 与后端API集成
  10. 部署方案
  11. 完整实战案例
  12. 最佳实践总结

1. AI应用前端架构设计

1.1 AI应用的特殊性

与传统Web应用相比,AI应用的前端面临几个独特挑战:

维度 传统应用 AI应用
响应模式 同步请求/响应 流式输出(SSE/WS)
响应时间 毫秒级 秒到分钟级
内容格式 JSON/HTML Markdown/代码/多模态
交互模式 表单/点击 对话/多轮交互
状态复杂度 相对简单 上下文、历史、Token管理

1.2 推荐技术栈

┌─────────────────────────────────────────────┐
│              AI应用前端技术栈                  │
├─────────────────────────────────────────────┤
│  框架层   │ React 18+ / Next.js 14+         │
│  状态层   │ Zustand / Jotai / Redux Toolkit  │
│  UI层     │ Tailwind CSS + shadcn/ui         │
│  流式层   │ Vercel AI SDK / EventSource       │
│  渲染层   │ react-markdown + rehype-highlight │
│  网络层   │ fetch / axios / SWR / TanStack   │
│  部署层   │ Vercel / Cloudflare / Docker     │
└─────────────────────────────────────────────┘

1.3 项目结构

ai-chat-app/
├── src/
│   ├── app/                    # Next.js App Router
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── api/
│   │       └── chat/
│   │           └── route.ts    # API路由(代理LLM请求)
│   ├── components/
│   │   ├── chat/
│   │   │   ├── ChatWindow.tsx      # 对话窗口主组件
│   │   │   ├── MessageList.tsx     # 消息列表
│   │   │   ├── MessageBubble.tsx   # 单条消息气泡
│   │   │   ├── ChatInput.tsx       # 输入框组件
│   │   │   ├── StreamingText.tsx   # 流式文本渲染
│   │   │   └── CodeBlock.tsx       # 代码块组件
│   │   ├── ui/                     # 通用UI组件
│   │   └── layout/                 # 布局组件
│   ├── hooks/
│   │   ├── useChat.ts             # 对话核心Hook
│   │   ├── useStream.ts           # 流式输出Hook
│   │   └── useTokenCount.ts       # Token计数Hook
│   ├── stores/
│   │   ├── chatStore.ts           # 对话状态
│   │   └── settingsStore.ts       # 设置状态
│   ├── lib/
│   │   ├── api.ts                 # API客户端
│   │   ├── markdown.ts            # Markdown处理
│   │   └── token.ts               # Token工具
│   └── types/
│       └── chat.ts                # 类型定义
├── public/
├── package.json
└── tailwind.config.ts

1.4 核心类型定义

// types/chat.ts

export interface Message {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: number;
  status: 'sending' | 'streaming' | 'complete' | 'error';
  tokenUsage?: {
    prompt: number;
    completion: number;
  };
  attachments?: Attachment[];
}

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

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

export interface StreamEvent {
  type: 'text' | 'error' | 'done' | 'usage';
  content?: string;
  error?: string;
  usage?: { prompt: number; completion: number };
}

export interface ChatConfig {
  model: string;
  temperature: number;
  maxTokens: number;
  systemPrompt: string;
}

2. 流式输出渲染:SSE与WebSocket

2.1 为什么需要流式输出

LLM生成文本是逐Token进行的,完整响应可能需要5-30秒。如果等到全部生成完再显示,用户体验极差。流式输出让用户看到"打字机效果",大幅降低感知等待时间。

两种主流方案对比:

特性 SSE (Server-Sent Events) WebSocket
方向 单向(服务端→客户端) 双向
协议 HTTP WS
复杂度
自动重连 内置支持 需手动实现
适用场景 LLM流式输出 实时协作、聊天室
浏览器支持 EventSource API WebSocket API

LLM场景推荐SSE:因为数据流是单向的(服务端生成→客户端展示),SSE更简单可靠。

2.2 后端SSE实现(Next.js API Route)

// app/api/chat/route.ts
import { OpenAI } from 'openai';

const openai = new OpenAI();

export async function POST(req: Request) {
  const { messages, model = 'gpt-4o', maxTokens = 4096 } = await req.json();

  // 创建流式响应
  const stream = await openai.chat.completions.create({
    model,
    messages,
    max_tokens: maxTokens,
    stream: true,
  });

  // 将OpenAI流转换为ReadableStream
  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      try {
        for await (const chunk of stream) {
          const content = chunk.choices[0]?.delta?.content;
          if (content) {
            // SSE格式:data: {json}\n\n
            const data = JSON.stringify({ type: 'text', content });
            controller.enqueue(encoder.encode(`data: ${data}\n\n`));
          }
        }
        // 发送完成信号
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ type: 'done' })}\n\n`)
        );
      } catch (error) {
        const errData = JSON.stringify({ 
          type: 'error', 
          error: String(error) 
        });
        controller.enqueue(encoder.encode(`data: ${errData}\n\n`));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(readable, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

2.3 前端SSE消费

// hooks/useStream.ts
import { useState, useCallback, useRef } from 'react';
import type { StreamEvent } from '@/types/chat';

interface UseStreamOptions {
  onText?: (text: string) => void;
  onDone?: () => void;
  onError?: (error: string) => void;
}

export function useStream(options: UseStreamOptions = {}) {
  const [isStreaming, setIsStreaming] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  const startStream = useCallback(async (
    url: string, 
    body: Record<string, unknown>
  ) => {
    setIsStreaming(true);
    setError(null);
    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,
      });

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

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

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

        buffer += decoder.decode(value, { stream: true });
        
        // 按SSE格式解析
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';  // 保留未完成的行

        for (const line of lines) {
          if (line.startsWith('data: ')) {
            try {
              const event: StreamEvent = JSON.parse(line.slice(6));
              
              switch (event.type) {
                case 'text':
                  options.onText?.(event.content!);
                  break;
                case 'done':
                  options.onDone?.();
                  break;
                case 'error':
                  setError(event.error!);
                  options.onError?.(event.error!);
                  break;
              }
            } catch {
              // 忽略解析错误
            }
          }
        }
      }
    } catch (err: any) {
      if (err.name !== 'AbortError') {
        const msg = String(err);
        setError(msg);
        options.onError?.(msg);
      }
    } finally {
      setIsStreaming(false);
    }
  }, [options]);

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

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

2.4 WebSocket方案(适用于实时协作场景)

// hooks/useWebSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';

export function useWebSocket(url: string) {
  const wsRef = useRef<WebSocket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
  const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();

  const connect = useCallback(() => {
    const ws = new WebSocket(url);
    
    ws.onopen = () => {
      setIsConnected(true);
      console.log('[WS] Connected');
    };
    
    ws.onclose = () => {
      setIsConnected(false);
      // 自动重连
      reconnectTimer.current = setTimeout(connect, 3000);
    };
    
    ws.onerror = (err) => {
      console.error('[WS] Error:', err);
    };
    
    wsRef.current = ws;
  }, [url]);

  useEffect(() => {
    connect();
    return () => {
      clearTimeout(reconnectTimer.current);
      wsRef.current?.close();
    };
  }, [connect]);

  const send = useCallback((data: unknown) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    }
  }, []);

  const onMessage = useCallback((handler: (data: unknown) => void) => {
    if (!wsRef.current) return;
    wsRef.current.onmessage = (event) => {
      try {
        handler(JSON.parse(event.data));
      } catch {
        handler(event.data);
      }
    };
  }, []);

  return { send, onMessage, isConnected };
}

3. Markdown渲染与代码高亮

3.1 核心依赖

npm install react-markdown remark-gfm rehype-highlight rehype-raw

3.2 Markdown渲染组件

// components/chat/MarkdownRenderer.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
import { CodeBlock } from './CodeBlock';

interface Props {
  content: string;
}

export function MarkdownRenderer({ content }: Props) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      rehypePlugins={[rehypeHighlight, rehypeRaw]}
      components={{
        // 自定义代码块渲染
        code({ node, className, children, ...props }) {
          const match = /language-(\w+)/.exec(className || '');
          const isInline = !match && !String(children).includes('\n');
          
          if (isInline) {
            return (
              <code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm font-mono text-red-600" {...props}>
                {children}
              </code>
            );
          }
          
          return (
            <CodeBlock
              language={match?.[1] || 'text'}
              value={String(children).replace(/\n$/, '')}
            />
          );
        },
        // 自定义表格样式
        table({ children }) {
          return (
            <div className="overflow-x-auto my-4">
              <table className="min-w-full border-collapse border border-gray-200">
                {children}
              </table>
            </div>
          );
        },
        // 自定义链接(新窗口打开)
        a({ href, children }) {
          return (
            <a 
              href={href} 
              target="_blank" 
              rel="noopener noreferrer"
              className="text-blue-600 hover:text-blue-800 underline"
            >
              {children}
            </a>
          );
        },
      }}
    >
      {content}
    </ReactMarkdown>
  );
}

3.3 代码块组件(带复制和语言标签)

// components/chat/CodeBlock.tsx
import { useState, useCallback } from 'react';

interface Props {
  language: string;
  value: string;
}

export function CodeBlock({ language, value }: Props) {
  const [copied, setCopied] = useState(false);

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

  return (
    <div className="relative group my-4 rounded-lg overflow-hidden border border-gray-200">
      {/* 语言标签和复制按钮 */}
      <div className="flex items-center justify-between px-4 py-2 bg-gray-800 text-gray-300 text-xs">
        <span className="font-mono">{language}</span>
        <button
          onClick={handleCopy}
          className="flex items-center gap-1 px-2 py-1 rounded hover:bg-gray-700 
                     transition-colors opacity-0 group-hover:opacity-100"
        >
          {copied ? (
            <>
              <CheckIcon /> 已复制
            </>
          ) : (
            <>
              <CopyIcon /> 复制
            </>
          )}
        </button>
      </div>
      
      {/* 代码内容 */}
      <pre className="p-4 bg-gray-900 overflow-x-auto">
        <code className={`language-${language} text-sm leading-relaxed`}>
          {value}
        </code>
      </pre>
    </div>
  );
}

function CopyIcon() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
      <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
    </svg>
  );
}

function CheckIcon() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <polyline points="20 6 9 17 4 12" />
    </svg>
  );
}

3.4 流式Markdown渲染

在流式输出过程中,Markdown可能不完整(例如代码块没有闭合),需要特殊处理:

// components/chat/StreamingMarkdown.tsx
import { useMemo } from 'react';
import { MarkdownRenderer } from './MarkdownRenderer';

interface Props {
  content: string;
  isStreaming: boolean;
}

export function StreamingMarkdown({ content, isStreaming }: Props) {
  // 流式输出时,补全未闭合的Markdown结构
  const safeContent = useMemo(() => {
    if (!isStreaming) return content;
    
    let text = content;
    
    // 补全未闭合的代码块
    const codeBlockCount = (text.match(/```/g) || []).length;
    if (codeBlockCount % 2 !== 0) {
      text += '\n```';
    }
    
    // 补全未闭合的行内代码
    const inlineCodeCount = (text.match(/(?<!`)`(?!`)/g) || []).length;
    if (inlineCodeCount % 2 !== 0) {
      text += '`';
    }
    
    // 补全未闭合的粗体
    const boldCount = (text.match(/\*\*/g) || []).length;
    if (boldCount % 2 !== 0) {
      text += '**';
    }
    
    return text;
  }, [content, isStreaming]);

  return (
    <div className="prose prose-sm max-w-none">
      <MarkdownRenderer content={safeContent} />
      {isStreaming && (
        <span className="inline-block w-2 h-4 bg-gray-900 animate-pulse ml-0.5" />
      )}
    </div>
  );
}

4. 对话界面UI组件

4.1 消息气泡组件

// components/chat/MessageBubble.tsx
import { StreamingMarkdown } from './StreamingMarkdown';
import type { Message } from '@/types/chat';
import { formatDistanceToNow } from 'date-fns';

interface Props {
  message: Message;
  isStreaming?: boolean;
}

export function MessageBubble({ message, isStreaming }: Props) {
  const isUser = message.role === 'user';
  
  return (
    <div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
      {/* 头像 */}
      <div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm
        ${isUser ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
        {isUser ? '👤' : '🤖'}
      </div>
      
      {/* 消息内容 */}
      <div className={`flex flex-col max-w-[80%] ${isUser ? 'items-end' : 'items-start'}`}>
        <div className={`rounded-2xl px-4 py-2.5 ${
          isUser 
            ? 'bg-blue-600 text-white rounded-tr-sm' 
            : 'bg-white border border-gray-200 rounded-tl-sm shadow-sm'
        }`}>
          {isUser ? (
            <p className="text-sm leading-relaxed whitespace-pre-wrap">{message.content}</p>
          ) : (
            <StreamingMarkdown 
              content={message.content} 
              isStreaming={isStreaming || message.status === 'streaming'} 
            />
          )}
        </div>
        
        {/* 时间和状态 */}
        <div className="flex items-center gap-2 mt-1 px-1">
          <span className="text-xs text-gray-400">
            {formatDistanceToNow(message.timestamp, { addSuffix: true })}
          </span>
          {message.status === 'error' && (
            <span className="text-xs text-red-500">发送失败</span>
          )}
          {message.tokenUsage && (
            <span className="text-xs text-gray-400">
              {message.tokenUsage.completion} tokens
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

4.2 对话输入框

// components/chat/ChatInput.tsx
import { useState, useRef, useCallback, useEffect } from 'react';

interface Props {
  onSend: (message: string) => void;
  onStop?: () => void;
  isLoading: boolean;
  disabled?: boolean;
  placeholder?: string;
}

export function ChatInput({ 
  onSend, onStop, isLoading, disabled, placeholder = '输入消息...' 
}: Props) {
  const [input, setInput] = useState('');
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // 自动调整高度
  useEffect(() => {
    const el = textareaRef.current;
    if (el) {
      el.style.height = 'auto';
      el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
    }
  }, [input]);

  const handleSend = useCallback(() => {
    const trimmed = input.trim();
    if (!trimmed || disabled) return;
    
    onSend(trimmed);
    setInput('');
    
    // 重置高度
    if (textareaRef.current) {
      textareaRef.current.style.height = 'auto';
    }
  }, [input, disabled, onSend]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === 'Enter' && !e.shiftKey) {
        e.preventDefault();
        handleSend();
      }
    },
    [handleSend]
  );

  return (
    <div className="border-t border-gray-200 bg-white p-4">
      <div className="flex items-end gap-3 max-w-3xl mx-auto">
        <div className="flex-1 relative">
          <textarea
            ref={textareaRef}
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={handleKeyDown}
            placeholder={placeholder}
            disabled={disabled}
            rows={1}
            className="w-full resize-none rounded-xl border border-gray-300 px-4 py-3 
                       pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 
                       focus:border-transparent disabled:opacity-50"
          />
        </div>
        
        {isLoading ? (
          <button
            onClick={onStop}
            className="flex-shrink-0 w-10 h-10 rounded-xl bg-red-500 text-white 
                       flex items-center justify-center hover:bg-red-600 transition-colors"
          >
            <StopIcon />
          </button>
        ) : (
          <button
            onClick={handleSend}
            disabled={!input.trim() || disabled}
            className="flex-shrink-0 w-10 h-10 rounded-xl bg-blue-600 text-white 
                       flex items-center justify-center hover:bg-blue-700 
                       transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
          >
            <SendIcon />
          </button>
        )}
      </div>
      
      <p className="text-center text-xs text-gray-400 mt-2">
        Shift + Enter 换行 · Enter 发送
      </p>
    </div>
  );
}

function SendIcon() {
  return (
    <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
      <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
    </svg>
  );
}

function StopIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
      <rect x="6" y="6" width="12" height="12" rx="2" />
    </svg>
  );
}

4.3 对话窗口主组件

// components/chat/ChatWindow.tsx
import { useRef, useEffect } from 'react';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { useChat } from '@/hooks/useChat';

export function ChatWindow() {
  const { messages, sendMessage, stopGeneration, isStreaming } = useChat();
  const listRef = useRef<HTMLDivElement>(null);

  // 自动滚动到底部
  useEffect(() => {
    const el = listRef.current;
    if (el) {
      el.scrollTop = el.scrollHeight;
    }
  }, [messages]);

  return (
    <div className="flex flex-col h-screen bg-gray-50">
      {/* 头部 */}
      <header className="border-b border-gray-200 bg-white px-6 py-3">
        <h1 className="text-lg font-semibold">AI 助手</h1>
        <p className="text-sm text-gray-500">
          {messages.length} 条消息
        </p>
      </header>

      {/* 消息列表 */}
      <div ref={listRef} className="flex-1 overflow-y-auto px-4 py-6">
        <div className="max-w-3xl mx-auto space-y-6">
          {messages.length === 0 && (
            <div className="text-center text-gray-400 mt-20">
              <div className="text-5xl mb-4">🤖</div>
              <p className="text-lg">有什么可以帮助你的?</p>
            </div>
          )}
          
          {messages.map((msg) => (
            <MessageBubble 
              key={msg.id} 
              message={msg} 
              isStreaming={msg.status === 'streaming'}
            />
          ))}
        </div>
      </div>

      {/* 输入框 */}
      <ChatInput
        onSend={sendMessage}
        onStop={stopGeneration}
        isLoading={isStreaming}
      />
    </div>
  );
}

5. 文件上传与多模态输入

5.1 图片上传与预览

// components/chat/ImageUpload.tsx
import { useState, useRef, useCallback } from 'react';

interface Props {
  onUpload: (files: File[]) => void;
  maxFiles?: number;
  maxSize?: number; // MB
}

export function ImageUpload({ onUpload, maxFiles = 5, maxSize = 10 }: Props) {
  const [previews, setPreviews] = useState<{ file: File; url: string }[]>([]);
  const inputRef = useRef<HTMLInputElement>(null);

  const handleFiles = useCallback((files: FileList | null) => {
    if (!files) return;
    
    const validFiles: File[] = [];
    const newPreviews: { file: File; url: string }[] = [];

    Array.from(files).forEach((file) => {
      // 检查文件大小
      if (file.size > maxSize * 1024 * 1024) {
        alert(`文件 ${file.name} 超过 ${maxSize}MB 限制`);
        return;
      }
      // 检查文件类型
      if (!file.type.startsWith('image/')) {
        alert(`文件 ${file.name} 不是图片`);
        return;
      }
      
      validFiles.push(file);
      newPreviews.push({
        file,
        url: URL.createObjectURL(file),
      });
    });

    setPreviews((prev) => [...prev, ...newPreviews].slice(0, maxFiles));
    onUpload(validFiles);
  }, [maxFiles, maxSize, onUpload]);

  const removePreview = useCallback((index: number) => {
    setPreviews((prev) => {
      URL.revokeObjectURL(prev[index].url);
      return prev.filter((_, i) => i !== index);
    });
  }, []);

  return (
    <div className="space-y-2">
      {/* 预览区 */}
      {previews.length > 0 && (
        <div className="flex gap-2 flex-wrap">
          {previews.map((preview, i) => (
            <div key={i} className="relative group">
              <img 
                src={preview.url} 
                alt="" 
                className="w-16 h-16 object-cover rounded-lg border"
              />
              <button
                onClick={() => removePreview(i)}
                className="absolute -top-1.5 -right-1.5 w-5 h-5 bg-red-500 text-white 
                           rounded-full text-xs flex items-center justify-center 
                           opacity-0 group-hover:opacity-100 transition-opacity"
              >
                ×
              </button>
            </div>
          ))}
        </div>
      )}

      {/* 上传按钮 */}
      <button
        onClick={() => inputRef.current?.click()}
        className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
      >
        <ImageIcon />
        <span>上传图片</span>
      </button>
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        multiple
        className="hidden"
        onChange={(e) => handleFiles(e.target.files)}
      />
    </div>
  );
}

function ImageIcon() {
  return (
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" 
         stroke="currentColor" strokeWidth="2">
      <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
      <circle cx="8.5" cy="8.5" r="1.5" />
      <polyline points="21 15 16 10 5 21" />
    </svg>
  );
}

5.2 多模态消息发送

// 发送包含图片的消息
async function sendMultimodalMessage(
  text: string, 
  images: File[]
): Promise<void> {
  // 方案1:先上传图片获取URL,再发送消息
  const imageUrls = await Promise.all(
    images.map(async (file) => {
      const formData = new FormData();
      formData.append('file', file);
      const res = await fetch('/api/upload', { method: 'POST', body: formData });
      const { url } = await res.json();
      return url;
    })
  );

  // 构造多模态消息(OpenAI格式)
  const content = [
    { type: 'text', text },
    ...imageUrls.map((url) => ({
      type: 'image_url' as const,
      image_url: { url, detail: 'auto' as const },
    })),
  ];

  await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      messages: [
        { role: 'user', content }
      ],
    }),
  });
}

5.3 文件拖拽上传

// hooks/useDragDrop.ts
import { useState, useCallback, DragEvent } from 'react';

export function useDragDrop(onDrop: (files: File[]) => void) {
  const [isDragging, setIsDragging] = useState(false);

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  }, []);

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    // 只在真正离开容器时取消
    if (e.currentTarget === e.target) {
      setIsDragging(false);
    }
  }, []);

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDrop = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
    
    const files = Array.from(e.dataTransfer.files);
    if (files.length > 0) {
      onDrop(files);
    }
  }, [onDrop]);

  return {
    isDragging,
    dragHandlers: {
      onDragEnter: handleDragEnter,
      onDragLeave: handleDragLeave,
      onDragOver: handleDragOver,
      onDrop: handleDrop,
    },
  };
}

6. 状态管理:Zustand与Redux

6.1 为什么选择Zustand

对于AI应用的状态管理,Zustand比Redux Toolkit更轻量、更灵活:

特性 Zustand Redux Toolkit
模板代码 极少 中等
学习曲线
包大小 ~1KB ~11KB
TypeScript 原生支持 需要配置
中间件 简洁 生态丰富
DevTools 支持 原生支持

6.2 对话状态Store

// stores/chatStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import type { Message, Conversation, ChatConfig } from '@/types/chat';

interface ChatState {
  // 当前对话
  conversations: Conversation[];
  activeConversationId: string | null;
  config: ChatConfig;
  
  // 操作
  createConversation: () => string;
  addMessage: (conversationId: string, message: Message) => void;
  updateMessage: (conversationId: string, messageId: string, content: string) => void;
  setMessageStatus: (conversationId: string, messageId: string, status: Message['status']) => void;
  deleteConversation: (id: string) => void;
  setActiveConversation: (id: string) => void;
  updateConfig: (config: Partial<ChatConfig>) => void;
  
  // 派生数据
  getActiveConversation: () => Conversation | null;
  getActiveMessages: () => Message[];
}

export const useChatStore = create<ChatState>()(
  persist(
    immer((set, get) => ({
      conversations: [],
      activeConversationId: null,
      config: {
        model: 'gpt-4o',
        temperature: 0.7,
        maxTokens: 4096,
        systemPrompt: '你是一个有帮助的AI助手。',
      },

      createConversation: () => {
        const id = crypto.randomUUID();
        const conversation: Conversation = {
          id,
          title: '新对话',
          messages: [],
          model: get().config.model,
          createdAt: Date.now(),
          updatedAt: Date.now(),
          totalTokens: 0,
        };
        
        set((state) => {
          state.conversations.unshift(conversation);
          state.activeConversationId = id;
        });
        
        return id;
      },

      addMessage: (conversationId, message) => {
        set((state) => {
          const conv = state.conversations.find((c) => c.id === conversationId);
          if (conv) {
            conv.messages.push(message);
            conv.updatedAt = Date.now();
            
            // 自动更新标题(取第一条用户消息)
            if (conv.messages.length === 1 && message.role === 'user') {
              conv.title = message.content.slice(0, 30) + 
                (message.content.length > 30 ? '...' : '');
            }
          }
        });
      },

      updateMessage: (conversationId, messageId, content) => {
        set((state) => {
          const conv = state.conversations.find((c) => c.id === conversationId);
          if (conv) {
            const msg = conv.messages.find((m) => m.id === messageId);
            if (msg) {
              msg.content = content;
            }
          }
        });
      },

      setMessageStatus: (conversationId, messageId, status) => {
        set((state) => {
          const conv = state.conversations.find((c) => c.id === conversationId);
          if (conv) {
            const msg = conv.messages.find((m) => m.id === messageId);
            if (msg) {
              msg.status = status;
            }
          }
        });
      },

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

      setActiveConversation: (id) => {
        set({ activeConversationId: id });
      },

      updateConfig: (config) => {
        set((state) => {
          Object.assign(state.config, config);
        });
      },

      getActiveConversation: () => {
        const state = get();
        return state.conversations.find((c) => c.id === state.activeConversationId) || null;
      },

      getActiveMessages: () => {
        const conv = get().getActiveConversation();
        return conv?.messages || [];
      },
    })),
    {
      name: 'chat-storage',
      // 只持久化对话数据,不持久化临时状态
      partialize: (state) => ({
        conversations: state.conversations.slice(0, 50), // 最多保留50个对话
        activeConversationId: state.activeConversationId,
        config: state.config,
      }),
    }
  )
);

6.3 核心Chat Hook

// hooks/useChat.ts
import { useCallback, useRef } from 'react';
import { useChatStore } from '@/stores/chatStore';
import { useStream } from './useStream';

export function useChat() {
  const store = useChatStore();
  const activeConv = store.getActiveConversation();
  const assistantMsgIdRef = useRef<string | null>(null);

  const { startStream, stopStream, isStreaming } = useStream({
    onText: (text) => {
      if (assistantMsgIdRef.current && store.activeConversationId) {
        // 追加流式文本
        const conv = store.getActiveConversation();
        const msg = conv?.messages.find((m) => m.id === assistantMsgIdRef.current);
        if (msg) {
          store.updateMessage(
            store.activeConversationId,
            assistantMsgIdRef.current,
            msg.content + text
          );
        }
      }
    },
    onDone: () => {
      if (assistantMsgIdRef.current && store.activeConversationId) {
        store.setMessageStatus(
          store.activeConversationId,
          assistantMsgIdRef.current,
          'complete'
        );
      }
      assistantMsgIdRef.current = null;
    },
    onError: (error) => {
      if (assistantMsgIdRef.current && store.activeConversationId) {
        store.setMessageStatus(
          store.activeConversationId,
          assistantMsgIdRef.current,
          'error'
        );
      }
      console.error('Stream error:', error);
    },
  });

  const sendMessage = useCallback(async (content: string) => {
    // 确保有活跃对话
    let convId = store.activeConversationId;
    if (!convId) {
      convId = store.createConversation();
    }

    // 添加用户消息
    const userMsg = {
      id: crypto.randomUUID(),
      role: 'user' as const,
      content,
      timestamp: Date.now(),
      status: 'complete' as const,
    };
    store.addMessage(convId, userMsg);

    // 添加助手消息占位
    const assistantMsg = {
      id: crypto.randomUUID(),
      role: 'assistant' as const,
      content: '',
      timestamp: Date.now(),
      status: 'streaming' as const,
    };
    assistantMsgIdRef.current = assistantMsg.id;
    store.addMessage(convId, assistantMsg);

    // 获取完整消息历史用于请求
    const conv = store.getActiveConversation();
    const messages = [
      { role: 'system' as const, content: store.config.systemPrompt },
      ...(conv?.messages
        .filter((m) => m.status !== 'error')
        .map((m) => ({ role: m.role, content: m.content })) || []),
    ];

    // 开始流式请求
    await startStream('/api/chat', {
      messages,
      model: store.config.model,
      maxTokens: store.config.maxTokens,
    });
  }, [store, startStream]);

  const stopGeneration = useCallback(() => {
    stopStream();
    if (assistantMsgIdRef.current && store.activeConversationId) {
      store.setMessageStatus(
        store.activeConversationId,
        assistantMsgIdRef.current,
        'complete'
      );
    }
  }, [stopStream, store]);

  return {
    messages: store.getActiveMessages(),
    sendMessage,
    stopGeneration,
    isStreaming,
    conversations: store.conversations,
    activeConversationId: store.activeConversationId,
    config: store.config,
    createConversation: store.createConversation,
    setActiveConversation: store.setActiveConversation,
    deleteConversation: store.deleteConversation,
    updateConfig: store.updateConfig,
  };
}

7. Vercel AI SDK使用

7.1 Vercel AI SDK简介

Vercel AI SDK(ai包)是目前React + LLM集成的最佳开发体验方案,提供了:

  • useChat Hook:一行代码实现完整的对话功能
  • useCompletion Hook:文本补全场景
  • 流式渲染内置支持
  • 多模型适配(OpenAI、Anthropic、Google等)

7.2 后端集成

// app/api/chat/route.ts (使用Vercel AI SDK)
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

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

  const result = streamText({
    model: openai('gpt-4o'),
    system: '你是一个有帮助的AI助手。',
    messages,
    maxTokens: 4096,
    temperature: 0.7,
  });

  return result.toDataStreamResponse();
}

7.3 前端使用useChat

// app/page.tsx
'use client';

import { useChat } from 'ai/react';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { ChatInput } from '@/components/chat/ChatInput';

export default function ChatPage() {
  const { 
    messages, 
    input, 
    handleInputChange, 
    handleSubmit, 
    isLoading, 
    stop,
    error,
    reload,
  } = useChat({
    api: '/api/chat',
    onError: (err) => {
      console.error('Chat error:', err);
    },
    onFinish: (message) => {
      console.log('Finished:', message.usage);
    },
  });

  return (
    <div className="flex flex-col h-screen">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg) => (
          <MessageBubble
            key={msg.id}
            message={{
              id: msg.id,
              role: msg.role as 'user' | 'assistant',
              content: msg.content,
              timestamp: Date.now(),
              status: isLoading && msg.id === messages[messages.length - 1]?.id
                ? 'streaming' : 'complete',
            }}
          />
        ))}
      </div>

      <form onSubmit={handleSubmit} className="border-t p-4">
        <div className="flex gap-2 max-w-3xl mx-auto">
          <input
            value={input}
            onChange={handleInputChange}
            placeholder="输入消息..."
            className="flex-1 border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          {isLoading ? (
            <button type="button" onClick={stop} 
                    className="px-4 py-2 bg-red-500 text-white rounded-lg">
              停止
            </button>
          ) : (
            <button type="submit" 
                    className="px-4 py-2 bg-blue-600 text-white rounded-lg">
              发送
            </button>
          )}
        </div>
      </form>
    </div>
  );
}

7.4 useCompletion(文本补全场景)

// 适用于非对话场景,如文本续写、代码补全
'use client';

import { useCompletion } from 'ai/react';

export function TextCompleter() {
  const { completion, input, handleInputChange, handleSubmit, isLoading } = 
    useCompletion({
      api: '/api/completion',
    });

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <textarea value={input} onChange={handleInputChange} />
        <button type="submit" disabled={isLoading}>生成</button>
      </form>
      {completion && (
        <div className="mt-4 p-4 bg-gray-50 rounded">
          <p>{completion}</p>
        </div>
      )}
    </div>
  );
}

8. 性能优化

8.1 消息列表虚拟化

当对话消息很多时,需要虚拟化渲染以保持流畅:

npm install @tanstack/react-virtual
// components/chat/VirtualizedMessageList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
import { MessageBubble } from './MessageBubble';
import type { Message } from '@/types/chat';

interface Props {
  messages: Message[];
}

export function VirtualizedMessageList({ messages }: Props) {
  const parentRef = useRef<HTMLDivElement>(null);

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

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

8.2 防抖与节流

// hooks/useDebounce.ts
import { useState, useEffect } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

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

  return debouncedValue;
}

// 在流式输出中使用节流避免过于频繁的渲染
// hooks/useThrottledValue.ts
import { useState, useEffect, useRef } from 'react';

export function useThrottledValue<T>(value: T, interval: number = 50): T {
  const [throttled, setThrottled] = useState(value);
  const lastUpdate = useRef(0);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    const now = Date.now();
    const remaining = interval - (now - lastUpdate.current);

    if (remaining <= 0) {
      lastUpdate.current = now;
      setThrottled(value);
    } else {
      clearTimeout(timerRef.current);
      timerRef.current = setTimeout(() => {
        lastUpdate.current = Date.now();
        setThrottled(value);
      }, remaining);
    }

    return () => clearTimeout(timerRef.current);
  }, [value, interval]);

  return throttled;
}

8.3 代码分割与懒加载

// 动态导入重型Markdown组件
import dynamic from 'next/dynamic';

const MarkdownRenderer = dynamic(
  () => import('@/components/chat/MarkdownRenderer').then(mod => mod.MarkdownRenderer),
  {
    loading: () => <div className="animate-pulse bg-gray-100 h-20 rounded" />,
    ssr: false, // Markdown渲染不需要SSR
  }
);

// 代码高亮样式按需加载
import { useEffect } from 'react';

export function useHighlightTheme(theme: string = 'github-dark') {
  useEffect(() => {
    import(`highlight.js/styles/${theme}.css`);
  }, [theme]);
}

8.4 缓存策略

// 使用SWR缓存对话历史
import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then(r => r.json());

export function useConversationHistory(conversationId: string | null) {
  return useSWR(
    conversationId ? `/api/conversations/${conversationId}` : null,
    fetcher,
    {
      revalidateOnFocus: false,     // 切换标签不重新获取
      revalidateOnReconnect: false, // 断网重连不重新获取
      dedupingInterval: 60000,      // 1分钟内去重
    }
  );
}

9. 与后端API集成

9.1 API客户端封装

// lib/api.ts

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || '';

interface RequestOptions {
  method?: string;
  body?: unknown;
  headers?: Record<string, string>;
  signal?: AbortSignal;
}

class APIClient {
  private baseURL: string;
  private token: string | null = null;

  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }

  setToken(token: string) {
    this.token = token;
  }

  async request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
    const { method = 'GET', body, headers = {}, signal } = options;

    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method,
      headers: {
        'Content-Type': 'application/json',
        ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
        ...headers,
      },
      body: body ? JSON.stringify(body) : undefined,
      signal,
    });

    if (!response.ok) {
      const error = await response.json().catch(() => ({}));
      throw new APIError(response.status, error.message || '请求失败');
    }

    return response.json();
  }

  // 流式请求
  async *stream(endpoint: string, body: unknown): AsyncGenerator<string> {
    const response = await fetch(`${this.baseURL}${endpoint}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
      },
      body: JSON.stringify(body),
    });

    if (!response.ok) {
      throw new APIError(response.status, '流式请求失败');
    }

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

    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: ')) {
          try {
            const event = JSON.parse(line.slice(6));
            if (event.type === 'text' && event.content) {
              yield event.content;
            }
          } catch {
            // 忽略解析错误
          }
        }
      }
    }
  }
}

class APIError extends Error {
  constructor(public status: number, message: string) {
    super(message);
    this.name = 'APIError';
  }
}

export const api = new APIClient(BASE_URL);

9.2 错误处理与重试

// lib/retry.ts

interface RetryOptions {
  maxRetries?: number;
  delay?: number;
  backoff?: number;
  retryOn?: (error: Error) => boolean;
}

export async function withRetry<T>(
  fn: () => Promise<T>,
  options: RetryOptions = {}
): Promise<T> {
  const { 
    maxRetries = 3, 
    delay = 1000, 
    backoff = 2,
    retryOn = defaultRetryCondition 
  } = options;

  let lastError: Error;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      
      if (attempt === maxRetries || !retryOn(lastError)) {
        throw lastError;
      }
      
      // 指数退避
      const waitTime = delay * Math.pow(backoff, attempt);
      console.log(`重试 ${attempt + 1}/${maxRetries},等待 ${waitTime}ms...`);
      await new Promise(r => setTimeout(r, waitTime));
    }
  }
  
  throw lastError!;
}

function defaultRetryCondition(error: Error): boolean {
  // 网络错误或5xx错误可以重试
  if (error instanceof APIError) {
    return error.status >= 500 || error.status === 429;
  }
  return error.message.includes('fetch') || error.message.includes('network');
}

// 使用示例
const response = await withRetry(
  () => api.request('/api/chat', { method: 'POST', body: { messages } }),
  { maxRetries: 2, delay: 1000 }
);

9.3 认证集成

// hooks/useAuth.ts
'use client';

import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { api } from '@/lib/api';

interface AuthState {
  user: { id: string; email: string; name: string } | null;
  isLoading: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthState | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<AuthState['user']>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 检查已保存的Token
    const token = localStorage.getItem('auth_token');
    if (token) {
      api.setToken(token);
      api.request('/api/me')
        .then(setUser)
        .catch(() => localStorage.removeItem('auth_token'))
        .finally(() => setIsLoading(false));
    } else {
      setIsLoading(false);
    }
  }, []);

  const login = async (email: string, password: string) => {
    const { token, user } = await api.request<{ token: string; user: AuthState['user'] }>(
      '/api/auth/login',
      { method: 'POST', body: { email, password } }
    );
    localStorage.setItem('auth_token', token);
    api.setToken(token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem('auth_token');
    api.setToken('');
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be inside AuthProvider');
  return ctx;
}

10. 部署方案

10.1 Vercel部署(推荐)

// vercel.json
{
  "framework": "nextjs",
  "regions": ["iad1"],
  "functions": {
    "app/api/chat/route.ts": {
      "maxDuration": 60,
      "memory": 1024
    }
  }
}
# 部署命令
vercel --prod

# 环境变量设置
vercel env add OPENAI_API_KEY production

Vercel部署优势:

  • 零配置Next.js支持
  • 边缘函数(Edge Runtime)低延迟
  • 自动HTTPS和CDN
  • 与AI SDK深度集成

10.2 Docker部署

# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

# 复制构建产物
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

EXPOSE 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
  ai-chat:
    build: .
    ports:
      - "3000:3000"
    environment:
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - NEXT_PUBLIC_API_URL=${API_URL}
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

10.3 Cloudflare Pages部署

# 使用OpenNext适配器
npm install -g opennext
opennext build
# 将输出部署到Cloudflare Pages

10.4 部署检查清单

  • 环境变量已配置(API Key等敏感信息)
  • API路由设置了合理的超时时间
  • 静态资源已配置CDN缓存
  • 错误监控已接入(Sentry等)
  • 日志收集已配置
  • 健康检查端点已实现
  • 限流策略已配置
  • CORS策略已设置

11. 完整实战案例

11.1 项目初始化

# 创建Next.js项目
npx create-next-app@latest ai-chat --typescript --tailwind --app --src-dir
cd ai-chat

# 安装依赖
npm install ai @ai-sdk/openai zustand immer
npm install react-markdown remark-gfm rehype-highlight rehype-raw
npm install @tanstack/react-virtual date-fns nanoid
npm install -D @types/node

11.2 完整应用入口

// src/app/page.tsx
'use client';

import { ChatWindow } from '@/components/chat/ChatWindow';
import { Sidebar } from '@/components/layout/Sidebar';
import { useState } from 'react';

export default function Home() {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  return (
    <div className="flex h-screen bg-gray-50">
      <Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
      <main className="flex-1">
        <ChatWindow />
      </main>
    </div>
  );
}

11.3 侧边栏组件

// src/components/layout/Sidebar.tsx
import { useChatStore } from '@/stores/chatStore';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';

interface Props {
  isOpen: boolean;
  onToggle: () => void;
}

export function Sidebar({ isOpen, onToggle }: Props) {
  const { conversations, activeConversationId, createConversation, 
          setActiveConversation, deleteConversation } = useChatStore();

  return (
    <aside className={`${isOpen ? 'w-64' : 'w-0'} transition-all duration-300 
                       border-r border-gray-200 bg-white flex flex-col overflow-hidden`}>
      {/* 新建对话 */}
      <div className="p-3 border-b">
        <button
          onClick={createConversation}
          className="w-full flex items-center gap-2 px-3 py-2 rounded-lg 
                     bg-blue-600 text-white hover:bg-blue-700 transition-colors text-sm"
        >
          <PlusIcon />
          新对话
        </button>
      </div>

      {/* 对话列表 */}
      <div className="flex-1 overflow-y-auto">
        {conversations.map((conv) => (
          <div
            key={conv.id}
            onClick={() => setActiveConversation(conv.id)}
            className={`group flex items-center gap-2 px-3 py-2.5 cursor-pointer 
                        hover:bg-gray-50 transition-colors ${
                          conv.id === activeConversationId ? 'bg-blue-50 border-r-2 border-blue-600' : ''
                        }`}
          >
            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium truncate">{conv.title}</p>
              <p className="text-xs text-gray-400">
                {formatDistanceToNow(conv.updatedAt, { addSuffix: true, locale: zhCN })}
              </p>
            </div>
            <button
              onClick={(e) => {
                e.stopPropagation();
                deleteConversation(conv.id);
              }}
              className="opacity-0 group-hover:opacity-100 text-gray-400 
                         hover:text-red-500 transition-all"
            >
              <TrashIcon />
            </button>
          </div>
        ))}
      </div>

      {/* 折叠按钮 */}
      <button
        onClick={onToggle}
        className="p-3 border-t text-gray-400 hover:text-gray-600"
      >
        {isOpen ? '◀ 收起' : '▶'}
      </button>
    </aside>
  );
}

function PlusIcon() {
  return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>;
}

function TrashIcon() {
  return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" /></svg>;
}

12. 最佳实践总结

12.1 架构原则

  1. API路由代理:永远不要在前端直接调用LLM API,通过后端API Route代理,保护API Key
  2. 流式优先:所有LLM交互默认使用流式输出
  3. 乐观更新:用户消息立即显示,不必等待服务端确认
  4. 状态持久化:使用localStorage或IndexedDB持久化对话历史

12.2 用户体验要点

  • 流式输出使用打字机光标效果
  • 支持停止生成(AbortController)
  • 代码块带复制按钮和语言标签
  • 输入框支持Shift+Enter换行
  • 自动滚动到最新消息(但用户上滚时不强制拉回)
  • 加载状态有明确的视觉反馈
  • 错误状态有重试按钮
  • 长消息支持折叠/展开

12.3 性能清单

  • 消息列表虚拟化(>50条消息时)
  • 流式输出节流(50-100ms间隔更新UI)
  • Markdown组件懒加载
  • 图片懒加载和压缩
  • 代码高亮按需加载语言包
  • 对话历史分页加载

12.4 安全要点

  • API Key只在服务端使用
  • 输入内容做XSS过滤
  • Markdown渲染配置白名单标签
  • 文件上传限制类型和大小
  • 请求频率限制
  • 用户认证和会话隔离

本教程覆盖了AI应用前端开发的完整技术栈。从流式输出到状态管理,从UI组件到生产部署,这些知识和代码可以直接用于构建高质量的AI对话应用。关键是:始终以用户体验为核心,让AI的能力通过精心设计的前端界面得到最好的呈现。

内容声明

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

目录