Avatar / frontend /src /components /AssistantChat.jsx
DataSage12's picture
Initial commit - HOLOKIA-AVATAR v2.2
de63014
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>
);
}