spititout / src /components /Chat.tsx
MSF
with api option
eb426ec
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Send, Trash2, Flame, Sparkles, MessageSquare, Mic, Square, Play, Volume2, Loader2 } from "lucide-react";
import { chatWithSpaceModel, generateSpeech, ChatMode, Message } from "../services/hfSpaceService";
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [mode, setMode] = useState<ChatMode>(ChatMode.VENTING);
const [isLoading, setIsLoading] = useState(false);
const [showSwitchPrompt, setShowSwitchPrompt] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [recordedAudio, setRecordedAudio] = useState<string | null>(null);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
const [generatingSpeech, setGeneratingSpeech] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, isLoading]);
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = async () => {
const blob = new Blob(chunks, { type: "audio/webm" });
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64 = (reader.result as string).split(",")[1];
setRecordedAudio(base64);
};
stream.getTracks().forEach(track => track.stop());
};
recorder.start();
setMediaRecorder(recorder);
setIsRecording(true);
} catch (err) {
console.error("Error accessing microphone:", err);
alert("无法访问麦克风,请检查权限。");
}
};
const stopRecording = () => {
if (mediaRecorder) {
mediaRecorder.stop();
setIsRecording(false);
}
};
const playAudio = (base64Wav: string) => {
const audio = new Audio(`data:audio/wav;base64,${base64Wav}`);
return audio.play();
};
const handleGenerateSpeech = async (message: Message) => {
if (message.aiAudio) {
playAudio(message.aiAudio).catch(console.error);
return;
}
if (generatingSpeech !== null) return;
setGeneratingSpeech(message.timestamp);
const aiAudio = await generateSpeech(message.text);
setGeneratingSpeech(null);
if (!aiAudio) return;
setMessages(prev => prev.map(item => (
item.timestamp === message.timestamp ? { ...item, aiAudio } : item
)));
playAudio(aiAudio).catch(console.error);
};
const handleSend = async (audioPayload?: string) => {
const finalInput = input.trim();
const finalAudio = audioPayload || recordedAudio;
if (!finalInput && !finalAudio) return;
if (isLoading) return;
const userMessage: Message = {
role: "user",
text: finalInput || "🎤 语音消息",
timestamp: Date.now(),
audio: finalAudio || undefined
};
const newMessages = [...messages, userMessage];
setMessages(newMessages);
setInput("");
setRecordedAudio(null);
setIsLoading(true);
const response = await chatWithSpaceModel(newMessages, mode, finalAudio || undefined);
const aiMessage: Message = {
role: "model",
text: response,
timestamp: Date.now(),
};
setMessages([...newMessages, aiMessage]);
setIsLoading(false);
// Suggest switching to Guiding mode after 4 user messages in Venting mode
if (mode === ChatMode.VENTING && newMessages.filter(m => m.role === "user").length >= 4) {
setShowSwitchPrompt(true);
}
};
const toggleMode = (newMode: ChatMode) => {
setMode(newMode);
setShowSwitchPrompt(false);
// Add a transition message from AI when mode changes
const transitionMsg: Message = {
role: "model",
text: newMode === ChatMode.GUIDING
? "既然你愿意听听我的看法,那我们就坐下来,慢慢把这件事捋顺。❤️"
: "好嘞!咱们继续,这事儿换谁谁不气啊?咱接着骂!🔥",
timestamp: Date.now(),
};
setMessages(prev => [...prev, transitionMsg]);
};
const clearChat = () => {
setMessages([]);
setMode(ChatMode.VENTING);
setShowSwitchPrompt(false);
};
return (
<div className={`flex flex-col h-screen transition-colors duration-1000 ${
mode === ChatMode.VENTING ? "bg-red-950/20" : "bg-teal-950/20"
}`}>
{/* Header */}
<header className="fixed top-0 w-full p-4 flex justify-between items-center z-10 backdrop-blur-md border-b border-white/10">
<div className="flex items-center gap-2">
{mode === ChatMode.VENTING ? (
<Flame className="text-orange-500 animate-pulse" />
) : (
<Sparkles className="text-teal-400 animate-pulse" />
)}
<h1 className="font-sans font-bold text-lg tracking-tight text-white/90">
{mode === ChatMode.VENTING ? "情绪宣泄室" : "静心引导室"}
</h1>
</div>
<button
onClick={clearChat}
className="p-2 rounded-full hover:bg-white/10 text-white/60 transition-colors"
title="重新开始"
>
<Trash2 size={20} />
</button>
</header>
{/* Chat Area */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto px-4 pt-20 pb-32 space-y-4 scroll-smooth"
>
{messages.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-center p-8 space-y-6">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
className={`w-24 h-24 rounded-3xl flex items-center justify-center ${
mode === ChatMode.VENTING ? "bg-orange-500/20" : "bg-teal-500/20"
}`}
>
<MessageSquare size={48} className={mode === ChatMode.VENTING ? "text-orange-500" : "text-teal-400"} />
</motion.div>
<div className="space-y-2">
<h2 className="text-2xl font-bold text-white/90">受委屈了?</h2>
<p className="text-white/40 max-w-xs">在这里,你可以毫无顾忌地发泄。我永远站在你这一边。</p>
</div>
<div className="grid grid-cols-2 gap-3 w-full max-w-sm">
<button
onClick={() => setInput("今天遇到了一个超级奇葩的同事...")}
className="p-3 rounded-xl bg-white/5 border border-white/10 text-xs text-white/60 hover:bg-white/10 transition-colors text-left"
>
"同事太奇葩了..."
</button>
<button
onClick={() => setInput("这破天气说变就变,害我计划全乱了!")}
className="p-3 rounded-xl bg-white/5 border border-white/10 text-xs text-white/60 hover:bg-white/10 transition-colors text-left"
>
"这鬼天气..."
</button>
</div>
</div>
)}
<AnimatePresence>
{messages.map((msg, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] px-4 py-3 rounded-2xl relative group ${
msg.role === "user"
? "bg-white/20 text-white rounded-tr-none"
: mode === ChatMode.VENTING
? "bg-orange-600/30 border border-orange-500/20 text-orange-50 rounded-tl-none"
: "bg-teal-600/30 border border-teal-500/20 text-teal-50 rounded-tl-none"
}`}
>
{msg.audio && (
<div className="flex items-center gap-2 mb-2 p-2 rounded-lg bg-black/20">
<Volume2 size={16} className="text-white/60" />
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden">
<div className="w-full h-full bg-white/40" />
</div>
<span className="text-[10px] text-white/40 italic">User Audio</span>
</div>
)}
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.text}</p>
{msg.role === "model" && (
<button
onClick={() => handleGenerateSpeech(msg)}
disabled={generatingSpeech !== null && generatingSpeech !== msg.timestamp}
className="mt-2 flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-[10px] text-white/80"
>
{generatingSpeech === msg.timestamp ? (
<Loader2 size={10} className="animate-spin" />
) : msg.aiAudio ? (
<Play size={10} fill="currentColor" />
) : (
<Volume2 size={10} />
)}
<span>{generatingSpeech === msg.timestamp ? "生成中..." : msg.aiAudio ? "播放语音" : "生成语音"}</span>
</button>
)}
<p className="text-[10px] opacity-40 mt-1 text-right">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</motion.div>
))}
</AnimatePresence>
{isLoading && (
<div className="flex justify-start">
<div className="bg-white/10 px-4 py-2 rounded-2xl flex gap-1 items-center">
<span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce"></span>
<span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce [animation-delay:0.2s]"></span>
<span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce [animation-delay:0.4s]"></span>
</div>
</div>
)}
{showSwitchPrompt && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="mx-4 p-4 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl text-center space-y-3"
>
<p className="text-xs text-white/80">心跳平复一点了吗?要不要尝试一点静心开导?</p>
<div className="flex gap-2 justify-center">
<button
onClick={() => setMode(ChatMode.VENTING)}
className="px-4 py-1.5 rounded-full bg-orange-500 text-white text-xs font-bold shadow-lg shadow-orange-500/20"
>
不了,再骂会儿!
</button>
<button
onClick={() => toggleMode(ChatMode.GUIDING)}
className="px-4 py-1.5 rounded-full bg-teal-500 text-white text-xs font-bold shadow-lg shadow-teal-500/20"
>
好,我想静静
</button>
</div>
</motion.div>
)}
</div>
{/* Input Area */}
<div className="fixed bottom-0 w-full p-4 pb-8 backdrop-blur-lg border-t border-white/10 bg-black/20">
<div className="max-w-4xl mx-auto flex flex-col gap-3">
{recordedAudio && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="flex items-center gap-3 bg-white/10 p-3 rounded-2xl border border-white/20"
>
<div className="w-10 h-10 rounded-full bg-orange-500 flex items-center justify-center animate-pulse">
<Volume2 size={20} className="text-white" />
</div>
<div className="flex-1">
<p className="text-xs text-white/80 font-medium">语音已录制</p>
<p className="text-[10px] text-white/40">点击发送键一起发送文字</p>
</div>
<button
onClick={() => setRecordedAudio(null)}
className="p-2 text-white/40 hover:text-white/90"
>
<Trash2 size={16} />
</button>
</motion.div>
)}
<div className="relative flex items-end gap-2">
<button
onMouseDown={startRecording}
onMouseUp={stopRecording}
onMouseLeave={stopRecording}
onTouchStart={startRecording}
onTouchEnd={stopRecording}
className={`p-3 rounded-2xl transition-all relative ${
isRecording
? "bg-red-500 scale-110 shadow-lg shadow-red-500/50"
: "bg-white/10 hover:bg-white/20 text-white/60"
}`}
title="长按说话"
>
{isRecording ? <Square size={20} fill="white" /> : <Mic size={20} />}
{isRecording && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-ping" />
)}
</button>
<div className="flex-1 min-h-[48px] bg-white/10 rounded-2xl border border-white/20 focus-within:border-white/40 transition-all px-4 py-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder={isRecording ? "正在倾听..." : (mode === ChatMode.VENTING ? "发泄你的不爽..." : "输入你的想法...")}
className="w-full bg-transparent border-none focus:ring-0 text-white placeholder-white/40 text-sm resize-none py-2 max-h-32"
rows={1}
/>
</div>
<button
onClick={() => handleSend()}
disabled={(!input.trim() && !recordedAudio) || isLoading}
className={`p-3 rounded-2xl transition-all ${
(input.trim() || recordedAudio) && !isLoading
? mode === ChatMode.VENTING ? "bg-orange-500 shadow-lg shadow-orange-500/40" : "bg-teal-500 shadow-lg shadow-teal-500/40"
: "bg-white/10 text-white/20"
}`}
>
<Send size={20} className={(input.trim() || recordedAudio) && !isLoading ? "text-white" : ""} />
</button>
</div>
</div>
</div>
</div>
);
}