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(); }}
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={{
// 代码块:添加语言标签 + 复制按钮
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>,
}}
>
{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"
}
}学习资源