Sprint 6c — Frontend T2 Live UI + state machine + integration
feat(t2-live): state machine pure 9 states x 8 events (resolves FTD-09) feat(t2-live): useT2LiveSession WS orchestrator + audio hooks + close codes feat(t2-live): T2SujetsPage, T2PreparationPage (2min timer + notes + ideas), T2DialoguePage (3:30 dialogue + terminal screen with WAV download) feat(t2-live): T2LiveContext provider for sujet sharing between pages fix(TaskSelector): unlock EO_T2_LIVE card via hasAccess (resolves FTD-33) chore: Tache type + labels + config extended with EO_T2_LIVE test: 21 t2-machine tests — 259/259 green (+21)
This commit is contained in:
parent
7862f7c9f3
commit
1d95166611
17 changed files with 1229 additions and 33 deletions
|
|
@ -29,6 +29,34 @@ Chaque entrée suit ce format :
|
|||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-04-26 — Sprint 6c — Frontend T2 Live UI
|
||||
|
||||
### Added
|
||||
|
||||
- `t2-machine.ts` — state machine pure T2 Live : 9 états (`idle` → `preparing` → `connecting` → `ready` → `speaking` ↔ `listening` → `processing` → `ended` / `error`), 8 events. 21 tests. Résout FTD-09.
|
||||
- `useT2LiveSession.ts` — hook orchestrateur : WebSocket + state machine + hooks audio (capture/playback/recording). Parse format Gemini natif (`serverContent.modelTurn`) + messages applicatifs backend (`warning`/`report`/`error`). Close codes 1000/4001/4003/4004. Timer dialogue 210 s. Ping 30 s keep-alive.
|
||||
- `T2LiveContext.tsx` — Provider léger pour partager le sujet sélectionné entre les pages T2.
|
||||
- `T2SujetsPage.tsx` — grille de sélection des sujets T2 (`GET /sujets?mode=EO&tache=2`).
|
||||
- `T2PreparationPage.tsx` — timer 2 min, consigne affichée, zone de notes locale, bouton « Suggestions d'idées » (DeepSeek, actif immédiatement), bouton « Je suis prêt », pré-warm micro via `getUserMedia`. Transition auto vers dialogue à 0:00.
|
||||
- `T2DialoguePage.tsx` — timer 3:30, indicateur d'état IA, waveform, bouton « Terminer ». Écran terminal (state `ended`) : bouton « Télécharger l'audio » (WAV mono 24 kHz) + bouton « Voir le rapport » (→ `/rapport/:id`).
|
||||
- 3 routes : `/simulation/eo/t2`, `/simulation/eo/t2/preparation`, `/simulation/eo/t2/dialogue` sous `T2LiveLayout`.
|
||||
|
||||
### Changed
|
||||
|
||||
- `TaskSelector.tsx` — carte EO T2 Live déverrouillée via `hasAccess(plan, 'oral_t2_live')` + prop `onT2LiveSelect`. Résout FTD-33.
|
||||
- `SimulationEOPage.tsx` — branche `onT2LiveSelect` vers `/simulation/eo/t2`.
|
||||
- `entities/production/` — `Tache` type, labels, `mapTacheToSujetParams`, config étendus avec `EO_T2_LIVE`.
|
||||
- `features/historique/` — `TACHE_NUMBER` étendu.
|
||||
|
||||
### Notes
|
||||
|
||||
- Tests frontend : 238 → 259 verts (+21 — tous sur t2-machine).
|
||||
- FTD-09 résolue (state machine testée).
|
||||
- FTD-33 résolue (carte déverrouillée via hasAccess).
|
||||
- `useT2LiveSession` non testé en unit (WebSocket non supporté jsdom) — validation manuelle prévue.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] — 2026-04-26 — Sprint 6b — Frontend audio (T2 Live)
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# TECH_DEBT.md — Expria Frontend
|
||||
|
||||
> **Document de référence — Version 1.27**
|
||||
> **Document de référence — Version 1.28**
|
||||
> Ce document recense les décisions techniques prises par pragmatisme qui devront être revisitées, les stubs temporaires, et les fonctionnalités reportées.
|
||||
> À mettre à jour après chaque session de développement.
|
||||
>
|
||||
|
|
@ -421,29 +421,7 @@ Frontend :
|
|||
|
||||
---
|
||||
|
||||
### FTD-09 — Tests de la state machine T2 Live non implémentés
|
||||
|
||||
**Priorité :** 🟡 Important
|
||||
**Statut :** Gelé — Sprint 5.5 (2026-04-26)
|
||||
**Estimation de session :** 3h
|
||||
**Description :** La state machine T2 Live (`src/features/t2-live/state/t2-machine.ts`) n'existe pas encore. Quand elle sera créée, elle devra être testée de manière exhaustive (6+ tests couvrant les transitions d'états et les cas d'erreur).
|
||||
|
||||
**Motif de gel :** Gelé — le code n'existe pas encore, sera créé et testé au Sprint 6.
|
||||
|
||||
**Condition de résolution :** fin Sprint 6 (T2 Live).
|
||||
|
||||
---
|
||||
|
||||
### FTD-33 — Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`)
|
||||
|
||||
**Priorité :** 🟢 Mineur
|
||||
**Statut :** Gelé — Sprint 5.5 (2026-04-26)
|
||||
**Estimation de session :** 0,5 jour
|
||||
**Description :** Dans `TaskSelector`, la carte EO_T2_LIVE a `tache: null` ce qui la rend inactive pour tous les plans, indépendamment de `hasAccess(plan, 'oral_t2_live')`. C'est volontaire tant que T2 Live n'est pas livré (Sprint 6) — un utilisateur Premium ne doit pas accéder à une feature non implémentée. À nettoyer dès que T2 Live est wired pour respecter strictement la Règle D.
|
||||
|
||||
**Motif de gel :** Gelé — condition de résolution = Sprint 6 T2 Live.
|
||||
|
||||
**Condition de résolution :** lancement de T2 Live (Sprint 6).
|
||||
> FTD-09 et FTD-33 résolues au Sprint 6c (2026-04-26) — voir §5 Historique.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -534,6 +512,8 @@ Frontend :
|
|||
| FTD-24 | Pas de polling automatique pour exercices / modèle `pending` | 2026-04-23 | Polling conditionnel dans `useRapport` via `refetchInterval: 3000` tant que `exercices_status === 'pending' \|\| modele_status === 'pending'`. Arrêt automatique dès que les deux sortent de pending (ready ou error). Timeout global 2 min → `hasTimedOut = true` + bouton « Réessayer » dans `JobStatusFallback` (primitive `@/shared/ui/Button`). `refetch()` réinitialise le flag et relance le polling. `staleTime: Infinity` conservé. 5 tests nouveaux dans `useRapport.test.tsx`. |
|
||||
| FTD-25 | Mise à jour ARCHITECTURE.md §3 (arborescence réelle) | 2026-04-25 | §3 réécrite : `app/` documenté avec entry points + layout (AppLayout, Sidebar, Topbar, BottomNav, MaintenancePage) ; ajout `entities/{patterns,presentation,transcription}` ; ajout `features/{historique,progression,design-system}` ; extension `simulations/` (pages EO, components/rapport/, lib/, state/) ; mise à jour `shared/`. `t2-live/` et `billing/` retirés (non implémentés — voir ROADMAP). Note explicative ajoutée sous `app/`. Bump doc v1.1. |
|
||||
| FTD-26 | Clarifier cohabitation `shared/ui/` vs `shared/components/ui/` | 2026-04-25 | Section dédiée ajoutée dans ARCHITECTURE.md §3 : tableau de distinction (PascalCase wrappers Expria vs kebab-case primitives shadcn) + règle d'évolution (toute nouvelle primitive Expria va dans `shared/ui/`, `shared/components/ui/` réservé à la CLI shadcn). Aucun fichier déplacé — documentation uniquement. |
|
||||
| FTD-09 | Tests de la state machine T2 Live non implémentés | 2026-04-26 | Sprint 6c — State machine pure créée (`src/features/t2-live/state/t2-machine.ts`, 9 états × 8 events) + 21 tests Vitest couvrant transitions nominales, END_REQUESTED depuis tout état actif, ERROR terminal, événements invalides ignorés. Dégelée et fermée. |
|
||||
| FTD-33 | Carte EO_T2_LIVE verrouillée en dur (pas via `hasAccess`) | 2026-04-26 | Sprint 6c — Carte EO_T2_LIVE déverrouillée via `hasAccess(plan, 'oral_t2_live')` + nouvelle prop `onT2LiveSelect` dans `TaskSelector`. Si plan donne accès, clic navigue vers `/simulation/eo/t2` (la production est créée par le backend en fin de session, pas au clic). Sinon, carte reste verrouillée avec lockLabel « Exclusivité Premium ». Dégelée et fermée. |
|
||||
| FTD-14 | Anti-FOUC thème : script inline manquant dans `<head>` | 2026-04-26 | Sprint 5.5 — Script `.light` déjà en place dans `index.html` (lignes 14-20), conforme DESIGN_SYSTEM v2.0. L'exemple `.dark` documenté dans la fiche FTD-14 datait de la DA Boréal v1.0 (obsolète). Aucune action code requise — FTD fermée comme déjà résolue. |
|
||||
| FTD-35 | `PresentationGenereeT1Page` : refresh sans simulation active | 2026-04-26 | Sprint 5.5 — Subsumée par FTD-41 : la résolution de FTD-41 (persistance T1 en BDD) élimine le problème de FTD-35 (localStorage instable). Aucune action propre. |
|
||||
| FTD-38 | `useAudioRecorder` : mise à jour de ref pendant le render | 2026-04-26 | Sprint 5.5 — Refactor `optionsRef.current = options` (assignation pendant render + eslint-disable) en `useEffect(() => { optionsRef.current = options })`. Sémantique préservée : effet sans deps run après chaque commit, donc avant le prochain render qui lit la ref. eslint-disable retiré. 195 lignes de tests `useAudioRecorder.test.ts` toujours vertes (219/219). |
|
||||
|
|
@ -574,3 +554,4 @@ Frontend :
|
|||
| 1.25 | 2026-04-25 | Sprint 4.5 — Ajout FTD-40 🟡 (conclusion `conseil_nclc` backend incohérente quand NCLC atteint > cible — patch frontend en place dans `ConseilNclcCallout`) et FTD-41 🔴 (persistance présentation EO T1 en BDD — résout FTD-35). **19 FTD actives — cap 15 dépassé de 4. Résorption obligatoire au Sprint 5.5 avant toute nouvelle FTD.** |
|
||||
| 1.26 | 2026-04-26 | Sprint 5e (clean Sprint 5 Billing) — Ajout FTD-42 🟡 (modal prorata Standard→Premium avec montant exact — divergence PARCOURS_UTILISATEURS §3, actuellement Customer Portal natif sans preview in-app) et FTD-43 🟢 (race condition webhook post-redirect Stripe — `usePlan()` peut retourner ancien plan brièvement). **21 FTD actives — cap 15 dépassé de 6. Résorption FTD critique au Sprint 5.5 avant Sprint 6.** |
|
||||
| 1.27 | 2026-04-26 | Sprint 5.5 Clean — FTD-09, FTD-33, FTD-42 gelées. FTD-35 fermée (subsumée par FTD-41). FTD-14, FTD-38, FTD-39 résolues. **14 FTD actives** (cap 15 respecté). |
|
||||
| 1.28 | 2026-04-26 | Sprint 6c — FTD-09 et FTD-33 résolues (dégelées → fermées). **14 FTD actives** (inchangé — les gelées ne comptaient pas dans le cap). |
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import { ProgressionPage } from '@/features/progression/pages/ProgressionPage'
|
|||
import { PricingPage } from '@/features/billing/pages/PricingPage'
|
||||
import { ParametresPage } from '@/features/account/pages/ParametresPage'
|
||||
import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider'
|
||||
import { T2LiveProvider } from '@/features/t2-live/state/T2LiveContext'
|
||||
import { T2SujetsPage } from '@/features/t2-live/pages/T2SujetsPage'
|
||||
import { T2PreparationPage } from '@/features/t2-live/pages/T2PreparationPage'
|
||||
import { T2DialoguePage } from '@/features/t2-live/pages/T2DialoguePage'
|
||||
import { AppLayout } from './AppLayout'
|
||||
|
||||
const DesignSystemPage = import.meta.env.DEV
|
||||
|
|
@ -53,6 +57,14 @@ function SimulationFlowLayout() {
|
|||
)
|
||||
}
|
||||
|
||||
function T2LiveLayout() {
|
||||
return (
|
||||
<T2LiveProvider>
|
||||
<Outlet />
|
||||
</T2LiveProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<Routes>
|
||||
|
|
@ -86,6 +98,13 @@ export function AppRouter() {
|
|||
<Route path="/rapport/:id" element={<RapportPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Sprint 6c — T2 Live (Premium) : sélection sujet → prépa 2min → dialogue 3:30 */}
|
||||
<Route element={<T2LiveLayout />}>
|
||||
<Route path="/simulation/eo/t2" element={<T2SujetsPage />} />
|
||||
<Route path="/simulation/eo/t2/preparation" element={<T2PreparationPage />} />
|
||||
<Route path="/simulation/eo/t2/dialogue" element={<T2DialoguePage />} />
|
||||
</Route>
|
||||
|
||||
{/* Autres sections — Sprint 4+ */}
|
||||
<Route path="/examen" element={<ComingSoon />} />
|
||||
<Route path="/progression" element={<ProgressionPage />} />
|
||||
|
|
|
|||
|
|
@ -78,7 +78,10 @@ export async function updateSujet(id: string, sujetId: string): Promise<void> {
|
|||
/**
|
||||
* Mappe une Tache vers les paramètres de la route `GET /sujets`.
|
||||
* Retourne `null` pour les tâches sans catalogue de sujets côté base
|
||||
* (EO_T1 : sujet fixe connu, EO_T2_LIVE : interaction sans sujet).
|
||||
* (EO_T1 : sujet fixe connu).
|
||||
*
|
||||
* Sprint 6c : `EO_T2_LIVE` mappe vers (mode='EO', tache=2) pour récupérer
|
||||
* la grille de sujets T2 (rôle + contexte alimentés en BDD).
|
||||
*/
|
||||
function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber: 1 | 2 | 3 } | null {
|
||||
switch (tache) {
|
||||
|
|
@ -88,6 +91,8 @@ function mapTacheToSujetParams(tache: Tache): { mode: 'EE' | 'EO'; tacheNumber:
|
|||
return { mode: 'EE', tacheNumber: 2 }
|
||||
case 'EE_T3':
|
||||
return { mode: 'EE', tacheNumber: 3 }
|
||||
case 'EO_T2_LIVE':
|
||||
return { mode: 'EO', tacheNumber: 2 }
|
||||
case 'EO_T3':
|
||||
return { mode: 'EO', tacheNumber: 3 }
|
||||
case 'EO_T1':
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const TACHE_LABELS: Record<Tache, string> = {
|
|||
EE_T2: 'Expression Écrite — Tâche 2',
|
||||
EE_T3: 'Expression Écrite — Tâche 3',
|
||||
EO_T1: 'Expression Orale — Tâche 1',
|
||||
EO_T2_LIVE: 'Expression Orale — Tâche 2 Live',
|
||||
EO_T3: 'Expression Orale — Tâche 3',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,11 @@
|
|||
* Ne jamais les injecter comme HTML — passer par react-markdown dans les composants.
|
||||
*/
|
||||
|
||||
/** Identifiants des tâches disponibles en mode simulation (hors T2 Live). */
|
||||
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3'
|
||||
/**
|
||||
* Identifiants des tâches disponibles en mode simulation.
|
||||
* `EO_T2_LIVE` désigne la T2 EO en dialogue live (Sprint 6).
|
||||
*/
|
||||
export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T2_LIVE' | 'EO_T3'
|
||||
|
||||
/** Mode de la simulation — examen uniquement accessible au plan Premium. */
|
||||
export type Mode = 'entrainement' | 'examen'
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ const TACHE_NUMBER: Record<Tache, string> = {
|
|||
EE_T2: 'EE · Tâche 2',
|
||||
EE_T3: 'EE · Tâche 3',
|
||||
EO_T1: 'EO · Tâche 1',
|
||||
EO_T2_LIVE: 'EO · Tâche 2 Live',
|
||||
EO_T3: 'EO · Tâche 3',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import { Lock, Loader2 } from 'lucide-react'
|
||||
import { canSimulate } from '@/entities/user/lib'
|
||||
import { canSimulate, hasAccess } from '@/entities/user/lib'
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { Badge } from '@/shared/ui/Badge'
|
||||
|
|
@ -25,6 +25,14 @@ interface Props {
|
|||
simulationsUsed: number
|
||||
isLoading: boolean
|
||||
onSelect: (payload: CreateSimulationPayload) => void
|
||||
/**
|
||||
* Sprint 6c — handler dédié pour la carte EO_T2_LIVE. Si fourni ET que
|
||||
* l'utilisateur a accès (`oral_t2_live`), un clic appelle ce handler au
|
||||
* lieu d'`onSelect` (la production T2 Live est créée en backend en fin
|
||||
* de session, pas au clic). Si absent OU plan insuffisant, la carte
|
||||
* reste verrouillée.
|
||||
*/
|
||||
onT2LiveSelect?: () => void
|
||||
}
|
||||
|
||||
interface TaskCard {
|
||||
|
|
@ -46,17 +54,25 @@ const EO_CARDS: readonly TaskCard[] = [
|
|||
{ key: 'EO_T3', tache: 'EO_T3', label: 'Expression Orale', sublabel: 'Point de vue' },
|
||||
{
|
||||
key: 'EO_T2_LIVE',
|
||||
tache: null,
|
||||
tache: 'EO_T2_LIVE',
|
||||
label: 'Expression Orale',
|
||||
sublabel: 'Tâche 2 — Live',
|
||||
lockLabel: 'Exclusivité Premium',
|
||||
},
|
||||
]
|
||||
|
||||
export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect }: Props) {
|
||||
export function TaskSelector({
|
||||
type,
|
||||
plan,
|
||||
simulationsUsed,
|
||||
isLoading,
|
||||
onSelect,
|
||||
onT2LiveSelect,
|
||||
}: Props) {
|
||||
const simulationCheck = canSimulate(plan, simulationsUsed)
|
||||
const quotaBlocked = !simulationCheck.allowed
|
||||
const cards = type === 'EE' ? EE_CARDS : EO_CARDS
|
||||
const t2LiveUnlocked = hasAccess(plan, 'oral_t2_live') && Boolean(onT2LiveSelect)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -82,13 +98,16 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
|
|||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{cards.map((card) => {
|
||||
const locked = card.tache === null || quotaBlocked
|
||||
const isT2Live = card.tache === 'EO_T2_LIVE'
|
||||
// T2 Live ne consomme pas le quota Free (plan Premium uniquement, illimité).
|
||||
// Verrouillage : T2 Live → hasAccess(plan, 'oral_t2_live') ; autres → quota.
|
||||
const locked = isT2Live ? !t2LiveUnlocked : card.tache === null || quotaBlocked
|
||||
const abbrev = card.tache ? card.tache.split('_')[0] : 'EO'
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<Card key={card.key} variant="default" className="flex flex-col p-4 opacity-60">
|
||||
{card.tache === null && (
|
||||
{(card.tache === null || isT2Live) && (
|
||||
<Lock className="mb-2 size-4 text-ink-secondary" aria-hidden="true" />
|
||||
)}
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
|
||||
|
|
@ -108,7 +127,12 @@ export function TaskSelector({ type, plan, simulationsUsed, isLoading, onSelect
|
|||
variant="interactive"
|
||||
className={cn('relative flex flex-col p-4', isLoading && 'cursor-wait')}
|
||||
onClick={() => {
|
||||
if (!isLoading && card.tache) {
|
||||
if (isLoading) return
|
||||
if (isT2Live && onT2LiveSelect) {
|
||||
onT2LiveSelect()
|
||||
return
|
||||
}
|
||||
if (card.tache && card.tache !== 'EO_T2_LIVE') {
|
||||
onSelect({ tache: card.tache, mode: 'entrainement' })
|
||||
}
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,15 @@ const SIMULATION_CONFIG: Record<Tache, SimulationConfig> = {
|
|||
dureeRecommandeeSecondes: 120,
|
||||
enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
|
||||
},
|
||||
EO_T2_LIVE: {
|
||||
dureeMinutes: 6,
|
||||
motsMin: 0,
|
||||
motsCibleMin: 0,
|
||||
motsCibleMax: 0,
|
||||
// 2 min prépa + 3 min 30 dialogue (Sprint 6c).
|
||||
dureeRecommandeeSecondes: 330,
|
||||
enregistrementMinSecondes: EO_MIN_RECORDING_SECONDS,
|
||||
},
|
||||
EO_T3: {
|
||||
dureeMinutes: 5,
|
||||
motsMin: 0,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
* Règle H : aucune logique métier — délègue au provider.
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { TaskSelector } from '../components/TaskSelector'
|
||||
|
|
@ -36,6 +37,7 @@ export function SimulationEOPage() {
|
|||
} = usePlan()
|
||||
|
||||
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
|
|
@ -61,6 +63,7 @@ export function SimulationEOPage() {
|
|||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
onT2LiveSelect={() => navigate('/simulation/eo/t2')}
|
||||
/>
|
||||
|
||||
{taskUnavailableMessage && (
|
||||
|
|
|
|||
376
src/features/t2-live/hooks/useT2LiveSession.ts
Normal file
376
src/features/t2-live/hooks/useT2LiveSession.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
/**
|
||||
* useT2LiveSession — Hook orchestrateur du dialogue T2 Live (Sprint 6c).
|
||||
*
|
||||
* Responsabilités :
|
||||
* 1. Ouvre la WebSocket vers `wss://${API_URL}/t2/live?token=<jwt>&sujet=<uuid>`
|
||||
* au démarrage du dialogue (PAS pendant la prépa).
|
||||
* 2. Branche les hooks audio :
|
||||
* - useAudioCapture → onChunk → ws.send + addCandidateChunk(recording)
|
||||
* - useAudioPlayback → playChunk(base64 IA reçu)
|
||||
* - useAudioRecording → buffer chronologique pour exportWAV
|
||||
* 3. Parse les messages WS :
|
||||
* - Frames Gemini natifs forwardés (serverContent.modelTurn.parts.inlineData)
|
||||
* - Messages applicatifs backend (warning, report, error)
|
||||
* 4. Pilote la state machine T2 (ready → speaking → listening → processing → ended).
|
||||
* 5. Timer 210 s côté frontend (redondant avec le timer backend, ceinture+bretelles).
|
||||
* 6. Gère les close codes (1000, 4001, 4003, 4004, 4005, 4006).
|
||||
*
|
||||
* Validation : test manuel uniquement (WebSocket + AudioContext non testables en jsdom).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { env } from '@/shared/config/env'
|
||||
import { getAccessToken } from '@/shared/lib/auth-client'
|
||||
import { useAudioCapture } from './useAudioCapture'
|
||||
import { useAudioPlayback } from './useAudioPlayback'
|
||||
import { useAudioRecording } from './useAudioRecording'
|
||||
import { transition, T2_INITIAL_STATE, type T2State, type T2Event } from '../state/t2-machine'
|
||||
|
||||
const DIALOGUE_TIMEOUT_MS = 210_000 // 3 min 30
|
||||
const WS_PING_INTERVAL_MS = 30_000
|
||||
|
||||
export interface UseT2LiveSessionOptions {
|
||||
sujetId: string
|
||||
/** Appelé quand le rapport est reçu — l'appelant décide de la navigation. */
|
||||
onReportReady?: (simulationId: string) => void
|
||||
}
|
||||
|
||||
export interface UseT2LiveSessionResult {
|
||||
state: T2State
|
||||
startDialogue: () => Promise<void>
|
||||
endDialogue: () => void
|
||||
warning: boolean
|
||||
errorMessage: string | null
|
||||
simulationId: string | null
|
||||
recording: ReturnType<typeof useAudioRecording>
|
||||
/** Secondes écoulées depuis l'ouverture du WS — pour le timer affiché. */
|
||||
elapsedSeconds: number
|
||||
}
|
||||
|
||||
interface GeminiPart {
|
||||
inlineData?: { data?: string; mimeType?: string }
|
||||
}
|
||||
interface GeminiServerContent {
|
||||
modelTurn?: { parts?: GeminiPart[] }
|
||||
inputTranscription?: { text?: string }
|
||||
outputTranscription?: { text?: string }
|
||||
interrupted?: boolean
|
||||
turnComplete?: boolean
|
||||
}
|
||||
interface GeminiMessage {
|
||||
serverContent?: GeminiServerContent
|
||||
}
|
||||
interface AppMessage {
|
||||
type: 'warning' | 'report' | 'error' | 'audio'
|
||||
data?: { simulation_id?: string } & Record<string, unknown>
|
||||
message?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
function buildWsUrl(token: string, sujetId: string): string {
|
||||
const base = env.VITE_API_URL.replace(/^http/, 'ws')
|
||||
return `${base}/t2/live?token=${encodeURIComponent(token)}&sujet=${encodeURIComponent(sujetId)}`
|
||||
}
|
||||
|
||||
export function useT2LiveSession(opts: UseT2LiveSessionOptions): UseT2LiveSessionResult {
|
||||
const { sujetId, onReportReady } = opts
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [state, setState] = useState<T2State>(T2_INITIAL_STATE)
|
||||
const [warning, setWarning] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [simulationId, setSimulationId] = useState<string | null>(null)
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const sessionEndedRef = useRef(false)
|
||||
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const userSpeakingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const playback = useAudioPlayback()
|
||||
const recording = useAudioRecording()
|
||||
|
||||
// Capture branchée à l'envoi WS + au buffer recording.
|
||||
const capture = useAudioCapture({
|
||||
onChunk: (base64: string) => {
|
||||
const ws = wsRef.current
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'audio', data: base64 }))
|
||||
}
|
||||
// Décoder pour le buffer recording — base64 → ArrayBuffer 16k Int16 LE.
|
||||
try {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
recording.addCandidateChunk(bytes.buffer)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const dispatch = useCallback((event: T2Event) => {
|
||||
setState((prev) => transition(prev, event))
|
||||
}, [])
|
||||
|
||||
const cleanupTimers = useCallback(() => {
|
||||
if (timeoutTimerRef.current !== null) {
|
||||
clearTimeout(timeoutTimerRef.current)
|
||||
timeoutTimerRef.current = null
|
||||
}
|
||||
if (elapsedTimerRef.current !== null) {
|
||||
clearInterval(elapsedTimerRef.current)
|
||||
elapsedTimerRef.current = null
|
||||
}
|
||||
if (pingTimerRef.current !== null) {
|
||||
clearInterval(pingTimerRef.current)
|
||||
pingTimerRef.current = null
|
||||
}
|
||||
if (userSpeakingTimerRef.current !== null) {
|
||||
clearTimeout(userSpeakingTimerRef.current)
|
||||
userSpeakingTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
sessionEndedRef.current = true
|
||||
cleanupTimers()
|
||||
capture.stop()
|
||||
if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) {
|
||||
try {
|
||||
wsRef.current.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// playback continue jusqu'à fin de file pour ne pas couper la dernière phrase IA.
|
||||
}, [capture, cleanupTimers])
|
||||
|
||||
const handleAudioReceived = useCallback(
|
||||
(base64: string) => {
|
||||
// Heuristique d'activité IA = on est en 'listening' (l'IA parle).
|
||||
// L'évènement USER_SPEAKING est inféré côté capture micro plutôt — ici on
|
||||
// signale au moins qu'on quitte 'speaking' si on y était.
|
||||
playback.playChunk(base64)
|
||||
recording.addAIChunk(base64)
|
||||
},
|
||||
[playback, recording],
|
||||
)
|
||||
|
||||
const handleGeminiMessage = useCallback(
|
||||
(msg: GeminiMessage) => {
|
||||
const sc = msg.serverContent
|
||||
if (!sc) return
|
||||
if (sc.modelTurn?.parts) {
|
||||
for (const part of sc.modelTurn.parts) {
|
||||
if (part.inlineData?.data) {
|
||||
handleAudioReceived(part.inlineData.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Les transcriptions sont accumulées côté backend pour la correction
|
||||
// finale — le frontend n'en a pas besoin pour l'UI temps réel.
|
||||
},
|
||||
[handleAudioReceived],
|
||||
)
|
||||
|
||||
const handleAppMessage = useCallback(
|
||||
(msg: AppMessage) => {
|
||||
if (msg.type === 'warning') {
|
||||
setWarning(true)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'report' && msg.data) {
|
||||
const simId = (msg.data.simulation_id as string | undefined) ?? null
|
||||
if (simId) setSimulationId(simId)
|
||||
dispatch({ type: 'REPORT_READY' })
|
||||
if (simId && onReportReady) onReportReady(simId)
|
||||
return
|
||||
}
|
||||
if (msg.type === 'error') {
|
||||
setErrorMessage(msg.message ?? 'Une erreur est survenue.')
|
||||
dispatch({ type: 'ERROR', message: msg.message })
|
||||
}
|
||||
},
|
||||
[dispatch, onReportReady],
|
||||
)
|
||||
|
||||
const handleWsMessage = useCallback(
|
||||
(evt: MessageEvent) => {
|
||||
// Les chunks audio Gemini peuvent arriver en binary ou en JSON
|
||||
// (selon proxy). Le backend Sprint 6a forward les frames Gemini telles
|
||||
// quelles — donc majoritairement string JSON.
|
||||
let text: string
|
||||
if (typeof evt.data === 'string') {
|
||||
text = evt.data
|
||||
} else if (evt.data instanceof ArrayBuffer) {
|
||||
text = new TextDecoder().decode(evt.data)
|
||||
} else if (evt.data instanceof Blob) {
|
||||
// Fallback async — ignorer ce frame, log seulement.
|
||||
console.warn('[T2] Frame Blob reçu, attendu string/ArrayBuffer')
|
||||
return
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as GeminiMessage & AppMessage
|
||||
if (parsed.type === 'warning' || parsed.type === 'report' || parsed.type === 'error') {
|
||||
handleAppMessage(parsed as AppMessage)
|
||||
} else if (parsed.serverContent) {
|
||||
handleGeminiMessage(parsed as GeminiMessage)
|
||||
}
|
||||
} catch {
|
||||
/* JSON malformé — ignorer */
|
||||
}
|
||||
},
|
||||
[handleAppMessage, handleGeminiMessage],
|
||||
)
|
||||
|
||||
const handleWsClose = useCallback(
|
||||
(evt: CloseEvent) => {
|
||||
if (sessionEndedRef.current) return
|
||||
sessionEndedRef.current = true
|
||||
cleanupTimers()
|
||||
capture.stop()
|
||||
|
||||
switch (evt.code) {
|
||||
case 1000:
|
||||
// Fermeture normale — si on n'a pas reçu le rapport, c'est anormal.
|
||||
if (state !== 'ended') {
|
||||
dispatch({ type: 'ERROR', message: 'Session interrompue' })
|
||||
}
|
||||
break
|
||||
case 4001:
|
||||
setErrorMessage('Authentification expirée.')
|
||||
dispatch({ type: 'ERROR', code: 4001 })
|
||||
navigate('/login')
|
||||
break
|
||||
case 4003:
|
||||
setErrorMessage('La Tâche 2 Live est réservée au plan Premium.')
|
||||
dispatch({ type: 'ERROR', code: 4003 })
|
||||
break
|
||||
case 4004:
|
||||
setErrorMessage('Sujet introuvable.')
|
||||
dispatch({ type: 'ERROR', code: 4004 })
|
||||
break
|
||||
case 4005:
|
||||
setErrorMessage('Service temporairement indisponible.')
|
||||
dispatch({ type: 'ERROR', code: 4005 })
|
||||
break
|
||||
case 4006:
|
||||
setErrorMessage('Connexion à l’examinateur perdue.')
|
||||
dispatch({ type: 'ERROR', code: 4006 })
|
||||
break
|
||||
default:
|
||||
setErrorMessage('Une erreur est survenue. Réessayez dans quelques instants.')
|
||||
dispatch({ type: 'ERROR', code: evt.code })
|
||||
}
|
||||
},
|
||||
[capture, cleanupTimers, dispatch, navigate, state],
|
||||
)
|
||||
|
||||
const startDialogue = useCallback(async () => {
|
||||
if (wsRef.current) return
|
||||
setErrorMessage(null)
|
||||
setWarning(false)
|
||||
sessionEndedRef.current = false
|
||||
dispatch({ type: 'START_DIALOGUE' })
|
||||
|
||||
const token = await getAccessToken()
|
||||
if (!token) {
|
||||
setErrorMessage('Authentification requise.')
|
||||
dispatch({ type: 'ERROR', code: 4001 })
|
||||
navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl(token, sujetId))
|
||||
ws.binaryType = 'arraybuffer'
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Connexion impossible'
|
||||
setErrorMessage(message)
|
||||
dispatch({ type: 'ERROR' })
|
||||
return
|
||||
}
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'WS_OPENED' })
|
||||
// Démarrer la capture micro UNE FOIS le WS ouvert.
|
||||
void capture.start()
|
||||
|
||||
// Timer écoulé pour l'UI.
|
||||
const startTime = Date.now()
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000))
|
||||
}, 250)
|
||||
|
||||
// Timeout dur côté frontend (redondance avec le 210 s backend).
|
||||
timeoutTimerRef.current = setTimeout(() => {
|
||||
if (sessionEndedRef.current) return
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'end' }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'END_REQUESTED' })
|
||||
}, DIALOGUE_TIMEOUT_MS)
|
||||
|
||||
// Keep-alive ping (certains proxies coupent à 30s d'inactivité).
|
||||
pingTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, WS_PING_INTERVAL_MS)
|
||||
}
|
||||
ws.onmessage = handleWsMessage
|
||||
ws.onclose = handleWsClose
|
||||
ws.onerror = () => {
|
||||
// 'close' suit toujours 'error' — gestion centralisée dans handleWsClose.
|
||||
}
|
||||
}, [capture, dispatch, handleWsClose, handleWsMessage, navigate, sujetId])
|
||||
|
||||
const endDialogue = useCallback(() => {
|
||||
if (sessionEndedRef.current) return
|
||||
cleanupTimers()
|
||||
capture.stop()
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: 'end' }))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'END_REQUESTED' })
|
||||
}, [capture, cleanupTimers, dispatch])
|
||||
|
||||
// Cleanup au démontage.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeAll()
|
||||
}
|
||||
}, [closeAll])
|
||||
|
||||
return {
|
||||
state,
|
||||
startDialogue,
|
||||
endDialogue,
|
||||
warning,
|
||||
errorMessage,
|
||||
simulationId,
|
||||
recording,
|
||||
elapsedSeconds,
|
||||
}
|
||||
}
|
||||
212
src/features/t2-live/pages/T2DialoguePage.tsx
Normal file
212
src/features/t2-live/pages/T2DialoguePage.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Page /simulation/eo/t2/dialogue — phase de dialogue live (Sprint 6c).
|
||||
*
|
||||
* Démarre la session WS au mount, pilote l'UI selon l'état machine T2, affiche
|
||||
* le timer 3:30 et l'indicateur d'état. À la fin (REPORT_READY), affiche un
|
||||
* écran terminal avec deux boutons : "Télécharger l'audio" et "Voir le rapport".
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mic, Download, FileText, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
import { useT2LiveSession } from '../hooks/useT2LiveSession'
|
||||
|
||||
const DIALOGUE_SECONDS = 210 // 3:30
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const remaining = Math.max(0, totalSeconds)
|
||||
const m = Math.floor(remaining / 60)
|
||||
const s = remaining % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function T2DialoguePage() {
|
||||
const navigate = useNavigate()
|
||||
const { sujet, reset: resetContext } = useT2LiveContext()
|
||||
const [autoStarted, setAutoStarted] = useState(false)
|
||||
|
||||
const session = useT2LiveSession({
|
||||
sujetId: sujet?.id ?? '',
|
||||
})
|
||||
|
||||
// Garde-fou : pas de sujet → retour à la sélection.
|
||||
useEffect(() => {
|
||||
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
|
||||
}, [sujet, navigate])
|
||||
|
||||
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
|
||||
useEffect(() => {
|
||||
if (!sujet) return
|
||||
if (autoStarted) return
|
||||
setAutoStarted(true)
|
||||
void session.startDialogue()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sujet, autoStarted])
|
||||
|
||||
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
|
||||
const stateLabel = (() => {
|
||||
switch (session.state) {
|
||||
case 'idle':
|
||||
case 'connecting':
|
||||
return 'Connexion à l’examinateur…'
|
||||
case 'ready':
|
||||
return 'À vous — prenez la parole.'
|
||||
case 'speaking':
|
||||
return 'L’examinateur écoute…'
|
||||
case 'listening':
|
||||
return 'L’examinateur répond…'
|
||||
case 'processing':
|
||||
return 'Évaluation en cours…'
|
||||
case 'ended':
|
||||
return 'Session terminée.'
|
||||
case 'error':
|
||||
return 'Erreur.'
|
||||
case 'preparing':
|
||||
return 'Préparation…'
|
||||
}
|
||||
})()
|
||||
|
||||
function handleDownload() {
|
||||
const blob = session.recording.exportWAV()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `expria-t2-${new Date().toISOString().slice(0, 10)}.wav`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function handleViewReport() {
|
||||
if (!session.simulationId) return
|
||||
resetContext()
|
||||
navigate(`/rapport/${session.simulationId}`)
|
||||
}
|
||||
|
||||
function handleBackToSujets() {
|
||||
resetContext()
|
||||
navigate('/simulation/eo/t2')
|
||||
}
|
||||
|
||||
if (!sujet) return null
|
||||
|
||||
// ── État terminal : rapport prêt ─────────────────────────────────────────
|
||||
if (session.state === 'ended') {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
|
||||
<Card variant="default" className="space-y-3 p-5">
|
||||
<p className="text-sm text-ink-primary">
|
||||
Votre dialogue a été évalué. Vous pouvez télécharger l'enregistrement audio avant de
|
||||
consulter le rapport.
|
||||
</p>
|
||||
<p className="text-xs text-ink-secondary">
|
||||
Durée enregistrée :{' '}
|
||||
<span className="font-semibold tabular-nums text-ink-primary">
|
||||
{session.recording.durationSeconds.toFixed(1)} s
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<Download className="size-4" aria-hidden="true" />}
|
||||
onClick={handleDownload}
|
||||
disabled={session.recording.durationSeconds === 0}
|
||||
>
|
||||
Télécharger l'audio
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<FileText className="size-4" aria-hidden="true" />}
|
||||
onClick={handleViewReport}
|
||||
disabled={!session.simulationId}
|
||||
>
|
||||
Voir le rapport
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── État erreur ──────────────────────────────────────────────────────────
|
||||
if (session.state === 'error') {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl space-y-5">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
|
||||
>
|
||||
{session.errorMessage ?? 'Une erreur est survenue.'}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={handleBackToSujets}>
|
||||
Retour aux sujets
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── État dialogue actif ──────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Dialogue en cours</h2>
|
||||
<span
|
||||
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
|
||||
aria-label="Temps restant de dialogue"
|
||||
>
|
||||
{formatMmSs(remaining)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.warning && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
|
||||
>
|
||||
⏱ 30 secondes restantes.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card variant="default" className="space-y-4 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{session.state === 'processing' ? (
|
||||
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
|
||||
) : (
|
||||
<Mic
|
||||
className={
|
||||
session.state === 'speaking' ? 'size-5 text-success' : 'size-5 text-ink-secondary'
|
||||
}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
|
||||
</div>
|
||||
<p className="text-xs text-ink-secondary">{sujet.consigne}</p>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => session.endDialogue()}
|
||||
disabled={session.state === 'processing'}
|
||||
>
|
||||
Terminer le dialogue
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
src/features/t2-live/pages/T2PreparationPage.tsx
Normal file
210
src/features/t2-live/pages/T2PreparationPage.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Page /simulation/eo/t2/preparation — phase de préparation T2 Live (Sprint 6c).
|
||||
*
|
||||
* - Timer 2 min visible (countdown).
|
||||
* - Consigne + contexte du sujet affichés.
|
||||
* - Zone de notes locale (état interne, non sauvegardée).
|
||||
* - Bouton "Suggestions d'idées" → réutilise useIdees (POST /sujets/idees).
|
||||
* Pas de seuil MIN_WORDS : on passe la consigne du sujet comme contenu_partiel.
|
||||
* - Bouton "Je suis prêt" → navigation vers /dialogue avant la fin du timer.
|
||||
* - Auto-navigation à 0:00.
|
||||
* - Pré-warm de la permission micro pour éviter le délai au début du dialogue.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Sparkles, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { Card } from '@/shared/ui/Card'
|
||||
import { useIdees } from '@/features/simulations/hooks/useIdees'
|
||||
import { IdeesSuggestions } from '@/features/simulations/components/IdeesSuggestions'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
|
||||
const PREPARATION_SECONDS = 120
|
||||
|
||||
function formatMmSs(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60)
|
||||
const s = totalSeconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function T2PreparationPage() {
|
||||
const navigate = useNavigate()
|
||||
const { sujet } = useT2LiveContext()
|
||||
|
||||
const [secondsLeft, setSecondsLeft] = useState(PREPARATION_SECONDS)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [showIdees, setShowIdees] = useState(false)
|
||||
const [micWarmed, setMicWarmed] = useState<boolean | null>(null)
|
||||
const expiredRef = useRef(false)
|
||||
|
||||
const idees = useIdees()
|
||||
|
||||
// Garde-fou : pas de sujet → retour à la sélection.
|
||||
useEffect(() => {
|
||||
if (!sujet) navigate('/simulation/eo/t2', { replace: true })
|
||||
}, [sujet, navigate])
|
||||
|
||||
// Pré-warm permission micro (le hook useAudioCapture le fera de toute façon
|
||||
// au start, mais on demande la permission ici pour éviter la latence à 0:00).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let stream: MediaStream | null = null
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
})
|
||||
.then((s) => {
|
||||
stream = s
|
||||
if (cancelled) {
|
||||
s.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
// Permission acquise — on relâche le stream pour ne pas bloquer le micro.
|
||||
s.getTracks().forEach((t) => t.stop())
|
||||
setMicWarmed(true)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMicWarmed(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (stream) stream.getTracks().forEach((t) => t.stop())
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Timer countdown 2 min.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setSecondsLeft((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(id)
|
||||
if (!expiredRef.current) {
|
||||
expiredRef.current = true
|
||||
// Auto-navigation à la fin de la prépa.
|
||||
queueMicrotask(() => navigate('/simulation/eo/t2/dialogue'))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [navigate])
|
||||
|
||||
function handleReady() {
|
||||
navigate('/simulation/eo/t2/dialogue')
|
||||
}
|
||||
|
||||
function handleIdees() {
|
||||
if (!sujet) return
|
||||
// Pas de seuil MIN_WORDS pour T2 prép : on passe la consigne + contexte
|
||||
// comme contenu_partiel pour respecter la validation backend (≥ 30 mots).
|
||||
// Le mécanisme DeepSeek génère des idées de questions à poser.
|
||||
const consigne = sujet.consigne ?? 'Tâche 2 — interaction de service'
|
||||
const contexte = (sujet as { contexte?: string | null }).contexte ?? consigne
|
||||
setShowIdees(true)
|
||||
idees.fetchIdees({ consigne, contenu: contexte })
|
||||
}
|
||||
|
||||
if (!sujet) return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Préparation — Tâche 2 Live</h2>
|
||||
<span
|
||||
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
|
||||
aria-label="Temps restant de préparation"
|
||||
>
|
||||
{formatMmSs(secondsLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Card variant="default" className="space-y-3 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||
Consigne
|
||||
</p>
|
||||
<p className="text-sm text-ink-primary">{sujet.consigne}</p>
|
||||
{(sujet as { contexte?: string | null }).contexte && (
|
||||
<>
|
||||
<p className="pt-2 text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary">
|
||||
Contexte
|
||||
</p>
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{(sujet as { contexte?: string | null }).contexte}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="default" className="p-4">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
<strong className="text-ink-primary">Comment ça se passe :</strong> c'est à vous de
|
||||
prendre la parole en premier pour initier la conversation, comme à l'examen réel.
|
||||
L'examinateur IA attend que vous lui posiez vos questions.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card variant="default" className="space-y-2 p-4">
|
||||
<label
|
||||
htmlFor="t2-notes"
|
||||
className="text-[11px] font-semibold uppercase tracking-wider text-ink-tertiary"
|
||||
>
|
||||
Vos notes (locales — non sauvegardées)
|
||||
</label>
|
||||
<textarea
|
||||
id="t2-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Notez vos questions, vos points à aborder…"
|
||||
className="w-full resize-y rounded-md border border-border bg-surface px-3 py-2 text-sm text-ink-primary placeholder:text-ink-tertiary focus:outline-none focus:shadow-focus"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{micWarmed === false && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
|
||||
>
|
||||
Accès au micro refusé. Activez-le dans les paramètres du navigateur avant de démarrer le
|
||||
dialogue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={
|
||||
idees.isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleIdees}
|
||||
disabled={idees.isLoading}
|
||||
>
|
||||
Suggestions d'idées
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleReady}>
|
||||
Je suis prêt — démarrer le dialogue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<IdeesSuggestions
|
||||
idees={idees.idees}
|
||||
isLoading={idees.isLoading}
|
||||
error={idees.error}
|
||||
isOpen={showIdees}
|
||||
onClose={() => {
|
||||
setShowIdees(false)
|
||||
idees.reset()
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
src/features/t2-live/pages/T2SujetsPage.tsx
Normal file
109
src/features/t2-live/pages/T2SujetsPage.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* Page /simulation/eo/t2 — sélection d'un sujet T2 EO Live (Sprint 6c).
|
||||
*
|
||||
* Pattern emprunté à SujetsEOPage : grille de sujets + sujet aléatoire.
|
||||
* Différence clé : le sujet est stocké dans T2LiveContext (pas SimulationFlowProvider)
|
||||
* — la production sera créée par le backend en fin de session, pas au clic.
|
||||
*/
|
||||
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shuffle } from 'lucide-react'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { useSujets } from '@/features/simulations/hooks/useSujets'
|
||||
import { SujetCard } from '@/features/simulations/components/SujetCard'
|
||||
import type { SujetData } from '@/entities/production/types'
|
||||
import { useT2LiveContext } from '../state/T2LiveContext'
|
||||
|
||||
function SujetsSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" aria-busy="true">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-lg bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function T2SujetsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { setSujet } = useT2LiveContext()
|
||||
|
||||
const { data: sujets, isLoading, isError, refetch } = useSujets('EO_T2_LIVE', true)
|
||||
|
||||
function handleSelect(sujet: SujetData) {
|
||||
setSujet(sujet)
|
||||
navigate('/simulation/eo/t2/preparation')
|
||||
}
|
||||
|
||||
function handleRandom() {
|
||||
if (!sujets || sujets.length === 0) return
|
||||
const pick = sujets[Math.floor(Math.random() * sujets.length)]
|
||||
if (pick) handleSelect(pick)
|
||||
}
|
||||
|
||||
const hasSujets = (sujets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/simulation/eo')}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
|
||||
Choisir un sujet — Tâche 2 Live
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{isLoading
|
||||
? 'Chargement des sujets…'
|
||||
: hasSujets
|
||||
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
|
||||
: 'Aucun sujet disponible pour cette tâche.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Shuffle className="size-4" aria-hidden="true" />}
|
||||
onClick={handleRandom}
|
||||
disabled={!hasSujets}
|
||||
>
|
||||
Sujet aléatoire
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
Impossible de charger les sujets.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<SujetsSkeleton />
|
||||
) : hasSujets ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sujets!.map((sujet) => (
|
||||
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/features/t2-live/state/T2LiveContext.tsx
Normal file
38
src/features/t2-live/state/T2LiveContext.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* T2LiveContext — partage le sujet sélectionné entre les pages T2 Live.
|
||||
*
|
||||
* Les pages /simulation/eo/t2/preparation et /simulation/eo/t2/dialogue
|
||||
* ont besoin du sujet choisi sur /simulation/eo/t2 (id, role, contexte,
|
||||
* consigne). Le sujet n'est pas persisté en backend tant que le rapport
|
||||
* n'est pas généré — donc un Provider React simple suffit.
|
||||
*
|
||||
* Si un utilisateur arrive directement sur /preparation ou /dialogue
|
||||
* sans sujet (ex: refresh), les pages redirigent vers /simulation/eo/t2.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import type { SujetData } from '@/entities/production/types'
|
||||
|
||||
interface T2LiveContextValue {
|
||||
sujet: SujetData | null
|
||||
setSujet: (sujet: SujetData | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const T2LiveContext = createContext<T2LiveContextValue | null>(null)
|
||||
|
||||
export function T2LiveProvider({ children }: { children: ReactNode }) {
|
||||
const [sujet, setSujet] = useState<SujetData | null>(null)
|
||||
const reset = () => setSujet(null)
|
||||
return (
|
||||
<T2LiveContext.Provider value={{ sujet, setSujet, reset }}>{children}</T2LiveContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useT2LiveContext(): T2LiveContextValue {
|
||||
const ctx = useContext(T2LiveContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useT2LiveContext must be used within T2LiveProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
75
src/features/t2-live/state/__tests__/t2-machine.test.ts
Normal file
75
src/features/t2-live/state/__tests__/t2-machine.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { transition, T2_INITIAL_STATE } from '../t2-machine'
|
||||
import type { T2State } from '../t2-machine'
|
||||
|
||||
describe('T2 state machine — transitions nominales', () => {
|
||||
it('idle → preparing sur START_PREPARATION', () => {
|
||||
expect(transition('idle', { type: 'START_PREPARATION' })).toBe('preparing')
|
||||
})
|
||||
|
||||
it('preparing → connecting sur START_DIALOGUE', () => {
|
||||
expect(transition('preparing', { type: 'START_DIALOGUE' })).toBe('connecting')
|
||||
})
|
||||
|
||||
it('connecting → ready sur WS_OPENED', () => {
|
||||
expect(transition('connecting', { type: 'WS_OPENED' })).toBe('ready')
|
||||
})
|
||||
|
||||
it('ready ↔ speaking ↔ listening (cycle de dialogue)', () => {
|
||||
expect(transition('ready', { type: 'USER_SPEAKING' })).toBe('speaking')
|
||||
expect(transition('speaking', { type: 'USER_SILENT' })).toBe('listening')
|
||||
expect(transition('listening', { type: 'USER_SPEAKING' })).toBe('speaking')
|
||||
})
|
||||
|
||||
it('processing → ended sur REPORT_READY', () => {
|
||||
expect(transition('processing', { type: 'REPORT_READY' })).toBe('ended')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2 state machine — END_REQUESTED → processing depuis tout état actif', () => {
|
||||
it.each<T2State>(['connecting', 'ready', 'speaking', 'listening'])(
|
||||
'transition %s → processing sur END_REQUESTED',
|
||||
(from) => {
|
||||
expect(transition(from, { type: 'END_REQUESTED' })).toBe('processing')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('T2 state machine — ERROR terminal', () => {
|
||||
it.each<T2State>([
|
||||
'idle',
|
||||
'preparing',
|
||||
'connecting',
|
||||
'ready',
|
||||
'speaking',
|
||||
'listening',
|
||||
'processing',
|
||||
])('transition %s → error sur ERROR', (from) => {
|
||||
expect(transition(from, { type: 'ERROR', code: 4001 })).toBe('error')
|
||||
})
|
||||
|
||||
it('ended est insensible à ERROR (état terminal protégé)', () => {
|
||||
expect(transition('ended', { type: 'ERROR', code: 4001 })).toBe('ended')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2 state machine — événements invalides ignorés', () => {
|
||||
it('USER_SPEAKING en idle est ignoré', () => {
|
||||
expect(transition('idle', { type: 'USER_SPEAKING' })).toBe('idle')
|
||||
})
|
||||
|
||||
it('REPORT_READY en ready est ignoré (doit passer par END_REQUESTED → processing)', () => {
|
||||
expect(transition('ready', { type: 'REPORT_READY' })).toBe('ready')
|
||||
})
|
||||
|
||||
it('états terminaux (ended, error) sont insensibles aux events nominaux', () => {
|
||||
expect(transition('ended', { type: 'USER_SPEAKING' })).toBe('ended')
|
||||
expect(transition('error', { type: 'WS_OPENED' })).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('T2_INITIAL_STATE', () => {
|
||||
it('vaut idle', () => {
|
||||
expect(T2_INITIAL_STATE).toBe('idle')
|
||||
})
|
||||
})
|
||||
102
src/features/t2-live/state/t2-machine.ts
Normal file
102
src/features/t2-live/state/t2-machine.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* t2-machine — State machine pure pour le flux T2 Live (Sprint 6c).
|
||||
*
|
||||
* Résout FTD-09 (planifiée Sprint 2.5, déclenchée Sprint 6).
|
||||
*
|
||||
* Cycle de vie d'une session :
|
||||
*
|
||||
* idle
|
||||
* └─ START_PREPARATION ─▶ preparing
|
||||
* └─ START_DIALOGUE ─▶ connecting
|
||||
* └─ WS_OPENED ─▶ ready
|
||||
* ├─ USER_SPEAKING ─▶ speaking
|
||||
* │ └─ USER_SILENT ─▶ listening
|
||||
* │ └─ USER_SPEAKING ─▶ speaking
|
||||
* ├─ END_REQUESTED ─▶ processing
|
||||
* │ └─ REPORT_READY ─▶ ended
|
||||
* └─ ERROR ─▶ error
|
||||
*
|
||||
* Toute transition non listée est ignorée (état conservé). La machine est
|
||||
* une fonction pure — aucun side-effect, aucune référence à du DOM, à React
|
||||
* ou à un WebSocket. Les side-effects sont orchestrés par useT2LiveSession.
|
||||
*/
|
||||
|
||||
export type T2State =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'connecting'
|
||||
| 'ready'
|
||||
| 'speaking'
|
||||
| 'listening'
|
||||
| 'processing'
|
||||
| 'ended'
|
||||
| 'error'
|
||||
|
||||
export type T2Event =
|
||||
| { type: 'START_PREPARATION' }
|
||||
| { type: 'START_DIALOGUE' }
|
||||
| { type: 'WS_OPENED' }
|
||||
| { type: 'USER_SPEAKING' }
|
||||
| { type: 'USER_SILENT' }
|
||||
| { type: 'END_REQUESTED' }
|
||||
| { type: 'REPORT_READY' }
|
||||
| { type: 'ERROR'; code?: number; message?: string }
|
||||
|
||||
/**
|
||||
* Transition pure : (state, event) → newState.
|
||||
*
|
||||
* Les événements `WARNING` (timer 30 s restantes) ne sont pas modélisés ici
|
||||
* car ils n'affectent pas l'état — ils déclenchent un side-effect d'affichage
|
||||
* géré directement par le hook orchestrateur.
|
||||
*/
|
||||
export function transition(state: T2State, event: T2Event): T2State {
|
||||
// ERROR est terminal et bypasse tous les guards : peut être émis depuis
|
||||
// n'importe quel état non-terminal.
|
||||
if (event.type === 'ERROR' && state !== 'ended') {
|
||||
return 'error'
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
if (event.type === 'START_PREPARATION') return 'preparing'
|
||||
// Permet de sauter la prépa si l'appelant le souhaite.
|
||||
if (event.type === 'START_DIALOGUE') return 'connecting'
|
||||
return state
|
||||
|
||||
case 'preparing':
|
||||
if (event.type === 'START_DIALOGUE') return 'connecting'
|
||||
return state
|
||||
|
||||
case 'connecting':
|
||||
if (event.type === 'WS_OPENED') return 'ready'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'ready':
|
||||
if (event.type === 'USER_SPEAKING') return 'speaking'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'speaking':
|
||||
if (event.type === 'USER_SILENT') return 'listening'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'listening':
|
||||
if (event.type === 'USER_SPEAKING') return 'speaking'
|
||||
if (event.type === 'END_REQUESTED') return 'processing'
|
||||
return state
|
||||
|
||||
case 'processing':
|
||||
if (event.type === 'REPORT_READY') return 'ended'
|
||||
return state
|
||||
|
||||
case 'ended':
|
||||
case 'error':
|
||||
// États terminaux — aucune transition.
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
/** État initial de toute session T2 Live. */
|
||||
export const T2_INITIAL_STATE: T2State = 'idle'
|
||||
Loading…
Add table
Add a link
Reference in a new issue