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

@@ -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()
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 denvoyer 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() {
</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({
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,
},
}
})