Initial PWA assistant proof of concept
This commit is contained in:
180
static/app.js
Normal file
180
static/app.js
Normal file
@@ -0,0 +1,180 @@
|
||||
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 sessionId = getSessionId();
|
||||
let recorder = null;
|
||||
let chunks = [];
|
||||
let isRecording = false;
|
||||
let audioUnlocked = false;
|
||||
|
||||
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) {
|
||||
value = crypto.randomUUID();
|
||||
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;
|
||||
const audio = new Audio();
|
||||
audio.muted = true;
|
||||
try {
|
||||
await audio.play();
|
||||
} catch {
|
||||
// Mobile browsers only need the attempt to happen inside a tap handler.
|
||||
}
|
||||
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: audioToggle.checked,
|
||||
}),
|
||||
});
|
||||
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() {
|
||||
await unlockAudio();
|
||||
if (isRecording) {
|
||||
recorder.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
const mimeType = pickMimeType();
|
||||
recorder = new MediaRecorder(stream, mimeType ? {mimeType} : undefined);
|
||||
chunks = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size) chunks.push(event.data);
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
isRecording = false;
|
||||
voiceButton.classList.remove("recording");
|
||||
voiceLabel.textContent = "Demarrer";
|
||||
await sendVoiceBlob(new Blob(chunks, {type: recorder.mimeType || "audio/webm"}));
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
isRecording = true;
|
||||
voiceButton.classList.add("recording");
|
||||
voiceLabel.textContent = "Stop";
|
||||
setStatus("Je t'ecoute", "live");
|
||||
} catch (error) {
|
||||
addBubble("Micro indisponible : " + error.message, "error");
|
||||
setStatus("Micro indisponible", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function pickMimeType() {
|
||||
const types = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"];
|
||||
return types.find((type) => MediaRecorder.isTypeSupported(type)) || "";
|
||||
}
|
||||
|
||||
async function sendVoiceBlob(blob) {
|
||||
addBubble("Message vocal envoye", "user");
|
||||
setStatus("Transcription", "live");
|
||||
|
||||
const form = new FormData();
|
||||
const extension = blob.type.includes("ogg") ? "ogg" : "webm";
|
||||
form.append("sessionId", sessionId);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssistantResponse(data) {
|
||||
addBubble(data.reply || "", "assistant");
|
||||
setStatus(data.emailSent ? "Email envoye" : "Pret", data.emailSent ? "live" : "idle");
|
||||
|
||||
if (audioToggle.checked && data.audioUrl) {
|
||||
try {
|
||||
const audio = new Audio(data.audioUrl);
|
||||
await audio.play();
|
||||
} catch {
|
||||
addBubble("Audio pret, mais le navigateur a bloque la lecture automatique.", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user