From 56086ec5570c2878806f4dd3c592282c169441da Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sun, 5 Apr 2026 10:22:09 +0200 Subject: [PATCH] Add Voice --- docker-compose.yml | 10 ++- frontend/src/App.jsx | 169 ++++++++++++++++++++++++++-------------- frontend/vite.config.js | 34 ++++++-- 3 files changed, 147 insertions(+), 66 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 590aa0c..79e7a58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: APP_ENV: development FRONTEND_ORIGIN: http://localhost:3000 ports: - - "8001:8000" + - "8000:8000" depends_on: - postgres - redis @@ -34,9 +34,13 @@ services: frontend: build: ./frontend environment: - VITE_API_BASE_URL: http://localhost:8000 + VITE_ALLOWED_HOST: prof.open-squared.tech + VITE_DEV_API_PROXY_TARGET: http://backend:8000 + VITE_HMR_HOST: prof.open-squared.tech + VITE_HMR_PROTOCOL: wss + VITE_HMR_CLIENT_PORT: 443 ports: - - "3001:3000" + - "3000:3000" depends_on: - backend volumes: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7d38afa..c003afa 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,33 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' const API_BASE = '/api' +async function parseApiResponse(res) { + const contentType = res.headers.get('content-type') || '' + const bodyText = await res.text() + const isJson = contentType.includes('application/json') + const data = isJson && bodyText ? JSON.parse(bodyText) : null + + if (!res.ok) { + const detail = + data?.detail || + data?.message || + bodyText.trim() || + `Erreur API (${res.status})` + throw new Error(detail) + } + + if (!isJson) { + throw new Error(`Réponse API invalide (${res.status})`) + } + + return data +} + +async function apiFetch(path, options) { + const res = await fetch(`${API_BASE}${path}`, options) + return parseApiResponse(res) +} + function Avatar({ speaking }) { return (
@@ -59,6 +86,7 @@ export default function App() { const [isRecording, setIsRecording] = useState(false) const [isTranscribing, setIsTranscribing] = useState(false) const [voiceStatus, setVoiceStatus] = useState('') + const [errorMessage, setErrorMessage] = useState('') const recognitionRef = useRef(null) const mediaRecorderRef = useRef(null) const mediaStreamRef = useRef(null) @@ -90,33 +118,50 @@ export default function App() { }, []) async function loadStudents() { - const res = await fetch(`${API_BASE}/students`) - const data = await res.json() - setStudents(data) - if (data.length && !selectedStudentId) { - setSelectedStudentId(String(data[0].id)) + try { + setErrorMessage('') + const data = await apiFetch('/students') + setStudents(data) + if (data.length && !selectedStudentId) { + setSelectedStudentId(String(data[0].id)) + } + } catch (error) { + setErrorMessage(error.message || 'Impossible de charger les élèves.') } } async function createStudent(e) { e.preventDefault() - const res = await fetch(`${API_BASE}/students`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...form, age: Number(form.age) }), - }) - const data = await res.json() - await loadStudents() - setSelectedStudentId(String(data.id)) + if (!form.first_name.trim()) { + setErrorMessage('Le prénom est obligatoire.') + return + } + + try { + setErrorMessage('') + const data = await apiFetch('/students', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...form, first_name: form.first_name.trim(), age: Number(form.age) }), + }) + await loadStudents() + setSelectedStudentId(String(data.id)) + } catch (error) { + setErrorMessage(error.message || 'Impossible de créer l’élève.') + } } async function startSession() { if (!selectedStudentId) return - const res = await fetch(`${API_BASE}/session/start?student_id=${selectedStudentId}`, { method: 'POST' }) - const data = await res.json() - appendMessage('assistant', data.reply) - speak(data.reply) - await loadProgress(selectedStudentId) + try { + setErrorMessage('') + const data = await apiFetch(`/session/start?student_id=${selectedStudentId}`, { method: 'POST' }) + appendMessage('assistant', data.reply) + speak(data.reply) + await loadProgress(selectedStudentId) + } catch (error) { + setErrorMessage(error.message || 'Impossible de démarrer la séance.') + } } function appendMessage(role, content) { @@ -129,48 +174,64 @@ export default function App() { const text = input.trim() appendMessage('user', text) setInput('') - const res = await fetch(`${API_BASE}/chat`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), - }) - const data = await res.json() - appendMessage('assistant', data.reply) - speak(data.reply) + try { + setErrorMessage('') + const data = await apiFetch('/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), + }) + appendMessage('assistant', data.reply) + speak(data.reply) + } catch (error) { + setErrorMessage(error.message || 'Impossible d’envoyer le message.') + } } async function loadProgress(studentId) { - const res = await fetch(`${API_BASE}/progress/${studentId}`) - const data = await res.json() - setProgress(data.progress || []) + try { + setErrorMessage('') + const data = await apiFetch(`/progress/${studentId}`) + setProgress(data.progress || []) + } catch (error) { + setErrorMessage(error.message || 'Impossible de charger la progression.') + } } async function fetchAssessment() { if (!selectedStudentId) return - const res = await fetch(`${API_BASE}/assessment/next/${selectedStudentId}`) - const data = await res.json() - setAssessment(data) - setAssessmentAnswer('') + try { + setErrorMessage('') + const data = await apiFetch(`/assessment/next/${selectedStudentId}`) + setAssessment(data) + setAssessmentAnswer('') + } catch (error) { + setErrorMessage(error.message || 'Impossible de charger le mini-test.') + } } async function submitAssessment(e) { e.preventDefault() if (!assessment || !assessmentAnswer.trim()) return - const res = await fetch(`${API_BASE}/assessment/answer`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - student_id: Number(selectedStudentId), - skill_code: assessment.skill_code, - answer: assessmentAnswer, - }), - }) - const data = await res.json() - appendMessage('assistant', `Quiz: ${data.feedback}`) - speak(data.feedback) - setAssessment(null) - setAssessmentAnswer('') - await loadProgress(selectedStudentId) + try { + setErrorMessage('') + const data = await apiFetch('/assessment/answer', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + student_id: Number(selectedStudentId), + skill_code: assessment.skill_code, + answer: assessmentAnswer, + }), + }) + appendMessage('assistant', `Quiz: ${data.feedback}`) + speak(data.feedback) + setAssessment(null) + setAssessmentAnswer('') + await loadProgress(selectedStudentId) + } catch (error) { + setErrorMessage(error.message || 'Impossible de valider la réponse.') + } } function speak(text) { @@ -203,17 +264,10 @@ export default function App() { setVoiceStatus('Transcription en cours...') try { - const res = await fetch(`${API_BASE}/transcribe`, { + const data = await apiFetch('/transcribe', { method: 'POST', body: formData, }) - - if (!res.ok) { - const error = await res.json().catch(() => ({})) - throw new Error(error.detail || 'La transcription a échoué.') - } - - const data = await res.json() setInput(data.text || '') setVoiceStatus(data.text ? 'Texte dicté prêt à être envoyé.' : 'Aucun texte reconnu.') } catch (error) { @@ -416,7 +470,8 @@ export default function App() { {voiceStatus &&

{voiceStatus}

} + {errorMessage &&

{errorMessage}

}
) -} \ No newline at end of file +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 08dba1b..a32acd7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,10 +1,32 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' -export default defineConfig({ - plugins: [react()], - server: { - host: true, - allowedHosts: ['prof.open-squared.tech'] +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const allowedHost = env.VITE_ALLOWED_HOST || 'prof.open-squared.tech' + const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || 'http://backend:8000' + const hmrHost = env.VITE_HMR_HOST + const hmrProtocol = env.VITE_HMR_PROTOCOL + const hmrClientPort = env.VITE_HMR_CLIENT_PORT ? Number(env.VITE_HMR_CLIENT_PORT) : undefined + + return { + plugins: [react()], + server: { + host: true, + allowedHosts: [allowedHost], + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + }, + }, + hmr: hmrHost + ? { + host: hmrHost, + protocol: hmrProtocol || 'ws', + clientPort: hmrClientPort, + } + : undefined, + }, } })