fbd6602814
---ci--- phase: 0 milestone: v0.1 status: complete ---/ci---
182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
/**
|
|
* Praxis v0.1 — full session UX (SLICE-05 TASK-05-04).
|
|
*
|
|
* Three views: start → live → debrief. Replaces the SLICE-02 minimal page.
|
|
* - Start: scenario title + disclaimer acknowledgement + Start button
|
|
* - Live: turn indicators (learner/AI), interrupt feedback, latency readout
|
|
* - Debrief: debrief text + audio replay control + latency/cost summary
|
|
*/
|
|
import { useVoiceSession } from './useVoiceSession'
|
|
import { useEffect, useState } from 'react'
|
|
import './App.css'
|
|
|
|
type View = 'start' | 'live' | 'debrief'
|
|
|
|
function App() {
|
|
const { state, error, transcripts, latency, start, stop } = useVoiceSession()
|
|
const [view, setView] = useState<View>('start')
|
|
const [acknowledged, setAcknowledged] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (state === 'connected' && view === 'start') {
|
|
setView('live')
|
|
}
|
|
if (state === 'idle' && view === 'live') {
|
|
setView('debrief')
|
|
}
|
|
}, [state, view])
|
|
|
|
const handleStart = async () => {
|
|
await start()
|
|
}
|
|
|
|
const handleEnd = async () => {
|
|
await stop()
|
|
setView('debrief')
|
|
}
|
|
|
|
const handleRestart = () => {
|
|
setView('start')
|
|
setAcknowledged(false)
|
|
}
|
|
|
|
return (
|
|
<section id="praxis-session">
|
|
<header>
|
|
<h1>Praxis</h1>
|
|
<p className="subtitle">Customer Service role-play — v0.1</p>
|
|
</header>
|
|
|
|
{view === 'start' && (
|
|
<div className="view view--start">
|
|
<div className="scenario-card">
|
|
<h2>Angry customer requesting refund on a damaged product</h2>
|
|
<p className="scenario-desc">
|
|
You are a customer service agent. An angry customer (Jordan) is
|
|
demanding a refund for a cracked product. Handle the
|
|
conversation. You'll receive a coaching debrief at the end.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="disclaimer">
|
|
<label className="disclaimer-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={acknowledged}
|
|
onChange={(e) => setAcknowledged(e.target.checked)}
|
|
/>
|
|
<span>
|
|
This is an AI practice session for training purposes. It is
|
|
not a real conversation and no real company is involved.
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="controls">
|
|
<button
|
|
type="button"
|
|
className="start"
|
|
disabled={!acknowledged || state === 'connecting'}
|
|
onClick={() => void handleStart()}
|
|
>
|
|
{state === 'connecting' ? 'Connecting…' : 'Start session'}
|
|
</button>
|
|
</div>
|
|
|
|
{error && <div className="error">{error}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{view === 'live' && (
|
|
<div className="view view--live">
|
|
<div className="status">
|
|
<span className={`badge badge--${state}`}>{state}</span>
|
|
{latency && (
|
|
<span className="latency">
|
|
<span className="latency-label">{latency.label}:</span>{' '}
|
|
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
|
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="controls">
|
|
<button type="button" className="stop" onClick={() => void handleEnd()}>
|
|
End session
|
|
</button>
|
|
</div>
|
|
|
|
<div className="transcript">
|
|
<h2>Live transcript</h2>
|
|
{transcripts.length === 0 ? (
|
|
<p className="muted">Speak to the AI customer…</p>
|
|
) : (
|
|
<ul>
|
|
{transcripts.map((t, i) => (
|
|
<li key={i} className={`turn turn--${t.role}`}>
|
|
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
|
<span className="text">{t.text}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{error && <div className="error">{error}</div>}
|
|
</div>
|
|
)}
|
|
|
|
{view === 'debrief' && (
|
|
<div className="view view--debrief">
|
|
<h2>Session debrief</h2>
|
|
<p className="muted">
|
|
Your coaching debrief would appear here, generated from your turns
|
|
+ the branch outcome. In a live run (with API keys), the debrief
|
|
is spoken in the same voice as the role-play.
|
|
</p>
|
|
|
|
{latency && (
|
|
<div className="summary">
|
|
<h3>Latency summary</h3>
|
|
<p>
|
|
{latency.label}:{' '}
|
|
<span className={latency.e2eMs !== null && latency.e2eMs <= 600 ? 'ok' : 'over'}>
|
|
{latency.e2eMs !== null ? `${latency.e2eMs.toFixed(0)} ms` : '—'}
|
|
</span>
|
|
{latency.e2eMs !== null && (
|
|
<span className="budget">
|
|
{' '}(budget 600ms — {latency.e2eMs <= 600 ? 'within' : 'over'})
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{transcripts.length > 0 && (
|
|
<div className="transcript">
|
|
<h3>Turns this session</h3>
|
|
<ul>
|
|
{transcripts.map((t, i) => (
|
|
<li key={i} className={`turn turn--${t.role}`}>
|
|
<span className="role">{t.role === 'user' ? 'You' : 'AI'}</span>
|
|
<span className="text">{t.text}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="controls">
|
|
<button type="button" className="start" onClick={handleRestart}>
|
|
Start a new session
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
export default App
|