add
This commit is contained in:
@@ -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>
|
||||
|
||||
118
src/index.css
118
src/index.css
@@ -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; }
|
||||
}
|
||||
|
||||
8
src/styles/typing-indicator.css
Normal file
8
src/styles/typing-indicator.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.typing-indicator {
|
||||
display: inline-block;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
Reference in New Issue
Block a user