Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from "react"; | |
| import { FiSend, FiUser } from "react-icons/fi"; | |
| import { FaMicrophone, FaStop } from "react-icons/fa"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { v4 as uuidv4 } from "uuid"; | |
| import { API_LLM, API_TTS, WS_URL } from "../config/api"; | |
| export default function AssistantChat({ onAudioGenerated, audioRef }) { | |
| const [messages, setMessages] = useState([]); | |
| const [inputText, setInputText] = useState(""); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [recording, setRecording] = useState(false); | |
| const [transcript, setTranscript] = useState(""); | |
| const [responseText, setResponseText] = useState(""); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const [history, setHistory] = useState([]); | |
| const [userId] = useState(uuidv4()); | |
| const audioContextRef = useRef(null); | |
| const mediaStreamRef = useRef(null); | |
| const wsRef = useRef(null); | |
| const messagesEndRef = useRef(null); | |
| const isStoppingRef = useRef(false); | |
| const workletNodeRef = useRef(null); | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| return () => stopRecording(); | |
| }, [messages]); | |
| const sendMessage = async () => { | |
| const userMsg = inputText.trim(); | |
| if (!userMsg || isLoading) return; | |
| setIsLoading(true); | |
| setMessages((prev) => [...prev, { sender: "user", text: userMsg, timestamp: new Date() }]); | |
| setInputText(""); | |
| try { | |
| const updatedHistory = [...history, { role: "user", content: userMsg }]; | |
| const llmResp = await fetch(API_LLM, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ text: userMsg, user_id: userId, history: updatedHistory }), | |
| }); | |
| const llmData = await llmResp.json(); | |
| if (!llmResp.ok) { | |
| throw new Error(llmData.detail || "Erreur serveur LLM"); | |
| } | |
| const assistantText = llmData?.response || "Je n'ai pas compris."; | |
| const lang = llmData?.lang || "fr"; | |
| const newHistory = llmData?.history || updatedHistory; | |
| console.log("Requête texte envoyée:", { text: userMsg, user_id: userId, history: updatedHistory }); | |
| console.log("Réponse LLM reçue:", llmData); | |
| setMessages((prev) => [...prev, { sender: "assistant", text: assistantText, timestamp: new Date() }]); | |
| setHistory(newHistory); | |
| const ttsResp = await fetch(API_TTS, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ text: assistantText, lang }), | |
| }); | |
| const ttsData = await ttsResp.json(); | |
| const audioUrl = ttsData?.url; | |
| if (audioUrl) { | |
| onAudioGenerated?.(audioUrl); | |
| if (audioRef?.current) { | |
| audioRef.current.src = audioUrl; | |
| audioRef.current.crossOrigin = "anonymous"; | |
| await audioRef.current.play().catch((err) => { | |
| console.warn("Erreur lecture audio:", err); | |
| }); | |
| } | |
| } | |
| } catch (err) { | |
| console.warn("Erreur LLM/TTS:", err); | |
| setMessages((prev) => [...prev, { sender: "assistant", text: "Erreur serveur.", timestamp: new Date() }]); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleKeyDown = (e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }; | |
| const startRecording = async () => { | |
| if (recording || isProcessing) return; | |
| isStoppingRef.current = false; | |
| try { | |
| setTranscript(""); | |
| setResponseText(""); | |
| setIsProcessing(true); | |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)({ | |
| sampleRate: 16000, | |
| }); | |
| audioContextRef.current = audioContext; | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }, | |
| }); | |
| mediaStreamRef.current = stream; | |
| const source = audioContext.createMediaStreamSource(stream); | |
| await audioContext.audioWorklet.addModule("/pcm-worklet.js").catch((err) => { | |
| throw new Error("Échec chargement pcm-worklet.js: " + err.message); | |
| }); | |
| const recorderNode = new AudioWorkletNode(audioContext, "pcm-processor", { | |
| processorOptions: { | |
| sampleRate: 16000, | |
| inputSampleRate: audioContext.sampleRate, | |
| chunkSize: 1024, | |
| silenceThreshold: 0.01, | |
| silenceWindow: 3, | |
| }, | |
| }); | |
| workletNodeRef.current = recorderNode; | |
| source.connect(recorderNode); | |
| const ws = new WebSocket(`${WS_URL}/live_stream/${userId}?room_id=room1`); | |
| ws.binaryType = "arraybuffer"; | |
| wsRef.current = ws; | |
| ws.onopen = () => { | |
| ws.send(JSON.stringify({ type: "start", sampleRate: 16000, format: "pcm16", channels: 1 })); | |
| setRecording(true); | |
| setIsProcessing(false); | |
| }; | |
| ws.onmessage = async (event) => { | |
| const msg = event.data; | |
| console.log("WebSocket message:", msg); | |
| if (typeof msg === "string") { | |
| const langRegex = new RegExp(`${userId}\\((fr|en|ar)\\): (.+)`); | |
| const match = msg.match(langRegex); | |
| if (match) { | |
| const trans = match[2].trim(); | |
| if (trans) { | |
| setTranscript((prev) => prev + trans + " "); | |
| setMessages((prev) => [...prev, { sender: "user", text: trans, timestamp: new Date() }]); | |
| setHistory((prev) => [...prev, { role: "user", content: trans }]); | |
| } | |
| } else if (msg.startsWith("Système: ")) { | |
| const resp = msg.replace("Système: ", "").trim(); | |
| if (resp) { | |
| setResponseText((prev) => prev + resp + " "); | |
| setMessages((prev) => [...prev, { sender: "assistant", text: resp, timestamp: new Date() }]); | |
| setHistory((prev) => [...prev, { role: "assistant", content: resp }]); | |
| } | |
| } else { | |
| try { | |
| const data = JSON.parse(msg); | |
| if (data.type === "pong") return; | |
| if (data.type === "stopped") { | |
| safeStopRecording(); | |
| return; | |
| } | |
| if (data.type === "error" && data.message?.trim()) { | |
| console.error("Erreur WebSocket:", data.message); // Log l'erreur dans la console | |
| setMessages((prev) => [...prev, { sender: "assistant", text: data.message.trim(), timestamp: new Date() }]); | |
| setHistory((prev) => [...prev, { role: "assistant", content: data.message.trim() }]); | |
| } else if (data.message?.trim()) { | |
| setResponseText((prev) => prev + data.message + " "); | |
| setMessages((prev) => [...prev, { sender: "assistant", text: data.message.trim(), timestamp: new Date() }]); | |
| setHistory((prev) => [...prev, { role: "assistant", content: data.message.trim() }]); | |
| } | |
| } catch {} | |
| } | |
| } else if (msg instanceof ArrayBuffer || msg instanceof Blob) { | |
| const blob = msg instanceof Blob ? msg : new Blob([msg], { type: "audio/wav" }); | |
| const url = URL.createObjectURL(blob); | |
| if (audioRef?.current) { | |
| audioRef.current.src = url; | |
| await audioRef.current.play().catch((err) => { | |
| console.warn("Erreur lecture audio:", err); | |
| }); | |
| } | |
| } | |
| }; | |
| ws.onclose = (e) => { | |
| if (!isStoppingRef.current && e.code !== 1000) { | |
| setTimeout(startRecording, 1000); | |
| } | |
| safeStopRecording(); | |
| }; | |
| ws.onerror = (err) => { | |
| console.warn("Erreur WebSocket:", err); | |
| safeStopRecording(); | |
| }; | |
| recorderNode.port.onmessage = (e) => { | |
| const { buffer } = e.data || {}; | |
| if (buffer && ws.readyState === WebSocket.OPEN && !isStoppingRef.current) { | |
| ws.send(buffer); | |
| } | |
| }; | |
| wsRef.current.pingInterval = setInterval(() => { | |
| if (ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ type: "ping" })); | |
| } | |
| }, 20000); | |
| } catch (err) { | |
| console.warn("Erreur enregistrement:", err); | |
| console.error("Micro inaccessible:", err?.message || "Erreur inconnue"); | |
| safeStopRecording(); | |
| } | |
| }; | |
| const safeStopRecording = () => { | |
| if (isStoppingRef.current) return; | |
| isStoppingRef.current = true; | |
| try { | |
| workletNodeRef.current?.port?.postMessage({ type: "flush" }); | |
| } catch {} | |
| try { | |
| if (wsRef.current?.readyState === WebSocket.OPEN) { | |
| wsRef.current.send(JSON.stringify({ type: "stop" })); | |
| } | |
| } catch {} | |
| if (wsRef.current?.pingInterval) clearInterval(wsRef.current.pingInterval); | |
| try { | |
| mediaStreamRef.current?.getTracks()?.forEach((t) => t.stop()); | |
| } catch {} | |
| mediaStreamRef.current = null; | |
| try { | |
| audioContextRef.current?.close(); | |
| } catch {} | |
| audioContextRef.current = null; | |
| if (transcript.trim() && !messages.some((msg) => msg.sender === "user" && msg.text === transcript.trim())) { | |
| setMessages((prev) => [...prev, { sender: "user", text: transcript.trim(), timestamp: new Date() }]); | |
| setHistory((prev) => [...prev, { role: "user", content: transcript.trim() }]); | |
| } | |
| setRecording(false); | |
| setIsProcessing(false); | |
| setTranscript(""); | |
| setResponseText(""); | |
| workletNodeRef.current = null; | |
| wsRef.current = null; | |
| }; | |
| const stopRecording = () => { | |
| if (wsRef.current?.readyState === WebSocket.OPEN) { | |
| wsRef.current.send(JSON.stringify({ type: "stop" })); | |
| } | |
| }; | |
| const formatTime = (date) => date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); | |
| return ( | |
| <div className="flex flex-col h-full"> | |
| <div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50/50 rounded-2xl shadow-inner"> | |
| <AnimatePresence initial={false}> | |
| {messages.map((msg, idx) => ( | |
| <motion.div | |
| key={idx} | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0 }} | |
| className={`flex flex-col ${msg.sender === "user" ? "items-end" : "items-start"}`} | |
| > | |
| <div className="flex items-center mb-1"> | |
| <div | |
| className={`w-8 h-8 rounded-full flex items-center justify-center mr-2 ${ | |
| msg.sender === "user" ? "bg-blue-100 text-blue-600" : "bg-indigo-100 text-indigo-600" | |
| }`} | |
| > | |
| {msg.sender === "user" ? <FiUser size={16} /> : <div className="w-4 h-4 bg-indigo-600 rounded-full" />} | |
| </div> | |
| <span className="text-xs text-gray-500"> | |
| {msg.sender === "user" ? "Vous" : "Assistant"} • {formatTime(msg.timestamp)} | |
| </span> | |
| </div> | |
| <div | |
| className={`p-4 rounded-2xl max-w-[85%] shadow-md ${ | |
| msg.sender === "user" ? "bg-blue-500 text-white" : "bg-white text-gray-800" | |
| }`} | |
| > | |
| {msg.text} | |
| </div> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| <div className="flex items-center space-x-2 mt-3"> | |
| <div className="flex-1 flex bg-white/80 backdrop-blur-sm rounded-xl border border-gray-200 overflow-hidden"> | |
| <input | |
| type="text" | |
| className="flex-1 px-4 py-3 focus:outline-none bg-transparent" | |
| placeholder="Écrivez votre message..." | |
| value={inputText} | |
| onChange={(e) => setInputText(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| disabled={isLoading || recording} | |
| /> | |
| <button | |
| className={`flex items-center justify-center w-14 transition-colors ${ | |
| isLoading || !inputText.trim() | |
| ? "bg-gray-300 text-gray-500" | |
| : "bg-gradient-to-r from-blue-500 to-indigo-600 text-white hover:from-blue-600 hover:to-indigo-700" | |
| }`} | |
| onClick={sendMessage} | |
| disabled={isLoading || !inputText.trim() || recording} | |
| > | |
| {isLoading ? ( | |
| <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <FiSend size={20} /> | |
| )} | |
| </button> | |
| </div> | |
| <button | |
| onClick={recording ? stopRecording : startRecording} | |
| disabled={isProcessing} | |
| className={`w-16 h-16 rounded-full shadow-xl transition transform hover:scale-105 | |
| ${ | |
| recording | |
| ? "bg-gradient-to-r from-blue-500 to-indigo-600 text-white" | |
| : "bg-gradient-to-r from-red-500 to-red-600 text-white" | |
| } | |
| flex items-center justify-center`} | |
| > | |
| {recording ? ( | |
| <FaStop size={24} /> | |
| ) : isProcessing ? ( | |
| <div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <FaMicrophone size={24} /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } |