Add Voice
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 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,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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user