This commit is contained in:
maojindao55
2025-02-12 19:22:13 +08:00
parent efa6d1792b
commit 3fac860583
7 changed files with 522 additions and 150 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { Send, Menu, MoreHorizontal, UserPlus, UserMinus } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -42,11 +42,205 @@ const ChatUI = () => {
{ id: 5, name: "我" },
]);
const [showMembers, setShowMembers] = useState(false);
const [messages, setMessages] = useState([
{ id: 1, sender: users[0], content: "大家好!", isAI: false },
{ id: 2, sender: users[4], content: "你好!", isAI: false },
]);
const [inputMessage, setInputMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [pendingContent, setPendingContent] = useState("");
const [isTyping, setIsTyping] = useState(false);
const typingSpeed = 30;
const currentMessageRef = useRef<number | null>(null);
const typewriterRef = useRef<NodeJS.Timeout | null>(null);
const accumulatedContentRef = useRef(""); // 用于跟踪完整内容
const abortController = new AbortController();
const handleRemoveUser = (userId: number) => {
setUsers(users.filter(user => user.id !== userId));
};
const typeWriter = (newContent: string, messageId: number) => {
if (!newContent) return;
setIsTyping(true);
currentMessageRef.current = messageId;
// 获取已显示的内容长度作为起始位置
const startIndex = accumulatedContentRef.current.length;
let currentIndex = startIndex;
// 清除之前的打字效果
if (typewriterRef.current) {
clearInterval(typewriterRef.current);
}
typewriterRef.current = setInterval(() => {
currentIndex++;
setMessages(prev => {
const newMessages = [...prev];
const messageIndex = newMessages.findIndex(msg => msg.id === messageId);
if (messageIndex !== -1) {
newMessages[messageIndex] = {
...newMessages[messageIndex],
content: newContent.slice(0, currentIndex)
};
}
return newMessages;
});
if (currentIndex >= newContent.length) {
if (typewriterRef.current) {
clearInterval(typewriterRef.current);
}
setIsTyping(false);
currentMessageRef.current = null;
accumulatedContentRef.current = newContent; // 更新完整内容
}
}, typingSpeed);
};
const handleSendMessage = async () => {
if (!inputMessage.trim()) return;
const userMessage = {
id: messages.length + 1,
sender: users[4],
content: inputMessage,
isAI: false
};
setMessages(prev => [...prev, userMessage]);
const aiMessage = {
id: messages.length + 2,
sender: { id: 0, name: "AI助手" },
content: "",
isAI: true
};
setMessages(prev => [...prev, aiMessage]);
setInputMessage("");
setIsLoading(true);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: inputMessage,
}),
});
if (!response.ok) {
throw new Error('请求失败');
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('无法获取响应流');
}
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码并添加到buffer
buffer += decoder.decode(value, { stream: true });
// 处理buffer中的完整SSE消息
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.content) {
// 使用函数式更新确保状态更新正确
setMessages(prev => {
const newMessages = [...prev];
const aiMessageIndex = newMessages.findIndex(msg => msg.id === aiMessage.id);
if (aiMessageIndex !== -1) {
newMessages[aiMessageIndex] = {
...newMessages[aiMessageIndex],
content: newMessages[aiMessageIndex].content + data.content
};
}
return newMessages;
});
// 滚动到底部
setTimeout(() => {
const chatContainer = document.querySelector('.scroll-area-viewport');
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}, 0);
}
} catch (e) {
console.error('解析响应数据失败:', e);
}
}
}
}
// 处理剩余的buffer
if (buffer.length > 0 && buffer.startsWith('data: ')) {
try {
const data = JSON.parse(buffer.slice(6));
if (data.content) {
setMessages(prev => {
const newMessages = [...prev];
const aiMessageIndex = newMessages.findIndex(msg => msg.id === aiMessage.id);
if (aiMessageIndex !== -1) {
newMessages[aiMessageIndex] = {
...newMessages[aiMessageIndex],
content: newMessages[aiMessageIndex].content + data.content
};
}
return newMessages;
});
}
} catch (e) {
console.error('解析最终响应数据失败:', e);
}
}
} catch (error) {
console.error("发送消息失败:", error);
setMessages(prev => prev.map(msg =>
msg.id === aiMessage.id
? { ...msg, content: "错误: " + error.message, isError: true }
: msg
));
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
abortController.abort();
};
// 清理打字机效果
useEffect(() => {
return () => {
if (typewriterRef.current) {
clearInterval(typewriterRef.current);
}
};
}, []);
return (
<div className="h-screen flex flex-col bg-gray-100">
{/* Header */}
@@ -112,35 +306,36 @@ const ChatUI = () => {
<div className="flex-1 flex flex-col overflow-hidden">
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{/* Message 1 */}
<div className="flex items-start gap-3">
<Avatar>
<AvatarFallback style={{ backgroundColor: getAvatarData(users[0].name).backgroundColor, color: 'white' }}>
{users[0].name[0]}
</AvatarFallback>
</Avatar>
<div>
<div className="text-sm text-gray-500">{users[0].name}</div>
<div className="mt-1 bg-white p-3 rounded-lg shadow-sm">
{messages.map((message) => (
<div key={message.id}
className={`flex items-start gap-3 ${message.sender.name === "我" ? "justify-end" : ""}`}>
{message.sender.name !== "我" && (
<Avatar>
<AvatarFallback style={{ backgroundColor: getAvatarData(message.sender.name).backgroundColor, color: 'white' }}>
{message.sender.name[0]}
</AvatarFallback>
</Avatar>
)}
<div className={message.sender.name === "我" ? "text-right" : ""}>
<div className="text-sm text-gray-500">{message.sender.name}</div>
<div className={`mt-1 p-3 rounded-lg shadow-sm ${
message.sender.name === "我" ? "bg-blue-500 text-white" : "bg-white"
}`}>
{message.content}
{message.isAI && isTyping && currentMessageRef.current === message.id && (
<span className="typing-indicator ml-1"></span>
)}
</div>
</div>
{message.sender.name === "我" && (
<Avatar>
<AvatarFallback style={{ backgroundColor: getAvatarData(message.sender.name).backgroundColor, color: 'white' }}>
{message.sender.name[0]}
</AvatarFallback>
</Avatar>
)}
</div>
</div>
{/* Message 2 */}
<div className="flex items-start gap-3 justify-end">
<div className="text-right">
<div className="text-sm text-gray-500">{users[4].name}</div>
<div className="mt-1 bg-blue-500 text-white p-3 rounded-lg shadow-sm">
</div>
</div>
<Avatar>
<AvatarFallback style={{ backgroundColor: getAvatarData(users[4].name).backgroundColor, color: 'white' }}>
{users[4].name[0]}
</AvatarFallback>
</Avatar>
</div>
))}
</div>
</ScrollArea>
@@ -150,9 +345,19 @@ const ChatUI = () => {
<Input
placeholder="输入消息..."
className="flex-1"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
/>
<Button>
<Send className="w-4 h-4 mr-2" />
<Button
onClick={handleSendMessage}
disabled={isLoading}
>
{isLoading ? (
<div className="w-4 h-4 mr-2 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : (
<Send className="w-4 h-4 mr-2" />
)}
</Button>
</div>

View File

@@ -1,120 +1,12 @@
/* 基础样式重置 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ... */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
}
.typing-indicator {
display: inline-block;
animation: blink 1s step-end infinite;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
@keyframes blink {
50% { opacity: 0; }
}

View File

@@ -0,0 +1,8 @@
.typing-indicator {
display: inline-block;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}