SFM2001/spititout
0
1import React, { useState, useEffect, useRef } from "react";2import { motion, AnimatePresence } from "motion/react";3import { Send, Trash2, Flame, Sparkles, MessageSquare, Mic, Square, Play, Volume2, Loader2 } from "lucide-react";4import { chatWithSpaceModel, generateSpeech, ChatMode, Message } from "../services/hfSpaceService";5 6export default function Chat() {7 const [messages, setMessages] = useState<Message[]>([]);8 const [input, setInput] = useState("");9 const [mode, setMode] = useState<ChatMode>(ChatMode.VENTING);10 const [isLoading, setIsLoading] = useState(false);11 const [showSwitchPrompt, setShowSwitchPrompt] = useState(false);12 const [isRecording, setIsRecording] = useState(false);13 const [recordedAudio, setRecordedAudio] = useState<string | null>(null);14 const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);15 const [generatingSpeech, setGeneratingSpeech] = useState<number | null>(null);16 const scrollRef = useRef<HTMLDivElement>(null);17 18 // Auto-scroll to bottom19 useEffect(() => {20 if (scrollRef.current) {21 scrollRef.current.scrollTop = scrollRef.current.scrollHeight;22 }23 }, [messages, isLoading]);24 25 const startRecording = async () => {26 try {27 const stream = await navigator.mediaDevices.getUserMedia({ audio: true });28 const recorder = new MediaRecorder(stream);29 const chunks: Blob[] = [];30 31 recorder.ondataavailable = (e) => chunks.push(e.data);32 recorder.onstop = async () => {33 const blob = new Blob(chunks, { type: "audio/webm" });34 const reader = new FileReader();35 reader.readAsDataURL(blob);36 reader.onloadend = () => {37 const base64 = (reader.result as string).split(",")[1];38 setRecordedAudio(base64);39 };40 stream.getTracks().forEach(track => track.stop());41 };42 43 recorder.start();44 setMediaRecorder(recorder);45 setIsRecording(true);46 } catch (err) {47 console.error("Error accessing microphone:", err);48 alert("无法访问麦克风,请检查权限。");49 }50 };51 52 const stopRecording = () => {53 if (mediaRecorder) {54 mediaRecorder.stop();55 setIsRecording(false);56 }57 };58 59 const playAudio = (base64Wav: string) => {60 const audio = new Audio(`data:audio/wav;base64,${base64Wav}`);61 return audio.play();62 };63 64 const handleGenerateSpeech = async (message: Message) => {65 if (message.aiAudio) {66 playAudio(message.aiAudio).catch(console.error);67 return;68 }69 if (generatingSpeech !== null) return;70 71 setGeneratingSpeech(message.timestamp);72 const aiAudio = await generateSpeech(message.text);73 setGeneratingSpeech(null);74 75 if (!aiAudio) return;76 77 setMessages(prev => prev.map(item => (78 item.timestamp === message.timestamp ? { ...item, aiAudio } : item79 )));80 playAudio(aiAudio).catch(console.error);81 };82 83 const handleSend = async (audioPayload?: string) => {84 const finalInput = input.trim();85 const finalAudio = audioPayload || recordedAudio;86 87 if (!finalInput && !finalAudio) return;88 if (isLoading) return;89 90 const userMessage: Message = {91 role: "user",92 text: finalInput || "🎤 语音消息",93 timestamp: Date.now(),94 audio: finalAudio || undefined95 };96 97 const newMessages = [...messages, userMessage];98 setMessages(newMessages);99 setInput("");100 setRecordedAudio(null);101 setIsLoading(true);102 103 const response = await chatWithSpaceModel(newMessages, mode, finalAudio || undefined);104 105 const aiMessage: Message = {106 role: "model",107 text: response,108 timestamp: Date.now(),109 };110 111 setMessages([...newMessages, aiMessage]);112 setIsLoading(false);113 114 // Suggest switching to Guiding mode after 4 user messages in Venting mode115 if (mode === ChatMode.VENTING && newMessages.filter(m => m.role === "user").length >= 4) {116 setShowSwitchPrompt(true);117 }118 };119 120 const toggleMode = (newMode: ChatMode) => {121 setMode(newMode);122 setShowSwitchPrompt(false);123 // Add a transition message from AI when mode changes124 const transitionMsg: Message = {125 role: "model",126 text: newMode === ChatMode.GUIDING 127 ? "既然你愿意听听我的看法,那我们就坐下来,慢慢把这件事捋顺。❤️" 128 : "好嘞!咱们继续,这事儿换谁谁不气啊?咱接着骂!🔥",129 timestamp: Date.now(),130 };131 setMessages(prev => [...prev, transitionMsg]);132 };133 134 const clearChat = () => {135 setMessages([]);136 setMode(ChatMode.VENTING);137 setShowSwitchPrompt(false);138 };139 140 return (141 <div className={`flex flex-col h-screen transition-colors duration-1000 ${142 mode === ChatMode.VENTING ? "bg-red-950/20" : "bg-teal-950/20"143 }`}>144 {/* Header */}145 <header className="fixed top-0 w-full p-4 flex justify-between items-center z-10 backdrop-blur-md border-b border-white/10">146 <div className="flex items-center gap-2">147 {mode === ChatMode.VENTING ? (148 <Flame className="text-orange-500 animate-pulse" />149 ) : (150 <Sparkles className="text-teal-400 animate-pulse" />151 )}152 <h1 className="font-sans font-bold text-lg tracking-tight text-white/90">153 {mode === ChatMode.VENTING ? "情绪宣泄室" : "静心引导室"}154 </h1>155 </div>156 <button 157 onClick={clearChat}158 className="p-2 rounded-full hover:bg-white/10 text-white/60 transition-colors"159 title="重新开始"160 >161 <Trash2 size={20} />162 </button>163 </header>164 165 {/* Chat Area */}166 <div 167 ref={scrollRef}168 className="flex-1 overflow-y-auto px-4 pt-20 pb-32 space-y-4 scroll-smooth"169 >170 {messages.length === 0 && (171 <div className="h-full flex flex-col items-center justify-center text-center p-8 space-y-6">172 <motion.div173 initial={{ scale: 0.8, opacity: 0 }}174 animate={{ scale: 1, opacity: 1 }}175 transition={{ duration: 0.5 }}176 className={`w-24 h-24 rounded-3xl flex items-center justify-center ${177 mode === ChatMode.VENTING ? "bg-orange-500/20" : "bg-teal-500/20"178 }`}179 >180 <MessageSquare size={48} className={mode === ChatMode.VENTING ? "text-orange-500" : "text-teal-400"} />181 </motion.div>182 <div className="space-y-2">183 <h2 className="text-2xl font-bold text-white/90">受委屈了?</h2>184 <p className="text-white/40 max-w-xs">在这里,你可以毫无顾忌地发泄。我永远站在你这一边。</p>185 </div>186 <div className="grid grid-cols-2 gap-3 w-full max-w-sm">187 <button 188 onClick={() => setInput("今天遇到了一个超级奇葩的同事...")}189 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"190 >191 "同事太奇葩了..."192 </button>193 <button 194 onClick={() => setInput("这破天气说变就变,害我计划全乱了!")}195 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"196 >197 "这鬼天气..."198 </button>199 </div>200 </div>201 )}202 203 <AnimatePresence>204 {messages.map((msg, i) => (205 <motion.div206 key={i}207 initial={{ opacity: 0, y: 10 }}208 animate={{ opacity: 1, y: 0 }}209 exit={{ opacity: 0, scale: 0.95 }}210 className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}211 >212 <div213 className={`max-w-[85%] px-4 py-3 rounded-2xl relative group ${214 msg.role === "user"215 ? "bg-white/20 text-white rounded-tr-none"216 : mode === ChatMode.VENTING217 ? "bg-orange-600/30 border border-orange-500/20 text-orange-50 rounded-tl-none"218 : "bg-teal-600/30 border border-teal-500/20 text-teal-50 rounded-tl-none"219 }`}220 >221 {msg.audio && (222 <div className="flex items-center gap-2 mb-2 p-2 rounded-lg bg-black/20">223 <Volume2 size={16} className="text-white/60" />224 <div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden">225 <div className="w-full h-full bg-white/40" />226 </div>227 <span className="text-[10px] text-white/40 italic">User Audio</span>228 </div>229 )}230 <p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.text}</p>231 232 {msg.role === "model" && (233 <button 234 onClick={() => handleGenerateSpeech(msg)}235 disabled={generatingSpeech !== null && generatingSpeech !== msg.timestamp}236 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"237 >238 {generatingSpeech === msg.timestamp ? (239 <Loader2 size={10} className="animate-spin" />240 ) : msg.aiAudio ? (241 <Play size={10} fill="currentColor" />242 ) : (243 <Volume2 size={10} />244 )}245 <span>{generatingSpeech === msg.timestamp ? "生成中..." : msg.aiAudio ? "播放语音" : "生成语音"}</span>246 </button>247 )}248 249 <p className="text-[10px] opacity-40 mt-1 text-right">250 {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}251 </p>252 </div>253 </motion.div>254 ))}255 </AnimatePresence>256 257 {isLoading && (258 <div className="flex justify-start">259 <div className="bg-white/10 px-4 py-2 rounded-2xl flex gap-1 items-center">260 <span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce"></span>261 <span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce [animation-delay:0.2s]"></span>262 <span className="w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce [animation-delay:0.4s]"></span>263 </div>264 </div>265 )}266 267 {showSwitchPrompt && (268 <motion.div 269 initial={{ opacity: 0, scale: 0.9 }}270 animate={{ opacity: 1, scale: 1 }}271 className="mx-4 p-4 rounded-2xl bg-white/10 border border-white/20 backdrop-blur-xl text-center space-y-3"272 >273 <p className="text-xs text-white/80">心跳平复一点了吗?要不要尝试一点静心开导?</p>274 <div className="flex gap-2 justify-center">275 <button 276 onClick={() => setMode(ChatMode.VENTING)}277 className="px-4 py-1.5 rounded-full bg-orange-500 text-white text-xs font-bold shadow-lg shadow-orange-500/20"278 >279 不了,再骂会儿!280 </button>281 <button 282 onClick={() => toggleMode(ChatMode.GUIDING)}283 className="px-4 py-1.5 rounded-full bg-teal-500 text-white text-xs font-bold shadow-lg shadow-teal-500/20"284 >285 好,我想静静286 </button>287 </div>288 </motion.div>289 )}290 </div>291 292 {/* Input Area */}293 <div className="fixed bottom-0 w-full p-4 pb-8 backdrop-blur-lg border-t border-white/10 bg-black/20">294 <div className="max-w-4xl mx-auto flex flex-col gap-3">295 {recordedAudio && (296 <motion.div 297 initial={{ scale: 0.9, opacity: 0 }}298 animate={{ scale: 1, opacity: 1 }}299 className="flex items-center gap-3 bg-white/10 p-3 rounded-2xl border border-white/20"300 >301 <div className="w-10 h-10 rounded-full bg-orange-500 flex items-center justify-center animate-pulse">302 <Volume2 size={20} className="text-white" />303 </div>304 <div className="flex-1">305 <p className="text-xs text-white/80 font-medium">语音已录制</p>306 <p className="text-[10px] text-white/40">点击发送键一起发送文字</p>307 </div>308 <button 309 onClick={() => setRecordedAudio(null)}310 className="p-2 text-white/40 hover:text-white/90"311 >312 <Trash2 size={16} />313 </button>314 </motion.div>315 )}316 317 <div className="relative flex items-end gap-2">318 <button319 onMouseDown={startRecording}320 onMouseUp={stopRecording}321 onMouseLeave={stopRecording}322 onTouchStart={startRecording}323 onTouchEnd={stopRecording}324 className={`p-3 rounded-2xl transition-all relative ${325 isRecording 326 ? "bg-red-500 scale-110 shadow-lg shadow-red-500/50" 327 : "bg-white/10 hover:bg-white/20 text-white/60"328 }`}329 title="长按说话"330 >331 {isRecording ? <Square size={20} fill="white" /> : <Mic size={20} />}332 {isRecording && (333 <span className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-ping" />334 )}335 </button>336 337 <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">338 <textarea339 value={input}340 onChange={(e) => setInput(e.target.value)}341 onKeyDown={(e) => {342 if (e.key === "Enter" && !e.shiftKey) {343 e.preventDefault();344 handleSend();345 }346 }}347 placeholder={isRecording ? "正在倾听..." : (mode === ChatMode.VENTING ? "发泄你的不爽..." : "输入你的想法...")}348 className="w-full bg-transparent border-none focus:ring-0 text-white placeholder-white/40 text-sm resize-none py-2 max-h-32"349 rows={1}350 />351 </div>352 353 <button354 onClick={() => handleSend()}355 disabled={(!input.trim() && !recordedAudio) || isLoading}356 className={`p-3 rounded-2xl transition-all ${357 (input.trim() || recordedAudio) && !isLoading358 ? mode === ChatMode.VENTING ? "bg-orange-500 shadow-lg shadow-orange-500/40" : "bg-teal-500 shadow-lg shadow-teal-500/40"359 : "bg-white/10 text-white/20"360 }`}361 >362 <Send size={20} className={(input.trim() || recordedAudio) && !isLoading ? "text-white" : ""} />363 </button>364 </div>365 </div>366 </div>367 </div>368 );369}370 