Files
openbot/static/app.js

404 lines
11 KiB
JavaScript

const conversation = document.querySelector("#conversation");
const statusDot = document.querySelector("#statusDot");
const statusText = document.querySelector("#statusText");
const textInput = document.querySelector("#textInput");
const sendButton = document.querySelector("#sendButton");
const voiceButton = document.querySelector("#voiceButton");
const voiceLabel = document.querySelector("#voiceLabel");
const resetButton = document.querySelector("#resetButton");
const audioToggle = document.querySelector("#audioToggle");
const SILENCE_DELAY_MS = 2000;
const MIN_VOICE_MS = 450;
const MAX_UTTERANCE_MS = 30000;
const VOICE_THRESHOLD = 0.025;
const sessionId = getSessionId();
let recorder = null;
let chunks = [];
let audioUnlocked = false;
let handsFreeMode = false;
let processingVoice = false;
let mediaStream = null;
let audioContext = null;
let analyser = null;
let analyserData = null;
let monitorFrame = null;
let speechDetected = false;
let speechStartedAt = 0;
let silenceStartedAt = null;
let utteranceStartedAt = 0;
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
sendButton.addEventListener("click", sendText);
voiceButton.addEventListener("click", toggleVoice);
resetButton.addEventListener("click", resetConversation);
textInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendText();
}
});
function getSessionId() {
const key = "opensquared_assistant_session";
let value = localStorage.getItem(key);
if (!value) {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
value = window.crypto.randomUUID();
} else {
value = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
localStorage.setItem(key, value);
}
return value;
}
function setStatus(text, mode = "idle") {
statusText.textContent = text;
statusDot.classList.toggle("live", mode === "live");
statusDot.classList.toggle("error", mode === "error");
}
function addBubble(text, role = "assistant") {
const bubble = document.createElement("article");
bubble.className = `bubble ${role}`;
bubble.textContent = text;
conversation.appendChild(bubble);
conversation.scrollTop = conversation.scrollHeight;
return bubble;
}
async function unlockAudio() {
if (audioUnlocked) return;
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
const context = new AudioContext();
if (context.state === "suspended") {
await context.resume();
}
await context.close();
}
} catch {
// The click itself is enough to authorize later playback in most browsers.
}
audioUnlocked = true;
}
async function sendText() {
const text = textInput.value.trim();
if (!text) return;
textInput.value = "";
addBubble(text, "user");
setStatus("Analyse en cours", "live");
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
sessionId,
text,
withAudio: false,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Erreur serveur");
await handleAssistantResponse(data);
} catch (error) {
addBubble(error.message, "error");
setStatus("Erreur", "error");
}
}
async function toggleVoice() {
if (handsFreeMode) {
stopHandsFree("Arrete");
return;
}
await startHandsFree();
}
async function startHandsFree() {
setStatus("Activation du micro", "live");
await unlockAudio();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
addBubble("Le micro n'est pas disponible dans ce navigateur. Ouvre l'app en HTTPS avec Chrome.", "error");
setStatus("Micro indisponible", "error");
return;
}
if (typeof MediaRecorder === "undefined") {
addBubble("L'enregistrement audio n'est pas supporte par ce navigateur.", "error");
setStatus("Enregistrement indisponible", "error");
return;
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
handsFreeMode = true;
voiceButton.classList.add("recording");
voiceLabel.textContent = "Arreter";
await startListeningTurn();
} catch (error) {
addBubble("Micro indisponible : " + error.message, "error");
setStatus("Micro indisponible", "error");
}
}
async function startListeningTurn() {
if (!handsFreeMode || processingVoice) return;
cleanupRecorder();
chunks = [];
speechDetected = false;
speechStartedAt = 0;
silenceStartedAt = null;
utteranceStartedAt = Date.now();
const mimeType = pickMimeType();
recorder = new MediaRecorder(mediaStream, mimeType ? {mimeType} : undefined);
recorder.ondataavailable = (event) => {
if (event.data.size) chunks.push(event.data);
};
recorder.onstop = async () => {
stopVolumeMonitor();
if (!handsFreeMode) return;
const spokenLongEnough = speechDetected && Date.now() - speechStartedAt >= MIN_VOICE_MS;
if (!spokenLongEnough) {
window.setTimeout(startListeningTurn, 120);
return;
}
const blob = new Blob(chunks, {type: recorder.mimeType || "audio/webm"});
await sendVoiceBlob(blob);
};
recorder.start(250);
startVolumeMonitor();
setStatus("Je t'ecoute", "live");
}
function pickMimeType() {
const types = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"];
return types.find((type) => MediaRecorder.isTypeSupported(type)) || "";
}
function startVolumeMonitor() {
stopVolumeMonitor();
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(mediaStream);
analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyserData = new Uint8Array(analyser.fftSize);
source.connect(analyser);
const tick = () => {
if (!recorder || recorder.state !== "recording") return;
const now = Date.now();
const volume = getCurrentVolume();
if (volume > VOICE_THRESHOLD) {
if (!speechDetected) {
speechStartedAt = now;
setStatus("Je t'entends", "live");
}
speechDetected = true;
silenceStartedAt = null;
} else if (speechDetected) {
silenceStartedAt = silenceStartedAt || now;
if (now - silenceStartedAt >= SILENCE_DELAY_MS) {
setStatus("Message recu", "live");
recorder.stop();
return;
}
}
if (now - utteranceStartedAt >= MAX_UTTERANCE_MS) {
setStatus("Message recu", "live");
recorder.stop();
return;
}
monitorFrame = window.requestAnimationFrame(tick);
};
monitorFrame = window.requestAnimationFrame(tick);
}
function getCurrentVolume() {
analyser.getByteTimeDomainData(analyserData);
let sum = 0;
for (const value of analyserData) {
const centered = (value - 128) / 128;
sum += centered * centered;
}
return Math.sqrt(sum / analyserData.length);
}
function stopVolumeMonitor() {
if (monitorFrame) {
window.cancelAnimationFrame(monitorFrame);
monitorFrame = null;
}
if (audioContext) {
audioContext.close().catch(() => {});
audioContext = null;
}
analyser = null;
analyserData = null;
}
function cleanupRecorder() {
stopVolumeMonitor();
recorder = null;
chunks = [];
}
function stopHandsFree(label = "Pret") {
handsFreeMode = false;
processingVoice = false;
if (recorder && recorder.state !== "inactive") {
recorder.onstop = null;
recorder.stop();
}
cleanupRecorder();
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
window.speechSynthesis?.cancel();
voiceButton.classList.remove("recording");
voiceLabel.textContent = "Demarrer";
setStatus(label, "idle");
}
async function sendVoiceBlob(blob) {
processingVoice = true;
setStatus("Transcription", "live");
const form = new FormData();
const extension = blob.type.includes("ogg") ? "ogg" : "webm";
form.append("sessionId", sessionId);
form.append("withAudio", "false");
form.append("audio", blob, `voice.${extension}`);
try {
const response = await fetch("/api/voice", {method: "POST", body: form});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Erreur serveur");
if (data.transcript) addBubble(data.transcript, "user");
await handleAssistantResponse(data);
} catch (error) {
addBubble(error.message, "error");
setStatus("Erreur", "error");
} finally {
processingVoice = false;
if (handsFreeMode) {
window.setTimeout(startListeningTurn, 250);
}
}
}
async function handleAssistantResponse(data) {
const reply = data.reply || "";
addBubble(reply, "assistant");
const status = data.emailSent ? "Email envoye" : data.contactSaved ? "Contact ajoute" : "Pret";
setStatus(status, data.emailSent || data.contactSaved ? "live" : "idle");
if (!audioToggle.checked || !reply) return;
if (data.audioUrl) {
try {
const audio = new Audio(data.audioUrl);
await audio.play();
return;
} catch {
// Fall through to the local French browser voice.
}
}
await speakInFrench(reply);
}
function speakInFrench(text) {
return new Promise((resolve) => {
if (!("speechSynthesis" in window) || !("SpeechSynthesisUtterance" in window)) {
resolve();
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "fr-FR";
utterance.rate = 0.9;
utterance.pitch = 1.08;
utterance.volume = 0.92;
utterance.voice = pickSoftFrenchVoice();
utterance.onend = resolve;
utterance.onerror = resolve;
window.speechSynthesis.speak(utterance);
});
}
function pickSoftFrenchVoice() {
const voices = window.speechSynthesis.getVoices();
const frenchVoices = voices.filter((voice) => voice.lang && voice.lang.toLowerCase().startsWith("fr"));
const preferredNames = [
"audrey",
"aurelie",
"aurélie",
"marie",
"julie",
"celine",
"céline",
"lea",
"léa",
"amelie",
"amélie",
"sylvie",
"hortense"
];
return (
frenchVoices.find((voice) => preferredNames.some((name) => voice.name.toLowerCase().includes(name))) ||
frenchVoices.find((voice) => voice.lang === "fr-FR" && /female|femme|google|microsoft/i.test(voice.name)) ||
frenchVoices.find((voice) => voice.lang === "fr-FR") ||
frenchVoices[0] ||
null
);
}
window.speechSynthesis?.addEventListener?.("voiceschanged", pickSoftFrenchVoice);
async function resetConversation() {
await fetch("/api/reset", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({sessionId}),
}).catch(() => {});
conversation.innerHTML = "";
addBubble("Nouvelle conversation. Je t'ecoute.", "assistant");
setStatus("Conversation reinitialisee", "live");
}