Add Voice

This commit is contained in:
2026-04-05 10:22:09 +02:00
parent 55bd8a4e6b
commit 56086ec557
3 changed files with 147 additions and 66 deletions

View File

@@ -24,7 +24,7 @@ services:
APP_ENV: development APP_ENV: development
FRONTEND_ORIGIN: http://localhost:3000 FRONTEND_ORIGIN: http://localhost:3000
ports: ports:
- "8001:8000" - "8000:8000"
depends_on: depends_on:
- postgres - postgres
- redis - redis
@@ -34,9 +34,13 @@ services:
frontend: frontend:
build: ./frontend build: ./frontend
environment: 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: ports:
- "3001:3000" - "3000:3000"
depends_on: depends_on:
- backend - backend
volumes: volumes:

View File

@@ -2,6 +2,33 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
const API_BASE = '/api' 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 }) { function Avatar({ speaking }) {
return ( return (
<div className="avatar-shell"> <div className="avatar-shell">
@@ -59,6 +86,7 @@ export default function App() {
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [isTranscribing, setIsTranscribing] = useState(false) const [isTranscribing, setIsTranscribing] = useState(false)
const [voiceStatus, setVoiceStatus] = useState('') const [voiceStatus, setVoiceStatus] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const recognitionRef = useRef(null) const recognitionRef = useRef(null)
const mediaRecorderRef = useRef(null) const mediaRecorderRef = useRef(null)
const mediaStreamRef = useRef(null) const mediaStreamRef = useRef(null)
@@ -90,33 +118,50 @@ export default function App() {
}, []) }, [])
async function loadStudents() { async function loadStudents() {
const res = await fetch(`${API_BASE}/students`) try {
const data = await res.json() setErrorMessage('')
const data = await apiFetch('/students')
setStudents(data) setStudents(data)
if (data.length && !selectedStudentId) { if (data.length && !selectedStudentId) {
setSelectedStudentId(String(data[0].id)) setSelectedStudentId(String(data[0].id))
} }
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger les élèves.')
}
} }
async function createStudent(e) { async function createStudent(e) {
e.preventDefault() e.preventDefault()
const res = await fetch(`${API_BASE}/students`, { if (!form.first_name.trim()) {
setErrorMessage('Le prénom est obligatoire.')
return
}
try {
setErrorMessage('')
const data = await apiFetch('/students', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, age: Number(form.age) }), body: JSON.stringify({ ...form, first_name: form.first_name.trim(), age: Number(form.age) }),
}) })
const data = await res.json()
await loadStudents() await loadStudents()
setSelectedStudentId(String(data.id)) setSelectedStudentId(String(data.id))
} catch (error) {
setErrorMessage(error.message || 'Impossible de créer lélève.')
}
} }
async function startSession() { async function startSession() {
if (!selectedStudentId) return if (!selectedStudentId) return
const res = await fetch(`${API_BASE}/session/start?student_id=${selectedStudentId}`, { method: 'POST' }) try {
const data = await res.json() setErrorMessage('')
const data = await apiFetch(`/session/start?student_id=${selectedStudentId}`, { method: 'POST' })
appendMessage('assistant', data.reply) appendMessage('assistant', data.reply)
speak(data.reply) speak(data.reply)
await loadProgress(selectedStudentId) await loadProgress(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible de démarrer la séance.')
}
} }
function appendMessage(role, content) { function appendMessage(role, content) {
@@ -129,34 +174,48 @@ export default function App() {
const text = input.trim() const text = input.trim()
appendMessage('user', text) appendMessage('user', text)
setInput('') setInput('')
const res = await fetch(`${API_BASE}/chat`, { try {
setErrorMessage('')
const data = await apiFetch('/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }),
}) })
const data = await res.json()
appendMessage('assistant', data.reply) appendMessage('assistant', data.reply)
speak(data.reply) speak(data.reply)
} catch (error) {
setErrorMessage(error.message || 'Impossible denvoyer le message.')
}
} }
async function loadProgress(studentId) { async function loadProgress(studentId) {
const res = await fetch(`${API_BASE}/progress/${studentId}`) try {
const data = await res.json() setErrorMessage('')
const data = await apiFetch(`/progress/${studentId}`)
setProgress(data.progress || []) setProgress(data.progress || [])
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la progression.')
}
} }
async function fetchAssessment() { async function fetchAssessment() {
if (!selectedStudentId) return if (!selectedStudentId) return
const res = await fetch(`${API_BASE}/assessment/next/${selectedStudentId}`) try {
const data = await res.json() setErrorMessage('')
const data = await apiFetch(`/assessment/next/${selectedStudentId}`)
setAssessment(data) setAssessment(data)
setAssessmentAnswer('') setAssessmentAnswer('')
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger le mini-test.')
}
} }
async function submitAssessment(e) { async function submitAssessment(e) {
e.preventDefault() e.preventDefault()
if (!assessment || !assessmentAnswer.trim()) return if (!assessment || !assessmentAnswer.trim()) return
const res = await fetch(`${API_BASE}/assessment/answer`, { try {
setErrorMessage('')
const data = await apiFetch('/assessment/answer', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -165,12 +224,14 @@ export default function App() {
answer: assessmentAnswer, answer: assessmentAnswer,
}), }),
}) })
const data = await res.json()
appendMessage('assistant', `Quiz: ${data.feedback}`) appendMessage('assistant', `Quiz: ${data.feedback}`)
speak(data.feedback) speak(data.feedback)
setAssessment(null) setAssessment(null)
setAssessmentAnswer('') setAssessmentAnswer('')
await loadProgress(selectedStudentId) await loadProgress(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible de valider la réponse.')
}
} }
function speak(text) { function speak(text) {
@@ -203,17 +264,10 @@ export default function App() {
setVoiceStatus('Transcription en cours...') setVoiceStatus('Transcription en cours...')
try { try {
const res = await fetch(`${API_BASE}/transcribe`, { const data = await apiFetch('/transcribe', {
method: 'POST', method: 'POST',
body: formData, 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 || '') setInput(data.text || '')
setVoiceStatus(data.text ? 'Texte dicté prêt à être envoyé.' : 'Aucun texte reconnu.') setVoiceStatus(data.text ? 'Texte dicté prêt à être envoyé.' : 'Aucun texte reconnu.')
} catch (error) { } catch (error) {
@@ -416,6 +470,7 @@ export default function App() {
</form> </form>
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>} {voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status">{errorMessage}</p>}
</main> </main>
</div> </div>
) )

View File

@@ -1,10 +1,32 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ 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()], plugins: [react()],
server: { server: {
host: true, host: true,
allowedHosts: ['prof.open-squared.tech'] allowedHosts: [allowedHost],
proxy: {
'/api': {
target: proxyTarget,
changeOrigin: true,
},
},
hmr: hmrHost
? {
host: hmrHost,
protocol: hmrProtocol || 'ws',
clientPort: hmrClientPort,
}
: undefined,
},
} }
}) })