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('')
setStudents(data) const data = await apiFetch('/students')
if (data.length && !selectedStudentId) { setStudents(data)
setSelectedStudentId(String(data[0].id)) if (data.length && !selectedStudentId) {
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()) {
method: 'POST', setErrorMessage('Le prénom est obligatoire.')
headers: { 'Content-Type': 'application/json' }, return
body: JSON.stringify({ ...form, age: Number(form.age) }), }
})
const data = await res.json() try {
await loadStudents() setErrorMessage('')
setSelectedStudentId(String(data.id)) 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() { 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('')
appendMessage('assistant', data.reply) const data = await apiFetch(`/session/start?student_id=${selectedStudentId}`, { method: 'POST' })
speak(data.reply) appendMessage('assistant', data.reply)
await loadProgress(selectedStudentId) speak(data.reply)
await loadProgress(selectedStudentId)
} catch (error) {
setErrorMessage(error.message || 'Impossible de démarrer la séance.')
}
} }
function appendMessage(role, content) { function appendMessage(role, content) {
@@ -129,48 +174,64 @@ 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 {
method: 'POST', setErrorMessage('')
headers: { 'Content-Type': 'application/json' }, const data = await apiFetch('/chat', {
body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), method: 'POST',
}) headers: { 'Content-Type': 'application/json' },
const data = await res.json() body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }),
appendMessage('assistant', data.reply) })
speak(data.reply) appendMessage('assistant', 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('')
setProgress(data.progress || []) const data = await apiFetch(`/progress/${studentId}`)
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('')
setAssessment(data) const data = await apiFetch(`/assessment/next/${selectedStudentId}`)
setAssessmentAnswer('') setAssessment(data)
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 {
method: 'POST', setErrorMessage('')
headers: { 'Content-Type': 'application/json' }, const data = await apiFetch('/assessment/answer', {
body: JSON.stringify({ method: 'POST',
student_id: Number(selectedStudentId), headers: { 'Content-Type': 'application/json' },
skill_code: assessment.skill_code, body: JSON.stringify({
answer: assessmentAnswer, student_id: Number(selectedStudentId),
}), skill_code: assessment.skill_code,
}) answer: assessmentAnswer,
const data = await res.json() }),
appendMessage('assistant', `Quiz: ${data.feedback}`) })
speak(data.feedback) appendMessage('assistant', `Quiz: ${data.feedback}`)
setAssessment(null) speak(data.feedback)
setAssessmentAnswer('') setAssessment(null)
await loadProgress(selectedStudentId) setAssessmentAnswer('')
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,7 +470,8 @@ 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 }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), '')
server: { const allowedHost = env.VITE_ALLOWED_HOST || 'prof.open-squared.tech'
host: true, const proxyTarget = env.VITE_DEV_API_PROXY_TARGET || 'http://backend:8000'
allowedHosts: ['prof.open-squared.tech'] 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,
},
} }
}) })