Initial PWA assistant proof of concept

This commit is contained in:
2026-04-26 21:50:39 +02:00
commit fb8cc4d086
12 changed files with 901 additions and 0 deletions

180
static/app.js Normal file
View 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");
}