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