Skip to content

7.5 前端交互设计

AI 应用的前端体验直接影响用户满意度。流式打字效果、Markdown 渲染、代码高亮、文件上传是 Chat 类产品的标配能力。

学习时长:2-3 周


学习时长:2-3 周

AI 应用的前端体验直接影响用户满意度。流式打字效果、Markdown 渲染、代码高亮、文件上传等是 Chat 类产品的标配能力。本节以 React + TypeScript 为主要技术栈,覆盖从单个组件到完整对话界面的实现。


7.5.1 对话式 UI 设计

核心组件架构

ChatApp
├── MessageList          # 消息列表(滚动容器)
│   ├── MessageItem      # 单条消息(用户/AI)
│   │   ├── Avatar       # 头像
│   │   ├── MessageBubble # 消息气泡(含 Markdown 渲染)
│   │   └── MessageActions # 复制/重新生成/点赞
│   └── TypingIndicator  # AI 正在输入动画
├── ChatInput            # 输入区域
│   ├── Textarea         # 自适应高度文本框
│   ├── FileUpload       # 文件/图片上传
│   └── SendButton       # 发送按钮(含停止生成)
└── ConversationSidebar  # 历史对话侧边栏

1. 核心数据结构

typescript
// types.ts
export type MessageRole = "user" | "assistant" | "system";
export type MessageStatus = "sending" | "streaming" | "done" | "error";

export interface Attachment {
  id: string;
  type: "image" | "file";
  name: string;
  url: string;       // 本地预览 URL 或上传后的远端 URL
  size: number;
  mimeType: string;
}

export interface Message {
  id: string;
  role: MessageRole;
  content: string;
  status: MessageStatus;
  attachments?: Attachment[];
  createdAt: number;
  tokenCount?: number;
  model?: string;
}

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

2. 消息列表组件

tsx
// components/MessageList.tsx
import { useEffect, useRef } from "react";
import { Message } from "../types";
import MessageItem from "./MessageItem";
import TypingIndicator from "./TypingIndicator";

interface Props {
  messages: Message[];
  isStreaming: boolean;
}

export default function MessageList({ messages, isStreaming }: Props) {
  const bottomRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  // 新消息到达时自动滚动到底部
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const isNearBottom =
      container.scrollHeight - container.scrollTop - container.clientHeight < 100;

    if (isNearBottom || isStreaming) {
      bottomRef.current?.scrollIntoView({ behavior: "smooth" });
    }
  }, [messages, isStreaming]);

  return (
    <div
      ref={containerRef}
      className="flex-1 overflow-y-auto px-4 py-6 space-y-6 scroll-smooth"
    >
      {messages.length === 0 && (
        <div className="flex flex-col items-center justify-center h-full text-gray-400">
          <p className="text-lg">有什么可以帮助你的?</p>
        </div>
      )}

      {messages.map((msg) => (
        <MessageItem key={msg.id} message={msg} />
      ))}

      {isStreaming && <TypingIndicator />}

      <div ref={bottomRef} />
    </div>
  );
}

3. 消息气泡组件

tsx
// components/MessageItem.tsx
import { useState } from "react";
import { Message } from "../types";
import MarkdownRenderer from "./MarkdownRenderer";
import MessageActions from "./MessageActions";

interface Props {
  message: Message;
  onRegenerate?: (messageId: string) => void;
}

export default function MessageItem({ message, onRegenerate }: Props) {
  const [showActions, setShowActions] = useState(false);
  const isUser = message.role === "user";

  return (
    <div
      className={`flex gap-3 ${isUser ? "flex-row-reverse" : "flex-row"}`}
      onMouseEnter={() => setShowActions(true)}
      onMouseLeave={() => setShowActions(false)}
    >
      {/* 头像 */}
      <div className={`w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-sm font-bold
        ${isUser ? "bg-blue-500 text-white" : "bg-gray-700 text-white"}`}>
        {isUser ? "U" : "AI"}
      </div>

      {/* 消息内容 */}
      <div className={`max-w-[80%] ${isUser ? "items-end" : "items-start"} flex flex-col gap-1`}>
        {/* 附件预览 */}
        {message.attachments?.map((att) => (
          att.type === "image" ? (
            <img
              key={att.id}
              src={att.url}
              alt={att.name}
              className="max-w-xs rounded-lg"
            />
          ) : (
            <div key={att.id} className="flex items-center gap-2 bg-gray-100 rounded px-3 py-2 text-sm">
              <span>📄</span>
              <span>{att.name}</span>
            </div>
          )
        ))}

        {/* 消息气泡 */}
        <div className={`rounded-2xl px-4 py-3 text-sm leading-relaxed
          ${isUser
            ? "bg-blue-500 text-white rounded-tr-sm"
            : "bg-gray-100 text-gray-900 rounded-tl-sm"
          }
          ${message.status === "error" ? "border border-red-300 bg-red-50" : ""}
        `}>
          {isUser ? (
            <p className="whitespace-pre-wrap">{message.content}</p>
          ) : (
            <MarkdownRenderer
              content={message.content}
              isStreaming={message.status === "streaming"}
            />
          )}

          {/* 流式光标 */}
          {message.status === "streaming" && (
            <span className="inline-block w-0.5 h-4 bg-gray-600 ml-0.5 animate-pulse" />
          )}
        </div>

        {/* 元信息 */}
        <div className="flex items-center gap-2 text-xs text-gray-400">
          <span>{new Date(message.createdAt).toLocaleTimeString()}</span>
          {message.model && <span{message.model}</span>}
        </div>

        {/* 操作按钮 */}
        {showActions && message.status === "done" && (
          <MessageActions message={message} onRegenerate={onRegenerate} />
        )}
      </div>
    </div>
  );
}

4. 输入框组件(自适应高度)

tsx
// components/ChatInput.tsx
import { useState, useRef, useCallback, KeyboardEvent } from "react";
import { Attachment } from "../types";

interface Props {
  onSend: (content: string, attachments: Attachment[]) => void;
  onStop: () => void;
  isStreaming: boolean;
  disabled?: boolean;
  placeholder?: string;
}

export default function ChatInput({
  onSend, onStop, isStreaming, disabled, placeholder = "输入消息..."
}: Props) {
  const [input, setInput] = useState("");
  const [attachments, setAttachments] = useState<Attachment[]>([]);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  // 自动调整文本框高度
  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 = () => {
    if (!input.trim() && attachments.length === 0) return;
    onSend(input.trim(), attachments);
    setInput("");
    setAttachments([]);
    if (textareaRef.current) textareaRef.current.style.height = "auto";
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
      e.preventDefault();
      handleSend();
    }
  };

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    const newAttachments: Attachment[] = files.map((file) => ({
      id: crypto.randomUUID(),
      type: file.type.startsWith("image/") ? "image" : "file",
      name: file.name,
      url: URL.createObjectURL(file),
      size: file.size,
      mimeType: file.type
    }));
    setAttachments((prev) => [...prev, ...newAttachments]);
    e.target.value = "";
  };

  const removeAttachment = (id: string) => {
    setAttachments((prev) => prev.filter((a) => a.id !== id));
  };

  return (
    <div className="border-t bg-white p-4">
      {/* 附件预览 */}
      {attachments.length > 0 && (
        <div className="flex gap-2 mb-3 flex-wrap">
          {attachments.map((att) => (
            <div key={att.id} className="relative group">
              {att.type === "image" ? (
                <img src={att.url} alt={att.name}
                  className="h-16 w-16 object-cover rounded-lg border" />
              ) : (
                <div className="h-16 w-32 flex items-center gap-1 bg-gray-100 rounded-lg px-2 text-xs">
                  <span>📄</span>
                  <span className="truncate">{att.name}</span>
                </div>
              )}
              <button
                onClick={() => removeAttachment(att.id)}
                className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white
                  rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity"
              >×</button>
            </div>
          ))}
        </div>
      )}

      <div className="flex items-end gap-2 bg-gray-50 rounded-2xl border px-4 py-2">
        {/* 附件上传按钮 */}
        <button
          onClick={() => fileInputRef.current?.click()}
          className="text-gray-400 hover:text-gray-600 mb-1 flex-shrink-0"
          title="上传文件或图片"
        >
          <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
              d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
          </svg>
        </button>
        <input
          ref={fileInputRef}
          type="file"
          className="hidden"
          accept="image/*,.pdf,.txt,.doc,.docx"
          multiple
          onChange={handleFileSelect}
        />

        {/* 文本输入框 */}
        <textarea
          ref={textareaRef}
          value={input}
          onChange={(e) => { setInput(e.target.value); adjustHeight(); &#125;&#125;
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          disabled={disabled}
          rows={1}
          className="flex-1 bg-transparent resize-none outline-none text-sm
            leading-relaxed max-h-48 py-1"
        />

        {/* 发送/停止按钮 */}
        {isStreaming ? (
          <button
            onClick={onStop}
            className="mb-1 flex-shrink-0 w-8 h-8 bg-gray-200 hover:bg-gray-300
              rounded-full flex items-center justify-center"
            title="停止生成"
          >
            <span className="w-3 h-3 bg-gray-700 rounded-sm" />
          </button>
        ) : (
          <button
            onClick={handleSend}
            disabled={(!input.trim() && attachments.length === 0) || disabled}
            className="mb-1 flex-shrink-0 w-8 h-8 bg-blue-500 hover:bg-blue-600
              disabled:bg-gray-200 disabled:cursor-not-allowed rounded-full
              flex items-center justify-center transition-colors"
            title="发送(Enter)"
          >
            <svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
              <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
            </svg>
          </button>
        )}
      </div>

      <p className="text-xs text-gray-400 text-center mt-2">
        Enter 发送,Shift+Enter 换行
      </p>
    </div>
  );
}

7.5.2 流式打字效果实现

1. SSE 消费 Hook

typescript
// hooks/useSSEChat.ts
import { useState, useRef, useCallback } from "react";
import { Message, Attachment } from "../types";

interface UseSSEChatOptions {
  apiUrl: string;
  apiKey?: string;
  model?: string;
  systemPrompt?: string;
}

export function useSSEChat({
  apiUrl, apiKey, model = "gpt-4o-mini", systemPrompt
}: UseSSEChatOptions) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);

  const appendToken = useCallback((token: string) => {
    setMessages((prev) => {
      const last = prev[prev.length - 1];
      if (!last || last.role !== "assistant") return prev;
      return [
        ...prev.slice(0, -1),
        { ...last, content: last.content + token, status: "streaming" as const }
      ];
    });
  }, []);

  const sendMessage = useCallback(async (
    content: string,
    attachments: Attachment[] = []
  ) => {
    if (isStreaming) return;

    // 添加用户消息
    const userMsg: Message = {
      id: crypto.randomUUID(),
      role: "user",
      content,
      status: "done",
      attachments,
      createdAt: Date.now()
    };

    // 添加占位 AI 消息
    const assistantMsg: Message = {
      id: crypto.randomUUID(),
      role: "assistant",
      content: "",
      status: "streaming",
      createdAt: Date.now(),
      model
    };

    setMessages((prev) => [...prev, userMsg, assistantMsg]);
    setIsStreaming(true);

    abortControllerRef.current = new AbortController();

    try {
      // 构建消息历史(包含附件)
      const historyMessages = messages.map((m) => {
        if (m.attachments?.length) {
          const contentParts: object[] = m.attachments
            .filter((a) => a.type === "image")
            .map((a) => ({ type: "image_url", image_url: { url: a.url } }));
          if (m.content) contentParts.push({ type: "text", text: m.content });
          return { role: m.role, content: contentParts };
        }
        return { role: m.role, content: m.content };
      });

      const body: object = {
        messages: [
          ...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
          ...historyMessages,
          { role: "user", content }
        ],
        model,
        stream: true
      };

      const response = await fetch(apiUrl, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
        },
        body: JSON.stringify(body),
        signal: abortControllerRef.current.signal
      });

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

      // 解析 SSE 流
      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: ")) continue;
          const data = line.slice(6).trim();
          if (data === "[DONE]") break;

          try {
            const parsed = JSON.parse(data);
            const token = parsed.choices?.[0]?.delta?.content
              ?? parsed.token;  // 兼容自定义格式
            if (token) appendToken(token);
          } catch {
            // 忽略解析错误
          }
        }
      }

      // 标记完成
      setMessages((prev) => {
        const last = prev[prev.length - 1];
        if (!last || last.role !== "assistant") return prev;
        return [...prev.slice(0, -1), { ...last, status: "done" as const }];
      });

    } catch (error: unknown) {
      if (error instanceof Error && error.name === "AbortError") {
        // 用户主动停止
        setMessages((prev) => {
          const last = prev[prev.length - 1];
          if (!last || last.role !== "assistant") return prev;
          return [...prev.slice(0, -1), { ...last, status: "done" as const }];
        });
      } else {
        setMessages((prev) => {
          const last = prev[prev.length - 1];
          if (!last) return prev;
          return [...prev.slice(0, -1), {
            ...last,
            content: `请求失败: ${error instanceof Error ? error.message : "未知错误"}`,
            status: "error" as const
          }];
        });
      }
    } finally {
      setIsStreaming(false);
      abortControllerRef.current = null;
    }
  }, [messages, isStreaming, apiUrl, apiKey, model, systemPrompt, appendToken]);

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

  const clearMessages = useCallback(() => {
    setMessages([]);
  }, []);

  return { messages, isStreaming, sendMessage, stopStreaming, clearMessages };
}

2. WebSocket 对话 Hook

typescript
// hooks/useWebSocketChat.ts
import { useState, useEffect, useRef, useCallback } from "react";
import { Message } from "../types";

export function useWebSocketChat(wsUrl: string, clientId: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const [connected, setConnected] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const ws = new WebSocket(`${wsUrl}/${clientId}`);
    wsRef.current = ws;

    ws.onopen = () => setConnected(true);
    ws.onclose = () => setConnected(false);

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case "start":
          setIsStreaming(true);
          setMessages((prev) => [...prev, {
            id: crypto.randomUUID(), role: "assistant",
            content: "", status: "streaming", createdAt: Date.now()
          }]);
          break;

        case "token":
          setMessages((prev) => {
            const last = prev[prev.length - 1];
            if (!last || last.role !== "assistant") return prev;
            return [...prev.slice(0, -1),
              { ...last, content: last.content + data.content }];
          });
          break;

        case "done":
          setIsStreaming(false);
          setMessages((prev) => {
            const last = prev[prev.length - 1];
            if (!last) return prev;
            return [...prev.slice(0, -1), { ...last, status: "done" }];
          });
          break;

        case "error":
          setIsStreaming(false);
          break;
      }
    };

    return () => ws.close();
  }, [wsUrl, clientId]);

  const sendMessage = useCallback((content: string) => {
    if (!connected || isStreaming) return;
    setMessages((prev) => [...prev, {
      id: crypto.randomUUID(), role: "user",
      content, status: "done", createdAt: Date.now()
    }]);
    wsRef.current?.send(JSON.stringify({ message: content }));
  }, [connected, isStreaming]);

  return { messages, isStreaming, connected, sendMessage };
}

7.5.3 Markdown 与代码高亮渲染

tsx
// pip install (npm): react-markdown remark-gfm rehype-highlight highlight.js

// components/MarkdownRenderer.tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
import { useState } from "react";
import "highlight.js/styles/github-dark.css";

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

function CopyButton({ text }: { text: string }) {
  const [copied, setCopied] = useState(false);

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

  return (
    <button
      onClick={handleCopy}
      className="absolute top-2 right-2 px-2 py-1 text-xs bg-gray-700
        hover:bg-gray-600 text-gray-300 rounded transition-colors"
    >
      {copied ? "已复制" : "复制"}
    </button>
  );
}

export default function MarkdownRenderer({ content, isStreaming }: Props) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      rehypePlugins={[rehypeHighlight]}
      components=&#123;&#123;
        // 代码块:添加语言标签 + 复制按钮
        pre({ children, ...props }) {
          const codeElement = (children as React.ReactElement)?.props;
          const codeText = codeElement?.children || "";
          const language = (codeElement?.className || "")
            .replace("language-", "").trim() || "text";

          return (
            <div className="relative my-4">
              <div className="flex items-center justify-between bg-gray-800
                rounded-t-lg px-4 py-2">
                <span className="text-xs text-gray-400 font-mono">{language}</span>
                <CopyButton text={typeof codeText === "string" ? codeText : ""} />
              </div>
              <pre {...props} className="!mt-0 !rounded-t-none overflow-x-auto">
                {children}
              </pre>
            </div>
          );
        },

        // 行内代码
        code({ children, className }) {
          if (className) return <code className={className}>{children}</code>;
          return (
            <code className="bg-gray-100 text-pink-600 px-1.5 py-0.5
              rounded text-sm font-mono">
              {children}
            </code>
          );
        },

        // 表格
        table({ children }) {
          return (
            <div className="overflow-x-auto my-4">
              <table className="min-w-full border-collapse border border-gray-200 text-sm">
                {children}
              </table>
            </div>
          );
        },
        th({ children }) {
          return (
            <th className="border border-gray-200 bg-gray-50 px-4 py-2 text-left font-semibold">
              {children}
            </th>
          );
        },
        td({ children }) {
          return <td className="border border-gray-200 px-4 py-2">{children}</td>;
        },

        // 引用块
        blockquote({ children }) {
          return (
            <blockquote className="border-l-4 border-blue-400 bg-blue-50
              pl-4 py-2 my-4 text-gray-700 italic">
              {children}
            </blockquote>
          );
        },

        // 链接(在新标签页打开)
        a({ href, children }) {
          return (
            <a href={href} target="_blank" rel="noopener noreferrer"
              className="text-blue-500 hover:underline">
              {children}
            </a>
          );
        },

        // 标题
        h1: ({ children }) => <h1 className="text-2xl font-bold mt-6 mb-3">{children}</h1>,
        h2: ({ children }) => <h2 className="text-xl font-bold mt-5 mb-2">{children}</h2>,
        h3: ({ children }) => <h3 className="text-lg font-semibold mt-4 mb-2">{children}</h3>,

        // 段落
        p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,

        // 列表
        ul: ({ children }) => <ul className="list-disc list-inside my-2 space-y-1">{children}</ul>,
        ol: ({ children }) => <ol className="list-decimal list-inside my-2 space-y-1">{children}</ol>,
      &#125;&#125;
    >
      {content}
    </ReactMarkdown>
  );
}

7.5.4 文件上传与多模态输入

1. 图片上传与预览

typescript
// hooks/useFileUpload.ts
import { useState, useCallback } from "react";
import { Attachment } from "../types";

const MAX_FILE_SIZE = 20 * 1024 * 1024;  // 20MB
const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const ALLOWED_DOC_TYPES = ["application/pdf", "text/plain"];

export function useFileUpload() {
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const validateFile = (file: File): string | null => {
    if (file.size > MAX_FILE_SIZE) {
      return `文件过大,最大支持 20MB(当前 ${(file.size / 1024 / 1024).toFixed(1)}MB)`;
    }
    const allowed = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_DOC_TYPES];
    if (!allowed.includes(file.type)) {
      return `不支持的文件类型:${file.type}`;
    }
    return null;
  };

  const processFile = useCallback(async (file: File): Promise<Attachment | null> => {
    const validationError = validateFile(file);
    if (validationError) {
      setError(validationError);
      return null;
    }

    setError(null);

    // 图片:直接转 base64(用于发送给视觉模型)
    if (file.type.startsWith("image/")) {
      const base64 = await fileToBase64(file);
      return {
        id: crypto.randomUUID(),
        type: "image",
        name: file.name,
        url: base64,        // base64 data URL,直接传给 API
        size: file.size,
        mimeType: file.type
      };
    }

    // 文档:提取文本内容(通过后端接口)
    if (file.type === "application/pdf" || file.type === "text/plain") {
      setUploading(true);
      try {
        const formData = new FormData();
        formData.append("file", file);
        const resp = await fetch("/api/extract-text", { method: "POST", body: formData });
        const { text, pages } = await resp.json();

        return {
          id: crypto.randomUUID(),
          type: "file",
          name: file.name,
          url: text,    // 提取的文本内容
          size: file.size,
          mimeType: file.type
        };
      } catch (e) {
        setError("文件处理失败");
        return null;
      } finally {
        setUploading(false);
      }
    }

    return null;
  }, []);

  const processFiles = useCallback(async (files: File[]): Promise<Attachment[]> => {
    const results = await Promise.all(files.map(processFile));
    return results.filter(Boolean) as Attachment[];
  }, [processFile]);

  return { processFiles, uploading, error };
}

function fileToBase64(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as string);
    reader.onerror = reject;
    reader.readAsDataURL(file);
  });
}

2. 粘贴图片支持(Ctrl+V)

typescript
// hooks/usePasteImage.ts
import { useEffect, useCallback } from "react";
import { Attachment } from "../types";

export function usePasteImage(onImage: (attachment: Attachment) => void) {
  const handlePaste = useCallback((e: ClipboardEvent) => {
    const items = Array.from(e.clipboardData?.items || []);
    const imageItem = items.find((item) => item.type.startsWith("image/"));

    if (!imageItem) return;

    const file = imageItem.getAsFile();
    if (!file) return;

    const reader = new FileReader();
    reader.onload = () => {
      onImage({
        id: crypto.randomUUID(),
        type: "image",
        name: `paste_${Date.now()}.png`,
        url: reader.result as string,
        size: file.size,
        mimeType: file.type
      });
    };
    reader.readAsDataURL(file);
  }, [onImage]);

  useEffect(() => {
    document.addEventListener("paste", handlePaste);
    return () => document.removeEventListener("paste", handlePaste);
  }, [handlePaste]);
}

3. 拖拽上传

tsx
// components/DropZone.tsx
import { useState, DragEvent, useRef } from "react";
import { Attachment } from "../types";
import { useFileUpload } from "../hooks/useFileUpload";

interface Props {
  onFilesAdded: (attachments: Attachment[]) => void;
  children: React.ReactNode;
}

export default function DropZone({ onFilesAdded, children }: Props) {
  const [isDragging, setIsDragging] = useState(false);
  const dragCounter = useRef(0);
  const { processFiles } = useFileUpload();

  const handleDragEnter = (e: DragEvent) => {
    e.preventDefault();
    dragCounter.current++;
    if (e.dataTransfer.items.length > 0) setIsDragging(true);
  };

  const handleDragLeave = (e: DragEvent) => {
    e.preventDefault();
    dragCounter.current--;
    if (dragCounter.current === 0) setIsDragging(false);
  };

  const handleDrop = async (e: DragEvent) => {
    e.preventDefault();
    setIsDragging(false);
    dragCounter.current = 0;

    const files = Array.from(e.dataTransfer.files);
    if (files.length === 0) return;

    const attachments = await processFiles(files);
    if (attachments.length > 0) onFilesAdded(attachments);
  };

  return (
    <div
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={(e) => e.preventDefault()}
      onDrop={handleDrop}
      className="relative"
    >
      {isDragging && (
        <div className="absolute inset-0 z-50 bg-blue-50/90 border-2 border-dashed
          border-blue-400 rounded-xl flex items-center justify-center pointer-events-none">
          <div className="text-center text-blue-600">
            <p className="text-4xl mb-2">📎</p>
            <p className="font-medium">松开以上传文件</p>
            <p className="text-sm text-blue-400">支持图片、PDF、TXT</p>
          </div>
        </div>
      )}
      {children}
    </div>
  );
}

7.5.5 综合实战:完整 Chat 应用

tsx
// App.tsx - 完整对话应用入口
import { useState } from "react";
import { useSSEChat } from "./hooks/useSSEChat";
import { usePasteImage } from "./hooks/usePasteImage";
import MessageList from "./components/MessageList";
import ChatInput from "./components/ChatInput";
import DropZone from "./components/DropZone";
import { Attachment } from "./types";

const SYSTEM_PROMPT = `你是一个专业的AI助手。
- 回答准确、简洁
- 代码示例使用正确的语言标记
- 复杂问题先给结论再解释`;

export default function App() {
  const [attachmentQueue, setAttachmentQueue] = useState<Attachment[]>([]);

  const { messages, isStreaming, sendMessage, stopStreaming, clearMessages } =
    useSSEChat({
      apiUrl: "http://localhost:8000/v1/chat/completions",
      apiKey: "sk-demo-key-123",
      model: "gpt-4o-mini",
      systemPrompt: SYSTEM_PROMPT
    });

  // 支持粘贴图片
  usePasteImage((attachment) => {
    setAttachmentQueue((prev) => [...prev, attachment]);
  });

  const handleSend = (content: string, attachments: Attachment[]) => {
    sendMessage(content, attachments);
    setAttachmentQueue([]);
  };

  return (
    <div className="flex h-screen bg-white">
      {/* 侧边栏(历史对话列表,略) */}
      <aside className="w-64 border-r bg-gray-50 p-4 hidden lg:block">
        <div className="flex justify-between items-center mb-4">
          <h2 className="font-semibold text-gray-700">历史对话</h2>
          <button
            onClick={clearMessages}
            className="text-xs text-gray-400 hover:text-gray-600"
          >
            新对话
          </button>
        </div>
        {/* 历史列表省略 */}
      </aside>

      {/* 主对话区 */}
      <main className="flex-1 flex flex-col min-w-0">
        {/* 顶栏 */}
        <header className="flex items-center justify-between px-6 py-3 border-b">
          <h1 className="font-semibold text-gray-800">AI 助手</h1>
          <div className="flex items-center gap-2 text-xs text-gray-400">
            {isStreaming && (
              <span className="flex items-center gap-1">
                <span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
                生成中...
              </span>
            )}
          </div>
        </header>

        {/* 消息列表 + 拖拽上传 */}
        <DropZone onFilesAdded={(atts) => setAttachmentQueue((p) => [...p, ...atts])}>
          <MessageList messages={messages} isStreaming={isStreaming} />
        </DropZone>

        {/* 输入区 */}
        <ChatInput
          onSend={handleSend}
          onStop={stopStreaming}
          isStreaming={isStreaming}
          placeholder="输入消息,支持 Ctrl+V 粘贴图片,拖拽上传文件..."
        />
      </main>
    </div>
  );
}
json
// package.json 核心依赖
{
  "dependencies": {
    "react": "^18.3.0",
    "typescript": "^5.0.0",
    "react-markdown": "^9.0.0",
    "remark-gfm": "^4.0.0",
    "rehype-highlight": "^7.0.0",
    "highlight.js": "^11.0.0",
    "tailwindcss": "^3.4.0"
  }
}

学习资源

坚持是一种品格