CoolFace
Apppublic

SFM2001/spititout

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
Chat.tsx370 linesDownload Raw Back to components
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