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
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:

View File

@@ -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 (
<div className="avatar-shell">
@@ -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()
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`, {
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, 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()
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()
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,34 +174,48 @@ export default function App() {
const text = input.trim()
appendMessage('user', text)
setInput('')
const res = await fetch(`${API_BASE}/chat`, {
try {
setErrorMessage('')
const data = await apiFetch('/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)
} catch (error) {
setErrorMessage(error.message || 'Impossible denvoyer le message.')
}
}
async function loadProgress(studentId) {
const res = await fetch(`${API_BASE}/progress/${studentId}`)
const data = await res.json()
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()
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`, {
try {
setErrorMessage('')
const data = await apiFetch('/assessment/answer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -165,12 +224,14 @@ export default function App() {
answer: assessmentAnswer,
}),
})
const data = await res.json()
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,6 +470,7 @@ export default function App() {
</form>
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status">{errorMessage}</p>}
</main>
</div>
)

View File

@@ -1,10 +1,32 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
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()],
server: {
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,
},
}
})