| 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); |
|
|
| |
| 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); |
|
|
| |
| if (mode === ChatMode.VENTING && newMessages.filter(m => m.role === "user").length >= 4) { |
| setShowSwitchPrompt(true); |
| } |
| }; |
|
|
| const toggleMode = (newMode: ChatMode) => { |
| setMode(newMode); |
| setShowSwitchPrompt(false); |
| |
| 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> |
| ); |
| } |
|
|